]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #682 from ArthurHoaro/delete-button
authorArthur <arthur@hoa.ro>
Wed, 4 Jan 2017 15:35:29 +0000 (16:35 +0100)
committerGitHub <noreply@github.com>
Wed, 4 Jan 2017 15:35:29 +0000 (16:35 +0100)
Bugfixes on link deletion, and use a GET form

39 files changed:
.htaccess [new file with mode: 0644]
.travis.yml
CHANGELOG.md
application/FeedBuilder.php
application/HttpUtils.php
application/LinkFilter.php
application/Updater.php
application/Url.php
application/Utils.php
application/api/ApiMiddleware.php [new file with mode: 0644]
application/api/ApiUtils.php [new file with mode: 0644]
application/api/controllers/ApiController.php [new file with mode: 0644]
application/api/controllers/Info.php [new file with mode: 0644]
application/api/exceptions/ApiAuthorizationException.php [new file with mode: 0644]
application/api/exceptions/ApiBadParametersException.php [new file with mode: 0644]
application/api/exceptions/ApiException.php [new file with mode: 0644]
application/api/exceptions/ApiInternalException.php [new file with mode: 0644]
application/config/ConfigManager.php
composer.json
index.php
plugins/markdown/markdown.meta
plugins/pubsubhubbub/README.md [new file with mode: 0644]
plugins/pubsubhubbub/hub.atom.xml [new file with mode: 0644]
plugins/pubsubhubbub/hub.rss.xml [new file with mode: 0644]
plugins/pubsubhubbub/pubsubhubbub.meta [new file with mode: 0644]
plugins/pubsubhubbub/pubsubhubbub.php [new file with mode: 0644]
tests/FeedBuilderTest.php
tests/HttpUtils/ServerUrlTest.php
tests/Updater/UpdaterTest.php
tests/Url/CleanupUrlTest.php
tests/UtilsTest.php
tests/api/ApiMiddlewareTest.php [new file with mode: 0644]
tests/api/ApiUtilsTest.php [new file with mode: 0644]
tests/api/controllers/InfoTest.php [new file with mode: 0644]
tests/plugins/PluginPubsubhubbubTest.php [new file with mode: 0644]
tpl/configure.html
tpl/feed.atom.html
tpl/feed.rss.html
tpl/install.html

diff --git a/.htaccess b/.htaccess
new file mode 100644 (file)
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]
index 6ff1b20f564e130e345821eaefe75a0fb829d55f..03071a4734535485b8f9cc0f0106a3f8668de34c 100644 (file)
@@ -8,8 +8,6 @@ php:
   - 7.0
   - 5.6
   - 5.5
-  - 5.4
-  - 5.3
 install:
   - composer self-update
   - composer install --prefer-dist
index 21d5436ccdefeae86fe4dc72385b732816e8c324..fe775b3e61de9c8c426ce637466b5f420261cef1 100644 (file)
@@ -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
 
+**WARNING**: Shaarli now requires PHP 5.5+. 
+
 ### Added
 
+- REST API: see [Shaarli API documentation](http://shaarli.github.io/api-documentation/)
+
 ### Changed
 
 ### Fixed
index fedd90e661faefc4d6cec4a3152eaae2996bcbf9..a1f4da4810c0b25dceebb8d6698de93cf4eb81e9 100644 (file)
@@ -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.
      *
index e705cfd6030cb0da7ff5e90bde930433bcecbbe0..e8fc1f5db45fc4ca11da2793068c373013339614 100644 (file)
@@ -297,9 +297,17 @@ function server_url($server)
             // Keep forwarded port
             if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) {
                 $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']);
-                $port = ':' . trim($ports[0]);
+                $port = trim($ports[0]);
             } else {
-                $port = ':' . $server['HTTP_X_FORWARDED_PORT'];
+                $port = $server['HTTP_X_FORWARDED_PORT'];
+            }
+
+            if (($scheme == 'http' && $port != '80')
+                || ($scheme == 'https' && $port != '443')
+            ) {
+                $port = ':' . $port;
+            } else {
+                $port = '';
             }
         }
 
index daa6d9cc26a8ed139f34581ff619d98969415073..57ebfd5cb8070e55bd87545f9ad716434a8c488b 100644 (file)
@@ -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);
     }
 }
 
