aboutsummaryrefslogtreecommitdiffhomepage
path: root/application/front
diff options
context:
space:
mode:
Diffstat (limited to 'application/front')
-rw-r--r--application/front/ShaarliMiddleware.php6
-rw-r--r--application/front/controller/admin/ConfigureController.php14
-rw-r--r--application/front/controller/admin/ExportController.php4
-rw-r--r--application/front/controller/admin/ImportController.php4
-rw-r--r--application/front/controller/admin/ManageShaareController.php371
-rw-r--r--application/front/controller/admin/ManageTagController.php37
-rw-r--r--application/front/controller/admin/MetadataController.php29
-rw-r--r--application/front/controller/admin/PasswordController.php4
-rw-r--r--application/front/controller/admin/PluginsController.php4
-rw-r--r--application/front/controller/admin/ServerController.php96
-rw-r--r--application/front/controller/admin/SessionFilterController.php2
-rw-r--r--application/front/controller/admin/ShaareAddController.php34
-rw-r--r--application/front/controller/admin/ShaareManageController.php202
-rw-r--r--application/front/controller/admin/ShaarePublishController.php274
-rw-r--r--application/front/controller/admin/ThumbnailsController.php4
-rw-r--r--application/front/controller/admin/ToolsController.php2
-rw-r--r--application/front/controller/visitor/BookmarkListController.php53
-rw-r--r--application/front/controller/visitor/DailyController.php105
-rw-r--r--application/front/controller/visitor/ErrorController.php12
-rw-r--r--application/front/controller/visitor/FeedController.php2
-rw-r--r--application/front/controller/visitor/InstallController.php39
-rw-r--r--application/front/controller/visitor/LoginController.php9
-rw-r--r--application/front/controller/visitor/PictureWallController.php2
-rw-r--r--application/front/controller/visitor/ShaarliVisitorController.php6
-rw-r--r--application/front/controller/visitor/TagCloudController.php12
-rw-r--r--application/front/controller/visitor/TagController.php18
26 files changed, 849 insertions, 496 deletions
diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php
index d1aa1399..164217f4 100644
--- a/application/front/ShaarliMiddleware.php
+++ b/application/front/ShaarliMiddleware.php
@@ -42,7 +42,8 @@ class ShaarliMiddleware
42 $this->initBasePath($request); 42 $this->initBasePath($request);
43 43
44 try { 44 try {
45 if (!is_file($this->container->conf->getConfigFileExt()) 45 if (
46 !is_file($this->container->conf->getConfigFileExt())
46 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true) 47 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
47 ) { 48 ) {
48 return $response->withRedirect($this->container->basePath . '/install'); 49 return $response->withRedirect($this->container->basePath . '/install');
@@ -86,7 +87,8 @@ class ShaarliMiddleware
86 */ 87 */
87 protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool 88 protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
88 { 89 {
89 if (// if the user isn't logged in 90 if (
91// if the user isn't logged in
90 !$this->container->loginManager->isLoggedIn() 92 !$this->container->loginManager->isLoggedIn()
91 // and Shaarli doesn't have public content... 93 // and Shaarli doesn't have public content...
92 && $this->container->conf->get('privacy.hide_public_links') 94 && $this->container->conf->get('privacy.hide_public_links')
diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php
index e675fcca..dc421661 100644
--- a/application/front/controller/admin/ConfigureController.php
+++ b/application/front/controller/admin/ConfigureController.php
@@ -30,7 +30,7 @@ class ConfigureController extends ShaarliAdminController
30 'theme_available', 30 'theme_available',
31 ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl')) 31 ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl'))
32 ); 32 );
33 $this->assignView('formatter_available', ['default', 'markdown']); 33 $this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']);
34 list($continents, $cities) = generateTimeZoneData( 34 list($continents, $cities) = generateTimeZoneData(
35 timezone_identifiers_list(), 35 timezone_identifiers_list(),
36 $this->container->conf->get('general.timezone') 36 $this->container->conf->get('general.timezone')
@@ -51,7 +51,10 @@ class ConfigureController extends ShaarliAdminController
51 $this->assignView('languages', Languages::getAvailableLanguages()); 51 $this->assignView('languages', Languages::getAvailableLanguages());
52 $this->assignView('gd_enabled', extension_loaded('gd')); 52 $this->assignView('gd_enabled', extension_loaded('gd'));
53 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)); 53 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
54 $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 54 $this->assignView(
55 'pagetitle',
56 t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
57 );
55 58
56 return $response->write($this->render(TemplatePage::CONFIGURE)); 59 return $response->write($this->render(TemplatePage::CONFIGURE));
57 } 60 }
@@ -95,12 +98,15 @@ class ConfigureController extends ShaarliAdminController
95 } 98 }
96 99
97 $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE; 100 $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
98 if ($thumbnailsMode !== Thumbnailer::MODE_NONE 101 if (
102 $thumbnailsMode !== Thumbnailer::MODE_NONE
99 && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) 103 && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
100 ) { 104 ) {
101 $this->saveWarningMessage( 105 $this->saveWarningMessage(
102 t('You have enabled or changed thumbnails mode.') . 106 t('You have enabled or changed thumbnails mode.') .
103 '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>' 107 '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
108 t('Please synchronize them.') .
109 '</a>'
104 ); 110 );
105 } 111 }
106 $this->container->conf->set('thumbnails.mode', $thumbnailsMode); 112 $this->container->conf->set('thumbnails.mode', $thumbnailsMode);
diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php
index 2be957fa..f01d7e9b 100644
--- a/application/front/controller/admin/ExportController.php
+++ b/application/front/controller/admin/ExportController.php
@@ -23,7 +23,7 @@ class ExportController extends ShaarliAdminController
23 */ 23 */
24 public function index(Request $request, Response $response): Response 24 public function index(Request $request, Response $response): Response
25 { 25 {
26 $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 26 $this->assignView('pagetitle', t('Export') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
27 27
28 return $response->write($this->render(TemplatePage::EXPORT)); 28 return $response->write($this->render(TemplatePage::EXPORT));
29 } 29 }
@@ -68,7 +68,7 @@ class ExportController extends ShaarliAdminController
68 $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8'); 68 $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
69 $response = $response->withHeader( 69 $response = $response->withHeader(
70 'Content-disposition', 70 'Content-disposition',
71 'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html' 71 'attachment; filename=bookmarks_' . $selection . '_' . $now->format(Bookmark::LINK_DATE_FORMAT) . '.html'
72 ); 72 );
73 73
74 $this->assignView('date', $now->format(DateTime::RFC822)); 74 $this->assignView('date', $now->format(DateTime::RFC822));
diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php
index 758d5ef9..c2ad6a09 100644
--- a/application/front/controller/admin/ImportController.php
+++ b/application/front/controller/admin/ImportController.php
@@ -38,7 +38,7 @@ class ImportController extends ShaarliAdminController
38 true 38 true
39 ) 39 )
40 ); 40 );
41 $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 41 $this->assignView('pagetitle', t('Import') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
42 42
43 return $response->write($this->render(TemplatePage::IMPORT)); 43 return $response->write($this->render(TemplatePage::IMPORT));
44 } 44 }
@@ -64,7 +64,7 @@ class ImportController extends ShaarliAdminController
64 $msg = sprintf( 64 $msg = sprintf(
65 t( 65 t(
66 'The file you are trying to upload is probably bigger than what this webserver can accept' 66 'The file you are trying to upload is probably bigger than what this webserver can accept'
67 .' (%s). Please upload in smaller chunks.' 67 . ' (%s). Please upload in smaller chunks.'
68 ), 68 ),
69 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')) 69 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
70 ); 70 );
diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php
deleted file mode 100644
index bb083486..00000000
--- a/application/front/controller/admin/ManageShaareController.php
+++ /dev/null
@@ -1,371 +0,0 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkMarkdownFormatter;
10use Shaarli\Render\TemplatePage;
11use Shaarli\Thumbnailer;
12use Slim\Http\Request;
13use Slim\Http\Response;
14
15/**
16 * Class PostBookmarkController
17 *
18 * Slim controller used to handle Shaarli create or edit bookmarks.
19 */
20class ManageShaareController extends ShaarliAdminController
21{
22 /**
23 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
24 */
25 public function addShaare(Request $request, Response $response): Response
26 {
27 $this->assignView(
28 'pagetitle',
29 t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
30 );
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34
35 /**
36 * GET /admin/shaare - Displays the bookmark form for creation.
37 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
38 */
39 public function displayCreateForm(Request $request, Response $response): Response
40 {
41 $url = cleanup_url($request->getParam('post'));
42
43 $linkIsNew = false;
44 // Check if URL is not already in database (in this case, we will edit the existing link)
45 $bookmark = $this->container->bookmarkService->findByUrl($url);
46 if (null === $bookmark) {
47 $linkIsNew = true;
48 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
49 $title = $request->getParam('title');
50 $description = $request->getParam('description');
51 $tags = $request->getParam('tags');
52 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
53
54 // If this is an HTTP(S) link, we try go get the page to extract
55 // the title (otherwise we will to straight to the edit form.)
56 if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
57 $retrieveDescription = $this->container->conf->get('general.retrieve_description');
58 // Short timeout to keep the application responsive
59 // The callback will fill $charset and $title with data from the downloaded page.
60 $this->container->httpAccess->getHttpResponse(
61 $url,
62 $this->container->conf->get('general.download_timeout', 30),
63 $this->container->conf->get('general.download_max_size', 4194304),
64 $this->container->httpAccess->getCurlDownloadCallback(
65 $charset,
66 $title,
67 $description,
68 $tags,
69 $retrieveDescription
70 )
71 );
72 if (! empty($title) && strtolower($charset) !== 'utf-8' && mb_check_encoding($charset)) {
73 $title = mb_convert_encoding($title, 'utf-8', $charset);
74 }
75 }
76
77 if (empty($url) && empty($title)) {
78 $title = $this->container->conf->get('general.default_note_title', t('Note: '));
79 }
80
81 $link = [
82 'title' => $title,
83 'url' => $url ?? '',
84 'description' => $description ?? '',
85 'tags' => $tags ?? '',
86 'private' => $private,
87 ];
88 } else {
89 $formatter = $this->container->formatterFactory->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91 }
92
93 return $this->displayForm($link, $linkIsNew, $request, $response);
94 }
95
96 /**
97 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
98 */
99 public function displayEditForm(Request $request, Response $response, array $args): Response
100 {
101 $id = $args['id'] ?? '';
102 try {
103 if (false === ctype_digit($id)) {
104 throw new BookmarkNotFoundException();
105 }
106 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
107 } catch (BookmarkNotFoundException $e) {
108 $this->saveErrorMessage(sprintf(
109 t('Bookmark with identifier %s could not be found.'),
110 $id
111 ));
112
113 return $this->redirect($response, '/');
114 }
115
116 $formatter = $this->container->formatterFactory->getFormatter('raw');
117 $link = $formatter->format($bookmark);
118
119 return $this->displayForm($link, false, $request, $response);
120 }
121
122 /**
123 * POST /admin/shaare
124 */
125 public function save(Request $request, Response $response): Response
126 {
127 $this->checkToken($request);
128
129 // lf_id should only be present if the link exists.
130 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
131 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
132 // Edit
133 $bookmark = $this->container->bookmarkService->get($id);
134 } else {
135 // New link
136 $bookmark = new Bookmark();
137 }
138
139 $bookmark->setTitle($request->getParam('lf_title'));
140 $bookmark->setDescription($request->getParam('lf_description'));
141 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
142 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
143 $bookmark->setTagsString($request->getParam('lf_tags'));
144
145 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
146 && false === $bookmark->isNote()
147 ) {
148 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
149 }
150 $this->container->bookmarkService->addOrSet($bookmark, false);
151
152 // To preserve backward compatibility with 3rd parties, plugins still use arrays
153 $formatter = $this->container->formatterFactory->getFormatter('raw');
154 $data = $formatter->format($bookmark);
155 $this->executePageHooks('save_link', $data);
156
157 $bookmark->fromArray($data);
158 $this->container->bookmarkService->set($bookmark);
159
160 // If we are called from the bookmarklet, we must close the popup:
161 if ($request->getParam('source') === 'bookmarklet') {
162 return $response->write('<script>self.close();</script>');
163 }
164
165 if (!empty($request->getParam('returnurl'))) {
166 $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl'));
167 }
168
169 return $this->redirectFromReferer(
170 $request,
171 $response,
172 ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'],
173 $bookmark->getShortUrl()
174 );
175 }
176
177 /**
178 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
179 */
180 public function deleteBookmark(Request $request, Response $response): Response
181 {
182 $this->checkToken($request);
183
184 $ids = escape(trim($request->getParam('id') ?? ''));
185 if (empty($ids) || strpos($ids, ' ') !== false) {
186 // multiple, space-separated ids provided
187 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
188 } else {
189 $ids = [$ids];
190 }
191
192 // assert at least one id is given
193 if (0 === count($ids)) {
194 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
195
196 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
197 }
198
199 $formatter = $this->container->formatterFactory->getFormatter('raw');
200 $count = 0;
201 foreach ($ids as $id) {
202 try {
203 $bookmark = $this->container->bookmarkService->get((int) $id);
204 } catch (BookmarkNotFoundException $e) {
205 $this->saveErrorMessage(sprintf(
206 t('Bookmark with identifier %s could not be found.'),
207 $id
208 ));
209
210 continue;
211 }
212
213 $data = $formatter->format($bookmark);
214 $this->executePageHooks('delete_link', $data);
215 $this->container->bookmarkService->remove($bookmark, false);
216 ++ $count;
217 }
218
219 if ($count > 0) {
220 $this->container->bookmarkService->save();
221 }
222
223 // If we are called from the bookmarklet, we must close the popup:
224 if ($request->getParam('source') === 'bookmarklet') {
225 return $response->write('<script>self.close();</script>');
226 }
227
228 // Don't redirect to where we were previously because the datastore has changed.
229 return $this->redirect($response, '/');
230 }
231
232 /**
233 * GET /admin/shaare/visibility
234 *
235 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
236 */
237 public function changeVisibility(Request $request, Response $response): Response
238 {
239 $this->checkToken($request);
240
241 $ids = trim(escape($request->getParam('id') ?? ''));
242 if (empty($ids) || strpos($ids, ' ') !== false) {
243 // multiple, space-separated ids provided
244 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
245 } else {
246 // only a single id provided
247 $ids = [$ids];
248 }
249
250 // assert at least one id is given
251 if (0 === count($ids)) {
252 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
253
254 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
255 }
256
257 // assert that the visibility is valid
258 $visibility = $request->getParam('newVisibility');
259 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
260 $this->saveErrorMessage(t('Invalid visibility provided.'));
261
262 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
263 } else {
264 $isPrivate = $visibility === 'private';
265 }
266
267 $formatter = $this->container->formatterFactory->getFormatter('raw');
268 $count = 0;
269
270 foreach ($ids as $id) {
271 try {
272 $bookmark = $this->container->bookmarkService->get((int) $id);
273 } catch (BookmarkNotFoundException $e) {
274 $this->saveErrorMessage(sprintf(
275 t('Bookmark with identifier %s could not be found.'),
276 $id
277 ));
278
279 continue;
280 }
281
282 $bookmark->setPrivate($isPrivate);
283
284 // To preserve backward compatibility with 3rd parties, plugins still use arrays
285 $data = $formatter->format($bookmark);
286 $this->executePageHooks('save_link', $data);
287 $bookmark->fromArray($data);
288
289 $this->container->bookmarkService->set($bookmark, false);
290 ++$count;
291 }
292
293 if ($count > 0) {
294 $this->container->bookmarkService->save();
295 }
296
297 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
298 }
299
300 /**
301 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
302 */
303 public function pinBookmark(Request $request, Response $response, array $args): Response
304 {
305 $this->checkToken($request);
306
307 $id = $args['id'] ?? '';
308 try {
309 if (false === ctype_digit($id)) {
310 throw new BookmarkNotFoundException();
311 }
312 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
313 } catch (BookmarkNotFoundException $e) {
314 $this->saveErrorMessage(sprintf(
315 t('Bookmark with identifier %s could not be found.'),
316 $id
317 ));
318
319 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
320 }
321
322 $formatter = $this->container->formatterFactory->getFormatter('raw');
323
324 $bookmark->setSticky(!$bookmark->isSticky());
325
326 // To preserve backward compatibility with 3rd parties, plugins still use arrays
327 $data = $formatter->format($bookmark);
328 $this->executePageHooks('save_link', $data);
329 $bookmark->fromArray($data);
330
331 $this->container->bookmarkService->set($bookmark);
332
333 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
334 }
335
336 /**
337 * Helper function used to display the shaare form whether it's a new or existing bookmark.
338 *
339 * @param array $link data used in template, either from parameters or from the data store
340 */
341 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
342 {
343 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
344 if ($this->container->conf->get('formatter') === 'markdown') {
345 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
346 }
347
348 $data = escape([
349 'link' => $link,
350 'link_is_new' => $isNew,
351 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
352 'source' => $request->getParam('source') ?? '',
353 'tags' => $tags,
354 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
355 ]);
356
357 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
358
359 foreach ($data as $key => $value) {
360 $this->assignView($key, $value);
361 }
362
363 $editLabel = false === $isNew ? t('Edit') .' ' : '';
364 $this->assignView(
365 'pagetitle',
366 $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
367 );
368
369 return $response->write($this->render(TemplatePage::EDIT_LINK));
370 }
371}
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php
index 2065c3e2..8675a0c5 100644
--- a/application/front/controller/admin/ManageTagController.php
+++ b/application/front/controller/admin/ManageTagController.php
@@ -24,9 +24,15 @@ class ManageTagController extends ShaarliAdminController
24 $fromTag = $request->getParam('fromtag') ?? ''; 24 $fromTag = $request->getParam('fromtag') ?? '';
25 25
26 $this->assignView('fromtag', escape($fromTag)); 26 $this->assignView('fromtag', escape($fromTag));
27 $separator = escape($this->container->conf->get('general.tags_separator', ' '));
28 if ($separator === ' ') {
29 $separator = '&nbsp;';
30 $this->assignView('tags_separator_desc', t('whitespace'));
31 }
32 $this->assignView('tags_separator', $separator);
27 $this->assignView( 33 $this->assignView(
28 'pagetitle', 34 'pagetitle',
29 t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') 35 t('Manage tags') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
30 ); 36 );
31 37
32 return $response->write($this->render(TemplatePage::CHANGE_TAG)); 38 return $response->write($this->render(TemplatePage::CHANGE_TAG));
@@ -81,8 +87,35 @@ class ManageTagController extends ShaarliAdminController
81 87
82 $this->saveSuccessMessage($alert); 88 $this->saveSuccessMessage($alert);
83 89
84 $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag); 90 $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags=' . urlencode($toTag);
85 91
86 return $this->redirect($response, $redirect); 92 return $this->redirect($response, $redirect);
87 } 93 }
94
95 /**
96 * POST /admin/tags/change-separator - Change tag separator
97 */
98 public function changeSeparator(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 $reservedCharacters = ['-', '.', '*'];
103 $newSeparator = $request->getParam('separator');
104 if ($newSeparator === null || mb_strlen($newSeparator) !== 1) {
105 $this->saveErrorMessage(t('Tags separator must be a single character.'));
106 } elseif (in_array($newSeparator, $reservedCharacters, true)) {
107 $reservedCharacters = implode(' ', array_map(function (string $character) {
108 return '<code>' . $character . '</code>';
109 }, $reservedCharacters));
110 $this->saveErrorMessage(
111 t('These characters are reserved and can\'t be used as tags separator: ') . $reservedCharacters
112 );
113 } else {
114 $this->container->conf->set('general.tags_separator', $newSeparator, true, true);
115
116 $this->saveSuccessMessage('Your tags separator setting has been updated!');
117 }
118
119 return $this->redirect($response, '/admin/tags');
120 }
88} 121}
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php
new file mode 100644
index 00000000..ff845944
--- /dev/null
+++ b/application/front/controller/admin/MetadataController.php
@@ -0,0 +1,29 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Controller used to retrieve/update bookmark's metadata.
12 */
13class MetadataController extends ShaarliAdminController
14{
15 /**
16 * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
17 */
18 public function ajaxRetrieveTitle(Request $request, Response $response): Response
19 {
20 $url = $request->getParam('url');
21
22 // Only try to extract metadata from URL with HTTP(s) scheme
23 if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
24 return $response->withJson($this->container->metadataRetriever->retrieve($url));
25 }
26
27 return $response->withJson([]);
28 }
29}
diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php
index 5ec0d24b..4aaf1f82 100644
--- a/application/front/controller/admin/PasswordController.php
+++ b/application/front/controller/admin/PasswordController.php
@@ -25,7 +25,7 @@ class PasswordController extends ShaarliAdminController
25 25
26 $this->assignView( 26 $this->assignView(
27 'pagetitle', 27 'pagetitle',
28 t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli') 28 t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
29 ); 29 );
30 } 30 }
31 31
@@ -78,7 +78,7 @@ class PasswordController extends ShaarliAdminController
78 78
79 // Save new password 79 // Save new password
80 // Salt renders rainbow-tables attacks useless. 80 // Salt renders rainbow-tables attacks useless.
81 $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); 81 $this->container->conf->set('credentials.salt', sha1(uniqid('', true) . '_' . mt_rand()));
82 $this->container->conf->set( 82 $this->container->conf->set(
83 'credentials.hash', 83 'credentials.hash',
84 sha1( 84 sha1(
diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php
index 8e059681..ae47c1af 100644
--- a/application/front/controller/admin/PluginsController.php
+++ b/application/front/controller/admin/PluginsController.php
@@ -42,7 +42,7 @@ class PluginsController extends ShaarliAdminController
42 $this->assignView('disabledPlugins', $disabledPlugins); 42 $this->assignView('disabledPlugins', $disabledPlugins);
43 $this->assignView( 43 $this->assignView(
44 'pagetitle', 44 'pagetitle',
45 t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') 45 t('Plugin Administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
46 ); 46 );
47 47
48 return $response->write($this->render(TemplatePage::PLUGINS_ADMIN)); 48 return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
@@ -64,7 +64,7 @@ class PluginsController extends ShaarliAdminController
64 unset($parameters['parameters_form']); 64 unset($parameters['parameters_form']);
65 unset($parameters['token']); 65 unset($parameters['token']);
66 foreach ($parameters as $param => $value) { 66 foreach ($parameters as $param => $value) {
67 $this->container->conf->set('plugins.'. $param, escape($value)); 67 $this->container->conf->set('plugins.' . $param, escape($value));
68 } 68 }
69 } else { 69 } else {
70 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters)); 70 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));
diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php
new file mode 100644
index 00000000..fabeaf2f
--- /dev/null
+++ b/application/front/controller/admin/ServerController.php
@@ -0,0 +1,96 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Helper\ApplicationUtils;
8use Shaarli\Helper\FileUtils;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Slim controller used to handle Server administration page, and actions.
14 */
15class ServerController extends ShaarliAdminController
16{
17 /** @var string Cache type - main - by default pagecache/ and tmp/ */
18 protected const CACHE_MAIN = 'main';
19
20 /** @var string Cache type - thumbnails - by default cache/ */
21 protected const CACHE_THUMB = 'thumbnails';
22
23 /**
24 * GET /admin/server - Display page Server administration
25 */
26 public function index(Request $request, Response $response): Response
27 {
28 $releaseUrl = ApplicationUtils::$GITHUB_URL . '/releases/';
29 if ($this->container->conf->get('updates.check_updates', true)) {
30 $latestVersion = 'v' . ApplicationUtils::getVersion(
31 ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE
32 );
33 $releaseUrl .= 'tag/' . $latestVersion;
34 } else {
35 $latestVersion = t('Check disabled');
36 }
37
38 $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php');
39 $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion;
40 $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
41
42 $this->assignView('php_version', PHP_VERSION);
43 $this->assignView('php_eol', format_date($phpEol, false));
44 $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
45 $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
46 $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
47 $this->assignView('release_url', $releaseUrl);
48 $this->assignView('latest_version', $latestVersion);
49 $this->assignView('current_version', $currentVersion);
50 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode'));
51 $this->assignView('index_url', index_url($this->container->environment));
52 $this->assignView('client_ip', client_ip_id($this->container->environment));
53 $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', []));
54
55 $this->assignView(
56 'pagetitle',
57 t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
58 );
59
60 return $response->write($this->render('server'));
61 }
62
63 /**
64 * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails).
65 */
66 public function clearCache(Request $request, Response $response): Response
67 {
68 $exclude = ['.htaccess'];
69
70 if ($request->getQueryParam('type') === static::CACHE_THUMB) {
71 $folders = [$this->container->conf->get('resource.thumbnails_cache')];
72
73 $this->saveWarningMessage(
74 t('Thumbnails cache has been cleared.') . ' ' .
75 '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
76 t('Please synchronize them.') .
77 '</a>'
78 );
79 } else {
80 $folders = [
81 $this->container->conf->get('resource.page_cache'),
82 $this->container->conf->get('resource.raintpl_tmp'),
83 ];
84
85 $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!'));
86 }
87
88 // Make sure that we don't delete root cache folder
89 $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders))));
90 foreach ($folders as $folder) {
91 FileUtils::clearFolder($folder, false, $exclude);
92 }
93
94 return $this->redirect($response, '/admin/server');
95 }
96}
diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php
index d9a7a2e0..0917b6d2 100644
--- a/application/front/controller/admin/SessionFilterController.php
+++ b/application/front/controller/admin/SessionFilterController.php
@@ -45,6 +45,4 @@ class SessionFilterController extends ShaarliAdminController
45 45
46 return $this->redirectFromReferer($request, $response, ['visibility']); 46 return $this->redirectFromReferer($request, $response, ['visibility']);
47 } 47 }
48
49
50} 48}
diff --git a/application/front/controller/admin/ShaareAddController.php b/application/front/controller/admin/ShaareAddController.php
new file mode 100644
index 00000000..ab8e7f40
--- /dev/null
+++ b/application/front/controller/admin/ShaareAddController.php
@@ -0,0 +1,34 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Formatter\BookmarkMarkdownFormatter;
8use Shaarli\Render\TemplatePage;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12class ShaareAddController extends ShaarliAdminController
13{
14 /**
15 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
16 */
17 public function addShaare(Request $request, Response $response): Response
18 {
19 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
20 if ($this->container->conf->get('formatter') === 'markdown') {
21 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
22 }
23
24 $this->assignView(
25 'pagetitle',
26 t('Shaare a new link') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
27 );
28 $this->assignView('tags', $tags);
29 $this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false));
30 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34}
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php
new file mode 100644
index 00000000..35837baa
--- /dev/null
+++ b/application/front/controller/admin/ShaareManageController.php
@@ -0,0 +1,202 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class PostBookmarkController
13 *
14 * Slim controller used to handle Shaarli create or edit bookmarks.
15 */
16class ShaareManageController extends ShaarliAdminController
17{
18 /**
19 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
20 */
21 public function deleteBookmark(Request $request, Response $response): Response
22 {
23 $this->checkToken($request);
24
25 $ids = escape(trim($request->getParam('id') ?? ''));
26 if (empty($ids) || strpos($ids, ' ') !== false) {
27 // multiple, space-separated ids provided
28 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
29 } else {
30 $ids = [$ids];
31 }
32
33 // assert at least one id is given
34 if (0 === count($ids)) {
35 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
36
37 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
38 }
39
40 $formatter = $this->container->formatterFactory->getFormatter('raw');
41 $count = 0;
42 foreach ($ids as $id) {
43 try {
44 $bookmark = $this->container->bookmarkService->get((int) $id);
45 } catch (BookmarkNotFoundException $e) {
46 $this->saveErrorMessage(sprintf(
47 t('Bookmark with identifier %s could not be found.'),
48 $id
49 ));
50
51 continue;
52 }
53
54 $data = $formatter->format($bookmark);
55 $this->executePageHooks('delete_link', $data);
56 $this->container->bookmarkService->remove($bookmark, false);
57 ++$count;
58 }
59
60 if ($count > 0) {
61 $this->container->bookmarkService->save();
62 }
63
64 // If we are called from the bookmarklet, we must close the popup:
65 if ($request->getParam('source') === 'bookmarklet') {
66 return $response->write('<script>self.close();</script>');
67 }
68
69 // Don't redirect to permalink after deletion.
70 return $this->redirectFromReferer($request, $response, ['shaare/']);
71 }
72
73 /**
74 * GET /admin/shaare/visibility
75 *
76 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
77 */
78 public function changeVisibility(Request $request, Response $response): Response
79 {
80 $this->checkToken($request);
81
82 $ids = trim(escape($request->getParam('id') ?? ''));
83 if (empty($ids) || strpos($ids, ' ') !== false) {
84 // multiple, space-separated ids provided
85 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
86 } else {
87 // only a single id provided
88 $ids = [$ids];
89 }
90
91 // assert at least one id is given
92 if (0 === count($ids)) {
93 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
94
95 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
96 }
97
98 // assert that the visibility is valid
99 $visibility = $request->getParam('newVisibility');
100 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
101 $this->saveErrorMessage(t('Invalid visibility provided.'));
102
103 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
104 } else {
105 $isPrivate = $visibility === 'private';
106 }
107
108 $formatter = $this->container->formatterFactory->getFormatter('raw');
109 $count = 0;
110
111 foreach ($ids as $id) {
112 try {
113 $bookmark = $this->container->bookmarkService->get((int) $id);
114 } catch (BookmarkNotFoundException $e) {
115 $this->saveErrorMessage(sprintf(
116 t('Bookmark with identifier %s could not be found.'),
117 $id
118 ));
119
120 continue;
121 }
122
123 $bookmark->setPrivate($isPrivate);
124
125 // To preserve backward compatibility with 3rd parties, plugins still use arrays
126 $data = $formatter->format($bookmark);
127 $this->executePageHooks('save_link', $data);
128 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
129
130 $this->container->bookmarkService->set($bookmark, false);
131 ++$count;
132 }
133
134 if ($count > 0) {
135 $this->container->bookmarkService->save();
136 }
137
138 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
139 }
140
141 /**
142 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
143 */
144 public function pinBookmark(Request $request, Response $response, array $args): Response
145 {
146 $this->checkToken($request);
147
148 $id = $args['id'] ?? '';
149 try {
150 if (false === ctype_digit($id)) {
151 throw new BookmarkNotFoundException();
152 }
153 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
154 } catch (BookmarkNotFoundException $e) {
155 $this->saveErrorMessage(sprintf(
156 t('Bookmark with identifier %s could not be found.'),
157 $id
158 ));
159
160 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
161 }
162
163 $formatter = $this->container->formatterFactory->getFormatter('raw');
164
165 $bookmark->setSticky(!$bookmark->isSticky());
166
167 // To preserve backward compatibility with 3rd parties, plugins still use arrays
168 $data = $formatter->format($bookmark);
169 $this->executePageHooks('save_link', $data);
170 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
171
172 $this->container->bookmarkService->set($bookmark);
173
174 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
175 }
176
177 /**
178 * GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL.
179 */
180 public function sharePrivate(Request $request, Response $response, array $args): Response
181 {
182 $this->checkToken($request);
183
184 $hash = $args['hash'] ?? '';
185 $bookmark = $this->container->bookmarkService->findByHash($hash);
186
187 if ($bookmark->isPrivate() !== true) {
188 return $this->redirect($response, '/shaare/' . $hash);
189 }
190
191 if (empty($bookmark->getAdditionalContentEntry('private_key'))) {
192 $privateKey = bin2hex(random_bytes(16));
193 $bookmark->addAdditionalContentEntry('private_key', $privateKey);
194 $this->container->bookmarkService->set($bookmark);
195 }
196
197 return $this->redirect(
198 $response,
199 '/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key')
200 );
201 }
202}
diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php
new file mode 100644
index 00000000..4cbfcdc5
--- /dev/null
+++ b/application/front/controller/admin/ShaarePublishController.php
@@ -0,0 +1,274 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkFormatter;
10use Shaarli\Formatter\BookmarkMarkdownFormatter;
11use Shaarli\Render\TemplatePage;
12use Shaarli\Thumbnailer;
13use Slim\Http\Request;
14use Slim\Http\Response;
15
16class ShaarePublishController extends ShaarliAdminController
17{
18 /**
19 * @var BookmarkFormatter[] Statically cached instances of formatters
20 */
21 protected $formatters = [];
22
23 /**
24 * @var array Statically cached bookmark's tags counts
25 */
26 protected $tags;
27
28 /**
29 * GET /admin/shaare - Displays the bookmark form for creation.
30 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
31 */
32 public function displayCreateForm(Request $request, Response $response): Response
33 {
34 $url = cleanup_url($request->getParam('post'));
35 $link = $this->buildLinkDataFromUrl($request, $url);
36
37 return $this->displayForm($link, $link['linkIsNew'], $request, $response);
38 }
39
40 /**
41 * POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page.
42 */
43 public function displayCreateBatchForms(Request $request, Response $response): Response
44 {
45 $urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls')));
46
47 $links = [];
48 foreach ($urls as $url) {
49 if (empty($url)) {
50 continue;
51 }
52 $link = $this->buildLinkDataFromUrl($request, $url);
53 $data = $this->buildFormData($link, $link['linkIsNew'], $request);
54 $data['token'] = $this->container->sessionManager->generateToken();
55 $data['source'] = 'batch';
56
57 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
58
59 $links[] = $data;
60 }
61
62 $this->assignView('links', $links);
63 $this->assignView('batch_mode', true);
64 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
65
66 return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH));
67 }
68
69 /**
70 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
71 */
72 public function displayEditForm(Request $request, Response $response, array $args): Response
73 {
74 $id = $args['id'] ?? '';
75 try {
76 if (false === ctype_digit($id)) {
77 throw new BookmarkNotFoundException();
78 }
79 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
80 } catch (BookmarkNotFoundException $e) {
81 $this->saveErrorMessage(sprintf(
82 t('Bookmark with identifier %s could not be found.'),
83 $id
84 ));
85
86 return $this->redirect($response, '/');
87 }
88
89 $formatter = $this->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91
92 return $this->displayForm($link, false, $request, $response);
93 }
94
95 /**
96 * POST /admin/shaare
97 */
98 public function save(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 // lf_id should only be present if the link exists.
103 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
104 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
105 // Edit
106 $bookmark = $this->container->bookmarkService->get($id);
107 } else {
108 // New link
109 $bookmark = new Bookmark();
110 }
111
112 $bookmark->setTitle($request->getParam('lf_title'));
113 $bookmark->setDescription($request->getParam('lf_description'));
114 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
115 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
116 $bookmark->setTagsString(
117 $request->getParam('lf_tags'),
118 $this->container->conf->get('general.tags_separator', ' ')
119 );
120
121 if (
122 $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
123 && true !== $this->container->conf->get('general.enable_async_metadata', true)
124 && $bookmark->shouldUpdateThumbnail()
125 ) {
126 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
127 }
128 $this->container->bookmarkService->addOrSet($bookmark, false);
129
130 // To preserve backward compatibility with 3rd parties, plugins still use arrays
131 $formatter = $this->getFormatter('raw');
132 $data = $formatter->format($bookmark);
133 $this->executePageHooks('save_link', $data);
134
135 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
136 $this->container->bookmarkService->set($bookmark);
137
138 // If we are called from the bookmarklet, we must close the popup:
139 if ($request->getParam('source') === 'bookmarklet') {
140 return $response->write('<script>self.close();</script>');
141 } elseif ($request->getParam('source') === 'batch') {
142 return $response;
143 }
144
145 if (!empty($request->getParam('returnurl'))) {
146 $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
147 }
148
149 return $this->redirectFromReferer(
150 $request,
151 $response,
152 ['/admin/add-shaare', '/admin/shaare'],
153 ['addlink', 'post', 'edit_link'],
154 $bookmark->getShortUrl()
155 );
156 }
157
158 /**
159 * Helper function used to display the shaare form whether it's a new or existing bookmark.
160 *
161 * @param array $link data used in template, either from parameters or from the data store
162 */
163 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
164 {
165 $data = $this->buildFormData($link, $isNew, $request);
166
167 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
168
169 foreach ($data as $key => $value) {
170 $this->assignView($key, $value);
171 }
172
173 $editLabel = false === $isNew ? t('Edit') . ' ' : '';
174 $this->assignView(
175 'pagetitle',
176 $editLabel . t('Shaare') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
177 );
178
179 return $response->write($this->render(TemplatePage::EDIT_LINK));
180 }
181
182 protected function buildLinkDataFromUrl(Request $request, string $url): array
183 {
184 // Check if URL is not already in database (in this case, we will edit the existing link)
185 $bookmark = $this->container->bookmarkService->findByUrl($url);
186 if (null === $bookmark) {
187 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
188 $title = $request->getParam('title');
189 $description = $request->getParam('description');
190 $tags = $request->getParam('tags');
191 if ($request->getParam('private') !== null) {
192 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
193 } else {
194 $private = $this->container->conf->get('privacy.default_private_links', false);
195 }
196
197 // If this is an HTTP(S) link, we try go get the page to extract
198 // the title (otherwise we will to straight to the edit form.)
199 if (
200 true !== $this->container->conf->get('general.enable_async_metadata', true)
201 && empty($title)
202 && strpos(get_url_scheme($url) ?: '', 'http') !== false
203 ) {
204 $metadata = $this->container->metadataRetriever->retrieve($url);
205 }
206
207 if (empty($url)) {
208 $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
209 }
210
211 return [
212 'title' => $title ?? $metadata['title'] ?? '',
213 'url' => $url ?? '',
214 'description' => $description ?? $metadata['description'] ?? '',
215 'tags' => $tags ?? $metadata['tags'] ?? '',
216 'private' => $private,
217 'linkIsNew' => true,
218 ];
219 }
220
221 $formatter = $this->getFormatter('raw');
222 $link = $formatter->format($bookmark);
223 $link['linkIsNew'] = false;
224
225 return $link;
226 }
227
228 protected function buildFormData(array $link, bool $isNew, Request $request): array
229 {
230 $link['tags'] = strlen($link['tags']) > 0
231 ? $link['tags'] . $this->container->conf->get('general.tags_separator', ' ')
232 : $link['tags']
233 ;
234
235 return escape([
236 'link' => $link,
237 'link_is_new' => $isNew,
238 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
239 'source' => $request->getParam('source') ?? '',
240 'tags' => $this->getTags(),
241 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
242 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
243 'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
244 ]);
245 }
246
247 /**
248 * Memoize formatterFactory->getFormatter() calls.
249 */
250 protected function getFormatter(string $type): BookmarkFormatter
251 {
252 if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) {
253 $this->formatters[$type] = $this->container->formatterFactory->getFormatter($type);
254 }
255
256 return $this->formatters[$type];
257 }
258
259 /**
260 * Memoize bookmarkService->bookmarksCountPerTag() calls.
261 */
262 protected function getTags(): array
263 {
264 if ($this->tags === null) {
265 $this->tags = $this->container->bookmarkService->bookmarksCountPerTag();
266
267 if ($this->container->conf->get('formatter') === 'markdown') {
268 $this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
269 }
270 }
271
272 return $this->tags;
273 }
274}
diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php
index 81c87ed0..94d97d4b 100644
--- a/application/front/controller/admin/ThumbnailsController.php
+++ b/application/front/controller/admin/ThumbnailsController.php
@@ -34,7 +34,7 @@ class ThumbnailsController extends ShaarliAdminController
34 $this->assignView('ids', $ids); 34 $this->assignView('ids', $ids);
35 $this->assignView( 35 $this->assignView(
36 'pagetitle', 36 'pagetitle',
37 t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli') 37 t('Thumbnails update') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
38 ); 38 );
39 39
40 return $response->write($this->render(TemplatePage::THUMBNAILS)); 40 return $response->write($this->render(TemplatePage::THUMBNAILS));
@@ -52,7 +52,7 @@ class ThumbnailsController extends ShaarliAdminController
52 } 52 }
53 53
54 try { 54 try {
55 $bookmark = $this->container->bookmarkService->get($id); 55 $bookmark = $this->container->bookmarkService->get((int) $id);
56 } catch (BookmarkNotFoundException $e) { 56 } catch (BookmarkNotFoundException $e) {
57 return $response->withStatus(404); 57 return $response->withStatus(404);
58 } 58 }
diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php
index a87f20d2..560e5e3e 100644
--- a/application/front/controller/admin/ToolsController.php
+++ b/application/front/controller/admin/ToolsController.php
@@ -28,7 +28,7 @@ class ToolsController extends ShaarliAdminController
28 $this->assignView($key, $value); 28 $this->assignView($key, $value);
29 } 29 }
30 30
31 $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 31 $this->assignView('pagetitle', t('Tools') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
32 32
33 return $response->write($this->render(TemplatePage::TOOLS)); 33 return $response->write($this->render(TemplatePage::TOOLS));
34 } 34 }
diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php
index 18368751..fe8231be 100644
--- a/application/front/controller/visitor/BookmarkListController.php
+++ b/application/front/controller/visitor/BookmarkListController.php
@@ -35,7 +35,8 @@ class BookmarkListController extends ShaarliVisitorController
35 $formatter->addContextData('base_path', $this->container->basePath); 35 $formatter->addContextData('base_path', $this->container->basePath);
36 36
37 $searchTags = normalize_spaces($request->getParam('searchtags') ?? ''); 37 $searchTags = normalize_spaces($request->getParam('searchtags') ?? '');
38 $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; 38 $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));
39 ;
39 40
40 // Filter bookmarks according search parameters. 41 // Filter bookmarks according search parameters.
41 $visibility = $this->container->sessionManager->getSessionParameter('visibility'); 42 $visibility = $this->container->sessionManager->getSessionParameter('visibility');
@@ -95,6 +96,10 @@ class BookmarkListController extends ShaarliVisitorController
95 $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl; 96 $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
96 } 97 }
97 98
99 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
100 $searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator));
101 $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
102
98 // Fill all template fields. 103 // Fill all template fields.
99 $data = array_merge( 104 $data = array_merge(
100 $this->initializeTemplateVars(), 105 $this->initializeTemplateVars(),
@@ -106,7 +111,7 @@ class BookmarkListController extends ShaarliVisitorController
106 'result_count' => count($linksToDisplay), 111 'result_count' => count($linksToDisplay),
107 'search_term' => escape($searchTerm), 112 'search_term' => escape($searchTerm),
108 'search_tags' => escape($searchTags), 113 'search_tags' => escape($searchTags),
109 'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)), 114 'search_tags_url' => $searchTagsUrlEncoded,
110 'visibility' => $visibility, 115 'visibility' => $visibility,
111 'links' => $linkDisp, 116 'links' => $linkDisp,
112 ] 117 ]
@@ -119,8 +124,9 @@ class BookmarkListController extends ShaarliVisitorController
119 return '[' . $tag . ']'; 124 return '[' . $tag . ']';
120 }; 125 };
121 $data['pagetitle'] .= ! empty($searchTags) 126 $data['pagetitle'] .= ! empty($searchTags)
122 ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' ' 127 ? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
123 : ''; 128 : ''
129 ;
124 $data['pagetitle'] .= '- '; 130 $data['pagetitle'] .= '- ';
125 } 131 }
126 132
@@ -137,8 +143,10 @@ class BookmarkListController extends ShaarliVisitorController
137 */ 143 */
138 public function permalink(Request $request, Response $response, array $args): Response 144 public function permalink(Request $request, Response $response, array $args): Response
139 { 145 {
146 $privateKey = $request->getParam('key');
147
140 try { 148 try {
141 $bookmark = $this->container->bookmarkService->findByHash($args['hash']); 149 $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
142 } catch (BookmarkNotFoundException $e) { 150 } catch (BookmarkNotFoundException $e) {
143 $this->assignView('error_message', $e->getMessage()); 151 $this->assignView('error_message', $e->getMessage());
144 152
@@ -153,7 +161,7 @@ class BookmarkListController extends ShaarliVisitorController
153 $data = array_merge( 161 $data = array_merge(
154 $this->initializeTemplateVars(), 162 $this->initializeTemplateVars(),
155 [ 163 [
156 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'), 164 'pagetitle' => $bookmark->getTitle() . ' - ' . $this->container->conf->get('general.title', 'Shaarli'),
157 'links' => [$formatter->format($bookmark)], 165 'links' => [$formatter->format($bookmark)],
158 ] 166 ]
159 ); 167 );
@@ -169,19 +177,25 @@ class BookmarkListController extends ShaarliVisitorController
169 */ 177 */
170 protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool 178 protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
171 { 179 {
172 // Logged in, thumbnails enabled, not a note, is HTTP 180 if (false === $this->container->loginManager->isLoggedIn()) {
173 // and (never retrieved yet or no valid cache file) 181 return false;
174 if ($this->container->loginManager->isLoggedIn() 182 }
175 && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE 183
176 && false !== $bookmark->getThumbnail() 184 // If thumbnail should be updated, we reset it to null
177 && !$bookmark->isNote() 185 if ($bookmark->shouldUpdateThumbnail()) {
178 && (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail())) 186 $bookmark->setThumbnail(null);
179 && startsWith(strtolower($bookmark->getUrl()), 'http') 187
180 ) { 188 // Requires an update, not async retrieval, thumbnails enabled
181 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); 189 if (
182 $this->container->bookmarkService->set($bookmark, $writeDatastore); 190 $bookmark->shouldUpdateThumbnail()
183 191 && true !== $this->container->conf->get('general.enable_async_metadata', true)
184 return true; 192 && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
193 ) {
194 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
195 $this->container->bookmarkService->set($bookmark, $writeDatastore);
196
197 return true;
198 }
185 } 199 }
186 200
187 return false; 201 return false;
@@ -198,6 +212,7 @@ class BookmarkListController extends ShaarliVisitorController
198 'page_max' => '', 212 'page_max' => '',
199 'search_tags' => '', 213 'search_tags' => '',
200 'result_count' => '', 214 'result_count' => '',
215 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
201 ]; 216 ];
202 } 217 }
203 218
diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php
index 07617cf1..846cfe22 100644
--- a/application/front/controller/visitor/DailyController.php
+++ b/application/front/controller/visitor/DailyController.php
@@ -5,8 +5,8 @@ declare(strict_types=1);
5namespace Shaarli\Front\Controller\Visitor; 5namespace Shaarli\Front\Controller\Visitor;
6 6
7use DateTime; 7use DateTime;
8use DateTimeImmutable;
9use Shaarli\Bookmark\Bookmark; 8use Shaarli\Bookmark\Bookmark;
9use Shaarli\Helper\DailyPageHelper;
10use Shaarli\Render\TemplatePage; 10use Shaarli\Render\TemplatePage;
11use Slim\Http\Request; 11use Slim\Http\Request;
12use Slim\Http\Response; 12use Slim\Http\Response;
@@ -26,32 +26,20 @@ class DailyController extends ShaarliVisitorController
26 */ 26 */
27 public function index(Request $request, Response $response): Response 27 public function index(Request $request, Response $response): Response
28 { 28 {
29 $day = $request->getQueryParam('day') ?? date('Ymd'); 29 $type = DailyPageHelper::extractRequestedType($request);
30 30 $format = DailyPageHelper::getFormatByType($type);
31 $availableDates = $this->container->bookmarkService->days(); 31 $latestBookmark = $this->container->bookmarkService->getLatest();
32 $nbAvailableDates = count($availableDates); 32 $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
33 $index = array_search($day, $availableDates); 33 $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
34 34 $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
35 if ($index === false) { 35 $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
36 // no bookmarks for day, but at least one day with bookmarks 36
37 $day = $availableDates[$nbAvailableDates - 1] ?? $day; 37 $linksToDisplay = $this->container->bookmarkService->findByDate(
38 $previousDay = $availableDates[$nbAvailableDates - 2] ?? ''; 38 $start,
39 } else { 39 $end,
40 $previousDay = $availableDates[$index - 1] ?? ''; 40 $previousDay,
41 $nextDay = $availableDates[$index + 1] ?? ''; 41 $nextDay
42 } 42 );
43
44 if ($day === date('Ymd')) {
45 $this->assignView('dayDesc', t('Today'));
46 } elseif ($day === date('Ymd', strtotime('-1 days'))) {
47 $this->assignView('dayDesc', t('Yesterday'));
48 }
49
50 try {
51 $linksToDisplay = $this->container->bookmarkService->filterDay($day);
52 } catch (\Exception $exc) {
53 $linksToDisplay = [];
54 }
55 43
56 $formatter = $this->container->formatterFactory->getFormatter(); 44 $formatter = $this->container->formatterFactory->getFormatter();
57 $formatter->addContextData('base_path', $this->container->basePath); 45 $formatter->addContextData('base_path', $this->container->basePath);
@@ -63,13 +51,15 @@ class DailyController extends ShaarliVisitorController
63 $linksToDisplay[$key]['description'] = $bookmark->getDescription(); 51 $linksToDisplay[$key]['description'] = $bookmark->getDescription();
64 } 52 }
65 53
66 $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
67 $data = [ 54 $data = [
68 'linksToDisplay' => $linksToDisplay, 55 'linksToDisplay' => $linksToDisplay,
69 'day' => $dayDate->getTimestamp(), 56 'dayDate' => $start,
70 'dayDate' => $dayDate, 57 'day' => $start->getTimestamp(),
71 'previousday' => $previousDay ?? '', 58 'previousday' => $previousDay ? $previousDay->format($format) : '',
72 'nextday' => $nextDay ?? '', 59 'nextday' => $nextDay ? $nextDay->format($format) : '',
60 'dayDesc' => $dailyDesc,
61 'type' => $type,
62 'localizedType' => $this->translateType($type),
73 ]; 63 ];
74 64
75 // Hooks are called before column construction so that plugins don't have to deal with columns. 65 // Hooks are called before column construction so that plugins don't have to deal with columns.
@@ -82,7 +72,7 @@ class DailyController extends ShaarliVisitorController
82 $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); 72 $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
83 $this->assignView( 73 $this->assignView(
84 'pagetitle', 74 'pagetitle',
85 t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle 75 $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
86 ); 76 );
87 77
88 return $response->write($this->render(TemplatePage::DAILY)); 78 return $response->write($this->render(TemplatePage::DAILY));
@@ -106,11 +96,14 @@ class DailyController extends ShaarliVisitorController
106 } 96 }
107 97
108 $days = []; 98 $days = [];
99 $type = DailyPageHelper::extractRequestedType($request);
100 $format = DailyPageHelper::getFormatByType($type);
101 $length = DailyPageHelper::getRssLengthByType($type);
109 foreach ($this->container->bookmarkService->search() as $bookmark) { 102 foreach ($this->container->bookmarkService->search() as $bookmark) {
110 $day = $bookmark->getCreated()->format('Ymd'); 103 $day = $bookmark->getCreated()->format($format);
111 104
112 // Stop iterating after DAILY_RSS_NB_DAYS entries 105 // Stop iterating after DAILY_RSS_NB_DAYS entries
113 if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) { 106 if (count($days) === $length && !isset($days[$day])) {
114 break; 107 break;
115 } 108 }
116 109
@@ -127,12 +120,19 @@ class DailyController extends ShaarliVisitorController
127 120
128 /** @var Bookmark[] $bookmarks */ 121 /** @var Bookmark[] $bookmarks */
129 foreach ($days as $day => $bookmarks) { 122 foreach ($days as $day => $bookmarks) {
130 $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); 123 $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
124 $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
125
126 // We only want the RSS entry to be published when the period is over.
127 if (new DateTime() < $endDateTime) {
128 continue;
129 }
130
131 $dataPerDay[$day] = [ 131 $dataPerDay[$day] = [
132 'date' => $dayDatetime, 132 'date' => $endDateTime,
133 'date_rss' => $dayDatetime->format(DateTime::RSS), 133 'date_rss' => $endDateTime->format(DateTime::RSS),
134 'date_human' => format_date($dayDatetime, false, true), 134 'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime),
135 'absolute_url' => $indexUrl . 'daily?day=' . $day, 135 'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
136 'links' => [], 136 'links' => [],
137 ]; 137 ];
138 138
@@ -141,16 +141,20 @@ class DailyController extends ShaarliVisitorController
141 141
142 // Make permalink URL absolute 142 // Make permalink URL absolute
143 if ($bookmark->isNote()) { 143 if ($bookmark->isNote()) {
144 $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl(); 144 $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
145 } 145 }
146 } 146 }
147 } 147 }
148 148
149 $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); 149 $this->assignAllView([
150 $this->assignView('index_url', $indexUrl); 150 'title' => $this->container->conf->get('general.title', 'Shaarli'),
151 $this->assignView('page_url', $pageUrl); 151 'index_url' => $indexUrl,
152 $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); 152 'page_url' => $pageUrl,
153 $this->assignView('days', $dataPerDay); 153 'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
154 'days' => $dataPerDay,
155 'type' => $type,
156 'localizedType' => $this->translateType($type),
157 ]);
154 158
155 $rssContent = $this->render(TemplatePage::DAILY_RSS); 159 $rssContent = $this->render(TemplatePage::DAILY_RSS);
156 160
@@ -189,4 +193,13 @@ class DailyController extends ShaarliVisitorController
189 193
190 return $columns; 194 return $columns;
191 } 195 }
196
197 protected function translateType($type): string
198 {
199 return [
200 t('day') => t('Daily'),
201 t('week') => t('Weekly'),
202 t('month') => t('Monthly'),
203 ][t($type)] ?? t('Daily');
204 }
192} 205}
diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php
index 10aa84c8..428e8254 100644
--- a/application/front/controller/visitor/ErrorController.php
+++ b/application/front/controller/visitor/ErrorController.php
@@ -26,12 +26,15 @@ class ErrorController extends ShaarliVisitorController
26 $response = $response->withStatus($throwable->getCode()); 26 $response = $response->withStatus($throwable->getCode());
27 } else { 27 } else {
28 // Internal error (any other Throwable) 28 // Internal error (any other Throwable)
29 if ($this->container->conf->get('dev.debug', false)) { 29 if ($this->container->conf->get('dev.debug', false) || $this->container->loginManager->isLoggedIn()) {
30 $this->assignView('message', $throwable->getMessage()); 30 $this->assignView('message', t('Error: ') . $throwable->getMessage());
31 $this->assignView( 31 $this->assignView(
32 'stacktrace', 32 'text',
33 nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString()) 33 '<a href="https://github.com/shaarli/Shaarli/issues/new">'
34 . t('Please report it on Github.')
35 . '</a>'
34 ); 36 );
37 $this->assignView('stacktrace', exception2text($throwable));
35 } else { 38 } else {
36 $this->assignView('message', t('An unexpected error occurred.')); 39 $this->assignView('message', t('An unexpected error occurred.'));
37 } 40 }
@@ -39,7 +42,6 @@ class ErrorController extends ShaarliVisitorController
39 $response = $response->withStatus(500); 42 $response = $response->withStatus(500);
40 } 43 }
41 44
42
43 return $response->write($this->render('error')); 45 return $response->write($this->render('error'));
44 } 46 }
45} 47}
diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php
index 8d8b546a..edc7ef43 100644
--- a/application/front/controller/visitor/FeedController.php
+++ b/application/front/controller/visitor/FeedController.php
@@ -27,7 +27,7 @@ class FeedController extends ShaarliVisitorController
27 27
28 protected function processRequest(string $feedType, Request $request, Response $response): Response 28 protected function processRequest(string $feedType, Request $request, Response $response): Response
29 { 29 {
30 $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); 30 $response = $response->withHeader('Content-Type', 'application/' . $feedType . '+xml; charset=utf-8');
31 31
32 $pageUrl = page_url($this->container->environment); 32 $pageUrl = page_url($this->container->environment);
33 $cache = $this->container->pageCacheManager->getCachePage($pageUrl); 33 $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php
index 7cb32777..bf965929 100644
--- a/application/front/controller/visitor/InstallController.php
+++ b/application/front/controller/visitor/InstallController.php
@@ -4,10 +4,10 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Front\Controller\Visitor; 5namespace Shaarli\Front\Controller\Visitor;
6 6
7use Shaarli\ApplicationUtils;
8use Shaarli\Container\ShaarliContainer; 7use Shaarli\Container\ShaarliContainer;
9use Shaarli\Front\Exception\AlreadyInstalledException; 8use Shaarli\Front\Exception\AlreadyInstalledException;
10use Shaarli\Front\Exception\ResourcePermissionException; 9use Shaarli\Front\Exception\ResourcePermissionException;
10use Shaarli\Helper\ApplicationUtils;
11use Shaarli\Languages; 11use Shaarli\Languages;
12use Shaarli\Security\SessionManager; 12use Shaarli\Security\SessionManager;
13use Slim\Http\Request; 13use Slim\Http\Request;
@@ -39,7 +39,8 @@ class InstallController extends ShaarliVisitorController
39 // Before installation, we'll make sure that permissions are set properly, and sessions are working. 39 // Before installation, we'll make sure that permissions are set properly, and sessions are working.
40 $this->checkPermissions(); 40 $this->checkPermissions();
41 41
42 if (static::SESSION_TEST_VALUE 42 if (
43 static::SESSION_TEST_VALUE
43 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) 44 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
44 ) { 45 ) {
45 $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE); 46 $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
@@ -53,6 +54,16 @@ class InstallController extends ShaarliVisitorController
53 $this->assignView('cities', $cities); 54 $this->assignView('cities', $cities);
54 $this->assignView('languages', Languages::getAvailableLanguages()); 55 $this->assignView('languages', Languages::getAvailableLanguages());
55 56
57 $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
58
59 $this->assignView('php_version', PHP_VERSION);
60 $this->assignView('php_eol', format_date($phpEol, false));
61 $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
62 $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
63 $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
64
65 $this->assignView('pagetitle', t('Install Shaarli'));
66
56 return $response->write($this->render('install')); 67 return $response->write($this->render('install'));
57 } 68 }
58 69
@@ -65,17 +76,18 @@ class InstallController extends ShaarliVisitorController
65 // This part makes sure sessions works correctly. 76 // This part makes sure sessions works correctly.
66 // (Because on some hosts, session.save_path may not be set correctly, 77 // (Because on some hosts, session.save_path may not be set correctly,
67 // or we may not have write access to it.) 78 // or we may not have write access to it.)
68 if (static::SESSION_TEST_VALUE 79 if (
80 static::SESSION_TEST_VALUE
69 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) 81 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
70 ) { 82 ) {
71 // Step 2: Check if data in session is correct. 83 // Step 2: Check if data in session is correct.
72 $msg = t( 84 $msg = t(
73 '<pre>Sessions do not seem to work correctly on your server.<br>'. 85 '<pre>Sessions do not seem to work correctly on your server.<br>' .
74 'Make sure the variable "session.save_path" is set correctly in your PHP config, '. 86 'Make sure the variable "session.save_path" is set correctly in your PHP config, ' .
75 'and that you have write access to it.<br>'. 87 'and that you have write access to it.<br>' .
76 'It currently points to %s.<br>'. 88 'It currently points to %s.<br>' .
77 'On some browsers, accessing your server via a hostname like \'localhost\' '. 89 'On some browsers, accessing your server via a hostname like \'localhost\' ' .
78 'or any custom hostname without a dot causes cookie storage to fail. '. 90 'or any custom hostname without a dot causes cookie storage to fail. ' .
79 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>' 91 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
80 ); 92 );
81 $msg = sprintf($msg, $this->container->sessionManager->getSavePath()); 93 $msg = sprintf($msg, $this->container->sessionManager->getSavePath());
@@ -94,7 +106,8 @@ class InstallController extends ShaarliVisitorController
94 public function save(Request $request, Response $response): Response 106 public function save(Request $request, Response $response): Response
95 { 107 {
96 $timezone = 'UTC'; 108 $timezone = 'UTC';
97 if (!empty($request->getParam('continent')) 109 if (
110 !empty($request->getParam('continent'))
98 && !empty($request->getParam('city')) 111 && !empty($request->getParam('city'))
99 && isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) 112 && isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
100 ) { 113 ) {
@@ -104,7 +117,7 @@ class InstallController extends ShaarliVisitorController
104 117
105 $login = $request->getParam('setlogin'); 118 $login = $request->getParam('setlogin');
106 $this->container->conf->set('credentials.login', $login); 119 $this->container->conf->set('credentials.login', $login);
107 $salt = sha1(uniqid('', true) .'_'. mt_rand()); 120 $salt = sha1(uniqid('', true) . '_' . mt_rand());
108 $this->container->conf->set('credentials.salt', $salt); 121 $this->container->conf->set('credentials.salt', $salt);
109 $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); 122 $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
110 123
@@ -113,7 +126,7 @@ class InstallController extends ShaarliVisitorController
113 } else { 126 } else {
114 $this->container->conf->set( 127 $this->container->conf->set(
115 'general.title', 128 'general.title',
116 'Shared bookmarks on '.escape(index_url($this->container->environment)) 129 'Shared bookmarks on ' . escape(index_url($this->container->environment))
117 ); 130 );
118 } 131 }
119 132
@@ -150,7 +163,7 @@ class InstallController extends ShaarliVisitorController
150 protected function checkPermissions(): bool 163 protected function checkPermissions(): bool
151 { 164 {
152 // Ensure Shaarli has proper access to its resources 165 // Ensure Shaarli has proper access to its resources
153 $errors = ApplicationUtils::checkResourcePermissions($this->container->conf); 166 $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
154 if (empty($errors)) { 167 if (empty($errors)) {
155 return true; 168 return true;
156 } 169 }
diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php
index 121ba40b..4b881535 100644
--- a/application/front/controller/visitor/LoginController.php
+++ b/application/front/controller/visitor/LoginController.php
@@ -43,7 +43,7 @@ class LoginController extends ShaarliVisitorController
43 $this 43 $this
44 ->assignView('returnurl', escape($returnUrl)) 44 ->assignView('returnurl', escape($returnUrl))
45 ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) 45 ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
46 ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) 46 ->assignView('pagetitle', t('Login') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'))
47 ; 47 ;
48 48
49 return $response->write($this->render(TemplatePage::LOGIN)); 49 return $response->write($this->render(TemplatePage::LOGIN));
@@ -64,8 +64,8 @@ class LoginController extends ShaarliVisitorController
64 return $this->redirect($response, '/'); 64 return $this->redirect($response, '/');
65 } 65 }
66 66
67 if (!$this->container->loginManager->checkCredentials( 67 if (
68 $this->container->environment['REMOTE_ADDR'], 68 !$this->container->loginManager->checkCredentials(
69 client_ip_id($this->container->environment), 69 client_ip_id($this->container->environment),
70 $request->getParam('login'), 70 $request->getParam('login'),
71 $request->getParam('password') 71 $request->getParam('password')
@@ -102,7 +102,8 @@ class LoginController extends ShaarliVisitorController
102 */ 102 */
103 protected function checkLoginState(): bool 103 protected function checkLoginState(): bool
104 { 104 {
105 if ($this->container->loginManager->isLoggedIn() 105 if (
106 $this->container->loginManager->isLoggedIn()
106 || $this->container->conf->get('security.open_shaarli', false) 107 || $this->container->conf->get('security.open_shaarli', false)
107 ) { 108 ) {
108 throw new CantLoginException(); 109 throw new CantLoginException();
diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php
index 3c57f8dd..23553ee6 100644
--- a/application/front/controller/visitor/PictureWallController.php
+++ b/application/front/controller/visitor/PictureWallController.php
@@ -26,7 +26,7 @@ class PictureWallController extends ShaarliVisitorController
26 26
27 $this->assignView( 27 $this->assignView(
28 'pagetitle', 28 'pagetitle',
29 t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') 29 t('Picture wall') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
30 ); 30 );
31 31
32 // Optionally filter the results: 32 // Optionally filter the results:
diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php
index 55c075a2..ae946c59 100644
--- a/application/front/controller/visitor/ShaarliVisitorController.php
+++ b/application/front/controller/visitor/ShaarliVisitorController.php
@@ -106,6 +106,7 @@ abstract class ShaarliVisitorController
106 'target' => $template, 106 'target' => $template,
107 'loggedin' => $this->container->loginManager->isLoggedIn(), 107 'loggedin' => $this->container->loginManager->isLoggedIn(),
108 'basePath' => $this->container->basePath, 108 'basePath' => $this->container->basePath,
109 'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath),
109 'bookmarkService' => $this->container->bookmarkService 110 'bookmarkService' => $this->container->bookmarkService
110 ]; 111 ];
111 } 112 }
@@ -143,7 +144,8 @@ abstract class ShaarliVisitorController
143 if (null !== $referer) { 144 if (null !== $referer) {
144 $currentUrl = parse_url($referer); 145 $currentUrl = parse_url($referer);
145 // If the referer is not related to Shaarli instance, redirect to default 146 // If the referer is not related to Shaarli instance, redirect to default
146 if (isset($currentUrl['host']) 147 if (
148 isset($currentUrl['host'])
147 && strpos(index_url($this->container->environment), $currentUrl['host']) === false 149 && strpos(index_url($this->container->environment), $currentUrl['host']) === false
148 ) { 150 ) {
149 return $response->withRedirect($defaultPath); 151 return $response->withRedirect($defaultPath);
@@ -172,7 +174,7 @@ abstract class ShaarliVisitorController
172 } 174 }
173 } 175 }
174 176
175 $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; 177 $queryString = count($params) > 0 ? '?' . http_build_query($params) : '';
176 $anchor = $anchor ? '#' . $anchor : ''; 178 $anchor = $anchor ? '#' . $anchor : '';
177 179
178 return $response->withRedirect($path . $queryString . $anchor); 180 return $response->withRedirect($path . $queryString . $anchor);
diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php
index 76ed7690..46d62779 100644
--- a/application/front/controller/visitor/TagCloudController.php
+++ b/application/front/controller/visitor/TagCloudController.php
@@ -47,13 +47,14 @@ class TagCloudController extends ShaarliVisitorController
47 */ 47 */
48 protected function processRequest(string $type, Request $request, Response $response): Response 48 protected function processRequest(string $type, Request $request, Response $response): Response
49 { 49 {
50 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
50 if ($this->container->loginManager->isLoggedIn() === true) { 51 if ($this->container->loginManager->isLoggedIn() === true) {
51 $visibility = $this->container->sessionManager->getSessionParameter('visibility'); 52 $visibility = $this->container->sessionManager->getSessionParameter('visibility');
52 } 53 }
53 54
54 $sort = $request->getQueryParam('sort'); 55 $sort = $request->getQueryParam('sort');
55 $searchTags = $request->getQueryParam('searchtags'); 56 $searchTags = $request->getQueryParam('searchtags');
56 $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; 57 $filteringTags = $searchTags !== null ? explode($tagsSeparator, $searchTags) : [];
57 58
58 $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); 59 $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
59 60
@@ -71,8 +72,9 @@ class TagCloudController extends ShaarliVisitorController
71 $tagsUrl[escape($tag)] = urlencode((string) $tag); 72 $tagsUrl[escape($tag)] = urlencode((string) $tag);
72 } 73 }
73 74
74 $searchTags = implode(' ', escape($filteringTags)); 75 $searchTags = tags_array2str($filteringTags, $tagsSeparator);
75 $searchTagsUrl = urlencode(implode(' ', $filteringTags)); 76 $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
77 $searchTagsUrl = urlencode($searchTags);
76 $data = [ 78 $data = [
77 'search_tags' => escape($searchTags), 79 'search_tags' => escape($searchTags),
78 'search_tags_url' => $searchTagsUrl, 80 'search_tags_url' => $searchTagsUrl,
@@ -82,10 +84,10 @@ class TagCloudController extends ShaarliVisitorController
82 $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); 84 $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
83 $this->assignAllView($data); 85 $this->assignAllView($data);
84 86
85 $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; 87 $searchTags = !empty($searchTags) ? trim(str_replace($tagsSeparator, ' ', $searchTags)) . ' - ' : '';
86 $this->assignView( 88 $this->assignView(
87 'pagetitle', 89 'pagetitle',
88 $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') 90 $searchTags . t('Tag ' . $type) . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
89 ); 91 );
90 92
91 return $response->write($this->render('tag.' . $type)); 93 return $response->write($this->render('tag.' . $type));
diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php
index de4e7ea2..3aa58542 100644
--- a/application/front/controller/visitor/TagController.php
+++ b/application/front/controller/visitor/TagController.php
@@ -27,7 +27,7 @@ class TagController extends ShaarliVisitorController
27 // In case browser does not send HTTP_REFERER, we search a single tag 27 // In case browser does not send HTTP_REFERER, we search a single tag
28 if (null === $referer) { 28 if (null === $referer) {
29 if (null !== $newTag) { 29 if (null !== $newTag) {
30 return $this->redirect($response, '/?searchtags='. urlencode($newTag)); 30 return $this->redirect($response, '/?searchtags=' . urlencode($newTag));
31 } 31 }
32 32
33 return $this->redirect($response, '/'); 33 return $this->redirect($response, '/');
@@ -37,7 +37,7 @@ class TagController extends ShaarliVisitorController
37 parse_str($currentUrl['query'] ?? '', $params); 37 parse_str($currentUrl['query'] ?? '', $params);
38 38
39 if (null === $newTag) { 39 if (null === $newTag) {
40 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); 40 return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
41 } 41 }
42 42
43 // Prevent redirection loop 43 // Prevent redirection loop
@@ -45,9 +45,10 @@ class TagController extends ShaarliVisitorController
45 unset($params['addtag']); 45 unset($params['addtag']);
46 } 46 }
47 47
48 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
48 // Check if this tag is already in the search query and ignore it if it is. 49 // Check if this tag is already in the search query and ignore it if it is.
49 // Each tag is always separated by a space 50 // Each tag is always separated by a space
50 $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; 51 $currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
51 52
52 $addtag = true; 53 $addtag = true;
53 foreach ($currentTags as $value) { 54 foreach ($currentTags as $value) {
@@ -62,12 +63,12 @@ class TagController extends ShaarliVisitorController
62 $currentTags[] = trim($newTag); 63 $currentTags[] = trim($newTag);
63 } 64 }
64 65
65 $params['searchtags'] = trim(implode(' ', $currentTags)); 66 $params['searchtags'] = tags_array2str($currentTags, $tagsSeparator);
66 67
67 // We also remove page (keeping the same page has no sense, since the results are different) 68 // We also remove page (keeping the same page has no sense, since the results are different)
68 unset($params['page']); 69 unset($params['page']);
69 70
70 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); 71 return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
71 } 72 }
72 73
73 /** 74 /**
@@ -89,7 +90,7 @@ class TagController extends ShaarliVisitorController
89 parse_str($currentUrl['query'] ?? '', $params); 90 parse_str($currentUrl['query'] ?? '', $params);
90 91
91 if (null === $tagToRemove) { 92 if (null === $tagToRemove) {
92 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); 93 return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
93 } 94 }
94 95
95 // Prevent redirection loop 96 // Prevent redirection loop
@@ -98,10 +99,11 @@ class TagController extends ShaarliVisitorController
98 } 99 }
99 100
100 if (isset($params['searchtags'])) { 101 if (isset($params['searchtags'])) {
101 $tags = explode(' ', $params['searchtags']); 102 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
103 $tags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
102 // Remove value from array $tags. 104 // Remove value from array $tags.
103 $tags = array_diff($tags, [$tagToRemove]); 105 $tags = array_diff($tags, [$tagToRemove]);
104 $params['searchtags'] = implode(' ', $tags); 106 $params['searchtags'] = tags_array2str($tags, $tagsSeparator);
105 107
106 if (empty($params['searchtags'])) { 108 if (empty($params['searchtags'])) {
107 unset($params['searchtags']); 109 unset($params['searchtags']);