aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.htaccess4
-rw-r--r--.travis.yml2
-rw-r--r--CHANGELOG.md4
-rw-r--r--application/FeedBuilder.php16
-rw-r--r--application/HttpUtils.php12
-rw-r--r--application/LinkFilter.php2
-rw-r--r--application/Updater.php23
-rw-r--r--application/Url.php5
-rw-r--r--application/Utils.php39
-rw-r--r--application/api/ApiMiddleware.php132
-rw-r--r--application/api/ApiUtils.php51
-rw-r--r--application/api/controllers/ApiController.php54
-rw-r--r--application/api/controllers/Info.php42
-rw-r--r--application/api/exceptions/ApiAuthorizationException.php34
-rw-r--r--application/api/exceptions/ApiBadParametersException.php19
-rw-r--r--application/api/exceptions/ApiException.php77
-rw-r--r--application/api/exceptions/ApiInternalException.php19
-rw-r--r--application/config/ConfigManager.php4
-rw-r--r--composer.json13
-rw-r--r--index.php67
-rw-r--r--plugins/markdown/markdown.meta2
-rw-r--r--plugins/pubsubhubbub/README.md20
-rw-r--r--plugins/pubsubhubbub/hub.atom.xml1
-rw-r--r--plugins/pubsubhubbub/hub.rss.xml1
-rw-r--r--plugins/pubsubhubbub/pubsubhubbub.meta2
-rw-r--r--plugins/pubsubhubbub/pubsubhubbub.php101
-rw-r--r--tests/FeedBuilderTest.php14
-rw-r--r--tests/HttpUtils/ServerUrlTest.php13
-rw-r--r--tests/Updater/UpdaterTest.php40
-rw-r--r--tests/Url/CleanupUrlTest.php102
-rw-r--r--tests/UtilsTest.php29
-rw-r--r--tests/api/ApiMiddlewareTest.php184
-rw-r--r--tests/api/ApiUtilsTest.php206
-rw-r--r--tests/api/controllers/InfoTest.php113
-rw-r--r--tests/plugins/PluginPubsubhubbubTest.php54
-rw-r--r--tpl/configure.html14
-rw-r--r--tpl/feed.atom.html13
-rw-r--r--tpl/feed.rss.html12
-rw-r--r--tpl/install.html12
39 files changed, 1447 insertions, 105 deletions
diff --git a/.htaccess b/.htaccess
new file mode 100644
index 00000000..66ef8f69
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,4 @@
1RewriteEngine On
2RewriteCond %{REQUEST_FILENAME} !-f
3RewriteCond %{REQUEST_FILENAME} !-d
4RewriteRule ^ index.php [QSA,L]
diff --git a/.travis.yml b/.travis.yml
index 6ff1b20f..03071a47 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -8,8 +8,6 @@ php:
8 - 7.0 8 - 7.0
9 - 5.6 9 - 5.6
10 - 5.5 10 - 5.5
11 - 5.4
12 - 5.3
13install: 11install:
14 - composer self-update 12 - composer self-update
15 - composer install --prefer-dist 13 - composer install --prefer-dist
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21d5436c..fe775b3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,8 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
7 7
8## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - UNPUBLISHED 8## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - UNPUBLISHED
9 9
10**WARNING**: Shaarli now requires PHP 5.5+.
11
10### Added 12### Added
11 13
14- REST API: see [Shaarli API documentation](http://shaarli.github.io/api-documentation/)
15
12### Changed 16### Changed
13 17
14### Fixed 18### Fixed
diff --git a/application/FeedBuilder.php b/application/FeedBuilder.php
index fedd90e6..a1f4da48 100644
--- a/application/FeedBuilder.php
+++ b/application/FeedBuilder.php
@@ -63,11 +63,6 @@ class FeedBuilder
63 protected $hideDates; 63 protected $hideDates;
64 64
65 /** 65 /**
66 * @var string PubSub hub URL.
67 */
68 protected $pubsubhubUrl;
69
70 /**
71 * @var string server locale. 66 * @var string server locale.
72 */ 67 */
73 protected $locale; 68 protected $locale;
@@ -120,7 +115,6 @@ class FeedBuilder
120 } 115 }
121 116
122 $data['language'] = $this->getTypeLanguage(); 117 $data['language'] = $this->getTypeLanguage();
123 $data['pubsubhub_url'] = $this->pubsubhubUrl;
124 $data['last_update'] = $this->getLatestDateFormatted(); 118 $data['last_update'] = $this->getLatestDateFormatted();
125 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; 119 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
126 // Remove leading slash from REQUEST_URI. 120 // Remove leading slash from REQUEST_URI.
@@ -183,16 +177,6 @@ class FeedBuilder
183 } 177 }
184 178
185 /** 179 /**
186 * Assign PubSub hub URL.
187 *
188 * @param string $pubsubhubUrl PubSub hub url.
189 */
190 public function setPubsubhubUrl($pubsubhubUrl)
191 {
192 $this->pubsubhubUrl = $pubsubhubUrl;
193 }
194
195 /**
196 * Set this to true to use permalinks instead of direct links. 180 * Set this to true to use permalinks instead of direct links.
197 * 181 *
198 * @param boolean $usePermalinks true to force permalinks. 182 * @param boolean $usePermalinks true to force permalinks.
diff --git a/application/HttpUtils.php b/application/HttpUtils.php
index e705cfd6..e8fc1f5d 100644
--- a/application/HttpUtils.php
+++ b/application/HttpUtils.php
@@ -297,9 +297,17 @@ function server_url($server)
297 // Keep forwarded port 297 // Keep forwarded port
298 if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) { 298 if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) {
299 $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']); 299 $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']);
300 $port = ':' . trim($ports[0]); 300 $port = trim($ports[0]);
301 } else { 301 } else {
302 $port = ':' . $server['HTTP_X_FORWARDED_PORT']; 302 $port = $server['HTTP_X_FORWARDED_PORT'];
303 }
304
305 if (($scheme == 'http' && $port != '80')
306 || ($scheme == 'https' && $port != '443')
307 ) {
308 $port = ':' . $port;
309 } else {
310 $port = '';
303 } 311 }
304 } 312 }
305 313
diff --git a/application/LinkFilter.php b/application/LinkFilter.php
index daa6d9cc..57ebfd5c 100644
--- a/application/LinkFilter.php
+++ b/application/LinkFilter.php
@@ -348,7 +348,7 @@ class LinkFilter
348 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); 348 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
349 $tagsOut = str_replace(',', ' ', $tagsOut); 349 $tagsOut = str_replace(',', ' ', $tagsOut);
350 350
351 return array_values(array_filter(explode(' ', trim($tagsOut)), 'strlen')); 351 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
352 } 352 }
353} 353}
354 354
diff --git a/application/Updater.php b/application/Updater.php
index f0d02814..38de3350 100644
--- a/application/Updater.php
+++ b/application/Updater.php
@@ -256,6 +256,29 @@ class Updater
256 256
257 return true; 257 return true;
258 } 258 }
259
260 /**
261 * Initialize API settings:
262 * - api.enabled: true
263 * - api.secret: generated secret
264 */
265 public function updateMethodApiSettings()
266 {
267 if ($this->conf->exists('api.secret')) {
268 return true;
269 }
270
271 $this->conf->set('api.enabled', true);
272 $this->conf->set(
273 'api.secret',
274 generate_api_secret(
275 $this->conf->get('credentials.login'),
276 $this->conf->get('credentials.salt')
277 )
278 );
279 $this->conf->write($this->isLoggedIn);
280 return true;
281 }
259} 282}
260 283
261/** 284/**
diff --git a/application/Url.php b/application/Url.php
index c5c7dd18..25a62a8a 100644
--- a/application/Url.php
+++ b/application/Url.php
@@ -94,7 +94,10 @@ class Url
94 'utm_', 94 'utm_',
95 95
96 // ATInternet 96 // ATInternet
97 'xtor=' 97 'xtor=',
98
99 // Other
100 'campaign_'
98 ); 101 );
99 102
100 private static $annoyingFragments = array( 103 private static $annoyingFragments = array(
diff --git a/application/Utils.php b/application/Utils.php
index 0a5b476e..35d65224 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -231,3 +231,42 @@ function autoLocale($headerLocale)
231 } 231 }
232 setlocale(LC_ALL, $attempts); 232 setlocale(LC_ALL, $attempts);
233} 233}
234
235/**
236 * Generates a default API secret.
237 *
238 * Note that the random-ish methods used in this function are predictable,
239 * which makes them NOT suitable for crypto.
240 * BUT the random string is salted with the salt and hashed with the username.
241 * It makes the generated API secret secured enough for Shaarli.
242 *
243 * PHP 7 provides random_int(), designed for cryptography.
244 * More info: http://stackoverflow.com/questions/4356289/php-random-string-generator
245
246 * @param string $username Shaarli login username
247 * @param string $salt Shaarli password hash salt
248 *
249 * @return string|bool Generated API secret, 12 char length.
250 * Or false if invalid parameters are provided (which will make the API unusable).
251 */
252function generate_api_secret($username, $salt)
253{
254 if (empty($username) || empty($salt)) {
255 return false;
256 }
257
258 return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12));
259}
260
261/**
262 * Trim string, replace sequences of whitespaces by a single space.
263 * PHP equivalent to `normalize-space` XSLT function.
264 *
265 * @param string $string Input string.
266 *
267 * @return mixed Normalized string.
268 */
269function normalize_spaces($string)
270{
271 return preg_replace('/\s{2,}/', ' ', trim($string));
272}
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
new file mode 100644
index 00000000..162e88e0
--- /dev/null
+++ b/application/api/ApiMiddleware.php
@@ -0,0 +1,132 @@
1<?php
2
3namespace Shaarli\Api;
4
5use Shaarli\Api\Exceptions\ApiException;
6use Shaarli\Api\Exceptions\ApiAuthorizationException;
7use Slim\Container;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class ApiMiddleware
13 *
14 * This will be called before accessing any API Controller.
15 * Its role is to make sure that the API is enabled, configured, and to validate the JWT token.
16 *
17 * If the request is validated, the controller is called, otherwise a JSON error response is returned.
18 *
19 * @package Api
20 */
21class ApiMiddleware
22{
23 /**
24 * @var int JWT token validity in seconds (9 min).
25 */
26 public static $TOKEN_DURATION = 540;
27
28 /**
29 * @var Container: contains conf, plugins, etc.
30 */
31 protected $container;
32
33 /**
34 * @var \ConfigManager instance.
35 */
36 protected $conf;
37
38 /**
39 * ApiMiddleware constructor.
40 *
41 * @param Container $container instance.
42 */
43 public function __construct($container)
44 {
45 $this->container = $container;
46 $this->conf = $this->container->get('conf');
47 $this->setLinkDb($this->conf);
48 }
49
50 /**
51 * Middleware execution:
52 * - check the API request
53 * - execute the controller
54 * - return the response
55 *
56 * @param Request $request Slim request
57 * @param Response $response Slim response
58 * @param callable $next Next action
59 *
60 * @return Response response.
61 */
62 public function __invoke($request, $response, $next)
63 {
64 try {
65 $this->checkRequest($request);
66 $response = $next($request, $response);
67 } catch(ApiException $e) {
68 $e->setResponse($response);
69 $e->setDebug($this->conf->get('dev.debug', false));
70 $response = $e->getApiResponse();
71 }
72
73 return $response;
74 }
75
76 /**
77 * Check the request validity (HTTP method, request value, etc.),
78 * that the API is enabled, and the JWT token validity.
79 *
80 * @param Request $request Slim request
81 *
82 * @throws ApiAuthorizationException The API is disabled or the token is invalid.
83 */
84 protected function checkRequest($request)
85 {
86 if (! $this->conf->get('api.enabled', true)) {
87 throw new ApiAuthorizationException('API is disabled');
88 }
89 $this->checkToken($request);
90 }
91
92 /**
93 * Check that the JWT token is set and valid.
94 * The API secret setting must be set.
95 *
96 * @param Request $request Slim request
97 *
98 * @throws ApiAuthorizationException The token couldn't be validated.
99 */
100 protected function checkToken($request) {
101 $jwt = $request->getHeaderLine('jwt');
102 if (empty($jwt)) {
103 throw new ApiAuthorizationException('JWT token not provided');
104 }
105
106 if (empty($this->conf->get('api.secret'))) {
107 throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
108 }
109
110 ApiUtils::validateJwtToken($jwt, $this->conf->get('api.secret'));
111 }
112
113 /**
114 * Instantiate a new LinkDB including private links,
115 * and load in the Slim container.
116 *
117 * FIXME! LinkDB could use a refactoring to avoid this trick.
118 *
119 * @param \ConfigManager $conf instance.
120 */
121 protected function setLinkDb($conf)
122 {
123 $linkDb = new \LinkDB(
124 $conf->get('resource.datastore'),
125 true,
126 $conf->get('privacy.hide_public_links'),
127 $conf->get('redirector.url'),
128 $conf->get('redirector.encode_url')
129 );
130 $this->container['db'] = $linkDb;
131 }
132}
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
new file mode 100644
index 00000000..fbb1e72f
--- /dev/null
+++ b/application/api/ApiUtils.php
@@ -0,0 +1,51 @@
1<?php
2
3namespace Shaarli\Api;
4
5use Shaarli\Api\Exceptions\ApiAuthorizationException;
6
7/**
8 * Class ApiUtils
9 *
10 * Utility functions for the API.
11 */
12class ApiUtils
13{
14 /**
15 * Validates a JWT token authenticity.
16 *
17 * @param string $token JWT token extracted from the headers.
18 * @param string $secret API secret set in the settings.
19 *
20 * @throws ApiAuthorizationException the token is not valid.
21 */
22 public static function validateJwtToken($token, $secret)
23 {
24 $parts = explode('.', $token);
25 if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) {
26 throw new ApiAuthorizationException('Malformed JWT token');
27 }
28
29 $genSign = hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret);
30 if ($parts[2] != $genSign) {
31 throw new ApiAuthorizationException('Invalid JWT signature');
32 }
33
34 $header = json_decode(base64_decode($parts[0]));
35 if ($header === null) {
36 throw new ApiAuthorizationException('Invalid JWT header');
37 }
38
39 $payload = json_decode(base64_decode($parts[1]));
40 if ($payload === null) {
41 throw new ApiAuthorizationException('Invalid JWT payload');
42 }
43
44 if (empty($payload->iat)
45 || $payload->iat > time()
46 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
47 ) {
48 throw new ApiAuthorizationException('Invalid JWT issued time');
49 }
50 }
51}
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
new file mode 100644
index 00000000..1dd47f17
--- /dev/null
+++ b/application/api/controllers/ApiController.php
@@ -0,0 +1,54 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use \Slim\Container;
6
7/**
8 * Abstract Class ApiController
9 *
10 * Defines REST API Controller dependencies injected from the container.
11 *
12 * @package Api\Controllers
13 */
14abstract class ApiController
15{
16 /**
17 * @var Container
18 */
19 protected $ci;
20
21 /**
22 * @var \ConfigManager
23 */
24 protected $conf;
25
26 /**
27 * @var \LinkDB
28 */
29 protected $linkDb;
30
31 /**
32 * @var int|null JSON style option.
33 */
34 protected $jsonStyle;
35
36 /**
37 * ApiController constructor.
38 *
39 * Note: enabling debug mode displays JSON with readable formatting.
40 *
41 * @param Container $ci Slim container.
42 */
43 public function __construct(Container $ci)
44 {
45 $this->ci = $ci;
46 $this->conf = $ci->get('conf');
47 $this->linkDb = $ci->get('db');
48 if ($this->conf->get('dev.debug', false)) {
49 $this->jsonStyle = JSON_PRETTY_PRINT;
50 } else {
51 $this->jsonStyle = null;
52 }
53 }
54}
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
new file mode 100644
index 00000000..25433f72
--- /dev/null
+++ b/application/api/controllers/Info.php
@@ -0,0 +1,42 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Slim\Http\Request;
6use Slim\Http\Response;
7
8/**
9 * Class Info
10 *
11 * REST API Controller: /info
12 *
13 * @package Api\Controllers
14 * @see http://shaarli.github.io/api-documentation/#links-instance-information-get
15 */
16class Info extends ApiController
17{
18 /**
19 * Service providing various information about Shaarli instance.
20 *
21 * @param Request $request Slim request.
22 * @param Response $response Slim response.
23 *
24 * @return Response response.
25 */
26 public function getInfo($request, $response)
27 {
28 $info = [
29 'global_counter' => count($this->linkDb),
30 'private_counter' => count_private($this->linkDb),
31 'settings' => array(
32 'title' => $this->conf->get('general.title', 'Shaarli'),
33 'header_link' => $this->conf->get('general.header_link', '?'),
34 'timezone' => $this->conf->get('general.timezone', 'UTC'),
35 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
36 'default_private_links' => $this->conf->get('privacy.default_private_links', false),
37 ),
38 ];
39
40 return $response->withJson($info, 200, $this->jsonStyle);
41 }
42}
diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php
new file mode 100644
index 00000000..0e3f4776
--- /dev/null
+++ b/application/api/exceptions/ApiAuthorizationException.php
@@ -0,0 +1,34 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiAuthorizationException
7 *
8 * Request not authorized, return a 401 HTTP code.
9 */
10class ApiAuthorizationException extends ApiException
11{
12 /**
13 * {@inheritdoc}
14 */
15 public function getApiResponse()
16 {
17 $this->setMessage('Not authorized');
18 return $this->buildApiResponse(401);
19 }
20
21 /**
22 * Set the exception message.
23 *
24 * We only return a generic error message in production mode to avoid giving
25 * to much security information.
26 *
27 * @param $message string the exception message.
28 */
29 public function setMessage($message)
30 {
31 $original = $this->debug === true ? ': '. $this->getMessage() : '';
32 $this->message = $message . $original;
33 }
34}
diff --git a/application/api/exceptions/ApiBadParametersException.php b/application/api/exceptions/ApiBadParametersException.php
new file mode 100644
index 00000000..e5cc19ea
--- /dev/null
+++ b/application/api/exceptions/ApiBadParametersException.php
@@ -0,0 +1,19 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiBadParametersException
7 *
8 * Invalid request exception, return a 400 HTTP code.
9 */
10class ApiBadParametersException extends ApiException
11{
12 /**
13 * {@inheritdoc}
14 */
15 public function getApiResponse()
16 {
17 return $this->buildApiResponse(400);
18 }
19}
diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php
new file mode 100644
index 00000000..c8490e0c
--- /dev/null
+++ b/application/api/exceptions/ApiException.php
@@ -0,0 +1,77 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5use Slim\Http\Response;
6
7/**
8 * Abstract class ApiException
9 *
10 * Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
11 * Also can include various information in debug mode.
12 */
13abstract class ApiException extends \Exception {
14
15 /**
16 * @var Response instance from Slim.
17 */
18 protected $response;
19
20 /**
21 * @var bool Debug mode enabled/disabled.
22 */
23 protected $debug;
24
25 /**
26 * Build the final response.
27 *
28 * @return Response Final response to give.
29 */
30 public abstract function getApiResponse();
31
32 /**
33 * Creates ApiResponse body.
34 * In production mode, it will only return the exception message,
35 * but in dev mode, it includes additional information in an array.
36 *
37 * @return array|string response body
38 */
39 protected function getApiResponseBody() {
40 if ($this->debug !== true) {
41 return $this->getMessage();
42 }
43 return [
44 'message' => $this->getMessage(),
45 'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
46 ];
47 }
48
49 /**
50 * Build the Response object to return.
51 *
52 * @param int $code HTTP status.
53 *
54 * @return Response with status + body.
55 */
56 protected function buildApiResponse($code)
57 {
58 $style = $this->debug ? JSON_PRETTY_PRINT : null;
59 return $this->response->withJson($this->getApiResponseBody(), $code, $style);
60 }
61
62 /**
63 * @param Response $response
64 */
65 public function setResponse($response)
66 {
67 $this->response = $response;
68 }
69
70 /**
71 * @param bool $debug
72 */
73 public function setDebug($debug)
74 {
75 $this->debug = $debug;
76 }
77}
diff --git a/application/api/exceptions/ApiInternalException.php b/application/api/exceptions/ApiInternalException.php
new file mode 100644
index 00000000..1cb05532
--- /dev/null
+++ b/application/api/exceptions/ApiInternalException.php
@@ -0,0 +1,19 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiInternalException
7 *
8 * Generic exception, return a 500 HTTP code.
9 */
10class ApiInternalException extends ApiException
11{
12 /**
13 * @inheritdoc
14 */
15 public function getApiResponse()
16 {
17 return $this->buildApiResponse(500);
18 }
19}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index f5f753f8..ca8918b5 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -20,6 +20,8 @@ class ConfigManager
20 */ 20 */
21 protected static $NOT_FOUND = 'NOT_FOUND'; 21 protected static $NOT_FOUND = 'NOT_FOUND';
22 22
23 public static $DEFAULT_PLUGINS = array('qrcode');
24
23 /** 25 /**
24 * @var string Config folder. 26 * @var string Config folder.
25 */ 27 */
@@ -308,7 +310,7 @@ class ConfigManager
308 310
309 $this->setEmpty('general.header_link', '?'); 311 $this->setEmpty('general.header_link', '?');
310 $this->setEmpty('general.links_per_page', 20); 312 $this->setEmpty('general.links_per_page', 20);
311 $this->setEmpty('general.enabled_plugins', array('qrcode')); 313 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
312 314
313 $this->setEmpty('updates.check_updates', false); 315 $this->setEmpty('updates.check_updates', false);
314 $this->setEmpty('updates.check_updates_branch', 'stable'); 316 $this->setEmpty('updates.check_updates_branch', 'stable');
diff --git a/composer.json b/composer.json
index f7d26a31..cfbde1a0 100644
--- a/composer.json
+++ b/composer.json
@@ -10,14 +10,23 @@
10 }, 10 },
11 "keywords": ["bookmark", "link", "share", "web"], 11 "keywords": ["bookmark", "link", "share", "web"],
12 "require": { 12 "require": {
13 "php": ">=5.3.4", 13 "php": ">=5.5",
14 "shaarli/netscape-bookmark-parser": "1.*", 14 "shaarli/netscape-bookmark-parser": "1.*",
15 "erusev/parsedown": "1.6" 15 "erusev/parsedown": "1.6",
16 "slim/slim": "^3.0",
17 "pubsubhubbub/publisher": "dev-master"
16 }, 18 },
17 "require-dev": { 19 "require-dev": {
18 "phpmd/phpmd" : "@stable", 20 "phpmd/phpmd" : "@stable",
19 "phpunit/phpunit": "4.8.*", 21 "phpunit/phpunit": "4.8.*",
20 "sebastian/phpcpd": "*", 22 "sebastian/phpcpd": "*",
21 "squizlabs/php_codesniffer": "2.*" 23 "squizlabs/php_codesniffer": "2.*"
24 },
25 "autoload": {
26 "psr-4": {
27 "Shaarli\\Api\\": "application/api/",
28 "Shaarli\\Api\\Controllers\\": "application/api/controllers",
29 "Shaarli\\Api\\Exceptions\\": "application/api/exceptions"
30 }
22 } 31 }
23} 32}
diff --git a/index.php b/index.php
index 34f0e381..2ed14d4f 100644
--- a/index.php
+++ b/index.php
@@ -175,7 +175,6 @@ define('STAY_SIGNED_IN_TOKEN', sha1($conf->get('credentials.hash') . $_SERVER['R
175if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { 175if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
176 autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']); 176 autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']);
177} 177}
178header('Content-Type: text/html; charset=utf-8'); // We use UTF-8 for proper international characters handling.
179 178
180/** 179/**
181 * Checking session state (i.e. is the user still logged in) 180 * Checking session state (i.e. is the user still logged in)
@@ -731,17 +730,10 @@ function showLinkList($PAGE, $LINKSDB, $conf, $pluginManager) {
731 * 730 *
732 * @param ConfigManager $conf Configuration Manager instance. 731 * @param ConfigManager $conf Configuration Manager instance.
733 * @param PluginManager $pluginManager Plugin Manager instance, 732 * @param PluginManager $pluginManager Plugin Manager instance,
733 * @param LinkDB $LINKSDB
734 */ 734 */
735function renderPage($conf, $pluginManager) 735function renderPage($conf, $pluginManager, $LINKSDB)
736{ 736{
737 $LINKSDB = new LinkDB(
738 $conf->get('resource.datastore'),
739 isLoggedIn(),
740 $conf->get('privacy.hide_public_links'),
741 $conf->get('redirector.url'),
742 $conf->get('redirector.encode_url')
743 );
744
745 $updater = new Updater( 737 $updater = new Updater(
746 read_updates_file($conf->get('resource.updates')), 738 read_updates_file($conf->get('resource.updates')),
747 $LINKSDB, 739 $LINKSDB,
@@ -918,10 +910,6 @@ function renderPage($conf, $pluginManager)
918 $feedGenerator->setLocale(strtolower(setlocale(LC_COLLATE, 0))); 910 $feedGenerator->setLocale(strtolower(setlocale(LC_COLLATE, 0)));
919 $feedGenerator->setHideDates($conf->get('privacy.hide_timestamps') && !isLoggedIn()); 911 $feedGenerator->setHideDates($conf->get('privacy.hide_timestamps') && !isLoggedIn());
920 $feedGenerator->setUsePermalinks(isset($_GET['permalinks']) || !$conf->get('feed.rss_permalinks')); 912 $feedGenerator->setUsePermalinks(isset($_GET['permalinks']) || !$conf->get('feed.rss_permalinks'));
921 $pshUrl = $conf->get('config.PUBSUBHUB_URL');
922 if (!empty($pshUrl)) {
923 $feedGenerator->setPubsubhubUrl($pshUrl);
924 }
925 $data = $feedGenerator->buildData(); 913 $data = $feedGenerator->buildData();
926 914
927 // Process plugin hook. 915 // Process plugin hook.
@@ -938,7 +926,7 @@ function renderPage($conf, $pluginManager)
938 exit; 926 exit;
939 } 927 }
940 928
941 // Display openseach plugin (XML) 929 // Display opensearch plugin (XML)
942 if ($targetPage == Router::$PAGE_OPENSEARCH) { 930 if ($targetPage == Router::$PAGE_OPENSEARCH) {
943 header('Content-Type: application/xml; charset=utf-8'); 931 header('Content-Type: application/xml; charset=utf-8');
944 $PAGE->assign('serverurl', index_url($_SERVER)); 932 $PAGE->assign('serverurl', index_url($_SERVER));
@@ -1142,6 +1130,8 @@ function renderPage($conf, $pluginManager)
1142 $conf->set('feed.rss_permalinks', !empty($_POST['enableRssPermalinks'])); 1130 $conf->set('feed.rss_permalinks', !empty($_POST['enableRssPermalinks']));
1143 $conf->set('updates.check_updates', !empty($_POST['updateCheck'])); 1131 $conf->set('updates.check_updates', !empty($_POST['updateCheck']));
1144 $conf->set('privacy.hide_public_links', !empty($_POST['hidePublicLinks'])); 1132 $conf->set('privacy.hide_public_links', !empty($_POST['hidePublicLinks']));
1133 $conf->set('api.enabled', !empty($_POST['apiEnabled']));
1134 $conf->set('api.secret', escape($_POST['apiSecret']));
1145 try { 1135 try {
1146 $conf->write(isLoggedIn()); 1136 $conf->write(isLoggedIn());
1147 } 1137 }
@@ -1170,6 +1160,8 @@ function renderPage($conf, $pluginManager)
1170 $PAGE->assign('enable_rss_permalinks', $conf->get('feed.rss_permalinks', false)); 1160 $PAGE->assign('enable_rss_permalinks', $conf->get('feed.rss_permalinks', false));
1171 $PAGE->assign('enable_update_check', $conf->get('updates.check_updates', true)); 1161 $PAGE->assign('enable_update_check', $conf->get('updates.check_updates', true));
1172 $PAGE->assign('hide_public_links', $conf->get('privacy.hide_public_links', false)); 1162 $PAGE->assign('hide_public_links', $conf->get('privacy.hide_public_links', false));
1163 $PAGE->assign('api_enabled', $conf->get('api.enabled', true));
1164 $PAGE->assign('api_secret', $conf->get('api.secret'));
1173 $PAGE->renderPage('configure'); 1165 $PAGE->renderPage('configure');
1174 exit; 1166 exit;
1175 } 1167 }
@@ -1293,7 +1285,6 @@ function renderPage($conf, $pluginManager)
1293 1285
1294 $LINKSDB[$id] = $link; 1286 $LINKSDB[$id] = $link;
1295 $LINKSDB->save($conf->get('resource.page_cache')); 1287 $LINKSDB->save($conf->get('resource.page_cache'));
1296 pubsubhub($conf);
1297 1288
1298 // If we are called from the bookmarklet, we must close the popup: 1289 // If we are called from the bookmarklet, we must close the popup:
1299 if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) { 1290 if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) {
@@ -1610,8 +1601,8 @@ function renderPage($conf, $pluginManager)
1610function buildLinkList($PAGE,$LINKSDB, $conf, $pluginManager) 1601function buildLinkList($PAGE,$LINKSDB, $conf, $pluginManager)
1611{ 1602{
1612 // Used in templates 1603 // Used in templates
1613 $searchtags = !empty($_GET['searchtags']) ? escape($_GET['searchtags']) : ''; 1604 $searchtags = !empty($_GET['searchtags']) ? escape(normalize_spaces($_GET['searchtags'])) : '';
1614 $searchterm = !empty($_GET['searchterm']) ? escape($_GET['searchterm']) : ''; 1605 $searchterm = !empty($_GET['searchterm']) ? escape(normalize_spaces($_GET['searchterm'])) : '';
1615 1606
1616 // Smallhash filter 1607 // Smallhash filter
1617 if (! empty($_SERVER['QUERY_STRING']) 1608 if (! empty($_SERVER['QUERY_STRING'])
@@ -1658,7 +1649,7 @@ function buildLinkList($PAGE,$LINKSDB, $conf, $pluginManager)
1658 } else { 1649 } else {
1659 $link['updated_timestamp'] = ''; 1650 $link['updated_timestamp'] = '';
1660 } 1651 }
1661 $taglist = explode(' ', $link['tags']); 1652 $taglist = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY);
1662 uasort($taglist, 'strcasecmp'); 1653 uasort($taglist, 'strcasecmp');
1663 $link['taglist'] = $taglist; 1654 $link['taglist'] = $taglist;
1664 // Check for both signs of a note: starting with ? and 7 chars long. 1655 // Check for both signs of a note: starting with ? and 7 chars long.
@@ -1954,6 +1945,14 @@ function install($conf)
1954 $conf->set('general.title', 'Shared links on '.escape(index_url($_SERVER))); 1945 $conf->set('general.title', 'Shared links on '.escape(index_url($_SERVER)));
1955 } 1946 }
1956 $conf->set('updates.check_updates', !empty($_POST['updateCheck'])); 1947 $conf->set('updates.check_updates', !empty($_POST['updateCheck']));
1948 $conf->set('api.enabled', !empty($_POST['enableApi']));
1949 $conf->set(
1950 'api.secret',
1951 generate_api_secret(
1952 $conf->get('credentials.login'),
1953 $conf->get('credentials.salt')
1954 )
1955 );
1957 try { 1956 try {
1958 // Everything is ok, let's create config file. 1957 // Everything is ok, let's create config file.
1959 $conf->write(isLoggedIn()); 1958 $conf->write(isLoggedIn());
@@ -2216,4 +2215,32 @@ if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=
2216if (!isset($_SESSION['LINKS_PER_PAGE'])) { 2215if (!isset($_SESSION['LINKS_PER_PAGE'])) {
2217 $_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20); 2216 $_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20);
2218} 2217}
2219renderPage($conf, $pluginManager); 2218
2219$linkDb = new LinkDB(
2220 $conf->get('resource.datastore'),
2221 isLoggedIn(),
2222 $conf->get('privacy.hide_public_links'),
2223 $conf->get('redirector.url'),
2224 $conf->get('redirector.encode_url')
2225);
2226
2227$container = new \Slim\Container();
2228$container['conf'] = $conf;
2229$container['plugins'] = $pluginManager;
2230$app = new \Slim\App($container);
2231
2232// REST API routes
2233$app->group('/api/v1', function() {
2234 $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo');
2235})->add('\Shaarli\Api\ApiMiddleware');
2236
2237$response = $app->run(true);
2238// Hack to make Slim and Shaarli router work together:
2239// If a Slim route isn't found, we call renderPage().
2240if ($response->getStatusCode() == 404) {
2241 // We use UTF-8 for proper international characters handling.
2242 header('Content-Type: text/html; charset=utf-8');
2243 renderPage($conf, $pluginManager, $linkDb);
2244} else {
2245 $app->respond($response);
2246}
diff --git a/plugins/markdown/markdown.meta b/plugins/markdown/markdown.meta
index 8df2ed0b..322856ea 100644
--- a/plugins/markdown/markdown.meta
+++ b/plugins/markdown/markdown.meta
@@ -1,4 +1,4 @@
1description="Render shaare description with Markdown syntax.<br><strong>Warning</strong>: 1description="Render shaare description with Markdown syntax.<br><strong>Warning</strong>:
2If your shaared descriptions containing HTML tags before enabling the markdown plugin, 2If your shaared descriptions contained HTML tags before enabling the markdown plugin,
3enabling it might break your page. 3enabling it might break your page.
4See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/markdown#html-rendering\">README</a>." 4See 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
index 00000000..3a65492a
--- /dev/null
+++ b/plugins/pubsubhubbub/README.md
@@ -0,0 +1,20 @@
1# PubSubHubbub plugin
2
3Enable this plugin to notify a Hub everytime you add or edit a link.
4
5This allow hub subcribers to receive update notifications in real time,
6which is useful for feed syndication service which supports PubSubHubbub.
7
8## Public Hub
9
10By default, Shaarli will use [Google's public hub](http://pubsubhubbub.appspot.com/).
11
12[Here](https://github.com/pubsubhubbub/PubSubHubbub/wiki/Hubs) is a list of public hubs.
13
14You can also host your own PubSubHubbub server implementation, such as [phubb](https://github.com/cweiske/phubb).
15
16## cURL
17
18While there is a fallback function to notify the hub, it's recommended that
19you have PHP cURL extension enabled to use this plugin.
20
diff --git a/plugins/pubsubhubbub/hub.atom.xml b/plugins/pubsubhubbub/hub.atom.xml
new file mode 100644
index 00000000..24d93d3e
--- /dev/null
+++ b/plugins/pubsubhubbub/hub.atom.xml
@@ -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
index 00000000..27bf67a6
--- /dev/null
+++ b/plugins/pubsubhubbub/hub.rss.xml
@@ -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
index 00000000..289f5cdb
--- /dev/null
+++ b/plugins/pubsubhubbub/pubsubhubbub.meta
@@ -0,0 +1,2 @@
1description="Enable PubSubHubbub feed publishing."
2parameters="PUBSUBHUB_URL"
diff --git a/plugins/pubsubhubbub/pubsubhubbub.php b/plugins/pubsubhubbub/pubsubhubbub.php
new file mode 100644
index 00000000..03b6757b
--- /dev/null
+++ b/plugins/pubsubhubbub/pubsubhubbub.php
@@ -0,0 +1,101 @@
1<?php
2
3/**
4 * PubSubHubbub plugin.
5 *
6 * PubSub is a protocol which fasten up RSS fetching:
7 * - Every time a new link is posted, Shaarli notify the hub.
8 * - The hub notify all feed subscribers that a new link has been posted.
9 * - Subscribers retrieve the new link.
10 */
11
12use pubsubhubbub\publisher\Publisher;
13
14/**
15 * Plugin init function - set the hub to the default appspot one.
16 *
17 * @param ConfigManager $conf instance.
18 */
19function pubsubhubbub_init($conf)
20{
21 $hub = $conf->get('plugins.PUBSUBHUB_URL');
22 if (empty($hub)) {
23 // Default hub.
24 $conf->set('plugins.PUBSUBHUB_URL', 'https://pubsubhubbub.appspot.com/');
25 }
26}
27
28
29/**
30 * Render feed hook.
31 * Adds the hub URL in ATOM and RSS feed.
32 *
33 * @param array $data Template data.
34 * @param ConfigManager $conf instance.
35 *
36 * @return array updated template data.
37 */
38function hook_pubsubhubbub_render_feed($data, $conf)
39{
40 $feedType = $data['_PAGE_'] == Router::$PAGE_FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
41 $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.'. $feedType .'.xml');
42 $data['feed_plugins_header'][] = sprintf($template, $conf->get('plugins.PUBSUBHUB_URL'));
43
44 return $data;
45}
46
47/**
48 * Save link hook.
49 * Publish to the hub when a link is saved.
50 *
51 * @param array $data Template data.
52 * @param ConfigManager $conf instance.
53 *
54 * @return array unaltered data.
55 */
56function hook_pubsubhubbub_save_link($data, $conf)
57{
58 $feeds = array(
59 index_url($_SERVER) .'?do=atom',
60 index_url($_SERVER) .'?do=rss',
61 );
62
63 $httpPost = function_exists('curl_version') ? false : 'nocurl_http_post';
64 try {
65 $p = new Publisher($conf->get('plugins.PUBSUBHUB_URL'));
66 $p->publish_update($feeds, $httpPost);
67 } catch (Exception $e) {
68 error_log('Could not publish to PubSubHubbub: ' . $e->getMessage());
69 }
70
71 return $data;
72}
73
74/**
75 * Http function used to post to the hub endpoint without cURL extension.
76 *
77 * @param string $url Hub endpoint.
78 * @param string $postString String to POST.
79 *
80 * @return bool
81 *
82 * @throws Exception An error occurred.
83 */
84function nocurl_http_post($url, $postString) {
85 $params = array('http' => array(
86 'method' => 'POST',
87 'content' => $postString,
88 'user_agent' => 'PubSubHubbub-Publisher-PHP/1.0',
89 ));
90
91 $context = stream_context_create($params);
92 $fp = @fopen($url, 'rb', false, $context);
93 if (!$fp) {
94 throw new Exception('Could not post to '. $url);
95 }
96 $response = @stream_get_contents($fp);
97 if ($response === false) {
98 throw new Exception('Bad response from the hub '. $url);
99 }
100 return $response;
101}
diff --git a/tests/FeedBuilderTest.php b/tests/FeedBuilderTest.php
index 06a44506..a590306d 100644
--- a/tests/FeedBuilderTest.php
+++ b/tests/FeedBuilderTest.php
@@ -75,7 +75,6 @@ class FeedBuilderTest extends PHPUnit_Framework_TestCase
75 $data = $feedBuilder->buildData(); 75 $data = $feedBuilder->buildData();
76 // Test headers (RSS) 76 // Test headers (RSS)
77 $this->assertEquals(self::$RSS_LANGUAGE, $data['language']); 77 $this->assertEquals(self::$RSS_LANGUAGE, $data['language']);
78 $this->assertEmpty($data['pubsubhub_url']);
79 $this->assertRegExp('/Wed, 03 Aug 2016 09:30:33 \+\d{4}/', $data['last_update']); 78 $this->assertRegExp('/Wed, 03 Aug 2016 09:30:33 \+\d{4}/', $data['last_update']);
80 $this->assertEquals(true, $data['show_dates']); 79 $this->assertEquals(true, $data['show_dates']);
81 $this->assertEquals('http://host.tld/index.php?do=feed', $data['self_link']); 80 $this->assertEquals('http://host.tld/index.php?do=feed', $data['self_link']);
@@ -211,19 +210,6 @@ class FeedBuilderTest extends PHPUnit_Framework_TestCase
211 } 210 }
212 211
213 /** 212 /**
214 * Test buildData with hide dates settings.
215 */
216 public function testBuildDataPubsubhub()
217 {
218 $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, false);
219 $feedBuilder->setLocale(self::$LOCALE);
220 $feedBuilder->setPubsubhubUrl('http://pubsubhub.io');
221 $data = $feedBuilder->buildData();
222 $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
223 $this->assertEquals('http://pubsubhub.io', $data['pubsubhub_url']);
224 }
225
226 /**
227 * Test buildData when Shaarli is served from a subdirectory 213 * Test buildData when Shaarli is served from a subdirectory
228 */ 214 */
229 public function testBuildDataServerSubdir() 215 public function testBuildDataServerSubdir()
diff --git a/tests/HttpUtils/ServerUrlTest.php b/tests/HttpUtils/ServerUrlTest.php
index 8a55a220..7fdad659 100644
--- a/tests/HttpUtils/ServerUrlTest.php
+++ b/tests/HttpUtils/ServerUrlTest.php
@@ -69,6 +69,19 @@ class ServerUrlTest extends PHPUnit_Framework_TestCase
69 ); 69 );
70 70
71 $this->assertEquals( 71 $this->assertEquals(
72 'https://host.tld',
73 server_url(
74 array(
75 'HTTPS' => 'Off',
76 'SERVER_NAME' => 'host.tld',
77 'SERVER_PORT' => '80',
78 'HTTP_X_FORWARDED_PROTO' => 'https',
79 'HTTP_X_FORWARDED_PORT' => '443'
80 )
81 )
82 );
83
84 $this->assertEquals(
72 'https://host.tld:4974', 85 'https://host.tld:4974',
73 server_url( 86 server_url(
74 array( 87 array(
diff --git a/tests/Updater/UpdaterTest.php b/tests/Updater/UpdaterTest.php
index 4948fe52..0171daad 100644
--- a/tests/Updater/UpdaterTest.php
+++ b/tests/Updater/UpdaterTest.php
@@ -271,7 +271,7 @@ $GLOBALS[\'privateLinkByDefault\'] = true;';
271 public function testEscapeConfig() 271 public function testEscapeConfig()
272 { 272 {
273 $sandbox = 'sandbox/config'; 273 $sandbox = 'sandbox/config';
274 copy(self::$configFile .'.json.php', $sandbox .'.json.php'); 274 copy(self::$configFile . '.json.php', $sandbox . '.json.php');
275 $this->conf = new ConfigManager($sandbox); 275 $this->conf = new ConfigManager($sandbox);
276 $title = '<script>alert("title");</script>'; 276 $title = '<script>alert("title");</script>';
277 $headerLink = '<script>alert("header_link");</script>'; 277 $headerLink = '<script>alert("header_link");</script>';
@@ -286,7 +286,43 @@ $GLOBALS[\'privateLinkByDefault\'] = true;';
286 $this->assertEquals(escape($title), $this->conf->get('general.title')); 286 $this->assertEquals(escape($title), $this->conf->get('general.title'));
287 $this->assertEquals(escape($headerLink), $this->conf->get('general.header_link')); 287 $this->assertEquals(escape($headerLink), $this->conf->get('general.header_link'));
288 $this->assertEquals(escape($redirectorUrl), $this->conf->get('redirector.url')); 288 $this->assertEquals(escape($redirectorUrl), $this->conf->get('redirector.url'));
289 unlink($sandbox .'.json.php'); 289 unlink($sandbox . '.json.php');
290 }
291
292 /**
293 * Test updateMethodApiSettings(): create default settings for the API (enabled + secret).
294 */
295 public function testUpdateApiSettings()
296 {
297 $confFile = 'sandbox/config';
298 copy(self::$configFile .'.json.php', $confFile .'.json.php');
299 $conf = new ConfigManager($confFile);
300 $updater = new Updater(array(), array(), $conf, true);
301
302 $this->assertFalse($conf->exists('api.enabled'));
303 $this->assertFalse($conf->exists('api.secret'));
304 $updater->updateMethodApiSettings();
305 $conf->reload();
306 $this->assertTrue($conf->get('api.enabled'));
307 $this->assertTrue($conf->exists('api.secret'));
308 unlink($confFile .'.json.php');
309 }
310
311 /**
312 * Test updateMethodApiSettings(): already set, do nothing.
313 */
314 public function testUpdateApiSettingsNothingToDo()
315 {
316 $confFile = 'sandbox/config';
317 copy(self::$configFile .'.json.php', $confFile .'.json.php');
318 $conf = new ConfigManager($confFile);
319 $conf->set('api.enabled', false);
320 $conf->set('api.secret', '');
321 $updater = new Updater(array(), array(), $conf, true);
322 $updater->updateMethodApiSettings();
323 $this->assertFalse($conf->get('api.enabled'));
324 $this->assertEmpty($conf->get('api.secret'));
325 unlink($confFile .'.json.php');
290 } 326 }
291 327
292 /** 328 /**
diff --git a/tests/Url/CleanupUrlTest.php b/tests/Url/CleanupUrlTest.php
index ba9a0437..1407d7d2 100644
--- a/tests/Url/CleanupUrlTest.php
+++ b/tests/Url/CleanupUrlTest.php
@@ -8,7 +8,13 @@ require_once 'application/Url.php';
8class CleanupUrlTest extends PHPUnit_Framework_TestCase 8class CleanupUrlTest extends PHPUnit_Framework_TestCase
9{ 9{
10 /** 10 /**
11 * Clean empty UrlThanks for building nothing 11 * @var string reference URL
12 */
13 protected $ref = 'http://domain.tld:3000';
14
15
16 /**
17 * Clean empty URL
12 */ 18 */
13 public function testCleanupUrlEmpty() 19 public function testCleanupUrlEmpty()
14 { 20 {
@@ -16,59 +22,87 @@ class CleanupUrlTest extends PHPUnit_Framework_TestCase
16 } 22 }
17 23
18 /** 24 /**
19 * Clean an already cleaned Url 25 * Clean an already cleaned URL
20 */ 26 */
21 public function testCleanupUrlAlreadyClean() 27 public function testCleanupUrlAlreadyClean()
22 { 28 {
23 $ref = 'http://domain.tld:3000'; 29 $this->assertEquals($this->ref, cleanup_url($this->ref));
24 $this->assertEquals($ref, cleanup_url($ref)); 30 $this->ref2 = $this->ref.'/path/to/dir/';
25 $ref = $ref.'/path/to/dir/'; 31 $this->assertEquals($this->ref2, cleanup_url($this->ref2));
26 $this->assertEquals($ref, cleanup_url($ref)); 32 }
33
34 /**
35 * Clean URL fragments
36 */
37 public function testCleanupUrlFragment()
38 {
39 $this->assertEquals($this->ref, cleanup_url($this->ref.'#tk.rss_all'));
40 $this->assertEquals($this->ref, cleanup_url($this->ref.'#xtor=RSS-'));
41 $this->assertEquals($this->ref, cleanup_url($this->ref.'#xtor=RSS-U3ht0tkc4b'));
42 }
43
44 /**
45 * Clean URL query - single annoying parameter
46 */
47 public function testCleanupUrlQuerySingle()
48 {
49 $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_object_map=junk'));
50 $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_ref_map=Cr4p!'));
51 $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_type_map=g4R84g3'));
52
53 $this->assertEquals($this->ref, cleanup_url($this->ref.'?fb_stuff=v41u3'));
54 $this->assertEquals($this->ref, cleanup_url($this->ref.'?fb=71m3w4573'));
55
56 $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_campaign=zomg'));
57 $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_medium=numnum'));
58 $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_source=c0d3'));
59 $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_term=1n4l'));
60
61 $this->assertEquals($this->ref, cleanup_url($this->ref.'?xtor=some-url'));
62
63 $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_name=junk'));
64 $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_start=junk'));
65 $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_item_index=junk'));
27 } 66 }
28 67
29 /** 68 /**
30 * Clean Url needing cleaning 69 * Clean URL query - multiple annoying parameters
31 */ 70 */
32 public function testCleanupUrlNeedClean() 71 public function testCleanupUrlQueryMultiple()
33 { 72 {
34 $ref = 'http://domain.tld:3000'; 73 $this->assertEquals($this->ref, cleanup_url($this->ref.'?xtor=some-url&fb=som3th1ng'));
35 $this->assertEquals($ref, cleanup_url($ref.'#tk.rss_all')); 74
36 $this->assertEquals($ref, cleanup_url($ref.'#xtor=RSS-')); 75 $this->assertEquals($this->ref, cleanup_url(
37 $this->assertEquals($ref, cleanup_url($ref.'#xtor=RSS-U3ht0tkc4b')); 76 $this->ref.'?fb=stuff&utm_campaign=zomg&utm_medium=numnum&utm_source=c0d3'
38 $this->assertEquals($ref, cleanup_url($ref.'?action_object_map=junk'));
39 $this->assertEquals($ref, cleanup_url($ref.'?action_ref_map=Cr4p!'));
40 $this->assertEquals($ref, cleanup_url($ref.'?action_type_map=g4R84g3'));
41
42 $this->assertEquals($ref, cleanup_url($ref.'?fb_stuff=v41u3'));
43 $this->assertEquals($ref, cleanup_url($ref.'?fb=71m3w4573'));
44
45 $this->assertEquals($ref, cleanup_url($ref.'?utm_campaign=zomg'));
46 $this->assertEquals($ref, cleanup_url($ref.'?utm_medium=numnum'));
47 $this->assertEquals($ref, cleanup_url($ref.'?utm_source=c0d3'));
48 $this->assertEquals($ref, cleanup_url($ref.'?utm_term=1n4l'));
49
50 $this->assertEquals($ref, cleanup_url($ref.'?xtor=some-url'));
51 $this->assertEquals($ref, cleanup_url($ref.'?xtor=some-url&fb=som3th1ng'));
52 $this->assertEquals($ref, cleanup_url(
53 $ref.'?fb=stuff&utm_campaign=zomg&utm_medium=numnum&utm_source=c0d3'
54 )); 77 ));
55 $this->assertEquals($ref, cleanup_url( 78
56 $ref.'?xtor=some-url&fb=som3th1ng#tk.rss_all' 79 $this->assertEquals($this->ref, cleanup_url(
80 $this->ref.'?campaign_start=zomg&campaign_name=numnum'
81 ));
82 }
83
84 /**
85 * Clean URL query - multiple annoying parameters and fragment
86 */
87 public function testCleanupUrlQueryFragment()
88 {
89 $this->assertEquals($this->ref, cleanup_url(
90 $this->ref.'?xtor=some-url&fb=som3th1ng#tk.rss_all'
57 )); 91 ));
58 92
59 // ditch annoying query params and fragment, keep useful params 93 // ditch annoying query params and fragment, keep useful params
60 $this->assertEquals( 94 $this->assertEquals(
61 $ref.'?my=stuff&is=kept', 95 $this->ref.'?my=stuff&is=kept',
62 cleanup_url( 96 cleanup_url(
63 $ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#tk.rss_all' 97 $this->ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#tk.rss_all'
64 ) 98 )
65 ); 99 );
66 100
67 // ditch annoying query params, keep useful params and fragment 101 // ditch annoying query params, keep useful params and fragment
68 $this->assertEquals( 102 $this->assertEquals(
69 $ref.'?my=stuff&is=kept#again', 103 $this->ref.'?my=stuff&is=kept#again',
70 cleanup_url( 104 cleanup_url(
71 $ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#again' 105 $this->ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#again'
72 ) 106 )
73 ); 107 );
74 } 108 }
diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php
index 6a7870c4..c885f552 100644
--- a/tests/UtilsTest.php
+++ b/tests/UtilsTest.php
@@ -253,4 +253,33 @@ class UtilsTest extends PHPUnit_Framework_TestCase
253 is_session_id_valid('c0ZqcWF3VFE2NmJBdm1HMVQ0ZHJ3UmZPbTFsNGhkNHI=') 253 is_session_id_valid('c0ZqcWF3VFE2NmJBdm1HMVQ0ZHJ3UmZPbTFsNGhkNHI=')
254 ); 254 );
255 } 255 }
256
257 /**
258 * Test generateSecretApi.
259 */
260 public function testGenerateSecretApi()
261 {
262 $this->assertEquals(12, strlen(generate_api_secret('foo', 'bar')));
263 }
264
265 /**
266 * Test generateSecretApi with invalid parameters.
267 */
268 public function testGenerateSecretApiInvalid()
269 {
270 $this->assertFalse(generate_api_secret('', ''));
271 $this->assertFalse(generate_api_secret(false, false));
272 }
273
274 /**
275 * Test normalize_spaces.
276 */
277 public function testNormalizeSpace()
278 {
279 $str = ' foo bar is important ';
280 $this->assertEquals('foo bar is important', normalize_spaces($str));
281 $this->assertEquals('foo', normalize_spaces('foo'));
282 $this->assertEquals('', normalize_spaces(''));
283 $this->assertEquals(null, normalize_spaces(null));
284 }
256} 285}
diff --git a/tests/api/ApiMiddlewareTest.php b/tests/api/ApiMiddlewareTest.php
new file mode 100644
index 00000000..4d4dd9b9
--- /dev/null
+++ b/tests/api/ApiMiddlewareTest.php
@@ -0,0 +1,184 @@
1<?php
2
3namespace Shaarli\Api;
4
5use Slim\Container;
6use Slim\Http\Environment;
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class ApiMiddlewareTest
12 *
13 * Test the REST API Slim Middleware.
14 *
15 * Note that we can't test a valid use case here, because the middleware
16 * needs to call a valid controller/action during its execution.
17 *
18 * @package Api
19 */
20class ApiMiddlewareTest extends \PHPUnit_Framework_TestCase
21{
22 /**
23 * @var string datastore to test write operations
24 */
25 protected static $testDatastore = 'sandbox/datastore.php';
26
27 /**
28 * @var \ConfigManager instance
29 */
30 protected $conf;
31
32 /**
33 * @var \ReferenceLinkDB instance.
34 */
35 protected $refDB = null;
36
37 /**
38 * @var Container instance.
39 */
40 protected $container;
41
42 /**
43 * Before every test, instantiate a new Api with its config, plugins and links.
44 */
45 public function setUp()
46 {
47 $this->conf = new \ConfigManager('tests/utils/config/configJson.json.php');
48 $this->conf->set('api.secret', 'NapoleonWasALizard');
49
50 $this->refDB = new \ReferenceLinkDB();
51 $this->refDB->write(self::$testDatastore);
52
53 $this->container = new Container();
54 $this->container['conf'] = $this->conf;
55 }
56
57 /**
58 * After every test, remove the test datastore.
59 */
60 public function tearDown()
61 {
62 @unlink(self::$testDatastore);
63 }
64
65 /**
66 * Invoke the middleware with the API disabled:
67 * should return a 401 error Unauthorized.
68 */
69 public function testInvokeMiddlewareApiDisabled()
70 {
71 $this->conf->set('api.enabled', false);
72 $mw = new ApiMiddleware($this->container);
73 $env = Environment::mock([
74 'REQUEST_METHOD' => 'GET',
75 'REQUEST_URI' => '/echo',
76 ]);
77 $request = Request::createFromEnvironment($env);
78 $response = new Response();
79 /** @var Response $response */
80 $response = $mw($request, $response, null);
81
82 $this->assertEquals(401, $response->getStatusCode());
83 $body = json_decode((string) $response->getBody());
84 $this->assertEquals('Not authorized', $body);
85 }
86
87 /**
88 * Invoke the middleware with the API disabled in debug mode:
89 * should return a 401 error Unauthorized - with a specific message and a stacktrace.
90 */
91 public function testInvokeMiddlewareApiDisabledDebug()
92 {
93 $this->conf->set('api.enabled', false);
94 $this->conf->set('dev.debug', true);
95 $mw = new ApiMiddleware($this->container);
96 $env = Environment::mock([
97 'REQUEST_METHOD' => 'GET',
98 'REQUEST_URI' => '/echo',
99 ]);
100 $request = Request::createFromEnvironment($env);
101 $response = new Response();
102 /** @var Response $response */
103 $response = $mw($request, $response, null);
104
105 $this->assertEquals(401, $response->getStatusCode());
106 $body = json_decode((string) $response->getBody());
107 $this->assertEquals('Not authorized: API is disabled', $body->message);
108 $this->assertContains('ApiAuthorizationException', $body->stacktrace);
109 }
110
111 /**
112 * Invoke the middleware without a token (debug):
113 * should return a 401 error Unauthorized - with a specific message and a stacktrace.
114 */
115 public function testInvokeMiddlewareNoTokenProvidedDebug()
116 {
117 $this->conf->set('dev.debug', true);
118 $mw = new ApiMiddleware($this->container);
119 $env = Environment::mock([
120 'REQUEST_METHOD' => 'GET',
121 'REQUEST_URI' => '/echo',
122 ]);
123 $request = Request::createFromEnvironment($env);
124 $response = new Response();
125 /** @var Response $response */
126 $response = $mw($request, $response, null);
127
128 $this->assertEquals(401, $response->getStatusCode());
129 $body = json_decode((string) $response->getBody());
130 $this->assertEquals('Not authorized: JWT token not provided', $body->message);
131 $this->assertContains('ApiAuthorizationException', $body->stacktrace);
132 }
133
134 /**
135 * Invoke the middleware without a secret set in settings (debug):
136 * should return a 401 error Unauthorized - with a specific message and a stacktrace.
137 */
138 public function testInvokeMiddlewareNoSecretSetDebug()
139 {
140 $this->conf->set('dev.debug', true);
141 $this->conf->set('api.secret', '');
142 $mw = new ApiMiddleware($this->container);
143 $env = Environment::mock([
144 'REQUEST_METHOD' => 'GET',
145 'REQUEST_URI' => '/echo',
146 'HTTP_JWT'=> 'jwt',
147 ]);
148 $request = Request::createFromEnvironment($env);
149 $response = new Response();
150 /** @var Response $response */
151 $response = $mw($request, $response, null);
152
153 $this->assertEquals(401, $response->getStatusCode());
154 $body = json_decode((string) $response->getBody());
155 $this->assertEquals('Not authorized: Token secret must be set in Shaarli\'s administration', $body->message);
156 $this->assertContains('ApiAuthorizationException', $body->stacktrace);
157 }
158
159 /**
160 * Invoke the middleware without an invalid JWT token (debug):
161 * should return a 401 error Unauthorized - with a specific message and a stacktrace.
162 *
163 * Note: specific JWT errors tests are handled in ApiUtilsTest.
164 */
165 public function testInvokeMiddlewareInvalidJwtDebug()
166 {
167 $this->conf->set('dev.debug', true);
168 $mw = new ApiMiddleware($this->container);
169 $env = Environment::mock([
170 'REQUEST_METHOD' => 'GET',
171 'REQUEST_URI' => '/echo',
172 'HTTP_JWT'=> 'bad jwt',
173 ]);
174 $request = Request::createFromEnvironment($env);
175 $response = new Response();
176 /** @var Response $response */
177 $response = $mw($request, $response, null);
178
179 $this->assertEquals(401, $response->getStatusCode());
180 $body = json_decode((string) $response->getBody());
181 $this->assertEquals('Not authorized: Malformed JWT token', $body->message);
182 $this->assertContains('ApiAuthorizationException', $body->stacktrace);
183 }
184}
diff --git a/tests/api/ApiUtilsTest.php b/tests/api/ApiUtilsTest.php
new file mode 100644
index 00000000..10da1459
--- /dev/null
+++ b/tests/api/ApiUtilsTest.php
@@ -0,0 +1,206 @@
1<?php
2
3namespace Shaarli\Api;
4
5/**
6 * Class ApiUtilsTest
7 */
8class ApiUtilsTest extends \PHPUnit_Framework_TestCase
9{
10 /**
11 * Force the timezone for ISO datetimes.
12 */
13 public static function setUpBeforeClass()
14 {
15 date_default_timezone_set('UTC');
16 }
17
18 /**
19 * Generate a valid JWT token.
20 *
21 * @param string $secret API secret used to generate the signature.
22 *
23 * @return string Generated token.
24 */
25 public static function generateValidJwtToken($secret)
26 {
27 $header = base64_encode('{
28 "typ": "JWT",
29 "alg": "HS512"
30 }');
31 $payload = base64_encode('{
32 "iat": '. time() .'
33 }');
34 $signature = hash_hmac('sha512', $header .'.'. $payload , $secret);
35 return $header .'.'. $payload .'.'. $signature;
36 }
37
38 /**
39 * Generate a JWT token from given header and payload.
40 *
41 * @param string $header Header in JSON format.
42 * @param string $payload Payload in JSON format.
43 * @param string $secret API secret used to hash the signature.
44 *
45 * @return string JWT token.
46 */
47 public static function generateCustomJwtToken($header, $payload, $secret)
48 {
49 $header = base64_encode($header);
50 $payload = base64_encode($payload);
51 $signature = hash_hmac('sha512', $header . '.' . $payload, $secret);
52 return $header . '.' . $payload . '.' . $signature;
53 }
54
55 /**
56 * Test validateJwtToken() with a valid JWT token.
57 */
58 public function testValidateJwtTokenValid()
59 {
60 $secret = 'WarIsPeace';
61 ApiUtils::validateJwtToken(self::generateValidJwtToken($secret), $secret);
62 }
63
64 /**
65 * Test validateJwtToken() with a malformed JWT token.
66 *
67 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
68 * @expectedExceptionMessage Malformed JWT token
69 */
70 public function testValidateJwtTokenMalformed()
71 {
72 $token = 'ABC.DEF';
73 ApiUtils::validateJwtToken($token, 'foo');
74 }
75
76 /**
77 * Test validateJwtToken() with an empty JWT token.
78 *
79 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
80 * @expectedExceptionMessage Malformed JWT token
81 */
82 public function testValidateJwtTokenMalformedEmpty()
83 {
84 $token = false;
85 ApiUtils::validateJwtToken($token, 'foo');
86 }
87
88 /**
89 * Test validateJwtToken() with a JWT token without header.
90 *
91 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
92 * @expectedExceptionMessage Malformed JWT token
93 */
94 public function testValidateJwtTokenMalformedEmptyHeader()
95 {
96 $token = '.payload.signature';
97 ApiUtils::validateJwtToken($token, 'foo');
98 }
99
100 /**
101 * Test validateJwtToken() with a JWT token without payload
102 *
103 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
104 * @expectedExceptionMessage Malformed JWT token
105 */
106 public function testValidateJwtTokenMalformedEmptyPayload()
107 {
108 $token = 'header..signature';
109 ApiUtils::validateJwtToken($token, 'foo');
110 }
111
112 /**
113 * Test validateJwtToken() with a JWT token with an empty signature.
114 *
115 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
116 * @expectedExceptionMessage Invalid JWT signature
117 */
118 public function testValidateJwtTokenInvalidSignatureEmpty()
119 {
120 $token = 'header.payload.';
121 ApiUtils::validateJwtToken($token, 'foo');
122 }
123
124 /**
125 * Test validateJwtToken() with a JWT token with an invalid signature.
126 *
127 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
128 * @expectedExceptionMessage Invalid JWT signature
129 */
130 public function testValidateJwtTokenInvalidSignature()
131 {
132 $token = 'header.payload.nope';
133 ApiUtils::validateJwtToken($token, 'foo');
134 }
135
136 /**
137 * Test validateJwtToken() with a JWT token with a signature generated with the wrong API secret.
138 *
139 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
140 * @expectedExceptionMessage Invalid JWT signature
141 */
142 public function testValidateJwtTokenInvalidSignatureSecret()
143 {
144 ApiUtils::validateJwtToken(self::generateValidJwtToken('foo'), 'bar');
145 }
146
147 /**
148 * Test validateJwtToken() with a JWT token with a an invalid header (not JSON).
149 *
150 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
151 * @expectedExceptionMessage Invalid JWT header
152 */
153 public function testValidateJwtTokenInvalidHeader()
154 {
155 $token = $this->generateCustomJwtToken('notJSON', '{"JSON":1}', 'secret');
156 ApiUtils::validateJwtToken($token, 'secret');
157 }
158
159 /**
160 * Test validateJwtToken() with a JWT token with a an invalid payload (not JSON).
161 *
162 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
163 * @expectedExceptionMessage Invalid JWT payload
164 */
165 public function testValidateJwtTokenInvalidPayload()
166 {
167 $token = $this->generateCustomJwtToken('{"JSON":1}', 'notJSON', 'secret');
168 ApiUtils::validateJwtToken($token, 'secret');
169 }
170
171 /**
172 * Test validateJwtToken() with a JWT token without issued time.
173 *
174 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
175 * @expectedExceptionMessage Invalid JWT issued time
176 */
177 public function testValidateJwtTokenInvalidTimeEmpty()
178 {
179 $token = $this->generateCustomJwtToken('{"JSON":1}', '{"JSON":1}', 'secret');
180 ApiUtils::validateJwtToken($token, 'secret');
181 }
182
183 /**
184 * Test validateJwtToken() with an expired JWT token.
185 *
186 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
187 * @expectedExceptionMessage Invalid JWT issued time
188 */
189 public function testValidateJwtTokenInvalidTimeExpired()
190 {
191 $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() - 600) . '}', 'secret');
192 ApiUtils::validateJwtToken($token, 'secret');
193 }
194
195 /**
196 * Test validateJwtToken() with a JWT token issued in the future.
197 *
198 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
199 * @expectedExceptionMessage Invalid JWT issued time
200 */
201 public function testValidateJwtTokenInvalidTimeFuture()
202 {
203 $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() + 60) . '}', 'secret');
204 ApiUtils::validateJwtToken($token, 'secret');
205 }
206}
diff --git a/tests/api/controllers/InfoTest.php b/tests/api/controllers/InfoTest.php
new file mode 100644
index 00000000..2916eed8
--- /dev/null
+++ b/tests/api/controllers/InfoTest.php
@@ -0,0 +1,113 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Slim\Container;
6use Slim\Http\Environment;
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class InfoTest
12 *
13 * Test REST API controller Info.
14 *
15 * @package Api\Controllers
16 */
17class InfoTest extends \PHPUnit_Framework_TestCase
18{
19 /**
20 * @var string datastore to test write operations
21 */
22 protected static $testDatastore = 'sandbox/datastore.php';
23
24 /**
25 * @var \ConfigManager instance
26 */
27 protected $conf;
28
29 /**
30 * @var \ReferenceLinkDB instance.
31 */
32 protected $refDB = null;
33
34 /**
35 * @var Container instance.
36 */
37 protected $container;
38
39 /**
40 * @var Info controller instance.
41 */
42 protected $controller;
43
44 /**
45 * Before every test, instantiate a new Api with its config, plugins and links.
46 */
47 public function setUp()
48 {
49 $this->conf = new \ConfigManager('tests/utils/config/configJson.json.php');
50 $this->refDB = new \ReferenceLinkDB();
51 $this->refDB->write(self::$testDatastore);
52
53 $this->container = new Container();
54 $this->container['conf'] = $this->conf;
55 $this->container['db'] = new \LinkDB(self::$testDatastore, true, false);
56
57 $this->controller = new Info($this->container);
58 }
59
60 /**
61 * After every test, remove the test datastore.
62 */
63 public function tearDown()
64 {
65 @unlink(self::$testDatastore);
66 }
67
68 /**
69 * Test /info service.
70 */
71 public function testGetInfo()
72 {
73 $env = Environment::mock([
74 'REQUEST_METHOD' => 'GET',
75 ]);
76 $request = Request::createFromEnvironment($env);
77
78 $response = $this->controller->getInfo($request, new Response());
79 $this->assertEquals(200, $response->getStatusCode());
80 $data = json_decode((string) $response->getBody(), true);
81
82 $this->assertEquals(8, $data['global_counter']);
83 $this->assertEquals(2, $data['private_counter']);
84 $this->assertEquals('Shaarli', $data['settings']['title']);
85 $this->assertEquals('?', $data['settings']['header_link']);
86 $this->assertEquals('UTC', $data['settings']['timezone']);
87 $this->assertEquals(\ConfigManager::$DEFAULT_PLUGINS, $data['settings']['enabled_plugins']);
88 $this->assertEquals(false, $data['settings']['default_private_links']);
89
90 $title = 'My links';
91 $headerLink = 'http://shaarli.tld';
92 $timezone = 'Europe/Paris';
93 $enabledPlugins = array('foo', 'bar');
94 $defaultPrivateLinks = true;
95 $this->conf->set('general.title', $title);
96 $this->conf->set('general.header_link', $headerLink);
97 $this->conf->set('general.timezone', $timezone);
98 $this->conf->set('general.enabled_plugins', $enabledPlugins);
99 $this->conf->set('privacy.default_private_links', $defaultPrivateLinks);
100
101 $response = $this->controller->getInfo($request, new Response());
102 $this->assertEquals(200, $response->getStatusCode());
103 $data = json_decode((string) $response->getBody(), true);
104
105 $this->assertEquals(8, $data['global_counter']);
106 $this->assertEquals(2, $data['private_counter']);
107 $this->assertEquals($title, $data['settings']['title']);
108 $this->assertEquals($headerLink, $data['settings']['header_link']);
109 $this->assertEquals($timezone, $data['settings']['timezone']);
110 $this->assertEquals($enabledPlugins, $data['settings']['enabled_plugins']);
111 $this->assertEquals($defaultPrivateLinks, $data['settings']['default_private_links']);
112 }
113}
diff --git a/tests/plugins/PluginPubsubhubbubTest.php b/tests/plugins/PluginPubsubhubbubTest.php
new file mode 100644
index 00000000..24dd7a11
--- /dev/null
+++ b/tests/plugins/PluginPubsubhubbubTest.php
@@ -0,0 +1,54 @@
1<?php
2
3require_once 'plugins/pubsubhubbub/pubsubhubbub.php';
4require_once 'application/Router.php';
5
6/**
7 * Class PluginPubsubhubbubTest
8 * Unit test for the pubsubhubbub plugin
9 */
10class PluginPubsubhubbubTest extends PHPUnit_Framework_TestCase
11{
12 /**
13 * @var string Config file path (without extension).
14 */
15 protected static $configFile = 'tests/utils/config/configJson';
16
17 /**
18 * Reset plugin path
19 */
20 function setUp()
21 {
22 PluginManager::$PLUGINS_PATH = 'plugins';
23 }
24
25 /**
26 * Test render_feed hook with an RSS feed.
27 */
28 function testPubSubRssRenderFeed()
29 {
30 $hub = 'http://domain.hub';
31 $conf = new ConfigManager(self::$configFile);
32 $conf->set('plugins.PUBSUBHUB_URL', $hub);
33 $data['_PAGE_'] = Router::$PAGE_FEED_RSS;
34
35 $data = hook_pubsubhubbub_render_feed($data, $conf);
36 $expected = '<atom:link rel="hub" href="'. $hub .'" />';
37 $this->assertEquals($expected, $data['feed_plugins_header'][0]);
38 }
39
40 /**
41 * Test render_feed hook with an ATOM feed.
42 */
43 function testPubSubAtomRenderFeed()
44 {
45 $hub = 'http://domain.hub';
46 $conf = new ConfigManager(self::$configFile);
47 $conf->set('plugins.PUBSUBHUB_URL', $hub);
48 $data['_PAGE_'] = Router::$PAGE_FEED_ATOM;
49
50 $data = hook_pubsubhubbub_render_feed($data, $conf);
51 $expected = '<link rel="hub" href="'. $hub .'" />';
52 $this->assertEquals($expected, $data['feed_plugins_header'][0]);
53 }
54}
diff --git a/tpl/configure.html b/tpl/configure.html
index 983bcd08..b4197bf9 100644
--- a/tpl/configure.html
+++ b/tpl/configure.html
@@ -80,6 +80,20 @@
80 <label for="updateCheck">&nbsp;Notify me when a new release is ready</label> 80 <label for="updateCheck">&nbsp;Notify me when a new release is ready</label>
81 </td> 81 </td>
82 </tr> 82 </tr>
83 <tr>
84 <td valign="top"><b>Enable REST API</b></td>
85 <td>
86 <input type="checkbox" name="apiEnabled" id="apiEnabled"
87 {if="$api_enabled"}checked{/if}/>
88 <label for="apiEnabled">&nbsp;Allow third party software to use Shaarli such as mobile application.</label>
89 </td>
90 </tr>
91 <tr>
92 <td valign="top"><b>API secret</b></td>
93 <td>
94 <input type="text" name="apiSecret" id="apiSecret" size="50" value="{$api_secret}" />
95 </td>
96 </tr>
83 97
84 <tr> 98 <tr>
85 <td></td> 99 <td></td>
diff --git a/tpl/feed.atom.html b/tpl/feed.atom.html
index aead0459..49798e85 100644
--- a/tpl/feed.atom.html
+++ b/tpl/feed.atom.html
@@ -6,11 +6,11 @@
6 <updated>{$last_update}</updated> 6 <updated>{$last_update}</updated>
7 {/if} 7 {/if}
8 <link rel="self" href="{$self_link}#" /> 8 <link rel="self" href="{$self_link}#" />
9 {if="!empty($pubsubhub_url)"} 9 <link rel="search" type="application/opensearchdescription+xml" href="{$index_url}?do=opensearch#"
10 <!-- PubSubHubbub Discovery --> 10 title="Shaarli search - {$shaarlititle}" />
11 <link rel="hub" href="{$pubsubhub_url}#" /> 11 {loop="$feed_plugins_header"}
12 <!-- End Of PubSubHubbub Discovery --> 12 {$value}
13 {/if} 13 {/loop}
14 <author> 14 <author>
15 <name>{$index_url}</name> 15 <name>{$index_url}</name>
16 <uri>{$index_url}</uri> 16 <uri>{$index_url}</uri>
@@ -34,6 +34,9 @@
34 {loop="$value.taglist"} 34 {loop="$value.taglist"}
35 <category scheme="{$index_url}?searchtags=" term="{$value|strtolower}" label="{$value}" /> 35 <category scheme="{$index_url}?searchtags=" term="{$value|strtolower}" label="{$value}" />
36 {/loop} 36 {/loop}
37 {loop="$value.feed_plugins"}
38 {$value}
39 {/loop}
37 </entry> 40 </entry>
38 {/loop} 41 {/loop}
39</feed> 42</feed>
diff --git a/tpl/feed.rss.html b/tpl/feed.rss.html
index e18dbf9b..ee3fef88 100644
--- a/tpl/feed.rss.html
+++ b/tpl/feed.rss.html
@@ -8,10 +8,11 @@
8 <copyright>{$index_url}</copyright> 8 <copyright>{$index_url}</copyright>
9 <generator>Shaarli</generator> 9 <generator>Shaarli</generator>
10 <atom:link rel="self" href="{$self_link}" /> 10 <atom:link rel="self" href="{$self_link}" />
11 {if="!empty($pubsubhub_url)"} 11 <atom:link rel="search" type="application/opensearchdescription+xml" href="{$index_url}?do=opensearch#"
12 <!-- PubSubHubbub Discovery --> 12 title="Shaarli search - {$shaarlititle}" />
13 <atom:link rel="hub" href="{$pubsubhub_url}" /> 13 {loop="$feed_plugins_header"}
14 {/if} 14 {$value}
15 {/loop}
15 {loop="$links"} 16 {loop="$links"}
16 <item> 17 <item>
17 <title>{$value.title}</title> 18 <title>{$value.title}</title>
@@ -29,6 +30,9 @@
29 {loop="$value.taglist"} 30 {loop="$value.taglist"}
30 <category domain="{$index_url}?searchtags=">{$value}</category> 31 <category domain="{$index_url}?searchtags=">{$value}</category>
31 {/loop} 32 {/loop}
33 {loop="$value.feed_plugins"}
34 {$value}
35 {/loop}
32 </item> 36 </item>
33 {/loop} 37 {/loop}
34 </channel> 38 </channel>
diff --git a/tpl/install.html b/tpl/install.html
index 88eb540e..42874dcd 100644
--- a/tpl/install.html
+++ b/tpl/install.html
@@ -14,6 +14,18 @@
14 <tr><td valign="top"><b>Update:</b></td><td> 14 <tr><td valign="top"><b>Update:</b></td><td>
15 <input type="checkbox" name="updateCheck" id="updateCheck" checked="checked"><label for="updateCheck">&nbsp;Notify me when a new release is ready</label></td> 15 <input type="checkbox" name="updateCheck" id="updateCheck" checked="checked"><label for="updateCheck">&nbsp;Notify me when a new release is ready</label></td>
16 </tr> 16 </tr>
17 <tr>
18 <td valign="top">
19 <b>API:</b>
20 </td>
21 <td>
22 <input type="checkbox" name="enableApi" id="enableApi" checked="checked">
23 <label for="enableApi">
24 &nbsp;Enable Shaarli's REST API.
25 Allow third party software to use Shaarli such as mobile application.
26 </label>
27 </td>
28 </tr>
17 <tr><td colspan="2"><input type="submit" name="Save" value="Save config" class="bigbutton"></td></tr> 29 <tr><td colspan="2"><input type="submit" name="Save" value="Save config" class="bigbutton"></td></tr>
18 </table> 30 </table>
19 </form> 31 </form>