index f0d02814b5599654b0c6638e40e02e86068a5ab7..38de33503a417ac06c9b186298c7621a471d3667 100644 (file)
@@ -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;
+    }
 }
 
 /**
index c5c7dd187bbc0d1f1f3d7aff97bbb4cfc375bb61..25a62a8ab3fb5dafa02870ab968418038ef21d37 100644 (file)
@@ -94,7 +94,10 @@ class Url
         'utm_',
 
         // ATInternet
-        'xtor='
+        'xtor=',
+
+        // Other
+        'campaign_'
     );
 
     private static $annoyingFragments = array(
index 0a5b476ebf9779bbe47f8ea7f28ef0de21b28481..35d652241bb6a5a4c42c7ded7b7381be48dc7f15 100644 (file)
@@ -231,3 +231,42 @@ 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));
+}
+
+/**
+ * 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/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
new file mode 100644 (file)
index 0000000..162e88e
--- /dev/null
@@ -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;
+    }
+}
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
new file mode 100644 (file)
index 0000000..fbb1e72
--- /dev/null
@@ -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');
+        }
+    }
+}
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
new file mode 100644 (file)
index 0000000..1dd47f1
--- /dev/null
@@ -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;
+        }
+    }
+}
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
new file mode 100644 (file)
index 0000000..25433f7
--- /dev/null
@@ -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);
+    }
+}
diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php
new file mode 100644 (file)
index 0000000..0e3f477
--- /dev/null
@@ -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;
+    }
+}
diff --git a/application/api/exceptions/ApiBadParametersException.php b/application/api/exceptions/ApiBadParametersException.php
new file mode 100644 (file)
index 0000000..e5cc19e
--- /dev/null
@@ -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);
+    }
+}
diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php
new file mode 100644 (file)
index 0000000..c8490e0
--- /dev/null
@@ -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;
+    }
+}
diff --git a/application/api/exceptions/ApiInternalException.php b/application/api/exceptions/ApiInternalException.php
new file mode 100644 (file)
index 0000000..1cb0553
--- /dev/null
@@ -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);
+    }
+}
index f5f753f8fb55853688824327cb19ea5669dcead8..ca8918b52b2be8693bc53397cad247e72f3661b4 100644 (file)
@@ -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');
index f7d26a315f5fb136240a51df7a7c31f4b18c678a..cfbde1a0695ee7a58eb86ee93e340e36baa6e9d4 100644 (file)
     },
     "keywords": ["bookmark", "link", "share", "web"],
     "require": {
-        "php": ">=5.3.4",
+        "php": ">=5.5",
         "shaarli/netscape-bookmark-parser": "1.*",
-        "erusev/parsedown": "1.6"
+        "erusev/parsedown": "1.6",
+        "slim/slim": "^3.0",
+        "pubsubhubbub/publisher": "dev-master"
     },
     "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"
+        }
     }
 }
index 34f0e3817782b288f59fab89114ce6a1dde07ede..2ed14d4f2f3ac67ed6bd5e041fc434b27a7aa050 100644 (file)
--- 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,
@@ -918,10 +910,6 @@ function renderPage($conf, $pluginManager)
         $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.
@@ -938,7 +926,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));
@@ -1142,6 +1130,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 +1160,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;
         }
@@ -1293,7 +1285,6 @@ function renderPage($conf, $pluginManager)
 
         $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')) {
@@ -1610,8 +1601,8 @@ function renderPage($conf, $pluginManager)
 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'])
@@ -1658,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.
@@ -1954,6 +1945,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(
+                $conf->get('credentials.login'),
+                $conf->get('credentials.salt')
+            )
+        );
         try {
             // Everything is ok, let's create config file.
             $conf->write(isLoggedIn());
@@ -2216,4 +2215,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', '\Shaarli\Api\Controllers\Info:getInfo');
+})->add('\Shaarli\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);
+}
index 8df2ed0bc3b6af6694166c3c613db97d8fdf924a..322856ea9c146ff55916240c1e5a3d7d64602d15 100644 (file)
@@ -1,4 +1,4 @@
 description="Render shaare description with Markdown syntax.<br><strong>Warning</strong>:
-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 <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/markdown#html-rendering\">README</a>."
diff --git a/plugins/pubsubhubbub/README.md b/plugins/pubsubhubbub/README.md
new file mode 100644 (file)
index 0000000..3a65492
--- /dev/null
@@ -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 (file)
index 0000000..24d93d3
--- /dev/null
@@ -0,0 +1 @@
+<link rel="hub" href="%s" />
\ No newline at end of file
diff --git a/plugins/pubsubhubbub/hub.rss.xml b/plugins/pubsubhubbub/hub.rss.xml
new file mode 100644 (file)
index 0000000..27bf67a
--- /dev/null
@@ -0,0 +1 @@
+<atom:link rel="hub" href="%s" />
\ No newline at end of file
diff --git a/plugins/pubsubhubbub/pubsubhubbub.meta b/plugins/pubsubhubbub/pubsubhubbub.meta
new file mode 100644 (file)
index 0000000..289f5cd
--- /dev/null
@@ -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 (file)
index 0000000..03b6757
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+
+/**
+ * PubSubHubbub plugin.
+ *
+ * PubSub is a protocol which fasten up RSS fetching:
+ *   - Every time a new link is posted, Shaarli notify the hub.
+ *   - The hub notify all feed subscribers that a new link has been posted.
+ *   - Subscribers retrieve the new link. 
+ */
+
+use pubsubhubbub\publisher\Publisher;
+
+/**
+ * Plugin init function - set the hub to the default appspot one.
+ *
+ * @param ConfigManager $conf instance.
+ */
+function pubsubhubbub_init($conf)
+{
+    $hub = $conf->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;
+}
index 06a445064db3aab7c46fceda27ba08b205d1a143..a590306db54524e8fc4a98a6d5a2d8e023c3d4ee 100644 (file)
@@ -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
      */
