aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
authorArthurHoaro <arthur@hoa.ro>2019-07-27 12:34:30 +0200
committerArthurHoaro <arthur@hoa.ro>2019-07-27 12:34:30 +0200
commit38672ba0d1c722e5d6d33a58255ceb55e9410e46 (patch)
treedae4c7c47532380eac3ae641db99122fc77c93dc /application
parent83faedadff76c5bdca036f39f13943f63b27e164 (diff)
parent1e77e0448bbd25675d8c0fe4a73206ad9048904b (diff)
downloadShaarli-38672ba0d1c722e5d6d33a58255ceb55e9410e46.tar.gz
Shaarli-38672ba0d1c722e5d6d33a58255ceb55e9410e46.tar.zst
Shaarli-38672ba0d1c722e5d6d33a58255ceb55e9410e46.zip
Merge tag 'v0.10.4' into stable
Release v0.10.4
Diffstat (limited to 'application')
-rw-r--r--application/ApplicationUtils.php17
-rw-r--r--application/Base64Url.php7
-rw-r--r--application/FeedBuilder.php4
-rw-r--r--application/FileUtils.php2
-rw-r--r--application/HttpUtils.php62
-rw-r--r--application/Languages.php34
-rw-r--r--application/LinkDB.php67
-rw-r--r--application/LinkFilter.php33
-rw-r--r--application/LinkUtils.php20
-rw-r--r--application/NetscapeBookmarkUtils.php24
-rw-r--r--application/PageBuilder.php50
-rw-r--r--application/PluginManager.php3
-rw-r--r--application/Router.php18
-rw-r--r--application/SessionManager.php83
-rw-r--r--application/Thumbnailer.php130
-rw-r--r--application/Updater.php96
-rw-r--r--application/Url.php23
-rw-r--r--application/Utils.php16
-rw-r--r--application/api/ApiMiddleware.php5
-rw-r--r--application/api/ApiUtils.php16
-rw-r--r--application/api/controllers/ApiController.php2
-rw-r--r--application/api/controllers/History.php5
-rw-r--r--application/api/controllers/Info.php4
-rw-r--r--application/api/controllers/Links.php12
-rw-r--r--application/api/controllers/Tags.php161
-rw-r--r--application/api/exceptions/ApiException.php8
-rw-r--r--application/api/exceptions/ApiLinkNotFoundException.php1
-rw-r--r--application/api/exceptions/ApiTagNotFoundException.php31
-rw-r--r--application/config/ConfigManager.php58
-rw-r--r--application/config/ConfigPhp.php12
-rw-r--r--application/config/ConfigPlugin.php3
-rw-r--r--application/security/LoginManager.php264
-rw-r--r--application/security/SessionManager.php199
33 files changed, 1242 insertions, 228 deletions
diff --git a/application/ApplicationUtils.php b/application/ApplicationUtils.php
index 911873a0..a3b2dcb1 100644
--- a/application/ApplicationUtils.php
+++ b/application/ApplicationUtils.php
@@ -24,7 +24,7 @@ class ApplicationUtils
24 * 24 *
25 * @return mixed the version code from the repository if available, else 'false' 25 * @return mixed the version code from the repository if available, else 'false'
26 */ 26 */
27 public static function getLatestGitVersionCode($url, $timeout=2) 27 public static function getLatestGitVersionCode($url, $timeout = 2)
28 { 28 {
29 list($headers, $data) = get_http_response($url, $timeout); 29 list($headers, $data) = get_http_response($url, $timeout);
30 30
@@ -86,13 +86,14 @@ class ApplicationUtils
86 * 86 *
87 * @return mixed the new version code if available and greater, else 'false' 87 * @return mixed the new version code if available and greater, else 'false'
88 */ 88 */
89 public static function checkUpdate($currentVersion, 89 public static function checkUpdate(
90 $updateFile, 90 $currentVersion,
91 $checkInterval, 91 $updateFile,
92 $enableCheck, 92 $checkInterval,
93 $isLoggedIn, 93 $enableCheck,
94 $branch='stable') 94 $isLoggedIn,
95 { 95 $branch = 'stable'
96 ) {
96 // Do not check versions for visitors 97 // Do not check versions for visitors
97 // Do not check if the user doesn't want to 98 // Do not check if the user doesn't want to
98 // Do not check with dev version 99 // Do not check with dev version
diff --git a/application/Base64Url.php b/application/Base64Url.php
index 61590e43..54d0fcd5 100644
--- a/application/Base64Url.php
+++ b/application/Base64Url.php
@@ -2,7 +2,6 @@
2 2
3namespace Shaarli; 3namespace Shaarli;
4 4
5
6/** 5/**
7 * URL-safe Base64 operations 6 * URL-safe Base64 operations
8 * 7 *
@@ -17,7 +16,8 @@ class Base64Url
17 * 16 *
18 * @return string Base64Url-encoded data 17 * @return string Base64Url-encoded data
19 */ 18 */
20 public static function encode($data) { 19 public static function encode($data)
20 {
21 return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); 21 return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
22 } 22 }
23 23
@@ -28,7 +28,8 @@ class Base64Url
28 * 28 *
29 * @return string Decoded data 29 * @return string Decoded data
30 */ 30 */
31 public static function decode($data) { 31 public static function decode($data)
32 {
32 return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT)); 33 return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT));
33 } 34 }
34} 35}
diff --git a/application/FeedBuilder.php b/application/FeedBuilder.php
index ebae18b4..73fafcbe 100644
--- a/application/FeedBuilder.php
+++ b/application/FeedBuilder.php
@@ -163,7 +163,8 @@ class FeedBuilder
163 $upDate = $link['updated']; 163 $upDate = $link['updated'];
164 $link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM); 164 $link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM);
165 } else { 165 } else {
166 $link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM);; 166 $link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM);
167 ;
167 } 168 }
168 169
169 // Save the more recent item. 170 // Save the more recent item.
@@ -261,7 +262,6 @@ class FeedBuilder
261 } 262 }
262 if ($this->feedType == self::$FEED_RSS) { 263 if ($this->feedType == self::$FEED_RSS) {
263 return $date->format(DateTime::RSS); 264 return $date->format(DateTime::RSS);
264
265 } 265 }
266 return $date->format(DateTime::ATOM); 266 return $date->format(DateTime::ATOM);
267 } 267 }
diff --git a/application/FileUtils.php b/application/FileUtils.php
index 918cb83b..b89ea12b 100644
--- a/application/FileUtils.php
+++ b/application/FileUtils.php
@@ -37,7 +37,7 @@ class FileUtils
37 if (is_file($file) && !is_writeable($file)) { 37 if (is_file($file) && !is_writeable($file)) {
38 // The datastore exists but is not writeable 38 // The datastore exists but is not writeable
39 throw new IOException($file); 39 throw new IOException($file);
40 } else if (!is_file($file) && !is_writeable(dirname($file))) { 40 } elseif (!is_file($file) && !is_writeable(dirname($file))) {
41 // The datastore does not exist and its parent directory is not writeable 41 // The datastore does not exist and its parent directory is not writeable
42 throw new IOException(dirname($file)); 42 throw new IOException(dirname($file));
43 } 43 }
diff --git a/application/HttpUtils.php b/application/HttpUtils.php
index 83a4c5e2..9c438160 100644
--- a/application/HttpUtils.php
+++ b/application/HttpUtils.php
@@ -1,13 +1,14 @@
1<?php 1<?php
2/** 2/**
3 * GET an HTTP URL to retrieve its content 3 * GET an HTTP URL to retrieve its content
4 * Uses the cURL library or a fallback method 4 * Uses the cURL library or a fallback method
5 * 5 *
6 * @param string $url URL to get (http://...) 6 * @param string $url URL to get (http://...)
7 * @param int $timeout network timeout (in seconds) 7 * @param int $timeout network timeout (in seconds)
8 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) 8 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
9 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION). 9 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
10 * Can be used to add download conditions on the headers (response code, content type, etc.). 10 * Can be used to add download conditions on the
11 * headers (response code, content type, etc.).
11 * 12 *
12 * @return array HTTP response headers, downloaded content 13 * @return array HTTP response headers, downloaded content
13 * 14 *
@@ -64,29 +65,30 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
64 } 65 }
65 66
66 // General cURL settings 67 // General cURL settings
67 curl_setopt($ch, CURLOPT_AUTOREFERER, true); 68 curl_setopt($ch, CURLOPT_AUTOREFERER, true);
68 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 69 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
69 curl_setopt($ch, CURLOPT_HEADER, true); 70 curl_setopt($ch, CURLOPT_HEADER, true);
70 curl_setopt( 71 curl_setopt(
71 $ch, 72 $ch,
72 CURLOPT_HTTPHEADER, 73 CURLOPT_HTTPHEADER,
73 array('Accept-Language: ' . $acceptLanguage) 74 array('Accept-Language: ' . $acceptLanguage)
74 ); 75 );
75 curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs); 76 curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
76 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 77 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
77 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); 78 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
78 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); 79 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
79 80
80 if (is_callable($curlWriteFunction)) { 81 if (is_callable($curlWriteFunction)) {
81 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); 82 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
82 } 83 }
83 84
84 // Max download size management 85 // Max download size management
85 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16); 86 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
86 curl_setopt($ch, CURLOPT_NOPROGRESS, false); 87 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
87 curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, 88 curl_setopt(
88 function($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) 89 $ch,
89 { 90 CURLOPT_PROGRESSFUNCTION,
91 function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) {
90 if (version_compare(phpversion(), '5.5', '<')) { 92 if (version_compare(phpversion(), '5.5', '<')) {
91 // PHP version lower than 5.5 93 // PHP version lower than 5.5
92 // Callback has 4 arguments 94 // Callback has 4 arguments
@@ -232,7 +234,6 @@ function get_redirected_headers($url, $redirectionLimit = 3)
232 && !empty($headers) 234 && !empty($headers)
233 && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false) 235 && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false)
234 && !empty($headers['Location'])) { 236 && !empty($headers['Location'])) {
235
236 $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location']; 237 $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location'];
237 if ($redirection != $url) { 238 if ($redirection != $url) {
238 $redirection = getAbsoluteUrl($url, $redirection); 239 $redirection = getAbsoluteUrl($url, $redirection);
@@ -415,6 +416,37 @@ function getIpAddressFromProxy($server, $trustedIps)
415 return array_pop($ips); 416 return array_pop($ips);
416} 417}
417 418
419
420/**
421 * Return an identifier based on the advertised client IP address(es)
422 *
423 * This aims at preventing session hijacking from users behind the same proxy
424 * by relying on HTTP headers.
425 *
426 * See:
427 * - https://secure.php.net/manual/en/reserved.variables.server.php
428 * - https://stackoverflow.com/questions/3003145/how-to-get-the-client-ip-address-in-php
429 * - https://stackoverflow.com/questions/12233406/preventing-session-hijacking
430 * - https://stackoverflow.com/questions/21354859/trusting-x-forwarded-for-to-identify-a-visitor
431 *
432 * @param array $server The $_SERVER array
433 *
434 * @return string An identifier based on client IP address information
435 */
436function client_ip_id($server)
437{
438 $ip = $server['REMOTE_ADDR'];
439
440 if (isset($server['HTTP_X_FORWARDED_FOR'])) {
441 $ip = $ip . '_' . $server['HTTP_X_FORWARDED_FOR'];
442 }
443 if (isset($server['HTTP_CLIENT_IP'])) {
444 $ip = $ip . '_' . $server['HTTP_CLIENT_IP'];
445 }
446 return $ip;
447}
448
449
418/** 450/**
419 * Returns true if Shaarli's currently browsed in HTTPS. 451 * Returns true if Shaarli's currently browsed in HTTPS.
420 * Supports reverse proxies (if the headers are correctly set). 452 * Supports reverse proxies (if the headers are correctly set).
diff --git a/application/Languages.php b/application/Languages.php
index 3eb3388f..b9c5d0e8 100644
--- a/application/Languages.php
+++ b/application/Languages.php
@@ -92,12 +92,18 @@ class Languages
92 /** 92 /**
93 * Initialize the translator using php gettext extension (gettext dependency act as a wrapper). 93 * Initialize the translator using php gettext extension (gettext dependency act as a wrapper).
94 */ 94 */
95 protected function initGettextTranslator () 95 protected function initGettextTranslator()
96 { 96 {
97 $this->translator = new GettextTranslator(); 97 $this->translator = new GettextTranslator();
98 $this->translator->setLanguage($this->language); 98 $this->translator->setLanguage($this->language);
99 $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages'); 99 $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
100 100
101 // Default extension translation from the current theme
102 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $this->conf->get('theme') .'/language';
103 if (is_dir($themeTransFolder)) {
104 $this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
105 }
106
101 foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) { 107 foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
102 if ($domain !== self::DEFAULT_DOMAIN) { 108 if ($domain !== self::DEFAULT_DOMAIN) {
103 $this->translator->loadDomain($domain, $translationPath, false); 109 $this->translator->loadDomain($domain, $translationPath, false);
@@ -116,12 +122,25 @@ class Languages
116 $translations = new Translations(); 122 $translations = new Translations();
117 // Core translations 123 // Core translations
118 try { 124 try {
119 /** @var Translations $translations */
120 $translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po'); 125 $translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po');
121 $translations->setDomain('shaarli'); 126 $translations->setDomain('shaarli');
122 $this->translator->loadTranslations($translations); 127 $this->translator->loadTranslations($translations);
123 } catch (\InvalidArgumentException $e) {} 128 } catch (\InvalidArgumentException $e) {
129 }
124 130
131 // Default extension translation from the current theme
132 $theme = $this->conf->get('theme');
133 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language';
134 if (is_dir($themeTransFolder)) {
135 try {
136 $translations = Translations::fromPoFile(
137 $themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po'
138 );
139 $translations->setDomain($theme);
140 $this->translator->loadTranslations($translations);
141 } catch (\InvalidArgumentException $e) {
142 }
143 }
125 144
126 // Extension translations (plugins, themes, etc.). 145 // Extension translations (plugins, themes, etc.).
127 foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) { 146 foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
@@ -130,11 +149,13 @@ class Languages
130 } 149 }
131 150
132 try { 151 try {
133 /** @var Translations $extension */ 152 $extension = Translations::fromPoFile(
134 $extension = Translations::fromPoFile($translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po'); 153 $translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po'
154 );
135 $extension->setDomain($domain); 155 $extension->setDomain($domain);
136 $this->translator->loadTranslations($extension); 156 $this->translator->loadTranslations($extension);
137 } catch (\InvalidArgumentException $e) {} 157 } catch (\InvalidArgumentException $e) {
158 }
138 } 159 }
139 } 160 }
140 161
@@ -161,6 +182,7 @@ class Languages
161 'auto' => t('Automatic'), 182 'auto' => t('Automatic'),
162 'en' => t('English'), 183 'en' => t('English'),
163 'fr' => t('French'), 184 'fr' => t('French'),
185 'de' => t('German'),
164 ]; 186 ];
165 } 187 }
166} 188}
diff --git a/application/LinkDB.php b/application/LinkDB.php
index c1661d52..803757ca 100644
--- a/application/LinkDB.php
+++ b/application/LinkDB.php
@@ -107,8 +107,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
107 $hidePublicLinks, 107 $hidePublicLinks,
108 $redirector = '', 108 $redirector = '',
109 $redirectorEncode = true 109 $redirectorEncode = true
110 ) 110 ) {
111 {
112 $this->datastore = $datastore; 111 $this->datastore = $datastore;
113 $this->loggedIn = $isLoggedIn; 112 $this->loggedIn = $isLoggedIn;
114 $this->hidePublicLinks = $hidePublicLinks; 113 $this->hidePublicLinks = $hidePublicLinks;
@@ -250,14 +249,18 @@ class LinkDB implements Iterator, Countable, ArrayAccess
250 'id' => 1, 249 'id' => 1,
251 'title'=> t('The personal, minimalist, super-fast, database free, bookmarking service'), 250 'title'=> t('The personal, minimalist, super-fast, database free, bookmarking service'),
252 'url'=>'https://shaarli.readthedocs.io', 251 'url'=>'https://shaarli.readthedocs.io',
253 'description'=>t('Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login. 252 'description'=>t(
253 'Welcome to Shaarli! This is your first public bookmark. '
254 .'To edit or delete me, you must first login.
254 255
255To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. 256To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
256 257
257You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'), 258You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
259 ),
258 'private'=>0, 260 'private'=>0,
259 'created'=> new DateTime(), 261 'created'=> new DateTime(),
260 'tags'=>'opensource software' 262 'tags'=>'opensource software',
263 'sticky' => false,
261 ); 264 );
262 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 265 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
263 $this->links[1] = $link; 266 $this->links[1] = $link;
@@ -270,6 +273,7 @@ You use the community supported version of the original Shaarli project, by Seba
270 'private'=>1, 273 'private'=>1,
271 'created'=> new DateTime('1 minute ago'), 274 'created'=> new DateTime('1 minute ago'),
272 'tags'=>'secretstuff', 275 'tags'=>'secretstuff',
276 'sticky' => false,
273 ); 277 );
274 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 278 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
275 $this->links[0] = $link; 279 $this->links[0] = $link;
@@ -317,11 +321,12 @@ You use the community supported version of the original Shaarli project, by Seba
317 } else { 321 } else {
318 $link['real_url'] .= $link['url']; 322 $link['real_url'] .= $link['url'];
319 } 323 }
320 } 324 } else {
321 else {
322 $link['real_url'] = $link['url']; 325 $link['real_url'] = $link['url'];
323 } 326 }
324 327
328 $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
329
325 // To be able to load links before running the update, and prepare the update 330 // To be able to load links before running the update, and prepare the update
326 if (! isset($link['created'])) { 331 if (! isset($link['created'])) {
327 $link['id'] = $link['linkdate']; 332 $link['id'] = $link['linkdate'];
@@ -403,7 +408,8 @@ You use the community supported version of the original Shaarli project, by Seba
403 * 408 *
404 * @return array list of shaare found. 409 * @return array list of shaare found.
405 */ 410 */
406 public function filterDay($request) { 411 public function filterDay($request)
412 {
407 $linkFilter = new LinkFilter($this->links); 413 $linkFilter = new LinkFilter($this->links);
408 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); 414 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
409 } 415 }
@@ -420,8 +426,12 @@ You use the community supported version of the original Shaarli project, by Seba
420 * 426 *
421 * @return array filtered links, all links if no suitable filter was provided. 427 * @return array filtered links, all links if no suitable filter was provided.
422 */ 428 */
423 public function filterSearch($filterRequest = array(), $casesensitive = false, $visibility = 'all', $untaggedonly = false) 429 public function filterSearch(
424 { 430 $filterRequest = array(),
431 $casesensitive = false,
432 $visibility = 'all',
433 $untaggedonly = false
434 ) {
425 // Filter link database according to parameters. 435 // Filter link database according to parameters.
426 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; 436 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
427 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; 437 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
@@ -436,15 +446,17 @@ You use the community supported version of the original Shaarli project, by Seba
436 446
437 /** 447 /**
438 * Returns the list tags appearing in the links with the given tags 448 * Returns the list tags appearing in the links with the given tags
439 * @param $filteringTags: tags selecting the links to consider 449 *
440 * @param $visibility: process only all/private/public links 450 * @param array $filteringTags tags selecting the links to consider
441 * @return: a tag=>linksCount array 451 * @param string $visibility process only all/private/public links
452 *
453 * @return array tag => linksCount
442 */ 454 */
443 public function linksCountPerTag($filteringTags = [], $visibility = 'all') 455 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
444 { 456 {
445 $links = empty($filteringTags) ? $this->links : $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); 457 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
446 $tags = array(); 458 $tags = [];
447 $caseMapping = array(); 459 $caseMapping = [];
448 foreach ($links as $link) { 460 foreach ($links as $link) {
449 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { 461 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
450 if (empty($tag)) { 462 if (empty($tag)) {
@@ -458,8 +470,19 @@ You use the community supported version of the original Shaarli project, by Seba
458 $tags[$caseMapping[strtolower($tag)]]++; 470 $tags[$caseMapping[strtolower($tag)]]++;
459 } 471 }
460 } 472 }
461 // Sort tags by usage (most used tag first) 473
462 arsort($tags); 474 /*
475 * Formerly used arsort(), which doesn't define the sort behaviour for equal values.
476 * Also, this function doesn't produce the same result between PHP 5.6 and 7.
477 *
478 * So we now use array_multisort() to sort tags by DESC occurrences,
479 * then ASC alphabetically for equal values.
480 *
481 * @see https://github.com/shaarli/Shaarli/issues/1142
482 */
483 $keys = array_keys($tags);
484 $tmpTags = array_combine($keys, $keys);
485 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
463 return $tags; 486 return $tags;
464 } 487 }
465 488
@@ -479,8 +502,7 @@ You use the community supported version of the original Shaarli project, by Seba
479 $delete = empty($to); 502 $delete = empty($to);
480 // True for case-sensitive tag search. 503 // True for case-sensitive tag search.
481 $linksToAlter = $this->filterSearch(['searchtags' => $from], true); 504 $linksToAlter = $this->filterSearch(['searchtags' => $from], true);
482 foreach($linksToAlter as $key => &$value) 505 foreach ($linksToAlter as $key => &$value) {
483 {
484 $tags = preg_split('/\s+/', trim($value['tags'])); 506 $tags = preg_split('/\s+/', trim($value['tags']));
485 if (($pos = array_search($from, $tags)) !== false) { 507 if (($pos = array_search($from, $tags)) !== false) {
486 if ($delete) { 508 if ($delete) {
@@ -523,7 +545,10 @@ You use the community supported version of the original Shaarli project, by Seba
523 { 545 {
524 $order = $order === 'ASC' ? -1 : 1; 546 $order = $order === 'ASC' ? -1 : 1;
525 // Reorder array by dates. 547 // Reorder array by dates.
526 usort($this->links, function($a, $b) use ($order) { 548 usort($this->links, function ($a, $b) use ($order) {
549 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
550 return $a['sticky'] ? -1 : 1;
551 }
527 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; 552 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
528 }); 553 });
529 554
diff --git a/application/LinkFilter.php b/application/LinkFilter.php
index 12376e27..8f147974 100644
--- a/application/LinkFilter.php
+++ b/application/LinkFilter.php
@@ -62,7 +62,7 @@ class LinkFilter
62 $visibility = 'all'; 62 $visibility = 'all';
63 } 63 }
64 64
65 switch($type) { 65 switch ($type) {
66 case self::$FILTER_HASH: 66 case self::$FILTER_HASH:
67 return $this->filterSmallHash($request); 67 return $this->filterSmallHash($request);
68 case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext" 68 case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
@@ -117,7 +117,7 @@ class LinkFilter
117 foreach ($this->links as $key => $value) { 117 foreach ($this->links as $key => $value) {
118 if ($value['private'] && $visibility === 'private') { 118 if ($value['private'] && $visibility === 'private') {
119 $out[$key] = $value; 119 $out[$key] = $value;
120 } else if (! $value['private'] && $visibility === 'public') { 120 } elseif (! $value['private'] && $visibility === 'public') {
121 $out[$key] = $value; 121 $out[$key] = $value;
122 } 122 }
123 } 123 }
@@ -205,12 +205,11 @@ class LinkFilter
205 205
206 // Iterate over every stored link. 206 // Iterate over every stored link.
207 foreach ($this->links as $id => $link) { 207 foreach ($this->links as $id => $link) {
208
209 // ignore non private links when 'privatonly' is on. 208 // ignore non private links when 'privatonly' is on.
210 if ($visibility !== 'all') { 209 if ($visibility !== 'all') {
211 if (! $link['private'] && $visibility === 'private') { 210 if (! $link['private'] && $visibility === 'private') {
212 continue; 211 continue;
213 } else if ($link['private'] && $visibility === 'public') { 212 } elseif ($link['private'] && $visibility === 'public') {
214 continue; 213 continue;
215 } 214 }
216 } 215 }
@@ -257,11 +256,11 @@ class LinkFilter
257 private static function tag2regex($tag) 256 private static function tag2regex($tag)
258 { 257 {
259 $len = strlen($tag); 258 $len = strlen($tag);
260 if(!$len || $tag === "-" || $tag === "*"){ 259 if (!$len || $tag === "-" || $tag === "*") {
261 // nothing to search, return empty regex 260 // nothing to search, return empty regex
262 return ''; 261 return '';
263 } 262 }
264 if($tag[0] === "-") { 263 if ($tag[0] === "-") {
265 // query is negated 264 // query is negated
266 $i = 1; // use offset to start after '-' character 265 $i = 1; // use offset to start after '-' character
267 $regex = '(?!'; // create negative lookahead 266 $regex = '(?!'; // create negative lookahead
@@ -271,14 +270,14 @@ class LinkFilter
271 } 270 }
272 $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning 271 $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning
273 // iterate over string, separating it into placeholder and content 272 // iterate over string, separating it into placeholder and content
274 for(; $i < $len; $i++){ 273 for (; $i < $len; $i++) {
275 if($tag[$i] === '*'){ 274 if ($tag[$i] === '*') {
276 // placeholder found 275 // placeholder found
277 $regex .= '[^ ]*?'; 276 $regex .= '[^ ]*?';
278 } else { 277 } else {
279 // regular characters 278 // regular characters
280 $offset = strpos($tag, '*', $i); 279 $offset = strpos($tag, '*', $i);
281 if($offset === false){ 280 if ($offset === false) {
282 // no placeholder found, set offset to end of string 281 // no placeholder found, set offset to end of string
283 $offset = $len; 282 $offset = $len;
284 } 283 }
@@ -310,19 +309,19 @@ class LinkFilter
310 { 309 {
311 // get single tags (we may get passed an array, even though the docs say different) 310 // get single tags (we may get passed an array, even though the docs say different)
312 $inputTags = $tags; 311 $inputTags = $tags;
313 if(!is_array($tags)) { 312 if (!is_array($tags)) {
314 // we got an input string, split tags 313 // we got an input string, split tags
315 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); 314 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY);
316 } 315 }
317 316
318 if(!count($inputTags)){ 317 if (!count($inputTags)) {
319 // no input tags 318 // no input tags
320 return $this->noFilter($visibility); 319 return $this->noFilter($visibility);
321 } 320 }
322 321
323 // build regex from all tags 322 // build regex from all tags
324 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; 323 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
325 if(!$casesensitive) { 324 if (!$casesensitive) {
326 // make regex case insensitive 325 // make regex case insensitive
327 $re .= 'i'; 326 $re .= 'i';
328 } 327 }
@@ -337,12 +336,12 @@ class LinkFilter
337 if ($visibility !== 'all') { 336 if ($visibility !== 'all') {
338 if (! $link['private'] && $visibility === 'private') { 337 if (! $link['private'] && $visibility === 'private') {
339 continue; 338 continue;
340 } else if ($link['private'] && $visibility === 'public') { 339 } elseif ($link['private'] && $visibility === 'public') {
341 continue; 340 continue;
342 } 341 }
343 } 342 }
344 $search = $link['tags']; // build search string, start with tags of current link 343 $search = $link['tags']; // build search string, start with tags of current link
345 if(strlen(trim($link['description'])) && strpos($link['description'], '#') !== false){ 344 if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) {
346 // description given and at least one possible tag found 345 // description given and at least one possible tag found
347 $descTags = array(); 346 $descTags = array();
348 // find all tags in the form of #tag in the description 347 // find all tags in the form of #tag in the description
@@ -351,13 +350,13 @@ class LinkFilter
351 $link['description'], 350 $link['description'],
352 $descTags 351 $descTags
353 ); 352 );
354 if(count($descTags[1])){ 353 if (count($descTags[1])) {
355 // there were some tags in the description, add them to the search string 354 // there were some tags in the description, add them to the search string
356 $search .= ' ' . implode(' ', $descTags[1]); 355 $search .= ' ' . implode(' ', $descTags[1]);
357 } 356 }
358 }; 357 };
359 // match regular expression with search string 358 // match regular expression with search string
360 if(!preg_match($re, $search)){ 359 if (!preg_match($re, $search)) {
361 // this entry does _not_ match our regex 360 // this entry does _not_ match our regex
362 continue; 361 continue;
363 } 362 }
@@ -380,7 +379,7 @@ class LinkFilter
380 if ($visibility !== 'all') { 379 if ($visibility !== 'all') {
381 if (! $link['private'] && $visibility === 'private') { 380 if (! $link['private'] && $visibility === 'private') {
382 continue; 381 continue;
383 } else if ($link['private'] && $visibility === 'public') { 382 } elseif ($link['private'] && $visibility === 'public') {
384 continue; 383 continue;
385 } 384 }
386 } 385 }
diff --git a/application/LinkUtils.php b/application/LinkUtils.php
index 3705f7e9..d56e019f 100644
--- a/application/LinkUtils.php
+++ b/application/LinkUtils.php
@@ -11,6 +11,7 @@
11 */ 11 */
12function get_curl_download_callback(&$charset, &$title, $curlGetInfo = 'curl_getinfo') 12function get_curl_download_callback(&$charset, &$title, $curlGetInfo = 'curl_getinfo')
13{ 13{
14 $isRedirected = false;
14 /** 15 /**
15 * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download). 16 * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
16 * 17 *
@@ -22,16 +23,24 @@ function get_curl_download_callback(&$charset, &$title, $curlGetInfo = 'curl_get
22 * 23 *
23 * @return int|bool length of $data or false if we need to stop the download 24 * @return int|bool length of $data or false if we need to stop the download
24 */ 25 */
25 return function(&$ch, $data) use ($curlGetInfo, &$charset, &$title) { 26 return function (&$ch, $data) use ($curlGetInfo, &$charset, &$title, &$isRedirected) {
26 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); 27 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
27 if (!empty($responseCode) && $responseCode != 200) { 28 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
29 $isRedirected = true;
30 return strlen($data);
31 }
32 if (!empty($responseCode) && $responseCode !== 200) {
28 return false; 33 return false;
29 } 34 }
30 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE); 35 // After a redirection, the content type will keep the previous request value
36 // until it finds the next content-type header.
37 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
38 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
39 }
31 if (!empty($contentType) && strpos($contentType, 'text/html') === false) { 40 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
32 return false; 41 return false;
33 } 42 }
34 if (empty($charset)) { 43 if (!empty($contentType) && empty($charset)) {
35 $charset = header_extract_charset($contentType); 44 $charset = header_extract_charset($contentType);
36 } 45 }
37 if (empty($charset)) { 46 if (empty($charset)) {
@@ -192,7 +201,8 @@ function space2nbsp($text)
192 201
193 * @return string formatted description. 202 * @return string formatted description.
194 */ 203 */
195function format_description($description, $redirector = '', $urlEncode = true, $indexUrl = '') { 204function format_description($description, $redirector = '', $urlEncode = true, $indexUrl = '')
205{
196 return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector, $urlEncode), $indexUrl))); 206 return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector, $urlEncode), $indexUrl)));
197} 207}
198 208
diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php
index dd7057f8..84dd2b20 100644
--- a/application/NetscapeBookmarkUtils.php
+++ b/application/NetscapeBookmarkUtils.php
@@ -72,18 +72,20 @@ class NetscapeBookmarkUtils
72 private static function importStatus( 72 private static function importStatus(
73 $filename, 73 $filename,
74 $filesize, 74 $filesize,
75 $importCount=0, 75 $importCount = 0,
76 $overwriteCount=0, 76 $overwriteCount = 0,
77 $skipCount=0, 77 $skipCount = 0,
78 $duration=0 78 $duration = 0
79 ) 79 ) {
80 {
81 $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize); 80 $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
82 if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) { 81 if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
83 $status .= t('has an unknown file format. Nothing was imported.'); 82 $status .= t('has an unknown file format. Nothing was imported.');
84 } else { 83 } else {
85 $status .= vsprintf( 84 $status .= vsprintf(
86 t('was successfully processed in %d seconds: %d links imported, %d links overwritten, %d links skipped.'), 85 t(
86 'was successfully processed in %d seconds: '
87 .'%d links imported, %d links overwritten, %d links skipped.'
88 ),
87 [$duration, $importCount, $overwriteCount, $skipCount] 89 [$duration, $importCount, $overwriteCount, $skipCount]
88 ); 90 );
89 } 91 }
@@ -108,7 +110,7 @@ class NetscapeBookmarkUtils
108 $filesize = $files['filetoupload']['size']; 110 $filesize = $files['filetoupload']['size'];
109 $data = file_get_contents($files['filetoupload']['tmp_name']); 111 $data = file_get_contents($files['filetoupload']['tmp_name']);
110 112
111 if (strpos($data, '<!DOCTYPE NETSCAPE-Bookmark-file-1>') === false) { 113 if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) {
112 return self::importStatus($filename, $filesize); 114 return self::importStatus($filename, $filesize);
113 } 115 }
114 116
@@ -154,13 +156,13 @@ class NetscapeBookmarkUtils
154 if (empty($post['privacy']) || $post['privacy'] == 'default') { 156 if (empty($post['privacy']) || $post['privacy'] == 'default') {
155 // use value from the imported file 157 // use value from the imported file
156 $private = $bkm['pub'] == '1' ? 0 : 1; 158 $private = $bkm['pub'] == '1' ? 0 : 1;
157 } else if ($post['privacy'] == 'private') { 159 } elseif ($post['privacy'] == 'private') {
158 // all imported links are private 160 // all imported links are private
159 $private = 1; 161 $private = 1;
160 } else if ($post['privacy'] == 'public') { 162 } elseif ($post['privacy'] == 'public') {
161 // all imported links are public 163 // all imported links are public
162 $private = 0; 164 $private = 0;
163 } 165 }
164 166
165 $newLink = array( 167 $newLink = array(
166 'title' => $bkm['title'], 168 'title' => $bkm['title'],
diff --git a/application/PageBuilder.php b/application/PageBuilder.php
index 468f144b..2ca95832 100644
--- a/application/PageBuilder.php
+++ b/application/PageBuilder.php
@@ -1,6 +1,7 @@
1<?php 1<?php
2 2
3use Shaarli\Config\ConfigManager; 3use Shaarli\Config\ConfigManager;
4use Shaarli\Thumbnailer;
4 5
5/** 6/**
6 * This class is in charge of building the final page. 7 * This class is in charge of building the final page.
@@ -22,24 +23,41 @@ class PageBuilder
22 protected $conf; 23 protected $conf;
23 24
24 /** 25 /**
26 * @var array $_SESSION
27 */
28 protected $session;
29
30 /**
25 * @var LinkDB $linkDB instance. 31 * @var LinkDB $linkDB instance.
26 */ 32 */
27 protected $linkDB; 33 protected $linkDB;
28 34
29 /** 35 /**
36 * @var null|string XSRF token
37 */
38 protected $token;
39
40 /** @var bool $isLoggedIn Whether the user is logged in **/
41 protected $isLoggedIn = false;
42
43 /**
30 * PageBuilder constructor. 44 * PageBuilder constructor.
31 * $tpl is initialized at false for lazy loading. 45 * $tpl is initialized at false for lazy loading.
32 * 46 *
33 * @param ConfigManager $conf Configuration Manager instance (reference). 47 * @param ConfigManager $conf Configuration Manager instance (reference).
34 * @param LinkDB $linkDB instance. 48 * @param array $session $_SESSION array
35 * @param string $token Session token 49 * @param LinkDB $linkDB instance.
50 * @param string $token Session token
51 * @param bool $isLoggedIn
36 */ 52 */
37 public function __construct(&$conf, $linkDB = null, $token = null) 53 public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false)
38 { 54 {
39 $this->tpl = false; 55 $this->tpl = false;
40 $this->conf = $conf; 56 $this->conf = $conf;
57 $this->session = $session;
41 $this->linkDB = $linkDB; 58 $this->linkDB = $linkDB;
42 $this->token = $token; 59 $this->token = $token;
60 $this->isLoggedIn = $isLoggedIn;
43 } 61 }
44 62
45 /** 63 /**
@@ -55,18 +73,18 @@ class PageBuilder
55 $this->conf->get('resource.update_check'), 73 $this->conf->get('resource.update_check'),
56 $this->conf->get('updates.check_updates_interval'), 74 $this->conf->get('updates.check_updates_interval'),
57 $this->conf->get('updates.check_updates'), 75 $this->conf->get('updates.check_updates'),
58 isLoggedIn(), 76 $this->isLoggedIn,
59 $this->conf->get('updates.check_updates_branch') 77 $this->conf->get('updates.check_updates_branch')
60 ); 78 );
61 $this->tpl->assign('newVersion', escape($version)); 79 $this->tpl->assign('newVersion', escape($version));
62 $this->tpl->assign('versionError', ''); 80 $this->tpl->assign('versionError', '');
63
64 } catch (Exception $exc) { 81 } catch (Exception $exc) {
65 logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); 82 logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage());
66 $this->tpl->assign('newVersion', ''); 83 $this->tpl->assign('newVersion', '');
67 $this->tpl->assign('versionError', escape($exc->getMessage())); 84 $this->tpl->assign('versionError', escape($exc->getMessage()));
68 } 85 }
69 86
87 $this->tpl->assign('is_logged_in', $this->isLoggedIn);
70 $this->tpl->assign('feedurl', escape(index_url($_SERVER))); 88 $this->tpl->assign('feedurl', escape(index_url($_SERVER)));
71 $searchcrits = ''; // Search criteria 89 $searchcrits = ''; // Search criteria
72 if (!empty($_GET['searchtags'])) { 90 if (!empty($_GET['searchtags'])) {
@@ -82,8 +100,9 @@ class PageBuilder
82 'version_hash', 100 'version_hash',
83 ApplicationUtils::getVersionHash(SHAARLI_VERSION, $this->conf->get('credentials.salt')) 101 ApplicationUtils::getVersionHash(SHAARLI_VERSION, $this->conf->get('credentials.salt'))
84 ); 102 );
85 $this->tpl->assign('scripturl', index_url($_SERVER)); 103 $this->tpl->assign('index_url', index_url($_SERVER));
86 $this->tpl->assign('privateonly', !empty($_SESSION['privateonly'])); // Show only private links? 104 $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : '';
105 $this->tpl->assign('visibility', $visibility);
87 $this->tpl->assign('untaggedonly', !empty($_SESSION['untaggedonly'])); 106 $this->tpl->assign('untaggedonly', !empty($_SESSION['untaggedonly']));
88 $this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli')); 107 $this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli'));
89 if ($this->conf->exists('general.header_link')) { 108 if ($this->conf->exists('general.header_link')) {
@@ -99,6 +118,19 @@ class PageBuilder
99 if ($this->linkDB !== null) { 118 if ($this->linkDB !== null) {
100 $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); 119 $this->tpl->assign('tags', $this->linkDB->linksCountPerTag());
101 } 120 }
121
122 $this->tpl->assign(
123 'thumbnails_enabled',
124 $this->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
125 );
126 $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width'));
127 $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height'));
128
129 if (! empty($_SESSION['warnings'])) {
130 $this->tpl->assign('global_warnings', $_SESSION['warnings']);
131 unset($_SESSION['warnings']);
132 }
133
102 // To be removed with a proper theme configuration. 134 // To be removed with a proper theme configuration.
103 $this->tpl->assign('conf', $this->conf); 135 $this->tpl->assign('conf', $this->conf);
104 } 136 }
@@ -130,7 +162,7 @@ class PageBuilder
130 $this->initialize(); 162 $this->initialize();
131 } 163 }
132 164
133 if (empty($data) || !is_array($data)){ 165 if (empty($data) || !is_array($data)) {
134 return false; 166 return false;
135 } 167 }
136 168
diff --git a/application/PluginManager.php b/application/PluginManager.php
index cf603845..1ed4db4b 100644
--- a/application/PluginManager.php
+++ b/application/PluginManager.php
@@ -75,8 +75,7 @@ class PluginManager
75 75
76 try { 76 try {
77 $this->loadPlugin($dirs[$index], $plugin); 77 $this->loadPlugin($dirs[$index], $plugin);
78 } 78 } catch (PluginFileNotFoundException $e) {
79 catch (PluginFileNotFoundException $e) {
80 error_log($e->getMessage()); 79 error_log($e->getMessage());
81 } 80 }
82 } 81 }
diff --git a/application/Router.php b/application/Router.php
index 4df0387c..beb3165b 100644
--- a/application/Router.php
+++ b/application/Router.php
@@ -7,6 +7,8 @@
7 */ 7 */
8class Router 8class Router
9{ 9{
10 public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update';
11
10 public static $PAGE_LOGIN = 'login'; 12 public static $PAGE_LOGIN = 'login';
11 13
12 public static $PAGE_PICWALL = 'picwall'; 14 public static $PAGE_PICWALL = 'picwall';
@@ -35,6 +37,8 @@ class Router
35 37
36 public static $PAGE_DELETELINK = 'delete_link'; 38 public static $PAGE_DELETELINK = 'delete_link';
37 39
40 public static $PAGE_PINLINK = 'pin';
41
38 public static $PAGE_EXPORT = 'export'; 42 public static $PAGE_EXPORT = 'export';
39 43
40 public static $PAGE_IMPORT = 'import'; 44 public static $PAGE_IMPORT = 'import';
@@ -47,6 +51,8 @@ class Router
47 51
48 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin'; 52 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
49 53
54 public static $PAGE_THUMBS_UPDATE = 'thumbs_update';
55
50 public static $GET_TOKEN = 'token'; 56 public static $GET_TOKEN = 'token';
51 57
52 /** 58 /**
@@ -101,6 +107,14 @@ class Router
101 return self::$PAGE_FEED_RSS; 107 return self::$PAGE_FEED_RSS;
102 } 108 }
103 109
110 if (startsWith($query, 'do='. self::$PAGE_THUMBS_UPDATE)) {
111 return self::$PAGE_THUMBS_UPDATE;
112 }
113
114 if (startsWith($query, 'do='. self::$AJAX_THUMB_UPDATE)) {
115 return self::$AJAX_THUMB_UPDATE;
116 }
117
104 // At this point, only loggedin pages. 118 // At this point, only loggedin pages.
105 if (!$loggedIn) { 119 if (!$loggedIn) {
106 return self::$PAGE_LINKLIST; 120 return self::$PAGE_LINKLIST;
@@ -134,6 +148,10 @@ class Router
134 return self::$PAGE_DELETELINK; 148 return self::$PAGE_DELETELINK;
135 } 149 }
136 150
151 if (startsWith($query, 'do='. self::$PAGE_PINLINK)) {
152 return self::$PAGE_PINLINK;
153 }
154
137 if (startsWith($query, 'do='. self::$PAGE_EXPORT)) { 155 if (startsWith($query, 'do='. self::$PAGE_EXPORT)) {
138 return self::$PAGE_EXPORT; 156 return self::$PAGE_EXPORT;
139 } 157 }
diff --git a/application/SessionManager.php b/application/SessionManager.php
deleted file mode 100644
index 71f0b38d..00000000
--- a/application/SessionManager.php
+++ /dev/null
@@ -1,83 +0,0 @@
1<?php
2namespace Shaarli;
3
4/**
5 * Manages the server-side session
6 */
7class SessionManager
8{
9 protected $session = [];
10
11 /**
12 * Constructor
13 *
14 * @param array $session The $_SESSION array (reference)
15 * @param ConfigManager $conf ConfigManager instance
16 */
17 public function __construct(& $session, $conf)
18 {
19 $this->session = &$session;
20 $this->conf = $conf;
21 }
22
23 /**
24 * Generates a session token
25 *
26 * @return string token
27 */
28 public function generateToken()
29 {
30 $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt'));
31 $this->session['tokens'][$token] = 1;
32 return $token;
33 }
34
35 /**
36 * Checks the validity of a session token, and destroys it afterwards
37 *
38 * @param string $token The token to check
39 *
40 * @return bool true if the token is valid, else false
41 */
42 public function checkToken($token)
43 {
44 if (! isset($this->session['tokens'][$token])) {
45 // the token is wrong, or has already been used
46 return false;
47 }
48
49 // destroy the token to prevent future use
50 unset($this->session['tokens'][$token]);
51 return true;
52 }
53
54 /**
55 * Validate session ID to prevent Full Path Disclosure.
56 *
57 * See #298.
58 * The session ID's format depends on the hash algorithm set in PHP settings
59 *
60 * @param string $sessionId Session ID
61 *
62 * @return true if valid, false otherwise.
63 *
64 * @see http://php.net/manual/en/function.hash-algos.php
65 * @see http://php.net/manual/en/session.configuration.php
66 */
67 public static function checkId($sessionId)
68 {
69 if (empty($sessionId)) {
70 return false;
71 }
72
73 if (!$sessionId) {
74 return false;
75 }
76
77 if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) {
78 return false;
79 }
80
81 return true;
82 }
83}
diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php
new file mode 100644
index 00000000..167d6296
--- /dev/null
+++ b/application/Thumbnailer.php
@@ -0,0 +1,130 @@
1<?php
2
3namespace Shaarli;
4
5use Shaarli\Config\ConfigManager;
6use WebThumbnailer\Exception\WebThumbnailerException;
7use WebThumbnailer\WebThumbnailer;
8use WebThumbnailer\Application\ConfigManager as WTConfigManager;
9
10/**
11 * Class Thumbnailer
12 *
13 * Utility class used to retrieve thumbnails using web-thumbnailer dependency.
14 */
15class Thumbnailer
16{
17 const COMMON_MEDIA_DOMAINS = [
18 'imgur.com',
19 'flickr.com',
20 'youtube.com',
21 'wikimedia.org',
22 'redd.it',
23 'gfycat.com',
24 'media.giphy.com',
25 'twitter.com',
26 'twimg.com',
27 'instagram.com',
28 'pinterest.com',
29 'pinterest.fr',
30 'tumblr.com',
31 'deviantart.com',
32 ];
33
34 const MODE_ALL = 'all';
35 const MODE_COMMON = 'common';
36 const MODE_NONE = 'none';
37
38 /**
39 * @var WebThumbnailer instance.
40 */
41 protected $wt;
42
43 /**
44 * @var ConfigManager instance.
45 */
46 protected $conf;
47
48 /**
49 * Thumbnailer constructor.
50 *
51 * @param ConfigManager $conf instance.
52 */
53 public function __construct($conf)
54 {
55 $this->conf = $conf;
56
57 if (! $this->checkRequirements()) {
58 $this->conf->set('thumbnails.mode', Thumbnailer::MODE_NONE);
59 $this->conf->write(true);
60 // TODO: create a proper error handling system able to catch exceptions...
61 die(t(
62 'php-gd extension must be loaded to use thumbnails. '
63 .'Thumbnails are now disabled. Please reload the page.'
64 ));
65 }
66
67 $this->wt = new WebThumbnailer();
68 WTConfigManager::addFile('inc/web-thumbnailer.json');
69 $this->wt->maxWidth($this->conf->get('thumbnails.width'))
70 ->maxHeight($this->conf->get('thumbnails.height'))
71 ->crop(true)
72 ->debug($this->conf->get('dev.debug', false));
73 }
74
75 /**
76 * Retrieve a thumbnail for given URL
77 *
78 * @param string $url where to look for a thumbnail.
79 *
80 * @return bool|string The thumbnail relative cache file path, or false if none has been found.
81 */
82 public function get($url)
83 {
84 if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON
85 && ! $this->isCommonMediaOrImage($url)
86 ) {
87 return false;
88 }
89
90 try {
91 return $this->wt->thumbnail($url);
92 } catch (WebThumbnailerException $e) {
93 // Exceptions are only thrown in debug mode.
94 error_log(get_class($e) . ': ' . $e->getMessage());
95 }
96 return false;
97 }
98
99 /**
100 * We check weather the given URL is from a common media domain,
101 * or if the file extension is an image.
102 *
103 * @param string $url to check
104 *
105 * @return bool true if it's an image or from a common media domain, false otherwise.
106 */
107 public function isCommonMediaOrImage($url)
108 {
109 foreach (self::COMMON_MEDIA_DOMAINS as $domain) {
110 if (strpos($url, $domain) !== false) {
111 return true;
112 }
113 }
114
115 if (endsWith($url, '.jpg') || endsWith($url, '.png') || endsWith($url, '.jpeg')) {
116 return true;
117 }
118
119 return false;
120 }
121
122 /**
123 * Make sure that requirements are match to use thumbnails:
124 * - php-gd is loaded
125 */
126 protected function checkRequirements()
127 {
128 return extension_loaded('gd');
129 }
130}
diff --git a/application/Updater.php b/application/Updater.php
index 034b8ed8..86a21fc3 100644
--- a/application/Updater.php
+++ b/application/Updater.php
@@ -2,6 +2,7 @@
2use Shaarli\Config\ConfigJson; 2use Shaarli\Config\ConfigJson;
3use Shaarli\Config\ConfigPhp; 3use Shaarli\Config\ConfigPhp;
4use Shaarli\Config\ConfigManager; 4use Shaarli\Config\ConfigManager;
5use Shaarli\Thumbnailer;
5 6
6/** 7/**
7 * Class Updater. 8 * Class Updater.
@@ -31,6 +32,11 @@ class Updater
31 protected $isLoggedIn; 32 protected $isLoggedIn;
32 33
33 /** 34 /**
35 * @var array $_SESSION
36 */
37 protected $session;
38
39 /**
34 * @var ReflectionMethod[] List of current class methods. 40 * @var ReflectionMethod[] List of current class methods.
35 */ 41 */
36 protected $methods; 42 protected $methods;
@@ -42,13 +48,17 @@ class Updater
42 * @param LinkDB $linkDB LinkDB instance. 48 * @param LinkDB $linkDB LinkDB instance.
43 * @param ConfigManager $conf Configuration Manager instance. 49 * @param ConfigManager $conf Configuration Manager instance.
44 * @param boolean $isLoggedIn True if the user is logged in. 50 * @param boolean $isLoggedIn True if the user is logged in.
51 * @param array $session $_SESSION (by reference)
52 *
53 * @throws ReflectionException
45 */ 54 */
46 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn) 55 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = [])
47 { 56 {
48 $this->doneUpdates = $doneUpdates; 57 $this->doneUpdates = $doneUpdates;
49 $this->linkDB = $linkDB; 58 $this->linkDB = $linkDB;
50 $this->conf = $conf; 59 $this->conf = $conf;
51 $this->isLoggedIn = $isLoggedIn; 60 $this->isLoggedIn = $isLoggedIn;
61 $this->session = &$session;
52 62
53 // Retrieve all update methods. 63 // Retrieve all update methods.
54 $class = new ReflectionClass($this); 64 $class = new ReflectionClass($this);
@@ -173,7 +183,7 @@ class Updater
173 } 183 }
174 } 184 }
175 185
176 try{ 186 try {
177 $this->conf->write($this->isLoggedIn); 187 $this->conf->write($this->isLoggedIn);
178 return true; 188 return true;
179 } catch (IOException $e) { 189 } catch (IOException $e) {
@@ -447,6 +457,88 @@ class Updater
447 $this->linkDB->save($this->conf->get('resource.page_cache')); 457 $this->linkDB->save($this->conf->get('resource.page_cache'));
448 return true; 458 return true;
449 } 459 }
460
461 /**
462 * Change privateonly session key to visibility.
463 */
464 public function updateMethodVisibilitySession()
465 {
466 if (isset($_SESSION['privateonly'])) {
467 unset($_SESSION['privateonly']);
468 $_SESSION['visibility'] = 'private';
469 }
470 return true;
471 }
472
473 /**
474 * Add download size and timeout to the configuration file
475 *
476 * @return bool true if the update is successful, false otherwise.
477 */
478 public function updateMethodDownloadSizeAndTimeoutConf()
479 {
480 if ($this->conf->exists('general.download_max_size')
481 && $this->conf->exists('general.download_timeout')
482 ) {
483 return true;
484 }
485
486 if (! $this->conf->exists('general.download_max_size')) {
487 $this->conf->set('general.download_max_size', 1024*1024*4);
488 }
489
490 if (! $this->conf->exists('general.download_timeout')) {
491 $this->conf->set('general.download_timeout', 30);
492 }
493
494 $this->conf->write($this->isLoggedIn);
495 return true;
496 }
497
498 /**
499 * * Move thumbnails management to WebThumbnailer, coming with new settings.
500 */
501 public function updateMethodWebThumbnailer()
502 {
503 if ($this->conf->exists('thumbnails.mode')) {
504 return true;
505 }
506
507 $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true);
508 $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
509 $this->conf->set('thumbnails.width', 125);
510 $this->conf->set('thumbnails.height', 90);
511 $this->conf->remove('thumbnail');
512 $this->conf->write(true);
513
514 if ($thumbnailsEnabled) {
515 $this->session['warnings'][] = t(
516 'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
517 );
518 }
519
520 return true;
521 }
522
523 /**
524 * Set sticky = false on all links
525 *
526 * @return bool true if the update is successful, false otherwise.
527 */
528 public function updateMethodSetSticky()
529 {
530 foreach ($this->linkDB as $key => $link) {
531 if (isset($link['sticky'])) {
532 return true;
533 }
534 $link['sticky'] = false;
535 $this->linkDB[$key] = $link;
536 }
537
538 $this->linkDB->save($this->conf->get('resource.page_cache'));
539
540 return true;
541 }
450} 542}
451 543
452/** 544/**
diff --git a/application/Url.php b/application/Url.php
index b3759377..3b7f19c2 100644
--- a/application/Url.php
+++ b/application/Url.php
@@ -34,8 +34,8 @@ function unparse_url($parsedUrl)
34 */ 34 */
35function cleanup_url($url) 35function cleanup_url($url)
36{ 36{
37 $obj_url = new Url($url); 37 $obj_url = new Url($url);
38 return $obj_url->cleanup(); 38 return $obj_url->cleanup();
39} 39}
40 40
41/** 41/**
@@ -47,8 +47,8 @@ function cleanup_url($url)
47 */ 47 */
48function get_url_scheme($url) 48function get_url_scheme($url)
49{ 49{
50 $obj_url = new Url($url); 50 $obj_url = new Url($url);
51 return $obj_url->getScheme(); 51 return $obj_url->getScheme();
52} 52}
53 53
54/** 54/**
@@ -81,7 +81,7 @@ function whitelist_protocols($url, $protocols)
81 // Protocol not allowed: we remove it and replace it with http 81 // Protocol not allowed: we remove it and replace it with http
82 if ($protocol === 1 && ! in_array($match[1], $protocols)) { 82 if ($protocol === 1 && ! in_array($match[1], $protocols)) {
83 $url = str_replace($match[0], 'http://', $url); 83 $url = str_replace($match[0], 'http://', $url);
84 } else if ($protocol !== 1) { 84 } elseif ($protocol !== 1) {
85 $url = 'http://' . $url; 85 $url = 'http://' . $url;
86 } 86 }
87 return $url; 87 return $url;
@@ -217,7 +217,7 @@ class Url
217 } 217 }
218 218
219 $this->parts['query'] = implode('&', $queryParams); 219 $this->parts['query'] = implode('&', $queryParams);
220 } 220 }
221 221
222 /** 222 /**
223 * Removes undesired fragments 223 * Removes undesired fragments
@@ -260,7 +260,7 @@ class Url
260 if (! function_exists('idn_to_ascii') || ! isset($this->parts['host'])) { 260 if (! function_exists('idn_to_ascii') || ! isset($this->parts['host'])) {
261 return $out; 261 return $out;
262 } 262 }
263 $asciiHost = idn_to_ascii($this->parts['host']); 263 $asciiHost = idn_to_ascii($this->parts['host'], 0, INTL_IDNA_VARIANT_UTS46);
264 return str_replace($this->parts['host'], $asciiHost, $out); 264 return str_replace($this->parts['host'], $asciiHost, $out);
265 } 265 }
266 266
@@ -269,7 +269,8 @@ class Url
269 * 269 *
270 * @return string the URL scheme or false if none is provided. 270 * @return string the URL scheme or false if none is provided.
271 */ 271 */
272 public function getScheme() { 272 public function getScheme()
273 {
273 if (!isset($this->parts['scheme'])) { 274 if (!isset($this->parts['scheme'])) {
274 return false; 275 return false;
275 } 276 }
@@ -281,7 +282,8 @@ class Url
281 * 282 *
282 * @return string the URL host or false if none is provided. 283 * @return string the URL host or false if none is provided.
283 */ 284 */
284 public function getHost() { 285 public function getHost()
286 {
285 if (empty($this->parts['host'])) { 287 if (empty($this->parts['host'])) {
286 return false; 288 return false;
287 } 289 }
@@ -293,7 +295,8 @@ class Url
293 * 295 *
294 * @return true is HTTP, false otherwise. 296 * @return true is HTTP, false otherwise.
295 */ 297 */
296 public function isHttp() { 298 public function isHttp()
299 {
297 return strpos(strtolower($this->parts['scheme']), 'http') !== false; 300 return strpos(strtolower($this->parts['scheme']), 'http') !== false;
298 } 301 }
299} 302}
diff --git a/application/Utils.php b/application/Utils.php
index 97b12fcf..925e1a22 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -97,7 +97,7 @@ function escape($input)
97 97
98 if (is_array($input)) { 98 if (is_array($input)) {
99 $out = array(); 99 $out = array();
100 foreach($input as $key => $value) { 100 foreach ($input as $key => $value) {
101 $out[$key] = escape($value); 101 $out[$key] = escape($value);
102 } 102 }
103 return $out; 103 return $out;
@@ -355,10 +355,13 @@ function return_bytes($val)
355 $val = trim($val); 355 $val = trim($val);
356 $last = strtolower($val[strlen($val)-1]); 356 $last = strtolower($val[strlen($val)-1]);
357 $val = intval(substr($val, 0, -1)); 357 $val = intval(substr($val, 0, -1));
358 switch($last) { 358 switch ($last) {
359 case 'g': $val *= 1024; 359 case 'g':
360 case 'm': $val *= 1024; 360 $val *= 1024;
361 case 'k': $val *= 1024; 361 case 'm':
362 $val *= 1024;
363 case 'k':
364 $val *= 1024;
362 } 365 }
363 return $val; 366 return $val;
364} 367}
@@ -452,6 +455,7 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
452 * 455 *
453 * @return string Text translated. 456 * @return string Text translated.
454 */ 457 */
455function t($text, $nText = '', $nb = 1, $domain = 'shaarli') { 458function t($text, $nText = '', $nb = 1, $domain = 'shaarli')
459{
456 return dn__($domain, $text, $nText, $nb); 460 return dn__($domain, $text, $nText, $nb);
457} 461}
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
index ff209393..66eac133 100644
--- a/application/api/ApiMiddleware.php
+++ b/application/api/ApiMiddleware.php
@@ -65,7 +65,7 @@ class ApiMiddleware
65 try { 65 try {
66 $this->checkRequest($request); 66 $this->checkRequest($request);
67 $response = $next($request, $response); 67 $response = $next($request, $response);
68 } catch(ApiException $e) { 68 } catch (ApiException $e) {
69 $e->setResponse($response); 69 $e->setResponse($response);
70 $e->setDebug($this->conf->get('dev.debug', false)); 70 $e->setDebug($this->conf->get('dev.debug', false));
71 $response = $e->getApiResponse(); 71 $response = $e->getApiResponse();
@@ -98,7 +98,8 @@ class ApiMiddleware
98 * 98 *
99 * @throws ApiAuthorizationException The token couldn't be validated. 99 * @throws ApiAuthorizationException The token couldn't be validated.
100 */ 100 */
101 protected function checkToken($request) { 101 protected function checkToken($request)
102 {
102 if (! $request->hasHeader('Authorization')) { 103 if (! $request->hasHeader('Authorization')) {
103 throw new ApiAuthorizationException('JWT token not provided'); 104 throw new ApiAuthorizationException('JWT token not provided');
104 } 105 }
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
index f154bb52..fc5ecaf1 100644
--- a/application/api/ApiUtils.php
+++ b/application/api/ApiUtils.php
@@ -134,4 +134,20 @@ class ApiUtils
134 134
135 return $oldLink; 135 return $oldLink;
136 } 136 }
137
138 /**
139 * Format a Tag for the REST API.
140 *
141 * @param string $tag Tag name
142 * @param int $occurrences Number of links using this tag
143 *
144 * @return array Link data formatted for the REST API.
145 */
146 public static function formatTag($tag, $occurences)
147 {
148 return [
149 'name' => $tag,
150 'occurrences' => $occurences,
151 ];
152 }
137} 153}
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
index 3be85b98..9edefcf6 100644
--- a/application/api/controllers/ApiController.php
+++ b/application/api/controllers/ApiController.php
@@ -41,7 +41,7 @@ abstract class ApiController
41 41
42 /** 42 /**
43 * ApiController constructor. 43 * ApiController constructor.
44 * 44 *
45 * Note: enabling debug mode displays JSON with readable formatting. 45 * Note: enabling debug mode displays JSON with readable formatting.
46 * 46 *
47 * @param Container $ci Slim container. 47 * @param Container $ci Slim container.
diff --git a/application/api/controllers/History.php b/application/api/controllers/History.php
index 2ff9deaf..4582e8b2 100644
--- a/application/api/controllers/History.php
+++ b/application/api/controllers/History.php
@@ -35,8 +35,7 @@ class History extends ApiController
35 $offset = $request->getParam('offset'); 35 $offset = $request->getParam('offset');
36 if (empty($offset)) { 36 if (empty($offset)) {
37 $offset = 0; 37 $offset = 0;
38 } 38 } elseif (ctype_digit($offset)) {
39 else if (ctype_digit($offset)) {
40 $offset = (int) $offset; 39 $offset = (int) $offset;
41 } else { 40 } else {
42 throw new ApiBadParametersException('Invalid offset'); 41 throw new ApiBadParametersException('Invalid offset');
@@ -46,7 +45,7 @@ class History extends ApiController
46 $limit = $request->getParam('limit'); 45 $limit = $request->getParam('limit');
47 if (empty($limit)) { 46 if (empty($limit)) {
48 $limit = count($history); 47 $limit = count($history);
49 } else if (ctype_digit($limit)) { 48 } elseif (ctype_digit($limit)) {
50 $limit = (int) $limit; 49 $limit = (int) $limit;
51 } else { 50 } else {
52 throw new ApiBadParametersException('Invalid limit'); 51 throw new ApiBadParametersException('Invalid limit');
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
index 25433f72..f37dcae5 100644
--- a/application/api/controllers/Info.php
+++ b/application/api/controllers/Info.php
@@ -7,7 +7,7 @@ use Slim\Http\Response;
7 7
8/** 8/**
9 * Class Info 9 * Class Info
10 * 10 *
11 * REST API Controller: /info 11 * REST API Controller: /info
12 * 12 *
13 * @package Api\Controllers 13 * @package Api\Controllers
@@ -17,7 +17,7 @@ class Info extends ApiController
17{ 17{
18 /** 18 /**
19 * Service providing various information about Shaarli instance. 19 * Service providing various information about Shaarli instance.
20 * 20 *
21 * @param Request $request Slim request. 21 * @param Request $request Slim request.
22 * @param Response $response Slim response. 22 * @param Response $response Slim response.
23 * 23 *
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
index eb78dd26..ffcfd4c7 100644
--- a/application/api/controllers/Links.php
+++ b/application/api/controllers/Links.php
@@ -59,25 +59,25 @@ class Links extends ApiController
59 $limit = $request->getParam('limit'); 59 $limit = $request->getParam('limit');
60 if (empty($limit)) { 60 if (empty($limit)) {
61 $limit = self::$DEFAULT_LIMIT; 61 $limit = self::$DEFAULT_LIMIT;
62 } else if (ctype_digit($limit)) { 62 } elseif (ctype_digit($limit)) {
63 $limit = intval($limit); 63 $limit = intval($limit);
64 } else if ($limit === 'all') { 64 } elseif ($limit === 'all') {
65 $limit = count($links); 65 $limit = count($links);
66 } else { 66 } else {
67 throw new ApiBadParametersException('Invalid limit'); 67 throw new ApiBadParametersException('Invalid limit');
68 } 68 }
69 69
70 // 'environment' is set by Slim and encapsulate $_SERVER. 70 // 'environment' is set by Slim and encapsulate $_SERVER.
71 $index = index_url($this->ci['environment']); 71 $indexUrl = index_url($this->ci['environment']);
72 72
73 $out = []; 73 $out = [];
74 $cpt = 0; 74 $index = 0;
75 foreach ($links as $link) { 75 foreach ($links as $link) {
76 if (count($out) >= $limit) { 76 if (count($out) >= $limit) {
77 break; 77 break;
78 } 78 }
79 if ($cpt++ >= $offset) { 79 if ($index++ >= $offset) {
80 $out[] = ApiUtils::formatLink($link, $index); 80 $out[] = ApiUtils::formatLink($link, $indexUrl);
81 } 81 }
82 } 82 }
83 83
diff --git a/application/api/controllers/Tags.php b/application/api/controllers/Tags.php
new file mode 100644
index 00000000..6dd78750
--- /dev/null
+++ b/application/api/controllers/Tags.php
@@ -0,0 +1,161 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Shaarli\Api\ApiUtils;
6use Shaarli\Api\Exceptions\ApiBadParametersException;
7use Shaarli\Api\Exceptions\ApiLinkNotFoundException;
8use Shaarli\Api\Exceptions\ApiTagNotFoundException;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class Tags
14 *
15 * REST API Controller: all services related to tags collection.
16 *
17 * @package Api\Controllers
18 */
19class Tags extends ApiController
20{
21 /**
22 * @var int Number of links returned if no limit is provided.
23 */
24 public static $DEFAULT_LIMIT = 'all';
25
26 /**
27 * Retrieve a list of tags, allowing different filters.
28 *
29 * @param Request $request Slim request.
30 * @param Response $response Slim response.
31 *
32 * @return Response response.
33 *
34 * @throws ApiBadParametersException Invalid parameters.
35 */
36 public function getTags($request, $response)
37 {
38 $visibility = $request->getParam('visibility');
39 $tags = $this->linkDb->linksCountPerTag([], $visibility);
40
41 // Return tags from the {offset}th tag, starting from 0.
42 $offset = $request->getParam('offset');
43 if (! empty($offset) && ! ctype_digit($offset)) {
44 throw new ApiBadParametersException('Invalid offset');
45 }
46 $offset = ! empty($offset) ? intval($offset) : 0;
47 if ($offset > count($tags)) {
48 return $response->withJson([], 200, $this->jsonStyle);
49 }
50
51 // limit parameter is either a number of links or 'all' for everything.
52 $limit = $request->getParam('limit');
53 if (empty($limit)) {
54 $limit = self::$DEFAULT_LIMIT;
55 }
56 if (ctype_digit($limit)) {
57 $limit = intval($limit);
58 } elseif ($limit === 'all') {
59 $limit = count($tags);
60 } else {
61 throw new ApiBadParametersException('Invalid limit');
62 }
63
64 $out = [];
65 $index = 0;
66 foreach ($tags as $tag => $occurrences) {
67 if (count($out) >= $limit) {
68 break;
69 }
70 if ($index++ >= $offset) {
71 $out[] = ApiUtils::formatTag($tag, $occurrences);
72 }
73 }
74
75 return $response->withJson($out, 200, $this->jsonStyle);
76 }
77
78 /**
79 * Return a single formatted tag by its name.
80 *
81 * @param Request $request Slim request.
82 * @param Response $response Slim response.
83 * @param array $args Path parameters. including the tag name.
84 *
85 * @return Response containing the link array.
86 *
87 * @throws ApiTagNotFoundException generating a 404 error.
88 */
89 public function getTag($request, $response, $args)
90 {
91 $tags = $this->linkDb->linksCountPerTag();
92 if (!isset($tags[$args['tagName']])) {
93 throw new ApiTagNotFoundException();
94 }
95 $out = ApiUtils::formatTag($args['tagName'], $tags[$args['tagName']]);
96
97 return $response->withJson($out, 200, $this->jsonStyle);
98 }
99
100 /**
101 * Rename a tag from the given name.
102 * If the new name provided matches an existing tag, they will be merged.
103 *
104 * @param Request $request Slim request.
105 * @param Response $response Slim response.
106 * @param array $args Path parameters. including the tag name.
107 *
108 * @return Response response.
109 *
110 * @throws ApiTagNotFoundException generating a 404 error.
111 * @throws ApiBadParametersException new tag name not provided
112 */
113 public function putTag($request, $response, $args)
114 {
115 $tags = $this->linkDb->linksCountPerTag();
116 if (! isset($tags[$args['tagName']])) {
117 throw new ApiTagNotFoundException();
118 }
119
120 $data = $request->getParsedBody();
121 if (empty($data['name'])) {
122 throw new ApiBadParametersException('New tag name is required in the request body');
123 }
124
125 $updated = $this->linkDb->renameTag($args['tagName'], $data['name']);
126 $this->linkDb->save($this->conf->get('resource.page_cache'));
127 foreach ($updated as $link) {
128 $this->history->updateLink($link);
129 }
130
131 $tags = $this->linkDb->linksCountPerTag();
132 $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
133 return $response->withJson($out, 200, $this->jsonStyle);
134 }
135
136 /**
137 * Delete an existing tag by its name.
138 *
139 * @param Request $request Slim request.
140 * @param Response $response Slim response.
141 * @param array $args Path parameters. including the tag name.
142 *
143 * @return Response response.
144 *
145 * @throws ApiTagNotFoundException generating a 404 error.
146 */
147 public function deleteTag($request, $response, $args)
148 {
149 $tags = $this->linkDb->linksCountPerTag();
150 if (! isset($tags[$args['tagName']])) {
151 throw new ApiTagNotFoundException();
152 }
153 $updated = $this->linkDb->renameTag($args['tagName'], null);
154 $this->linkDb->save($this->conf->get('resource.page_cache'));
155 foreach ($updated as $link) {
156 $this->history->updateLink($link);
157 }
158
159 return $response->withStatus(204);
160 }
161}
diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php
index c8490e0c..d6b66323 100644
--- a/application/api/exceptions/ApiException.php
+++ b/application/api/exceptions/ApiException.php
@@ -10,7 +10,8 @@ use Slim\Http\Response;
10 * Parent Exception related to the API, able to generate a valid Response (ResponseInterface). 10 * Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
11 * Also can include various information in debug mode. 11 * Also can include various information in debug mode.
12 */ 12 */
13abstract class ApiException extends \Exception { 13abstract class ApiException extends \Exception
14{
14 15
15 /** 16 /**
16 * @var Response instance from Slim. 17 * @var Response instance from Slim.
@@ -27,7 +28,7 @@ abstract class ApiException extends \Exception {
27 * 28 *
28 * @return Response Final response to give. 29 * @return Response Final response to give.
29 */ 30 */
30 public abstract function getApiResponse(); 31 abstract public function getApiResponse();
31 32
32 /** 33 /**
33 * Creates ApiResponse body. 34 * Creates ApiResponse body.
@@ -36,7 +37,8 @@ abstract class ApiException extends \Exception {
36 * 37 *
37 * @return array|string response body 38 * @return array|string response body
38 */ 39 */
39 protected function getApiResponseBody() { 40 protected function getApiResponseBody()
41 {
40 if ($this->debug !== true) { 42 if ($this->debug !== true) {
41 return $this->getMessage(); 43 return $this->getMessage();
42 } 44 }
diff --git a/application/api/exceptions/ApiLinkNotFoundException.php b/application/api/exceptions/ApiLinkNotFoundException.php
index de7e14f5..c727f4f0 100644
--- a/application/api/exceptions/ApiLinkNotFoundException.php
+++ b/application/api/exceptions/ApiLinkNotFoundException.php
@@ -2,7 +2,6 @@
2 2
3namespace Shaarli\Api\Exceptions; 3namespace Shaarli\Api\Exceptions;
4 4
5
6use Slim\Http\Response; 5use Slim\Http\Response;
7 6
8/** 7/**
diff --git a/application/api/exceptions/ApiTagNotFoundException.php b/application/api/exceptions/ApiTagNotFoundException.php
new file mode 100644
index 00000000..eee152fe
--- /dev/null
+++ b/application/api/exceptions/ApiTagNotFoundException.php
@@ -0,0 +1,31 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5use Slim\Http\Response;
6
7/**
8 * Class ApiTagNotFoundException
9 *
10 * Tag selected by name couldn't be found in the datastore, results in a 404 error.
11 *
12 * @package Shaarli\Api\Exceptions
13 */
14class ApiTagNotFoundException extends ApiException
15{
16 /**
17 * ApiLinkNotFoundException constructor.
18 */
19 public function __construct()
20 {
21 $this->message = 'Tag not found';
22 }
23
24 /**
25 * {@inheritdoc}
26 */
27 public function getApiResponse()
28 {
29 return $this->buildApiResponse(404);
30 }
31}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index 9e4c9f63..32aaea48 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -123,7 +123,7 @@ class ConfigManager
123 * Supports nested settings with dot separated keys. 123 * Supports nested settings with dot separated keys.
124 * 124 *
125 * @param string $setting Asked setting, keys separated with dots. 125 * @param string $setting Asked setting, keys separated with dots.
126 * @param string $value Value to set. 126 * @param mixed $value Value to set.
127 * @param bool $write Write the new setting in the config file, default false. 127 * @param bool $write Write the new setting in the config file, default false.
128 * @param bool $isLoggedIn User login state, default false. 128 * @param bool $isLoggedIn User login state, default false.
129 * 129 *
@@ -148,6 +148,33 @@ class ConfigManager
148 } 148 }
149 149
150 /** 150 /**
151 * Remove a config element from the config file.
152 *
153 * @param string $setting Asked setting, keys separated with dots.
154 * @param bool $write Write the new setting in the config file, default false.
155 * @param bool $isLoggedIn User login state, default false.
156 *
157 * @throws \Exception Invalid
158 */
159 public function remove($setting, $write = false, $isLoggedIn = false)
160 {
161 if (empty($setting) || ! is_string($setting)) {
162 throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting));
163 }
164
165 // During the ConfigIO transition, map legacy settings to the new ones.
166 if ($this->configIO instanceof ConfigPhp && isset(ConfigPhp::$LEGACY_KEYS_MAPPING[$setting])) {
167 $setting = ConfigPhp::$LEGACY_KEYS_MAPPING[$setting];
168 }
169
170 $settings = explode('.', $setting);
171 self::removeConfig($settings, $this->loadedConfig);
172 if ($write) {
173 $this->write($isLoggedIn);
174 }
175 }
176
177 /**
151 * Check if a settings exists. 178 * Check if a settings exists.
152 * 179 *
153 * Supports nested settings with dot separated keys. 180 * Supports nested settings with dot separated keys.
@@ -272,7 +299,7 @@ class ConfigManager
272 * 299 *
273 * @param array $settings Ordered array which contains keys to find. 300 * @param array $settings Ordered array which contains keys to find.
274 * @param mixed $value 301 * @param mixed $value
275 * @param array $conf Loaded settings, then sub-array. 302 * @param array $conf Loaded settings, then sub-array.
276 * 303 *
277 * @return mixed Found setting or NOT_FOUND flag. 304 * @return mixed Found setting or NOT_FOUND flag.
278 */ 305 */
@@ -290,6 +317,27 @@ class ConfigManager
290 } 317 }
291 318
292 /** 319 /**
320 * Recursive function which find asked setting in the loaded config and deletes it.
321 *
322 * @param array $settings Ordered array which contains keys to find.
323 * @param array $conf Loaded settings, then sub-array.
324 *
325 * @return mixed Found setting or NOT_FOUND flag.
326 */
327 protected static function removeConfig($settings, &$conf)
328 {
329 if (!is_array($settings) || count($settings) == 0) {
330 return self::$NOT_FOUND;
331 }
332
333 $setting = array_shift($settings);
334 if (count($settings) > 0) {
335 return self::removeConfig($settings, $conf[$setting]);
336 }
337 unset($conf[$setting]);
338 }
339
340 /**
293 * Set a bunch of default values allowing Shaarli to start without a config file. 341 * Set a bunch of default values allowing Shaarli to start without a config file.
294 */ 342 */
295 protected function setDefaultValues() 343 protected function setDefaultValues()
@@ -333,12 +381,12 @@ class ConfigManager
333 // default state of the 'remember me' checkbox of the login form 381 // default state of the 'remember me' checkbox of the login form
334 $this->setEmpty('privacy.remember_user_default', true); 382 $this->setEmpty('privacy.remember_user_default', true);
335 383
336 $this->setEmpty('thumbnail.enable_thumbnails', true);
337 $this->setEmpty('thumbnail.enable_localcache', true);
338
339 $this->setEmpty('redirector.url', ''); 384 $this->setEmpty('redirector.url', '');
340 $this->setEmpty('redirector.encode_url', true); 385 $this->setEmpty('redirector.encode_url', true);
341 386
387 $this->setEmpty('thumbnails.width', '125');
388 $this->setEmpty('thumbnails.height', '90');
389
342 $this->setEmpty('translation.language', 'auto'); 390 $this->setEmpty('translation.language', 'auto');
343 $this->setEmpty('translation.mode', 'php'); 391 $this->setEmpty('translation.mode', 'php');
344 $this->setEmpty('translation.extensions', []); 392 $this->setEmpty('translation.extensions', []);
diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php
index 8add8bcd..9625fe1a 100644
--- a/application/config/ConfigPhp.php
+++ b/application/config/ConfigPhp.php
@@ -104,12 +104,20 @@ class ConfigPhp implements ConfigIO
104 104
105 // Store all $conf['config'] 105 // Store all $conf['config']
106 foreach ($conf['config'] as $key => $value) { 106 foreach ($conf['config'] as $key => $value) {
107 $configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($conf['config'][$key], true).';'. PHP_EOL; 107 $configStr .= '$GLOBALS[\'config\'][\''
108 . $key
109 .'\'] = '
110 .var_export($conf['config'][$key], true).';'
111 . PHP_EOL;
108 } 112 }
109 113
110 if (isset($conf['plugins'])) { 114 if (isset($conf['plugins'])) {
111 foreach ($conf['plugins'] as $key => $value) { 115 foreach ($conf['plugins'] as $key => $value) {
112 $configStr .= '$GLOBALS[\'plugins\'][\''. $key .'\'] = '.var_export($conf['plugins'][$key], true).';'. PHP_EOL; 116 $configStr .= '$GLOBALS[\'plugins\'][\''
117 . $key
118 .'\'] = '
119 .var_export($conf['plugins'][$key], true).';'
120 . PHP_EOL;
113 } 121 }
114 } 122 }
115 123
diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php
index b3d9752b..dbb24937 100644
--- a/application/config/ConfigPlugin.php
+++ b/application/config/ConfigPlugin.php
@@ -34,8 +34,7 @@ function save_plugin_config($formData)
34 // If there is no order, it means a disabled plugin has been enabled. 34 // If there is no order, it means a disabled plugin has been enabled.
35 if (isset($formData['order_' . $key])) { 35 if (isset($formData['order_' . $key])) {
36 $plugins[(int) $formData['order_' . $key]] = $key; 36 $plugins[(int) $formData['order_' . $key]] = $key;
37 } 37 } else {
38 else {
39 $newEnabledPlugins[] = $key; 38 $newEnabledPlugins[] = $key;
40 } 39 }
41 } 40 }
diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php
new file mode 100644
index 00000000..0f315483
--- /dev/null
+++ b/application/security/LoginManager.php
@@ -0,0 +1,264 @@
1<?php
2namespace Shaarli\Security;
3
4use Shaarli\Config\ConfigManager;
5
6/**
7 * User login management
8 */
9class LoginManager
10{
11 /** @var string Name of the cookie set after logging in **/
12 public static $STAY_SIGNED_IN_COOKIE = 'shaarli_staySignedIn';
13
14 /** @var array A reference to the $_GLOBALS array */
15 protected $globals = [];
16
17 /** @var ConfigManager Configuration Manager instance **/
18 protected $configManager = null;
19
20 /** @var SessionManager Session Manager instance **/
21 protected $sessionManager = null;
22
23 /** @var string Path to the file containing IP bans */
24 protected $banFile = '';
25
26 /** @var bool Whether the user is logged in **/
27 protected $isLoggedIn = false;
28
29 /** @var bool Whether the Shaarli instance is open to public edition **/
30 protected $openShaarli = false;
31
32 /** @var string User sign-in token depending on remote IP and credentials */
33 protected $staySignedInToken = '';
34
35 /**
36 * Constructor
37 *
38 * @param array $globals The $GLOBALS array (reference)
39 * @param ConfigManager $configManager Configuration Manager instance
40 * @param SessionManager $sessionManager SessionManager instance
41 */
42 public function __construct(& $globals, $configManager, $sessionManager)
43 {
44 $this->globals = &$globals;
45 $this->configManager = $configManager;
46 $this->sessionManager = $sessionManager;
47 $this->banFile = $this->configManager->get('resource.ban_file', 'data/ipbans.php');
48 $this->readBanFile();
49 if ($this->configManager->get('security.open_shaarli') === true) {
50 $this->openShaarli = true;
51 }
52 }
53
54 /**
55 * Generate a token depending on deployment salt, user password and client IP
56 *
57 * @param string $clientIpAddress The remote client IP address
58 */
59 public function generateStaySignedInToken($clientIpAddress)
60 {
61 $this->staySignedInToken = sha1(
62 $this->configManager->get('credentials.hash')
63 . $clientIpAddress
64 . $this->configManager->get('credentials.salt')
65 );
66 }
67
68 /**
69 * Return the user's client stay-signed-in token
70 *
71 * @return string User's client stay-signed-in token
72 */
73 public function getStaySignedInToken()
74 {
75 return $this->staySignedInToken;
76 }
77
78 /**
79 * Check user session state and validity (expiration)
80 *
81 * @param array $cookie The $_COOKIE array
82 * @param string $clientIpId Client IP address identifier
83 */
84 public function checkLoginState($cookie, $clientIpId)
85 {
86 if (! $this->configManager->exists('credentials.login')) {
87 // Shaarli is not configured yet
88 $this->isLoggedIn = false;
89 return;
90 }
91
92 if (isset($cookie[self::$STAY_SIGNED_IN_COOKIE])
93 && $cookie[self::$STAY_SIGNED_IN_COOKIE] === $this->staySignedInToken
94 ) {
95 // The user client has a valid stay-signed-in cookie
96 // Session information is updated with the current client information
97 $this->sessionManager->storeLoginInfo($clientIpId);
98 } elseif ($this->sessionManager->hasSessionExpired()
99 || $this->sessionManager->hasClientIpChanged($clientIpId)
100 ) {
101 $this->sessionManager->logout();
102 $this->isLoggedIn = false;
103 return;
104 }
105
106 $this->isLoggedIn = true;
107 $this->sessionManager->extendSession();
108 }
109
110 /**
111 * Return whether the user is currently logged in
112 *
113 * @return true when the user is logged in, false otherwise
114 */
115 public function isLoggedIn()
116 {
117 if ($this->openShaarli) {
118 return true;
119 }
120 return $this->isLoggedIn;
121 }
122
123 /**
124 * Check user credentials are valid
125 *
126 * @param string $remoteIp Remote client IP address
127 * @param string $clientIpId Client IP address identifier
128 * @param string $login Username
129 * @param string $password Password
130 *
131 * @return bool true if the provided credentials are valid, false otherwise
132 */
133 public function checkCredentials($remoteIp, $clientIpId, $login, $password)
134 {
135 $hash = sha1($password . $login . $this->configManager->get('credentials.salt'));
136
137 if ($login != $this->configManager->get('credentials.login')
138 || $hash != $this->configManager->get('credentials.hash')
139 ) {
140 logm(
141 $this->configManager->get('resource.log'),
142 $remoteIp,
143 'Login failed for user ' . $login
144 );
145 return false;
146 }
147
148 $this->sessionManager->storeLoginInfo($clientIpId);
149 logm(
150 $this->configManager->get('resource.log'),
151 $remoteIp,
152 'Login successful'
153 );
154 return true;
155 }
156
157 /**
158 * Read a file containing banned IPs
159 */
160 protected function readBanFile()
161 {
162 if (! file_exists($this->banFile)) {
163 return;
164 }
165 include $this->banFile;
166 }
167
168 /**
169 * Write the banned IPs to a file
170 */
171 protected function writeBanFile()
172 {
173 if (! array_key_exists('IPBANS', $this->globals)) {
174 return;
175 }
176 file_put_contents(
177 $this->banFile,
178 "<?php\n\$GLOBALS['IPBANS']=" . var_export($this->globals['IPBANS'], true) . ";\n?>"
179 );
180 }
181
182 /**
183 * Handle a failed login and ban the IP after too many failed attempts
184 *
185 * @param array $server The $_SERVER array
186 */
187 public function handleFailedLogin($server)
188 {
189 $ip = $server['REMOTE_ADDR'];
190 $trusted = $this->configManager->get('security.trusted_proxies', []);
191
192 if (in_array($ip, $trusted)) {
193 $ip = getIpAddressFromProxy($server, $trusted);
194 if (! $ip) {
195 // the IP is behind a trusted forward proxy, but is not forwarded
196 // in the HTTP headers, so we do nothing
197 return;
198 }
199 }
200
201 // increment the fail count for this IP
202 if (isset($this->globals['IPBANS']['FAILURES'][$ip])) {
203 $this->globals['IPBANS']['FAILURES'][$ip]++;
204 } else {
205 $this->globals['IPBANS']['FAILURES'][$ip] = 1;
206 }
207
208 if ($this->globals['IPBANS']['FAILURES'][$ip] >= $this->configManager->get('security.ban_after')) {
209 $this->globals['IPBANS']['BANS'][$ip] = time() + $this->configManager->get('security.ban_duration', 1800);
210 logm(
211 $this->configManager->get('resource.log'),
212 $server['REMOTE_ADDR'],
213 'IP address banned from login'
214 );
215 }
216 $this->writeBanFile();
217 }
218
219 /**
220 * Handle a successful login
221 *
222 * @param array $server The $_SERVER array
223 */
224 public function handleSuccessfulLogin($server)
225 {
226 $ip = $server['REMOTE_ADDR'];
227 // FIXME unban when behind a trusted proxy?
228
229 unset($this->globals['IPBANS']['FAILURES'][$ip]);
230 unset($this->globals['IPBANS']['BANS'][$ip]);
231
232 $this->writeBanFile();
233 }
234
235 /**
236 * Check if the user can login from this IP
237 *
238 * @param array $server The $_SERVER array
239 *
240 * @return bool true if the user is allowed to login
241 */
242 public function canLogin($server)
243 {
244 $ip = $server['REMOTE_ADDR'];
245
246 if (! isset($this->globals['IPBANS']['BANS'][$ip])) {
247 // the user is not banned
248 return true;
249 }
250
251 if ($this->globals['IPBANS']['BANS'][$ip] > time()) {
252 // the user is still banned
253 return false;
254 }
255
256 // the ban has expired, the user can attempt to log in again
257 logm($this->configManager->get('resource.log'), $server['REMOTE_ADDR'], 'Ban lifted.');
258 unset($this->globals['IPBANS']['FAILURES'][$ip]);
259 unset($this->globals['IPBANS']['BANS'][$ip]);
260
261 $this->writeBanFile();
262 return true;
263 }
264}
diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php
new file mode 100644
index 00000000..b8b8ab8d
--- /dev/null
+++ b/application/security/SessionManager.php
@@ -0,0 +1,199 @@
1<?php
2namespace Shaarli\Security;
3
4use Shaarli\Config\ConfigManager;
5
6/**
7 * Manages the server-side session
8 */
9class SessionManager
10{
11 /** @var int Session expiration timeout, in seconds */
12 public static $SHORT_TIMEOUT = 3600; // 1 hour
13
14 /** @var int Session expiration timeout, in seconds */
15 public static $LONG_TIMEOUT = 31536000; // 1 year
16
17 /** @var array Local reference to the global $_SESSION array */
18 protected $session = [];
19
20 /** @var ConfigManager Configuration Manager instance **/
21 protected $conf = null;
22
23 /** @var bool Whether the user should stay signed in (LONG_TIMEOUT) */
24 protected $staySignedIn = false;
25
26 /**
27 * Constructor
28 *
29 * @param array $session The $_SESSION array (reference)
30 * @param ConfigManager $conf ConfigManager instance
31 */
32 public function __construct(& $session, $conf)
33 {
34 $this->session = &$session;
35 $this->conf = $conf;
36 }
37
38 /**
39 * Define whether the user should stay signed in across browser sessions
40 *
41 * @param bool $staySignedIn Keep the user signed in
42 */
43 public function setStaySignedIn($staySignedIn)
44 {
45 $this->staySignedIn = $staySignedIn;
46 }
47
48 /**
49 * Generates a session token
50 *
51 * @return string token
52 */
53 public function generateToken()
54 {
55 $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt'));
56 $this->session['tokens'][$token] = 1;
57 return $token;
58 }
59
60 /**
61 * Checks the validity of a session token, and destroys it afterwards
62 *
63 * @param string $token The token to check
64 *
65 * @return bool true if the token is valid, else false
66 */
67 public function checkToken($token)
68 {
69 if (! isset($this->session['tokens'][$token])) {
70 // the token is wrong, or has already been used
71 return false;
72 }
73
74 // destroy the token to prevent future use
75 unset($this->session['tokens'][$token]);
76 return true;
77 }
78
79 /**
80 * Validate session ID to prevent Full Path Disclosure.
81 *
82 * See #298.
83 * The session ID's format depends on the hash algorithm set in PHP settings
84 *
85 * @param string $sessionId Session ID
86 *
87 * @return true if valid, false otherwise.
88 *
89 * @see http://php.net/manual/en/function.hash-algos.php
90 * @see http://php.net/manual/en/session.configuration.php
91 */
92 public static function checkId($sessionId)
93 {
94 if (empty($sessionId)) {
95 return false;
96 }
97
98 if (!$sessionId) {
99 return false;
100 }
101
102 if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) {
103 return false;
104 }
105
106 return true;
107 }
108
109 /**
110 * Store user login information after a successful login
111 *
112 * @param string $clientIpId Client IP address identifier
113 */
114 public function storeLoginInfo($clientIpId)
115 {
116 $this->session['ip'] = $clientIpId;
117 $this->session['username'] = $this->conf->get('credentials.login');
118 $this->extendTimeValidityBy(self::$SHORT_TIMEOUT);
119 }
120
121 /**
122 * Extend session validity
123 */
124 public function extendSession()
125 {
126 if ($this->staySignedIn) {
127 return $this->extendTimeValidityBy(self::$LONG_TIMEOUT);
128 }
129 return $this->extendTimeValidityBy(self::$SHORT_TIMEOUT);
130 }
131
132 /**
133 * Extend expiration time
134 *
135 * @param int $duration Expiration time extension (seconds)
136 *
137 * @return int New session expiration time
138 */
139 protected function extendTimeValidityBy($duration)
140 {
141 $expirationTime = time() + $duration;
142 $this->session['expires_on'] = $expirationTime;
143 return $expirationTime;
144 }
145
146 /**
147 * Logout a user by unsetting all login information
148 *
149 * See:
150 * - https://secure.php.net/manual/en/function.setcookie.php
151 */
152 public function logout()
153 {
154 if (isset($this->session)) {
155 unset($this->session['ip']);
156 unset($this->session['expires_on']);
157 unset($this->session['username']);
158 unset($this->session['visibility']);
159 unset($this->session['untaggedonly']);
160 }
161 }
162
163 /**
164 * Check whether the session has expired
165 *
166 * @param string $clientIpId Client IP address identifier
167 *
168 * @return bool true if the session has expired, false otherwise
169 */
170 public function hasSessionExpired()
171 {
172 if (empty($this->session['expires_on'])) {
173 return true;
174 }
175 if (time() >= $this->session['expires_on']) {
176 return true;
177 }
178 return false;
179 }
180
181 /**
182 * Check whether the client IP address has changed
183 *
184 * @param string $clientIpId Client IP address identifier
185 *
186 * @return bool true if the IP has changed, false if it has not, or
187 * if session protection has been disabled
188 */
189 public function hasClientIpChanged($clientIpId)
190 {
191 if ($this->conf->get('security.session_protection_disabled') === true) {
192 return false;
193 }
194 if (isset($this->session['ip']) && $this->session['ip'] === $clientIpId) {
195 return false;
196 }
197 return true;
198 }
199}