diff options
author | ArthurHoaro <arthur@hoa.ro> | 2020-11-12 13:02:36 +0100 |
---|---|---|
committer | ArthurHoaro <arthur@hoa.ro> | 2020-11-12 13:02:36 +0100 |
commit | 1409f1c89a7ca01456ae2dcd6357d296e2b99f5a (patch) | |
tree | ffa30a9358e82d27be75d8fc5e57f3c8820dc6d3 /application | |
parent | 054e03f37fa29da8066f1a637919f13c7e7dc5d2 (diff) | |
parent | a6935feb22df8d9634189ee87d257da9f03eedbd (diff) | |
download | Shaarli-27ca44e31b73358f7df4940132b8264596250958.tar.gz Shaarli-27ca44e31b73358f7df4940132b8264596250958.tar.zst Shaarli-27ca44e31b73358f7df4940132b8264596250958.zip |
Diffstat (limited to 'application')
91 files changed, 2568 insertions, 1164 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..60e91631 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); |
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 f5b53b01..9fb88358 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php | |||
@@ -1,6 +1,8 @@ | |||
1 | <?php | 1 | <?php |
2 | |||
2 | namespace Shaarli\Api; | 3 | namespace Shaarli\Api; |
3 | 4 | ||
5 | use malkusch\lock\mutex\FlockMutex; | ||
4 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | 6 | use Shaarli\Api\Exceptions\ApiAuthorizationException; |
5 | use Shaarli\Api\Exceptions\ApiException; | 7 | use Shaarli\Api\Exceptions\ApiException; |
6 | use Shaarli\Bookmark\BookmarkFileService; | 8 | use Shaarli\Bookmark\BookmarkFileService; |
@@ -107,7 +109,8 @@ class ApiMiddleware | |||
107 | */ | 109 | */ |
108 | protected function checkToken($request) | 110 | protected function checkToken($request) |
109 | { | 111 | { |
110 | if (!$request->hasHeader('Authorization') | 112 | if ( |
113 | !$request->hasHeader('Authorization') | ||
111 | && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION']) | 114 | && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION']) |
112 | ) { | 115 | ) { |
113 | throw new ApiAuthorizationException('JWT token not provided'); | 116 | throw new ApiAuthorizationException('JWT token not provided'); |
@@ -143,6 +146,7 @@ class ApiMiddleware | |||
143 | $linkDb = new BookmarkFileService( | 146 | $linkDb = new BookmarkFileService( |
144 | $conf, | 147 | $conf, |
145 | $this->container->get('history'), | 148 | $this->container->get('history'), |
149 | new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), | ||
146 | true | 150 | true |
147 | ); | 151 | ); |
148 | $this->container['db'] = $linkDb; | 152 | $this->container['db'] = $linkDb; |
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php index faebb8f5..05a2840a 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,12 +91,12 @@ 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 $input Request Link. | 94 | * @param array|null $input Request Link. |
93 | * @param bool $defaultPrivate Request Link. | 95 | * @param bool $defaultPrivate Setting defined if a bookmark is private by default. |
94 | * | 96 | * |
95 | * @return Bookmark instance. | 97 | * @return Bookmark instance. |
96 | */ | 98 | */ |
97 | public static function buildLinkFromRequest($input, $defaultPrivate) | 99 | public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark |
98 | { | 100 | { |
99 | $bookmark = new Bookmark(); | 101 | $bookmark = new Bookmark(); |
100 | $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; | 102 | $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; |
@@ -110,6 +112,15 @@ class ApiUtils | |||
110 | $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); | 112 | $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); |
111 | $bookmark->setPrivate($private); | 113 | $bookmark->setPrivate($private); |
112 | 114 | ||
115 | $created = \DateTime::createFromFormat(\DateTime::ATOM, $input['created'] ?? ''); | ||
116 | if ($created instanceof \DateTimeInterface) { | ||
117 | $bookmark->setCreated($created); | ||
118 | } | ||
119 | $updated = \DateTime::createFromFormat(\DateTime::ATOM, $input['updated'] ?? ''); | ||
120 | if ($updated instanceof \DateTimeInterface) { | ||
121 | $bookmark->setUpdated($updated); | ||
122 | } | ||
123 | |||
113 | return $bookmark; | 124 | return $bookmark; |
114 | } | 125 | } |
115 | 126 | ||
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php index c4b3d0c3..88a845eb 100644 --- a/application/api/controllers/ApiController.php +++ b/application/api/controllers/ApiController.php | |||
@@ -4,6 +4,7 @@ namespace Shaarli\Api\Controllers; | |||
4 | 4 | ||
5 | use Shaarli\Bookmark\BookmarkServiceInterface; | 5 | use Shaarli\Bookmark\BookmarkServiceInterface; |
6 | use Shaarli\Config\ConfigManager; | 6 | use Shaarli\Config\ConfigManager; |
7 | use Shaarli\History; | ||
7 | use Slim\Container; | 8 | use Slim\Container; |
8 | 9 | ||
9 | /** | 10 | /** |
@@ -31,7 +32,7 @@ abstract class ApiController | |||
31 | protected $bookmarkService; | 32 | protected $bookmarkService; |
32 | 33 | ||
33 | /** | 34 | /** |
34 | * @var HistoryController | 35 | * @var History |
35 | */ | 36 | */ |
36 | protected $history; | 37 | protected $history; |
37 | 38 | ||
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 29247950..c379b962 100644 --- a/application/api/controllers/Links.php +++ b/application/api/controllers/Links.php | |||
@@ -96,11 +96,12 @@ class Links extends ApiController | |||
96 | */ | 96 | */ |
97 | public function getLink($request, $response, $args) | 97 | public function getLink($request, $response, $args) |
98 | { | 98 | { |
99 | if (!$this->bookmarkService->exists($args['id'])) { | 99 | $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null; |
100 | if ($id === null || ! $this->bookmarkService->exists($id)) { | ||
100 | throw new ApiLinkNotFoundException(); | 101 | throw new ApiLinkNotFoundException(); |
101 | } | 102 | } |
102 | $index = index_url($this->ci['environment']); | 103 | $index = index_url($this->ci['environment']); |
103 | $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index); | 104 | $out = ApiUtils::formatLink($this->bookmarkService->get($id), $index); |
104 | 105 | ||
105 | return $response->withJson($out, 200, $this->jsonStyle); | 106 | return $response->withJson($out, 200, $this->jsonStyle); |
106 | } | 107 | } |
@@ -115,10 +116,11 @@ class Links extends ApiController | |||
115 | */ | 116 | */ |
116 | public function postLink($request, $response) | 117 | public function postLink($request, $response) |
117 | { | 118 | { |
118 | $data = $request->getParsedBody(); | 119 | $data = (array) ($request->getParsedBody() ?? []); |
119 | $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); | 120 | $bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); |
120 | // duplicate by URL, return 409 Conflict | 121 | // duplicate by URL, return 409 Conflict |
121 | if (! empty($bookmark->getUrl()) | 122 | if ( |
123 | ! empty($bookmark->getUrl()) | ||
122 | && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) | 124 | && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) |
123 | ) { | 125 | ) { |
124 | return $response->withJson( | 126 | return $response->withJson( |
@@ -130,7 +132,7 @@ class Links extends ApiController | |||
130 | 132 | ||
131 | $this->bookmarkService->add($bookmark); | 133 | $this->bookmarkService->add($bookmark); |
132 | $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment'])); | 134 | $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment'])); |
133 | $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]); | 135 | $redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]); |
134 | return $response->withAddedHeader('Location', $redirect) | 136 | return $response->withAddedHeader('Location', $redirect) |
135 | ->withJson($out, 201, $this->jsonStyle); | 137 | ->withJson($out, 201, $this->jsonStyle); |
136 | } | 138 | } |
@@ -148,18 +150,20 @@ class Links extends ApiController | |||
148 | */ | 150 | */ |
149 | public function putLink($request, $response, $args) | 151 | public function putLink($request, $response, $args) |
150 | { | 152 | { |
151 | if (! $this->bookmarkService->exists($args['id'])) { | 153 | $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null; |
154 | if ($id === null || !$this->bookmarkService->exists($id)) { | ||
152 | throw new ApiLinkNotFoundException(); | 155 | throw new ApiLinkNotFoundException(); |
153 | } | 156 | } |
154 | 157 | ||
155 | $index = index_url($this->ci['environment']); | 158 | $index = index_url($this->ci['environment']); |
156 | $data = $request->getParsedBody(); | 159 | $data = $request->getParsedBody(); |
157 | 160 | ||
158 | $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); | 161 | $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); |
159 | // duplicate URL on a different link, return 409 Conflict | 162 | // duplicate URL on a different link, return 409 Conflict |
160 | if (! empty($requestBookmark->getUrl()) | 163 | if ( |
164 | ! empty($requestBookmark->getUrl()) | ||
161 | && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) | 165 | && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) |
162 | && $dup->getId() != $args['id'] | 166 | && $dup->getId() != $id |
163 | ) { | 167 | ) { |
164 | return $response->withJson( | 168 | return $response->withJson( |
165 | ApiUtils::formatLink($dup, $index), | 169 | ApiUtils::formatLink($dup, $index), |
@@ -168,7 +172,7 @@ class Links extends ApiController | |||
168 | ); | 172 | ); |
169 | } | 173 | } |
170 | 174 | ||
171 | $responseBookmark = $this->bookmarkService->get($args['id']); | 175 | $responseBookmark = $this->bookmarkService->get($id); |
172 | $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark); | 176 | $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark); |
173 | $this->bookmarkService->set($responseBookmark); | 177 | $this->bookmarkService->set($responseBookmark); |
174 | 178 | ||
@@ -189,10 +193,11 @@ class Links extends ApiController | |||
189 | */ | 193 | */ |
190 | public function deleteLink($request, $response, $args) | 194 | public function deleteLink($request, $response, $args) |
191 | { | 195 | { |
192 | if (! $this->bookmarkService->exists($args['id'])) { | 196 | $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null; |
197 | if ($id === null || !$this->bookmarkService->exists($id)) { | ||
193 | throw new ApiLinkNotFoundException(); | 198 | throw new ApiLinkNotFoundException(); |
194 | } | 199 | } |
195 | $bookmark = $this->bookmarkService->get($args['id']); | 200 | $bookmark = $this->bookmarkService->get($id); |
196 | $this->bookmarkService->remove($bookmark); | 201 | $this->bookmarkService->remove($bookmark); |
197 | 202 | ||
198 | return $response->withStatus(204); | 203 | return $response->withStatus(204); |
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 1beb8be2..4238ef25 100644 --- a/application/bookmark/Bookmark.php +++ b/application/bookmark/Bookmark.php | |||
@@ -1,5 +1,7 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
5 | use DateTime; | 7 | use DateTime; |
@@ -17,7 +19,7 @@ use Shaarli\Bookmark\Exception\InvalidBookmarkException; | |||
17 | class Bookmark | 19 | class Bookmark |
18 | { | 20 | { |
19 | /** @var string Date format used in string (former ID format) */ | 21 | /** @var string Date format used in string (former ID format) */ |
20 | const LINK_DATE_FORMAT = 'Ymd_His'; | 22 | public const LINK_DATE_FORMAT = 'Ymd_His'; |
21 | 23 | ||
22 | /** @var int Bookmark ID */ | 24 | /** @var int Bookmark ID */ |
23 | protected $id; | 25 | protected $id; |
@@ -52,32 +54,37 @@ class Bookmark | |||
52 | /** @var bool True if the bookmark can only be seen while logged in */ | 54 | /** @var bool True if the bookmark can only be seen while logged in */ |
53 | protected $private; | 55 | protected $private; |
54 | 56 | ||
57 | /** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */ | ||
58 | protected $additionalContent = []; | ||
59 | |||
55 | /** | 60 | /** |
56 | * 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. |
57 | * | 62 | * |
58 | * @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. | ||
59 | * | 66 | * |
60 | * @return $this | 67 | * @return $this |
61 | */ | 68 | */ |
62 | public function fromArray($data) | 69 | public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark |
63 | { | 70 | { |
64 | $this->id = $data['id']; | 71 | $this->id = $data['id'] ?? null; |
65 | $this->shortUrl = $data['shorturl']; | 72 | $this->shortUrl = $data['shorturl'] ?? null; |
66 | $this->url = $data['url']; | 73 | $this->url = $data['url'] ?? null; |
67 | $this->title = $data['title']; | 74 | $this->title = $data['title'] ?? null; |
68 | $this->description = $data['description']; | 75 | $this->description = $data['description'] ?? null; |
69 | $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null; | 76 | $this->thumbnail = $data['thumbnail'] ?? null; |
70 | $this->sticky = isset($data['sticky']) ? $data['sticky'] : false; | 77 | $this->sticky = $data['sticky'] ?? false; |
71 | $this->created = $data['created']; | 78 | $this->created = $data['created'] ?? null; |
72 | if (is_array($data['tags'])) { | 79 | if (is_array($data['tags'])) { |
73 | $this->tags = $data['tags']; | 80 | $this->tags = $data['tags']; |
74 | } else { | 81 | } else { |
75 | $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY); | 82 | $this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator); |
76 | } | 83 | } |
77 | if (! empty($data['updated'])) { | 84 | if (! empty($data['updated'])) { |
78 | $this->updated = $data['updated']; | 85 | $this->updated = $data['updated']; |
79 | } | 86 | } |
80 | $this->private = $data['private'] ? true : false; | 87 | $this->private = ($data['private'] ?? false) ? true : false; |
81 | 88 | ||
82 | return $this; | 89 | return $this; |
83 | } | 90 | } |
@@ -93,24 +100,29 @@ class Bookmark | |||
93 | * - the URL with the permalink | 100 | * - the URL with the permalink |
94 | * - the title with the URL | 101 | * - the title with the URL |
95 | * | 102 | * |
103 | * Also make sure that we do not save search highlights in the datastore. | ||
104 | * | ||
96 | * @throws InvalidBookmarkException | 105 | * @throws InvalidBookmarkException |
97 | */ | 106 | */ |
98 | public function validate() | 107 | public function validate(): void |
99 | { | 108 | { |
100 | if ($this->id === null | 109 | if ( |
110 | $this->id === null | ||
101 | || ! is_int($this->id) | 111 | || ! is_int($this->id) |
102 | || empty($this->shortUrl) | 112 | || empty($this->shortUrl) |
103 | || empty($this->created) | 113 | || empty($this->created) |
104 | || ! $this->created instanceof DateTimeInterface | ||
105 | ) { | 114 | ) { |
106 | throw new InvalidBookmarkException($this); | 115 | throw new InvalidBookmarkException($this); |
107 | } | 116 | } |
108 | if (empty($this->url)) { | 117 | if (empty($this->url)) { |
109 | $this->url = '/shaare/'. $this->shortUrl; | 118 | $this->url = '/shaare/' . $this->shortUrl; |
110 | } | 119 | } |
111 | if (empty($this->title)) { | 120 | if (empty($this->title)) { |
112 | $this->title = $this->url; | 121 | $this->title = $this->url; |
113 | } | 122 | } |
123 | if (array_key_exists('search_highlight', $this->additionalContent)) { | ||
124 | unset($this->additionalContent['search_highlight']); | ||
125 | } | ||
114 | } | 126 | } |
115 | 127 | ||
116 | /** | 128 | /** |
@@ -119,11 +131,11 @@ class Bookmark | |||
119 | * - created: with the current datetime | 131 | * - created: with the current datetime |
120 | * - shortUrl: with a generated small hash from the date and the given ID | 132 | * - shortUrl: with a generated small hash from the date and the given ID |
121 | * | 133 | * |
122 | * @param int $id | 134 | * @param int|null $id |
123 | * | 135 | * |
124 | * @return Bookmark | 136 | * @return Bookmark |
125 | */ | 137 | */ |
126 | public function setId($id) | 138 | public function setId(?int $id): Bookmark |
127 | { | 139 | { |
128 | $this->id = $id; | 140 | $this->id = $id; |
129 | if (empty($this->created)) { | 141 | if (empty($this->created)) { |
@@ -139,9 +151,9 @@ class Bookmark | |||
139 | /** | 151 | /** |
140 | * Get the Id. | 152 | * Get the Id. |
141 | * | 153 | * |
142 | * @return int | 154 | * @return int|null |
143 | */ | 155 | */ |
144 | public function getId() | 156 | public function getId(): ?int |
145 | { | 157 | { |
146 | return $this->id; | 158 | return $this->id; |
147 | } | 159 | } |
@@ -149,9 +161,9 @@ class Bookmark | |||
149 | /** | 161 | /** |
150 | * Get the ShortUrl. | 162 | * Get the ShortUrl. |
151 | * | 163 | * |
152 | * @return string | 164 | * @return string|null |
153 | */ | 165 | */ |
154 | public function getShortUrl() | 166 | public function getShortUrl(): ?string |
155 | { | 167 | { |
156 | return $this->shortUrl; | 168 | return $this->shortUrl; |
157 | } | 169 | } |
@@ -159,9 +171,9 @@ class Bookmark | |||
159 | /** | 171 | /** |
160 | * Get the Url. | 172 | * Get the Url. |
161 | * | 173 | * |
162 | * @return string | 174 | * @return string|null |
163 | */ | 175 | */ |
164 | public function getUrl() | 176 | public function getUrl(): ?string |
165 | { | 177 | { |
166 | return $this->url; | 178 | return $this->url; |
167 | } | 179 | } |
@@ -171,7 +183,7 @@ class Bookmark | |||
171 | * | 183 | * |
172 | * @return string | 184 | * @return string |
173 | */ | 185 | */ |
174 | public function getTitle() | 186 | public function getTitle(): ?string |
175 | { | 187 | { |
176 | return $this->title; | 188 | return $this->title; |
177 | } | 189 | } |
@@ -181,7 +193,7 @@ class Bookmark | |||
181 | * | 193 | * |
182 | * @return string | 194 | * @return string |
183 | */ | 195 | */ |
184 | public function getDescription() | 196 | public function getDescription(): string |
185 | { | 197 | { |
186 | return ! empty($this->description) ? $this->description : ''; | 198 | return ! empty($this->description) ? $this->description : ''; |
187 | } | 199 | } |
@@ -191,7 +203,7 @@ class Bookmark | |||
191 | * | 203 | * |
192 | * @return DateTimeInterface | 204 | * @return DateTimeInterface |
193 | */ | 205 | */ |
194 | public function getCreated() | 206 | public function getCreated(): ?DateTimeInterface |
195 | { | 207 | { |
196 | return $this->created; | 208 | return $this->created; |
197 | } | 209 | } |
@@ -201,7 +213,7 @@ class Bookmark | |||
201 | * | 213 | * |
202 | * @return DateTimeInterface | 214 | * @return DateTimeInterface |
203 | */ | 215 | */ |
204 | public function getUpdated() | 216 | public function getUpdated(): ?DateTimeInterface |
205 | { | 217 | { |
206 | return $this->updated; | 218 | return $this->updated; |
207 | } | 219 | } |
@@ -209,11 +221,11 @@ class Bookmark | |||
209 | /** | 221 | /** |
210 | * Set the ShortUrl. | 222 | * Set the ShortUrl. |
211 | * | 223 | * |
212 | * @param string $shortUrl | 224 | * @param string|null $shortUrl |
213 | * | 225 | * |
214 | * @return Bookmark | 226 | * @return Bookmark |
215 | */ | 227 | */ |
216 | public function setShortUrl($shortUrl) | 228 | public function setShortUrl(?string $shortUrl): Bookmark |
217 | { | 229 | { |
218 | $this->shortUrl = $shortUrl; | 230 | $this->shortUrl = $shortUrl; |
219 | 231 | ||
@@ -223,14 +235,14 @@ class Bookmark | |||
223 | /** | 235 | /** |
224 | * Set the Url. | 236 | * Set the Url. |
225 | * | 237 | * |
226 | * @param string $url | 238 | * @param string|null $url |
227 | * @param array $allowedProtocols | 239 | * @param string[] $allowedProtocols |
228 | * | 240 | * |
229 | * @return Bookmark | 241 | * @return Bookmark |
230 | */ | 242 | */ |
231 | public function setUrl($url, $allowedProtocols = []) | 243 | public function setUrl(?string $url, array $allowedProtocols = []): Bookmark |
232 | { | 244 | { |
233 | $url = trim($url); | 245 | $url = $url !== null ? trim($url) : ''; |
234 | if (! empty($url)) { | 246 | if (! empty($url)) { |
235 | $url = whitelist_protocols($url, $allowedProtocols); | 247 | $url = whitelist_protocols($url, $allowedProtocols); |
236 | } | 248 | } |
@@ -242,13 +254,13 @@ class Bookmark | |||
242 | /** | 254 | /** |
243 | * Set the Title. | 255 | * Set the Title. |
244 | * | 256 | * |
245 | * @param string $title | 257 | * @param string|null $title |
246 | * | 258 | * |
247 | * @return Bookmark | 259 | * @return Bookmark |
248 | */ | 260 | */ |
249 | public function setTitle($title) | 261 | public function setTitle(?string $title): Bookmark |
250 | { | 262 | { |
251 | $this->title = trim($title); | 263 | $this->title = $title !== null ? trim($title) : ''; |
252 | 264 | ||
253 | return $this; | 265 | return $this; |
254 | } | 266 | } |
@@ -256,11 +268,11 @@ class Bookmark | |||
256 | /** | 268 | /** |
257 | * Set the Description. | 269 | * Set the Description. |
258 | * | 270 | * |
259 | * @param string $description | 271 | * @param string|null $description |
260 | * | 272 | * |
261 | * @return Bookmark | 273 | * @return Bookmark |
262 | */ | 274 | */ |
263 | public function setDescription($description) | 275 | public function setDescription(?string $description): Bookmark |
264 | { | 276 | { |
265 | $this->description = $description; | 277 | $this->description = $description; |
266 | 278 | ||
@@ -271,11 +283,11 @@ class Bookmark | |||
271 | * Set the Created. | 283 | * Set the Created. |
272 | * Note: you shouldn't set this manually except for special cases (like bookmark import) | 284 | * Note: you shouldn't set this manually except for special cases (like bookmark import) |
273 | * | 285 | * |
274 | * @param DateTimeInterface $created | 286 | * @param DateTimeInterface|null $created |
275 | * | 287 | * |
276 | * @return Bookmark | 288 | * @return Bookmark |
277 | */ | 289 | */ |
278 | public function setCreated($created) | 290 | public function setCreated(?DateTimeInterface $created): Bookmark |
279 | { | 291 | { |
280 | $this->created = $created; | 292 | $this->created = $created; |
281 | 293 | ||
@@ -285,11 +297,11 @@ class Bookmark | |||
285 | /** | 297 | /** |
286 | * Set the Updated. | 298 | * Set the Updated. |
287 | * | 299 | * |
288 | * @param DateTimeInterface $updated | 300 | * @param DateTimeInterface|null $updated |
289 | * | 301 | * |
290 | * @return Bookmark | 302 | * @return Bookmark |
291 | */ | 303 | */ |
292 | public function setUpdated($updated) | 304 | public function setUpdated(?DateTimeInterface $updated): Bookmark |
293 | { | 305 | { |
294 | $this->updated = $updated; | 306 | $this->updated = $updated; |
295 | 307 | ||
@@ -301,7 +313,7 @@ class Bookmark | |||
301 | * | 313 | * |
302 | * @return bool | 314 | * @return bool |
303 | */ | 315 | */ |
304 | public function isPrivate() | 316 | public function isPrivate(): bool |
305 | { | 317 | { |
306 | return $this->private ? true : false; | 318 | return $this->private ? true : false; |
307 | } | 319 | } |
@@ -309,11 +321,11 @@ class Bookmark | |||
309 | /** | 321 | /** |
310 | * Set the Private. | 322 | * Set the Private. |
311 | * | 323 | * |
312 | * @param bool $private | 324 | * @param bool|null $private |
313 | * | 325 | * |
314 | * @return Bookmark | 326 | * @return Bookmark |
315 | */ | 327 | */ |
316 | public function setPrivate($private) | 328 | public function setPrivate(?bool $private): Bookmark |
317 | { | 329 | { |
318 | $this->private = $private ? true : false; | 330 | $this->private = $private ? true : false; |
319 | 331 | ||
@@ -323,9 +335,9 @@ class Bookmark | |||
323 | /** | 335 | /** |
324 | * Get the Tags. | 336 | * Get the Tags. |
325 | * | 337 | * |
326 | * @return array | 338 | * @return string[] |
327 | */ | 339 | */ |
328 | public function getTags() | 340 | public function getTags(): array |
329 | { | 341 | { |
330 | return is_array($this->tags) ? $this->tags : []; | 342 | return is_array($this->tags) ? $this->tags : []; |
331 | } | 343 | } |
@@ -333,13 +345,18 @@ class Bookmark | |||
333 | /** | 345 | /** |
334 | * Set the Tags. | 346 | * Set the Tags. |
335 | * | 347 | * |
336 | * @param array $tags | 348 | * @param string[]|null $tags |
337 | * | 349 | * |
338 | * @return Bookmark | 350 | * @return Bookmark |
339 | */ | 351 | */ |
340 | public function setTags($tags) | 352 | public function setTags(?array $tags): Bookmark |
341 | { | 353 | { |
342 | $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 | ); | ||
343 | 360 | ||
344 | return $this; | 361 | return $this; |
345 | } | 362 | } |
@@ -357,11 +374,11 @@ class Bookmark | |||
357 | /** | 374 | /** |
358 | * Set the Thumbnail. | 375 | * Set the Thumbnail. |
359 | * | 376 | * |
360 | * @param string|bool $thumbnail Thumbnail's URL - false if no thumbnail could be found | 377 | * @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found |
361 | * | 378 | * |
362 | * @return Bookmark | 379 | * @return Bookmark |
363 | */ | 380 | */ |
364 | public function setThumbnail($thumbnail) | 381 | public function setThumbnail($thumbnail): Bookmark |
365 | { | 382 | { |
366 | $this->thumbnail = $thumbnail; | 383 | $this->thumbnail = $thumbnail; |
367 | 384 | ||
@@ -369,11 +386,29 @@ class Bookmark | |||
369 | } | 386 | } |
370 | 387 | ||
371 | /** | 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 | /** | ||
372 | * Get the Sticky. | 407 | * Get the Sticky. |
373 | * | 408 | * |
374 | * @return bool | 409 | * @return bool |
375 | */ | 410 | */ |
376 | public function isSticky() | 411 | public function isSticky(): bool |
377 | { | 412 | { |
378 | return $this->sticky ? true : false; | 413 | return $this->sticky ? true : false; |
379 | } | 414 | } |
@@ -381,11 +416,11 @@ class Bookmark | |||
381 | /** | 416 | /** |
382 | * Set the Sticky. | 417 | * Set the Sticky. |
383 | * | 418 | * |
384 | * @param bool $sticky | 419 | * @param bool|null $sticky |
385 | * | 420 | * |
386 | * @return Bookmark | 421 | * @return Bookmark |
387 | */ | 422 | */ |
388 | public function setSticky($sticky) | 423 | public function setSticky(?bool $sticky): Bookmark |
389 | { | 424 | { |
390 | $this->sticky = $sticky ? true : false; | 425 | $this->sticky = $sticky ? true : false; |
391 | 426 | ||
@@ -393,17 +428,19 @@ class Bookmark | |||
393 | } | 428 | } |
394 | 429 | ||
395 | /** | 430 | /** |
396 | * @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 | ||
397 | */ | 434 | */ |
398 | public function getTagsString() | 435 | public function getTagsString(string $separator = ' '): string |
399 | { | 436 | { |
400 | return implode(' ', $this->getTags()); | 437 | return tags_array2str($this->getTags(), $separator); |
401 | } | 438 | } |
402 | 439 | ||
403 | /** | 440 | /** |
404 | * @return bool | 441 | * @return bool |
405 | */ | 442 | */ |
406 | public function isNote() | 443 | public function isNote(): bool |
407 | { | 444 | { |
408 | // We check empty value to get a valid result if the link has not been saved yet | 445 | // We check empty value to get a valid result if the link has not been saved yet |
409 | return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?'; | 446 | return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?'; |
@@ -416,33 +453,65 @@ class Bookmark | |||
416 | * - multiple spaces will be removed | 453 | * - multiple spaces will be removed |
417 | * - trailing dash in tags will be removed | 454 | * - trailing dash in tags will be removed |
418 | * | 455 | * |
419 | * @param string $tags | 456 | * @param string|null $tags |
457 | * @param string $separator Tags separator loaded from the config file. | ||
420 | * | 458 | * |
421 | * @return $this | 459 | * @return $this |
422 | */ | 460 | */ |
423 | public function setTagsString($tags) | 461 | public function setTagsString(?string $tags, string $separator = ' '): Bookmark |
424 | { | 462 | { |
425 | // Remove first '-' char in tags. | 463 | $this->setTags(tags_str2array($tags, $separator)); |
426 | $tags = preg_replace('/(^| )\-/', '$1', $tags); | ||
427 | // Explode all tags separted by spaces or commas | ||
428 | $tags = preg_split('/[\s,]+/', $tags); | ||
429 | // Remove eventual empty values | ||
430 | $tags = array_values(array_filter($tags)); | ||
431 | 464 | ||
432 | $this->tags = $tags; | 465 | return $this; |
466 | } | ||
467 | |||
468 | /** | ||
469 | * Get entire additionalContent array. | ||
470 | * | ||
471 | * @return mixed[] | ||
472 | */ | ||
473 | public function getAdditionalContent(): array | ||
474 | { | ||
475 | return $this->additionalContent; | ||
476 | } | ||
477 | |||
478 | /** | ||
479 | * Set a single entry in additionalContent, by key. | ||
480 | * | ||
481 | * @param string $key | ||
482 | * @param mixed|null $value Any type of value can be set. | ||
483 | * | ||
484 | * @return $this | ||
485 | */ | ||
486 | public function addAdditionalContentEntry(string $key, $value): self | ||
487 | { | ||
488 | $this->additionalContent[$key] = $value; | ||
433 | 489 | ||
434 | return $this; | 490 | return $this; |
435 | } | 491 | } |
436 | 492 | ||
437 | /** | 493 | /** |
494 | * Get a single entry in additionalContent, by key. | ||
495 | * | ||
496 | * @param string $key | ||
497 | * @param mixed|null $default | ||
498 | * | ||
499 | * @return mixed|null can be any type or even null. | ||
500 | */ | ||
501 | public function getAdditionalContentEntry(string $key, $default = null) | ||
502 | { | ||
503 | return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default; | ||
504 | } | ||
505 | |||
506 | /** | ||
438 | * Rename a tag in tags list. | 507 | * Rename a tag in tags list. |
439 | * | 508 | * |
440 | * @param string $fromTag | 509 | * @param string $fromTag |
441 | * @param string $toTag | 510 | * @param string $toTag |
442 | */ | 511 | */ |
443 | public function renameTag($fromTag, $toTag) | 512 | public function renameTag(string $fromTag, string $toTag): void |
444 | { | 513 | { |
445 | if (($pos = array_search($fromTag, $this->tags)) !== false) { | 514 | if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) { |
446 | $this->tags[$pos] = trim($toTag); | 515 | $this->tags[$pos] = trim($toTag); |
447 | } | 516 | } |
448 | } | 517 | } |
@@ -452,9 +521,9 @@ class Bookmark | |||
452 | * | 521 | * |
453 | * @param string $tag | 522 | * @param string $tag |
454 | */ | 523 | */ |
455 | public function deleteTag($tag) | 524 | public function deleteTag(string $tag): void |
456 | { | 525 | { |
457 | if (($pos = array_search($tag, $this->tags)) !== false) { | 526 | if (($pos = array_search($tag, $this->tags ?? [])) !== false) { |
458 | unset($this->tags[$pos]); | 527 | unset($this->tags[$pos]); |
459 | $this->tags = array_values($this->tags); | 528 | $this->tags = array_values($this->tags); |
460 | } | 529 | } |
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php index 3bd5eb20..b9328116 100644 --- a/application/bookmark/BookmarkArray.php +++ b/application/bookmark/BookmarkArray.php | |||
@@ -1,5 +1,7 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
5 | use Shaarli\Bookmark\Exception\InvalidBookmarkException; | 7 | use Shaarli\Bookmark\Exception\InvalidBookmarkException; |
@@ -70,7 +72,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess | |||
70 | */ | 72 | */ |
71 | public function offsetSet($offset, $value) | 73 | public function offsetSet($offset, $value) |
72 | { | 74 | { |
73 | if (! $value instanceof Bookmark | 75 | if ( |
76 | ! $value instanceof Bookmark | ||
74 | || $value->getId() === null || empty($value->getUrl()) | 77 | || $value->getId() === null || empty($value->getUrl()) |
75 | || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) | 78 | || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) |
76 | || $offset !== null && $offset !== $value->getId() | 79 | || $offset !== null && $offset !== $value->getId() |
@@ -187,13 +190,13 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess | |||
187 | /** | 190 | /** |
188 | * Returns a bookmark offset in bookmarks array from its unique ID. | 191 | * Returns a bookmark offset in bookmarks array from its unique ID. |
189 | * | 192 | * |
190 | * @param int $id Persistent ID of a bookmark. | 193 | * @param int|null $id Persistent ID of a bookmark. |
191 | * | 194 | * |
192 | * @return int Real offset in local array, or null if doesn't exist. | 195 | * @return int Real offset in local array, or null if doesn't exist. |
193 | */ | 196 | */ |
194 | protected function getBookmarkOffset($id) | 197 | protected function getBookmarkOffset(?int $id): ?int |
195 | { | 198 | { |
196 | if (isset($this->ids[$id])) { | 199 | if ($id !== null && isset($this->ids[$id])) { |
197 | return $this->ids[$id]; | 200 | return $this->ids[$id]; |
198 | } | 201 | } |
199 | return null; | 202 | return null; |
@@ -205,7 +208,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess | |||
205 | * | 208 | * |
206 | * @return int next ID. | 209 | * @return int next ID. |
207 | */ | 210 | */ |
208 | public function getNextId() | 211 | public function getNextId(): int |
209 | { | 212 | { |
210 | if (!empty($this->ids)) { | 213 | if (!empty($this->ids)) { |
211 | return max(array_keys($this->ids)) + 1; | 214 | return max(array_keys($this->ids)) + 1; |
@@ -214,13 +217,14 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess | |||
214 | } | 217 | } |
215 | 218 | ||
216 | /** | 219 | /** |
217 | * @param $url | 220 | * @param string $url |
218 | * | 221 | * |
219 | * @return Bookmark|null | 222 | * @return Bookmark|null |
220 | */ | 223 | */ |
221 | public function getByUrl($url) | 224 | public function getByUrl(string $url): ?Bookmark |
222 | { | 225 | { |
223 | if (! empty($url) | 226 | if ( |
227 | ! empty($url) | ||
224 | && isset($this->urls[$url]) | 228 | && isset($this->urls[$url]) |
225 | && isset($this->bookmarks[$this->urls[$url]]) | 229 | && isset($this->bookmarks[$this->urls[$url]]) |
226 | ) { | 230 | ) { |
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index c9ec2609..6666a251 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php | |||
@@ -1,10 +1,12 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
3 | 4 | ||
4 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
5 | 6 | ||
6 | 7 | use DateTime; | |
7 | use Exception; | 8 | use Exception; |
9 | use malkusch\lock\mutex\Mutex; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | 10 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
9 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | 11 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; |
10 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; | 12 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; |
@@ -47,15 +49,19 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
47 | /** @var bool true for logged in users. Default value to retrieve private bookmarks. */ | 49 | /** @var bool true for logged in users. Default value to retrieve private bookmarks. */ |
48 | protected $isLoggedIn; | 50 | protected $isLoggedIn; |
49 | 51 | ||
52 | /** @var Mutex */ | ||
53 | protected $mutex; | ||
54 | |||
50 | /** | 55 | /** |
51 | * @inheritDoc | 56 | * @inheritDoc |
52 | */ | 57 | */ |
53 | public function __construct(ConfigManager $conf, History $history, $isLoggedIn) | 58 | public function __construct(ConfigManager $conf, History $history, Mutex $mutex, bool $isLoggedIn) |
54 | { | 59 | { |
55 | $this->conf = $conf; | 60 | $this->conf = $conf; |
56 | $this->history = $history; | 61 | $this->history = $history; |
62 | $this->mutex = $mutex; | ||
57 | $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn); | 63 | $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn); |
58 | $this->bookmarksIO = new BookmarkIO($this->conf); | 64 | $this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex); |
59 | $this->isLoggedIn = $isLoggedIn; | 65 | $this->isLoggedIn = $isLoggedIn; |
60 | 66 | ||
61 | if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { | 67 | if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { |
@@ -63,7 +69,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
63 | } else { | 69 | } else { |
64 | try { | 70 | try { |
65 | $this->bookmarks = $this->bookmarksIO->read(); | 71 | $this->bookmarks = $this->bookmarksIO->read(); |
66 | } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { | 72 | } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) { |
67 | $this->bookmarks = new BookmarkArray(); | 73 | $this->bookmarks = new BookmarkArray(); |
68 | 74 | ||
69 | if ($this->isLoggedIn) { | 75 | if ($this->isLoggedIn) { |
@@ -79,25 +85,29 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
79 | if (! $this->bookmarks instanceof BookmarkArray) { | 85 | if (! $this->bookmarks instanceof BookmarkArray) { |
80 | $this->migrate(); | 86 | $this->migrate(); |
81 | exit( | 87 | exit( |
82 | '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 . |
83 | '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.' |
84 | ); | 90 | ); |
85 | } | 91 | } |
86 | } | 92 | } |
87 | 93 | ||
88 | $this->bookmarkFilter = new BookmarkFilter($this->bookmarks); | 94 | $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf); |
89 | } | 95 | } |
90 | 96 | ||
91 | /** | 97 | /** |
92 | * @inheritDoc | 98 | * @inheritDoc |
93 | */ | 99 | */ |
94 | public function findByHash($hash) | 100 | public function findByHash(string $hash, string $privateKey = null): Bookmark |
95 | { | 101 | { |
96 | $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); | 102 | $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); |
97 | // PHP 7.3 introduced array_key_first() to avoid this hack | 103 | // PHP 7.3 introduced array_key_first() to avoid this hack |
98 | $first = reset($bookmark); | 104 | $first = reset($bookmark); |
99 | if (! $this->isLoggedIn && $first->isPrivate()) { | 105 | if ( |
100 | throw new Exception('Not authorized'); | 106 | !$this->isLoggedIn |
107 | && $first->isPrivate() | ||
108 | && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key')) | ||
109 | ) { | ||
110 | throw new BookmarkNotFoundException(); | ||
101 | } | 111 | } |
102 | 112 | ||
103 | return $first; | 113 | return $first; |
@@ -106,7 +116,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
106 | /** | 116 | /** |
107 | * @inheritDoc | 117 | * @inheritDoc |
108 | */ | 118 | */ |
109 | public function findByUrl($url) | 119 | public function findByUrl(string $url): ?Bookmark |
110 | { | 120 | { |
111 | return $this->bookmarks->getByUrl($url); | 121 | return $this->bookmarks->getByUrl($url); |
112 | } | 122 | } |
@@ -115,10 +125,10 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
115 | * @inheritDoc | 125 | * @inheritDoc |
116 | */ | 126 | */ |
117 | public function search( | 127 | public function search( |
118 | $request = [], | 128 | array $request = [], |
119 | $visibility = null, | 129 | string $visibility = null, |
120 | $caseSensitive = false, | 130 | bool $caseSensitive = false, |
121 | $untaggedOnly = false, | 131 | bool $untaggedOnly = false, |
122 | bool $ignoreSticky = false | 132 | bool $ignoreSticky = false |
123 | ) { | 133 | ) { |
124 | if ($visibility === null) { | 134 | if ($visibility === null) { |
@@ -126,8 +136,8 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
126 | } | 136 | } |
127 | 137 | ||
128 | // Filter bookmark database according to parameters. | 138 | // Filter bookmark database according to parameters. |
129 | $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; | 139 | $searchTags = isset($request['searchtags']) ? $request['searchtags'] : ''; |
130 | $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; | 140 | $searchTerm = isset($request['searchterm']) ? $request['searchterm'] : ''; |
131 | 141 | ||
132 | if ($ignoreSticky) { | 142 | if ($ignoreSticky) { |
133 | $this->bookmarks->reorder('DESC', true); | 143 | $this->bookmarks->reorder('DESC', true); |
@@ -135,7 +145,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
135 | 145 | ||
136 | return $this->bookmarkFilter->filter( | 146 | return $this->bookmarkFilter->filter( |
137 | BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, | 147 | BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, |
138 | [$searchtags, $searchterm], | 148 | [$searchTags, $searchTerm], |
139 | $caseSensitive, | 149 | $caseSensitive, |
140 | $visibility, | 150 | $visibility, |
141 | $untaggedOnly | 151 | $untaggedOnly |
@@ -145,7 +155,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
145 | /** | 155 | /** |
146 | * @inheritDoc | 156 | * @inheritDoc |
147 | */ | 157 | */ |
148 | public function get($id, $visibility = null) | 158 | public function get(int $id, string $visibility = null): Bookmark |
149 | { | 159 | { |
150 | if (! isset($this->bookmarks[$id])) { | 160 | if (! isset($this->bookmarks[$id])) { |
151 | throw new BookmarkNotFoundException(); | 161 | throw new BookmarkNotFoundException(); |
@@ -156,7 +166,8 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
156 | } | 166 | } |
157 | 167 | ||
158 | $bookmark = $this->bookmarks[$id]; | 168 | $bookmark = $this->bookmarks[$id]; |
159 | if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') | 169 | if ( |
170 | ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') | ||
160 | || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') | 171 | || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') |
161 | ) { | 172 | ) { |
162 | throw new Exception('Unauthorized'); | 173 | throw new Exception('Unauthorized'); |
@@ -168,20 +179,17 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
168 | /** | 179 | /** |
169 | * @inheritDoc | 180 | * @inheritDoc |
170 | */ | 181 | */ |
171 | public function set($bookmark, $save = true) | 182 | public function set(Bookmark $bookmark, bool $save = true): Bookmark |
172 | { | 183 | { |
173 | if (true !== $this->isLoggedIn) { | 184 | if (true !== $this->isLoggedIn) { |
174 | throw new Exception(t('You\'re not authorized to alter the datastore')); | 185 | throw new Exception(t('You\'re not authorized to alter the datastore')); |
175 | } | 186 | } |
176 | if (! $bookmark instanceof Bookmark) { | ||
177 | throw new Exception(t('Provided data is invalid')); | ||
178 | } | ||
179 | if (! isset($this->bookmarks[$bookmark->getId()])) { | 187 | if (! isset($this->bookmarks[$bookmark->getId()])) { |
180 | throw new BookmarkNotFoundException(); | 188 | throw new BookmarkNotFoundException(); |
181 | } | 189 | } |
182 | $bookmark->validate(); | 190 | $bookmark->validate(); |
183 | 191 | ||
184 | $bookmark->setUpdated(new \DateTime()); | 192 | $bookmark->setUpdated(new DateTime()); |
185 | $this->bookmarks[$bookmark->getId()] = $bookmark; | 193 | $this->bookmarks[$bookmark->getId()] = $bookmark; |
186 | if ($save === true) { | 194 | if ($save === true) { |
187 | $this->save(); | 195 | $this->save(); |
@@ -193,15 +201,12 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
193 | /** | 201 | /** |
194 | * @inheritDoc | 202 | * @inheritDoc |
195 | */ | 203 | */ |
196 | public function add($bookmark, $save = true) | 204 | public function add(Bookmark $bookmark, bool $save = true): Bookmark |
197 | { | 205 | { |
198 | if (true !== $this->isLoggedIn) { | 206 | if (true !== $this->isLoggedIn) { |
199 | throw new Exception(t('You\'re not authorized to alter the datastore')); | 207 | throw new Exception(t('You\'re not authorized to alter the datastore')); |
200 | } | 208 | } |
201 | if (! $bookmark instanceof Bookmark) { | 209 | if (!empty($bookmark->getId())) { |
202 | throw new Exception(t('Provided data is invalid')); | ||
203 | } | ||
204 | if (! empty($bookmark->getId())) { | ||
205 | throw new Exception(t('This bookmarks already exists')); | 210 | throw new Exception(t('This bookmarks already exists')); |
206 | } | 211 | } |
207 | $bookmark->setId($this->bookmarks->getNextId()); | 212 | $bookmark->setId($this->bookmarks->getNextId()); |
@@ -218,14 +223,11 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
218 | /** | 223 | /** |
219 | * @inheritDoc | 224 | * @inheritDoc |
220 | */ | 225 | */ |
221 | public function addOrSet($bookmark, $save = true) | 226 | public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark |
222 | { | 227 | { |
223 | if (true !== $this->isLoggedIn) { | 228 | if (true !== $this->isLoggedIn) { |
224 | throw new Exception(t('You\'re not authorized to alter the datastore')); | 229 | throw new Exception(t('You\'re not authorized to alter the datastore')); |
225 | } | 230 | } |
226 | if (! $bookmark instanceof Bookmark) { | ||
227 | throw new Exception('Provided data is invalid'); | ||
228 | } | ||
229 | if ($bookmark->getId() === null) { | 231 | if ($bookmark->getId() === null) { |
230 | return $this->add($bookmark, $save); | 232 | return $this->add($bookmark, $save); |
231 | } | 233 | } |
@@ -235,14 +237,11 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
235 | /** | 237 | /** |
236 | * @inheritDoc | 238 | * @inheritDoc |
237 | */ | 239 | */ |
238 | public function remove($bookmark, $save = true) | 240 | public function remove(Bookmark $bookmark, bool $save = true): void |
239 | { | 241 | { |
240 | if (true !== $this->isLoggedIn) { | 242 | if (true !== $this->isLoggedIn) { |
241 | throw new Exception(t('You\'re not authorized to alter the datastore')); | 243 | throw new Exception(t('You\'re not authorized to alter the datastore')); |
242 | } | 244 | } |
243 | if (! $bookmark instanceof Bookmark) { | ||
244 | throw new Exception(t('Provided data is invalid')); | ||
245 | } | ||
246 | if (! isset($this->bookmarks[$bookmark->getId()])) { | 245 | if (! isset($this->bookmarks[$bookmark->getId()])) { |
247 | throw new BookmarkNotFoundException(); | 246 | throw new BookmarkNotFoundException(); |
248 | } | 247 | } |
@@ -257,7 +256,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
257 | /** | 256 | /** |
258 | * @inheritDoc | 257 | * @inheritDoc |
259 | */ | 258 | */ |
260 | public function exists($id, $visibility = null) | 259 | public function exists(int $id, string $visibility = null): bool |
261 | { | 260 | { |
262 | if (! isset($this->bookmarks[$id])) { | 261 | if (! isset($this->bookmarks[$id])) { |
263 | return false; | 262 | return false; |
@@ -268,7 +267,8 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
268 | } | 267 | } |
269 | 268 | ||
270 | $bookmark = $this->bookmarks[$id]; | 269 | $bookmark = $this->bookmarks[$id]; |
271 | if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') | 270 | if ( |
271 | ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') | ||
272 | || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') | 272 | || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') |
273 | ) { | 273 | ) { |
274 | return false; | 274 | return false; |
@@ -280,7 +280,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
280 | /** | 280 | /** |
281 | * @inheritDoc | 281 | * @inheritDoc |
282 | */ | 282 | */ |
283 | public function count($visibility = null) | 283 | public function count(string $visibility = null): int |
284 | { | 284 | { |
285 | return count($this->search([], $visibility)); | 285 | return count($this->search([], $visibility)); |
286 | } | 286 | } |
@@ -288,7 +288,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
288 | /** | 288 | /** |
289 | * @inheritDoc | 289 | * @inheritDoc |
290 | */ | 290 | */ |
291 | public function save() | 291 | public function save(): void |
292 | { | 292 | { |
293 | if (true !== $this->isLoggedIn) { | 293 | if (true !== $this->isLoggedIn) { |
294 | // TODO: raise an Exception instead | 294 | // TODO: raise an Exception instead |
@@ -303,14 +303,15 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
303 | /** | 303 | /** |
304 | * @inheritDoc | 304 | * @inheritDoc |
305 | */ | 305 | */ |
306 | public function bookmarksCountPerTag($filteringTags = [], $visibility = null) | 306 | public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array |
307 | { | 307 | { |
308 | $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); | 308 | $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); |
309 | $tags = []; | 309 | $tags = []; |
310 | $caseMapping = []; | 310 | $caseMapping = []; |
311 | foreach ($bookmarks as $bookmark) { | 311 | foreach ($bookmarks as $bookmark) { |
312 | foreach ($bookmark->getTags() as $tag) { | 312 | foreach ($bookmark->getTags() as $tag) { |
313 | if (empty($tag) | 313 | if ( |
314 | empty($tag) | ||
314 | || (! $this->isLoggedIn && startsWith($tag, '.')) | 315 | || (! $this->isLoggedIn && startsWith($tag, '.')) |
315 | || $tag === BookmarkMarkdownFormatter::NO_MD_TAG | 316 | || $tag === BookmarkMarkdownFormatter::NO_MD_TAG |
316 | || in_array($tag, $filteringTags, true) | 317 | || in_array($tag, $filteringTags, true) |
@@ -339,38 +340,55 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
339 | $keys = array_keys($tags); | 340 | $keys = array_keys($tags); |
340 | $tmpTags = array_combine($keys, $keys); | 341 | $tmpTags = array_combine($keys, $keys); |
341 | array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); | 342 | array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); |
343 | |||
342 | return $tags; | 344 | return $tags; |
343 | } | 345 | } |
344 | 346 | ||
345 | /** | 347 | /** |
346 | * @inheritDoc | 348 | * @inheritDoc |
347 | */ | 349 | */ |
348 | public function days() | 350 | public function findByDate( |
349 | { | 351 | \DateTimeInterface $from, |
350 | $bookmarkDays = []; | 352 | \DateTimeInterface $to, |
351 | foreach ($this->search() as $bookmark) { | 353 | ?\DateTimeInterface &$previous, |
352 | $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 | } | ||
353 | } | 371 | } |
354 | $bookmarkDays = array_keys($bookmarkDays); | ||
355 | sort($bookmarkDays); | ||
356 | 372 | ||
357 | return $bookmarkDays; | 373 | return $out; |
358 | } | 374 | } |
359 | 375 | ||
360 | /** | 376 | /** |
361 | * @inheritDoc | 377 | * @inheritDoc |
362 | */ | 378 | */ |
363 | public function filterDay($request) | 379 | public function getLatest(): ?Bookmark |
364 | { | 380 | { |
365 | $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; | 381 | foreach ($this->search([], null, false, false, true) as $bookmark) { |
382 | return $bookmark; | ||
383 | } | ||
366 | 384 | ||
367 | return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility); | 385 | return null; |
368 | } | 386 | } |
369 | 387 | ||
370 | /** | 388 | /** |
371 | * @inheritDoc | 389 | * @inheritDoc |
372 | */ | 390 | */ |
373 | public function initialize() | 391 | public function initialize(): void |
374 | { | 392 | { |
375 | $initializer = new BookmarkInitializer($this); | 393 | $initializer = new BookmarkInitializer($this); |
376 | $initializer->initialize(); | 394 | $initializer->initialize(); |
@@ -383,7 +401,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
383 | /** | 401 | /** |
384 | * Handles migration to the new database format (BookmarksArray). | 402 | * Handles migration to the new database format (BookmarksArray). |
385 | */ | 403 | */ |
386 | protected function migrate() | 404 | protected function migrate(): void |
387 | { | 405 | { |
388 | $bookmarkDb = new LegacyLinkDB( | 406 | $bookmarkDb = new LegacyLinkDB( |
389 | $this->conf->get('resource.datastore'), | 407 | $this->conf->get('resource.datastore'), |
@@ -391,14 +409,14 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
391 | false | 409 | false |
392 | ); | 410 | ); |
393 | $updater = new LegacyUpdater( | 411 | $updater = new LegacyUpdater( |
394 | UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), | 412 | UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')), |
395 | $bookmarkDb, | 413 | $bookmarkDb, |
396 | $this->conf, | 414 | $this->conf, |
397 | true | 415 | true |
398 | ); | 416 | ); |
399 | $newUpdates = $updater->update(); | 417 | $newUpdates = $updater->update(); |
400 | if (! empty($newUpdates)) { | 418 | if (! empty($newUpdates)) { |
401 | UpdaterUtils::write_updates_file( | 419 | UpdaterUtils::writeUpdatesFile( |
402 | $this->conf->get('resource.updates'), | 420 | $this->conf->get('resource.updates'), |
403 | $updater->getDoneUpdates() | 421 | $updater->getDoneUpdates() |
404 | ); | 422 | ); |
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php index 6636bbfe..db83c51c 100644 --- a/application/bookmark/BookmarkFilter.php +++ b/application/bookmark/BookmarkFilter.php | |||
@@ -1,9 +1,12 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
5 | use Exception; | 7 | use Exception; |
6 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | 8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
9 | use Shaarli\Config\ConfigManager; | ||
7 | 10 | ||
8 | /** | 11 | /** |
9 | * Class LinkFilter. | 12 | * Class LinkFilter. |
@@ -56,12 +59,16 @@ class BookmarkFilter | |||
56 | */ | 59 | */ |
57 | private $bookmarks; | 60 | private $bookmarks; |
58 | 61 | ||
62 | /** @var ConfigManager */ | ||
63 | protected $conf; | ||
64 | |||
59 | /** | 65 | /** |
60 | * @param Bookmark[] $bookmarks initialization. | 66 | * @param Bookmark[] $bookmarks initialization. |
61 | */ | 67 | */ |
62 | public function __construct($bookmarks) | 68 | public function __construct($bookmarks, ConfigManager $conf) |
63 | { | 69 | { |
64 | $this->bookmarks = $bookmarks; | 70 | $this->bookmarks = $bookmarks; |
71 | $this->conf = $conf; | ||
65 | } | 72 | } |
66 | 73 | ||
67 | /** | 74 | /** |
@@ -77,8 +84,13 @@ class BookmarkFilter | |||
77 | * | 84 | * |
78 | * @throws BookmarkNotFoundException | 85 | * @throws BookmarkNotFoundException |
79 | */ | 86 | */ |
80 | public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) | 87 | public function filter( |
81 | { | 88 | string $type, |
89 | $request, | ||
90 | bool $casesensitive = false, | ||
91 | string $visibility = 'all', | ||
92 | bool $untaggedonly = false | ||
93 | ) { | ||
82 | if (!in_array($visibility, ['all', 'public', 'private'])) { | 94 | if (!in_array($visibility, ['all', 'public', 'private'])) { |
83 | $visibility = 'all'; | 95 | $visibility = 'all'; |
84 | } | 96 | } |
@@ -100,10 +112,14 @@ class BookmarkFilter | |||
100 | $filtered = $this->bookmarks; | 112 | $filtered = $this->bookmarks; |
101 | } | 113 | } |
102 | if (!empty($request[0])) { | 114 | if (!empty($request[0])) { |
103 | $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); | 115 | $filtered = (new BookmarkFilter($filtered, $this->conf)) |
116 | ->filterTags($request[0], $casesensitive, $visibility) | ||
117 | ; | ||
104 | } | 118 | } |
105 | if (!empty($request[1])) { | 119 | if (!empty($request[1])) { |
106 | $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); | 120 | $filtered = (new BookmarkFilter($filtered, $this->conf)) |
121 | ->filterFulltext($request[1], $visibility) | ||
122 | ; | ||
107 | } | 123 | } |
108 | return $filtered; | 124 | return $filtered; |
109 | case self::$FILTER_TEXT: | 125 | case self::$FILTER_TEXT: |
@@ -128,13 +144,13 @@ class BookmarkFilter | |||
128 | * | 144 | * |
129 | * @return Bookmark[] filtered bookmarks. | 145 | * @return Bookmark[] filtered bookmarks. |
130 | */ | 146 | */ |
131 | private function noFilter($visibility = 'all') | 147 | private function noFilter(string $visibility = 'all') |
132 | { | 148 | { |
133 | if ($visibility === 'all') { | 149 | if ($visibility === 'all') { |
134 | return $this->bookmarks; | 150 | return $this->bookmarks; |
135 | } | 151 | } |
136 | 152 | ||
137 | $out = array(); | 153 | $out = []; |
138 | foreach ($this->bookmarks as $key => $value) { | 154 | foreach ($this->bookmarks as $key => $value) { |
139 | if ($value->isPrivate() && $visibility === 'private') { | 155 | if ($value->isPrivate() && $visibility === 'private') { |
140 | $out[$key] = $value; | 156 | $out[$key] = $value; |
@@ -151,11 +167,11 @@ class BookmarkFilter | |||
151 | * | 167 | * |
152 | * @param string $smallHash permalink hash. | 168 | * @param string $smallHash permalink hash. |
153 | * | 169 | * |
154 | * @return array $filtered array containing permalink data. | 170 | * @return Bookmark[] $filtered array containing permalink data. |
155 | * | 171 | * |
156 | * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link. | 172 | * @throws BookmarkNotFoundException if the smallhash doesn't match any link. |
157 | */ | 173 | */ |
158 | private function filterSmallHash($smallHash) | 174 | private function filterSmallHash(string $smallHash) |
159 | { | 175 | { |
160 | foreach ($this->bookmarks as $key => $l) { | 176 | foreach ($this->bookmarks as $key => $l) { |
161 | if ($smallHash == $l->getShortUrl()) { | 177 | if ($smallHash == $l->getShortUrl()) { |
@@ -186,15 +202,15 @@ class BookmarkFilter | |||
186 | * @param string $searchterms search query. | 202 | * @param string $searchterms search query. |
187 | * @param string $visibility Optional: return only all/private/public bookmarks. | 203 | * @param string $visibility Optional: return only all/private/public bookmarks. |
188 | * | 204 | * |
189 | * @return array search results. | 205 | * @return Bookmark[] search results. |
190 | */ | 206 | */ |
191 | private function filterFulltext($searchterms, $visibility = 'all') | 207 | private function filterFulltext(string $searchterms, string $visibility = 'all') |
192 | { | 208 | { |
193 | if (empty($searchterms)) { | 209 | if (empty($searchterms)) { |
194 | return $this->noFilter($visibility); | 210 | return $this->noFilter($visibility); |
195 | } | 211 | } |
196 | 212 | ||
197 | $filtered = array(); | 213 | $filtered = []; |
198 | $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); | 214 | $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); |
199 | $exactRegex = '/"([^"]+)"/'; | 215 | $exactRegex = '/"([^"]+)"/'; |
200 | // Retrieve exact search terms. | 216 | // Retrieve exact search terms. |
@@ -206,8 +222,8 @@ class BookmarkFilter | |||
206 | $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); | 222 | $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); |
207 | 223 | ||
208 | // Filter excluding terms and update andSearch. | 224 | // Filter excluding terms and update andSearch. |
209 | $excludeSearch = array(); | 225 | $excludeSearch = []; |
210 | $andSearch = array(); | 226 | $andSearch = []; |
211 | foreach ($explodedSearchAnd as $needle) { | 227 | foreach ($explodedSearchAnd as $needle) { |
212 | if ($needle[0] == '-' && strlen($needle) > 1) { | 228 | if ($needle[0] == '-' && strlen($needle) > 1) { |
213 | $excludeSearch[] = substr($needle, 1); | 229 | $excludeSearch[] = substr($needle, 1); |
@@ -227,33 +243,38 @@ class BookmarkFilter | |||
227 | } | 243 | } |
228 | } | 244 | } |
229 | 245 | ||
230 | // Concatenate link fields to search across fields. | 246 | $lengths = []; |
231 | // Adds a '\' separator for exact search terms. | 247 | $content = $this->buildFullTextSearchableLink($link, $lengths); |
232 | $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; | ||
233 | $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; | ||
234 | $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; | ||
235 | $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; | ||
236 | 248 | ||
237 | // Be optimistic | 249 | // Be optimistic |
238 | $found = true; | 250 | $found = true; |
251 | $foundPositions = []; | ||
239 | 252 | ||
240 | // First, we look for exact term search | 253 | // First, we look for exact term search |
241 | for ($i = 0; $i < count($exactSearch) && $found; $i++) { | 254 | // Then iterate over keywords, if keyword is not found, |
242 | $found = strpos($content, $exactSearch[$i]) !== false; | ||
243 | } | ||
244 | |||
245 | // Iterate over keywords, if keyword is not found, | ||
246 | // no need to check for the others. We want all or nothing. | 255 | // no need to check for the others. We want all or nothing. |
247 | for ($i = 0; $i < count($andSearch) && $found; $i++) { | 256 | foreach ([$exactSearch, $andSearch] as $search) { |
248 | $found = strpos($content, $andSearch[$i]) !== false; | 257 | for ($i = 0; $i < count($search) && $found !== false; $i++) { |
258 | $found = mb_strpos($content, $search[$i]); | ||
259 | if ($found === false) { | ||
260 | break; | ||
261 | } | ||
262 | |||
263 | $foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])]; | ||
264 | } | ||
249 | } | 265 | } |
250 | 266 | ||
251 | // Exclude terms. | 267 | // Exclude terms. |
252 | for ($i = 0; $i < count($excludeSearch) && $found; $i++) { | 268 | for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) { |
253 | $found = strpos($content, $excludeSearch[$i]) === false; | 269 | $found = strpos($content, $excludeSearch[$i]) === false; |
254 | } | 270 | } |
255 | 271 | ||
256 | if ($found) { | 272 | if ($found !== false) { |
273 | $link->addAdditionalContentEntry( | ||
274 | 'search_highlight', | ||
275 | $this->postProcessFoundPositions($lengths, $foundPositions) | ||
276 | ); | ||
277 | |||
257 | $filtered[$id] = $link; | 278 | $filtered[$id] = $link; |
258 | } | 279 | } |
259 | } | 280 | } |
@@ -268,8 +289,9 @@ class BookmarkFilter | |||
268 | * | 289 | * |
269 | * @return string generated regex fragment | 290 | * @return string generated regex fragment |
270 | */ | 291 | */ |
271 | private static function tag2regex($tag) | 292 | protected function tag2regex(string $tag): string |
272 | { | 293 | { |
294 | $tagsSeparator = $this->conf->get('general.tags_separator', ' '); | ||
273 | $len = strlen($tag); | 295 | $len = strlen($tag); |
274 | if (!$len || $tag === "-" || $tag === "*") { | 296 | if (!$len || $tag === "-" || $tag === "*") { |
275 | // nothing to search, return empty regex | 297 | // nothing to search, return empty regex |
@@ -283,12 +305,13 @@ class BookmarkFilter | |||
283 | $i = 0; // start at first character | 305 | $i = 0; // start at first character |
284 | $regex = '(?='; // use positive lookahead | 306 | $regex = '(?='; // use positive lookahead |
285 | } | 307 | } |
286 | $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 . ')'; | ||
287 | // iterate over string, separating it into placeholder and content | 310 | // iterate over string, separating it into placeholder and content |
288 | for (; $i < $len; $i++) { | 311 | for (; $i < $len; $i++) { |
289 | if ($tag[$i] === '*') { | 312 | if ($tag[$i] === '*') { |
290 | // placeholder found | 313 | // placeholder found |
291 | $regex .= '[^ ]*?'; | 314 | $regex .= '[^' . $tagsSeparator . ']*?'; |
292 | } else { | 315 | } else { |
293 | // regular characters | 316 | // regular characters |
294 | $offset = strpos($tag, '*', $i); | 317 | $offset = strpos($tag, '*', $i); |
@@ -304,7 +327,8 @@ class BookmarkFilter | |||
304 | $i = $offset; | 327 | $i = $offset; |
305 | } | 328 | } |
306 | } | 329 | } |
307 | $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 . '))'; | ||
308 | return $regex; | 332 | return $regex; |
309 | } | 333 | } |
310 | 334 | ||
@@ -314,22 +338,23 @@ class BookmarkFilter | |||
314 | * You can specify one or more tags, separated by space or a comma, e.g. | 338 | * You can specify one or more tags, separated by space or a comma, e.g. |
315 | * print_r($mydb->filterTags('linux programming')); | 339 | * print_r($mydb->filterTags('linux programming')); |
316 | * | 340 | * |
317 | * @param string $tags list of tags separated by commas or blank spaces. | 341 | * @param string|array $tags list of tags, separated by commas or blank spaces if passed as string. |
318 | * @param bool $casesensitive ignore case if false. | 342 | * @param bool $casesensitive ignore case if false. |
319 | * @param string $visibility Optional: return only all/private/public bookmarks. | 343 | * @param string $visibility Optional: return only all/private/public bookmarks. |
320 | * | 344 | * |
321 | * @return array filtered bookmarks. | 345 | * @return Bookmark[] filtered bookmarks. |
322 | */ | 346 | */ |
323 | public function filterTags($tags, $casesensitive = false, $visibility = 'all') | 347 | public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all') |
324 | { | 348 | { |
349 | $tagsSeparator = $this->conf->get('general.tags_separator', ' '); | ||
325 | // 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) |
326 | $inputTags = $tags; | 351 | $inputTags = $tags; |
327 | if (!is_array($tags)) { | 352 | if (!is_array($tags)) { |
328 | // we got an input string, split tags | 353 | // we got an input string, split tags |
329 | $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); | 354 | $inputTags = tags_str2array($inputTags, $tagsSeparator); |
330 | } | 355 | } |
331 | 356 | ||
332 | if (!count($inputTags)) { | 357 | if (count($inputTags) === 0) { |
333 | // no input tags | 358 | // no input tags |
334 | return $this->noFilter($visibility); | 359 | return $this->noFilter($visibility); |
335 | } | 360 | } |
@@ -346,7 +371,7 @@ class BookmarkFilter | |||
346 | } | 371 | } |
347 | 372 | ||
348 | // build regex from all tags | 373 | // build regex from all tags |
349 | $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; | 374 | $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/'; |
350 | if (!$casesensitive) { | 375 | if (!$casesensitive) { |
351 | // make regex case insensitive | 376 | // make regex case insensitive |
352 | $re .= 'i'; | 377 | $re .= 'i'; |
@@ -366,10 +391,11 @@ class BookmarkFilter | |||
366 | continue; | 391 | continue; |
367 | } | 392 | } |
368 | } | 393 | } |
369 | $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); | ||
370 | if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { | 396 | if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { |
371 | // description given and at least one possible tag found | 397 | // description given and at least one possible tag found |
372 | $descTags = array(); | 398 | $descTags = []; |
373 | // find all tags in the form of #tag in the description | 399 | // find all tags in the form of #tag in the description |
374 | preg_match_all( | 400 | preg_match_all( |
375 | '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', | 401 | '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', |
@@ -378,9 +404,9 @@ class BookmarkFilter | |||
378 | ); | 404 | ); |
379 | if (count($descTags[1])) { | 405 | if (count($descTags[1])) { |
380 | // 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 |
381 | $search .= ' ' . implode(' ', $descTags[1]); | 407 | $search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator); |
382 | } | 408 | } |
383 | }; | 409 | } |
384 | // match regular expression with search string | 410 | // match regular expression with search string |
385 | if (!preg_match($re, $search)) { | 411 | if (!preg_match($re, $search)) { |
386 | // this entry does _not_ match our regex | 412 | // this entry does _not_ match our regex |
@@ -396,9 +422,9 @@ class BookmarkFilter | |||
396 | * | 422 | * |
397 | * @param string $visibility return only all/private/public bookmarks. | 423 | * @param string $visibility return only all/private/public bookmarks. |
398 | * | 424 | * |
399 | * @return array filtered bookmarks. | 425 | * @return Bookmark[] filtered bookmarks. |
400 | */ | 426 | */ |
401 | public function filterUntagged($visibility) | 427 | public function filterUntagged(string $visibility) |
402 | { | 428 | { |
403 | $filtered = []; | 429 | $filtered = []; |
404 | foreach ($this->bookmarks as $key => $link) { | 430 | foreach ($this->bookmarks as $key => $link) { |
@@ -410,7 +436,7 @@ class BookmarkFilter | |||
410 | } | 436 | } |
411 | } | 437 | } |
412 | 438 | ||
413 | if (empty(trim($link->getTagsString()))) { | 439 | if (empty($link->getTags())) { |
414 | $filtered[$key] = $link; | 440 | $filtered[$key] = $link; |
415 | } | 441 | } |
416 | } | 442 | } |
@@ -427,11 +453,11 @@ class BookmarkFilter | |||
427 | * @param string $day day to filter. | 453 | * @param string $day day to filter. |
428 | * @param string $visibility return only all/private/public bookmarks. | 454 | * @param string $visibility return only all/private/public bookmarks. |
429 | 455 | ||
430 | * @return array all link matching given day. | 456 | * @return Bookmark[] all link matching given day. |
431 | * | 457 | * |
432 | * @throws Exception if date format is invalid. | 458 | * @throws Exception if date format is invalid. |
433 | */ | 459 | */ |
434 | public function filterDay($day, $visibility) | 460 | public function filterDay(string $day, string $visibility) |
435 | { | 461 | { |
436 | if (!checkDateFormat('Ymd', $day)) { | 462 | if (!checkDateFormat('Ymd', $day)) { |
437 | throw new Exception('Invalid date format'); | 463 | throw new Exception('Invalid date format'); |
@@ -460,9 +486,9 @@ class BookmarkFilter | |||
460 | * @param string $tags string containing a list of tags. | 486 | * @param string $tags string containing a list of tags. |
461 | * @param bool $casesensitive will convert everything to lowercase if false. | 487 | * @param bool $casesensitive will convert everything to lowercase if false. |
462 | * | 488 | * |
463 | * @return array filtered tags string. | 489 | * @return string[] filtered tags string. |
464 | */ | 490 | */ |
465 | public static function tagsStrToArray($tags, $casesensitive) | 491 | public static function tagsStrToArray(string $tags, bool $casesensitive): array |
466 | { | 492 | { |
467 | // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) | 493 | // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) |
468 | $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); | 494 | $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); |
@@ -470,4 +496,75 @@ class BookmarkFilter | |||
470 | 496 | ||
471 | return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); | 497 | return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); |
472 | } | 498 | } |
499 | |||
500 | /** | ||
501 | * This method finalize the content of the foundPositions array, | ||
502 | * by associated all search results to their associated bookmark field, | ||
503 | * making sure that there is no overlapping results, etc. | ||
504 | * | ||
505 | * @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content. | ||
506 | * @param array $foundPositions Positions where the search results were found in the aggregated content. | ||
507 | * | ||
508 | * @return array Updated $foundPositions, by bookmark field. | ||
509 | */ | ||
510 | protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array | ||
511 | { | ||
512 | // Sort results by starting position ASC. | ||
513 | usort($foundPositions, function (array $entryA, array $entryB): int { | ||
514 | return $entryA['start'] > $entryB['start'] ? 1 : -1; | ||
515 | }); | ||
516 | |||
517 | $out = []; | ||
518 | $currentMax = -1; | ||
519 | foreach ($foundPositions as $foundPosition) { | ||
520 | // we do not allow overlapping highlights | ||
521 | if ($foundPosition['start'] < $currentMax) { | ||
522 | continue; | ||
523 | } | ||
524 | |||
525 | $currentMax = $foundPosition['end']; | ||
526 | foreach ($fieldLengths as $part => $length) { | ||
527 | if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) { | ||
528 | continue; | ||
529 | } | ||
530 | |||
531 | $out[$part][] = [ | ||
532 | 'start' => $foundPosition['start'] - $length['start'], | ||
533 | 'end' => $foundPosition['end'] - $length['start'], | ||
534 | ]; | ||
535 | break; | ||
536 | } | ||
537 | } | ||
538 | |||
539 | return $out; | ||
540 | } | ||
541 | |||
542 | /** | ||
543 | * Concatenate link fields to search across fields. Adds a '\' separator for exact search terms. | ||
544 | * Also populate $length array with starting and ending positions of every bookmark field | ||
545 | * inside concatenated content. | ||
546 | * | ||
547 | * @param Bookmark $link | ||
548 | * @param array $lengths (by reference) | ||
549 | * | ||
550 | * @return string Lowercase concatenated fields content. | ||
551 | */ | ||
552 | protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string | ||
553 | { | ||
554 | $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' ')); | ||
555 | $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\'; | ||
556 | $content .= mb_convert_case($link->getDescription(), 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') . '\\'; | ||
559 | |||
560 | $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())]; | ||
561 | $nextField = $lengths['title']['end'] + 1; | ||
562 | $lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())]; | ||
563 | $nextField = $lengths['description']['end'] + 1; | ||
564 | $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())]; | ||
565 | $nextField = $lengths['url']['end'] + 1; | ||
566 | $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)]; | ||
567 | |||
568 | return $content; | ||
569 | } | ||
473 | } | 570 | } |
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php index 6bf7f365..c78dbe41 100644 --- a/application/bookmark/BookmarkIO.php +++ b/application/bookmark/BookmarkIO.php | |||
@@ -1,7 +1,11 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
7 | use malkusch\lock\mutex\Mutex; | ||
8 | use malkusch\lock\mutex\NoMutex; | ||
5 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | 9 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; |
6 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; | 10 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; |
7 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; | 11 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; |
@@ -27,11 +31,14 @@ class BookmarkIO | |||
27 | */ | 31 | */ |
28 | protected $conf; | 32 | protected $conf; |
29 | 33 | ||
34 | |||
35 | /** @var Mutex */ | ||
36 | protected $mutex; | ||
37 | |||
30 | /** | 38 | /** |
31 | * string Datastore PHP prefix | 39 | * string Datastore PHP prefix |
32 | */ | 40 | */ |
33 | protected static $phpPrefix = '<?php /* '; | 41 | protected static $phpPrefix = '<?php /* '; |
34 | |||
35 | /** | 42 | /** |
36 | * string Datastore PHP suffix | 43 | * string Datastore PHP suffix |
37 | */ | 44 | */ |
@@ -42,16 +49,21 @@ class BookmarkIO | |||
42 | * | 49 | * |
43 | * @param ConfigManager $conf instance | 50 | * @param ConfigManager $conf instance |
44 | */ | 51 | */ |
45 | public function __construct($conf) | 52 | public function __construct(ConfigManager $conf, Mutex $mutex = null) |
46 | { | 53 | { |
54 | if ($mutex === null) { | ||
55 | // This should only happen with legacy classes | ||
56 | $mutex = new NoMutex(); | ||
57 | } | ||
47 | $this->conf = $conf; | 58 | $this->conf = $conf; |
48 | $this->datastore = $conf->get('resource.datastore'); | 59 | $this->datastore = $conf->get('resource.datastore'); |
60 | $this->mutex = $mutex; | ||
49 | } | 61 | } |
50 | 62 | ||
51 | /** | 63 | /** |
52 | * Reads database from disk to memory | 64 | * Reads database from disk to memory |
53 | * | 65 | * |
54 | * @return BookmarkArray instance | 66 | * @return Bookmark[] |
55 | * | 67 | * |
56 | * @throws NotWritableDataStoreException Data couldn't be loaded | 68 | * @throws NotWritableDataStoreException Data couldn't be loaded |
57 | * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark | 69 | * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark |
@@ -67,11 +79,16 @@ class BookmarkIO | |||
67 | throw new NotWritableDataStoreException($this->datastore); | 79 | throw new NotWritableDataStoreException($this->datastore); |
68 | } | 80 | } |
69 | 81 | ||
82 | $content = null; | ||
83 | $this->mutex->synchronized(function () use (&$content) { | ||
84 | $content = file_get_contents($this->datastore); | ||
85 | }); | ||
86 | |||
70 | // Note that gzinflate is faster than gzuncompress. | 87 | // Note that gzinflate is faster than gzuncompress. |
71 | // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 | 88 | // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 |
72 | $links = unserialize(gzinflate(base64_decode( | 89 | $links = unserialize(gzinflate(base64_decode( |
73 | substr(file_get_contents($this->datastore), | 90 | substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix)) |
74 | strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); | 91 | ))); |
75 | 92 | ||
76 | if (empty($links)) { | 93 | if (empty($links)) { |
77 | if (filesize($this->datastore) > 100) { | 94 | if (filesize($this->datastore) > 100) { |
@@ -86,7 +103,7 @@ class BookmarkIO | |||
86 | /** | 103 | /** |
87 | * Saves the database from memory to disk | 104 | * Saves the database from memory to disk |
88 | * | 105 | * |
89 | * @param BookmarkArray $links instance. | 106 | * @param Bookmark[] $links |
90 | * | 107 | * |
91 | * @throws NotWritableDataStoreException the datastore is not writable | 108 | * @throws NotWritableDataStoreException the datastore is not writable |
92 | */ | 109 | */ |
@@ -95,14 +112,18 @@ class BookmarkIO | |||
95 | if (is_file($this->datastore) && !is_writeable($this->datastore)) { | 112 | if (is_file($this->datastore) && !is_writeable($this->datastore)) { |
96 | // The datastore exists but is not writeable | 113 | // The datastore exists but is not writeable |
97 | throw new NotWritableDataStoreException($this->datastore); | 114 | throw new NotWritableDataStoreException($this->datastore); |
98 | } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { | 115 | } elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { |
99 | // The datastore does not exist and its parent directory is not writeable | 116 | // The datastore does not exist and its parent directory is not writeable |
100 | throw new NotWritableDataStoreException(dirname($this->datastore)); | 117 | throw new NotWritableDataStoreException(dirname($this->datastore)); |
101 | } | 118 | } |
102 | 119 | ||
103 | file_put_contents( | 120 | $data = self::$phpPrefix . base64_encode(gzdeflate(serialize($links))) . self::$phpSuffix; |
104 | $this->datastore, | 121 | |
105 | self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix | 122 | $this->mutex->synchronized(function () use ($data) { |
106 | ); | 123 | file_put_contents( |
124 | $this->datastore, | ||
125 | $data | ||
126 | ); | ||
127 | }); | ||
107 | } | 128 | } |
108 | } | 129 | } |
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php index 815047e3..8ab5c441 100644 --- a/application/bookmark/BookmarkInitializer.php +++ b/application/bookmark/BookmarkInitializer.php | |||
@@ -1,5 +1,7 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
5 | /** | 7 | /** |
@@ -11,6 +13,9 @@ namespace Shaarli\Bookmark; | |||
11 | * To prevent data corruption, it does not overwrite existing bookmarks, | 13 | * To prevent data corruption, it does not overwrite existing bookmarks, |
12 | * even though there should not be any. | 14 | * even though there should not be any. |
13 | * | 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 | * | ||
14 | * @package Shaarli\Bookmark | 19 | * @package Shaarli\Bookmark |
15 | */ | 20 | */ |
16 | class BookmarkInitializer | 21 | class BookmarkInitializer |
@@ -23,7 +28,7 @@ class BookmarkInitializer | |||
23 | * | 28 | * |
24 | * @param BookmarkServiceInterface $bookmarkService | 29 | * @param BookmarkServiceInterface $bookmarkService |
25 | */ | 30 | */ |
26 | public function __construct($bookmarkService) | 31 | public function __construct(BookmarkServiceInterface $bookmarkService) |
27 | { | 32 | { |
28 | $this->bookmarkService = $bookmarkService; | 33 | $this->bookmarkService = $bookmarkService; |
29 | } | 34 | } |
@@ -31,13 +36,13 @@ class BookmarkInitializer | |||
31 | /** | 36 | /** |
32 | * Initialize the data store with default bookmarks | 37 | * Initialize the data store with default bookmarks |
33 | */ | 38 | */ |
34 | public function initialize() | 39 | public function initialize(): void |
35 | { | 40 | { |
36 | $bookmark = new Bookmark(); | 41 | $bookmark = new Bookmark(); |
37 | $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)')); |
38 | $bookmark->setUrl('https://vimeo.com/153493904'); | 43 | $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c'); |
39 | $bookmark->setDescription(t( | 44 | $bookmark->setDescription(t( |
40 | '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. |
41 | 46 | ||
42 | Explore your new Shaarli instance by trying out controls and menus. | 47 | Explore your new Shaarli instance by trying out controls and menus. |
43 | 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. |
@@ -52,7 +57,7 @@ Now you can edit or delete the default shaares. | |||
52 | $bookmark = new Bookmark(); | 57 | $bookmark = new Bookmark(); |
53 | $bookmark->setTitle(t('Note: Shaare descriptions')); | 58 | $bookmark->setTitle(t('Note: Shaare descriptions')); |
54 | $bookmark->setDescription(t( | 59 | $bookmark->setDescription(t( |
55 | '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. |
56 | 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. |
57 | 62 | ||
58 | 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. |
@@ -89,7 +94,7 @@ Markdown also supports tables: | |||
89 | 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') | 94 | 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') |
90 | ); | 95 | ); |
91 | $bookmark->setDescription(t( | 96 | $bookmark->setDescription(t( |
92 | 'Welcome to Shaarli! | 97 | 'Welcome to Shaarli! |
93 | 98 | ||
94 | 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. |
95 | 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 b9b483eb..08cdbb4e 100644 --- a/application/bookmark/BookmarkServiceInterface.php +++ b/application/bookmark/BookmarkServiceInterface.php | |||
@@ -1,79 +1,73 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | namespace Shaarli\Bookmark; | 3 | declare(strict_types=1); |
4 | 4 | ||
5 | namespace Shaarli\Bookmark; | ||
5 | 6 | ||
6 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | 7 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
7 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; | 8 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; |
8 | use Shaarli\Config\ConfigManager; | ||
9 | use Shaarli\History; | ||
10 | 9 | ||
11 | /** | 10 | /** |
12 | * Class BookmarksService | 11 | * Class BookmarksService |
13 | * | 12 | * |
14 | * This is the entry point to manipulate the bookmark DB. | 13 | * This is the entry point to manipulate the bookmark DB. |
14 | * | ||
15 | * Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation, | ||
16 | * so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added. | ||
15 | */ | 17 | */ |
16 | interface BookmarkServiceInterface | 18 | interface BookmarkServiceInterface |
17 | { | 19 | { |
18 | /** | 20 | /** |
19 | * BookmarksService constructor. | ||
20 | * | ||
21 | * @param ConfigManager $conf instance | ||
22 | * @param History $history instance | ||
23 | * @param bool $isLoggedIn true if the current user is logged in | ||
24 | */ | ||
25 | public function __construct(ConfigManager $conf, History $history, $isLoggedIn); | ||
26 | |||
27 | /** | ||
28 | * Find a bookmark by hash | 21 | * Find a bookmark by hash |
29 | * | 22 | * |
30 | * @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 | ||
31 | * | 25 | * |
32 | * @return mixed | 26 | * @return Bookmark |
33 | * | 27 | * |
34 | * @throws \Exception | 28 | * @throws \Exception |
35 | */ | 29 | */ |
36 | public function findByHash($hash); | 30 | public function findByHash(string $hash, string $privateKey = null); |
37 | 31 | ||
38 | /** | 32 | /** |
39 | * @param $url | 33 | * @param $url |
40 | * | 34 | * |
41 | * @return Bookmark|null | 35 | * @return Bookmark|null |
42 | */ | 36 | */ |
43 | public function findByUrl($url); | 37 | public function findByUrl(string $url): ?Bookmark; |
44 | 38 | ||
45 | /** | 39 | /** |
46 | * Search bookmarks | 40 | * Search bookmarks |
47 | * | 41 | * |
48 | * @param mixed $request | 42 | * @param array $request |
49 | * @param string $visibility | 43 | * @param ?string $visibility |
50 | * @param bool $caseSensitive | 44 | * @param bool $caseSensitive |
51 | * @param bool $untaggedOnly | 45 | * @param bool $untaggedOnly |
52 | * @param bool $ignoreSticky | 46 | * @param bool $ignoreSticky |
53 | * | 47 | * |
54 | * @return Bookmark[] | 48 | * @return Bookmark[] |
55 | */ | 49 | */ |
56 | public function search( | 50 | public function search( |
57 | $request = [], | 51 | array $request = [], |
58 | $visibility = null, | 52 | string $visibility = null, |
59 | $caseSensitive = false, | 53 | bool $caseSensitive = false, |
60 | $untaggedOnly = false, | 54 | bool $untaggedOnly = false, |
61 | bool $ignoreSticky = false | 55 | bool $ignoreSticky = false |
62 | ); | 56 | ); |
63 | 57 | ||
64 | /** | 58 | /** |
65 | * Get a single bookmark by its ID. | 59 | * Get a single bookmark by its ID. |
66 | * | 60 | * |
67 | * @param int $id Bookmark ID | 61 | * @param int $id Bookmark ID |
68 | * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an | 62 | * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an |
69 | * exception | 63 | * exception |
70 | * | 64 | * |
71 | * @return Bookmark | 65 | * @return Bookmark |
72 | * | 66 | * |
73 | * @throws BookmarkNotFoundException | 67 | * @throws BookmarkNotFoundException |
74 | * @throws \Exception | 68 | * @throws \Exception |
75 | */ | 69 | */ |
76 | public function get($id, $visibility = null); | 70 | public function get(int $id, string $visibility = null); |
77 | 71 | ||
78 | /** | 72 | /** |
79 | * Updates an existing bookmark (depending on its ID). | 73 | * Updates an existing bookmark (depending on its ID). |
@@ -86,7 +80,7 @@ interface BookmarkServiceInterface | |||
86 | * @throws BookmarkNotFoundException | 80 | * @throws BookmarkNotFoundException |
87 | * @throws \Exception | 81 | * @throws \Exception |
88 | */ | 82 | */ |
89 | public function set($bookmark, $save = true); | 83 | public function set(Bookmark $bookmark, bool $save = true): Bookmark; |
90 | 84 | ||
91 | /** | 85 | /** |
92 | * Adds a new bookmark (the ID must be empty). | 86 | * Adds a new bookmark (the ID must be empty). |
@@ -98,7 +92,7 @@ interface BookmarkServiceInterface | |||
98 | * | 92 | * |
99 | * @throws \Exception | 93 | * @throws \Exception |
100 | */ | 94 | */ |
101 | public function add($bookmark, $save = true); | 95 | public function add(Bookmark $bookmark, bool $save = true): Bookmark; |
102 | 96 | ||
103 | /** | 97 | /** |
104 | * Adds or updates a bookmark depending on its ID: | 98 | * Adds or updates a bookmark depending on its ID: |
@@ -112,7 +106,7 @@ interface BookmarkServiceInterface | |||
112 | * | 106 | * |
113 | * @throws \Exception | 107 | * @throws \Exception |
114 | */ | 108 | */ |
115 | public function addOrSet($bookmark, $save = true); | 109 | public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark; |
116 | 110 | ||
117 | /** | 111 | /** |
118 | * Deletes a bookmark. | 112 | * Deletes a bookmark. |
@@ -122,65 +116,72 @@ interface BookmarkServiceInterface | |||
122 | * | 116 | * |
123 | * @throws \Exception | 117 | * @throws \Exception |
124 | */ | 118 | */ |
125 | public function remove($bookmark, $save = true); | 119 | public function remove(Bookmark $bookmark, bool $save = true): void; |
126 | 120 | ||
127 | /** | 121 | /** |
128 | * Get a single bookmark by its ID. | 122 | * Get a single bookmark by its ID. |
129 | * | 123 | * |
130 | * @param int $id Bookmark ID | 124 | * @param int $id Bookmark ID |
131 | * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an | 125 | * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an |
132 | * exception | 126 | * exception |
133 | * | 127 | * |
134 | * @return bool | 128 | * @return bool |
135 | */ | 129 | */ |
136 | public function exists($id, $visibility = null); | 130 | public function exists(int $id, string $visibility = null): bool; |
137 | 131 | ||
138 | /** | 132 | /** |
139 | * Return the number of available bookmarks for given visibility. | 133 | * Return the number of available bookmarks for given visibility. |
140 | * | 134 | * |
141 | * @param string $visibility public|private|all | 135 | * @param ?string $visibility public|private|all |
142 | * | 136 | * |
143 | * @return int Number of bookmarks | 137 | * @return int Number of bookmarks |
144 | */ | 138 | */ |
145 | public function count($visibility = null); | 139 | public function count(string $visibility = null): int; |
146 | 140 | ||
147 | /** | 141 | /** |
148 | * Write the datastore. | 142 | * Write the datastore. |
149 | * | 143 | * |
150 | * @throws NotWritableDataStoreException | 144 | * @throws NotWritableDataStoreException |
151 | */ | 145 | */ |
152 | public function save(); | 146 | public function save(): void; |
153 | 147 | ||
154 | /** | 148 | /** |
155 | * Returns the list tags appearing in the bookmarks with the given tags | 149 | * Returns the list tags appearing in the bookmarks with the given tags |
156 | * | 150 | * |
157 | * @param array $filteringTags tags selecting the bookmarks to consider | 151 | * @param array|null $filteringTags tags selecting the bookmarks to consider |
158 | * @param string $visibility process only all/private/public bookmarks | 152 | * @param string|null $visibility process only all/private/public bookmarks |
159 | * | 153 | * |
160 | * @return array tag => bookmarksCount | 154 | * @return array tag => bookmarksCount |
161 | */ | 155 | */ |
162 | public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all'); | 156 | public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array; |
163 | 157 | ||
164 | /** | 158 | /** |
165 | * 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. | ||
161 | * | ||
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 | * | 166 | * |
167 | * @return array containing days (in format YYYYMMDD). | 167 | * @return array List of bookmarks matching provided period of time. |
168 | */ | 168 | */ |
169 | public function days(); | 169 | public function findByDate( |
170 | \DateTimeInterface $from, | ||
171 | \DateTimeInterface $to, | ||
172 | ?\DateTimeInterface &$previous, | ||
173 | ?\DateTimeInterface &$next | ||
174 | ): array; | ||
170 | 175 | ||
171 | /** | 176 | /** |
172 | * Returns the list of articles for a given day. | 177 | * Returns the latest bookmark by creation date. |
173 | * | 178 | * |
174 | * @param string $request day to filter. Format: YYYYMMDD. | 179 | * @return Bookmark|null Found Bookmark or null if the datastore is empty. |
175 | * | ||
176 | * @return Bookmark[] list of shaare found. | ||
177 | * | ||
178 | * @throws BookmarkNotFoundException | ||
179 | */ | 180 | */ |
180 | public function filterDay($request); | 181 | public function getLatest(): ?Bookmark; |
181 | 182 | ||
182 | /** | 183 | /** |
183 | * Creates the default database after a fresh install. | 184 | * Creates the default database after a fresh install. |
184 | */ | 185 | */ |
185 | public function initialize(); | 186 | public function initialize(): void; |
186 | } | 187 | } |
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php index e7af4d55..d65e97ed 100644 --- a/application/bookmark/LinkUtils.php +++ b/application/bookmark/LinkUtils.php | |||
@@ -66,16 +66,19 @@ function html_extract_tag($tag, $html) | |||
66 | { | 66 | { |
67 | $propertiesKey = ['property', 'name', 'itemprop']; | 67 | $propertiesKey = ['property', 'name', 'itemprop']; |
68 | $properties = implode('|', $propertiesKey); | 68 | $properties = implode('|', $propertiesKey); |
69 | // Try to retrieve OpenGraph image. | 69 | // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"' |
70 | $ogRegex = '#<meta[^>]+(?:'. $properties .')=["\']?(?:og:)?'. $tag .'["\'\s][^>]*content=["\']?(.*?)["\'/>]#'; | 70 | $orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]'; |
71 | // Try to retrieve OpenGraph tag. | ||
72 | $ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*content=(["\'])([^\1]*?)\1.*?>#'; | ||
71 | // If the attributes are not in the order property => content (e.g. Github) | 73 | // If the attributes are not in the order property => content (e.g. Github) |
72 | // New regex to keep this readable... more or less. | 74 | // New regex to keep this readable... more or less. |
73 | $ogRegexReverse = '#<meta[^>]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#'; | 75 | $ogRegexReverse = '#<meta[^>]+content=(["\'])([^\1]*?)\1[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#'; |
74 | 76 | ||
75 | if (preg_match($ogRegex, $html, $matches) > 0 | 77 | if ( |
78 | preg_match($ogRegex, $html, $matches) > 0 | ||
76 | || preg_match($ogRegexReverse, $html, $matches) > 0 | 79 | || preg_match($ogRegexReverse, $html, $matches) > 0 |
77 | ) { | 80 | ) { |
78 | return $matches[1]; | 81 | return $matches[2]; |
79 | } | 82 | } |
80 | 83 | ||
81 | return false; | 84 | return false; |
@@ -114,7 +117,7 @@ function hashtag_autolink($description, $indexUrl = '') | |||
114 | * \p{Mn} - any non marking space (accents, umlauts, etc) | 117 | * \p{Mn} - any non marking space (accents, umlauts, etc) |
115 | */ | 118 | */ |
116 | $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; | 119 | $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; |
117 | $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>'; | 120 | $replacement = '$1<a href="' . $indexUrl . './add-tag/$2" title="Hashtag $2">#$2</a>'; |
118 | return preg_replace($regex, $replacement, $description); | 121 | return preg_replace($regex, $replacement, $description); |
119 | } | 122 | } |
120 | 123 | ||
@@ -136,12 +139,17 @@ function space2nbsp($text) | |||
136 | * | 139 | * |
137 | * @param string $description shaare's description. | 140 | * @param string $description shaare's description. |
138 | * @param string $indexUrl URL to Shaarli's index. | 141 | * @param string $indexUrl URL to Shaarli's index. |
139 | 142 | * @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags | |
143 | * | ||
140 | * @return string formatted description. | 144 | * @return string formatted description. |
141 | */ | 145 | */ |
142 | function format_description($description, $indexUrl = '') | 146 | function format_description($description, $indexUrl = '', $autolink = true) |
143 | { | 147 | { |
144 | return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl))); | 148 | if ($autolink) { |
149 | $description = hashtag_autolink(text2clickable($description), $indexUrl); | ||
150 | } | ||
151 | |||
152 | return nl2br(space2nbsp($description)); | ||
145 | } | 153 | } |
146 | 154 | ||
147 | /** | 155 | /** |
@@ -169,3 +177,49 @@ function is_note($linkUrl) | |||
169 | { | 177 | { |
170 | return isset($linkUrl[0]) && $linkUrl[0] === '?'; | 178 | return isset($linkUrl[0]) && $linkUrl[0] === '?'; |
171 | } | 179 | } |
180 | |||
181 | /** | ||
182 | * Extract an array of tags from a given tag string, with provided separator. | ||
183 | * | ||
184 | * @param string|null $tags String containing a list of tags separated by $separator. | ||
185 | * @param string $separator Shaarli's default: ' ' (whitespace) | ||
186 | * | ||
187 | * @return array List of tags | ||
188 | */ | ||
189 | function tags_str2array(?string $tags, string $separator): array | ||
190 | { | ||
191 | // For whitespaces, we use the special \s regex character | ||
192 | $separator = $separator === ' ' ? '\s' : $separator; | ||
193 | |||
194 | return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY); | ||
195 | } | ||
196 | |||
197 | /** | ||
198 | * Return a tag string with provided separator from a list of tags. | ||
199 | * Note that given array is clean up by tags_filter(). | ||
200 | * | ||
201 | * @param array|null $tags List of tags | ||
202 | * @param string $separator | ||
203 | * | ||
204 | * @return string | ||
205 | */ | ||
206 | function tags_array2str(?array $tags, string $separator): string | ||
207 | { | ||
208 | return implode($separator, tags_filter($tags, $separator)); | ||
209 | } | ||
210 | |||
211 | /** | ||
212 | * Clean an array of tags: trim + remove empty entries | ||
213 | * | ||
214 | * @param array|null $tags List of tags | ||
215 | * @param string $separator | ||
216 | * | ||
217 | * @return array | ||
218 | */ | ||
219 | function tags_filter(?array $tags, string $separator): array | ||
220 | { | ||
221 | $trimDefault = " \t\n\r\0\x0B"; | ||
222 | return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string { | ||
223 | return trim($entry, $trimDefault . $separator); | ||
224 | }, $tags ?? []))); | ||
225 | } | ||
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 55bb51b5..f0234eca 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php | |||
@@ -4,6 +4,8 @@ declare(strict_types=1); | |||
4 | 4 | ||
5 | namespace Shaarli\Container; | 5 | namespace Shaarli\Container; |
6 | 6 | ||
7 | use malkusch\lock\mutex\FlockMutex; | ||
8 | use Psr\Log\LoggerInterface; | ||
7 | use Shaarli\Bookmark\BookmarkFileService; | 9 | use Shaarli\Bookmark\BookmarkFileService; |
8 | use Shaarli\Bookmark\BookmarkServiceInterface; | 10 | use Shaarli\Bookmark\BookmarkServiceInterface; |
9 | use Shaarli\Config\ConfigManager; | 11 | use Shaarli\Config\ConfigManager; |
@@ -13,6 +15,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController; | |||
13 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; | 15 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; |
14 | use Shaarli\History; | 16 | use Shaarli\History; |
15 | use Shaarli\Http\HttpAccess; | 17 | use Shaarli\Http\HttpAccess; |
18 | use Shaarli\Http\MetadataRetriever; | ||
16 | use Shaarli\Netscape\NetscapeBookmarkUtils; | 19 | use Shaarli\Netscape\NetscapeBookmarkUtils; |
17 | use Shaarli\Plugin\PluginManager; | 20 | use Shaarli\Plugin\PluginManager; |
18 | use Shaarli\Render\PageBuilder; | 21 | use Shaarli\Render\PageBuilder; |
@@ -47,6 +50,9 @@ class ContainerBuilder | |||
47 | /** @var LoginManager */ | 50 | /** @var LoginManager */ |
48 | protected $login; | 51 | protected $login; |
49 | 52 | ||
53 | /** @var LoggerInterface */ | ||
54 | protected $logger; | ||
55 | |||
50 | /** @var string|null */ | 56 | /** @var string|null */ |
51 | protected $basePath = null; | 57 | protected $basePath = null; |
52 | 58 | ||
@@ -54,12 +60,14 @@ class ContainerBuilder | |||
54 | ConfigManager $conf, | 60 | ConfigManager $conf, |
55 | SessionManager $session, | 61 | SessionManager $session, |
56 | CookieManager $cookieManager, | 62 | CookieManager $cookieManager, |
57 | LoginManager $login | 63 | LoginManager $login, |
64 | LoggerInterface $logger | ||
58 | ) { | 65 | ) { |
59 | $this->conf = $conf; | 66 | $this->conf = $conf; |
60 | $this->session = $session; | 67 | $this->session = $session; |
61 | $this->login = $login; | 68 | $this->login = $login; |
62 | $this->cookieManager = $cookieManager; | 69 | $this->cookieManager = $cookieManager; |
70 | $this->logger = $logger; | ||
63 | } | 71 | } |
64 | 72 | ||
65 | public function build(): ShaarliContainer | 73 | public function build(): ShaarliContainer |
@@ -70,6 +78,7 @@ class ContainerBuilder | |||
70 | $container['sessionManager'] = $this->session; | 78 | $container['sessionManager'] = $this->session; |
71 | $container['cookieManager'] = $this->cookieManager; | 79 | $container['cookieManager'] = $this->cookieManager; |
72 | $container['loginManager'] = $this->login; | 80 | $container['loginManager'] = $this->login; |
81 | $container['logger'] = $this->logger; | ||
73 | $container['basePath'] = $this->basePath; | 82 | $container['basePath'] = $this->basePath; |
74 | 83 | ||
75 | $container['plugins'] = function (ShaarliContainer $container): PluginManager { | 84 | $container['plugins'] = function (ShaarliContainer $container): PluginManager { |
@@ -84,14 +93,20 @@ class ContainerBuilder | |||
84 | return new BookmarkFileService( | 93 | return new BookmarkFileService( |
85 | $container->conf, | 94 | $container->conf, |
86 | $container->history, | 95 | $container->history, |
96 | new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), | ||
87 | $container->loginManager->isLoggedIn() | 97 | $container->loginManager->isLoggedIn() |
88 | ); | 98 | ); |
89 | }; | 99 | }; |
90 | 100 | ||
101 | $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever { | ||
102 | return new MetadataRetriever($container->conf, $container->httpAccess); | ||
103 | }; | ||
104 | |||
91 | $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { | 105 | $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { |
92 | return new PageBuilder( | 106 | return new PageBuilder( |
93 | $container->conf, | 107 | $container->conf, |
94 | $container->sessionManager->getSession(), | 108 | $container->sessionManager->getSession(), |
109 | $container->logger, | ||
95 | $container->bookmarkService, | 110 | $container->bookmarkService, |
96 | $container->sessionManager->generateToken(), | 111 | $container->sessionManager->generateToken(), |
97 | $container->loginManager->isLoggedIn() | 112 | $container->loginManager->isLoggedIn() |
@@ -143,7 +158,7 @@ class ContainerBuilder | |||
143 | 158 | ||
144 | $container['updater'] = function (ShaarliContainer $container): Updater { | 159 | $container['updater'] = function (ShaarliContainer $container): Updater { |
145 | return new Updater( | 160 | return new Updater( |
146 | UpdaterUtils::read_updates_file($container->conf->get('resource.updates')), | 161 | UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')), |
147 | $container->bookmarkService, | 162 | $container->bookmarkService, |
148 | $container->conf, | 163 | $container->conf, |
149 | $container->loginManager->isLoggedIn() | 164 | $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/FeedBuilder.php b/application/feed/FeedBuilder.php index f6def630..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; |
@@ -102,19 +103,19 @@ class FeedBuilder | |||
102 | } | 103 | } |
103 | 104 | ||
104 | // Optionally filter the results: | 105 | // Optionally filter the results: |
105 | $linksToDisplay = $this->linkDB->search($userInput, null, false, false, true); | 106 | $linksToDisplay = $this->linkDB->search($userInput ?? [], null, false, false, true); |
106 | 107 | ||
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 9d4a0fa0..7e0afafc 100644 --- a/application/formatter/BookmarkDefaultFormatter.php +++ b/application/formatter/BookmarkDefaultFormatter.php | |||
@@ -12,10 +12,13 @@ namespace Shaarli\Formatter; | |||
12 | */ | 12 | */ |
13 | class BookmarkDefaultFormatter extends BookmarkFormatter | 13 | class BookmarkDefaultFormatter extends BookmarkFormatter |
14 | { | 14 | { |
15 | protected const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT'; | ||
16 | protected const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|'; | ||
17 | |||
15 | /** | 18 | /** |
16 | * @inheritdoc | 19 | * @inheritdoc |
17 | */ | 20 | */ |
18 | public function formatTitle($bookmark) | 21 | protected function formatTitle($bookmark) |
19 | { | 22 | { |
20 | return escape($bookmark->getTitle()); | 23 | return escape($bookmark->getTitle()); |
21 | } | 24 | } |
@@ -23,10 +26,33 @@ class BookmarkDefaultFormatter extends BookmarkFormatter | |||
23 | /** | 26 | /** |
24 | * @inheritdoc | 27 | * @inheritdoc |
25 | */ | 28 | */ |
26 | public function formatDescription($bookmark) | 29 | protected function formatTitleHtml($bookmark) |
30 | { | ||
31 | $title = $this->tokenizeSearchHighlightField( | ||
32 | $bookmark->getTitle() ?? '', | ||
33 | $bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? [] | ||
34 | ); | ||
35 | |||
36 | return $this->replaceTokens(escape($title)); | ||
37 | } | ||
38 | |||
39 | /** | ||
40 | * @inheritdoc | ||
41 | */ | ||
42 | protected function formatDescription($bookmark) | ||
27 | { | 43 | { |
28 | $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; | 44 | $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; |
29 | return format_description(escape($bookmark->getDescription()), $indexUrl); | 45 | $description = $this->tokenizeSearchHighlightField( |
46 | $bookmark->getDescription() ?? '', | ||
47 | $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? [] | ||
48 | ); | ||
49 | $description = format_description( | ||
50 | escape($description), | ||
51 | $indexUrl, | ||
52 | $this->conf->get('formatter_settings.autolink', true) | ||
53 | ); | ||
54 | |||
55 | return $this->replaceTokens($description); | ||
30 | } | 56 | } |
31 | 57 | ||
32 | /** | 58 | /** |
@@ -40,15 +66,36 @@ class BookmarkDefaultFormatter extends BookmarkFormatter | |||
40 | /** | 66 | /** |
41 | * @inheritdoc | 67 | * @inheritdoc |
42 | */ | 68 | */ |
43 | public function formatTagString($bookmark) | 69 | protected function formatTagListHtml($bookmark) |
44 | { | 70 | { |
45 | return implode(' ', $this->formatTagList($bookmark)); | 71 | $tagsSeparator = $this->conf->get('general.tags_separator', ' '); |
72 | if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) { | ||
73 | return $this->formatTagList($bookmark); | ||
74 | } | ||
75 | |||
76 | $tags = $this->tokenizeSearchHighlightField( | ||
77 | $bookmark->getTagsString($tagsSeparator), | ||
78 | $bookmark->getAdditionalContentEntry('search_highlight')['tags'] | ||
79 | ); | ||
80 | $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator)); | ||
81 | $tags = escape($tags); | ||
82 | $tags = $this->replaceTokensArray($tags); | ||
83 | |||
84 | return $tags; | ||
46 | } | 85 | } |
47 | 86 | ||
48 | /** | 87 | /** |
49 | * @inheritdoc | 88 | * @inheritdoc |
50 | */ | 89 | */ |
51 | public function formatUrl($bookmark) | 90 | protected function formatTagString($bookmark) |
91 | { | ||
92 | return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark)); | ||
93 | } | ||
94 | |||
95 | /** | ||
96 | * @inheritdoc | ||
97 | */ | ||
98 | protected function formatUrl($bookmark) | ||
52 | { | 99 | { |
53 | if ($bookmark->isNote() && isset($this->contextData['index_url'])) { | 100 | if ($bookmark->isNote() && isset($this->contextData['index_url'])) { |
54 | return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/')); | 101 | return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/')); |
@@ -80,8 +127,89 @@ class BookmarkDefaultFormatter extends BookmarkFormatter | |||
80 | /** | 127 | /** |
81 | * @inheritdoc | 128 | * @inheritdoc |
82 | */ | 129 | */ |
130 | protected function formatUrlHtml($bookmark) | ||
131 | { | ||
132 | $url = $this->tokenizeSearchHighlightField( | ||
133 | $bookmark->getUrl() ?? '', | ||
134 | $bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? [] | ||
135 | ); | ||
136 | |||
137 | return $this->replaceTokens(escape($url)); | ||
138 | } | ||
139 | |||
140 | /** | ||
141 | * @inheritdoc | ||
142 | */ | ||
83 | protected function formatThumbnail($bookmark) | 143 | protected function formatThumbnail($bookmark) |
84 | { | 144 | { |
85 | return escape($bookmark->getThumbnail()); | 145 | return escape($bookmark->getThumbnail()); |
86 | } | 146 | } |
147 | |||
148 | /** | ||
149 | * Insert search highlight token in provided field content based on a list of search result positions | ||
150 | * | ||
151 | * @param string $fieldContent | ||
152 | * @param array|null $positions List of of search results with 'start' and 'end' positions. | ||
153 | * | ||
154 | * @return string Updated $fieldContent. | ||
155 | */ | ||
156 | protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string | ||
157 | { | ||
158 | if (empty($positions)) { | ||
159 | return $fieldContent; | ||
160 | } | ||
161 | |||
162 | $insertedTokens = 0; | ||
163 | $tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN); | ||
164 | foreach ($positions as $position) { | ||
165 | $position = [ | ||
166 | 'start' => $position['start'] + ($insertedTokens * $tokenLength), | ||
167 | 'end' => $position['end'] + ($insertedTokens * $tokenLength), | ||
168 | ]; | ||
169 | |||
170 | $content = mb_substr($fieldContent, 0, $position['start']); | ||
171 | $content .= static::SEARCH_HIGHLIGHT_OPEN; | ||
172 | $content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']); | ||
173 | $content .= static::SEARCH_HIGHLIGHT_CLOSE; | ||
174 | $content .= mb_substr($fieldContent, $position['end']); | ||
175 | |||
176 | $fieldContent = $content; | ||
177 | |||
178 | $insertedTokens += 2; | ||
179 | } | ||
180 | |||
181 | return $fieldContent; | ||
182 | } | ||
183 | |||
184 | /** | ||
185 | * Replace search highlight tokens with HTML highlighted span. | ||
186 | * | ||
187 | * @param string $fieldContent | ||
188 | * | ||
189 | * @return string updated content. | ||
190 | */ | ||
191 | protected function replaceTokens(string $fieldContent): string | ||
192 | { | ||
193 | return str_replace( | ||
194 | [static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE], | ||
195 | ['<span class="search-highlight">', '</span>'], | ||
196 | $fieldContent | ||
197 | ); | ||
198 | } | ||
199 | |||
200 | /** | ||
201 | * Apply replaceTokens to an array of content strings. | ||
202 | * | ||
203 | * @param string[] $fieldContents | ||
204 | * | ||
205 | * @return array | ||
206 | */ | ||
207 | protected function replaceTokensArray(array $fieldContents): array | ||
208 | { | ||
209 | foreach ($fieldContents as &$entry) { | ||
210 | $entry = $this->replaceTokens($entry); | ||
211 | } | ||
212 | |||
213 | return $fieldContents; | ||
214 | } | ||
87 | } | 215 | } |
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php index 0042dafe..124ce78b 100644 --- a/application/formatter/BookmarkFormatter.php +++ b/application/formatter/BookmarkFormatter.php | |||
@@ -2,7 +2,7 @@ | |||
2 | 2 | ||
3 | namespace Shaarli\Formatter; | 3 | namespace Shaarli\Formatter; |
4 | 4 | ||
5 | use DateTime; | 5 | use DateTimeInterface; |
6 | use Shaarli\Bookmark\Bookmark; | 6 | use Shaarli\Bookmark\Bookmark; |
7 | use Shaarli\Config\ConfigManager; | 7 | use Shaarli\Config\ConfigManager; |
8 | 8 | ||
@@ -11,6 +11,29 @@ use Shaarli\Config\ConfigManager; | |||
11 | * | 11 | * |
12 | * Abstract class processing all bookmark attributes through methods designed to be overridden. | 12 | * Abstract class processing all bookmark attributes through methods designed to be overridden. |
13 | * | 13 | * |
14 | * List of available formatted fields: | ||
15 | * - id ID | ||
16 | * - shorturl Unique identifier, used in permalinks | ||
17 | * - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy | ||
18 | * - real_url (legacy) same as `url` | ||
19 | * - url_html URL to be displayed in HTML content (it can contain HTML tags) | ||
20 | * - title Title | ||
21 | * - title_html Title to be displayed in HTML content (it can contain HTML tags) | ||
22 | * - description Description content. It most likely contains HTML tags | ||
23 | * - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved | ||
24 | * - taglist List of tags (array) | ||
25 | * - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag | ||
26 | * - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags) | ||
27 | * - tags Tags separated by a single whitespace | ||
28 | * - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link | ||
29 | * - sticky Is sticky (bool) | ||
30 | * - private Is private (bool) | ||
31 | * - class Additional CSS class | ||
32 | * - created Creation DateTime | ||
33 | * - updated Last edit DateTime | ||
34 | * - timestamp Creation timestamp | ||
35 | * - updated_timestamp Last edit timestamp | ||
36 | * | ||
14 | * @package Shaarli\Formatter | 37 | * @package Shaarli\Formatter |
15 | */ | 38 | */ |
16 | abstract class BookmarkFormatter | 39 | abstract class BookmarkFormatter |
@@ -55,13 +78,16 @@ abstract class BookmarkFormatter | |||
55 | $out['shorturl'] = $this->formatShortUrl($bookmark); | 78 | $out['shorturl'] = $this->formatShortUrl($bookmark); |
56 | $out['url'] = $this->formatUrl($bookmark); | 79 | $out['url'] = $this->formatUrl($bookmark); |
57 | $out['real_url'] = $this->formatRealUrl($bookmark); | 80 | $out['real_url'] = $this->formatRealUrl($bookmark); |
81 | $out['url_html'] = $this->formatUrlHtml($bookmark); | ||
58 | $out['title'] = $this->formatTitle($bookmark); | 82 | $out['title'] = $this->formatTitle($bookmark); |
83 | $out['title_html'] = $this->formatTitleHtml($bookmark); | ||
59 | $out['description'] = $this->formatDescription($bookmark); | 84 | $out['description'] = $this->formatDescription($bookmark); |
60 | $out['thumbnail'] = $this->formatThumbnail($bookmark); | 85 | $out['thumbnail'] = $this->formatThumbnail($bookmark); |
61 | $out['urlencoded_taglist'] = $this->formatUrlEncodedTagList($bookmark); | ||
62 | $out['taglist'] = $this->formatTagList($bookmark); | 86 | $out['taglist'] = $this->formatTagList($bookmark); |
63 | $out['urlencoded_tags'] = $this->formatUrlEncodedTagString($bookmark); | 87 | $out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark); |
88 | $out['taglist_html'] = $this->formatTagListHtml($bookmark); | ||
64 | $out['tags'] = $this->formatTagString($bookmark); | 89 | $out['tags'] = $this->formatTagString($bookmark); |
90 | $out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark); | ||
65 | $out['sticky'] = $bookmark->isSticky(); | 91 | $out['sticky'] = $bookmark->isSticky(); |
66 | $out['private'] = $bookmark->isPrivate(); | 92 | $out['private'] = $bookmark->isPrivate(); |
67 | $out['class'] = $this->formatClass($bookmark); | 93 | $out['class'] = $this->formatClass($bookmark); |
@@ -69,6 +95,7 @@ abstract class BookmarkFormatter | |||
69 | $out['updated'] = $this->formatUpdated($bookmark); | 95 | $out['updated'] = $this->formatUpdated($bookmark); |
70 | $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); | 96 | $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); |
71 | $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); | 97 | $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); |
98 | |||
72 | return $out; | 99 | return $out; |
73 | } | 100 | } |
74 | 101 | ||
@@ -136,6 +163,18 @@ abstract class BookmarkFormatter | |||
136 | } | 163 | } |
137 | 164 | ||
138 | /** | 165 | /** |
166 | * Format Url Html: to be displayed in HTML content, it can contains HTML tags. | ||
167 | * | ||
168 | * @param Bookmark $bookmark instance | ||
169 | * | ||
170 | * @return string formatted Url HTML | ||
171 | */ | ||
172 | protected function formatUrlHtml($bookmark) | ||
173 | { | ||
174 | return $this->formatUrl($bookmark); | ||
175 | } | ||
176 | |||
177 | /** | ||
139 | * Format Title | 178 | * Format Title |
140 | * | 179 | * |
141 | * @param Bookmark $bookmark instance | 180 | * @param Bookmark $bookmark instance |
@@ -148,6 +187,18 @@ abstract class BookmarkFormatter | |||
148 | } | 187 | } |
149 | 188 | ||
150 | /** | 189 | /** |
190 | * Format Title HTML: to be displayed in HTML content, it can contains HTML tags. | ||
191 | * | ||
192 | * @param Bookmark $bookmark instance | ||
193 | * | ||
194 | * @return string formatted Title | ||
195 | */ | ||
196 | protected function formatTitleHtml($bookmark) | ||
197 | { | ||
198 | return $bookmark->getTitle(); | ||
199 | } | ||
200 | |||
201 | /** | ||
151 | * Format Description | 202 | * Format Description |
152 | * | 203 | * |
153 | * @param Bookmark $bookmark instance | 204 | * @param Bookmark $bookmark instance |
@@ -190,12 +241,24 @@ abstract class BookmarkFormatter | |||
190 | * | 241 | * |
191 | * @return array formatted Tags | 242 | * @return array formatted Tags |
192 | */ | 243 | */ |
193 | protected function formatUrlEncodedTagList($bookmark) | 244 | protected function formatTagListUrlEncoded($bookmark) |
194 | { | 245 | { |
195 | return array_map('urlencode', $this->filterTagList($bookmark->getTags())); | 246 | return array_map('urlencode', $this->filterTagList($bookmark->getTags())); |
196 | } | 247 | } |
197 | 248 | ||
198 | /** | 249 | /** |
250 | * Format Tags HTML: to be displayed in HTML content, it can contains HTML tags. | ||
251 | * | ||
252 | * @param Bookmark $bookmark instance | ||
253 | * | ||
254 | * @return array formatted Tags | ||
255 | */ | ||
256 | protected function formatTagListHtml($bookmark) | ||
257 | { | ||
258 | return $this->formatTagList($bookmark); | ||
259 | } | ||
260 | |||
261 | /** | ||
199 | * Format TagString | 262 | * Format TagString |
200 | * | 263 | * |
201 | * @param Bookmark $bookmark instance | 264 | * @param Bookmark $bookmark instance |
@@ -204,7 +267,7 @@ abstract class BookmarkFormatter | |||
204 | */ | 267 | */ |
205 | protected function formatTagString($bookmark) | 268 | protected function formatTagString($bookmark) |
206 | { | 269 | { |
207 | return implode(' ', $this->formatTagList($bookmark)); | 270 | return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark)); |
208 | } | 271 | } |
209 | 272 | ||
210 | /** | 273 | /** |
@@ -214,9 +277,9 @@ abstract class BookmarkFormatter | |||
214 | * | 277 | * |
215 | * @return string formatted TagString | 278 | * @return string formatted TagString |
216 | */ | 279 | */ |
217 | protected function formatUrlEncodedTagString($bookmark) | 280 | protected function formatTagStringUrlEncoded($bookmark) |
218 | { | 281 | { |
219 | return implode(' ', $this->formatUrlEncodedTagList($bookmark)); | 282 | return implode(' ', $this->formatTagListUrlEncoded($bookmark)); |
220 | } | 283 | } |
221 | 284 | ||
222 | /** | 285 | /** |
@@ -237,7 +300,7 @@ abstract class BookmarkFormatter | |||
237 | * | 300 | * |
238 | * @param Bookmark $bookmark instance | 301 | * @param Bookmark $bookmark instance |
239 | * | 302 | * |
240 | * @return DateTime instance | 303 | * @return DateTimeInterface instance |
241 | */ | 304 | */ |
242 | protected function formatCreated(Bookmark $bookmark) | 305 | protected function formatCreated(Bookmark $bookmark) |
243 | { | 306 | { |
@@ -249,7 +312,7 @@ abstract class BookmarkFormatter | |||
249 | * | 312 | * |
250 | * @param Bookmark $bookmark instance | 313 | * @param Bookmark $bookmark instance |
251 | * | 314 | * |
252 | * @return DateTime instance | 315 | * @return DateTimeInterface instance |
253 | */ | 316 | */ |
254 | protected function formatUpdated(Bookmark $bookmark) | 317 | protected function formatUpdated(Bookmark $bookmark) |
255 | { | 318 | { |
@@ -288,6 +351,7 @@ abstract class BookmarkFormatter | |||
288 | 351 | ||
289 | /** | 352 | /** |
290 | * 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. | ||
291 | * | 355 | * |
292 | * @param array $tags | 356 | * @param array $tags |
293 | * | 357 | * |
diff --git a/application/formatter/BookmarkMarkdownExtraFormatter.php b/application/formatter/BookmarkMarkdownExtraFormatter.php new file mode 100644 index 00000000..0694b23f --- /dev/null +++ b/application/formatter/BookmarkMarkdownExtraFormatter.php | |||
@@ -0,0 +1,24 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Formatter; | ||
4 | |||
5 | use Shaarli\Config\ConfigManager; | ||
6 | |||
7 | /** | ||
8 | * Class BookmarkMarkdownExtraFormatter | ||
9 | * | ||
10 | * Format bookmark description into MarkdownExtra format. | ||
11 | * | ||
12 | * @see https://michelf.ca/projects/php-markdown/extra/ | ||
13 | * | ||
14 | * @package Shaarli\Formatter | ||
15 | */ | ||
16 | class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter | ||
17 | { | ||
18 | public function __construct(ConfigManager $conf, bool $isLoggedIn) | ||
19 | { | ||
20 | parent::__construct($conf, $isLoggedIn); | ||
21 | |||
22 | $this->parsedown = new \ParsedownExtra(); | ||
23 | } | ||
24 | } | ||
diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php index 5d244d4c..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; |
@@ -56,7 +56,10 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter | |||
56 | return parent::formatDescription($bookmark); | 56 | return parent::formatDescription($bookmark); |
57 | } | 57 | } |
58 | 58 | ||
59 | $processedDescription = $bookmark->getDescription(); | 59 | $processedDescription = $this->tokenizeSearchHighlightField( |
60 | $bookmark->getDescription() ?? '', | ||
61 | $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? [] | ||
62 | ); | ||
60 | $processedDescription = $this->filterProtocols($processedDescription); | 63 | $processedDescription = $this->filterProtocols($processedDescription); |
61 | $processedDescription = $this->formatHashTags($processedDescription); | 64 | $processedDescription = $this->formatHashTags($processedDescription); |
62 | $processedDescription = $this->reverseEscapedHtml($processedDescription); | 65 | $processedDescription = $this->reverseEscapedHtml($processedDescription); |
@@ -65,9 +68,10 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter | |||
65 | ->setBreaksEnabled(true) | 68 | ->setBreaksEnabled(true) |
66 | ->text($processedDescription); | 69 | ->text($processedDescription); |
67 | $processedDescription = $this->sanitizeHtml($processedDescription); | 70 | $processedDescription = $this->sanitizeHtml($processedDescription); |
71 | $processedDescription = $this->replaceTokens($processedDescription); | ||
68 | 72 | ||
69 | if (!empty($processedDescription)) { | 73 | if (!empty($processedDescription)) { |
70 | $processedDescription = '<div class="markdown">'. $processedDescription . '</div>'; | 74 | $processedDescription = '<div class="markdown">' . $processedDescription . '</div>'; |
71 | } | 75 | } |
72 | 76 | ||
73 | return $processedDescription; | 77 | return $processedDescription; |
@@ -106,7 +110,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter | |||
106 | function ($match) use ($allowedProtocols, $indexUrl) { | 110 | function ($match) use ($allowedProtocols, $indexUrl) { |
107 | $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : ''; | 111 | $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : ''; |
108 | $link .= whitelist_protocols($match[1], $allowedProtocols); | 112 | $link .= whitelist_protocols($match[1], $allowedProtocols); |
109 | return ']('. $link.')'; | 113 | return '](' . $link . ')'; |
110 | }, | 114 | }, |
111 | $description | 115 | $description |
112 | ); | 116 | ); |
@@ -133,7 +137,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter | |||
133 | * \p{Mn} - any non marking space (accents, umlauts, etc) | 137 | * \p{Mn} - any non marking space (accents, umlauts, etc) |
134 | */ | 138 | */ |
135 | $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; | 139 | $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; |
136 | $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)'; | 140 | $replacement = '$1[#$2](' . $indexUrl . './add-tag/$2)'; |
137 | 141 | ||
138 | $descriptionLines = explode(PHP_EOL, $description); | 142 | $descriptionLines = explode(PHP_EOL, $description); |
139 | $descriptionOut = ''; | 143 | $descriptionOut = ''; |
@@ -174,17 +178,17 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter | |||
174 | */ | 178 | */ |
175 | protected function sanitizeHtml($description) | 179 | protected function sanitizeHtml($description) |
176 | { | 180 | { |
177 | $escapeTags = array( | 181 | $escapeTags = [ |
178 | 'script', | 182 | 'script', |
179 | 'style', | 183 | 'style', |
180 | 'link', | 184 | 'link', |
181 | 'iframe', | 185 | 'iframe', |
182 | 'frameset', | 186 | 'frameset', |
183 | 'frame', | 187 | 'frame', |
184 | ); | 188 | ]; |
185 | foreach ($escapeTags as $tag) { | 189 | foreach ($escapeTags as $tag) { |
186 | $description = preg_replace_callback( | 190 | $description = preg_replace_callback( |
187 | '#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is', | 191 | '#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is', |
188 | function ($match) { | 192 | function ($match) { |
189 | return escape($match[0]); | 193 | return escape($match[0]); |
190 | }, | 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 e675fcca..dc421661 100644 --- a/application/front/controller/admin/ConfigureController.php +++ b/application/front/controller/admin/ConfigureController.php | |||
@@ -30,7 +30,7 @@ class ConfigureController extends ShaarliAdminController | |||
30 | 'theme_available', | 30 | 'theme_available', |
31 | ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl')) | 31 | ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl')) |
32 | ); | 32 | ); |
33 | $this->assignView('formatter_available', ['default', 'markdown']); | 33 | $this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']); |
34 | list($continents, $cities) = generateTimeZoneData( | 34 | list($continents, $cities) = generateTimeZoneData( |
35 | timezone_identifiers_list(), | 35 | timezone_identifiers_list(), |
36 | $this->container->conf->get('general.timezone') | 36 | $this->container->conf->get('general.timezone') |
@@ -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..fabeaf2f --- /dev/null +++ b/application/front/controller/admin/ServerController.php | |||
@@ -0,0 +1,96 @@ | |||
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 | $this->assignView('php_version', PHP_VERSION); | ||
43 | $this->assignView('php_eol', format_date($phpEol, false)); | ||
44 | $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable()); | ||
45 | $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement()); | ||
46 | $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf)); | ||
47 | $this->assignView('release_url', $releaseUrl); | ||
48 | $this->assignView('latest_version', $latestVersion); | ||
49 | $this->assignView('current_version', $currentVersion); | ||
50 | $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode')); | ||
51 | $this->assignView('index_url', index_url($this->container->environment)); | ||
52 | $this->assignView('client_ip', client_ip_id($this->container->environment)); | ||
53 | $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', [])); | ||
54 | |||
55 | $this->assignView( | ||
56 | 'pagetitle', | ||
57 | t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') | ||
58 | ); | ||
59 | |||
60 | return $response->write($this->render('server')); | ||
61 | } | ||
62 | |||
63 | /** | ||
64 | * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails). | ||
65 | */ | ||
66 | public function clearCache(Request $request, Response $response): Response | ||
67 | { | ||
68 | $exclude = ['.htaccess']; | ||
69 | |||
70 | if ($request->getQueryParam('type') === static::CACHE_THUMB) { | ||
71 | $folders = [$this->container->conf->get('resource.thumbnails_cache')]; | ||
72 | |||
73 | $this->saveWarningMessage( | ||
74 | t('Thumbnails cache has been cleared.') . ' ' . | ||
75 | '<a href="' . $this->container->basePath . '/admin/thumbnails">' . | ||
76 | t('Please synchronize them.') . | ||
77 | '</a>' | ||
78 | ); | ||
79 | } else { | ||
80 | $folders = [ | ||
81 | $this->container->conf->get('resource.page_cache'), | ||
82 | $this->container->conf->get('resource.raintpl_tmp'), | ||
83 | ]; | ||
84 | |||
85 | $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!')); | ||
86 | } | ||
87 | |||
88 | // Make sure that we don't delete root cache folder | ||
89 | $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders)))); | ||
90 | foreach ($folders as $folder) { | ||
91 | FileUtils::clearFolder($folder, false, $exclude); | ||
92 | } | ||
93 | |||
94 | return $this->redirect($response, '/admin/server'); | ||
95 | } | ||
96 | } | ||
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..4cbfcdc5 --- /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'] = 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 81c87ed0..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)); |
@@ -52,7 +52,7 @@ class ThumbnailsController extends ShaarliAdminController | |||
52 | } | 52 | } |
53 | 53 | ||
54 | try { | 54 | try { |
55 | $bookmark = $this->container->bookmarkService->get($id); | 55 | $bookmark = $this->container->bookmarkService->get((int) $id); |
56 | } catch (BookmarkNotFoundException $e) { | 56 | } catch (BookmarkNotFoundException $e) { |
57 | return $response->withStatus(404); | 57 | return $response->withStatus(404); |
58 | } | 58 | } |
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..846cfe22 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)); |
@@ -106,11 +96,14 @@ class DailyController extends ShaarliVisitorController | |||
106 | } | 96 | } |
107 | 97 | ||
108 | $days = []; | 98 | $days = []; |
99 | $type = DailyPageHelper::extractRequestedType($request); | ||
100 | $format = DailyPageHelper::getFormatByType($type); | ||
101 | $length = DailyPageHelper::getRssLengthByType($type); | ||
109 | foreach ($this->container->bookmarkService->search() as $bookmark) { | 102 | foreach ($this->container->bookmarkService->search() as $bookmark) { |
110 | $day = $bookmark->getCreated()->format('Ymd'); | 103 | $day = $bookmark->getCreated()->format($format); |
111 | 104 | ||
112 | // Stop iterating after DAILY_RSS_NB_DAYS entries | 105 | // Stop iterating after DAILY_RSS_NB_DAYS entries |
113 | if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) { | 106 | if (count($days) === $length && !isset($days[$day])) { |
114 | break; | 107 | break; |
115 | } | 108 | } |
116 | 109 | ||
@@ -127,12 +120,19 @@ class DailyController extends ShaarliVisitorController | |||
127 | 120 | ||
128 | /** @var Bookmark[] $bookmarks */ | 121 | /** @var Bookmark[] $bookmarks */ |
129 | foreach ($days as $day => $bookmarks) { | 122 | foreach ($days as $day => $bookmarks) { |
130 | $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); | 123 | $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day); |
124 | $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime); | ||
125 | |||
126 | // We only want the RSS entry to be published when the period is over. | ||
127 | if (new DateTime() < $endDateTime) { | ||
128 | continue; | ||
129 | } | ||
130 | |||
131 | $dataPerDay[$day] = [ | 131 | $dataPerDay[$day] = [ |
132 | 'date' => $dayDatetime, | 132 | 'date' => $endDateTime, |
133 | 'date_rss' => $dayDatetime->format(DateTime::RSS), | 133 | 'date_rss' => $endDateTime->format(DateTime::RSS), |
134 | 'date_human' => format_date($dayDatetime, false, true), | 134 | 'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime), |
135 | 'absolute_url' => $indexUrl . 'daily?day=' . $day, | 135 | 'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day, |
136 | 'links' => [], | 136 | 'links' => [], |
137 | ]; | 137 | ]; |
138 | 138 | ||
@@ -141,16 +141,20 @@ class DailyController extends ShaarliVisitorController | |||
141 | 141 | ||
142 | // Make permalink URL absolute | 142 | // Make permalink URL absolute |
143 | if ($bookmark->isNote()) { | 143 | if ($bookmark->isNote()) { |
144 | $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl(); | 144 | $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl(); |
145 | } | 145 | } |
146 | } | 146 | } |
147 | } | 147 | } |
148 | 148 | ||
149 | $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); | 149 | $this->assignAllView([ |
150 | $this->assignView('index_url', $indexUrl); | 150 | 'title' => $this->container->conf->get('general.title', 'Shaarli'), |
151 | $this->assignView('page_url', $pageUrl); | 151 | 'index_url' => $indexUrl, |
152 | $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); | 152 | 'page_url' => $pageUrl, |
153 | $this->assignView('days', $dataPerDay); | 153 | 'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false), |
154 | 'days' => $dataPerDay, | ||
155 | 'type' => $type, | ||
156 | 'localizedType' => $this->translateType($type), | ||
157 | ]); | ||
154 | 158 | ||
155 | $rssContent = $this->render(TemplatePage::DAILY_RSS); | 159 | $rssContent = $this->render(TemplatePage::DAILY_RSS); |
156 | 160 | ||
@@ -189,4 +193,13 @@ class DailyController extends ShaarliVisitorController | |||
189 | 193 | ||
190 | return $columns; | 194 | return $columns; |
191 | } | 195 | } |
196 | |||
197 | protected function translateType($type): string | ||
198 | { | ||
199 | return [ | ||
200 | t('day') => t('Daily'), | ||
201 | t('week') => t('Weekly'), | ||
202 | t('month') => t('Monthly'), | ||
203 | ][t($type)] ?? t('Daily'); | ||
204 | } | ||
192 | } | 205 | } |
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..bf965929 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,16 @@ 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 | $this->assignView('php_version', PHP_VERSION); | ||
60 | $this->assignView('php_eol', format_date($phpEol, false)); | ||
61 | $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable()); | ||
62 | $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement()); | ||
63 | $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf)); | ||
64 | |||
65 | $this->assignView('pagetitle', t('Install Shaarli')); | ||
66 | |||
56 | return $response->write($this->render('install')); | 67 | return $response->write($this->render('install')); |
57 | } | 68 | } |
58 | 69 | ||
@@ -65,17 +76,18 @@ class InstallController extends ShaarliVisitorController | |||
65 | // This part makes sure sessions works correctly. | 76 | // This part makes sure sessions works correctly. |
66 | // (Because on some hosts, session.save_path may not be set correctly, | 77 | // (Because on some hosts, session.save_path may not be set correctly, |
67 | // or we may not have write access to it.) | 78 | // or we may not have write access to it.) |
68 | if (static::SESSION_TEST_VALUE | 79 | if ( |
80 | static::SESSION_TEST_VALUE | ||
69 | !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) | 81 | !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) |
70 | ) { | 82 | ) { |
71 | // Step 2: Check if data in session is correct. | 83 | // Step 2: Check if data in session is correct. |
72 | $msg = t( | 84 | $msg = t( |
73 | '<pre>Sessions do not seem to work correctly on your server.<br>'. | 85 | '<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, '. | 86 | 'Make sure the variable "session.save_path" is set correctly in your PHP config, ' . |
75 | 'and that you have write access to it.<br>'. | 87 | 'and that you have write access to it.<br>' . |
76 | 'It currently points to %s.<br>'. | 88 | 'It currently points to %s.<br>' . |
77 | 'On some browsers, accessing your server via a hostname like \'localhost\' '. | 89 | 'On some browsers, accessing your server via a hostname like \'localhost\' ' . |
78 | 'or any custom hostname without a dot causes cookie storage to fail. '. | 90 | '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>' | 91 | 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>' |
80 | ); | 92 | ); |
81 | $msg = sprintf($msg, $this->container->sessionManager->getSavePath()); | 93 | $msg = sprintf($msg, $this->container->sessionManager->getSavePath()); |
@@ -94,7 +106,8 @@ class InstallController extends ShaarliVisitorController | |||
94 | public function save(Request $request, Response $response): Response | 106 | public function save(Request $request, Response $response): Response |
95 | { | 107 | { |
96 | $timezone = 'UTC'; | 108 | $timezone = 'UTC'; |
97 | if (!empty($request->getParam('continent')) | 109 | if ( |
110 | !empty($request->getParam('continent')) | ||
98 | && !empty($request->getParam('city')) | 111 | && !empty($request->getParam('city')) |
99 | && isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) | 112 | && isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) |
100 | ) { | 113 | ) { |
@@ -104,7 +117,7 @@ class InstallController extends ShaarliVisitorController | |||
104 | 117 | ||
105 | $login = $request->getParam('setlogin'); | 118 | $login = $request->getParam('setlogin'); |
106 | $this->container->conf->set('credentials.login', $login); | 119 | $this->container->conf->set('credentials.login', $login); |
107 | $salt = sha1(uniqid('', true) .'_'. mt_rand()); | 120 | $salt = sha1(uniqid('', true) . '_' . mt_rand()); |
108 | $this->container->conf->set('credentials.salt', $salt); | 121 | $this->container->conf->set('credentials.salt', $salt); |
109 | $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); | 122 | $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); |
110 | 123 | ||
@@ -113,7 +126,7 @@ class InstallController extends ShaarliVisitorController | |||
113 | } else { | 126 | } else { |
114 | $this->container->conf->set( | 127 | $this->container->conf->set( |
115 | 'general.title', | 128 | 'general.title', |
116 | 'Shared bookmarks on '.escape(index_url($this->container->environment)) | 129 | 'Shared bookmarks on ' . escape(index_url($this->container->environment)) |
117 | ); | 130 | ); |
118 | } | 131 | } |
119 | 132 | ||
@@ -150,7 +163,7 @@ class InstallController extends ShaarliVisitorController | |||
150 | protected function checkPermissions(): bool | 163 | protected function checkPermissions(): bool |
151 | { | 164 | { |
152 | // Ensure Shaarli has proper access to its resources | 165 | // Ensure Shaarli has proper access to its resources |
153 | $errors = ApplicationUtils::checkResourcePermissions($this->container->conf); | 166 | $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true); |
154 | if (empty($errors)) { | 167 | if (empty($errors)) { |
155 | return true; | 168 | return true; |
156 | } | 169 | } |
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 55c075a2..ae946c59 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php | |||
@@ -106,6 +106,7 @@ abstract class ShaarliVisitorController | |||
106 | 'target' => $template, | 106 | 'target' => $template, |
107 | 'loggedin' => $this->container->loginManager->isLoggedIn(), | 107 | 'loggedin' => $this->container->loginManager->isLoggedIn(), |
108 | 'basePath' => $this->container->basePath, | 108 | 'basePath' => $this->container->basePath, |
109 | 'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath), | ||
109 | 'bookmarkService' => $this->container->bookmarkService | 110 | 'bookmarkService' => $this->container->bookmarkService |
110 | ]; | 111 | ]; |
111 | } | 112 | } |
@@ -143,7 +144,8 @@ abstract class ShaarliVisitorController | |||
143 | if (null !== $referer) { | 144 | if (null !== $referer) { |
144 | $currentUrl = parse_url($referer); | 145 | $currentUrl = parse_url($referer); |
145 | // 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 |
146 | if (isset($currentUrl['host']) | 147 | if ( |
148 | isset($currentUrl['host']) | ||
147 | && strpos(index_url($this->container->environment), $currentUrl['host']) === false | 149 | && strpos(index_url($this->container->environment), $currentUrl['host']) === false |
148 | ) { | 150 | ) { |
149 | return $response->withRedirect($defaultPath); | 151 | return $response->withRedirect($defaultPath); |
@@ -172,7 +174,7 @@ abstract class ShaarliVisitorController | |||
172 | } | 174 | } |
173 | } | 175 | } |
174 | 176 | ||
175 | $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; | 177 | $queryString = count($params) > 0 ? '?' . http_build_query($params) : ''; |
176 | $anchor = $anchor ? '#' . $anchor : ''; | 178 | $anchor = $anchor ? '#' . $anchor : ''; |
177 | 179 | ||
178 | 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..212dd8e2 100644 --- a/application/ApplicationUtils.php +++ b/application/helper/ApplicationUtils.php | |||
@@ -1,5 +1,6 @@ | |||
1 | <?php | 1 | <?php |
2 | namespace Shaarli; | 2 | |
3 | namespace Shaarli\Helper; | ||
3 | 4 | ||
4 | use Exception; | 5 | use Exception; |
5 | use Shaarli\Config\ConfigManager; | 6 | use Shaarli\Config\ConfigManager; |
@@ -14,8 +15,9 @@ class ApplicationUtils | |||
14 | */ | 15 | */ |
15 | public static $VERSION_FILE = 'shaarli_version.php'; | 16 | public static $VERSION_FILE = 'shaarli_version.php'; |
16 | 17 | ||
17 | private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; | 18 | public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli'; |
18 | private static $GIT_BRANCHES = array('latest', 'stable'); | 19 | public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; |
20 | public static $GIT_BRANCHES = ['latest', 'stable']; | ||
19 | private static $VERSION_START_TAG = '<?php /* '; | 21 | private static $VERSION_START_TAG = '<?php /* '; |
20 | private static $VERSION_END_TAG = ' */ ?>'; | 22 | private static $VERSION_END_TAG = ' */ ?>'; |
21 | 23 | ||
@@ -63,8 +65,8 @@ class ApplicationUtils | |||
63 | } | 65 | } |
64 | 66 | ||
65 | return str_replace( | 67 | return str_replace( |
66 | array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), | 68 | [self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL], |
67 | array('', '', ''), | 69 | ['', '', ''], |
68 | $data | 70 | $data |
69 | ); | 71 | ); |
70 | } | 72 | } |
@@ -125,7 +127,7 @@ class ApplicationUtils | |||
125 | // Late Static Binding allows overriding within tests | 127 | // Late Static Binding allows overriding within tests |
126 | // See http://php.net/manual/en/language.oop5.late-static-bindings.php | 128 | // See http://php.net/manual/en/language.oop5.late-static-bindings.php |
127 | $latestVersion = static::getVersion( | 129 | $latestVersion = static::getVersion( |
128 | self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE | 130 | self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE |
129 | ); | 131 | ); |
130 | 132 | ||
131 | if (!$latestVersion) { | 133 | if (!$latestVersion) { |
@@ -171,35 +173,47 @@ class ApplicationUtils | |||
171 | /** | 173 | /** |
172 | * Checks Shaarli has the proper access permissions to its resources | 174 | * Checks Shaarli has the proper access permissions to its resources |
173 | * | 175 | * |
174 | * @param ConfigManager $conf Configuration Manager instance. | 176 | * @param ConfigManager $conf Configuration Manager instance. |
177 | * @param bool $minimalMode In minimal mode we only check permissions to be able to display a template. | ||
178 | * Currently we only need to be able to read the theme and write in raintpl cache. | ||
175 | * | 179 | * |
176 | * @return array A list of the detected configuration issues | 180 | * @return array A list of the detected configuration issues |
177 | */ | 181 | */ |
178 | public static function checkResourcePermissions($conf) | 182 | public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array |
179 | { | 183 | { |
180 | $errors = array(); | 184 | $errors = []; |
181 | $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/'); | 185 | $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/'); |
182 | 186 | ||
183 | // Check script and template directories are readable | 187 | // Check script and template directories are readable |
184 | foreach (array( | 188 | foreach ( |
185 | 'application', | 189 | [ |
186 | 'inc', | 190 | 'application', |
187 | 'plugins', | 191 | 'inc', |
188 | $rainTplDir, | 192 | 'plugins', |
189 | $rainTplDir . '/' . $conf->get('resource.theme'), | 193 | $rainTplDir, |
190 | ) as $path) { | 194 | $rainTplDir . '/' . $conf->get('resource.theme'), |
195 | ] as $path | ||
196 | ) { | ||
191 | if (!is_readable(realpath($path))) { | 197 | if (!is_readable(realpath($path))) { |
192 | $errors[] = '"' . $path . '" ' . t('directory is not readable'); | 198 | $errors[] = '"' . $path . '" ' . t('directory is not readable'); |
193 | } | 199 | } |
194 | } | 200 | } |
195 | 201 | ||
196 | // Check cache and data directories are readable and writable | 202 | // Check cache and data directories are readable and writable |
197 | foreach (array( | 203 | if ($minimalMode) { |
198 | $conf->get('resource.thumbnails_cache'), | 204 | $folders = [ |
199 | $conf->get('resource.data_dir'), | 205 | $conf->get('resource.raintpl_tmp'), |
200 | $conf->get('resource.page_cache'), | 206 | ]; |
201 | $conf->get('resource.raintpl_tmp'), | 207 | } else { |
202 | ) as $path) { | 208 | $folders = [ |
209 | $conf->get('resource.thumbnails_cache'), | ||
210 | $conf->get('resource.data_dir'), | ||
211 | $conf->get('resource.page_cache'), | ||
212 | $conf->get('resource.raintpl_tmp'), | ||
213 | ]; | ||
214 | } | ||
215 | |||
216 | foreach ($folders as $path) { | ||
203 | if (!is_readable(realpath($path))) { | 217 | if (!is_readable(realpath($path))) { |
204 | $errors[] = '"' . $path . '" ' . t('directory is not readable'); | 218 | $errors[] = '"' . $path . '" ' . t('directory is not readable'); |
205 | } | 219 | } |
@@ -208,14 +222,20 @@ class ApplicationUtils | |||
208 | } | 222 | } |
209 | } | 223 | } |
210 | 224 | ||
225 | if ($minimalMode) { | ||
226 | return $errors; | ||
227 | } | ||
228 | |||
211 | // Check configuration files are readable and writable | 229 | // Check configuration files are readable and writable |
212 | foreach (array( | 230 | foreach ( |
213 | $conf->getConfigFileExt(), | 231 | [ |
214 | $conf->get('resource.datastore'), | 232 | $conf->getConfigFileExt(), |
215 | $conf->get('resource.ban_file'), | 233 | $conf->get('resource.datastore'), |
216 | $conf->get('resource.log'), | 234 | $conf->get('resource.ban_file'), |
217 | $conf->get('resource.update_check'), | 235 | $conf->get('resource.log'), |
218 | ) as $path) { | 236 | $conf->get('resource.update_check'), |
237 | ] as $path | ||
238 | ) { | ||
219 | if (!is_file(realpath($path))) { | 239 | if (!is_file(realpath($path))) { |
220 | # the file may not exist yet | 240 | # the file may not exist yet |
221 | continue; | 241 | continue; |
@@ -246,4 +266,54 @@ class ApplicationUtils | |||
246 | { | 266 | { |
247 | return hash_hmac('sha256', $currentVersion, $salt); | 267 | return hash_hmac('sha256', $currentVersion, $salt); |
248 | } | 268 | } |
269 | |||
270 | /** | ||
271 | * Get a list of PHP extensions used by Shaarli. | ||
272 | * | ||
273 | * @return array[] List of extension with following keys: | ||
274 | * - name: extension name | ||
275 | * - required: whether the extension is required to use Shaarli | ||
276 | * - desc: short description of extension usage in Shaarli | ||
277 | * - loaded: whether the extension is properly loaded or not | ||
278 | */ | ||
279 | public static function getPhpExtensionsRequirement(): array | ||
280 | { | ||
281 | $extensions = [ | ||
282 | ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')], | ||
283 | ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')], | ||
284 | ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')], | ||
285 | ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')], | ||
286 | ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')], | ||
287 | ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')], | ||
288 | ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')], | ||
289 | ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')], | ||
290 | ]; | ||
291 | |||
292 | foreach ($extensions as &$extension) { | ||
293 | $extension['loaded'] = extension_loaded($extension['name']); | ||
294 | } | ||
295 | |||
296 | return $extensions; | ||
297 | } | ||
298 | |||
299 | /** | ||
300 | * Return the EOL date of given PHP version. If the version is unknown, | ||
301 | * we return today + 2 years. | ||
302 | * | ||
303 | * @param string $fullVersion PHP version, e.g. 7.4.7 | ||
304 | * | ||
305 | * @return string Date format: YYYY-MM-DD | ||
306 | */ | ||
307 | public static function getPhpEol(string $fullVersion): string | ||
308 | { | ||
309 | preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches); | ||
310 | |||
311 | return [ | ||
312 | '7.1' => '2019-12-01', | ||
313 | '7.2' => '2020-11-30', | ||
314 | '7.3' => '2021-12-06', | ||
315 | '7.4' => '2022-11-28', | ||
316 | '8.0' => '2023-12-01', | ||
317 | ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d'); | ||
318 | } | ||
249 | } | 319 | } |
diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php new file mode 100644 index 00000000..5fabc907 --- /dev/null +++ b/application/helper/DailyPageHelper.php | |||
@@ -0,0 +1,208 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Helper; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Slim\Http\Request; | ||
9 | |||
10 | class DailyPageHelper | ||
11 | { | ||
12 | public const MONTH = 'month'; | ||
13 | public const WEEK = 'week'; | ||
14 | public const DAY = 'day'; | ||
15 | |||
16 | /** | ||
17 | * Extracts the type of the daily to display from the HTTP request parameters | ||
18 | * | ||
19 | * @param Request $request HTTP request | ||
20 | * | ||
21 | * @return string month/week/day | ||
22 | */ | ||
23 | public static function extractRequestedType(Request $request): string | ||
24 | { | ||
25 | if ($request->getQueryParam(static::MONTH) !== null) { | ||
26 | return static::MONTH; | ||
27 | } elseif ($request->getQueryParam(static::WEEK) !== null) { | ||
28 | return static::WEEK; | ||
29 | } | ||
30 | |||
31 | return static::DAY; | ||
32 | } | ||
33 | |||
34 | /** | ||
35 | * Extracts a DateTimeImmutable from provided HTTP request. | ||
36 | * If no parameter is provided, we rely on the creation date of the latest provided created bookmark. | ||
37 | * If the datastore is empty or no bookmark is provided, we use the current date. | ||
38 | * | ||
39 | * @param string $type month/week/day | ||
40 | * @param string|null $requestedDate Input string extracted from the request | ||
41 | * @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date) | ||
42 | * | ||
43 | * @return \DateTimeImmutable from input or latest bookmark. | ||
44 | * | ||
45 | * @throws \Exception Type not supported. | ||
46 | */ | ||
47 | public static function extractRequestedDateTime( | ||
48 | string $type, | ||
49 | ?string $requestedDate, | ||
50 | Bookmark $latestBookmark = null | ||
51 | ): \DateTimeImmutable { | ||
52 | $format = static::getFormatByType($type); | ||
53 | if (empty($requestedDate)) { | ||
54 | return $latestBookmark instanceof Bookmark | ||
55 | ? new \DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM)) | ||
56 | : new \DateTimeImmutable() | ||
57 | ; | ||
58 | } | ||
59 | |||
60 | // W is not supported by createFromFormat... | ||
61 | if ($type === static::WEEK) { | ||
62 | return (new \DateTimeImmutable()) | ||
63 | ->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2)) | ||
64 | ; | ||
65 | } | ||
66 | |||
67 | return \DateTimeImmutable::createFromFormat($format, $requestedDate); | ||
68 | } | ||
69 | |||
70 | /** | ||
71 | * Get the DateTime format used by provided type | ||
72 | * Examples: | ||
73 | * - day: 20201016 (<year><month><day>) | ||
74 | * - week: 202041 (<year><week number>) | ||
75 | * - month: 202010 (<year><month>) | ||
76 | * | ||
77 | * @param string $type month/week/day | ||
78 | * | ||
79 | * @return string DateTime compatible format | ||
80 | * | ||
81 | * @see https://www.php.net/manual/en/datetime.format.php | ||
82 | * | ||
83 | * @throws \Exception Type not supported. | ||
84 | */ | ||
85 | public static function getFormatByType(string $type): string | ||
86 | { | ||
87 | switch ($type) { | ||
88 | case static::MONTH: | ||
89 | return 'Ym'; | ||
90 | case static::WEEK: | ||
91 | return 'YW'; | ||
92 | case static::DAY: | ||
93 | return 'Ymd'; | ||
94 | default: | ||
95 | throw new \Exception('Unsupported daily format type'); | ||
96 | } | ||
97 | } | ||
98 | |||
99 | /** | ||
100 | * Get the first DateTime of the time period depending on given datetime and type. | ||
101 | * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax | ||
102 | * and we don't want to alter original datetime. | ||
103 | * | ||
104 | * @param string $type month/week/day | ||
105 | * @param \DateTimeImmutable $requested DateTime extracted from request input | ||
106 | * (should come from extractRequestedDateTime) | ||
107 | * | ||
108 | * @return \DateTimeInterface First DateTime of the time period | ||
109 | * | ||
110 | * @throws \Exception Type not supported. | ||
111 | */ | ||
112 | public static function getStartDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface | ||
113 | { | ||
114 | switch ($type) { | ||
115 | case static::MONTH: | ||
116 | return $requested->modify('first day of this month midnight'); | ||
117 | case static::WEEK: | ||
118 | return $requested->modify('Monday this week midnight'); | ||
119 | case static::DAY: | ||
120 | return $requested->modify('Today midnight'); | ||
121 | default: | ||
122 | throw new \Exception('Unsupported daily format type'); | ||
123 | } | ||
124 | } | ||
125 | |||
126 | /** | ||
127 | * Get the last DateTime of the time period depending on given datetime and type. | ||
128 | * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax | ||
129 | * and we don't want to alter original datetime. | ||
130 | * | ||
131 | * @param string $type month/week/day | ||
132 | * @param \DateTimeImmutable $requested DateTime extracted from request input | ||
133 | * (should come from extractRequestedDateTime) | ||
134 | * | ||
135 | * @return \DateTimeInterface Last DateTime of the time period | ||
136 | * | ||
137 | * @throws \Exception Type not supported. | ||
138 | */ | ||
139 | public static function getEndDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface | ||
140 | { | ||
141 | switch ($type) { | ||
142 | case static::MONTH: | ||
143 | return $requested->modify('last day of this month 23:59:59'); | ||
144 | case static::WEEK: | ||
145 | return $requested->modify('Sunday this week 23:59:59'); | ||
146 | case static::DAY: | ||
147 | return $requested->modify('Today 23:59:59'); | ||
148 | default: | ||
149 | throw new \Exception('Unsupported daily format type'); | ||
150 | } | ||
151 | } | ||
152 | |||
153 | /** | ||
154 | * Get localized description of the time period depending on given datetime and type. | ||
155 | * Example: for a month period, it returns `October, 2020`. | ||
156 | * | ||
157 | * @param string $type month/week/day | ||
158 | * @param \DateTimeImmutable $requested DateTime extracted from request input | ||
159 | * (should come from extractRequestedDateTime) | ||
160 | * | ||
161 | * @return string Localized time period description | ||
162 | * | ||
163 | * @throws \Exception Type not supported. | ||
164 | */ | ||
165 | public static function getDescriptionByType(string $type, \DateTimeImmutable $requested): string | ||
166 | { | ||
167 | switch ($type) { | ||
168 | case static::MONTH: | ||
169 | return $requested->format('F') . ', ' . $requested->format('Y'); | ||
170 | case static::WEEK: | ||
171 | $requested = $requested->modify('Monday this week'); | ||
172 | return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')'; | ||
173 | case static::DAY: | ||
174 | $out = ''; | ||
175 | if ($requested->format('Ymd') === date('Ymd')) { | ||
176 | $out = t('Today') . ' - '; | ||
177 | } elseif ($requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) { | ||
178 | $out = t('Yesterday') . ' - '; | ||
179 | } | ||
180 | return $out . format_date($requested, false); | ||
181 | default: | ||
182 | throw new \Exception('Unsupported daily format type'); | ||
183 | } | ||
184 | } | ||
185 | |||
186 | /** | ||
187 | * Get the number of items to display in the RSS feed depending on the given type. | ||
188 | * | ||
189 | * @param string $type month/week/day | ||
190 | * | ||
191 | * @return int number of elements | ||
192 | * | ||
193 | * @throws \Exception Type not supported. | ||
194 | */ | ||
195 | public static function getRssLengthByType(string $type): int | ||
196 | { | ||
197 | switch ($type) { | ||
198 | case static::MONTH: | ||
199 | return 12; // 1 year | ||
200 | case static::WEEK: | ||
201 | return 26; // ~6 months | ||
202 | case static::DAY: | ||
203 | return 30; // ~1 month | ||
204 | default: | ||
205 | throw new \Exception('Unsupported daily format type'); | ||
206 | } | ||
207 | } | ||
208 | } | ||
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..2e1401ec --- /dev/null +++ b/application/http/MetadataRetriever.php | |||
@@ -0,0 +1,69 @@ | |||
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 [ | ||
64 | 'title' => $title, | ||
65 | 'description' => $description, | ||
66 | 'tags' => $tags, | ||
67 | ]; | ||
68 | } | ||
69 | } | ||
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 1b2197c9..3ea55728 100644 --- a/application/plugin/PluginManager.php +++ b/application/plugin/PluginManager.php | |||
@@ -1,4 +1,5 @@ | |||
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; |
@@ -23,7 +24,7 @@ class PluginManager | |||
23 | * | 24 | * |
24 | * @var array $loadedPlugins | 25 | * @var array $loadedPlugins |
25 | */ | 26 | */ |
26 | private $loadedPlugins = array(); | 27 | private $loadedPlugins = []; |
27 | 28 | ||
28 | /** | 29 | /** |
29 | * @var ConfigManager Configuration Manager instance. | 30 | * @var ConfigManager Configuration Manager instance. |
@@ -57,7 +58,7 @@ class PluginManager | |||
57 | public function __construct(&$conf) | 58 | public function __construct(&$conf) |
58 | { | 59 | { |
59 | $this->conf = $conf; | 60 | $this->conf = $conf; |
60 | $this->errors = array(); | 61 | $this->errors = []; |
61 | } | 62 | } |
62 | 63 | ||
63 | /** | 64 | /** |
@@ -98,12 +99,13 @@ class PluginManager | |||
98 | * | 99 | * |
99 | * @return void | 100 | * @return void |
100 | */ | 101 | */ |
101 | public function executeHooks($hook, &$data, $params = array()) | 102 | public function executeHooks($hook, &$data, $params = []) |
102 | { | 103 | { |
103 | $metadataParameters = [ | 104 | $metadataParameters = [ |
104 | 'target' => '_PAGE_', | 105 | 'target' => '_PAGE_', |
105 | 'loggedin' => '_LOGGEDIN_', | 106 | 'loggedin' => '_LOGGEDIN_', |
106 | 'basePath' => '_BASE_PATH_', | 107 | 'basePath' => '_BASE_PATH_', |
108 | 'rootPath' => '_ROOT_PATH_', | ||
107 | 'bookmarkService' => '_BOOKMARK_SERVICE_', | 109 | 'bookmarkService' => '_BOOKMARK_SERVICE_', |
108 | ]; | 110 | ]; |
109 | 111 | ||
@@ -195,7 +197,7 @@ class PluginManager | |||
195 | */ | 197 | */ |
196 | public function getPluginsMeta() | 198 | public function getPluginsMeta() |
197 | { | 199 | { |
198 | $metaData = array(); | 200 | $metaData = []; |
199 | $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); | 201 | $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); |
200 | 202 | ||
201 | // Browse all plugin directories. | 203 | // Browse all plugin directories. |
@@ -216,9 +218,9 @@ class PluginManager | |||
216 | if (isset($metaData[$plugin]['parameters'])) { | 218 | if (isset($metaData[$plugin]['parameters'])) { |
217 | $params = explode(';', $metaData[$plugin]['parameters']); | 219 | $params = explode(';', $metaData[$plugin]['parameters']); |
218 | } else { | 220 | } else { |
219 | $params = array(); | 221 | $params = []; |
220 | } | 222 | } |
221 | $metaData[$plugin]['parameters'] = array(); | 223 | $metaData[$plugin]['parameters'] = []; |
222 | foreach ($params as $param) { | 224 | foreach ($params as $param) { |
223 | if (empty($param)) { | 225 | if (empty($param)) { |
224 | continue; | 226 | continue; |
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/render/PageBuilder.php b/application/render/PageBuilder.php index 41b357dd..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); |
@@ -174,10 +186,12 @@ class PageBuilder | |||
174 | } | 186 | } |
175 | } | 187 | } |
176 | 188 | ||
189 | $rootPath = preg_replace('#/index\.php$#', '', $basePath); | ||
177 | $this->assign('base_path', $basePath); | 190 | $this->assign('base_path', $basePath); |
191 | $this->assign('root_path', $rootPath); | ||
178 | $this->assign( | 192 | $this->assign( |
179 | 'asset_path', | 193 | 'asset_path', |
180 | $basePath . '/' . | 194 | $rootPath . '/' . |
181 | rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' . | 195 | rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' . |
182 | $this->conf->get('resource.theme', 'default') | 196 | $this->conf->get('resource.theme', 'default') |
183 | ); | 197 | ); |
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 d74c3118..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(); |
@@ -118,7 +125,7 @@ class LoginManager | |||
118 | * | 125 | * |
119 | * @return true when the user is logged in, false otherwise | 126 | * @return true when the user is logged in, false otherwise |
120 | */ | 127 | */ |
121 | public function isLoggedIn() | 128 | public function isLoggedIn(): bool |
122 | { | 129 | { |
123 | if ($this->openShaarli) { | 130 | if ($this->openShaarli) { |
124 | return true; | 131 | return true; |
@@ -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 | } |