index 8a55a2202be11910e9dcb22c38f60554820efbc3..7fdad6594961e5995d40f050fc3f90fbeded7f3d 100644 (file)
@@ -68,6 +68,19 @@ class ServerUrlTest extends PHPUnit_Framework_TestCase
             )
         );
 
+        $this->assertEquals(
+            'https://host.tld',
+            server_url(
+                array(
+                    'HTTPS' => 'Off',
+                    'SERVER_NAME' => 'host.tld',
+                    'SERVER_PORT' => '80',
+                    'HTTP_X_FORWARDED_PROTO' => 'https',
+                    'HTTP_X_FORWARDED_PORT' => '443'
+                )
+            )
+        );
+
         $this->assertEquals(
             'https://host.tld:4974',
             server_url(
index 4948fe52d20ec1e39ec30b5eb5f39633f70880d5..0171daada17ca4d424fc3ceb811c692b4da7d63c 100644 (file)
@@ -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 = '<script>alert("title");</script>';
         $headerLink = '<script>alert("header_link");</script>';
@@ -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');
     }
 
     /**
index ba9a04377d8721098475a3c5bb1aad8f3fa50fbe..1407d7d2581689bddaba990c5435ec9506781db3 100644 (file)
@@ -8,7 +8,13 @@ require_once 'application/Url.php';
 class CleanupUrlTest extends PHPUnit_Framework_TestCase
 {
     /**
-     * Clean empty UrlThanks for building nothing
+     * @var string reference URL
+     */
+    protected $ref = 'http://domain.tld:3000';
+
+
+    /**
+     * Clean empty URL
      */
     public function testCleanupUrlEmpty()
     {
@@ -16,59 +22,87 @@ class CleanupUrlTest extends PHPUnit_Framework_TestCase
     }
 
     /**
-     * Clean an already cleaned Url
+     * Clean an already cleaned URL
      */
     public function testCleanupUrlAlreadyClean()
     {
-        $ref = 'http://domain.tld:3000';
-        $this->assertEquals($ref, cleanup_url($ref));
-        $ref = $ref.'/path/to/dir/';
-        $this->assertEquals($ref, cleanup_url($ref));
+        $this->assertEquals($this->ref, cleanup_url($this->ref));
+        $this->ref2 = $this->ref.'/path/to/dir/';
+        $this->assertEquals($this->ref2, cleanup_url($this->ref2));
+    }
+
+    /**
+     * Clean URL fragments
+     */
+    public function testCleanupUrlFragment()
+    {
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'#tk.rss_all'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'#xtor=RSS-'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'#xtor=RSS-U3ht0tkc4b'));
+    }
+
+    /**
+     * Clean URL query - single annoying parameter
+     */
+    public function testCleanupUrlQuerySingle()
+    {
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_object_map=junk'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_ref_map=Cr4p!'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_type_map=g4R84g3'));
+
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?fb_stuff=v41u3'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?fb=71m3w4573'));
+
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_campaign=zomg'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_medium=numnum'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_source=c0d3'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_term=1n4l'));
+
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?xtor=some-url'));
+
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_name=junk'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_start=junk'));
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_item_index=junk'));
     }
 
     /**
-     * Clean Url needing cleaning
+     * Clean URL query - multiple annoying parameters
      */
