diff options
Diffstat (limited to 'application')
94 files changed, 5669 insertions, 1118 deletions
diff --git a/application/History.php b/application/History.php index 4fd2f294..bd5c1bf7 100644 --- a/application/History.php +++ b/application/History.php | |||
@@ -4,6 +4,7 @@ namespace Shaarli; | |||
4 | use DateTime; | 4 | use DateTime; |
5 | use Exception; | 5 | use Exception; |
6 | use Shaarli\Bookmark\Bookmark; | 6 | use Shaarli\Bookmark\Bookmark; |
7 | use Shaarli\Helper\FileUtils; | ||
7 | 8 | ||
8 | /** | 9 | /** |
9 | * Class History | 10 | * Class History |
diff --git a/application/Languages.php b/application/Languages.php index 5cda802e..d83e0765 100644 --- a/application/Languages.php +++ b/application/Languages.php | |||
@@ -179,9 +179,10 @@ class Languages | |||
179 | { | 179 | { |
180 | return [ | 180 | return [ |
181 | 'auto' => t('Automatic'), | 181 | 'auto' => t('Automatic'), |
182 | 'de' => t('German'), | ||
182 | 'en' => t('English'), | 183 | 'en' => t('English'), |
183 | 'fr' => t('French'), | 184 | 'fr' => t('French'), |
184 | 'de' => t('German'), | 185 | 'jp' => t('Japanese'), |
185 | ]; | 186 | ]; |
186 | } | 187 | } |
187 | } | 188 | } |
diff --git a/application/Router.php b/application/Router.php deleted file mode 100644 index d7187487..00000000 --- a/application/Router.php +++ /dev/null | |||
@@ -1,184 +0,0 @@ | |||
1 | <?php | ||
2 | namespace Shaarli; | ||
3 | |||
4 | /** | ||
5 | * Class Router | ||
6 | * | ||
7 | * (only displayable pages here) | ||
8 | */ | ||
9 | class Router | ||
10 | { | ||
11 | public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update'; | ||
12 | |||
13 | public static $PAGE_LOGIN = 'login'; | ||
14 | |||
15 | public static $PAGE_PICWALL = 'picwall'; | ||
16 | |||
17 | public static $PAGE_TAGCLOUD = 'tagcloud'; | ||
18 | |||
19 | public static $PAGE_TAGLIST = 'taglist'; | ||
20 | |||
21 | public static $PAGE_DAILY = 'daily'; | ||
22 | |||
23 | public static $PAGE_FEED_ATOM = 'atom'; | ||
24 | |||
25 | public static $PAGE_FEED_RSS = 'rss'; | ||
26 | |||
27 | public static $PAGE_TOOLS = 'tools'; | ||
28 | |||
29 | public static $PAGE_CHANGEPASSWORD = 'changepasswd'; | ||
30 | |||
31 | public static $PAGE_CONFIGURE = 'configure'; | ||
32 | |||
33 | public static $PAGE_CHANGETAG = 'changetag'; | ||
34 | |||
35 | public static $PAGE_ADDLINK = 'addlink'; | ||
36 | |||
37 | public static $PAGE_EDITLINK = 'edit_link'; | ||
38 | |||
39 | public static $PAGE_DELETELINK = 'delete_link'; | ||
40 | |||
41 | public static $PAGE_CHANGE_VISIBILITY = 'change_visibility'; | ||
42 | |||
43 | public static $PAGE_PINLINK = 'pin'; | ||
44 | |||
45 | public static $PAGE_EXPORT = 'export'; | ||
46 | |||
47 | public static $PAGE_IMPORT = 'import'; | ||
48 | |||
49 | public static $PAGE_OPENSEARCH = 'opensearch'; | ||
50 | |||
51 | public static $PAGE_LINKLIST = 'linklist'; | ||
52 | |||
53 | public static $PAGE_PLUGINSADMIN = 'pluginadmin'; | ||
54 | |||
55 | public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin'; | ||
56 | |||
57 | public static $PAGE_THUMBS_UPDATE = 'thumbs_update'; | ||
58 | |||
59 | public static $GET_TOKEN = 'token'; | ||
60 | |||
61 | /** | ||
62 | * Reproducing renderPage() if hell, to avoid regression. | ||
63 | * | ||
64 | * This highlights how bad this needs to be rewrite, | ||
65 | * but let's focus on plugins for now. | ||
66 | * | ||
67 | * @param string $query $_SERVER['QUERY_STRING']. | ||
68 | * @param array $get $_SERVER['GET']. | ||
69 | * @param bool $loggedIn true if authenticated user. | ||
70 | * | ||
71 | * @return string page found. | ||
72 | */ | ||
73 | public static function findPage($query, $get, $loggedIn) | ||
74 | { | ||
75 | $loggedIn = ($loggedIn === true) ? true : false; | ||
76 | |||
77 | if (empty($query) && !isset($get['edit_link']) && !isset($get['post'])) { | ||
78 | return self::$PAGE_LINKLIST; | ||
79 | } | ||
80 | |||
81 | if (startsWith($query, 'do=' . self::$PAGE_LOGIN) && $loggedIn === false) { | ||
82 | return self::$PAGE_LOGIN; | ||
83 | } | ||
84 | |||
85 | if (startsWith($query, 'do=' . self::$PAGE_PICWALL)) { | ||
86 | return self::$PAGE_PICWALL; | ||
87 | } | ||
88 | |||
89 | if (startsWith($query, 'do=' . self::$PAGE_TAGCLOUD)) { | ||
90 | return self::$PAGE_TAGCLOUD; | ||
91 | } | ||
92 | |||
93 | if (startsWith($query, 'do=' . self::$PAGE_TAGLIST)) { | ||
94 | return self::$PAGE_TAGLIST; | ||
95 | } | ||
96 | |||
97 | if (startsWith($query, 'do=' . self::$PAGE_OPENSEARCH)) { | ||
98 | return self::$PAGE_OPENSEARCH; | ||
99 | } | ||
100 | |||
101 | if (startsWith($query, 'do=' . self::$PAGE_DAILY)) { | ||
102 | return self::$PAGE_DAILY; | ||
103 | } | ||
104 | |||
105 | if (startsWith($query, 'do=' . self::$PAGE_FEED_ATOM)) { | ||
106 | return self::$PAGE_FEED_ATOM; | ||
107 | } | ||
108 | |||
109 | if (startsWith($query, 'do=' . self::$PAGE_FEED_RSS)) { | ||
110 | return self::$PAGE_FEED_RSS; | ||
111 | } | ||
112 | |||
113 | if (startsWith($query, 'do=' . self::$PAGE_THUMBS_UPDATE)) { | ||
114 | return self::$PAGE_THUMBS_UPDATE; | ||
115 | } | ||
116 | |||
117 | if (startsWith($query, 'do=' . self::$AJAX_THUMB_UPDATE)) { | ||
118 | return self::$AJAX_THUMB_UPDATE; | ||
119 | } | ||
120 | |||
121 | // At this point, only loggedin pages. | ||
122 | if (!$loggedIn) { | ||
123 | return self::$PAGE_LINKLIST; | ||
124 | } | ||
125 | |||
126 | if (startsWith($query, 'do=' . self::$PAGE_TOOLS)) { | ||
127 | return self::$PAGE_TOOLS; | ||
128 | } | ||
129 | |||
130 | if (startsWith($query, 'do=' . self::$PAGE_CHANGEPASSWORD)) { | ||
131 | return self::$PAGE_CHANGEPASSWORD; | ||
132 | } | ||
133 | |||
134 | if (startsWith($query, 'do=' . self::$PAGE_CONFIGURE)) { | ||
135 | return self::$PAGE_CONFIGURE; | ||
136 | } | ||
137 | |||
138 | if (startsWith($query, 'do=' . self::$PAGE_CHANGETAG)) { | ||
139 | return self::$PAGE_CHANGETAG; | ||
140 | } | ||
141 | |||
142 | if (startsWith($query, 'do=' . self::$PAGE_ADDLINK)) { | ||
143 | return self::$PAGE_ADDLINK; | ||
144 | } | ||
145 | |||
146 | if (isset($get['edit_link']) || isset($get['post'])) { | ||
147 | return self::$PAGE_EDITLINK; | ||
148 | } | ||
149 | |||
150 | if (isset($get['delete_link'])) { | ||
151 | return self::$PAGE_DELETELINK; | ||
152 | } | ||
153 | |||
154 | if (isset($get[self::$PAGE_CHANGE_VISIBILITY])) { | ||
155 | return self::$PAGE_CHANGE_VISIBILITY; | ||
156 | } | ||
157 | |||
158 | if (startsWith($query, 'do=' . self::$PAGE_PINLINK)) { | ||
159 | return self::$PAGE_PINLINK; | ||
160 | } | ||
161 | |||
162 | if (startsWith($query, 'do=' . self::$PAGE_EXPORT)) { | ||
163 | return self::$PAGE_EXPORT; | ||
164 | } | ||
165 | |||
166 | if (startsWith($query, 'do=' . self::$PAGE_IMPORT)) { | ||
167 | return self::$PAGE_IMPORT; | ||
168 | } | ||
169 | |||
170 | if (startsWith($query, 'do=' . self::$PAGE_PLUGINSADMIN)) { | ||
171 | return self::$PAGE_PLUGINSADMIN; | ||
172 | } | ||
173 | |||
174 | if (startsWith($query, 'do=' . self::$PAGE_SAVE_PLUGINSADMIN)) { | ||
175 | return self::$PAGE_SAVE_PLUGINSADMIN; | ||
176 | } | ||
177 | |||
178 | if (startsWith($query, 'do=' . self::$GET_TOKEN)) { | ||
179 | return self::$GET_TOKEN; | ||
180 | } | ||
181 | |||
182 | return self::$PAGE_LINKLIST; | ||
183 | } | ||
184 | } | ||
diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php index 314baf0d..5aec23c8 100644 --- a/application/Thumbnailer.php +++ b/application/Thumbnailer.php | |||
@@ -4,7 +4,6 @@ namespace Shaarli; | |||
4 | 4 | ||
5 | use Shaarli\Config\ConfigManager; | 5 | use Shaarli\Config\ConfigManager; |
6 | use WebThumbnailer\Application\ConfigManager as WTConfigManager; | 6 | use WebThumbnailer\Application\ConfigManager as WTConfigManager; |
7 | use WebThumbnailer\Exception\WebThumbnailerException; | ||
8 | use WebThumbnailer\WebThumbnailer; | 7 | use WebThumbnailer\WebThumbnailer; |
9 | 8 | ||
10 | /** | 9 | /** |
@@ -90,7 +89,7 @@ class Thumbnailer | |||
90 | 89 | ||
91 | try { | 90 | try { |
92 | return $this->wt->thumbnail($url); | 91 | return $this->wt->thumbnail($url); |
93 | } catch (WebThumbnailerException $e) { | 92 | } catch (\Throwable $e) { |
94 | // Exceptions are only thrown in debug mode. | 93 | // Exceptions are only thrown in debug mode. |
95 | error_log(get_class($e) . ': ' . $e->getMessage()); | 94 | error_log(get_class($e) . ': ' . $e->getMessage()); |
96 | } | 95 | } |
diff --git a/application/Utils.php b/application/Utils.php index 4b7fc546..db046893 100644 --- a/application/Utils.php +++ b/application/Utils.php | |||
@@ -4,21 +4,23 @@ | |||
4 | */ | 4 | */ |
5 | 5 | ||
6 | /** | 6 | /** |
7 | * Logs a message to a text file | 7 | * Format log using provided data. |
8 | * | 8 | * |
9 | * The log format is compatible with fail2ban. | 9 | * @param string $message the message to log |
10 | * @param string|null $clientIp the client's remote IPv4/IPv6 address | ||
10 | * | 11 | * |
11 | * @param string $logFile where to write the logs | 12 | * @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 | */ | 13 | */ |
15 | function logm($logFile, $clientIp, $message) | 14 | function format_log(string $message, string $clientIp = null): string |
16 | { | 15 | { |
17 | file_put_contents( | 16 | $out = $message; |
18 | $logFile, | 17 | |
19 | date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL, | 18 | if (!empty($clientIp)) { |
20 | FILE_APPEND | 19 | // Note: we keep the first dash to avoid breaking fail2ban configs |
21 | ); | 20 | $out = '- ' . $clientIp . ' - ' . $out; |
21 | } | ||
22 | |||
23 | return $out; | ||
22 | } | 24 | } |
23 | 25 | ||
24 | /** | 26 | /** |
@@ -87,18 +89,22 @@ function endsWith($haystack, $needle, $case = true) | |||
87 | * | 89 | * |
88 | * @param mixed $input Data to escape: a single string or an array of strings. | 90 | * @param mixed $input Data to escape: a single string or an array of strings. |
89 | * | 91 | * |
90 | * @return string escaped. | 92 | * @return string|array escaped. |
91 | */ | 93 | */ |
92 | function escape($input) | 94 | function escape($input) |
93 | { | 95 | { |
94 | if (is_bool($input)) { | 96 | if (null === $input) { |
97 | return null; | ||
98 | } | ||
99 | |||
100 | if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) { | ||
95 | return $input; | 101 | return $input; |
96 | } | 102 | } |
97 | 103 | ||
98 | if (is_array($input)) { | 104 | if (is_array($input)) { |
99 | $out = array(); | 105 | $out = array(); |
100 | foreach ($input as $key => $value) { | 106 | foreach ($input as $key => $value) { |
101 | $out[$key] = escape($value); | 107 | $out[escape($key)] = escape($value); |
102 | } | 108 | } |
103 | return $out; | 109 | return $out; |
104 | } | 110 | } |
@@ -294,15 +300,15 @@ function normalize_spaces($string) | |||
294 | * Requires php-intl to display international datetimes, | 300 | * Requires php-intl to display international datetimes, |
295 | * otherwise default format '%c' will be returned. | 301 | * otherwise default format '%c' will be returned. |
296 | * | 302 | * |
297 | * @param DateTime $date to format. | 303 | * @param DateTimeInterface $date to format. |
298 | * @param bool $time Displays time if true. | 304 | * @param bool $time Displays time if true. |
299 | * @param bool $intl Use international format if true. | 305 | * @param bool $intl Use international format if true. |
300 | * | 306 | * |
301 | * @return bool|string Formatted date, or false if the input is invalid. | 307 | * @return bool|string Formatted date, or false if the input is invalid. |
302 | */ | 308 | */ |
303 | function format_date($date, $time = true, $intl = true) | 309 | function format_date($date, $time = true, $intl = true) |
304 | { | 310 | { |
305 | if (! $date instanceof DateTime) { | 311 | if (! $date instanceof DateTimeInterface) { |
306 | return false; | 312 | return false; |
307 | } | 313 | } |
308 | 314 | ||
@@ -321,6 +327,23 @@ function format_date($date, $time = true, $intl = true) | |||
321 | } | 327 | } |
322 | 328 | ||
323 | /** | 329 | /** |
330 | * Format the date month according to the locale. | ||
331 | * | ||
332 | * @param DateTimeInterface $date to format. | ||
333 | * | ||
334 | * @return bool|string Formatted date, or false if the input is invalid. | ||
335 | */ | ||
336 | function format_month(DateTimeInterface $date) | ||
337 | { | ||
338 | if (! $date instanceof DateTimeInterface) { | ||
339 | return false; | ||
340 | } | ||
341 | |||
342 | return strftime('%B', $date->getTimestamp()); | ||
343 | } | ||
344 | |||
345 | |||
346 | /** | ||
324 | * Check if the input is an integer, no matter its real type. | 347 | * Check if the input is an integer, no matter its real type. |
325 | * | 348 | * |
326 | * PHP is a bit messy regarding this: | 349 | * PHP is a bit messy regarding this: |
@@ -448,14 +471,27 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false) | |||
448 | * Wrapper function for translation which match the API | 471 | * Wrapper function for translation which match the API |
449 | * of gettext()/_() and ngettext(). | 472 | * of gettext()/_() and ngettext(). |
450 | * | 473 | * |
451 | * @param string $text Text to translate. | 474 | * @param string $text Text to translate. |
452 | * @param string $nText The plural message ID. | 475 | * @param string $nText The plural message ID. |
453 | * @param int $nb The number of items for plural forms. | 476 | * @param int $nb The number of items for plural forms. |
454 | * @param string $domain The domain where the translation is stored (default: shaarli). | 477 | * @param string $domain The domain where the translation is stored (default: shaarli). |
478 | * @param array $variables Associative array of variables to replace in translated text. | ||
479 | * @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables. | ||
455 | * | 480 | * |
456 | * @return string Text translated. | 481 | * @return string Text translated. |
457 | */ | 482 | */ |
458 | function t($text, $nText = '', $nb = 1, $domain = 'shaarli') | 483 | function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false) |
484 | { | ||
485 | $postFunction = $fixCase ? 'ucfirst' : function ($input) { return $input; }; | ||
486 | |||
487 | return $postFunction(dn__($domain, $text, $nText, $nb, $variables)); | ||
488 | } | ||
489 | |||
490 | /** | ||
491 | * Converts an exception into a printable stack trace string. | ||
492 | */ | ||
493 | function exception2text(Throwable $e): string | ||
459 | { | 494 | { |
460 | return dn__($domain, $text, $nText, $nb); | 495 | return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString(); |
461 | } | 496 | } |
497 | |||
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php index 4745ac94..adc8b266 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php | |||
@@ -1,6 +1,7 @@ | |||
1 | <?php | 1 | <?php |
2 | namespace Shaarli\Api; | 2 | namespace Shaarli\Api; |
3 | 3 | ||
4 | use malkusch\lock\mutex\FlockMutex; | ||
4 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | 5 | use Shaarli\Api\Exceptions\ApiAuthorizationException; |
5 | use Shaarli\Api\Exceptions\ApiException; | 6 | use Shaarli\Api\Exceptions\ApiException; |
6 | use Shaarli\Bookmark\BookmarkFileService; | 7 | use Shaarli\Bookmark\BookmarkFileService; |
@@ -71,7 +72,14 @@ class ApiMiddleware | |||
71 | $response = $e->getApiResponse(); | 72 | $response = $e->getApiResponse(); |
72 | } | 73 | } |
73 | 74 | ||
74 | return $response; | 75 | return $response |
76 | ->withHeader('Access-Control-Allow-Origin', '*') | ||
77 | ->withHeader( | ||
78 | 'Access-Control-Allow-Headers', | ||
79 | 'X-Requested-With, Content-Type, Accept, Origin, Authorization' | ||
80 | ) | ||
81 | ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') | ||
82 | ; | ||
75 | } | 83 | } |
76 | 84 | ||
77 | /** | 85 | /** |
@@ -100,7 +108,9 @@ class ApiMiddleware | |||
100 | */ | 108 | */ |
101 | protected function checkToken($request) | 109 | protected function checkToken($request) |
102 | { | 110 | { |
103 | if (! $request->hasHeader('Authorization')) { | 111 | if (!$request->hasHeader('Authorization') |
112 | && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION']) | ||
113 | ) { | ||
104 | throw new ApiAuthorizationException('JWT token not provided'); | 114 | throw new ApiAuthorizationException('JWT token not provided'); |
105 | } | 115 | } |
106 | 116 | ||
@@ -108,7 +118,11 @@ class ApiMiddleware | |||
108 | throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); | 118 | throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); |
109 | } | 119 | } |
110 | 120 | ||
111 | $authorization = $request->getHeaderLine('Authorization'); | 121 | if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) { |
122 | $authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION']; | ||
123 | } else { | ||
124 | $authorization = $request->getHeaderLine('Authorization'); | ||
125 | } | ||
112 | 126 | ||
113 | if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) { | 127 | if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) { |
114 | throw new ApiAuthorizationException('Invalid JWT header'); | 128 | throw new ApiAuthorizationException('Invalid JWT header'); |
@@ -130,6 +144,7 @@ class ApiMiddleware | |||
130 | $linkDb = new BookmarkFileService( | 144 | $linkDb = new BookmarkFileService( |
131 | $conf, | 145 | $conf, |
132 | $this->container->get('history'), | 146 | $this->container->get('history'), |
147 | new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), | ||
133 | true | 148 | true |
134 | ); | 149 | ); |
135 | $this->container['db'] = $linkDb; | 150 | $this->container['db'] = $linkDb; |
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php index 5156a5f7..eb1ca9bc 100644 --- a/application/api/ApiUtils.php +++ b/application/api/ApiUtils.php | |||
@@ -67,7 +67,7 @@ class ApiUtils | |||
67 | if (! $bookmark->isNote()) { | 67 | if (! $bookmark->isNote()) { |
68 | $out['url'] = $bookmark->getUrl(); | 68 | $out['url'] = $bookmark->getUrl(); |
69 | } else { | 69 | } else { |
70 | $out['url'] = $indexUrl . $bookmark->getUrl(); | 70 | $out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/'); |
71 | } | 71 | } |
72 | $out['shorturl'] = $bookmark->getShortUrl(); | 72 | $out['shorturl'] = $bookmark->getShortUrl(); |
73 | $out['title'] = $bookmark->getTitle(); | 73 | $out['title'] = $bookmark->getTitle(); |
@@ -89,12 +89,12 @@ class ApiUtils | |||
89 | * If no URL is provided, it will generate a local note URL. | 89 | * 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. | 90 | * If no title is provided, it will use the URL as title. |
91 | * | 91 | * |
92 | * @param array $input Request Link. | 92 | * @param array|null $input Request Link. |
93 | * @param bool $defaultPrivate Request Link. | 93 | * @param bool $defaultPrivate Setting defined if a bookmark is private by default. |
94 | * | 94 | * |
95 | * @return Bookmark instance. | 95 | * @return Bookmark instance. |
96 | */ | 96 | */ |
97 | public static function buildLinkFromRequest($input, $defaultPrivate) | 97 | public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark |
98 | { | 98 | { |
99 | $bookmark = new Bookmark(); | 99 | $bookmark = new Bookmark(); |
100 | $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; | 100 | $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; |
@@ -110,6 +110,15 @@ class ApiUtils | |||
110 | $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); | 110 | $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); |
111 | $bookmark->setPrivate($private); | 111 | $bookmark->setPrivate($private); |
112 | 112 | ||
113 | $created = \DateTime::createFromFormat(\DateTime::ATOM, $input['created'] ?? ''); | ||
114 | if ($created instanceof \DateTimeInterface) { | ||
115 | $bookmark->setCreated($created); | ||
116 | } | ||
117 | $updated = \DateTime::createFromFormat(\DateTime::ATOM, $input['updated'] ?? ''); | ||
118 | if ($updated instanceof \DateTimeInterface) { | ||
119 | $bookmark->setUpdated($updated); | ||
120 | } | ||
121 | |||
113 | return $bookmark; | 122 | return $bookmark; |
114 | } | 123 | } |
115 | 124 | ||
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php index c4b3d0c3..88a845eb 100644 --- a/application/api/controllers/ApiController.php +++ b/application/api/controllers/ApiController.php | |||
@@ -4,6 +4,7 @@ namespace Shaarli\Api\Controllers; | |||
4 | 4 | ||
5 | use Shaarli\Bookmark\BookmarkServiceInterface; | 5 | use Shaarli\Bookmark\BookmarkServiceInterface; |
6 | use Shaarli\Config\ConfigManager; | 6 | use Shaarli\Config\ConfigManager; |
7 | use Shaarli\History; | ||
7 | use Slim\Container; | 8 | use Slim\Container; |
8 | 9 | ||
9 | /** | 10 | /** |
@@ -31,7 +32,7 @@ abstract class ApiController | |||
31 | protected $bookmarkService; | 32 | protected $bookmarkService; |
32 | 33 | ||
33 | /** | 34 | /** |
34 | * @var HistoryController | 35 | * @var History |
35 | */ | 36 | */ |
36 | protected $history; | 37 | protected $history; |
37 | 38 | ||
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php index 16fc8688..6bf529e4 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,8 +116,8 @@ 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 (! empty($bookmark->getUrl()) |
122 | && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) | 123 | && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) |
@@ -148,18 +149,19 @@ class Links extends ApiController | |||
148 | */ | 149 | */ |
149 | public function putLink($request, $response, $args) | 150 | public function putLink($request, $response, $args) |
150 | { | 151 | { |
151 | if (! $this->bookmarkService->exists($args['id'])) { | 152 | $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null; |
153 | if ($id === null || !$this->bookmarkService->exists($id)) { | ||
152 | throw new ApiLinkNotFoundException(); | 154 | throw new ApiLinkNotFoundException(); |
153 | } | 155 | } |
154 | 156 | ||
155 | $index = index_url($this->ci['environment']); | 157 | $index = index_url($this->ci['environment']); |
156 | $data = $request->getParsedBody(); | 158 | $data = $request->getParsedBody(); |
157 | 159 | ||
158 | $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); | 160 | $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); |
159 | // duplicate URL on a different link, return 409 Conflict | 161 | // duplicate URL on a different link, return 409 Conflict |
160 | if (! empty($requestBookmark->getUrl()) | 162 | if (! empty($requestBookmark->getUrl()) |
161 | && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) | 163 | && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) |
162 | && $dup->getId() != $args['id'] | 164 | && $dup->getId() != $id |
163 | ) { | 165 | ) { |
164 | return $response->withJson( | 166 | return $response->withJson( |
165 | ApiUtils::formatLink($dup, $index), | 167 | ApiUtils::formatLink($dup, $index), |
@@ -168,7 +170,7 @@ class Links extends ApiController | |||
168 | ); | 170 | ); |
169 | } | 171 | } |
170 | 172 | ||
171 | $responseBookmark = $this->bookmarkService->get($args['id']); | 173 | $responseBookmark = $this->bookmarkService->get($id); |
172 | $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark); | 174 | $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark); |
173 | $this->bookmarkService->set($responseBookmark); | 175 | $this->bookmarkService->set($responseBookmark); |
174 | 176 | ||
@@ -189,10 +191,11 @@ class Links extends ApiController | |||
189 | */ | 191 | */ |
190 | public function deleteLink($request, $response, $args) | 192 | public function deleteLink($request, $response, $args) |
191 | { | 193 | { |
192 | if (! $this->bookmarkService->exists($args['id'])) { | 194 | $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null; |
195 | if ($id === null || !$this->bookmarkService->exists($id)) { | ||
193 | throw new ApiLinkNotFoundException(); | 196 | throw new ApiLinkNotFoundException(); |
194 | } | 197 | } |
195 | $bookmark = $this->bookmarkService->get($args['id']); | 198 | $bookmark = $this->bookmarkService->get($id); |
196 | $this->bookmarkService->remove($bookmark); | 199 | $this->bookmarkService->remove($bookmark); |
197 | 200 | ||
198 | return $response->withStatus(204); | 201 | return $response->withStatus(204); |
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php index f9b21d3d..4810c5e6 100644 --- a/application/bookmark/Bookmark.php +++ b/application/bookmark/Bookmark.php | |||
@@ -1,8 +1,11 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
5 | use DateTime; | 7 | use DateTime; |
8 | use DateTimeInterface; | ||
6 | use Shaarli\Bookmark\Exception\InvalidBookmarkException; | 9 | use Shaarli\Bookmark\Exception\InvalidBookmarkException; |
7 | 10 | ||
8 | /** | 11 | /** |
@@ -36,21 +39,24 @@ class Bookmark | |||
36 | /** @var array List of bookmark's tags */ | 39 | /** @var array List of bookmark's tags */ |
37 | protected $tags; | 40 | protected $tags; |
38 | 41 | ||
39 | /** @var string Thumbnail's URL - false if no thumbnail could be found */ | 42 | /** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */ |
40 | protected $thumbnail; | 43 | protected $thumbnail; |
41 | 44 | ||
42 | /** @var bool Set to true if the bookmark is set as sticky */ | 45 | /** @var bool Set to true if the bookmark is set as sticky */ |
43 | protected $sticky; | 46 | protected $sticky; |
44 | 47 | ||
45 | /** @var DateTime Creation datetime */ | 48 | /** @var DateTimeInterface Creation datetime */ |
46 | protected $created; | 49 | protected $created; |
47 | 50 | ||
48 | /** @var DateTime Update datetime */ | 51 | /** @var DateTimeInterface datetime */ |
49 | protected $updated; | 52 | protected $updated; |
50 | 53 | ||
51 | /** @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 */ |
52 | protected $private; | 55 | protected $private; |
53 | 56 | ||
57 | /** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */ | ||
58 | protected $additionalContent = []; | ||
59 | |||
54 | /** | 60 | /** |
55 | * 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. |
56 | * | 62 | * |
@@ -58,25 +64,25 @@ class Bookmark | |||
58 | * | 64 | * |
59 | * @return $this | 65 | * @return $this |
60 | */ | 66 | */ |
61 | public function fromArray($data) | 67 | public function fromArray(array $data): Bookmark |
62 | { | 68 | { |
63 | $this->id = $data['id']; | 69 | $this->id = $data['id'] ?? null; |
64 | $this->shortUrl = $data['shorturl']; | 70 | $this->shortUrl = $data['shorturl'] ?? null; |
65 | $this->url = $data['url']; | 71 | $this->url = $data['url'] ?? null; |
66 | $this->title = $data['title']; | 72 | $this->title = $data['title'] ?? null; |
67 | $this->description = $data['description']; | 73 | $this->description = $data['description'] ?? null; |
68 | $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null; | 74 | $this->thumbnail = $data['thumbnail'] ?? null; |
69 | $this->sticky = isset($data['sticky']) ? $data['sticky'] : false; | 75 | $this->sticky = $data['sticky'] ?? false; |
70 | $this->created = $data['created']; | 76 | $this->created = $data['created'] ?? null; |
71 | if (is_array($data['tags'])) { | 77 | if (is_array($data['tags'])) { |
72 | $this->tags = $data['tags']; | 78 | $this->tags = $data['tags']; |
73 | } else { | 79 | } else { |
74 | $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY); | 80 | $this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY); |
75 | } | 81 | } |
76 | if (! empty($data['updated'])) { | 82 | if (! empty($data['updated'])) { |
77 | $this->updated = $data['updated']; | 83 | $this->updated = $data['updated']; |
78 | } | 84 | } |
79 | $this->private = $data['private'] ? true : false; | 85 | $this->private = ($data['private'] ?? false) ? true : false; |
80 | 86 | ||
81 | return $this; | 87 | return $this; |
82 | } | 88 | } |
@@ -92,24 +98,28 @@ class Bookmark | |||
92 | * - the URL with the permalink | 98 | * - the URL with the permalink |
93 | * - the title with the URL | 99 | * - the title with the URL |
94 | * | 100 | * |
101 | * Also make sure that we do not save search highlights in the datastore. | ||
102 | * | ||
95 | * @throws InvalidBookmarkException | 103 | * @throws InvalidBookmarkException |
96 | */ | 104 | */ |
97 | public function validate() | 105 | public function validate(): void |
98 | { | 106 | { |
99 | if ($this->id === null | 107 | if ($this->id === null |
100 | || ! is_int($this->id) | 108 | || ! is_int($this->id) |
101 | || empty($this->shortUrl) | 109 | || empty($this->shortUrl) |
102 | || empty($this->created) | 110 | || empty($this->created) |
103 | || ! $this->created instanceof DateTime | ||
104 | ) { | 111 | ) { |
105 | throw new InvalidBookmarkException($this); | 112 | throw new InvalidBookmarkException($this); |
106 | } | 113 | } |
107 | if (empty($this->url)) { | 114 | if (empty($this->url)) { |
108 | $this->url = '?'. $this->shortUrl; | 115 | $this->url = '/shaare/'. $this->shortUrl; |
109 | } | 116 | } |
110 | if (empty($this->title)) { | 117 | if (empty($this->title)) { |
111 | $this->title = $this->url; | 118 | $this->title = $this->url; |
112 | } | 119 | } |
120 | if (array_key_exists('search_highlight', $this->additionalContent)) { | ||
121 | unset($this->additionalContent['search_highlight']); | ||
122 | } | ||
113 | } | 123 | } |
114 | 124 | ||
115 | /** | 125 | /** |
@@ -118,11 +128,11 @@ class Bookmark | |||
118 | * - created: with the current datetime | 128 | * - created: with the current datetime |
119 | * - shortUrl: with a generated small hash from the date and the given ID | 129 | * - shortUrl: with a generated small hash from the date and the given ID |
120 | * | 130 | * |
121 | * @param int $id | 131 | * @param int|null $id |
122 | * | 132 | * |
123 | * @return Bookmark | 133 | * @return Bookmark |
124 | */ | 134 | */ |
125 | public function setId($id) | 135 | public function setId(?int $id): Bookmark |
126 | { | 136 | { |
127 | $this->id = $id; | 137 | $this->id = $id; |
128 | if (empty($this->created)) { | 138 | if (empty($this->created)) { |
@@ -138,9 +148,9 @@ class Bookmark | |||
138 | /** | 148 | /** |
139 | * Get the Id. | 149 | * Get the Id. |
140 | * | 150 | * |
141 | * @return int | 151 | * @return int|null |
142 | */ | 152 | */ |
143 | public function getId() | 153 | public function getId(): ?int |
144 | { | 154 | { |
145 | return $this->id; | 155 | return $this->id; |
146 | } | 156 | } |
@@ -148,9 +158,9 @@ class Bookmark | |||
148 | /** | 158 | /** |
149 | * Get the ShortUrl. | 159 | * Get the ShortUrl. |
150 | * | 160 | * |
151 | * @return string | 161 | * @return string|null |
152 | */ | 162 | */ |
153 | public function getShortUrl() | 163 | public function getShortUrl(): ?string |
154 | { | 164 | { |
155 | return $this->shortUrl; | 165 | return $this->shortUrl; |
156 | } | 166 | } |
@@ -158,9 +168,9 @@ class Bookmark | |||
158 | /** | 168 | /** |
159 | * Get the Url. | 169 | * Get the Url. |
160 | * | 170 | * |
161 | * @return string | 171 | * @return string|null |
162 | */ | 172 | */ |
163 | public function getUrl() | 173 | public function getUrl(): ?string |
164 | { | 174 | { |
165 | return $this->url; | 175 | return $this->url; |
166 | } | 176 | } |
@@ -170,7 +180,7 @@ class Bookmark | |||
170 | * | 180 | * |
171 | * @return string | 181 | * @return string |
172 | */ | 182 | */ |
173 | public function getTitle() | 183 | public function getTitle(): ?string |
174 | { | 184 | { |
175 | return $this->title; | 185 | return $this->title; |
176 | } | 186 | } |
@@ -180,7 +190,7 @@ class Bookmark | |||
180 | * | 190 | * |
181 | * @return string | 191 | * @return string |
182 | */ | 192 | */ |
183 | public function getDescription() | 193 | public function getDescription(): string |
184 | { | 194 | { |
185 | return ! empty($this->description) ? $this->description : ''; | 195 | return ! empty($this->description) ? $this->description : ''; |
186 | } | 196 | } |
@@ -188,9 +198,9 @@ class Bookmark | |||
188 | /** | 198 | /** |
189 | * Get the Created. | 199 | * Get the Created. |
190 | * | 200 | * |
191 | * @return DateTime | 201 | * @return DateTimeInterface |
192 | */ | 202 | */ |
193 | public function getCreated() | 203 | public function getCreated(): ?DateTimeInterface |
194 | { | 204 | { |
195 | return $this->created; | 205 | return $this->created; |
196 | } | 206 | } |
@@ -198,9 +208,9 @@ class Bookmark | |||
198 | /** | 208 | /** |
199 | * Get the Updated. | 209 | * Get the Updated. |
200 | * | 210 | * |
201 | * @return DateTime | 211 | * @return DateTimeInterface |
202 | */ | 212 | */ |
203 | public function getUpdated() | 213 | public function getUpdated(): ?DateTimeInterface |
204 | { | 214 | { |
205 | return $this->updated; | 215 | return $this->updated; |
206 | } | 216 | } |
@@ -208,11 +218,11 @@ class Bookmark | |||
208 | /** | 218 | /** |
209 | * Set the ShortUrl. | 219 | * Set the ShortUrl. |
210 | * | 220 | * |
211 | * @param string $shortUrl | 221 | * @param string|null $shortUrl |
212 | * | 222 | * |
213 | * @return Bookmark | 223 | * @return Bookmark |
214 | */ | 224 | */ |
215 | public function setShortUrl($shortUrl) | 225 | public function setShortUrl(?string $shortUrl): Bookmark |
216 | { | 226 | { |
217 | $this->shortUrl = $shortUrl; | 227 | $this->shortUrl = $shortUrl; |
218 | 228 | ||
@@ -222,14 +232,14 @@ class Bookmark | |||
222 | /** | 232 | /** |
223 | * Set the Url. | 233 | * Set the Url. |
224 | * | 234 | * |
225 | * @param string $url | 235 | * @param string|null $url |
226 | * @param array $allowedProtocols | 236 | * @param string[] $allowedProtocols |
227 | * | 237 | * |
228 | * @return Bookmark | 238 | * @return Bookmark |
229 | */ | 239 | */ |
230 | public function setUrl($url, $allowedProtocols = []) | 240 | public function setUrl(?string $url, array $allowedProtocols = []): Bookmark |
231 | { | 241 | { |
232 | $url = trim($url); | 242 | $url = $url !== null ? trim($url) : ''; |
233 | if (! empty($url)) { | 243 | if (! empty($url)) { |
234 | $url = whitelist_protocols($url, $allowedProtocols); | 244 | $url = whitelist_protocols($url, $allowedProtocols); |
235 | } | 245 | } |
@@ -241,13 +251,13 @@ class Bookmark | |||
241 | /** | 251 | /** |
242 | * Set the Title. | 252 | * Set the Title. |
243 | * | 253 | * |
244 | * @param string $title | 254 | * @param string|null $title |
245 | * | 255 | * |
246 | * @return Bookmark | 256 | * @return Bookmark |
247 | */ | 257 | */ |
248 | public function setTitle($title) | 258 | public function setTitle(?string $title): Bookmark |
249 | { | 259 | { |
250 | $this->title = trim($title); | 260 | $this->title = $title !== null ? trim($title) : ''; |
251 | 261 | ||
252 | return $this; | 262 | return $this; |
253 | } | 263 | } |
@@ -255,11 +265,11 @@ class Bookmark | |||
255 | /** | 265 | /** |
256 | * Set the Description. | 266 | * Set the Description. |
257 | * | 267 | * |
258 | * @param string $description | 268 | * @param string|null $description |
259 | * | 269 | * |
260 | * @return Bookmark | 270 | * @return Bookmark |
261 | */ | 271 | */ |
262 | public function setDescription($description) | 272 | public function setDescription(?string $description): Bookmark |
263 | { | 273 | { |
264 | $this->description = $description; | 274 | $this->description = $description; |
265 | 275 | ||
@@ -270,11 +280,11 @@ class Bookmark | |||
270 | * Set the Created. | 280 | * Set the Created. |
271 | * Note: you shouldn't set this manually except for special cases (like bookmark import) | 281 | * Note: you shouldn't set this manually except for special cases (like bookmark import) |
272 | * | 282 | * |
273 | * @param DateTime $created | 283 | * @param DateTimeInterface|null $created |
274 | * | 284 | * |
275 | * @return Bookmark | 285 | * @return Bookmark |
276 | */ | 286 | */ |
277 | public function setCreated($created) | 287 | public function setCreated(?DateTimeInterface $created): Bookmark |
278 | { | 288 | { |
279 | $this->created = $created; | 289 | $this->created = $created; |
280 | 290 | ||
@@ -284,11 +294,11 @@ class Bookmark | |||
284 | /** | 294 | /** |
285 | * Set the Updated. | 295 | * Set the Updated. |
286 | * | 296 | * |
287 | * @param DateTime $updated | 297 | * @param DateTimeInterface|null $updated |
288 | * | 298 | * |
289 | * @return Bookmark | 299 | * @return Bookmark |
290 | */ | 300 | */ |
291 | public function setUpdated($updated) | 301 | public function setUpdated(?DateTimeInterface $updated): Bookmark |
292 | { | 302 | { |
293 | $this->updated = $updated; | 303 | $this->updated = $updated; |
294 | 304 | ||
@@ -300,7 +310,7 @@ class Bookmark | |||
300 | * | 310 | * |
301 | * @return bool | 311 | * @return bool |
302 | */ | 312 | */ |
303 | public function isPrivate() | 313 | public function isPrivate(): bool |
304 | { | 314 | { |
305 | return $this->private ? true : false; | 315 | return $this->private ? true : false; |
306 | } | 316 | } |
@@ -308,11 +318,11 @@ class Bookmark | |||
308 | /** | 318 | /** |
309 | * Set the Private. | 319 | * Set the Private. |
310 | * | 320 | * |
311 | * @param bool $private | 321 | * @param bool|null $private |
312 | * | 322 | * |
313 | * @return Bookmark | 323 | * @return Bookmark |
314 | */ | 324 | */ |
315 | public function setPrivate($private) | 325 | public function setPrivate(?bool $private): Bookmark |
316 | { | 326 | { |
317 | $this->private = $private ? true : false; | 327 | $this->private = $private ? true : false; |
318 | 328 | ||
@@ -322,9 +332,9 @@ class Bookmark | |||
322 | /** | 332 | /** |
323 | * Get the Tags. | 333 | * Get the Tags. |
324 | * | 334 | * |
325 | * @return array | 335 | * @return string[] |
326 | */ | 336 | */ |
327 | public function getTags() | 337 | public function getTags(): array |
328 | { | 338 | { |
329 | return is_array($this->tags) ? $this->tags : []; | 339 | return is_array($this->tags) ? $this->tags : []; |
330 | } | 340 | } |
@@ -332,13 +342,13 @@ class Bookmark | |||
332 | /** | 342 | /** |
333 | * Set the Tags. | 343 | * Set the Tags. |
334 | * | 344 | * |
335 | * @param array $tags | 345 | * @param string[]|null $tags |
336 | * | 346 | * |
337 | * @return Bookmark | 347 | * @return Bookmark |
338 | */ | 348 | */ |
339 | public function setTags($tags) | 349 | public function setTags(?array $tags): Bookmark |
340 | { | 350 | { |
341 | $this->setTagsString(implode(' ', $tags)); | 351 | $this->setTagsString(implode(' ', $tags ?? [])); |
342 | 352 | ||
343 | return $this; | 353 | return $this; |
344 | } | 354 | } |
@@ -346,7 +356,7 @@ class Bookmark | |||
346 | /** | 356 | /** |
347 | * Get the Thumbnail. | 357 | * Get the Thumbnail. |
348 | * | 358 | * |
349 | * @return string|bool | 359 | * @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found |
350 | */ | 360 | */ |
351 | public function getThumbnail() | 361 | public function getThumbnail() |
352 | { | 362 | { |
@@ -356,11 +366,11 @@ class Bookmark | |||
356 | /** | 366 | /** |
357 | * Set the Thumbnail. | 367 | * Set the Thumbnail. |
358 | * | 368 | * |
359 | * @param string|bool $thumbnail | 369 | * @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found |
360 | * | 370 | * |
361 | * @return Bookmark | 371 | * @return Bookmark |
362 | */ | 372 | */ |
363 | public function setThumbnail($thumbnail) | 373 | public function setThumbnail($thumbnail): Bookmark |
364 | { | 374 | { |
365 | $this->thumbnail = $thumbnail; | 375 | $this->thumbnail = $thumbnail; |
366 | 376 | ||
@@ -368,11 +378,29 @@ class Bookmark | |||
368 | } | 378 | } |
369 | 379 | ||
370 | /** | 380 | /** |
381 | * Return true if: | ||
382 | * - the bookmark's thumbnail is not already set to false (= not found) | ||
383 | * - it's not a note | ||
384 | * - it's an HTTP(S) link | ||
385 | * - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore | ||
386 | * | ||
387 | * @return bool True if the bookmark's thumbnail needs to be retrieved. | ||
388 | */ | ||
389 | public function shouldUpdateThumbnail(): bool | ||
390 | { | ||
391 | return $this->thumbnail !== false | ||
392 | && !$this->isNote() | ||
393 | && startsWith(strtolower($this->url), 'http') | ||
394 | && (null === $this->thumbnail || !is_file($this->thumbnail)) | ||
395 | ; | ||
396 | } | ||
397 | |||
398 | /** | ||
371 | * Get the Sticky. | 399 | * Get the Sticky. |
372 | * | 400 | * |
373 | * @return bool | 401 | * @return bool |
374 | */ | 402 | */ |
375 | public function isSticky() | 403 | public function isSticky(): bool |
376 | { | 404 | { |
377 | return $this->sticky ? true : false; | 405 | return $this->sticky ? true : false; |
378 | } | 406 | } |
@@ -380,11 +408,11 @@ class Bookmark | |||
380 | /** | 408 | /** |
381 | * Set the Sticky. | 409 | * Set the Sticky. |
382 | * | 410 | * |
383 | * @param bool $sticky | 411 | * @param bool|null $sticky |
384 | * | 412 | * |
385 | * @return Bookmark | 413 | * @return Bookmark |
386 | */ | 414 | */ |
387 | public function setSticky($sticky) | 415 | public function setSticky(?bool $sticky): Bookmark |
388 | { | 416 | { |
389 | $this->sticky = $sticky ? true : false; | 417 | $this->sticky = $sticky ? true : false; |
390 | 418 | ||
@@ -394,7 +422,7 @@ class Bookmark | |||
394 | /** | 422 | /** |
395 | * @return string Bookmark's tags as a string, separated by a space | 423 | * @return string Bookmark's tags as a string, separated by a space |
396 | */ | 424 | */ |
397 | public function getTagsString() | 425 | public function getTagsString(): string |
398 | { | 426 | { |
399 | return implode(' ', $this->getTags()); | 427 | return implode(' ', $this->getTags()); |
400 | } | 428 | } |
@@ -402,10 +430,10 @@ class Bookmark | |||
402 | /** | 430 | /** |
403 | * @return bool | 431 | * @return bool |
404 | */ | 432 | */ |
405 | public function isNote() | 433 | public function isNote(): bool |
406 | { | 434 | { |
407 | // We check empty value to get a valid result if the link has not been saved yet | 435 | // We check empty value to get a valid result if the link has not been saved yet |
408 | return empty($this->url) || $this->url[0] === '?'; | 436 | return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?'; |
409 | } | 437 | } |
410 | 438 | ||
411 | /** | 439 | /** |
@@ -415,14 +443,14 @@ class Bookmark | |||
415 | * - multiple spaces will be removed | 443 | * - multiple spaces will be removed |
416 | * - trailing dash in tags will be removed | 444 | * - trailing dash in tags will be removed |
417 | * | 445 | * |
418 | * @param string $tags | 446 | * @param string|null $tags |
419 | * | 447 | * |
420 | * @return $this | 448 | * @return $this |
421 | */ | 449 | */ |
422 | public function setTagsString($tags) | 450 | public function setTagsString(?string $tags): Bookmark |
423 | { | 451 | { |
424 | // Remove first '-' char in tags. | 452 | // Remove first '-' char in tags. |
425 | $tags = preg_replace('/(^| )\-/', '$1', $tags); | 453 | $tags = preg_replace('/(^| )\-/', '$1', $tags ?? ''); |
426 | // Explode all tags separted by spaces or commas | 454 | // Explode all tags separted by spaces or commas |
427 | $tags = preg_split('/[\s,]+/', $tags); | 455 | $tags = preg_split('/[\s,]+/', $tags); |
428 | // Remove eventual empty values | 456 | // Remove eventual empty values |
@@ -434,12 +462,50 @@ class Bookmark | |||
434 | } | 462 | } |
435 | 463 | ||
436 | /** | 464 | /** |
465 | * Get entire additionalContent array. | ||
466 | * | ||
467 | * @return mixed[] | ||
468 | */ | ||
469 | public function getAdditionalContent(): array | ||
470 | { | ||
471 | return $this->additionalContent; | ||
472 | } | ||
473 | |||
474 | /** | ||
475 | * Set a single entry in additionalContent, by key. | ||
476 | * | ||
477 | * @param string $key | ||
478 | * @param mixed|null $value Any type of value can be set. | ||
479 | * | ||
480 | * @return $this | ||
481 | */ | ||
482 | public function addAdditionalContentEntry(string $key, $value): self | ||
483 | { | ||
484 | $this->additionalContent[$key] = $value; | ||
485 | |||
486 | return $this; | ||
487 | } | ||
488 | |||
489 | /** | ||
490 | * Get a single entry in additionalContent, by key. | ||
491 | * | ||
492 | * @param string $key | ||
493 | * @param mixed|null $default | ||
494 | * | ||
495 | * @return mixed|null can be any type or even null. | ||
496 | */ | ||
497 | public function getAdditionalContentEntry(string $key, $default = null) | ||
498 | { | ||
499 | return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default; | ||
500 | } | ||
501 | |||
502 | /** | ||
437 | * Rename a tag in tags list. | 503 | * Rename a tag in tags list. |
438 | * | 504 | * |
439 | * @param string $fromTag | 505 | * @param string $fromTag |
440 | * @param string $toTag | 506 | * @param string $toTag |
441 | */ | 507 | */ |
442 | public function renameTag($fromTag, $toTag) | 508 | public function renameTag(string $fromTag, string $toTag): void |
443 | { | 509 | { |
444 | if (($pos = array_search($fromTag, $this->tags)) !== false) { | 510 | if (($pos = array_search($fromTag, $this->tags)) !== false) { |
445 | $this->tags[$pos] = trim($toTag); | 511 | $this->tags[$pos] = trim($toTag); |
@@ -451,7 +517,7 @@ class Bookmark | |||
451 | * | 517 | * |
452 | * @param string $tag | 518 | * @param string $tag |
453 | */ | 519 | */ |
454 | public function deleteTag($tag) | 520 | public function deleteTag(string $tag): void |
455 | { | 521 | { |
456 | if (($pos = array_search($tag, $this->tags)) !== false) { | 522 | if (($pos = array_search($tag, $this->tags)) !== false) { |
457 | unset($this->tags[$pos]); | 523 | unset($this->tags[$pos]); |
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php index d87d43b4..67bb3b73 100644 --- a/application/bookmark/BookmarkArray.php +++ b/application/bookmark/BookmarkArray.php | |||
@@ -1,5 +1,7 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
5 | use Shaarli\Bookmark\Exception\InvalidBookmarkException; | 7 | use Shaarli\Bookmark\Exception\InvalidBookmarkException; |
@@ -187,13 +189,13 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess | |||
187 | /** | 189 | /** |
188 | * Returns a bookmark offset in bookmarks array from its unique ID. | 190 | * Returns a bookmark offset in bookmarks array from its unique ID. |
189 | * | 191 | * |
190 | * @param int $id Persistent ID of a bookmark. | 192 | * @param int|null $id Persistent ID of a bookmark. |
191 | * | 193 | * |
192 | * @return int Real offset in local array, or null if doesn't exist. | 194 | * @return int Real offset in local array, or null if doesn't exist. |
193 | */ | 195 | */ |
194 | protected function getBookmarkOffset($id) | 196 | protected function getBookmarkOffset(?int $id): ?int |
195 | { | 197 | { |
196 | if (isset($this->ids[$id])) { | 198 | if ($id !== null && isset($this->ids[$id])) { |
197 | return $this->ids[$id]; | 199 | return $this->ids[$id]; |
198 | } | 200 | } |
199 | return null; | 201 | return null; |
@@ -205,7 +207,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess | |||
205 | * | 207 | * |
206 | * @return int next ID. | 208 | * @return int next ID. |
207 | */ | 209 | */ |
208 | public function getNextId() | 210 | public function getNextId(): int |
209 | { | 211 | { |
210 | if (!empty($this->ids)) { | 212 | if (!empty($this->ids)) { |
211 | return max(array_keys($this->ids)) + 1; | 213 | return max(array_keys($this->ids)) + 1; |
@@ -214,11 +216,11 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess | |||
214 | } | 216 | } |
215 | 217 | ||
216 | /** | 218 | /** |
217 | * @param $url | 219 | * @param string $url |
218 | * | 220 | * |
219 | * @return Bookmark|null | 221 | * @return Bookmark|null |
220 | */ | 222 | */ |
221 | public function getByUrl($url) | 223 | public function getByUrl(string $url): ?Bookmark |
222 | { | 224 | { |
223 | if (! empty($url) | 225 | if (! empty($url) |
224 | && isset($this->urls[$url]) | 226 | && isset($this->urls[$url]) |
@@ -234,16 +236,17 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess | |||
234 | * | 236 | * |
235 | * Also update the urls and ids mapping arrays. | 237 | * Also update the urls and ids mapping arrays. |
236 | * | 238 | * |
237 | * @param string $order ASC|DESC | 239 | * @param string $order ASC|DESC |
240 | * @param bool $ignoreSticky If set to true, sticky bookmarks won't be first | ||
238 | */ | 241 | */ |
239 | public function reorder($order = 'DESC') | 242 | public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void |
240 | { | 243 | { |
241 | $order = $order === 'ASC' ? -1 : 1; | 244 | $order = $order === 'ASC' ? -1 : 1; |
242 | // Reorder array by dates. | 245 | // Reorder array by dates. |
243 | usort($this->bookmarks, function ($a, $b) use ($order) { | 246 | usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) { |
244 | /** @var $a Bookmark */ | 247 | /** @var $a Bookmark */ |
245 | /** @var $b Bookmark */ | 248 | /** @var $b Bookmark */ |
246 | if ($a->isSticky() !== $b->isSticky()) { | 249 | if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) { |
247 | return $a->isSticky() ? -1 : 1; | 250 | return $a->isSticky() ? -1 : 1; |
248 | } | 251 | } |
249 | return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order; | 252 | return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order; |
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index 9c59e139..3ea98a45 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php | |||
@@ -1,17 +1,21 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
3 | 4 | ||
4 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
5 | 6 | ||
6 | 7 | use DateTime; | |
7 | use Exception; | 8 | use Exception; |
9 | use malkusch\lock\mutex\Mutex; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | 10 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
11 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | ||
9 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; | 12 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; |
10 | use Shaarli\Config\ConfigManager; | 13 | use Shaarli\Config\ConfigManager; |
11 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | 14 | use Shaarli\Formatter\BookmarkMarkdownFormatter; |
12 | use Shaarli\History; | 15 | use Shaarli\History; |
13 | use Shaarli\Legacy\LegacyLinkDB; | 16 | use Shaarli\Legacy\LegacyLinkDB; |
14 | use Shaarli\Legacy\LegacyUpdater; | 17 | use Shaarli\Legacy\LegacyUpdater; |
18 | use Shaarli\Render\PageCacheManager; | ||
15 | use Shaarli\Updater\UpdaterUtils; | 19 | use Shaarli\Updater\UpdaterUtils; |
16 | 20 | ||
17 | /** | 21 | /** |
@@ -39,17 +43,25 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
39 | /** @var History instance */ | 43 | /** @var History instance */ |
40 | protected $history; | 44 | protected $history; |
41 | 45 | ||
46 | /** @var PageCacheManager instance */ | ||
47 | protected $pageCacheManager; | ||
48 | |||
42 | /** @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. */ |
43 | protected $isLoggedIn; | 50 | protected $isLoggedIn; |
44 | 51 | ||
52 | /** @var Mutex */ | ||
53 | protected $mutex; | ||
54 | |||
45 | /** | 55 | /** |
46 | * @inheritDoc | 56 | * @inheritDoc |
47 | */ | 57 | */ |
48 | public function __construct(ConfigManager $conf, History $history, $isLoggedIn) | 58 | public function __construct(ConfigManager $conf, History $history, Mutex $mutex, bool $isLoggedIn) |
49 | { | 59 | { |
50 | $this->conf = $conf; | 60 | $this->conf = $conf; |
51 | $this->history = $history; | 61 | $this->history = $history; |
52 | $this->bookmarksIO = new BookmarkIO($this->conf); | 62 | $this->mutex = $mutex; |
63 | $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn); | ||
64 | $this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex); | ||
53 | $this->isLoggedIn = $isLoggedIn; | 65 | $this->isLoggedIn = $isLoggedIn; |
54 | 66 | ||
55 | if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { | 67 | if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { |
@@ -57,10 +69,16 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
57 | } else { | 69 | } else { |
58 | try { | 70 | try { |
59 | $this->bookmarks = $this->bookmarksIO->read(); | 71 | $this->bookmarks = $this->bookmarksIO->read(); |
60 | } catch (EmptyDataStoreException $e) { | 72 | } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { |
61 | $this->bookmarks = new BookmarkArray(); | 73 | $this->bookmarks = new BookmarkArray(); |
62 | if ($isLoggedIn) { | 74 | |
63 | $this->save(); | 75 | if ($this->isLoggedIn) { |
76 | // Datastore file does not exists, we initialize it with default bookmarks. | ||
77 | if ($e instanceof DatastoreNotInitializedException) { | ||
78 | $this->initialize(); | ||
79 | } else { | ||
80 | $this->save(); | ||
81 | } | ||
64 | } | 82 | } |
65 | } | 83 | } |
66 | 84 | ||
@@ -79,22 +97,25 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
79 | /** | 97 | /** |
80 | * @inheritDoc | 98 | * @inheritDoc |
81 | */ | 99 | */ |
82 | public function findByHash($hash) | 100 | public function findByHash(string $hash, string $privateKey = null): Bookmark |
83 | { | 101 | { |
84 | $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); | 102 | $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); |
85 | // PHP 7.3 introduced array_key_first() to avoid this hack | 103 | // PHP 7.3 introduced array_key_first() to avoid this hack |
86 | $first = reset($bookmark); | 104 | $first = reset($bookmark); |
87 | if (! $this->isLoggedIn && $first->isPrivate()) { | 105 | if (!$this->isLoggedIn |
88 | throw new Exception('Not authorized'); | 106 | && $first->isPrivate() |
107 | && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key')) | ||
108 | ) { | ||
109 | throw new BookmarkNotFoundException(); | ||
89 | } | 110 | } |
90 | 111 | ||
91 | return $bookmark; | 112 | return $first; |
92 | } | 113 | } |
93 | 114 | ||
94 | /** | 115 | /** |
95 | * @inheritDoc | 116 | * @inheritDoc |
96 | */ | 117 | */ |
97 | public function findByUrl($url) | 118 | public function findByUrl(string $url): ?Bookmark |
98 | { | 119 | { |
99 | return $this->bookmarks->getByUrl($url); | 120 | return $this->bookmarks->getByUrl($url); |
100 | } | 121 | } |
@@ -102,19 +123,28 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
102 | /** | 123 | /** |
103 | * @inheritDoc | 124 | * @inheritDoc |
104 | */ | 125 | */ |
105 | public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false) | 126 | public function search( |
106 | { | 127 | array $request = [], |
128 | string $visibility = null, | ||
129 | bool $caseSensitive = false, | ||
130 | bool $untaggedOnly = false, | ||
131 | bool $ignoreSticky = false | ||
132 | ) { | ||
107 | if ($visibility === null) { | 133 | if ($visibility === null) { |
108 | $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; | 134 | $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; |
109 | } | 135 | } |
110 | 136 | ||
111 | // Filter bookmark database according to parameters. | 137 | // Filter bookmark database according to parameters. |
112 | $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; | 138 | $searchTags = isset($request['searchtags']) ? $request['searchtags'] : ''; |
113 | $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; | 139 | $searchTerm = isset($request['searchterm']) ? $request['searchterm'] : ''; |
140 | |||
141 | if ($ignoreSticky) { | ||
142 | $this->bookmarks->reorder('DESC', true); | ||
143 | } | ||
114 | 144 | ||
115 | return $this->bookmarkFilter->filter( | 145 | return $this->bookmarkFilter->filter( |
116 | BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, | 146 | BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, |
117 | [$searchtags, $searchterm], | 147 | [$searchTags, $searchTerm], |
118 | $caseSensitive, | 148 | $caseSensitive, |
119 | $visibility, | 149 | $visibility, |
120 | $untaggedOnly | 150 | $untaggedOnly |
@@ -124,7 +154,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
124 | /** | 154 | /** |
125 | * @inheritDoc | 155 | * @inheritDoc |
126 | */ | 156 | */ |
127 | public function get($id, $visibility = null) | 157 | public function get(int $id, string $visibility = null): Bookmark |
128 | { | 158 | { |
129 | if (! isset($this->bookmarks[$id])) { | 159 | if (! isset($this->bookmarks[$id])) { |
130 | throw new BookmarkNotFoundException(); | 160 | throw new BookmarkNotFoundException(); |
@@ -147,20 +177,17 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
147 | /** | 177 | /** |
148 | * @inheritDoc | 178 | * @inheritDoc |
149 | */ | 179 | */ |
150 | public function set($bookmark, $save = true) | 180 | public function set(Bookmark $bookmark, bool $save = true): Bookmark |
151 | { | 181 | { |
152 | if ($this->isLoggedIn !== true) { | 182 | if (true !== $this->isLoggedIn) { |
153 | throw new Exception(t('You\'re not authorized to alter the datastore')); | 183 | throw new Exception(t('You\'re not authorized to alter the datastore')); |
154 | } | 184 | } |
155 | if (! $bookmark instanceof Bookmark) { | ||
156 | throw new Exception(t('Provided data is invalid')); | ||
157 | } | ||
158 | if (! isset($this->bookmarks[$bookmark->getId()])) { | 185 | if (! isset($this->bookmarks[$bookmark->getId()])) { |
159 | throw new BookmarkNotFoundException(); | 186 | throw new BookmarkNotFoundException(); |
160 | } | 187 | } |
161 | $bookmark->validate(); | 188 | $bookmark->validate(); |
162 | 189 | ||
163 | $bookmark->setUpdated(new \DateTime()); | 190 | $bookmark->setUpdated(new DateTime()); |
164 | $this->bookmarks[$bookmark->getId()] = $bookmark; | 191 | $this->bookmarks[$bookmark->getId()] = $bookmark; |
165 | if ($save === true) { | 192 | if ($save === true) { |
166 | $this->save(); | 193 | $this->save(); |
@@ -172,15 +199,12 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
172 | /** | 199 | /** |
173 | * @inheritDoc | 200 | * @inheritDoc |
174 | */ | 201 | */ |
175 | public function add($bookmark, $save = true) | 202 | public function add(Bookmark $bookmark, bool $save = true): Bookmark |
176 | { | 203 | { |
177 | if ($this->isLoggedIn !== true) { | 204 | if (true !== $this->isLoggedIn) { |
178 | throw new Exception(t('You\'re not authorized to alter the datastore')); | 205 | throw new Exception(t('You\'re not authorized to alter the datastore')); |
179 | } | 206 | } |
180 | if (! $bookmark instanceof Bookmark) { | 207 | if (!empty($bookmark->getId())) { |
181 | throw new Exception(t('Provided data is invalid')); | ||
182 | } | ||
183 | if (! empty($bookmark->getId())) { | ||
184 | throw new Exception(t('This bookmarks already exists')); | 208 | throw new Exception(t('This bookmarks already exists')); |
185 | } | 209 | } |
186 | $bookmark->setId($this->bookmarks->getNextId()); | 210 | $bookmark->setId($this->bookmarks->getNextId()); |
@@ -197,14 +221,11 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
197 | /** | 221 | /** |
198 | * @inheritDoc | 222 | * @inheritDoc |
199 | */ | 223 | */ |
200 | public function addOrSet($bookmark, $save = true) | 224 | public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark |
201 | { | 225 | { |
202 | if ($this->isLoggedIn !== true) { | 226 | if (true !== $this->isLoggedIn) { |
203 | throw new Exception(t('You\'re not authorized to alter the datastore')); | 227 | throw new Exception(t('You\'re not authorized to alter the datastore')); |
204 | } | 228 | } |
205 | if (! $bookmark instanceof Bookmark) { | ||
206 | throw new Exception('Provided data is invalid'); | ||
207 | } | ||
208 | if ($bookmark->getId() === null) { | 229 | if ($bookmark->getId() === null) { |
209 | return $this->add($bookmark, $save); | 230 | return $this->add($bookmark, $save); |
210 | } | 231 | } |
@@ -214,14 +235,11 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
214 | /** | 235 | /** |
215 | * @inheritDoc | 236 | * @inheritDoc |
216 | */ | 237 | */ |
217 | public function remove($bookmark, $save = true) | 238 | public function remove(Bookmark $bookmark, bool $save = true): void |
218 | { | 239 | { |
219 | if ($this->isLoggedIn !== true) { | 240 | if (true !== $this->isLoggedIn) { |
220 | throw new Exception(t('You\'re not authorized to alter the datastore')); | 241 | throw new Exception(t('You\'re not authorized to alter the datastore')); |
221 | } | 242 | } |
222 | if (! $bookmark instanceof Bookmark) { | ||
223 | throw new Exception(t('Provided data is invalid')); | ||
224 | } | ||
225 | if (! isset($this->bookmarks[$bookmark->getId()])) { | 243 | if (! isset($this->bookmarks[$bookmark->getId()])) { |
226 | throw new BookmarkNotFoundException(); | 244 | throw new BookmarkNotFoundException(); |
227 | } | 245 | } |
@@ -236,7 +254,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
236 | /** | 254 | /** |
237 | * @inheritDoc | 255 | * @inheritDoc |
238 | */ | 256 | */ |
239 | public function exists($id, $visibility = null) | 257 | public function exists(int $id, string $visibility = null): bool |
240 | { | 258 | { |
241 | if (! isset($this->bookmarks[$id])) { | 259 | if (! isset($this->bookmarks[$id])) { |
242 | return false; | 260 | return false; |
@@ -259,7 +277,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
259 | /** | 277 | /** |
260 | * @inheritDoc | 278 | * @inheritDoc |
261 | */ | 279 | */ |
262 | public function count($visibility = null) | 280 | public function count(string $visibility = null): int |
263 | { | 281 | { |
264 | return count($this->search([], $visibility)); | 282 | return count($this->search([], $visibility)); |
265 | } | 283 | } |
@@ -267,21 +285,22 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
267 | /** | 285 | /** |
268 | * @inheritDoc | 286 | * @inheritDoc |
269 | */ | 287 | */ |
270 | public function save() | 288 | public function save(): void |
271 | { | 289 | { |
272 | if (!$this->isLoggedIn) { | 290 | if (true !== $this->isLoggedIn) { |
273 | // TODO: raise an Exception instead | 291 | // TODO: raise an Exception instead |
274 | die('You are not authorized to change the database.'); | 292 | die('You are not authorized to change the database.'); |
275 | } | 293 | } |
294 | |||
276 | $this->bookmarks->reorder(); | 295 | $this->bookmarks->reorder(); |
277 | $this->bookmarksIO->write($this->bookmarks); | 296 | $this->bookmarksIO->write($this->bookmarks); |
278 | invalidateCaches($this->conf->get('resource.page_cache')); | 297 | $this->pageCacheManager->invalidateCaches(); |
279 | } | 298 | } |
280 | 299 | ||
281 | /** | 300 | /** |
282 | * @inheritDoc | 301 | * @inheritDoc |
283 | */ | 302 | */ |
284 | public function bookmarksCountPerTag($filteringTags = [], $visibility = null) | 303 | public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array |
285 | { | 304 | { |
286 | $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); | 305 | $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); |
287 | $tags = []; | 306 | $tags = []; |
@@ -291,6 +310,7 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
291 | if (empty($tag) | 310 | if (empty($tag) |
292 | || (! $this->isLoggedIn && startsWith($tag, '.')) | 311 | || (! $this->isLoggedIn && startsWith($tag, '.')) |
293 | || $tag === BookmarkMarkdownFormatter::NO_MD_TAG | 312 | || $tag === BookmarkMarkdownFormatter::NO_MD_TAG |
313 | || in_array($tag, $filteringTags, true) | ||
294 | ) { | 314 | ) { |
295 | continue; | 315 | continue; |
296 | } | 316 | } |
@@ -316,45 +336,68 @@ class BookmarkFileService implements BookmarkServiceInterface | |||
316 | $keys = array_keys($tags); | 336 | $keys = array_keys($tags); |
317 | $tmpTags = array_combine($keys, $keys); | 337 | $tmpTags = array_combine($keys, $keys); |
318 | array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); | 338 | array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); |
339 | |||
319 | return $tags; | 340 | return $tags; |
320 | } | 341 | } |
321 | 342 | ||
322 | /** | 343 | /** |
323 | * @inheritDoc | 344 | * @inheritDoc |
324 | */ | 345 | */ |
325 | public function days() | 346 | public function findByDate( |
326 | { | 347 | \DateTimeInterface $from, |
327 | $bookmarkDays = []; | 348 | \DateTimeInterface $to, |
328 | foreach ($this->search() as $bookmark) { | 349 | ?\DateTimeInterface &$previous, |
329 | $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; | 350 | ?\DateTimeInterface &$next |
351 | ): array { | ||
352 | $out = []; | ||
353 | $previous = null; | ||
354 | $next = null; | ||
355 | |||
356 | foreach ($this->search([], null, false, false, true) as $bookmark) { | ||
357 | if ($to < $bookmark->getCreated()) { | ||
358 | $next = $bookmark->getCreated(); | ||
359 | } else if ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) { | ||
360 | $out[] = $bookmark; | ||
361 | } else { | ||
362 | if ($previous !== null) { | ||
363 | break; | ||
364 | } | ||
365 | $previous = $bookmark->getCreated(); | ||
366 | } | ||
330 | } | 367 | } |
331 | $bookmarkDays = array_keys($bookmarkDays); | ||
332 | sort($bookmarkDays); | ||
333 | 368 | ||
334 | return $bookmarkDays; | 369 | return $out; |
335 | } | 370 | } |
336 | 371 | ||
337 | /** | 372 | /** |
338 | * @inheritDoc | 373 | * @inheritDoc |
339 | */ | 374 | */ |
340 | public function filterDay($request) | 375 | public function getLatest(): ?Bookmark |
341 | { | 376 | { |
342 | return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request); | 377 | foreach ($this->search([], null, false, false, true) as $bookmark) { |
378 | return $bookmark; | ||
379 | } | ||
380 | |||
381 | return null; | ||
343 | } | 382 | } |
344 | 383 | ||
345 | /** | 384 | /** |
346 | * @inheritDoc | 385 | * @inheritDoc |
347 | */ | 386 | */ |
348 | public function initialize() | 387 | public function initialize(): void |
349 | { | 388 | { |
350 | $initializer = new BookmarkInitializer($this); | 389 | $initializer = new BookmarkInitializer($this); |
351 | $initializer->initialize(); | 390 | $initializer->initialize(); |
391 | |||
392 | if (true === $this->isLoggedIn) { | ||
393 | $this->save(); | ||
394 | } | ||
352 | } | 395 | } |
353 | 396 | ||
354 | /** | 397 | /** |
355 | * Handles migration to the new database format (BookmarksArray). | 398 | * Handles migration to the new database format (BookmarksArray). |
356 | */ | 399 | */ |
357 | protected function migrate() | 400 | protected function migrate(): void |
358 | { | 401 | { |
359 | $bookmarkDb = new LegacyLinkDB( | 402 | $bookmarkDb = new LegacyLinkDB( |
360 | $this->conf->get('resource.datastore'), | 403 | $this->conf->get('resource.datastore'), |
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php index fd556679..c79386ea 100644 --- a/application/bookmark/BookmarkFilter.php +++ b/application/bookmark/BookmarkFilter.php | |||
@@ -1,5 +1,7 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
5 | use Exception; | 7 | use Exception; |
@@ -77,8 +79,13 @@ class BookmarkFilter | |||
77 | * | 79 | * |
78 | * @throws BookmarkNotFoundException | 80 | * @throws BookmarkNotFoundException |
79 | */ | 81 | */ |
80 | public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) | 82 | public function filter( |
81 | { | 83 | string $type, |
84 | $request, | ||
85 | bool $casesensitive = false, | ||
86 | string $visibility = 'all', | ||
87 | bool $untaggedonly = false | ||
88 | ) { | ||
82 | if (!in_array($visibility, ['all', 'public', 'private'])) { | 89 | if (!in_array($visibility, ['all', 'public', 'private'])) { |
83 | $visibility = 'all'; | 90 | $visibility = 'all'; |
84 | } | 91 | } |
@@ -115,7 +122,7 @@ class BookmarkFilter | |||
115 | return $this->filterTags($request, $casesensitive, $visibility); | 122 | return $this->filterTags($request, $casesensitive, $visibility); |
116 | } | 123 | } |
117 | case self::$FILTER_DAY: | 124 | case self::$FILTER_DAY: |
118 | return $this->filterDay($request); | 125 | return $this->filterDay($request, $visibility); |
119 | default: | 126 | default: |
120 | return $this->noFilter($visibility); | 127 | return $this->noFilter($visibility); |
121 | } | 128 | } |
@@ -128,7 +135,7 @@ class BookmarkFilter | |||
128 | * | 135 | * |
129 | * @return Bookmark[] filtered bookmarks. | 136 | * @return Bookmark[] filtered bookmarks. |
130 | */ | 137 | */ |
131 | private function noFilter($visibility = 'all') | 138 | private function noFilter(string $visibility = 'all') |
132 | { | 139 | { |
133 | if ($visibility === 'all') { | 140 | if ($visibility === 'all') { |
134 | return $this->bookmarks; | 141 | return $this->bookmarks; |
@@ -151,11 +158,11 @@ class BookmarkFilter | |||
151 | * | 158 | * |
152 | * @param string $smallHash permalink hash. | 159 | * @param string $smallHash permalink hash. |
153 | * | 160 | * |
154 | * @return array $filtered array containing permalink data. | 161 | * @return Bookmark[] $filtered array containing permalink data. |
155 | * | 162 | * |
156 | * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link. | 163 | * @throws BookmarkNotFoundException if the smallhash doesn't match any link. |
157 | */ | 164 | */ |
158 | private function filterSmallHash($smallHash) | 165 | private function filterSmallHash(string $smallHash) |
159 | { | 166 | { |
160 | foreach ($this->bookmarks as $key => $l) { | 167 | foreach ($this->bookmarks as $key => $l) { |
161 | if ($smallHash == $l->getShortUrl()) { | 168 | if ($smallHash == $l->getShortUrl()) { |
@@ -186,15 +193,15 @@ class BookmarkFilter | |||
186 | * @param string $searchterms search query. | 193 | * @param string $searchterms search query. |
187 | * @param string $visibility Optional: return only all/private/public bookmarks. | 194 | * @param string $visibility Optional: return only all/private/public bookmarks. |
188 | * | 195 | * |
189 | * @return array search results. | 196 | * @return Bookmark[] search results. |
190 | */ | 197 | */ |
191 | private function filterFulltext($searchterms, $visibility = 'all') | 198 | private function filterFulltext(string $searchterms, string $visibility = 'all') |
192 | { | 199 | { |
193 | if (empty($searchterms)) { | 200 | if (empty($searchterms)) { |
194 | return $this->noFilter($visibility); | 201 | return $this->noFilter($visibility); |
195 | } | 202 | } |
196 | 203 | ||
197 | $filtered = array(); | 204 | $filtered = []; |
198 | $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); | 205 | $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); |
199 | $exactRegex = '/"([^"]+)"/'; | 206 | $exactRegex = '/"([^"]+)"/'; |
200 | // Retrieve exact search terms. | 207 | // Retrieve exact search terms. |
@@ -206,8 +213,8 @@ class BookmarkFilter | |||
206 | $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); | 213 | $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); |
207 | 214 | ||
208 | // Filter excluding terms and update andSearch. | 215 | // Filter excluding terms and update andSearch. |
209 | $excludeSearch = array(); | 216 | $excludeSearch = []; |
210 | $andSearch = array(); | 217 | $andSearch = []; |
211 | foreach ($explodedSearchAnd as $needle) { | 218 | foreach ($explodedSearchAnd as $needle) { |
212 | if ($needle[0] == '-' && strlen($needle) > 1) { | 219 | if ($needle[0] == '-' && strlen($needle) > 1) { |
213 | $excludeSearch[] = substr($needle, 1); | 220 | $excludeSearch[] = substr($needle, 1); |
@@ -227,33 +234,38 @@ class BookmarkFilter | |||
227 | } | 234 | } |
228 | } | 235 | } |
229 | 236 | ||
230 | // Concatenate link fields to search across fields. | 237 | $lengths = []; |
231 | // Adds a '\' separator for exact search terms. | 238 | $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 | 239 | ||
237 | // Be optimistic | 240 | // Be optimistic |
238 | $found = true; | 241 | $found = true; |
242 | $foundPositions = []; | ||
239 | 243 | ||
240 | // First, we look for exact term search | 244 | // First, we look for exact term search |
241 | for ($i = 0; $i < count($exactSearch) && $found; $i++) { | 245 | // 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. | 246 | // no need to check for the others. We want all or nothing. |
247 | for ($i = 0; $i < count($andSearch) && $found; $i++) { | 247 | foreach ([$exactSearch, $andSearch] as $search) { |
248 | $found = strpos($content, $andSearch[$i]) !== false; | 248 | for ($i = 0; $i < count($search) && $found !== false; $i++) { |
249 | $found = mb_strpos($content, $search[$i]); | ||
250 | if ($found === false) { | ||
251 | break; | ||
252 | } | ||
253 | |||
254 | $foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])]; | ||
255 | } | ||
249 | } | 256 | } |
250 | 257 | ||
251 | // Exclude terms. | 258 | // Exclude terms. |
252 | for ($i = 0; $i < count($excludeSearch) && $found; $i++) { | 259 | for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) { |
253 | $found = strpos($content, $excludeSearch[$i]) === false; | 260 | $found = strpos($content, $excludeSearch[$i]) === false; |
254 | } | 261 | } |
255 | 262 | ||
256 | if ($found) { | 263 | if ($found !== false) { |
264 | $link->addAdditionalContentEntry( | ||
265 | 'search_highlight', | ||
266 | $this->postProcessFoundPositions($lengths, $foundPositions) | ||
267 | ); | ||
268 | |||
257 | $filtered[$id] = $link; | 269 | $filtered[$id] = $link; |
258 | } | 270 | } |
259 | } | 271 | } |
@@ -268,7 +280,7 @@ class BookmarkFilter | |||
268 | * | 280 | * |
269 | * @return string generated regex fragment | 281 | * @return string generated regex fragment |
270 | */ | 282 | */ |
271 | private static function tag2regex($tag) | 283 | private static function tag2regex(string $tag): string |
272 | { | 284 | { |
273 | $len = strlen($tag); | 285 | $len = strlen($tag); |
274 | if (!$len || $tag === "-" || $tag === "*") { | 286 | if (!$len || $tag === "-" || $tag === "*") { |
@@ -314,13 +326,13 @@ class BookmarkFilter | |||
314 | * You can specify one or more tags, separated by space or a comma, e.g. | 326 | * You can specify one or more tags, separated by space or a comma, e.g. |
315 | * print_r($mydb->filterTags('linux programming')); | 327 | * print_r($mydb->filterTags('linux programming')); |
316 | * | 328 | * |
317 | * @param string $tags list of tags separated by commas or blank spaces. | 329 | * @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. | 330 | * @param bool $casesensitive ignore case if false. |
319 | * @param string $visibility Optional: return only all/private/public bookmarks. | 331 | * @param string $visibility Optional: return only all/private/public bookmarks. |
320 | * | 332 | * |
321 | * @return array filtered bookmarks. | 333 | * @return Bookmark[] filtered bookmarks. |
322 | */ | 334 | */ |
323 | public function filterTags($tags, $casesensitive = false, $visibility = 'all') | 335 | public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all') |
324 | { | 336 | { |
325 | // get single tags (we may get passed an array, even though the docs say different) | 337 | // get single tags (we may get passed an array, even though the docs say different) |
326 | $inputTags = $tags; | 338 | $inputTags = $tags; |
@@ -396,9 +408,9 @@ class BookmarkFilter | |||
396 | * | 408 | * |
397 | * @param string $visibility return only all/private/public bookmarks. | 409 | * @param string $visibility return only all/private/public bookmarks. |
398 | * | 410 | * |
399 | * @return array filtered bookmarks. | 411 | * @return Bookmark[] filtered bookmarks. |
400 | */ | 412 | */ |
401 | public function filterUntagged($visibility) | 413 | public function filterUntagged(string $visibility) |
402 | { | 414 | { |
403 | $filtered = []; | 415 | $filtered = []; |
404 | foreach ($this->bookmarks as $key => $link) { | 416 | foreach ($this->bookmarks as $key => $link) { |
@@ -425,21 +437,26 @@ class BookmarkFilter | |||
425 | * print_r($mydb->filterDay('20120125')); | 437 | * print_r($mydb->filterDay('20120125')); |
426 | * | 438 | * |
427 | * @param string $day day to filter. | 439 | * @param string $day day to filter. |
428 | * | 440 | * @param string $visibility return only all/private/public bookmarks. |
429 | * @return array all link matching given day. | 441 | |
442 | * @return Bookmark[] all link matching given day. | ||
430 | * | 443 | * |
431 | * @throws Exception if date format is invalid. | 444 | * @throws Exception if date format is invalid. |
432 | */ | 445 | */ |
433 | public function filterDay($day) | 446 | public function filterDay(string $day, string $visibility) |
434 | { | 447 | { |
435 | if (!checkDateFormat('Ymd', $day)) { | 448 | if (!checkDateFormat('Ymd', $day)) { |
436 | throw new Exception('Invalid date format'); | 449 | throw new Exception('Invalid date format'); |
437 | } | 450 | } |
438 | 451 | ||
439 | $filtered = array(); | 452 | $filtered = []; |
440 | foreach ($this->bookmarks as $key => $l) { | 453 | foreach ($this->bookmarks as $key => $bookmark) { |
441 | if ($l->getCreated()->format('Ymd') == $day) { | 454 | if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) { |
442 | $filtered[$key] = $l; | 455 | continue; |
456 | } | ||
457 | |||
458 | if ($bookmark->getCreated()->format('Ymd') == $day) { | ||
459 | $filtered[$key] = $bookmark; | ||
443 | } | 460 | } |
444 | } | 461 | } |
445 | 462 | ||
@@ -455,9 +472,9 @@ class BookmarkFilter | |||
455 | * @param string $tags string containing a list of tags. | 472 | * @param string $tags string containing a list of tags. |
456 | * @param bool $casesensitive will convert everything to lowercase if false. | 473 | * @param bool $casesensitive will convert everything to lowercase if false. |
457 | * | 474 | * |
458 | * @return array filtered tags string. | 475 | * @return string[] filtered tags string. |
459 | */ | 476 | */ |
460 | public static function tagsStrToArray($tags, $casesensitive) | 477 | public static function tagsStrToArray(string $tags, bool $casesensitive): array |
461 | { | 478 | { |
462 | // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) | 479 | // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) |
463 | $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); | 480 | $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); |
@@ -465,4 +482,74 @@ class BookmarkFilter | |||
465 | 482 | ||
466 | return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); | 483 | return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); |
467 | } | 484 | } |
485 | |||
486 | /** | ||
487 | * This method finalize the content of the foundPositions array, | ||
488 | * by associated all search results to their associated bookmark field, | ||
489 | * making sure that there is no overlapping results, etc. | ||
490 | * | ||
491 | * @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content. | ||
492 | * @param array $foundPositions Positions where the search results were found in the aggregated content. | ||
493 | * | ||
494 | * @return array Updated $foundPositions, by bookmark field. | ||
495 | */ | ||
496 | protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array | ||
497 | { | ||
498 | // Sort results by starting position ASC. | ||
499 | usort($foundPositions, function (array $entryA, array $entryB): int { | ||
500 | return $entryA['start'] > $entryB['start'] ? 1 : -1; | ||
501 | }); | ||
502 | |||
503 | $out = []; | ||
504 | $currentMax = -1; | ||
505 | foreach ($foundPositions as $foundPosition) { | ||
506 | // we do not allow overlapping highlights | ||
507 | if ($foundPosition['start'] < $currentMax) { | ||
508 | continue; | ||
509 | } | ||
510 | |||
511 | $currentMax = $foundPosition['end']; | ||
512 | foreach ($fieldLengths as $part => $length) { | ||
513 | if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) { | ||
514 | continue; | ||
515 | } | ||
516 | |||
517 | $out[$part][] = [ | ||
518 | 'start' => $foundPosition['start'] - $length['start'], | ||
519 | 'end' => $foundPosition['end'] - $length['start'], | ||
520 | ]; | ||
521 | break; | ||
522 | } | ||
523 | } | ||
524 | |||
525 | return $out; | ||
526 | } | ||
527 | |||
528 | /** | ||
529 | * Concatenate link fields to search across fields. Adds a '\' separator for exact search terms. | ||
530 | * Also populate $length array with starting and ending positions of every bookmark field | ||
531 | * inside concatenated content. | ||
532 | * | ||
533 | * @param Bookmark $link | ||
534 | * @param array $lengths (by reference) | ||
535 | * | ||
536 | * @return string Lowercase concatenated fields content. | ||
537 | */ | ||
538 | protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string | ||
539 | { | ||
540 | $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; | ||
541 | $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; | ||
542 | $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; | ||
543 | $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; | ||
544 | |||
545 | $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())]; | ||
546 | $nextField = $lengths['title']['end'] + 1; | ||
547 | $lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())]; | ||
548 | $nextField = $lengths['description']['end'] + 1; | ||
549 | $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())]; | ||
550 | $nextField = $lengths['url']['end'] + 1; | ||
551 | $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getTagsString())]; | ||
552 | |||
553 | return $content; | ||
554 | } | ||
468 | } | 555 | } |
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php index ae9ffcb4..f40fa476 100644 --- a/application/bookmark/BookmarkIO.php +++ b/application/bookmark/BookmarkIO.php | |||
@@ -1,7 +1,12 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
7 | use malkusch\lock\mutex\Mutex; | ||
8 | use malkusch\lock\mutex\NoMutex; | ||
9 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | ||
5 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; | 10 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; |
6 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; | 11 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; |
7 | use Shaarli\Config\ConfigManager; | 12 | use Shaarli\Config\ConfigManager; |
@@ -26,11 +31,14 @@ class BookmarkIO | |||
26 | */ | 31 | */ |
27 | protected $conf; | 32 | protected $conf; |
28 | 33 | ||
34 | |||
35 | /** @var Mutex */ | ||
36 | protected $mutex; | ||
37 | |||
29 | /** | 38 | /** |
30 | * string Datastore PHP prefix | 39 | * string Datastore PHP prefix |
31 | */ | 40 | */ |
32 | protected static $phpPrefix = '<?php /* '; | 41 | protected static $phpPrefix = '<?php /* '; |
33 | |||
34 | /** | 42 | /** |
35 | * string Datastore PHP suffix | 43 | * string Datastore PHP suffix |
36 | */ | 44 | */ |
@@ -41,35 +49,46 @@ class BookmarkIO | |||
41 | * | 49 | * |
42 | * @param ConfigManager $conf instance | 50 | * @param ConfigManager $conf instance |
43 | */ | 51 | */ |
44 | public function __construct($conf) | 52 | public function __construct(ConfigManager $conf, Mutex $mutex = null) |
45 | { | 53 | { |
54 | if ($mutex === null) { | ||
55 | // This should only happen with legacy classes | ||
56 | $mutex = new NoMutex(); | ||
57 | } | ||
46 | $this->conf = $conf; | 58 | $this->conf = $conf; |
47 | $this->datastore = $conf->get('resource.datastore'); | 59 | $this->datastore = $conf->get('resource.datastore'); |
60 | $this->mutex = $mutex; | ||
48 | } | 61 | } |
49 | 62 | ||
50 | /** | 63 | /** |
51 | * Reads database from disk to memory | 64 | * Reads database from disk to memory |
52 | * | 65 | * |
53 | * @return BookmarkArray instance | 66 | * @return Bookmark[] |
54 | * | 67 | * |
55 | * @throws NotWritableDataStoreException Data couldn't be loaded | 68 | * @throws NotWritableDataStoreException Data couldn't be loaded |
56 | * @throws EmptyDataStoreException Datastore doesn't exist | 69 | * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark |
70 | * @throws DatastoreNotInitializedException File does not exists | ||
57 | */ | 71 | */ |
58 | public function read() | 72 | public function read() |
59 | { | 73 | { |
60 | if (! file_exists($this->datastore)) { | 74 | if (! file_exists($this->datastore)) { |
61 | throw new EmptyDataStoreException(); | 75 | throw new DatastoreNotInitializedException(); |
62 | } | 76 | } |
63 | 77 | ||
64 | if (!is_writable($this->datastore)) { | 78 | if (!is_writable($this->datastore)) { |
65 | throw new NotWritableDataStoreException($this->datastore); | 79 | throw new NotWritableDataStoreException($this->datastore); |
66 | } | 80 | } |
67 | 81 | ||
82 | $content = null; | ||
83 | $this->mutex->synchronized(function () use (&$content) { | ||
84 | $content = file_get_contents($this->datastore); | ||
85 | }); | ||
86 | |||
68 | // Note that gzinflate is faster than gzuncompress. | 87 | // Note that gzinflate is faster than gzuncompress. |
69 | // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 | 88 | // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 |
70 | $links = unserialize(gzinflate(base64_decode( | 89 | $links = unserialize(gzinflate(base64_decode( |
71 | substr(file_get_contents($this->datastore), | 90 | substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix)) |
72 | strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); | 91 | ))); |
73 | 92 | ||
74 | if (empty($links)) { | 93 | if (empty($links)) { |
75 | if (filesize($this->datastore) > 100) { | 94 | if (filesize($this->datastore) > 100) { |
@@ -84,7 +103,7 @@ class BookmarkIO | |||
84 | /** | 103 | /** |
85 | * Saves the database from memory to disk | 104 | * Saves the database from memory to disk |
86 | * | 105 | * |
87 | * @param BookmarkArray $links instance. | 106 | * @param Bookmark[] $links |
88 | * | 107 | * |
89 | * @throws NotWritableDataStoreException the datastore is not writable | 108 | * @throws NotWritableDataStoreException the datastore is not writable |
90 | */ | 109 | */ |
@@ -98,11 +117,13 @@ class BookmarkIO | |||
98 | throw new NotWritableDataStoreException(dirname($this->datastore)); | 117 | throw new NotWritableDataStoreException(dirname($this->datastore)); |
99 | } | 118 | } |
100 | 119 | ||
101 | file_put_contents( | 120 | $data = self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix; |
102 | $this->datastore, | ||
103 | self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix | ||
104 | ); | ||
105 | 121 | ||
106 | invalidateCaches($this->conf->get('resource.page_cache')); | 122 | $this->mutex->synchronized(function () use ($data) { |
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 9eee9a35..04b996f3 100644 --- a/application/bookmark/BookmarkInitializer.php +++ b/application/bookmark/BookmarkInitializer.php | |||
@@ -1,13 +1,14 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | declare(strict_types=1); | ||
4 | |||
3 | namespace Shaarli\Bookmark; | 5 | namespace Shaarli\Bookmark; |
4 | 6 | ||
5 | /** | 7 | /** |
6 | * Class BookmarkInitializer | 8 | * Class BookmarkInitializer |
7 | * | 9 | * |
8 | * This class is used to initialized default bookmarks after a fresh install of Shaarli. | 10 | * This class is used to initialized default bookmarks after a fresh install of Shaarli. |
9 | * It is no longer call when the data store is empty, | 11 | * It should be only called if the datastore file does not exist(users might want to delete the default bookmarks). |
10 | * because user might want to delete default bookmarks after the install. | ||
11 | * | 12 | * |
12 | * To prevent data corruption, it does not overwrite existing bookmarks, | 13 | * To prevent data corruption, it does not overwrite existing bookmarks, |
13 | * even though there should not be any. | 14 | * even though there should not be any. |
@@ -24,7 +25,7 @@ class BookmarkInitializer | |||
24 | * | 25 | * |
25 | * @param BookmarkServiceInterface $bookmarkService | 26 | * @param BookmarkServiceInterface $bookmarkService |
26 | */ | 27 | */ |
27 | public function __construct($bookmarkService) | 28 | public function __construct(BookmarkServiceInterface $bookmarkService) |
28 | { | 29 | { |
29 | $this->bookmarkService = $bookmarkService; | 30 | $this->bookmarkService = $bookmarkService; |
30 | } | 31 | } |
@@ -32,28 +33,80 @@ class BookmarkInitializer | |||
32 | /** | 33 | /** |
33 | * Initialize the data store with default bookmarks | 34 | * Initialize the data store with default bookmarks |
34 | */ | 35 | */ |
35 | public function initialize() | 36 | public function initialize(): void |
36 | { | 37 | { |
37 | $bookmark = new Bookmark(); | 38 | $bookmark = new Bookmark(); |
38 | $bookmark->setTitle(t('My secret stuff... - Pastebin.com')); | 39 | $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)')); |
39 | $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', []); | 40 | $bookmark->setUrl('https://vimeo.com/153493904'); |
40 | $bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.')); | 41 | $bookmark->setDescription(t( |
41 | $bookmark->setTagsString('secretstuff'); | 42 | 'Shaarli will automatically pick up the thumbnail for links to a variety of websites. |
43 | |||
44 | Explore your new Shaarli instance by trying out controls and menus. | ||
45 | Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. | ||
46 | |||
47 | Now you can edit or delete the default shaares. | ||
48 | ' | ||
49 | )); | ||
50 | $bookmark->setTagsString('shaarli help thumbnail'); | ||
51 | $bookmark->setPrivate(true); | ||
52 | $this->bookmarkService->add($bookmark, false); | ||
53 | |||
54 | $bookmark = new Bookmark(); | ||
55 | $bookmark->setTitle(t('Note: Shaare descriptions')); | ||
56 | $bookmark->setDescription(t( | ||
57 | 'Adding a shaare without entering a URL creates a text-only "note" post such as this one. | ||
58 | This note is private, so you are the only one able to see it while logged in. | ||
59 | |||
60 | You can use this to keep notes, post articles, code snippets, and much more. | ||
61 | |||
62 | The Markdown formatting setting allows you to format your notes and bookmark description: | ||
63 | |||
64 | ### Title headings | ||
65 | |||
66 | #### Multiple headings levels | ||
67 | * bullet lists | ||
68 | * _italic_ text | ||
69 | * **bold** text | ||
70 | * ~~strike through~~ text | ||
71 | * `code` blocks | ||
72 | * images | ||
73 | * [links](https://en.wikipedia.org/wiki/Markdown) | ||
74 | |||
75 | Markdown also supports tables: | ||
76 | |||
77 | | Name | Type | Color | Qty | | ||
78 | | ------- | --------- | ------ | ----- | | ||
79 | | Orange | Fruit | Orange | 126 | | ||
80 | | Apple | Fruit | Any | 62 | | ||
81 | | Lemon | Fruit | Yellow | 30 | | ||
82 | | Carrot | Vegetable | Red | 14 | | ||
83 | ' | ||
84 | )); | ||
85 | $bookmark->setTagsString('shaarli help'); | ||
42 | $bookmark->setPrivate(true); | 86 | $bookmark->setPrivate(true); |
43 | $this->bookmarkService->add($bookmark); | 87 | $this->bookmarkService->add($bookmark, false); |
44 | 88 | ||
45 | $bookmark = new Bookmark(); | 89 | $bookmark = new Bookmark(); |
46 | $bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service')); | 90 | $bookmark->setTitle( |
47 | $bookmark->setUrl('https://shaarli.readthedocs.io', []); | 91 | 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') |
92 | ); | ||
48 | $bookmark->setDescription(t( | 93 | $bookmark->setDescription(t( |
49 | 'Welcome to Shaarli! This is your first public bookmark. ' | 94 | 'Welcome to Shaarli! |
50 | . 'To edit or delete me, you must first login. | 95 | |
96 | Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. | ||
97 | You can add a description to your bookmarks, such as this one, and tag them. | ||
98 | |||
99 | Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.). | ||
51 | 100 | ||
52 | To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. | 101 | You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`). |
102 | Hashtags such as #shaarli #help are also supported. | ||
103 | You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search. | ||
53 | 104 | ||
54 | You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' | 105 | We hope that you will enjoy using Shaarli, maintained with ❤️ by the community! |
106 | Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue. | ||
107 | ' | ||
55 | )); | 108 | )); |
56 | $bookmark->setTagsString('opensource software'); | 109 | $bookmark->setTagsString('shaarli help'); |
57 | $this->bookmarkService->add($bookmark); | 110 | $this->bookmarkService->add($bookmark, false); |
58 | } | 111 | } |
59 | } | 112 | } |
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php index 7b7a4f09..08cdbb4e 100644 --- a/application/bookmark/BookmarkServiceInterface.php +++ b/application/bookmark/BookmarkServiceInterface.php | |||
@@ -1,73 +1,73 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | namespace Shaarli\Bookmark; | 3 | declare(strict_types=1); |
4 | 4 | ||
5 | namespace Shaarli\Bookmark; | ||
5 | 6 | ||
6 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | 7 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
7 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; | 8 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; |
8 | use Shaarli\Config\ConfigManager; | ||
9 | use Shaarli\Exceptions\IOException; | ||
10 | use Shaarli\History; | ||
11 | 9 | ||
12 | /** | 10 | /** |
13 | * Class BookmarksService | 11 | * Class BookmarksService |
14 | * | 12 | * |
15 | * 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. | ||
16 | */ | 17 | */ |
17 | interface BookmarkServiceInterface | 18 | interface BookmarkServiceInterface |
18 | { | 19 | { |
19 | /** | 20 | /** |
20 | * BookmarksService constructor. | ||
21 | * | ||
22 | * @param ConfigManager $conf instance | ||
23 | * @param History $history instance | ||
24 | * @param bool $isLoggedIn true if the current user is logged in | ||
25 | */ | ||
26 | public function __construct(ConfigManager $conf, History $history, $isLoggedIn); | ||
27 | |||
28 | /** | ||
29 | * Find a bookmark by hash | 21 | * Find a bookmark by hash |
30 | * | 22 | * |
31 | * @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 | ||
32 | * | 25 | * |
33 | * @return mixed | 26 | * @return Bookmark |
34 | * | 27 | * |
35 | * @throws \Exception | 28 | * @throws \Exception |
36 | */ | 29 | */ |
37 | public function findByHash($hash); | 30 | public function findByHash(string $hash, string $privateKey = null); |
38 | 31 | ||
39 | /** | 32 | /** |
40 | * @param $url | 33 | * @param $url |
41 | * | 34 | * |
42 | * @return Bookmark|null | 35 | * @return Bookmark|null |
43 | */ | 36 | */ |
44 | public function findByUrl($url); | 37 | public function findByUrl(string $url): ?Bookmark; |
45 | 38 | ||
46 | /** | 39 | /** |
47 | * Search bookmarks | 40 | * Search bookmarks |
48 | * | 41 | * |
49 | * @param mixed $request | 42 | * @param array $request |
50 | * @param string $visibility | 43 | * @param ?string $visibility |
51 | * @param bool $caseSensitive | 44 | * @param bool $caseSensitive |
52 | * @param bool $untaggedOnly | 45 | * @param bool $untaggedOnly |
46 | * @param bool $ignoreSticky | ||
53 | * | 47 | * |
54 | * @return Bookmark[] | 48 | * @return Bookmark[] |
55 | */ | 49 | */ |
56 | public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false); | 50 | public function search( |
51 | array $request = [], | ||
52 | string $visibility = null, | ||
53 | bool $caseSensitive = false, | ||
54 | bool $untaggedOnly = false, | ||
55 | bool $ignoreSticky = false | ||
56 | ); | ||
57 | 57 | ||
58 | /** | 58 | /** |
59 | * Get a single bookmark by its ID. | 59 | * Get a single bookmark by its ID. |
60 | * | 60 | * |
61 | * @param int $id Bookmark ID | 61 | * @param int $id Bookmark ID |
62 | * @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 |
63 | * exception | 63 | * exception |
64 | * | 64 | * |
65 | * @return Bookmark | 65 | * @return Bookmark |
66 | * | 66 | * |
67 | * @throws BookmarkNotFoundException | 67 | * @throws BookmarkNotFoundException |
68 | * @throws \Exception | 68 | * @throws \Exception |
69 | */ | 69 | */ |
70 | public function get($id, $visibility = null); | 70 | public function get(int $id, string $visibility = null); |
71 | 71 | ||
72 | /** | 72 | /** |
73 | * Updates an existing bookmark (depending on its ID). | 73 | * Updates an existing bookmark (depending on its ID). |
@@ -80,7 +80,7 @@ interface BookmarkServiceInterface | |||
80 | * @throws BookmarkNotFoundException | 80 | * @throws BookmarkNotFoundException |
81 | * @throws \Exception | 81 | * @throws \Exception |
82 | */ | 82 | */ |
83 | public function set($bookmark, $save = true); | 83 | public function set(Bookmark $bookmark, bool $save = true): Bookmark; |
84 | 84 | ||
85 | /** | 85 | /** |
86 | * Adds a new bookmark (the ID must be empty). | 86 | * Adds a new bookmark (the ID must be empty). |
@@ -92,7 +92,7 @@ interface BookmarkServiceInterface | |||
92 | * | 92 | * |
93 | * @throws \Exception | 93 | * @throws \Exception |
94 | */ | 94 | */ |
95 | public function add($bookmark, $save = true); | 95 | public function add(Bookmark $bookmark, bool $save = true): Bookmark; |
96 | 96 | ||
97 | /** | 97 | /** |
98 | * Adds or updates a bookmark depending on its ID: | 98 | * Adds or updates a bookmark depending on its ID: |
@@ -106,7 +106,7 @@ interface BookmarkServiceInterface | |||
106 | * | 106 | * |
107 | * @throws \Exception | 107 | * @throws \Exception |
108 | */ | 108 | */ |
109 | public function addOrSet($bookmark, $save = true); | 109 | public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark; |
110 | 110 | ||
111 | /** | 111 | /** |
112 | * Deletes a bookmark. | 112 | * Deletes a bookmark. |
@@ -116,65 +116,72 @@ interface BookmarkServiceInterface | |||
116 | * | 116 | * |
117 | * @throws \Exception | 117 | * @throws \Exception |
118 | */ | 118 | */ |
119 | public function remove($bookmark, $save = true); | 119 | public function remove(Bookmark $bookmark, bool $save = true): void; |
120 | 120 | ||
121 | /** | 121 | /** |
122 | * Get a single bookmark by its ID. | 122 | * Get a single bookmark by its ID. |
123 | * | 123 | * |
124 | * @param int $id Bookmark ID | 124 | * @param int $id Bookmark ID |
125 | * @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 |
126 | * exception | 126 | * exception |
127 | * | 127 | * |
128 | * @return bool | 128 | * @return bool |
129 | */ | 129 | */ |
130 | public function exists($id, $visibility = null); | 130 | public function exists(int $id, string $visibility = null): bool; |
131 | 131 | ||
132 | /** | 132 | /** |
133 | * Return the number of available bookmarks for given visibility. | 133 | * Return the number of available bookmarks for given visibility. |
134 | * | 134 | * |
135 | * @param string $visibility public|private|all | 135 | * @param ?string $visibility public|private|all |
136 | * | 136 | * |
137 | * @return int Number of bookmarks | 137 | * @return int Number of bookmarks |
138 | */ | 138 | */ |
139 | public function count($visibility = null); | 139 | public function count(string $visibility = null): int; |
140 | 140 | ||
141 | /** | 141 | /** |
142 | * Write the datastore. | 142 | * Write the datastore. |
143 | * | 143 | * |
144 | * @throws NotWritableDataStoreException | 144 | * @throws NotWritableDataStoreException |
145 | */ | 145 | */ |
146 | public function save(); | 146 | public function save(): void; |
147 | 147 | ||
148 | /** | 148 | /** |
149 | * 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 |
150 | * | 150 | * |
151 | * @param array $filteringTags tags selecting the bookmarks to consider | 151 | * @param array|null $filteringTags tags selecting the bookmarks to consider |
152 | * @param string $visibility process only all/private/public bookmarks | 152 | * @param string|null $visibility process only all/private/public bookmarks |
153 | * | 153 | * |
154 | * @return array tag => bookmarksCount | 154 | * @return array tag => bookmarksCount |
155 | */ | 155 | */ |
156 | public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all'); | 156 | public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array; |
157 | 157 | ||
158 | /** | 158 | /** |
159 | * 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. | ||
160 | * | 166 | * |
161 | * @return array containing days (in format YYYYMMDD). | 167 | * @return array List of bookmarks matching provided period of time. |
162 | */ | 168 | */ |
163 | public function days(); | 169 | public function findByDate( |
170 | \DateTimeInterface $from, | ||
171 | \DateTimeInterface $to, | ||
172 | ?\DateTimeInterface &$previous, | ||
173 | ?\DateTimeInterface &$next | ||
174 | ): array; | ||
164 | 175 | ||
165 | /** | 176 | /** |
166 | * Returns the list of articles for a given day. | 177 | * Returns the latest bookmark by creation date. |
167 | * | 178 | * |
168 | * @param string $request day to filter. Format: YYYYMMDD. | 179 | * @return Bookmark|null Found Bookmark or null if the datastore is empty. |
169 | * | ||
170 | * @return Bookmark[] list of shaare found. | ||
171 | * | ||
172 | * @throws BookmarkNotFoundException | ||
173 | */ | 180 | */ |
174 | public function filterDay($request); | 181 | public function getLatest(): ?Bookmark; |
175 | 182 | ||
176 | /** | 183 | /** |
177 | * Creates the default database after a fresh install. | 184 | * Creates the default database after a fresh install. |
178 | */ | 185 | */ |
179 | public function initialize(); | 186 | public function initialize(): void; |
180 | } | 187 | } |
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php index 88379430..faf5dbfd 100644 --- a/application/bookmark/LinkUtils.php +++ b/application/bookmark/LinkUtils.php | |||
@@ -3,112 +3,6 @@ | |||
3 | use Shaarli\Bookmark\Bookmark; | 3 | use Shaarli\Bookmark\Bookmark; |
4 | 4 | ||
5 | /** | 5 | /** |
6 | * Get cURL callback function for CURLOPT_WRITEFUNCTION | ||
7 | * | ||
8 | * @param string $charset to extract from the downloaded page (reference) | ||
9 | * @param string $title to extract from the downloaded page (reference) | ||
10 | * @param string $description to extract from the downloaded page (reference) | ||
11 | * @param string $keywords to extract from the downloaded page (reference) | ||
12 | * @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content | ||
13 | * @param string $curlGetInfo Optionally overrides curl_getinfo function | ||
14 | * | ||
15 | * @return Closure | ||
16 | */ | ||
17 | function get_curl_download_callback( | ||
18 | &$charset, | ||
19 | &$title, | ||
20 | &$description, | ||
21 | &$keywords, | ||
22 | $retrieveDescription, | ||
23 | $curlGetInfo = 'curl_getinfo' | ||
24 | ) { | ||
25 | $isRedirected = false; | ||
26 | $currentChunk = 0; | ||
27 | $foundChunk = null; | ||
28 | |||
29 | /** | ||
30 | * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download). | ||
31 | * | ||
32 | * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text' | ||
33 | * Then we extract the title and the charset and stop the download when it's done. | ||
34 | * | ||
35 | * @param resource $ch cURL resource | ||
36 | * @param string $data chunk of data being downloaded | ||
37 | * | ||
38 | * @return int|bool length of $data or false if we need to stop the download | ||
39 | */ | ||
40 | return function (&$ch, $data) use ( | ||
41 | $retrieveDescription, | ||
42 | $curlGetInfo, | ||
43 | &$charset, | ||
44 | &$title, | ||
45 | &$description, | ||
46 | &$keywords, | ||
47 | &$isRedirected, | ||
48 | &$currentChunk, | ||
49 | &$foundChunk | ||
50 | ) { | ||
51 | $currentChunk++; | ||
52 | $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); | ||
53 | if (!empty($responseCode) && in_array($responseCode, [301, 302])) { | ||
54 | $isRedirected = true; | ||
55 | return strlen($data); | ||
56 | } | ||
57 | if (!empty($responseCode) && $responseCode !== 200) { | ||
58 | return false; | ||
59 | } | ||
60 | // After a redirection, the content type will keep the previous request value | ||
61 | // until it finds the next content-type header. | ||
62 | if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) { | ||
63 | $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE); | ||
64 | } | ||
65 | if (!empty($contentType) && strpos($contentType, 'text/html') === false) { | ||
66 | return false; | ||
67 | } | ||
68 | if (!empty($contentType) && empty($charset)) { | ||
69 | $charset = header_extract_charset($contentType); | ||
70 | } | ||
71 | if (empty($charset)) { | ||
72 | $charset = html_extract_charset($data); | ||
73 | } | ||
74 | if (empty($title)) { | ||
75 | $title = html_extract_title($data); | ||
76 | $foundChunk = ! empty($title) ? $currentChunk : $foundChunk; | ||
77 | } | ||
78 | if ($retrieveDescription && empty($description)) { | ||
79 | $description = html_extract_tag('description', $data); | ||
80 | $foundChunk = ! empty($description) ? $currentChunk : $foundChunk; | ||
81 | } | ||
82 | if ($retrieveDescription && empty($keywords)) { | ||
83 | $keywords = html_extract_tag('keywords', $data); | ||
84 | if (! empty($keywords)) { | ||
85 | $foundChunk = $currentChunk; | ||
86 | // Keywords use the format tag1, tag2 multiple words, tag | ||
87 | // So we format them to match Shaarli's separator and glue multiple words with '-' | ||
88 | $keywords = implode(' ', array_map(function($keyword) { | ||
89 | return implode('-', preg_split('/\s+/', trim($keyword))); | ||
90 | }, explode(',', $keywords))); | ||
91 | } | ||
92 | } | ||
93 | |||
94 | // We got everything we want, stop the download. | ||
95 | // If we already found either the title, description or keywords, | ||
96 | // it's highly unlikely that we'll found the other metas further than | ||
97 | // in the same chunk of data or the next one. So we also stop the download after that. | ||
98 | if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null | ||
99 | && (! $retrieveDescription | ||
100 | || $foundChunk < $currentChunk | ||
101 | || (!empty($title) && !empty($description) && !empty($keywords)) | ||
102 | ) | ||
103 | ) { | ||
104 | return false; | ||
105 | } | ||
106 | |||
107 | return strlen($data); | ||
108 | }; | ||
109 | } | ||
110 | |||
111 | /** | ||
112 | * Extract title from an HTML document. | 6 | * Extract title from an HTML document. |
113 | * | 7 | * |
114 | * @param string $html HTML content where to look for a title. | 8 | * @param string $html HTML content where to look for a title. |
@@ -132,7 +26,7 @@ function html_extract_title($html) | |||
132 | */ | 26 | */ |
133 | function header_extract_charset($header) | 27 | function header_extract_charset($header) |
134 | { | 28 | { |
135 | preg_match('/charset="?([^; ]+)/i', $header, $match); | 29 | preg_match('/charset=["\']?([^; "\']+)/i', $header, $match); |
136 | if (! empty($match[1])) { | 30 | if (! empty($match[1])) { |
137 | return strtolower(trim($match[1])); | 31 | return strtolower(trim($match[1])); |
138 | } | 32 | } |
@@ -172,11 +66,13 @@ function html_extract_tag($tag, $html) | |||
172 | { | 66 | { |
173 | $propertiesKey = ['property', 'name', 'itemprop']; | 67 | $propertiesKey = ['property', 'name', 'itemprop']; |
174 | $properties = implode('|', $propertiesKey); | 68 | $properties = implode('|', $propertiesKey); |
69 | // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"' | ||
70 | $orCondition = '["\']?(?:og:)?'. $tag .'["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]'; | ||
175 | // Try to retrieve OpenGraph image. | 71 | // Try to retrieve OpenGraph image. |
176 | $ogRegex = '#<meta[^>]+(?:'. $properties .')=["\']?(?:og:)?'. $tag .'["\'\s][^>]*content=["\']?(.*?)["\'/>]#'; | 72 | $ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#'; |
177 | // 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) |
178 | // New regex to keep this readable... more or less. | 74 | // New regex to keep this readable... more or less. |
179 | $ogRegexReverse = '#<meta[^>]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#'; | 75 | $ogRegexReverse = '#<meta[^>]+content=["\'](.*?)["\'][^>]+(?:'. $properties .')=(?:'. $orCondition .').*?>#'; |
180 | 76 | ||
181 | if (preg_match($ogRegex, $html, $matches) > 0 | 77 | if (preg_match($ogRegex, $html, $matches) > 0 |
182 | || preg_match($ogRegexReverse, $html, $matches) > 0 | 78 | || preg_match($ogRegexReverse, $html, $matches) > 0 |
@@ -220,7 +116,7 @@ function hashtag_autolink($description, $indexUrl = '') | |||
220 | * \p{Mn} - any non marking space (accents, umlauts, etc) | 116 | * \p{Mn} - any non marking space (accents, umlauts, etc) |
221 | */ | 117 | */ |
222 | $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; | 118 | $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; |
223 | $replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>'; | 119 | $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>'; |
224 | return preg_replace($regex, $replacement, $description); | 120 | return preg_replace($regex, $replacement, $description); |
225 | } | 121 | } |
226 | 122 | ||
diff --git a/application/bookmark/exception/DatastoreNotInitializedException.php b/application/bookmark/exception/DatastoreNotInitializedException.php new file mode 100644 index 00000000..f495049d --- /dev/null +++ b/application/bookmark/exception/DatastoreNotInitializedException.php | |||
@@ -0,0 +1,10 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Bookmark\Exception; | ||
6 | |||
7 | class DatastoreNotInitializedException extends \Exception | ||
8 | { | ||
9 | |||
10 | } | ||
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php index 4509357c..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( |
@@ -46,7 +46,7 @@ class ConfigJson implements ConfigIO | |||
46 | // JSON_PRETTY_PRINT is available from PHP 5.4. | 46 | // JSON_PRETTY_PRINT is available from PHP 5.4. |
47 | $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0; | 47 | $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0; |
48 | $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix(); | 48 | $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix(); |
49 | if (!file_put_contents($filepath, $data)) { | 49 | if (empty($filepath) || !file_put_contents($filepath, $data)) { |
50 | throw new \Shaarli\Exceptions\IOException( | 50 | throw new \Shaarli\Exceptions\IOException( |
51 | $filepath, | 51 | $filepath, |
52 | t('Shaarli could not create the config file. '. | 52 | t('Shaarli could not create the config file. '. |
@@ -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 e45bb4c3..fb085023 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php | |||
@@ -3,6 +3,7 @@ namespace Shaarli\Config; | |||
3 | 3 | ||
4 | use Shaarli\Config\Exception\MissingFieldConfigException; | 4 | use Shaarli\Config\Exception\MissingFieldConfigException; |
5 | use Shaarli\Config\Exception\UnauthorizedConfigException; | 5 | use Shaarli\Config\Exception\UnauthorizedConfigException; |
6 | use Shaarli\Thumbnailer; | ||
6 | 7 | ||
7 | /** | 8 | /** |
8 | * Class ConfigManager | 9 | * Class ConfigManager |
@@ -361,11 +362,12 @@ class ConfigManager | |||
361 | $this->setEmpty('security.open_shaarli', false); | 362 | $this->setEmpty('security.open_shaarli', false); |
362 | $this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']); | 363 | $this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']); |
363 | 364 | ||
364 | $this->setEmpty('general.header_link', '?'); | 365 | $this->setEmpty('general.header_link', '/'); |
365 | $this->setEmpty('general.links_per_page', 20); | 366 | $this->setEmpty('general.links_per_page', 20); |
366 | $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); | 367 | $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); |
367 | $this->setEmpty('general.default_note_title', 'Note: '); | 368 | $this->setEmpty('general.default_note_title', 'Note: '); |
368 | $this->setEmpty('general.retrieve_description', false); | 369 | $this->setEmpty('general.retrieve_description', true); |
370 | $this->setEmpty('general.enable_async_metadata', true); | ||
369 | 371 | ||
370 | $this->setEmpty('updates.check_updates', false); | 372 | $this->setEmpty('updates.check_updates', false); |
371 | $this->setEmpty('updates.check_updates_branch', 'stable'); | 373 | $this->setEmpty('updates.check_updates_branch', 'stable'); |
@@ -381,6 +383,7 @@ class ConfigManager | |||
381 | // default state of the 'remember me' checkbox of the login form | 383 | // default state of the 'remember me' checkbox of the login form |
382 | $this->setEmpty('privacy.remember_user_default', true); | 384 | $this->setEmpty('privacy.remember_user_default', true); |
383 | 385 | ||
386 | $this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL); | ||
384 | $this->setEmpty('thumbnails.width', '125'); | 387 | $this->setEmpty('thumbnails.width', '125'); |
385 | $this->setEmpty('thumbnails.height', '90'); | 388 | $this->setEmpty('thumbnails.height', '90'); |
386 | 389 | ||
diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php index dbb24937..ea8dfbda 100644 --- a/application/config/ConfigPlugin.php +++ b/application/config/ConfigPlugin.php | |||
@@ -1,6 +1,7 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | use Shaarli\Config\Exception\PluginConfigOrderException; | 3 | use Shaarli\Config\Exception\PluginConfigOrderException; |
4 | use Shaarli\Plugin\PluginManager; | ||
4 | 5 | ||
5 | /** | 6 | /** |
6 | * Plugin configuration helper functions. | 7 | * Plugin configuration helper functions. |
@@ -19,6 +20,20 @@ use Shaarli\Config\Exception\PluginConfigOrderException; | |||
19 | */ | 20 | */ |
20 | function save_plugin_config($formData) | 21 | function save_plugin_config($formData) |
21 | { | 22 | { |
23 | // We can only save existing plugins | ||
24 | $directories = str_replace( | ||
25 | PluginManager::$PLUGINS_PATH . '/', | ||
26 | '', | ||
27 | glob(PluginManager::$PLUGINS_PATH . '/*') | ||
28 | ); | ||
29 | $formData = array_filter( | ||
30 | $formData, | ||
31 | function ($value, string $key) use ($directories) { | ||
32 | return startsWith($key, 'order') || in_array($key, $directories); | ||
33 | }, | ||
34 | ARRAY_FILTER_USE_BOTH | ||
35 | ); | ||
36 | |||
22 | // Make sure there are no duplicates in orders. | 37 | // Make sure there are no duplicates in orders. |
23 | if (!validate_plugin_order($formData)) { | 38 | if (!validate_plugin_order($formData)) { |
24 | throw new PluginConfigOrderException(); | 39 | throw new PluginConfigOrderException(); |
@@ -69,7 +84,7 @@ function validate_plugin_order($formData) | |||
69 | $orders = array(); | 84 | $orders = array(); |
70 | foreach ($formData as $key => $value) { | 85 | foreach ($formData as $key => $value) { |
71 | // No duplicate order allowed. | 86 | // No duplicate order allowed. |
72 | if (in_array($value, $orders)) { | 87 | if (in_array($value, $orders, true)) { |
73 | return false; | 88 | return false; |
74 | } | 89 | } |
75 | 90 | ||
diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index e2c78ccc..d84418ad 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php | |||
@@ -4,14 +4,28 @@ declare(strict_types=1); | |||
4 | 4 | ||
5 | namespace Shaarli\Container; | 5 | namespace Shaarli\Container; |
6 | 6 | ||
7 | use malkusch\lock\mutex\FlockMutex; | ||
8 | use Psr\Log\LoggerInterface; | ||
7 | use Shaarli\Bookmark\BookmarkFileService; | 9 | use Shaarli\Bookmark\BookmarkFileService; |
8 | use Shaarli\Bookmark\BookmarkServiceInterface; | 10 | use Shaarli\Bookmark\BookmarkServiceInterface; |
9 | use Shaarli\Config\ConfigManager; | 11 | use Shaarli\Config\ConfigManager; |
12 | use Shaarli\Feed\FeedBuilder; | ||
13 | use Shaarli\Formatter\FormatterFactory; | ||
14 | use Shaarli\Front\Controller\Visitor\ErrorController; | ||
15 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; | ||
10 | use Shaarli\History; | 16 | use Shaarli\History; |
17 | use Shaarli\Http\HttpAccess; | ||
18 | use Shaarli\Http\MetadataRetriever; | ||
19 | use Shaarli\Netscape\NetscapeBookmarkUtils; | ||
11 | use Shaarli\Plugin\PluginManager; | 20 | use Shaarli\Plugin\PluginManager; |
12 | use Shaarli\Render\PageBuilder; | 21 | use Shaarli\Render\PageBuilder; |
22 | use Shaarli\Render\PageCacheManager; | ||
23 | use Shaarli\Security\CookieManager; | ||
13 | use Shaarli\Security\LoginManager; | 24 | use Shaarli\Security\LoginManager; |
14 | use Shaarli\Security\SessionManager; | 25 | use Shaarli\Security\SessionManager; |
26 | use Shaarli\Thumbnailer; | ||
27 | use Shaarli\Updater\Updater; | ||
28 | use Shaarli\Updater\UpdaterUtils; | ||
15 | 29 | ||
16 | /** | 30 | /** |
17 | * Class ContainerBuilder | 31 | * Class ContainerBuilder |
@@ -30,22 +44,43 @@ class ContainerBuilder | |||
30 | /** @var SessionManager */ | 44 | /** @var SessionManager */ |
31 | protected $session; | 45 | protected $session; |
32 | 46 | ||
47 | /** @var CookieManager */ | ||
48 | protected $cookieManager; | ||
49 | |||
33 | /** @var LoginManager */ | 50 | /** @var LoginManager */ |
34 | protected $login; | 51 | protected $login; |
35 | 52 | ||
36 | public function __construct(ConfigManager $conf, SessionManager $session, LoginManager $login) | 53 | /** @var LoggerInterface */ |
37 | { | 54 | protected $logger; |
55 | |||
56 | /** @var string|null */ | ||
57 | protected $basePath = null; | ||
58 | |||
59 | public function __construct( | ||
60 | ConfigManager $conf, | ||
61 | SessionManager $session, | ||
62 | CookieManager $cookieManager, | ||
63 | LoginManager $login, | ||
64 | LoggerInterface $logger | ||
65 | ) { | ||
38 | $this->conf = $conf; | 66 | $this->conf = $conf; |
39 | $this->session = $session; | 67 | $this->session = $session; |
40 | $this->login = $login; | 68 | $this->login = $login; |
69 | $this->cookieManager = $cookieManager; | ||
70 | $this->logger = $logger; | ||
41 | } | 71 | } |
42 | 72 | ||
43 | public function build(): ShaarliContainer | 73 | public function build(): ShaarliContainer |
44 | { | 74 | { |
45 | $container = new ShaarliContainer(); | 75 | $container = new ShaarliContainer(); |
76 | |||
46 | $container['conf'] = $this->conf; | 77 | $container['conf'] = $this->conf; |
47 | $container['sessionManager'] = $this->session; | 78 | $container['sessionManager'] = $this->session; |
79 | $container['cookieManager'] = $this->cookieManager; | ||
48 | $container['loginManager'] = $this->login; | 80 | $container['loginManager'] = $this->login; |
81 | $container['logger'] = $this->logger; | ||
82 | $container['basePath'] = $this->basePath; | ||
83 | |||
49 | $container['plugins'] = function (ShaarliContainer $container): PluginManager { | 84 | $container['plugins'] = function (ShaarliContainer $container): PluginManager { |
50 | return new PluginManager($container->conf); | 85 | return new PluginManager($container->conf); |
51 | }; | 86 | }; |
@@ -58,14 +93,20 @@ class ContainerBuilder | |||
58 | return new BookmarkFileService( | 93 | return new BookmarkFileService( |
59 | $container->conf, | 94 | $container->conf, |
60 | $container->history, | 95 | $container->history, |
96 | new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), | ||
61 | $container->loginManager->isLoggedIn() | 97 | $container->loginManager->isLoggedIn() |
62 | ); | 98 | ); |
63 | }; | 99 | }; |
64 | 100 | ||
101 | $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever { | ||
102 | return new MetadataRetriever($container->conf, $container->httpAccess); | ||
103 | }; | ||
104 | |||
65 | $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { | 105 | $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { |
66 | return new PageBuilder( | 106 | return new PageBuilder( |
67 | $container->conf, | 107 | $container->conf, |
68 | $container->sessionManager->getSession(), | 108 | $container->sessionManager->getSession(), |
109 | $container->logger, | ||
69 | $container->bookmarkService, | 110 | $container->bookmarkService, |
70 | $container->sessionManager->generateToken(), | 111 | $container->sessionManager->generateToken(), |
71 | $container->loginManager->isLoggedIn() | 112 | $container->loginManager->isLoggedIn() |
@@ -73,7 +114,65 @@ class ContainerBuilder | |||
73 | }; | 114 | }; |
74 | 115 | ||
75 | $container['pluginManager'] = function (ShaarliContainer $container): PluginManager { | 116 | $container['pluginManager'] = function (ShaarliContainer $container): PluginManager { |
76 | return new PluginManager($container->conf); | 117 | $pluginManager = new PluginManager($container->conf); |
118 | |||
119 | $pluginManager->load($container->conf->get('general.enabled_plugins')); | ||
120 | |||
121 | return $pluginManager; | ||
122 | }; | ||
123 | |||
124 | $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { | ||
125 | return new FormatterFactory( | ||
126 | $container->conf, | ||
127 | $container->loginManager->isLoggedIn() | ||
128 | ); | ||
129 | }; | ||
130 | |||
131 | $container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager { | ||
132 | return new PageCacheManager( | ||
133 | $container->conf->get('resource.page_cache'), | ||
134 | $container->loginManager->isLoggedIn() | ||
135 | ); | ||
136 | }; | ||
137 | |||
138 | $container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder { | ||
139 | return new FeedBuilder( | ||
140 | $container->bookmarkService, | ||
141 | $container->formatterFactory->getFormatter(), | ||
142 | $container->environment, | ||
143 | $container->loginManager->isLoggedIn() | ||
144 | ); | ||
145 | }; | ||
146 | |||
147 | $container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer { | ||
148 | return new Thumbnailer($container->conf); | ||
149 | }; | ||
150 | |||
151 | $container['httpAccess'] = function (): HttpAccess { | ||
152 | return new HttpAccess(); | ||
153 | }; | ||
154 | |||
155 | $container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils { | ||
156 | return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history); | ||
157 | }; | ||
158 | |||
159 | $container['updater'] = function (ShaarliContainer $container): Updater { | ||
160 | return new Updater( | ||
161 | UpdaterUtils::read_updates_file($container->conf->get('resource.updates')), | ||
162 | $container->bookmarkService, | ||
163 | $container->conf, | ||
164 | $container->loginManager->isLoggedIn() | ||
165 | ); | ||
166 | }; | ||
167 | |||
168 | $container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController { | ||
169 | return new ErrorNotFoundController($container); | ||
170 | }; | ||
171 | $container['errorHandler'] = function (ShaarliContainer $container): ErrorController { | ||
172 | return new ErrorController($container); | ||
173 | }; | ||
174 | $container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController { | ||
175 | return new ErrorController($container); | ||
77 | }; | 176 | }; |
78 | 177 | ||
79 | return $container; | 178 | return $container; |
diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index 3fa9116e..3e5bd252 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php | |||
@@ -4,25 +4,50 @@ declare(strict_types=1); | |||
4 | 4 | ||
5 | namespace Shaarli\Container; | 5 | namespace Shaarli\Container; |
6 | 6 | ||
7 | use Psr\Log\LoggerInterface; | ||
7 | use Shaarli\Bookmark\BookmarkServiceInterface; | 8 | use Shaarli\Bookmark\BookmarkServiceInterface; |
8 | use Shaarli\Config\ConfigManager; | 9 | use Shaarli\Config\ConfigManager; |
10 | use Shaarli\Feed\FeedBuilder; | ||
11 | use Shaarli\Formatter\FormatterFactory; | ||
9 | use Shaarli\History; | 12 | use Shaarli\History; |
13 | use Shaarli\Http\HttpAccess; | ||
14 | use Shaarli\Http\MetadataRetriever; | ||
15 | use Shaarli\Netscape\NetscapeBookmarkUtils; | ||
10 | use Shaarli\Plugin\PluginManager; | 16 | use Shaarli\Plugin\PluginManager; |
11 | use Shaarli\Render\PageBuilder; | 17 | use Shaarli\Render\PageBuilder; |
18 | use Shaarli\Render\PageCacheManager; | ||
19 | use Shaarli\Security\CookieManager; | ||
12 | use Shaarli\Security\LoginManager; | 20 | use Shaarli\Security\LoginManager; |
13 | use Shaarli\Security\SessionManager; | 21 | use Shaarli\Security\SessionManager; |
22 | use Shaarli\Thumbnailer; | ||
23 | use Shaarli\Updater\Updater; | ||
14 | use Slim\Container; | 24 | use Slim\Container; |
15 | 25 | ||
16 | /** | 26 | /** |
17 | * Extension of Slim container to document the injected objects. | 27 | * Extension of Slim container to document the injected objects. |
18 | * | 28 | * |
29 | * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`) | ||
30 | * @property BookmarkServiceInterface $bookmarkService | ||
31 | * @property CookieManager $cookieManager | ||
19 | * @property ConfigManager $conf | 32 | * @property ConfigManager $conf |
20 | * @property SessionManager $sessionManager | 33 | * @property mixed[] $environment $_SERVER automatically injected by Slim |
21 | * @property LoginManager $loginManager | 34 | * @property callable $errorHandler Overrides default Slim exception display |
35 | * @property FeedBuilder $feedBuilder | ||
36 | * @property FormatterFactory $formatterFactory | ||
22 | * @property History $history | 37 | * @property History $history |
23 | * @property BookmarkServiceInterface $bookmarkService | 38 | * @property HttpAccess $httpAccess |
39 | * @property LoginManager $loginManager | ||
40 | * @property LoggerInterface $logger | ||
41 | * @property MetadataRetriever $metadataRetriever | ||
42 | * @property NetscapeBookmarkUtils $netscapeBookmarkUtils | ||
43 | * @property callable $notFoundHandler Overrides default Slim exception display | ||
24 | * @property PageBuilder $pageBuilder | 44 | * @property PageBuilder $pageBuilder |
45 | * @property PageCacheManager $pageCacheManager | ||
46 | * @property callable $phpErrorHandler Overrides default Slim PHP error display | ||
25 | * @property PluginManager $pluginManager | 47 | * @property PluginManager $pluginManager |
48 | * @property SessionManager $sessionManager | ||
49 | * @property Thumbnailer $thumbnailer | ||
50 | * @property Updater $updater | ||
26 | */ | 51 | */ |
27 | class ShaarliContainer extends Container | 52 | class ShaarliContainer extends Container |
28 | { | 53 | { |
diff --git a/application/feed/Cache.php b/application/feed/Cache.php deleted file mode 100644 index e5d43e61..00000000 --- a/application/feed/Cache.php +++ /dev/null | |||
@@ -1,38 +0,0 @@ | |||
1 | <?php | ||
2 | /** | ||
3 | * Cache utilities | ||
4 | */ | ||
5 | |||
6 | /** | ||
7 | * Purges all cached pages | ||
8 | * | ||
9 | * @param string $pageCacheDir page cache directory | ||
10 | * | ||
11 | * @return mixed an error string if the directory is missing | ||
12 | */ | ||
13 | function purgeCachedPages($pageCacheDir) | ||
14 | { | ||
15 | if (! is_dir($pageCacheDir)) { | ||
16 | $error = sprintf(t('Cannot purge %s: no directory'), $pageCacheDir); | ||
17 | error_log($error); | ||
18 | return $error; | ||
19 | } | ||
20 | |||
21 | array_map('unlink', glob($pageCacheDir.'/*.cache')); | ||
22 | } | ||
23 | |||
24 | /** | ||
25 | * Invalidates caches when the database is changed or the user logs out. | ||
26 | * | ||
27 | * @param string $pageCacheDir page cache directory | ||
28 | */ | ||
29 | function invalidateCaches($pageCacheDir) | ||
30 | { | ||
31 | // Purge cache attached to session. | ||
32 | if (isset($_SESSION['tags'])) { | ||
33 | unset($_SESSION['tags']); | ||
34 | } | ||
35 | |||
36 | // Purge page cache shared by sessions. | ||
37 | purgeCachedPages($pageCacheDir); | ||
38 | } | ||
diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index 40bd4f15..f70fce4f 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php | |||
@@ -43,22 +43,10 @@ class FeedBuilder | |||
43 | */ | 43 | */ |
44 | protected $formatter; | 44 | protected $formatter; |
45 | 45 | ||
46 | /** | 46 | /** @var mixed[] $_SERVER */ |
47 | * @var string RSS or ATOM feed. | ||
48 | */ | ||
49 | protected $feedType; | ||
50 | |||
51 | /** | ||
52 | * @var array $_SERVER | ||
53 | */ | ||
54 | protected $serverInfo; | 47 | protected $serverInfo; |
55 | 48 | ||
56 | /** | 49 | /** |
57 | * @var array $_GET | ||
58 | */ | ||
59 | protected $userInput; | ||
60 | |||
61 | /** | ||
62 | * @var boolean True if the user is currently logged in, false otherwise. | 50 | * @var boolean True if the user is currently logged in, false otherwise. |
63 | */ | 51 | */ |
64 | protected $isLoggedIn; | 52 | protected $isLoggedIn; |
@@ -77,7 +65,6 @@ class FeedBuilder | |||
77 | * @var string server locale. | 65 | * @var string server locale. |
78 | */ | 66 | */ |
79 | protected $locale; | 67 | protected $locale; |
80 | |||
81 | /** | 68 | /** |
82 | * @var DateTime Latest item date. | 69 | * @var DateTime Latest item date. |
83 | */ | 70 | */ |
@@ -88,37 +75,36 @@ class FeedBuilder | |||
88 | * | 75 | * |
89 | * @param BookmarkServiceInterface $linkDB LinkDB instance. | 76 | * @param BookmarkServiceInterface $linkDB LinkDB instance. |
90 | * @param BookmarkFormatter $formatter instance. | 77 | * @param BookmarkFormatter $formatter instance. |
91 | * @param string $feedType Type of feed. | ||
92 | * @param array $serverInfo $_SERVER. | 78 | * @param array $serverInfo $_SERVER. |
93 | * @param array $userInput $_GET. | ||
94 | * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise. | 79 | * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise. |
95 | */ | 80 | */ |
96 | public function __construct($linkDB, $formatter, $feedType, $serverInfo, $userInput, $isLoggedIn) | 81 | public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn) |
97 | { | 82 | { |
98 | $this->linkDB = $linkDB; | 83 | $this->linkDB = $linkDB; |
99 | $this->formatter = $formatter; | 84 | $this->formatter = $formatter; |
100 | $this->feedType = $feedType; | ||
101 | $this->serverInfo = $serverInfo; | 85 | $this->serverInfo = $serverInfo; |
102 | $this->userInput = $userInput; | ||
103 | $this->isLoggedIn = $isLoggedIn; | 86 | $this->isLoggedIn = $isLoggedIn; |
104 | } | 87 | } |
105 | 88 | ||
106 | /** | 89 | /** |
107 | * Build data for feed templates. | 90 | * Build data for feed templates. |
108 | * | 91 | * |
92 | * @param string $feedType Type of feed (RSS/ATOM). | ||
93 | * @param array $userInput $_GET. | ||
94 | * | ||
109 | * @return array Formatted data for feeds templates. | 95 | * @return array Formatted data for feeds templates. |
110 | */ | 96 | */ |
111 | public function buildData() | 97 | public function buildData(string $feedType, ?array $userInput) |
112 | { | 98 | { |
113 | // Search for untagged bookmarks | 99 | // Search for untagged bookmarks |
114 | if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { | 100 | if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) { |
115 | $this->userInput['searchtags'] = false; | 101 | $userInput['searchtags'] = false; |
116 | } | 102 | } |
117 | 103 | ||
118 | // Optionally filter the results: | 104 | // Optionally filter the results: |
119 | $linksToDisplay = $this->linkDB->search($this->userInput); | 105 | $linksToDisplay = $this->linkDB->search($userInput ?? [], null, false, false, true); |
120 | 106 | ||
121 | $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); | 107 | $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput); |
122 | 108 | ||
123 | // Can't use array_keys() because $link is a LinkDB instance and not a real array. | 109 | // Can't use array_keys() because $link is a LinkDB instance and not a real array. |
124 | $keys = array(); | 110 | $keys = array(); |
@@ -130,15 +116,15 @@ class FeedBuilder | |||
130 | $this->formatter->addContextData('index_url', $pageaddr); | 116 | $this->formatter->addContextData('index_url', $pageaddr); |
131 | $linkDisplayed = array(); | 117 | $linkDisplayed = array(); |
132 | for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { | 118 | for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { |
133 | $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); | 119 | $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr); |
134 | } | 120 | } |
135 | 121 | ||
136 | $data['language'] = $this->getTypeLanguage(); | 122 | $data['language'] = $this->getTypeLanguage($feedType); |
137 | $data['last_update'] = $this->getLatestDateFormatted(); | 123 | $data['last_update'] = $this->getLatestDateFormatted($feedType); |
138 | $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; | 124 | $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; |
139 | // Remove leading slash from REQUEST_URI. | 125 | // Remove leading path from REQUEST_URI (already contained in $pageaddr). |
140 | $data['self_link'] = escape(server_url($this->serverInfo)) | 126 | $requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI'])); |
141 | . escape($this->serverInfo['REQUEST_URI']); | 127 | $data['self_link'] = $pageaddr . $requestUri; |
142 | $data['index_url'] = $pageaddr; | 128 | $data['index_url'] = $pageaddr; |
143 | $data['usepermalinks'] = $this->usePermalinks === true; | 129 | $data['usepermalinks'] = $this->usePermalinks === true; |
144 | $data['links'] = $linkDisplayed; | 130 | $data['links'] = $linkDisplayed; |
@@ -147,17 +133,48 @@ class FeedBuilder | |||
147 | } | 133 | } |
148 | 134 | ||
149 | /** | 135 | /** |
136 | * Set this to true to use permalinks instead of direct bookmarks. | ||
137 | * | ||
138 | * @param boolean $usePermalinks true to force permalinks. | ||
139 | */ | ||
140 | public function setUsePermalinks($usePermalinks) | ||
141 | { | ||
142 | $this->usePermalinks = $usePermalinks; | ||
143 | } | ||
144 | |||
145 | /** | ||
146 | * Set this to true to hide timestamps in feeds. | ||
147 | * | ||
148 | * @param boolean $hideDates true to enable. | ||
149 | */ | ||
150 | public function setHideDates($hideDates) | ||
151 | { | ||
152 | $this->hideDates = $hideDates; | ||
153 | } | ||
154 | |||
155 | /** | ||
156 | * Set the locale. Used to show feed language. | ||
157 | * | ||
158 | * @param string $locale The locale (eg. 'fr_FR.UTF8'). | ||
159 | */ | ||
160 | public function setLocale($locale) | ||
161 | { | ||
162 | $this->locale = strtolower($locale); | ||
163 | } | ||
164 | |||
165 | /** | ||
150 | * Build a feed item (one per shaare). | 166 | * Build a feed item (one per shaare). |
151 | * | 167 | * |
168 | * @param string $feedType Type of feed (RSS/ATOM). | ||
152 | * @param Bookmark $link Single link array extracted from LinkDB. | 169 | * @param Bookmark $link Single link array extracted from LinkDB. |
153 | * @param string $pageaddr Index URL. | 170 | * @param string $pageaddr Index URL. |
154 | * | 171 | * |
155 | * @return array Link array with feed attributes. | 172 | * @return array Link array with feed attributes. |
156 | */ | 173 | */ |
157 | protected function buildItem($link, $pageaddr) | 174 | protected function buildItem(string $feedType, $link, $pageaddr) |
158 | { | 175 | { |
159 | $data = $this->formatter->format($link); | 176 | $data = $this->formatter->format($link); |
160 | $data['guid'] = $pageaddr . '?' . $data['shorturl']; | 177 | $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl']; |
161 | if ($this->usePermalinks === true) { | 178 | if ($this->usePermalinks === true) { |
162 | $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>'; | 179 | $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>'; |
163 | } else { | 180 | } else { |
@@ -165,13 +182,13 @@ class FeedBuilder | |||
165 | } | 182 | } |
166 | $data['description'] .= PHP_EOL . PHP_EOL . '<br>— ' . $permalink; | 183 | $data['description'] .= PHP_EOL . PHP_EOL . '<br>— ' . $permalink; |
167 | 184 | ||
168 | $data['pub_iso_date'] = $this->getIsoDate($data['created']); | 185 | $data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']); |
169 | 186 | ||
170 | // atom:entry elements MUST contain exactly one atom:updated element. | 187 | // atom:entry elements MUST contain exactly one atom:updated element. |
171 | if (!empty($link->getUpdated())) { | 188 | if (!empty($link->getUpdated())) { |
172 | $data['up_iso_date'] = $this->getIsoDate($data['updated'], DateTime::ATOM); | 189 | $data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM); |
173 | } else { | 190 | } else { |
174 | $data['up_iso_date'] = $this->getIsoDate($data['created'], DateTime::ATOM); | 191 | $data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM); |
175 | } | 192 | } |
176 | 193 | ||
177 | // Save the more recent item. | 194 | // Save the more recent item. |
@@ -186,51 +203,23 @@ class FeedBuilder | |||
186 | } | 203 | } |
187 | 204 | ||
188 | /** | 205 | /** |
189 | * Set this to true to use permalinks instead of direct bookmarks. | ||
190 | * | ||
191 | * @param boolean $usePermalinks true to force permalinks. | ||
192 | */ | ||
193 | public function setUsePermalinks($usePermalinks) | ||
194 | { | ||
195 | $this->usePermalinks = $usePermalinks; | ||
196 | } | ||
197 | |||
198 | /** | ||
199 | * Set this to true to hide timestamps in feeds. | ||
200 | * | ||
201 | * @param boolean $hideDates true to enable. | ||
202 | */ | ||
203 | public function setHideDates($hideDates) | ||
204 | { | ||
205 | $this->hideDates = $hideDates; | ||
206 | } | ||
207 | |||
208 | /** | ||
209 | * Set the locale. Used to show feed language. | ||
210 | * | ||
211 | * @param string $locale The locale (eg. 'fr_FR.UTF8'). | ||
212 | */ | ||
213 | public function setLocale($locale) | ||
214 | { | ||
215 | $this->locale = strtolower($locale); | ||
216 | } | ||
217 | |||
218 | /** | ||
219 | * Get the language according to the feed type, based on the locale: | 206 | * Get the language according to the feed type, based on the locale: |
220 | * | 207 | * |
221 | * - RSS format: en-us (default: 'en-en'). | 208 | * - RSS format: en-us (default: 'en-en'). |
222 | * - ATOM format: fr (default: 'en'). | 209 | * - ATOM format: fr (default: 'en'). |
223 | * | 210 | * |
211 | * @param string $feedType Type of feed (RSS/ATOM). | ||
212 | * | ||
224 | * @return string The language. | 213 | * @return string The language. |
225 | */ | 214 | */ |
226 | public function getTypeLanguage() | 215 | protected function getTypeLanguage(string $feedType) |
227 | { | 216 | { |
228 | // Use the locale do define the language, if available. | 217 | // Use the locale do define the language, if available. |
229 | if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) { | 218 | if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) { |
230 | $length = ($this->feedType === self::$FEED_RSS) ? 5 : 2; | 219 | $length = ($feedType === self::$FEED_RSS) ? 5 : 2; |
231 | return str_replace('_', '-', substr($this->locale, 0, $length)); | 220 | return str_replace('_', '-', substr($this->locale, 0, $length)); |
232 | } | 221 | } |
233 | return ($this->feedType === self::$FEED_RSS) ? 'en-en' : 'en'; | 222 | return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en'; |
234 | } | 223 | } |
235 | 224 | ||
236 | /** | 225 | /** |
@@ -238,32 +227,35 @@ class FeedBuilder | |||
238 | * | 227 | * |
239 | * Return an empty string if invalid DateTime is passed. | 228 | * Return an empty string if invalid DateTime is passed. |
240 | * | 229 | * |
230 | * @param string $feedType Type of feed (RSS/ATOM). | ||
231 | * | ||
241 | * @return string Formatted date. | 232 | * @return string Formatted date. |
242 | */ | 233 | */ |
243 | protected function getLatestDateFormatted() | 234 | protected function getLatestDateFormatted(string $feedType) |
244 | { | 235 | { |
245 | if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) { | 236 | if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) { |
246 | return ''; | 237 | return ''; |
247 | } | 238 | } |
248 | 239 | ||
249 | $type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM; | 240 | $type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM; |
250 | return $this->latestDate->format($type); | 241 | return $this->latestDate->format($type); |
251 | } | 242 | } |
252 | 243 | ||
253 | /** | 244 | /** |
254 | * Get ISO date from DateTime according to feed type. | 245 | * Get ISO date from DateTime according to feed type. |
255 | * | 246 | * |
247 | * @param string $feedType Type of feed (RSS/ATOM). | ||
256 | * @param DateTime $date Date to format. | 248 | * @param DateTime $date Date to format. |
257 | * @param string|bool $format Force format. | 249 | * @param string|bool $format Force format. |
258 | * | 250 | * |
259 | * @return string Formatted date. | 251 | * @return string Formatted date. |
260 | */ | 252 | */ |
261 | protected function getIsoDate(DateTime $date, $format = false) | 253 | protected function getIsoDate(string $feedType, DateTime $date, $format = false) |
262 | { | 254 | { |
263 | if ($format !== false) { | 255 | if ($format !== false) { |
264 | return $date->format($format); | 256 | return $date->format($format); |
265 | } | 257 | } |
266 | if ($this->feedType == self::$FEED_RSS) { | 258 | if ($feedType == self::$FEED_RSS) { |
267 | return $date->format(DateTime::RSS); | 259 | return $date->format(DateTime::RSS); |
268 | } | 260 | } |
269 | return $date->format(DateTime::ATOM); | 261 | return $date->format(DateTime::ATOM); |
@@ -275,21 +267,22 @@ class FeedBuilder | |||
275 | * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. | 267 | * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. |
276 | * If 'nb' is set to 'all', display all filtered bookmarks (max parameter). | 268 | * If 'nb' is set to 'all', display all filtered bookmarks (max parameter). |
277 | * | 269 | * |
278 | * @param int $max maximum number of bookmarks to display. | 270 | * @param int $max maximum number of bookmarks to display. |
271 | * @param array $userInput $_GET. | ||
279 | * | 272 | * |
280 | * @return int number of bookmarks to display. | 273 | * @return int number of bookmarks to display. |
281 | */ | 274 | */ |
282 | public function getNbLinks($max) | 275 | protected function getNbLinks($max, ?array $userInput) |
283 | { | 276 | { |
284 | if (empty($this->userInput['nb'])) { | 277 | if (empty($userInput['nb'])) { |
285 | return self::$DEFAULT_NB_LINKS; | 278 | return self::$DEFAULT_NB_LINKS; |
286 | } | 279 | } |
287 | 280 | ||
288 | if ($this->userInput['nb'] == 'all') { | 281 | if ($userInput['nb'] == 'all') { |
289 | return $max; | 282 | return $max; |
290 | } | 283 | } |
291 | 284 | ||
292 | $intNb = intval($this->userInput['nb']); | 285 | $intNb = intval($userInput['nb']); |
293 | if (!is_int($intNb) || $intNb == 0) { | 286 | if (!is_int($intNb) || $intNb == 0) { |
294 | return self::$DEFAULT_NB_LINKS; | 287 | return self::$DEFAULT_NB_LINKS; |
295 | } | 288 | } |
diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php index c6c59064..d58a5e39 100644 --- a/application/formatter/BookmarkDefaultFormatter.php +++ b/application/formatter/BookmarkDefaultFormatter.php | |||
@@ -12,10 +12,13 @@ namespace Shaarli\Formatter; | |||
12 | */ | 12 | */ |
13 | class BookmarkDefaultFormatter extends BookmarkFormatter | 13 | class BookmarkDefaultFormatter extends BookmarkFormatter |
14 | { | 14 | { |
15 | const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT'; | ||
16 | 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,28 @@ 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 | |||
50 | return $this->replaceTokens(format_description(escape($description), $indexUrl)); | ||
30 | } | 51 | } |
31 | 52 | ||
32 | /** | 53 | /** |
@@ -40,7 +61,27 @@ class BookmarkDefaultFormatter extends BookmarkFormatter | |||
40 | /** | 61 | /** |
41 | * @inheritdoc | 62 | * @inheritdoc |
42 | */ | 63 | */ |
43 | public function formatTagString($bookmark) | 64 | protected function formatTagListHtml($bookmark) |
65 | { | ||
66 | if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) { | ||
67 | return $this->formatTagList($bookmark); | ||
68 | } | ||
69 | |||
70 | $tags = $this->tokenizeSearchHighlightField( | ||
71 | $bookmark->getTagsString(), | ||
72 | $bookmark->getAdditionalContentEntry('search_highlight')['tags'] | ||
73 | ); | ||
74 | $tags = $this->filterTagList(explode(' ', $tags)); | ||
75 | $tags = escape($tags); | ||
76 | $tags = $this->replaceTokensArray($tags); | ||
77 | |||
78 | return $tags; | ||
79 | } | ||
80 | |||
81 | /** | ||
82 | * @inheritdoc | ||
83 | */ | ||
84 | protected function formatTagString($bookmark) | ||
44 | { | 85 | { |
45 | return implode(' ', $this->formatTagList($bookmark)); | 86 | return implode(' ', $this->formatTagList($bookmark)); |
46 | } | 87 | } |
@@ -48,13 +89,12 @@ class BookmarkDefaultFormatter extends BookmarkFormatter | |||
48 | /** | 89 | /** |
49 | * @inheritdoc | 90 | * @inheritdoc |
50 | */ | 91 | */ |
51 | public function formatUrl($bookmark) | 92 | protected function formatUrl($bookmark) |
52 | { | 93 | { |
53 | if (! empty($this->contextData['index_url']) && ( | 94 | if ($bookmark->isNote() && isset($this->contextData['index_url'])) { |
54 | startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') | 95 | return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/')); |
55 | )) { | ||
56 | return $this->contextData['index_url'] . escape($bookmark->getUrl()); | ||
57 | } | 96 | } |
97 | |||
58 | return escape($bookmark->getUrl()); | 98 | return escape($bookmark->getUrl()); |
59 | } | 99 | } |
60 | 100 | ||
@@ -63,19 +103,107 @@ class BookmarkDefaultFormatter extends BookmarkFormatter | |||
63 | */ | 103 | */ |
64 | protected function formatRealUrl($bookmark) | 104 | protected function formatRealUrl($bookmark) |
65 | { | 105 | { |
66 | if (! empty($this->contextData['index_url']) && ( | 106 | if ($bookmark->isNote()) { |
67 | startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') | 107 | if (isset($this->contextData['index_url'])) { |
68 | )) { | 108 | $prefix = rtrim($this->contextData['index_url'], '/') . '/'; |
69 | return $this->contextData['index_url'] . escape($bookmark->getUrl()); | 109 | } |
110 | |||
111 | if (isset($this->contextData['base_path'])) { | ||
112 | $prefix = rtrim($this->contextData['base_path'], '/') . '/'; | ||
113 | } | ||
114 | |||
115 | return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl(), '/')); | ||
70 | } | 116 | } |
117 | |||
71 | return escape($bookmark->getUrl()); | 118 | return escape($bookmark->getUrl()); |
72 | } | 119 | } |
73 | 120 | ||
74 | /** | 121 | /** |
75 | * @inheritdoc | 122 | * @inheritdoc |
76 | */ | 123 | */ |
124 | protected function formatUrlHtml($bookmark) | ||
125 | { | ||
126 | $url = $this->tokenizeSearchHighlightField( | ||
127 | $bookmark->getUrl() ?? '', | ||
128 | $bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? [] | ||
129 | ); | ||
130 | |||
131 | return $this->replaceTokens(escape($url)); | ||
132 | } | ||
133 | |||
134 | /** | ||
135 | * @inheritdoc | ||
136 | */ | ||
77 | protected function formatThumbnail($bookmark) | 137 | protected function formatThumbnail($bookmark) |
78 | { | 138 | { |
79 | return escape($bookmark->getThumbnail()); | 139 | return escape($bookmark->getThumbnail()); |
80 | } | 140 | } |
141 | |||
142 | /** | ||
143 | * Insert search highlight token in provided field content based on a list of search result positions | ||
144 | * | ||
145 | * @param string $fieldContent | ||
146 | * @param array|null $positions List of of search results with 'start' and 'end' positions. | ||
147 | * | ||
148 | * @return string Updated $fieldContent. | ||
149 | */ | ||
150 | protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string | ||
151 | { | ||
152 | if (empty($positions)) { | ||
153 | return $fieldContent; | ||
154 | } | ||
155 | |||
156 | $insertedTokens = 0; | ||
157 | $tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN); | ||
158 | foreach ($positions as $position) { | ||
159 | $position = [ | ||
160 | 'start' => $position['start'] + ($insertedTokens * $tokenLength), | ||
161 | 'end' => $position['end'] + ($insertedTokens * $tokenLength), | ||
162 | ]; | ||
163 | |||
164 | $content = mb_substr($fieldContent, 0, $position['start']); | ||
165 | $content .= static::SEARCH_HIGHLIGHT_OPEN; | ||
166 | $content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']); | ||
167 | $content .= static::SEARCH_HIGHLIGHT_CLOSE; | ||
168 | $content .= mb_substr($fieldContent, $position['end']); | ||
169 | |||
170 | $fieldContent = $content; | ||
171 | |||
172 | $insertedTokens += 2; | ||
173 | } | ||
174 | |||
175 | return $fieldContent; | ||
176 | } | ||
177 | |||
178 | /** | ||
179 | * Replace search highlight tokens with HTML highlighted span. | ||
180 | * | ||
181 | * @param string $fieldContent | ||
182 | * | ||
183 | * @return string updated content. | ||
184 | */ | ||
185 | protected function replaceTokens(string $fieldContent): string | ||
186 | { | ||
187 | return str_replace( | ||
188 | [static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE], | ||
189 | ['<span class="search-highlight">', '</span>'], | ||
190 | $fieldContent | ||
191 | ); | ||
192 | } | ||
193 | |||
194 | /** | ||
195 | * Apply replaceTokens to an array of content strings. | ||
196 | * | ||
197 | * @param string[] $fieldContents | ||
198 | * | ||
199 | * @return array | ||
200 | */ | ||
201 | protected function replaceTokensArray(array $fieldContents): array | ||
202 | { | ||
203 | foreach ($fieldContents as &$entry) { | ||
204 | $entry = $this->replaceTokens($entry); | ||
205 | } | ||
206 | |||
207 | return $fieldContents; | ||
208 | } | ||
81 | } | 209 | } |
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php index a80d83fc..e1b7f705 100644 --- a/application/formatter/BookmarkFormatter.php +++ b/application/formatter/BookmarkFormatter.php | |||
@@ -2,15 +2,38 @@ | |||
2 | 2 | ||
3 | namespace Shaarli\Formatter; | 3 | namespace Shaarli\Formatter; |
4 | 4 | ||
5 | use DateTime; | 5 | use DateTimeInterface; |
6 | use Shaarli\Config\ConfigManager; | ||
7 | use Shaarli\Bookmark\Bookmark; | 6 | use Shaarli\Bookmark\Bookmark; |
7 | use Shaarli\Config\ConfigManager; | ||
8 | 8 | ||
9 | /** | 9 | /** |
10 | * Class BookmarkFormatter | 10 | * Class BookmarkFormatter |
11 | * | 11 | * |
12 | * Abstract class processing all bookmark attributes through methods designed to be overridden. | 12 | * Abstract class processing all bookmark attributes through methods designed to be overridden. |
13 | * | 13 | * |
14 | * List of available formatted fields: | ||
15 | * - id ID | ||
16 | * - shorturl Unique identifier, used in permalinks | ||
17 | * - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy | ||
18 | * - real_url (legacy) same as `url` | ||
19 | * - url_html URL to be displayed in HTML content (it can contain HTML tags) | ||
20 | * - title Title | ||
21 | * - title_html Title to be displayed in HTML content (it can contain HTML tags) | ||
22 | * - description Description content. It most likely contains HTML tags | ||
23 | * - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved | ||
24 | * - taglist List of tags (array) | ||
25 | * - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag | ||
26 | * - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags) | ||
27 | * - tags Tags separated by a single whitespace | ||
28 | * - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link | ||
29 | * - sticky Is sticky (bool) | ||
30 | * - private Is private (bool) | ||
31 | * - class Additional CSS class | ||
32 | * - created Creation DateTime | ||
33 | * - updated Last edit DateTime | ||
34 | * - timestamp Creation timestamp | ||
35 | * - updated_timestamp Last edit timestamp | ||
36 | * | ||
14 | * @package Shaarli\Formatter | 37 | * @package Shaarli\Formatter |
15 | */ | 38 | */ |
16 | abstract class BookmarkFormatter | 39 | abstract class BookmarkFormatter |
@@ -55,11 +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['taglist'] = $this->formatTagList($bookmark); | 86 | $out['taglist'] = $this->formatTagList($bookmark); |
87 | $out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark); | ||
88 | $out['taglist_html'] = $this->formatTagListHtml($bookmark); | ||
62 | $out['tags'] = $this->formatTagString($bookmark); | 89 | $out['tags'] = $this->formatTagString($bookmark); |
90 | $out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark); | ||
63 | $out['sticky'] = $bookmark->isSticky(); | 91 | $out['sticky'] = $bookmark->isSticky(); |
64 | $out['private'] = $bookmark->isPrivate(); | 92 | $out['private'] = $bookmark->isPrivate(); |
65 | $out['class'] = $this->formatClass($bookmark); | 93 | $out['class'] = $this->formatClass($bookmark); |
@@ -67,6 +95,7 @@ abstract class BookmarkFormatter | |||
67 | $out['updated'] = $this->formatUpdated($bookmark); | 95 | $out['updated'] = $this->formatUpdated($bookmark); |
68 | $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); | 96 | $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); |
69 | $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); | 97 | $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); |
98 | |||
70 | return $out; | 99 | return $out; |
71 | } | 100 | } |
72 | 101 | ||
@@ -80,6 +109,8 @@ abstract class BookmarkFormatter | |||
80 | public function addContextData($key, $value) | 109 | public function addContextData($key, $value) |
81 | { | 110 | { |
82 | $this->contextData[$key] = $value; | 111 | $this->contextData[$key] = $value; |
112 | |||
113 | return $this; | ||
83 | } | 114 | } |
84 | 115 | ||
85 | /** | 116 | /** |
@@ -128,7 +159,19 @@ abstract class BookmarkFormatter | |||
128 | */ | 159 | */ |
129 | protected function formatRealUrl($bookmark) | 160 | protected function formatRealUrl($bookmark) |
130 | { | 161 | { |
131 | return $bookmark->getUrl(); | 162 | return $this->formatUrl($bookmark); |
163 | } | ||
164 | |||
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); | ||
132 | } | 175 | } |
133 | 176 | ||
134 | /** | 177 | /** |
@@ -144,6 +187,18 @@ abstract class BookmarkFormatter | |||
144 | } | 187 | } |
145 | 188 | ||
146 | /** | 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 | /** | ||
147 | * Format Description | 202 | * Format Description |
148 | * | 203 | * |
149 | * @param Bookmark $bookmark instance | 204 | * @param Bookmark $bookmark instance |
@@ -180,6 +235,30 @@ abstract class BookmarkFormatter | |||
180 | } | 235 | } |
181 | 236 | ||
182 | /** | 237 | /** |
238 | * Format Url Encoded Tags | ||
239 | * | ||
240 | * @param Bookmark $bookmark instance | ||
241 | * | ||
242 | * @return array formatted Tags | ||
243 | */ | ||
244 | protected function formatTagListUrlEncoded($bookmark) | ||
245 | { | ||
246 | return array_map('urlencode', $this->filterTagList($bookmark->getTags())); | ||
247 | } | ||
248 | |||
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 | /** | ||
183 | * Format TagString | 262 | * Format TagString |
184 | * | 263 | * |
185 | * @param Bookmark $bookmark instance | 264 | * @param Bookmark $bookmark instance |
@@ -192,6 +271,18 @@ abstract class BookmarkFormatter | |||
192 | } | 271 | } |
193 | 272 | ||
194 | /** | 273 | /** |
274 | * Format TagString | ||
275 | * | ||
276 | * @param Bookmark $bookmark instance | ||
277 | * | ||
278 | * @return string formatted TagString | ||
279 | */ | ||
280 | protected function formatTagStringUrlEncoded($bookmark) | ||
281 | { | ||
282 | return implode(' ', $this->formatTagListUrlEncoded($bookmark)); | ||
283 | } | ||
284 | |||
285 | /** | ||
195 | * Format Class | 286 | * Format Class |
196 | * Used to add specific CSS class for a link | 287 | * Used to add specific CSS class for a link |
197 | * | 288 | * |
@@ -209,7 +300,7 @@ abstract class BookmarkFormatter | |||
209 | * | 300 | * |
210 | * @param Bookmark $bookmark instance | 301 | * @param Bookmark $bookmark instance |
211 | * | 302 | * |
212 | * @return DateTime instance | 303 | * @return DateTimeInterface instance |
213 | */ | 304 | */ |
214 | protected function formatCreated(Bookmark $bookmark) | 305 | protected function formatCreated(Bookmark $bookmark) |
215 | { | 306 | { |
@@ -221,7 +312,7 @@ abstract class BookmarkFormatter | |||
221 | * | 312 | * |
222 | * @param Bookmark $bookmark instance | 313 | * @param Bookmark $bookmark instance |
223 | * | 314 | * |
224 | * @return DateTime instance | 315 | * @return DateTimeInterface instance |
225 | */ | 316 | */ |
226 | protected function formatUpdated(Bookmark $bookmark) | 317 | protected function formatUpdated(Bookmark $bookmark) |
227 | { | 318 | { |
diff --git a/application/formatter/BookmarkMarkdownExtraFormatter.php b/application/formatter/BookmarkMarkdownExtraFormatter.php new file mode 100644 index 00000000..0694b23f --- /dev/null +++ b/application/formatter/BookmarkMarkdownExtraFormatter.php | |||
@@ -0,0 +1,24 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Formatter; | ||
4 | |||
5 | use Shaarli\Config\ConfigManager; | ||
6 | |||
7 | /** | ||
8 | * Class BookmarkMarkdownExtraFormatter | ||
9 | * | ||
10 | * Format bookmark description into MarkdownExtra format. | ||
11 | * | ||
12 | * @see https://michelf.ca/projects/php-markdown/extra/ | ||
13 | * | ||
14 | * @package Shaarli\Formatter | ||
15 | */ | ||
16 | class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter | ||
17 | { | ||
18 | public function __construct(ConfigManager $conf, bool $isLoggedIn) | ||
19 | { | ||
20 | parent::__construct($conf, $isLoggedIn); | ||
21 | |||
22 | $this->parsedown = new \ParsedownExtra(); | ||
23 | } | ||
24 | } | ||
diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php index 077e5312..f7714be9 100644 --- a/application/formatter/BookmarkMarkdownFormatter.php +++ b/application/formatter/BookmarkMarkdownFormatter.php | |||
@@ -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,6 +68,7 @@ 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>'; |
@@ -114,7 +118,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter | |||
114 | 118 | ||
115 | /** | 119 | /** |
116 | * Replace hashtag in Markdown links format | 120 | * Replace hashtag in Markdown links format |
117 | * E.g. `#hashtag` becomes `[#hashtag](?addtag=hashtag)` | 121 | * E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)` |
118 | * It includes the index URL if specified. | 122 | * It includes the index URL if specified. |
119 | * | 123 | * |
120 | * @param string $description | 124 | * @param string $description |
@@ -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 .'?addtag=$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 = ''; |
diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php index 5f282f68..a029579f 100644 --- a/application/formatter/FormatterFactory.php +++ b/application/formatter/FormatterFactory.php | |||
@@ -38,7 +38,7 @@ class FormatterFactory | |||
38 | * | 38 | * |
39 | * @return BookmarkFormatter instance. | 39 | * @return BookmarkFormatter instance. |
40 | */ | 40 | */ |
41 | public function getFormatter(string $type = null) | 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'; |
diff --git a/application/front/ShaarliAdminMiddleware.php b/application/front/ShaarliAdminMiddleware.php new file mode 100644 index 00000000..35ce4a3b --- /dev/null +++ b/application/front/ShaarliAdminMiddleware.php | |||
@@ -0,0 +1,27 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Front; | ||
4 | |||
5 | use Slim\Http\Request; | ||
6 | use Slim\Http\Response; | ||
7 | |||
8 | /** | ||
9 | * Middleware used for controller requiring to be authenticated. | ||
10 | * It extends ShaarliMiddleware, and just make sure that the user is authenticated. | ||
11 | * Otherwise, it redirects to the login page. | ||
12 | */ | ||
13 | class ShaarliAdminMiddleware extends ShaarliMiddleware | ||
14 | { | ||
15 | public function __invoke(Request $request, Response $response, callable $next): Response | ||
16 | { | ||
17 | $this->initBasePath($request); | ||
18 | |||
19 | if (true !== $this->container->loginManager->isLoggedIn()) { | ||
20 | $returnUrl = urlencode($this->container->environment['REQUEST_URI']); | ||
21 | |||
22 | return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl); | ||
23 | } | ||
24 | |||
25 | return parent::__invoke($request, $response, $next); | ||
26 | } | ||
27 | } | ||
diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index fa6c6467..d1aa1399 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php | |||
@@ -3,7 +3,7 @@ | |||
3 | namespace Shaarli\Front; | 3 | namespace Shaarli\Front; |
4 | 4 | ||
5 | use Shaarli\Container\ShaarliContainer; | 5 | use Shaarli\Container\ShaarliContainer; |
6 | use Shaarli\Front\Exception\ShaarliException; | 6 | use Shaarli\Front\Exception\UnauthorizedException; |
7 | use Slim\Http\Request; | 7 | use Slim\Http\Request; |
8 | use Slim\Http\Response; | 8 | use Slim\Http\Response; |
9 | 9 | ||
@@ -24,6 +24,8 @@ class ShaarliMiddleware | |||
24 | 24 | ||
25 | /** | 25 | /** |
26 | * Middleware execution: | 26 | * Middleware execution: |
27 | * - run updates | ||
28 | * - if not logged in open shaarli, redirect to login | ||
27 | * - execute the controller | 29 | * - execute the controller |
28 | * - return the response | 30 | * - return the response |
29 | * | 31 | * |
@@ -35,23 +37,78 @@ class ShaarliMiddleware | |||
35 | * | 37 | * |
36 | * @return Response response. | 38 | * @return Response response. |
37 | */ | 39 | */ |
38 | public function __invoke(Request $request, Response $response, callable $next) | 40 | public function __invoke(Request $request, Response $response, callable $next): Response |
39 | { | 41 | { |
42 | $this->initBasePath($request); | ||
43 | |||
40 | try { | 44 | try { |
41 | $response = $next($request, $response); | 45 | if (!is_file($this->container->conf->getConfigFileExt()) |
42 | } catch (ShaarliException $e) { | 46 | && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true) |
43 | $this->container->pageBuilder->assign('message', $e->getMessage()); | 47 | ) { |
44 | if ($this->container->conf->get('dev.debug', false)) { | 48 | return $response->withRedirect($this->container->basePath . '/install'); |
45 | $this->container->pageBuilder->assign( | ||
46 | 'stacktrace', | ||
47 | nl2br(get_class($this) .': '. $e->getTraceAsString()) | ||
48 | ); | ||
49 | } | 49 | } |
50 | 50 | ||
51 | $response = $response->withStatus($e->getCode()); | 51 | $this->runUpdates(); |
52 | $response = $response->write($this->container->pageBuilder->render('error')); | 52 | $this->checkOpenShaarli($request, $response, $next); |
53 | |||
54 | return $next($request, $response); | ||
55 | } catch (UnauthorizedException $e) { | ||
56 | $returnUrl = urlencode($this->container->environment['REQUEST_URI']); | ||
57 | |||
58 | return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl); | ||
59 | } | ||
60 | // Other exceptions are handled by ErrorController | ||
61 | } | ||
62 | |||
63 | /** | ||
64 | * Run the updater for every requests processed while logged in. | ||
65 | */ | ||
66 | protected function runUpdates(): void | ||
67 | { | ||
68 | if ($this->container->loginManager->isLoggedIn() !== true) { | ||
69 | return; | ||
70 | } | ||
71 | |||
72 | $this->container->updater->setBasePath($this->container->basePath); | ||
73 | $newUpdates = $this->container->updater->update(); | ||
74 | if (!empty($newUpdates)) { | ||
75 | $this->container->updater->writeUpdates( | ||
76 | $this->container->conf->get('resource.updates'), | ||
77 | $this->container->updater->getDoneUpdates() | ||
78 | ); | ||
79 | |||
80 | $this->container->pageCacheManager->invalidateCaches(); | ||
81 | } | ||
82 | } | ||
83 | |||
84 | /** | ||
85 | * Access is denied to most pages with `hide_public_links` + `force_login` settings. | ||
86 | */ | ||
87 | protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool | ||
88 | { | ||
89 | if (// if the user isn't logged in | ||
90 | !$this->container->loginManager->isLoggedIn() | ||
91 | // and Shaarli doesn't have public content... | ||
92 | && $this->container->conf->get('privacy.hide_public_links') | ||
93 | // and is configured to enforce the login | ||
94 | && $this->container->conf->get('privacy.force_login') | ||
95 | // and the current page isn't already the login page | ||
96 | // and the user is not requesting a feed (which would lead to a different content-type as expected) | ||
97 | && !in_array($next->getName(), ['login', 'processLogin', 'atom', 'rss'], true) | ||
98 | ) { | ||
99 | throw new UnauthorizedException(); | ||
53 | } | 100 | } |
54 | 101 | ||
55 | return $response; | 102 | return true; |
103 | } | ||
104 | |||
105 | /** | ||
106 | * Initialize the URL base path if it hasn't been defined yet. | ||
107 | */ | ||
108 | protected function initBasePath(Request $request): void | ||
109 | { | ||
110 | if (null === $this->container->basePath) { | ||
111 | $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); | ||
112 | } | ||
56 | } | 113 | } |
57 | } | 114 | } |
diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php new file mode 100644 index 00000000..0ed7ad81 --- /dev/null +++ b/application/front/controller/admin/ConfigureController.php | |||
@@ -0,0 +1,126 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Languages; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Shaarli\Render\ThemeUtils; | ||
10 | use Shaarli\Thumbnailer; | ||
11 | use Slim\Http\Request; | ||
12 | use Slim\Http\Response; | ||
13 | use Throwable; | ||
14 | |||
15 | /** | ||
16 | * Class ConfigureController | ||
17 | * | ||
18 | * Slim controller used to handle Shaarli configuration page (display + save new config). | ||
19 | */ | ||
20 | class ConfigureController extends ShaarliAdminController | ||
21 | { | ||
22 | /** | ||
23 | * GET /admin/configure - Displays the configuration page | ||
24 | */ | ||
25 | public function index(Request $request, Response $response): Response | ||
26 | { | ||
27 | $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); | ||
28 | $this->assignView('theme', $this->container->conf->get('resource.theme')); | ||
29 | $this->assignView( | ||
30 | 'theme_available', | ||
31 | ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl')) | ||
32 | ); | ||
33 | $this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']); | ||
34 | list($continents, $cities) = generateTimeZoneData( | ||
35 | timezone_identifiers_list(), | ||
36 | $this->container->conf->get('general.timezone') | ||
37 | ); | ||
38 | $this->assignView('continents', $continents); | ||
39 | $this->assignView('cities', $cities); | ||
40 | $this->assignView('retrieve_description', $this->container->conf->get('general.retrieve_description', false)); | ||
41 | $this->assignView('private_links_default', $this->container->conf->get('privacy.default_private_links', false)); | ||
42 | $this->assignView( | ||
43 | 'session_protection_disabled', | ||
44 | $this->container->conf->get('security.session_protection_disabled', false) | ||
45 | ); | ||
46 | $this->assignView('enable_rss_permalinks', $this->container->conf->get('feed.rss_permalinks', false)); | ||
47 | $this->assignView('enable_update_check', $this->container->conf->get('updates.check_updates', true)); | ||
48 | $this->assignView('hide_public_links', $this->container->conf->get('privacy.hide_public_links', false)); | ||
49 | $this->assignView('api_enabled', $this->container->conf->get('api.enabled', true)); | ||
50 | $this->assignView('api_secret', $this->container->conf->get('api.secret')); | ||
51 | $this->assignView('languages', Languages::getAvailableLanguages()); | ||
52 | $this->assignView('gd_enabled', extension_loaded('gd')); | ||
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')); | ||
55 | |||
56 | return $response->write($this->render(TemplatePage::CONFIGURE)); | ||
57 | } | ||
58 | |||
59 | /** | ||
60 | * POST /admin/configure - Update Shaarli's configuration | ||
61 | */ | ||
62 | public function save(Request $request, Response $response): Response | ||
63 | { | ||
64 | $this->checkToken($request); | ||
65 | |||
66 | $continent = $request->getParam('continent'); | ||
67 | $city = $request->getParam('city'); | ||
68 | $tz = 'UTC'; | ||
69 | if (null !== $continent && null !== $city && isTimeZoneValid($continent, $city)) { | ||
70 | $tz = $continent . '/' . $city; | ||
71 | } | ||
72 | |||
73 | $this->container->conf->set('general.timezone', $tz); | ||
74 | $this->container->conf->set('general.title', escape($request->getParam('title'))); | ||
75 | $this->container->conf->set('general.header_link', escape($request->getParam('titleLink'))); | ||
76 | $this->container->conf->set('general.retrieve_description', !empty($request->getParam('retrieveDescription'))); | ||
77 | $this->container->conf->set('resource.theme', escape($request->getParam('theme'))); | ||
78 | $this->container->conf->set( | ||
79 | 'security.session_protection_disabled', | ||
80 | !empty($request->getParam('disablesessionprotection')) | ||
81 | ); | ||
82 | $this->container->conf->set( | ||
83 | 'privacy.default_private_links', | ||
84 | !empty($request->getParam('privateLinkByDefault')) | ||
85 | ); | ||
86 | $this->container->conf->set('feed.rss_permalinks', !empty($request->getParam('enableRssPermalinks'))); | ||
87 | $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck'))); | ||
88 | $this->container->conf->set('privacy.hide_public_links', !empty($request->getParam('hidePublicLinks'))); | ||
89 | $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi'))); | ||
90 | $this->container->conf->set('api.secret', escape($request->getParam('apiSecret'))); | ||
91 | $this->container->conf->set('formatter', escape($request->getParam('formatter'))); | ||
92 | |||
93 | if (!empty($request->getParam('language'))) { | ||
94 | $this->container->conf->set('translation.language', escape($request->getParam('language'))); | ||
95 | } | ||
96 | |||
97 | $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE; | ||
98 | if ($thumbnailsMode !== Thumbnailer::MODE_NONE | ||
99 | && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) | ||
100 | ) { | ||
101 | $this->saveWarningMessage( | ||
102 | t('You have enabled or changed thumbnails mode.') . | ||
103 | '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>' | ||
104 | ); | ||
105 | } | ||
106 | $this->container->conf->set('thumbnails.mode', $thumbnailsMode); | ||
107 | |||
108 | try { | ||
109 | $this->container->conf->write($this->container->loginManager->isLoggedIn()); | ||
110 | $this->container->history->updateSettings(); | ||
111 | $this->container->pageCacheManager->invalidateCaches(); | ||
112 | } catch (Throwable $e) { | ||
113 | $this->assignView('message', t('Error while writing config file after configuration update.')); | ||
114 | |||
115 | if ($this->container->conf->get('dev.debug', false)) { | ||
116 | $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString()); | ||
117 | } | ||
118 | |||
119 | return $response->write($this->render('error')); | ||
120 | } | ||
121 | |||
122 | $this->saveSuccessMessage(t('Configuration was saved.')); | ||
123 | |||
124 | return $this->redirect($response, '/admin/configure'); | ||
125 | } | ||
126 | } | ||
diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php new file mode 100644 index 00000000..2be957fa --- /dev/null +++ b/application/front/controller/admin/ExportController.php | |||
@@ -0,0 +1,80 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use DateTime; | ||
8 | use Shaarli\Bookmark\Bookmark; | ||
9 | use Shaarli\Render\TemplatePage; | ||
10 | use Slim\Http\Request; | ||
11 | use Slim\Http\Response; | ||
12 | |||
13 | /** | ||
14 | * Class ExportController | ||
15 | * | ||
16 | * Slim controller used to display Shaarli data export page, | ||
17 | * and process the bookmarks export as a Netscape Bookmarks file. | ||
18 | */ | ||
19 | class ExportController extends ShaarliAdminController | ||
20 | { | ||
21 | /** | ||
22 | * GET /admin/export - Display export page | ||
23 | */ | ||
24 | public function index(Request $request, Response $response): Response | ||
25 | { | ||
26 | $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli')); | ||
27 | |||
28 | return $response->write($this->render(TemplatePage::EXPORT)); | ||
29 | } | ||
30 | |||
31 | /** | ||
32 | * POST /admin/export - Process export, and serve download file named | ||
33 | * bookmarks_(all|private|public)_datetime.html | ||
34 | */ | ||
35 | public function export(Request $request, Response $response): Response | ||
36 | { | ||
37 | $this->checkToken($request); | ||
38 | |||
39 | $selection = $request->getParam('selection'); | ||
40 | |||
41 | if (empty($selection)) { | ||
42 | $this->saveErrorMessage(t('Please select an export mode.')); | ||
43 | |||
44 | return $this->redirect($response, '/admin/export'); | ||
45 | } | ||
46 | |||
47 | $prependNoteUrl = filter_var($request->getParam('prepend_note_url') ?? false, FILTER_VALIDATE_BOOLEAN); | ||
48 | |||
49 | try { | ||
50 | $formatter = $this->container->formatterFactory->getFormatter('raw'); | ||
51 | |||
52 | $this->assignView( | ||
53 | 'links', | ||
54 | $this->container->netscapeBookmarkUtils->filterAndFormat( | ||
55 | $formatter, | ||
56 | $selection, | ||
57 | $prependNoteUrl, | ||
58 | index_url($this->container->environment) | ||
59 | ) | ||
60 | ); | ||
61 | } catch (\Exception $exc) { | ||
62 | $this->saveErrorMessage($exc->getMessage()); | ||
63 | |||
64 | return $this->redirect($response, '/admin/export'); | ||
65 | } | ||
66 | |||
67 | $now = new DateTime(); | ||
68 | $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8'); | ||
69 | $response = $response->withHeader( | ||
70 | 'Content-disposition', | ||
71 | 'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html' | ||
72 | ); | ||
73 | |||
74 | $this->assignView('date', $now->format(DateTime::RFC822)); | ||
75 | $this->assignView('eol', PHP_EOL); | ||
76 | $this->assignView('selection', $selection); | ||
77 | |||
78 | return $response->write($this->render(TemplatePage::NETSCAPE_EXPORT_BOOKMARKS)); | ||
79 | } | ||
80 | } | ||
diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php new file mode 100644 index 00000000..758d5ef9 --- /dev/null +++ b/application/front/controller/admin/ImportController.php | |||
@@ -0,0 +1,82 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Psr\Http\Message\UploadedFileInterface; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ImportController | ||
14 | * | ||
15 | * Slim controller used to display Shaarli data import page, | ||
16 | * and import bookmarks from Netscape Bookmarks file. | ||
17 | */ | ||
18 | class ImportController extends ShaarliAdminController | ||
19 | { | ||
20 | /** | ||
21 | * GET /admin/import - Display import page | ||
22 | */ | ||
23 | public function index(Request $request, Response $response): Response | ||
24 | { | ||
25 | $this->assignView( | ||
26 | 'maxfilesize', | ||
27 | get_max_upload_size( | ||
28 | ini_get('post_max_size'), | ||
29 | ini_get('upload_max_filesize'), | ||
30 | false | ||
31 | ) | ||
32 | ); | ||
33 | $this->assignView( | ||
34 | 'maxfilesizeHuman', | ||
35 | get_max_upload_size( | ||
36 | ini_get('post_max_size'), | ||
37 | ini_get('upload_max_filesize'), | ||
38 | true | ||
39 | ) | ||
40 | ); | ||
41 | $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli')); | ||
42 | |||
43 | return $response->write($this->render(TemplatePage::IMPORT)); | ||
44 | } | ||
45 | |||
46 | /** | ||
47 | * POST /admin/import - Process import file provided and create bookmarks | ||
48 | */ | ||
49 | public function import(Request $request, Response $response): Response | ||
50 | { | ||
51 | $this->checkToken($request); | ||
52 | |||
53 | $file = ($request->getUploadedFiles() ?? [])['filetoupload'] ?? null; | ||
54 | if (!$file instanceof UploadedFileInterface) { | ||
55 | $this->saveErrorMessage(t('No import file provided.')); | ||
56 | |||
57 | return $this->redirect($response, '/admin/import'); | ||
58 | } | ||
59 | |||
60 | |||
61 | // Import bookmarks from an uploaded file | ||
62 | if (0 === $file->getSize()) { | ||
63 | // The file is too big or some form field may be missing. | ||
64 | $msg = sprintf( | ||
65 | t( | ||
66 | 'The file you are trying to upload is probably bigger than what this webserver can accept' | ||
67 | .' (%s). Please upload in smaller chunks.' | ||
68 | ), | ||
69 | get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')) | ||
70 | ); | ||
71 | $this->saveErrorMessage($msg); | ||
72 | |||
73 | return $this->redirect($response, '/admin/import'); | ||
74 | } | ||
75 | |||
76 | $status = $this->container->netscapeBookmarkUtils->import($request->getParams(), $file); | ||
77 | |||
78 | $this->saveSuccessMessage($status); | ||
79 | |||
80 | return $this->redirect($response, '/admin/import'); | ||
81 | } | ||
82 | } | ||
diff --git a/application/front/controller/admin/LogoutController.php b/application/front/controller/admin/LogoutController.php new file mode 100644 index 00000000..28165129 --- /dev/null +++ b/application/front/controller/admin/LogoutController.php | |||
@@ -0,0 +1,33 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Security\CookieManager; | ||
8 | use Shaarli\Security\LoginManager; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class LogoutController | ||
14 | * | ||
15 | * Slim controller used to logout the user. | ||
16 | * It invalidates page cache and terminate the user session. Then it redirects to the homepage. | ||
17 | */ | ||
18 | class LogoutController extends ShaarliAdminController | ||
19 | { | ||
20 | public function index(Request $request, Response $response): Response | ||
21 | { | ||
22 | $this->container->pageCacheManager->invalidateCaches(); | ||
23 | $this->container->sessionManager->logout(); | ||
24 | $this->container->cookieManager->setCookieParameter( | ||
25 | CookieManager::STAY_SIGNED_IN, | ||
26 | 'false', | ||
27 | 0, | ||
28 | $this->container->basePath . '/' | ||
29 | ); | ||
30 | |||
31 | return $this->redirect($response, '/'); | ||
32 | } | ||
33 | } | ||
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php new file mode 100644 index 00000000..2065c3e2 --- /dev/null +++ b/application/front/controller/admin/ManageTagController.php | |||
@@ -0,0 +1,88 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ManageTagController | ||
14 | * | ||
15 | * Slim controller used to handle Shaarli manage tags page (rename and delete tags). | ||
16 | */ | ||
17 | class ManageTagController extends ShaarliAdminController | ||
18 | { | ||
19 | /** | ||
20 | * GET /admin/tags - Displays the manage tags page | ||
21 | */ | ||
22 | public function index(Request $request, Response $response): Response | ||
23 | { | ||
24 | $fromTag = $request->getParam('fromtag') ?? ''; | ||
25 | |||
26 | $this->assignView('fromtag', escape($fromTag)); | ||
27 | $this->assignView( | ||
28 | 'pagetitle', | ||
29 | t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
30 | ); | ||
31 | |||
32 | return $response->write($this->render(TemplatePage::CHANGE_TAG)); | ||
33 | } | ||
34 | |||
35 | /** | ||
36 | * POST /admin/tags - Update or delete provided tag | ||
37 | */ | ||
38 | public function save(Request $request, Response $response): Response | ||
39 | { | ||
40 | $this->checkToken($request); | ||
41 | |||
42 | $isDelete = null !== $request->getParam('deletetag') && null === $request->getParam('renametag'); | ||
43 | |||
44 | $fromTag = trim($request->getParam('fromtag') ?? ''); | ||
45 | $toTag = trim($request->getParam('totag') ?? ''); | ||
46 | |||
47 | if (0 === strlen($fromTag) || false === $isDelete && 0 === strlen($toTag)) { | ||
48 | $this->saveWarningMessage(t('Invalid tags provided.')); | ||
49 | |||
50 | return $this->redirect($response, '/admin/tags'); | ||
51 | } | ||
52 | |||
53 | // TODO: move this to bookmark service | ||
54 | $count = 0; | ||
55 | $bookmarks = $this->container->bookmarkService->search(['searchtags' => $fromTag], BookmarkFilter::$ALL, true); | ||
56 | foreach ($bookmarks as $bookmark) { | ||
57 | if (false === $isDelete) { | ||
58 | $bookmark->renameTag($fromTag, $toTag); | ||
59 | } else { | ||
60 | $bookmark->deleteTag($fromTag); | ||
61 | } | ||
62 | |||
63 | $this->container->bookmarkService->set($bookmark, false); | ||
64 | $this->container->history->updateLink($bookmark); | ||
65 | $count++; | ||
66 | } | ||
67 | |||
68 | $this->container->bookmarkService->save(); | ||
69 | |||
70 | if (true === $isDelete) { | ||
71 | $alert = sprintf( | ||
72 | t('The tag was removed from %d bookmark.', 'The tag was removed from %d bookmarks.', $count), | ||
73 | $count | ||
74 | ); | ||
75 | } else { | ||
76 | $alert = sprintf( | ||
77 | t('The tag was renamed in %d bookmark.', 'The tag was renamed in %d bookmarks.', $count), | ||
78 | $count | ||
79 | ); | ||
80 | } | ||
81 | |||
82 | $this->saveSuccessMessage($alert); | ||
83 | |||
84 | $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag); | ||
85 | |||
86 | return $this->redirect($response, $redirect); | ||
87 | } | ||
88 | } | ||
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php new file mode 100644 index 00000000..ff845944 --- /dev/null +++ b/application/front/controller/admin/MetadataController.php | |||
@@ -0,0 +1,29 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Controller used to retrieve/update bookmark's metadata. | ||
12 | */ | ||
13 | class MetadataController extends ShaarliAdminController | ||
14 | { | ||
15 | /** | ||
16 | * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL. | ||
17 | */ | ||
18 | public function ajaxRetrieveTitle(Request $request, Response $response): Response | ||
19 | { | ||
20 | $url = $request->getParam('url'); | ||
21 | |||
22 | // Only try to extract metadata from URL with HTTP(s) scheme | ||
23 | if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) { | ||
24 | return $response->withJson($this->container->metadataRetriever->retrieve($url)); | ||
25 | } | ||
26 | |||
27 | return $response->withJson([]); | ||
28 | } | ||
29 | } | ||
diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php new file mode 100644 index 00000000..5ec0d24b --- /dev/null +++ b/application/front/controller/admin/PasswordController.php | |||
@@ -0,0 +1,101 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Container\ShaarliContainer; | ||
8 | use Shaarli\Front\Exception\OpenShaarliPasswordException; | ||
9 | use Shaarli\Front\Exception\ShaarliFrontException; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Slim\Http\Request; | ||
12 | use Slim\Http\Response; | ||
13 | use Throwable; | ||
14 | |||
15 | /** | ||
16 | * Class PasswordController | ||
17 | * | ||
18 | * Slim controller used to handle passwords update. | ||
19 | */ | ||
20 | class PasswordController extends ShaarliAdminController | ||
21 | { | ||
22 | public function __construct(ShaarliContainer $container) | ||
23 | { | ||
24 | parent::__construct($container); | ||
25 | |||
26 | $this->assignView( | ||
27 | 'pagetitle', | ||
28 | t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
29 | ); | ||
30 | } | ||
31 | |||
32 | /** | ||
33 | * GET /admin/password - Displays the change password template | ||
34 | */ | ||
35 | public function index(Request $request, Response $response): Response | ||
36 | { | ||
37 | return $response->write($this->render(TemplatePage::CHANGE_PASSWORD)); | ||
38 | } | ||
39 | |||
40 | /** | ||
41 | * POST /admin/password - Change admin password - existing and new passwords need to be provided. | ||
42 | */ | ||
43 | public function change(Request $request, Response $response): Response | ||
44 | { | ||
45 | $this->checkToken($request); | ||
46 | |||
47 | if ($this->container->conf->get('security.open_shaarli', false)) { | ||
48 | throw new OpenShaarliPasswordException(); | ||
49 | } | ||
50 | |||
51 | $oldPassword = $request->getParam('oldpassword'); | ||
52 | $newPassword = $request->getParam('setpassword'); | ||
53 | |||
54 | if (empty($newPassword) || empty($oldPassword)) { | ||
55 | $this->saveErrorMessage(t('You must provide the current and new password to change it.')); | ||
56 | |||
57 | return $response | ||
58 | ->withStatus(400) | ||
59 | ->write($this->render(TemplatePage::CHANGE_PASSWORD)) | ||
60 | ; | ||
61 | } | ||
62 | |||
63 | // Make sure old password is correct. | ||
64 | $oldHash = sha1( | ||
65 | $oldPassword . | ||
66 | $this->container->conf->get('credentials.login') . | ||
67 | $this->container->conf->get('credentials.salt') | ||
68 | ); | ||
69 | |||
70 | if ($oldHash !== $this->container->conf->get('credentials.hash')) { | ||
71 | $this->saveErrorMessage(t('The old password is not correct.')); | ||
72 | |||
73 | return $response | ||
74 | ->withStatus(400) | ||
75 | ->write($this->render(TemplatePage::CHANGE_PASSWORD)) | ||
76 | ; | ||
77 | } | ||
78 | |||
79 | // Save new password | ||
80 | // Salt renders rainbow-tables attacks useless. | ||
81 | $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); | ||
82 | $this->container->conf->set( | ||
83 | 'credentials.hash', | ||
84 | sha1( | ||
85 | $newPassword | ||
86 | . $this->container->conf->get('credentials.login') | ||
87 | . $this->container->conf->get('credentials.salt') | ||
88 | ) | ||
89 | ); | ||
90 | |||
91 | try { | ||
92 | $this->container->conf->write($this->container->loginManager->isLoggedIn()); | ||
93 | } catch (Throwable $e) { | ||
94 | throw new ShaarliFrontException($e->getMessage(), 500, $e); | ||
95 | } | ||
96 | |||
97 | $this->saveSuccessMessage(t('Your password has been changed')); | ||
98 | |||
99 | return $response->write($this->render(TemplatePage::CHANGE_PASSWORD)); | ||
100 | } | ||
101 | } | ||
diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php new file mode 100644 index 00000000..8e059681 --- /dev/null +++ b/application/front/controller/admin/PluginsController.php | |||
@@ -0,0 +1,85 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Exception; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class PluginsController | ||
14 | * | ||
15 | * Slim controller used to handle Shaarli plugins configuration page (display + save new config). | ||
16 | */ | ||
17 | class PluginsController extends ShaarliAdminController | ||
18 | { | ||
19 | /** | ||
20 | * GET /admin/plugins - Displays the configuration page | ||
21 | */ | ||
22 | public function index(Request $request, Response $response): Response | ||
23 | { | ||
24 | $pluginMeta = $this->container->pluginManager->getPluginsMeta(); | ||
25 | |||
26 | // Split plugins into 2 arrays: ordered enabled plugins and disabled. | ||
27 | $enabledPlugins = array_filter($pluginMeta, function ($v) { | ||
28 | return ($v['order'] ?? false) !== false; | ||
29 | }); | ||
30 | $enabledPlugins = load_plugin_parameter_values($enabledPlugins, $this->container->conf->get('plugins', [])); | ||
31 | uasort( | ||
32 | $enabledPlugins, | ||
33 | function ($a, $b) { | ||
34 | return $a['order'] - $b['order']; | ||
35 | } | ||
36 | ); | ||
37 | $disabledPlugins = array_filter($pluginMeta, function ($v) { | ||
38 | return ($v['order'] ?? false) === false; | ||
39 | }); | ||
40 | |||
41 | $this->assignView('enabledPlugins', $enabledPlugins); | ||
42 | $this->assignView('disabledPlugins', $disabledPlugins); | ||
43 | $this->assignView( | ||
44 | 'pagetitle', | ||
45 | t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
46 | ); | ||
47 | |||
48 | return $response->write($this->render(TemplatePage::PLUGINS_ADMIN)); | ||
49 | } | ||
50 | |||
51 | /** | ||
52 | * POST /admin/plugins - Update Shaarli's configuration | ||
53 | */ | ||
54 | public function save(Request $request, Response $response): Response | ||
55 | { | ||
56 | $this->checkToken($request); | ||
57 | |||
58 | try { | ||
59 | $parameters = $request->getParams() ?? []; | ||
60 | |||
61 | $this->executePageHooks('save_plugin_parameters', $parameters); | ||
62 | |||
63 | if (isset($parameters['parameters_form'])) { | ||
64 | unset($parameters['parameters_form']); | ||
65 | unset($parameters['token']); | ||
66 | foreach ($parameters as $param => $value) { | ||
67 | $this->container->conf->set('plugins.'. $param, escape($value)); | ||
68 | } | ||
69 | } else { | ||
70 | $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters)); | ||
71 | } | ||
72 | |||
73 | $this->container->conf->write($this->container->loginManager->isLoggedIn()); | ||
74 | $this->container->history->updateSettings(); | ||
75 | |||
76 | $this->saveSuccessMessage(t('Setting successfully saved.')); | ||
77 | } catch (Exception $e) { | ||
78 | $this->saveErrorMessage( | ||
79 | t('Error while saving plugin configuration: ') . PHP_EOL . $e->getMessage() | ||
80 | ); | ||
81 | } | ||
82 | |||
83 | return $this->redirect($response, '/admin/plugins'); | ||
84 | } | ||
85 | } | ||
diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php new file mode 100644 index 00000000..bfc99422 --- /dev/null +++ b/application/front/controller/admin/ServerController.php | |||
@@ -0,0 +1,87 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Helper\ApplicationUtils; | ||
8 | use Shaarli\Helper\FileUtils; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Slim controller used to handle Server administration page, and actions. | ||
14 | */ | ||
15 | class ServerController extends ShaarliAdminController | ||
16 | { | ||
17 | /** @var string Cache type - main - by default pagecache/ and tmp/ */ | ||
18 | protected const CACHE_MAIN = 'main'; | ||
19 | |||
20 | /** @var string Cache type - thumbnails - by default cache/ */ | ||
21 | protected const CACHE_THUMB = 'thumbnails'; | ||
22 | |||
23 | /** | ||
24 | * GET /admin/server - Display page Server administration | ||
25 | */ | ||
26 | public function index(Request $request, Response $response): Response | ||
27 | { | ||
28 | $latestVersion = 'v' . ApplicationUtils::getVersion( | ||
29 | ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE | ||
30 | ); | ||
31 | $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php'); | ||
32 | $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion; | ||
33 | $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION)); | ||
34 | |||
35 | $this->assignView('php_version', PHP_VERSION); | ||
36 | $this->assignView('php_eol', format_date($phpEol, false)); | ||
37 | $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable()); | ||
38 | $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement()); | ||
39 | $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf)); | ||
40 | $this->assignView('release_url', ApplicationUtils::$GITHUB_URL . '/releases/tag/' . $latestVersion); | ||
41 | $this->assignView('latest_version', $latestVersion); | ||
42 | $this->assignView('current_version', $currentVersion); | ||
43 | $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode')); | ||
44 | $this->assignView('index_url', index_url($this->container->environment)); | ||
45 | $this->assignView('client_ip', client_ip_id($this->container->environment)); | ||
46 | $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', [])); | ||
47 | |||
48 | $this->assignView( | ||
49 | 'pagetitle', | ||
50 | t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') | ||
51 | ); | ||
52 | |||
53 | return $response->write($this->render('server')); | ||
54 | } | ||
55 | |||
56 | /** | ||
57 | * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails). | ||
58 | */ | ||
59 | public function clearCache(Request $request, Response $response): Response | ||
60 | { | ||
61 | $exclude = ['.htaccess']; | ||
62 | |||
63 | if ($request->getQueryParam('type') === static::CACHE_THUMB) { | ||
64 | $folders = [$this->container->conf->get('resource.thumbnails_cache')]; | ||
65 | |||
66 | $this->saveWarningMessage( | ||
67 | t('Thumbnails cache has been cleared.') . ' ' . | ||
68 | '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>' | ||
69 | ); | ||
70 | } else { | ||
71 | $folders = [ | ||
72 | $this->container->conf->get('resource.page_cache'), | ||
73 | $this->container->conf->get('resource.raintpl_tmp'), | ||
74 | ]; | ||
75 | |||
76 | $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!')); | ||
77 | } | ||
78 | |||
79 | // Make sure that we don't delete root cache folder | ||
80 | $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders)))); | ||
81 | foreach ($folders as $folder) { | ||
82 | FileUtils::clearFolder($folder, false, $exclude); | ||
83 | } | ||
84 | |||
85 | return $this->redirect($response, '/admin/server'); | ||
86 | } | ||
87 | } | ||
diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php new file mode 100644 index 00000000..d9a7a2e0 --- /dev/null +++ b/application/front/controller/admin/SessionFilterController.php | |||
@@ -0,0 +1,50 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Security\SessionManager; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class SessionFilterController | ||
14 | * | ||
15 | * Slim controller used to handle filters stored in the user session, such as visibility, etc. | ||
16 | */ | ||
17 | class SessionFilterController extends ShaarliAdminController | ||
18 | { | ||
19 | /** | ||
20 | * GET /admin/visibility: allows to display only public or only private bookmarks in linklist | ||
21 | */ | ||
22 | public function visibility(Request $request, Response $response, array $args): Response | ||
23 | { | ||
24 | if (false === $this->container->loginManager->isLoggedIn()) { | ||
25 | return $this->redirectFromReferer($request, $response, ['visibility']); | ||
26 | } | ||
27 | |||
28 | $newVisibility = $args['visibility'] ?? null; | ||
29 | if (false === in_array($newVisibility, [BookmarkFilter::$PRIVATE, BookmarkFilter::$PUBLIC], true)) { | ||
30 | $newVisibility = null; | ||
31 | } | ||
32 | |||
33 | $currentVisibility = $this->container->sessionManager->getSessionParameter(SessionManager::KEY_VISIBILITY); | ||
34 | |||
35 | // Visibility not set or not already expected value, set expected value, otherwise reset it | ||
36 | if ($newVisibility !== null && (null === $currentVisibility || $currentVisibility !== $newVisibility)) { | ||
37 | // See only public bookmarks | ||
38 | $this->container->sessionManager->setSessionParameter( | ||
39 | SessionManager::KEY_VISIBILITY, | ||
40 | $newVisibility | ||
41 | ); | ||
42 | } else { | ||
43 | $this->container->sessionManager->deleteSessionParameter(SessionManager::KEY_VISIBILITY); | ||
44 | } | ||
45 | |||
46 | return $this->redirectFromReferer($request, $response, ['visibility']); | ||
47 | } | ||
48 | |||
49 | |||
50 | } | ||
diff --git a/application/front/controller/admin/ShaareAddController.php b/application/front/controller/admin/ShaareAddController.php new file mode 100644 index 00000000..8dc386b2 --- /dev/null +++ b/application/front/controller/admin/ShaareAddController.php | |||
@@ -0,0 +1,34 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | class ShaareAddController extends ShaarliAdminController | ||
13 | { | ||
14 | /** | ||
15 | * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL | ||
16 | */ | ||
17 | public function addShaare(Request $request, Response $response): Response | ||
18 | { | ||
19 | $tags = $this->container->bookmarkService->bookmarksCountPerTag(); | ||
20 | if ($this->container->conf->get('formatter') === 'markdown') { | ||
21 | $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; | ||
22 | } | ||
23 | |||
24 | $this->assignView( | ||
25 | 'pagetitle', | ||
26 | t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
27 | ); | ||
28 | $this->assignView('tags', $tags); | ||
29 | $this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false)); | ||
30 | $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true)); | ||
31 | |||
32 | return $response->write($this->render(TemplatePage::ADDLINK)); | ||
33 | } | ||
34 | } | ||
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php new file mode 100644 index 00000000..7ceb8d8a --- /dev/null +++ b/application/front/controller/admin/ShaareManageController.php | |||
@@ -0,0 +1,202 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class PostBookmarkController | ||
13 | * | ||
14 | * Slim controller used to handle Shaarli create or edit bookmarks. | ||
15 | */ | ||
16 | class ShaareManageController extends ShaarliAdminController | ||
17 | { | ||
18 | /** | ||
19 | * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter). | ||
20 | */ | ||
21 | public function deleteBookmark(Request $request, Response $response): Response | ||
22 | { | ||
23 | $this->checkToken($request); | ||
24 | |||
25 | $ids = escape(trim($request->getParam('id') ?? '')); | ||
26 | if (empty($ids) || strpos($ids, ' ') !== false) { | ||
27 | // multiple, space-separated ids provided | ||
28 | $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit')); | ||
29 | } else { | ||
30 | $ids = [$ids]; | ||
31 | } | ||
32 | |||
33 | // assert at least one id is given | ||
34 | if (0 === count($ids)) { | ||
35 | $this->saveErrorMessage(t('Invalid bookmark ID provided.')); | ||
36 | |||
37 | return $this->redirectFromReferer($request, $response, [], ['delete-shaare']); | ||
38 | } | ||
39 | |||
40 | $formatter = $this->container->formatterFactory->getFormatter('raw'); | ||
41 | $count = 0; | ||
42 | foreach ($ids as $id) { | ||
43 | try { | ||
44 | $bookmark = $this->container->bookmarkService->get((int) $id); | ||
45 | } catch (BookmarkNotFoundException $e) { | ||
46 | $this->saveErrorMessage(sprintf( | ||
47 | t('Bookmark with identifier %s could not be found.'), | ||
48 | $id | ||
49 | )); | ||
50 | |||
51 | continue; | ||
52 | } | ||
53 | |||
54 | $data = $formatter->format($bookmark); | ||
55 | $this->executePageHooks('delete_link', $data); | ||
56 | $this->container->bookmarkService->remove($bookmark, false); | ||
57 | ++ $count; | ||
58 | } | ||
59 | |||
60 | if ($count > 0) { | ||
61 | $this->container->bookmarkService->save(); | ||
62 | } | ||
63 | |||
64 | // If we are called from the bookmarklet, we must close the popup: | ||
65 | if ($request->getParam('source') === 'bookmarklet') { | ||
66 | return $response->write('<script>self.close();</script>'); | ||
67 | } | ||
68 | |||
69 | // Don't redirect to where we were previously because the datastore has changed. | ||
70 | return $this->redirect($response, '/'); | ||
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); | ||
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); | ||
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..18afc2d1 --- /dev/null +++ b/application/front/controller/admin/ShaarePublishController.php | |||
@@ -0,0 +1,263 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Formatter\BookmarkFormatter; | ||
10 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
11 | use Shaarli\Render\TemplatePage; | ||
12 | use Shaarli\Thumbnailer; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | class ShaarePublishController extends ShaarliAdminController | ||
17 | { | ||
18 | /** | ||
19 | * @var BookmarkFormatter[] Statically cached instances of formatters | ||
20 | */ | ||
21 | protected $formatters = []; | ||
22 | |||
23 | /** | ||
24 | * @var array Statically cached bookmark's tags counts | ||
25 | */ | ||
26 | protected $tags; | ||
27 | |||
28 | /** | ||
29 | * GET /admin/shaare - Displays the bookmark form for creation. | ||
30 | * Note that if the URL is found in existing bookmarks, then it will be in edit mode. | ||
31 | */ | ||
32 | public function displayCreateForm(Request $request, Response $response): Response | ||
33 | { | ||
34 | $url = cleanup_url($request->getParam('post')); | ||
35 | $link = $this->buildLinkDataFromUrl($request, $url); | ||
36 | |||
37 | return $this->displayForm($link, $link['linkIsNew'], $request, $response); | ||
38 | } | ||
39 | |||
40 | /** | ||
41 | * POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page. | ||
42 | */ | ||
43 | public function displayCreateBatchForms(Request $request, Response $response): Response | ||
44 | { | ||
45 | $urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls'))); | ||
46 | |||
47 | $links = []; | ||
48 | foreach ($urls as $url) { | ||
49 | if (empty($url)) { | ||
50 | continue; | ||
51 | } | ||
52 | $link = $this->buildLinkDataFromUrl($request, $url); | ||
53 | $data = $this->buildFormData($link, $link['linkIsNew'], $request); | ||
54 | $data['token'] = $this->container->sessionManager->generateToken(); | ||
55 | $data['source'] = 'batch'; | ||
56 | |||
57 | $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); | ||
58 | |||
59 | $links[] = $data; | ||
60 | } | ||
61 | |||
62 | $this->assignView('links', $links); | ||
63 | $this->assignView('batch_mode', true); | ||
64 | $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true)); | ||
65 | |||
66 | return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH)); | ||
67 | } | ||
68 | |||
69 | /** | ||
70 | * GET /admin/shaare/{id} - Displays the bookmark form in edition mode. | ||
71 | */ | ||
72 | public function displayEditForm(Request $request, Response $response, array $args): Response | ||
73 | { | ||
74 | $id = $args['id'] ?? ''; | ||
75 | try { | ||
76 | if (false === ctype_digit($id)) { | ||
77 | throw new BookmarkNotFoundException(); | ||
78 | } | ||
79 | $bookmark = $this->container->bookmarkService->get((int) $id); // Read database | ||
80 | } catch (BookmarkNotFoundException $e) { | ||
81 | $this->saveErrorMessage(sprintf( | ||
82 | t('Bookmark with identifier %s could not be found.'), | ||
83 | $id | ||
84 | )); | ||
85 | |||
86 | return $this->redirect($response, '/'); | ||
87 | } | ||
88 | |||
89 | $formatter = $this->getFormatter('raw'); | ||
90 | $link = $formatter->format($bookmark); | ||
91 | |||
92 | return $this->displayForm($link, false, $request, $response); | ||
93 | } | ||
94 | |||
95 | /** | ||
96 | * POST /admin/shaare | ||
97 | */ | ||
98 | public function save(Request $request, Response $response): Response | ||
99 | { | ||
100 | $this->checkToken($request); | ||
101 | |||
102 | // lf_id should only be present if the link exists. | ||
103 | $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null; | ||
104 | if (null !== $id && true === $this->container->bookmarkService->exists($id)) { | ||
105 | // Edit | ||
106 | $bookmark = $this->container->bookmarkService->get($id); | ||
107 | } else { | ||
108 | // New link | ||
109 | $bookmark = new Bookmark(); | ||
110 | } | ||
111 | |||
112 | $bookmark->setTitle($request->getParam('lf_title')); | ||
113 | $bookmark->setDescription($request->getParam('lf_description')); | ||
114 | $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', [])); | ||
115 | $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN)); | ||
116 | $bookmark->setTagsString($request->getParam('lf_tags')); | ||
117 | |||
118 | if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE | ||
119 | && true !== $this->container->conf->get('general.enable_async_metadata', true) | ||
120 | && $bookmark->shouldUpdateThumbnail() | ||
121 | ) { | ||
122 | $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); | ||
123 | } | ||
124 | $this->container->bookmarkService->addOrSet($bookmark, false); | ||
125 | |||
126 | // To preserve backward compatibility with 3rd parties, plugins still use arrays | ||
127 | $formatter = $this->getFormatter('raw'); | ||
128 | $data = $formatter->format($bookmark); | ||
129 | $this->executePageHooks('save_link', $data); | ||
130 | |||
131 | $bookmark->fromArray($data); | ||
132 | $this->container->bookmarkService->set($bookmark); | ||
133 | |||
134 | // If we are called from the bookmarklet, we must close the popup: | ||
135 | if ($request->getParam('source') === 'bookmarklet') { | ||
136 | return $response->write('<script>self.close();</script>'); | ||
137 | } elseif ($request->getParam('source') === 'batch') { | ||
138 | return $response; | ||
139 | } | ||
140 | |||
141 | if (!empty($request->getParam('returnurl'))) { | ||
142 | $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl'); | ||
143 | } | ||
144 | |||
145 | return $this->redirectFromReferer( | ||
146 | $request, | ||
147 | $response, | ||
148 | ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'], | ||
149 | $bookmark->getShortUrl() | ||
150 | ); | ||
151 | } | ||
152 | |||
153 | /** | ||
154 | * Helper function used to display the shaare form whether it's a new or existing bookmark. | ||
155 | * | ||
156 | * @param array $link data used in template, either from parameters or from the data store | ||
157 | */ | ||
158 | protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response | ||
159 | { | ||
160 | $data = $this->buildFormData($link, $isNew, $request); | ||
161 | |||
162 | $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); | ||
163 | |||
164 | foreach ($data as $key => $value) { | ||
165 | $this->assignView($key, $value); | ||
166 | } | ||
167 | |||
168 | $editLabel = false === $isNew ? t('Edit') .' ' : ''; | ||
169 | $this->assignView( | ||
170 | 'pagetitle', | ||
171 | $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
172 | ); | ||
173 | |||
174 | return $response->write($this->render(TemplatePage::EDIT_LINK)); | ||
175 | } | ||
176 | |||
177 | protected function buildLinkDataFromUrl(Request $request, string $url): array | ||
178 | { | ||
179 | // Check if URL is not already in database (in this case, we will edit the existing link) | ||
180 | $bookmark = $this->container->bookmarkService->findByUrl($url); | ||
181 | if (null === $bookmark) { | ||
182 | // Get shaare data if it was provided in URL (e.g.: by the bookmarklet). | ||
183 | $title = $request->getParam('title'); | ||
184 | $description = $request->getParam('description'); | ||
185 | $tags = $request->getParam('tags'); | ||
186 | if ($request->getParam('private') !== null) { | ||
187 | $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN); | ||
188 | } else { | ||
189 | $private = $this->container->conf->get('privacy.default_private_links', false); | ||
190 | } | ||
191 | |||
192 | // If this is an HTTP(S) link, we try go get the page to extract | ||
193 | // the title (otherwise we will to straight to the edit form.) | ||
194 | if (true !== $this->container->conf->get('general.enable_async_metadata', true) | ||
195 | && empty($title) | ||
196 | && strpos(get_url_scheme($url) ?: '', 'http') !== false | ||
197 | ) { | ||
198 | $metadata = $this->container->metadataRetriever->retrieve($url); | ||
199 | } | ||
200 | |||
201 | if (empty($url)) { | ||
202 | $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: ')); | ||
203 | } | ||
204 | |||
205 | return [ | ||
206 | 'title' => $title ?? $metadata['title'] ?? '', | ||
207 | 'url' => $url ?? '', | ||
208 | 'description' => $description ?? $metadata['description'] ?? '', | ||
209 | 'tags' => $tags ?? $metadata['tags'] ?? '', | ||
210 | 'private' => $private, | ||
211 | 'linkIsNew' => true, | ||
212 | ]; | ||
213 | } | ||
214 | |||
215 | $formatter = $this->getFormatter('raw'); | ||
216 | $link = $formatter->format($bookmark); | ||
217 | $link['linkIsNew'] = false; | ||
218 | |||
219 | return $link; | ||
220 | } | ||
221 | |||
222 | protected function buildFormData(array $link, bool $isNew, Request $request): array | ||
223 | { | ||
224 | return escape([ | ||
225 | 'link' => $link, | ||
226 | 'link_is_new' => $isNew, | ||
227 | 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '', | ||
228 | 'source' => $request->getParam('source') ?? '', | ||
229 | 'tags' => $this->getTags(), | ||
230 | 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false), | ||
231 | 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true), | ||
232 | 'retrieve_description' => $this->container->conf->get('general.retrieve_description', false), | ||
233 | ]); | ||
234 | } | ||
235 | |||
236 | /** | ||
237 | * Memoize formatterFactory->getFormatter() calls. | ||
238 | */ | ||
239 | protected function getFormatter(string $type): BookmarkFormatter | ||
240 | { | ||
241 | if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) { | ||
242 | $this->formatters[$type] = $this->container->formatterFactory->getFormatter($type); | ||
243 | } | ||
244 | |||
245 | return $this->formatters[$type]; | ||
246 | } | ||
247 | |||
248 | /** | ||
249 | * Memoize bookmarkService->bookmarksCountPerTag() calls. | ||
250 | */ | ||
251 | protected function getTags(): array | ||
252 | { | ||
253 | if ($this->tags === null) { | ||
254 | $this->tags = $this->container->bookmarkService->bookmarksCountPerTag(); | ||
255 | |||
256 | if ($this->container->conf->get('formatter') === 'markdown') { | ||
257 | $this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; | ||
258 | } | ||
259 | } | ||
260 | |||
261 | return $this->tags; | ||
262 | } | ||
263 | } | ||
diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php new file mode 100644 index 00000000..c26c9cbe --- /dev/null +++ b/application/front/controller/admin/ShaarliAdminController.php | |||
@@ -0,0 +1,71 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Front\Controller\Visitor\ShaarliVisitorController; | ||
8 | use Shaarli\Front\Exception\WrongTokenException; | ||
9 | use Shaarli\Security\SessionManager; | ||
10 | use Slim\Http\Request; | ||
11 | |||
12 | /** | ||
13 | * Class ShaarliAdminController | ||
14 | * | ||
15 | * All admin controllers (for logged in users) MUST extend this abstract class. | ||
16 | * It makes sure that the user is properly logged in, and otherwise throw an exception | ||
17 | * which will redirect to the login page. | ||
18 | * | ||
19 | * @package Shaarli\Front\Controller\Admin | ||
20 | */ | ||
21 | abstract class ShaarliAdminController extends ShaarliVisitorController | ||
22 | { | ||
23 | /** | ||
24 | * Any persistent action to the config or data store must check the XSRF token validity. | ||
25 | */ | ||
26 | protected function checkToken(Request $request): bool | ||
27 | { | ||
28 | if (!$this->container->sessionManager->checkToken($request->getParam('token'))) { | ||
29 | throw new WrongTokenException(); | ||
30 | } | ||
31 | |||
32 | return true; | ||
33 | } | ||
34 | |||
35 | /** | ||
36 | * Save a SUCCESS message in user session, which will be displayed on any template page. | ||
37 | */ | ||
38 | protected function saveSuccessMessage(string $message): void | ||
39 | { | ||
40 | $this->saveMessage(SessionManager::KEY_SUCCESS_MESSAGES, $message); | ||
41 | } | ||
42 | |||
43 | /** | ||
44 | * Save a WARNING message in user session, which will be displayed on any template page. | ||
45 | */ | ||
46 | protected function saveWarningMessage(string $message): void | ||
47 | { | ||
48 | $this->saveMessage(SessionManager::KEY_WARNING_MESSAGES, $message); | ||
49 | } | ||
50 | |||
51 | /** | ||
52 | * Save an ERROR message in user session, which will be displayed on any template page. | ||
53 | */ | ||
54 | protected function saveErrorMessage(string $message): void | ||
55 | { | ||
56 | $this->saveMessage(SessionManager::KEY_ERROR_MESSAGES, $message); | ||
57 | } | ||
58 | |||
59 | /** | ||
60 | * Use the sessionManager to save the provided message using the proper type. | ||
61 | * | ||
62 | * @param string $type successed/warnings/errors | ||
63 | */ | ||
64 | protected function saveMessage(string $type, string $message): void | ||
65 | { | ||
66 | $messages = $this->container->sessionManager->getSessionParameter($type) ?? []; | ||
67 | $messages[] = $message; | ||
68 | |||
69 | $this->container->sessionManager->setSessionParameter($type, $messages); | ||
70 | } | ||
71 | } | ||
diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php new file mode 100644 index 00000000..4dc09d38 --- /dev/null +++ b/application/front/controller/admin/ThumbnailsController.php | |||
@@ -0,0 +1,65 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ToolsController | ||
14 | * | ||
15 | * Slim controller used to handle thumbnails update. | ||
16 | */ | ||
17 | class ThumbnailsController extends ShaarliAdminController | ||
18 | { | ||
19 | /** | ||
20 | * GET /admin/thumbnails - Display thumbnails update page | ||
21 | */ | ||
22 | public function index(Request $request, Response $response): Response | ||
23 | { | ||
24 | $ids = []; | ||
25 | foreach ($this->container->bookmarkService->search() as $bookmark) { | ||
26 | // A note or not HTTP(S) | ||
27 | if ($bookmark->isNote() || !startsWith(strtolower($bookmark->getUrl()), 'http')) { | ||
28 | continue; | ||
29 | } | ||
30 | |||
31 | $ids[] = $bookmark->getId(); | ||
32 | } | ||
33 | |||
34 | $this->assignView('ids', $ids); | ||
35 | $this->assignView( | ||
36 | 'pagetitle', | ||
37 | t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
38 | ); | ||
39 | |||
40 | return $response->write($this->render(TemplatePage::THUMBNAILS)); | ||
41 | } | ||
42 | |||
43 | /** | ||
44 | * PATCH /admin/shaare/{id}/thumbnail-update - Route for AJAX calls | ||
45 | */ | ||
46 | public function ajaxUpdate(Request $request, Response $response, array $args): Response | ||
47 | { | ||
48 | $id = $args['id'] ?? null; | ||
49 | |||
50 | if (false === ctype_digit($id)) { | ||
51 | return $response->withStatus(400); | ||
52 | } | ||
53 | |||
54 | try { | ||
55 | $bookmark = $this->container->bookmarkService->get((int) $id); | ||
56 | } catch (BookmarkNotFoundException $e) { | ||
57 | return $response->withStatus(404); | ||
58 | } | ||
59 | |||
60 | $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); | ||
61 | $this->container->bookmarkService->set($bookmark); | ||
62 | |||
63 | return $response->withJson($this->container->formatterFactory->getFormatter('raw')->format($bookmark)); | ||
64 | } | ||
65 | } | ||
diff --git a/application/front/controller/admin/TokenController.php b/application/front/controller/admin/TokenController.php new file mode 100644 index 00000000..08d68d0a --- /dev/null +++ b/application/front/controller/admin/TokenController.php | |||
@@ -0,0 +1,26 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class TokenController | ||
12 | * | ||
13 | * Endpoint used to retrieve a XSRF token. Useful for AJAX requests. | ||
14 | */ | ||
15 | class TokenController extends ShaarliAdminController | ||
16 | { | ||
17 | /** | ||
18 | * GET /admin/token | ||
19 | */ | ||
20 | public function getToken(Request $request, Response $response): Response | ||
21 | { | ||
22 | $response = $response->withHeader('Content-Type', 'text/plain'); | ||
23 | |||
24 | return $response->write($this->container->sessionManager->generateToken()); | ||
25 | } | ||
26 | } | ||
diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php new file mode 100644 index 00000000..a87f20d2 --- /dev/null +++ b/application/front/controller/admin/ToolsController.php | |||
@@ -0,0 +1,35 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Render\TemplatePage; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class ToolsController | ||
13 | * | ||
14 | * Slim controller used to display the tools page. | ||
15 | */ | ||
16 | class ToolsController extends ShaarliAdminController | ||
17 | { | ||
18 | public function index(Request $request, Response $response): Response | ||
19 | { | ||
20 | $data = [ | ||
21 | 'pageabsaddr' => index_url($this->container->environment), | ||
22 | 'sslenabled' => is_https($this->container->environment), | ||
23 | ]; | ||
24 | |||
25 | $this->executePageHooks('render_tools', $data, TemplatePage::TOOLS); | ||
26 | |||
27 | foreach ($data as $key => $value) { | ||
28 | $this->assignView($key, $value); | ||
29 | } | ||
30 | |||
31 | $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli')); | ||
32 | |||
33 | return $response->write($this->render(TemplatePage::TOOLS)); | ||
34 | } | ||
35 | } | ||
diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php new file mode 100644 index 00000000..78c474c9 --- /dev/null +++ b/application/front/controller/visitor/BookmarkListController.php | |||
@@ -0,0 +1,249 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Legacy\LegacyController; | ||
10 | use Shaarli\Legacy\UnknowLegacyRouteException; | ||
11 | use Shaarli\Render\TemplatePage; | ||
12 | use Shaarli\Thumbnailer; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | /** | ||
17 | * Class BookmarkListController | ||
18 | * | ||
19 | * Slim controller used to render the bookmark list, the home page of Shaarli. | ||
20 | * It also displays permalinks, and process legacy routes based on GET parameters. | ||
21 | */ | ||
22 | class BookmarkListController extends ShaarliVisitorController | ||
23 | { | ||
24 | /** | ||
25 | * GET / - Displays the bookmark list, with optional filter parameters. | ||
26 | */ | ||
27 | public function index(Request $request, Response $response): Response | ||
28 | { | ||
29 | $legacyResponse = $this->processLegacyController($request, $response); | ||
30 | if (null !== $legacyResponse) { | ||
31 | return $legacyResponse; | ||
32 | } | ||
33 | |||
34 | $formatter = $this->container->formatterFactory->getFormatter(); | ||
35 | $formatter->addContextData('base_path', $this->container->basePath); | ||
36 | |||
37 | $searchTags = normalize_spaces($request->getParam('searchtags') ?? ''); | ||
38 | $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; | ||
39 | |||
40 | // Filter bookmarks according search parameters. | ||
41 | $visibility = $this->container->sessionManager->getSessionParameter('visibility'); | ||
42 | $search = [ | ||
43 | 'searchtags' => $searchTags, | ||
44 | 'searchterm' => $searchTerm, | ||
45 | ]; | ||
46 | $linksToDisplay = $this->container->bookmarkService->search( | ||
47 | $search, | ||
48 | $visibility, | ||
49 | false, | ||
50 | !!$this->container->sessionManager->getSessionParameter('untaggedonly') | ||
51 | ) ?? []; | ||
52 | |||
53 | // ---- Handle paging. | ||
54 | $keys = []; | ||
55 | foreach ($linksToDisplay as $key => $value) { | ||
56 | $keys[] = $key; | ||
57 | } | ||
58 | |||
59 | $linksPerPage = $this->container->sessionManager->getSessionParameter('LINKS_PER_PAGE', 20) ?: 20; | ||
60 | |||
61 | // Select articles according to paging. | ||
62 | $pageCount = (int) ceil(count($keys) / $linksPerPage) ?: 1; | ||
63 | $page = (int) $request->getParam('page') ?? 1; | ||
64 | $page = $page < 1 ? 1 : $page; | ||
65 | $page = $page > $pageCount ? $pageCount : $page; | ||
66 | |||
67 | // Start index. | ||
68 | $i = ($page - 1) * $linksPerPage; | ||
69 | $end = $i + $linksPerPage; | ||
70 | |||
71 | $linkDisp = []; | ||
72 | $save = false; | ||
73 | while ($i < $end && $i < count($keys)) { | ||
74 | $save = $this->updateThumbnail($linksToDisplay[$keys[$i]], false) || $save; | ||
75 | $link = $formatter->format($linksToDisplay[$keys[$i]]); | ||
76 | |||
77 | $linkDisp[$keys[$i]] = $link; | ||
78 | $i++; | ||
79 | } | ||
80 | |||
81 | if ($save) { | ||
82 | $this->container->bookmarkService->save(); | ||
83 | } | ||
84 | |||
85 | // Compute paging navigation | ||
86 | $searchtagsUrl = $searchTags === '' ? '' : '&searchtags=' . urlencode($searchTags); | ||
87 | $searchtermUrl = $searchTerm === '' ? '' : '&searchterm=' . urlencode($searchTerm); | ||
88 | |||
89 | $previous_page_url = ''; | ||
90 | if ($i !== count($keys)) { | ||
91 | $previous_page_url = '?page=' . ($page + 1) . $searchtermUrl . $searchtagsUrl; | ||
92 | } | ||
93 | $next_page_url = ''; | ||
94 | if ($page > 1) { | ||
95 | $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl; | ||
96 | } | ||
97 | |||
98 | // Fill all template fields. | ||
99 | $data = array_merge( | ||
100 | $this->initializeTemplateVars(), | ||
101 | [ | ||
102 | 'previous_page_url' => $previous_page_url, | ||
103 | 'next_page_url' => $next_page_url, | ||
104 | 'page_current' => $page, | ||
105 | 'page_max' => $pageCount, | ||
106 | 'result_count' => count($linksToDisplay), | ||
107 | 'search_term' => escape($searchTerm), | ||
108 | 'search_tags' => escape($searchTags), | ||
109 | 'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)), | ||
110 | 'visibility' => $visibility, | ||
111 | 'links' => $linkDisp, | ||
112 | ] | ||
113 | ); | ||
114 | |||
115 | if (!empty($searchTerm) || !empty($searchTags)) { | ||
116 | $data['pagetitle'] = t('Search: '); | ||
117 | $data['pagetitle'] .= ! empty($searchTerm) ? $searchTerm . ' ' : ''; | ||
118 | $bracketWrap = function ($tag) { | ||
119 | return '[' . $tag . ']'; | ||
120 | }; | ||
121 | $data['pagetitle'] .= ! empty($searchTags) | ||
122 | ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' ' | ||
123 | : ''; | ||
124 | $data['pagetitle'] .= '- '; | ||
125 | } | ||
126 | |||
127 | $data['pagetitle'] = ($data['pagetitle'] ?? '') . $this->container->conf->get('general.title', 'Shaarli'); | ||
128 | |||
129 | $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST); | ||
130 | $this->assignAllView($data); | ||
131 | |||
132 | return $response->write($this->render(TemplatePage::LINKLIST)); | ||
133 | } | ||
134 | |||
135 | /** | ||
136 | * GET /shaare/{hash} - Display a single shaare | ||
137 | */ | ||
138 | public function permalink(Request $request, Response $response, array $args): Response | ||
139 | { | ||
140 | $privateKey = $request->getParam('key'); | ||
141 | |||
142 | try { | ||
143 | $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey); | ||
144 | } catch (BookmarkNotFoundException $e) { | ||
145 | $this->assignView('error_message', $e->getMessage()); | ||
146 | |||
147 | return $response->write($this->render(TemplatePage::ERROR_404)); | ||
148 | } | ||
149 | |||
150 | $this->updateThumbnail($bookmark); | ||
151 | |||
152 | $formatter = $this->container->formatterFactory->getFormatter(); | ||
153 | $formatter->addContextData('base_path', $this->container->basePath); | ||
154 | |||
155 | $data = array_merge( | ||
156 | $this->initializeTemplateVars(), | ||
157 | [ | ||
158 | 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'), | ||
159 | 'links' => [$formatter->format($bookmark)], | ||
160 | ] | ||
161 | ); | ||
162 | |||
163 | $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST); | ||
164 | $this->assignAllView($data); | ||
165 | |||
166 | return $response->write($this->render(TemplatePage::LINKLIST)); | ||
167 | } | ||
168 | |||
169 | /** | ||
170 | * Update the thumbnail of a single bookmark if necessary. | ||
171 | */ | ||
172 | protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool | ||
173 | { | ||
174 | if (false === $this->container->loginManager->isLoggedIn()) { | ||
175 | return false; | ||
176 | } | ||
177 | |||
178 | // If thumbnail should be updated, we reset it to null | ||
179 | if ($bookmark->shouldUpdateThumbnail()) { | ||
180 | $bookmark->setThumbnail(null); | ||
181 | |||
182 | // Requires an update, not async retrieval, thumbnails enabled | ||
183 | if ($bookmark->shouldUpdateThumbnail() | ||
184 | && true !== $this->container->conf->get('general.enable_async_metadata', true) | ||
185 | && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE | ||
186 | ) { | ||
187 | $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); | ||
188 | $this->container->bookmarkService->set($bookmark, $writeDatastore); | ||
189 | |||
190 | return true; | ||
191 | } | ||
192 | } | ||
193 | |||
194 | return false; | ||
195 | } | ||
196 | |||
197 | /** | ||
198 | * @return string[] Default template variables without values. | ||
199 | */ | ||
200 | protected function initializeTemplateVars(): array | ||
201 | { | ||
202 | return [ | ||
203 | 'previous_page_url' => '', | ||
204 | 'next_page_url' => '', | ||
205 | 'page_max' => '', | ||
206 | 'search_tags' => '', | ||
207 | 'result_count' => '', | ||
208 | 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true) | ||
209 | ]; | ||
210 | } | ||
211 | |||
212 | /** | ||
213 | * Process legacy routes if necessary. They used query parameters. | ||
214 | * If no legacy routes is passed, return null. | ||
215 | */ | ||
216 | protected function processLegacyController(Request $request, Response $response): ?Response | ||
217 | { | ||
218 | // Legacy smallhash filter | ||
219 | $queryString = $this->container->environment['QUERY_STRING'] ?? null; | ||
220 | if (null !== $queryString && 1 === preg_match('/^([a-zA-Z0-9-_@]{6})($|&|#)/', $queryString, $match)) { | ||
221 | return $this->redirect($response, '/shaare/' . $match[1]); | ||
222 | } | ||
223 | |||
224 | // Legacy controllers (mostly used for redirections) | ||
225 | if (null !== $request->getQueryParam('do')) { | ||
226 | $legacyController = new LegacyController($this->container); | ||
227 | |||
228 | try { | ||
229 | return $legacyController->process($request, $response, $request->getQueryParam('do')); | ||
230 | } catch (UnknowLegacyRouteException $e) { | ||
231 | // We ignore legacy 404 | ||
232 | return null; | ||
233 | } | ||
234 | } | ||
235 | |||
236 | // Legacy GET admin routes | ||
237 | $legacyGetRoutes = array_intersect( | ||
238 | LegacyController::LEGACY_GET_ROUTES, | ||
239 | array_keys($request->getQueryParams() ?? []) | ||
240 | ); | ||
241 | if (1 === count($legacyGetRoutes)) { | ||
242 | $legacyController = new LegacyController($this->container); | ||
243 | |||
244 | return $legacyController->process($request, $response, $legacyGetRoutes[0]); | ||
245 | } | ||
246 | |||
247 | return null; | ||
248 | } | ||
249 | } | ||
diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php new file mode 100644 index 00000000..728bc2d8 --- /dev/null +++ b/application/front/controller/visitor/DailyController.php | |||
@@ -0,0 +1,205 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use DateTime; | ||
8 | use Shaarli\Bookmark\Bookmark; | ||
9 | use Shaarli\Helper\DailyPageHelper; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Slim\Http\Request; | ||
12 | use Slim\Http\Response; | ||
13 | |||
14 | /** | ||
15 | * Class DailyController | ||
16 | * | ||
17 | * Slim controller used to render the daily page. | ||
18 | */ | ||
19 | class DailyController extends ShaarliVisitorController | ||
20 | { | ||
21 | public static $DAILY_RSS_NB_DAYS = 8; | ||
22 | |||
23 | /** | ||
24 | * Controller displaying all bookmarks published in a single day. | ||
25 | * It take a `day` date query parameter (format YYYYMMDD). | ||
26 | */ | ||
27 | public function index(Request $request, Response $response): Response | ||
28 | { | ||
29 | $type = DailyPageHelper::extractRequestedType($request); | ||
30 | $format = DailyPageHelper::getFormatByType($type); | ||
31 | $latestBookmark = $this->container->bookmarkService->getLatest(); | ||
32 | $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark); | ||
33 | $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime); | ||
34 | $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime); | ||
35 | $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime); | ||
36 | |||
37 | $linksToDisplay = $this->container->bookmarkService->findByDate( | ||
38 | $start, | ||
39 | $end, | ||
40 | $previousDay, | ||
41 | $nextDay | ||
42 | ); | ||
43 | |||
44 | $formatter = $this->container->formatterFactory->getFormatter(); | ||
45 | $formatter->addContextData('base_path', $this->container->basePath); | ||
46 | // We pre-format some fields for proper output. | ||
47 | foreach ($linksToDisplay as $key => $bookmark) { | ||
48 | $linksToDisplay[$key] = $formatter->format($bookmark); | ||
49 | // This page is a bit specific, we need raw description to calculate the length | ||
50 | $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description']; | ||
51 | $linksToDisplay[$key]['description'] = $bookmark->getDescription(); | ||
52 | } | ||
53 | |||
54 | $data = [ | ||
55 | 'linksToDisplay' => $linksToDisplay, | ||
56 | 'dayDate' => $start, | ||
57 | 'day' => $start->getTimestamp(), | ||
58 | 'previousday' => $previousDay ? $previousDay->format($format) : '', | ||
59 | 'nextday' => $nextDay ? $nextDay->format($format) : '', | ||
60 | 'dayDesc' => $dailyDesc, | ||
61 | 'type' => $type, | ||
62 | 'localizedType' => $this->translateType($type), | ||
63 | ]; | ||
64 | |||
65 | // Hooks are called before column construction so that plugins don't have to deal with columns. | ||
66 | $this->executePageHooks('render_daily', $data, TemplatePage::DAILY); | ||
67 | |||
68 | $data['cols'] = $this->calculateColumns($data['linksToDisplay']); | ||
69 | |||
70 | $this->assignAllView($data); | ||
71 | |||
72 | $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); | ||
73 | $this->assignView( | ||
74 | 'pagetitle', | ||
75 | $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle | ||
76 | ); | ||
77 | |||
78 | return $response->write($this->render(TemplatePage::DAILY)); | ||
79 | } | ||
80 | |||
81 | /** | ||
82 | * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day. | ||
83 | * Gives the last 7 days (which have bookmarks). | ||
84 | * This RSS feed cannot be filtered and does not trigger plugins yet. | ||
85 | */ | ||
86 | public function rss(Request $request, Response $response): Response | ||
87 | { | ||
88 | $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); | ||
89 | |||
90 | $pageUrl = page_url($this->container->environment); | ||
91 | $cache = $this->container->pageCacheManager->getCachePage($pageUrl); | ||
92 | |||
93 | $cached = $cache->cachedVersion(); | ||
94 | if (!empty($cached)) { | ||
95 | return $response->write($cached); | ||
96 | } | ||
97 | |||
98 | $days = []; | ||
99 | $type = DailyPageHelper::extractRequestedType($request); | ||
100 | $format = DailyPageHelper::getFormatByType($type); | ||
101 | $length = DailyPageHelper::getRssLengthByType($type); | ||
102 | foreach ($this->container->bookmarkService->search() as $bookmark) { | ||
103 | $day = $bookmark->getCreated()->format($format); | ||
104 | |||
105 | // Stop iterating after DAILY_RSS_NB_DAYS entries | ||
106 | if (count($days) === $length && !isset($days[$day])) { | ||
107 | break; | ||
108 | } | ||
109 | |||
110 | $days[$day][] = $bookmark; | ||
111 | } | ||
112 | |||
113 | // Build the RSS feed. | ||
114 | $indexUrl = escape(index_url($this->container->environment)); | ||
115 | |||
116 | $formatter = $this->container->formatterFactory->getFormatter(); | ||
117 | $formatter->addContextData('index_url', $indexUrl); | ||
118 | |||
119 | $dataPerDay = []; | ||
120 | |||
121 | /** @var Bookmark[] $bookmarks */ | ||
122 | foreach ($days as $day => $bookmarks) { | ||
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] = [ | ||
132 | 'date' => $endDateTime, | ||
133 | 'date_rss' => $endDateTime->format(DateTime::RSS), | ||
134 | 'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime), | ||
135 | 'absolute_url' => $indexUrl . 'daily?'. $type .'=' . $day, | ||
136 | 'links' => [], | ||
137 | ]; | ||
138 | |||
139 | foreach ($bookmarks as $key => $bookmark) { | ||
140 | $dataPerDay[$day]['links'][$key] = $formatter->format($bookmark); | ||
141 | |||
142 | // Make permalink URL absolute | ||
143 | if ($bookmark->isNote()) { | ||
144 | $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl(); | ||
145 | } | ||
146 | } | ||
147 | } | ||
148 | |||
149 | $this->assignAllView([ | ||
150 | 'title' => $this->container->conf->get('general.title', 'Shaarli'), | ||
151 | 'index_url' => $indexUrl, | ||
152 | 'page_url' => $pageUrl, | ||
153 | 'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false), | ||
154 | 'days' => $dataPerDay, | ||
155 | 'type' => $type, | ||
156 | 'localizedType' => $this->translateType($type), | ||
157 | ]); | ||
158 | |||
159 | $rssContent = $this->render(TemplatePage::DAILY_RSS); | ||
160 | |||
161 | $cache->cache($rssContent); | ||
162 | |||
163 | return $response->write($rssContent); | ||
164 | } | ||
165 | |||
166 | /** | ||
167 | * We need to spread the articles on 3 columns. | ||
168 | * did not want to use a JavaScript lib like http://masonry.desandro.com/ | ||
169 | * so I manually spread entries with a simple method: I roughly evaluate the | ||
170 | * height of a div according to title and description length. | ||
171 | */ | ||
172 | protected function calculateColumns(array $links): array | ||
173 | { | ||
174 | // Entries to display, for each column. | ||
175 | $columns = [[], [], []]; | ||
176 | // Rough estimate of columns fill. | ||
177 | $fill = [0, 0, 0]; | ||
178 | foreach ($links as $link) { | ||
179 | // Roughly estimate length of entry (by counting characters) | ||
180 | // Title: 30 chars = 1 line. 1 line is 30 pixels height. | ||
181 | // Description: 836 characters gives roughly 342 pixel height. | ||
182 | // This is not perfect, but it's usually OK. | ||
183 | $length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836; | ||
184 | if (! empty($link['thumbnail'])) { | ||
185 | $length += 100; // 1 thumbnails roughly takes 100 pixels height. | ||
186 | } | ||
187 | // Then put in column which is the less filled: | ||
188 | $smallest = min($fill); // find smallest value in array. | ||
189 | $index = array_search($smallest, $fill); // find index of this smallest value. | ||
190 | array_push($columns[$index], $link); // Put entry in this column. | ||
191 | $fill[$index] += $length; | ||
192 | } | ||
193 | |||
194 | return $columns; | ||
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 | } | ||
205 | } | ||
diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php new file mode 100644 index 00000000..8da11172 --- /dev/null +++ b/application/front/controller/visitor/ErrorController.php | |||
@@ -0,0 +1,42 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Front\Exception\ShaarliFrontException; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Controller used to render the error page, with a provided exception. | ||
13 | * It is actually used as a Slim error handler. | ||
14 | */ | ||
15 | class ErrorController extends ShaarliVisitorController | ||
16 | { | ||
17 | public function __invoke(Request $request, Response $response, \Throwable $throwable): Response | ||
18 | { | ||
19 | // Unknown error encountered | ||
20 | $this->container->pageBuilder->reset(); | ||
21 | |||
22 | if ($throwable instanceof ShaarliFrontException) { | ||
23 | // Functional error | ||
24 | $this->assignView('message', nl2br($throwable->getMessage())); | ||
25 | |||
26 | $response = $response->withStatus($throwable->getCode()); | ||
27 | } else { | ||
28 | // Internal error (any other Throwable) | ||
29 | if ($this->container->conf->get('dev.debug', false)) { | ||
30 | $this->assignView('message', $throwable->getMessage()); | ||
31 | $this->assignView('stacktrace', exception2text($throwable)); | ||
32 | } else { | ||
33 | $this->assignView('message', t('An unexpected error occurred.')); | ||
34 | } | ||
35 | |||
36 | $response = $response->withStatus(500); | ||
37 | } | ||
38 | |||
39 | |||
40 | return $response->write($this->render('error')); | ||
41 | } | ||
42 | } | ||
diff --git a/application/front/controller/visitor/ErrorNotFoundController.php b/application/front/controller/visitor/ErrorNotFoundController.php new file mode 100644 index 00000000..758dd83b --- /dev/null +++ b/application/front/controller/visitor/ErrorNotFoundController.php | |||
@@ -0,0 +1,29 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Controller used to render the 404 error page. | ||
12 | */ | ||
13 | class ErrorNotFoundController extends ShaarliVisitorController | ||
14 | { | ||
15 | public function __invoke(Request $request, Response $response): Response | ||
16 | { | ||
17 | // Request from the API | ||
18 | if (false !== strpos($request->getRequestTarget(), '/api/v1')) { | ||
19 | return $response->withStatus(404); | ||
20 | } | ||
21 | |||
22 | // This is required because the middleware is ignored if the route is not found. | ||
23 | $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); | ||
24 | |||
25 | $this->assignView('error_message', t('Requested page could not be found.')); | ||
26 | |||
27 | return $response->withStatus(404)->write($this->render('404')); | ||
28 | } | ||
29 | } | ||
diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php new file mode 100644 index 00000000..8d8b546a --- /dev/null +++ b/application/front/controller/visitor/FeedController.php | |||
@@ -0,0 +1,58 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Feed\FeedBuilder; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class FeedController | ||
13 | * | ||
14 | * Slim controller handling ATOM and RSS feed. | ||
15 | */ | ||
16 | class FeedController extends ShaarliVisitorController | ||
17 | { | ||
18 | public function atom(Request $request, Response $response): Response | ||
19 | { | ||
20 | return $this->processRequest(FeedBuilder::$FEED_ATOM, $request, $response); | ||
21 | } | ||
22 | |||
23 | public function rss(Request $request, Response $response): Response | ||
24 | { | ||
25 | return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response); | ||
26 | } | ||
27 | |||
28 | protected function processRequest(string $feedType, Request $request, Response $response): Response | ||
29 | { | ||
30 | $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); | ||
31 | |||
32 | $pageUrl = page_url($this->container->environment); | ||
33 | $cache = $this->container->pageCacheManager->getCachePage($pageUrl); | ||
34 | |||
35 | $cached = $cache->cachedVersion(); | ||
36 | if (!empty($cached)) { | ||
37 | return $response->write($cached); | ||
38 | } | ||
39 | |||
40 | // Generate data. | ||
41 | $this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0))); | ||
42 | $this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false)); | ||
43 | $this->container->feedBuilder->setUsePermalinks( | ||
44 | null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks') | ||
45 | ); | ||
46 | |||
47 | $data = $this->container->feedBuilder->buildData($feedType, $request->getParams()); | ||
48 | |||
49 | $this->executePageHooks('render_feed', $data, 'feed.' . $feedType); | ||
50 | $this->assignAllView($data); | ||
51 | |||
52 | $content = $this->render('feed.' . $feedType); | ||
53 | |||
54 | $cache->cache($content); | ||
55 | |||
56 | return $response->write($content); | ||
57 | } | ||
58 | } | ||
diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php new file mode 100644 index 00000000..22329294 --- /dev/null +++ b/application/front/controller/visitor/InstallController.php | |||
@@ -0,0 +1,175 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Container\ShaarliContainer; | ||
8 | use Shaarli\Front\Exception\AlreadyInstalledException; | ||
9 | use Shaarli\Front\Exception\ResourcePermissionException; | ||
10 | use Shaarli\Helper\ApplicationUtils; | ||
11 | use Shaarli\Languages; | ||
12 | use Shaarli\Security\SessionManager; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | /** | ||
17 | * Slim controller used to render install page, and create initial configuration file. | ||
18 | */ | ||
19 | class InstallController extends ShaarliVisitorController | ||
20 | { | ||
21 | public const SESSION_TEST_KEY = 'session_tested'; | ||
22 | public const SESSION_TEST_VALUE = 'Working'; | ||
23 | |||
24 | public function __construct(ShaarliContainer $container) | ||
25 | { | ||
26 | parent::__construct($container); | ||
27 | |||
28 | if (is_file($this->container->conf->getConfigFileExt())) { | ||
29 | throw new AlreadyInstalledException(); | ||
30 | } | ||
31 | } | ||
32 | |||
33 | /** | ||
34 | * Display the install template page. | ||
35 | * Also test file permissions and sessions beforehand. | ||
36 | */ | ||
37 | public function index(Request $request, Response $response): Response | ||
38 | { | ||
39 | // Before installation, we'll make sure that permissions are set properly, and sessions are working. | ||
40 | $this->checkPermissions(); | ||
41 | |||
42 | if (static::SESSION_TEST_VALUE | ||
43 | !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) | ||
44 | ) { | ||
45 | $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE); | ||
46 | |||
47 | return $this->redirect($response, '/install/session-test'); | ||
48 | } | ||
49 | |||
50 | [$continents, $cities] = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get()); | ||
51 | |||
52 | $this->assignView('continents', $continents); | ||
53 | $this->assignView('cities', $cities); | ||
54 | $this->assignView('languages', Languages::getAvailableLanguages()); | ||
55 | |||
56 | $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION)); | ||
57 | |||
58 | $this->assignView('php_version', PHP_VERSION); | ||
59 | $this->assignView('php_eol', format_date($phpEol, false)); | ||
60 | $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable()); | ||
61 | $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement()); | ||
62 | $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf)); | ||
63 | |||
64 | $this->assignView('pagetitle', t('Install Shaarli')); | ||
65 | |||
66 | return $response->write($this->render('install')); | ||
67 | } | ||
68 | |||
69 | /** | ||
70 | * Route checking that the session parameter has been properly saved between two distinct requests. | ||
71 | * If the session parameter is preserved, redirect to install template page, otherwise displays error. | ||
72 | */ | ||
73 | public function sessionTest(Request $request, Response $response): Response | ||
74 | { | ||
75 | // This part makes sure sessions works correctly. | ||
76 | // (Because on some hosts, session.save_path may not be set correctly, | ||
77 | // or we may not have write access to it.) | ||
78 | if (static::SESSION_TEST_VALUE | ||
79 | !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) | ||
80 | ) { | ||
81 | // Step 2: Check if data in session is correct. | ||
82 | $msg = t( | ||
83 | '<pre>Sessions do not seem to work correctly on your server.<br>'. | ||
84 | 'Make sure the variable "session.save_path" is set correctly in your PHP config, '. | ||
85 | 'and that you have write access to it.<br>'. | ||
86 | 'It currently points to %s.<br>'. | ||
87 | 'On some browsers, accessing your server via a hostname like \'localhost\' '. | ||
88 | 'or any custom hostname without a dot causes cookie storage to fail. '. | ||
89 | 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>' | ||
90 | ); | ||
91 | $msg = sprintf($msg, $this->container->sessionManager->getSavePath()); | ||
92 | |||
93 | $this->assignView('message', $msg); | ||
94 | |||
95 | return $response->write($this->render('error')); | ||
96 | } | ||
97 | |||
98 | return $this->redirect($response, '/install'); | ||
99 | } | ||
100 | |||
101 | /** | ||
102 | * Save installation form and initialize config file and datastore if necessary. | ||
103 | */ | ||
104 | public function save(Request $request, Response $response): Response | ||
105 | { | ||
106 | $timezone = 'UTC'; | ||
107 | if (!empty($request->getParam('continent')) | ||
108 | && !empty($request->getParam('city')) | ||
109 | && isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) | ||
110 | ) { | ||
111 | $timezone = $request->getParam('continent') . '/' . $request->getParam('city'); | ||
112 | } | ||
113 | $this->container->conf->set('general.timezone', $timezone); | ||
114 | |||
115 | $login = $request->getParam('setlogin'); | ||
116 | $this->container->conf->set('credentials.login', $login); | ||
117 | $salt = sha1(uniqid('', true) .'_'. mt_rand()); | ||
118 | $this->container->conf->set('credentials.salt', $salt); | ||
119 | $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); | ||
120 | |||
121 | if (!empty($request->getParam('title'))) { | ||
122 | $this->container->conf->set('general.title', escape($request->getParam('title'))); | ||
123 | } else { | ||
124 | $this->container->conf->set( | ||
125 | 'general.title', | ||
126 | 'Shared bookmarks on '.escape(index_url($this->container->environment)) | ||
127 | ); | ||
128 | } | ||
129 | |||
130 | $this->container->conf->set('translation.language', escape($request->getParam('language'))); | ||
131 | $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck'))); | ||
132 | $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi'))); | ||
133 | $this->container->conf->set( | ||
134 | 'api.secret', | ||
135 | generate_api_secret( | ||
136 | $this->container->conf->get('credentials.login'), | ||
137 | $this->container->conf->get('credentials.salt') | ||
138 | ) | ||
139 | ); | ||
140 | $this->container->conf->set('general.header_link', $this->container->basePath . '/'); | ||
141 | |||
142 | try { | ||
143 | // Everything is ok, let's create config file. | ||
144 | $this->container->conf->write($this->container->loginManager->isLoggedIn()); | ||
145 | } catch (\Exception $e) { | ||
146 | $this->assignView('message', t('Error while writing config file after configuration update.')); | ||
147 | $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString()); | ||
148 | |||
149 | return $response->write($this->render('error')); | ||
150 | } | ||
151 | |||
152 | $this->container->sessionManager->setSessionParameter( | ||
153 | SessionManager::KEY_SUCCESS_MESSAGES, | ||
154 | [t('Shaarli is now configured. Please login and start shaaring your bookmarks!')] | ||
155 | ); | ||
156 | |||
157 | return $this->redirect($response, '/login'); | ||
158 | } | ||
159 | |||
160 | protected function checkPermissions(): bool | ||
161 | { | ||
162 | // Ensure Shaarli has proper access to its resources | ||
163 | $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true); | ||
164 | if (empty($errors)) { | ||
165 | return true; | ||
166 | } | ||
167 | |||
168 | $message = t('Insufficient permissions:') . PHP_EOL; | ||
169 | foreach ($errors as $error) { | ||
170 | $message .= PHP_EOL . $error; | ||
171 | } | ||
172 | |||
173 | throw new ResourcePermissionException($message); | ||
174 | } | ||
175 | } | ||
diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php new file mode 100644 index 00000000..f5038fe3 --- /dev/null +++ b/application/front/controller/visitor/LoginController.php | |||
@@ -0,0 +1,153 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Front\Exception\CantLoginException; | ||
8 | use Shaarli\Front\Exception\LoginBannedException; | ||
9 | use Shaarli\Front\Exception\WrongTokenException; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Shaarli\Security\CookieManager; | ||
12 | use Shaarli\Security\SessionManager; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | /** | ||
17 | * Class LoginController | ||
18 | * | ||
19 | * Slim controller used to render the login page. | ||
20 | * | ||
21 | * The login page is not available if the user is banned | ||
22 | * or if open shaarli setting is enabled. | ||
23 | */ | ||
24 | class LoginController extends ShaarliVisitorController | ||
25 | { | ||
26 | /** | ||
27 | * GET /login - Display the login page. | ||
28 | */ | ||
29 | public function index(Request $request, Response $response): Response | ||
30 | { | ||
31 | try { | ||
32 | $this->checkLoginState(); | ||
33 | } catch (CantLoginException $e) { | ||
34 | return $this->redirect($response, '/'); | ||
35 | } | ||
36 | |||
37 | if ($request->getParam('login') !== null) { | ||
38 | $this->assignView('username', escape($request->getParam('login'))); | ||
39 | } | ||
40 | |||
41 | $returnUrl = $request->getParam('returnurl') ?? $this->container->environment['HTTP_REFERER'] ?? null; | ||
42 | |||
43 | $this | ||
44 | ->assignView('returnurl', escape($returnUrl)) | ||
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')) | ||
47 | ; | ||
48 | |||
49 | return $response->write($this->render(TemplatePage::LOGIN)); | ||
50 | } | ||
51 | |||
52 | /** | ||
53 | * POST /login - Process login | ||
54 | */ | ||
55 | public function login(Request $request, Response $response): Response | ||
56 | { | ||
57 | if (!$this->container->sessionManager->checkToken($request->getParam('token'))) { | ||
58 | throw new WrongTokenException(); | ||
59 | } | ||
60 | |||
61 | try { | ||
62 | $this->checkLoginState(); | ||
63 | } catch (CantLoginException $e) { | ||
64 | return $this->redirect($response, '/'); | ||
65 | } | ||
66 | |||
67 | if (!$this->container->loginManager->checkCredentials( | ||
68 | client_ip_id($this->container->environment), | ||
69 | $request->getParam('login'), | ||
70 | $request->getParam('password') | ||
71 | ) | ||
72 | ) { | ||
73 | $this->container->loginManager->handleFailedLogin($this->container->environment); | ||
74 | |||
75 | $this->container->sessionManager->setSessionParameter( | ||
76 | SessionManager::KEY_ERROR_MESSAGES, | ||
77 | [t('Wrong login/password.')] | ||
78 | ); | ||
79 | |||
80 | // Call controller directly instead of unnecessary redirection | ||
81 | return $this->index($request, $response); | ||
82 | } | ||
83 | |||
84 | $this->container->loginManager->handleSuccessfulLogin($this->container->environment); | ||
85 | |||
86 | $cookiePath = $this->container->basePath . '/'; | ||
87 | $expirationTime = $this->saveLongLastingSession($request, $cookiePath); | ||
88 | $this->renewUserSession($cookiePath, $expirationTime); | ||
89 | |||
90 | // Force referer from given return URL | ||
91 | $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl'); | ||
92 | |||
93 | return $this->redirectFromReferer($request, $response, ['login', 'install']); | ||
94 | } | ||
95 | |||
96 | /** | ||
97 | * Make sure that the user is allowed to login and/or displaying the login page: | ||
98 | * - not already logged in | ||
99 | * - not open shaarli | ||
100 | * - not banned | ||
101 | */ | ||
102 | protected function checkLoginState(): bool | ||
103 | { | ||
104 | if ($this->container->loginManager->isLoggedIn() | ||
105 | || $this->container->conf->get('security.open_shaarli', false) | ||
106 | ) { | ||
107 | throw new CantLoginException(); | ||
108 | } | ||
109 | |||
110 | if (true !== $this->container->loginManager->canLogin($this->container->environment)) { | ||
111 | throw new LoginBannedException(); | ||
112 | } | ||
113 | |||
114 | return true; | ||
115 | } | ||
116 | |||
117 | /** | ||
118 | * @return int Session duration in seconds | ||
119 | */ | ||
120 | protected function saveLongLastingSession(Request $request, string $cookiePath): int | ||
121 | { | ||
122 | if (empty($request->getParam('longlastingsession'))) { | ||
123 | // Standard session expiration (=when browser closes) | ||
124 | $expirationTime = 0; | ||
125 | } else { | ||
126 | // Keep the session cookie even after the browser closes | ||
127 | $this->container->sessionManager->setStaySignedIn(true); | ||
128 | $expirationTime = $this->container->sessionManager->extendSession(); | ||
129 | } | ||
130 | |||
131 | $this->container->cookieManager->setCookieParameter( | ||
132 | CookieManager::STAY_SIGNED_IN, | ||
133 | $this->container->loginManager->getStaySignedInToken(), | ||
134 | $expirationTime, | ||
135 | $cookiePath | ||
136 | ); | ||
137 | |||
138 | return $expirationTime; | ||
139 | } | ||
140 | |||
141 | protected function renewUserSession(string $cookiePath, int $expirationTime): void | ||
142 | { | ||
143 | // Send cookie with the new expiration date to the browser | ||
144 | $this->container->sessionManager->destroy(); | ||
145 | $this->container->sessionManager->cookieParameters( | ||
146 | $expirationTime, | ||
147 | $cookiePath, | ||
148 | $this->container->environment['SERVER_NAME'] | ||
149 | ); | ||
150 | $this->container->sessionManager->start(); | ||
151 | $this->container->sessionManager->regenerateId(true); | ||
152 | } | ||
153 | } | ||
diff --git a/application/front/controller/visitor/OpenSearchController.php b/application/front/controller/visitor/OpenSearchController.php new file mode 100644 index 00000000..36d60acf --- /dev/null +++ b/application/front/controller/visitor/OpenSearchController.php | |||
@@ -0,0 +1,27 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Render\TemplatePage; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class OpenSearchController | ||
13 | * | ||
14 | * Slim controller used to render open search template. | ||
15 | * This allows to add Shaarli as a search engine within the browser. | ||
16 | */ | ||
17 | class OpenSearchController extends ShaarliVisitorController | ||
18 | { | ||
19 | public function index(Request $request, Response $response): Response | ||
20 | { | ||
21 | $response = $response->withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8'); | ||
22 | |||
23 | $this->assignView('serverurl', index_url($this->container->environment)); | ||
24 | |||
25 | return $response->write($this->render(TemplatePage::OPEN_SEARCH)); | ||
26 | } | ||
27 | } | ||
diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php new file mode 100644 index 00000000..3c57f8dd --- /dev/null +++ b/application/front/controller/visitor/PictureWallController.php | |||
@@ -0,0 +1,54 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Front\Exception\ThumbnailsDisabledException; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Shaarli\Thumbnailer; | ||
10 | use Slim\Http\Request; | ||
11 | use Slim\Http\Response; | ||
12 | |||
13 | /** | ||
14 | * Class PicturesWallController | ||
15 | * | ||
16 | * Slim controller used to render the pictures wall page. | ||
17 | * If thumbnails mode is set to NONE, we just render the template without any image. | ||
18 | */ | ||
19 | class PictureWallController extends ShaarliVisitorController | ||
20 | { | ||
21 | public function index(Request $request, Response $response): Response | ||
22 | { | ||
23 | if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) { | ||
24 | throw new ThumbnailsDisabledException(); | ||
25 | } | ||
26 | |||
27 | $this->assignView( | ||
28 | 'pagetitle', | ||
29 | t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
30 | ); | ||
31 | |||
32 | // Optionally filter the results: | ||
33 | $links = $this->container->bookmarkService->search($request->getQueryParams()); | ||
34 | $linksToDisplay = []; | ||
35 | |||
36 | // Get only bookmarks which have a thumbnail. | ||
37 | // Note: we do not retrieve thumbnails here, the request is too heavy. | ||
38 | $formatter = $this->container->formatterFactory->getFormatter('raw'); | ||
39 | foreach ($links as $key => $link) { | ||
40 | if (!empty($link->getThumbnail())) { | ||
41 | $linksToDisplay[] = $formatter->format($link); | ||
42 | } | ||
43 | } | ||
44 | |||
45 | $data = ['linksToDisplay' => $linksToDisplay]; | ||
46 | $this->executePageHooks('render_picwall', $data, TemplatePage::PICTURE_WALL); | ||
47 | |||
48 | foreach ($data as $key => $value) { | ||
49 | $this->assignView($key, $value); | ||
50 | } | ||
51 | |||
52 | return $response->write($this->render(TemplatePage::PICTURE_WALL)); | ||
53 | } | ||
54 | } | ||
diff --git a/application/front/controller/visitor/PublicSessionFilterController.php b/application/front/controller/visitor/PublicSessionFilterController.php new file mode 100644 index 00000000..1a66362d --- /dev/null +++ b/application/front/controller/visitor/PublicSessionFilterController.php | |||
@@ -0,0 +1,46 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Security\SessionManager; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Slim controller used to handle filters stored in the visitor session, links per page, etc. | ||
13 | */ | ||
14 | class PublicSessionFilterController extends ShaarliVisitorController | ||
15 | { | ||
16 | /** | ||
17 | * GET /links-per-page: set the number of bookmarks to display per page in homepage | ||
18 | */ | ||
19 | public function linksPerPage(Request $request, Response $response): Response | ||
20 | { | ||
21 | $linksPerPage = $request->getParam('nb') ?? null; | ||
22 | if (null === $linksPerPage || false === is_numeric($linksPerPage)) { | ||
23 | $linksPerPage = $this->container->conf->get('general.links_per_page', 20); | ||
24 | } | ||
25 | |||
26 | $this->container->sessionManager->setSessionParameter( | ||
27 | SessionManager::KEY_LINKS_PER_PAGE, | ||
28 | abs(intval($linksPerPage)) | ||
29 | ); | ||
30 | |||
31 | return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']); | ||
32 | } | ||
33 | |||
34 | /** | ||
35 | * GET /untagged-only: allows to display only bookmarks without any tag | ||
36 | */ | ||
37 | public function untaggedOnly(Request $request, Response $response): Response | ||
38 | { | ||
39 | $this->container->sessionManager->setSessionParameter( | ||
40 | SessionManager::KEY_UNTAGGED_ONLY, | ||
41 | empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY)) | ||
42 | ); | ||
43 | |||
44 | return $this->redirectFromReferer($request, $response, ['untaggedonly', 'untagged-only']); | ||
45 | } | ||
46 | } | ||
diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php new file mode 100644 index 00000000..54f9fe03 --- /dev/null +++ b/application/front/controller/visitor/ShaarliVisitorController.php | |||
@@ -0,0 +1,181 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Container\ShaarliContainer; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ShaarliVisitorController | ||
14 | * | ||
15 | * All controllers accessible by visitors (non logged in users) should extend this abstract class. | ||
16 | * Contains a few helper function for template rendering, plugins, etc. | ||
17 | * | ||
18 | * @package Shaarli\Front\Controller\Visitor | ||
19 | */ | ||
20 | abstract class ShaarliVisitorController | ||
21 | { | ||
22 | /** @var ShaarliContainer */ | ||
23 | protected $container; | ||
24 | |||
25 | /** @param ShaarliContainer $container Slim container (extended for attribute completion). */ | ||
26 | public function __construct(ShaarliContainer $container) | ||
27 | { | ||
28 | $this->container = $container; | ||
29 | } | ||
30 | |||
31 | /** | ||
32 | * Assign variables to RainTPL template through the PageBuilder. | ||
33 | * | ||
34 | * @param mixed $value Value to assign to the template | ||
35 | */ | ||
36 | protected function assignView(string $name, $value): self | ||
37 | { | ||
38 | $this->container->pageBuilder->assign($name, $value); | ||
39 | |||
40 | return $this; | ||
41 | } | ||
42 | |||
43 | /** | ||
44 | * Assign variables to RainTPL template through the PageBuilder. | ||
45 | * | ||
46 | * @param mixed $data Values to assign to the template and their keys | ||
47 | */ | ||
48 | protected function assignAllView(array $data): self | ||
49 | { | ||
50 | foreach ($data as $key => $value) { | ||
51 | $this->assignView($key, $value); | ||
52 | } | ||
53 | |||
54 | return $this; | ||
55 | } | ||
56 | |||
57 | protected function render(string $template): string | ||
58 | { | ||
59 | $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL)); | ||
60 | $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE)); | ||
61 | |||
62 | $this->executeDefaultHooks($template); | ||
63 | |||
64 | $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); | ||
65 | |||
66 | return $this->container->pageBuilder->render($template, $this->container->basePath); | ||
67 | } | ||
68 | |||
69 | /** | ||
70 | * Call plugin hooks for header, footer and includes, specifying which page will be rendered. | ||
71 | * Then assign generated data to RainTPL. | ||
72 | */ | ||
73 | protected function executeDefaultHooks(string $template): void | ||
74 | { | ||
75 | $common_hooks = [ | ||
76 | 'includes', | ||
77 | 'header', | ||
78 | 'footer', | ||
79 | ]; | ||
80 | |||
81 | $parameters = $this->buildPluginParameters($template); | ||
82 | |||
83 | foreach ($common_hooks as $name) { | ||
84 | $pluginData = []; | ||
85 | $this->container->pluginManager->executeHooks( | ||
86 | 'render_' . $name, | ||
87 | $pluginData, | ||
88 | $parameters | ||
89 | ); | ||
90 | $this->assignView('plugins_' . $name, $pluginData); | ||
91 | } | ||
92 | } | ||
93 | |||
94 | protected function executePageHooks(string $hook, array &$data, string $template = null): void | ||
95 | { | ||
96 | $this->container->pluginManager->executeHooks( | ||
97 | $hook, | ||
98 | $data, | ||
99 | $this->buildPluginParameters($template) | ||
100 | ); | ||
101 | } | ||
102 | |||
103 | protected function buildPluginParameters(?string $template): array | ||
104 | { | ||
105 | return [ | ||
106 | 'target' => $template, | ||
107 | 'loggedin' => $this->container->loginManager->isLoggedIn(), | ||
108 | 'basePath' => $this->container->basePath, | ||
109 | 'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath), | ||
110 | 'bookmarkService' => $this->container->bookmarkService | ||
111 | ]; | ||
112 | } | ||
113 | |||
114 | /** | ||
115 | * Simple helper which prepend the base path to redirect path. | ||
116 | * | ||
117 | * @param Response $response | ||
118 | * @param string $path Absolute path, e.g.: `/`, or `/admin/shaare/123` regardless of install directory | ||
119 | * | ||
120 | * @return Response updated | ||
121 | */ | ||
122 | protected function redirect(Response $response, string $path): Response | ||
123 | { | ||
124 | return $response->withRedirect($this->container->basePath . $path); | ||
125 | } | ||
126 | |||
127 | /** | ||
128 | * Generates a redirection to the previous page, based on the HTTP_REFERER. | ||
129 | * It fails back to the home page. | ||
130 | * | ||
131 | * @param array $loopTerms Terms to remove from path and query string to prevent direction loop. | ||
132 | * @param array $clearParams List of parameter to remove from the query string of the referrer. | ||
133 | */ | ||
134 | protected function redirectFromReferer( | ||
135 | Request $request, | ||
136 | Response $response, | ||
137 | array $loopTerms = [], | ||
138 | array $clearParams = [], | ||
139 | string $anchor = null | ||
140 | ): Response { | ||
141 | $defaultPath = $this->container->basePath . '/'; | ||
142 | $referer = $this->container->environment['HTTP_REFERER'] ?? null; | ||
143 | |||
144 | if (null !== $referer) { | ||
145 | $currentUrl = parse_url($referer); | ||
146 | // If the referer is not related to Shaarli instance, redirect to default | ||
147 | if (isset($currentUrl['host']) | ||
148 | && strpos(index_url($this->container->environment), $currentUrl['host']) === false | ||
149 | ) { | ||
150 | return $response->withRedirect($defaultPath); | ||
151 | } | ||
152 | |||
153 | parse_str($currentUrl['query'] ?? '', $params); | ||
154 | $path = $currentUrl['path'] ?? $defaultPath; | ||
155 | } else { | ||
156 | $params = []; | ||
157 | $path = $defaultPath; | ||
158 | } | ||
159 | |||
160 | // Prevent redirection loop | ||
161 | if (isset($currentUrl)) { | ||
162 | foreach ($clearParams as $value) { | ||
163 | unset($params[$value]); | ||
164 | } | ||
165 | |||
166 | $checkQuery = implode('', array_keys($params)); | ||
167 | foreach ($loopTerms as $value) { | ||
168 | if (strpos($path . $checkQuery, $value) !== false) { | ||
169 | $params = []; | ||
170 | $path = $defaultPath; | ||
171 | break; | ||
172 | } | ||
173 | } | ||
174 | } | ||
175 | |||
176 | $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; | ||
177 | $anchor = $anchor ? '#' . $anchor : ''; | ||
178 | |||
179 | return $response->withRedirect($path . $queryString . $anchor); | ||
180 | } | ||
181 | } | ||
diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php new file mode 100644 index 00000000..76ed7690 --- /dev/null +++ b/application/front/controller/visitor/TagCloudController.php | |||
@@ -0,0 +1,121 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class TagCloud | ||
12 | * | ||
13 | * Slim controller used to render the tag cloud and tag list pages. | ||
14 | */ | ||
15 | class TagCloudController extends ShaarliVisitorController | ||
16 | { | ||
17 | protected const TYPE_CLOUD = 'cloud'; | ||
18 | protected const TYPE_LIST = 'list'; | ||
19 | |||
20 | /** | ||
21 | * Display the tag cloud through the template engine. | ||
22 | * This controller a few filters: | ||
23 | * - Visibility stored in the session for logged in users | ||
24 | * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark | ||
25 | */ | ||
26 | public function cloud(Request $request, Response $response): Response | ||
27 | { | ||
28 | return $this->processRequest(static::TYPE_CLOUD, $request, $response); | ||
29 | } | ||
30 | |||
31 | /** | ||
32 | * Display the tag list through the template engine. | ||
33 | * This controller a few filters: | ||
34 | * - Visibility stored in the session for logged in users | ||
35 | * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark | ||
36 | * - `sort` query parameters: | ||
37 | * + `usage` (default): most used tags first | ||
38 | * + `alpha`: alphabetical order | ||
39 | */ | ||
40 | public function list(Request $request, Response $response): Response | ||
41 | { | ||
42 | return $this->processRequest(static::TYPE_LIST, $request, $response); | ||
43 | } | ||
44 | |||
45 | /** | ||
46 | * Process the request for both tag cloud and tag list endpoints. | ||
47 | */ | ||
48 | protected function processRequest(string $type, Request $request, Response $response): Response | ||
49 | { | ||
50 | if ($this->container->loginManager->isLoggedIn() === true) { | ||
51 | $visibility = $this->container->sessionManager->getSessionParameter('visibility'); | ||
52 | } | ||
53 | |||
54 | $sort = $request->getQueryParam('sort'); | ||
55 | $searchTags = $request->getQueryParam('searchtags'); | ||
56 | $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; | ||
57 | |||
58 | $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); | ||
59 | |||
60 | if (static::TYPE_CLOUD === $type || 'alpha' === $sort) { | ||
61 | // TODO: the sorting should be handled by bookmarkService instead of the controller | ||
62 | alphabetical_sort($tags, false, true); | ||
63 | } | ||
64 | |||
65 | if (static::TYPE_CLOUD === $type) { | ||
66 | $tags = $this->formatTagsForCloud($tags); | ||
67 | } | ||
68 | |||
69 | $tagsUrl = []; | ||
70 | foreach ($tags as $tag => $value) { | ||
71 | $tagsUrl[escape($tag)] = urlencode((string) $tag); | ||
72 | } | ||
73 | |||
74 | $searchTags = implode(' ', escape($filteringTags)); | ||
75 | $searchTagsUrl = urlencode(implode(' ', $filteringTags)); | ||
76 | $data = [ | ||
77 | 'search_tags' => escape($searchTags), | ||
78 | 'search_tags_url' => $searchTagsUrl, | ||
79 | 'tags' => escape($tags), | ||
80 | 'tags_url' => $tagsUrl, | ||
81 | ]; | ||
82 | $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); | ||
83 | $this->assignAllView($data); | ||
84 | |||
85 | $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; | ||
86 | $this->assignView( | ||
87 | 'pagetitle', | ||
88 | $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
89 | ); | ||
90 | |||
91 | return $response->write($this->render('tag.' . $type)); | ||
92 | } | ||
93 | |||
94 | /** | ||
95 | * Format the tags array for the tag cloud template. | ||
96 | * | ||
97 | * @param array<string, int> $tags List of tags as key with count as value | ||
98 | * | ||
99 | * @return mixed[] List of tags as key, with count and expected font size in a subarray | ||
100 | */ | ||
101 | protected function formatTagsForCloud(array $tags): array | ||
102 | { | ||
103 | // We sort tags alphabetically, then choose a font size according to count. | ||
104 | // First, find max value. | ||
105 | $maxCount = count($tags) > 0 ? max($tags) : 0; | ||
106 | $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1; | ||
107 | $tagList = []; | ||
108 | foreach ($tags as $key => $value) { | ||
109 | // Tag font size scaling: | ||
110 | // default 15 and 30 logarithm bases affect scaling, | ||
111 | // 2.2 and 0.8 are arbitrary font sizes in em. | ||
112 | $size = log($value, 15) / $logMaxCount * 2.2 + 0.8; | ||
113 | $tagList[$key] = [ | ||
114 | 'count' => $value, | ||
115 | 'size' => number_format($size, 2, '.', ''), | ||
116 | ]; | ||
117 | } | ||
118 | |||
119 | return $tagList; | ||
120 | } | ||
121 | } | ||
diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php new file mode 100644 index 00000000..de4e7ea2 --- /dev/null +++ b/application/front/controller/visitor/TagController.php | |||
@@ -0,0 +1,118 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class TagController | ||
12 | * | ||
13 | * Slim controller handle tags. | ||
14 | */ | ||
15 | class TagController extends ShaarliVisitorController | ||
16 | { | ||
17 | /** | ||
18 | * Add another tag in the current search through an HTTP redirection. | ||
19 | * | ||
20 | * @param array $args Should contain `newTag` key as tag to add to current search | ||
21 | */ | ||
22 | public function addTag(Request $request, Response $response, array $args): Response | ||
23 | { | ||
24 | $newTag = $args['newTag'] ?? null; | ||
25 | $referer = $this->container->environment['HTTP_REFERER'] ?? null; | ||
26 | |||
27 | // In case browser does not send HTTP_REFERER, we search a single tag | ||
28 | if (null === $referer) { | ||
29 | if (null !== $newTag) { | ||
30 | return $this->redirect($response, '/?searchtags='. urlencode($newTag)); | ||
31 | } | ||
32 | |||
33 | return $this->redirect($response, '/'); | ||
34 | } | ||
35 | |||
36 | $currentUrl = parse_url($referer); | ||
37 | parse_str($currentUrl['query'] ?? '', $params); | ||
38 | |||
39 | if (null === $newTag) { | ||
40 | return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); | ||
41 | } | ||
42 | |||
43 | // Prevent redirection loop | ||
44 | if (isset($params['addtag'])) { | ||
45 | unset($params['addtag']); | ||
46 | } | ||
47 | |||
48 | // 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 | $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; | ||
51 | |||
52 | $addtag = true; | ||
53 | foreach ($currentTags as $value) { | ||
54 | if ($value === $newTag) { | ||
55 | $addtag = false; | ||
56 | break; | ||
57 | } | ||
58 | } | ||
59 | |||
60 | // Append the tag if necessary | ||
61 | if (true === $addtag) { | ||
62 | $currentTags[] = trim($newTag); | ||
63 | } | ||
64 | |||
65 | $params['searchtags'] = trim(implode(' ', $currentTags)); | ||
66 | |||
67 | // We also remove page (keeping the same page has no sense, since the results are different) | ||
68 | unset($params['page']); | ||
69 | |||
70 | return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); | ||
71 | } | ||
72 | |||
73 | /** | ||
74 | * Remove a tag from the current search through an HTTP redirection. | ||
75 | * | ||
76 | * @param array $args Should contain `tag` key as tag to remove from current search | ||
77 | */ | ||
78 | public function removeTag(Request $request, Response $response, array $args): Response | ||
79 | { | ||
80 | $referer = $this->container->environment['HTTP_REFERER'] ?? null; | ||
81 | |||
82 | // If the referrer is not provided, we can update the search, so we failback on the bookmark list | ||
83 | if (empty($referer)) { | ||
84 | return $this->redirect($response, '/'); | ||
85 | } | ||
86 | |||
87 | $tagToRemove = $args['tag'] ?? null; | ||
88 | $currentUrl = parse_url($referer); | ||
89 | parse_str($currentUrl['query'] ?? '', $params); | ||
90 | |||
91 | if (null === $tagToRemove) { | ||
92 | return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); | ||
93 | } | ||
94 | |||
95 | // Prevent redirection loop | ||
96 | if (isset($params['removetag'])) { | ||
97 | unset($params['removetag']); | ||
98 | } | ||
99 | |||
100 | if (isset($params['searchtags'])) { | ||
101 | $tags = explode(' ', $params['searchtags']); | ||
102 | // Remove value from array $tags. | ||
103 | $tags = array_diff($tags, [$tagToRemove]); | ||
104 | $params['searchtags'] = implode(' ', $tags); | ||
105 | |||
106 | if (empty($params['searchtags'])) { | ||
107 | unset($params['searchtags']); | ||
108 | } | ||
109 | |||
110 | // We also remove page (keeping the same page has no sense, since the results are different) | ||
111 | unset($params['page']); | ||
112 | } | ||
113 | |||
114 | $queryParams = count($params) > 0 ? '?' . http_build_query($params) : ''; | ||
115 | |||
116 | return $response->withRedirect(($currentUrl['path'] ?? './') . $queryParams); | ||
117 | } | ||
118 | } | ||
diff --git a/application/front/controllers/LoginController.php b/application/front/controllers/LoginController.php deleted file mode 100644 index ae3599e0..00000000 --- a/application/front/controllers/LoginController.php +++ /dev/null | |||
@@ -1,48 +0,0 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller; | ||
6 | |||
7 | use Shaarli\Front\Exception\LoginBannedException; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class LoginController | ||
13 | * | ||
14 | * Slim controller used to render the login page. | ||
15 | * | ||
16 | * The login page is not available if the user is banned | ||
17 | * or if open shaarli setting is enabled. | ||
18 | * | ||
19 | * @package Front\Controller | ||
20 | */ | ||
21 | class LoginController extends ShaarliController | ||
22 | { | ||
23 | public function index(Request $request, Response $response): Response | ||
24 | { | ||
25 | if ($this->container->loginManager->isLoggedIn() | ||
26 | || $this->container->conf->get('security.open_shaarli', false) | ||
27 | ) { | ||
28 | return $response->withRedirect('./'); | ||
29 | } | ||
30 | |||
31 | $userCanLogin = $this->container->loginManager->canLogin($request->getServerParams()); | ||
32 | if ($userCanLogin !== true) { | ||
33 | throw new LoginBannedException(); | ||
34 | } | ||
35 | |||
36 | if ($request->getParam('username') !== null) { | ||
37 | $this->assignView('username', escape($request->getParam('username'))); | ||
38 | } | ||
39 | |||
40 | $this | ||
41 | ->assignView('returnurl', escape($request->getServerParam('HTTP_REFERER'))) | ||
42 | ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) | ||
43 | ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) | ||
44 | ; | ||
45 | |||
46 | return $response->write($this->render('loginform')); | ||
47 | } | ||
48 | } | ||
diff --git a/application/front/controllers/ShaarliController.php b/application/front/controllers/ShaarliController.php deleted file mode 100644 index 2b828588..00000000 --- a/application/front/controllers/ShaarliController.php +++ /dev/null | |||
@@ -1,69 +0,0 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Container\ShaarliContainer; | ||
9 | |||
10 | abstract class ShaarliController | ||
11 | { | ||
12 | /** @var ShaarliContainer */ | ||
13 | protected $container; | ||
14 | |||
15 | /** @param ShaarliContainer $container Slim container (extended for attribute completion). */ | ||
16 | public function __construct(ShaarliContainer $container) | ||
17 | { | ||
18 | $this->container = $container; | ||
19 | } | ||
20 | |||
21 | /** | ||
22 | * Assign variables to RainTPL template through the PageBuilder. | ||
23 | * | ||
24 | * @param mixed $value Value to assign to the template | ||
25 | */ | ||
26 | protected function assignView(string $name, $value): self | ||
27 | { | ||
28 | $this->container->pageBuilder->assign($name, $value); | ||
29 | |||
30 | return $this; | ||
31 | } | ||
32 | |||
33 | protected function render(string $template): string | ||
34 | { | ||
35 | $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL)); | ||
36 | $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE)); | ||
37 | $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); | ||
38 | |||
39 | $this->executeDefaultHooks($template); | ||
40 | |||
41 | return $this->container->pageBuilder->render($template); | ||
42 | } | ||
43 | |||
44 | /** | ||
45 | * Call plugin hooks for header, footer and includes, specifying which page will be rendered. | ||
46 | * Then assign generated data to RainTPL. | ||
47 | */ | ||
48 | protected function executeDefaultHooks(string $template): void | ||
49 | { | ||
50 | $common_hooks = [ | ||
51 | 'includes', | ||
52 | 'header', | ||
53 | 'footer', | ||
54 | ]; | ||
55 | |||
56 | foreach ($common_hooks as $name) { | ||
57 | $plugin_data = []; | ||
58 | $this->container->pluginManager->executeHooks( | ||
59 | 'render_' . $name, | ||
60 | $plugin_data, | ||
61 | [ | ||
62 | 'target' => $template, | ||
63 | 'loggedin' => $this->container->loginManager->isLoggedIn() | ||
64 | ] | ||
65 | ); | ||
66 | $this->assignView('plugins_' . $name, $plugin_data); | ||
67 | } | ||
68 | } | ||
69 | } | ||
diff --git a/application/front/exceptions/AlreadyInstalledException.php b/application/front/exceptions/AlreadyInstalledException.php new file mode 100644 index 00000000..4add86cf --- /dev/null +++ b/application/front/exceptions/AlreadyInstalledException.php | |||
@@ -0,0 +1,15 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | class AlreadyInstalledException extends ShaarliFrontException | ||
8 | { | ||
9 | public function __construct() | ||
10 | { | ||
11 | $message = t('Shaarli has already been installed. Login to edit the configuration.'); | ||
12 | |||
13 | parent::__construct($message, 401); | ||
14 | } | ||
15 | } | ||
diff --git a/application/front/exceptions/CantLoginException.php b/application/front/exceptions/CantLoginException.php new file mode 100644 index 00000000..cd16635d --- /dev/null +++ b/application/front/exceptions/CantLoginException.php | |||
@@ -0,0 +1,10 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | class CantLoginException extends \Exception | ||
8 | { | ||
9 | |||
10 | } | ||
diff --git a/application/front/exceptions/LoginBannedException.php b/application/front/exceptions/LoginBannedException.php index b31a4a14..79d0ea15 100644 --- a/application/front/exceptions/LoginBannedException.php +++ b/application/front/exceptions/LoginBannedException.php | |||
@@ -4,7 +4,7 @@ declare(strict_types=1); | |||
4 | 4 | ||
5 | namespace Shaarli\Front\Exception; | 5 | namespace Shaarli\Front\Exception; |
6 | 6 | ||
7 | class LoginBannedException extends ShaarliException | 7 | class LoginBannedException extends ShaarliFrontException |
8 | { | 8 | { |
9 | public function __construct() | 9 | public function __construct() |
10 | { | 10 | { |
diff --git a/application/front/exceptions/OpenShaarliPasswordException.php b/application/front/exceptions/OpenShaarliPasswordException.php new file mode 100644 index 00000000..a6f0b3ae --- /dev/null +++ b/application/front/exceptions/OpenShaarliPasswordException.php | |||
@@ -0,0 +1,18 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | /** | ||
8 | * Class OpenShaarliPasswordException | ||
9 | * | ||
10 | * Raised if the user tries to change the admin password on an open shaarli instance. | ||
11 | */ | ||
12 | class OpenShaarliPasswordException extends ShaarliFrontException | ||
13 | { | ||
14 | public function __construct() | ||
15 | { | ||
16 | parent::__construct(t('You are not supposed to change a password on an Open Shaarli.'), 403); | ||
17 | } | ||
18 | } | ||
diff --git a/application/front/exceptions/ResourcePermissionException.php b/application/front/exceptions/ResourcePermissionException.php new file mode 100644 index 00000000..8fbf03b9 --- /dev/null +++ b/application/front/exceptions/ResourcePermissionException.php | |||
@@ -0,0 +1,13 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | class ResourcePermissionException extends ShaarliFrontException | ||
8 | { | ||
9 | public function __construct(string $message) | ||
10 | { | ||
11 | parent::__construct($message, 500); | ||
12 | } | ||
13 | } | ||
diff --git a/application/front/exceptions/ShaarliException.php b/application/front/exceptions/ShaarliFrontException.php index 800bfbec..73847e6d 100644 --- a/application/front/exceptions/ShaarliException.php +++ b/application/front/exceptions/ShaarliFrontException.php | |||
@@ -9,11 +9,11 @@ use Throwable; | |||
9 | /** | 9 | /** |
10 | * Class ShaarliException | 10 | * Class ShaarliException |
11 | * | 11 | * |
12 | * Abstract exception class used to defined any custom exception thrown during front rendering. | 12 | * Exception class used to defined any custom exception thrown during front rendering. |
13 | * | 13 | * |
14 | * @package Front\Exception | 14 | * @package Front\Exception |
15 | */ | 15 | */ |
16 | abstract class ShaarliException extends \Exception | 16 | class ShaarliFrontException extends \Exception |
17 | { | 17 | { |
18 | /** Override parent constructor to force $message and $httpCode parameters to be set. */ | 18 | /** Override parent constructor to force $message and $httpCode parameters to be set. */ |
19 | public function __construct(string $message, int $httpCode, Throwable $previous = null) | 19 | public function __construct(string $message, int $httpCode, Throwable $previous = null) |
diff --git a/application/front/exceptions/ThumbnailsDisabledException.php b/application/front/exceptions/ThumbnailsDisabledException.php new file mode 100644 index 00000000..0ed337f5 --- /dev/null +++ b/application/front/exceptions/ThumbnailsDisabledException.php | |||
@@ -0,0 +1,15 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | class ThumbnailsDisabledException extends ShaarliFrontException | ||
8 | { | ||
9 | public function __construct() | ||
10 | { | ||
11 | $message = t('Picture wall unavailable (thumbnails are disabled).'); | ||
12 | |||
13 | parent::__construct($message, 400); | ||
14 | } | ||
15 | } | ||
diff --git a/application/front/exceptions/UnauthorizedException.php b/application/front/exceptions/UnauthorizedException.php new file mode 100644 index 00000000..4231094a --- /dev/null +++ b/application/front/exceptions/UnauthorizedException.php | |||
@@ -0,0 +1,15 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | /** | ||
8 | * Class UnauthorizedException | ||
9 | * | ||
10 | * Exception raised if the user tries to access a ShaarliAdminController while logged out. | ||
11 | */ | ||
12 | class UnauthorizedException extends \Exception | ||
13 | { | ||
14 | |||
15 | } | ||
diff --git a/application/front/exceptions/WrongTokenException.php b/application/front/exceptions/WrongTokenException.php new file mode 100644 index 00000000..42002720 --- /dev/null +++ b/application/front/exceptions/WrongTokenException.php | |||
@@ -0,0 +1,18 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | /** | ||
8 | * Class OpenShaarliPasswordException | ||
9 | * | ||
10 | * Raised if the user tries to perform an action with an invalid XSRF token. | ||
11 | */ | ||
12 | class WrongTokenException extends ShaarliFrontException | ||
13 | { | ||
14 | public function __construct() | ||
15 | { | ||
16 | parent::__construct(t('Wrong token.'), 403); | ||
17 | } | ||
18 | } | ||
diff --git a/application/ApplicationUtils.php b/application/helper/ApplicationUtils.php index 3aa21829..4b34e114 100644 --- a/application/ApplicationUtils.php +++ b/application/helper/ApplicationUtils.php | |||
@@ -1,5 +1,5 @@ | |||
1 | <?php | 1 | <?php |
2 | namespace Shaarli; | 2 | namespace Shaarli\Helper; |
3 | 3 | ||
4 | use Exception; | 4 | use Exception; |
5 | use Shaarli\Config\ConfigManager; | 5 | use Shaarli\Config\ConfigManager; |
@@ -14,8 +14,9 @@ class ApplicationUtils | |||
14 | */ | 14 | */ |
15 | public static $VERSION_FILE = 'shaarli_version.php'; | 15 | public static $VERSION_FILE = 'shaarli_version.php'; |
16 | 16 | ||
17 | private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; | 17 | public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli'; |
18 | private static $GIT_BRANCHES = array('latest', 'stable'); | 18 | public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; |
19 | public static $GIT_BRANCHES = array('latest', 'stable'); | ||
19 | private static $VERSION_START_TAG = '<?php /* '; | 20 | private static $VERSION_START_TAG = '<?php /* '; |
20 | private static $VERSION_END_TAG = ' */ ?>'; | 21 | private static $VERSION_END_TAG = ' */ ?>'; |
21 | 22 | ||
@@ -125,7 +126,7 @@ class ApplicationUtils | |||
125 | // Late Static Binding allows overriding within tests | 126 | // Late Static Binding allows overriding within tests |
126 | // See http://php.net/manual/en/language.oop5.late-static-bindings.php | 127 | // See http://php.net/manual/en/language.oop5.late-static-bindings.php |
127 | $latestVersion = static::getVersion( | 128 | $latestVersion = static::getVersion( |
128 | self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE | 129 | self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE |
129 | ); | 130 | ); |
130 | 131 | ||
131 | if (!$latestVersion) { | 132 | if (!$latestVersion) { |
@@ -171,35 +172,45 @@ class ApplicationUtils | |||
171 | /** | 172 | /** |
172 | * Checks Shaarli has the proper access permissions to its resources | 173 | * Checks Shaarli has the proper access permissions to its resources |
173 | * | 174 | * |
174 | * @param ConfigManager $conf Configuration Manager instance. | 175 | * @param ConfigManager $conf Configuration Manager instance. |
176 | * @param bool $minimalMode In minimal mode we only check permissions to be able to display a template. | ||
177 | * Currently we only need to be able to read the theme and write in raintpl cache. | ||
175 | * | 178 | * |
176 | * @return array A list of the detected configuration issues | 179 | * @return array A list of the detected configuration issues |
177 | */ | 180 | */ |
178 | public static function checkResourcePermissions($conf) | 181 | public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array |
179 | { | 182 | { |
180 | $errors = array(); | 183 | $errors = []; |
181 | $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/'); | 184 | $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/'); |
182 | 185 | ||
183 | // Check script and template directories are readable | 186 | // Check script and template directories are readable |
184 | foreach (array( | 187 | foreach ([ |
185 | 'application', | 188 | 'application', |
186 | 'inc', | 189 | 'inc', |
187 | 'plugins', | 190 | 'plugins', |
188 | $rainTplDir, | 191 | $rainTplDir, |
189 | $rainTplDir . '/' . $conf->get('resource.theme'), | 192 | $rainTplDir . '/' . $conf->get('resource.theme'), |
190 | ) as $path) { | 193 | ] as $path) { |
191 | if (!is_readable(realpath($path))) { | 194 | if (!is_readable(realpath($path))) { |
192 | $errors[] = '"' . $path . '" ' . t('directory is not readable'); | 195 | $errors[] = '"' . $path . '" ' . t('directory is not readable'); |
193 | } | 196 | } |
194 | } | 197 | } |
195 | 198 | ||
196 | // Check cache and data directories are readable and writable | 199 | // Check cache and data directories are readable and writable |
197 | foreach (array( | 200 | if ($minimalMode) { |
198 | $conf->get('resource.thumbnails_cache'), | 201 | $folders = [ |
199 | $conf->get('resource.data_dir'), | 202 | $conf->get('resource.raintpl_tmp'), |
200 | $conf->get('resource.page_cache'), | 203 | ]; |
201 | $conf->get('resource.raintpl_tmp'), | 204 | } else { |
202 | ) as $path) { | 205 | $folders = [ |
206 | $conf->get('resource.thumbnails_cache'), | ||
207 | $conf->get('resource.data_dir'), | ||
208 | $conf->get('resource.page_cache'), | ||
209 | $conf->get('resource.raintpl_tmp'), | ||
210 | ]; | ||
211 | } | ||
212 | |||
213 | foreach ($folders as $path) { | ||
203 | if (!is_readable(realpath($path))) { | 214 | if (!is_readable(realpath($path))) { |
204 | $errors[] = '"' . $path . '" ' . t('directory is not readable'); | 215 | $errors[] = '"' . $path . '" ' . t('directory is not readable'); |
205 | } | 216 | } |
@@ -208,6 +219,10 @@ class ApplicationUtils | |||
208 | } | 219 | } |
209 | } | 220 | } |
210 | 221 | ||
222 | if ($minimalMode) { | ||
223 | return $errors; | ||
224 | } | ||
225 | |||
211 | // Check configuration files are readable and writable | 226 | // Check configuration files are readable and writable |
212 | foreach (array( | 227 | foreach (array( |
213 | $conf->getConfigFileExt(), | 228 | $conf->getConfigFileExt(), |
@@ -246,4 +261,54 @@ class ApplicationUtils | |||
246 | { | 261 | { |
247 | return hash_hmac('sha256', $currentVersion, $salt); | 262 | return hash_hmac('sha256', $currentVersion, $salt); |
248 | } | 263 | } |
264 | |||
265 | /** | ||
266 | * Get a list of PHP extensions used by Shaarli. | ||
267 | * | ||
268 | * @return array[] List of extension with following keys: | ||
269 | * - name: extension name | ||
270 | * - required: whether the extension is required to use Shaarli | ||
271 | * - desc: short description of extension usage in Shaarli | ||
272 | * - loaded: whether the extension is properly loaded or not | ||
273 | */ | ||
274 | public static function getPhpExtensionsRequirement(): array | ||
275 | { | ||
276 | $extensions = [ | ||
277 | ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')], | ||
278 | ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')], | ||
279 | ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')], | ||
280 | ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')], | ||
281 | ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')], | ||
282 | ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')], | ||
283 | ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')], | ||
284 | ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')], | ||
285 | ]; | ||
286 | |||
287 | foreach ($extensions as &$extension) { | ||
288 | $extension['loaded'] = extension_loaded($extension['name']); | ||
289 | } | ||
290 | |||
291 | return $extensions; | ||
292 | } | ||
293 | |||
294 | /** | ||
295 | * Return the EOL date of given PHP version. If the version is unknown, | ||
296 | * we return today + 2 years. | ||
297 | * | ||
298 | * @param string $fullVersion PHP version, e.g. 7.4.7 | ||
299 | * | ||
300 | * @return string Date format: YYYY-MM-DD | ||
301 | */ | ||
302 | public static function getPhpEol(string $fullVersion): string | ||
303 | { | ||
304 | preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches); | ||
305 | |||
306 | return [ | ||
307 | '7.1' => '2019-12-01', | ||
308 | '7.2' => '2020-11-30', | ||
309 | '7.3' => '2021-12-06', | ||
310 | '7.4' => '2022-11-28', | ||
311 | '8.0' => '2023-12-01', | ||
312 | ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d'); | ||
313 | } | ||
249 | } | 314 | } |
diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php new file mode 100644 index 00000000..5fabc907 --- /dev/null +++ b/application/helper/DailyPageHelper.php | |||
@@ -0,0 +1,208 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Helper; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Slim\Http\Request; | ||
9 | |||
10 | class DailyPageHelper | ||
11 | { | ||
12 | public const MONTH = 'month'; | ||
13 | public const WEEK = 'week'; | ||
14 | public const DAY = 'day'; | ||
15 | |||
16 | /** | ||
17 | * Extracts the type of the daily to display from the HTTP request parameters | ||
18 | * | ||
19 | * @param Request $request HTTP request | ||
20 | * | ||
21 | * @return string month/week/day | ||
22 | */ | ||
23 | public static function extractRequestedType(Request $request): string | ||
24 | { | ||
25 | if ($request->getQueryParam(static::MONTH) !== null) { | ||
26 | return static::MONTH; | ||
27 | } elseif ($request->getQueryParam(static::WEEK) !== null) { | ||
28 | return static::WEEK; | ||
29 | } | ||
30 | |||
31 | return static::DAY; | ||
32 | } | ||
33 | |||
34 | /** | ||
35 | * Extracts a DateTimeImmutable from provided HTTP request. | ||
36 | * If no parameter is provided, we rely on the creation date of the latest provided created bookmark. | ||
37 | * If the datastore is empty or no bookmark is provided, we use the current date. | ||
38 | * | ||
39 | * @param string $type month/week/day | ||
40 | * @param string|null $requestedDate Input string extracted from the request | ||
41 | * @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date) | ||
42 | * | ||
43 | * @return \DateTimeImmutable from input or latest bookmark. | ||
44 | * | ||
45 | * @throws \Exception Type not supported. | ||
46 | */ | ||
47 | public static function extractRequestedDateTime( | ||
48 | string $type, | ||
49 | ?string $requestedDate, | ||
50 | Bookmark $latestBookmark = null | ||
51 | ): \DateTimeImmutable { | ||
52 | $format = static::getFormatByType($type); | ||
53 | if (empty($requestedDate)) { | ||
54 | return $latestBookmark instanceof Bookmark | ||
55 | ? new \DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM)) | ||
56 | : new \DateTimeImmutable() | ||
57 | ; | ||
58 | } | ||
59 | |||
60 | // W is not supported by createFromFormat... | ||
61 | if ($type === static::WEEK) { | ||
62 | return (new \DateTimeImmutable()) | ||
63 | ->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2)) | ||
64 | ; | ||
65 | } | ||
66 | |||
67 | return \DateTimeImmutable::createFromFormat($format, $requestedDate); | ||
68 | } | ||
69 | |||
70 | /** | ||
71 | * Get the DateTime format used by provided type | ||
72 | * Examples: | ||
73 | * - day: 20201016 (<year><month><day>) | ||
74 | * - week: 202041 (<year><week number>) | ||
75 | * - month: 202010 (<year><month>) | ||
76 | * | ||
77 | * @param string $type month/week/day | ||
78 | * | ||
79 | * @return string DateTime compatible format | ||
80 | * | ||
81 | * @see https://www.php.net/manual/en/datetime.format.php | ||
82 | * | ||
83 | * @throws \Exception Type not supported. | ||
84 | */ | ||
85 | public static function getFormatByType(string $type): string | ||
86 | { | ||
87 | switch ($type) { | ||
88 | case static::MONTH: | ||
89 | return 'Ym'; | ||
90 | case static::WEEK: | ||
91 | return 'YW'; | ||
92 | case static::DAY: | ||
93 | return 'Ymd'; | ||
94 | default: | ||
95 | throw new \Exception('Unsupported daily format type'); | ||
96 | } | ||
97 | } | ||
98 | |||
99 | /** | ||
100 | * Get the first DateTime of the time period depending on given datetime and type. | ||
101 | * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax | ||
102 | * and we don't want to alter original datetime. | ||
103 | * | ||
104 | * @param string $type month/week/day | ||
105 | * @param \DateTimeImmutable $requested DateTime extracted from request input | ||
106 | * (should come from extractRequestedDateTime) | ||
107 | * | ||
108 | * @return \DateTimeInterface First DateTime of the time period | ||
109 | * | ||
110 | * @throws \Exception Type not supported. | ||
111 | */ | ||
112 | public static function getStartDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface | ||
113 | { | ||
114 | switch ($type) { | ||
115 | case static::MONTH: | ||
116 | return $requested->modify('first day of this month midnight'); | ||
117 | case static::WEEK: | ||
118 | return $requested->modify('Monday this week midnight'); | ||
119 | case static::DAY: | ||
120 | return $requested->modify('Today midnight'); | ||
121 | default: | ||
122 | throw new \Exception('Unsupported daily format type'); | ||
123 | } | ||
124 | } | ||
125 | |||
126 | /** | ||
127 | * Get the last DateTime of the time period depending on given datetime and type. | ||
128 | * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax | ||
129 | * and we don't want to alter original datetime. | ||
130 | * | ||
131 | * @param string $type month/week/day | ||
132 | * @param \DateTimeImmutable $requested DateTime extracted from request input | ||
133 | * (should come from extractRequestedDateTime) | ||
134 | * | ||
135 | * @return \DateTimeInterface Last DateTime of the time period | ||
136 | * | ||
137 | * @throws \Exception Type not supported. | ||
138 | */ | ||
139 | public static function getEndDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface | ||
140 | { | ||
141 | switch ($type) { | ||
142 | case static::MONTH: | ||
143 | return $requested->modify('last day of this month 23:59:59'); | ||
144 | case static::WEEK: | ||
145 | return $requested->modify('Sunday this week 23:59:59'); | ||
146 | case static::DAY: | ||
147 | return $requested->modify('Today 23:59:59'); | ||
148 | default: | ||
149 | throw new \Exception('Unsupported daily format type'); | ||
150 | } | ||
151 | } | ||
152 | |||
153 | /** | ||
154 | * Get localized description of the time period depending on given datetime and type. | ||
155 | * Example: for a month period, it returns `October, 2020`. | ||
156 | * | ||
157 | * @param string $type month/week/day | ||
158 | * @param \DateTimeImmutable $requested DateTime extracted from request input | ||
159 | * (should come from extractRequestedDateTime) | ||
160 | * | ||
161 | * @return string Localized time period description | ||
162 | * | ||
163 | * @throws \Exception Type not supported. | ||
164 | */ | ||
165 | public static function getDescriptionByType(string $type, \DateTimeImmutable $requested): string | ||
166 | { | ||
167 | switch ($type) { | ||
168 | case static::MONTH: | ||
169 | return $requested->format('F') . ', ' . $requested->format('Y'); | ||
170 | case static::WEEK: | ||
171 | $requested = $requested->modify('Monday this week'); | ||
172 | return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')'; | ||
173 | case static::DAY: | ||
174 | $out = ''; | ||
175 | if ($requested->format('Ymd') === date('Ymd')) { | ||
176 | $out = t('Today') . ' - '; | ||
177 | } elseif ($requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) { | ||
178 | $out = t('Yesterday') . ' - '; | ||
179 | } | ||
180 | return $out . format_date($requested, false); | ||
181 | default: | ||
182 | throw new \Exception('Unsupported daily format type'); | ||
183 | } | ||
184 | } | ||
185 | |||
186 | /** | ||
187 | * Get the number of items to display in the RSS feed depending on the given type. | ||
188 | * | ||
189 | * @param string $type month/week/day | ||
190 | * | ||
191 | * @return int number of elements | ||
192 | * | ||
193 | * @throws \Exception Type not supported. | ||
194 | */ | ||
195 | public static function getRssLengthByType(string $type): int | ||
196 | { | ||
197 | switch ($type) { | ||
198 | case static::MONTH: | ||
199 | return 12; // 1 year | ||
200 | case static::WEEK: | ||
201 | return 26; // ~6 months | ||
202 | case static::DAY: | ||
203 | return 30; // ~1 month | ||
204 | default: | ||
205 | throw new \Exception('Unsupported daily format type'); | ||
206 | } | ||
207 | } | ||
208 | } | ||
diff --git a/application/FileUtils.php b/application/helper/FileUtils.php index 30560bfc..2eac0793 100644 --- a/application/FileUtils.php +++ b/application/helper/FileUtils.php | |||
@@ -1,6 +1,6 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | namespace Shaarli; | 3 | namespace Shaarli\Helper; |
4 | 4 | ||
5 | use Shaarli\Exceptions\IOException; | 5 | use Shaarli\Exceptions\IOException; |
6 | 6 | ||
@@ -81,4 +81,60 @@ class FileUtils | |||
81 | ) | 81 | ) |
82 | ); | 82 | ); |
83 | } | 83 | } |
84 | |||
85 | /** | ||
86 | * Recursively deletes a folder content, and deletes itself optionally. | ||
87 | * If an excluded file is found, folders won't be deleted. | ||
88 | * | ||
89 | * Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory. | ||
90 | * | ||
91 | * @param string $path | ||
92 | * @param bool $selfDelete Delete the provided folder if true, only its content if false. | ||
93 | * @param array $exclude | ||
94 | */ | ||
95 | public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool | ||
96 | { | ||
97 | $skipped = false; | ||
98 | |||
99 | if (!is_dir($path)) { | ||
100 | throw new IOException(t('Provided path is not a directory.')); | ||
101 | } | ||
102 | |||
103 | if (!static::isPathInShaarliFolder($path)) { | ||
104 | throw new IOException(t('Trying to delete a folder outside of Shaarli path.')); | ||
105 | } | ||
106 | |||
107 | foreach (new \DirectoryIterator($path) as $file) { | ||
108 | if($file->isDot()) { | ||
109 | continue; | ||
110 | } | ||
111 | |||
112 | if (in_array($file->getBasename(), $exclude, true)) { | ||
113 | $skipped = true; | ||
114 | continue; | ||
115 | } | ||
116 | |||
117 | if ($file->isFile()) { | ||
118 | unlink($file->getPathname()); | ||
119 | } elseif($file->isDir()) { | ||
120 | $skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped; | ||
121 | } | ||
122 | } | ||
123 | |||
124 | if ($selfDelete && !$skipped) { | ||
125 | rmdir($path); | ||
126 | } | ||
127 | |||
128 | return $skipped; | ||
129 | } | ||
130 | |||
131 | /** | ||
132 | * Checks that the given path is inside Shaarli directory. | ||
133 | */ | ||
134 | public static function isPathInShaarliFolder(string $path): bool | ||
135 | { | ||
136 | $rootDirectory = dirname(dirname(dirname(__FILE__))); | ||
137 | |||
138 | return strpos(realpath($path), $rootDirectory) !== false; | ||
139 | } | ||
84 | } | 140 | } |
diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php new file mode 100644 index 00000000..646a5264 --- /dev/null +++ b/application/http/HttpAccess.php | |||
@@ -0,0 +1,47 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Http; | ||
6 | |||
7 | /** | ||
8 | * Class HttpAccess | ||
9 | * | ||
10 | * This is mostly an OOP wrapper for HTTP functions defined in `HttpUtils`. | ||
11 | * It is used as dependency injection in Shaarli's container. | ||
12 | * | ||
13 | * @package Shaarli\Http | ||
14 | */ | ||
15 | class HttpAccess | ||
16 | { | ||
17 | public function getHttpResponse( | ||
18 | $url, | ||
19 | $timeout = 30, | ||
20 | $maxBytes = 4194304, | ||
21 | $curlHeaderFunction = null, | ||
22 | $curlWriteFunction = null | ||
23 | ) { | ||
24 | return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction); | ||
25 | } | ||
26 | |||
27 | public function getCurlDownloadCallback( | ||
28 | &$charset, | ||
29 | &$title, | ||
30 | &$description, | ||
31 | &$keywords, | ||
32 | $retrieveDescription | ||
33 | ) { | ||
34 | return get_curl_download_callback( | ||
35 | $charset, | ||
36 | $title, | ||
37 | $description, | ||
38 | $keywords, | ||
39 | $retrieveDescription | ||
40 | ); | ||
41 | } | ||
42 | |||
43 | public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo') | ||
44 | { | ||
45 | return get_curl_header_callback($charset, $curlGetInfo); | ||
46 | } | ||
47 | } | ||
diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php index 2ea9195d..28c12969 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,8 +37,13 @@ use Shaarli\Http\Url; | |||
35 | * @see http://stackoverflow.com/q/9183178 | 37 | * @see http://stackoverflow.com/q/9183178 |
36 | * @see http://stackoverflow.com/q/1462720 | 38 | * @see http://stackoverflow.com/q/1462720 |
37 | */ | 39 | */ |
38 | function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) | 40 | function get_http_response( |
39 | { | 41 | $url, |
42 | $timeout = 30, | ||
43 | $maxBytes = 4194304, | ||
44 | $curlHeaderFunction = null, | ||
45 | $curlWriteFunction = null | ||
46 | ) { | ||
40 | $urlObj = new Url($url); | 47 | $urlObj = new Url($url); |
41 | $cleanUrl = $urlObj->idnToAscii(); | 48 | $cleanUrl = $urlObj->idnToAscii(); |
42 | 49 | ||
@@ -70,7 +77,8 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF | |||
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, |
@@ -81,25 +89,21 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF | |||
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 | ||
84 | if (is_callable($curlWriteFunction)) { | ||
85 | curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); | ||
86 | } | ||
87 | |||
88 | // Max download size management | 92 | // Max download size management |
89 | curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16); | 93 | curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16); |
90 | curl_setopt($ch, CURLOPT_NOPROGRESS, false); | 94 | curl_setopt($ch, CURLOPT_NOPROGRESS, false); |
95 | if (is_callable($curlHeaderFunction)) { | ||
96 | curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction); | ||
97 | } | ||
98 | if (is_callable($curlWriteFunction)) { | ||
99 | curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); | ||
100 | } | ||
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 | } |
@@ -369,7 +373,11 @@ function server_url($server) | |||
369 | */ | 373 | */ |
370 | function index_url($server) | 374 | function index_url($server) |
371 | { | 375 | { |
372 | $scriptname = $server['SCRIPT_NAME']; | 376 | if (defined('SHAARLI_ROOT_URL') && null !== SHAARLI_ROOT_URL) { |
377 | return rtrim(SHAARLI_ROOT_URL, '/') . '/'; | ||
378 | } | ||
379 | |||
380 | $scriptname = !empty($server['SCRIPT_NAME']) ? $server['SCRIPT_NAME'] : '/'; | ||
373 | if (endsWith($scriptname, 'index.php')) { | 381 | if (endsWith($scriptname, 'index.php')) { |
374 | $scriptname = substr($scriptname, 0, -9); | 382 | $scriptname = substr($scriptname, 0, -9); |
375 | } | 383 | } |
@@ -377,7 +385,7 @@ function index_url($server) | |||
377 | } | 385 | } |
378 | 386 | ||
379 | /** | 387 | /** |
380 | * Returns the absolute URL of the current script, with the query | 388 | * Returns the absolute URL of the current script, with current route and query |
381 | * | 389 | * |
382 | * If the resource is "index.php", then it is removed (for better-looking URLs) | 390 | * If the resource is "index.php", then it is removed (for better-looking URLs) |
383 | * | 391 | * |
@@ -387,10 +395,17 @@ function index_url($server) | |||
387 | */ | 395 | */ |
388 | function page_url($server) | 396 | function page_url($server) |
389 | { | 397 | { |
398 | $scriptname = $server['SCRIPT_NAME'] ?? ''; | ||
399 | if (endsWith($scriptname, 'index.php')) { | ||
400 | $scriptname = substr($scriptname, 0, -9); | ||
401 | } | ||
402 | |||
403 | $route = preg_replace('@^' . $scriptname . '@', '', $server['REQUEST_URI'] ?? ''); | ||
390 | if (! empty($server['QUERY_STRING'])) { | 404 | if (! empty($server['QUERY_STRING'])) { |
391 | return index_url($server).'?'.$server['QUERY_STRING']; | 405 | return index_url($server) . $route . '?' . $server['QUERY_STRING']; |
392 | } | 406 | } |
393 | return index_url($server); | 407 | |
408 | return index_url($server) . $route; | ||
394 | } | 409 | } |
395 | 410 | ||
396 | /** | 411 | /** |
@@ -477,3 +492,132 @@ function is_https($server) | |||
477 | 492 | ||
478 | return ! empty($server['HTTPS']); | 493 | return ! empty($server['HTTPS']); |
479 | } | 494 | } |
495 | |||
496 | /** | ||
497 | * Get cURL callback function for CURLOPT_WRITEFUNCTION | ||
498 | * | ||
499 | * @param string $charset to extract from the downloaded page (reference) | ||
500 | * @param string $curlGetInfo Optionally overrides curl_getinfo function | ||
501 | * | ||
502 | * @return Closure | ||
503 | */ | ||
504 | function get_curl_header_callback( | ||
505 | &$charset, | ||
506 | $curlGetInfo = 'curl_getinfo' | ||
507 | ) { | ||
508 | $isRedirected = false; | ||
509 | |||
510 | return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) { | ||
511 | $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); | ||
512 | $chunkLength = strlen($data); | ||
513 | if (!empty($responseCode) && in_array($responseCode, [301, 302])) { | ||
514 | $isRedirected = true; | ||
515 | return $chunkLength; | ||
516 | } | ||
517 | if (!empty($responseCode) && $responseCode !== 200) { | ||
518 | return false; | ||
519 | } | ||
520 | // After a redirection, the content type will keep the previous request value | ||
521 | // until it finds the next content-type header. | ||
522 | if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) { | ||
523 | $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE); | ||
524 | } | ||
525 | if (!empty($contentType) && strpos($contentType, 'text/html') === false) { | ||
526 | return false; | ||
527 | } | ||
528 | if (!empty($contentType) && empty($charset)) { | ||
529 | $charset = header_extract_charset($contentType); | ||
530 | } | ||
531 | |||
532 | return $chunkLength; | ||
533 | }; | ||
534 | } | ||
535 | |||
536 | /** | ||
537 | * Get cURL callback function for CURLOPT_WRITEFUNCTION | ||
538 | * | ||
539 | * @param string $charset to extract from the downloaded page (reference) | ||
540 | * @param string $title to extract from the downloaded page (reference) | ||
541 | * @param string $description to extract from the downloaded page (reference) | ||
542 | * @param string $keywords to extract from the downloaded page (reference) | ||
543 | * @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content | ||
544 | * @param string $curlGetInfo Optionally overrides curl_getinfo function | ||
545 | * | ||
546 | * @return Closure | ||
547 | */ | ||
548 | function get_curl_download_callback( | ||
549 | &$charset, | ||
550 | &$title, | ||
551 | &$description, | ||
552 | &$keywords, | ||
553 | $retrieveDescription | ||
554 | ) { | ||
555 | $currentChunk = 0; | ||
556 | $foundChunk = null; | ||
557 | |||
558 | /** | ||
559 | * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download). | ||
560 | * | ||
561 | * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text' | ||
562 | * Then we extract the title and the charset and stop the download when it's done. | ||
563 | * | ||
564 | * @param resource $ch cURL resource | ||
565 | * @param string $data chunk of data being downloaded | ||
566 | * | ||
567 | * @return int|bool length of $data or false if we need to stop the download | ||
568 | */ | ||
569 | return function ($ch, $data) use ( | ||
570 | $retrieveDescription, | ||
571 | &$charset, | ||
572 | &$title, | ||
573 | &$description, | ||
574 | &$keywords, | ||
575 | &$currentChunk, | ||
576 | &$foundChunk | ||
577 | ) { | ||
578 | $chunkLength = strlen($data); | ||
579 | $currentChunk++; | ||
580 | |||
581 | if (empty($charset)) { | ||
582 | $charset = html_extract_charset($data); | ||
583 | } | ||
584 | if (empty($title)) { | ||
585 | $title = html_extract_title($data); | ||
586 | $foundChunk = ! empty($title) ? $currentChunk : $foundChunk; | ||
587 | } | ||
588 | if (empty($title)) { | ||
589 | $title = html_extract_tag('title', $data); | ||
590 | $foundChunk = ! empty($title) ? $currentChunk : $foundChunk; | ||
591 | } | ||
592 | if ($retrieveDescription && empty($description)) { | ||
593 | $description = html_extract_tag('description', $data); | ||
594 | $foundChunk = ! empty($description) ? $currentChunk : $foundChunk; | ||
595 | } | ||
596 | if ($retrieveDescription && empty($keywords)) { | ||
597 | $keywords = html_extract_tag('keywords', $data); | ||
598 | if (! empty($keywords)) { | ||
599 | $foundChunk = $currentChunk; | ||
600 | // Keywords use the format tag1, tag2 multiple words, tag | ||
601 | // So we format them to match Shaarli's separator and glue multiple words with '-' | ||
602 | $keywords = implode(' ', array_map(function($keyword) { | ||
603 | return implode('-', preg_split('/\s+/', trim($keyword))); | ||
604 | }, explode(',', $keywords))); | ||
605 | } | ||
606 | } | ||
607 | |||
608 | // We got everything we want, stop the download. | ||
609 | // If we already found either the title, description or keywords, | ||
610 | // it's highly unlikely that we'll found the other metas further than | ||
611 | // in the same chunk of data or the next one. So we also stop the download after that. | ||
612 | if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null | ||
613 | && (! $retrieveDescription | ||
614 | || $foundChunk < $currentChunk | ||
615 | || (!empty($title) && !empty($description) && !empty($keywords)) | ||
616 | ) | ||
617 | ) { | ||
618 | return false; | ||
619 | } | ||
620 | |||
621 | return $chunkLength; | ||
622 | }; | ||
623 | } | ||
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php new file mode 100644 index 00000000..ba9bd40c --- /dev/null +++ b/application/http/MetadataRetriever.php | |||
@@ -0,0 +1,69 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Http; | ||
6 | |||
7 | use Shaarli\Config\ConfigManager; | ||
8 | |||
9 | /** | ||
10 | * HTTP Tool used to extract metadata from external URL (title, description, etc.). | ||
11 | */ | ||
12 | class MetadataRetriever | ||
13 | { | ||
14 | /** @var ConfigManager */ | ||
15 | protected $conf; | ||
16 | |||
17 | /** @var HttpAccess */ | ||
18 | protected $httpAccess; | ||
19 | |||
20 | public function __construct(ConfigManager $conf, HttpAccess $httpAccess) | ||
21 | { | ||
22 | $this->conf = $conf; | ||
23 | $this->httpAccess = $httpAccess; | ||
24 | } | ||
25 | |||
26 | /** | ||
27 | * Retrieve metadata for given URL. | ||
28 | * | ||
29 | * @return array [ | ||
30 | * 'title' => <remote title>, | ||
31 | * 'description' => <remote description>, | ||
32 | * 'tags' => <remote keywords>, | ||
33 | * ] | ||
34 | */ | ||
35 | public function retrieve(string $url): array | ||
36 | { | ||
37 | $charset = null; | ||
38 | $title = null; | ||
39 | $description = null; | ||
40 | $tags = null; | ||
41 | $retrieveDescription = $this->conf->get('general.retrieve_description'); | ||
42 | |||
43 | // Short timeout to keep the application responsive | ||
44 | // The callback will fill $charset and $title with data from the downloaded page. | ||
45 | $this->httpAccess->getHttpResponse( | ||
46 | $url, | ||
47 | $this->conf->get('general.download_timeout', 30), | ||
48 | $this->conf->get('general.download_max_size', 4194304), | ||
49 | $this->httpAccess->getCurlHeaderCallback($charset), | ||
50 | $this->httpAccess->getCurlDownloadCallback( | ||
51 | $charset, | ||
52 | $title, | ||
53 | $description, | ||
54 | $tags, | ||
55 | $retrieveDescription | ||
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/legacy/LegacyController.php b/application/legacy/LegacyController.php new file mode 100644 index 00000000..826604e7 --- /dev/null +++ b/application/legacy/LegacyController.php | |||
@@ -0,0 +1,162 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Legacy; | ||
6 | |||
7 | use Shaarli\Feed\FeedBuilder; | ||
8 | use Shaarli\Front\Controller\Visitor\ShaarliVisitorController; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * We use this to maintain legacy routes, and redirect requests to the corresponding Slim route. | ||
14 | * Only public routes, and both `?addlink` and `?post` were kept here. | ||
15 | * Other routes will just display the linklist. | ||
16 | * | ||
17 | * @deprecated | ||
18 | */ | ||
19 | class LegacyController extends ShaarliVisitorController | ||
20 | { | ||
21 | /** @var string[] Both `?post` and `?addlink` do not use `?do=` format. */ | ||
22 | public const LEGACY_GET_ROUTES = [ | ||
23 | 'post', | ||
24 | 'addlink', | ||
25 | ]; | ||
26 | |||
27 | /** | ||
28 | * This method will call `$action` method, which will redirect to corresponding Slim route. | ||
29 | */ | ||
30 | public function process(Request $request, Response $response, string $action): Response | ||
31 | { | ||
32 | if (!method_exists($this, $action)) { | ||
33 | throw new UnknowLegacyRouteException(); | ||
34 | } | ||
35 | |||
36 | return $this->{$action}($request, $response); | ||
37 | } | ||
38 | |||
39 | /** Legacy route: ?post= */ | ||
40 | public function post(Request $request, Response $response): Response | ||
41 | { | ||
42 | $route = '/admin/shaare'; | ||
43 | $buildParameters = function (?array $parameters, bool $encode) { | ||
44 | if ($encode) { | ||
45 | $parameters = array_map('urlencode', $parameters); | ||
46 | } | ||
47 | |||
48 | return count($parameters) > 0 ? '?' . http_build_query($parameters) : ''; | ||
49 | }; | ||
50 | |||
51 | |||
52 | if (!$this->container->loginManager->isLoggedIn()) { | ||
53 | $parameters = $buildParameters($request->getQueryParams(), true); | ||
54 | return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters); | ||
55 | } | ||
56 | |||
57 | $parameters = $buildParameters($request->getQueryParams(), false); | ||
58 | |||
59 | return $this->redirect($response, $route . $parameters); | ||
60 | } | ||
61 | |||
62 | /** Legacy route: ?addlink= */ | ||
63 | protected function addlink(Request $request, Response $response): Response | ||
64 | { | ||
65 | $route = '/admin/add-shaare'; | ||
66 | |||
67 | if (!$this->container->loginManager->isLoggedIn()) { | ||
68 | return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route); | ||
69 | } | ||
70 | |||
71 | return $this->redirect($response, $route); | ||
72 | } | ||
73 | |||
74 | /** Legacy route: ?do=login */ | ||
75 | protected function login(Request $request, Response $response): Response | ||
76 | { | ||
77 | $returnUrl = $request->getQueryParam('returnurl'); | ||
78 | |||
79 | return $this->redirect($response, '/login' . ($returnUrl ? '?returnurl=' . $returnUrl : '')); | ||
80 | } | ||
81 | |||
82 | /** Legacy route: ?do=logout */ | ||
83 | protected function logout(Request $request, Response $response): Response | ||
84 | { | ||
85 | return $this->redirect($response, '/admin/logout'); | ||
86 | } | ||
87 | |||
88 | /** Legacy route: ?do=picwall */ | ||
89 | protected function picwall(Request $request, Response $response): Response | ||
90 | { | ||
91 | return $this->redirect($response, '/picture-wall'); | ||
92 | } | ||
93 | |||
94 | /** Legacy route: ?do=tagcloud */ | ||
95 | protected function tagcloud(Request $request, Response $response): Response | ||
96 | { | ||
97 | return $this->redirect($response, '/tags/cloud'); | ||
98 | } | ||
99 | |||
100 | /** Legacy route: ?do=taglist */ | ||
101 | protected function taglist(Request $request, Response $response): Response | ||
102 | { | ||
103 | return $this->redirect($response, '/tags/list'); | ||
104 | } | ||
105 | |||
106 | /** Legacy route: ?do=daily */ | ||
107 | protected function daily(Request $request, Response $response): Response | ||
108 | { | ||
109 | $dayParam = !empty($request->getParam('day')) ? '?day=' . escape($request->getParam('day')) : ''; | ||
110 | |||
111 | return $this->redirect($response, '/daily' . $dayParam); | ||
112 | } | ||
113 | |||
114 | /** Legacy route: ?do=rss */ | ||
115 | protected function rss(Request $request, Response $response): Response | ||
116 | { | ||
117 | return $this->feed($request, $response, FeedBuilder::$FEED_RSS); | ||
118 | } | ||
119 | |||
120 | /** Legacy route: ?do=atom */ | ||
121 | protected function atom(Request $request, Response $response): Response | ||
122 | { | ||
123 | return $this->feed($request, $response, FeedBuilder::$FEED_ATOM); | ||
124 | } | ||
125 | |||
126 | /** Legacy route: ?do=opensearch */ | ||
127 | protected function opensearch(Request $request, Response $response): Response | ||
128 | { | ||
129 | return $this->redirect($response, '/open-search'); | ||
130 | } | ||
131 | |||
132 | /** Legacy route: ?do=dailyrss */ | ||
133 | protected function dailyrss(Request $request, Response $response): Response | ||
134 | { | ||
135 | return $this->redirect($response, '/daily-rss'); | ||
136 | } | ||
137 | |||
138 | /** Legacy route: ?do=feed */ | ||
139 | protected function feed(Request $request, Response $response, string $feedType): Response | ||
140 | { | ||
141 | $parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : ''; | ||
142 | |||
143 | return $this->redirect($response, '/feed/' . $feedType . $parameters); | ||
144 | } | ||
145 | |||
146 | /** Legacy route: ?do=configure */ | ||
147 | protected function configure(Request $request, Response $response): Response | ||
148 | { | ||
149 | $route = '/admin/configure'; | ||
150 | |||
151 | if (!$this->container->loginManager->isLoggedIn()) { | ||
152 | return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route); | ||
153 | } | ||
154 | |||
155 | return $this->redirect($response, $route); | ||
156 | } | ||
157 | |||
158 | protected function getBasePath(): string | ||
159 | { | ||
160 | return $this->container->basePath ?: ''; | ||
161 | } | ||
162 | } | ||
diff --git a/application/legacy/LegacyLinkDB.php b/application/legacy/LegacyLinkDB.php index 7ccf5e54..5c02a21b 100644 --- a/application/legacy/LegacyLinkDB.php +++ b/application/legacy/LegacyLinkDB.php | |||
@@ -8,7 +8,8 @@ use DateTime; | |||
8 | use Iterator; | 8 | use Iterator; |
9 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | 9 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
10 | use Shaarli\Exceptions\IOException; | 10 | use Shaarli\Exceptions\IOException; |
11 | use Shaarli\FileUtils; | 11 | use Shaarli\Helper\FileUtils; |
12 | use Shaarli\Render\PageCacheManager; | ||
12 | 13 | ||
13 | /** | 14 | /** |
14 | * Data storage for bookmarks. | 15 | * Data storage for bookmarks. |
@@ -352,7 +353,8 @@ You use the community supported version of the original Shaarli project, by Seba | |||
352 | 353 | ||
353 | $this->write(); | 354 | $this->write(); |
354 | 355 | ||
355 | invalidateCaches($pageCacheDir); | 356 | $pageCacheManager = new PageCacheManager($pageCacheDir, $this->loggedIn); |
357 | $pageCacheManager->invalidateCaches(); | ||
356 | } | 358 | } |
357 | 359 | ||
358 | /** | 360 | /** |
diff --git a/application/legacy/LegacyRouter.php b/application/legacy/LegacyRouter.php new file mode 100644 index 00000000..0449c7e1 --- /dev/null +++ b/application/legacy/LegacyRouter.php | |||
@@ -0,0 +1,63 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Legacy; | ||
4 | |||
5 | /** | ||
6 | * Class Router | ||
7 | * | ||
8 | * (only displayable pages here) | ||
9 | * | ||
10 | * @deprecated | ||
11 | */ | ||
12 | class LegacyRouter | ||
13 | { | ||
14 | public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update'; | ||
15 | |||
16 | public static $PAGE_LOGIN = 'login'; | ||
17 | |||
18 | public static $PAGE_PICWALL = 'picwall'; | ||
19 | |||
20 | public static $PAGE_TAGCLOUD = 'tag.cloud'; | ||
21 | |||
22 | public static $PAGE_TAGLIST = 'tag.list'; | ||
23 | |||
24 | public static $PAGE_DAILY = 'daily'; | ||
25 | |||
26 | public static $PAGE_FEED_ATOM = 'feed.atom'; | ||
27 | |||
28 | public static $PAGE_FEED_RSS = 'feed.rss'; | ||
29 | |||
30 | public static $PAGE_TOOLS = 'tools'; | ||
31 | |||
32 | public static $PAGE_CHANGEPASSWORD = 'changepasswd'; | ||
33 | |||
34 | public static $PAGE_CONFIGURE = 'configure'; | ||
35 | |||
36 | public static $PAGE_CHANGETAG = 'changetag'; | ||
37 | |||
38 | public static $PAGE_ADDLINK = 'addlink'; | ||
39 | |||
40 | public static $PAGE_EDITLINK = 'editlink'; | ||
41 | |||
42 | public static $PAGE_DELETELINK = 'delete_link'; | ||
43 | |||
44 | public static $PAGE_CHANGE_VISIBILITY = 'change_visibility'; | ||
45 | |||
46 | public static $PAGE_PINLINK = 'pin'; | ||
47 | |||
48 | public static $PAGE_EXPORT = 'export'; | ||
49 | |||
50 | public static $PAGE_IMPORT = 'import'; | ||
51 | |||
52 | public static $PAGE_OPENSEARCH = 'opensearch'; | ||
53 | |||
54 | public static $PAGE_LINKLIST = 'linklist'; | ||
55 | |||
56 | public static $PAGE_PLUGINSADMIN = 'pluginadmin'; | ||
57 | |||
58 | public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin'; | ||
59 | |||
60 | public static $PAGE_THUMBS_UPDATE = 'thumbs_update'; | ||
61 | |||
62 | public static $GET_TOKEN = 'token'; | ||
63 | } | ||
diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php index 3a5de79f..fe1a286f 100644 --- a/application/legacy/LegacyUpdater.php +++ b/application/legacy/LegacyUpdater.php | |||
@@ -7,16 +7,16 @@ use RainTPL; | |||
7 | use ReflectionClass; | 7 | use ReflectionClass; |
8 | use ReflectionException; | 8 | use ReflectionException; |
9 | use ReflectionMethod; | 9 | use ReflectionMethod; |
10 | use Shaarli\ApplicationUtils; | ||
11 | use Shaarli\Bookmark\Bookmark; | 10 | use Shaarli\Bookmark\Bookmark; |
12 | use Shaarli\Bookmark\BookmarkArray; | 11 | use Shaarli\Bookmark\BookmarkArray; |
13 | use Shaarli\Bookmark\LinkDB; | ||
14 | use Shaarli\Bookmark\BookmarkFilter; | 12 | use Shaarli\Bookmark\BookmarkFilter; |
15 | use Shaarli\Bookmark\BookmarkIO; | 13 | use Shaarli\Bookmark\BookmarkIO; |
14 | use Shaarli\Bookmark\LinkDB; | ||
16 | use Shaarli\Config\ConfigJson; | 15 | use Shaarli\Config\ConfigJson; |
17 | use Shaarli\Config\ConfigManager; | 16 | use Shaarli\Config\ConfigManager; |
18 | use Shaarli\Config\ConfigPhp; | 17 | use Shaarli\Config\ConfigPhp; |
19 | use Shaarli\Exceptions\IOException; | 18 | use Shaarli\Exceptions\IOException; |
19 | use Shaarli\Helper\ApplicationUtils; | ||
20 | use Shaarli\Thumbnailer; | 20 | use Shaarli\Thumbnailer; |
21 | use Shaarli\Updater\Exception\UpdaterException; | 21 | use Shaarli\Updater\Exception\UpdaterException; |
22 | 22 | ||
@@ -534,7 +534,8 @@ class LegacyUpdater | |||
534 | 534 | ||
535 | if ($thumbnailsEnabled) { | 535 | if ($thumbnailsEnabled) { |
536 | $this->session['warnings'][] = t( | 536 | $this->session['warnings'][] = t( |
537 | 'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.' | 537 | t('You have enabled or changed thumbnails mode.') . |
538 | '<a href="./admin/thumbnails">' . t('Please synchronize them.') . '</a>' | ||
538 | ); | 539 | ); |
539 | } | 540 | } |
540 | 541 | ||
diff --git a/application/legacy/UnknowLegacyRouteException.php b/application/legacy/UnknowLegacyRouteException.php new file mode 100644 index 00000000..ae1518ad --- /dev/null +++ b/application/legacy/UnknowLegacyRouteException.php | |||
@@ -0,0 +1,9 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Legacy; | ||
6 | |||
7 | class UnknowLegacyRouteException extends \Exception | ||
8 | { | ||
9 | } | ||
diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php index d64eef7f..b83f16f8 100644 --- a/application/netscape/NetscapeBookmarkUtils.php +++ b/application/netscape/NetscapeBookmarkUtils.php | |||
@@ -6,6 +6,7 @@ use DateTime; | |||
6 | use DateTimeZone; | 6 | use DateTimeZone; |
7 | use Exception; | 7 | use Exception; |
8 | use Katzgrau\KLogger\Logger; | 8 | use Katzgrau\KLogger\Logger; |
9 | use Psr\Http\Message\UploadedFileInterface; | ||
9 | use Psr\Log\LogLevel; | 10 | use Psr\Log\LogLevel; |
10 | use Shaarli\Bookmark\Bookmark; | 11 | use Shaarli\Bookmark\Bookmark; |
11 | use Shaarli\Bookmark\BookmarkServiceInterface; | 12 | use Shaarli\Bookmark\BookmarkServiceInterface; |
@@ -16,10 +17,24 @@ use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; | |||
16 | 17 | ||
17 | /** | 18 | /** |
18 | * Utilities to import and export bookmarks using the Netscape format | 19 | * Utilities to import and export bookmarks using the Netscape format |
19 | * TODO: Not static, use a container. | ||
20 | */ | 20 | */ |
21 | class NetscapeBookmarkUtils | 21 | class NetscapeBookmarkUtils |
22 | { | 22 | { |
23 | /** @var BookmarkServiceInterface */ | ||
24 | protected $bookmarkService; | ||
25 | |||
26 | /** @var ConfigManager */ | ||
27 | protected $conf; | ||
28 | |||
29 | /** @var History */ | ||
30 | protected $history; | ||
31 | |||
32 | public function __construct(BookmarkServiceInterface $bookmarkService, ConfigManager $conf, History $history) | ||
33 | { | ||
34 | $this->bookmarkService = $bookmarkService; | ||
35 | $this->conf = $conf; | ||
36 | $this->history = $history; | ||
37 | } | ||
23 | 38 | ||
24 | /** | 39 | /** |
25 | * Filters bookmarks and adds Netscape-formatted fields | 40 | * Filters bookmarks and adds Netscape-formatted fields |
@@ -28,18 +43,16 @@ class NetscapeBookmarkUtils | |||
28 | * - timestamp link addition date, using the Unix epoch format | 43 | * - timestamp link addition date, using the Unix epoch format |
29 | * - taglist comma-separated tag list | 44 | * - taglist comma-separated tag list |
30 | * | 45 | * |
31 | * @param BookmarkServiceInterface $bookmarkService Link datastore | ||
32 | * @param BookmarkFormatter $formatter instance | 46 | * @param BookmarkFormatter $formatter instance |
33 | * @param string $selection Which bookmarks to export: (all|private|public) | 47 | * @param string $selection Which bookmarks to export: (all|private|public) |
34 | * @param bool $prependNoteUrl Prepend note permalinks with the server's URL | 48 | * @param bool $prependNoteUrl Prepend note permalinks with the server's URL |
35 | * @param string $indexUrl Absolute URL of the Shaarli index page | 49 | * @param string $indexUrl Absolute URL of the Shaarli index page |
36 | * | 50 | * |
37 | * @return array The bookmarks to be exported, with additional fields | 51 | * @return array The bookmarks to be exported, with additional fields |
38 | *@throws Exception Invalid export selection | ||
39 | * | 52 | * |
53 | * @throws Exception Invalid export selection | ||
40 | */ | 54 | */ |
41 | public static function filterAndFormat( | 55 | public function filterAndFormat( |
42 | $bookmarkService, | ||
43 | $formatter, | 56 | $formatter, |
44 | $selection, | 57 | $selection, |
45 | $prependNoteUrl, | 58 | $prependNoteUrl, |
@@ -51,11 +64,11 @@ class NetscapeBookmarkUtils | |||
51 | } | 64 | } |
52 | 65 | ||
53 | $bookmarkLinks = array(); | 66 | $bookmarkLinks = array(); |
54 | foreach ($bookmarkService->search([], $selection) as $bookmark) { | 67 | foreach ($this->bookmarkService->search([], $selection) as $bookmark) { |
55 | $link = $formatter->format($bookmark); | 68 | $link = $formatter->format($bookmark); |
56 | $link['taglist'] = implode(',', $bookmark->getTags()); | 69 | $link['taglist'] = implode(',', $bookmark->getTags()); |
57 | if ($bookmark->isNote() && $prependNoteUrl) { | 70 | if ($bookmark->isNote() && $prependNoteUrl) { |
58 | $link['url'] = $indexUrl . $link['url']; | 71 | $link['url'] = rtrim($indexUrl, '/') . '/' . ltrim($link['url'], '/'); |
59 | } | 72 | } |
60 | 73 | ||
61 | $bookmarkLinks[] = $link; | 74 | $bookmarkLinks[] = $link; |
@@ -65,60 +78,22 @@ class NetscapeBookmarkUtils | |||
65 | } | 78 | } |
66 | 79 | ||
67 | /** | 80 | /** |
68 | * Generates an import status summary | ||
69 | * | ||
70 | * @param string $filename name of the file to import | ||
71 | * @param int $filesize size of the file to import | ||
72 | * @param int $importCount how many bookmarks were imported | ||
73 | * @param int $overwriteCount how many bookmarks were overwritten | ||
74 | * @param int $skipCount how many bookmarks were skipped | ||
75 | * @param int $duration how many seconds did the import take | ||
76 | * | ||
77 | * @return string Summary of the bookmark import status | ||
78 | */ | ||
79 | private static function importStatus( | ||
80 | $filename, | ||
81 | $filesize, | ||
82 | $importCount = 0, | ||
83 | $overwriteCount = 0, | ||
84 | $skipCount = 0, | ||
85 | $duration = 0 | ||
86 | ) { | ||
87 | $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize); | ||
88 | if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) { | ||
89 | $status .= t('has an unknown file format. Nothing was imported.'); | ||
90 | } else { | ||
91 | $status .= vsprintf( | ||
92 | t( | ||
93 | 'was successfully processed in %d seconds: ' | ||
94 | . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.' | ||
95 | ), | ||
96 | [$duration, $importCount, $overwriteCount, $skipCount] | ||
97 | ); | ||
98 | } | ||
99 | return $status; | ||
100 | } | ||
101 | |||
102 | /** | ||
103 | * Imports Web bookmarks from an uploaded Netscape bookmark dump | 81 | * Imports Web bookmarks from an uploaded Netscape bookmark dump |
104 | * | 82 | * |
105 | * @param array $post Server $_POST parameters | 83 | * @param array $post Server $_POST parameters |
106 | * @param array $files Server $_FILES parameters | 84 | * @param UploadedFileInterface $file File in PSR-7 object format |
107 | * @param BookmarkServiceInterface $bookmarkService Loaded LinkDB instance | ||
108 | * @param ConfigManager $conf instance | ||
109 | * @param History $history History instance | ||
110 | * | 85 | * |
111 | * @return string Summary of the bookmark import status | 86 | * @return string Summary of the bookmark import status |
112 | */ | 87 | */ |
113 | public static function import($post, $files, $bookmarkService, $conf, $history) | 88 | public function import($post, UploadedFileInterface $file) |
114 | { | 89 | { |
115 | $start = time(); | 90 | $start = time(); |
116 | $filename = $files['filetoupload']['name']; | 91 | $filename = $file->getClientFilename(); |
117 | $filesize = $files['filetoupload']['size']; | 92 | $filesize = $file->getSize(); |
118 | $data = file_get_contents($files['filetoupload']['tmp_name']); | 93 | $data = (string) $file->getStream(); |
119 | 94 | ||
120 | if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) { | 95 | if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) { |
121 | return self::importStatus($filename, $filesize); | 96 | return $this->importStatus($filename, $filesize); |
122 | } | 97 | } |
123 | 98 | ||
124 | // Overwrite existing bookmarks? | 99 | // Overwrite existing bookmarks? |
@@ -141,11 +116,11 @@ class NetscapeBookmarkUtils | |||
141 | true, // nested tag support | 116 | true, // nested tag support |
142 | $defaultTags, // additional user-specified tags | 117 | $defaultTags, // additional user-specified tags |
143 | strval(1 - $defaultPrivacy), // defaultPub = 1 - defaultPrivacy | 118 | strval(1 - $defaultPrivacy), // defaultPub = 1 - defaultPrivacy |
144 | $conf->get('resource.data_dir') // log path, will be overridden | 119 | $this->conf->get('resource.data_dir') // log path, will be overridden |
145 | ); | 120 | ); |
146 | $logger = new Logger( | 121 | $logger = new Logger( |
147 | $conf->get('resource.data_dir'), | 122 | $this->conf->get('resource.data_dir'), |
148 | !$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG, | 123 | !$this->conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG, |
149 | [ | 124 | [ |
150 | 'prefix' => 'import.', | 125 | 'prefix' => 'import.', |
151 | 'extension' => 'log', | 126 | 'extension' => 'log', |
@@ -171,7 +146,7 @@ class NetscapeBookmarkUtils | |||
171 | $private = 0; | 146 | $private = 0; |
172 | } | 147 | } |
173 | 148 | ||
174 | $link = $bookmarkService->findByUrl($bkm['uri']); | 149 | $link = $this->bookmarkService->findByUrl($bkm['uri']); |
175 | $existingLink = $link !== null; | 150 | $existingLink = $link !== null; |
176 | if (! $existingLink) { | 151 | if (! $existingLink) { |
177 | $link = new Bookmark(); | 152 | $link = new Bookmark(); |
@@ -193,20 +168,21 @@ class NetscapeBookmarkUtils | |||
193 | } | 168 | } |
194 | 169 | ||
195 | $link->setTitle($bkm['title']); | 170 | $link->setTitle($bkm['title']); |
196 | $link->setUrl($bkm['uri'], $conf->get('security.allowed_protocols')); | 171 | $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols')); |
197 | $link->setDescription($bkm['note']); | 172 | $link->setDescription($bkm['note']); |
198 | $link->setPrivate($private); | 173 | $link->setPrivate($private); |
199 | $link->setTagsString($bkm['tags']); | 174 | $link->setTagsString($bkm['tags']); |
200 | 175 | ||
201 | $bookmarkService->addOrSet($link, false); | 176 | $this->bookmarkService->addOrSet($link, false); |
202 | $importCount++; | 177 | $importCount++; |
203 | } | 178 | } |
204 | 179 | ||
205 | $bookmarkService->save(); | 180 | $this->bookmarkService->save(); |
206 | $history->importLinks(); | 181 | $this->history->importLinks(); |
207 | 182 | ||
208 | $duration = time() - $start; | 183 | $duration = time() - $start; |
209 | return self::importStatus( | 184 | |
185 | return $this->importStatus( | ||
210 | $filename, | 186 | $filename, |
211 | $filesize, | 187 | $filesize, |
212 | $importCount, | 188 | $importCount, |
@@ -215,4 +191,39 @@ class NetscapeBookmarkUtils | |||
215 | $duration | 191 | $duration |
216 | ); | 192 | ); |
217 | } | 193 | } |
194 | |||
195 | /** | ||
196 | * Generates an import status summary | ||
197 | * | ||
198 | * @param string $filename name of the file to import | ||
199 | * @param int $filesize size of the file to import | ||
200 | * @param int $importCount how many bookmarks were imported | ||
201 | * @param int $overwriteCount how many bookmarks were overwritten | ||
202 | * @param int $skipCount how many bookmarks were skipped | ||
203 | * @param int $duration how many seconds did the import take | ||
204 | * | ||
205 | * @return string Summary of the bookmark import status | ||
206 | */ | ||
207 | protected function importStatus( | ||
208 | $filename, | ||
209 | $filesize, | ||
210 | $importCount = 0, | ||
211 | $overwriteCount = 0, | ||
212 | $skipCount = 0, | ||
213 | $duration = 0 | ||
214 | ) { | ||
215 | $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize); | ||
216 | if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) { | ||
217 | $status .= t('has an unknown file format. Nothing was imported.'); | ||
218 | } else { | ||
219 | $status .= vsprintf( | ||
220 | t( | ||
221 | 'was successfully processed in %d seconds: ' | ||
222 | . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.' | ||
223 | ), | ||
224 | [$duration, $importCount, $overwriteCount, $skipCount] | ||
225 | ); | ||
226 | } | ||
227 | return $status; | ||
228 | } | ||
218 | } | 229 | } |
diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php index f7b24a8e..da66dea3 100644 --- a/application/plugin/PluginManager.php +++ b/application/plugin/PluginManager.php | |||
@@ -16,7 +16,7 @@ class PluginManager | |||
16 | * | 16 | * |
17 | * @var array $authorizedPlugins | 17 | * @var array $authorizedPlugins |
18 | */ | 18 | */ |
19 | private $authorizedPlugins; | 19 | private $authorizedPlugins = []; |
20 | 20 | ||
21 | /** | 21 | /** |
22 | * List of loaded plugins. | 22 | * List of loaded plugins. |
@@ -100,21 +100,36 @@ class PluginManager | |||
100 | */ | 100 | */ |
101 | public function executeHooks($hook, &$data, $params = array()) | 101 | public function executeHooks($hook, &$data, $params = array()) |
102 | { | 102 | { |
103 | if (!empty($params['target'])) { | 103 | $metadataParameters = [ |
104 | $data['_PAGE_'] = $params['target']; | 104 | 'target' => '_PAGE_', |
105 | } | 105 | 'loggedin' => '_LOGGEDIN_', |
106 | 106 | 'basePath' => '_BASE_PATH_', | |
107 | if (isset($params['loggedin'])) { | 107 | 'rootPath' => '_ROOT_PATH_', |
108 | $data['_LOGGEDIN_'] = $params['loggedin']; | 108 | 'bookmarkService' => '_BOOKMARK_SERVICE_', |
109 | ]; | ||
110 | |||
111 | foreach ($metadataParameters as $parameter => $metaKey) { | ||
112 | if (array_key_exists($parameter, $params)) { | ||
113 | $data[$metaKey] = $params[$parameter]; | ||
114 | } | ||
109 | } | 115 | } |
110 | 116 | ||
111 | foreach ($this->loadedPlugins as $plugin) { | 117 | foreach ($this->loadedPlugins as $plugin) { |
112 | $hookFunction = $this->buildHookName($hook, $plugin); | 118 | $hookFunction = $this->buildHookName($hook, $plugin); |
113 | 119 | ||
114 | if (function_exists($hookFunction)) { | 120 | if (function_exists($hookFunction)) { |
115 | $data = call_user_func($hookFunction, $data, $this->conf); | 121 | try { |
122 | $data = call_user_func($hookFunction, $data, $this->conf); | ||
123 | } catch (\Throwable $e) { | ||
124 | $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage(); | ||
125 | $this->errors = array_unique(array_merge($this->errors, [$error])); | ||
126 | } | ||
116 | } | 127 | } |
117 | } | 128 | } |
129 | |||
130 | foreach ($metadataParameters as $metaKey) { | ||
131 | unset($data[$metaKey]); | ||
132 | } | ||
118 | } | 133 | } |
119 | 134 | ||
120 | /** | 135 | /** |
diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index f4fefda8..c2fae705 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php | |||
@@ -3,10 +3,12 @@ | |||
3 | namespace Shaarli\Render; | 3 | namespace Shaarli\Render; |
4 | 4 | ||
5 | use Exception; | 5 | use Exception; |
6 | use Psr\Log\LoggerInterface; | ||
6 | use RainTPL; | 7 | use RainTPL; |
7 | use Shaarli\ApplicationUtils; | ||
8 | use Shaarli\Bookmark\BookmarkServiceInterface; | 8 | use Shaarli\Bookmark\BookmarkServiceInterface; |
9 | use Shaarli\Config\ConfigManager; | 9 | use Shaarli\Config\ConfigManager; |
10 | use Shaarli\Helper\ApplicationUtils; | ||
11 | use Shaarli\Security\SessionManager; | ||
10 | use Shaarli\Thumbnailer; | 12 | use Shaarli\Thumbnailer; |
11 | 13 | ||
12 | /** | 14 | /** |
@@ -33,6 +35,9 @@ class PageBuilder | |||
33 | */ | 35 | */ |
34 | protected $session; | 36 | protected $session; |
35 | 37 | ||
38 | /** @var LoggerInterface */ | ||
39 | protected $logger; | ||
40 | |||
36 | /** | 41 | /** |
37 | * @var BookmarkServiceInterface $bookmarkService instance. | 42 | * @var BookmarkServiceInterface $bookmarkService instance. |
38 | */ | 43 | */ |
@@ -52,23 +57,40 @@ class PageBuilder | |||
52 | * PageBuilder constructor. | 57 | * PageBuilder constructor. |
53 | * $tpl is initialized at false for lazy loading. | 58 | * $tpl is initialized at false for lazy loading. |
54 | * | 59 | * |
55 | * @param ConfigManager $conf Configuration Manager instance (reference). | 60 | * @param ConfigManager $conf Configuration Manager instance (reference). |
56 | * @param array $session $_SESSION array | 61 | * @param array $session $_SESSION array |
57 | * @param BookmarkServiceInterface $linkDB instance. | 62 | * @param LoggerInterface $logger |
58 | * @param string $token Session token | 63 | * @param null $linkDB instance. |
59 | * @param bool $isLoggedIn | 64 | * @param null $token Session token |
65 | * @param bool $isLoggedIn | ||
60 | */ | 66 | */ |
61 | public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) | 67 | public function __construct( |
62 | { | 68 | ConfigManager &$conf, |
69 | array $session, | ||
70 | LoggerInterface $logger, | ||
71 | $linkDB = null, | ||
72 | $token = null, | ||
73 | $isLoggedIn = false | ||
74 | ) { | ||
63 | $this->tpl = false; | 75 | $this->tpl = false; |
64 | $this->conf = $conf; | 76 | $this->conf = $conf; |
65 | $this->session = $session; | 77 | $this->session = $session; |
78 | $this->logger = $logger; | ||
66 | $this->bookmarkService = $linkDB; | 79 | $this->bookmarkService = $linkDB; |
67 | $this->token = $token; | 80 | $this->token = $token; |
68 | $this->isLoggedIn = $isLoggedIn; | 81 | $this->isLoggedIn = $isLoggedIn; |
69 | } | 82 | } |
70 | 83 | ||
71 | /** | 84 | /** |
85 | * Reset current state of template rendering. | ||
86 | * Mostly useful for error handling. We remove everything, and display the error template. | ||
87 | */ | ||
88 | public function reset(): void | ||
89 | { | ||
90 | $this->tpl = false; | ||
91 | } | ||
92 | |||
93 | /** | ||
72 | * Initialize all default tpl tags. | 94 | * Initialize all default tpl tags. |
73 | */ | 95 | */ |
74 | private function initialize() | 96 | private function initialize() |
@@ -87,7 +109,7 @@ class PageBuilder | |||
87 | $this->tpl->assign('newVersion', escape($version)); | 109 | $this->tpl->assign('newVersion', escape($version)); |
88 | $this->tpl->assign('versionError', ''); | 110 | $this->tpl->assign('versionError', ''); |
89 | } catch (Exception $exc) { | 111 | } catch (Exception $exc) { |
90 | logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); | 112 | $this->logger->error(format_log('Error: ' . $exc->getMessage(), client_ip_id($_SERVER))); |
91 | $this->tpl->assign('newVersion', ''); | 113 | $this->tpl->assign('newVersion', ''); |
92 | $this->tpl->assign('versionError', escape($exc->getMessage())); | 114 | $this->tpl->assign('versionError', escape($exc->getMessage())); |
93 | } | 115 | } |
@@ -126,7 +148,7 @@ class PageBuilder | |||
126 | $this->tpl->assign('language', $this->conf->get('translation.language')); | 148 | $this->tpl->assign('language', $this->conf->get('translation.language')); |
127 | 149 | ||
128 | if ($this->bookmarkService !== null) { | 150 | if ($this->bookmarkService !== null) { |
129 | $this->tpl->assign('tags', $this->bookmarkService->bookmarksCountPerTag()); | 151 | $this->tpl->assign('tags', escape($this->bookmarkService->bookmarksCountPerTag())); |
130 | } | 152 | } |
131 | 153 | ||
132 | $this->tpl->assign( | 154 | $this->tpl->assign( |
@@ -136,18 +158,45 @@ class PageBuilder | |||
136 | $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width')); | 158 | $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width')); |
137 | $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height')); | 159 | $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height')); |
138 | 160 | ||
139 | if (!empty($_SESSION['warnings'])) { | ||
140 | $this->tpl->assign('global_warnings', $_SESSION['warnings']); | ||
141 | unset($_SESSION['warnings']); | ||
142 | } | ||
143 | |||
144 | $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); | 161 | $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); |
145 | 162 | ||
163 | $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20); | ||
164 | |||
146 | // To be removed with a proper theme configuration. | 165 | // To be removed with a proper theme configuration. |
147 | $this->tpl->assign('conf', $this->conf); | 166 | $this->tpl->assign('conf', $this->conf); |
148 | } | 167 | } |
149 | 168 | ||
150 | /** | 169 | /** |
170 | * Affect variable after controller processing. | ||
171 | * Used for alert messages. | ||
172 | */ | ||
173 | protected function finalize(string $basePath): void | ||
174 | { | ||
175 | // TODO: use the SessionManager | ||
176 | $messageKeys = [ | ||
177 | SessionManager::KEY_SUCCESS_MESSAGES, | ||
178 | SessionManager::KEY_WARNING_MESSAGES, | ||
179 | SessionManager::KEY_ERROR_MESSAGES | ||
180 | ]; | ||
181 | foreach ($messageKeys as $messageKey) { | ||
182 | if (!empty($_SESSION[$messageKey])) { | ||
183 | $this->tpl->assign('global_' . $messageKey, $_SESSION[$messageKey]); | ||
184 | unset($_SESSION[$messageKey]); | ||
185 | } | ||
186 | } | ||
187 | |||
188 | $rootPath = preg_replace('#/index\.php$#', '', $basePath); | ||
189 | $this->assign('base_path', $basePath); | ||
190 | $this->assign('root_path', $rootPath); | ||
191 | $this->assign( | ||
192 | 'asset_path', | ||
193 | $rootPath . '/' . | ||
194 | rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' . | ||
195 | $this->conf->get('resource.theme', 'default') | ||
196 | ); | ||
197 | } | ||
198 | |||
199 | /** | ||
151 | * The following assign() method is basically the same as RainTPL (except lazy loading) | 200 | * The following assign() method is basically the same as RainTPL (except lazy loading) |
152 | * | 201 | * |
153 | * @param string $placeholder Template placeholder. | 202 | * @param string $placeholder Template placeholder. |
@@ -185,21 +234,6 @@ class PageBuilder | |||
185 | } | 234 | } |
186 | 235 | ||
187 | /** | 236 | /** |
188 | * Render a specific page (using a template file). | ||
189 | * e.g. $pb->renderPage('picwall'); | ||
190 | * | ||
191 | * @param string $page Template filename (without extension). | ||
192 | */ | ||
193 | public function renderPage($page) | ||
194 | { | ||
195 | if ($this->tpl === false) { | ||
196 | $this->initialize(); | ||
197 | } | ||
198 | |||
199 | $this->tpl->draw($page); | ||
200 | } | ||
201 | |||
202 | /** | ||
203 | * Render a specific page as string (using a template file). | 237 | * Render a specific page as string (using a template file). |
204 | * e.g. $pb->render('picwall'); | 238 | * e.g. $pb->render('picwall'); |
205 | * | 239 | * |
@@ -207,28 +241,14 @@ class PageBuilder | |||
207 | * | 241 | * |
208 | * @return string Processed template content | 242 | * @return string Processed template content |
209 | */ | 243 | */ |
210 | public function render(string $page): string | 244 | public function render(string $page, string $basePath): string |
211 | { | 245 | { |
212 | if ($this->tpl === false) { | 246 | if ($this->tpl === false) { |
213 | $this->initialize(); | 247 | $this->initialize(); |
214 | } | 248 | } |
215 | 249 | ||
216 | return $this->tpl->draw($page, true); | 250 | $this->finalize($basePath); |
217 | } | ||
218 | 251 | ||
219 | /** | 252 | return $this->tpl->draw($page, true); |
220 | * Render a 404 page (uses the template : tpl/404.tpl) | ||
221 | * usage: $PAGE->render404('The link was deleted') | ||
222 | * | ||
223 | * @param string $message A message to display what is not found | ||
224 | */ | ||
225 | public function render404($message = '') | ||
226 | { | ||
227 | if (empty($message)) { | ||
228 | $message = t('The page you are trying to reach does not exist or has been deleted.'); | ||
229 | } | ||
230 | header($_SERVER['SERVER_PROTOCOL'] . ' ' . t('404 Not Found')); | ||
231 | $this->tpl->assign('error_message', $message); | ||
232 | $this->renderPage('404'); | ||
233 | } | 253 | } |
234 | } | 254 | } |
diff --git a/application/render/PageCacheManager.php b/application/render/PageCacheManager.php new file mode 100644 index 00000000..97805c35 --- /dev/null +++ b/application/render/PageCacheManager.php | |||
@@ -0,0 +1,60 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Render; | ||
4 | |||
5 | use Shaarli\Feed\CachedPage; | ||
6 | |||
7 | /** | ||
8 | * Cache utilities | ||
9 | */ | ||
10 | class PageCacheManager | ||
11 | { | ||
12 | /** @var string Cache directory */ | ||
13 | protected $pageCacheDir; | ||
14 | |||
15 | /** @var bool */ | ||
16 | protected $isLoggedIn; | ||
17 | |||
18 | public function __construct(string $pageCacheDir, bool $isLoggedIn) | ||
19 | { | ||
20 | $this->pageCacheDir = $pageCacheDir; | ||
21 | $this->isLoggedIn = $isLoggedIn; | ||
22 | } | ||
23 | |||
24 | /** | ||
25 | * Purges all cached pages | ||
26 | * | ||
27 | * @return string|null an error string if the directory is missing | ||
28 | */ | ||
29 | public function purgeCachedPages(): ?string | ||
30 | { | ||
31 | if (!is_dir($this->pageCacheDir)) { | ||
32 | $error = sprintf(t('Cannot purge %s: no directory'), $this->pageCacheDir); | ||
33 | error_log($error); | ||
34 | |||
35 | return $error; | ||
36 | } | ||
37 | |||
38 | array_map('unlink', glob($this->pageCacheDir . '/*.cache')); | ||
39 | |||
40 | return null; | ||
41 | } | ||
42 | |||
43 | /** | ||
44 | * Invalidates caches when the database is changed or the user logs out. | ||
45 | */ | ||
46 | public function invalidateCaches(): void | ||
47 | { | ||
48 | // Purge page cache shared by sessions. | ||
49 | $this->purgeCachedPages(); | ||
50 | } | ||
51 | |||
52 | public function getCachePage(string $pageUrl): CachedPage | ||
53 | { | ||
54 | return new CachedPage( | ||
55 | $this->pageCacheDir, | ||
56 | $pageUrl, | ||
57 | false === $this->isLoggedIn | ||
58 | ); | ||
59 | } | ||
60 | } | ||
diff --git a/application/render/TemplatePage.php b/application/render/TemplatePage.php new file mode 100644 index 00000000..03b424f3 --- /dev/null +++ b/application/render/TemplatePage.php | |||
@@ -0,0 +1,34 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Render; | ||
6 | |||
7 | interface TemplatePage | ||
8 | { | ||
9 | public const ERROR_404 = '404'; | ||
10 | public const ADDLINK = 'addlink'; | ||
11 | public const CHANGE_PASSWORD = 'changepassword'; | ||
12 | public const CHANGE_TAG = 'changetag'; | ||
13 | public const CONFIGURE = 'configure'; | ||
14 | public const DAILY = 'daily'; | ||
15 | public const DAILY_RSS = 'dailyrss'; | ||
16 | public const EDIT_LINK = 'editlink'; | ||
17 | public const EDIT_LINK_BATCH = 'editlink.batch'; | ||
18 | public const ERROR = 'error'; | ||
19 | public const EXPORT = 'export'; | ||
20 | public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks'; | ||
21 | public const FEED_ATOM = 'feed.atom'; | ||
22 | public const FEED_RSS = 'feed.rss'; | ||
23 | public const IMPORT = 'import'; | ||
24 | public const INSTALL = 'install'; | ||
25 | public const LINKLIST = 'linklist'; | ||
26 | public const LOGIN = 'loginform'; | ||
27 | public const OPEN_SEARCH = 'opensearch'; | ||
28 | public const PICTURE_WALL = 'picwall'; | ||
29 | public const PLUGINS_ADMIN = 'pluginsadmin'; | ||
30 | public const TAG_CLOUD = 'tag.cloud'; | ||
31 | public const TAG_LIST = 'tag.list'; | ||
32 | public const THUMBNAILS = 'thumbnails'; | ||
33 | public const TOOLS = 'tools'; | ||
34 | } | ||
diff --git a/application/security/BanManager.php b/application/security/BanManager.php index 68190c54..288cbde0 100644 --- a/application/security/BanManager.php +++ b/application/security/BanManager.php | |||
@@ -3,7 +3,8 @@ | |||
3 | 3 | ||
4 | namespace Shaarli\Security; | 4 | namespace Shaarli\Security; |
5 | 5 | ||
6 | use Shaarli\FileUtils; | 6 | use Psr\Log\LoggerInterface; |
7 | use Shaarli\Helper\FileUtils; | ||
7 | 8 | ||
8 | /** | 9 | /** |
9 | * Class BanManager | 10 | * Class BanManager |
@@ -28,8 +29,8 @@ class BanManager | |||
28 | /** @var string Path to the file containing IP bans and failures */ | 29 | /** @var string Path to the file containing IP bans and failures */ |
29 | protected $banFile; | 30 | protected $banFile; |
30 | 31 | ||
31 | /** @var string Path to the log file, used to log bans */ | 32 | /** @var LoggerInterface Path to the log file, used to log bans */ |
32 | protected $logFile; | 33 | protected $logger; |
33 | 34 | ||
34 | /** @var array List of IP with their associated number of failed attempts */ | 35 | /** @var array List of IP with their associated number of failed attempts */ |
35 | protected $failures = []; | 36 | protected $failures = []; |
@@ -40,18 +41,19 @@ class BanManager | |||
40 | /** | 41 | /** |
41 | * BanManager constructor. | 42 | * BanManager constructor. |
42 | * | 43 | * |
43 | * @param array $trustedProxies List of allowed proxies IP | 44 | * @param array $trustedProxies List of allowed proxies IP |
44 | * @param int $nbAttempts Number of allowed failed attempt before the ban | 45 | * @param int $nbAttempts Number of allowed failed attempt before the ban |
45 | * @param int $banDuration Ban duration in seconds | 46 | * @param int $banDuration Ban duration in seconds |
46 | * @param string $banFile Path to the file containing IP bans and failures | 47 | * @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 | 48 | * @param LoggerInterface $logger PSR-3 logger to save login attempts in log directory |
48 | */ | 49 | */ |
49 | public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, $logFile) { | 50 | public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, LoggerInterface $logger) { |
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/CookieManager.php b/application/security/CookieManager.php new file mode 100644 index 00000000..cde4746e --- /dev/null +++ b/application/security/CookieManager.php | |||
@@ -0,0 +1,33 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Security; | ||
6 | |||
7 | class CookieManager | ||
8 | { | ||
9 | /** @var string Name of the cookie set after logging in **/ | ||
10 | public const STAY_SIGNED_IN = 'shaarli_staySignedIn'; | ||
11 | |||
12 | /** @var mixed $_COOKIE set by reference */ | ||
13 | protected $cookies; | ||
14 | |||
15 | public function __construct(array &$cookies) | ||
16 | { | ||
17 | $this->cookies = $cookies; | ||
18 | } | ||
19 | |||
20 | public function setCookieParameter(string $key, string $value, int $expires, string $path): self | ||
21 | { | ||
22 | $this->cookies[$key] = $value; | ||
23 | |||
24 | setcookie($key, $value, $expires, $path); | ||
25 | |||
26 | return $this; | ||
27 | } | ||
28 | |||
29 | public function getCookieParameter(string $key, string $default = null): ?string | ||
30 | { | ||
31 | return $this->cookies[$key] ?? $default; | ||
32 | } | ||
33 | } | ||
diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php index 39ec9b2e..426e785e 100644 --- a/application/security/LoginManager.php +++ b/application/security/LoginManager.php | |||
@@ -2,6 +2,7 @@ | |||
2 | namespace Shaarli\Security; | 2 | namespace Shaarli\Security; |
3 | 3 | ||
4 | use Exception; | 4 | use Exception; |
5 | use Psr\Log\LoggerInterface; | ||
5 | use Shaarli\Config\ConfigManager; | 6 | use Shaarli\Config\ConfigManager; |
6 | 7 | ||
7 | /** | 8 | /** |
@@ -9,9 +10,6 @@ use Shaarli\Config\ConfigManager; | |||
9 | */ | 10 | */ |
10 | class LoginManager | 11 | class LoginManager |
11 | { | 12 | { |
12 | /** @var string Name of the cookie set after logging in **/ | ||
13 | public static $STAY_SIGNED_IN_COOKIE = 'shaarli_staySignedIn'; | ||
14 | |||
15 | /** @var array A reference to the $_GLOBALS array */ | 13 | /** @var array A reference to the $_GLOBALS array */ |
16 | protected $globals = []; | 14 | protected $globals = []; |
17 | 15 | ||
@@ -32,24 +30,32 @@ class LoginManager | |||
32 | 30 | ||
33 | /** @var string User sign-in token depending on remote IP and credentials */ | 31 | /** @var string User sign-in token depending on remote IP and credentials */ |
34 | protected $staySignedInToken = ''; | 32 | protected $staySignedInToken = ''; |
33 | /** @var CookieManager */ | ||
34 | protected $cookieManager; | ||
35 | /** @var LoggerInterface */ | ||
36 | protected $logger; | ||
35 | 37 | ||
36 | /** | 38 | /** |
37 | * Constructor | 39 | * Constructor |
38 | * | 40 | * |
39 | * @param ConfigManager $configManager Configuration Manager instance | 41 | * @param ConfigManager $configManager Configuration Manager instance |
40 | * @param SessionManager $sessionManager SessionManager instance | 42 | * @param SessionManager $sessionManager SessionManager instance |
43 | * @param CookieManager $cookieManager CookieManager instance | ||
44 | * @param BanManager $banManager | ||
45 | * @param LoggerInterface $logger Used to log login attempts | ||
41 | */ | 46 | */ |
42 | public function __construct($configManager, $sessionManager) | 47 | public function __construct( |
43 | { | 48 | ConfigManager $configManager, |
49 | SessionManager $sessionManager, | ||
50 | CookieManager $cookieManager, | ||
51 | BanManager $banManager, | ||
52 | LoggerInterface $logger | ||
53 | ) { | ||
44 | $this->configManager = $configManager; | 54 | $this->configManager = $configManager; |
45 | $this->sessionManager = $sessionManager; | 55 | $this->sessionManager = $sessionManager; |
46 | $this->banManager = new BanManager( | 56 | $this->cookieManager = $cookieManager; |
47 | $this->configManager->get('security.trusted_proxies', []), | 57 | $this->banManager = $banManager; |
48 | $this->configManager->get('security.ban_after'), | 58 | $this->logger = $logger; |
49 | $this->configManager->get('security.ban_duration'), | ||
50 | $this->configManager->get('resource.ban_file', 'data/ipbans.php'), | ||
51 | $this->configManager->get('resource.log') | ||
52 | ); | ||
53 | 59 | ||
54 | if ($this->configManager->get('security.open_shaarli') === true) { | 60 | if ($this->configManager->get('security.open_shaarli') === true) { |
55 | $this->openShaarli = true; | 61 | $this->openShaarli = true; |
@@ -86,10 +92,9 @@ class LoginManager | |||
86 | /** | 92 | /** |
87 | * Check user session state and validity (expiration) | 93 | * Check user session state and validity (expiration) |
88 | * | 94 | * |
89 | * @param array $cookie The $_COOKIE array | ||
90 | * @param string $clientIpId Client IP address identifier | 95 | * @param string $clientIpId Client IP address identifier |
91 | */ | 96 | */ |
92 | public function checkLoginState($cookie, $clientIpId) | 97 | public function checkLoginState($clientIpId) |
93 | { | 98 | { |
94 | if (! $this->configManager->exists('credentials.login')) { | 99 | if (! $this->configManager->exists('credentials.login')) { |
95 | // Shaarli is not configured yet | 100 | // Shaarli is not configured yet |
@@ -97,9 +102,7 @@ class LoginManager | |||
97 | return; | 102 | return; |
98 | } | 103 | } |
99 | 104 | ||
100 | if (isset($cookie[self::$STAY_SIGNED_IN_COOKIE]) | 105 | if ($this->staySignedInToken === $this->cookieManager->getCookieParameter(CookieManager::STAY_SIGNED_IN)) { |
101 | && $cookie[self::$STAY_SIGNED_IN_COOKIE] === $this->staySignedInToken | ||
102 | ) { | ||
103 | // The user client has a valid stay-signed-in cookie | 106 | // The user client has a valid stay-signed-in cookie |
104 | // Session information is updated with the current client information | 107 | // Session information is updated with the current client information |
105 | $this->sessionManager->storeLoginInfo($clientIpId); | 108 | $this->sessionManager->storeLoginInfo($clientIpId); |
@@ -120,7 +123,7 @@ class LoginManager | |||
120 | * | 123 | * |
121 | * @return true when the user is logged in, false otherwise | 124 | * @return true when the user is logged in, false otherwise |
122 | */ | 125 | */ |
123 | public function isLoggedIn() | 126 | public function isLoggedIn(): bool |
124 | { | 127 | { |
125 | if ($this->openShaarli) { | 128 | if ($this->openShaarli) { |
126 | return true; | 129 | return true; |
@@ -131,48 +134,34 @@ class LoginManager | |||
131 | /** | 134 | /** |
132 | * Check user credentials are valid | 135 | * Check user credentials are valid |
133 | * | 136 | * |
134 | * @param string $remoteIp Remote client IP address | ||
135 | * @param string $clientIpId Client IP address identifier | 137 | * @param string $clientIpId Client IP address identifier |
136 | * @param string $login Username | 138 | * @param string $login Username |
137 | * @param string $password Password | 139 | * @param string $password Password |
138 | * | 140 | * |
139 | * @return bool true if the provided credentials are valid, false otherwise | 141 | * @return bool true if the provided credentials are valid, false otherwise |
140 | */ | 142 | */ |
141 | public function checkCredentials($remoteIp, $clientIpId, $login, $password) | 143 | public function checkCredentials($clientIpId, $login, $password) |
142 | { | 144 | { |
143 | // Check login matches config | ||
144 | if ($login !== $this->configManager->get('credentials.login')) { | ||
145 | return false; | ||
146 | } | ||
147 | |||
148 | // Check credentials | 145 | // Check credentials |
149 | try { | 146 | try { |
150 | $useLdapLogin = !empty($this->configManager->get('ldap.host')); | 147 | $useLdapLogin = !empty($this->configManager->get('ldap.host')); |
151 | if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password)) | 148 | if ($login === $this->configManager->get('credentials.login') |
152 | || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password)) | 149 | && ( |
150 | (false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password)) | ||
151 | || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password)) | ||
152 | ) | ||
153 | ) { | 153 | ) { |
154 | $this->sessionManager->storeLoginInfo($clientIpId); | 154 | $this->sessionManager->storeLoginInfo($clientIpId); |
155 | logm( | 155 | $this->logger->info(format_log('Login successful', $clientIpId)); |
156 | $this->configManager->get('resource.log'), | 156 | |
157 | $remoteIp, | 157 | return true; |
158 | 'Login successful' | ||
159 | ); | ||
160 | return true; | ||
161 | } | 158 | } |
162 | } | 159 | } catch(Exception $exception) { |
163 | catch(Exception $exception) { | 160 | $this->logger->info(format_log('Exception while checking credentials: ' . $exception, $clientIpId)); |
164 | logm( | ||
165 | $this->configManager->get('resource.log'), | ||
166 | $remoteIp, | ||
167 | 'Exception while checking credentials: ' . $exception | ||
168 | ); | ||
169 | } | 161 | } |
170 | 162 | ||
171 | logm( | 163 | $this->logger->info(format_log('Login failed for user ' . $login, $clientIpId)); |
172 | $this->configManager->get('resource.log'), | 164 | |
173 | $remoteIp, | ||
174 | 'Login failed for user ' . $login | ||
175 | ); | ||
176 | return false; | 165 | return false; |
177 | } | 166 | } |
178 | 167 | ||
diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php index 994fcbe5..96bf193c 100644 --- a/application/security/SessionManager.php +++ b/application/security/SessionManager.php | |||
@@ -8,6 +8,14 @@ use Shaarli\Config\ConfigManager; | |||
8 | */ | 8 | */ |
9 | class SessionManager | 9 | class SessionManager |
10 | { | 10 | { |
11 | public const KEY_LINKS_PER_PAGE = 'LINKS_PER_PAGE'; | ||
12 | public const KEY_VISIBILITY = 'visibility'; | ||
13 | public const KEY_UNTAGGED_ONLY = 'untaggedonly'; | ||
14 | |||
15 | public const KEY_SUCCESS_MESSAGES = 'successes'; | ||
16 | public const KEY_WARNING_MESSAGES = 'warnings'; | ||
17 | public const KEY_ERROR_MESSAGES = 'errors'; | ||
18 | |||
11 | /** @var int Session expiration timeout, in seconds */ | 19 | /** @var int Session expiration timeout, in seconds */ |
12 | public static $SHORT_TIMEOUT = 3600; // 1 hour | 20 | public static $SHORT_TIMEOUT = 3600; // 1 hour |
13 | 21 | ||
@@ -23,16 +31,35 @@ class SessionManager | |||
23 | /** @var bool Whether the user should stay signed in (LONG_TIMEOUT) */ | 31 | /** @var bool Whether the user should stay signed in (LONG_TIMEOUT) */ |
24 | protected $staySignedIn = false; | 32 | protected $staySignedIn = false; |
25 | 33 | ||
34 | /** @var string */ | ||
35 | protected $savePath; | ||
36 | |||
26 | /** | 37 | /** |
27 | * Constructor | 38 | * Constructor |
28 | * | 39 | * |
29 | * @param array $session The $_SESSION array (reference) | 40 | * @param array $session The $_SESSION array (reference) |
30 | * @param ConfigManager $conf ConfigManager instance | 41 | * @param ConfigManager $conf ConfigManager instance |
42 | * @param string $savePath Session save path returned by builtin function session_save_path() | ||
31 | */ | 43 | */ |
32 | public function __construct(& $session, $conf) | 44 | public function __construct(&$session, $conf, string $savePath) |
33 | { | 45 | { |
34 | $this->session = &$session; | 46 | $this->session = &$session; |
35 | $this->conf = $conf; | 47 | $this->conf = $conf; |
48 | $this->savePath = $savePath; | ||
49 | } | ||
50 | |||
51 | /** | ||
52 | * Initialize XSRF token and links per page session variables. | ||
53 | */ | ||
54 | public function initialize(): void | ||
55 | { | ||
56 | if (!isset($this->session['tokens'])) { | ||
57 | $this->session['tokens'] = []; | ||
58 | } | ||
59 | |||
60 | if (!isset($this->session['LINKS_PER_PAGE'])) { | ||
61 | $this->session['LINKS_PER_PAGE'] = $this->conf->get('general.links_per_page', 20); | ||
62 | } | ||
36 | } | 63 | } |
37 | 64 | ||
38 | /** | 65 | /** |
@@ -156,7 +183,6 @@ class SessionManager | |||
156 | unset($this->session['expires_on']); | 183 | unset($this->session['expires_on']); |
157 | unset($this->session['username']); | 184 | unset($this->session['username']); |
158 | unset($this->session['visibility']); | 185 | unset($this->session['visibility']); |
159 | unset($this->session['untaggedonly']); | ||
160 | } | 186 | } |
161 | } | 187 | } |
162 | 188 | ||
@@ -202,4 +228,81 @@ class SessionManager | |||
202 | { | 228 | { |
203 | return $this->session; | 229 | return $this->session; |
204 | } | 230 | } |
231 | |||
232 | /** | ||
233 | * @param mixed $default value which will be returned if the $key is undefined | ||
234 | * | ||
235 | * @return mixed Content stored in session | ||
236 | */ | ||
237 | public function getSessionParameter(string $key, $default = null) | ||
238 | { | ||
239 | return $this->session[$key] ?? $default; | ||
240 | } | ||
241 | |||
242 | /** | ||
243 | * Store a variable in user session. | ||
244 | * | ||
245 | * @param string $key Session key | ||
246 | * @param mixed $value Session value to store | ||
247 | * | ||
248 | * @return $this | ||
249 | */ | ||
250 | public function setSessionParameter(string $key, $value): self | ||
251 | { | ||
252 | $this->session[$key] = $value; | ||
253 | |||
254 | return $this; | ||
255 | } | ||
256 | |||
257 | /** | ||
258 | * Store a variable in user session. | ||
259 | * | ||
260 | * @param string $key Session key | ||
261 | * | ||
262 | * @return $this | ||
263 | */ | ||
264 | public function deleteSessionParameter(string $key): self | ||
265 | { | ||
266 | unset($this->session[$key]); | ||
267 | |||
268 | return $this; | ||
269 | } | ||
270 | |||
271 | public function getSavePath(): string | ||
272 | { | ||
273 | return $this->savePath; | ||
274 | } | ||
275 | |||
276 | /* | ||
277 | * Next public functions wrapping native PHP session API. | ||
278 | */ | ||
279 | |||
280 | public function destroy(): bool | ||
281 | { | ||
282 | $this->session = []; | ||
283 | |||
284 | return session_destroy(); | ||
285 | } | ||
286 | |||
287 | public function start(): bool | ||
288 | { | ||
289 | if (session_status() === PHP_SESSION_ACTIVE) { | ||
290 | $this->destroy(); | ||
291 | } | ||
292 | |||
293 | return session_start(); | ||
294 | } | ||
295 | |||
296 | /** | ||
297 | * Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2. | ||
298 | */ | ||
299 | public function cookieParameters(int $lifeTime, string $path, string $domain): void | ||
300 | { | ||
301 | session_set_cookie_params($lifeTime, $path, $domain); | ||
302 | } | ||
303 | |||
304 | public function regenerateId(bool $deleteOldSession = false): bool | ||
305 | { | ||
306 | return session_regenerate_id($deleteOldSession); | ||
307 | } | ||
205 | } | 308 | } |
diff --git a/application/updater/Updater.php b/application/updater/Updater.php index 95654d81..88a7bc7b 100644 --- a/application/updater/Updater.php +++ b/application/updater/Updater.php | |||
@@ -2,8 +2,8 @@ | |||
2 | 2 | ||
3 | namespace Shaarli\Updater; | 3 | namespace Shaarli\Updater; |
4 | 4 | ||
5 | use Shaarli\Config\ConfigManager; | ||
6 | use Shaarli\Bookmark\BookmarkServiceInterface; | 5 | use Shaarli\Bookmark\BookmarkServiceInterface; |
6 | use Shaarli\Config\ConfigManager; | ||
7 | use Shaarli\Updater\Exception\UpdaterException; | 7 | use Shaarli\Updater\Exception\UpdaterException; |
8 | 8 | ||
9 | /** | 9 | /** |
@@ -21,7 +21,7 @@ class Updater | |||
21 | /** | 21 | /** |
22 | * @var BookmarkServiceInterface instance. | 22 | * @var BookmarkServiceInterface instance. |
23 | */ | 23 | */ |
24 | protected $linkServices; | 24 | protected $bookmarkService; |
25 | 25 | ||
26 | /** | 26 | /** |
27 | * @var ConfigManager $conf Configuration Manager instance. | 27 | * @var ConfigManager $conf Configuration Manager instance. |
@@ -39,6 +39,11 @@ class Updater | |||
39 | protected $methods; | 39 | protected $methods; |
40 | 40 | ||
41 | /** | 41 | /** |
42 | * @var string $basePath Shaarli root directory (from HTTP Request) | ||
43 | */ | ||
44 | protected $basePath = null; | ||
45 | |||
46 | /** | ||
42 | * Object constructor. | 47 | * Object constructor. |
43 | * | 48 | * |
44 | * @param array $doneUpdates Updates which are already done. | 49 | * @param array $doneUpdates Updates which are already done. |
@@ -49,7 +54,7 @@ class Updater | |||
49 | public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn) | 54 | public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn) |
50 | { | 55 | { |
51 | $this->doneUpdates = $doneUpdates; | 56 | $this->doneUpdates = $doneUpdates; |
52 | $this->linkServices = $linkDB; | 57 | $this->bookmarkService = $linkDB; |
53 | $this->conf = $conf; | 58 | $this->conf = $conf; |
54 | $this->isLoggedIn = $isLoggedIn; | 59 | $this->isLoggedIn = $isLoggedIn; |
55 | 60 | ||
@@ -62,13 +67,15 @@ class Updater | |||
62 | * Run all new updates. | 67 | * Run all new updates. |
63 | * Update methods have to start with 'updateMethod' and return true (on success). | 68 | * Update methods have to start with 'updateMethod' and return true (on success). |
64 | * | 69 | * |
70 | * @param string $basePath Shaarli root directory (from HTTP Request) | ||
71 | * | ||
65 | * @return array An array containing ran updates. | 72 | * @return array An array containing ran updates. |
66 | * | 73 | * |
67 | * @throws UpdaterException If something went wrong. | 74 | * @throws UpdaterException If something went wrong. |
68 | */ | 75 | */ |
69 | public function update() | 76 | public function update(string $basePath = null) |
70 | { | 77 | { |
71 | $updatesRan = array(); | 78 | $updatesRan = []; |
72 | 79 | ||
73 | // If the user isn't logged in, exit without updating. | 80 | // If the user isn't logged in, exit without updating. |
74 | if ($this->isLoggedIn !== true) { | 81 | if ($this->isLoggedIn !== true) { |
@@ -111,4 +118,62 @@ class Updater | |||
111 | { | 118 | { |
112 | return $this->doneUpdates; | 119 | return $this->doneUpdates; |
113 | } | 120 | } |
121 | |||
122 | public function readUpdates(string $updatesFilepath): array | ||
123 | { | ||
124 | return UpdaterUtils::read_updates_file($updatesFilepath); | ||
125 | } | ||
126 | |||
127 | public function writeUpdates(string $updatesFilepath, array $updates): void | ||
128 | { | ||
129 | UpdaterUtils::write_updates_file($updatesFilepath, $updates); | ||
130 | } | ||
131 | |||
132 | /** | ||
133 | * With the Slim routing system, default header link should be `/subfolder/` instead of `?`. | ||
134 | * Otherwise you can not go back to the home page. | ||
135 | * Example: `/subfolder/picture-wall` -> `/subfolder/picture-wall?` instead of `/subfolder/`. | ||
136 | */ | ||
137 | public function updateMethodRelativeHomeLink(): bool | ||
138 | { | ||
139 | if ('?' === trim($this->conf->get('general.header_link'))) { | ||
140 | $this->conf->set('general.header_link', $this->basePath . '/', true, true); | ||
141 | } | ||
142 | |||
143 | return true; | ||
144 | } | ||
145 | |||
146 | /** | ||
147 | * With the Slim routing system, note bookmarks URL formatted `?abcdef` | ||
148 | * should be replaced with `/shaare/abcdef` | ||
149 | */ | ||
150 | public function updateMethodMigrateExistingNotesUrl(): bool | ||
151 | { | ||
152 | $updated = false; | ||
153 | |||
154 | foreach ($this->bookmarkService->search() as $bookmark) { | ||
155 | if ($bookmark->isNote() | ||
156 | && startsWith($bookmark->getUrl(), '?') | ||
157 | && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match) | ||
158 | ) { | ||
159 | $updated = true; | ||
160 | $bookmark = $bookmark->setUrl('/shaare/' . $match[1]); | ||
161 | |||
162 | $this->bookmarkService->set($bookmark, false); | ||
163 | } | ||
164 | } | ||
165 | |||
166 | if ($updated) { | ||
167 | $this->bookmarkService->save(); | ||
168 | } | ||
169 | |||
170 | return true; | ||
171 | } | ||
172 | |||
173 | public function setBasePath(string $basePath): self | ||
174 | { | ||
175 | $this->basePath = $basePath; | ||
176 | |||
177 | return $this; | ||
178 | } | ||
114 | } | 179 | } |