--- /dev/null
+RewriteEngine On
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteRule ^ index.php [QSA,L]
- 7.0
- 5.6
- 5.5
- - 5.4
- - 5.3
install:
- composer self-update
- composer install --prefer-dist
## [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
*/
protected $hideDates;
- /**
- * @var string PubSub hub URL.
- */
- protected $pubsubhubUrl;
-
/**
* @var string server locale.
*/
}
$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.
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.
*
// 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 = '';
}
}
$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);
}
}
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;
+ }
}
/**
'utm_',
// ATInternet
- 'xtor='
+ 'xtor=',
+
+ // Other
+ 'campaign_'
);
private static $annoyingFragments = array(
}
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));
+}
--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+<?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');
+ }
+ }
+}
--- /dev/null
+<?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;
+ }
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+<?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);
+ }
+}
*/
protected static $NOT_FOUND = 'NOT_FOUND';
+ public static $DEFAULT_PLUGINS = array('qrcode');
+
/**
* @var string Config folder.
*/
$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');
},
"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"
+ }
}
}
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)
*
* @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,
$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.
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));
$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());
}
$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;
}
$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')) {
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'])
} 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.
$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());
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);
+}
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>."
--- /dev/null
+# 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.
+
--- /dev/null
+<link rel="hub" href="%s" />
\ No newline at end of file
--- /dev/null
+<atom:link rel="hub" href="%s" />
\ No newline at end of file
--- /dev/null
+description="Enable PubSubHubbub feed publishing."
+parameters="PUBSUBHUB_URL"
--- /dev/null
+<?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;
+}
$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']);
$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
*/
)
);
+ $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(
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>';
$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');
}
/**
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()
{
}
/**
- * 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'
)
);
}
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));
+ }
}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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');
+ }
+}
--- /dev/null
+<?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']);
+ }
+}
--- /dev/null
+<?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]);
+ }
+}
<label for="updateCheck"> 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"> 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>
<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>
{loop="$value.taglist"}
<category scheme="{$index_url}?searchtags=" term="{$value|strtolower}" label="{$value}" />
{/loop}
+ {loop="$value.feed_plugins"}
+ {$value}
+ {/loop}
</entry>
{/loop}
</feed>
<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>
{loop="$value.taglist"}
<category domain="{$index_url}?searchtags=">{$value}</category>
{/loop}
+ {loop="$value.feed_plugins"}
+ {$value}
+ {/loop}
</item>
{/loop}
</channel>
<tr><td valign="top"><b>Update:</b></td><td>
<input type="checkbox" name="updateCheck" id="updateCheck" checked="checked"><label for="updateCheck"> Notify me when a new release is ready</label></td>
</tr>
+ <tr>
+ <td valign="top">
+ <b>API:</b>
+ </td>
+ <td>
+ <input type="checkbox" name="enableApi" id="enableApi" checked="checked">
+ <label for="enableApi">
+ Enable Shaarli's REST API.
+ Allow third party software to use Shaarli such as mobile application.
+ </label>
+ </td>
+ </tr>
<tr><td colspan="2"><input type="submit" name="Save" value="Save config" class="bigbutton"></td></tr>
</table>
</form>