-    public function testCleanupUrlNeedClean()
+    public function testCleanupUrlQueryMultiple()
     {
-        $ref = 'http://domain.tld:3000';
-        $this->assertEquals($ref, cleanup_url($ref.'#tk.rss_all'));
-        $this->assertEquals($ref, cleanup_url($ref.'#xtor=RSS-'));
-        $this->assertEquals($ref, cleanup_url($ref.'#xtor=RSS-U3ht0tkc4b'));
-        $this->assertEquals($ref, cleanup_url($ref.'?action_object_map=junk'));
-        $this->assertEquals($ref, cleanup_url($ref.'?action_ref_map=Cr4p!'));
-        $this->assertEquals($ref, cleanup_url($ref.'?action_type_map=g4R84g3'));
-
-        $this->assertEquals($ref, cleanup_url($ref.'?fb_stuff=v41u3'));
-        $this->assertEquals($ref, cleanup_url($ref.'?fb=71m3w4573'));
-
-        $this->assertEquals($ref, cleanup_url($ref.'?utm_campaign=zomg'));
-        $this->assertEquals($ref, cleanup_url($ref.'?utm_medium=numnum'));
-        $this->assertEquals($ref, cleanup_url($ref.'?utm_source=c0d3'));
-        $this->assertEquals($ref, cleanup_url($ref.'?utm_term=1n4l'));
-
-        $this->assertEquals($ref, cleanup_url($ref.'?xtor=some-url'));
-        $this->assertEquals($ref, cleanup_url($ref.'?xtor=some-url&fb=som3th1ng'));
-        $this->assertEquals($ref, cleanup_url(
-            $ref.'?fb=stuff&utm_campaign=zomg&utm_medium=numnum&utm_source=c0d3'
+        $this->assertEquals($this->ref, cleanup_url($this->ref.'?xtor=some-url&fb=som3th1ng'));
+
+        $this->assertEquals($this->ref, cleanup_url(
+            $this->ref.'?fb=stuff&utm_campaign=zomg&utm_medium=numnum&utm_source=c0d3'
         ));
-        $this->assertEquals($ref, cleanup_url(
-            $ref.'?xtor=some-url&fb=som3th1ng#tk.rss_all'
+
+        $this->assertEquals($this->ref, cleanup_url(
+            $this->ref.'?campaign_start=zomg&campaign_name=numnum'
+        ));
+    }
+
+    /**
+     * Clean URL query - multiple annoying parameters and fragment
+     */
+    public function testCleanupUrlQueryFragment()
+    {
+        $this->assertEquals($this->ref, cleanup_url(
+            $this->ref.'?xtor=some-url&fb=som3th1ng#tk.rss_all'
         ));
 
         // ditch annoying query params and fragment, keep useful params
         $this->assertEquals(
-            $ref.'?my=stuff&is=kept',
+            $this->ref.'?my=stuff&is=kept',
             cleanup_url(
-                $ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#tk.rss_all'
+                $this->ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#tk.rss_all'
             )
         );
 
         // ditch annoying query params, keep useful params and fragment
         $this->assertEquals(
-            $ref.'?my=stuff&is=kept#again',
+            $this->ref.'?my=stuff&is=kept#again',
             cleanup_url(
-                $ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#again'
+                $this->ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#again'
             )
         );
     }
index 6a7870c4620ac5cd3bbf2a838f108c27755dd411..c885f552350b62cf003921c5b36673b3224eea14 100644 (file)
@@ -253,4 +253,33 @@ 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));
+    }
+
+    /**
+     * 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));
+    }
 }
diff --git a/tests/api/ApiMiddlewareTest.php b/tests/api/ApiMiddlewareTest.php
new file mode 100644 (file)
index 0000000..4d4dd9b
--- /dev/null
@@ -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);
+    }
+}
diff --git a/tests/api/ApiUtilsTest.php b/tests/api/ApiUtilsTest.php
new file mode 100644 (file)
index 0000000..10da145
--- /dev/null
@@ -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');
+    }
+}
diff --git a/tests/api/controllers/InfoTest.php b/tests/api/controllers/InfoTest.php
new file mode 100644 (file)
index 0000000..2916eed
--- /dev/null
@@ -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']);
+    }
+}
diff --git a/tests/plugins/PluginPubsubhubbubTest.php b/tests/plugins/PluginPubsubhubbubTest.php
new file mode 100644 (file)
index 0000000..24dd7a1
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+
+require_once 'plugins/pubsubhubbub/pubsubhubbub.php';
+require_once 'application/Router.php';
+
+/**
+ * Class PluginPubsubhubbubTest
+ * Unit test for the pubsubhubbub plugin
+ */
+class PluginPubsubhubbubTest extends PHPUnit_Framework_TestCase
+{
+    /**
+     * @var string Config file path (without extension).
+     */
+    protected static $configFile = 'tests/utils/config/configJson';
+
+    /**
+     * Reset plugin path
+     */
+    function setUp()
+    {
+        PluginManager::$PLUGINS_PATH = 'plugins';
+    }
+
+    /**
+     * Test render_feed hook with an RSS feed.
+     */
+    function testPubSubRssRenderFeed()
+    {
+        $hub = 'http://domain.hub';
+        $conf = new ConfigManager(self::$configFile);
+        $conf->set('plugins.PUBSUBHUB_URL', $hub);
+        $data['_PAGE_'] = Router::$PAGE_FEED_RSS;
+
+        $data = hook_pubsubhubbub_render_feed($data, $conf);
+        $expected = '<atom:link rel="hub" href="'. $hub .'" />';
+        $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 = '<link rel="hub" href="'. $hub .'" />';
+        $this->assertEquals($expected, $data['feed_plugins_header'][0]);
+    }
+}
index 983bcd085125923d58806d59f4f87af302b05122..b4197bf9faee7d6b07b552c0250cd6cd757b1222 100644 (file)
           <label for="updateCheck">&nbsp;Notify me when a new release is ready</label>
         </td>
       </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">&nbsp;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>
         <td></td>
index aead04592af4daf988d3ef9643c0b85a3203cf74..49798e8576998c919f4bf10d78a9b236a88a57d7 100644 (file)
@@ -6,11 +6,11 @@
     <updated>{$last_update}</updated>
   {/if}
   <link rel="self" href="{$self_link}#" />
-  {if="!empty($pubsubhub_url)"}
-    <!-- PubSubHubbub Discovery -->
-    <link rel="hub" href="{$pubsubhub_url}#" />
-    <!-- End Of PubSubHubbub Discovery -->
-  {/if}
+  <link rel="search" type="application/opensearchdescription+xml" href="{$index_url}?do=opensearch#"
+             title="Shaarli search - {$shaarlititle}" />
+  {loop="$feed_plugins_header"}
+    {$value}
+  {/loop}
   <author>
     <name>{$index_url}</name>
     <uri>{$index_url}</uri>
@@ -34,6 +34,9 @@
       {loop="$value.taglist"}
         <category scheme="{$index_url}?searchtags=" term="{$value|strtolower}" label="{$value}" />
       {/loop}
+      {loop="$value.feed_plugins"}
+        {$value}
+      {/loop}
     </entry>
   {/loop}
 </feed>
index e18dbf9bcea3f70f6ec4578ff12571e002a06cc8..ee3fef880de3b7e931d4f04968d4cb3ca0403706 100644 (file)
@@ -8,10 +8,11 @@
     <copyright>{$index_url}</copyright>
     <generator>Shaarli</generator>
     <atom:link rel="self" href="{$self_link}"  />
-    {if="!empty($pubsubhub_url)"}
-      <!-- PubSubHubbub Discovery -->
-      <atom:link rel="hub" href="{$pubsubhub_url}" />
-    {/if}
+    <atom:link rel="search" type="application/opensearchdescription+xml" href="{$index_url}?do=opensearch#"
+               title="Shaarli search - {$shaarlititle}" />
+    {loop="$feed_plugins_header"}
+      {$value}
+    {/loop}
     {loop="$links"}
       <item>
         <title>{$value.title}</title>
@@ -29,6 +30,9 @@
         {loop="$value.taglist"}
           <category domain="{$index_url}?searchtags=">{$value}</category>
         {/loop}
+        {loop="$value.feed_plugins"}
+          {$value}
+        {/loop}
       </item>
     {/loop}
   </channel>
index 88eb540e1f18b8fc5d61056e2579e695f14cc15e..42874dcdb42c61963050599c6df922a079b615f9 100644 (file)
             <tr><td valign="top"><b>Update:</b></td><td>
                 <input type="checkbox" name="updateCheck" id="updateCheck" checked="checked"><label for="updateCheck">&nbsp;Notify me when a new release is ready</label></td>
             </tr>
+            <tr>
+                <td valign="top">
+                    <b>API:</b>
+                </td>
+                <td>
+                    <input type="checkbox" name="enableApi" id="enableApi" checked="checked">
+                    <label for="enableApi">
+                        &nbsp;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>
         </table>
     </form>