aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to 'application')
-rw-r--r--application/History.php12
-rw-r--r--application/Languages.php18
-rw-r--r--application/Thumbnailer.php13
-rw-r--r--application/TimeZone.php7
-rw-r--r--application/Utils.php78
-rw-r--r--application/api/ApiMiddleware.php4
-rw-r--r--application/api/ApiUtils.php27
-rw-r--r--application/api/controllers/HistoryController.php1
-rw-r--r--application/api/controllers/Info.php4
-rw-r--r--application/api/controllers/Links.php20
-rw-r--r--application/api/exceptions/ApiAuthorizationException.php2
-rw-r--r--application/api/exceptions/ApiException.php2
-rw-r--r--application/bookmark/Bookmark.php64
-rw-r--r--application/bookmark/BookmarkArray.php6
-rw-r--r--application/bookmark/BookmarkFileService.php67
-rw-r--r--application/bookmark/BookmarkFilter.php57
-rw-r--r--application/bookmark/BookmarkIO.php26
-rw-r--r--application/bookmark/BookmarkInitializer.php13
-rw-r--r--application/bookmark/BookmarkServiceInterface.php32
-rw-r--r--application/bookmark/LinkUtils.php74
-rw-r--r--application/bookmark/exception/BookmarkNotFoundException.php1
-rw-r--r--application/bookmark/exception/EmptyDataStoreException.php6
-rw-r--r--application/bookmark/exception/InvalidBookmarkException.php14
-rw-r--r--application/bookmark/exception/NotWritableDataStoreException.php4
-rw-r--r--application/config/ConfigIO.php1
-rw-r--r--application/config/ConfigJson.php6
-rw-r--r--application/config/ConfigManager.php21
-rw-r--r--application/config/ConfigPhp.php28
-rw-r--r--application/config/ConfigPlugin.php8
-rw-r--r--application/config/exception/MissingFieldConfigException.php1
-rw-r--r--application/config/exception/UnauthorizedConfigException.php1
-rw-r--r--application/container/ContainerBuilder.php34
-rw-r--r--application/container/ShaarliContainer.php4
-rw-r--r--application/exceptions/IOException.php1
-rw-r--r--application/feed/CachedPage.php45
-rw-r--r--application/feed/FeedBuilder.php9
-rw-r--r--application/formatter/BookmarkDefaultFormatter.php18
-rw-r--r--application/formatter/BookmarkFormatter.php3
-rw-r--r--application/formatter/BookmarkMarkdownFormatter.php14
-rw-r--r--application/formatter/BookmarkRawFormatter.php4
-rw-r--r--application/formatter/FormatterFactory.php2
-rw-r--r--application/front/ShaarliMiddleware.php6
-rw-r--r--application/front/controller/admin/ConfigureController.php12
-rw-r--r--application/front/controller/admin/ExportController.php4
-rw-r--r--application/front/controller/admin/ImportController.php4
-rw-r--r--application/front/controller/admin/ManageShaareController.php371
-rw-r--r--application/front/controller/admin/ManageTagController.php37
-rw-r--r--application/front/controller/admin/MetadataController.php29
-rw-r--r--application/front/controller/admin/PasswordController.php4
-rw-r--r--application/front/controller/admin/PluginsController.php4
-rw-r--r--application/front/controller/admin/ServerController.php101
-rw-r--r--application/front/controller/admin/SessionFilterController.php2
-rw-r--r--application/front/controller/admin/ShaareAddController.php34
-rw-r--r--application/front/controller/admin/ShaareManageController.php202
-rw-r--r--application/front/controller/admin/ShaarePublishController.php274
-rw-r--r--application/front/controller/admin/ThumbnailsController.php2
-rw-r--r--application/front/controller/admin/ToolsController.php2
-rw-r--r--application/front/controller/visitor/BookmarkListController.php53
-rw-r--r--application/front/controller/visitor/DailyController.php108
-rw-r--r--application/front/controller/visitor/ErrorController.php12
-rw-r--r--application/front/controller/visitor/FeedController.php2
-rw-r--r--application/front/controller/visitor/InstallController.php44
-rw-r--r--application/front/controller/visitor/LoginController.php9
-rw-r--r--application/front/controller/visitor/PictureWallController.php2
-rw-r--r--application/front/controller/visitor/ShaarliVisitorController.php5
-rw-r--r--application/front/controller/visitor/TagCloudController.php12
-rw-r--r--application/front/controller/visitor/TagController.php18
-rw-r--r--application/helper/ApplicationUtils.php (renamed from application/ApplicationUtils.php)144
-rw-r--r--application/helper/DailyPageHelper.php236
-rw-r--r--application/helper/FileUtils.php (renamed from application/FileUtils.php)58
-rw-r--r--application/http/HttpAccess.php20
-rw-r--r--application/http/HttpUtils.php198
-rw-r--r--application/http/MetadataRetriever.php74
-rw-r--r--application/http/Url.php10
-rw-r--r--application/http/UrlUtils.php11
-rw-r--r--application/legacy/LegacyController.php2
-rw-r--r--application/legacy/LegacyLinkDB.php22
-rw-r--r--application/legacy/LegacyLinkFilter.php18
-rw-r--r--application/legacy/LegacyUpdater.php16
-rw-r--r--application/netscape/NetscapeBookmarkUtils.php14
-rw-r--r--application/plugin/PluginManager.php77
-rw-r--r--application/plugin/exception/PluginFileNotFoundException.php1
-rw-r--r--application/plugin/exception/PluginInvalidRouteException.php26
-rw-r--r--application/render/PageBuilder.php34
-rw-r--r--application/render/PageCacheManager.php14
-rw-r--r--application/render/TemplatePage.php1
-rw-r--r--application/render/ThemeUtils.php4
-rw-r--r--application/security/BanManager.php32
-rw-r--r--application/security/LoginManager.php81
-rw-r--r--application/security/SessionManager.php10
-rw-r--r--application/updater/Updater.php10
-rw-r--r--application/updater/UpdaterUtils.php8
92 files changed, 2234 insertions, 992 deletions
diff --git a/application/History.php b/application/History.php
index 4fd2f294..d230f39d 100644
--- a/application/History.php
+++ b/application/History.php
@@ -1,9 +1,11 @@
1<?php 1<?php
2
2namespace Shaarli; 3namespace Shaarli;
3 4
4use DateTime; 5use DateTime;
5use Exception; 6use Exception;
6use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Helper\FileUtils;
7 9
8/** 10/**
9 * Class History 11 * Class History
@@ -30,27 +32,27 @@ class History
30 /** 32 /**
31 * @var string Action key: a new link has been created. 33 * @var string Action key: a new link has been created.
32 */ 34 */
33 const CREATED = 'CREATED'; 35 public const CREATED = 'CREATED';
34 36
35 /** 37 /**
36 * @var string Action key: a link has been updated. 38 * @var string Action key: a link has been updated.
37 */ 39 */
38 const UPDATED = 'UPDATED'; 40 public const UPDATED = 'UPDATED';
39 41
40 /** 42 /**
41 * @var string Action key: a link has been deleted. 43 * @var string Action key: a link has been deleted.
42 */ 44 */
43 const DELETED = 'DELETED'; 45 public const DELETED = 'DELETED';
44 46
45 /** 47 /**
46 * @var string Action key: settings have been updated. 48 * @var string Action key: settings have been updated.
47 */ 49 */
48 const SETTINGS = 'SETTINGS'; 50 public const SETTINGS = 'SETTINGS';
49 51
50 /** 52 /**
51 * @var string Action key: a bulk import has been processed. 53 * @var string Action key: a bulk import has been processed.
52 */ 54 */
53 const IMPORT = 'IMPORT'; 55 public const IMPORT = 'IMPORT';
54 56
55 /** 57 /**
56 * @var string History file path. 58 * @var string History file path.
diff --git a/application/Languages.php b/application/Languages.php
index d83e0765..7177db2c 100644
--- a/application/Languages.php
+++ b/application/Languages.php
@@ -41,7 +41,7 @@ class Languages
41 /** 41 /**
42 * Core translations domain 42 * Core translations domain
43 */ 43 */
44 const DEFAULT_DOMAIN = 'shaarli'; 44 public const DEFAULT_DOMAIN = 'shaarli';
45 45
46 /** 46 /**
47 * @var TranslatorInterface 47 * @var TranslatorInterface
@@ -76,7 +76,8 @@ class Languages
76 $this->language = $confLanguage; 76 $this->language = $confLanguage;
77 } 77 }
78 78
79 if (! extension_loaded('gettext') 79 if (
80 ! extension_loaded('gettext')
80 || in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php']) 81 || in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
81 ) { 82 ) {
82 $this->initPhpTranslator(); 83 $this->initPhpTranslator();
@@ -98,7 +99,7 @@ class Languages
98 $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages'); 99 $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
99 100
100 // Default extension translation from the current theme 101 // Default extension translation from the current theme
101 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $this->conf->get('theme') .'/language'; 102 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $this->conf->get('theme') . '/language';
102 if (is_dir($themeTransFolder)) { 103 if (is_dir($themeTransFolder)) {
103 $this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false); 104 $this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
104 } 105 }
@@ -121,7 +122,9 @@ class Languages
121 $translations = new Translations(); 122 $translations = new Translations();
122 // Core translations 123 // Core translations
123 try { 124 try {
124 $translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po'); 125 $translations = $translations->addFromPoFile(
126 'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po'
127 );
125 $translations->setDomain('shaarli'); 128 $translations->setDomain('shaarli');
126 $this->translator->loadTranslations($translations); 129 $this->translator->loadTranslations($translations);
127 } catch (\InvalidArgumentException $e) { 130 } catch (\InvalidArgumentException $e) {
@@ -129,11 +132,11 @@ class Languages
129 132
130 // Default extension translation from the current theme 133 // Default extension translation from the current theme
131 $theme = $this->conf->get('theme'); 134 $theme = $this->conf->get('theme');
132 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language'; 135 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language';
133 if (is_dir($themeTransFolder)) { 136 if (is_dir($themeTransFolder)) {
134 try { 137 try {
135 $translations = Translations::fromPoFile( 138 $translations = Translations::fromPoFile(
136 $themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po' 139 $themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po'
137 ); 140 );
138 $translations->setDomain($theme); 141 $translations->setDomain($theme);
139 $this->translator->loadTranslations($translations); 142 $this->translator->loadTranslations($translations);
@@ -149,7 +152,7 @@ class Languages
149 152
150 try { 153 try {
151 $extension = Translations::fromPoFile( 154 $extension = Translations::fromPoFile(
152 $translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po' 155 $translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po'
153 ); 156 );
154 $extension->setDomain($domain); 157 $extension->setDomain($domain);
155 $this->translator->loadTranslations($extension); 158 $this->translator->loadTranslations($extension);
@@ -183,6 +186,7 @@ class Languages
183 'en' => t('English'), 186 'en' => t('English'),
184 'fr' => t('French'), 187 'fr' => t('French'),
185 'jp' => t('Japanese'), 188 'jp' => t('Japanese'),
189 'ru' => t('Russian'),
186 ]; 190 ];
187 } 191 }
188} 192}
diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php
index 5aec23c8..c4ff8d7a 100644
--- a/application/Thumbnailer.php
+++ b/application/Thumbnailer.php
@@ -13,7 +13,7 @@ use WebThumbnailer\WebThumbnailer;
13 */ 13 */
14class Thumbnailer 14class Thumbnailer
15{ 15{
16 const COMMON_MEDIA_DOMAINS = [ 16 protected const COMMON_MEDIA_DOMAINS = [
17 'imgur.com', 17 'imgur.com',
18 'flickr.com', 18 'flickr.com',
19 'youtube.com', 19 'youtube.com',
@@ -31,9 +31,9 @@ class Thumbnailer
31 'deviantart.com', 31 'deviantart.com',
32 ]; 32 ];
33 33
34 const MODE_ALL = 'all'; 34 public const MODE_ALL = 'all';
35 const MODE_COMMON = 'common'; 35 public const MODE_COMMON = 'common';
36 const MODE_NONE = 'none'; 36 public const MODE_NONE = 'none';
37 37
38 /** 38 /**
39 * @var WebThumbnailer instance. 39 * @var WebThumbnailer instance.
@@ -60,7 +60,7 @@ class Thumbnailer
60 // TODO: create a proper error handling system able to catch exceptions... 60 // TODO: create a proper error handling system able to catch exceptions...
61 die(t( 61 die(t(
62 'php-gd extension must be loaded to use thumbnails. ' 62 'php-gd extension must be loaded to use thumbnails. '
63 .'Thumbnails are now disabled. Please reload the page.' 63 . 'Thumbnails are now disabled. Please reload the page.'
64 )); 64 ));
65 } 65 }
66 66
@@ -81,7 +81,8 @@ class Thumbnailer
81 */ 81 */
82 public function get($url) 82 public function get($url)
83 { 83 {
84 if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON 84 if (
85 $this->conf->get('thumbnails.mode') === self::MODE_COMMON
85 && ! $this->isCommonMediaOrImage($url) 86 && ! $this->isCommonMediaOrImage($url)
86 ) { 87 ) {
87 return false; 88 return false;
diff --git a/application/TimeZone.php b/application/TimeZone.php
index c1869ef8..a420eb96 100644
--- a/application/TimeZone.php
+++ b/application/TimeZone.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Generates a list of available timezone continents and cities. 4 * Generates a list of available timezone continents and cities.
4 * 5 *
@@ -43,7 +44,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
43 // Try to split the provided timezone 44 // Try to split the provided timezone
44 $spos = strpos($preselectedTimezone, '/'); 45 $spos = strpos($preselectedTimezone, '/');
45 $pcontinent = substr($preselectedTimezone, 0, $spos); 46 $pcontinent = substr($preselectedTimezone, 0, $spos);
46 $pcity = substr($preselectedTimezone, $spos+1); 47 $pcity = substr($preselectedTimezone, $spos + 1);
47 } 48 }
48 49
49 $continents = []; 50 $continents = [];
@@ -60,7 +61,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
60 } 61 }
61 62
62 $continent = substr($tz, 0, $spos); 63 $continent = substr($tz, 0, $spos);
63 $city = substr($tz, $spos+1); 64 $city = substr($tz, $spos + 1);
64 $cities[] = ['continent' => $continent, 'city' => $city]; 65 $cities[] = ['continent' => $continent, 'city' => $city];
65 $continents[$continent] = true; 66 $continents[$continent] = true;
66 } 67 }
@@ -85,7 +86,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
85function isTimeZoneValid($continent, $city) 86function isTimeZoneValid($continent, $city)
86{ 87{
87 return in_array( 88 return in_array(
88 $continent.'/'.$city, 89 $continent . '/' . $city,
89 timezone_identifiers_list() 90 timezone_identifiers_list()
90 ); 91 );
91} 92}
diff --git a/application/Utils.php b/application/Utils.php
index bcfda65c..952378ab 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -1,24 +1,27 @@
1<?php 1<?php
2
2/** 3/**
3 * Shaarli utilities 4 * Shaarli utilities
4 */ 5 */
5 6
6/** 7/**
7 * Logs a message to a text file 8 * Format log using provided data.
8 * 9 *
9 * The log format is compatible with fail2ban. 10 * @param string $message the message to log
11 * @param string|null $clientIp the client's remote IPv4/IPv6 address
10 * 12 *
11 * @param string $logFile where to write the logs 13 * @return string Formatted message to log
12 * @param string $clientIp the client's remote IPv4/IPv6 address
13 * @param string $message the message to log
14 */ 14 */
15function logm($logFile, $clientIp, $message) 15function format_log(string $message, string $clientIp = null): string
16{ 16{
17 file_put_contents( 17 $out = $message;
18 $logFile, 18
19 date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL, 19 if (!empty($clientIp)) {
20 FILE_APPEND 20 // Note: we keep the first dash to avoid breaking fail2ban configs
21 ); 21 $out = '- ' . $clientIp . ' - ' . $out;
22 }
23
24 return $out;
22} 25}
23 26
24/** 27/**
@@ -100,7 +103,7 @@ function escape($input)
100 } 103 }
101 104
102 if (is_array($input)) { 105 if (is_array($input)) {
103 $out = array(); 106 $out = [];
104 foreach ($input as $key => $value) { 107 foreach ($input as $key => $value) {
105 $out[escape($key)] = escape($value); 108 $out[escape($key)] = escape($value);
106 } 109 }
@@ -161,7 +164,7 @@ function checkDateFormat($format, $string)
161 * 164 *
162 * @return string $referer - final referer. 165 * @return string $referer - final referer.
163 */ 166 */
164function generateLocation($referer, $host, $loopTerms = array()) 167function generateLocation($referer, $host, $loopTerms = [])
165{ 168{
166 $finalReferer = './?'; 169 $finalReferer = './?';
167 170
@@ -194,7 +197,7 @@ function generateLocation($referer, $host, $loopTerms = array())
194function autoLocale($headerLocale) 197function autoLocale($headerLocale)
195{ 198{
196 // Default if browser does not send HTTP_ACCEPT_LANGUAGE 199 // Default if browser does not send HTTP_ACCEPT_LANGUAGE
197 $locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8'); 200 $locales = ['en_US', 'en_US.utf8', 'en_US.UTF-8'];
198 if (! empty($headerLocale)) { 201 if (! empty($headerLocale)) {
199 if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) { 202 if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
200 $attempts = []; 203 $attempts = [];
@@ -325,6 +328,23 @@ function format_date($date, $time = true, $intl = true)
325} 328}
326 329
327/** 330/**
331 * Format the date month according to the locale.
332 *
333 * @param DateTimeInterface $date to format.
334 *
335 * @return bool|string Formatted date, or false if the input is invalid.
336 */
337function format_month(DateTimeInterface $date)
338{
339 if (! $date instanceof DateTimeInterface) {
340 return false;
341 }
342
343 return strftime('%B', $date->getTimestamp());
344}
345
346
347/**
328 * Check if the input is an integer, no matter its real type. 348 * Check if the input is an integer, no matter its real type.
329 * 349 *
330 * PHP is a bit messy regarding this: 350 * PHP is a bit messy regarding this:
@@ -357,13 +377,15 @@ function return_bytes($val)
357 return $val; 377 return $val;
358 } 378 }
359 $val = trim($val); 379 $val = trim($val);
360 $last = strtolower($val[strlen($val)-1]); 380 $last = strtolower($val[strlen($val) - 1]);
361 $val = intval(substr($val, 0, -1)); 381 $val = intval(substr($val, 0, -1));
362 switch ($last) { 382 switch ($last) {
363 case 'g': 383 case 'g':
364 $val *= 1024; 384 $val *= 1024;
385 // do no break in order 1024^2 for each unit
365 case 'm': 386 case 'm':
366 $val *= 1024; 387 $val *= 1024;
388 // do no break in order 1024^2 for each unit
367 case 'k': 389 case 'k':
368 $val *= 1024; 390 $val *= 1024;
369 } 391 }
@@ -452,14 +474,28 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
452 * Wrapper function for translation which match the API 474 * Wrapper function for translation which match the API
453 * of gettext()/_() and ngettext(). 475 * of gettext()/_() and ngettext().
454 * 476 *
455 * @param string $text Text to translate. 477 * @param string $text Text to translate.
456 * @param string $nText The plural message ID. 478 * @param string $nText The plural message ID.
457 * @param int $nb The number of items for plural forms. 479 * @param int $nb The number of items for plural forms.
458 * @param string $domain The domain where the translation is stored (default: shaarli). 480 * @param string $domain The domain where the translation is stored (default: shaarli).
481 * @param array $variables Associative array of variables to replace in translated text.
482 * @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables.
459 * 483 *
460 * @return string Text translated. 484 * @return string Text translated.
461 */ 485 */
462function t($text, $nText = '', $nb = 1, $domain = 'shaarli') 486function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
487{
488 $postFunction = $fixCase ? 'ucfirst' : function ($input) {
489 return $input;
490 };
491
492 return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
493}
494
495/**
496 * Converts an exception into a printable stack trace string.
497 */
498function exception2text(Throwable $e): string
463{ 499{
464 return dn__($domain, $text, $nText, $nb); 500 return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
465} 501}
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
index adc8b266..9fb88358 100644
--- a/application/api/ApiMiddleware.php
+++ b/application/api/ApiMiddleware.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Api; 3namespace Shaarli\Api;
3 4
4use malkusch\lock\mutex\FlockMutex; 5use malkusch\lock\mutex\FlockMutex;
@@ -108,7 +109,8 @@ class ApiMiddleware
108 */ 109 */
109 protected function checkToken($request) 110 protected function checkToken($request)
110 { 111 {
111 if (!$request->hasHeader('Authorization') 112 if (
113 !$request->hasHeader('Authorization')
112 && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION']) 114 && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
113 ) { 115 ) {
114 throw new ApiAuthorizationException('JWT token not provided'); 116 throw new ApiAuthorizationException('JWT token not provided');
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
index eb1ca9bc..9228bb2d 100644
--- a/application/api/ApiUtils.php
+++ b/application/api/ApiUtils.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Api; 3namespace Shaarli\Api;
3 4
4use Shaarli\Api\Exceptions\ApiAuthorizationException; 5use Shaarli\Api\Exceptions\ApiAuthorizationException;
@@ -27,7 +28,7 @@ class ApiUtils
27 throw new ApiAuthorizationException('Malformed JWT token'); 28 throw new ApiAuthorizationException('Malformed JWT token');
28 } 29 }
29 30
30 $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true)); 31 $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
31 if ($parts[2] != $genSign) { 32 if ($parts[2] != $genSign) {
32 throw new ApiAuthorizationException('Invalid JWT signature'); 33 throw new ApiAuthorizationException('Invalid JWT signature');
33 } 34 }
@@ -42,7 +43,8 @@ class ApiUtils
42 throw new ApiAuthorizationException('Invalid JWT payload'); 43 throw new ApiAuthorizationException('Invalid JWT payload');
43 } 44 }
44 45
45 if (empty($payload->iat) 46 if (
47 empty($payload->iat)
46 || $payload->iat > time() 48 || $payload->iat > time()
47 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION 49 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
48 ) { 50 ) {
@@ -89,13 +91,17 @@ class ApiUtils
89 * If no URL is provided, it will generate a local note URL. 91 * If no URL is provided, it will generate a local note URL.
90 * If no title is provided, it will use the URL as title. 92 * If no title is provided, it will use the URL as title.
91 * 93 *
92 * @param array|null $input Request Link. 94 * @param array|null $input Request Link.
93 * @param bool $defaultPrivate Setting defined if a bookmark is private by default. 95 * @param bool $defaultPrivate Setting defined if a bookmark is private by default.
96 * @param string $tagsSeparator Tags separator loaded from the config file.
94 * 97 *
95 * @return Bookmark instance. 98 * @return Bookmark instance.
96 */ 99 */
97 public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark 100 public static function buildBookmarkFromRequest(
98 { 101 ?array $input,
102 bool $defaultPrivate,
103 string $tagsSeparator
104 ): Bookmark {
99 $bookmark = new Bookmark(); 105 $bookmark = new Bookmark();
100 $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; 106 $url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
101 if (isset($input['private'])) { 107 if (isset($input['private'])) {
@@ -107,6 +113,15 @@ class ApiUtils
107 $bookmark->setTitle(! empty($input['title']) ? $input['title'] : ''); 113 $bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
108 $bookmark->setUrl($url); 114 $bookmark->setUrl($url);
109 $bookmark->setDescription(! empty($input['description']) ? $input['description'] : ''); 115 $bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
116
117 // Be permissive with provided tags format
118 if (is_string($input['tags'] ?? null)) {
119 $input['tags'] = tags_str2array($input['tags'], $tagsSeparator);
120 }
121 if (is_array($input['tags'] ?? null) && count($input['tags']) === 1 && is_string($input['tags'][0])) {
122 $input['tags'] = tags_str2array($input['tags'][0], $tagsSeparator);
123 }
124
110 $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); 125 $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
111 $bookmark->setPrivate($private); 126 $bookmark->setPrivate($private);
112 127
diff --git a/application/api/controllers/HistoryController.php b/application/api/controllers/HistoryController.php
index 505647a9..d83a3a25 100644
--- a/application/api/controllers/HistoryController.php
+++ b/application/api/controllers/HistoryController.php
@@ -1,6 +1,5 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Api\Controllers; 3namespace Shaarli\Api\Controllers;
5 4
6use Shaarli\Api\Exceptions\ApiBadParametersException; 5use Shaarli\Api\Exceptions\ApiBadParametersException;
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
index 12f6b2f0..ae7db93e 100644
--- a/application/api/controllers/Info.php
+++ b/application/api/controllers/Info.php
@@ -29,13 +29,13 @@ class Info extends ApiController
29 $info = [ 29 $info = [
30 'global_counter' => $this->bookmarkService->count(), 30 'global_counter' => $this->bookmarkService->count(),
31 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE), 31 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
32 'settings' => array( 32 'settings' => [
33 'title' => $this->conf->get('general.title', 'Shaarli'), 33 'title' => $this->conf->get('general.title', 'Shaarli'),
34 'header_link' => $this->conf->get('general.header_link', '?'), 34 'header_link' => $this->conf->get('general.header_link', '?'),
35 'timezone' => $this->conf->get('general.timezone', 'UTC'), 35 'timezone' => $this->conf->get('general.timezone', 'UTC'),
36 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []), 36 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
37 'default_private_links' => $this->conf->get('privacy.default_private_links', false), 37 'default_private_links' => $this->conf->get('privacy.default_private_links', false),
38 ), 38 ],
39 ]; 39 ];
40 40
41 return $response->withJson($info, 200, $this->jsonStyle); 41 return $response->withJson($info, 200, $this->jsonStyle);
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
index 73a1b84e..b83b2260 100644
--- a/application/api/controllers/Links.php
+++ b/application/api/controllers/Links.php
@@ -117,9 +117,14 @@ class Links extends ApiController
117 public function postLink($request, $response) 117 public function postLink($request, $response)
118 { 118 {
119 $data = (array) ($request->getParsedBody() ?? []); 119 $data = (array) ($request->getParsedBody() ?? []);
120 $bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); 120 $bookmark = ApiUtils::buildBookmarkFromRequest(
121 $data,
122 $this->conf->get('privacy.default_private_links'),
123 $this->conf->get('general.tags_separator', ' ')
124 );
121 // duplicate by URL, return 409 Conflict 125 // duplicate by URL, return 409 Conflict
122 if (! empty($bookmark->getUrl()) 126 if (
127 ! empty($bookmark->getUrl())
123 && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) 128 && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
124 ) { 129 ) {
125 return $response->withJson( 130 return $response->withJson(
@@ -131,7 +136,7 @@ class Links extends ApiController
131 136
132 $this->bookmarkService->add($bookmark); 137 $this->bookmarkService->add($bookmark);
133 $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment'])); 138 $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
134 $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]); 139 $redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
135 return $response->withAddedHeader('Location', $redirect) 140 return $response->withAddedHeader('Location', $redirect)
136 ->withJson($out, 201, $this->jsonStyle); 141 ->withJson($out, 201, $this->jsonStyle);
137 } 142 }
@@ -157,9 +162,14 @@ class Links extends ApiController
157 $index = index_url($this->ci['environment']); 162 $index = index_url($this->ci['environment']);
158 $data = $request->getParsedBody(); 163 $data = $request->getParsedBody();
159 164
160 $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); 165 $requestBookmark = ApiUtils::buildBookmarkFromRequest(
166 $data,
167 $this->conf->get('privacy.default_private_links'),
168 $this->conf->get('general.tags_separator', ' ')
169 );
161 // duplicate URL on a different link, return 409 Conflict 170 // duplicate URL on a different link, return 409 Conflict
162 if (! empty($requestBookmark->getUrl()) 171 if (
172 ! empty($requestBookmark->getUrl())
163 && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) 173 && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
164 && $dup->getId() != $id 174 && $dup->getId() != $id
165 ) { 175 ) {
diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php
index 0e3f4776..c77e9eea 100644
--- a/application/api/exceptions/ApiAuthorizationException.php
+++ b/application/api/exceptions/ApiAuthorizationException.php
@@ -28,7 +28,7 @@ class ApiAuthorizationException extends ApiException
28 */ 28 */
29 public function setMessage($message) 29 public function setMessage($message)
30 { 30 {
31 $original = $this->debug === true ? ': '. $this->getMessage() : ''; 31 $original = $this->debug === true ? ': ' . $this->getMessage() : '';
32 $this->message = $message . $original; 32 $this->message = $message . $original;
33 } 33 }
34} 34}
diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php
index d6b66323..7deafb96 100644
--- a/application/api/exceptions/ApiException.php
+++ b/application/api/exceptions/ApiException.php
@@ -44,7 +44,7 @@ abstract class ApiException extends \Exception
44 } 44 }
45 return [ 45 return [
46 'message' => $this->getMessage(), 46 'message' => $this->getMessage(),
47 'stacktrace' => get_class($this) .': '. $this->getTraceAsString() 47 'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
48 ]; 48 ];
49 } 49 }
50 50
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
index ea565d1f..4238ef25 100644
--- a/application/bookmark/Bookmark.php
+++ b/application/bookmark/Bookmark.php
@@ -19,7 +19,7 @@ use Shaarli\Bookmark\Exception\InvalidBookmarkException;
19class Bookmark 19class Bookmark
20{ 20{
21 /** @var string Date format used in string (former ID format) */ 21 /** @var string Date format used in string (former ID format) */
22 const LINK_DATE_FORMAT = 'Ymd_His'; 22 public const LINK_DATE_FORMAT = 'Ymd_His';
23 23
24 /** @var int Bookmark ID */ 24 /** @var int Bookmark ID */
25 protected $id; 25 protected $id;
@@ -60,11 +60,13 @@ class Bookmark
60 /** 60 /**
61 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format. 61 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
62 * 62 *
63 * @param array $data 63 * @param array $data
64 * @param string $tagsSeparator Tags separator loaded from the config file.
65 * This is a context data, and it should *never* be stored in the Bookmark object.
64 * 66 *
65 * @return $this 67 * @return $this
66 */ 68 */
67 public function fromArray(array $data): Bookmark 69 public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
68 { 70 {
69 $this->id = $data['id'] ?? null; 71 $this->id = $data['id'] ?? null;
70 $this->shortUrl = $data['shorturl'] ?? null; 72 $this->shortUrl = $data['shorturl'] ?? null;
@@ -77,7 +79,7 @@ class Bookmark
77 if (is_array($data['tags'])) { 79 if (is_array($data['tags'])) {
78 $this->tags = $data['tags']; 80 $this->tags = $data['tags'];
79 } else { 81 } else {
80 $this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY); 82 $this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
81 } 83 }
82 if (! empty($data['updated'])) { 84 if (! empty($data['updated'])) {
83 $this->updated = $data['updated']; 85 $this->updated = $data['updated'];
@@ -104,7 +106,8 @@ class Bookmark
104 */ 106 */
105 public function validate(): void 107 public function validate(): void
106 { 108 {
107 if ($this->id === null 109 if (
110 $this->id === null
108 || ! is_int($this->id) 111 || ! is_int($this->id)
109 || empty($this->shortUrl) 112 || empty($this->shortUrl)
110 || empty($this->created) 113 || empty($this->created)
@@ -112,7 +115,7 @@ class Bookmark
112 throw new InvalidBookmarkException($this); 115 throw new InvalidBookmarkException($this);
113 } 116 }
114 if (empty($this->url)) { 117 if (empty($this->url)) {
115 $this->url = '/shaare/'. $this->shortUrl; 118 $this->url = '/shaare/' . $this->shortUrl;
116 } 119 }
117 if (empty($this->title)) { 120 if (empty($this->title)) {
118 $this->title = $this->url; 121 $this->title = $this->url;
@@ -348,7 +351,12 @@ class Bookmark
348 */ 351 */
349 public function setTags(?array $tags): Bookmark 352 public function setTags(?array $tags): Bookmark
350 { 353 {
351 $this->setTagsString(implode(' ', $tags ?? [])); 354 $this->tags = array_map(
355 function (string $tag): string {
356 return $tag[0] === '-' ? substr($tag, 1) : $tag;
357 },
358 tags_filter($tags, ' ')
359 );
352 360
353 return $this; 361 return $this;
354 } 362 }
@@ -378,6 +386,24 @@ class Bookmark
378 } 386 }
379 387
380 /** 388 /**
389 * Return true if:
390 * - the bookmark's thumbnail is not already set to false (= not found)
391 * - it's not a note
392 * - it's an HTTP(S) link
393 * - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
394 *
395 * @return bool True if the bookmark's thumbnail needs to be retrieved.
396 */
397 public function shouldUpdateThumbnail(): bool
398 {
399 return $this->thumbnail !== false
400 && !$this->isNote()
401 && startsWith(strtolower($this->url), 'http')
402 && (null === $this->thumbnail || !is_file($this->thumbnail))
403 ;
404 }
405
406 /**
381 * Get the Sticky. 407 * Get the Sticky.
382 * 408 *
383 * @return bool 409 * @return bool
@@ -402,11 +428,13 @@ class Bookmark
402 } 428 }
403 429
404 /** 430 /**
405 * @return string Bookmark's tags as a string, separated by a space 431 * @param string $separator Tags separator loaded from the config file.
432 *
433 * @return string Bookmark's tags as a string, separated by a separator
406 */ 434 */
407 public function getTagsString(): string 435 public function getTagsString(string $separator = ' '): string
408 { 436 {
409 return implode(' ', $this->getTags()); 437 return tags_array2str($this->getTags(), $separator);
410 } 438 }
411 439
412 /** 440 /**
@@ -426,19 +454,13 @@ class Bookmark
426 * - trailing dash in tags will be removed 454 * - trailing dash in tags will be removed
427 * 455 *
428 * @param string|null $tags 456 * @param string|null $tags
457 * @param string $separator Tags separator loaded from the config file.
429 * 458 *
430 * @return $this 459 * @return $this
431 */ 460 */
432 public function setTagsString(?string $tags): Bookmark 461 public function setTagsString(?string $tags, string $separator = ' '): Bookmark
433 { 462 {
434 // Remove first '-' char in tags. 463 $this->setTags(tags_str2array($tags, $separator));
435 $tags = preg_replace('/(^| )\-/', '$1', $tags ?? '');
436 // Explode all tags separted by spaces or commas
437 $tags = preg_split('/[\s,]+/', $tags);
438 // Remove eventual empty values
439 $tags = array_values(array_filter($tags));
440
441 $this->tags = $tags;
442 464
443 return $this; 465 return $this;
444 } 466 }
@@ -489,7 +511,7 @@ class Bookmark
489 */ 511 */
490 public function renameTag(string $fromTag, string $toTag): void 512 public function renameTag(string $fromTag, string $toTag): void
491 { 513 {
492 if (($pos = array_search($fromTag, $this->tags)) !== false) { 514 if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
493 $this->tags[$pos] = trim($toTag); 515 $this->tags[$pos] = trim($toTag);
494 } 516 }
495 } 517 }
@@ -501,7 +523,7 @@ class Bookmark
501 */ 523 */
502 public function deleteTag(string $tag): void 524 public function deleteTag(string $tag): void
503 { 525 {
504 if (($pos = array_search($tag, $this->tags)) !== false) { 526 if (($pos = array_search($tag, $this->tags ?? [])) !== false) {
505 unset($this->tags[$pos]); 527 unset($this->tags[$pos]);
506 $this->tags = array_values($this->tags); 528 $this->tags = array_values($this->tags);
507 } 529 }
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
index 67bb3b73..b9328116 100644
--- a/application/bookmark/BookmarkArray.php
+++ b/application/bookmark/BookmarkArray.php
@@ -72,7 +72,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
72 */ 72 */
73 public function offsetSet($offset, $value) 73 public function offsetSet($offset, $value)
74 { 74 {
75 if (! $value instanceof Bookmark 75 if (
76 ! $value instanceof Bookmark
76 || $value->getId() === null || empty($value->getUrl()) 77 || $value->getId() === null || empty($value->getUrl())
77 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) 78 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
78 || $offset !== null && $offset !== $value->getId() 79 || $offset !== null && $offset !== $value->getId()
@@ -222,7 +223,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
222 */ 223 */
223 public function getByUrl(string $url): ?Bookmark 224 public function getByUrl(string $url): ?Bookmark
224 { 225 {
225 if (! empty($url) 226 if (
227 ! empty($url)
226 && isset($this->urls[$url]) 228 && isset($this->urls[$url])
227 && isset($this->bookmarks[$this->urls[$url]]) 229 && isset($this->bookmarks[$this->urls[$url]])
228 ) { 230 ) {
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
index eb7899bf..6666a251 100644
--- a/application/bookmark/BookmarkFileService.php
+++ b/application/bookmark/BookmarkFileService.php
@@ -69,7 +69,7 @@ class BookmarkFileService implements BookmarkServiceInterface
69 } else { 69 } else {
70 try { 70 try {
71 $this->bookmarks = $this->bookmarksIO->read(); 71 $this->bookmarks = $this->bookmarksIO->read();
72 } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { 72 } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
73 $this->bookmarks = new BookmarkArray(); 73 $this->bookmarks = new BookmarkArray();
74 74
75 if ($this->isLoggedIn) { 75 if ($this->isLoggedIn) {
@@ -85,25 +85,29 @@ class BookmarkFileService implements BookmarkServiceInterface
85 if (! $this->bookmarks instanceof BookmarkArray) { 85 if (! $this->bookmarks instanceof BookmarkArray) {
86 $this->migrate(); 86 $this->migrate();
87 exit( 87 exit(
88 'Your data store has been migrated, please reload the page.'. PHP_EOL . 88 'Your data store has been migrated, please reload the page.' . PHP_EOL .
89 'If this message keeps showing up, please delete data/updates.txt file.' 89 'If this message keeps showing up, please delete data/updates.txt file.'
90 ); 90 );
91 } 91 }
92 } 92 }
93 93
94 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks); 94 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf);
95 } 95 }
96 96
97 /** 97 /**
98 * @inheritDoc 98 * @inheritDoc
99 */ 99 */
100 public function findByHash(string $hash): Bookmark 100 public function findByHash(string $hash, string $privateKey = null): Bookmark
101 { 101 {
102 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); 102 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
103 // PHP 7.3 introduced array_key_first() to avoid this hack 103 // PHP 7.3 introduced array_key_first() to avoid this hack
104 $first = reset($bookmark); 104 $first = reset($bookmark);
105 if (! $this->isLoggedIn && $first->isPrivate()) { 105 if (
106 throw new Exception('Not authorized'); 106 !$this->isLoggedIn
107 && $first->isPrivate()
108 && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
109 ) {
110 throw new BookmarkNotFoundException();
107 } 111 }
108 112
109 return $first; 113 return $first;
@@ -162,7 +166,8 @@ class BookmarkFileService implements BookmarkServiceInterface
162 } 166 }
163 167
164 $bookmark = $this->bookmarks[$id]; 168 $bookmark = $this->bookmarks[$id];
165 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') 169 if (
170 ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
166 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') 171 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
167 ) { 172 ) {
168 throw new Exception('Unauthorized'); 173 throw new Exception('Unauthorized');
@@ -262,7 +267,8 @@ class BookmarkFileService implements BookmarkServiceInterface
262 } 267 }
263 268
264 $bookmark = $this->bookmarks[$id]; 269 $bookmark = $this->bookmarks[$id];
265 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') 270 if (
271 ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
266 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') 272 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
267 ) { 273 ) {
268 return false; 274 return false;
@@ -304,7 +310,8 @@ class BookmarkFileService implements BookmarkServiceInterface
304 $caseMapping = []; 310 $caseMapping = [];
305 foreach ($bookmarks as $bookmark) { 311 foreach ($bookmarks as $bookmark) {
306 foreach ($bookmark->getTags() as $tag) { 312 foreach ($bookmark->getTags() as $tag) {
307 if (empty($tag) 313 if (
314 empty($tag)
308 || (! $this->isLoggedIn && startsWith($tag, '.')) 315 || (! $this->isLoggedIn && startsWith($tag, '.'))
309 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG 316 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
310 || in_array($tag, $filteringTags, true) 317 || in_array($tag, $filteringTags, true)
@@ -340,26 +347,42 @@ class BookmarkFileService implements BookmarkServiceInterface
340 /** 347 /**
341 * @inheritDoc 348 * @inheritDoc
342 */ 349 */
343 public function days(): array 350 public function findByDate(
344 { 351 \DateTimeInterface $from,
345 $bookmarkDays = []; 352 \DateTimeInterface $to,
346 foreach ($this->search() as $bookmark) { 353 ?\DateTimeInterface &$previous,
347 $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; 354 ?\DateTimeInterface &$next
355 ): array {
356 $out = [];
357 $previous = null;
358 $next = null;
359
360 foreach ($this->search([], null, false, false, true) as $bookmark) {
361 if ($to < $bookmark->getCreated()) {
362 $next = $bookmark->getCreated();
363 } elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
364 $out[] = $bookmark;
365 } else {
366 if ($previous !== null) {
367 break;
368 }
369 $previous = $bookmark->getCreated();
370 }
348 } 371 }
349 $bookmarkDays = array_keys($bookmarkDays);
350 sort($bookmarkDays);
351 372
352 return array_map('strval', $bookmarkDays); 373 return $out;
353 } 374 }
354 375
355 /** 376 /**
356 * @inheritDoc 377 * @inheritDoc
357 */ 378 */
358 public function filterDay(string $request) 379 public function getLatest(): ?Bookmark
359 { 380 {
360 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; 381 foreach ($this->search([], null, false, false, true) as $bookmark) {
382 return $bookmark;
383 }
361 384
362 return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility); 385 return null;
363 } 386 }
364 387
365 /** 388 /**
@@ -386,14 +409,14 @@ class BookmarkFileService implements BookmarkServiceInterface
386 false 409 false
387 ); 410 );
388 $updater = new LegacyUpdater( 411 $updater = new LegacyUpdater(
389 UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), 412 UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
390 $bookmarkDb, 413 $bookmarkDb,
391 $this->conf, 414 $this->conf,
392 true 415 true
393 ); 416 );
394 $newUpdates = $updater->update(); 417 $newUpdates = $updater->update();
395 if (! empty($newUpdates)) { 418 if (! empty($newUpdates)) {
396 UpdaterUtils::write_updates_file( 419 UpdaterUtils::writeUpdatesFile(
397 $this->conf->get('resource.updates'), 420 $this->conf->get('resource.updates'),
398 $updater->getDoneUpdates() 421 $updater->getDoneUpdates()
399 ); 422 );
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
index c79386ea..db83c51c 100644
--- a/application/bookmark/BookmarkFilter.php
+++ b/application/bookmark/BookmarkFilter.php
@@ -6,6 +6,7 @@ namespace Shaarli\Bookmark;
6 6
7use Exception; 7use Exception;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Config\ConfigManager;
9 10
10/** 11/**
11 * Class LinkFilter. 12 * Class LinkFilter.
@@ -58,12 +59,16 @@ class BookmarkFilter
58 */ 59 */
59 private $bookmarks; 60 private $bookmarks;
60 61
62 /** @var ConfigManager */
63 protected $conf;
64
61 /** 65 /**
62 * @param Bookmark[] $bookmarks initialization. 66 * @param Bookmark[] $bookmarks initialization.
63 */ 67 */
64 public function __construct($bookmarks) 68 public function __construct($bookmarks, ConfigManager $conf)
65 { 69 {
66 $this->bookmarks = $bookmarks; 70 $this->bookmarks = $bookmarks;
71 $this->conf = $conf;
67 } 72 }
68 73
69 /** 74 /**
@@ -107,10 +112,14 @@ class BookmarkFilter
107 $filtered = $this->bookmarks; 112 $filtered = $this->bookmarks;
108 } 113 }
109 if (!empty($request[0])) { 114 if (!empty($request[0])) {
110 $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); 115 $filtered = (new BookmarkFilter($filtered, $this->conf))
116 ->filterTags($request[0], $casesensitive, $visibility)
117 ;
111 } 118 }
112 if (!empty($request[1])) { 119 if (!empty($request[1])) {
113 $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); 120 $filtered = (new BookmarkFilter($filtered, $this->conf))
121 ->filterFulltext($request[1], $visibility)
122 ;
114 } 123 }
115 return $filtered; 124 return $filtered;
116 case self::$FILTER_TEXT: 125 case self::$FILTER_TEXT:
@@ -141,7 +150,7 @@ class BookmarkFilter
141 return $this->bookmarks; 150 return $this->bookmarks;
142 } 151 }
143 152
144 $out = array(); 153 $out = [];
145 foreach ($this->bookmarks as $key => $value) { 154 foreach ($this->bookmarks as $key => $value) {
146 if ($value->isPrivate() && $visibility === 'private') { 155 if ($value->isPrivate() && $visibility === 'private') {
147 $out[$key] = $value; 156 $out[$key] = $value;
@@ -280,8 +289,9 @@ class BookmarkFilter
280 * 289 *
281 * @return string generated regex fragment 290 * @return string generated regex fragment
282 */ 291 */
283 private static function tag2regex(string $tag): string 292 protected function tag2regex(string $tag): string
284 { 293 {
294 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
285 $len = strlen($tag); 295 $len = strlen($tag);
286 if (!$len || $tag === "-" || $tag === "*") { 296 if (!$len || $tag === "-" || $tag === "*") {
287 // nothing to search, return empty regex 297 // nothing to search, return empty regex
@@ -295,12 +305,13 @@ class BookmarkFilter
295 $i = 0; // start at first character 305 $i = 0; // start at first character
296 $regex = '(?='; // use positive lookahead 306 $regex = '(?='; // use positive lookahead
297 } 307 }
298 $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning 308 // before tag may only be the separator or the beginning
309 $regex .= '.*(?:^|' . $tagsSeparator . ')';
299 // iterate over string, separating it into placeholder and content 310 // iterate over string, separating it into placeholder and content
300 for (; $i < $len; $i++) { 311 for (; $i < $len; $i++) {
301 if ($tag[$i] === '*') { 312 if ($tag[$i] === '*') {
302 // placeholder found 313 // placeholder found
303 $regex .= '[^ ]*?'; 314 $regex .= '[^' . $tagsSeparator . ']*?';
304 } else { 315 } else {
305 // regular characters 316 // regular characters
306 $offset = strpos($tag, '*', $i); 317 $offset = strpos($tag, '*', $i);
@@ -316,7 +327,8 @@ class BookmarkFilter
316 $i = $offset; 327 $i = $offset;
317 } 328 }
318 } 329 }
319 $regex .= '(?:$| ))'; // after the tag may only be a space or the end 330 // after the tag may only be the separator or the end
331 $regex .= '(?:$|' . $tagsSeparator . '))';
320 return $regex; 332 return $regex;
321 } 333 }
322 334
@@ -334,14 +346,15 @@ class BookmarkFilter
334 */ 346 */
335 public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all') 347 public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
336 { 348 {
349 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
337 // get single tags (we may get passed an array, even though the docs say different) 350 // get single tags (we may get passed an array, even though the docs say different)
338 $inputTags = $tags; 351 $inputTags = $tags;
339 if (!is_array($tags)) { 352 if (!is_array($tags)) {
340 // we got an input string, split tags 353 // we got an input string, split tags
341 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); 354 $inputTags = tags_str2array($inputTags, $tagsSeparator);
342 } 355 }
343 356
344 if (!count($inputTags)) { 357 if (count($inputTags) === 0) {
345 // no input tags 358 // no input tags
346 return $this->noFilter($visibility); 359 return $this->noFilter($visibility);
347 } 360 }
@@ -358,7 +371,7 @@ class BookmarkFilter
358 } 371 }
359 372
360 // build regex from all tags 373 // build regex from all tags
361 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; 374 $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/';
362 if (!$casesensitive) { 375 if (!$casesensitive) {
363 // make regex case insensitive 376 // make regex case insensitive
364 $re .= 'i'; 377 $re .= 'i';
@@ -378,10 +391,11 @@ class BookmarkFilter
378 continue; 391 continue;
379 } 392 }
380 } 393 }
381 $search = $link->getTagsString(); // build search string, start with tags of current link 394 // build search string, start with tags of current link
395 $search = $link->getTagsString($tagsSeparator);
382 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { 396 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
383 // description given and at least one possible tag found 397 // description given and at least one possible tag found
384 $descTags = array(); 398 $descTags = [];
385 // find all tags in the form of #tag in the description 399 // find all tags in the form of #tag in the description
386 preg_match_all( 400 preg_match_all(
387 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', 401 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -390,9 +404,9 @@ class BookmarkFilter
390 ); 404 );
391 if (count($descTags[1])) { 405 if (count($descTags[1])) {
392 // there were some tags in the description, add them to the search string 406 // there were some tags in the description, add them to the search string
393 $search .= ' ' . implode(' ', $descTags[1]); 407 $search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
394 } 408 }
395 }; 409 }
396 // match regular expression with search string 410 // match regular expression with search string
397 if (!preg_match($re, $search)) { 411 if (!preg_match($re, $search)) {
398 // this entry does _not_ match our regex 412 // this entry does _not_ match our regex
@@ -422,7 +436,7 @@ class BookmarkFilter
422 } 436 }
423 } 437 }
424 438
425 if (empty(trim($link->getTagsString()))) { 439 if (empty($link->getTags())) {
426 $filtered[$key] = $link; 440 $filtered[$key] = $link;
427 } 441 }
428 } 442 }
@@ -537,10 +551,11 @@ class BookmarkFilter
537 */ 551 */
538 protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string 552 protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
539 { 553 {
540 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; 554 $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
541 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; 555 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
542 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; 556 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
543 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; 557 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\';
558 $content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') . '\\';
544 559
545 $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())]; 560 $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
546 $nextField = $lengths['title']['end'] + 1; 561 $nextField = $lengths['title']['end'] + 1;
@@ -548,7 +563,7 @@ class BookmarkFilter
548 $nextField = $lengths['description']['end'] + 1; 563 $nextField = $lengths['description']['end'] + 1;
549 $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())]; 564 $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
550 $nextField = $lengths['url']['end'] + 1; 565 $nextField = $lengths['url']['end'] + 1;
551 $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getTagsString())]; 566 $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
552 567
553 return $content; 568 return $content;
554 } 569 }
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php
index f40fa476..8439d470 100644
--- a/application/bookmark/BookmarkIO.php
+++ b/application/bookmark/BookmarkIO.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
6 6
7use malkusch\lock\exception\LockAcquireException;
7use malkusch\lock\mutex\Mutex; 8use malkusch\lock\mutex\Mutex;
8use malkusch\lock\mutex\NoMutex; 9use malkusch\lock\mutex\NoMutex;
9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; 10use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
@@ -80,7 +81,7 @@ class BookmarkIO
80 } 81 }
81 82
82 $content = null; 83 $content = null;
83 $this->mutex->synchronized(function () use (&$content) { 84 $this->synchronized(function () use (&$content) {
84 $content = file_get_contents($this->datastore); 85 $content = file_get_contents($this->datastore);
85 }); 86 });
86 87
@@ -112,18 +113,35 @@ class BookmarkIO
112 if (is_file($this->datastore) && !is_writeable($this->datastore)) { 113 if (is_file($this->datastore) && !is_writeable($this->datastore)) {
113 // The datastore exists but is not writeable 114 // The datastore exists but is not writeable
114 throw new NotWritableDataStoreException($this->datastore); 115 throw new NotWritableDataStoreException($this->datastore);
115 } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { 116 } elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
116 // The datastore does not exist and its parent directory is not writeable 117 // The datastore does not exist and its parent directory is not writeable
117 throw new NotWritableDataStoreException(dirname($this->datastore)); 118 throw new NotWritableDataStoreException(dirname($this->datastore));
118 } 119 }
119 120
120 $data = self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix; 121 $data = self::$phpPrefix . base64_encode(gzdeflate(serialize($links))) . self::$phpSuffix;
121 122
122 $this->mutex->synchronized(function () use ($data) { 123 $this->synchronized(function () use ($data) {
123 file_put_contents( 124 file_put_contents(
124 $this->datastore, 125 $this->datastore,
125 $data 126 $data
126 ); 127 );
127 }); 128 });
128 } 129 }
130
131 /**
132 * Wrapper applying mutex to provided function.
133 * If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
134 *
135 * @see https://github.com/shaarli/Shaarli/issues/1650
136 *
137 * @param callable $function
138 */
139 protected function synchronized(callable $function): void
140 {
141 try {
142 $this->mutex->synchronized($function);
143 } catch (LockAcquireException $exception) {
144 $function();
145 }
146 }
129} 147}
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php
index 04b996f3..8ab5c441 100644
--- a/application/bookmark/BookmarkInitializer.php
+++ b/application/bookmark/BookmarkInitializer.php
@@ -13,6 +13,9 @@ namespace Shaarli\Bookmark;
13 * To prevent data corruption, it does not overwrite existing bookmarks, 13 * To prevent data corruption, it does not overwrite existing bookmarks,
14 * even though there should not be any. 14 * even though there should not be any.
15 * 15 *
16 * We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
17 * @phpcs:disable Generic.Files.LineLength.TooLong
18 *
16 * @package Shaarli\Bookmark 19 * @package Shaarli\Bookmark
17 */ 20 */
18class BookmarkInitializer 21class BookmarkInitializer
@@ -36,10 +39,10 @@ class BookmarkInitializer
36 public function initialize(): void 39 public function initialize(): void
37 { 40 {
38 $bookmark = new Bookmark(); 41 $bookmark = new Bookmark();
39 $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)')); 42 $bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
40 $bookmark->setUrl('https://vimeo.com/153493904'); 43 $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
41 $bookmark->setDescription(t( 44 $bookmark->setDescription(t(
42'Shaarli will automatically pick up the thumbnail for links to a variety of websites. 45 'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
43 46
44Explore your new Shaarli instance by trying out controls and menus. 47Explore your new Shaarli instance by trying out controls and menus.
45Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. 48Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
@@ -54,7 +57,7 @@ Now you can edit or delete the default shaares.
54 $bookmark = new Bookmark(); 57 $bookmark = new Bookmark();
55 $bookmark->setTitle(t('Note: Shaare descriptions')); 58 $bookmark->setTitle(t('Note: Shaare descriptions'));
56 $bookmark->setDescription(t( 59 $bookmark->setDescription(t(
57'Adding a shaare without entering a URL creates a text-only "note" post such as this one. 60 'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
58This note is private, so you are the only one able to see it while logged in. 61This note is private, so you are the only one able to see it while logged in.
59 62
60You can use this to keep notes, post articles, code snippets, and much more. 63You can use this to keep notes, post articles, code snippets, and much more.
@@ -91,7 +94,7 @@ Markdown also supports tables:
91 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') 94 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
92 ); 95 );
93 $bookmark->setDescription(t( 96 $bookmark->setDescription(t(
94'Welcome to Shaarli! 97 'Welcome to Shaarli!
95 98
96Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. 99Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
97You can add a description to your bookmarks, such as this one, and tag them. 100You can add a description to your bookmarks, such as this one, and tag them.
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
index 37a54d03..08cdbb4e 100644
--- a/application/bookmark/BookmarkServiceInterface.php
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -20,13 +20,14 @@ interface BookmarkServiceInterface
20 /** 20 /**
21 * Find a bookmark by hash 21 * Find a bookmark by hash
22 * 22 *
23 * @param string $hash 23 * @param string $hash Bookmark's hash
24 * @param string|null $privateKey Optional key used to access private links while logged out
24 * 25 *
25 * @return Bookmark 26 * @return Bookmark
26 * 27 *
27 * @throws \Exception 28 * @throws \Exception
28 */ 29 */
29 public function findByHash(string $hash): Bookmark; 30 public function findByHash(string $hash, string $privateKey = null);
30 31
31 /** 32 /**
32 * @param $url 33 * @param $url
@@ -155,22 +156,29 @@ interface BookmarkServiceInterface
155 public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array; 156 public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
156 157
157 /** 158 /**
158 * Returns the list of days containing articles (oldest first) 159 * Return a list of bookmark matching provided period of time.
160 * It also update directly previous and next date outside of given period found in the datastore.
159 * 161 *
160 * @return array containing days (in format YYYYMMDD). 162 * @param \DateTimeInterface $from Starting date.
163 * @param \DateTimeInterface $to Ending date.
164 * @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
165 * @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to.
166 *
167 * @return array List of bookmarks matching provided period of time.
161 */ 168 */
162 public function days(): array; 169 public function findByDate(
170 \DateTimeInterface $from,
171 \DateTimeInterface $to,
172 ?\DateTimeInterface &$previous,
173 ?\DateTimeInterface &$next
174 ): array;
163 175
164 /** 176 /**
165 * Returns the list of articles for a given day. 177 * Returns the latest bookmark by creation date.
166 *
167 * @param string $request day to filter. Format: YYYYMMDD.
168 * 178 *
169 * @return Bookmark[] list of shaare found. 179 * @return Bookmark|null Found Bookmark or null if the datastore is empty.
170 *
171 * @throws BookmarkNotFoundException
172 */ 180 */
173 public function filterDay(string $request); 181 public function getLatest(): ?Bookmark;
174 182
175 /** 183 /**
176 * Creates the default database after a fresh install. 184 * Creates the default database after a fresh install.
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index faf5dbfd..0ab2d213 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -67,17 +67,20 @@ function html_extract_tag($tag, $html)
67 $propertiesKey = ['property', 'name', 'itemprop']; 67 $propertiesKey = ['property', 'name', 'itemprop'];
68 $properties = implode('|', $propertiesKey); 68 $properties = implode('|', $propertiesKey);
69 // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"' 69 // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
70 $orCondition = '["\']?(?:og:)?'. $tag .'["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]'; 70 $orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
71 // Try to retrieve OpenGraph image. 71 // Support quotes in double quoted content, and the other way around
72 $ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#'; 72 $content = 'content=(["\'])((?:(?!\1).)*)\1';
73 // Try to retrieve OpenGraph tag.
74 $ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
73 // If the attributes are not in the order property => content (e.g. Github) 75 // If the attributes are not in the order property => content (e.g. Github)
74 // New regex to keep this readable... more or less. 76 // New regex to keep this readable... more or less.
75 $ogRegexReverse = '#<meta[^>]+content=["\'](.*?)["\'][^>]+(?:'. $properties .')=(?:'. $orCondition .').*?>#'; 77 $ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
76 78
77 if (preg_match($ogRegex, $html, $matches) > 0 79 if (
80 preg_match($ogRegex, $html, $matches) > 0
78 || preg_match($ogRegexReverse, $html, $matches) > 0 81 || preg_match($ogRegexReverse, $html, $matches) > 0
79 ) { 82 ) {
80 return $matches[1]; 83 return $matches[2];
81 } 84 }
82 85
83 return false; 86 return false;
@@ -116,7 +119,7 @@ function hashtag_autolink($description, $indexUrl = '')
116 * \p{Mn} - any non marking space (accents, umlauts, etc) 119 * \p{Mn} - any non marking space (accents, umlauts, etc)
117 */ 120 */
118 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 121 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
119 $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>'; 122 $replacement = '$1<a href="' . $indexUrl . './add-tag/$2" title="Hashtag $2">#$2</a>';
120 return preg_replace($regex, $replacement, $description); 123 return preg_replace($regex, $replacement, $description);
121} 124}
122 125
@@ -138,12 +141,17 @@ function space2nbsp($text)
138 * 141 *
139 * @param string $description shaare's description. 142 * @param string $description shaare's description.
140 * @param string $indexUrl URL to Shaarli's index. 143 * @param string $indexUrl URL to Shaarli's index.
141 144 * @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
145 *
142 * @return string formatted description. 146 * @return string formatted description.
143 */ 147 */
144function format_description($description, $indexUrl = '') 148function format_description($description, $indexUrl = '', $autolink = true)
145{ 149{
146 return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl))); 150 if ($autolink) {
151 $description = hashtag_autolink(text2clickable($description), $indexUrl);
152 }
153
154 return nl2br(space2nbsp($description));
147} 155}
148 156
149/** 157/**
@@ -171,3 +179,49 @@ function is_note($linkUrl)
171{ 179{
172 return isset($linkUrl[0]) && $linkUrl[0] === '?'; 180 return isset($linkUrl[0]) && $linkUrl[0] === '?';
173} 181}
182
183/**
184 * Extract an array of tags from a given tag string, with provided separator.
185 *
186 * @param string|null $tags String containing a list of tags separated by $separator.
187 * @param string $separator Shaarli's default: ' ' (whitespace)
188 *
189 * @return array List of tags
190 */
191function tags_str2array(?string $tags, string $separator): array
192{
193 // For whitespaces, we use the special \s regex character
194 $separator = $separator === ' ' ? '\s' : $separator;
195
196 return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY);
197}
198
199/**
200 * Return a tag string with provided separator from a list of tags.
201 * Note that given array is clean up by tags_filter().
202 *
203 * @param array|null $tags List of tags
204 * @param string $separator
205 *
206 * @return string
207 */
208function tags_array2str(?array $tags, string $separator): string
209{
210 return implode($separator, tags_filter($tags, $separator));
211}
212
213/**
214 * Clean an array of tags: trim + remove empty entries
215 *
216 * @param array|null $tags List of tags
217 * @param string $separator
218 *
219 * @return array
220 */
221function tags_filter(?array $tags, string $separator): array
222{
223 $trimDefault = " \t\n\r\0\x0B";
224 return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
225 return trim($entry, $trimDefault . $separator);
226 }, $tags ?? [])));
227}
diff --git a/application/bookmark/exception/BookmarkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php
index 827a3d35..a91d1efa 100644
--- a/application/bookmark/exception/BookmarkNotFoundException.php
+++ b/application/bookmark/exception/BookmarkNotFoundException.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
3 4
4use Exception; 5use Exception;
diff --git a/application/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php
index cd48c1e6..16a98470 100644
--- a/application/bookmark/exception/EmptyDataStoreException.php
+++ b/application/bookmark/exception/EmptyDataStoreException.php
@@ -1,7 +1,7 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
5 4
6 5class EmptyDataStoreException extends \Exception
7class EmptyDataStoreException extends \Exception {} 6{
7}
diff --git a/application/bookmark/exception/InvalidBookmarkException.php b/application/bookmark/exception/InvalidBookmarkException.php
index 10c84a6d..fe184f8c 100644
--- a/application/bookmark/exception/InvalidBookmarkException.php
+++ b/application/bookmark/exception/InvalidBookmarkException.php
@@ -16,14 +16,14 @@ class InvalidBookmarkException extends \Exception
16 } else { 16 } else {
17 $created = 'Not a DateTime object'; 17 $created = 'Not a DateTime object';
18 } 18 }
19 $this->message = 'This bookmark is not valid'. PHP_EOL; 19 $this->message = 'This bookmark is not valid' . PHP_EOL;
20 $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL; 20 $this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL;
21 $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL; 21 $this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL;
22 $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL; 22 $this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL;
23 $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL; 23 $this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL;
24 $this->message .= ' - Created: '. $created . PHP_EOL; 24 $this->message .= ' - Created: ' . $created . PHP_EOL;
25 } else { 25 } else {
26 $this->message = 'The provided data is not a bookmark'. PHP_EOL; 26 $this->message = 'The provided data is not a bookmark' . PHP_EOL;
27 $this->message .= var_export($bookmark, true); 27 $this->message .= var_export($bookmark, true);
28 } 28 }
29 } 29 }
diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php
index 95f34b50..df91f3bc 100644
--- a/application/bookmark/exception/NotWritableDataStoreException.php
+++ b/application/bookmark/exception/NotWritableDataStoreException.php
@@ -1,9 +1,7 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
5 4
6
7class NotWritableDataStoreException extends \Exception 5class NotWritableDataStoreException extends \Exception
8{ 6{
9 /** 7 /**
@@ -13,7 +11,7 @@ class NotWritableDataStoreException extends \Exception
13 */ 11 */
14 public function __construct($dataStore) 12 public function __construct($dataStore)
15 { 13 {
16 $this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '. 14 $this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' .
17 'Your data might be corrupted, or your file isn\'t readable.'; 15 'Your data might be corrupted, or your file isn\'t readable.';
18 } 16 }
19} 17}
diff --git a/application/config/ConfigIO.php b/application/config/ConfigIO.php
index 3efe5b6f..a623bc8b 100644
--- a/application/config/ConfigIO.php
+++ b/application/config/ConfigIO.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Config; 3namespace Shaarli\Config;
3 4
4/** 5/**
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php
index c0c0dab9..23b22269 100644
--- a/application/config/ConfigJson.php
+++ b/application/config/ConfigJson.php
@@ -19,7 +19,7 @@ class ConfigJson implements ConfigIO
19 $data = file_get_contents($filepath); 19 $data = file_get_contents($filepath);
20 $data = str_replace(self::getPhpHeaders(), '', $data); 20 $data = str_replace(self::getPhpHeaders(), '', $data);
21 $data = str_replace(self::getPhpSuffix(), '', $data); 21 $data = str_replace(self::getPhpSuffix(), '', $data);
22 $data = json_decode($data, true); 22 $data = json_decode(trim($data), true);
23 if ($data === null) { 23 if ($data === null) {
24 $errorCode = json_last_error(); 24 $errorCode = json_last_error();
25 $error = sprintf( 25 $error = sprintf(
@@ -73,7 +73,7 @@ class ConfigJson implements ConfigIO
73 */ 73 */
74 public static function getPhpHeaders() 74 public static function getPhpHeaders()
75 { 75 {
76 return '<?php /*'. PHP_EOL; 76 return '<?php /*';
77 } 77 }
78 78
79 /** 79 /**
@@ -85,6 +85,6 @@ class ConfigJson implements ConfigIO
85 */ 85 */
86 public static function getPhpSuffix() 86 public static function getPhpSuffix()
87 { 87 {
88 return PHP_EOL . '*/ ?>'; 88 return '*/ ?>';
89 } 89 }
90} 90}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index 4c98be30..717a038f 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Config; 3namespace Shaarli\Config;
3 4
4use Shaarli\Config\Exception\MissingFieldConfigException; 5use Shaarli\Config\Exception\MissingFieldConfigException;
@@ -20,7 +21,7 @@ class ConfigManager
20 */ 21 */
21 protected static $NOT_FOUND = 'NOT_FOUND'; 22 protected static $NOT_FOUND = 'NOT_FOUND';
22 23
23 public static $DEFAULT_PLUGINS = array('qrcode'); 24 public static $DEFAULT_PLUGINS = ['qrcode'];
24 25
25 /** 26 /**
26 * @var string Config folder. 27 * @var string Config folder.
@@ -133,7 +134,7 @@ class ConfigManager
133 public function set($setting, $value, $write = false, $isLoggedIn = false) 134 public function set($setting, $value, $write = false, $isLoggedIn = false)
134 { 135 {
135 if (empty($setting) || ! is_string($setting)) { 136 if (empty($setting) || ! is_string($setting)) {
136 throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); 137 throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
137 } 138 }
138 139
139 // During the ConfigIO transition, map legacy settings to the new ones. 140 // During the ConfigIO transition, map legacy settings to the new ones.
@@ -160,7 +161,7 @@ class ConfigManager
160 public function remove($setting, $write = false, $isLoggedIn = false) 161 public function remove($setting, $write = false, $isLoggedIn = false)
161 { 162 {
162 if (empty($setting) || ! is_string($setting)) { 163 if (empty($setting) || ! is_string($setting)) {
163 throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); 164 throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
164 } 165 }
165 166
166 // During the ConfigIO transition, map legacy settings to the new ones. 167 // During the ConfigIO transition, map legacy settings to the new ones.
@@ -213,7 +214,7 @@ class ConfigManager
213 public function write($isLoggedIn) 214 public function write($isLoggedIn)
214 { 215 {
215 // These fields are required in configuration. 216 // These fields are required in configuration.
216 $mandatoryFields = array( 217 $mandatoryFields = [
217 'credentials.login', 218 'credentials.login',
218 'credentials.hash', 219 'credentials.hash',
219 'credentials.salt', 220 'credentials.salt',
@@ -222,7 +223,7 @@ class ConfigManager
222 'general.title', 223 'general.title',
223 'general.header_link', 224 'general.header_link',
224 'privacy.default_private_links', 225 'privacy.default_private_links',
225 ); 226 ];
226 227
227 // Only logged in user can alter config. 228 // Only logged in user can alter config.
228 if (is_file($this->getConfigFileExt()) && !$isLoggedIn) { 229 if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
@@ -366,10 +367,12 @@ class ConfigManager
366 $this->setEmpty('general.links_per_page', 20); 367 $this->setEmpty('general.links_per_page', 20);
367 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); 368 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
368 $this->setEmpty('general.default_note_title', 'Note: '); 369 $this->setEmpty('general.default_note_title', 'Note: ');
369 $this->setEmpty('general.retrieve_description', false); 370 $this->setEmpty('general.retrieve_description', true);
371 $this->setEmpty('general.enable_async_metadata', true);
372 $this->setEmpty('general.tags_separator', ' ');
370 373
371 $this->setEmpty('updates.check_updates', false); 374 $this->setEmpty('updates.check_updates', true);
372 $this->setEmpty('updates.check_updates_branch', 'stable'); 375 $this->setEmpty('updates.check_updates_branch', 'latest');
373 $this->setEmpty('updates.check_updates_interval', 86400); 376 $this->setEmpty('updates.check_updates_interval', 86400);
374 377
375 $this->setEmpty('feed.rss_permalinks', true); 378 $this->setEmpty('feed.rss_permalinks', true);
@@ -390,7 +393,7 @@ class ConfigManager
390 $this->setEmpty('translation.mode', 'php'); 393 $this->setEmpty('translation.mode', 'php');
391 $this->setEmpty('translation.extensions', []); 394 $this->setEmpty('translation.extensions', []);
392 395
393 $this->setEmpty('plugins', array()); 396 $this->setEmpty('plugins', []);
394 397
395 $this->setEmpty('formatter', 'markdown'); 398 $this->setEmpty('formatter', 'markdown');
396 } 399 }
diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php
index cad34594..53d6a7a3 100644
--- a/application/config/ConfigPhp.php
+++ b/application/config/ConfigPhp.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Config; 3namespace Shaarli\Config;
3 4
4/** 5/**
@@ -12,7 +13,7 @@ class ConfigPhp implements ConfigIO
12 /** 13 /**
13 * @var array List of config key without group. 14 * @var array List of config key without group.
14 */ 15 */
15 public static $ROOT_KEYS = array( 16 public static $ROOT_KEYS = [
16 'login', 17 'login',
17 'hash', 18 'hash',
18 'salt', 19 'salt',
@@ -22,7 +23,7 @@ class ConfigPhp implements ConfigIO
22 'redirector', 23 'redirector',
23 'disablesessionprotection', 24 'disablesessionprotection',
24 'privateLinkByDefault', 25 'privateLinkByDefault',
25 ); 26 ];
26 27
27 /** 28 /**
28 * Map legacy config keys with the new ones. 29 * Map legacy config keys with the new ones.
@@ -31,7 +32,7 @@ class ConfigPhp implements ConfigIO
31 * 32 *
32 * @var array current key => legacy key. 33 * @var array current key => legacy key.
33 */ 34 */
34 public static $LEGACY_KEYS_MAPPING = array( 35 public static $LEGACY_KEYS_MAPPING = [
35 'credentials.login' => 'login', 36 'credentials.login' => 'login',
36 'credentials.hash' => 'hash', 37 'credentials.hash' => 'hash',
37 'credentials.salt' => 'salt', 38 'credentials.salt' => 'salt',
@@ -68,7 +69,7 @@ class ConfigPhp implements ConfigIO
68 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS', 69 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
69 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS', 70 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
70 'security.open_shaarli' => 'config.OPEN_SHAARLI', 71 'security.open_shaarli' => 'config.OPEN_SHAARLI',
71 ); 72 ];
72 73
73 /** 74 /**
74 * @inheritdoc 75 * @inheritdoc
@@ -76,12 +77,12 @@ class ConfigPhp implements ConfigIO
76 public function read($filepath) 77 public function read($filepath)
77 { 78 {
78 if (! file_exists($filepath) || ! is_readable($filepath)) { 79 if (! file_exists($filepath) || ! is_readable($filepath)) {
79 return array(); 80 return [];
80 } 81 }
81 82
82 include $filepath; 83 include $filepath;
83 84
84 $out = array(); 85 $out = [];
85 foreach (self::$ROOT_KEYS as $key) { 86 foreach (self::$ROOT_KEYS as $key) {
86 $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : ''; 87 $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
87 } 88 }
@@ -95,7 +96,7 @@ class ConfigPhp implements ConfigIO
95 */ 96 */
96 public function write($filepath, $conf) 97 public function write($filepath, $conf)
97 { 98 {
98 $configStr = '<?php '. PHP_EOL; 99 $configStr = '<?php ' . PHP_EOL;
99 foreach (self::$ROOT_KEYS as $key) { 100 foreach (self::$ROOT_KEYS as $key) {
100 if (isset($conf[$key])) { 101 if (isset($conf[$key])) {
101 $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL; 102 $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
@@ -106,8 +107,8 @@ class ConfigPhp implements ConfigIO
106 foreach ($conf['config'] as $key => $value) { 107 foreach ($conf['config'] as $key => $value) {
107 $configStr .= '$GLOBALS[\'config\'][\'' 108 $configStr .= '$GLOBALS[\'config\'][\''
108 . $key 109 . $key
109 .'\'] = ' 110 . '\'] = '
110 .var_export($conf['config'][$key], true).';' 111 . var_export($conf['config'][$key], true) . ';'
111 . PHP_EOL; 112 . PHP_EOL;
112 } 113 }
113 114
@@ -115,18 +116,19 @@ class ConfigPhp implements ConfigIO
115 foreach ($conf['plugins'] as $key => $value) { 116 foreach ($conf['plugins'] as $key => $value) {
116 $configStr .= '$GLOBALS[\'plugins\'][\'' 117 $configStr .= '$GLOBALS[\'plugins\'][\''
117 . $key 118 . $key
118 .'\'] = ' 119 . '\'] = '
119 .var_export($conf['plugins'][$key], true).';' 120 . var_export($conf['plugins'][$key], true) . ';'
120 . PHP_EOL; 121 . PHP_EOL;
121 } 122 }
122 } 123 }
123 124
124 if (!file_put_contents($filepath, $configStr) 125 if (
126 !file_put_contents($filepath, $configStr)
125 || strcmp(file_get_contents($filepath), $configStr) != 0 127 || strcmp(file_get_contents($filepath), $configStr) != 0
126 ) { 128 ) {
127 throw new \Shaarli\Exceptions\IOException( 129 throw new \Shaarli\Exceptions\IOException(
128 $filepath, 130 $filepath,
129 t('Shaarli could not create the config file. '. 131 t('Shaarli could not create the config file. ' .
130 'Please make sure Shaarli has the right to write in the folder is it installed in.') 132 'Please make sure Shaarli has the right to write in the folder is it installed in.')
131 ); 133 );
132 } 134 }
diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php
index ea8dfbda..6cadef12 100644
--- a/application/config/ConfigPlugin.php
+++ b/application/config/ConfigPlugin.php
@@ -39,8 +39,8 @@ function save_plugin_config($formData)
39 throw new PluginConfigOrderException(); 39 throw new PluginConfigOrderException();
40 } 40 }
41 41
42 $plugins = array(); 42 $plugins = [];
43 $newEnabledPlugins = array(); 43 $newEnabledPlugins = [];
44 foreach ($formData as $key => $data) { 44 foreach ($formData as $key => $data) {
45 if (startsWith($key, 'order')) { 45 if (startsWith($key, 'order')) {
46 continue; 46 continue;
@@ -62,7 +62,7 @@ function save_plugin_config($formData)
62 throw new PluginConfigOrderException(); 62 throw new PluginConfigOrderException();
63 } 63 }
64 64
65 $finalPlugins = array(); 65 $finalPlugins = [];
66 // Make plugins order continuous. 66 // Make plugins order continuous.
67 foreach ($plugins as $plugin) { 67 foreach ($plugins as $plugin) {
68 $finalPlugins[] = $plugin; 68 $finalPlugins[] = $plugin;
@@ -81,7 +81,7 @@ function save_plugin_config($formData)
81 */ 81 */
82function validate_plugin_order($formData) 82function validate_plugin_order($formData)
83{ 83{
84 $orders = array(); 84 $orders = [];
85 foreach ($formData as $key => $value) { 85 foreach ($formData as $key => $value) {
86 // No duplicate order allowed. 86 // No duplicate order allowed.
87 if (in_array($value, $orders, true)) { 87 if (in_array($value, $orders, true)) {
diff --git a/application/config/exception/MissingFieldConfigException.php b/application/config/exception/MissingFieldConfigException.php
index 9e0a9359..a5f4356a 100644
--- a/application/config/exception/MissingFieldConfigException.php
+++ b/application/config/exception/MissingFieldConfigException.php
@@ -1,6 +1,5 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Config\Exception; 3namespace Shaarli\Config\Exception;
5 4
6/** 5/**
diff --git a/application/config/exception/UnauthorizedConfigException.php b/application/config/exception/UnauthorizedConfigException.php
index 72311fae..b041c6e3 100644
--- a/application/config/exception/UnauthorizedConfigException.php
+++ b/application/config/exception/UnauthorizedConfigException.php
@@ -1,6 +1,5 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Config\Exception; 3namespace Shaarli\Config\Exception;
5 4
6/** 5/**
diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php
index c21d58dd..6d69a880 100644
--- a/application/container/ContainerBuilder.php
+++ b/application/container/ContainerBuilder.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
5namespace Shaarli\Container; 5namespace Shaarli\Container;
6 6
7use malkusch\lock\mutex\FlockMutex; 7use malkusch\lock\mutex\FlockMutex;
8use Psr\Log\LoggerInterface;
8use Shaarli\Bookmark\BookmarkFileService; 9use Shaarli\Bookmark\BookmarkFileService;
9use Shaarli\Bookmark\BookmarkServiceInterface; 10use Shaarli\Bookmark\BookmarkServiceInterface;
10use Shaarli\Config\ConfigManager; 11use Shaarli\Config\ConfigManager;
@@ -14,6 +15,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
14use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; 15use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
15use Shaarli\History; 16use Shaarli\History;
16use Shaarli\Http\HttpAccess; 17use Shaarli\Http\HttpAccess;
18use Shaarli\Http\MetadataRetriever;
17use Shaarli\Netscape\NetscapeBookmarkUtils; 19use Shaarli\Netscape\NetscapeBookmarkUtils;
18use Shaarli\Plugin\PluginManager; 20use Shaarli\Plugin\PluginManager;
19use Shaarli\Render\PageBuilder; 21use Shaarli\Render\PageBuilder;
@@ -48,6 +50,12 @@ class ContainerBuilder
48 /** @var LoginManager */ 50 /** @var LoginManager */
49 protected $login; 51 protected $login;
50 52
53 /** @var PluginManager */
54 protected $pluginManager;
55
56 /** @var LoggerInterface */
57 protected $logger;
58
51 /** @var string|null */ 59 /** @var string|null */
52 protected $basePath = null; 60 protected $basePath = null;
53 61
@@ -55,12 +63,16 @@ class ContainerBuilder
55 ConfigManager $conf, 63 ConfigManager $conf,
56 SessionManager $session, 64 SessionManager $session,
57 CookieManager $cookieManager, 65 CookieManager $cookieManager,
58 LoginManager $login 66 LoginManager $login,
67 PluginManager $pluginManager,
68 LoggerInterface $logger
59 ) { 69 ) {
60 $this->conf = $conf; 70 $this->conf = $conf;
61 $this->session = $session; 71 $this->session = $session;
62 $this->login = $login; 72 $this->login = $login;
63 $this->cookieManager = $cookieManager; 73 $this->cookieManager = $cookieManager;
74 $this->pluginManager = $pluginManager;
75 $this->logger = $logger;
64 } 76 }
65 77
66 public function build(): ShaarliContainer 78 public function build(): ShaarliContainer
@@ -71,11 +83,10 @@ class ContainerBuilder
71 $container['sessionManager'] = $this->session; 83 $container['sessionManager'] = $this->session;
72 $container['cookieManager'] = $this->cookieManager; 84 $container['cookieManager'] = $this->cookieManager;
73 $container['loginManager'] = $this->login; 85 $container['loginManager'] = $this->login;
86 $container['pluginManager'] = $this->pluginManager;
87 $container['logger'] = $this->logger;
74 $container['basePath'] = $this->basePath; 88 $container['basePath'] = $this->basePath;
75 89
76 $container['plugins'] = function (ShaarliContainer $container): PluginManager {
77 return new PluginManager($container->conf);
78 };
79 90
80 $container['history'] = function (ShaarliContainer $container): History { 91 $container['history'] = function (ShaarliContainer $container): History {
81 return new History($container->conf->get('resource.history')); 92 return new History($container->conf->get('resource.history'));
@@ -90,24 +101,21 @@ class ContainerBuilder
90 ); 101 );
91 }; 102 };
92 103
104 $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
105 return new MetadataRetriever($container->conf, $container->httpAccess);
106 };
107
93 $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { 108 $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
94 return new PageBuilder( 109 return new PageBuilder(
95 $container->conf, 110 $container->conf,
96 $container->sessionManager->getSession(), 111 $container->sessionManager->getSession(),
112 $container->logger,
97 $container->bookmarkService, 113 $container->bookmarkService,
98 $container->sessionManager->generateToken(), 114 $container->sessionManager->generateToken(),
99 $container->loginManager->isLoggedIn() 115 $container->loginManager->isLoggedIn()
100 ); 116 );
101 }; 117 };
102 118
103 $container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
104 $pluginManager = new PluginManager($container->conf);
105
106 $pluginManager->load($container->conf->get('general.enabled_plugins'));
107
108 return $pluginManager;
109 };
110
111 $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { 119 $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
112 return new FormatterFactory( 120 return new FormatterFactory(
113 $container->conf, 121 $container->conf,
@@ -145,7 +153,7 @@ class ContainerBuilder
145 153
146 $container['updater'] = function (ShaarliContainer $container): Updater { 154 $container['updater'] = function (ShaarliContainer $container): Updater {
147 return new Updater( 155 return new Updater(
148 UpdaterUtils::read_updates_file($container->conf->get('resource.updates')), 156 UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
149 $container->bookmarkService, 157 $container->bookmarkService,
150 $container->conf, 158 $container->conf,
151 $container->loginManager->isLoggedIn() 159 $container->loginManager->isLoggedIn()
diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php
index 66e669aa..3e5bd252 100644
--- a/application/container/ShaarliContainer.php
+++ b/application/container/ShaarliContainer.php
@@ -4,12 +4,14 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Container; 5namespace Shaarli\Container;
6 6
7use Psr\Log\LoggerInterface;
7use Shaarli\Bookmark\BookmarkServiceInterface; 8use Shaarli\Bookmark\BookmarkServiceInterface;
8use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
9use Shaarli\Feed\FeedBuilder; 10use Shaarli\Feed\FeedBuilder;
10use Shaarli\Formatter\FormatterFactory; 11use Shaarli\Formatter\FormatterFactory;
11use Shaarli\History; 12use Shaarli\History;
12use Shaarli\Http\HttpAccess; 13use Shaarli\Http\HttpAccess;
14use Shaarli\Http\MetadataRetriever;
13use Shaarli\Netscape\NetscapeBookmarkUtils; 15use Shaarli\Netscape\NetscapeBookmarkUtils;
14use Shaarli\Plugin\PluginManager; 16use Shaarli\Plugin\PluginManager;
15use Shaarli\Render\PageBuilder; 17use Shaarli\Render\PageBuilder;
@@ -35,6 +37,8 @@ use Slim\Container;
35 * @property History $history 37 * @property History $history
36 * @property HttpAccess $httpAccess 38 * @property HttpAccess $httpAccess
37 * @property LoginManager $loginManager 39 * @property LoginManager $loginManager
40 * @property LoggerInterface $logger
41 * @property MetadataRetriever $metadataRetriever
38 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils 42 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils
39 * @property callable $notFoundHandler Overrides default Slim exception display 43 * @property callable $notFoundHandler Overrides default Slim exception display
40 * @property PageBuilder $pageBuilder 44 * @property PageBuilder $pageBuilder
diff --git a/application/exceptions/IOException.php b/application/exceptions/IOException.php
index 2aa25e5c..c1a9ffbe 100644
--- a/application/exceptions/IOException.php
+++ b/application/exceptions/IOException.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Exceptions; 3namespace Shaarli\Exceptions;
3 4
4use Exception; 5use Exception;
diff --git a/application/feed/CachedPage.php b/application/feed/CachedPage.php
index d809bdd9..c23c200f 100644
--- a/application/feed/CachedPage.php
+++ b/application/feed/CachedPage.php
@@ -1,34 +1,43 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Feed; 5namespace Shaarli\Feed;
4 6
7use DatePeriod;
8
5/** 9/**
6 * Simple cache system, mainly for the RSS/ATOM feeds 10 * Simple cache system, mainly for the RSS/ATOM feeds
7 */ 11 */
8class CachedPage 12class CachedPage
9{ 13{
10 // Directory containing page caches 14 /** Directory containing page caches */
11 private $cacheDir; 15 protected $cacheDir;
16
17 /** Should this URL be cached (boolean)? */
18 protected $shouldBeCached;
12 19
13 // Should this URL be cached (boolean)? 20 /** Name of the cache file for this URL */
14 private $shouldBeCached; 21 protected $filename;
15 22
16 // Name of the cache file for this URL 23 /** @var DatePeriod|null Optionally specify a period of time for cache validity */
17 private $filename; 24 protected $validityPeriod;
18 25
19 /** 26 /**
20 * Creates a new CachedPage 27 * Creates a new CachedPage
21 * 28 *
22 * @param string $cacheDir page cache directory 29 * @param string $cacheDir page cache directory
23 * @param string $url page URL 30 * @param string $url page URL
24 * @param bool $shouldBeCached whether this page needs to be cached 31 * @param bool $shouldBeCached whether this page needs to be cached
32 * @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
25 */ 33 */
26 public function __construct($cacheDir, $url, $shouldBeCached) 34 public function __construct($cacheDir, $url, $shouldBeCached, ?DatePeriod $validityPeriod)
27 { 35 {
28 // TODO: check write access to the cache directory 36 // TODO: check write access to the cache directory
29 $this->cacheDir = $cacheDir; 37 $this->cacheDir = $cacheDir;
30 $this->filename = $this->cacheDir . '/' . sha1($url) . '.cache'; 38 $this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
31 $this->shouldBeCached = $shouldBeCached; 39 $this->shouldBeCached = $shouldBeCached;
40 $this->validityPeriod = $validityPeriod;
32 } 41 }
33 42
34 /** 43 /**
@@ -41,10 +50,20 @@ class CachedPage
41 if (!$this->shouldBeCached) { 50 if (!$this->shouldBeCached) {
42 return null; 51 return null;
43 } 52 }
44 if (is_file($this->filename)) { 53 if (!is_file($this->filename)) {
45 return file_get_contents($this->filename); 54 return null;
55 }
56 if ($this->validityPeriod !== null) {
57 $cacheDate = \DateTime::createFromFormat('U', (string) filemtime($this->filename));
58 if (
59 $cacheDate < $this->validityPeriod->getStartDate()
60 || $cacheDate > $this->validityPeriod->getEndDate()
61 ) {
62 return null;
63 }
46 } 64 }
47 return null; 65
66 return file_get_contents($this->filename);
48 } 67 }
49 68
50 /** 69 /**
diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php
index f70fce4f..ed62af26 100644
--- a/application/feed/FeedBuilder.php
+++ b/application/feed/FeedBuilder.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Feed; 3namespace Shaarli\Feed;
3 4
4use DateTime; 5use DateTime;
@@ -107,14 +108,14 @@ class FeedBuilder
107 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput); 108 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput);
108 109
109 // Can't use array_keys() because $link is a LinkDB instance and not a real array. 110 // Can't use array_keys() because $link is a LinkDB instance and not a real array.
110 $keys = array(); 111 $keys = [];
111 foreach ($linksToDisplay as $key => $value) { 112 foreach ($linksToDisplay as $key => $value) {
112 $keys[] = $key; 113 $keys[] = $key;
113 } 114 }
114 115
115 $pageaddr = escape(index_url($this->serverInfo)); 116 $pageaddr = escape(index_url($this->serverInfo));
116 $this->formatter->addContextData('index_url', $pageaddr); 117 $this->formatter->addContextData('index_url', $pageaddr);
117 $linkDisplayed = array(); 118 $linkDisplayed = [];
118 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { 119 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
119 $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr); 120 $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr);
120 } 121 }
@@ -176,9 +177,9 @@ class FeedBuilder
176 $data = $this->formatter->format($link); 177 $data = $this->formatter->format($link);
177 $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl']; 178 $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
178 if ($this->usePermalinks === true) { 179 if ($this->usePermalinks === true) {
179 $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>'; 180 $permalink = '<a href="' . $data['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
180 } else { 181 } else {
181 $permalink = '<a href="'. $data['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>'; 182 $permalink = '<a href="' . $data['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
182 } 183 }
183 $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink; 184 $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
184 185
diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php
index d58a5e39..7e0afafc 100644
--- a/application/formatter/BookmarkDefaultFormatter.php
+++ b/application/formatter/BookmarkDefaultFormatter.php
@@ -12,8 +12,8 @@ namespace Shaarli\Formatter;
12 */ 12 */
13class BookmarkDefaultFormatter extends BookmarkFormatter 13class BookmarkDefaultFormatter extends BookmarkFormatter
14{ 14{
15 const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT'; 15 protected const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
16 const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|'; 16 protected const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
17 17
18 /** 18 /**
19 * @inheritdoc 19 * @inheritdoc
@@ -46,8 +46,13 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
46 $bookmark->getDescription() ?? '', 46 $bookmark->getDescription() ?? '',
47 $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? [] 47 $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
48 ); 48 );
49 $description = format_description(
50 escape($description),
51 $indexUrl,
52 $this->conf->get('formatter_settings.autolink', true)
53 );
49 54
50 return $this->replaceTokens(format_description(escape($description), $indexUrl)); 55 return $this->replaceTokens($description);
51 } 56 }
52 57
53 /** 58 /**
@@ -63,15 +68,16 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
63 */ 68 */
64 protected function formatTagListHtml($bookmark) 69 protected function formatTagListHtml($bookmark)
65 { 70 {
71 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
66 if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) { 72 if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
67 return $this->formatTagList($bookmark); 73 return $this->formatTagList($bookmark);
68 } 74 }
69 75
70 $tags = $this->tokenizeSearchHighlightField( 76 $tags = $this->tokenizeSearchHighlightField(
71 $bookmark->getTagsString(), 77 $bookmark->getTagsString($tagsSeparator),
72 $bookmark->getAdditionalContentEntry('search_highlight')['tags'] 78 $bookmark->getAdditionalContentEntry('search_highlight')['tags']
73 ); 79 );
74 $tags = $this->filterTagList(explode(' ', $tags)); 80 $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
75 $tags = escape($tags); 81 $tags = escape($tags);
76 $tags = $this->replaceTokensArray($tags); 82 $tags = $this->replaceTokensArray($tags);
77 83
@@ -83,7 +89,7 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
83 */ 89 */
84 protected function formatTagString($bookmark) 90 protected function formatTagString($bookmark)
85 { 91 {
86 return implode(' ', $this->formatTagList($bookmark)); 92 return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
87 } 93 }
88 94
89 /** 95 /**
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php
index e1b7f705..124ce78b 100644
--- a/application/formatter/BookmarkFormatter.php
+++ b/application/formatter/BookmarkFormatter.php
@@ -267,7 +267,7 @@ abstract class BookmarkFormatter
267 */ 267 */
268 protected function formatTagString($bookmark) 268 protected function formatTagString($bookmark)
269 { 269 {
270 return implode(' ', $this->formatTagList($bookmark)); 270 return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
271 } 271 }
272 272
273 /** 273 /**
@@ -351,6 +351,7 @@ abstract class BookmarkFormatter
351 351
352 /** 352 /**
353 * Format tag list, e.g. remove private tags if the user is not logged in. 353 * Format tag list, e.g. remove private tags if the user is not logged in.
354 * TODO: this method is called multiple time to format tags, the result should be cached.
354 * 355 *
355 * @param array $tags 356 * @param array $tags
356 * 357 *
diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php
index f7714be9..ee4e8dca 100644
--- a/application/formatter/BookmarkMarkdownFormatter.php
+++ b/application/formatter/BookmarkMarkdownFormatter.php
@@ -16,7 +16,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
16 /** 16 /**
17 * When this tag is present in a bookmark, its description should not be processed with Markdown 17 * When this tag is present in a bookmark, its description should not be processed with Markdown
18 */ 18 */
19 const NO_MD_TAG = 'nomarkdown'; 19 public const NO_MD_TAG = 'nomarkdown';
20 20
21 /** @var \Parsedown instance */ 21 /** @var \Parsedown instance */
22 protected $parsedown; 22 protected $parsedown;
@@ -71,7 +71,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
71 $processedDescription = $this->replaceTokens($processedDescription); 71 $processedDescription = $this->replaceTokens($processedDescription);
72 72
73 if (!empty($processedDescription)) { 73 if (!empty($processedDescription)) {
74 $processedDescription = '<div class="markdown">'. $processedDescription . '</div>'; 74 $processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
75 } 75 }
76 76
77 return $processedDescription; 77 return $processedDescription;
@@ -110,7 +110,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
110 function ($match) use ($allowedProtocols, $indexUrl) { 110 function ($match) use ($allowedProtocols, $indexUrl) {
111 $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : ''; 111 $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
112 $link .= whitelist_protocols($match[1], $allowedProtocols); 112 $link .= whitelist_protocols($match[1], $allowedProtocols);
113 return ']('. $link.')'; 113 return '](' . $link . ')';
114 }, 114 },
115 $description 115 $description
116 ); 116 );
@@ -137,7 +137,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
137 * \p{Mn} - any non marking space (accents, umlauts, etc) 137 * \p{Mn} - any non marking space (accents, umlauts, etc)
138 */ 138 */
139 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 139 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
140 $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)'; 140 $replacement = '$1[#$2](' . $indexUrl . './add-tag/$2)';
141 141
142 $descriptionLines = explode(PHP_EOL, $description); 142 $descriptionLines = explode(PHP_EOL, $description);
143 $descriptionOut = ''; 143 $descriptionOut = '';
@@ -178,17 +178,17 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
178 */ 178 */
179 protected function sanitizeHtml($description) 179 protected function sanitizeHtml($description)
180 { 180 {
181 $escapeTags = array( 181 $escapeTags = [
182 'script', 182 'script',
183 'style', 183 'style',
184 'link', 184 'link',
185 'iframe', 185 'iframe',
186 'frameset', 186 'frameset',
187 'frame', 187 'frame',
188 ); 188 ];
189 foreach ($escapeTags as $tag) { 189 foreach ($escapeTags as $tag) {
190 $description = preg_replace_callback( 190 $description = preg_replace_callback(
191 '#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is', 191 '#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is',
192 function ($match) { 192 function ($match) {
193 return escape($match[0]); 193 return escape($match[0]);
194 }, 194 },
diff --git a/application/formatter/BookmarkRawFormatter.php b/application/formatter/BookmarkRawFormatter.php
index bc372273..4ff07cdf 100644
--- a/application/formatter/BookmarkRawFormatter.php
+++ b/application/formatter/BookmarkRawFormatter.php
@@ -10,4 +10,6 @@ namespace Shaarli\Formatter;
10 * 10 *
11 * @package Shaarli\Formatter 11 * @package Shaarli\Formatter
12 */ 12 */
13class BookmarkRawFormatter extends BookmarkFormatter {} 13class BookmarkRawFormatter extends BookmarkFormatter
14{
15}
diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php
index a029579f..bb865aed 100644
--- a/application/formatter/FormatterFactory.php
+++ b/application/formatter/FormatterFactory.php
@@ -41,7 +41,7 @@ class FormatterFactory
41 public function getFormatter(string $type = null): BookmarkFormatter 41 public function getFormatter(string $type = null): BookmarkFormatter
42 { 42 {
43 $type = $type ? $type : $this->conf->get('formatter', 'default'); 43 $type = $type ? $type : $this->conf->get('formatter', 'default');
44 $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; 44 $className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter';
45 if (!class_exists($className)) { 45 if (!class_exists($className)) {
46 $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter'; 46 $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
47 } 47 }
diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php
index d1aa1399..164217f4 100644
--- a/application/front/ShaarliMiddleware.php
+++ b/application/front/ShaarliMiddleware.php
@@ -42,7 +42,8 @@ class ShaarliMiddleware
42 $this->initBasePath($request); 42 $this->initBasePath($request);
43 43
44 try { 44 try {
45 if (!is_file($this->container->conf->getConfigFileExt()) 45 if (
46 !is_file($this->container->conf->getConfigFileExt())
46 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true) 47 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
47 ) { 48 ) {
48 return $response->withRedirect($this->container->basePath . '/install'); 49 return $response->withRedirect($this->container->basePath . '/install');
@@ -86,7 +87,8 @@ class ShaarliMiddleware
86 */ 87 */
87 protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool 88 protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
88 { 89 {
89 if (// if the user isn't logged in 90 if (
91// if the user isn't logged in
90 !$this->container->loginManager->isLoggedIn() 92 !$this->container->loginManager->isLoggedIn()
91 // and Shaarli doesn't have public content... 93 // and Shaarli doesn't have public content...
92 && $this->container->conf->get('privacy.hide_public_links') 94 && $this->container->conf->get('privacy.hide_public_links')
diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php
index 0ed7ad81..dc421661 100644
--- a/application/front/controller/admin/ConfigureController.php
+++ b/application/front/controller/admin/ConfigureController.php
@@ -51,7 +51,10 @@ class ConfigureController extends ShaarliAdminController
51 $this->assignView('languages', Languages::getAvailableLanguages()); 51 $this->assignView('languages', Languages::getAvailableLanguages());
52 $this->assignView('gd_enabled', extension_loaded('gd')); 52 $this->assignView('gd_enabled', extension_loaded('gd'));
53 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)); 53 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
54 $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 54 $this->assignView(
55 'pagetitle',
56 t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
57 );
55 58
56 return $response->write($this->render(TemplatePage::CONFIGURE)); 59 return $response->write($this->render(TemplatePage::CONFIGURE));
57 } 60 }
@@ -95,12 +98,15 @@ class ConfigureController extends ShaarliAdminController
95 } 98 }
96 99
97 $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE; 100 $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
98 if ($thumbnailsMode !== Thumbnailer::MODE_NONE 101 if (
102 $thumbnailsMode !== Thumbnailer::MODE_NONE
99 && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) 103 && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
100 ) { 104 ) {
101 $this->saveWarningMessage( 105 $this->saveWarningMessage(
102 t('You have enabled or changed thumbnails mode.') . 106 t('You have enabled or changed thumbnails mode.') .
103 '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>' 107 '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
108 t('Please synchronize them.') .
109 '</a>'
104 ); 110 );
105 } 111 }
106 $this->container->conf->set('thumbnails.mode', $thumbnailsMode); 112 $this->container->conf->set('thumbnails.mode', $thumbnailsMode);
diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php
index 2be957fa..f01d7e9b 100644
--- a/application/front/controller/admin/ExportController.php
+++ b/application/front/controller/admin/ExportController.php
@@ -23,7 +23,7 @@ class ExportController extends ShaarliAdminController
23 */ 23 */
24 public function index(Request $request, Response $response): Response 24 public function index(Request $request, Response $response): Response
25 { 25 {
26 $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 26 $this->assignView('pagetitle', t('Export') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
27 27
28 return $response->write($this->render(TemplatePage::EXPORT)); 28 return $response->write($this->render(TemplatePage::EXPORT));
29 } 29 }
@@ -68,7 +68,7 @@ class ExportController extends ShaarliAdminController
68 $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8'); 68 $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
69 $response = $response->withHeader( 69 $response = $response->withHeader(
70 'Content-disposition', 70 'Content-disposition',
71 'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html' 71 'attachment; filename=bookmarks_' . $selection . '_' . $now->format(Bookmark::LINK_DATE_FORMAT) . '.html'
72 ); 72 );
73 73
74 $this->assignView('date', $now->format(DateTime::RFC822)); 74 $this->assignView('date', $now->format(DateTime::RFC822));
diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php
index 758d5ef9..c2ad6a09 100644
--- a/application/front/controller/admin/ImportController.php
+++ b/application/front/controller/admin/ImportController.php
@@ -38,7 +38,7 @@ class ImportController extends ShaarliAdminController
38 true 38 true
39 ) 39 )
40 ); 40 );
41 $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 41 $this->assignView('pagetitle', t('Import') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
42 42
43 return $response->write($this->render(TemplatePage::IMPORT)); 43 return $response->write($this->render(TemplatePage::IMPORT));
44 } 44 }
@@ -64,7 +64,7 @@ class ImportController extends ShaarliAdminController
64 $msg = sprintf( 64 $msg = sprintf(
65 t( 65 t(
66 'The file you are trying to upload is probably bigger than what this webserver can accept' 66 'The file you are trying to upload is probably bigger than what this webserver can accept'
67 .' (%s). Please upload in smaller chunks.' 67 . ' (%s). Please upload in smaller chunks.'
68 ), 68 ),
69 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')) 69 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
70 ); 70 );
diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php
deleted file mode 100644
index bb083486..00000000
--- a/application/front/controller/admin/ManageShaareController.php
+++ /dev/null
@@ -1,371 +0,0 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkMarkdownFormatter;
10use Shaarli\Render\TemplatePage;
11use Shaarli\Thumbnailer;
12use Slim\Http\Request;
13use Slim\Http\Response;
14
15/**
16 * Class PostBookmarkController
17 *
18 * Slim controller used to handle Shaarli create or edit bookmarks.
19 */
20class ManageShaareController extends ShaarliAdminController
21{
22 /**
23 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
24 */
25 public function addShaare(Request $request, Response $response): Response
26 {
27 $this->assignView(
28 'pagetitle',
29 t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
30 );
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34
35 /**
36 * GET /admin/shaare - Displays the bookmark form for creation.
37 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
38 */
39 public function displayCreateForm(Request $request, Response $response): Response
40 {
41 $url = cleanup_url($request->getParam('post'));
42
43 $linkIsNew = false;
44 // Check if URL is not already in database (in this case, we will edit the existing link)
45 $bookmark = $this->container->bookmarkService->findByUrl($url);
46 if (null === $bookmark) {
47 $linkIsNew = true;
48 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
49 $title = $request->getParam('title');
50 $description = $request->getParam('description');
51 $tags = $request->getParam('tags');
52 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
53
54 // If this is an HTTP(S) link, we try go get the page to extract
55 // the title (otherwise we will to straight to the edit form.)
56 if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
57 $retrieveDescription = $this->container->conf->get('general.retrieve_description');
58 // Short timeout to keep the application responsive
59 // The callback will fill $charset and $title with data from the downloaded page.
60 $this->container->httpAccess->getHttpResponse(
61 $url,
62 $this->container->conf->get('general.download_timeout', 30),
63 $this->container->conf->get('general.download_max_size', 4194304),
64 $this->container->httpAccess->getCurlDownloadCallback(
65 $charset,
66 $title,
67 $description,
68 $tags,
69 $retrieveDescription
70 )
71 );
72 if (! empty($title) && strtolower($charset) !== 'utf-8' && mb_check_encoding($charset)) {
73 $title = mb_convert_encoding($title, 'utf-8', $charset);
74 }
75 }
76
77 if (empty($url) && empty($title)) {
78 $title = $this->container->conf->get('general.default_note_title', t('Note: '));
79 }
80
81 $link = [
82 'title' => $title,
83 'url' => $url ?? '',
84 'description' => $description ?? '',
85 'tags' => $tags ?? '',
86 'private' => $private,
87 ];
88 } else {
89 $formatter = $this->container->formatterFactory->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91 }
92
93 return $this->displayForm($link, $linkIsNew, $request, $response);
94 }
95
96 /**
97 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
98 */
99 public function displayEditForm(Request $request, Response $response, array $args): Response
100 {
101 $id = $args['id'] ?? '';
102 try {
103 if (false === ctype_digit($id)) {
104 throw new BookmarkNotFoundException();
105 }
106 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
107 } catch (BookmarkNotFoundException $e) {
108 $this->saveErrorMessage(sprintf(
109 t('Bookmark with identifier %s could not be found.'),
110 $id
111 ));
112
113 return $this->redirect($response, '/');
114 }
115
116 $formatter = $this->container->formatterFactory->getFormatter('raw');
117 $link = $formatter->format($bookmark);
118
119 return $this->displayForm($link, false, $request, $response);
120 }
121
122 /**
123 * POST /admin/shaare
124 */
125 public function save(Request $request, Response $response): Response
126 {
127 $this->checkToken($request);
128
129 // lf_id should only be present if the link exists.
130 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
131 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
132 // Edit
133 $bookmark = $this->container->bookmarkService->get($id);
134 } else {
135 // New link
136 $bookmark = new Bookmark();
137 }
138
139 $bookmark->setTitle($request->getParam('lf_title'));
140 $bookmark->setDescription($request->getParam('lf_description'));
141 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
142 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
143 $bookmark->setTagsString($request->getParam('lf_tags'));
144
145 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
146 && false === $bookmark->isNote()
147 ) {
148 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
149 }
150 $this->container->bookmarkService->addOrSet($bookmark, false);
151
152 // To preserve backward compatibility with 3rd parties, plugins still use arrays
153 $formatter = $this->container->formatterFactory->getFormatter('raw');
154 $data = $formatter->format($bookmark);
155 $this->executePageHooks('save_link', $data);
156
157 $bookmark->fromArray($data);
158 $this->container->bookmarkService->set($bookmark);
159
160 // If we are called from the bookmarklet, we must close the popup:
161 if ($request->getParam('source') === 'bookmarklet') {
162 return $response->write('<script>self.close();</script>');
163 }
164
165 if (!empty($request->getParam('returnurl'))) {
166 $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl'));
167 }
168
169 return $this->redirectFromReferer(
170 $request,
171 $response,
172 ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'],
173 $bookmark->getShortUrl()
174 );
175 }
176
177 /**
178 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
179 */
180 public function deleteBookmark(Request $request, Response $response): Response
181 {
182 $this->checkToken($request);
183
184 $ids = escape(trim($request->getParam('id') ?? ''));
185 if (empty($ids) || strpos($ids, ' ') !== false) {
186 // multiple, space-separated ids provided
187 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
188 } else {
189 $ids = [$ids];
190 }
191
192 // assert at least one id is given
193 if (0 === count($ids)) {
194 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
195
196 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
197 }
198
199 $formatter = $this->container->formatterFactory->getFormatter('raw');
200 $count = 0;
201 foreach ($ids as $id) {
202 try {
203 $bookmark = $this->container->bookmarkService->get((int) $id);
204 } catch (BookmarkNotFoundException $e) {
205 $this->saveErrorMessage(sprintf(
206 t('Bookmark with identifier %s could not be found.'),
207 $id
208 ));
209
210 continue;
211 }
212
213 $data = $formatter->format($bookmark);
214 $this->executePageHooks('delete_link', $data);
215 $this->container->bookmarkService->remove($bookmark, false);
216 ++ $count;
217 }
218
219 if ($count > 0) {
220 $this->container->bookmarkService->save();
221 }
222
223 // If we are called from the bookmarklet, we must close the popup:
224 if ($request->getParam('source') === 'bookmarklet') {
225 return $response->write('<script>self.close();</script>');
226 }
227
228 // Don't redirect to where we were previously because the datastore has changed.
229 return $this->redirect($response, '/');
230 }
231
232 /**
233 * GET /admin/shaare/visibility
234 *
235 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
236 */
237 public function changeVisibility(Request $request, Response $response): Response
238 {
239 $this->checkToken($request);
240
241 $ids = trim(escape($request->getParam('id') ?? ''));
242 if (empty($ids) || strpos($ids, ' ') !== false) {
243 // multiple, space-separated ids provided
244 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
245 } else {
246 // only a single id provided
247 $ids = [$ids];
248 }
249
250 // assert at least one id is given
251 if (0 === count($ids)) {
252 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
253
254 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
255 }
256
257 // assert that the visibility is valid
258 $visibility = $request->getParam('newVisibility');
259 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
260 $this->saveErrorMessage(t('Invalid visibility provided.'));
261
262 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
263 } else {
264 $isPrivate = $visibility === 'private';
265 }
266
267 $formatter = $this->container->formatterFactory->getFormatter('raw');
268 $count = 0;
269
270 foreach ($ids as $id) {
271 try {
272 $bookmark = $this->container->bookmarkService->get((int) $id);
273 } catch (BookmarkNotFoundException $e) {
274 $this->saveErrorMessage(sprintf(
275 t('Bookmark with identifier %s could not be found.'),
276 $id
277 ));
278
279 continue;
280 }
281
282 $bookmark->setPrivate($isPrivate);
283
284 // To preserve backward compatibility with 3rd parties, plugins still use arrays
285 $data = $formatter->format($bookmark);
286 $this->executePageHooks('save_link', $data);
287 $bookmark->fromArray($data);
288
289 $this->container->bookmarkService->set($bookmark, false);
290 ++$count;
291 }
292
293 if ($count > 0) {
294 $this->container->bookmarkService->save();
295 }
296
297 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
298 }
299
300 /**
301 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
302 */
303 public function pinBookmark(Request $request, Response $response, array $args): Response
304 {
305 $this->checkToken($request);
306
307 $id = $args['id'] ?? '';
308 try {
309 if (false === ctype_digit($id)) {
310 throw new BookmarkNotFoundException();
311 }
312 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
313 } catch (BookmarkNotFoundException $e) {
314 $this->saveErrorMessage(sprintf(
315 t('Bookmark with identifier %s could not be found.'),
316 $id
317 ));
318
319 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
320 }
321
322 $formatter = $this->container->formatterFactory->getFormatter('raw');
323
324 $bookmark->setSticky(!$bookmark->isSticky());
325
326 // To preserve backward compatibility with 3rd parties, plugins still use arrays
327 $data = $formatter->format($bookmark);
328 $this->executePageHooks('save_link', $data);
329 $bookmark->fromArray($data);
330
331 $this->container->bookmarkService->set($bookmark);
332
333 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
334 }
335
336 /**
337 * Helper function used to display the shaare form whether it's a new or existing bookmark.
338 *
339 * @param array $link data used in template, either from parameters or from the data store
340 */
341 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
342 {
343 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
344 if ($this->container->conf->get('formatter') === 'markdown') {
345 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
346 }
347
348 $data = escape([
349 'link' => $link,
350 'link_is_new' => $isNew,
351 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
352 'source' => $request->getParam('source') ?? '',
353 'tags' => $tags,
354 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
355 ]);
356
357 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
358
359 foreach ($data as $key => $value) {
360 $this->assignView($key, $value);
361 }
362
363 $editLabel = false === $isNew ? t('Edit') .' ' : '';
364 $this->assignView(
365 'pagetitle',
366 $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
367 );
368
369 return $response->write($this->render(TemplatePage::EDIT_LINK));
370 }
371}
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php
index 2065c3e2..8675a0c5 100644
--- a/application/front/controller/admin/ManageTagController.php
+++ b/application/front/controller/admin/ManageTagController.php
@@ -24,9 +24,15 @@ class ManageTagController extends ShaarliAdminController
24 $fromTag = $request->getParam('fromtag') ?? ''; 24 $fromTag = $request->getParam('fromtag') ?? '';
25 25
26 $this->assignView('fromtag', escape($fromTag)); 26 $this->assignView('fromtag', escape($fromTag));
27 $separator = escape($this->container->conf->get('general.tags_separator', ' '));
28 if ($separator === ' ') {
29 $separator = '&nbsp;';
30 $this->assignView('tags_separator_desc', t('whitespace'));
31 }
32 $this->assignView('tags_separator', $separator);
27 $this->assignView( 33 $this->assignView(
28 'pagetitle', 34 'pagetitle',
29 t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') 35 t('Manage tags') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
30 ); 36 );
31 37
32 return $response->write($this->render(TemplatePage::CHANGE_TAG)); 38 return $response->write($this->render(TemplatePage::CHANGE_TAG));
@@ -81,8 +87,35 @@ class ManageTagController extends ShaarliAdminController
81 87
82 $this->saveSuccessMessage($alert); 88 $this->saveSuccessMessage($alert);
83 89
84 $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag); 90 $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags=' . urlencode($toTag);
85 91
86 return $this->redirect($response, $redirect); 92 return $this->redirect($response, $redirect);
87 } 93 }
94
95 /**
96 * POST /admin/tags/change-separator - Change tag separator
97 */
98 public function changeSeparator(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 $reservedCharacters = ['-', '.', '*'];
103 $newSeparator = $request->getParam('separator');
104 if ($newSeparator === null || mb_strlen($newSeparator) !== 1) {
105 $this->saveErrorMessage(t('Tags separator must be a single character.'));
106 } elseif (in_array($newSeparator, $reservedCharacters, true)) {
107 $reservedCharacters = implode(' ', array_map(function (string $character) {
108 return '<code>' . $character . '</code>';
109 }, $reservedCharacters));
110 $this->saveErrorMessage(
111 t('These characters are reserved and can\'t be used as tags separator: ') . $reservedCharacters
112 );
113 } else {
114 $this->container->conf->set('general.tags_separator', $newSeparator, true, true);
115
116 $this->saveSuccessMessage('Your tags separator setting has been updated!');
117 }
118
119 return $this->redirect($response, '/admin/tags');
120 }
88} 121}
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php
new file mode 100644
index 00000000..ff845944
--- /dev/null
+++ b/application/front/controller/admin/MetadataController.php
@@ -0,0 +1,29 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Controller used to retrieve/update bookmark's metadata.
12 */
13class MetadataController extends ShaarliAdminController
14{
15 /**
16 * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
17 */
18 public function ajaxRetrieveTitle(Request $request, Response $response): Response
19 {
20 $url = $request->getParam('url');
21
22 // Only try to extract metadata from URL with HTTP(s) scheme
23 if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
24 return $response->withJson($this->container->metadataRetriever->retrieve($url));
25 }
26
27 return $response->withJson([]);
28 }
29}
diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php
index 5ec0d24b..4aaf1f82 100644
--- a/application/front/controller/admin/PasswordController.php
+++ b/application/front/controller/admin/PasswordController.php
@@ -25,7 +25,7 @@ class PasswordController extends ShaarliAdminController
25 25
26 $this->assignView( 26 $this->assignView(
27 'pagetitle', 27 'pagetitle',
28 t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli') 28 t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
29 ); 29 );
30 } 30 }
31 31
@@ -78,7 +78,7 @@ class PasswordController extends ShaarliAdminController
78 78
79 // Save new password 79 // Save new password
80 // Salt renders rainbow-tables attacks useless. 80 // Salt renders rainbow-tables attacks useless.
81 $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); 81 $this->container->conf->set('credentials.salt', sha1(uniqid('', true) . '_' . mt_rand()));
82 $this->container->conf->set( 82 $this->container->conf->set(
83 'credentials.hash', 83 'credentials.hash',
84 sha1( 84 sha1(
diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php
index 8e059681..ae47c1af 100644
--- a/application/front/controller/admin/PluginsController.php
+++ b/application/front/controller/admin/PluginsController.php
@@ -42,7 +42,7 @@ class PluginsController extends ShaarliAdminController
42 $this->assignView('disabledPlugins', $disabledPlugins); 42 $this->assignView('disabledPlugins', $disabledPlugins);
43 $this->assignView( 43 $this->assignView(
44 'pagetitle', 44 'pagetitle',
45 t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') 45 t('Plugin Administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
46 ); 46 );
47 47
48 return $response->write($this->render(TemplatePage::PLUGINS_ADMIN)); 48 return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
@@ -64,7 +64,7 @@ class PluginsController extends ShaarliAdminController
64 unset($parameters['parameters_form']); 64 unset($parameters['parameters_form']);
65 unset($parameters['token']); 65 unset($parameters['token']);
66 foreach ($parameters as $param => $value) { 66 foreach ($parameters as $param => $value) {
67 $this->container->conf->set('plugins.'. $param, escape($value)); 67 $this->container->conf->set('plugins.' . $param, escape($value));
68 } 68 }
69 } else { 69 } else {
70 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters)); 70 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));
diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php
new file mode 100644
index 00000000..4b74f4a9
--- /dev/null
+++ b/application/front/controller/admin/ServerController.php
@@ -0,0 +1,101 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Helper\ApplicationUtils;
8use Shaarli\Helper\FileUtils;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Slim controller used to handle Server administration page, and actions.
14 */
15class ServerController extends ShaarliAdminController
16{
17 /** @var string Cache type - main - by default pagecache/ and tmp/ */
18 protected const CACHE_MAIN = 'main';
19
20 /** @var string Cache type - thumbnails - by default cache/ */
21 protected const CACHE_THUMB = 'thumbnails';
22
23 /**
24 * GET /admin/server - Display page Server administration
25 */
26 public function index(Request $request, Response $response): Response
27 {
28 $releaseUrl = ApplicationUtils::$GITHUB_URL . '/releases/';
29 if ($this->container->conf->get('updates.check_updates', true)) {
30 $latestVersion = 'v' . ApplicationUtils::getVersion(
31 ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE
32 );
33 $releaseUrl .= 'tag/' . $latestVersion;
34 } else {
35 $latestVersion = t('Check disabled');
36 }
37
38 $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php');
39 $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion;
40 $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
41
42 $permissions = array_merge(
43 ApplicationUtils::checkResourcePermissions($this->container->conf),
44 ApplicationUtils::checkDatastoreMutex()
45 );
46
47 $this->assignView('php_version', PHP_VERSION);
48 $this->assignView('php_eol', format_date($phpEol, false));
49 $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
50 $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
51 $this->assignView('permissions', $permissions);
52 $this->assignView('release_url', $releaseUrl);
53 $this->assignView('latest_version', $latestVersion);
54 $this->assignView('current_version', $currentVersion);
55 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode'));
56 $this->assignView('index_url', index_url($this->container->environment));
57 $this->assignView('client_ip', client_ip_id($this->container->environment));
58 $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', []));
59
60 $this->assignView(
61 'pagetitle',
62 t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
63 );
64
65 return $response->write($this->render('server'));
66 }
67
68 /**
69 * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails).
70 */
71 public function clearCache(Request $request, Response $response): Response
72 {
73 $exclude = ['.htaccess'];
74
75 if ($request->getQueryParam('type') === static::CACHE_THUMB) {
76 $folders = [$this->container->conf->get('resource.thumbnails_cache')];
77
78 $this->saveWarningMessage(
79 t('Thumbnails cache has been cleared.') . ' ' .
80 '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
81 t('Please synchronize them.') .
82 '</a>'
83 );
84 } else {
85 $folders = [
86 $this->container->conf->get('resource.page_cache'),
87 $this->container->conf->get('resource.raintpl_tmp'),
88 ];
89
90 $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!'));
91 }
92
93 // Make sure that we don't delete root cache folder
94 $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders))));
95 foreach ($folders as $folder) {
96 FileUtils::clearFolder($folder, false, $exclude);
97 }
98
99 return $this->redirect($response, '/admin/server');
100 }
101}
diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php
index d9a7a2e0..0917b6d2 100644
--- a/application/front/controller/admin/SessionFilterController.php
+++ b/application/front/controller/admin/SessionFilterController.php
@@ -45,6 +45,4 @@ class SessionFilterController extends ShaarliAdminController
45 45
46 return $this->redirectFromReferer($request, $response, ['visibility']); 46 return $this->redirectFromReferer($request, $response, ['visibility']);
47 } 47 }
48
49
50} 48}
diff --git a/application/front/controller/admin/ShaareAddController.php b/application/front/controller/admin/ShaareAddController.php
new file mode 100644
index 00000000..ab8e7f40
--- /dev/null
+++ b/application/front/controller/admin/ShaareAddController.php
@@ -0,0 +1,34 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Formatter\BookmarkMarkdownFormatter;
8use Shaarli\Render\TemplatePage;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12class ShaareAddController extends ShaarliAdminController
13{
14 /**
15 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
16 */
17 public function addShaare(Request $request, Response $response): Response
18 {
19 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
20 if ($this->container->conf->get('formatter') === 'markdown') {
21 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
22 }
23
24 $this->assignView(
25 'pagetitle',
26 t('Shaare a new link') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
27 );
28 $this->assignView('tags', $tags);
29 $this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false));
30 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34}
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php
new file mode 100644
index 00000000..35837baa
--- /dev/null
+++ b/application/front/controller/admin/ShaareManageController.php
@@ -0,0 +1,202 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class PostBookmarkController
13 *
14 * Slim controller used to handle Shaarli create or edit bookmarks.
15 */
16class ShaareManageController extends ShaarliAdminController
17{
18 /**
19 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
20 */
21 public function deleteBookmark(Request $request, Response $response): Response
22 {
23 $this->checkToken($request);
24
25 $ids = escape(trim($request->getParam('id') ?? ''));
26 if (empty($ids) || strpos($ids, ' ') !== false) {
27 // multiple, space-separated ids provided
28 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
29 } else {
30 $ids = [$ids];
31 }
32
33 // assert at least one id is given
34 if (0 === count($ids)) {
35 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
36
37 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
38 }
39
40 $formatter = $this->container->formatterFactory->getFormatter('raw');
41 $count = 0;
42 foreach ($ids as $id) {
43 try {
44 $bookmark = $this->container->bookmarkService->get((int) $id);
45 } catch (BookmarkNotFoundException $e) {
46 $this->saveErrorMessage(sprintf(
47 t('Bookmark with identifier %s could not be found.'),
48 $id
49 ));
50
51 continue;
52 }
53
54 $data = $formatter->format($bookmark);
55 $this->executePageHooks('delete_link', $data);
56 $this->container->bookmarkService->remove($bookmark, false);
57 ++$count;
58 }
59
60 if ($count > 0) {
61 $this->container->bookmarkService->save();
62 }
63
64 // If we are called from the bookmarklet, we must close the popup:
65 if ($request->getParam('source') === 'bookmarklet') {
66 return $response->write('<script>self.close();</script>');
67 }
68
69 // Don't redirect to permalink after deletion.
70 return $this->redirectFromReferer($request, $response, ['shaare/']);
71 }
72
73 /**
74 * GET /admin/shaare/visibility
75 *
76 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
77 */
78 public function changeVisibility(Request $request, Response $response): Response
79 {
80 $this->checkToken($request);
81
82 $ids = trim(escape($request->getParam('id') ?? ''));
83 if (empty($ids) || strpos($ids, ' ') !== false) {
84 // multiple, space-separated ids provided
85 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
86 } else {
87 // only a single id provided
88 $ids = [$ids];
89 }
90
91 // assert at least one id is given
92 if (0 === count($ids)) {
93 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
94
95 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
96 }
97
98 // assert that the visibility is valid
99 $visibility = $request->getParam('newVisibility');
100 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
101 $this->saveErrorMessage(t('Invalid visibility provided.'));
102
103 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
104 } else {
105 $isPrivate = $visibility === 'private';
106 }
107
108 $formatter = $this->container->formatterFactory->getFormatter('raw');
109 $count = 0;
110
111 foreach ($ids as $id) {
112 try {
113 $bookmark = $this->container->bookmarkService->get((int) $id);
114 } catch (BookmarkNotFoundException $e) {
115 $this->saveErrorMessage(sprintf(
116 t('Bookmark with identifier %s could not be found.'),
117 $id
118 ));
119
120 continue;
121 }
122
123 $bookmark->setPrivate($isPrivate);
124
125 // To preserve backward compatibility with 3rd parties, plugins still use arrays
126 $data = $formatter->format($bookmark);
127 $this->executePageHooks('save_link', $data);
128 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
129
130 $this->container->bookmarkService->set($bookmark, false);
131 ++$count;
132 }
133
134 if ($count > 0) {
135 $this->container->bookmarkService->save();
136 }
137
138 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
139 }
140
141 /**
142 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
143 */
144 public function pinBookmark(Request $request, Response $response, array $args): Response
145 {
146 $this->checkToken($request);
147
148 $id = $args['id'] ?? '';
149 try {
150 if (false === ctype_digit($id)) {
151 throw new BookmarkNotFoundException();
152 }
153 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
154 } catch (BookmarkNotFoundException $e) {
155 $this->saveErrorMessage(sprintf(
156 t('Bookmark with identifier %s could not be found.'),
157 $id
158 ));
159
160 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
161 }
162
163 $formatter = $this->container->formatterFactory->getFormatter('raw');
164
165 $bookmark->setSticky(!$bookmark->isSticky());
166
167 // To preserve backward compatibility with 3rd parties, plugins still use arrays
168 $data = $formatter->format($bookmark);
169 $this->executePageHooks('save_link', $data);
170 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
171
172 $this->container->bookmarkService->set($bookmark);
173
174 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
175 }
176
177 /**
178 * GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL.
179 */
180 public function sharePrivate(Request $request, Response $response, array $args): Response
181 {
182 $this->checkToken($request);
183
184 $hash = $args['hash'] ?? '';
185 $bookmark = $this->container->bookmarkService->findByHash($hash);
186
187 if ($bookmark->isPrivate() !== true) {
188 return $this->redirect($response, '/shaare/' . $hash);
189 }
190
191 if (empty($bookmark->getAdditionalContentEntry('private_key'))) {
192 $privateKey = bin2hex(random_bytes(16));
193 $bookmark->addAdditionalContentEntry('private_key', $privateKey);
194 $this->container->bookmarkService->set($bookmark);
195 }
196
197 return $this->redirect(
198 $response,
199 '/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key')
200 );
201 }
202}
diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php
new file mode 100644
index 00000000..fb9cacc2
--- /dev/null
+++ b/application/front/controller/admin/ShaarePublishController.php
@@ -0,0 +1,274 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkFormatter;
10use Shaarli\Formatter\BookmarkMarkdownFormatter;
11use Shaarli\Render\TemplatePage;
12use Shaarli\Thumbnailer;
13use Slim\Http\Request;
14use Slim\Http\Response;
15
16class ShaarePublishController extends ShaarliAdminController
17{
18 /**
19 * @var BookmarkFormatter[] Statically cached instances of formatters
20 */
21 protected $formatters = [];
22
23 /**
24 * @var array Statically cached bookmark's tags counts
25 */
26 protected $tags;
27
28 /**
29 * GET /admin/shaare - Displays the bookmark form for creation.
30 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
31 */
32 public function displayCreateForm(Request $request, Response $response): Response
33 {
34 $url = cleanup_url($request->getParam('post'));
35 $link = $this->buildLinkDataFromUrl($request, $url);
36
37 return $this->displayForm($link, $link['linkIsNew'], $request, $response);
38 }
39
40 /**
41 * POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page.
42 */
43 public function displayCreateBatchForms(Request $request, Response $response): Response
44 {
45 $urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls')));
46
47 $links = [];
48 foreach ($urls as $url) {
49 if (empty($url)) {
50 continue;
51 }
52 $link = $this->buildLinkDataFromUrl($request, $url);
53 $data = $this->buildFormData($link, $link['linkIsNew'], $request);
54 $data['token'] = $this->container->sessionManager->generateToken();
55 $data['source'] = 'batch';
56
57 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
58
59 $links[] = $data;
60 }
61
62 $this->assignView('links', $links);
63 $this->assignView('batch_mode', true);
64 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
65
66 return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH));
67 }
68
69 /**
70 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
71 */
72 public function displayEditForm(Request $request, Response $response, array $args): Response
73 {
74 $id = $args['id'] ?? '';
75 try {
76 if (false === ctype_digit($id)) {
77 throw new BookmarkNotFoundException();
78 }
79 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
80 } catch (BookmarkNotFoundException $e) {
81 $this->saveErrorMessage(sprintf(
82 t('Bookmark with identifier %s could not be found.'),
83 $id
84 ));
85
86 return $this->redirect($response, '/');
87 }
88
89 $formatter = $this->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91
92 return $this->displayForm($link, false, $request, $response);
93 }
94
95 /**
96 * POST /admin/shaare
97 */
98 public function save(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 // lf_id should only be present if the link exists.
103 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
104 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
105 // Edit
106 $bookmark = $this->container->bookmarkService->get($id);
107 } else {
108 // New link
109 $bookmark = new Bookmark();
110 }
111
112 $bookmark->setTitle($request->getParam('lf_title'));
113 $bookmark->setDescription($request->getParam('lf_description'));
114 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
115 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
116 $bookmark->setTagsString(
117 $request->getParam('lf_tags'),
118 $this->container->conf->get('general.tags_separator', ' ')
119 );
120
121 if (
122 $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
123 && true !== $this->container->conf->get('general.enable_async_metadata', true)
124 && $bookmark->shouldUpdateThumbnail()
125 ) {
126 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
127 }
128 $this->container->bookmarkService->addOrSet($bookmark, false);
129
130 // To preserve backward compatibility with 3rd parties, plugins still use arrays
131 $formatter = $this->getFormatter('raw');
132 $data = $formatter->format($bookmark);
133 $this->executePageHooks('save_link', $data);
134
135 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
136 $this->container->bookmarkService->set($bookmark);
137
138 // If we are called from the bookmarklet, we must close the popup:
139 if ($request->getParam('source') === 'bookmarklet') {
140 return $response->write('<script>self.close();</script>');
141 } elseif ($request->getParam('source') === 'batch') {
142 return $response;
143 }
144
145 if (!empty($request->getParam('returnurl'))) {
146 $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
147 }
148
149 return $this->redirectFromReferer(
150 $request,
151 $response,
152 ['/admin/add-shaare', '/admin/shaare'],
153 ['addlink', 'post', 'edit_link'],
154 $bookmark->getShortUrl()
155 );
156 }
157
158 /**
159 * Helper function used to display the shaare form whether it's a new or existing bookmark.
160 *
161 * @param array $link data used in template, either from parameters or from the data store
162 */
163 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
164 {
165 $data = $this->buildFormData($link, $isNew, $request);
166
167 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
168
169 foreach ($data as $key => $value) {
170 $this->assignView($key, $value);
171 }
172
173 $editLabel = false === $isNew ? t('Edit') . ' ' : '';
174 $this->assignView(
175 'pagetitle',
176 $editLabel . t('Shaare') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
177 );
178
179 return $response->write($this->render(TemplatePage::EDIT_LINK));
180 }
181
182 protected function buildLinkDataFromUrl(Request $request, string $url): array
183 {
184 // Check if URL is not already in database (in this case, we will edit the existing link)
185 $bookmark = $this->container->bookmarkService->findByUrl($url);
186 if (null === $bookmark) {
187 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
188 $title = $request->getParam('title');
189 $description = $request->getParam('description');
190 $tags = $request->getParam('tags');
191 if ($request->getParam('private') !== null) {
192 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
193 } else {
194 $private = $this->container->conf->get('privacy.default_private_links', false);
195 }
196
197 // If this is an HTTP(S) link, we try go get the page to extract
198 // the title (otherwise we will to straight to the edit form.)
199 if (
200 true !== $this->container->conf->get('general.enable_async_metadata', true)
201 && empty($title)
202 && strpos(get_url_scheme($url) ?: '', 'http') !== false
203 ) {
204 $metadata = $this->container->metadataRetriever->retrieve($url);
205 }
206
207 if (empty($url)) {
208 $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
209 }
210
211 return [
212 'title' => $title ?? $metadata['title'] ?? '',
213 'url' => $url ?? '',
214 'description' => $description ?? $metadata['description'] ?? '',
215 'tags' => $tags ?? $metadata['tags'] ?? '',
216 'private' => $private,
217 'linkIsNew' => true,
218 ];
219 }
220
221 $formatter = $this->getFormatter('raw');
222 $link = $formatter->format($bookmark);
223 $link['linkIsNew'] = false;
224
225 return $link;
226 }
227
228 protected function buildFormData(array $link, bool $isNew, Request $request): array
229 {
230 $link['tags'] = $link['tags'] !== null && strlen($link['tags']) > 0
231 ? $link['tags'] . $this->container->conf->get('general.tags_separator', ' ')
232 : $link['tags']
233 ;
234
235 return escape([
236 'link' => $link,
237 'link_is_new' => $isNew,
238 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
239 'source' => $request->getParam('source') ?? '',
240 'tags' => $this->getTags(),
241 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
242 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
243 'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
244 ]);
245 }
246
247 /**
248 * Memoize formatterFactory->getFormatter() calls.
249 */
250 protected function getFormatter(string $type): BookmarkFormatter
251 {
252 if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) {
253 $this->formatters[$type] = $this->container->formatterFactory->getFormatter($type);
254 }
255
256 return $this->formatters[$type];
257 }
258
259 /**
260 * Memoize bookmarkService->bookmarksCountPerTag() calls.
261 */
262 protected function getTags(): array
263 {
264 if ($this->tags === null) {
265 $this->tags = $this->container->bookmarkService->bookmarksCountPerTag();
266
267 if ($this->container->conf->get('formatter') === 'markdown') {
268 $this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
269 }
270 }
271
272 return $this->tags;
273 }
274}
diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php
index 4dc09d38..94d97d4b 100644
--- a/application/front/controller/admin/ThumbnailsController.php
+++ b/application/front/controller/admin/ThumbnailsController.php
@@ -34,7 +34,7 @@ class ThumbnailsController extends ShaarliAdminController
34 $this->assignView('ids', $ids); 34 $this->assignView('ids', $ids);
35 $this->assignView( 35 $this->assignView(
36 'pagetitle', 36 'pagetitle',
37 t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli') 37 t('Thumbnails update') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
38 ); 38 );
39 39
40 return $response->write($this->render(TemplatePage::THUMBNAILS)); 40 return $response->write($this->render(TemplatePage::THUMBNAILS));
diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php
index a87f20d2..560e5e3e 100644
--- a/application/front/controller/admin/ToolsController.php
+++ b/application/front/controller/admin/ToolsController.php
@@ -28,7 +28,7 @@ class ToolsController extends ShaarliAdminController
28 $this->assignView($key, $value); 28 $this->assignView($key, $value);
29 } 29 }
30 30
31 $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 31 $this->assignView('pagetitle', t('Tools') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
32 32
33 return $response->write($this->render(TemplatePage::TOOLS)); 33 return $response->write($this->render(TemplatePage::TOOLS));
34 } 34 }
diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php
index 18368751..fe8231be 100644
--- a/application/front/controller/visitor/BookmarkListController.php
+++ b/application/front/controller/visitor/BookmarkListController.php
@@ -35,7 +35,8 @@ class BookmarkListController extends ShaarliVisitorController
35 $formatter->addContextData('base_path', $this->container->basePath); 35 $formatter->addContextData('base_path', $this->container->basePath);
36 36
37 $searchTags = normalize_spaces($request->getParam('searchtags') ?? ''); 37 $searchTags = normalize_spaces($request->getParam('searchtags') ?? '');
38 $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; 38 $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));
39 ;
39 40
40 // Filter bookmarks according search parameters. 41 // Filter bookmarks according search parameters.
41 $visibility = $this->container->sessionManager->getSessionParameter('visibility'); 42 $visibility = $this->container->sessionManager->getSessionParameter('visibility');
@@ -95,6 +96,10 @@ class BookmarkListController extends ShaarliVisitorController
95 $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl; 96 $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
96 } 97 }
97 98
99 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
100 $searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator));
101 $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
102
98 // Fill all template fields. 103 // Fill all template fields.
99 $data = array_merge( 104 $data = array_merge(
100 $this->initializeTemplateVars(), 105 $this->initializeTemplateVars(),
@@ -106,7 +111,7 @@ class BookmarkListController extends ShaarliVisitorController
106 'result_count' => count($linksToDisplay), 111 'result_count' => count($linksToDisplay),
107 'search_term' => escape($searchTerm), 112 'search_term' => escape($searchTerm),
108 'search_tags' => escape($searchTags), 113 'search_tags' => escape($searchTags),
109 'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)), 114 'search_tags_url' => $searchTagsUrlEncoded,
110 'visibility' => $visibility, 115 'visibility' => $visibility,
111 'links' => $linkDisp, 116 'links' => $linkDisp,
112 ] 117 ]
@@ -119,8 +124,9 @@ class BookmarkListController extends ShaarliVisitorController
119 return '[' . $tag . ']'; 124 return '[' . $tag . ']';
120 }; 125 };
121 $data['pagetitle'] .= ! empty($searchTags) 126 $data['pagetitle'] .= ! empty($searchTags)
122 ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' ' 127 ? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
123 : ''; 128 : ''
129 ;
124 $data['pagetitle'] .= '- '; 130 $data['pagetitle'] .= '- ';
125 } 131 }
126 132
@@ -137,8 +143,10 @@ class BookmarkListController extends ShaarliVisitorController
137 */ 143 */
138 public function permalink(Request $request, Response $response, array $args): Response 144 public function permalink(Request $request, Response $response, array $args): Response
139 { 145 {
146 $privateKey = $request->getParam('key');
147
140 try { 148 try {
141 $bookmark = $this->container->bookmarkService->findByHash($args['hash']); 149 $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
142 } catch (BookmarkNotFoundException $e) { 150 } catch (BookmarkNotFoundException $e) {
143 $this->assignView('error_message', $e->getMessage()); 151 $this->assignView('error_message', $e->getMessage());
144 152
@@ -153,7 +161,7 @@ class BookmarkListController extends ShaarliVisitorController
153 $data = array_merge( 161 $data = array_merge(
154 $this->initializeTemplateVars(), 162 $this->initializeTemplateVars(),
155 [ 163 [
156 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'), 164 'pagetitle' => $bookmark->getTitle() . ' - ' . $this->container->conf->get('general.title', 'Shaarli'),
157 'links' => [$formatter->format($bookmark)], 165 'links' => [$formatter->format($bookmark)],
158 ] 166 ]
159 ); 167 );
@@ -169,19 +177,25 @@ class BookmarkListController extends ShaarliVisitorController
169 */ 177 */
170 protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool 178 protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
171 { 179 {
172 // Logged in, thumbnails enabled, not a note, is HTTP 180 if (false === $this->container->loginManager->isLoggedIn()) {
173 // and (never retrieved yet or no valid cache file) 181 return false;
174 if ($this->container->loginManager->isLoggedIn() 182 }
175 && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE 183
176 && false !== $bookmark->getThumbnail() 184 // If thumbnail should be updated, we reset it to null
177 && !$bookmark->isNote() 185 if ($bookmark->shouldUpdateThumbnail()) {
178 && (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail())) 186 $bookmark->setThumbnail(null);
179 && startsWith(strtolower($bookmark->getUrl()), 'http') 187
180 ) { 188 // Requires an update, not async retrieval, thumbnails enabled
181 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); 189 if (
182 $this->container->bookmarkService->set($bookmark, $writeDatastore); 190 $bookmark->shouldUpdateThumbnail()
183 191 && true !== $this->container->conf->get('general.enable_async_metadata', true)
184 return true; 192 && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
193 ) {
194 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
195 $this->container->bookmarkService->set($bookmark, $writeDatastore);
196
197 return true;
198 }
185 } 199 }
186 200
187 return false; 201 return false;
@@ -198,6 +212,7 @@ class BookmarkListController extends ShaarliVisitorController
198 'page_max' => '', 212 'page_max' => '',
199 'search_tags' => '', 213 'search_tags' => '',
200 'result_count' => '', 214 'result_count' => '',
215 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
201 ]; 216 ];
202 } 217 }
203 218
diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php
index 07617cf1..29492a5f 100644
--- a/application/front/controller/visitor/DailyController.php
+++ b/application/front/controller/visitor/DailyController.php
@@ -5,8 +5,8 @@ declare(strict_types=1);
5namespace Shaarli\Front\Controller\Visitor; 5namespace Shaarli\Front\Controller\Visitor;
6 6
7use DateTime; 7use DateTime;
8use DateTimeImmutable;
9use Shaarli\Bookmark\Bookmark; 8use Shaarli\Bookmark\Bookmark;
9use Shaarli\Helper\DailyPageHelper;
10use Shaarli\Render\TemplatePage; 10use Shaarli\Render\TemplatePage;
11use Slim\Http\Request; 11use Slim\Http\Request;
12use Slim\Http\Response; 12use Slim\Http\Response;
@@ -26,32 +26,20 @@ class DailyController extends ShaarliVisitorController
26 */ 26 */
27 public function index(Request $request, Response $response): Response 27 public function index(Request $request, Response $response): Response
28 { 28 {
29 $day = $request->getQueryParam('day') ?? date('Ymd'); 29 $type = DailyPageHelper::extractRequestedType($request);
30 30 $format = DailyPageHelper::getFormatByType($type);
31 $availableDates = $this->container->bookmarkService->days(); 31 $latestBookmark = $this->container->bookmarkService->getLatest();
32 $nbAvailableDates = count($availableDates); 32 $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
33 $index = array_search($day, $availableDates); 33 $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
34 34 $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
35 if ($index === false) { 35 $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
36 // no bookmarks for day, but at least one day with bookmarks 36
37 $day = $availableDates[$nbAvailableDates - 1] ?? $day; 37 $linksToDisplay = $this->container->bookmarkService->findByDate(
38 $previousDay = $availableDates[$nbAvailableDates - 2] ?? ''; 38 $start,
39 } else { 39 $end,
40 $previousDay = $availableDates[$index - 1] ?? ''; 40 $previousDay,
41 $nextDay = $availableDates[$index + 1] ?? ''; 41 $nextDay
42 } 42 );
43
44 if ($day === date('Ymd')) {
45 $this->assignView('dayDesc', t('Today'));
46 } elseif ($day === date('Ymd', strtotime('-1 days'))) {
47 $this->assignView('dayDesc', t('Yesterday'));
48 }
49
50 try {
51 $linksToDisplay = $this->container->bookmarkService->filterDay($day);
52 } catch (\Exception $exc) {
53 $linksToDisplay = [];
54 }
55 43
56 $formatter = $this->container->formatterFactory->getFormatter(); 44 $formatter = $this->container->formatterFactory->getFormatter();
57 $formatter->addContextData('base_path', $this->container->basePath); 45 $formatter->addContextData('base_path', $this->container->basePath);
@@ -63,13 +51,15 @@ class DailyController extends ShaarliVisitorController
63 $linksToDisplay[$key]['description'] = $bookmark->getDescription(); 51 $linksToDisplay[$key]['description'] = $bookmark->getDescription();
64 } 52 }
65 53
66 $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
67 $data = [ 54 $data = [
68 'linksToDisplay' => $linksToDisplay, 55 'linksToDisplay' => $linksToDisplay,
69 'day' => $dayDate->getTimestamp(), 56 'dayDate' => $start,
70 'dayDate' => $dayDate, 57 'day' => $start->getTimestamp(),
71 'previousday' => $previousDay ?? '', 58 'previousday' => $previousDay ? $previousDay->format($format) : '',
72 'nextday' => $nextDay ?? '', 59 'nextday' => $nextDay ? $nextDay->format($format) : '',
60 'dayDesc' => $dailyDesc,
61 'type' => $type,
62 'localizedType' => $this->translateType($type),
73 ]; 63 ];
74 64
75 // Hooks are called before column construction so that plugins don't have to deal with columns. 65 // Hooks are called before column construction so that plugins don't have to deal with columns.
@@ -82,7 +72,7 @@ class DailyController extends ShaarliVisitorController
82 $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); 72 $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
83 $this->assignView( 73 $this->assignView(
84 'pagetitle', 74 'pagetitle',
85 t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle 75 $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
86 ); 76 );
87 77
88 return $response->write($this->render(TemplatePage::DAILY)); 78 return $response->write($this->render(TemplatePage::DAILY));
@@ -96,9 +86,11 @@ class DailyController extends ShaarliVisitorController
96 public function rss(Request $request, Response $response): Response 86 public function rss(Request $request, Response $response): Response
97 { 87 {
98 $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); 88 $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
89 $type = DailyPageHelper::extractRequestedType($request);
90 $cacheDuration = DailyPageHelper::getCacheDatePeriodByType($type);
99 91
100 $pageUrl = page_url($this->container->environment); 92 $pageUrl = page_url($this->container->environment);
101 $cache = $this->container->pageCacheManager->getCachePage($pageUrl); 93 $cache = $this->container->pageCacheManager->getCachePage($pageUrl, $cacheDuration);
102 94
103 $cached = $cache->cachedVersion(); 95 $cached = $cache->cachedVersion();
104 if (!empty($cached)) { 96 if (!empty($cached)) {
@@ -106,11 +98,13 @@ class DailyController extends ShaarliVisitorController
106 } 98 }
107 99
108 $days = []; 100 $days = [];
101 $format = DailyPageHelper::getFormatByType($type);
102 $length = DailyPageHelper::getRssLengthByType($type);
109 foreach ($this->container->bookmarkService->search() as $bookmark) { 103 foreach ($this->container->bookmarkService->search() as $bookmark) {
110 $day = $bookmark->getCreated()->format('Ymd'); 104 $day = $bookmark->getCreated()->format($format);
111 105
112 // Stop iterating after DAILY_RSS_NB_DAYS entries 106 // Stop iterating after DAILY_RSS_NB_DAYS entries
113 if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) { 107 if (count($days) === $length && !isset($days[$day])) {
114 break; 108 break;
115 } 109 }
116 110
@@ -127,12 +121,19 @@ class DailyController extends ShaarliVisitorController
127 121
128 /** @var Bookmark[] $bookmarks */ 122 /** @var Bookmark[] $bookmarks */
129 foreach ($days as $day => $bookmarks) { 123 foreach ($days as $day => $bookmarks) {
130 $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); 124 $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
125 $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
126
127 // We only want the RSS entry to be published when the period is over.
128 if (new DateTime() < $endDateTime) {
129 continue;
130 }
131
131 $dataPerDay[$day] = [ 132 $dataPerDay[$day] = [
132 'date' => $dayDatetime, 133 'date' => $endDateTime,
133 'date_rss' => $dayDatetime->format(DateTime::RSS), 134 'date_rss' => $endDateTime->format(DateTime::RSS),
134 'date_human' => format_date($dayDatetime, false, true), 135 'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime, false),
135 'absolute_url' => $indexUrl . 'daily?day=' . $day, 136 'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
136 'links' => [], 137 'links' => [],
137 ]; 138 ];
138 139
@@ -141,16 +142,20 @@ class DailyController extends ShaarliVisitorController
141 142
142 // Make permalink URL absolute 143 // Make permalink URL absolute
143 if ($bookmark->isNote()) { 144 if ($bookmark->isNote()) {
144 $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl(); 145 $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
145 } 146 }
146 } 147 }
147 } 148 }
148 149
149 $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); 150 $this->assignAllView([
150 $this->assignView('index_url', $indexUrl); 151 'title' => $this->container->conf->get('general.title', 'Shaarli'),
151 $this->assignView('page_url', $pageUrl); 152 'index_url' => $indexUrl,
152 $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); 153 'page_url' => $pageUrl,
153 $this->assignView('days', $dataPerDay); 154 'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
155 'days' => $dataPerDay,
156 'type' => $type,
157 'localizedType' => $this->translateType($type),
158 ]);
154 159
155 $rssContent = $this->render(TemplatePage::DAILY_RSS); 160 $rssContent = $this->render(TemplatePage::DAILY_RSS);
156 161
@@ -189,4 +194,13 @@ class DailyController extends ShaarliVisitorController
189 194
190 return $columns; 195 return $columns;
191 } 196 }
197
198 protected function translateType($type): string
199 {
200 return [
201 t('day') => t('Daily'),
202 t('week') => t('Weekly'),
203 t('month') => t('Monthly'),
204 ][t($type)] ?? t('Daily');
205 }
192} 206}
diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php
index 10aa84c8..428e8254 100644
--- a/application/front/controller/visitor/ErrorController.php
+++ b/application/front/controller/visitor/ErrorController.php
@@ -26,12 +26,15 @@ class ErrorController extends ShaarliVisitorController
26 $response = $response->withStatus($throwable->getCode()); 26 $response = $response->withStatus($throwable->getCode());
27 } else { 27 } else {
28 // Internal error (any other Throwable) 28 // Internal error (any other Throwable)
29 if ($this->container->conf->get('dev.debug', false)) { 29 if ($this->container->conf->get('dev.debug', false) || $this->container->loginManager->isLoggedIn()) {
30 $this->assignView('message', $throwable->getMessage()); 30 $this->assignView('message', t('Error: ') . $throwable->getMessage());
31 $this->assignView( 31 $this->assignView(
32 'stacktrace', 32 'text',
33 nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString()) 33 '<a href="https://github.com/shaarli/Shaarli/issues/new">'
34 . t('Please report it on Github.')
35 . '</a>'
34 ); 36 );
37 $this->assignView('stacktrace', exception2text($throwable));
35 } else { 38 } else {
36 $this->assignView('message', t('An unexpected error occurred.')); 39 $this->assignView('message', t('An unexpected error occurred.'));
37 } 40 }
@@ -39,7 +42,6 @@ class ErrorController extends ShaarliVisitorController
39 $response = $response->withStatus(500); 42 $response = $response->withStatus(500);
40 } 43 }
41 44
42
43 return $response->write($this->render('error')); 45 return $response->write($this->render('error'));
44 } 46 }
45} 47}
diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php
index 8d8b546a..edc7ef43 100644
--- a/application/front/controller/visitor/FeedController.php
+++ b/application/front/controller/visitor/FeedController.php
@@ -27,7 +27,7 @@ class FeedController extends ShaarliVisitorController
27 27
28 protected function processRequest(string $feedType, Request $request, Response $response): Response 28 protected function processRequest(string $feedType, Request $request, Response $response): Response
29 { 29 {
30 $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); 30 $response = $response->withHeader('Content-Type', 'application/' . $feedType . '+xml; charset=utf-8');
31 31
32 $pageUrl = page_url($this->container->environment); 32 $pageUrl = page_url($this->container->environment);
33 $cache = $this->container->pageCacheManager->getCachePage($pageUrl); 33 $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php
index 7cb32777..418d4a49 100644
--- a/application/front/controller/visitor/InstallController.php
+++ b/application/front/controller/visitor/InstallController.php
@@ -4,10 +4,10 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Front\Controller\Visitor; 5namespace Shaarli\Front\Controller\Visitor;
6 6
7use Shaarli\ApplicationUtils;
8use Shaarli\Container\ShaarliContainer; 7use Shaarli\Container\ShaarliContainer;
9use Shaarli\Front\Exception\AlreadyInstalledException; 8use Shaarli\Front\Exception\AlreadyInstalledException;
10use Shaarli\Front\Exception\ResourcePermissionException; 9use Shaarli\Front\Exception\ResourcePermissionException;
10use Shaarli\Helper\ApplicationUtils;
11use Shaarli\Languages; 11use Shaarli\Languages;
12use Shaarli\Security\SessionManager; 12use Shaarli\Security\SessionManager;
13use Slim\Http\Request; 13use Slim\Http\Request;
@@ -39,7 +39,8 @@ class InstallController extends ShaarliVisitorController
39 // Before installation, we'll make sure that permissions are set properly, and sessions are working. 39 // Before installation, we'll make sure that permissions are set properly, and sessions are working.
40 $this->checkPermissions(); 40 $this->checkPermissions();
41 41
42 if (static::SESSION_TEST_VALUE 42 if (
43 static::SESSION_TEST_VALUE
43 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) 44 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
44 ) { 45 ) {
45 $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE); 46 $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
@@ -53,6 +54,21 @@ class InstallController extends ShaarliVisitorController
53 $this->assignView('cities', $cities); 54 $this->assignView('cities', $cities);
54 $this->assignView('languages', Languages::getAvailableLanguages()); 55 $this->assignView('languages', Languages::getAvailableLanguages());
55 56
57 $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
58
59 $permissions = array_merge(
60 ApplicationUtils::checkResourcePermissions($this->container->conf),
61 ApplicationUtils::checkDatastoreMutex()
62 );
63
64 $this->assignView('php_version', PHP_VERSION);
65 $this->assignView('php_eol', format_date($phpEol, false));
66 $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
67 $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
68 $this->assignView('permissions', $permissions);
69
70 $this->assignView('pagetitle', t('Install Shaarli'));
71
56 return $response->write($this->render('install')); 72 return $response->write($this->render('install'));
57 } 73 }
58 74
@@ -65,17 +81,18 @@ class InstallController extends ShaarliVisitorController
65 // This part makes sure sessions works correctly. 81 // This part makes sure sessions works correctly.
66 // (Because on some hosts, session.save_path may not be set correctly, 82 // (Because on some hosts, session.save_path may not be set correctly,
67 // or we may not have write access to it.) 83 // or we may not have write access to it.)
68 if (static::SESSION_TEST_VALUE 84 if (
85 static::SESSION_TEST_VALUE
69 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) 86 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
70 ) { 87 ) {
71 // Step 2: Check if data in session is correct. 88 // Step 2: Check if data in session is correct.
72 $msg = t( 89 $msg = t(
73 '<pre>Sessions do not seem to work correctly on your server.<br>'. 90 '<pre>Sessions do not seem to work correctly on your server.<br>' .
74 'Make sure the variable "session.save_path" is set correctly in your PHP config, '. 91 'Make sure the variable "session.save_path" is set correctly in your PHP config, ' .
75 'and that you have write access to it.<br>'. 92 'and that you have write access to it.<br>' .
76 'It currently points to %s.<br>'. 93 'It currently points to %s.<br>' .
77 'On some browsers, accessing your server via a hostname like \'localhost\' '. 94 'On some browsers, accessing your server via a hostname like \'localhost\' ' .
78 'or any custom hostname without a dot causes cookie storage to fail. '. 95 'or any custom hostname without a dot causes cookie storage to fail. ' .
79 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>' 96 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
80 ); 97 );
81 $msg = sprintf($msg, $this->container->sessionManager->getSavePath()); 98 $msg = sprintf($msg, $this->container->sessionManager->getSavePath());
@@ -94,7 +111,8 @@ class InstallController extends ShaarliVisitorController
94 public function save(Request $request, Response $response): Response 111 public function save(Request $request, Response $response): Response
95 { 112 {
96 $timezone = 'UTC'; 113 $timezone = 'UTC';
97 if (!empty($request->getParam('continent')) 114 if (
115 !empty($request->getParam('continent'))
98 && !empty($request->getParam('city')) 116 && !empty($request->getParam('city'))
99 && isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) 117 && isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
100 ) { 118 ) {
@@ -104,7 +122,7 @@ class InstallController extends ShaarliVisitorController
104 122
105 $login = $request->getParam('setlogin'); 123 $login = $request->getParam('setlogin');
106 $this->container->conf->set('credentials.login', $login); 124 $this->container->conf->set('credentials.login', $login);
107 $salt = sha1(uniqid('', true) .'_'. mt_rand()); 125 $salt = sha1(uniqid('', true) . '_' . mt_rand());
108 $this->container->conf->set('credentials.salt', $salt); 126 $this->container->conf->set('credentials.salt', $salt);
109 $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); 127 $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
110 128
@@ -113,7 +131,7 @@ class InstallController extends ShaarliVisitorController
113 } else { 131 } else {
114 $this->container->conf->set( 132 $this->container->conf->set(
115 'general.title', 133 'general.title',
116 'Shared bookmarks on '.escape(index_url($this->container->environment)) 134 'Shared bookmarks on ' . escape(index_url($this->container->environment))
117 ); 135 );
118 } 136 }
119 137
@@ -150,7 +168,7 @@ class InstallController extends ShaarliVisitorController
150 protected function checkPermissions(): bool 168 protected function checkPermissions(): bool
151 { 169 {
152 // Ensure Shaarli has proper access to its resources 170 // Ensure Shaarli has proper access to its resources
153 $errors = ApplicationUtils::checkResourcePermissions($this->container->conf); 171 $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
154 if (empty($errors)) { 172 if (empty($errors)) {
155 return true; 173 return true;
156 } 174 }
diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php
index 121ba40b..4b881535 100644
--- a/application/front/controller/visitor/LoginController.php
+++ b/application/front/controller/visitor/LoginController.php
@@ -43,7 +43,7 @@ class LoginController extends ShaarliVisitorController
43 $this 43 $this
44 ->assignView('returnurl', escape($returnUrl)) 44 ->assignView('returnurl', escape($returnUrl))
45 ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) 45 ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
46 ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) 46 ->assignView('pagetitle', t('Login') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'))
47 ; 47 ;
48 48
49 return $response->write($this->render(TemplatePage::LOGIN)); 49 return $response->write($this->render(TemplatePage::LOGIN));
@@ -64,8 +64,8 @@ class LoginController extends ShaarliVisitorController
64 return $this->redirect($response, '/'); 64 return $this->redirect($response, '/');
65 } 65 }
66 66
67 if (!$this->container->loginManager->checkCredentials( 67 if (
68 $this->container->environment['REMOTE_ADDR'], 68 !$this->container->loginManager->checkCredentials(
69 client_ip_id($this->container->environment), 69 client_ip_id($this->container->environment),
70 $request->getParam('login'), 70 $request->getParam('login'),
71 $request->getParam('password') 71 $request->getParam('password')
@@ -102,7 +102,8 @@ class LoginController extends ShaarliVisitorController
102 */ 102 */
103 protected function checkLoginState(): bool 103 protected function checkLoginState(): bool
104 { 104 {
105 if ($this->container->loginManager->isLoggedIn() 105 if (
106 $this->container->loginManager->isLoggedIn()
106 || $this->container->conf->get('security.open_shaarli', false) 107 || $this->container->conf->get('security.open_shaarli', false)
107 ) { 108 ) {
108 throw new CantLoginException(); 109 throw new CantLoginException();
diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php
index 3c57f8dd..23553ee6 100644
--- a/application/front/controller/visitor/PictureWallController.php
+++ b/application/front/controller/visitor/PictureWallController.php
@@ -26,7 +26,7 @@ class PictureWallController extends ShaarliVisitorController
26 26
27 $this->assignView( 27 $this->assignView(
28 'pagetitle', 28 'pagetitle',
29 t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') 29 t('Picture wall') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
30 ); 30 );
31 31
32 // Optionally filter the results: 32 // Optionally filter the results:
diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php
index 54f9fe03..ae946c59 100644
--- a/application/front/controller/visitor/ShaarliVisitorController.php
+++ b/application/front/controller/visitor/ShaarliVisitorController.php
@@ -144,7 +144,8 @@ abstract class ShaarliVisitorController
144 if (null !== $referer) { 144 if (null !== $referer) {
145 $currentUrl = parse_url($referer); 145 $currentUrl = parse_url($referer);
146 // If the referer is not related to Shaarli instance, redirect to default 146 // If the referer is not related to Shaarli instance, redirect to default
147 if (isset($currentUrl['host']) 147 if (
148 isset($currentUrl['host'])
148 && strpos(index_url($this->container->environment), $currentUrl['host']) === false 149 && strpos(index_url($this->container->environment), $currentUrl['host']) === false
149 ) { 150 ) {
150 return $response->withRedirect($defaultPath); 151 return $response->withRedirect($defaultPath);
@@ -173,7 +174,7 @@ abstract class ShaarliVisitorController
173 } 174 }
174 } 175 }
175 176
176 $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; 177 $queryString = count($params) > 0 ? '?' . http_build_query($params) : '';
177 $anchor = $anchor ? '#' . $anchor : ''; 178 $anchor = $anchor ? '#' . $anchor : '';
178 179
179 return $response->withRedirect($path . $queryString . $anchor); 180 return $response->withRedirect($path . $queryString . $anchor);
diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php
index 76ed7690..46d62779 100644
--- a/application/front/controller/visitor/TagCloudController.php
+++ b/application/front/controller/visitor/TagCloudController.php
@@ -47,13 +47,14 @@ class TagCloudController extends ShaarliVisitorController
47 */ 47 */
48 protected function processRequest(string $type, Request $request, Response $response): Response 48 protected function processRequest(string $type, Request $request, Response $response): Response
49 { 49 {
50 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
50 if ($this->container->loginManager->isLoggedIn() === true) { 51 if ($this->container->loginManager->isLoggedIn() === true) {
51 $visibility = $this->container->sessionManager->getSessionParameter('visibility'); 52 $visibility = $this->container->sessionManager->getSessionParameter('visibility');
52 } 53 }
53 54
54 $sort = $request->getQueryParam('sort'); 55 $sort = $request->getQueryParam('sort');
55 $searchTags = $request->getQueryParam('searchtags'); 56 $searchTags = $request->getQueryParam('searchtags');
56 $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; 57 $filteringTags = $searchTags !== null ? explode($tagsSeparator, $searchTags) : [];
57 58
58 $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); 59 $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
59 60
@@ -71,8 +72,9 @@ class TagCloudController extends ShaarliVisitorController
71 $tagsUrl[escape($tag)] = urlencode((string) $tag); 72 $tagsUrl[escape($tag)] = urlencode((string) $tag);
72 } 73 }
73 74
74 $searchTags = implode(' ', escape($filteringTags)); 75 $searchTags = tags_array2str($filteringTags, $tagsSeparator);
75 $searchTagsUrl = urlencode(implode(' ', $filteringTags)); 76 $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
77 $searchTagsUrl = urlencode($searchTags);
76 $data = [ 78 $data = [
77 'search_tags' => escape($searchTags), 79 'search_tags' => escape($searchTags),
78 'search_tags_url' => $searchTagsUrl, 80 'search_tags_url' => $searchTagsUrl,
@@ -82,10 +84,10 @@ class TagCloudController extends ShaarliVisitorController
82 $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); 84 $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
83 $this->assignAllView($data); 85 $this->assignAllView($data);
84 86
85 $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; 87 $searchTags = !empty($searchTags) ? trim(str_replace($tagsSeparator, ' ', $searchTags)) . ' - ' : '';
86 $this->assignView( 88 $this->assignView(
87 'pagetitle', 89 'pagetitle',
88 $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') 90 $searchTags . t('Tag ' . $type) . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
89 ); 91 );
90 92
91 return $response->write($this->render('tag.' . $type)); 93 return $response->write($this->render('tag.' . $type));
diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php
index de4e7ea2..3aa58542 100644
--- a/application/front/controller/visitor/TagController.php
+++ b/application/front/controller/visitor/TagController.php
@@ -27,7 +27,7 @@ class TagController extends ShaarliVisitorController
27 // In case browser does not send HTTP_REFERER, we search a single tag 27 // In case browser does not send HTTP_REFERER, we search a single tag
28 if (null === $referer) { 28 if (null === $referer) {
29 if (null !== $newTag) { 29 if (null !== $newTag) {
30 return $this->redirect($response, '/?searchtags='. urlencode($newTag)); 30 return $this->redirect($response, '/?searchtags=' . urlencode($newTag));
31 } 31 }
32 32
33 return $this->redirect($response, '/'); 33 return $this->redirect($response, '/');
@@ -37,7 +37,7 @@ class TagController extends ShaarliVisitorController
37 parse_str($currentUrl['query'] ?? '', $params); 37 parse_str($currentUrl['query'] ?? '', $params);
38 38
39 if (null === $newTag) { 39 if (null === $newTag) {
40 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); 40 return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
41 } 41 }
42 42
43 // Prevent redirection loop 43 // Prevent redirection loop
@@ -45,9 +45,10 @@ class TagController extends ShaarliVisitorController
45 unset($params['addtag']); 45 unset($params['addtag']);
46 } 46 }
47 47
48 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
48 // Check if this tag is already in the search query and ignore it if it is. 49 // Check if this tag is already in the search query and ignore it if it is.
49 // Each tag is always separated by a space 50 // Each tag is always separated by a space
50 $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; 51 $currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
51 52
52 $addtag = true; 53 $addtag = true;
53 foreach ($currentTags as $value) { 54 foreach ($currentTags as $value) {
@@ -62,12 +63,12 @@ class TagController extends ShaarliVisitorController
62 $currentTags[] = trim($newTag); 63 $currentTags[] = trim($newTag);
63 } 64 }
64 65
65 $params['searchtags'] = trim(implode(' ', $currentTags)); 66 $params['searchtags'] = tags_array2str($currentTags, $tagsSeparator);
66 67
67 // We also remove page (keeping the same page has no sense, since the results are different) 68 // We also remove page (keeping the same page has no sense, since the results are different)
68 unset($params['page']); 69 unset($params['page']);
69 70
70 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); 71 return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
71 } 72 }
72 73
73 /** 74 /**
@@ -89,7 +90,7 @@ class TagController extends ShaarliVisitorController
89 parse_str($currentUrl['query'] ?? '', $params); 90 parse_str($currentUrl['query'] ?? '', $params);
90 91
91 if (null === $tagToRemove) { 92 if (null === $tagToRemove) {
92 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); 93 return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
93 } 94 }
94 95
95 // Prevent redirection loop 96 // Prevent redirection loop
@@ -98,10 +99,11 @@ class TagController extends ShaarliVisitorController
98 } 99 }
99 100
100 if (isset($params['searchtags'])) { 101 if (isset($params['searchtags'])) {
101 $tags = explode(' ', $params['searchtags']); 102 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
103 $tags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
102 // Remove value from array $tags. 104 // Remove value from array $tags.
103 $tags = array_diff($tags, [$tagToRemove]); 105 $tags = array_diff($tags, [$tagToRemove]);
104 $params['searchtags'] = implode(' ', $tags); 106 $params['searchtags'] = tags_array2str($tags, $tagsSeparator);
105 107
106 if (empty($params['searchtags'])) { 108 if (empty($params['searchtags'])) {
107 unset($params['searchtags']); 109 unset($params['searchtags']);
diff --git a/application/ApplicationUtils.php b/application/helper/ApplicationUtils.php
index 3aa21829..a6c03aae 100644
--- a/application/ApplicationUtils.php
+++ b/application/helper/ApplicationUtils.php
@@ -1,7 +1,10 @@
1<?php 1<?php
2namespace Shaarli; 2
3namespace Shaarli\Helper;
3 4
4use Exception; 5use Exception;
6use malkusch\lock\exception\LockAcquireException;
7use malkusch\lock\mutex\FlockMutex;
5use Shaarli\Config\ConfigManager; 8use Shaarli\Config\ConfigManager;
6 9
7/** 10/**
@@ -14,8 +17,9 @@ class ApplicationUtils
14 */ 17 */
15 public static $VERSION_FILE = 'shaarli_version.php'; 18 public static $VERSION_FILE = 'shaarli_version.php';
16 19
17 private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; 20 public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli';
18 private static $GIT_BRANCHES = array('latest', 'stable'); 21 public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
22 public static $GIT_BRANCHES = ['latest', 'stable'];
19 private static $VERSION_START_TAG = '<?php /* '; 23 private static $VERSION_START_TAG = '<?php /* ';
20 private static $VERSION_END_TAG = ' */ ?>'; 24 private static $VERSION_END_TAG = ' */ ?>';
21 25
@@ -63,8 +67,8 @@ class ApplicationUtils
63 } 67 }
64 68
65 return str_replace( 69 return str_replace(
66 array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), 70 [self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL],
67 array('', '', ''), 71 ['', '', ''],
68 $data 72 $data
69 ); 73 );
70 } 74 }
@@ -125,7 +129,7 @@ class ApplicationUtils
125 // Late Static Binding allows overriding within tests 129 // Late Static Binding allows overriding within tests
126 // See http://php.net/manual/en/language.oop5.late-static-bindings.php 130 // See http://php.net/manual/en/language.oop5.late-static-bindings.php
127 $latestVersion = static::getVersion( 131 $latestVersion = static::getVersion(
128 self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE 132 self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE
129 ); 133 );
130 134
131 if (!$latestVersion) { 135 if (!$latestVersion) {
@@ -171,35 +175,47 @@ class ApplicationUtils
171 /** 175 /**
172 * Checks Shaarli has the proper access permissions to its resources 176 * Checks Shaarli has the proper access permissions to its resources
173 * 177 *
174 * @param ConfigManager $conf Configuration Manager instance. 178 * @param ConfigManager $conf Configuration Manager instance.
179 * @param bool $minimalMode In minimal mode we only check permissions to be able to display a template.
180 * Currently we only need to be able to read the theme and write in raintpl cache.
175 * 181 *
176 * @return array A list of the detected configuration issues 182 * @return array A list of the detected configuration issues
177 */ 183 */
178 public static function checkResourcePermissions($conf) 184 public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array
179 { 185 {
180 $errors = array(); 186 $errors = [];
181 $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/'); 187 $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
182 188
183 // Check script and template directories are readable 189 // Check script and template directories are readable
184 foreach (array( 190 foreach (
185 'application', 191 [
186 'inc', 192 'application',
187 'plugins', 193 'inc',
188 $rainTplDir, 194 'plugins',
189 $rainTplDir . '/' . $conf->get('resource.theme'), 195 $rainTplDir,
190 ) as $path) { 196 $rainTplDir . '/' . $conf->get('resource.theme'),
197 ] as $path
198 ) {
191 if (!is_readable(realpath($path))) { 199 if (!is_readable(realpath($path))) {
192 $errors[] = '"' . $path . '" ' . t('directory is not readable'); 200 $errors[] = '"' . $path . '" ' . t('directory is not readable');
193 } 201 }
194 } 202 }
195 203
196 // Check cache and data directories are readable and writable 204 // Check cache and data directories are readable and writable
197 foreach (array( 205 if ($minimalMode) {
198 $conf->get('resource.thumbnails_cache'), 206 $folders = [
199 $conf->get('resource.data_dir'), 207 $conf->get('resource.raintpl_tmp'),
200 $conf->get('resource.page_cache'), 208 ];
201 $conf->get('resource.raintpl_tmp'), 209 } else {
202 ) as $path) { 210 $folders = [
211 $conf->get('resource.thumbnails_cache'),
212 $conf->get('resource.data_dir'),
213 $conf->get('resource.page_cache'),
214 $conf->get('resource.raintpl_tmp'),
215 ];
216 }
217
218 foreach ($folders as $path) {
203 if (!is_readable(realpath($path))) { 219 if (!is_readable(realpath($path))) {
204 $errors[] = '"' . $path . '" ' . t('directory is not readable'); 220 $errors[] = '"' . $path . '" ' . t('directory is not readable');
205 } 221 }
@@ -208,14 +224,20 @@ class ApplicationUtils
208 } 224 }
209 } 225 }
210 226
227 if ($minimalMode) {
228 return $errors;
229 }
230
211 // Check configuration files are readable and writable 231 // Check configuration files are readable and writable
212 foreach (array( 232 foreach (
213 $conf->getConfigFileExt(), 233 [
214 $conf->get('resource.datastore'), 234 $conf->getConfigFileExt(),
215 $conf->get('resource.ban_file'), 235 $conf->get('resource.datastore'),
216 $conf->get('resource.log'), 236 $conf->get('resource.ban_file'),
217 $conf->get('resource.update_check'), 237 $conf->get('resource.log'),
218 ) as $path) { 238 $conf->get('resource.update_check'),
239 ] as $path
240 ) {
219 if (!is_file(realpath($path))) { 241 if (!is_file(realpath($path))) {
220 # the file may not exist yet 242 # the file may not exist yet
221 continue; 243 continue;
@@ -232,6 +254,20 @@ class ApplicationUtils
232 return $errors; 254 return $errors;
233 } 255 }
234 256
257 public static function checkDatastoreMutex(): array
258 {
259 $mutex = new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2);
260 try {
261 $mutex->synchronized(function () {
262 return true;
263 });
264 } catch (LockAcquireException $e) {
265 $errors[] = t('Lock can not be acquired on the datastore. You might encounter concurrent access issues.');
266 }
267
268 return $errors ?? [];
269 }
270
235 /** 271 /**
236 * Returns a salted hash representing the current Shaarli version. 272 * Returns a salted hash representing the current Shaarli version.
237 * 273 *
@@ -246,4 +282,54 @@ class ApplicationUtils
246 { 282 {
247 return hash_hmac('sha256', $currentVersion, $salt); 283 return hash_hmac('sha256', $currentVersion, $salt);
248 } 284 }
285
286 /**
287 * Get a list of PHP extensions used by Shaarli.
288 *
289 * @return array[] List of extension with following keys:
290 * - name: extension name
291 * - required: whether the extension is required to use Shaarli
292 * - desc: short description of extension usage in Shaarli
293 * - loaded: whether the extension is properly loaded or not
294 */
295 public static function getPhpExtensionsRequirement(): array
296 {
297 $extensions = [
298 ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')],
299 ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')],
300 ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')],
301 ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')],
302 ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')],
303 ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')],
304 ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')],
305 ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')],
306 ];
307
308 foreach ($extensions as &$extension) {
309 $extension['loaded'] = extension_loaded($extension['name']);
310 }
311
312 return $extensions;
313 }
314
315 /**
316 * Return the EOL date of given PHP version. If the version is unknown,
317 * we return today + 2 years.
318 *
319 * @param string $fullVersion PHP version, e.g. 7.4.7
320 *
321 * @return string Date format: YYYY-MM-DD
322 */
323 public static function getPhpEol(string $fullVersion): string
324 {
325 preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches);
326
327 return [
328 '7.1' => '2019-12-01',
329 '7.2' => '2020-11-30',
330 '7.3' => '2021-12-06',
331 '7.4' => '2022-11-28',
332 '8.0' => '2023-12-01',
333 ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d');
334 }
249} 335}
diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php
new file mode 100644
index 00000000..05f95812
--- /dev/null
+++ b/application/helper/DailyPageHelper.php
@@ -0,0 +1,236 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Helper;
6
7use DatePeriod;
8use DateTimeImmutable;
9use Exception;
10use Shaarli\Bookmark\Bookmark;
11use Slim\Http\Request;
12
13class DailyPageHelper
14{
15 public const MONTH = 'month';
16 public const WEEK = 'week';
17 public const DAY = 'day';
18
19 /**
20 * Extracts the type of the daily to display from the HTTP request parameters
21 *
22 * @param Request $request HTTP request
23 *
24 * @return string month/week/day
25 */
26 public static function extractRequestedType(Request $request): string
27 {
28 if ($request->getQueryParam(static::MONTH) !== null) {
29 return static::MONTH;
30 } elseif ($request->getQueryParam(static::WEEK) !== null) {
31 return static::WEEK;
32 }
33
34 return static::DAY;
35 }
36
37 /**
38 * Extracts a DateTimeImmutable from provided HTTP request.
39 * If no parameter is provided, we rely on the creation date of the latest provided created bookmark.
40 * If the datastore is empty or no bookmark is provided, we use the current date.
41 *
42 * @param string $type month/week/day
43 * @param string|null $requestedDate Input string extracted from the request
44 * @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date)
45 *
46 * @return DateTimeImmutable from input or latest bookmark.
47 *
48 * @throws Exception Type not supported.
49 */
50 public static function extractRequestedDateTime(
51 string $type,
52 ?string $requestedDate,
53 Bookmark $latestBookmark = null
54 ): DateTimeImmutable {
55 $format = static::getFormatByType($type);
56 if (empty($requestedDate)) {
57 return $latestBookmark instanceof Bookmark
58 ? new DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM))
59 : new DateTimeImmutable()
60 ;
61 }
62
63 // W is not supported by createFromFormat...
64 if ($type === static::WEEK) {
65 return (new DateTimeImmutable())
66 ->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2))
67 ;
68 }
69
70 return DateTimeImmutable::createFromFormat($format, $requestedDate);
71 }
72
73 /**
74 * Get the DateTime format used by provided type
75 * Examples:
76 * - day: 20201016 (<year><month><day>)
77 * - week: 202041 (<year><week number>)
78 * - month: 202010 (<year><month>)
79 *
80 * @param string $type month/week/day
81 *
82 * @return string DateTime compatible format
83 *
84 * @see https://www.php.net/manual/en/datetime.format.php
85 *
86 * @throws Exception Type not supported.
87 */
88 public static function getFormatByType(string $type): string
89 {
90 switch ($type) {
91 case static::MONTH:
92 return 'Ym';
93 case static::WEEK:
94 return 'YW';
95 case static::DAY:
96 return 'Ymd';
97 default:
98 throw new Exception('Unsupported daily format type');
99 }
100 }
101
102 /**
103 * Get the first DateTime of the time period depending on given datetime and type.
104 * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
105 * and we don't want to alter original datetime.
106 *
107 * @param string $type month/week/day
108 * @param DateTimeImmutable $requested DateTime extracted from request input
109 * (should come from extractRequestedDateTime)
110 *
111 * @return \DateTimeInterface First DateTime of the time period
112 *
113 * @throws Exception Type not supported.
114 */
115 public static function getStartDateTimeByType(string $type, DateTimeImmutable $requested): \DateTimeInterface
116 {
117 switch ($type) {
118 case static::MONTH:
119 return $requested->modify('first day of this month midnight');
120 case static::WEEK:
121 return $requested->modify('Monday this week midnight');
122 case static::DAY:
123 return $requested->modify('Today midnight');
124 default:
125 throw new Exception('Unsupported daily format type');
126 }
127 }
128
129 /**
130 * Get the last DateTime of the time period depending on given datetime and type.
131 * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
132 * and we don't want to alter original datetime.
133 *
134 * @param string $type month/week/day
135 * @param DateTimeImmutable $requested DateTime extracted from request input
136 * (should come from extractRequestedDateTime)
137 *
138 * @return \DateTimeInterface Last DateTime of the time period
139 *
140 * @throws Exception Type not supported.
141 */
142 public static function getEndDateTimeByType(string $type, DateTimeImmutable $requested): \DateTimeInterface
143 {
144 switch ($type) {
145 case static::MONTH:
146 return $requested->modify('last day of this month 23:59:59');
147 case static::WEEK:
148 return $requested->modify('Sunday this week 23:59:59');
149 case static::DAY:
150 return $requested->modify('Today 23:59:59');
151 default:
152 throw new Exception('Unsupported daily format type');
153 }
154 }
155
156 /**
157 * Get localized description of the time period depending on given datetime and type.
158 * Example: for a month period, it returns `October, 2020`.
159 *
160 * @param string $type month/week/day
161 * @param \DateTimeImmutable $requested DateTime extracted from request input
162 * (should come from extractRequestedDateTime)
163 * @param bool $includeRelative Include relative date description (today, yesterday, etc.)
164 *
165 * @return string Localized time period description
166 *
167 * @throws Exception Type not supported.
168 */
169 public static function getDescriptionByType(
170 string $type,
171 \DateTimeImmutable $requested,
172 bool $includeRelative = true
173 ): string {
174 switch ($type) {
175 case static::MONTH:
176 return $requested->format('F') . ', ' . $requested->format('Y');
177 case static::WEEK:
178 $requested = $requested->modify('Monday this week');
179 return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')';
180 case static::DAY:
181 $out = '';
182 if ($includeRelative && $requested->format('Ymd') === date('Ymd')) {
183 $out = t('Today') . ' - ';
184 } elseif ($includeRelative && $requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) {
185 $out = t('Yesterday') . ' - ';
186 }
187 return $out . format_date($requested, false);
188 default:
189 throw new Exception('Unsupported daily format type');
190 }
191 }
192
193 /**
194 * Get the number of items to display in the RSS feed depending on the given type.
195 *
196 * @param string $type month/week/day
197 *
198 * @return int number of elements
199 *
200 * @throws Exception Type not supported.
201 */
202 public static function getRssLengthByType(string $type): int
203 {
204 switch ($type) {
205 case static::MONTH:
206 return 12; // 1 year
207 case static::WEEK:
208 return 26; // ~6 months
209 case static::DAY:
210 return 30; // ~1 month
211 default:
212 throw new Exception('Unsupported daily format type');
213 }
214 }
215
216 /**
217 * Get the number of items to display in the RSS feed depending on the given type.
218 *
219 * @param string $type month/week/day
220 * @param ?DateTimeImmutable $requested Currently only used for UT
221 *
222 * @return DatePeriod number of elements
223 *
224 * @throws Exception Type not supported.
225 */
226 public static function getCacheDatePeriodByType(string $type, DateTimeImmutable $requested = null): DatePeriod
227 {
228 $requested = $requested ?? new DateTimeImmutable();
229
230 return new DatePeriod(
231 static::getStartDateTimeByType($type, $requested),
232 new \DateInterval('P1D'),
233 static::getEndDateTimeByType($type, $requested)
234 );
235 }
236}
diff --git a/application/FileUtils.php b/application/helper/FileUtils.php
index 30560bfc..e8a2168c 100644
--- a/application/FileUtils.php
+++ b/application/helper/FileUtils.php
@@ -1,6 +1,6 @@
1<?php 1<?php
2 2
3namespace Shaarli; 3namespace Shaarli\Helper;
4 4
5use Shaarli\Exceptions\IOException; 5use Shaarli\Exceptions\IOException;
6 6
@@ -81,4 +81,60 @@ class FileUtils
81 ) 81 )
82 ); 82 );
83 } 83 }
84
85 /**
86 * Recursively deletes a folder content, and deletes itself optionally.
87 * If an excluded file is found, folders won't be deleted.
88 *
89 * Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory.
90 *
91 * @param string $path
92 * @param bool $selfDelete Delete the provided folder if true, only its content if false.
93 * @param array $exclude
94 */
95 public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool
96 {
97 $skipped = false;
98
99 if (!is_dir($path)) {
100 throw new IOException(t('Provided path is not a directory.'));
101 }
102
103 if (!static::isPathInShaarliFolder($path)) {
104 throw new IOException(t('Trying to delete a folder outside of Shaarli path.'));
105 }
106
107 foreach (new \DirectoryIterator($path) as $file) {
108 if ($file->isDot()) {
109 continue;
110 }
111
112 if (in_array($file->getBasename(), $exclude, true)) {
113 $skipped = true;
114 continue;
115 }
116
117 if ($file->isFile()) {
118 unlink($file->getPathname());
119 } elseif ($file->isDir()) {
120 $skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped;
121 }
122 }
123
124 if ($selfDelete && !$skipped) {
125 rmdir($path);
126 }
127
128 return $skipped;
129 }
130
131 /**
132 * Checks that the given path is inside Shaarli directory.
133 */
134 public static function isPathInShaarliFolder(string $path): bool
135 {
136 $rootDirectory = dirname(dirname(dirname(__FILE__)));
137
138 return strpos(realpath($path), $rootDirectory) !== false;
139 }
84} 140}
diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php
index 81d9e076..e80e0c01 100644
--- a/application/http/HttpAccess.php
+++ b/application/http/HttpAccess.php
@@ -14,9 +14,14 @@ namespace Shaarli\Http;
14 */ 14 */
15class HttpAccess 15class HttpAccess
16{ 16{
17 public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) 17 public function getHttpResponse(
18 { 18 $url,
19 return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction); 19 $timeout = 30,
20 $maxBytes = 4194304,
21 $curlHeaderFunction = null,
22 $curlWriteFunction = null
23 ) {
24 return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction);
20 } 25 }
21 26
22 public function getCurlDownloadCallback( 27 public function getCurlDownloadCallback(
@@ -25,7 +30,7 @@ class HttpAccess
25 &$description, 30 &$description,
26 &$keywords, 31 &$keywords,
27 $retrieveDescription, 32 $retrieveDescription,
28 $curlGetInfo = 'curl_getinfo' 33 $tagsSeparator
29 ) { 34 ) {
30 return get_curl_download_callback( 35 return get_curl_download_callback(
31 $charset, 36 $charset,
@@ -33,7 +38,12 @@ class HttpAccess
33 $description, 38 $description,
34 $keywords, 39 $keywords,
35 $retrieveDescription, 40 $retrieveDescription,
36 $curlGetInfo 41 $tagsSeparator
37 ); 42 );
38 } 43 }
44
45 public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
46 {
47 return get_curl_header_callback($charset, $curlGetInfo);
48 }
39} 49}
diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php
index 9f414073..4bde1d5b 100644
--- a/application/http/HttpUtils.php
+++ b/application/http/HttpUtils.php
@@ -6,12 +6,14 @@ use Shaarli\Http\Url;
6 * GET an HTTP URL to retrieve its content 6 * GET an HTTP URL to retrieve its content
7 * Uses the cURL library or a fallback method 7 * Uses the cURL library or a fallback method
8 * 8 *
9 * @param string $url URL to get (http://...) 9 * @param string $url URL to get (http://...)
10 * @param int $timeout network timeout (in seconds) 10 * @param int $timeout network timeout (in seconds)
11 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) 11 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
12 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION). 12 * @param callable|string $curlHeaderFunction Optional callback called during the download of headers
13 * Can be used to add download conditions on the 13 * (CURLOPT_HEADERFUNCTION)
14 * headers (response code, content type, etc.). 14 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
15 * Can be used to add download conditions on the
16 * headers (response code, content type, etc.).
15 * 17 *
16 * @return array HTTP response headers, downloaded content 18 * @return array HTTP response headers, downloaded content
17 * 19 *
@@ -35,13 +37,18 @@ use Shaarli\Http\Url;
35 * @see http://stackoverflow.com/q/9183178 37 * @see http://stackoverflow.com/q/9183178
36 * @see http://stackoverflow.com/q/1462720 38 * @see http://stackoverflow.com/q/1462720
37 */ 39 */
38function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) 40function get_http_response(
39{ 41 $url,
42 $timeout = 30,
43 $maxBytes = 4194304,
44 $curlHeaderFunction = null,
45 $curlWriteFunction = null
46) {
40 $urlObj = new Url($url); 47 $urlObj = new Url($url);
41 $cleanUrl = $urlObj->idnToAscii(); 48 $cleanUrl = $urlObj->idnToAscii();
42 49
43 if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) { 50 if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
44 return array(array(0 => 'Invalid HTTP UrlUtils'), false); 51 return [[0 => 'Invalid HTTP UrlUtils'], false];
45 } 52 }
46 53
47 $userAgent = 54 $userAgent =
@@ -64,42 +71,39 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
64 71
65 $ch = curl_init($cleanUrl); 72 $ch = curl_init($cleanUrl);
66 if ($ch === false) { 73 if ($ch === false) {
67 return array(array(0 => 'curl_init() error'), false); 74 return [[0 => 'curl_init() error'], false];
68 } 75 }
69 76
70 // General cURL settings 77 // General cURL settings
71 curl_setopt($ch, CURLOPT_AUTOREFERER, true); 78 curl_setopt($ch, CURLOPT_AUTOREFERER, true);
72 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 79 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
73 curl_setopt($ch, CURLOPT_HEADER, true); 80 // Default header download if the $curlHeaderFunction is not defined
81 curl_setopt($ch, CURLOPT_HEADER, !is_callable($curlHeaderFunction));
74 curl_setopt( 82 curl_setopt(
75 $ch, 83 $ch,
76 CURLOPT_HTTPHEADER, 84 CURLOPT_HTTPHEADER,
77 array('Accept-Language: ' . $acceptLanguage) 85 ['Accept-Language: ' . $acceptLanguage]
78 ); 86 );
79 curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs); 87 curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
80 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 88 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
81 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); 89 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
82 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); 90 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
83 91
92 // Max download size management
93 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024 * 16);
94 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
95 if (is_callable($curlHeaderFunction)) {
96 curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction);
97 }
84 if (is_callable($curlWriteFunction)) { 98 if (is_callable($curlWriteFunction)) {
85 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); 99 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
86 } 100 }
87
88 // Max download size management
89 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
90 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
91 curl_setopt( 101 curl_setopt(
92 $ch, 102 $ch,
93 CURLOPT_PROGRESSFUNCTION, 103 CURLOPT_PROGRESSFUNCTION,
94 function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { 104 function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
95 if (version_compare(phpversion(), '5.5', '<')) { 105 $downloaded = $arg2;
96 // PHP version lower than 5.5 106
97 // Callback has 4 arguments
98 $downloaded = $arg1;
99 } else {
100 // Callback has 5 arguments
101 $downloaded = $arg2;
102 }
103 // Non-zero return stops downloading 107 // Non-zero return stops downloading
104 return ($downloaded > $maxBytes) ? 1 : 0; 108 return ($downloaded > $maxBytes) ? 1 : 0;
105 } 109 }
@@ -118,9 +122,9 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
118 * Removing this would require updating 122 * Removing this would require updating
119 * GetHttpUrlTest::testGetInvalidRemoteUrl() 123 * GetHttpUrlTest::testGetInvalidRemoteUrl()
120 */ 124 */
121 return array(false, false); 125 return [false, false];
122 } 126 }
123 return array(array(0 => 'curl_exec() error: ' . $errorStr), false); 127 return [[0 => 'curl_exec() error: ' . $errorStr], false];
124 } 128 }
125 129
126 // Formatting output like the fallback method 130 // Formatting output like the fallback method
@@ -131,7 +135,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
131 $rawHeadersLastRedir = end($rawHeadersArrayRedirs); 135 $rawHeadersLastRedir = end($rawHeadersArrayRedirs);
132 136
133 $content = substr($response, $headSize); 137 $content = substr($response, $headSize);
134 $headers = array(); 138 $headers = [];
135 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { 139 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
136 if (empty($line) || ctype_space($line)) { 140 if (empty($line) || ctype_space($line)) {
137 continue; 141 continue;
@@ -142,7 +146,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
142 $value = $splitLine[1]; 146 $value = $splitLine[1];
143 if (array_key_exists($key, $headers)) { 147 if (array_key_exists($key, $headers)) {
144 if (!is_array($headers[$key])) { 148 if (!is_array($headers[$key])) {
145 $headers[$key] = array(0 => $headers[$key]); 149 $headers[$key] = [0 => $headers[$key]];
146 } 150 }
147 $headers[$key][] = $value; 151 $headers[$key][] = $value;
148 } else { 152 } else {
@@ -153,7 +157,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
153 } 157 }
154 } 158 }
155 159
156 return array($headers, $content); 160 return [$headers, $content];
157} 161}
158 162
159/** 163/**
@@ -184,15 +188,15 @@ function get_http_response_fallback(
184 $acceptLanguage, 188 $acceptLanguage,
185 $maxRedr 189 $maxRedr
186) { 190) {
187 $options = array( 191 $options = [
188 'http' => array( 192 'http' => [
189 'method' => 'GET', 193 'method' => 'GET',
190 'timeout' => $timeout, 194 'timeout' => $timeout,
191 'user_agent' => $userAgent, 195 'user_agent' => $userAgent,
192 'header' => "Accept: */*\r\n" 196 'header' => "Accept: */*\r\n"
193 . 'Accept-Language: ' . $acceptLanguage 197 . 'Accept-Language: ' . $acceptLanguage
194 ) 198 ]
195 ); 199 ];
196 200
197 stream_context_set_default($options); 201 stream_context_set_default($options);
198 list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr); 202 list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
@@ -203,7 +207,7 @@ function get_http_response_fallback(
203 } 207 }
204 208
205 if (! $headers) { 209 if (! $headers) {
206 return array($headers, false); 210 return [$headers, false];
207 } 211 }
208 212
209 try { 213 try {
@@ -211,10 +215,10 @@ function get_http_response_fallback(
211 $context = stream_context_create($options); 215 $context = stream_context_create($options);
212 $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes); 216 $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes);
213 } catch (Exception $exc) { 217 } catch (Exception $exc) {
214 return array(array(0 => 'HTTP Error'), $exc->getMessage()); 218 return [[0 => 'HTTP Error'], $exc->getMessage()];
215 } 219 }
216 220
217 return array($headers, $content); 221 return [$headers, $content];
218} 222}
219 223
220/** 224/**
@@ -233,10 +237,12 @@ function get_redirected_headers($url, $redirectionLimit = 3)
233 } 237 }
234 238
235 // Headers found, redirection found, and limit not reached. 239 // Headers found, redirection found, and limit not reached.
236 if ($redirectionLimit-- > 0 240 if (
241 $redirectionLimit-- > 0
237 && !empty($headers) 242 && !empty($headers)
238 && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false) 243 && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false)
239 && !empty($headers['Location'])) { 244 && !empty($headers['Location'])
245 ) {
240 $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location']; 246 $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location'];
241 if ($redirection != $url) { 247 if ($redirection != $url) {
242 $redirection = getAbsoluteUrl($url, $redirection); 248 $redirection = getAbsoluteUrl($url, $redirection);
@@ -244,7 +250,7 @@ function get_redirected_headers($url, $redirectionLimit = 3)
244 } 250 }
245 } 251 }
246 252
247 return array($headers, $url); 253 return [$headers, $url];
248} 254}
249 255
250/** 256/**
@@ -266,7 +272,7 @@ function getAbsoluteUrl($originalUrl, $newUrl)
266 } 272 }
267 273
268 $parts = parse_url($originalUrl); 274 $parts = parse_url($originalUrl);
269 $final = $parts['scheme'] .'://'. $parts['host']; 275 $final = $parts['scheme'] . '://' . $parts['host'];
270 $final .= (!empty($parts['port'])) ? $parts['port'] : ''; 276 $final .= (!empty($parts['port'])) ? $parts['port'] : '';
271 $final .= '/'; 277 $final .= '/';
272 if ($newUrl[0] != '/') { 278 if ($newUrl[0] != '/') {
@@ -319,7 +325,8 @@ function server_url($server)
319 $scheme = 'https'; 325 $scheme = 'https';
320 } 326 }
321 327
322 if (($scheme == 'http' && $port != '80') 328 if (
329 ($scheme == 'http' && $port != '80')
323 || ($scheme == 'https' && $port != '443') 330 || ($scheme == 'https' && $port != '443')
324 ) { 331 ) {
325 $port = ':' . $port; 332 $port = ':' . $port;
@@ -340,22 +347,26 @@ function server_url($server)
340 $host = $server['SERVER_NAME']; 347 $host = $server['SERVER_NAME'];
341 } 348 }
342 349
343 return $scheme.'://'.$host.$port; 350 return $scheme . '://' . $host . $port;
344 } 351 }
345 352
346 // SSL detection 353 // SSL detection
347 if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on') 354 if (
348 || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) { 355 (! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
356 || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')
357 ) {
349 $scheme = 'https'; 358 $scheme = 'https';
350 } 359 }
351 360
352 // Do not append standard port values 361 // Do not append standard port values
353 if (($scheme == 'http' && $server['SERVER_PORT'] != '80') 362 if (
354 || ($scheme == 'https' && $server['SERVER_PORT'] != '443')) { 363 ($scheme == 'http' && $server['SERVER_PORT'] != '80')
355 $port = ':'.$server['SERVER_PORT']; 364 || ($scheme == 'https' && $server['SERVER_PORT'] != '443')
365 ) {
366 $port = ':' . $server['SERVER_PORT'];
356 } 367 }
357 368
358 return $scheme.'://'.$server['SERVER_NAME'].$port; 369 return $scheme . '://' . $server['SERVER_NAME'] . $port;
359} 370}
360 371
361/** 372/**
@@ -493,6 +504,46 @@ function is_https($server)
493 * Get cURL callback function for CURLOPT_WRITEFUNCTION 504 * Get cURL callback function for CURLOPT_WRITEFUNCTION
494 * 505 *
495 * @param string $charset to extract from the downloaded page (reference) 506 * @param string $charset to extract from the downloaded page (reference)
507 * @param string $curlGetInfo Optionally overrides curl_getinfo function
508 *
509 * @return Closure
510 */
511function get_curl_header_callback(
512 &$charset,
513 $curlGetInfo = 'curl_getinfo'
514) {
515 $isRedirected = false;
516
517 return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) {
518 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
519 $chunkLength = strlen($data);
520 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
521 $isRedirected = true;
522 return $chunkLength;
523 }
524 if (!empty($responseCode) && $responseCode !== 200) {
525 return false;
526 }
527 // After a redirection, the content type will keep the previous request value
528 // until it finds the next content-type header.
529 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
530 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
531 }
532 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
533 return false;
534 }
535 if (!empty($contentType) && empty($charset)) {
536 $charset = header_extract_charset($contentType);
537 }
538
539 return $chunkLength;
540 };
541}
542
543/**
544 * Get cURL callback function for CURLOPT_WRITEFUNCTION
545 *
546 * @param string $charset to extract from the downloaded page (reference)
496 * @param string $title to extract from the downloaded page (reference) 547 * @param string $title to extract from the downloaded page (reference)
497 * @param string $description to extract from the downloaded page (reference) 548 * @param string $description to extract from the downloaded page (reference)
498 * @param string $keywords to extract from the downloaded page (reference) 549 * @param string $keywords to extract from the downloaded page (reference)
@@ -507,9 +558,8 @@ function get_curl_download_callback(
507 &$description, 558 &$description,
508 &$keywords, 559 &$keywords,
509 $retrieveDescription, 560 $retrieveDescription,
510 $curlGetInfo = 'curl_getinfo' 561 $tagsSeparator
511) { 562) {
512 $isRedirected = false;
513 $currentChunk = 0; 563 $currentChunk = 0;
514 $foundChunk = null; 564 $foundChunk = null;
515 565
@@ -524,37 +574,22 @@ function get_curl_download_callback(
524 * 574 *
525 * @return int|bool length of $data or false if we need to stop the download 575 * @return int|bool length of $data or false if we need to stop the download
526 */ 576 */
527 return function (&$ch, $data) use ( 577 return function (
578 $ch,
579 $data
580 ) use (
528 $retrieveDescription, 581 $retrieveDescription,
529 $curlGetInfo, 582 $tagsSeparator,
530 &$charset, 583 &$charset,
531 &$title, 584 &$title,
532 &$description, 585 &$description,
533 &$keywords, 586 &$keywords,
534 &$isRedirected,
535 &$currentChunk, 587 &$currentChunk,
536 &$foundChunk 588 &$foundChunk
537 ) { 589 ) {
590 $chunkLength = strlen($data);
538 $currentChunk++; 591 $currentChunk++;
539 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); 592
540 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
541 $isRedirected = true;
542 return strlen($data);
543 }
544 if (!empty($responseCode) && $responseCode !== 200) {
545 return false;
546 }
547 // After a redirection, the content type will keep the previous request value
548 // until it finds the next content-type header.
549 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
550 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
551 }
552 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
553 return false;
554 }
555 if (!empty($contentType) && empty($charset)) {
556 $charset = header_extract_charset($contentType);
557 }
558 if (empty($charset)) { 593 if (empty($charset)) {
559 $charset = html_extract_charset($data); 594 $charset = html_extract_charset($data);
560 } 595 }
@@ -562,6 +597,10 @@ function get_curl_download_callback(
562 $title = html_extract_title($data); 597 $title = html_extract_title($data);
563 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk; 598 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
564 } 599 }
600 if (empty($title)) {
601 $title = html_extract_tag('title', $data);
602 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
603 }
565 if ($retrieveDescription && empty($description)) { 604 if ($retrieveDescription && empty($description)) {
566 $description = html_extract_tag('description', $data); 605 $description = html_extract_tag('description', $data);
567 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk; 606 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
@@ -571,10 +610,10 @@ function get_curl_download_callback(
571 if (! empty($keywords)) { 610 if (! empty($keywords)) {
572 $foundChunk = $currentChunk; 611 $foundChunk = $currentChunk;
573 // Keywords use the format tag1, tag2 multiple words, tag 612 // Keywords use the format tag1, tag2 multiple words, tag
574 // So we format them to match Shaarli's separator and glue multiple words with '-' 613 // So we split the result with `,`, then if a tag contains the separator we replace it by `-`.
575 $keywords = implode(' ', array_map(function($keyword) { 614 $keywords = tags_array2str(array_map(function (string $keyword) use ($tagsSeparator): string {
576 return implode('-', preg_split('/\s+/', trim($keyword))); 615 return tags_array2str(tags_str2array($keyword, $tagsSeparator), '-');
577 }, explode(',', $keywords))); 616 }, tags_str2array($keywords, ',')), $tagsSeparator);
578 } 617 }
579 } 618 }
580 619
@@ -582,7 +621,8 @@ function get_curl_download_callback(
582 // If we already found either the title, description or keywords, 621 // If we already found either the title, description or keywords,
583 // it's highly unlikely that we'll found the other metas further than 622 // it's highly unlikely that we'll found the other metas further than
584 // in the same chunk of data or the next one. So we also stop the download after that. 623 // in the same chunk of data or the next one. So we also stop the download after that.
585 if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null 624 if (
625 (!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
586 && (! $retrieveDescription 626 && (! $retrieveDescription
587 || $foundChunk < $currentChunk 627 || $foundChunk < $currentChunk
588 || (!empty($title) && !empty($description) && !empty($keywords)) 628 || (!empty($title) && !empty($description) && !empty($keywords))
@@ -591,6 +631,6 @@ function get_curl_download_callback(
591 return false; 631 return false;
592 } 632 }
593 633
594 return strlen($data); 634 return $chunkLength;
595 }; 635 };
596} 636}
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php
new file mode 100644
index 00000000..cfc72583
--- /dev/null
+++ b/application/http/MetadataRetriever.php
@@ -0,0 +1,74 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Http;
6
7use Shaarli\Config\ConfigManager;
8
9/**
10 * HTTP Tool used to extract metadata from external URL (title, description, etc.).
11 */
12class MetadataRetriever
13{
14 /** @var ConfigManager */
15 protected $conf;
16
17 /** @var HttpAccess */
18 protected $httpAccess;
19
20 public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
21 {
22 $this->conf = $conf;
23 $this->httpAccess = $httpAccess;
24 }
25
26 /**
27 * Retrieve metadata for given URL.
28 *
29 * @return array [
30 * 'title' => <remote title>,
31 * 'description' => <remote description>,
32 * 'tags' => <remote keywords>,
33 * ]
34 */
35 public function retrieve(string $url): array
36 {
37 $charset = null;
38 $title = null;
39 $description = null;
40 $tags = null;
41
42 // Short timeout to keep the application responsive
43 // The callback will fill $charset and $title with data from the downloaded page.
44 $this->httpAccess->getHttpResponse(
45 $url,
46 $this->conf->get('general.download_timeout', 30),
47 $this->conf->get('general.download_max_size', 4194304),
48 $this->httpAccess->getCurlHeaderCallback($charset),
49 $this->httpAccess->getCurlDownloadCallback(
50 $charset,
51 $title,
52 $description,
53 $tags,
54 $this->conf->get('general.retrieve_description'),
55 $this->conf->get('general.tags_separator', ' ')
56 )
57 );
58
59 if (!empty($title) && strtolower($charset) !== 'utf-8') {
60 $title = mb_convert_encoding($title, 'utf-8', $charset);
61 }
62
63 return array_map([$this, 'cleanMetadata'], [
64 'title' => $title,
65 'description' => $description,
66 'tags' => $tags,
67 ]);
68 }
69
70 protected function cleanMetadata($data): ?string
71 {
72 return !is_string($data) || empty(trim($data)) ? null : trim($data);
73 }
74}
diff --git a/application/http/Url.php b/application/http/Url.php
index 90444a2f..fe87088f 100644
--- a/application/http/Url.php
+++ b/application/http/Url.php
@@ -17,7 +17,7 @@ namespace Shaarli\Http;
17 */ 17 */
18class Url 18class Url
19{ 19{
20 private static $annoyingQueryParams = array( 20 private static $annoyingQueryParams = [
21 // Facebook 21 // Facebook
22 'action_object_map=', 22 'action_object_map=',
23 'action_ref_map=', 23 'action_ref_map=',
@@ -37,15 +37,15 @@ class Url
37 37
38 // Other 38 // Other
39 'campaign_' 39 'campaign_'
40 ); 40 ];
41 41
42 private static $annoyingFragments = array( 42 private static $annoyingFragments = [
43 // ATInternet 43 // ATInternet
44 'xtor=RSS-', 44 'xtor=RSS-',
45 45
46 // Misc. 46 // Misc.
47 'tk.rss_all' 47 'tk.rss_all'
48 ); 48 ];
49 49
50 /* 50 /*
51 * URL parts represented as an array 51 * URL parts represented as an array
@@ -120,7 +120,7 @@ class Url
120 foreach (self::$annoyingQueryParams as $annoying) { 120 foreach (self::$annoyingQueryParams as $annoying) {
121 foreach ($queryParams as $param) { 121 foreach ($queryParams as $param) {
122 if (startsWith($param, $annoying)) { 122 if (startsWith($param, $annoying)) {
123 $queryParams = array_diff($queryParams, array($param)); 123 $queryParams = array_diff($queryParams, [$param]);
124 continue; 124 continue;
125 } 125 }
126 } 126 }
diff --git a/application/http/UrlUtils.php b/application/http/UrlUtils.php
index e8d1a283..de5b7db1 100644
--- a/application/http/UrlUtils.php
+++ b/application/http/UrlUtils.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Converts an array-represented URL to a string 4 * Converts an array-represented URL to a string
4 * 5 *
@@ -12,15 +13,15 @@
12 */ 13 */
13function unparse_url($parsedUrl) 14function unparse_url($parsedUrl)
14{ 15{
15 $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'].'://' : ''; 16 $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : '';
16 $host = isset($parsedUrl['host']) ? $parsedUrl['host'] : ''; 17 $host = isset($parsedUrl['host']) ? $parsedUrl['host'] : '';
17 $port = isset($parsedUrl['port']) ? ':'.$parsedUrl['port'] : ''; 18 $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '';
18 $user = isset($parsedUrl['user']) ? $parsedUrl['user'] : ''; 19 $user = isset($parsedUrl['user']) ? $parsedUrl['user'] : '';
19 $pass = isset($parsedUrl['pass']) ? ':'.$parsedUrl['pass'] : ''; 20 $pass = isset($parsedUrl['pass']) ? ':' . $parsedUrl['pass'] : '';
20 $pass = ($user || $pass) ? "$pass@" : ''; 21 $pass = ($user || $pass) ? "$pass@" : '';
21 $path = isset($parsedUrl['path']) ? $parsedUrl['path'] : ''; 22 $path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '';
22 $query = isset($parsedUrl['query']) ? '?'.$parsedUrl['query'] : ''; 23 $query = isset($parsedUrl['query']) ? '?' . $parsedUrl['query'] : '';
23 $fragment = isset($parsedUrl['fragment']) ? '#'.$parsedUrl['fragment'] : ''; 24 $fragment = isset($parsedUrl['fragment']) ? '#' . $parsedUrl['fragment'] : '';
24 25
25 return "$scheme$user$pass$host$port$path$query$fragment"; 26 return "$scheme$user$pass$host$port$path$query$fragment";
26} 27}
diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php
index 826604e7..1fed418b 100644
--- a/application/legacy/LegacyController.php
+++ b/application/legacy/LegacyController.php
@@ -51,7 +51,7 @@ class LegacyController extends ShaarliVisitorController
51 51
52 if (!$this->container->loginManager->isLoggedIn()) { 52 if (!$this->container->loginManager->isLoggedIn()) {
53 $parameters = $buildParameters($request->getQueryParams(), true); 53 $parameters = $buildParameters($request->getQueryParams(), true);
54 return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters); 54 return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route . $parameters);
55 } 55 }
56 56
57 $parameters = $buildParameters($request->getQueryParams(), false); 57 $parameters = $buildParameters($request->getQueryParams(), false);
diff --git a/application/legacy/LegacyLinkDB.php b/application/legacy/LegacyLinkDB.php
index 7bf76fd4..d3beafe0 100644
--- a/application/legacy/LegacyLinkDB.php
+++ b/application/legacy/LegacyLinkDB.php
@@ -8,7 +8,7 @@ use DateTime;
8use Iterator; 8use Iterator;
9use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 9use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
10use Shaarli\Exceptions\IOException; 10use Shaarli\Exceptions\IOException;
11use Shaarli\FileUtils; 11use Shaarli\Helper\FileUtils;
12use Shaarli\Render\PageCacheManager; 12use Shaarli\Render\PageCacheManager;
13 13
14/** 14/**
@@ -62,7 +62,7 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess
62 private $datastore; 62 private $datastore;
63 63
64 // Link date storage format 64 // Link date storage format
65 const LINK_DATE_FORMAT = 'Ymd_His'; 65 public const LINK_DATE_FORMAT = 'Ymd_His';
66 66
67 // List of bookmarks (associative array) 67 // List of bookmarks (associative array)
68 // - key: link date (e.g. "20110823_124546"), 68 // - key: link date (e.g. "20110823_124546"),
@@ -240,8 +240,8 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess
240 } 240 }
241 241
242 // Create a dummy database for example 242 // Create a dummy database for example
243 $this->links = array(); 243 $this->links = [];
244 $link = array( 244 $link = [
245 'id' => 1, 245 'id' => 1,
246 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), 246 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
247 'url' => 'https://shaarli.readthedocs.io', 247 'url' => 'https://shaarli.readthedocs.io',
@@ -257,11 +257,11 @@ You use the community supported version of the original Shaarli project, by Seba
257 'created' => new DateTime(), 257 'created' => new DateTime(),
258 'tags' => 'opensource software', 258 'tags' => 'opensource software',
259 'sticky' => false, 259 'sticky' => false,
260 ); 260 ];
261 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 261 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
262 $this->links[1] = $link; 262 $this->links[1] = $link;
263 263
264 $link = array( 264 $link = [
265 'id' => 0, 265 'id' => 0,
266 'title' => t('My secret stuff... - Pastebin.com'), 266 'title' => t('My secret stuff... - Pastebin.com'),
267 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', 267 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
@@ -270,7 +270,7 @@ You use the community supported version of the original Shaarli project, by Seba
270 'created' => new DateTime('1 minute ago'), 270 'created' => new DateTime('1 minute ago'),
271 'tags' => 'secretstuff', 271 'tags' => 'secretstuff',
272 'sticky' => false, 272 'sticky' => false,
273 ); 273 ];
274 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 274 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
275 $this->links[0] = $link; 275 $this->links[0] = $link;
276 276
@@ -285,7 +285,7 @@ You use the community supported version of the original Shaarli project, by Seba
285 { 285 {
286 // Public bookmarks are hidden and user not logged in => nothing to show 286 // Public bookmarks are hidden and user not logged in => nothing to show
287 if ($this->hidePublicLinks && !$this->loggedIn) { 287 if ($this->hidePublicLinks && !$this->loggedIn) {
288 $this->links = array(); 288 $this->links = [];
289 return; 289 return;
290 } 290 }
291 291
@@ -293,7 +293,7 @@ You use the community supported version of the original Shaarli project, by Seba
293 $this->ids = []; 293 $this->ids = [];
294 $this->links = FileUtils::readFlatDB($this->datastore, []); 294 $this->links = FileUtils::readFlatDB($this->datastore, []);
295 295
296 $toremove = array(); 296 $toremove = [];
297 foreach ($this->links as $key => &$link) { 297 foreach ($this->links as $key => &$link) {
298 if (!$this->loggedIn && $link['private'] != 0) { 298 if (!$this->loggedIn && $link['private'] != 0) {
299 // Transition for not upgraded databases. 299 // Transition for not upgraded databases.
@@ -414,7 +414,7 @@ You use the community supported version of the original Shaarli project, by Seba
414 * @return array filtered bookmarks, all bookmarks if no suitable filter was provided. 414 * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
415 */ 415 */
416 public function filterSearch( 416 public function filterSearch(
417 $filterRequest = array(), 417 $filterRequest = [],
418 $casesensitive = false, 418 $casesensitive = false,
419 $visibility = 'all', 419 $visibility = 'all',
420 $untaggedonly = false 420 $untaggedonly = false
@@ -512,7 +512,7 @@ You use the community supported version of the original Shaarli project, by Seba
512 */ 512 */
513 public function days() 513 public function days()
514 { 514 {
515 $linkDays = array(); 515 $linkDays = [];
516 foreach ($this->links as $link) { 516 foreach ($this->links as $link) {
517 $linkDays[$link['created']->format('Ymd')] = 0; 517 $linkDays[$link['created']->format('Ymd')] = 0;
518 } 518 }
diff --git a/application/legacy/LegacyLinkFilter.php b/application/legacy/LegacyLinkFilter.php
index 7cf93d60..e6d186c4 100644
--- a/application/legacy/LegacyLinkFilter.php
+++ b/application/legacy/LegacyLinkFilter.php
@@ -120,7 +120,7 @@ class LegacyLinkFilter
120 return $this->links; 120 return $this->links;
121 } 121 }
122 122
123 $out = array(); 123 $out = [];
124 foreach ($this->links as $key => $value) { 124 foreach ($this->links as $key => $value) {
125 if ($value['private'] && $visibility === 'private') { 125 if ($value['private'] && $visibility === 'private') {
126 $out[$key] = $value; 126 $out[$key] = $value;
@@ -143,7 +143,7 @@ class LegacyLinkFilter
143 */ 143 */
144 private function filterSmallHash($smallHash) 144 private function filterSmallHash($smallHash)
145 { 145 {
146 $filtered = array(); 146 $filtered = [];
147 foreach ($this->links as $key => $l) { 147 foreach ($this->links as $key => $l) {
148 if ($smallHash == $l['shorturl']) { 148 if ($smallHash == $l['shorturl']) {
149 // Yes, this is ugly and slow 149 // Yes, this is ugly and slow
@@ -186,7 +186,7 @@ class LegacyLinkFilter
186 return $this->noFilter($visibility); 186 return $this->noFilter($visibility);
187 } 187 }
188 188
189 $filtered = array(); 189 $filtered = [];
190 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); 190 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
191 $exactRegex = '/"([^"]+)"/'; 191 $exactRegex = '/"([^"]+)"/';
192 // Retrieve exact search terms. 192 // Retrieve exact search terms.
@@ -198,8 +198,8 @@ class LegacyLinkFilter
198 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); 198 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
199 199
200 // Filter excluding terms and update andSearch. 200 // Filter excluding terms and update andSearch.
201 $excludeSearch = array(); 201 $excludeSearch = [];
202 $andSearch = array(); 202 $andSearch = [];
203 foreach ($explodedSearchAnd as $needle) { 203 foreach ($explodedSearchAnd as $needle) {
204 if ($needle[0] == '-' && strlen($needle) > 1) { 204 if ($needle[0] == '-' && strlen($needle) > 1) {
205 $excludeSearch[] = substr($needle, 1); 205 $excludeSearch[] = substr($needle, 1);
@@ -208,7 +208,7 @@ class LegacyLinkFilter
208 } 208 }
209 } 209 }
210 210
211 $keys = array('title', 'description', 'url', 'tags'); 211 $keys = ['title', 'description', 'url', 'tags'];
212 212
213 // Iterate over every stored link. 213 // Iterate over every stored link.
214 foreach ($this->links as $id => $link) { 214 foreach ($this->links as $id => $link) {
@@ -336,7 +336,7 @@ class LegacyLinkFilter
336 } 336 }
337 337
338 // create resulting array 338 // create resulting array
339 $filtered = array(); 339 $filtered = [];
340 340
341 // iterate over each link 341 // iterate over each link
342 foreach ($this->links as $key => $link) { 342 foreach ($this->links as $key => $link) {
@@ -352,7 +352,7 @@ class LegacyLinkFilter
352 $search = $link['tags']; // build search string, start with tags of current link 352 $search = $link['tags']; // build search string, start with tags of current link
353 if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { 353 if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) {
354 // description given and at least one possible tag found 354 // description given and at least one possible tag found
355 $descTags = array(); 355 $descTags = [];
356 // find all tags in the form of #tag in the description 356 // find all tags in the form of #tag in the description
357 preg_match_all( 357 preg_match_all(
358 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', 358 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -419,7 +419,7 @@ class LegacyLinkFilter
419 throw new Exception('Invalid date format'); 419 throw new Exception('Invalid date format');
420 } 420 }
421 421
422 $filtered = array(); 422 $filtered = [];
423 foreach ($this->links as $key => $l) { 423 foreach ($this->links as $key => $l) {
424 if ($l['created']->format('Ymd') == $day) { 424 if ($l['created']->format('Ymd') == $day) {
425 $filtered[$key] = $l; 425 $filtered[$key] = $l;
diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php
index 0ab3a55b..9bda54b8 100644
--- a/application/legacy/LegacyUpdater.php
+++ b/application/legacy/LegacyUpdater.php
@@ -7,7 +7,6 @@ use RainTPL;
7use ReflectionClass; 7use ReflectionClass;
8use ReflectionException; 8use ReflectionException;
9use ReflectionMethod; 9use ReflectionMethod;
10use Shaarli\ApplicationUtils;
11use Shaarli\Bookmark\Bookmark; 10use Shaarli\Bookmark\Bookmark;
12use Shaarli\Bookmark\BookmarkArray; 11use Shaarli\Bookmark\BookmarkArray;
13use Shaarli\Bookmark\BookmarkFilter; 12use Shaarli\Bookmark\BookmarkFilter;
@@ -17,6 +16,7 @@ use Shaarli\Config\ConfigJson;
17use Shaarli\Config\ConfigManager; 16use Shaarli\Config\ConfigManager;
18use Shaarli\Config\ConfigPhp; 17use Shaarli\Config\ConfigPhp;
19use Shaarli\Exceptions\IOException; 18use Shaarli\Exceptions\IOException;
19use Shaarli\Helper\ApplicationUtils;
20use Shaarli\Thumbnailer; 20use Shaarli\Thumbnailer;
21use Shaarli\Updater\Exception\UpdaterException; 21use Shaarli\Updater\Exception\UpdaterException;
22 22
@@ -93,7 +93,7 @@ class LegacyUpdater
93 */ 93 */
94 public function update() 94 public function update()
95 { 95 {
96 $updatesRan = array(); 96 $updatesRan = [];
97 97
98 // If the user isn't logged in, exit without updating. 98 // If the user isn't logged in, exit without updating.
99 if ($this->isLoggedIn !== true) { 99 if ($this->isLoggedIn !== true) {
@@ -106,7 +106,8 @@ class LegacyUpdater
106 106
107 foreach ($this->methods as $method) { 107 foreach ($this->methods as $method) {
108 // Not an update method or already done, pass. 108 // Not an update method or already done, pass.
109 if (!startsWith($method->getName(), 'updateMethod') 109 if (
110 !startsWith($method->getName(), 'updateMethod')
110 || in_array($method->getName(), $this->doneUpdates) 111 || in_array($method->getName(), $this->doneUpdates)
111 ) { 112 ) {
112 continue; 113 continue;
@@ -189,7 +190,7 @@ class LegacyUpdater
189 } 190 }
190 191
191 // Set sub config keys (config and plugins) 192 // Set sub config keys (config and plugins)
192 $subConfig = array('config', 'plugins'); 193 $subConfig = ['config', 'plugins'];
193 foreach ($subConfig as $sub) { 194 foreach ($subConfig as $sub) {
194 foreach ($oldConfig[$sub] as $key => $value) { 195 foreach ($oldConfig[$sub] as $key => $value) {
195 if (isset($legacyMap[$sub . '.' . $key])) { 196 if (isset($legacyMap[$sub . '.' . $key])) {
@@ -259,7 +260,7 @@ class LegacyUpdater
259 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php'; 260 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
260 copy($this->conf->get('resource.datastore'), $save); 261 copy($this->conf->get('resource.datastore'), $save);
261 262
262 $links = array(); 263 $links = [];
263 foreach ($this->linkDB as $offset => $value) { 264 foreach ($this->linkDB as $offset => $value) {
264 $links[] = $value; 265 $links[] = $value;
265 unset($this->linkDB[$offset]); 266 unset($this->linkDB[$offset]);
@@ -498,7 +499,8 @@ class LegacyUpdater
498 */ 499 */
499 public function updateMethodDownloadSizeAndTimeoutConf() 500 public function updateMethodDownloadSizeAndTimeoutConf()
500 { 501 {
501 if ($this->conf->exists('general.download_max_size') 502 if (
503 $this->conf->exists('general.download_max_size')
502 && $this->conf->exists('general.download_timeout') 504 && $this->conf->exists('general.download_timeout')
503 ) { 505 ) {
504 return true; 506 return true;
@@ -585,7 +587,7 @@ class LegacyUpdater
585 587
586 $linksArray = new BookmarkArray(); 588 $linksArray = new BookmarkArray();
587 foreach ($this->linkDB as $key => $link) { 589 foreach ($this->linkDB as $key => $link) {
588 $linksArray[$key] = (new Bookmark())->fromArray($link); 590 $linksArray[$key] = (new Bookmark())->fromArray($link, $this->conf->get('general.tags_separator', ' '));
589 } 591 }
590 $linksIo = new BookmarkIO($this->conf); 592 $linksIo = new BookmarkIO($this->conf);
591 $linksIo->write($linksArray); 593 $linksIo->write($linksArray);
diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php
index b83f16f8..2d97b4c8 100644
--- a/application/netscape/NetscapeBookmarkUtils.php
+++ b/application/netscape/NetscapeBookmarkUtils.php
@@ -59,11 +59,11 @@ class NetscapeBookmarkUtils
59 $indexUrl 59 $indexUrl
60 ) { 60 ) {
61 // see tpl/export.html for possible values 61 // see tpl/export.html for possible values
62 if (!in_array($selection, array('all', 'public', 'private'))) { 62 if (!in_array($selection, ['all', 'public', 'private'])) {
63 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); 63 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"');
64 } 64 }
65 65
66 $bookmarkLinks = array(); 66 $bookmarkLinks = [];
67 foreach ($this->bookmarkService->search([], $selection) as $bookmark) { 67 foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
68 $link = $formatter->format($bookmark); 68 $link = $formatter->format($bookmark);
69 $link['taglist'] = implode(',', $bookmark->getTags()); 69 $link['taglist'] = implode(',', $bookmark->getTags());
@@ -101,11 +101,11 @@ class NetscapeBookmarkUtils
101 101
102 // Add tags to all imported bookmarks? 102 // Add tags to all imported bookmarks?
103 if (empty($post['default_tags'])) { 103 if (empty($post['default_tags'])) {
104 $defaultTags = array(); 104 $defaultTags = [];
105 } else { 105 } else {
106 $defaultTags = preg_split( 106 $defaultTags = tags_str2array(
107 '/[\s,]+/', 107 escape($post['default_tags']),
108 escape($post['default_tags']) 108 $this->conf->get('general.tags_separator', ' ')
109 ); 109 );
110 } 110 }
111 111
@@ -171,7 +171,7 @@ class NetscapeBookmarkUtils
171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols')); 171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
172 $link->setDescription($bkm['note']); 172 $link->setDescription($bkm['note']);
173 $link->setPrivate($private); 173 $link->setPrivate($private);
174 $link->setTagsString($bkm['tags']); 174 $link->setTags($bkm['tags']);
175 175
176 $this->bookmarkService->addOrSet($link, false); 176 $this->bookmarkService->addOrSet($link, false);
177 $importCount++; 177 $importCount++;
diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php
index da66dea3..7fc0cb04 100644
--- a/application/plugin/PluginManager.php
+++ b/application/plugin/PluginManager.php
@@ -1,8 +1,10 @@
1<?php 1<?php
2
2namespace Shaarli\Plugin; 3namespace Shaarli\Plugin;
3 4
4use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
5use Shaarli\Plugin\Exception\PluginFileNotFoundException; 6use Shaarli\Plugin\Exception\PluginFileNotFoundException;
7use Shaarli\Plugin\Exception\PluginInvalidRouteException;
6 8
7/** 9/**
8 * Class PluginManager 10 * Class PluginManager
@@ -23,7 +25,15 @@ class PluginManager
23 * 25 *
24 * @var array $loadedPlugins 26 * @var array $loadedPlugins
25 */ 27 */
26 private $loadedPlugins = array(); 28 private $loadedPlugins = [];
29
30 /** @var array List of registered routes. Contains keys:
31 * - `method`: HTTP method, GET/POST/PUT/PATCH/DELETE
32 * - `route` (path): without prefix, e.g. `/up/{variable}`
33 * It will be later prefixed by `/plugin/<plugin name>/`.
34 * - `callable` string, function name or FQN class's method, e.g. `demo_plugin_custom_controller`.
35 */
36 protected $registeredRoutes = [];
27 37
28 /** 38 /**
29 * @var ConfigManager Configuration Manager instance. 39 * @var ConfigManager Configuration Manager instance.
@@ -57,7 +67,7 @@ class PluginManager
57 public function __construct(&$conf) 67 public function __construct(&$conf)
58 { 68 {
59 $this->conf = $conf; 69 $this->conf = $conf;
60 $this->errors = array(); 70 $this->errors = [];
61 } 71 }
62 72
63 /** 73 /**
@@ -85,6 +95,9 @@ class PluginManager
85 $this->loadPlugin($dirs[$index], $plugin); 95 $this->loadPlugin($dirs[$index], $plugin);
86 } catch (PluginFileNotFoundException $e) { 96 } catch (PluginFileNotFoundException $e) {
87 error_log($e->getMessage()); 97 error_log($e->getMessage());
98 } catch (\Throwable $e) {
99 $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
100 $this->errors = array_unique(array_merge($this->errors, [$error]));
88 } 101 }
89 } 102 }
90 } 103 }
@@ -98,7 +111,7 @@ class PluginManager
98 * 111 *
99 * @return void 112 * @return void
100 */ 113 */
101 public function executeHooks($hook, &$data, $params = array()) 114 public function executeHooks($hook, &$data, $params = [])
102 { 115 {
103 $metadataParameters = [ 116 $metadataParameters = [
104 'target' => '_PAGE_', 117 'target' => '_PAGE_',
@@ -165,6 +178,22 @@ class PluginManager
165 } 178 }
166 } 179 }
167 180
181 $registerRouteFunction = $pluginName . '_register_routes';
182 $routes = null;
183 if (function_exists($registerRouteFunction)) {
184 $routes = call_user_func($registerRouteFunction);
185 }
186
187 if ($routes !== null) {
188 foreach ($routes as $route) {
189 if (static::validateRouteRegistration($route)) {
190 $this->registeredRoutes[$pluginName][] = $route;
191 } else {
192 throw new PluginInvalidRouteException($pluginName);
193 }
194 }
195 }
196
168 $this->loadedPlugins[] = $pluginName; 197 $this->loadedPlugins[] = $pluginName;
169 } 198 }
170 199
@@ -196,7 +225,7 @@ class PluginManager
196 */ 225 */
197 public function getPluginsMeta() 226 public function getPluginsMeta()
198 { 227 {
199 $metaData = array(); 228 $metaData = [];
200 $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); 229 $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
201 230
202 // Browse all plugin directories. 231 // Browse all plugin directories.
@@ -217,9 +246,9 @@ class PluginManager
217 if (isset($metaData[$plugin]['parameters'])) { 246 if (isset($metaData[$plugin]['parameters'])) {
218 $params = explode(';', $metaData[$plugin]['parameters']); 247 $params = explode(';', $metaData[$plugin]['parameters']);
219 } else { 248 } else {
220 $params = array(); 249 $params = [];
221 } 250 }
222 $metaData[$plugin]['parameters'] = array(); 251 $metaData[$plugin]['parameters'] = [];
223 foreach ($params as $param) { 252 foreach ($params as $param) {
224 if (empty($param)) { 253 if (empty($param)) {
225 continue; 254 continue;
@@ -237,6 +266,14 @@ class PluginManager
237 } 266 }
238 267
239 /** 268 /**
269 * @return array List of registered custom routes by plugins.
270 */
271 public function getRegisteredRoutes(): array
272 {
273 return $this->registeredRoutes;
274 }
275
276 /**
240 * Return the list of encountered errors. 277 * Return the list of encountered errors.
241 * 278 *
242 * @return array List of errors (empty array if none exists). 279 * @return array List of errors (empty array if none exists).
@@ -245,4 +282,32 @@ class PluginManager
245 { 282 {
246 return $this->errors; 283 return $this->errors;
247 } 284 }
285
286 /**
287 * Checks whether provided input is valid to register a new route.
288 * It must contain keys `method`, `route`, `callable` (all strings).
289 *
290 * @param string[] $input
291 *
292 * @return bool
293 */
294 protected static function validateRouteRegistration(array $input): bool
295 {
296 if (
297 !array_key_exists('method', $input)
298 || !in_array(strtoupper($input['method']), ['GET', 'PUT', 'PATCH', 'POST', 'DELETE'])
299 ) {
300 return false;
301 }
302
303 if (!array_key_exists('route', $input) || !preg_match('#^[a-z\d/\.\-_]+$#', $input['route'])) {
304 return false;
305 }
306
307 if (!array_key_exists('callable', $input)) {
308 return false;
309 }
310
311 return true;
312 }
248} 313}
diff --git a/application/plugin/exception/PluginFileNotFoundException.php b/application/plugin/exception/PluginFileNotFoundException.php
index e5386f02..21ac6604 100644
--- a/application/plugin/exception/PluginFileNotFoundException.php
+++ b/application/plugin/exception/PluginFileNotFoundException.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Plugin\Exception; 3namespace Shaarli\Plugin\Exception;
3 4
4use Exception; 5use Exception;
diff --git a/application/plugin/exception/PluginInvalidRouteException.php b/application/plugin/exception/PluginInvalidRouteException.php
new file mode 100644
index 00000000..6ba9bc43
--- /dev/null
+++ b/application/plugin/exception/PluginInvalidRouteException.php
@@ -0,0 +1,26 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Plugin\Exception;
6
7use Exception;
8
9/**
10 * Class PluginFileNotFoundException
11 *
12 * Raise when plugin files can't be found.
13 */
14class PluginInvalidRouteException extends Exception
15{
16 /**
17 * Construct exception with plugin name.
18 * Generate message.
19 *
20 * @param string $pluginName name of the plugin not found
21 */
22 public function __construct()
23 {
24 $this->message = 'trying to register invalid route.';
25 }
26}
diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php
index 2d6d2dbe..bf0ae326 100644
--- a/application/render/PageBuilder.php
+++ b/application/render/PageBuilder.php
@@ -3,11 +3,11 @@
3namespace Shaarli\Render; 3namespace Shaarli\Render;
4 4
5use Exception; 5use Exception;
6use exceptions\MissingBasePathException; 6use Psr\Log\LoggerInterface;
7use RainTPL; 7use RainTPL;
8use Shaarli\ApplicationUtils;
9use Shaarli\Bookmark\BookmarkServiceInterface; 8use Shaarli\Bookmark\BookmarkServiceInterface;
10use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
10use Shaarli\Helper\ApplicationUtils;
11use Shaarli\Security\SessionManager; 11use Shaarli\Security\SessionManager;
12use Shaarli\Thumbnailer; 12use Shaarli\Thumbnailer;
13 13
@@ -35,6 +35,9 @@ class PageBuilder
35 */ 35 */
36 protected $session; 36 protected $session;
37 37
38 /** @var LoggerInterface */
39 protected $logger;
40
38 /** 41 /**
39 * @var BookmarkServiceInterface $bookmarkService instance. 42 * @var BookmarkServiceInterface $bookmarkService instance.
40 */ 43 */
@@ -54,17 +57,25 @@ class PageBuilder
54 * PageBuilder constructor. 57 * PageBuilder constructor.
55 * $tpl is initialized at false for lazy loading. 58 * $tpl is initialized at false for lazy loading.
56 * 59 *
57 * @param ConfigManager $conf Configuration Manager instance (reference). 60 * @param ConfigManager $conf Configuration Manager instance (reference).
58 * @param array $session $_SESSION array 61 * @param array $session $_SESSION array
59 * @param BookmarkServiceInterface $linkDB instance. 62 * @param LoggerInterface $logger
60 * @param string $token Session token 63 * @param null $linkDB instance.
61 * @param bool $isLoggedIn 64 * @param null $token Session token
65 * @param bool $isLoggedIn
62 */ 66 */
63 public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) 67 public function __construct(
64 { 68 ConfigManager &$conf,
69 array $session,
70 LoggerInterface $logger,
71 $linkDB = null,
72 $token = null,
73 $isLoggedIn = false
74 ) {
65 $this->tpl = false; 75 $this->tpl = false;
66 $this->conf = $conf; 76 $this->conf = $conf;
67 $this->session = $session; 77 $this->session = $session;
78 $this->logger = $logger;
68 $this->bookmarkService = $linkDB; 79 $this->bookmarkService = $linkDB;
69 $this->token = $token; 80 $this->token = $token;
70 $this->isLoggedIn = $isLoggedIn; 81 $this->isLoggedIn = $isLoggedIn;
@@ -98,7 +109,7 @@ class PageBuilder
98 $this->tpl->assign('newVersion', escape($version)); 109 $this->tpl->assign('newVersion', escape($version));
99 $this->tpl->assign('versionError', ''); 110 $this->tpl->assign('versionError', '');
100 } catch (Exception $exc) { 111 } catch (Exception $exc) {
101 logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); 112 $this->logger->error(format_log('Error: ' . $exc->getMessage(), client_ip_id($_SERVER)));
102 $this->tpl->assign('newVersion', ''); 113 $this->tpl->assign('newVersion', '');
103 $this->tpl->assign('versionError', escape($exc->getMessage())); 114 $this->tpl->assign('versionError', escape($exc->getMessage()));
104 } 115 }
@@ -149,7 +160,8 @@ class PageBuilder
149 160
150 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); 161 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
151 162
152 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE']); 163 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
164 $this->tpl->assign('tags_separator', $this->conf->get('general.tags_separator', ' '));
153 165
154 // To be removed with a proper theme configuration. 166 // To be removed with a proper theme configuration.
155 $this->tpl->assign('conf', $this->conf); 167 $this->tpl->assign('conf', $this->conf);
diff --git a/application/render/PageCacheManager.php b/application/render/PageCacheManager.php
index 97805c35..fe74bf27 100644
--- a/application/render/PageCacheManager.php
+++ b/application/render/PageCacheManager.php
@@ -2,6 +2,7 @@
2 2
3namespace Shaarli\Render; 3namespace Shaarli\Render;
4 4
5use DatePeriod;
5use Shaarli\Feed\CachedPage; 6use Shaarli\Feed\CachedPage;
6 7
7/** 8/**
@@ -49,12 +50,21 @@ class PageCacheManager
49 $this->purgeCachedPages(); 50 $this->purgeCachedPages();
50 } 51 }
51 52
52 public function getCachePage(string $pageUrl): CachedPage 53 /**
54 * Get CachedPage instance for provided URL.
55 *
56 * @param string $pageUrl
57 * @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
58 *
59 * @return CachedPage
60 */
61 public function getCachePage(string $pageUrl, DatePeriod $validityPeriod = null): CachedPage
53 { 62 {
54 return new CachedPage( 63 return new CachedPage(
55 $this->pageCacheDir, 64 $this->pageCacheDir,
56 $pageUrl, 65 $pageUrl,
57 false === $this->isLoggedIn 66 false === $this->isLoggedIn,
67 $validityPeriod
58 ); 68 );
59 } 69 }
60} 70}
diff --git a/application/render/TemplatePage.php b/application/render/TemplatePage.php
index 8af8228a..03b424f3 100644
--- a/application/render/TemplatePage.php
+++ b/application/render/TemplatePage.php
@@ -14,6 +14,7 @@ interface TemplatePage
14 public const DAILY = 'daily'; 14 public const DAILY = 'daily';
15 public const DAILY_RSS = 'dailyrss'; 15 public const DAILY_RSS = 'dailyrss';
16 public const EDIT_LINK = 'editlink'; 16 public const EDIT_LINK = 'editlink';
17 public const EDIT_LINK_BATCH = 'editlink.batch';
17 public const ERROR = 'error'; 18 public const ERROR = 'error';
18 public const EXPORT = 'export'; 19 public const EXPORT = 'export';
19 public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks'; 20 public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';
diff --git a/application/render/ThemeUtils.php b/application/render/ThemeUtils.php
index 86096c64..18471f0a 100644
--- a/application/render/ThemeUtils.php
+++ b/application/render/ThemeUtils.php
@@ -23,10 +23,10 @@ class ThemeUtils
23 public static function getThemes($tplDir) 23 public static function getThemes($tplDir)
24 { 24 {
25 $tplDir = rtrim($tplDir, '/'); 25 $tplDir = rtrim($tplDir, '/');
26 $allTheme = glob($tplDir.'/*', GLOB_ONLYDIR); 26 $allTheme = glob($tplDir . '/*', GLOB_ONLYDIR);
27 $themes = []; 27 $themes = [];
28 foreach ($allTheme as $value) { 28 foreach ($allTheme as $value) {
29 $themes[] = str_replace($tplDir.'/', '', $value); 29 $themes[] = str_replace($tplDir . '/', '', $value);
30 } 30 }
31 31
32 return $themes; 32 return $themes;
diff --git a/application/security/BanManager.php b/application/security/BanManager.php
index 68190c54..7077af5b 100644
--- a/application/security/BanManager.php
+++ b/application/security/BanManager.php
@@ -1,9 +1,9 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Security; 3namespace Shaarli\Security;
5 4
6use Shaarli\FileUtils; 5use Psr\Log\LoggerInterface;
6use Shaarli\Helper\FileUtils;
7 7
8/** 8/**
9 * Class BanManager 9 * Class BanManager
@@ -28,8 +28,8 @@ class BanManager
28 /** @var string Path to the file containing IP bans and failures */ 28 /** @var string Path to the file containing IP bans and failures */
29 protected $banFile; 29 protected $banFile;
30 30
31 /** @var string Path to the log file, used to log bans */ 31 /** @var LoggerInterface Path to the log file, used to log bans */
32 protected $logFile; 32 protected $logger;
33 33
34 /** @var array List of IP with their associated number of failed attempts */ 34 /** @var array List of IP with their associated number of failed attempts */
35 protected $failures = []; 35 protected $failures = [];
@@ -40,18 +40,20 @@ class BanManager
40 /** 40 /**
41 * BanManager constructor. 41 * BanManager constructor.
42 * 42 *
43 * @param array $trustedProxies List of allowed proxies IP 43 * @param array $trustedProxies List of allowed proxies IP
44 * @param int $nbAttempts Number of allowed failed attempt before the ban 44 * @param int $nbAttempts Number of allowed failed attempt before the ban
45 * @param int $banDuration Ban duration in seconds 45 * @param int $banDuration Ban duration in seconds
46 * @param string $banFile Path to the file containing IP bans and failures 46 * @param string $banFile Path to the file containing IP bans and failures
47 * @param string $logFile Path to the log file, used to log bans 47 * @param LoggerInterface $logger PSR-3 logger to save login attempts in log directory
48 */ 48 */
49 public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, $logFile) { 49 public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, LoggerInterface $logger)
50 {
50 $this->trustedProxies = $trustedProxies; 51 $this->trustedProxies = $trustedProxies;
51 $this->nbAttempts = $nbAttempts; 52 $this->nbAttempts = $nbAttempts;
52 $this->banDuration = $banDuration; 53 $this->banDuration = $banDuration;
53 $this->banFile = $banFile; 54 $this->banFile = $banFile;
54 $this->logFile = $logFile; 55 $this->logger = $logger;
56
55 $this->readBanFile(); 57 $this->readBanFile();
56 } 58 }
57 59
@@ -78,11 +80,7 @@ class BanManager
78 80
79 if ($this->failures[$ip] >= $this->nbAttempts) { 81 if ($this->failures[$ip] >= $this->nbAttempts) {
80 $this->bans[$ip] = time() + $this->banDuration; 82 $this->bans[$ip] = time() + $this->banDuration;
81 logm( 83 $this->logger->info(format_log('IP address banned from login: ' . $ip, $ip));
82 $this->logFile,
83 $server['REMOTE_ADDR'],
84 'IP address banned from login: '. $ip
85 );
86 } 84 }
87 $this->writeBanFile(); 85 $this->writeBanFile();
88 } 86 }
@@ -138,7 +136,7 @@ class BanManager
138 unset($this->failures[$ip]); 136 unset($this->failures[$ip]);
139 } 137 }
140 unset($this->bans[$ip]); 138 unset($this->bans[$ip]);
141 logm($this->logFile, $server['REMOTE_ADDR'], 'Ban lifted for: '. $ip); 139 $this->logger->info(format_log('Ban lifted for: ' . $ip, $ip));
142 140
143 $this->writeBanFile(); 141 $this->writeBanFile();
144 return false; 142 return false;
diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php
index 65048f10..b795b80e 100644
--- a/application/security/LoginManager.php
+++ b/application/security/LoginManager.php
@@ -1,7 +1,9 @@
1<?php 1<?php
2
2namespace Shaarli\Security; 3namespace Shaarli\Security;
3 4
4use Exception; 5use Exception;
6use Psr\Log\LoggerInterface;
5use Shaarli\Config\ConfigManager; 7use Shaarli\Config\ConfigManager;
6 8
7/** 9/**
@@ -31,26 +33,30 @@ class LoginManager
31 protected $staySignedInToken = ''; 33 protected $staySignedInToken = '';
32 /** @var CookieManager */ 34 /** @var CookieManager */
33 protected $cookieManager; 35 protected $cookieManager;
36 /** @var LoggerInterface */
37 protected $logger;
34 38
35 /** 39 /**
36 * Constructor 40 * Constructor
37 * 41 *
38 * @param ConfigManager $configManager Configuration Manager instance 42 * @param ConfigManager $configManager Configuration Manager instance
39 * @param SessionManager $sessionManager SessionManager instance 43 * @param SessionManager $sessionManager SessionManager instance
40 * @param CookieManager $cookieManager CookieManager instance 44 * @param CookieManager $cookieManager CookieManager instance
45 * @param BanManager $banManager
46 * @param LoggerInterface $logger Used to log login attempts
41 */ 47 */
42 public function __construct($configManager, $sessionManager, $cookieManager) 48 public function __construct(
43 { 49 ConfigManager $configManager,
50 SessionManager $sessionManager,
51 CookieManager $cookieManager,
52 BanManager $banManager,
53 LoggerInterface $logger
54 ) {
44 $this->configManager = $configManager; 55 $this->configManager = $configManager;
45 $this->sessionManager = $sessionManager; 56 $this->sessionManager = $sessionManager;
46 $this->cookieManager = $cookieManager; 57 $this->cookieManager = $cookieManager;
47 $this->banManager = new BanManager( 58 $this->banManager = $banManager;
48 $this->configManager->get('security.trusted_proxies', []), 59 $this->logger = $logger;
49 $this->configManager->get('security.ban_after'),
50 $this->configManager->get('security.ban_duration'),
51 $this->configManager->get('resource.ban_file', 'data/ipbans.php'),
52 $this->configManager->get('resource.log')
53 );
54 60
55 if ($this->configManager->get('security.open_shaarli') === true) { 61 if ($this->configManager->get('security.open_shaarli') === true) {
56 $this->openShaarli = true; 62 $this->openShaarli = true;
@@ -101,7 +107,8 @@ class LoginManager
101 // The user client has a valid stay-signed-in cookie 107 // The user client has a valid stay-signed-in cookie
102 // Session information is updated with the current client information 108 // Session information is updated with the current client information
103 $this->sessionManager->storeLoginInfo($clientIpId); 109 $this->sessionManager->storeLoginInfo($clientIpId);
104 } elseif ($this->sessionManager->hasSessionExpired() 110 } elseif (
111 $this->sessionManager->hasSessionExpired()
105 || $this->sessionManager->hasClientIpChanged($clientIpId) 112 || $this->sessionManager->hasClientIpChanged($clientIpId)
106 ) { 113 ) {
107 $this->sessionManager->logout(); 114 $this->sessionManager->logout();
@@ -129,48 +136,35 @@ class LoginManager
129 /** 136 /**
130 * Check user credentials are valid 137 * Check user credentials are valid
131 * 138 *
132 * @param string $remoteIp Remote client IP address
133 * @param string $clientIpId Client IP address identifier 139 * @param string $clientIpId Client IP address identifier
134 * @param string $login Username 140 * @param string $login Username
135 * @param string $password Password 141 * @param string $password Password
136 * 142 *
137 * @return bool true if the provided credentials are valid, false otherwise 143 * @return bool true if the provided credentials are valid, false otherwise
138 */ 144 */
139 public function checkCredentials($remoteIp, $clientIpId, $login, $password) 145 public function checkCredentials($clientIpId, $login, $password)
140 { 146 {
141 // Check login matches config
142 if ($login !== $this->configManager->get('credentials.login')) {
143 return false;
144 }
145
146 // Check credentials 147 // Check credentials
147 try { 148 try {
148 $useLdapLogin = !empty($this->configManager->get('ldap.host')); 149 $useLdapLogin = !empty($this->configManager->get('ldap.host'));
149 if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password)) 150 if (
150 || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password)) 151 $login === $this->configManager->get('credentials.login')
152 && (
153 (false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
154 || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
155 )
151 ) { 156 ) {
152 $this->sessionManager->storeLoginInfo($clientIpId); 157 $this->sessionManager->storeLoginInfo($clientIpId);
153 logm( 158 $this->logger->info(format_log('Login successful', $clientIpId));
154 $this->configManager->get('resource.log'), 159
155 $remoteIp, 160 return true;
156 'Login successful'
157 );
158 return true;
159 } 161 }
160 } 162 } catch (Exception $exception) {
161 catch(Exception $exception) { 163 $this->logger->info(format_log('Exception while checking credentials: ' . $exception, $clientIpId));
162 logm(
163 $this->configManager->get('resource.log'),
164 $remoteIp,
165 'Exception while checking credentials: ' . $exception
166 );
167 } 164 }
168 165
169 logm( 166 $this->logger->info(format_log('Login failed for user ' . $login, $clientIpId));
170 $this->configManager->get('resource.log'), 167
171 $remoteIp,
172 'Login failed for user ' . $login
173 );
174 return false; 168 return false;
175 } 169 }
176 170
@@ -183,7 +177,8 @@ class LoginManager
183 * 177 *
184 * @return bool true if the provided credentials are valid, false otherwise 178 * @return bool true if the provided credentials are valid, false otherwise
185 */ 179 */
186 public function checkCredentialsFromLocalConfig($login, $password) { 180 public function checkCredentialsFromLocalConfig($login, $password)
181 {
187 $hash = sha1($password . $login . $this->configManager->get('credentials.salt')); 182 $hash = sha1($password . $login . $this->configManager->get('credentials.salt'));
188 183
189 return $login == $this->configManager->get('credentials.login') 184 return $login == $this->configManager->get('credentials.login')
@@ -202,14 +197,14 @@ class LoginManager
202 */ 197 */
203 public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null) 198 public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null)
204 { 199 {
205 $connect = $connect ?? function($host) { 200 $connect = $connect ?? function ($host) {
206 $resource = ldap_connect($host); 201 $resource = ldap_connect($host);
207 202
208 ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3); 203 ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3);
209 204
210 return $resource; 205 return $resource;
211 }; 206 };
212 $bind = $bind ?? function($handle, $dn, $password) { 207 $bind = $bind ?? function ($handle, $dn, $password) {
213 return ldap_bind($handle, $dn, $password); 208 return ldap_bind($handle, $dn, $password);
214 }; 209 };
215 210
diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php
index 36df8c1c..f957b91a 100644
--- a/application/security/SessionManager.php
+++ b/application/security/SessionManager.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Security; 3namespace Shaarli\Security;
3 4
4use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
@@ -79,7 +80,7 @@ class SessionManager
79 */ 80 */
80 public function generateToken() 81 public function generateToken()
81 { 82 {
82 $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt')); 83 $token = sha1(uniqid('', true) . '_' . mt_rand() . $this->conf->get('credentials.salt'));
83 $this->session['tokens'][$token] = 1; 84 $this->session['tokens'][$token] = 1;
84 return $token; 85 return $token;
85 } 86 }
@@ -293,9 +294,12 @@ class SessionManager
293 return session_start(); 294 return session_start();
294 } 295 }
295 296
296 public function cookieParameters(int $lifeTime, string $path, string $domain): bool 297 /**
298 * Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2.
299 */
300 public function cookieParameters(int $lifeTime, string $path, string $domain): void
297 { 301 {
298 return session_set_cookie_params($lifeTime, $path, $domain); 302 session_set_cookie_params($lifeTime, $path, $domain);
299 } 303 }
300 304
301 public function regenerateId(bool $deleteOldSession = false): bool 305 public function regenerateId(bool $deleteOldSession = false): bool
diff --git a/application/updater/Updater.php b/application/updater/Updater.php
index 88a7bc7b..4f557d0f 100644
--- a/application/updater/Updater.php
+++ b/application/updater/Updater.php
@@ -88,7 +88,8 @@ class Updater
88 88
89 foreach ($this->methods as $method) { 89 foreach ($this->methods as $method) {
90 // Not an update method or already done, pass. 90 // Not an update method or already done, pass.
91 if (! startsWith($method->getName(), 'updateMethod') 91 if (
92 ! startsWith($method->getName(), 'updateMethod')
92 || in_array($method->getName(), $this->doneUpdates) 93 || in_array($method->getName(), $this->doneUpdates)
93 ) { 94 ) {
94 continue; 95 continue;
@@ -121,12 +122,12 @@ class Updater
121 122
122 public function readUpdates(string $updatesFilepath): array 123 public function readUpdates(string $updatesFilepath): array
123 { 124 {
124 return UpdaterUtils::read_updates_file($updatesFilepath); 125 return UpdaterUtils::readUpdatesFile($updatesFilepath);
125 } 126 }
126 127
127 public function writeUpdates(string $updatesFilepath, array $updates): void 128 public function writeUpdates(string $updatesFilepath, array $updates): void
128 { 129 {
129 UpdaterUtils::write_updates_file($updatesFilepath, $updates); 130 UpdaterUtils::writeUpdatesFile($updatesFilepath, $updates);
130 } 131 }
131 132
132 /** 133 /**
@@ -152,7 +153,8 @@ class Updater
152 $updated = false; 153 $updated = false;
153 154
154 foreach ($this->bookmarkService->search() as $bookmark) { 155 foreach ($this->bookmarkService->search() as $bookmark) {
155 if ($bookmark->isNote() 156 if (
157 $bookmark->isNote()
156 && startsWith($bookmark->getUrl(), '?') 158 && startsWith($bookmark->getUrl(), '?')
157 && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match) 159 && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
158 ) { 160 ) {
diff --git a/application/updater/UpdaterUtils.php b/application/updater/UpdaterUtils.php
index 828a49fc..206f826e 100644
--- a/application/updater/UpdaterUtils.php
+++ b/application/updater/UpdaterUtils.php
@@ -11,7 +11,7 @@ class UpdaterUtils
11 * 11 *
12 * @return array Already done update methods. 12 * @return array Already done update methods.
13 */ 13 */
14 public static function read_updates_file($updatesFilepath) 14 public static function readUpdatesFile($updatesFilepath)
15 { 15 {
16 if (! empty($updatesFilepath) && is_file($updatesFilepath)) { 16 if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
17 $content = file_get_contents($updatesFilepath); 17 $content = file_get_contents($updatesFilepath);
@@ -19,7 +19,7 @@ class UpdaterUtils
19 return explode(';', $content); 19 return explode(';', $content);
20 } 20 }
21 } 21 }
22 return array(); 22 return [];
23 } 23 }
24 24
25 /** 25 /**
@@ -30,7 +30,7 @@ class UpdaterUtils
30 * 30 *
31 * @throws \Exception Couldn't write version number. 31 * @throws \Exception Couldn't write version number.
32 */ 32 */
33 public static function write_updates_file($updatesFilepath, $updates) 33 public static function writeUpdatesFile($updatesFilepath, $updates)
34 { 34 {
35 if (empty($updatesFilepath)) { 35 if (empty($updatesFilepath)) {
36 throw new \Exception('Updates file path is not set, can\'t write updates.'); 36 throw new \Exception('Updates file path is not set, can\'t write updates.');
@@ -38,7 +38,7 @@ class UpdaterUtils
38 38
39 $res = file_put_contents($updatesFilepath, implode(';', $updates)); 39 $res = file_put_contents($updatesFilepath, implode(';', $updates));
40 if ($res === false) { 40 if ($res === false) {
41 throw new \Exception('Unable to write updates in '. $updatesFilepath . '.'); 41 throw new \Exception('Unable to write updates in ' . $updatesFilepath . '.');
42 } 42 }
43 } 43 }
44} 44}