aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to 'application')
-rw-r--r--application/FileUtils.php2
-rw-r--r--application/HttpUtils.php33
-rw-r--r--application/Languages.php21
-rw-r--r--application/LinkDB.php29
-rw-r--r--application/LinkFilter.php8
-rw-r--r--application/LinkUtils.php17
-rw-r--r--application/NetscapeBookmarkUtils.php8
-rw-r--r--application/PageBuilder.php45
-rw-r--r--application/Router.php12
-rw-r--r--application/SessionManager.php83
-rw-r--r--application/Thumbnailer.php127
-rw-r--r--application/Updater.php74
-rw-r--r--application/Url.php4
-rw-r--r--application/api/ApiUtils.php16
-rw-r--r--application/api/controllers/History.php4
-rw-r--r--application/api/controllers/Links.php12
-rw-r--r--application/api/controllers/Tags.php161
-rw-r--r--application/api/exceptions/ApiTagNotFoundException.php32
-rw-r--r--application/config/ConfigManager.php58
-rw-r--r--application/security/LoginManager.php265
-rw-r--r--application/security/SessionManager.php199
21 files changed, 1081 insertions, 129 deletions
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..e9282506 100644
--- a/application/HttpUtils.php
+++ b/application/HttpUtils.php
@@ -1,7 +1,7 @@
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)
@@ -415,6 +415,37 @@ function getIpAddressFromProxy($server, $trustedIps)
415 return array_pop($ips); 415 return array_pop($ips);
416} 416}
417 417
418
419/**
420 * Return an identifier based on the advertised client IP address(es)
421 *
422 * This aims at preventing session hijacking from users behind the same proxy
423 * by relying on HTTP headers.
424 *
425 * See:
426 * - https://secure.php.net/manual/en/reserved.variables.server.php
427 * - https://stackoverflow.com/questions/3003145/how-to-get-the-client-ip-address-in-php
428 * - https://stackoverflow.com/questions/12233406/preventing-session-hijacking
429 * - https://stackoverflow.com/questions/21354859/trusting-x-forwarded-for-to-identify-a-visitor
430 *
431 * @param array $server The $_SERVER array
432 *
433 * @return string An identifier based on client IP address information
434 */
435function client_ip_id($server)
436{
437 $ip = $server['REMOTE_ADDR'];
438
439 if (isset($server['HTTP_X_FORWARDED_FOR'])) {
440 $ip = $ip . '_' . $server['HTTP_X_FORWARDED_FOR'];
441 }
442 if (isset($server['HTTP_CLIENT_IP'])) {
443 $ip = $ip . '_' . $server['HTTP_CLIENT_IP'];
444 }
445 return $ip;
446}
447
448
418/** 449/**
419 * Returns true if Shaarli's currently browsed in HTTPS. 450 * Returns true if Shaarli's currently browsed in HTTPS.
420 * Supports reverse proxies (if the headers are correctly set). 451 * Supports reverse proxies (if the headers are correctly set).
diff --git a/application/Languages.php b/application/Languages.php
index 3eb3388f..4fa32426 100644
--- a/application/Languages.php
+++ b/application/Languages.php
@@ -98,6 +98,12 @@ class Languages
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,23 @@ 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) {}
124 129
130 // Default extension translation from the current theme
131 $theme = $this->conf->get('theme');
132 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language';
133 if (is_dir($themeTransFolder)) {
134 try {
135 $translations = Translations::fromPoFile(
136 $themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po'
137 );
138 $translations->setDomain($theme);
139 $this->translator->loadTranslations($translations);
140 } catch (\InvalidArgumentException $e) {}
141 }
125 142
126 // Extension translations (plugins, themes, etc.). 143 // Extension translations (plugins, themes, etc.).
127 foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) { 144 foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
@@ -130,7 +147,6 @@ class Languages
130 } 147 }
131 148
132 try { 149 try {
133 /** @var Translations $extension */
134 $extension = Translations::fromPoFile($translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po'); 150 $extension = Translations::fromPoFile($translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po');
135 $extension->setDomain($domain); 151 $extension->setDomain($domain);
136 $this->translator->loadTranslations($extension); 152 $this->translator->loadTranslations($extension);
@@ -161,6 +177,7 @@ class Languages
161 'auto' => t('Automatic'), 177 'auto' => t('Automatic'),
162 'en' => t('English'), 178 'en' => t('English'),
163 'fr' => t('French'), 179 'fr' => t('French'),
180 'de' => t('German'),
164 ]; 181 ];
165 } 182 }
166} 183}
diff --git a/application/LinkDB.php b/application/LinkDB.php
index c1661d52..cd0f2967 100644
--- a/application/LinkDB.php
+++ b/application/LinkDB.php
@@ -436,15 +436,17 @@ You use the community supported version of the original Shaarli project, by Seba
436 436
437 /** 437 /**
438 * Returns the list tags appearing in the links with the given tags 438 * Returns the list tags appearing in the links with the given tags
439 * @param $filteringTags: tags selecting the links to consider 439 *
440 * @param $visibility: process only all/private/public links 440 * @param array $filteringTags tags selecting the links to consider
441 * @return: a tag=>linksCount array 441 * @param string $visibility process only all/private/public links
442 *
443 * @return array tag => linksCount
442 */ 444 */
443 public function linksCountPerTag($filteringTags = [], $visibility = 'all') 445 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
444 { 446 {
445 $links = empty($filteringTags) ? $this->links : $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); 447 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
446 $tags = array(); 448 $tags = [];
447 $caseMapping = array(); 449 $caseMapping = [];
448 foreach ($links as $link) { 450 foreach ($links as $link) {
449 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { 451 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
450 if (empty($tag)) { 452 if (empty($tag)) {
@@ -458,8 +460,19 @@ You use the community supported version of the original Shaarli project, by Seba
458 $tags[$caseMapping[strtolower($tag)]]++; 460 $tags[$caseMapping[strtolower($tag)]]++;
459 } 461 }
460 } 462 }
461 // Sort tags by usage (most used tag first) 463
462 arsort($tags); 464 /*
465 * Formerly used arsort(), which doesn't define the sort behaviour for equal values.
466 * Also, this function doesn't produce the same result between PHP 5.6 and 7.
467 *
468 * So we now use array_multisort() to sort tags by DESC occurrences,
469 * then ASC alphabetically for equal values.
470 *
471 * @see https://github.com/shaarli/Shaarli/issues/1142
472 */
473 $keys = array_keys($tags);
474 $tmpTags = array_combine($keys, $keys);
475 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
463 return $tags; 476 return $tags;
464 } 477 }
465 478
diff --git a/application/LinkFilter.php b/application/LinkFilter.php
index 12376e27..e52239b8 100644
--- a/application/LinkFilter.php
+++ b/application/LinkFilter.php
@@ -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 }
@@ -210,7 +210,7 @@ class LinkFilter
210 if ($visibility !== 'all') { 210 if ($visibility !== 'all') {
211 if (! $link['private'] && $visibility === 'private') { 211 if (! $link['private'] && $visibility === 'private') {
212 continue; 212 continue;
213 } else if ($link['private'] && $visibility === 'public') { 213 } elseif ($link['private'] && $visibility === 'public') {
214 continue; 214 continue;
215 } 215 }
216 } 216 }
@@ -337,7 +337,7 @@ class LinkFilter
337 if ($visibility !== 'all') { 337 if ($visibility !== 'all') {
338 if (! $link['private'] && $visibility === 'private') { 338 if (! $link['private'] && $visibility === 'private') {
339 continue; 339 continue;
340 } else if ($link['private'] && $visibility === 'public') { 340 } elseif ($link['private'] && $visibility === 'public') {
341 continue; 341 continue;
342 } 342 }
343 } 343 }
@@ -380,7 +380,7 @@ class LinkFilter
380 if ($visibility !== 'all') { 380 if ($visibility !== 'all') {
381 if (! $link['private'] && $visibility === 'private') { 381 if (! $link['private'] && $visibility === 'private') {
382 continue; 382 continue;
383 } else if ($link['private'] && $visibility === 'public') { 383 } elseif ($link['private'] && $visibility === 'public') {
384 continue; 384 continue;
385 } 385 }
386 } 386 }
diff --git a/application/LinkUtils.php b/application/LinkUtils.php
index 3705f7e9..4df5c0ca 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)) {
diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php
index dd7057f8..b4d16d00 100644
--- a/application/NetscapeBookmarkUtils.php
+++ b/application/NetscapeBookmarkUtils.php
@@ -108,7 +108,7 @@ class NetscapeBookmarkUtils
108 $filesize = $files['filetoupload']['size']; 108 $filesize = $files['filetoupload']['size'];
109 $data = file_get_contents($files['filetoupload']['tmp_name']); 109 $data = file_get_contents($files['filetoupload']['tmp_name']);
110 110
111 if (strpos($data, '<!DOCTYPE NETSCAPE-Bookmark-file-1>') === false) { 111 if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) {
112 return self::importStatus($filename, $filesize); 112 return self::importStatus($filename, $filesize);
113 } 113 }
114 114
@@ -154,13 +154,13 @@ class NetscapeBookmarkUtils
154 if (empty($post['privacy']) || $post['privacy'] == 'default') { 154 if (empty($post['privacy']) || $post['privacy'] == 'default') {
155 // use value from the imported file 155 // use value from the imported file
156 $private = $bkm['pub'] == '1' ? 0 : 1; 156 $private = $bkm['pub'] == '1' ? 0 : 1;
157 } else if ($post['privacy'] == 'private') { 157 } elseif ($post['privacy'] == 'private') {
158 // all imported links are private 158 // all imported links are private
159 $private = 1; 159 $private = 1;
160 } else if ($post['privacy'] == 'public') { 160 } elseif ($post['privacy'] == 'public') {
161 // all imported links are public 161 // all imported links are public
162 $private = 0; 162 $private = 0;
163 } 163 }
164 164
165 $newLink = array( 165 $newLink = array(
166 'title' => $bkm['title'], 166 'title' => $bkm['title'],
diff --git a/application/PageBuilder.php b/application/PageBuilder.php
index 468f144b..b1abe0d0 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,7 +73,7 @@ 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));
@@ -67,6 +85,7 @@ class PageBuilder
67 $this->tpl->assign('versionError', escape($exc->getMessage())); 85 $this->tpl->assign('versionError', escape($exc->getMessage()));
68 } 86 }
69 87
88 $this->tpl->assign('is_logged_in', $this->isLoggedIn);
70 $this->tpl->assign('feedurl', escape(index_url($_SERVER))); 89 $this->tpl->assign('feedurl', escape(index_url($_SERVER)));
71 $searchcrits = ''; // Search criteria 90 $searchcrits = ''; // Search criteria
72 if (!empty($_GET['searchtags'])) { 91 if (!empty($_GET['searchtags'])) {
@@ -83,7 +102,8 @@ class PageBuilder
83 ApplicationUtils::getVersionHash(SHAARLI_VERSION, $this->conf->get('credentials.salt')) 102 ApplicationUtils::getVersionHash(SHAARLI_VERSION, $this->conf->get('credentials.salt'))
84 ); 103 );
85 $this->tpl->assign('scripturl', index_url($_SERVER)); 104 $this->tpl->assign('scripturl', index_url($_SERVER));
86 $this->tpl->assign('privateonly', !empty($_SESSION['privateonly'])); // Show only private links? 105 $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : '';
106 $this->tpl->assign('visibility', $visibility);
87 $this->tpl->assign('untaggedonly', !empty($_SESSION['untaggedonly'])); 107 $this->tpl->assign('untaggedonly', !empty($_SESSION['untaggedonly']));
88 $this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli')); 108 $this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli'));
89 if ($this->conf->exists('general.header_link')) { 109 if ($this->conf->exists('general.header_link')) {
@@ -99,6 +119,19 @@ class PageBuilder
99 if ($this->linkDB !== null) { 119 if ($this->linkDB !== null) {
100 $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); 120 $this->tpl->assign('tags', $this->linkDB->linksCountPerTag());
101 } 121 }
122
123 $this->tpl->assign(
124 'thumbnails_enabled',
125 $this->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
126 );
127 $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width'));
128 $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height'));
129
130 if (! empty($_SESSION['warnings'])) {
131 $this->tpl->assign('global_warnings', $_SESSION['warnings']);
132 unset($_SESSION['warnings']);
133 }
134
102 // To be removed with a proper theme configuration. 135 // To be removed with a proper theme configuration.
103 $this->tpl->assign('conf', $this->conf); 136 $this->tpl->assign('conf', $this->conf);
104 } 137 }
diff --git a/application/Router.php b/application/Router.php
index 4df0387c..bf86b884 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';
@@ -47,6 +49,8 @@ class Router
47 49
48 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin'; 50 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
49 51
52 public static $PAGE_THUMBS_UPDATE = 'thumbs_update';
53
50 public static $GET_TOKEN = 'token'; 54 public static $GET_TOKEN = 'token';
51 55
52 /** 56 /**
@@ -101,6 +105,14 @@ class Router
101 return self::$PAGE_FEED_RSS; 105 return self::$PAGE_FEED_RSS;
102 } 106 }
103 107
108 if (startsWith($query, 'do='. self::$PAGE_THUMBS_UPDATE)) {
109 return self::$PAGE_THUMBS_UPDATE;
110 }
111
112 if (startsWith($query, 'do='. self::$AJAX_THUMB_UPDATE)) {
113 return self::$AJAX_THUMB_UPDATE;
114 }
115
104 // At this point, only loggedin pages. 116 // At this point, only loggedin pages.
105 if (!$loggedIn) { 117 if (!$loggedIn) {
106 return self::$PAGE_LINKLIST; 118 return self::$PAGE_LINKLIST;
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..7d0d9c33
--- /dev/null
+++ b/application/Thumbnailer.php
@@ -0,0 +1,127 @@
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.enabled', false);
59 $this->conf->write(true);
60 // TODO: create a proper error handling system able to catch exceptions...
61 die(t('php-gd extension must be loaded to use thumbnails. Thumbnails are now disabled. Please reload the page.'));
62 }
63
64 $this->wt = new WebThumbnailer();
65 WTConfigManager::addFile('inc/web-thumbnailer.json');
66 $this->wt->maxWidth($this->conf->get('thumbnails.width'))
67 ->maxHeight($this->conf->get('thumbnails.height'))
68 ->crop(true)
69 ->debug($this->conf->get('dev.debug', false));
70 }
71
72 /**
73 * Retrieve a thumbnail for given URL
74 *
75 * @param string $url where to look for a thumbnail.
76 *
77 * @return bool|string The thumbnail relative cache file path, or false if none has been found.
78 */
79 public function get($url)
80 {
81 if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON
82 && ! $this->isCommonMediaOrImage($url)
83 ) {
84 return false;
85 }
86
87 try {
88 return $this->wt->thumbnail($url);
89 } catch (WebThumbnailerException $e) {
90 // Exceptions are only thrown in debug mode.
91 error_log(get_class($e) . ': ' . $e->getMessage());
92 }
93 return false;
94 }
95
96 /**
97 * We check weather the given URL is from a common media domain,
98 * or if the file extension is an image.
99 *
100 * @param string $url to check
101 *
102 * @return bool true if it's an image or from a common media domain, false otherwise.
103 */
104 public function isCommonMediaOrImage($url)
105 {
106 foreach (self::COMMON_MEDIA_DOMAINS as $domain) {
107 if (strpos($url, $domain) !== false) {
108 return true;
109 }
110 }
111
112 if (endsWith($url, '.jpg') || endsWith($url, '.png') || endsWith($url, '.jpeg')) {
113 return true;
114 }
115
116 return false;
117 }
118
119 /**
120 * Make sure that requirements are match to use thumbnails:
121 * - php-gd is loaded
122 */
123 protected function checkRequirements()
124 {
125 return extension_loaded('gd');
126 }
127}
diff --git a/application/Updater.php b/application/Updater.php
index 8d2bd577..c2aa1568 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);
@@ -445,6 +455,68 @@ class Updater
445 $this->linkDB->save($this->conf->get('resource.page_cache')); 455 $this->linkDB->save($this->conf->get('resource.page_cache'));
446 return true; 456 return true;
447 } 457 }
458
459 /**
460 * Change privateonly session key to visibility.
461 */
462 public function updateMethodVisibilitySession()
463 {
464 if (isset($_SESSION['privateonly'])) {
465 unset($_SESSION['privateonly']);
466 $_SESSION['visibility'] = 'private';
467 }
468 return true;
469 }
470
471 /**
472 * Add download size and timeout to the configuration file
473 *
474 * @return bool true if the update is successful, false otherwise.
475 */
476 public function updateMethodDownloadSizeAndTimeoutConf()
477 {
478 if ($this->conf->exists('general.download_max_size')
479 && $this->conf->exists('general.download_timeout')
480 ) {
481 return true;
482 }
483
484 if (! $this->conf->exists('general.download_max_size')) {
485 $this->conf->set('general.download_max_size', 1024*1024*4);
486 }
487
488 if (! $this->conf->exists('general.download_timeout')) {
489 $this->conf->set('general.download_timeout', 30);
490 }
491
492 $this->conf->write($this->isLoggedIn);
493 return true;
494 }
495
496 /**
497 * * Move thumbnails management to WebThumbnailer, coming with new settings.
498 */
499 public function updateMethodWebThumbnailer()
500 {
501 if ($this->conf->exists('thumbnails.mode')) {
502 return true;
503 }
504
505 $thumbnailsEnabled = $this->conf->get('thumbnail.enable_thumbnails', true);
506 $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
507 $this->conf->set('thumbnails.width', 125);
508 $this->conf->set('thumbnails.height', 90);
509 $this->conf->remove('thumbnail');
510 $this->conf->write(true);
511
512 if ($thumbnailsEnabled) {
513 $this->session['warnings'][] = t(
514 'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
515 );
516 }
517
518 return true;
519 }
448} 520}
449 521
450/** 522/**
diff --git a/application/Url.php b/application/Url.php
index b3759377..6b9870f0 100644
--- a/application/Url.php
+++ b/application/Url.php
@@ -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;
@@ -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
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/History.php b/application/api/controllers/History.php
index 2ff9deaf..5cc453bf 100644
--- a/application/api/controllers/History.php
+++ b/application/api/controllers/History.php
@@ -36,7 +36,7 @@ class History extends ApiController
36 if (empty($offset)) { 36 if (empty($offset)) {
37 $offset = 0; 37 $offset = 0;
38 } 38 }
39 else if (ctype_digit($offset)) { 39 elseif (ctype_digit($offset)) {
40 $offset = (int) $offset; 40 $offset = (int) $offset;
41 } else { 41 } else {
42 throw new ApiBadParametersException('Invalid offset'); 42 throw new ApiBadParametersException('Invalid offset');
@@ -46,7 +46,7 @@ class History extends ApiController
46 $limit = $request->getParam('limit'); 46 $limit = $request->getParam('limit');
47 if (empty($limit)) { 47 if (empty($limit)) {
48 $limit = count($history); 48 $limit = count($history);
49 } else if (ctype_digit($limit)) { 49 } elseif (ctype_digit($limit)) {
50 $limit = (int) $limit; 50 $limit = (int) $limit;
51 } else { 51 } else {
52 throw new ApiBadParametersException('Invalid limit'); 52 throw new ApiBadParametersException('Invalid limit');
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/ApiTagNotFoundException.php b/application/api/exceptions/ApiTagNotFoundException.php
new file mode 100644
index 00000000..eed5afa5
--- /dev/null
+++ b/application/api/exceptions/ApiTagNotFoundException.php
@@ -0,0 +1,32 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5
6use Slim\Http\Response;
7
8/**
9 * Class ApiTagNotFoundException
10 *
11 * Tag selected by name couldn't be found in the datastore, results in a 404 error.
12 *
13 * @package Shaarli\Api\Exceptions
14 */
15class ApiTagNotFoundException extends ApiException
16{
17 /**
18 * ApiLinkNotFoundException constructor.
19 */
20 public function __construct()
21 {
22 $this->message = 'Tag not found';
23 }
24
25 /**
26 * {@inheritdoc}
27 */
28 public function getApiResponse()
29 {
30 return $this->buildApiResponse(404);
31 }
32}
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/security/LoginManager.php b/application/security/LoginManager.php
new file mode 100644
index 00000000..d6784d6d
--- /dev/null
+++ b/application/security/LoginManager.php
@@ -0,0 +1,265 @@
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
99 } elseif ($this->sessionManager->hasSessionExpired()
100 || $this->sessionManager->hasClientIpChanged($clientIpId)
101 ) {
102 $this->sessionManager->logout();
103 $this->isLoggedIn = false;
104 return;
105 }
106
107 $this->isLoggedIn = true;
108 $this->sessionManager->extendSession();
109 }
110
111 /**
112 * Return whether the user is currently logged in
113 *
114 * @return true when the user is logged in, false otherwise
115 */
116 public function isLoggedIn()
117 {
118 if ($this->openShaarli) {
119 return true;
120 }
121 return $this->isLoggedIn;
122 }
123
124 /**
125 * Check user credentials are valid
126 *
127 * @param string $remoteIp Remote client IP address
128 * @param string $clientIpId Client IP address identifier
129 * @param string $login Username
130 * @param string $password Password
131 *
132 * @return bool true if the provided credentials are valid, false otherwise
133 */
134 public function checkCredentials($remoteIp, $clientIpId, $login, $password)
135 {
136 $hash = sha1($password . $login . $this->configManager->get('credentials.salt'));
137
138 if ($login != $this->configManager->get('credentials.login')
139 || $hash != $this->configManager->get('credentials.hash')
140 ) {
141 logm(
142 $this->configManager->get('resource.log'),
143 $remoteIp,
144 'Login failed for user ' . $login
145 );
146 return false;
147 }
148
149 $this->sessionManager->storeLoginInfo($clientIpId);
150 logm(
151 $this->configManager->get('resource.log'),
152 $remoteIp,
153 'Login successful'
154 );
155 return true;
156 }
157
158 /**
159 * Read a file containing banned IPs
160 */
161 protected function readBanFile()
162 {
163 if (! file_exists($this->banFile)) {
164 return;
165 }
166 include $this->banFile;
167 }
168
169 /**
170 * Write the banned IPs to a file
171 */
172 protected function writeBanFile()
173 {
174 if (! array_key_exists('IPBANS', $this->globals)) {
175 return;
176 }
177 file_put_contents(
178 $this->banFile,
179 "<?php\n\$GLOBALS['IPBANS']=" . var_export($this->globals['IPBANS'], true) . ";\n?>"
180 );
181 }
182
183 /**
184 * Handle a failed login and ban the IP after too many failed attempts
185 *
186 * @param array $server The $_SERVER array
187 */
188 public function handleFailedLogin($server)
189 {
190 $ip = $server['REMOTE_ADDR'];
191 $trusted = $this->configManager->get('security.trusted_proxies', []);
192
193 if (in_array($ip, $trusted)) {
194 $ip = getIpAddressFromProxy($server, $trusted);
195 if (! $ip) {
196 // the IP is behind a trusted forward proxy, but is not forwarded
197 // in the HTTP headers, so we do nothing
198 return;
199 }
200 }
201
202 // increment the fail count for this IP
203 if (isset($this->globals['IPBANS']['FAILURES'][$ip])) {
204 $this->globals['IPBANS']['FAILURES'][$ip]++;
205 } else {
206 $this->globals['IPBANS']['FAILURES'][$ip] = 1;
207 }
208
209 if ($this->globals['IPBANS']['FAILURES'][$ip] >= $this->configManager->get('security.ban_after')) {
210 $this->globals['IPBANS']['BANS'][$ip] = time() + $this->configManager->get('security.ban_duration', 1800);
211 logm(
212 $this->configManager->get('resource.log'),
213 $server['REMOTE_ADDR'],
214 'IP address banned from login'
215 );
216 }
217 $this->writeBanFile();
218 }
219
220 /**
221 * Handle a successful login
222 *
223 * @param array $server The $_SERVER array
224 */
225 public function handleSuccessfulLogin($server)
226 {
227 $ip = $server['REMOTE_ADDR'];
228 // FIXME unban when behind a trusted proxy?
229
230 unset($this->globals['IPBANS']['FAILURES'][$ip]);
231 unset($this->globals['IPBANS']['BANS'][$ip]);
232
233 $this->writeBanFile();
234 }
235
236 /**
237 * Check if the user can login from this IP
238 *
239 * @param array $server The $_SERVER array
240 *
241 * @return bool true if the user is allowed to login
242 */
243 public function canLogin($server)
244 {
245 $ip = $server['REMOTE_ADDR'];
246
247 if (! isset($this->globals['IPBANS']['BANS'][$ip])) {
248 // the user is not banned
249 return true;
250 }
251
252 if ($this->globals['IPBANS']['BANS'][$ip] > time()) {
253 // the user is still banned
254 return false;
255 }
256
257 // the ban has expired, the user can attempt to log in again
258 logm($this->configManager->get('resource.log'), $server['REMOTE_ADDR'], 'Ban lifted.');
259 unset($this->globals['IPBANS']['FAILURES'][$ip]);
260 unset($this->globals['IPBANS']['BANS'][$ip]);
261
262 $this->writeBanFile();
263 return true;
264 }
265}
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}