diff options
Diffstat (limited to 'application')
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 | |||
2 | namespace Shaarli; | 3 | namespace Shaarli; |
3 | 4 | ||
4 | use DateTime; | 5 | use DateTime; |
5 | use Exception; | 6 | use Exception; |
6 | use Shaarli\Bookmark\Bookmark; | 7 | use Shaarli\Bookmark\Bookmark; |
8 | use 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 | */ |
14 | class Thumbnailer | 14 | class 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 = '') | |||
85 | function isTimeZoneValid($continent, $city) | 86 | function 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 | */ |
15 | function logm($logFile, $clientIp, $message) | 15 | function 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 | */ |
164 | function generateLocation($referer, $host, $loopTerms = array()) | 167 | function generateLocation($referer, $host, $loopTerms = []) |
165 | { | 168 | { |
166 | $finalReferer = './?'; | 169 | $finalReferer = './?'; |
167 | 170 | ||
@@ -194,7 +197,7 @@ function generateLocation($referer, $host, $loopTerms = array()) | |||
194 | function autoLocale($headerLocale) | 197 | function 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 | */ | ||
337 | function 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 | */ |
462 | function t($text, $nText = '', $nb = 1, $domain = 'shaarli') | 486 | function 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 | */ | ||
498 | function 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 | |||
2 | namespace Shaarli\Api; | 3 | namespace Shaarli\Api; |
3 | 4 | ||
4 | use malkusch\lock\mutex\FlockMutex; | 5 | use 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 | |||
2 | namespace Shaarli\Api; | 3 | namespace Shaarli\Api; |
3 | 4 | ||
4 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | 5 | use 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 | |||
4 | namespace Shaarli\Api\Controllers; | 3 | namespace Shaarli\Api\Controllers; |
5 | 4 | ||
6 | use Shaarli\Api\Exceptions\ApiBadParametersException; | 5 | use 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; | |||
19 | class Bookmark | 19 | class 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 | ||
7 | use Exception; | 7 | use Exception; |
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | 8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
9 | use 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 | ||
5 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
6 | 6 | ||
7 | use malkusch\lock\exception\LockAcquireException; | ||
7 | use malkusch\lock\mutex\Mutex; | 8 | use malkusch\lock\mutex\Mutex; |
8 | use malkusch\lock\mutex\NoMutex; | 9 | use malkusch\lock\mutex\NoMutex; |
9 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | 10 | use 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 | */ |
18 | class BookmarkInitializer | 21 | class 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 | ||
44 | Explore your new Shaarli instance by trying out controls and menus. | 47 | Explore your new Shaarli instance by trying out controls and menus. |
45 | Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. | 48 | Visit 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. |
58 | This note is private, so you are the only one able to see it while logged in. | 61 | This note is private, so you are the only one able to see it while logged in. |
59 | 62 | ||
60 | You can use this to keep notes, post articles, code snippets, and much more. | 63 | You 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 | ||
96 | Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. | 99 | Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. |
97 | You can add a description to your bookmarks, such as this one, and tag them. | 100 | You 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 | */ |
144 | function format_description($description, $indexUrl = '') | 148 | function 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 | */ | ||
191 | function 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 | */ | ||
208 | function 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 | */ | ||
221 | function 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 | |||
2 | namespace Shaarli\Bookmark\Exception; | 3 | namespace Shaarli\Bookmark\Exception; |
3 | 4 | ||
4 | use Exception; | 5 | use 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 | |||
4 | namespace Shaarli\Bookmark\Exception; | 3 | namespace Shaarli\Bookmark\Exception; |
5 | 4 | ||
6 | 5 | class EmptyDataStoreException extends \Exception | |
7 | class 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 | |||
4 | namespace Shaarli\Bookmark\Exception; | 3 | namespace Shaarli\Bookmark\Exception; |
5 | 4 | ||
6 | |||
7 | class NotWritableDataStoreException extends \Exception | 5 | class 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 | |||
2 | namespace Shaarli\Config; | 3 | namespace 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 | |||
2 | namespace Shaarli\Config; | 3 | namespace Shaarli\Config; |
3 | 4 | ||
4 | use Shaarli\Config\Exception\MissingFieldConfigException; | 5 | use 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 | |||
2 | namespace Shaarli\Config; | 3 | namespace 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 | */ |
82 | function validate_plugin_order($formData) | 82 | function 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 | |||
4 | namespace Shaarli\Config\Exception; | 3 | namespace 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 | |||
4 | namespace Shaarli\Config\Exception; | 3 | namespace 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); | |||
5 | namespace Shaarli\Container; | 5 | namespace Shaarli\Container; |
6 | 6 | ||
7 | use malkusch\lock\mutex\FlockMutex; | 7 | use malkusch\lock\mutex\FlockMutex; |
8 | use Psr\Log\LoggerInterface; | ||
8 | use Shaarli\Bookmark\BookmarkFileService; | 9 | use Shaarli\Bookmark\BookmarkFileService; |
9 | use Shaarli\Bookmark\BookmarkServiceInterface; | 10 | use Shaarli\Bookmark\BookmarkServiceInterface; |
10 | use Shaarli\Config\ConfigManager; | 11 | use Shaarli\Config\ConfigManager; |
@@ -14,6 +15,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController; | |||
14 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; | 15 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; |
15 | use Shaarli\History; | 16 | use Shaarli\History; |
16 | use Shaarli\Http\HttpAccess; | 17 | use Shaarli\Http\HttpAccess; |
18 | use Shaarli\Http\MetadataRetriever; | ||
17 | use Shaarli\Netscape\NetscapeBookmarkUtils; | 19 | use Shaarli\Netscape\NetscapeBookmarkUtils; |
18 | use Shaarli\Plugin\PluginManager; | 20 | use Shaarli\Plugin\PluginManager; |
19 | use Shaarli\Render\PageBuilder; | 21 | use 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 | ||
5 | namespace Shaarli\Container; | 5 | namespace Shaarli\Container; |
6 | 6 | ||
7 | use Psr\Log\LoggerInterface; | ||
7 | use Shaarli\Bookmark\BookmarkServiceInterface; | 8 | use Shaarli\Bookmark\BookmarkServiceInterface; |
8 | use Shaarli\Config\ConfigManager; | 9 | use Shaarli\Config\ConfigManager; |
9 | use Shaarli\Feed\FeedBuilder; | 10 | use Shaarli\Feed\FeedBuilder; |
10 | use Shaarli\Formatter\FormatterFactory; | 11 | use Shaarli\Formatter\FormatterFactory; |
11 | use Shaarli\History; | 12 | use Shaarli\History; |
12 | use Shaarli\Http\HttpAccess; | 13 | use Shaarli\Http\HttpAccess; |
14 | use Shaarli\Http\MetadataRetriever; | ||
13 | use Shaarli\Netscape\NetscapeBookmarkUtils; | 15 | use Shaarli\Netscape\NetscapeBookmarkUtils; |
14 | use Shaarli\Plugin\PluginManager; | 16 | use Shaarli\Plugin\PluginManager; |
15 | use Shaarli\Render\PageBuilder; | 17 | use 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 | |||
2 | namespace Shaarli\Exceptions; | 3 | namespace Shaarli\Exceptions; |
3 | 4 | ||
4 | use Exception; | 5 | use 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 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Feed; | 5 | namespace Shaarli\Feed; |
4 | 6 | ||
7 | use 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 | */ |
8 | class CachedPage | 12 | class 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 | |||
2 | namespace Shaarli\Feed; | 3 | namespace Shaarli\Feed; |
3 | 4 | ||
4 | use DateTime; | 5 | use 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>— ' . $permalink; | 184 | $data['description'] .= PHP_EOL . PHP_EOL . '<br>— ' . $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 | */ |
13 | class BookmarkDefaultFormatter extends BookmarkFormatter | 13 | class 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 | */ |
13 | class BookmarkRawFormatter extends BookmarkFormatter {} | 13 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Shaarli\Thumbnailer; | ||
12 | use Slim\Http\Request; | ||
13 | use Slim\Http\Response; | ||
14 | |||
15 | /** | ||
16 | * Class PostBookmarkController | ||
17 | * | ||
18 | * Slim controller used to handle Shaarli create or edit bookmarks. | ||
19 | */ | ||
20 | class 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 = ' '; | ||
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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Controller used to retrieve/update bookmark's metadata. | ||
12 | */ | ||
13 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Helper\ApplicationUtils; | ||
8 | use Shaarli\Helper\FileUtils; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Slim controller used to handle Server administration page, and actions. | ||
14 | */ | ||
15 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class PostBookmarkController | ||
13 | * | ||
14 | * Slim controller used to handle Shaarli create or edit bookmarks. | ||
15 | */ | ||
16 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Formatter\BookmarkFormatter; | ||
10 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
11 | use Shaarli\Render\TemplatePage; | ||
12 | use Shaarli\Thumbnailer; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | class 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); | |||
5 | namespace Shaarli\Front\Controller\Visitor; | 5 | namespace Shaarli\Front\Controller\Visitor; |
6 | 6 | ||
7 | use DateTime; | 7 | use DateTime; |
8 | use DateTimeImmutable; | ||
9 | use Shaarli\Bookmark\Bookmark; | 8 | use Shaarli\Bookmark\Bookmark; |
9 | use Shaarli\Helper\DailyPageHelper; | ||
10 | use Shaarli\Render\TemplatePage; | 10 | use Shaarli\Render\TemplatePage; |
11 | use Slim\Http\Request; | 11 | use Slim\Http\Request; |
12 | use Slim\Http\Response; | 12 | use 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 | ||
5 | namespace Shaarli\Front\Controller\Visitor; | 5 | namespace Shaarli\Front\Controller\Visitor; |
6 | 6 | ||
7 | use Shaarli\ApplicationUtils; | ||
8 | use Shaarli\Container\ShaarliContainer; | 7 | use Shaarli\Container\ShaarliContainer; |
9 | use Shaarli\Front\Exception\AlreadyInstalledException; | 8 | use Shaarli\Front\Exception\AlreadyInstalledException; |
10 | use Shaarli\Front\Exception\ResourcePermissionException; | 9 | use Shaarli\Front\Exception\ResourcePermissionException; |
10 | use Shaarli\Helper\ApplicationUtils; | ||
11 | use Shaarli\Languages; | 11 | use Shaarli\Languages; |
12 | use Shaarli\Security\SessionManager; | 12 | use Shaarli\Security\SessionManager; |
13 | use Slim\Http\Request; | 13 | use 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 |
2 | namespace Shaarli; | 2 | |
3 | namespace Shaarli\Helper; | ||
3 | 4 | ||
4 | use Exception; | 5 | use Exception; |
6 | use malkusch\lock\exception\LockAcquireException; | ||
7 | use malkusch\lock\mutex\FlockMutex; | ||
5 | use Shaarli\Config\ConfigManager; | 8 | use 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Helper; | ||
6 | |||
7 | use DatePeriod; | ||
8 | use DateTimeImmutable; | ||
9 | use Exception; | ||
10 | use Shaarli\Bookmark\Bookmark; | ||
11 | use Slim\Http\Request; | ||
12 | |||
13 | class 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 | ||
3 | namespace Shaarli; | 3 | namespace Shaarli\Helper; |
4 | 4 | ||
5 | use Shaarli\Exceptions\IOException; | 5 | use 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 | */ |
15 | class HttpAccess | 15 | class 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 | */ |
38 | function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) | 40 | function 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 | */ | ||
511 | function 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Http; | ||
6 | |||
7 | use Shaarli\Config\ConfigManager; | ||
8 | |||
9 | /** | ||
10 | * HTTP Tool used to extract metadata from external URL (title, description, etc.). | ||
11 | */ | ||
12 | class 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 | */ |
18 | class Url | 18 | class Url |
19 | { | 19 | { |
20 | private static $annoyingQueryParams = array( | 20 | private static $annoyingQueryParams = [ |
21 | 21 | ||
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 | */ |
13 | function unparse_url($parsedUrl) | 14 | function 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; | |||
8 | use Iterator; | 8 | use Iterator; |
9 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | 9 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
10 | use Shaarli\Exceptions\IOException; | 10 | use Shaarli\Exceptions\IOException; |
11 | use Shaarli\FileUtils; | 11 | use Shaarli\Helper\FileUtils; |
12 | use Shaarli\Render\PageCacheManager; | 12 | use 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; | |||
7 | use ReflectionClass; | 7 | use ReflectionClass; |
8 | use ReflectionException; | 8 | use ReflectionException; |
9 | use ReflectionMethod; | 9 | use ReflectionMethod; |
10 | use Shaarli\ApplicationUtils; | ||
11 | use Shaarli\Bookmark\Bookmark; | 10 | use Shaarli\Bookmark\Bookmark; |
12 | use Shaarli\Bookmark\BookmarkArray; | 11 | use Shaarli\Bookmark\BookmarkArray; |
13 | use Shaarli\Bookmark\BookmarkFilter; | 12 | use Shaarli\Bookmark\BookmarkFilter; |
@@ -17,6 +16,7 @@ use Shaarli\Config\ConfigJson; | |||
17 | use Shaarli\Config\ConfigManager; | 16 | use Shaarli\Config\ConfigManager; |
18 | use Shaarli\Config\ConfigPhp; | 17 | use Shaarli\Config\ConfigPhp; |
19 | use Shaarli\Exceptions\IOException; | 18 | use Shaarli\Exceptions\IOException; |
19 | use Shaarli\Helper\ApplicationUtils; | ||
20 | use Shaarli\Thumbnailer; | 20 | use Shaarli\Thumbnailer; |
21 | use Shaarli\Updater\Exception\UpdaterException; | 21 | use 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 | |||
2 | namespace Shaarli\Plugin; | 3 | namespace Shaarli\Plugin; |
3 | 4 | ||
4 | use Shaarli\Config\ConfigManager; | 5 | use Shaarli\Config\ConfigManager; |
5 | use Shaarli\Plugin\Exception\PluginFileNotFoundException; | 6 | use Shaarli\Plugin\Exception\PluginFileNotFoundException; |
7 | use 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 | |||
2 | namespace Shaarli\Plugin\Exception; | 3 | namespace Shaarli\Plugin\Exception; |
3 | 4 | ||
4 | use Exception; | 5 | use 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Plugin\Exception; | ||
6 | |||
7 | use Exception; | ||
8 | |||
9 | /** | ||
10 | * Class PluginFileNotFoundException | ||
11 | * | ||
12 | * Raise when plugin files can't be found. | ||
13 | */ | ||
14 | class 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 @@ | |||
3 | namespace Shaarli\Render; | 3 | namespace Shaarli\Render; |
4 | 4 | ||
5 | use Exception; | 5 | use Exception; |
6 | use exceptions\MissingBasePathException; | 6 | use Psr\Log\LoggerInterface; |
7 | use RainTPL; | 7 | use RainTPL; |
8 | use Shaarli\ApplicationUtils; | ||
9 | use Shaarli\Bookmark\BookmarkServiceInterface; | 8 | use Shaarli\Bookmark\BookmarkServiceInterface; |
10 | use Shaarli\Config\ConfigManager; | 9 | use Shaarli\Config\ConfigManager; |
10 | use Shaarli\Helper\ApplicationUtils; | ||
11 | use Shaarli\Security\SessionManager; | 11 | use Shaarli\Security\SessionManager; |
12 | use Shaarli\Thumbnailer; | 12 | use 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 | ||
3 | namespace Shaarli\Render; | 3 | namespace Shaarli\Render; |
4 | 4 | ||
5 | use DatePeriod; | ||
5 | use Shaarli\Feed\CachedPage; | 6 | use 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 | |||
4 | namespace Shaarli\Security; | 3 | namespace Shaarli\Security; |
5 | 4 | ||
6 | use Shaarli\FileUtils; | 5 | use Psr\Log\LoggerInterface; |
6 | use 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 | |||
2 | namespace Shaarli\Security; | 3 | namespace Shaarli\Security; |
3 | 4 | ||
4 | use Exception; | 5 | use Exception; |
6 | use Psr\Log\LoggerInterface; | ||
5 | use Shaarli\Config\ConfigManager; | 7 | use 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 | |||
2 | namespace Shaarli\Security; | 3 | namespace Shaarli\Security; |
3 | 4 | ||
4 | use Shaarli\Config\ConfigManager; | 5 | use 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 | } |