aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to 'application')
-rw-r--r--application/History.php12
-rw-r--r--application/Languages.php17
-rw-r--r--application/Thumbnailer.php13
-rw-r--r--application/TimeZone.php7
-rw-r--r--application/Utils.php78
-rw-r--r--application/api/ApiMiddleware.php6
-rw-r--r--application/api/ApiUtils.php21
-rw-r--r--application/api/controllers/ApiController.php3
-rw-r--r--application/api/controllers/HistoryController.php1
-rw-r--r--application/api/controllers/Info.php4
-rw-r--r--application/api/controllers/Links.php31
-rw-r--r--application/api/exceptions/ApiAuthorizationException.php2
-rw-r--r--application/api/exceptions/ApiException.php2
-rw-r--r--application/bookmark/Bookmark.php217
-rw-r--r--application/bookmark/BookmarkArray.php20
-rw-r--r--application/bookmark/BookmarkFileService.php134
-rw-r--r--application/bookmark/BookmarkFilter.php203
-rw-r--r--application/bookmark/BookmarkIO.php43
-rw-r--r--application/bookmark/BookmarkInitializer.php19
-rw-r--r--application/bookmark/BookmarkServiceInterface.php109
-rw-r--r--application/bookmark/LinkUtils.php72
-rw-r--r--application/bookmark/exception/BookmarkNotFoundException.php1
-rw-r--r--application/bookmark/exception/EmptyDataStoreException.php6
-rw-r--r--application/bookmark/exception/InvalidBookmarkException.php14
-rw-r--r--application/bookmark/exception/NotWritableDataStoreException.php4
-rw-r--r--application/config/ConfigIO.php1
-rw-r--r--application/config/ConfigJson.php6
-rw-r--r--application/config/ConfigManager.php21
-rw-r--r--application/config/ConfigPhp.php28
-rw-r--r--application/config/ConfigPlugin.php8
-rw-r--r--application/config/exception/MissingFieldConfigException.php1
-rw-r--r--application/config/exception/UnauthorizedConfigException.php1
-rw-r--r--application/container/ContainerBuilder.php19
-rw-r--r--application/container/ShaarliContainer.php4
-rw-r--r--application/exceptions/IOException.php1
-rw-r--r--application/feed/FeedBuilder.php11
-rw-r--r--application/formatter/BookmarkDefaultFormatter.php140
-rw-r--r--application/formatter/BookmarkFormatter.php82
-rw-r--r--application/formatter/BookmarkMarkdownExtraFormatter.php24
-rw-r--r--application/formatter/BookmarkMarkdownFormatter.php20
-rw-r--r--application/formatter/BookmarkRawFormatter.php4
-rw-r--r--application/formatter/FormatterFactory.php2
-rw-r--r--application/front/ShaarliMiddleware.php6
-rw-r--r--application/front/controller/admin/ConfigureController.php14
-rw-r--r--application/front/controller/admin/ExportController.php4
-rw-r--r--application/front/controller/admin/ImportController.php4
-rw-r--r--application/front/controller/admin/ManageShaareController.php371
-rw-r--r--application/front/controller/admin/ManageTagController.php37
-rw-r--r--application/front/controller/admin/MetadataController.php29
-rw-r--r--application/front/controller/admin/PasswordController.php4
-rw-r--r--application/front/controller/admin/PluginsController.php4
-rw-r--r--application/front/controller/admin/ServerController.php96
-rw-r--r--application/front/controller/admin/SessionFilterController.php2
-rw-r--r--application/front/controller/admin/ShaareAddController.php34
-rw-r--r--application/front/controller/admin/ShaareManageController.php202
-rw-r--r--application/front/controller/admin/ShaarePublishController.php274
-rw-r--r--application/front/controller/admin/ThumbnailsController.php4
-rw-r--r--application/front/controller/admin/ToolsController.php2
-rw-r--r--application/front/controller/visitor/BookmarkListController.php53
-rw-r--r--application/front/controller/visitor/DailyController.php105
-rw-r--r--application/front/controller/visitor/ErrorController.php12
-rw-r--r--application/front/controller/visitor/FeedController.php2
-rw-r--r--application/front/controller/visitor/InstallController.php39
-rw-r--r--application/front/controller/visitor/LoginController.php9
-rw-r--r--application/front/controller/visitor/PictureWallController.php2
-rw-r--r--application/front/controller/visitor/ShaarliVisitorController.php6
-rw-r--r--application/front/controller/visitor/TagCloudController.php12
-rw-r--r--application/front/controller/visitor/TagController.php18
-rw-r--r--application/helper/ApplicationUtils.php (renamed from application/ApplicationUtils.php)128
-rw-r--r--application/helper/DailyPageHelper.php208
-rw-r--r--application/helper/FileUtils.php (renamed from application/FileUtils.php)58
-rw-r--r--application/http/HttpAccess.php20
-rw-r--r--application/http/HttpUtils.php198
-rw-r--r--application/http/MetadataRetriever.php69
-rw-r--r--application/http/Url.php10
-rw-r--r--application/http/UrlUtils.php11
-rw-r--r--application/legacy/LegacyController.php2
-rw-r--r--application/legacy/LegacyLinkDB.php22
-rw-r--r--application/legacy/LegacyLinkFilter.php18
-rw-r--r--application/legacy/LegacyUpdater.php16
-rw-r--r--application/netscape/NetscapeBookmarkUtils.php14
-rw-r--r--application/plugin/PluginManager.php14
-rw-r--r--application/plugin/exception/PluginFileNotFoundException.php1
-rw-r--r--application/render/PageBuilder.php38
-rw-r--r--application/render/TemplatePage.php1
-rw-r--r--application/render/ThemeUtils.php4
-rw-r--r--application/security/BanManager.php32
-rw-r--r--application/security/LoginManager.php83
-rw-r--r--application/security/SessionManager.php10
-rw-r--r--application/updater/Updater.php10
-rw-r--r--application/updater/UpdaterUtils.php8
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
2namespace Shaarli; 3namespace Shaarli;
3 4
4use DateTime; 5use DateTime;
5use Exception; 6use Exception;
6use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Helper\FileUtils;
7 9
8/** 10/**
9 * Class History 11 * Class History
@@ -30,27 +32,27 @@ class History
30 /** 32 /**
31 * @var string Action key: a new link has been created. 33 * @var string Action key: a new link has been created.
32 */ 34 */
33 const CREATED = 'CREATED'; 35 public const CREATED = 'CREATED';
34 36
35 /** 37 /**
36 * @var string Action key: a link has been updated. 38 * @var string Action key: a link has been updated.
37 */ 39 */
38 const UPDATED = 'UPDATED'; 40 public const UPDATED = 'UPDATED';
39 41
40 /** 42 /**
41 * @var string Action key: a link has been deleted. 43 * @var string Action key: a link has been deleted.
42 */ 44 */
43 const DELETED = 'DELETED'; 45 public const DELETED = 'DELETED';
44 46
45 /** 47 /**
46 * @var string Action key: settings have been updated. 48 * @var string Action key: settings have been updated.
47 */ 49 */
48 const SETTINGS = 'SETTINGS'; 50 public const SETTINGS = 'SETTINGS';
49 51
50 /** 52 /**
51 * @var string Action key: a bulk import has been processed. 53 * @var string Action key: a bulk import has been processed.
52 */ 54 */
53 const IMPORT = 'IMPORT'; 55 public const IMPORT = 'IMPORT';
54 56
55 /** 57 /**
56 * @var string History file path. 58 * @var string History file path.
diff --git a/application/Languages.php b/application/Languages.php
index d83e0765..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 */
14class Thumbnailer 14class Thumbnailer
15{ 15{
16 const COMMON_MEDIA_DOMAINS = [ 16 protected const COMMON_MEDIA_DOMAINS = [
17 'imgur.com', 17 'imgur.com',
18 'flickr.com', 18 'flickr.com',
19 'youtube.com', 19 'youtube.com',
@@ -31,9 +31,9 @@ class Thumbnailer
31 'deviantart.com', 31 'deviantart.com',
32 ]; 32 ];
33 33
34 const MODE_ALL = 'all'; 34 public const MODE_ALL = 'all';
35 const MODE_COMMON = 'common'; 35 public const MODE_COMMON = 'common';
36 const MODE_NONE = 'none'; 36 public const MODE_NONE = 'none';
37 37
38 /** 38 /**
39 * @var WebThumbnailer instance. 39 * @var WebThumbnailer instance.
@@ -60,7 +60,7 @@ class Thumbnailer
60 // TODO: create a proper error handling system able to catch exceptions... 60 // TODO: create a proper error handling system able to catch exceptions...
61 die(t( 61 die(t(
62 'php-gd extension must be loaded to use thumbnails. ' 62 'php-gd extension must be loaded to use thumbnails. '
63 .'Thumbnails are now disabled. Please reload the page.' 63 . 'Thumbnails are now disabled. Please reload the page.'
64 )); 64 ));
65 } 65 }
66 66
@@ -81,7 +81,8 @@ class Thumbnailer
81 */ 81 */
82 public function get($url) 82 public function get($url)
83 { 83 {
84 if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON 84 if (
85 $this->conf->get('thumbnails.mode') === self::MODE_COMMON
85 && ! $this->isCommonMediaOrImage($url) 86 && ! $this->isCommonMediaOrImage($url)
86 ) { 87 ) {
87 return false; 88 return false;
diff --git a/application/TimeZone.php b/application/TimeZone.php
index c1869ef8..a420eb96 100644
--- a/application/TimeZone.php
+++ b/application/TimeZone.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Generates a list of available timezone continents and cities. 4 * Generates a list of available timezone continents and cities.
4 * 5 *
@@ -43,7 +44,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
43 // Try to split the provided timezone 44 // Try to split the provided timezone
44 $spos = strpos($preselectedTimezone, '/'); 45 $spos = strpos($preselectedTimezone, '/');
45 $pcontinent = substr($preselectedTimezone, 0, $spos); 46 $pcontinent = substr($preselectedTimezone, 0, $spos);
46 $pcity = substr($preselectedTimezone, $spos+1); 47 $pcity = substr($preselectedTimezone, $spos + 1);
47 } 48 }
48 49
49 $continents = []; 50 $continents = [];
@@ -60,7 +61,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
60 } 61 }
61 62
62 $continent = substr($tz, 0, $spos); 63 $continent = substr($tz, 0, $spos);
63 $city = substr($tz, $spos+1); 64 $city = substr($tz, $spos + 1);
64 $cities[] = ['continent' => $continent, 'city' => $city]; 65 $cities[] = ['continent' => $continent, 'city' => $city];
65 $continents[$continent] = true; 66 $continents[$continent] = true;
66 } 67 }
@@ -85,7 +86,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
85function isTimeZoneValid($continent, $city) 86function isTimeZoneValid($continent, $city)
86{ 87{
87 return in_array( 88 return in_array(
88 $continent.'/'.$city, 89 $continent . '/' . $city,
89 timezone_identifiers_list() 90 timezone_identifiers_list()
90 ); 91 );
91} 92}
diff --git a/application/Utils.php b/application/Utils.php
index bcfda65c..952378ab 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -1,24 +1,27 @@
1<?php 1<?php
2
2/** 3/**
3 * Shaarli utilities 4 * Shaarli utilities
4 */ 5 */
5 6
6/** 7/**
7 * Logs a message to a text file 8 * Format log using provided data.
8 * 9 *
9 * The log format is compatible with fail2ban. 10 * @param string $message the message to log
11 * @param string|null $clientIp the client's remote IPv4/IPv6 address
10 * 12 *
11 * @param string $logFile where to write the logs 13 * @return string Formatted message to log
12 * @param string $clientIp the client's remote IPv4/IPv6 address
13 * @param string $message the message to log
14 */ 14 */
15function logm($logFile, $clientIp, $message) 15function format_log(string $message, string $clientIp = null): string
16{ 16{
17 file_put_contents( 17 $out = $message;
18 $logFile, 18
19 date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL, 19 if (!empty($clientIp)) {
20 FILE_APPEND 20 // Note: we keep the first dash to avoid breaking fail2ban configs
21 ); 21 $out = '- ' . $clientIp . ' - ' . $out;
22 }
23
24 return $out;
22} 25}
23 26
24/** 27/**
@@ -100,7 +103,7 @@ function escape($input)
100 } 103 }
101 104
102 if (is_array($input)) { 105 if (is_array($input)) {
103 $out = array(); 106 $out = [];
104 foreach ($input as $key => $value) { 107 foreach ($input as $key => $value) {
105 $out[escape($key)] = escape($value); 108 $out[escape($key)] = escape($value);
106 } 109 }
@@ -161,7 +164,7 @@ function checkDateFormat($format, $string)
161 * 164 *
162 * @return string $referer - final referer. 165 * @return string $referer - final referer.
163 */ 166 */
164function generateLocation($referer, $host, $loopTerms = array()) 167function generateLocation($referer, $host, $loopTerms = [])
165{ 168{
166 $finalReferer = './?'; 169 $finalReferer = './?';
167 170
@@ -194,7 +197,7 @@ function generateLocation($referer, $host, $loopTerms = array())
194function autoLocale($headerLocale) 197function autoLocale($headerLocale)
195{ 198{
196 // Default if browser does not send HTTP_ACCEPT_LANGUAGE 199 // Default if browser does not send HTTP_ACCEPT_LANGUAGE
197 $locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8'); 200 $locales = ['en_US', 'en_US.utf8', 'en_US.UTF-8'];
198 if (! empty($headerLocale)) { 201 if (! empty($headerLocale)) {
199 if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) { 202 if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
200 $attempts = []; 203 $attempts = [];
@@ -325,6 +328,23 @@ function format_date($date, $time = true, $intl = true)
325} 328}
326 329
327/** 330/**
331 * Format the date month according to the locale.
332 *
333 * @param DateTimeInterface $date to format.
334 *
335 * @return bool|string Formatted date, or false if the input is invalid.
336 */
337function format_month(DateTimeInterface $date)
338{
339 if (! $date instanceof DateTimeInterface) {
340 return false;
341 }
342
343 return strftime('%B', $date->getTimestamp());
344}
345
346
347/**
328 * Check if the input is an integer, no matter its real type. 348 * Check if the input is an integer, no matter its real type.
329 * 349 *
330 * PHP is a bit messy regarding this: 350 * PHP is a bit messy regarding this:
@@ -357,13 +377,15 @@ function return_bytes($val)
357 return $val; 377 return $val;
358 } 378 }
359 $val = trim($val); 379 $val = trim($val);
360 $last = strtolower($val[strlen($val)-1]); 380 $last = strtolower($val[strlen($val) - 1]);
361 $val = intval(substr($val, 0, -1)); 381 $val = intval(substr($val, 0, -1));
362 switch ($last) { 382 switch ($last) {
363 case 'g': 383 case 'g':
364 $val *= 1024; 384 $val *= 1024;
385 // do no break in order 1024^2 for each unit
365 case 'm': 386 case 'm':
366 $val *= 1024; 387 $val *= 1024;
388 // do no break in order 1024^2 for each unit
367 case 'k': 389 case 'k':
368 $val *= 1024; 390 $val *= 1024;
369 } 391 }
@@ -452,14 +474,28 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
452 * Wrapper function for translation which match the API 474 * Wrapper function for translation which match the API
453 * of gettext()/_() and ngettext(). 475 * of gettext()/_() and ngettext().
454 * 476 *
455 * @param string $text Text to translate. 477 * @param string $text Text to translate.
456 * @param string $nText The plural message ID. 478 * @param string $nText The plural message ID.
457 * @param int $nb The number of items for plural forms. 479 * @param int $nb The number of items for plural forms.
458 * @param string $domain The domain where the translation is stored (default: shaarli). 480 * @param string $domain The domain where the translation is stored (default: shaarli).
481 * @param array $variables Associative array of variables to replace in translated text.
482 * @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables.
459 * 483 *
460 * @return string Text translated. 484 * @return string Text translated.
461 */ 485 */
462function t($text, $nText = '', $nb = 1, $domain = 'shaarli') 486function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
487{
488 $postFunction = $fixCase ? 'ucfirst' : function ($input) {
489 return $input;
490 };
491
492 return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
493}
494
495/**
496 * Converts an exception into a printable stack trace string.
497 */
498function exception2text(Throwable $e): string
463{ 499{
464 return dn__($domain, $text, $nText, $nb); 500 return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
465} 501}
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
index f5b53b01..9fb88358 100644
--- a/application/api/ApiMiddleware.php
+++ b/application/api/ApiMiddleware.php
@@ -1,6 +1,8 @@
1<?php 1<?php
2
2namespace Shaarli\Api; 3namespace Shaarli\Api;
3 4
5use malkusch\lock\mutex\FlockMutex;
4use Shaarli\Api\Exceptions\ApiAuthorizationException; 6use Shaarli\Api\Exceptions\ApiAuthorizationException;
5use Shaarli\Api\Exceptions\ApiException; 7use Shaarli\Api\Exceptions\ApiException;
6use Shaarli\Bookmark\BookmarkFileService; 8use 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
2namespace Shaarli\Api; 3namespace Shaarli\Api;
3 4
4use Shaarli\Api\Exceptions\ApiAuthorizationException; 5use Shaarli\Api\Exceptions\ApiAuthorizationException;
@@ -27,7 +28,7 @@ class ApiUtils
27 throw new ApiAuthorizationException('Malformed JWT token'); 28 throw new ApiAuthorizationException('Malformed JWT token');
28 } 29 }
29 30
30 $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true)); 31 $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
31 if ($parts[2] != $genSign) { 32 if ($parts[2] != $genSign) {
32 throw new ApiAuthorizationException('Invalid JWT signature'); 33 throw new ApiAuthorizationException('Invalid JWT signature');
33 } 34 }
@@ -42,7 +43,8 @@ class ApiUtils
42 throw new ApiAuthorizationException('Invalid JWT payload'); 43 throw new ApiAuthorizationException('Invalid JWT payload');
43 } 44 }
44 45
45 if (empty($payload->iat) 46 if (
47 empty($payload->iat)
46 || $payload->iat > time() 48 || $payload->iat > time()
47 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION 49 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
48 ) { 50 ) {
@@ -89,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
5use Shaarli\Bookmark\BookmarkServiceInterface; 5use Shaarli\Bookmark\BookmarkServiceInterface;
6use Shaarli\Config\ConfigManager; 6use Shaarli\Config\ConfigManager;
7use Shaarli\History;
7use Slim\Container; 8use 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
4namespace Shaarli\Api\Controllers; 3namespace Shaarli\Api\Controllers;
5 4
6use Shaarli\Api\Exceptions\ApiBadParametersException; 5use Shaarli\Api\Exceptions\ApiBadParametersException;
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
index 12f6b2f0..ae7db93e 100644
--- a/application/api/controllers/Info.php
+++ b/application/api/controllers/Info.php
@@ -29,13 +29,13 @@ class Info extends ApiController
29 $info = [ 29 $info = [
30 'global_counter' => $this->bookmarkService->count(), 30 'global_counter' => $this->bookmarkService->count(),
31 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE), 31 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
32 'settings' => array( 32 'settings' => [
33 'title' => $this->conf->get('general.title', 'Shaarli'), 33 'title' => $this->conf->get('general.title', 'Shaarli'),
34 'header_link' => $this->conf->get('general.header_link', '?'), 34 'header_link' => $this->conf->get('general.header_link', '?'),
35 'timezone' => $this->conf->get('general.timezone', 'UTC'), 35 'timezone' => $this->conf->get('general.timezone', 'UTC'),
36 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []), 36 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
37 'default_private_links' => $this->conf->get('privacy.default_private_links', false), 37 'default_private_links' => $this->conf->get('privacy.default_private_links', false),
38 ), 38 ],
39 ]; 39 ];
40 40
41 return $response->withJson($info, 200, $this->jsonStyle); 41 return $response->withJson($info, 200, $this->jsonStyle);
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
index 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
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use DateTime; 7use DateTime;
@@ -17,7 +19,7 @@ use Shaarli\Bookmark\Exception\InvalidBookmarkException;
17class Bookmark 19class 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
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use Shaarli\Bookmark\Exception\InvalidBookmarkException; 7use 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
3declare(strict_types=1);
3 4
4namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
5 6
6 7use DateTime;
7use Exception; 8use Exception;
9use malkusch\lock\mutex\Mutex;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 10use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; 11use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
10use Shaarli\Bookmark\Exception\EmptyDataStoreException; 12use 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
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use Exception; 7use Exception;
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use 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
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
7use malkusch\lock\mutex\Mutex;
8use malkusch\lock\mutex\NoMutex;
5use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; 9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
6use Shaarli\Bookmark\Exception\EmptyDataStoreException; 10use Shaarli\Bookmark\Exception\EmptyDataStoreException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException; 11use 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
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace 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 */
16class BookmarkInitializer 21class 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
42Explore your new Shaarli instance by trying out controls and menus. 47Explore your new Shaarli instance by trying out controls and menus.
43Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. 48Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
@@ -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.
56This note is private, so you are the only one able to see it while logged in. 61This note is private, so you are the only one able to see it while logged in.
57 62
58You can use this to keep notes, post articles, code snippets, and much more. 63You can use this to keep notes, post articles, code snippets, and much more.
@@ -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
94Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. 99Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
95You can add a description to your bookmarks, such as this one, and tag them. 100You can add a description to your bookmarks, such as this one, and tag them.
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
index b9b483eb..08cdbb4e 100644
--- a/application/bookmark/BookmarkServiceInterface.php
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -1,79 +1,73 @@
1<?php 1<?php
2 2
3namespace Shaarli\Bookmark; 3declare(strict_types=1);
4 4
5namespace Shaarli\Bookmark;
5 6
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException; 8use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
8use Shaarli\Config\ConfigManager;
9use 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 */
16interface BookmarkServiceInterface 18interface 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 */
142function format_description($description, $indexUrl = '') 146function 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 */
189function 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 */
206function 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 */
219function 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
2namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
3 4
4use Exception; 5use Exception;
diff --git a/application/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php
index cd48c1e6..16a98470 100644
--- a/application/bookmark/exception/EmptyDataStoreException.php
+++ b/application/bookmark/exception/EmptyDataStoreException.php
@@ -1,7 +1,7 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
5 4
6 5class EmptyDataStoreException extends \Exception
7class EmptyDataStoreException extends \Exception {} 6{
7}
diff --git a/application/bookmark/exception/InvalidBookmarkException.php b/application/bookmark/exception/InvalidBookmarkException.php
index 10c84a6d..fe184f8c 100644
--- a/application/bookmark/exception/InvalidBookmarkException.php
+++ b/application/bookmark/exception/InvalidBookmarkException.php
@@ -16,14 +16,14 @@ class InvalidBookmarkException extends \Exception
16 } else { 16 } else {
17 $created = 'Not a DateTime object'; 17 $created = 'Not a DateTime object';
18 } 18 }
19 $this->message = 'This bookmark is not valid'. PHP_EOL; 19 $this->message = 'This bookmark is not valid' . PHP_EOL;
20 $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL; 20 $this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL;
21 $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL; 21 $this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL;
22 $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL; 22 $this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL;
23 $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL; 23 $this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL;
24 $this->message .= ' - Created: '. $created . PHP_EOL; 24 $this->message .= ' - Created: ' . $created . PHP_EOL;
25 } else { 25 } else {
26 $this->message = 'The provided data is not a bookmark'. PHP_EOL; 26 $this->message = 'The provided data is not a bookmark' . PHP_EOL;
27 $this->message .= var_export($bookmark, true); 27 $this->message .= var_export($bookmark, true);
28 } 28 }
29 } 29 }
diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php
index 95f34b50..df91f3bc 100644
--- a/application/bookmark/exception/NotWritableDataStoreException.php
+++ b/application/bookmark/exception/NotWritableDataStoreException.php
@@ -1,9 +1,7 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
5 4
6
7class NotWritableDataStoreException extends \Exception 5class NotWritableDataStoreException extends \Exception
8{ 6{
9 /** 7 /**
@@ -13,7 +11,7 @@ class NotWritableDataStoreException extends \Exception
13 */ 11 */
14 public function __construct($dataStore) 12 public function __construct($dataStore)
15 { 13 {
16 $this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '. 14 $this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' .
17 'Your data might be corrupted, or your file isn\'t readable.'; 15 'Your data might be corrupted, or your file isn\'t readable.';
18 } 16 }
19} 17}
diff --git a/application/config/ConfigIO.php b/application/config/ConfigIO.php
index 3efe5b6f..a623bc8b 100644
--- a/application/config/ConfigIO.php
+++ b/application/config/ConfigIO.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Config; 3namespace Shaarli\Config;
3 4
4/** 5/**
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php
index c0c0dab9..23b22269 100644
--- a/application/config/ConfigJson.php
+++ b/application/config/ConfigJson.php
@@ -19,7 +19,7 @@ class ConfigJson implements ConfigIO
19 $data = file_get_contents($filepath); 19 $data = file_get_contents($filepath);
20 $data = str_replace(self::getPhpHeaders(), '', $data); 20 $data = str_replace(self::getPhpHeaders(), '', $data);
21 $data = str_replace(self::getPhpSuffix(), '', $data); 21 $data = str_replace(self::getPhpSuffix(), '', $data);
22 $data = json_decode($data, true); 22 $data = json_decode(trim($data), true);
23 if ($data === null) { 23 if ($data === null) {
24 $errorCode = json_last_error(); 24 $errorCode = json_last_error();
25 $error = sprintf( 25 $error = sprintf(
@@ -73,7 +73,7 @@ class ConfigJson implements ConfigIO
73 */ 73 */
74 public static function getPhpHeaders() 74 public static function getPhpHeaders()
75 { 75 {
76 return '<?php /*'. PHP_EOL; 76 return '<?php /*';
77 } 77 }
78 78
79 /** 79 /**
@@ -85,6 +85,6 @@ class ConfigJson implements ConfigIO
85 */ 85 */
86 public static function getPhpSuffix() 86 public static function getPhpSuffix()
87 { 87 {
88 return PHP_EOL . '*/ ?>'; 88 return '*/ ?>';
89 } 89 }
90} 90}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index 4c98be30..717a038f 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Config; 3namespace Shaarli\Config;
3 4
4use Shaarli\Config\Exception\MissingFieldConfigException; 5use Shaarli\Config\Exception\MissingFieldConfigException;
@@ -20,7 +21,7 @@ class ConfigManager
20 */ 21 */
21 protected static $NOT_FOUND = 'NOT_FOUND'; 22 protected static $NOT_FOUND = 'NOT_FOUND';
22 23
23 public static $DEFAULT_PLUGINS = array('qrcode'); 24 public static $DEFAULT_PLUGINS = ['qrcode'];
24 25
25 /** 26 /**
26 * @var string Config folder. 27 * @var string Config folder.
@@ -133,7 +134,7 @@ class ConfigManager
133 public function set($setting, $value, $write = false, $isLoggedIn = false) 134 public function set($setting, $value, $write = false, $isLoggedIn = false)
134 { 135 {
135 if (empty($setting) || ! is_string($setting)) { 136 if (empty($setting) || ! is_string($setting)) {
136 throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); 137 throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
137 } 138 }
138 139
139 // During the ConfigIO transition, map legacy settings to the new ones. 140 // During the ConfigIO transition, map legacy settings to the new ones.
@@ -160,7 +161,7 @@ class ConfigManager
160 public function remove($setting, $write = false, $isLoggedIn = false) 161 public function remove($setting, $write = false, $isLoggedIn = false)
161 { 162 {
162 if (empty($setting) || ! is_string($setting)) { 163 if (empty($setting) || ! is_string($setting)) {
163 throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); 164 throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
164 } 165 }
165 166
166 // During the ConfigIO transition, map legacy settings to the new ones. 167 // During the ConfigIO transition, map legacy settings to the new ones.
@@ -213,7 +214,7 @@ class ConfigManager
213 public function write($isLoggedIn) 214 public function write($isLoggedIn)
214 { 215 {
215 // These fields are required in configuration. 216 // These fields are required in configuration.
216 $mandatoryFields = array( 217 $mandatoryFields = [
217 'credentials.login', 218 'credentials.login',
218 'credentials.hash', 219 'credentials.hash',
219 'credentials.salt', 220 'credentials.salt',
@@ -222,7 +223,7 @@ class ConfigManager
222 'general.title', 223 'general.title',
223 'general.header_link', 224 'general.header_link',
224 'privacy.default_private_links', 225 'privacy.default_private_links',
225 ); 226 ];
226 227
227 // Only logged in user can alter config. 228 // Only logged in user can alter config.
228 if (is_file($this->getConfigFileExt()) && !$isLoggedIn) { 229 if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
@@ -366,10 +367,12 @@ class ConfigManager
366 $this->setEmpty('general.links_per_page', 20); 367 $this->setEmpty('general.links_per_page', 20);
367 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); 368 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
368 $this->setEmpty('general.default_note_title', 'Note: '); 369 $this->setEmpty('general.default_note_title', 'Note: ');
369 $this->setEmpty('general.retrieve_description', false); 370 $this->setEmpty('general.retrieve_description', true);
371 $this->setEmpty('general.enable_async_metadata', true);
372 $this->setEmpty('general.tags_separator', ' ');
370 373
371 $this->setEmpty('updates.check_updates', false); 374 $this->setEmpty('updates.check_updates', true);
372 $this->setEmpty('updates.check_updates_branch', 'stable'); 375 $this->setEmpty('updates.check_updates_branch', 'latest');
373 $this->setEmpty('updates.check_updates_interval', 86400); 376 $this->setEmpty('updates.check_updates_interval', 86400);
374 377
375 $this->setEmpty('feed.rss_permalinks', true); 378 $this->setEmpty('feed.rss_permalinks', true);
@@ -390,7 +393,7 @@ class ConfigManager
390 $this->setEmpty('translation.mode', 'php'); 393 $this->setEmpty('translation.mode', 'php');
391 $this->setEmpty('translation.extensions', []); 394 $this->setEmpty('translation.extensions', []);
392 395
393 $this->setEmpty('plugins', array()); 396 $this->setEmpty('plugins', []);
394 397
395 $this->setEmpty('formatter', 'markdown'); 398 $this->setEmpty('formatter', 'markdown');
396 } 399 }
diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php
index cad34594..53d6a7a3 100644
--- a/application/config/ConfigPhp.php
+++ b/application/config/ConfigPhp.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Config; 3namespace Shaarli\Config;
3 4
4/** 5/**
@@ -12,7 +13,7 @@ class ConfigPhp implements ConfigIO
12 /** 13 /**
13 * @var array List of config key without group. 14 * @var array List of config key without group.
14 */ 15 */
15 public static $ROOT_KEYS = array( 16 public static $ROOT_KEYS = [
16 'login', 17 'login',
17 'hash', 18 'hash',
18 'salt', 19 'salt',
@@ -22,7 +23,7 @@ class ConfigPhp implements ConfigIO
22 'redirector', 23 'redirector',
23 'disablesessionprotection', 24 'disablesessionprotection',
24 'privateLinkByDefault', 25 'privateLinkByDefault',
25 ); 26 ];
26 27
27 /** 28 /**
28 * Map legacy config keys with the new ones. 29 * Map legacy config keys with the new ones.
@@ -31,7 +32,7 @@ class ConfigPhp implements ConfigIO
31 * 32 *
32 * @var array current key => legacy key. 33 * @var array current key => legacy key.
33 */ 34 */
34 public static $LEGACY_KEYS_MAPPING = array( 35 public static $LEGACY_KEYS_MAPPING = [
35 'credentials.login' => 'login', 36 'credentials.login' => 'login',
36 'credentials.hash' => 'hash', 37 'credentials.hash' => 'hash',
37 'credentials.salt' => 'salt', 38 'credentials.salt' => 'salt',
@@ -68,7 +69,7 @@ class ConfigPhp implements ConfigIO
68 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS', 69 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
69 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS', 70 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
70 'security.open_shaarli' => 'config.OPEN_SHAARLI', 71 'security.open_shaarli' => 'config.OPEN_SHAARLI',
71 ); 72 ];
72 73
73 /** 74 /**
74 * @inheritdoc 75 * @inheritdoc
@@ -76,12 +77,12 @@ class ConfigPhp implements ConfigIO
76 public function read($filepath) 77 public function read($filepath)
77 { 78 {
78 if (! file_exists($filepath) || ! is_readable($filepath)) { 79 if (! file_exists($filepath) || ! is_readable($filepath)) {
79 return array(); 80 return [];
80 } 81 }
81 82
82 include $filepath; 83 include $filepath;
83 84
84 $out = array(); 85 $out = [];
85 foreach (self::$ROOT_KEYS as $key) { 86 foreach (self::$ROOT_KEYS as $key) {
86 $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : ''; 87 $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
87 } 88 }
@@ -95,7 +96,7 @@ class ConfigPhp implements ConfigIO
95 */ 96 */
96 public function write($filepath, $conf) 97 public function write($filepath, $conf)
97 { 98 {
98 $configStr = '<?php '. PHP_EOL; 99 $configStr = '<?php ' . PHP_EOL;
99 foreach (self::$ROOT_KEYS as $key) { 100 foreach (self::$ROOT_KEYS as $key) {
100 if (isset($conf[$key])) { 101 if (isset($conf[$key])) {
101 $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL; 102 $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
@@ -106,8 +107,8 @@ class ConfigPhp implements ConfigIO
106 foreach ($conf['config'] as $key => $value) { 107 foreach ($conf['config'] as $key => $value) {
107 $configStr .= '$GLOBALS[\'config\'][\'' 108 $configStr .= '$GLOBALS[\'config\'][\''
108 . $key 109 . $key
109 .'\'] = ' 110 . '\'] = '
110 .var_export($conf['config'][$key], true).';' 111 . var_export($conf['config'][$key], true) . ';'
111 . PHP_EOL; 112 . PHP_EOL;
112 } 113 }
113 114
@@ -115,18 +116,19 @@ class ConfigPhp implements ConfigIO
115 foreach ($conf['plugins'] as $key => $value) { 116 foreach ($conf['plugins'] as $key => $value) {
116 $configStr .= '$GLOBALS[\'plugins\'][\'' 117 $configStr .= '$GLOBALS[\'plugins\'][\''
117 . $key 118 . $key
118 .'\'] = ' 119 . '\'] = '
119 .var_export($conf['plugins'][$key], true).';' 120 . var_export($conf['plugins'][$key], true) . ';'
120 . PHP_EOL; 121 . PHP_EOL;
121 } 122 }
122 } 123 }
123 124
124 if (!file_put_contents($filepath, $configStr) 125 if (
126 !file_put_contents($filepath, $configStr)
125 || strcmp(file_get_contents($filepath), $configStr) != 0 127 || strcmp(file_get_contents($filepath), $configStr) != 0
126 ) { 128 ) {
127 throw new \Shaarli\Exceptions\IOException( 129 throw new \Shaarli\Exceptions\IOException(
128 $filepath, 130 $filepath,
129 t('Shaarli could not create the config file. '. 131 t('Shaarli could not create the config file. ' .
130 'Please make sure Shaarli has the right to write in the folder is it installed in.') 132 'Please make sure Shaarli has the right to write in the folder is it installed in.')
131 ); 133 );
132 } 134 }
diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php
index ea8dfbda..6cadef12 100644
--- a/application/config/ConfigPlugin.php
+++ b/application/config/ConfigPlugin.php
@@ -39,8 +39,8 @@ function save_plugin_config($formData)
39 throw new PluginConfigOrderException(); 39 throw new PluginConfigOrderException();
40 } 40 }
41 41
42 $plugins = array(); 42 $plugins = [];
43 $newEnabledPlugins = array(); 43 $newEnabledPlugins = [];
44 foreach ($formData as $key => $data) { 44 foreach ($formData as $key => $data) {
45 if (startsWith($key, 'order')) { 45 if (startsWith($key, 'order')) {
46 continue; 46 continue;
@@ -62,7 +62,7 @@ function save_plugin_config($formData)
62 throw new PluginConfigOrderException(); 62 throw new PluginConfigOrderException();
63 } 63 }
64 64
65 $finalPlugins = array(); 65 $finalPlugins = [];
66 // Make plugins order continuous. 66 // Make plugins order continuous.
67 foreach ($plugins as $plugin) { 67 foreach ($plugins as $plugin) {
68 $finalPlugins[] = $plugin; 68 $finalPlugins[] = $plugin;
@@ -81,7 +81,7 @@ function save_plugin_config($formData)
81 */ 81 */
82function validate_plugin_order($formData) 82function validate_plugin_order($formData)
83{ 83{
84 $orders = array(); 84 $orders = [];
85 foreach ($formData as $key => $value) { 85 foreach ($formData as $key => $value) {
86 // No duplicate order allowed. 86 // No duplicate order allowed.
87 if (in_array($value, $orders, true)) { 87 if (in_array($value, $orders, true)) {
diff --git a/application/config/exception/MissingFieldConfigException.php b/application/config/exception/MissingFieldConfigException.php
index 9e0a9359..a5f4356a 100644
--- a/application/config/exception/MissingFieldConfigException.php
+++ b/application/config/exception/MissingFieldConfigException.php
@@ -1,6 +1,5 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Config\Exception; 3namespace Shaarli\Config\Exception;
5 4
6/** 5/**
diff --git a/application/config/exception/UnauthorizedConfigException.php b/application/config/exception/UnauthorizedConfigException.php
index 72311fae..b041c6e3 100644
--- a/application/config/exception/UnauthorizedConfigException.php
+++ b/application/config/exception/UnauthorizedConfigException.php
@@ -1,6 +1,5 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Config\Exception; 3namespace Shaarli\Config\Exception;
5 4
6/** 5/**
diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php
index 55bb51b5..f0234eca 100644
--- a/application/container/ContainerBuilder.php
+++ b/application/container/ContainerBuilder.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Container; 5namespace Shaarli\Container;
6 6
7use malkusch\lock\mutex\FlockMutex;
8use Psr\Log\LoggerInterface;
7use Shaarli\Bookmark\BookmarkFileService; 9use Shaarli\Bookmark\BookmarkFileService;
8use Shaarli\Bookmark\BookmarkServiceInterface; 10use Shaarli\Bookmark\BookmarkServiceInterface;
9use Shaarli\Config\ConfigManager; 11use Shaarli\Config\ConfigManager;
@@ -13,6 +15,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
13use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; 15use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
14use Shaarli\History; 16use Shaarli\History;
15use Shaarli\Http\HttpAccess; 17use Shaarli\Http\HttpAccess;
18use Shaarli\Http\MetadataRetriever;
16use Shaarli\Netscape\NetscapeBookmarkUtils; 19use Shaarli\Netscape\NetscapeBookmarkUtils;
17use Shaarli\Plugin\PluginManager; 20use Shaarli\Plugin\PluginManager;
18use Shaarli\Render\PageBuilder; 21use 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
5namespace Shaarli\Container; 5namespace Shaarli\Container;
6 6
7use Psr\Log\LoggerInterface;
7use Shaarli\Bookmark\BookmarkServiceInterface; 8use Shaarli\Bookmark\BookmarkServiceInterface;
8use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
9use Shaarli\Feed\FeedBuilder; 10use Shaarli\Feed\FeedBuilder;
10use Shaarli\Formatter\FormatterFactory; 11use Shaarli\Formatter\FormatterFactory;
11use Shaarli\History; 12use Shaarli\History;
12use Shaarli\Http\HttpAccess; 13use Shaarli\Http\HttpAccess;
14use Shaarli\Http\MetadataRetriever;
13use Shaarli\Netscape\NetscapeBookmarkUtils; 15use Shaarli\Netscape\NetscapeBookmarkUtils;
14use Shaarli\Plugin\PluginManager; 16use Shaarli\Plugin\PluginManager;
15use Shaarli\Render\PageBuilder; 17use Shaarli\Render\PageBuilder;
@@ -35,6 +37,8 @@ use Slim\Container;
35 * @property History $history 37 * @property History $history
36 * @property HttpAccess $httpAccess 38 * @property HttpAccess $httpAccess
37 * @property LoginManager $loginManager 39 * @property LoginManager $loginManager
40 * @property LoggerInterface $logger
41 * @property MetadataRetriever $metadataRetriever
38 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils 42 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils
39 * @property callable $notFoundHandler Overrides default Slim exception display 43 * @property callable $notFoundHandler Overrides default Slim exception display
40 * @property PageBuilder $pageBuilder 44 * @property PageBuilder $pageBuilder
diff --git a/application/exceptions/IOException.php b/application/exceptions/IOException.php
index 2aa25e5c..c1a9ffbe 100644
--- a/application/exceptions/IOException.php
+++ b/application/exceptions/IOException.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Exceptions; 3namespace Shaarli\Exceptions;
3 4
4use Exception; 5use Exception;
diff --git a/application/feed/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
2namespace Shaarli\Feed; 3namespace Shaarli\Feed;
3 4
4use DateTime; 5use 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>&#8212; ' . $permalink; 184 $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
184 185
diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php
index 9d4a0fa0..7e0afafc 100644
--- a/application/formatter/BookmarkDefaultFormatter.php
+++ b/application/formatter/BookmarkDefaultFormatter.php
@@ -12,10 +12,13 @@ namespace Shaarli\Formatter;
12 */ 12 */
13class BookmarkDefaultFormatter extends BookmarkFormatter 13class 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
3namespace Shaarli\Formatter; 3namespace Shaarli\Formatter;
4 4
5use DateTime; 5use DateTimeInterface;
6use Shaarli\Bookmark\Bookmark; 6use Shaarli\Bookmark\Bookmark;
7use Shaarli\Config\ConfigManager; 7use 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 */
16abstract class BookmarkFormatter 39abstract 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
3namespace Shaarli\Formatter;
4
5use 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 */
16class 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 */
13class BookmarkRawFormatter extends BookmarkFormatter {} 13class BookmarkRawFormatter extends BookmarkFormatter
14{
15}
diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php
index a029579f..bb865aed 100644
--- a/application/formatter/FormatterFactory.php
+++ b/application/formatter/FormatterFactory.php
@@ -41,7 +41,7 @@ class FormatterFactory
41 public function getFormatter(string $type = null): BookmarkFormatter 41 public function getFormatter(string $type = null): BookmarkFormatter
42 { 42 {
43 $type = $type ? $type : $this->conf->get('formatter', 'default'); 43 $type = $type ? $type : $this->conf->get('formatter', 'default');
44 $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; 44 $className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter';
45 if (!class_exists($className)) { 45 if (!class_exists($className)) {
46 $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter'; 46 $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
47 } 47 }
diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php
index d1aa1399..164217f4 100644
--- a/application/front/ShaarliMiddleware.php
+++ b/application/front/ShaarliMiddleware.php
@@ -42,7 +42,8 @@ class ShaarliMiddleware
42 $this->initBasePath($request); 42 $this->initBasePath($request);
43 43
44 try { 44 try {
45 if (!is_file($this->container->conf->getConfigFileExt()) 45 if (
46 !is_file($this->container->conf->getConfigFileExt())
46 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true) 47 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
47 ) { 48 ) {
48 return $response->withRedirect($this->container->basePath . '/install'); 49 return $response->withRedirect($this->container->basePath . '/install');
@@ -86,7 +87,8 @@ class ShaarliMiddleware
86 */ 87 */
87 protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool 88 protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
88 { 89 {
89 if (// if the user isn't logged in 90 if (
91// if the user isn't logged in
90 !$this->container->loginManager->isLoggedIn() 92 !$this->container->loginManager->isLoggedIn()
91 // and Shaarli doesn't have public content... 93 // and Shaarli doesn't have public content...
92 && $this->container->conf->get('privacy.hide_public_links') 94 && $this->container->conf->get('privacy.hide_public_links')
diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php
index 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
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkMarkdownFormatter;
10use Shaarli\Render\TemplatePage;
11use Shaarli\Thumbnailer;
12use Slim\Http\Request;
13use Slim\Http\Response;
14
15/**
16 * Class PostBookmarkController
17 *
18 * Slim controller used to handle Shaarli create or edit bookmarks.
19 */
20class ManageShaareController extends ShaarliAdminController
21{
22 /**
23 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
24 */
25 public function addShaare(Request $request, Response $response): Response
26 {
27 $this->assignView(
28 'pagetitle',
29 t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
30 );
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34
35 /**
36 * GET /admin/shaare - Displays the bookmark form for creation.
37 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
38 */
39 public function displayCreateForm(Request $request, Response $response): Response
40 {
41 $url = cleanup_url($request->getParam('post'));
42
43 $linkIsNew = false;
44 // Check if URL is not already in database (in this case, we will edit the existing link)
45 $bookmark = $this->container->bookmarkService->findByUrl($url);
46 if (null === $bookmark) {
47 $linkIsNew = true;
48 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
49 $title = $request->getParam('title');
50 $description = $request->getParam('description');
51 $tags = $request->getParam('tags');
52 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
53
54 // If this is an HTTP(S) link, we try go get the page to extract
55 // the title (otherwise we will to straight to the edit form.)
56 if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
57 $retrieveDescription = $this->container->conf->get('general.retrieve_description');
58 // Short timeout to keep the application responsive
59 // The callback will fill $charset and $title with data from the downloaded page.
60 $this->container->httpAccess->getHttpResponse(
61 $url,
62 $this->container->conf->get('general.download_timeout', 30),
63 $this->container->conf->get('general.download_max_size', 4194304),
64 $this->container->httpAccess->getCurlDownloadCallback(
65 $charset,
66 $title,
67 $description,
68 $tags,
69 $retrieveDescription
70 )
71 );
72 if (! empty($title) && strtolower($charset) !== 'utf-8' && mb_check_encoding($charset)) {
73 $title = mb_convert_encoding($title, 'utf-8', $charset);
74 }
75 }
76
77 if (empty($url) && empty($title)) {
78 $title = $this->container->conf->get('general.default_note_title', t('Note: '));
79 }
80
81 $link = [
82 'title' => $title,
83 'url' => $url ?? '',
84 'description' => $description ?? '',
85 'tags' => $tags ?? '',
86 'private' => $private,
87 ];
88 } else {
89 $formatter = $this->container->formatterFactory->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91 }
92
93 return $this->displayForm($link, $linkIsNew, $request, $response);
94 }
95
96 /**
97 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
98 */
99 public function displayEditForm(Request $request, Response $response, array $args): Response
100 {
101 $id = $args['id'] ?? '';
102 try {
103 if (false === ctype_digit($id)) {
104 throw new BookmarkNotFoundException();
105 }
106 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
107 } catch (BookmarkNotFoundException $e) {
108 $this->saveErrorMessage(sprintf(
109 t('Bookmark with identifier %s could not be found.'),
110 $id
111 ));
112
113 return $this->redirect($response, '/');
114 }
115
116 $formatter = $this->container->formatterFactory->getFormatter('raw');
117 $link = $formatter->format($bookmark);
118
119 return $this->displayForm($link, false, $request, $response);
120 }
121
122 /**
123 * POST /admin/shaare
124 */
125 public function save(Request $request, Response $response): Response
126 {
127 $this->checkToken($request);
128
129 // lf_id should only be present if the link exists.
130 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
131 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
132 // Edit
133 $bookmark = $this->container->bookmarkService->get($id);
134 } else {
135 // New link
136 $bookmark = new Bookmark();
137 }
138
139 $bookmark->setTitle($request->getParam('lf_title'));
140 $bookmark->setDescription($request->getParam('lf_description'));
141 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
142 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
143 $bookmark->setTagsString($request->getParam('lf_tags'));
144
145 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
146 && false === $bookmark->isNote()
147 ) {
148 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
149 }
150 $this->container->bookmarkService->addOrSet($bookmark, false);
151
152 // To preserve backward compatibility with 3rd parties, plugins still use arrays
153 $formatter = $this->container->formatterFactory->getFormatter('raw');
154 $data = $formatter->format($bookmark);
155 $this->executePageHooks('save_link', $data);
156
157 $bookmark->fromArray($data);
158 $this->container->bookmarkService->set($bookmark);
159
160 // If we are called from the bookmarklet, we must close the popup:
161 if ($request->getParam('source') === 'bookmarklet') {
162 return $response->write('<script>self.close();</script>');
163 }
164
165 if (!empty($request->getParam('returnurl'))) {
166 $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl'));
167 }
168
169 return $this->redirectFromReferer(
170 $request,
171 $response,
172 ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'],
173 $bookmark->getShortUrl()
174 );
175 }
176
177 /**
178 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
179 */
180 public function deleteBookmark(Request $request, Response $response): Response
181 {
182 $this->checkToken($request);
183
184 $ids = escape(trim($request->getParam('id') ?? ''));
185 if (empty($ids) || strpos($ids, ' ') !== false) {
186 // multiple, space-separated ids provided
187 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
188 } else {
189 $ids = [$ids];
190 }
191
192 // assert at least one id is given
193 if (0 === count($ids)) {
194 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
195
196 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
197 }
198
199 $formatter = $this->container->formatterFactory->getFormatter('raw');
200 $count = 0;
201 foreach ($ids as $id) {
202 try {
203 $bookmark = $this->container->bookmarkService->get((int) $id);
204 } catch (BookmarkNotFoundException $e) {
205 $this->saveErrorMessage(sprintf(
206 t('Bookmark with identifier %s could not be found.'),
207 $id
208 ));
209
210 continue;
211 }
212
213 $data = $formatter->format($bookmark);
214 $this->executePageHooks('delete_link', $data);
215 $this->container->bookmarkService->remove($bookmark, false);
216 ++ $count;
217 }
218
219 if ($count > 0) {
220 $this->container->bookmarkService->save();
221 }
222
223 // If we are called from the bookmarklet, we must close the popup:
224 if ($request->getParam('source') === 'bookmarklet') {
225 return $response->write('<script>self.close();</script>');
226 }
227
228 // Don't redirect to where we were previously because the datastore has changed.
229 return $this->redirect($response, '/');
230 }
231
232 /**
233 * GET /admin/shaare/visibility
234 *
235 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
236 */
237 public function changeVisibility(Request $request, Response $response): Response
238 {
239 $this->checkToken($request);
240
241 $ids = trim(escape($request->getParam('id') ?? ''));
242 if (empty($ids) || strpos($ids, ' ') !== false) {
243 // multiple, space-separated ids provided
244 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
245 } else {
246 // only a single id provided
247 $ids = [$ids];
248 }
249
250 // assert at least one id is given
251 if (0 === count($ids)) {
252 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
253
254 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
255 }
256
257 // assert that the visibility is valid
258 $visibility = $request->getParam('newVisibility');
259 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
260 $this->saveErrorMessage(t('Invalid visibility provided.'));
261
262 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
263 } else {
264 $isPrivate = $visibility === 'private';
265 }
266
267 $formatter = $this->container->formatterFactory->getFormatter('raw');
268 $count = 0;
269
270 foreach ($ids as $id) {
271 try {
272 $bookmark = $this->container->bookmarkService->get((int) $id);
273 } catch (BookmarkNotFoundException $e) {
274 $this->saveErrorMessage(sprintf(
275 t('Bookmark with identifier %s could not be found.'),
276 $id
277 ));
278
279 continue;
280 }
281
282 $bookmark->setPrivate($isPrivate);
283
284 // To preserve backward compatibility with 3rd parties, plugins still use arrays
285 $data = $formatter->format($bookmark);
286 $this->executePageHooks('save_link', $data);
287 $bookmark->fromArray($data);
288
289 $this->container->bookmarkService->set($bookmark, false);
290 ++$count;
291 }
292
293 if ($count > 0) {
294 $this->container->bookmarkService->save();
295 }
296
297 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
298 }
299
300 /**
301 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
302 */
303 public function pinBookmark(Request $request, Response $response, array $args): Response
304 {
305 $this->checkToken($request);
306
307 $id = $args['id'] ?? '';
308 try {
309 if (false === ctype_digit($id)) {
310 throw new BookmarkNotFoundException();
311 }
312 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
313 } catch (BookmarkNotFoundException $e) {
314 $this->saveErrorMessage(sprintf(
315 t('Bookmark with identifier %s could not be found.'),
316 $id
317 ));
318
319 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
320 }
321
322 $formatter = $this->container->formatterFactory->getFormatter('raw');
323
324 $bookmark->setSticky(!$bookmark->isSticky());
325
326 // To preserve backward compatibility with 3rd parties, plugins still use arrays
327 $data = $formatter->format($bookmark);
328 $this->executePageHooks('save_link', $data);
329 $bookmark->fromArray($data);
330
331 $this->container->bookmarkService->set($bookmark);
332
333 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
334 }
335
336 /**
337 * Helper function used to display the shaare form whether it's a new or existing bookmark.
338 *
339 * @param array $link data used in template, either from parameters or from the data store
340 */
341 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
342 {
343 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
344 if ($this->container->conf->get('formatter') === 'markdown') {
345 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
346 }
347
348 $data = escape([
349 'link' => $link,
350 'link_is_new' => $isNew,
351 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
352 'source' => $request->getParam('source') ?? '',
353 'tags' => $tags,
354 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
355 ]);
356
357 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
358
359 foreach ($data as $key => $value) {
360 $this->assignView($key, $value);
361 }
362
363 $editLabel = false === $isNew ? t('Edit') .' ' : '';
364 $this->assignView(
365 'pagetitle',
366 $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
367 );
368
369 return $response->write($this->render(TemplatePage::EDIT_LINK));
370 }
371}
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php
index 2065c3e2..8675a0c5 100644
--- a/application/front/controller/admin/ManageTagController.php
+++ b/application/front/controller/admin/ManageTagController.php
@@ -24,9 +24,15 @@ class ManageTagController extends ShaarliAdminController
24 $fromTag = $request->getParam('fromtag') ?? ''; 24 $fromTag = $request->getParam('fromtag') ?? '';
25 25
26 $this->assignView('fromtag', escape($fromTag)); 26 $this->assignView('fromtag', escape($fromTag));
27 $separator = escape($this->container->conf->get('general.tags_separator', ' '));
28 if ($separator === ' ') {
29 $separator = '&nbsp;';
30 $this->assignView('tags_separator_desc', t('whitespace'));
31 }
32 $this->assignView('tags_separator', $separator);
27 $this->assignView( 33 $this->assignView(
28 'pagetitle', 34 'pagetitle',
29 t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') 35 t('Manage tags') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
30 ); 36 );
31 37
32 return $response->write($this->render(TemplatePage::CHANGE_TAG)); 38 return $response->write($this->render(TemplatePage::CHANGE_TAG));
@@ -81,8 +87,35 @@ class ManageTagController extends ShaarliAdminController
81 87
82 $this->saveSuccessMessage($alert); 88 $this->saveSuccessMessage($alert);
83 89
84 $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag); 90 $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags=' . urlencode($toTag);
85 91
86 return $this->redirect($response, $redirect); 92 return $this->redirect($response, $redirect);
87 } 93 }
94
95 /**
96 * POST /admin/tags/change-separator - Change tag separator
97 */
98 public function changeSeparator(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 $reservedCharacters = ['-', '.', '*'];
103 $newSeparator = $request->getParam('separator');
104 if ($newSeparator === null || mb_strlen($newSeparator) !== 1) {
105 $this->saveErrorMessage(t('Tags separator must be a single character.'));
106 } elseif (in_array($newSeparator, $reservedCharacters, true)) {
107 $reservedCharacters = implode(' ', array_map(function (string $character) {
108 return '<code>' . $character . '</code>';
109 }, $reservedCharacters));
110 $this->saveErrorMessage(
111 t('These characters are reserved and can\'t be used as tags separator: ') . $reservedCharacters
112 );
113 } else {
114 $this->container->conf->set('general.tags_separator', $newSeparator, true, true);
115
116 $this->saveSuccessMessage('Your tags separator setting has been updated!');
117 }
118
119 return $this->redirect($response, '/admin/tags');
120 }
88} 121}
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php
new file mode 100644
index 00000000..ff845944
--- /dev/null
+++ b/application/front/controller/admin/MetadataController.php
@@ -0,0 +1,29 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Controller used to retrieve/update bookmark's metadata.
12 */
13class MetadataController extends ShaarliAdminController
14{
15 /**
16 * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
17 */
18 public function ajaxRetrieveTitle(Request $request, Response $response): Response
19 {
20 $url = $request->getParam('url');
21
22 // Only try to extract metadata from URL with HTTP(s) scheme
23 if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
24 return $response->withJson($this->container->metadataRetriever->retrieve($url));
25 }
26
27 return $response->withJson([]);
28 }
29}
diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php
index 5ec0d24b..4aaf1f82 100644
--- a/application/front/controller/admin/PasswordController.php
+++ b/application/front/controller/admin/PasswordController.php
@@ -25,7 +25,7 @@ class PasswordController extends ShaarliAdminController
25 25
26 $this->assignView( 26 $this->assignView(
27 'pagetitle', 27 'pagetitle',
28 t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli') 28 t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
29 ); 29 );
30 } 30 }
31 31
@@ -78,7 +78,7 @@ class PasswordController extends ShaarliAdminController
78 78
79 // Save new password 79 // Save new password
80 // Salt renders rainbow-tables attacks useless. 80 // Salt renders rainbow-tables attacks useless.
81 $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); 81 $this->container->conf->set('credentials.salt', sha1(uniqid('', true) . '_' . mt_rand()));
82 $this->container->conf->set( 82 $this->container->conf->set(
83 'credentials.hash', 83 'credentials.hash',
84 sha1( 84 sha1(
diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php
index 8e059681..ae47c1af 100644
--- a/application/front/controller/admin/PluginsController.php
+++ b/application/front/controller/admin/PluginsController.php
@@ -42,7 +42,7 @@ class PluginsController extends ShaarliAdminController
42 $this->assignView('disabledPlugins', $disabledPlugins); 42 $this->assignView('disabledPlugins', $disabledPlugins);
43 $this->assignView( 43 $this->assignView(
44 'pagetitle', 44 'pagetitle',
45 t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') 45 t('Plugin Administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
46 ); 46 );
47 47
48 return $response->write($this->render(TemplatePage::PLUGINS_ADMIN)); 48 return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
@@ -64,7 +64,7 @@ class PluginsController extends ShaarliAdminController
64 unset($parameters['parameters_form']); 64 unset($parameters['parameters_form']);
65 unset($parameters['token']); 65 unset($parameters['token']);
66 foreach ($parameters as $param => $value) { 66 foreach ($parameters as $param => $value) {
67 $this->container->conf->set('plugins.'. $param, escape($value)); 67 $this->container->conf->set('plugins.' . $param, escape($value));
68 } 68 }
69 } else { 69 } else {
70 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters)); 70 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));
diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php
new file mode 100644
index 00000000..fabeaf2f
--- /dev/null
+++ b/application/front/controller/admin/ServerController.php
@@ -0,0 +1,96 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Helper\ApplicationUtils;
8use Shaarli\Helper\FileUtils;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Slim controller used to handle Server administration page, and actions.
14 */
15class ServerController extends ShaarliAdminController
16{
17 /** @var string Cache type - main - by default pagecache/ and tmp/ */
18 protected const CACHE_MAIN = 'main';
19
20 /** @var string Cache type - thumbnails - by default cache/ */
21 protected const CACHE_THUMB = 'thumbnails';
22
23 /**
24 * GET /admin/server - Display page Server administration
25 */
26 public function index(Request $request, Response $response): Response
27 {
28 $releaseUrl = ApplicationUtils::$GITHUB_URL . '/releases/';
29 if ($this->container->conf->get('updates.check_updates', true)) {
30 $latestVersion = 'v' . ApplicationUtils::getVersion(
31 ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE
32 );
33 $releaseUrl .= 'tag/' . $latestVersion;
34 } else {
35 $latestVersion = t('Check disabled');
36 }
37
38 $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php');
39 $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion;
40 $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
41
42 $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
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Formatter\BookmarkMarkdownFormatter;
8use Shaarli\Render\TemplatePage;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12class ShaareAddController extends ShaarliAdminController
13{
14 /**
15 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
16 */
17 public function addShaare(Request $request, Response $response): Response
18 {
19 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
20 if ($this->container->conf->get('formatter') === 'markdown') {
21 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
22 }
23
24 $this->assignView(
25 'pagetitle',
26 t('Shaare a new link') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
27 );
28 $this->assignView('tags', $tags);
29 $this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false));
30 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34}
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php
new file mode 100644
index 00000000..35837baa
--- /dev/null
+++ b/application/front/controller/admin/ShaareManageController.php
@@ -0,0 +1,202 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class PostBookmarkController
13 *
14 * Slim controller used to handle Shaarli create or edit bookmarks.
15 */
16class ShaareManageController extends ShaarliAdminController
17{
18 /**
19 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
20 */
21 public function deleteBookmark(Request $request, Response $response): Response
22 {
23 $this->checkToken($request);
24
25 $ids = escape(trim($request->getParam('id') ?? ''));
26 if (empty($ids) || strpos($ids, ' ') !== false) {
27 // multiple, space-separated ids provided
28 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
29 } else {
30 $ids = [$ids];
31 }
32
33 // assert at least one id is given
34 if (0 === count($ids)) {
35 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
36
37 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
38 }
39
40 $formatter = $this->container->formatterFactory->getFormatter('raw');
41 $count = 0;
42 foreach ($ids as $id) {
43 try {
44 $bookmark = $this->container->bookmarkService->get((int) $id);
45 } catch (BookmarkNotFoundException $e) {
46 $this->saveErrorMessage(sprintf(
47 t('Bookmark with identifier %s could not be found.'),
48 $id
49 ));
50
51 continue;
52 }
53
54 $data = $formatter->format($bookmark);
55 $this->executePageHooks('delete_link', $data);
56 $this->container->bookmarkService->remove($bookmark, false);
57 ++$count;
58 }
59
60 if ($count > 0) {
61 $this->container->bookmarkService->save();
62 }
63
64 // If we are called from the bookmarklet, we must close the popup:
65 if ($request->getParam('source') === 'bookmarklet') {
66 return $response->write('<script>self.close();</script>');
67 }
68
69 // Don't redirect to permalink after deletion.
70 return $this->redirectFromReferer($request, $response, ['shaare/']);
71 }
72
73 /**
74 * GET /admin/shaare/visibility
75 *
76 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
77 */
78 public function changeVisibility(Request $request, Response $response): Response
79 {
80 $this->checkToken($request);
81
82 $ids = trim(escape($request->getParam('id') ?? ''));
83 if (empty($ids) || strpos($ids, ' ') !== false) {
84 // multiple, space-separated ids provided
85 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
86 } else {
87 // only a single id provided
88 $ids = [$ids];
89 }
90
91 // assert at least one id is given
92 if (0 === count($ids)) {
93 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
94
95 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
96 }
97
98 // assert that the visibility is valid
99 $visibility = $request->getParam('newVisibility');
100 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
101 $this->saveErrorMessage(t('Invalid visibility provided.'));
102
103 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
104 } else {
105 $isPrivate = $visibility === 'private';
106 }
107
108 $formatter = $this->container->formatterFactory->getFormatter('raw');
109 $count = 0;
110
111 foreach ($ids as $id) {
112 try {
113 $bookmark = $this->container->bookmarkService->get((int) $id);
114 } catch (BookmarkNotFoundException $e) {
115 $this->saveErrorMessage(sprintf(
116 t('Bookmark with identifier %s could not be found.'),
117 $id
118 ));
119
120 continue;
121 }
122
123 $bookmark->setPrivate($isPrivate);
124
125 // To preserve backward compatibility with 3rd parties, plugins still use arrays
126 $data = $formatter->format($bookmark);
127 $this->executePageHooks('save_link', $data);
128 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
129
130 $this->container->bookmarkService->set($bookmark, false);
131 ++$count;
132 }
133
134 if ($count > 0) {
135 $this->container->bookmarkService->save();
136 }
137
138 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
139 }
140
141 /**
142 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
143 */
144 public function pinBookmark(Request $request, Response $response, array $args): Response
145 {
146 $this->checkToken($request);
147
148 $id = $args['id'] ?? '';
149 try {
150 if (false === ctype_digit($id)) {
151 throw new BookmarkNotFoundException();
152 }
153 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
154 } catch (BookmarkNotFoundException $e) {
155 $this->saveErrorMessage(sprintf(
156 t('Bookmark with identifier %s could not be found.'),
157 $id
158 ));
159
160 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
161 }
162
163 $formatter = $this->container->formatterFactory->getFormatter('raw');
164
165 $bookmark->setSticky(!$bookmark->isSticky());
166
167 // To preserve backward compatibility with 3rd parties, plugins still use arrays
168 $data = $formatter->format($bookmark);
169 $this->executePageHooks('save_link', $data);
170 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
171
172 $this->container->bookmarkService->set($bookmark);
173
174 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
175 }
176
177 /**
178 * GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL.
179 */
180 public function sharePrivate(Request $request, Response $response, array $args): Response
181 {
182 $this->checkToken($request);
183
184 $hash = $args['hash'] ?? '';
185 $bookmark = $this->container->bookmarkService->findByHash($hash);
186
187 if ($bookmark->isPrivate() !== true) {
188 return $this->redirect($response, '/shaare/' . $hash);
189 }
190
191 if (empty($bookmark->getAdditionalContentEntry('private_key'))) {
192 $privateKey = bin2hex(random_bytes(16));
193 $bookmark->addAdditionalContentEntry('private_key', $privateKey);
194 $this->container->bookmarkService->set($bookmark);
195 }
196
197 return $this->redirect(
198 $response,
199 '/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key')
200 );
201 }
202}
diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php
new file mode 100644
index 00000000..4cbfcdc5
--- /dev/null
+++ b/application/front/controller/admin/ShaarePublishController.php
@@ -0,0 +1,274 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkFormatter;
10use Shaarli\Formatter\BookmarkMarkdownFormatter;
11use Shaarli\Render\TemplatePage;
12use Shaarli\Thumbnailer;
13use Slim\Http\Request;
14use Slim\Http\Response;
15
16class ShaarePublishController extends ShaarliAdminController
17{
18 /**
19 * @var BookmarkFormatter[] Statically cached instances of formatters
20 */
21 protected $formatters = [];
22
23 /**
24 * @var array Statically cached bookmark's tags counts
25 */
26 protected $tags;
27
28 /**
29 * GET /admin/shaare - Displays the bookmark form for creation.
30 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
31 */
32 public function displayCreateForm(Request $request, Response $response): Response
33 {
34 $url = cleanup_url($request->getParam('post'));
35 $link = $this->buildLinkDataFromUrl($request, $url);
36
37 return $this->displayForm($link, $link['linkIsNew'], $request, $response);
38 }
39
40 /**
41 * POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page.
42 */
43 public function displayCreateBatchForms(Request $request, Response $response): Response
44 {
45 $urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls')));
46
47 $links = [];
48 foreach ($urls as $url) {
49 if (empty($url)) {
50 continue;
51 }
52 $link = $this->buildLinkDataFromUrl($request, $url);
53 $data = $this->buildFormData($link, $link['linkIsNew'], $request);
54 $data['token'] = $this->container->sessionManager->generateToken();
55 $data['source'] = 'batch';
56
57 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
58
59 $links[] = $data;
60 }
61
62 $this->assignView('links', $links);
63 $this->assignView('batch_mode', true);
64 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
65
66 return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH));
67 }
68
69 /**
70 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
71 */
72 public function displayEditForm(Request $request, Response $response, array $args): Response
73 {
74 $id = $args['id'] ?? '';
75 try {
76 if (false === ctype_digit($id)) {
77 throw new BookmarkNotFoundException();
78 }
79 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
80 } catch (BookmarkNotFoundException $e) {
81 $this->saveErrorMessage(sprintf(
82 t('Bookmark with identifier %s could not be found.'),
83 $id
84 ));
85
86 return $this->redirect($response, '/');
87 }
88
89 $formatter = $this->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91
92 return $this->displayForm($link, false, $request, $response);
93 }
94
95 /**
96 * POST /admin/shaare
97 */
98 public function save(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 // lf_id should only be present if the link exists.
103 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
104 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
105 // Edit
106 $bookmark = $this->container->bookmarkService->get($id);
107 } else {
108 // New link
109 $bookmark = new Bookmark();
110 }
111
112 $bookmark->setTitle($request->getParam('lf_title'));
113 $bookmark->setDescription($request->getParam('lf_description'));
114 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
115 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
116 $bookmark->setTagsString(
117 $request->getParam('lf_tags'),
118 $this->container->conf->get('general.tags_separator', ' ')
119 );
120
121 if (
122 $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
123 && true !== $this->container->conf->get('general.enable_async_metadata', true)
124 && $bookmark->shouldUpdateThumbnail()
125 ) {
126 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
127 }
128 $this->container->bookmarkService->addOrSet($bookmark, false);
129
130 // To preserve backward compatibility with 3rd parties, plugins still use arrays
131 $formatter = $this->getFormatter('raw');
132 $data = $formatter->format($bookmark);
133 $this->executePageHooks('save_link', $data);
134
135 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
136 $this->container->bookmarkService->set($bookmark);
137
138 // If we are called from the bookmarklet, we must close the popup:
139 if ($request->getParam('source') === 'bookmarklet') {
140 return $response->write('<script>self.close();</script>');
141 } elseif ($request->getParam('source') === 'batch') {
142 return $response;
143 }
144
145 if (!empty($request->getParam('returnurl'))) {
146 $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
147 }
148
149 return $this->redirectFromReferer(
150 $request,
151 $response,
152 ['/admin/add-shaare', '/admin/shaare'],
153 ['addlink', 'post', 'edit_link'],
154 $bookmark->getShortUrl()
155 );
156 }
157
158 /**
159 * Helper function used to display the shaare form whether it's a new or existing bookmark.
160 *
161 * @param array $link data used in template, either from parameters or from the data store
162 */
163 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
164 {
165 $data = $this->buildFormData($link, $isNew, $request);
166
167 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
168
169 foreach ($data as $key => $value) {
170 $this->assignView($key, $value);
171 }
172
173 $editLabel = false === $isNew ? t('Edit') . ' ' : '';
174 $this->assignView(
175 'pagetitle',
176 $editLabel . t('Shaare') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
177 );
178
179 return $response->write($this->render(TemplatePage::EDIT_LINK));
180 }
181
182 protected function buildLinkDataFromUrl(Request $request, string $url): array
183 {
184 // Check if URL is not already in database (in this case, we will edit the existing link)
185 $bookmark = $this->container->bookmarkService->findByUrl($url);
186 if (null === $bookmark) {
187 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
188 $title = $request->getParam('title');
189 $description = $request->getParam('description');
190 $tags = $request->getParam('tags');
191 if ($request->getParam('private') !== null) {
192 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
193 } else {
194 $private = $this->container->conf->get('privacy.default_private_links', false);
195 }
196
197 // If this is an HTTP(S) link, we try go get the page to extract
198 // the title (otherwise we will to straight to the edit form.)
199 if (
200 true !== $this->container->conf->get('general.enable_async_metadata', true)
201 && empty($title)
202 && strpos(get_url_scheme($url) ?: '', 'http') !== false
203 ) {
204 $metadata = $this->container->metadataRetriever->retrieve($url);
205 }
206
207 if (empty($url)) {
208 $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
209 }
210
211 return [
212 'title' => $title ?? $metadata['title'] ?? '',
213 'url' => $url ?? '',
214 'description' => $description ?? $metadata['description'] ?? '',
215 'tags' => $tags ?? $metadata['tags'] ?? '',
216 'private' => $private,
217 'linkIsNew' => true,
218 ];
219 }
220
221 $formatter = $this->getFormatter('raw');
222 $link = $formatter->format($bookmark);
223 $link['linkIsNew'] = false;
224
225 return $link;
226 }
227
228 protected function buildFormData(array $link, bool $isNew, Request $request): array
229 {
230 $link['tags'] = 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);
5namespace Shaarli\Front\Controller\Visitor; 5namespace Shaarli\Front\Controller\Visitor;
6 6
7use DateTime; 7use DateTime;
8use DateTimeImmutable;
9use Shaarli\Bookmark\Bookmark; 8use Shaarli\Bookmark\Bookmark;
9use Shaarli\Helper\DailyPageHelper;
10use Shaarli\Render\TemplatePage; 10use Shaarli\Render\TemplatePage;
11use Slim\Http\Request; 11use Slim\Http\Request;
12use Slim\Http\Response; 12use Slim\Http\Response;
@@ -26,32 +26,20 @@ class DailyController extends ShaarliVisitorController
26 */ 26 */
27 public function index(Request $request, Response $response): Response 27 public function index(Request $request, Response $response): Response
28 { 28 {
29 $day = $request->getQueryParam('day') ?? date('Ymd'); 29 $type = DailyPageHelper::extractRequestedType($request);
30 30 $format = DailyPageHelper::getFormatByType($type);
31 $availableDates = $this->container->bookmarkService->days(); 31 $latestBookmark = $this->container->bookmarkService->getLatest();
32 $nbAvailableDates = count($availableDates); 32 $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
33 $index = array_search($day, $availableDates); 33 $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
34 34 $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
35 if ($index === false) { 35 $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
36 // no bookmarks for day, but at least one day with bookmarks 36
37 $day = $availableDates[$nbAvailableDates - 1] ?? $day; 37 $linksToDisplay = $this->container->bookmarkService->findByDate(
38 $previousDay = $availableDates[$nbAvailableDates - 2] ?? ''; 38 $start,
39 } else { 39 $end,
40 $previousDay = $availableDates[$index - 1] ?? ''; 40 $previousDay,
41 $nextDay = $availableDates[$index + 1] ?? ''; 41 $nextDay
42 } 42 );
43
44 if ($day === date('Ymd')) {
45 $this->assignView('dayDesc', t('Today'));
46 } elseif ($day === date('Ymd', strtotime('-1 days'))) {
47 $this->assignView('dayDesc', t('Yesterday'));
48 }
49
50 try {
51 $linksToDisplay = $this->container->bookmarkService->filterDay($day);
52 } catch (\Exception $exc) {
53 $linksToDisplay = [];
54 }
55 43
56 $formatter = $this->container->formatterFactory->getFormatter(); 44 $formatter = $this->container->formatterFactory->getFormatter();
57 $formatter->addContextData('base_path', $this->container->basePath); 45 $formatter->addContextData('base_path', $this->container->basePath);
@@ -63,13 +51,15 @@ class DailyController extends ShaarliVisitorController
63 $linksToDisplay[$key]['description'] = $bookmark->getDescription(); 51 $linksToDisplay[$key]['description'] = $bookmark->getDescription();
64 } 52 }
65 53
66 $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
67 $data = [ 54 $data = [
68 'linksToDisplay' => $linksToDisplay, 55 'linksToDisplay' => $linksToDisplay,
69 'day' => $dayDate->getTimestamp(), 56 'dayDate' => $start,
70 'dayDate' => $dayDate, 57 'day' => $start->getTimestamp(),
71 'previousday' => $previousDay ?? '', 58 'previousday' => $previousDay ? $previousDay->format($format) : '',
72 'nextday' => $nextDay ?? '', 59 'nextday' => $nextDay ? $nextDay->format($format) : '',
60 'dayDesc' => $dailyDesc,
61 'type' => $type,
62 'localizedType' => $this->translateType($type),
73 ]; 63 ];
74 64
75 // Hooks are called before column construction so that plugins don't have to deal with columns. 65 // Hooks are called before column construction so that plugins don't have to deal with columns.
@@ -82,7 +72,7 @@ class DailyController extends ShaarliVisitorController
82 $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); 72 $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
83 $this->assignView( 73 $this->assignView(
84 'pagetitle', 74 'pagetitle',
85 t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle 75 $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
86 ); 76 );
87 77
88 return $response->write($this->render(TemplatePage::DAILY)); 78 return $response->write($this->render(TemplatePage::DAILY));
@@ -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
5namespace Shaarli\Front\Controller\Visitor; 5namespace Shaarli\Front\Controller\Visitor;
6 6
7use Shaarli\ApplicationUtils;
8use Shaarli\Container\ShaarliContainer; 7use Shaarli\Container\ShaarliContainer;
9use Shaarli\Front\Exception\AlreadyInstalledException; 8use Shaarli\Front\Exception\AlreadyInstalledException;
10use Shaarli\Front\Exception\ResourcePermissionException; 9use Shaarli\Front\Exception\ResourcePermissionException;
10use Shaarli\Helper\ApplicationUtils;
11use Shaarli\Languages; 11use Shaarli\Languages;
12use Shaarli\Security\SessionManager; 12use Shaarli\Security\SessionManager;
13use Slim\Http\Request; 13use Slim\Http\Request;
@@ -39,7 +39,8 @@ class InstallController extends ShaarliVisitorController
39 // Before installation, we'll make sure that permissions are set properly, and sessions are working. 39 // Before installation, we'll make sure that permissions are set properly, and sessions are working.
40 $this->checkPermissions(); 40 $this->checkPermissions();
41 41
42 if (static::SESSION_TEST_VALUE 42 if (
43 static::SESSION_TEST_VALUE
43 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) 44 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
44 ) { 45 ) {
45 $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE); 46 $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
@@ -53,6 +54,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
2namespace Shaarli; 2
3namespace Shaarli\Helper;
3 4
4use Exception; 5use Exception;
5use Shaarli\Config\ConfigManager; 6use 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
3declare(strict_types=1);
4
5namespace Shaarli\Helper;
6
7use Shaarli\Bookmark\Bookmark;
8use Slim\Http\Request;
9
10class 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
3namespace Shaarli; 3namespace Shaarli\Helper;
4 4
5use Shaarli\Exceptions\IOException; 5use Shaarli\Exceptions\IOException;
6 6
@@ -81,4 +81,60 @@ class FileUtils
81 ) 81 )
82 ); 82 );
83 } 83 }
84
85 /**
86 * Recursively deletes a folder content, and deletes itself optionally.
87 * If an excluded file is found, folders won't be deleted.
88 *
89 * Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory.
90 *
91 * @param string $path
92 * @param bool $selfDelete Delete the provided folder if true, only its content if false.
93 * @param array $exclude
94 */
95 public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool
96 {
97 $skipped = false;
98
99 if (!is_dir($path)) {
100 throw new IOException(t('Provided path is not a directory.'));
101 }
102
103 if (!static::isPathInShaarliFolder($path)) {
104 throw new IOException(t('Trying to delete a folder outside of Shaarli path.'));
105 }
106
107 foreach (new \DirectoryIterator($path) as $file) {
108 if ($file->isDot()) {
109 continue;
110 }
111
112 if (in_array($file->getBasename(), $exclude, true)) {
113 $skipped = true;
114 continue;
115 }
116
117 if ($file->isFile()) {
118 unlink($file->getPathname());
119 } elseif ($file->isDir()) {
120 $skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped;
121 }
122 }
123
124 if ($selfDelete && !$skipped) {
125 rmdir($path);
126 }
127
128 return $skipped;
129 }
130
131 /**
132 * Checks that the given path is inside Shaarli directory.
133 */
134 public static function isPathInShaarliFolder(string $path): bool
135 {
136 $rootDirectory = dirname(dirname(dirname(__FILE__)));
137
138 return strpos(realpath($path), $rootDirectory) !== false;
139 }
84} 140}
diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php
index 81d9e076..e80e0c01 100644
--- a/application/http/HttpAccess.php
+++ b/application/http/HttpAccess.php
@@ -14,9 +14,14 @@ namespace Shaarli\Http;
14 */ 14 */
15class HttpAccess 15class HttpAccess
16{ 16{
17 public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) 17 public function getHttpResponse(
18 { 18 $url,
19 return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction); 19 $timeout = 30,
20 $maxBytes = 4194304,
21 $curlHeaderFunction = null,
22 $curlWriteFunction = null
23 ) {
24 return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction);
20 } 25 }
21 26
22 public function getCurlDownloadCallback( 27 public function getCurlDownloadCallback(
@@ -25,7 +30,7 @@ class HttpAccess
25 &$description, 30 &$description,
26 &$keywords, 31 &$keywords,
27 $retrieveDescription, 32 $retrieveDescription,
28 $curlGetInfo = 'curl_getinfo' 33 $tagsSeparator
29 ) { 34 ) {
30 return get_curl_download_callback( 35 return get_curl_download_callback(
31 $charset, 36 $charset,
@@ -33,7 +38,12 @@ class HttpAccess
33 $description, 38 $description,
34 $keywords, 39 $keywords,
35 $retrieveDescription, 40 $retrieveDescription,
36 $curlGetInfo 41 $tagsSeparator
37 ); 42 );
38 } 43 }
44
45 public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
46 {
47 return get_curl_header_callback($charset, $curlGetInfo);
48 }
39} 49}
diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php
index 9f414073..4bde1d5b 100644
--- a/application/http/HttpUtils.php
+++ b/application/http/HttpUtils.php
@@ -6,12 +6,14 @@ use Shaarli\Http\Url;
6 * GET an HTTP URL to retrieve its content 6 * GET an HTTP URL to retrieve its content
7 * Uses the cURL library or a fallback method 7 * Uses the cURL library or a fallback method
8 * 8 *
9 * @param string $url URL to get (http://...) 9 * @param string $url URL to get (http://...)
10 * @param int $timeout network timeout (in seconds) 10 * @param int $timeout network timeout (in seconds)
11 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) 11 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
12 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION). 12 * @param callable|string $curlHeaderFunction Optional callback called during the download of headers
13 * Can be used to add download conditions on the 13 * (CURLOPT_HEADERFUNCTION)
14 * headers (response code, content type, etc.). 14 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
15 * Can be used to add download conditions on the
16 * headers (response code, content type, etc.).
15 * 17 *
16 * @return array HTTP response headers, downloaded content 18 * @return array HTTP response headers, downloaded content
17 * 19 *
@@ -35,13 +37,18 @@ use Shaarli\Http\Url;
35 * @see http://stackoverflow.com/q/9183178 37 * @see http://stackoverflow.com/q/9183178
36 * @see http://stackoverflow.com/q/1462720 38 * @see http://stackoverflow.com/q/1462720
37 */ 39 */
38function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) 40function get_http_response(
39{ 41 $url,
42 $timeout = 30,
43 $maxBytes = 4194304,
44 $curlHeaderFunction = null,
45 $curlWriteFunction = null
46) {
40 $urlObj = new Url($url); 47 $urlObj = new Url($url);
41 $cleanUrl = $urlObj->idnToAscii(); 48 $cleanUrl = $urlObj->idnToAscii();
42 49
43 if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) { 50 if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
44 return array(array(0 => 'Invalid HTTP UrlUtils'), false); 51 return [[0 => 'Invalid HTTP UrlUtils'], false];
45 } 52 }
46 53
47 $userAgent = 54 $userAgent =
@@ -64,42 +71,39 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
64 71
65 $ch = curl_init($cleanUrl); 72 $ch = curl_init($cleanUrl);
66 if ($ch === false) { 73 if ($ch === false) {
67 return array(array(0 => 'curl_init() error'), false); 74 return [[0 => 'curl_init() error'], false];
68 } 75 }
69 76
70 // General cURL settings 77 // General cURL settings
71 curl_setopt($ch, CURLOPT_AUTOREFERER, true); 78 curl_setopt($ch, CURLOPT_AUTOREFERER, true);
72 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 79 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
73 curl_setopt($ch, CURLOPT_HEADER, true); 80 // Default header download if the $curlHeaderFunction is not defined
81 curl_setopt($ch, CURLOPT_HEADER, !is_callable($curlHeaderFunction));
74 curl_setopt( 82 curl_setopt(
75 $ch, 83 $ch,
76 CURLOPT_HTTPHEADER, 84 CURLOPT_HTTPHEADER,
77 array('Accept-Language: ' . $acceptLanguage) 85 ['Accept-Language: ' . $acceptLanguage]
78 ); 86 );
79 curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs); 87 curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
80 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 88 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
81 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); 89 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
82 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); 90 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
83 91
92 // Max download size management
93 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024 * 16);
94 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
95 if (is_callable($curlHeaderFunction)) {
96 curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction);
97 }
84 if (is_callable($curlWriteFunction)) { 98 if (is_callable($curlWriteFunction)) {
85 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); 99 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
86 } 100 }
87
88 // Max download size management
89 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
90 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
91 curl_setopt( 101 curl_setopt(
92 $ch, 102 $ch,
93 CURLOPT_PROGRESSFUNCTION, 103 CURLOPT_PROGRESSFUNCTION,
94 function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { 104 function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
95 if (version_compare(phpversion(), '5.5', '<')) { 105 $downloaded = $arg2;
96 // PHP version lower than 5.5 106
97 // Callback has 4 arguments
98 $downloaded = $arg1;
99 } else {
100 // Callback has 5 arguments
101 $downloaded = $arg2;
102 }
103 // Non-zero return stops downloading 107 // Non-zero return stops downloading
104 return ($downloaded > $maxBytes) ? 1 : 0; 108 return ($downloaded > $maxBytes) ? 1 : 0;
105 } 109 }
@@ -118,9 +122,9 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
118 * Removing this would require updating 122 * Removing this would require updating
119 * GetHttpUrlTest::testGetInvalidRemoteUrl() 123 * GetHttpUrlTest::testGetInvalidRemoteUrl()
120 */ 124 */
121 return array(false, false); 125 return [false, false];
122 } 126 }
123 return array(array(0 => 'curl_exec() error: ' . $errorStr), false); 127 return [[0 => 'curl_exec() error: ' . $errorStr], false];
124 } 128 }
125 129
126 // Formatting output like the fallback method 130 // Formatting output like the fallback method
@@ -131,7 +135,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
131 $rawHeadersLastRedir = end($rawHeadersArrayRedirs); 135 $rawHeadersLastRedir = end($rawHeadersArrayRedirs);
132 136
133 $content = substr($response, $headSize); 137 $content = substr($response, $headSize);
134 $headers = array(); 138 $headers = [];
135 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { 139 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
136 if (empty($line) || ctype_space($line)) { 140 if (empty($line) || ctype_space($line)) {
137 continue; 141 continue;
@@ -142,7 +146,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
142 $value = $splitLine[1]; 146 $value = $splitLine[1];
143 if (array_key_exists($key, $headers)) { 147 if (array_key_exists($key, $headers)) {
144 if (!is_array($headers[$key])) { 148 if (!is_array($headers[$key])) {
145 $headers[$key] = array(0 => $headers[$key]); 149 $headers[$key] = [0 => $headers[$key]];
146 } 150 }
147 $headers[$key][] = $value; 151 $headers[$key][] = $value;
148 } else { 152 } else {
@@ -153,7 +157,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
153 } 157 }
154 } 158 }
155 159
156 return array($headers, $content); 160 return [$headers, $content];
157} 161}
158 162
159/** 163/**
@@ -184,15 +188,15 @@ function get_http_response_fallback(
184 $acceptLanguage, 188 $acceptLanguage,
185 $maxRedr 189 $maxRedr
186) { 190) {
187 $options = array( 191 $options = [
188 'http' => array( 192 'http' => [
189 'method' => 'GET', 193 'method' => 'GET',
190 'timeout' => $timeout, 194 'timeout' => $timeout,
191 'user_agent' => $userAgent, 195 'user_agent' => $userAgent,
192 'header' => "Accept: */*\r\n" 196 'header' => "Accept: */*\r\n"
193 . 'Accept-Language: ' . $acceptLanguage 197 . 'Accept-Language: ' . $acceptLanguage
194 ) 198 ]
195 ); 199 ];
196 200
197 stream_context_set_default($options); 201 stream_context_set_default($options);
198 list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr); 202 list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
@@ -203,7 +207,7 @@ function get_http_response_fallback(
203 } 207 }
204 208
205 if (! $headers) { 209 if (! $headers) {
206 return array($headers, false); 210 return [$headers, false];
207 } 211 }
208 212
209 try { 213 try {
@@ -211,10 +215,10 @@ function get_http_response_fallback(
211 $context = stream_context_create($options); 215 $context = stream_context_create($options);
212 $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes); 216 $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes);
213 } catch (Exception $exc) { 217 } catch (Exception $exc) {
214 return array(array(0 => 'HTTP Error'), $exc->getMessage()); 218 return [[0 => 'HTTP Error'], $exc->getMessage()];
215 } 219 }
216 220
217 return array($headers, $content); 221 return [$headers, $content];
218} 222}
219 223
220/** 224/**
@@ -233,10 +237,12 @@ function get_redirected_headers($url, $redirectionLimit = 3)
233 } 237 }
234 238
235 // Headers found, redirection found, and limit not reached. 239 // Headers found, redirection found, and limit not reached.
236 if ($redirectionLimit-- > 0 240 if (
241 $redirectionLimit-- > 0
237 && !empty($headers) 242 && !empty($headers)
238 && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false) 243 && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false)
239 && !empty($headers['Location'])) { 244 && !empty($headers['Location'])
245 ) {
240 $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location']; 246 $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location'];
241 if ($redirection != $url) { 247 if ($redirection != $url) {
242 $redirection = getAbsoluteUrl($url, $redirection); 248 $redirection = getAbsoluteUrl($url, $redirection);
@@ -244,7 +250,7 @@ function get_redirected_headers($url, $redirectionLimit = 3)
244 } 250 }
245 } 251 }
246 252
247 return array($headers, $url); 253 return [$headers, $url];
248} 254}
249 255
250/** 256/**
@@ -266,7 +272,7 @@ function getAbsoluteUrl($originalUrl, $newUrl)
266 } 272 }
267 273
268 $parts = parse_url($originalUrl); 274 $parts = parse_url($originalUrl);
269 $final = $parts['scheme'] .'://'. $parts['host']; 275 $final = $parts['scheme'] . '://' . $parts['host'];
270 $final .= (!empty($parts['port'])) ? $parts['port'] : ''; 276 $final .= (!empty($parts['port'])) ? $parts['port'] : '';
271 $final .= '/'; 277 $final .= '/';
272 if ($newUrl[0] != '/') { 278 if ($newUrl[0] != '/') {
@@ -319,7 +325,8 @@ function server_url($server)
319 $scheme = 'https'; 325 $scheme = 'https';
320 } 326 }
321 327
322 if (($scheme == 'http' && $port != '80') 328 if (
329 ($scheme == 'http' && $port != '80')
323 || ($scheme == 'https' && $port != '443') 330 || ($scheme == 'https' && $port != '443')
324 ) { 331 ) {
325 $port = ':' . $port; 332 $port = ':' . $port;
@@ -340,22 +347,26 @@ function server_url($server)
340 $host = $server['SERVER_NAME']; 347 $host = $server['SERVER_NAME'];
341 } 348 }
342 349
343 return $scheme.'://'.$host.$port; 350 return $scheme . '://' . $host . $port;
344 } 351 }
345 352
346 // SSL detection 353 // SSL detection
347 if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on') 354 if (
348 || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) { 355 (! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
356 || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')
357 ) {
349 $scheme = 'https'; 358 $scheme = 'https';
350 } 359 }
351 360
352 // Do not append standard port values 361 // Do not append standard port values
353 if (($scheme == 'http' && $server['SERVER_PORT'] != '80') 362 if (
354 || ($scheme == 'https' && $server['SERVER_PORT'] != '443')) { 363 ($scheme == 'http' && $server['SERVER_PORT'] != '80')
355 $port = ':'.$server['SERVER_PORT']; 364 || ($scheme == 'https' && $server['SERVER_PORT'] != '443')
365 ) {
366 $port = ':' . $server['SERVER_PORT'];
356 } 367 }
357 368
358 return $scheme.'://'.$server['SERVER_NAME'].$port; 369 return $scheme . '://' . $server['SERVER_NAME'] . $port;
359} 370}
360 371
361/** 372/**
@@ -493,6 +504,46 @@ function is_https($server)
493 * Get cURL callback function for CURLOPT_WRITEFUNCTION 504 * Get cURL callback function for CURLOPT_WRITEFUNCTION
494 * 505 *
495 * @param string $charset to extract from the downloaded page (reference) 506 * @param string $charset to extract from the downloaded page (reference)
507 * @param string $curlGetInfo Optionally overrides curl_getinfo function
508 *
509 * @return Closure
510 */
511function get_curl_header_callback(
512 &$charset,
513 $curlGetInfo = 'curl_getinfo'
514) {
515 $isRedirected = false;
516
517 return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) {
518 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
519 $chunkLength = strlen($data);
520 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
521 $isRedirected = true;
522 return $chunkLength;
523 }
524 if (!empty($responseCode) && $responseCode !== 200) {
525 return false;
526 }
527 // After a redirection, the content type will keep the previous request value
528 // until it finds the next content-type header.
529 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
530 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
531 }
532 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
533 return false;
534 }
535 if (!empty($contentType) && empty($charset)) {
536 $charset = header_extract_charset($contentType);
537 }
538
539 return $chunkLength;
540 };
541}
542
543/**
544 * Get cURL callback function for CURLOPT_WRITEFUNCTION
545 *
546 * @param string $charset to extract from the downloaded page (reference)
496 * @param string $title to extract from the downloaded page (reference) 547 * @param string $title to extract from the downloaded page (reference)
497 * @param string $description to extract from the downloaded page (reference) 548 * @param string $description to extract from the downloaded page (reference)
498 * @param string $keywords to extract from the downloaded page (reference) 549 * @param string $keywords to extract from the downloaded page (reference)
@@ -507,9 +558,8 @@ function get_curl_download_callback(
507 &$description, 558 &$description,
508 &$keywords, 559 &$keywords,
509 $retrieveDescription, 560 $retrieveDescription,
510 $curlGetInfo = 'curl_getinfo' 561 $tagsSeparator
511) { 562) {
512 $isRedirected = false;
513 $currentChunk = 0; 563 $currentChunk = 0;
514 $foundChunk = null; 564 $foundChunk = null;
515 565
@@ -524,37 +574,22 @@ function get_curl_download_callback(
524 * 574 *
525 * @return int|bool length of $data or false if we need to stop the download 575 * @return int|bool length of $data or false if we need to stop the download
526 */ 576 */
527 return function (&$ch, $data) use ( 577 return function (
578 $ch,
579 $data
580 ) use (
528 $retrieveDescription, 581 $retrieveDescription,
529 $curlGetInfo, 582 $tagsSeparator,
530 &$charset, 583 &$charset,
531 &$title, 584 &$title,
532 &$description, 585 &$description,
533 &$keywords, 586 &$keywords,
534 &$isRedirected,
535 &$currentChunk, 587 &$currentChunk,
536 &$foundChunk 588 &$foundChunk
537 ) { 589 ) {
590 $chunkLength = strlen($data);
538 $currentChunk++; 591 $currentChunk++;
539 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); 592
540 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
541 $isRedirected = true;
542 return strlen($data);
543 }
544 if (!empty($responseCode) && $responseCode !== 200) {
545 return false;
546 }
547 // After a redirection, the content type will keep the previous request value
548 // until it finds the next content-type header.
549 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
550 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
551 }
552 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
553 return false;
554 }
555 if (!empty($contentType) && empty($charset)) {
556 $charset = header_extract_charset($contentType);
557 }
558 if (empty($charset)) { 593 if (empty($charset)) {
559 $charset = html_extract_charset($data); 594 $charset = html_extract_charset($data);
560 } 595 }
@@ -562,6 +597,10 @@ function get_curl_download_callback(
562 $title = html_extract_title($data); 597 $title = html_extract_title($data);
563 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk; 598 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
564 } 599 }
600 if (empty($title)) {
601 $title = html_extract_tag('title', $data);
602 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
603 }
565 if ($retrieveDescription && empty($description)) { 604 if ($retrieveDescription && empty($description)) {
566 $description = html_extract_tag('description', $data); 605 $description = html_extract_tag('description', $data);
567 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk; 606 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
@@ -571,10 +610,10 @@ function get_curl_download_callback(
571 if (! empty($keywords)) { 610 if (! empty($keywords)) {
572 $foundChunk = $currentChunk; 611 $foundChunk = $currentChunk;
573 // Keywords use the format tag1, tag2 multiple words, tag 612 // Keywords use the format tag1, tag2 multiple words, tag
574 // So we format them to match Shaarli's separator and glue multiple words with '-' 613 // So we split the result with `,`, then if a tag contains the separator we replace it by `-`.
575 $keywords = implode(' ', array_map(function($keyword) { 614 $keywords = tags_array2str(array_map(function (string $keyword) use ($tagsSeparator): string {
576 return implode('-', preg_split('/\s+/', trim($keyword))); 615 return tags_array2str(tags_str2array($keyword, $tagsSeparator), '-');
577 }, explode(',', $keywords))); 616 }, tags_str2array($keywords, ',')), $tagsSeparator);
578 } 617 }
579 } 618 }
580 619
@@ -582,7 +621,8 @@ function get_curl_download_callback(
582 // If we already found either the title, description or keywords, 621 // If we already found either the title, description or keywords,
583 // it's highly unlikely that we'll found the other metas further than 622 // it's highly unlikely that we'll found the other metas further than
584 // in the same chunk of data or the next one. So we also stop the download after that. 623 // in the same chunk of data or the next one. So we also stop the download after that.
585 if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null 624 if (
625 (!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
586 && (! $retrieveDescription 626 && (! $retrieveDescription
587 || $foundChunk < $currentChunk 627 || $foundChunk < $currentChunk
588 || (!empty($title) && !empty($description) && !empty($keywords)) 628 || (!empty($title) && !empty($description) && !empty($keywords))
@@ -591,6 +631,6 @@ function get_curl_download_callback(
591 return false; 631 return false;
592 } 632 }
593 633
594 return strlen($data); 634 return $chunkLength;
595 }; 635 };
596} 636}
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php
new file mode 100644
index 00000000..2e1401ec
--- /dev/null
+++ b/application/http/MetadataRetriever.php
@@ -0,0 +1,69 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Http;
6
7use Shaarli\Config\ConfigManager;
8
9/**
10 * HTTP Tool used to extract metadata from external URL (title, description, etc.).
11 */
12class MetadataRetriever
13{
14 /** @var ConfigManager */
15 protected $conf;
16
17 /** @var HttpAccess */
18 protected $httpAccess;
19
20 public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
21 {
22 $this->conf = $conf;
23 $this->httpAccess = $httpAccess;
24 }
25
26 /**
27 * Retrieve metadata for given URL.
28 *
29 * @return array [
30 * 'title' => <remote title>,
31 * 'description' => <remote description>,
32 * 'tags' => <remote keywords>,
33 * ]
34 */
35 public function retrieve(string $url): array
36 {
37 $charset = null;
38 $title = null;
39 $description = null;
40 $tags = null;
41
42 // Short timeout to keep the application responsive
43 // The callback will fill $charset and $title with data from the downloaded page.
44 $this->httpAccess->getHttpResponse(
45 $url,
46 $this->conf->get('general.download_timeout', 30),
47 $this->conf->get('general.download_max_size', 4194304),
48 $this->httpAccess->getCurlHeaderCallback($charset),
49 $this->httpAccess->getCurlDownloadCallback(
50 $charset,
51 $title,
52 $description,
53 $tags,
54 $this->conf->get('general.retrieve_description'),
55 $this->conf->get('general.tags_separator', ' ')
56 )
57 );
58
59 if (!empty($title) && strtolower($charset) !== 'utf-8') {
60 $title = mb_convert_encoding($title, 'utf-8', $charset);
61 }
62
63 return [
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 */
18class Url 18class Url
19{ 19{
20 private static $annoyingQueryParams = array( 20 private static $annoyingQueryParams = [
21 // Facebook 21 // Facebook
22 'action_object_map=', 22 'action_object_map=',
23 'action_ref_map=', 23 'action_ref_map=',
@@ -37,15 +37,15 @@ class Url
37 37
38 // Other 38 // Other
39 'campaign_' 39 'campaign_'
40 ); 40 ];
41 41
42 private static $annoyingFragments = array( 42 private static $annoyingFragments = [
43 // ATInternet 43 // ATInternet
44 'xtor=RSS-', 44 'xtor=RSS-',
45 45
46 // Misc. 46 // Misc.
47 'tk.rss_all' 47 'tk.rss_all'
48 ); 48 ];
49 49
50 /* 50 /*
51 * URL parts represented as an array 51 * URL parts represented as an array
@@ -120,7 +120,7 @@ class Url
120 foreach (self::$annoyingQueryParams as $annoying) { 120 foreach (self::$annoyingQueryParams as $annoying) {
121 foreach ($queryParams as $param) { 121 foreach ($queryParams as $param) {
122 if (startsWith($param, $annoying)) { 122 if (startsWith($param, $annoying)) {
123 $queryParams = array_diff($queryParams, array($param)); 123 $queryParams = array_diff($queryParams, [$param]);
124 continue; 124 continue;
125 } 125 }
126 } 126 }
diff --git a/application/http/UrlUtils.php b/application/http/UrlUtils.php
index e8d1a283..de5b7db1 100644
--- a/application/http/UrlUtils.php
+++ b/application/http/UrlUtils.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Converts an array-represented URL to a string 4 * Converts an array-represented URL to a string
4 * 5 *
@@ -12,15 +13,15 @@
12 */ 13 */
13function unparse_url($parsedUrl) 14function unparse_url($parsedUrl)
14{ 15{
15 $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'].'://' : ''; 16 $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : '';
16 $host = isset($parsedUrl['host']) ? $parsedUrl['host'] : ''; 17 $host = isset($parsedUrl['host']) ? $parsedUrl['host'] : '';
17 $port = isset($parsedUrl['port']) ? ':'.$parsedUrl['port'] : ''; 18 $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '';
18 $user = isset($parsedUrl['user']) ? $parsedUrl['user'] : ''; 19 $user = isset($parsedUrl['user']) ? $parsedUrl['user'] : '';
19 $pass = isset($parsedUrl['pass']) ? ':'.$parsedUrl['pass'] : ''; 20 $pass = isset($parsedUrl['pass']) ? ':' . $parsedUrl['pass'] : '';
20 $pass = ($user || $pass) ? "$pass@" : ''; 21 $pass = ($user || $pass) ? "$pass@" : '';
21 $path = isset($parsedUrl['path']) ? $parsedUrl['path'] : ''; 22 $path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '';
22 $query = isset($parsedUrl['query']) ? '?'.$parsedUrl['query'] : ''; 23 $query = isset($parsedUrl['query']) ? '?' . $parsedUrl['query'] : '';
23 $fragment = isset($parsedUrl['fragment']) ? '#'.$parsedUrl['fragment'] : ''; 24 $fragment = isset($parsedUrl['fragment']) ? '#' . $parsedUrl['fragment'] : '';
24 25
25 return "$scheme$user$pass$host$port$path$query$fragment"; 26 return "$scheme$user$pass$host$port$path$query$fragment";
26} 27}
diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php
index 826604e7..1fed418b 100644
--- a/application/legacy/LegacyController.php
+++ b/application/legacy/LegacyController.php
@@ -51,7 +51,7 @@ class LegacyController extends ShaarliVisitorController
51 51
52 if (!$this->container->loginManager->isLoggedIn()) { 52 if (!$this->container->loginManager->isLoggedIn()) {
53 $parameters = $buildParameters($request->getQueryParams(), true); 53 $parameters = $buildParameters($request->getQueryParams(), true);
54 return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters); 54 return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route . $parameters);
55 } 55 }
56 56
57 $parameters = $buildParameters($request->getQueryParams(), false); 57 $parameters = $buildParameters($request->getQueryParams(), false);
diff --git a/application/legacy/LegacyLinkDB.php b/application/legacy/LegacyLinkDB.php
index 7bf76fd4..d3beafe0 100644
--- a/application/legacy/LegacyLinkDB.php
+++ b/application/legacy/LegacyLinkDB.php
@@ -8,7 +8,7 @@ use DateTime;
8use Iterator; 8use Iterator;
9use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 9use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
10use Shaarli\Exceptions\IOException; 10use Shaarli\Exceptions\IOException;
11use Shaarli\FileUtils; 11use Shaarli\Helper\FileUtils;
12use Shaarli\Render\PageCacheManager; 12use Shaarli\Render\PageCacheManager;
13 13
14/** 14/**
@@ -62,7 +62,7 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess
62 private $datastore; 62 private $datastore;
63 63
64 // Link date storage format 64 // Link date storage format
65 const LINK_DATE_FORMAT = 'Ymd_His'; 65 public const LINK_DATE_FORMAT = 'Ymd_His';
66 66
67 // List of bookmarks (associative array) 67 // List of bookmarks (associative array)
68 // - key: link date (e.g. "20110823_124546"), 68 // - key: link date (e.g. "20110823_124546"),
@@ -240,8 +240,8 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess
240 } 240 }
241 241
242 // Create a dummy database for example 242 // Create a dummy database for example
243 $this->links = array(); 243 $this->links = [];
244 $link = array( 244 $link = [
245 'id' => 1, 245 'id' => 1,
246 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), 246 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
247 'url' => 'https://shaarli.readthedocs.io', 247 'url' => 'https://shaarli.readthedocs.io',
@@ -257,11 +257,11 @@ You use the community supported version of the original Shaarli project, by Seba
257 'created' => new DateTime(), 257 'created' => new DateTime(),
258 'tags' => 'opensource software', 258 'tags' => 'opensource software',
259 'sticky' => false, 259 'sticky' => false,
260 ); 260 ];
261 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 261 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
262 $this->links[1] = $link; 262 $this->links[1] = $link;
263 263
264 $link = array( 264 $link = [
265 'id' => 0, 265 'id' => 0,
266 'title' => t('My secret stuff... - Pastebin.com'), 266 'title' => t('My secret stuff... - Pastebin.com'),
267 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', 267 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
@@ -270,7 +270,7 @@ You use the community supported version of the original Shaarli project, by Seba
270 'created' => new DateTime('1 minute ago'), 270 'created' => new DateTime('1 minute ago'),
271 'tags' => 'secretstuff', 271 'tags' => 'secretstuff',
272 'sticky' => false, 272 'sticky' => false,
273 ); 273 ];
274 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 274 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
275 $this->links[0] = $link; 275 $this->links[0] = $link;
276 276
@@ -285,7 +285,7 @@ You use the community supported version of the original Shaarli project, by Seba
285 { 285 {
286 // Public bookmarks are hidden and user not logged in => nothing to show 286 // Public bookmarks are hidden and user not logged in => nothing to show
287 if ($this->hidePublicLinks && !$this->loggedIn) { 287 if ($this->hidePublicLinks && !$this->loggedIn) {
288 $this->links = array(); 288 $this->links = [];
289 return; 289 return;
290 } 290 }
291 291
@@ -293,7 +293,7 @@ You use the community supported version of the original Shaarli project, by Seba
293 $this->ids = []; 293 $this->ids = [];
294 $this->links = FileUtils::readFlatDB($this->datastore, []); 294 $this->links = FileUtils::readFlatDB($this->datastore, []);
295 295
296 $toremove = array(); 296 $toremove = [];
297 foreach ($this->links as $key => &$link) { 297 foreach ($this->links as $key => &$link) {
298 if (!$this->loggedIn && $link['private'] != 0) { 298 if (!$this->loggedIn && $link['private'] != 0) {
299 // Transition for not upgraded databases. 299 // Transition for not upgraded databases.
@@ -414,7 +414,7 @@ You use the community supported version of the original Shaarli project, by Seba
414 * @return array filtered bookmarks, all bookmarks if no suitable filter was provided. 414 * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
415 */ 415 */
416 public function filterSearch( 416 public function filterSearch(
417 $filterRequest = array(), 417 $filterRequest = [],
418 $casesensitive = false, 418 $casesensitive = false,
419 $visibility = 'all', 419 $visibility = 'all',
420 $untaggedonly = false 420 $untaggedonly = false
@@ -512,7 +512,7 @@ You use the community supported version of the original Shaarli project, by Seba
512 */ 512 */
513 public function days() 513 public function days()
514 { 514 {
515 $linkDays = array(); 515 $linkDays = [];
516 foreach ($this->links as $link) { 516 foreach ($this->links as $link) {
517 $linkDays[$link['created']->format('Ymd')] = 0; 517 $linkDays[$link['created']->format('Ymd')] = 0;
518 } 518 }
diff --git a/application/legacy/LegacyLinkFilter.php b/application/legacy/LegacyLinkFilter.php
index 7cf93d60..e6d186c4 100644
--- a/application/legacy/LegacyLinkFilter.php
+++ b/application/legacy/LegacyLinkFilter.php
@@ -120,7 +120,7 @@ class LegacyLinkFilter
120 return $this->links; 120 return $this->links;
121 } 121 }
122 122
123 $out = array(); 123 $out = [];
124 foreach ($this->links as $key => $value) { 124 foreach ($this->links as $key => $value) {
125 if ($value['private'] && $visibility === 'private') { 125 if ($value['private'] && $visibility === 'private') {
126 $out[$key] = $value; 126 $out[$key] = $value;
@@ -143,7 +143,7 @@ class LegacyLinkFilter
143 */ 143 */
144 private function filterSmallHash($smallHash) 144 private function filterSmallHash($smallHash)
145 { 145 {
146 $filtered = array(); 146 $filtered = [];
147 foreach ($this->links as $key => $l) { 147 foreach ($this->links as $key => $l) {
148 if ($smallHash == $l['shorturl']) { 148 if ($smallHash == $l['shorturl']) {
149 // Yes, this is ugly and slow 149 // Yes, this is ugly and slow
@@ -186,7 +186,7 @@ class LegacyLinkFilter
186 return $this->noFilter($visibility); 186 return $this->noFilter($visibility);
187 } 187 }
188 188
189 $filtered = array(); 189 $filtered = [];
190 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); 190 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
191 $exactRegex = '/"([^"]+)"/'; 191 $exactRegex = '/"([^"]+)"/';
192 // Retrieve exact search terms. 192 // Retrieve exact search terms.
@@ -198,8 +198,8 @@ class LegacyLinkFilter
198 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); 198 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
199 199
200 // Filter excluding terms and update andSearch. 200 // Filter excluding terms and update andSearch.
201 $excludeSearch = array(); 201 $excludeSearch = [];
202 $andSearch = array(); 202 $andSearch = [];
203 foreach ($explodedSearchAnd as $needle) { 203 foreach ($explodedSearchAnd as $needle) {
204 if ($needle[0] == '-' && strlen($needle) > 1) { 204 if ($needle[0] == '-' && strlen($needle) > 1) {
205 $excludeSearch[] = substr($needle, 1); 205 $excludeSearch[] = substr($needle, 1);
@@ -208,7 +208,7 @@ class LegacyLinkFilter
208 } 208 }
209 } 209 }
210 210
211 $keys = array('title', 'description', 'url', 'tags'); 211 $keys = ['title', 'description', 'url', 'tags'];
212 212
213 // Iterate over every stored link. 213 // Iterate over every stored link.
214 foreach ($this->links as $id => $link) { 214 foreach ($this->links as $id => $link) {
@@ -336,7 +336,7 @@ class LegacyLinkFilter
336 } 336 }
337 337
338 // create resulting array 338 // create resulting array
339 $filtered = array(); 339 $filtered = [];
340 340
341 // iterate over each link 341 // iterate over each link
342 foreach ($this->links as $key => $link) { 342 foreach ($this->links as $key => $link) {
@@ -352,7 +352,7 @@ class LegacyLinkFilter
352 $search = $link['tags']; // build search string, start with tags of current link 352 $search = $link['tags']; // build search string, start with tags of current link
353 if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { 353 if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) {
354 // description given and at least one possible tag found 354 // description given and at least one possible tag found
355 $descTags = array(); 355 $descTags = [];
356 // find all tags in the form of #tag in the description 356 // find all tags in the form of #tag in the description
357 preg_match_all( 357 preg_match_all(
358 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', 358 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -419,7 +419,7 @@ class LegacyLinkFilter
419 throw new Exception('Invalid date format'); 419 throw new Exception('Invalid date format');
420 } 420 }
421 421
422 $filtered = array(); 422 $filtered = [];
423 foreach ($this->links as $key => $l) { 423 foreach ($this->links as $key => $l) {
424 if ($l['created']->format('Ymd') == $day) { 424 if ($l['created']->format('Ymd') == $day) {
425 $filtered[$key] = $l; 425 $filtered[$key] = $l;
diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php
index 0ab3a55b..9bda54b8 100644
--- a/application/legacy/LegacyUpdater.php
+++ b/application/legacy/LegacyUpdater.php
@@ -7,7 +7,6 @@ use RainTPL;
7use ReflectionClass; 7use ReflectionClass;
8use ReflectionException; 8use ReflectionException;
9use ReflectionMethod; 9use ReflectionMethod;
10use Shaarli\ApplicationUtils;
11use Shaarli\Bookmark\Bookmark; 10use Shaarli\Bookmark\Bookmark;
12use Shaarli\Bookmark\BookmarkArray; 11use Shaarli\Bookmark\BookmarkArray;
13use Shaarli\Bookmark\BookmarkFilter; 12use Shaarli\Bookmark\BookmarkFilter;
@@ -17,6 +16,7 @@ use Shaarli\Config\ConfigJson;
17use Shaarli\Config\ConfigManager; 16use Shaarli\Config\ConfigManager;
18use Shaarli\Config\ConfigPhp; 17use Shaarli\Config\ConfigPhp;
19use Shaarli\Exceptions\IOException; 18use Shaarli\Exceptions\IOException;
19use Shaarli\Helper\ApplicationUtils;
20use Shaarli\Thumbnailer; 20use Shaarli\Thumbnailer;
21use Shaarli\Updater\Exception\UpdaterException; 21use Shaarli\Updater\Exception\UpdaterException;
22 22
@@ -93,7 +93,7 @@ class LegacyUpdater
93 */ 93 */
94 public function update() 94 public function update()
95 { 95 {
96 $updatesRan = array(); 96 $updatesRan = [];
97 97
98 // If the user isn't logged in, exit without updating. 98 // If the user isn't logged in, exit without updating.
99 if ($this->isLoggedIn !== true) { 99 if ($this->isLoggedIn !== true) {
@@ -106,7 +106,8 @@ class LegacyUpdater
106 106
107 foreach ($this->methods as $method) { 107 foreach ($this->methods as $method) {
108 // Not an update method or already done, pass. 108 // Not an update method or already done, pass.
109 if (!startsWith($method->getName(), 'updateMethod') 109 if (
110 !startsWith($method->getName(), 'updateMethod')
110 || in_array($method->getName(), $this->doneUpdates) 111 || in_array($method->getName(), $this->doneUpdates)
111 ) { 112 ) {
112 continue; 113 continue;
@@ -189,7 +190,7 @@ class LegacyUpdater
189 } 190 }
190 191
191 // Set sub config keys (config and plugins) 192 // Set sub config keys (config and plugins)
192 $subConfig = array('config', 'plugins'); 193 $subConfig = ['config', 'plugins'];
193 foreach ($subConfig as $sub) { 194 foreach ($subConfig as $sub) {
194 foreach ($oldConfig[$sub] as $key => $value) { 195 foreach ($oldConfig[$sub] as $key => $value) {
195 if (isset($legacyMap[$sub . '.' . $key])) { 196 if (isset($legacyMap[$sub . '.' . $key])) {
@@ -259,7 +260,7 @@ class LegacyUpdater
259 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php'; 260 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
260 copy($this->conf->get('resource.datastore'), $save); 261 copy($this->conf->get('resource.datastore'), $save);
261 262
262 $links = array(); 263 $links = [];
263 foreach ($this->linkDB as $offset => $value) { 264 foreach ($this->linkDB as $offset => $value) {
264 $links[] = $value; 265 $links[] = $value;
265 unset($this->linkDB[$offset]); 266 unset($this->linkDB[$offset]);
@@ -498,7 +499,8 @@ class LegacyUpdater
498 */ 499 */
499 public function updateMethodDownloadSizeAndTimeoutConf() 500 public function updateMethodDownloadSizeAndTimeoutConf()
500 { 501 {
501 if ($this->conf->exists('general.download_max_size') 502 if (
503 $this->conf->exists('general.download_max_size')
502 && $this->conf->exists('general.download_timeout') 504 && $this->conf->exists('general.download_timeout')
503 ) { 505 ) {
504 return true; 506 return true;
@@ -585,7 +587,7 @@ class LegacyUpdater
585 587
586 $linksArray = new BookmarkArray(); 588 $linksArray = new BookmarkArray();
587 foreach ($this->linkDB as $key => $link) { 589 foreach ($this->linkDB as $key => $link) {
588 $linksArray[$key] = (new Bookmark())->fromArray($link); 590 $linksArray[$key] = (new Bookmark())->fromArray($link, $this->conf->get('general.tags_separator', ' '));
589 } 591 }
590 $linksIo = new BookmarkIO($this->conf); 592 $linksIo = new BookmarkIO($this->conf);
591 $linksIo->write($linksArray); 593 $linksIo->write($linksArray);
diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php
index b83f16f8..2d97b4c8 100644
--- a/application/netscape/NetscapeBookmarkUtils.php
+++ b/application/netscape/NetscapeBookmarkUtils.php
@@ -59,11 +59,11 @@ class NetscapeBookmarkUtils
59 $indexUrl 59 $indexUrl
60 ) { 60 ) {
61 // see tpl/export.html for possible values 61 // see tpl/export.html for possible values
62 if (!in_array($selection, array('all', 'public', 'private'))) { 62 if (!in_array($selection, ['all', 'public', 'private'])) {
63 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); 63 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"');
64 } 64 }
65 65
66 $bookmarkLinks = array(); 66 $bookmarkLinks = [];
67 foreach ($this->bookmarkService->search([], $selection) as $bookmark) { 67 foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
68 $link = $formatter->format($bookmark); 68 $link = $formatter->format($bookmark);
69 $link['taglist'] = implode(',', $bookmark->getTags()); 69 $link['taglist'] = implode(',', $bookmark->getTags());
@@ -101,11 +101,11 @@ class NetscapeBookmarkUtils
101 101
102 // Add tags to all imported bookmarks? 102 // Add tags to all imported bookmarks?
103 if (empty($post['default_tags'])) { 103 if (empty($post['default_tags'])) {
104 $defaultTags = array(); 104 $defaultTags = [];
105 } else { 105 } else {
106 $defaultTags = preg_split( 106 $defaultTags = tags_str2array(
107 '/[\s,]+/', 107 escape($post['default_tags']),
108 escape($post['default_tags']) 108 $this->conf->get('general.tags_separator', ' ')
109 ); 109 );
110 } 110 }
111 111
@@ -171,7 +171,7 @@ class NetscapeBookmarkUtils
171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols')); 171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
172 $link->setDescription($bkm['note']); 172 $link->setDescription($bkm['note']);
173 $link->setPrivate($private); 173 $link->setPrivate($private);
174 $link->setTagsString($bkm['tags']); 174 $link->setTags($bkm['tags']);
175 175
176 $this->bookmarkService->addOrSet($link, false); 176 $this->bookmarkService->addOrSet($link, false);
177 $importCount++; 177 $importCount++;
diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php
index 1b2197c9..3ea55728 100644
--- a/application/plugin/PluginManager.php
+++ b/application/plugin/PluginManager.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Plugin; 3namespace Shaarli\Plugin;
3 4
4use Shaarli\Config\ConfigManager; 5use 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
2namespace Shaarli\Plugin\Exception; 3namespace Shaarli\Plugin\Exception;
3 4
4use Exception; 5use 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 @@
3namespace Shaarli\Render; 3namespace Shaarli\Render;
4 4
5use Exception; 5use Exception;
6use exceptions\MissingBasePathException; 6use Psr\Log\LoggerInterface;
7use RainTPL; 7use RainTPL;
8use Shaarli\ApplicationUtils;
9use Shaarli\Bookmark\BookmarkServiceInterface; 8use Shaarli\Bookmark\BookmarkServiceInterface;
10use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
10use Shaarli\Helper\ApplicationUtils;
11use Shaarli\Security\SessionManager; 11use Shaarli\Security\SessionManager;
12use Shaarli\Thumbnailer; 12use Shaarli\Thumbnailer;
13 13
@@ -35,6 +35,9 @@ class PageBuilder
35 */ 35 */
36 protected $session; 36 protected $session;
37 37
38 /** @var LoggerInterface */
39 protected $logger;
40
38 /** 41 /**
39 * @var BookmarkServiceInterface $bookmarkService instance. 42 * @var BookmarkServiceInterface $bookmarkService instance.
40 */ 43 */
@@ -54,17 +57,25 @@ class PageBuilder
54 * PageBuilder constructor. 57 * PageBuilder constructor.
55 * $tpl is initialized at false for lazy loading. 58 * $tpl is initialized at false for lazy loading.
56 * 59 *
57 * @param ConfigManager $conf Configuration Manager instance (reference). 60 * @param ConfigManager $conf Configuration Manager instance (reference).
58 * @param array $session $_SESSION array 61 * @param array $session $_SESSION array
59 * @param BookmarkServiceInterface $linkDB instance. 62 * @param LoggerInterface $logger
60 * @param string $token Session token 63 * @param null $linkDB instance.
61 * @param bool $isLoggedIn 64 * @param null $token Session token
65 * @param bool $isLoggedIn
62 */ 66 */
63 public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) 67 public function __construct(
64 { 68 ConfigManager &$conf,
69 array $session,
70 LoggerInterface $logger,
71 $linkDB = null,
72 $token = null,
73 $isLoggedIn = false
74 ) {
65 $this->tpl = false; 75 $this->tpl = false;
66 $this->conf = $conf; 76 $this->conf = $conf;
67 $this->session = $session; 77 $this->session = $session;
78 $this->logger = $logger;
68 $this->bookmarkService = $linkDB; 79 $this->bookmarkService = $linkDB;
69 $this->token = $token; 80 $this->token = $token;
70 $this->isLoggedIn = $isLoggedIn; 81 $this->isLoggedIn = $isLoggedIn;
@@ -98,7 +109,7 @@ class PageBuilder
98 $this->tpl->assign('newVersion', escape($version)); 109 $this->tpl->assign('newVersion', escape($version));
99 $this->tpl->assign('versionError', ''); 110 $this->tpl->assign('versionError', '');
100 } catch (Exception $exc) { 111 } catch (Exception $exc) {
101 logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); 112 $this->logger->error(format_log('Error: ' . $exc->getMessage(), client_ip_id($_SERVER)));
102 $this->tpl->assign('newVersion', ''); 113 $this->tpl->assign('newVersion', '');
103 $this->tpl->assign('versionError', escape($exc->getMessage())); 114 $this->tpl->assign('versionError', escape($exc->getMessage()));
104 } 115 }
@@ -149,7 +160,8 @@ class PageBuilder
149 160
150 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); 161 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
151 162
152 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE']); 163 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
164 $this->tpl->assign('tags_separator', $this->conf->get('general.tags_separator', ' '));
153 165
154 // To be removed with a proper theme configuration. 166 // To be removed with a proper theme configuration.
155 $this->tpl->assign('conf', $this->conf); 167 $this->tpl->assign('conf', $this->conf);
@@ -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
4namespace Shaarli\Security; 3namespace Shaarli\Security;
5 4
6use Shaarli\FileUtils; 5use Psr\Log\LoggerInterface;
6use Shaarli\Helper\FileUtils;
7 7
8/** 8/**
9 * Class BanManager 9 * Class BanManager
@@ -28,8 +28,8 @@ class BanManager
28 /** @var string Path to the file containing IP bans and failures */ 28 /** @var string Path to the file containing IP bans and failures */
29 protected $banFile; 29 protected $banFile;
30 30
31 /** @var string Path to the log file, used to log bans */ 31 /** @var LoggerInterface Path to the log file, used to log bans */
32 protected $logFile; 32 protected $logger;
33 33
34 /** @var array List of IP with their associated number of failed attempts */ 34 /** @var array List of IP with their associated number of failed attempts */
35 protected $failures = []; 35 protected $failures = [];
@@ -40,18 +40,20 @@ class BanManager
40 /** 40 /**
41 * BanManager constructor. 41 * BanManager constructor.
42 * 42 *
43 * @param array $trustedProxies List of allowed proxies IP 43 * @param array $trustedProxies List of allowed proxies IP
44 * @param int $nbAttempts Number of allowed failed attempt before the ban 44 * @param int $nbAttempts Number of allowed failed attempt before the ban
45 * @param int $banDuration Ban duration in seconds 45 * @param int $banDuration Ban duration in seconds
46 * @param string $banFile Path to the file containing IP bans and failures 46 * @param string $banFile Path to the file containing IP bans and failures
47 * @param string $logFile Path to the log file, used to log bans 47 * @param LoggerInterface $logger PSR-3 logger to save login attempts in log directory
48 */ 48 */
49 public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, $logFile) { 49 public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, LoggerInterface $logger)
50 {
50 $this->trustedProxies = $trustedProxies; 51 $this->trustedProxies = $trustedProxies;
51 $this->nbAttempts = $nbAttempts; 52 $this->nbAttempts = $nbAttempts;
52 $this->banDuration = $banDuration; 53 $this->banDuration = $banDuration;
53 $this->banFile = $banFile; 54 $this->banFile = $banFile;
54 $this->logFile = $logFile; 55 $this->logger = $logger;
56
55 $this->readBanFile(); 57 $this->readBanFile();
56 } 58 }
57 59
@@ -78,11 +80,7 @@ class BanManager
78 80
79 if ($this->failures[$ip] >= $this->nbAttempts) { 81 if ($this->failures[$ip] >= $this->nbAttempts) {
80 $this->bans[$ip] = time() + $this->banDuration; 82 $this->bans[$ip] = time() + $this->banDuration;
81 logm( 83 $this->logger->info(format_log('IP address banned from login: ' . $ip, $ip));
82 $this->logFile,
83 $server['REMOTE_ADDR'],
84 'IP address banned from login: '. $ip
85 );
86 } 84 }
87 $this->writeBanFile(); 85 $this->writeBanFile();
88 } 86 }
@@ -138,7 +136,7 @@ class BanManager
138 unset($this->failures[$ip]); 136 unset($this->failures[$ip]);
139 } 137 }
140 unset($this->bans[$ip]); 138 unset($this->bans[$ip]);
141 logm($this->logFile, $server['REMOTE_ADDR'], 'Ban lifted for: '. $ip); 139 $this->logger->info(format_log('Ban lifted for: ' . $ip, $ip));
142 140
143 $this->writeBanFile(); 141 $this->writeBanFile();
144 return false; 142 return false;
diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php
index d74c3118..b795b80e 100644
--- a/application/security/LoginManager.php
+++ b/application/security/LoginManager.php
@@ -1,7 +1,9 @@
1<?php 1<?php
2
2namespace Shaarli\Security; 3namespace Shaarli\Security;
3 4
4use Exception; 5use Exception;
6use Psr\Log\LoggerInterface;
5use Shaarli\Config\ConfigManager; 7use Shaarli\Config\ConfigManager;
6 8
7/** 9/**
@@ -31,26 +33,30 @@ class LoginManager
31 protected $staySignedInToken = ''; 33 protected $staySignedInToken = '';
32 /** @var CookieManager */ 34 /** @var CookieManager */
33 protected $cookieManager; 35 protected $cookieManager;
36 /** @var LoggerInterface */
37 protected $logger;
34 38
35 /** 39 /**
36 * Constructor 40 * Constructor
37 * 41 *
38 * @param ConfigManager $configManager Configuration Manager instance 42 * @param ConfigManager $configManager Configuration Manager instance
39 * @param SessionManager $sessionManager SessionManager instance 43 * @param SessionManager $sessionManager SessionManager instance
40 * @param CookieManager $cookieManager CookieManager instance 44 * @param CookieManager $cookieManager CookieManager instance
45 * @param BanManager $banManager
46 * @param LoggerInterface $logger Used to log login attempts
41 */ 47 */
42 public function __construct($configManager, $sessionManager, $cookieManager) 48 public function __construct(
43 { 49 ConfigManager $configManager,
50 SessionManager $sessionManager,
51 CookieManager $cookieManager,
52 BanManager $banManager,
53 LoggerInterface $logger
54 ) {
44 $this->configManager = $configManager; 55 $this->configManager = $configManager;
45 $this->sessionManager = $sessionManager; 56 $this->sessionManager = $sessionManager;
46 $this->cookieManager = $cookieManager; 57 $this->cookieManager = $cookieManager;
47 $this->banManager = new BanManager( 58 $this->banManager = $banManager;
48 $this->configManager->get('security.trusted_proxies', []), 59 $this->logger = $logger;
49 $this->configManager->get('security.ban_after'),
50 $this->configManager->get('security.ban_duration'),
51 $this->configManager->get('resource.ban_file', 'data/ipbans.php'),
52 $this->configManager->get('resource.log')
53 );
54 60
55 if ($this->configManager->get('security.open_shaarli') === true) { 61 if ($this->configManager->get('security.open_shaarli') === true) {
56 $this->openShaarli = true; 62 $this->openShaarli = true;
@@ -101,7 +107,8 @@ class LoginManager
101 // The user client has a valid stay-signed-in cookie 107 // The user client has a valid stay-signed-in cookie
102 // Session information is updated with the current client information 108 // Session information is updated with the current client information
103 $this->sessionManager->storeLoginInfo($clientIpId); 109 $this->sessionManager->storeLoginInfo($clientIpId);
104 } elseif ($this->sessionManager->hasSessionExpired() 110 } elseif (
111 $this->sessionManager->hasSessionExpired()
105 || $this->sessionManager->hasClientIpChanged($clientIpId) 112 || $this->sessionManager->hasClientIpChanged($clientIpId)
106 ) { 113 ) {
107 $this->sessionManager->logout(); 114 $this->sessionManager->logout();
@@ -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
2namespace Shaarli\Security; 3namespace Shaarli\Security;
3 4
4use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
@@ -79,7 +80,7 @@ class SessionManager
79 */ 80 */
80 public function generateToken() 81 public function generateToken()
81 { 82 {
82 $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt')); 83 $token = sha1(uniqid('', true) . '_' . mt_rand() . $this->conf->get('credentials.salt'));
83 $this->session['tokens'][$token] = 1; 84 $this->session['tokens'][$token] = 1;
84 return $token; 85 return $token;
85 } 86 }
@@ -293,9 +294,12 @@ class SessionManager
293 return session_start(); 294 return session_start();
294 } 295 }
295 296
296 public function cookieParameters(int $lifeTime, string $path, string $domain): bool 297 /**
298 * Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2.
299 */
300 public function cookieParameters(int $lifeTime, string $path, string $domain): void
297 { 301 {
298 return session_set_cookie_params($lifeTime, $path, $domain); 302 session_set_cookie_params($lifeTime, $path, $domain);
299 } 303 }
300 304
301 public function regenerateId(bool $deleteOldSession = false): bool 305 public function regenerateId(bool $deleteOldSession = false): bool
diff --git a/application/updater/Updater.php b/application/updater/Updater.php
index 88a7bc7b..4f557d0f 100644
--- a/application/updater/Updater.php
+++ b/application/updater/Updater.php
@@ -88,7 +88,8 @@ class Updater
88 88
89 foreach ($this->methods as $method) { 89 foreach ($this->methods as $method) {
90 // Not an update method or already done, pass. 90 // Not an update method or already done, pass.
91 if (! startsWith($method->getName(), 'updateMethod') 91 if (
92 ! startsWith($method->getName(), 'updateMethod')
92 || in_array($method->getName(), $this->doneUpdates) 93 || in_array($method->getName(), $this->doneUpdates)
93 ) { 94 ) {
94 continue; 95 continue;
@@ -121,12 +122,12 @@ class Updater
121 122
122 public function readUpdates(string $updatesFilepath): array 123 public function readUpdates(string $updatesFilepath): array
123 { 124 {
124 return UpdaterUtils::read_updates_file($updatesFilepath); 125 return UpdaterUtils::readUpdatesFile($updatesFilepath);
125 } 126 }
126 127
127 public function writeUpdates(string $updatesFilepath, array $updates): void 128 public function writeUpdates(string $updatesFilepath, array $updates): void
128 { 129 {
129 UpdaterUtils::write_updates_file($updatesFilepath, $updates); 130 UpdaterUtils::writeUpdatesFile($updatesFilepath, $updates);
130 } 131 }
131 132
132 /** 133 /**
@@ -152,7 +153,8 @@ class Updater
152 $updated = false; 153 $updated = false;
153 154
154 foreach ($this->bookmarkService->search() as $bookmark) { 155 foreach ($this->bookmarkService->search() as $bookmark) {
155 if ($bookmark->isNote() 156 if (
157 $bookmark->isNote()
156 && startsWith($bookmark->getUrl(), '?') 158 && startsWith($bookmark->getUrl(), '?')
157 && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match) 159 && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
158 ) { 160 ) {
diff --git a/application/updater/UpdaterUtils.php b/application/updater/UpdaterUtils.php
index 828a49fc..206f826e 100644
--- a/application/updater/UpdaterUtils.php
+++ b/application/updater/UpdaterUtils.php
@@ -11,7 +11,7 @@ class UpdaterUtils
11 * 11 *
12 * @return array Already done update methods. 12 * @return array Already done update methods.
13 */ 13 */
14 public static function read_updates_file($updatesFilepath) 14 public static function readUpdatesFile($updatesFilepath)
15 { 15 {
16 if (! empty($updatesFilepath) && is_file($updatesFilepath)) { 16 if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
17 $content = file_get_contents($updatesFilepath); 17 $content = file_get_contents($updatesFilepath);
@@ -19,7 +19,7 @@ class UpdaterUtils
19 return explode(';', $content); 19 return explode(';', $content);
20 } 20 }
21 } 21 }
22 return array(); 22 return [];
23 } 23 }
24 24
25 /** 25 /**
@@ -30,7 +30,7 @@ class UpdaterUtils
30 * 30 *
31 * @throws \Exception Couldn't write version number. 31 * @throws \Exception Couldn't write version number.
32 */ 32 */
33 public static function write_updates_file($updatesFilepath, $updates) 33 public static function writeUpdatesFile($updatesFilepath, $updates)
34 { 34 {
35 if (empty($updatesFilepath)) { 35 if (empty($updatesFilepath)) {
36 throw new \Exception('Updates file path is not set, can\'t write updates.'); 36 throw new \Exception('Updates file path is not set, can\'t write updates.');
@@ -38,7 +38,7 @@ class UpdaterUtils
38 38
39 $res = file_put_contents($updatesFilepath, implode(';', $updates)); 39 $res = file_put_contents($updatesFilepath, implode(';', $updates));
40 if ($res === false) { 40 if ($res === false) {
41 throw new \Exception('Unable to write updates in '. $updatesFilepath . '.'); 41 throw new \Exception('Unable to write updates in ' . $updatesFilepath . '.');
42 } 42 }
43 } 43 }
44} 44}