aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--application/Utils.php4
-rw-r--r--application/bookmark/LinkUtils.php106
-rw-r--r--application/container/ContainerBuilder.php10
-rw-r--r--application/container/ShaarliContainer.php4
-rw-r--r--application/front/controller/admin/PostBookmarkController.php258
-rw-r--r--application/front/controller/admin/ToolsController.php2
-rw-r--r--application/front/controller/visitor/DailyController.php2
-rw-r--r--application/front/controller/visitor/FeedController.php2
-rw-r--r--application/front/controller/visitor/ShaarliVisitorController.php14
-rw-r--r--application/http/HttpAccess.php39
-rw-r--r--application/http/HttpUtils.php106
-rw-r--r--doc/md/Translations.md2
-rw-r--r--index.php180
-rw-r--r--tests/container/ShaarliTestContainer.php4
-rw-r--r--tests/front/controller/admin/PostBookmarkControllerTest.php652
-rw-r--r--tpl/default/addlink.html2
-rw-r--r--tpl/default/editlink.html6
-rw-r--r--tpl/default/page.header.html2
-rw-r--r--tpl/vintage/addlink.html2
-rw-r--r--tpl/vintage/page.header.html4
20 files changed, 1121 insertions, 280 deletions
diff --git a/application/Utils.php b/application/Utils.php
index 72c90049..9c9eaaa2 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -91,6 +91,10 @@ function endsWith($haystack, $needle, $case = true)
91 */ 91 */
92function escape($input) 92function escape($input)
93{ 93{
94 if (null === $input) {
95 return null;
96 }
97
94 if (is_bool($input)) { 98 if (is_bool($input)) {
95 return $input; 99 return $input;
96 } 100 }
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index 98d9038a..68914fca 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -3,112 +3,6 @@
3use Shaarli\Bookmark\Bookmark; 3use 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 */
17function 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.
diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php
index 84406979..85126246 100644
--- a/application/container/ContainerBuilder.php
+++ b/application/container/ContainerBuilder.php
@@ -10,11 +10,13 @@ use Shaarli\Config\ConfigManager;
10use Shaarli\Feed\FeedBuilder; 10use Shaarli\Feed\FeedBuilder;
11use Shaarli\Formatter\FormatterFactory; 11use Shaarli\Formatter\FormatterFactory;
12use Shaarli\History; 12use Shaarli\History;
13use Shaarli\Http\HttpAccess;
13use Shaarli\Plugin\PluginManager; 14use Shaarli\Plugin\PluginManager;
14use Shaarli\Render\PageBuilder; 15use Shaarli\Render\PageBuilder;
15use Shaarli\Render\PageCacheManager; 16use Shaarli\Render\PageCacheManager;
16use Shaarli\Security\LoginManager; 17use Shaarli\Security\LoginManager;
17use Shaarli\Security\SessionManager; 18use Shaarli\Security\SessionManager;
19use Shaarli\Thumbnailer;
18 20
19/** 21/**
20 * Class ContainerBuilder 22 * Class ContainerBuilder
@@ -110,6 +112,14 @@ class ContainerBuilder
110 ); 112 );
111 }; 113 };
112 114
115 $container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
116 return new Thumbnailer($container->conf);
117 };
118
119 $container['httpAccess'] = function (): HttpAccess {
120 return new HttpAccess();
121 };
122
113 return $container; 123 return $container;
114 } 124 }
115} 125}
diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php
index deb07197..fec398d0 100644
--- a/application/container/ShaarliContainer.php
+++ b/application/container/ShaarliContainer.php
@@ -9,11 +9,13 @@ use Shaarli\Config\ConfigManager;
9use Shaarli\Feed\FeedBuilder; 9use Shaarli\Feed\FeedBuilder;
10use Shaarli\Formatter\FormatterFactory; 10use Shaarli\Formatter\FormatterFactory;
11use Shaarli\History; 11use Shaarli\History;
12use Shaarli\Http\HttpAccess;
12use Shaarli\Plugin\PluginManager; 13use Shaarli\Plugin\PluginManager;
13use Shaarli\Render\PageBuilder; 14use Shaarli\Render\PageBuilder;
14use Shaarli\Render\PageCacheManager; 15use Shaarli\Render\PageCacheManager;
15use Shaarli\Security\LoginManager; 16use Shaarli\Security\LoginManager;
16use Shaarli\Security\SessionManager; 17use Shaarli\Security\SessionManager;
18use Shaarli\Thumbnailer;
17use Slim\Container; 19use Slim\Container;
18 20
19/** 21/**
@@ -31,6 +33,8 @@ use Slim\Container;
31 * @property FormatterFactory $formatterFactory 33 * @property FormatterFactory $formatterFactory
32 * @property PageCacheManager $pageCacheManager 34 * @property PageCacheManager $pageCacheManager
33 * @property FeedBuilder $feedBuilder 35 * @property FeedBuilder $feedBuilder
36 * @property Thumbnailer $thumbnailer
37 * @property HttpAccess $httpAccess
34 */ 38 */
35class ShaarliContainer extends Container 39class ShaarliContainer extends Container
36{ 40{
diff --git a/application/front/controller/admin/PostBookmarkController.php b/application/front/controller/admin/PostBookmarkController.php
new file mode 100644
index 00000000..dbe570e2
--- /dev/null
+++ b/application/front/controller/admin/PostBookmarkController.php
@@ -0,0 +1,258 @@
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\Thumbnailer;
11use Slim\Http\Request;
12use Slim\Http\Response;
13
14/**
15 * Class PostBookmarkController
16 *
17 * Slim controller used to handle Shaarli create or edit bookmarks.
18 */
19class PostBookmarkController extends ShaarliAdminController
20{
21 /**
22 * GET /add-shaare - Displays the form used to create a new bookmark from an URL
23 */
24 public function addShaare(Request $request, Response $response): Response
25 {
26 $this->assignView(
27 'pagetitle',
28 t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
29 );
30
31 return $response->write($this->render('addlink'));
32 }
33
34 /**
35 * GET /shaare - Displays the bookmark form for creation.
36 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
37 */
38 public function displayCreateForm(Request $request, Response $response): Response
39 {
40 $url = cleanup_url($request->getParam('post'));
41
42 $linkIsNew = false;
43 // Check if URL is not already in database (in this case, we will edit the existing link)
44 $bookmark = $this->container->bookmarkService->findByUrl($url);
45 if (null === $bookmark) {
46 $linkIsNew = true;
47 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
48 $title = $request->getParam('title');
49 $description = $request->getParam('description');
50 $tags = $request->getParam('tags');
51 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
52
53 // If this is an HTTP(S) link, we try go get the page to extract
54 // the title (otherwise we will to straight to the edit form.)
55 if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
56 $retrieveDescription = $this->container->conf->get('general.retrieve_description');
57 // Short timeout to keep the application responsive
58 // The callback will fill $charset and $title with data from the downloaded page.
59 $this->container->httpAccess->getHttpResponse(
60 $url,
61 $this->container->conf->get('general.download_timeout', 30),
62 $this->container->conf->get('general.download_max_size', 4194304),
63 $this->container->httpAccess->getCurlDownloadCallback(
64 $charset,
65 $title,
66 $description,
67 $tags,
68 $retrieveDescription
69 )
70 );
71 if (! empty($title) && strtolower($charset) !== 'utf-8') {
72 $title = mb_convert_encoding($title, 'utf-8', $charset);
73 }
74 }
75
76 if (empty($url) && empty($title)) {
77 $title = $this->container->conf->get('general.default_note_title', t('Note: '));
78 }
79
80 $link = escape([
81 'title' => $title,
82 'url' => $url ?? '',
83 'description' => $description ?? '',
84 'tags' => $tags ?? '',
85 'private' => $private,
86 ]);
87 } else {
88 $formatter = $this->container->formatterFactory->getFormatter('raw');
89 $link = $formatter->format($bookmark);
90 }
91
92 return $this->displayForm($link, $linkIsNew, $request, $response);
93 }
94
95 /**
96 * GET /shaare-{id} - Displays the bookmark form in edition mode.
97 */
98 public function displayEditForm(Request $request, Response $response, array $args): Response
99 {
100 $id = $args['id'];
101 try {
102 if (false === ctype_digit($id)) {
103 throw new BookmarkNotFoundException();
104 }
105 $bookmark = $this->container->bookmarkService->get($id); // Read database
106 } catch (BookmarkNotFoundException $e) {
107 $this->saveErrorMessage(t('Bookmark not found'));
108
109 return $response->withRedirect('./');
110 }
111
112 $formatter = $this->container->formatterFactory->getFormatter('raw');
113 $link = $formatter->format($bookmark);
114
115 return $this->displayForm($link, false, $request, $response);
116 }
117
118 /**
119 * POST /shaare
120 */
121 public function save(Request $request, Response $response): Response
122 {
123 $this->checkToken($request);
124
125 // lf_id should only be present if the link exists.
126 $id = $request->getParam('lf_id') ? intval(escape($request->getParam('lf_id'))) : null;
127 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
128 // Edit
129 $bookmark = $this->container->bookmarkService->get($id);
130 } else {
131 // New link
132 $bookmark = new Bookmark();
133 }
134
135 $bookmark->setTitle($request->getParam('lf_title'));
136 $bookmark->setDescription($request->getParam('lf_description'));
137 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
138 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
139 $bookmark->setTagsString($request->getParam('lf_tags'));
140
141 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
142 && false === $bookmark->isNote()
143 ) {
144 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
145 }
146 $this->container->bookmarkService->addOrSet($bookmark, false);
147
148 // To preserve backward compatibility with 3rd parties, plugins still use arrays
149 $formatter = $this->container->formatterFactory->getFormatter('raw');
150 $data = $formatter->format($bookmark);
151 $data = $this->executeHooks('save_link', $data);
152
153 $bookmark->fromArray($data);
154 $this->container->bookmarkService->set($bookmark);
155
156 // If we are called from the bookmarklet, we must close the popup:
157 if ($request->getParam('source') === 'bookmarklet') {
158 return $response->write('<script>self.close();</script>');
159 }
160
161 if (!empty($request->getParam('returnurl'))) {
162 $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl'));
163 }
164
165 return $this->redirectFromReferer(
166 $request,
167 $response,
168 ['add-shaare', 'shaare'], ['addlink', 'post', 'edit_link'],
169 $bookmark->getShortUrl()
170 );
171 }
172
173 public function deleteBookmark(Request $request, Response $response): Response
174 {
175 $this->checkToken($request);
176
177 $ids = escape(trim($request->getParam('lf_linkdate')));
178 if (strpos($ids, ' ') !== false) {
179 // multiple, space-separated ids provided
180 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'strlen'));
181 } else {
182 $ids = [$ids];
183 }
184
185 // assert at least one id is given
186 if (0 === count($ids)) {
187 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
188
189 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
190 }
191
192 $formatter = $this->container->formatterFactory->getFormatter('raw');
193 foreach ($ids as $id) {
194 $id = (int) $id;
195 // TODO: check if it exists
196 $bookmark = $this->container->bookmarkService->get($id);
197 $data = $formatter->format($bookmark);
198 $this->container->pluginManager->executeHooks('delete_link', $data);
199 $this->container->bookmarkService->remove($bookmark, false);
200 }
201
202 $this->container->bookmarkService->save();
203
204 // If we are called from the bookmarklet, we must close the popup:
205 if ($request->getParam('source') === 'bookmarklet') {
206 return $response->write('<script>self.close();</script>');
207 }
208
209 // Don't redirect to where we were previously because the datastore has changed.
210 return $response->withRedirect('./');
211 }
212
213 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
214 {
215 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
216 if ($this->container->conf->get('formatter') === 'markdown') {
217 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
218 }
219
220 $data = [
221 'link' => $link,
222 'link_is_new' => $isNew,
223 'http_referer' => escape($this->container->environment['HTTP_REFERER'] ?? ''),
224 'source' => $request->getParam('source') ?? '',
225 'tags' => $tags,
226 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
227 ];
228
229 $data = $this->executeHooks('render_editlink', $data);
230
231 foreach ($data as $key => $value) {
232 $this->assignView($key, $value);
233 }
234
235 $editLabel = false === $isNew ? t('Edit') .' ' : '';
236 $this->assignView(
237 'pagetitle',
238 $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
239 );
240
241 return $response->write($this->render('editlink'));
242 }
243
244 /**
245 * @param mixed[] $data Variables passed to the template engine
246 *
247 * @return mixed[] Template data after active plugins render_picwall hook execution.
248 */
249 protected function executeHooks(string $hook, array $data): array
250 {
251 $this->container->pluginManager->executeHooks(
252 $hook,
253 $data
254 );
255
256 return $data;
257 }
258}
diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php
index 66db5ad9..d087f2cd 100644
--- a/application/front/controller/admin/ToolsController.php
+++ b/application/front/controller/admin/ToolsController.php
@@ -21,7 +21,7 @@ class ToolsController extends ShaarliAdminController
21 'sslenabled' => is_https($this->container->environment), 21 'sslenabled' => is_https($this->container->environment),
22 ]; 22 ];
23 23
24 $this->executeHooks($data); 24 $data = $this->executeHooks($data);
25 25
26 foreach ($data as $key => $value) { 26 foreach ($data as $key => $value) {
27 $this->assignView($key, $value); 27 $this->assignView($key, $value);
diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php
index 47e2503a..e5c9ddac 100644
--- a/application/front/controller/visitor/DailyController.php
+++ b/application/front/controller/visitor/DailyController.php
@@ -71,7 +71,7 @@ class DailyController extends ShaarliVisitorController
71 ]; 71 ];
72 72
73 // Hooks are called before column construction so that plugins don't have to deal with columns. 73 // Hooks are called before column construction so that plugins don't have to deal with columns.
74 $this->executeHooks($data); 74 $data = $this->executeHooks($data);
75 75
76 $data['cols'] = $this->calculateColumns($data['linksToDisplay']); 76 $data['cols'] = $this->calculateColumns($data['linksToDisplay']);
77 77
diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php
index 70664635..f76f55fd 100644
--- a/application/front/controller/visitor/FeedController.php
+++ b/application/front/controller/visitor/FeedController.php
@@ -46,7 +46,7 @@ class FeedController extends ShaarliVisitorController
46 46
47 $data = $this->container->feedBuilder->buildData($feedType, $request->getParams()); 47 $data = $this->container->feedBuilder->buildData($feedType, $request->getParams());
48 48
49 $this->executeHooks($data, $feedType); 49 $data = $this->executeHooks($data, $feedType);
50 $this->assignAllView($data); 50 $this->assignAllView($data);
51 51
52 $content = $this->render('feed.'. $feedType); 52 $content = $this->render('feed.'. $feedType);
diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php
index f12915c1..98423d90 100644
--- a/application/front/controller/visitor/ShaarliVisitorController.php
+++ b/application/front/controller/visitor/ShaarliVisitorController.php
@@ -78,16 +78,16 @@ abstract class ShaarliVisitorController
78 ]; 78 ];
79 79
80 foreach ($common_hooks as $name) { 80 foreach ($common_hooks as $name) {
81 $plugin_data = []; 81 $pluginData = [];
82 $this->container->pluginManager->executeHooks( 82 $this->container->pluginManager->executeHooks(
83 'render_' . $name, 83 'render_' . $name,
84 $plugin_data, 84 $pluginData,
85 [ 85 [
86 'target' => $template, 86 'target' => $template,
87 'loggedin' => $this->container->loginManager->isLoggedIn() 87 'loggedin' => $this->container->loginManager->isLoggedIn()
88 ] 88 ]
89 ); 89 );
90 $this->assignView('plugins_' . $name, $plugin_data); 90 $this->assignView('plugins_' . $name, $pluginData);
91 } 91 }
92 } 92 }
93 93
@@ -102,9 +102,10 @@ abstract class ShaarliVisitorController
102 Request $request, 102 Request $request,
103 Response $response, 103 Response $response,
104 array $loopTerms = [], 104 array $loopTerms = [],
105 array $clearParams = [] 105 array $clearParams = [],
106 string $anchor = null
106 ): Response { 107 ): Response {
107 $defaultPath = $request->getUri()->getBasePath(); 108 $defaultPath = rtrim($request->getUri()->getBasePath(), '/') . '/';
108 $referer = $this->container->environment['HTTP_REFERER'] ?? null; 109 $referer = $this->container->environment['HTTP_REFERER'] ?? null;
109 110
110 if (null !== $referer) { 111 if (null !== $referer) {
@@ -133,7 +134,8 @@ abstract class ShaarliVisitorController
133 } 134 }
134 135
135 $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; 136 $queryString = count($params) > 0 ? '?'. http_build_query($params) : '';
137 $anchor = $anchor ? '#' . $anchor : '';
136 138
137 return $response->withRedirect($path . $queryString); 139 return $response->withRedirect($path . $queryString . $anchor);
138 } 140 }
139} 141}
diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php
new file mode 100644
index 00000000..81d9e076
--- /dev/null
+++ b/application/http/HttpAccess.php
@@ -0,0 +1,39 @@
1<?php
2
3declare(strict_types=1);
4
5namespace 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 */
15class HttpAccess
16{
17 public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
18 {
19 return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction);
20 }
21
22 public function getCurlDownloadCallback(
23 &$charset,
24 &$title,
25 &$description,
26 &$keywords,
27 $retrieveDescription,
28 $curlGetInfo = 'curl_getinfo'
29 ) {
30 return get_curl_download_callback(
31 $charset,
32 $title,
33 $description,
34 $keywords,
35 $retrieveDescription,
36 $curlGetInfo
37 );
38 }
39}
diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php
index f00c4336..4fc4e3dc 100644
--- a/application/http/HttpUtils.php
+++ b/application/http/HttpUtils.php
@@ -484,3 +484,109 @@ function is_https($server)
484 484
485 return ! empty($server['HTTPS']); 485 return ! empty($server['HTTPS']);
486} 486}
487
488/**
489 * Get cURL callback function for CURLOPT_WRITEFUNCTION
490 *
491 * @param string $charset to extract from the downloaded page (reference)
492 * @param string $title to extract from the downloaded page (reference)
493 * @param string $description to extract from the downloaded page (reference)
494 * @param string $keywords to extract from the downloaded page (reference)
495 * @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
496 * @param string $curlGetInfo Optionally overrides curl_getinfo function
497 *
498 * @return Closure
499 */
500function get_curl_download_callback(
501 &$charset,
502 &$title,
503 &$description,
504 &$keywords,
505 $retrieveDescription,
506 $curlGetInfo = 'curl_getinfo'
507) {
508 $isRedirected = false;
509 $currentChunk = 0;
510 $foundChunk = null;
511
512 /**
513 * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
514 *
515 * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
516 * Then we extract the title and the charset and stop the download when it's done.
517 *
518 * @param resource $ch cURL resource
519 * @param string $data chunk of data being downloaded
520 *
521 * @return int|bool length of $data or false if we need to stop the download
522 */
523 return function (&$ch, $data) use (
524 $retrieveDescription,
525 $curlGetInfo,
526 &$charset,
527 &$title,
528 &$description,
529 &$keywords,
530 &$isRedirected,
531 &$currentChunk,
532 &$foundChunk
533 ) {
534 $currentChunk++;
535 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
536 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
537 $isRedirected = true;
538 return strlen($data);
539 }
540 if (!empty($responseCode) && $responseCode !== 200) {
541 return false;
542 }
543 // After a redirection, the content type will keep the previous request value
544 // until it finds the next content-type header.
545 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
546 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
547 }
548 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
549 return false;
550 }
551 if (!empty($contentType) && empty($charset)) {
552 $charset = header_extract_charset($contentType);
553 }
554 if (empty($charset)) {
555 $charset = html_extract_charset($data);
556 }
557 if (empty($title)) {
558 $title = html_extract_title($data);
559 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
560 }
561 if ($retrieveDescription && empty($description)) {
562 $description = html_extract_tag('description', $data);
563 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
564 }
565 if ($retrieveDescription && empty($keywords)) {
566 $keywords = html_extract_tag('keywords', $data);
567 if (! empty($keywords)) {
568 $foundChunk = $currentChunk;
569 // Keywords use the format tag1, tag2 multiple words, tag
570 // So we format them to match Shaarli's separator and glue multiple words with '-'
571 $keywords = implode(' ', array_map(function($keyword) {
572 return implode('-', preg_split('/\s+/', trim($keyword)));
573 }, explode(',', $keywords)));
574 }
575 }
576
577 // We got everything we want, stop the download.
578 // If we already found either the title, description or keywords,
579 // it's highly unlikely that we'll found the other metas further than
580 // in the same chunk of data or the next one. So we also stop the download after that.
581 if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
582 && (! $retrieveDescription
583 || $foundChunk < $currentChunk
584 || (!empty($title) && !empty($description) && !empty($keywords))
585 )
586 ) {
587 return false;
588 }
589
590 return strlen($data);
591 };
592}
diff --git a/doc/md/Translations.md b/doc/md/Translations.md
index 9a16075a..5c775d30 100644
--- a/doc/md/Translations.md
+++ b/doc/md/Translations.md
@@ -32,7 +32,7 @@ Here is a list :
32``` 32```
33http://<replace_domain>/ 33http://<replace_domain>/
34http://<replace_domain>/?nonope 34http://<replace_domain>/?nonope
35http://<replace_domain>/?do=addlink 35http://<replace_domain>/add-shaare
36http://<replace_domain>/?do=changepasswd 36http://<replace_domain>/?do=changepasswd
37http://<replace_domain>/?do=changetag 37http://<replace_domain>/?do=changetag
38http://<replace_domain>/configure 38http://<replace_domain>/configure
diff --git a/index.php b/index.php
index 00e4a40b..fb528eeb 100644
--- a/index.php
+++ b/index.php
@@ -519,69 +519,20 @@ function renderPage($conf, $pluginManager, $bookmarkService, $history, $sessionM
519 519
520 // -------- User wants to rename a tag or delete it 520 // -------- User wants to rename a tag or delete it
521 if ($targetPage == Router::$PAGE_CHANGETAG) { 521 if ($targetPage == Router::$PAGE_CHANGETAG) {
522 header('./manage-tags'); 522 header('Location: ./manage-tags');
523 exit; 523 exit;
524 } 524 }
525 525
526 // -------- User wants to add a link without using the bookmarklet: Show form. 526 // -------- User wants to add a link without using the bookmarklet: Show form.
527 if ($targetPage == Router::$PAGE_ADDLINK) { 527 if ($targetPage == Router::$PAGE_ADDLINK) {
528 $PAGE->assign('pagetitle', t('Shaare a new link') .' - '. $conf->get('general.title', 'Shaarli')); 528 header('Location: ./shaare');
529 $PAGE->renderPage('addlink');
530 exit; 529 exit;
531 } 530 }
532 531
533 // -------- User clicked the "Save" button when editing a link: Save link to database. 532 // -------- User clicked the "Save" button when editing a link: Save link to database.
534 if (isset($_POST['save_edit'])) { 533 if (isset($_POST['save_edit'])) {
535 // Go away! 534 // This route is no longer supported in legacy mode
536 if (! $sessionManager->checkToken($_POST['token'])) { 535 header('Location: ./');
537 die(t('Wrong token.'));
538 }
539
540 // lf_id should only be present if the link exists.
541 $id = isset($_POST['lf_id']) ? intval(escape($_POST['lf_id'])) : null;
542 if ($id && $bookmarkService->exists($id)) {
543 // Edit
544 $bookmark = $bookmarkService->get($id);
545 } else {
546 // New link
547 $bookmark = new Bookmark();
548 }
549
550 $bookmark->setTitle($_POST['lf_title']);
551 $bookmark->setDescription($_POST['lf_description']);
552 $bookmark->setUrl($_POST['lf_url'], $conf->get('security.allowed_protocols'));
553 $bookmark->setPrivate(isset($_POST['lf_private']));
554 $bookmark->setTagsString($_POST['lf_tags']);
555
556 if ($conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
557 && ! $bookmark->isNote()
558 ) {
559 $thumbnailer = new Thumbnailer($conf);
560 $bookmark->setThumbnail($thumbnailer->get($bookmark->getUrl()));
561 }
562 $bookmarkService->addOrSet($bookmark, false);
563
564 // To preserve backward compatibility with 3rd parties, plugins still use arrays
565 $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
566 $formatter = $factory->getFormatter('raw');
567 $data = $formatter->format($bookmark);
568 $pluginManager->executeHooks('save_link', $data);
569
570 $bookmark->fromArray($data);
571 $bookmarkService->set($bookmark);
572
573 // If we are called from the bookmarklet, we must close the popup:
574 if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) {
575 echo '<script>self.close();</script>';
576 exit;
577 }
578
579 $returnurl = !empty($_POST['returnurl']) ? $_POST['returnurl'] : '?';
580 $location = generateLocation($returnurl, $_SERVER['HTTP_HOST'], array('addlink', 'post', 'edit_link'));
581 // Scroll to the link which has been edited.
582 $location .= '#' . $bookmark->getShortUrl();
583 // After saving the link, redirect to the page the user was on.
584 header('Location: '. $location);
585 exit; 536 exit;
586 } 537 }
587 538
@@ -695,110 +646,13 @@ function renderPage($conf, $pluginManager, $bookmarkService, $history, $sessionM
695 // -------- User clicked the "EDIT" button on a link: Display link edit form. 646 // -------- User clicked the "EDIT" button on a link: Display link edit form.
696 if (isset($_GET['edit_link'])) { 647 if (isset($_GET['edit_link'])) {
697 $id = (int) escape($_GET['edit_link']); 648 $id = (int) escape($_GET['edit_link']);
698 try { 649 header('Location: ./shaare-' . $id);
699 $link = $bookmarkService->get($id); // Read database
700 } catch (BookmarkNotFoundException $e) {
701 // Link not found in database.
702 header('Location: ?');
703 exit;
704 }
705
706 $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
707 $formatter = $factory->getFormatter('raw');
708 $formattedLink = $formatter->format($link);
709 $tags = $bookmarkService->bookmarksCountPerTag();
710 if ($conf->get('formatter') === 'markdown') {
711 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
712 }
713 $data = array(
714 'link' => $formattedLink,
715 'link_is_new' => false,
716 'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
717 'tags' => $tags,
718 );
719 $pluginManager->executeHooks('render_editlink', $data);
720
721 foreach ($data as $key => $value) {
722 $PAGE->assign($key, $value);
723 }
724
725 $PAGE->assign('pagetitle', t('Edit') .' '. t('Shaare') .' - '. $conf->get('general.title', 'Shaarli'));
726 $PAGE->renderPage('editlink');
727 exit; 650 exit;
728 } 651 }
729 652
730 // -------- User want to post a new link: Display link edit form. 653 // -------- User want to post a new link: Display link edit form.
731 if (isset($_GET['post'])) { 654 if (isset($_GET['post'])) {
732 $url = cleanup_url($_GET['post']); 655 header('Location: ./shaare?' . http_build_query($_GET));
733
734 $link_is_new = false;
735 // Check if URL is not already in database (in this case, we will edit the existing link)
736 $bookmark = $bookmarkService->findByUrl($url);
737 if (! $bookmark) {
738 $link_is_new = true;
739 // Get title if it was provided in URL (by the bookmarklet).
740 $title = empty($_GET['title']) ? '' : escape($_GET['title']);
741 // Get description if it was provided in URL (by the bookmarklet). [Bronco added that]
742 $description = empty($_GET['description']) ? '' : escape($_GET['description']);
743 $tags = empty($_GET['tags']) ? '' : escape($_GET['tags']);
744 $private = !empty($_GET['private']) && $_GET['private'] === "1" ? 1 : 0;
745
746 // If this is an HTTP(S) link, we try go get the page to extract
747 // the title (otherwise we will to straight to the edit form.)
748 if (empty($title) && strpos(get_url_scheme($url), 'http') !== false) {
749 $retrieveDescription = $conf->get('general.retrieve_description');
750 // Short timeout to keep the application responsive
751 // The callback will fill $charset and $title with data from the downloaded page.
752 get_http_response(
753 $url,
754 $conf->get('general.download_timeout', 30),
755 $conf->get('general.download_max_size', 4194304),
756 get_curl_download_callback($charset, $title, $description, $tags, $retrieveDescription)
757 );
758 if (! empty($title) && strtolower($charset) != 'utf-8') {
759 $title = mb_convert_encoding($title, 'utf-8', $charset);
760 }
761 }
762
763 if ($url == '') {
764 $title = $conf->get('general.default_note_title', t('Note: '));
765 }
766 $url = escape($url);
767 $title = escape($title);
768
769 $link = [
770 'title' => $title,
771 'url' => $url,
772 'description' => $description,
773 'tags' => $tags,
774 'private' => $private,
775 ];
776 } else {
777 $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
778 $formatter = $factory->getFormatter('raw');
779 $link = $formatter->format($bookmark);
780 }
781
782 $tags = $bookmarkService->bookmarksCountPerTag();
783 if ($conf->get('formatter') === 'markdown') {
784 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
785 }
786 $data = [
787 'link' => $link,
788 'link_is_new' => $link_is_new,
789 'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
790 'source' => (isset($_GET['source']) ? $_GET['source'] : ''),
791 'tags' => $tags,
792 'default_private_links' => $conf->get('privacy.default_private_links', false),
793 ];
794 $pluginManager->executeHooks('render_editlink', $data);
795
796 foreach ($data as $key => $value) {
797 $PAGE->assign($key, $value);
798 }
799
800 $PAGE->assign('pagetitle', t('Shaare') .' - '. $conf->get('general.title', 'Shaarli'));
801 $PAGE->renderPage('editlink');
802 exit; 656 exit;
803 } 657 }
804 658
@@ -1351,19 +1205,29 @@ $app->group('', function () {
1351 $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save')->setName('saveConfigure'); 1205 $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save')->setName('saveConfigure');
1352 $this->get('/manage-tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index')->setName('manageTag'); 1206 $this->get('/manage-tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index')->setName('manageTag');
1353 $this->post('/manage-tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save')->setName('saveManageTag'); 1207 $this->post('/manage-tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save')->setName('saveManageTag');
1208 $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\PostBookmarkController:addShaare')->setName('addShaare');
1209 $this
1210 ->get('/shaare', '\Shaarli\Front\Controller\Admin\PostBookmarkController:displayCreateForm')
1211 ->setName('newShaare');
1212 $this
1213 ->get('/shaare-{id}', '\Shaarli\Front\Controller\Admin\PostBookmarkController:displayEditForm')
1214 ->setName('editShaare');
1215 $this
1216 ->post('/shaare', '\Shaarli\Front\Controller\Admin\PostBookmarkController:save')
1217 ->setName('saveShaare');
1218 $this
1219 ->get('/delete-shaare', '\Shaarli\Front\Controller\Admin\PostBookmarkController:deleteBookmark')
1220 ->setName('deleteShaare');
1354 1221
1355 $this 1222 $this
1356 ->get('/links-per-page', '\Shaarli\Front\Controller\Admin\SessionFilterController:linksPerPage') 1223 ->get('/links-per-page', '\Shaarli\Front\Controller\Admin\SessionFilterController:linksPerPage')
1357 ->setName('filter-links-per-page') 1224 ->setName('filter-links-per-page');
1358 ;
1359 $this 1225 $this
1360 ->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility') 1226 ->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility')
1361 ->setName('visibility') 1227 ->setName('visibility');
1362 ;
1363 $this 1228 $this
1364 ->get('/untagged-only', '\Shaarli\Front\Controller\Admin\SessionFilterController:untaggedOnly') 1229 ->get('/untagged-only', '\Shaarli\Front\Controller\Admin\SessionFilterController:untaggedOnly')
1365 ->setName('untagged-only') 1230 ->setName('untagged-only');
1366 ;
1367})->add('\Shaarli\Front\ShaarliMiddleware'); 1231})->add('\Shaarli\Front\ShaarliMiddleware');
1368 1232
1369$response = $app->run(true); 1233$response = $app->run(true);
diff --git a/tests/container/ShaarliTestContainer.php b/tests/container/ShaarliTestContainer.php
index 53197ae6..7dbe914c 100644
--- a/tests/container/ShaarliTestContainer.php
+++ b/tests/container/ShaarliTestContainer.php
@@ -10,11 +10,13 @@ use Shaarli\Config\ConfigManager;
10use Shaarli\Feed\FeedBuilder; 10use Shaarli\Feed\FeedBuilder;
11use Shaarli\Formatter\FormatterFactory; 11use Shaarli\Formatter\FormatterFactory;
12use Shaarli\History; 12use Shaarli\History;
13use Shaarli\Http\HttpAccess;
13use Shaarli\Plugin\PluginManager; 14use Shaarli\Plugin\PluginManager;
14use Shaarli\Render\PageBuilder; 15use Shaarli\Render\PageBuilder;
15use Shaarli\Render\PageCacheManager; 16use Shaarli\Render\PageCacheManager;
16use Shaarli\Security\LoginManager; 17use Shaarli\Security\LoginManager;
17use Shaarli\Security\SessionManager; 18use Shaarli\Security\SessionManager;
19use Shaarli\Thumbnailer;
18 20
19/** 21/**
20 * Test helper allowing auto-completion for MockObjects. 22 * Test helper allowing auto-completion for MockObjects.
@@ -31,6 +33,8 @@ use Shaarli\Security\SessionManager;
31 * @property MockObject|FormatterFactory $formatterFactory 33 * @property MockObject|FormatterFactory $formatterFactory
32 * @property MockObject|PageCacheManager $pageCacheManager 34 * @property MockObject|PageCacheManager $pageCacheManager
33 * @property MockObject|FeedBuilder $feedBuilder 35 * @property MockObject|FeedBuilder $feedBuilder
36 * @property MockObject|Thumbnailer $thumbnailer
37 * @property MockObject|HttpAccess $httpAccess
34 */ 38 */
35class ShaarliTestContainer extends ShaarliContainer 39class ShaarliTestContainer extends ShaarliContainer
36{ 40{
diff --git a/tests/front/controller/admin/PostBookmarkControllerTest.php b/tests/front/controller/admin/PostBookmarkControllerTest.php
new file mode 100644
index 00000000..f00a15c9
--- /dev/null
+++ b/tests/front/controller/admin/PostBookmarkControllerTest.php
@@ -0,0 +1,652 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use PHPUnit\Framework\TestCase;
8use Shaarli\Bookmark\Bookmark;
9use Shaarli\Config\ConfigManager;
10use Shaarli\Front\Exception\WrongTokenException;
11use Shaarli\Http\HttpAccess;
12use Shaarli\Security\SessionManager;
13use Shaarli\Thumbnailer;
14use Slim\Http\Request;
15use Slim\Http\Response;
16use Slim\Http\Uri;
17
18class PostBookmarkControllerTest extends TestCase
19{
20 use FrontAdminControllerMockHelper;
21
22 /** @var PostBookmarkController */
23 protected $controller;
24
25 public function setUp(): void
26 {
27 $this->createContainer();
28
29 $this->container->httpAccess = $this->createMock(HttpAccess::class);
30 $this->controller = new PostBookmarkController($this->container);
31 }
32
33 /**
34 * Test displaying add link page
35 */
36 public function testAddShaare(): void
37 {
38 $assignedVariables = [];
39 $this->assignTemplateVars($assignedVariables);
40
41 $request = $this->createMock(Request::class);
42 $response = new Response();
43
44 $result = $this->controller->addShaare($request, $response);
45
46 static::assertSame(200, $result->getStatusCode());
47 static::assertSame('addlink', (string) $result->getBody());
48
49 static::assertSame('Shaare a new link - Shaarli', $assignedVariables['pagetitle']);
50 }
51
52 /**
53 * Test displaying bookmark create form
54 * Ensure that every step of the standard workflow works properly.
55 */
56 public function testDisplayCreateFormWithUrl(): void
57 {
58 $this->container->environment = [
59 'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
60 ];
61
62 $assignedVariables = [];
63 $this->assignTemplateVars($assignedVariables);
64
65 $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
66 $expectedUrl = str_replace('&utm_ad=pay', '', $url);
67 $remoteTitle = 'Remote Title';
68 $remoteDesc = 'Sometimes the meta description is relevant.';
69 $remoteTags = 'abc def';
70
71 $request = $this->createMock(Request::class);
72 $request->method('getParam')->willReturnCallback(function (string $key) use ($url): ?string {
73 return $key === 'post' ? $url : null;
74 });
75 $response = new Response();
76
77 $this->container->httpAccess
78 ->expects(static::once())
79 ->method('getCurlDownloadCallback')
80 ->willReturnCallback(
81 function (&$charset, &$title, &$description, &$tags) use (
82 $remoteTitle,
83 $remoteDesc,
84 $remoteTags
85 ): callable {
86 return function () use (
87 &$charset,
88 &$title,
89 &$description,
90 &$tags,
91 $remoteTitle,
92 $remoteDesc,
93 $remoteTags
94 ): void {
95 $charset = 'ISO-8859-1';
96 $title = $remoteTitle;
97 $description = $remoteDesc;
98 $tags = $remoteTags;
99 };
100 }
101 )
102 ;
103 $this->container->httpAccess
104 ->expects(static::once())
105 ->method('getHttpResponse')
106 ->with($expectedUrl, 30, 4194304)
107 ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
108 $callback();
109 })
110 ;
111
112 $this->container->bookmarkService
113 ->expects(static::once())
114 ->method('bookmarksCountPerTag')
115 ->willReturn($tags = ['tag1' => 2, 'tag2' => 1])
116 ;
117
118 // Make sure that PluginManager hook is triggered
119 $this->container->pluginManager
120 ->expects(static::at(0))
121 ->method('executeHooks')
122 ->willReturnCallback(function (string $hook, array $data) use ($remoteTitle, $remoteDesc): array {
123 static::assertSame('render_editlink', $hook);
124 static::assertSame($remoteTitle, $data['link']['title']);
125 static::assertSame($remoteDesc, $data['link']['description']);
126
127 return $data;
128 })
129 ;
130
131 $result = $this->controller->displayCreateForm($request, $response);
132
133 static::assertSame(200, $result->getStatusCode());
134 static::assertSame('editlink', (string) $result->getBody());
135
136 static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
137
138 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
139 static::assertSame($remoteTitle, $assignedVariables['link']['title']);
140 static::assertSame($remoteDesc, $assignedVariables['link']['description']);
141 static::assertSame($remoteTags, $assignedVariables['link']['tags']);
142 static::assertFalse($assignedVariables['link']['private']);
143
144 static::assertTrue($assignedVariables['link_is_new']);
145 static::assertSame($referer, $assignedVariables['http_referer']);
146 static::assertSame($tags, $assignedVariables['tags']);
147 static::assertArrayHasKey('source', $assignedVariables);
148 static::assertArrayHasKey('default_private_links', $assignedVariables);
149 }
150
151 /**
152 * Test displaying bookmark create form
153 * Ensure all available query parameters are handled properly.
154 */
155 public function testDisplayCreateFormWithFullParameters(): void
156 {
157 $assignedVariables = [];
158 $this->assignTemplateVars($assignedVariables);
159
160 $parameters = [
161 'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash',
162 'title' => 'Provided Title',
163 'description' => 'Provided description.',
164 'tags' => 'abc def',
165 'private' => '1',
166 'source' => 'apps',
167 ];
168 $expectedUrl = str_replace('&utm_ad=pay', '', $parameters['post']);
169
170 $request = $this->createMock(Request::class);
171 $request
172 ->method('getParam')
173 ->willReturnCallback(function (string $key) use ($parameters): ?string {
174 return $parameters[$key] ?? null;
175 });
176 $response = new Response();
177
178 $result = $this->controller->displayCreateForm($request, $response);
179
180 static::assertSame(200, $result->getStatusCode());
181 static::assertSame('editlink', (string) $result->getBody());
182
183 static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
184
185 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
186 static::assertSame($parameters['title'], $assignedVariables['link']['title']);
187 static::assertSame($parameters['description'], $assignedVariables['link']['description']);
188 static::assertSame($parameters['tags'], $assignedVariables['link']['tags']);
189 static::assertTrue($assignedVariables['link']['private']);
190 static::assertTrue($assignedVariables['link_is_new']);
191 static::assertSame($parameters['source'], $assignedVariables['source']);
192 }
193
194 /**
195 * Test displaying bookmark create form
196 * Without any parameter.
197 */
198 public function testDisplayCreateFormEmpty(): void
199 {
200 $assignedVariables = [];
201 $this->assignTemplateVars($assignedVariables);
202
203 $request = $this->createMock(Request::class);
204 $response = new Response();
205
206 $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
207 $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
208
209 $result = $this->controller->displayCreateForm($request, $response);
210
211 static::assertSame(200, $result->getStatusCode());
212 static::assertSame('editlink', (string) $result->getBody());
213 static::assertSame('', $assignedVariables['link']['url']);
214 static::assertSame('Note: ', $assignedVariables['link']['title']);
215 static::assertSame('', $assignedVariables['link']['description']);
216 static::assertSame('', $assignedVariables['link']['tags']);
217 static::assertFalse($assignedVariables['link']['private']);
218 static::assertTrue($assignedVariables['link_is_new']);
219 }
220
221 /**
222 * Test displaying bookmark create form
223 * URL not using HTTP protocol: do not try to retrieve the title
224 */
225 public function testDisplayCreateFormNotHttp(): void
226 {
227 $assignedVariables = [];
228 $this->assignTemplateVars($assignedVariables);
229
230 $url = 'magnet://kubuntu.torrent';
231 $request = $this->createMock(Request::class);
232 $request
233 ->method('getParam')
234 ->willReturnCallback(function (string $key) use ($url): ?string {
235 return $key === 'post' ? $url : null;
236 });
237 $response = new Response();
238
239 $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
240 $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
241
242 $result = $this->controller->displayCreateForm($request, $response);
243
244 static::assertSame(200, $result->getStatusCode());
245 static::assertSame('editlink', (string) $result->getBody());
246 static::assertSame($url, $assignedVariables['link']['url']);
247 static::assertTrue($assignedVariables['link_is_new']);
248 }
249
250 /**
251 * Test displaying bookmark create form
252 * When markdown formatter is enabled, the no markdown tag should be added to existing tags.
253 */
254 public function testDisplayCreateFormWithMarkdownEnabled(): void
255 {
256 $assignedVariables = [];
257 $this->assignTemplateVars($assignedVariables);
258
259 $this->container->conf = $this->createMock(ConfigManager::class);
260 $this->container->conf
261 ->expects(static::atLeastOnce())
262 ->method('get')->willReturnCallback(function (string $key): ?string {
263 if ($key === 'formatter') {
264 return 'markdown';
265 }
266
267 return $key;
268 })
269 ;
270
271 $request = $this->createMock(Request::class);
272 $response = new Response();
273
274 $result = $this->controller->displayCreateForm($request, $response);
275
276 static::assertSame(200, $result->getStatusCode());
277 static::assertSame('editlink', (string) $result->getBody());
278 static::assertSame(['nomarkdown' => 1], $assignedVariables['tags']);
279 }
280
281 /**
282 * Test displaying bookmark create form
283 * When an existing URL is submitted, we want to edit the existing link.
284 */
285 public function testDisplayCreateFormWithExistingUrl(): void
286 {
287 $assignedVariables = [];
288 $this->assignTemplateVars($assignedVariables);
289
290 $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
291 $expectedUrl = str_replace('&utm_ad=pay', '', $url);
292
293 $request = $this->createMock(Request::class);
294 $request
295 ->method('getParam')
296 ->willReturnCallback(function (string $key) use ($url): ?string {
297 return $key === 'post' ? $url : null;
298 });
299 $response = new Response();
300
301 $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
302 $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
303
304 $this->container->bookmarkService
305 ->expects(static::once())
306 ->method('findByUrl')
307 ->with($expectedUrl)
308 ->willReturn(
309 (new Bookmark())
310 ->setId($id = 23)
311 ->setUrl($expectedUrl)
312 ->setTitle($title = 'Bookmark Title')
313 ->setDescription($description = 'Bookmark description.')
314 ->setTags($tags = ['abc', 'def'])
315 ->setPrivate(true)
316 ->setCreated($createdAt = new \DateTime('2020-06-10 18:45:44'))
317 )
318 ;
319
320 $result = $this->controller->displayCreateForm($request, $response);
321
322 static::assertSame(200, $result->getStatusCode());
323 static::assertSame('editlink', (string) $result->getBody());
324
325 static::assertSame('Edit Shaare - Shaarli', $assignedVariables['pagetitle']);
326 static::assertFalse($assignedVariables['link_is_new']);
327
328 static::assertSame($id, $assignedVariables['link']['id']);
329 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
330 static::assertSame($title, $assignedVariables['link']['title']);
331 static::assertSame($description, $assignedVariables['link']['description']);
332 static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
333 static::assertTrue($assignedVariables['link']['private']);
334 static::assertSame($createdAt, $assignedVariables['link']['created']);
335 }
336
337 /**
338 * Test displaying bookmark edit form
339 * When an existing ID is provided, ensure that default workflow works properly.
340 */
341 public function testDisplayEditFormDefault(): void
342 {
343 $assignedVariables = [];
344 $this->assignTemplateVars($assignedVariables);
345
346 $id = 11;
347
348 $request = $this->createMock(Request::class);
349 $response = new Response();
350
351 $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
352 $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
353
354 $this->container->bookmarkService
355 ->expects(static::once())
356 ->method('get')
357 ->with($id)
358 ->willReturn(
359 (new Bookmark())
360 ->setId($id)
361 ->setUrl($url = 'http://domain.tld')
362 ->setTitle($title = 'Bookmark Title')
363 ->setDescription($description = 'Bookmark description.')
364 ->setTags($tags = ['abc', 'def'])
365 ->setPrivate(true)
366 ->setCreated($createdAt = new \DateTime('2020-06-10 18:45:44'))
367 )
368 ;
369
370 $result = $this->controller->displayEditForm($request, $response, ['id' => (string) $id]);
371
372 static::assertSame(200, $result->getStatusCode());
373 static::assertSame('editlink', (string) $result->getBody());
374
375 static::assertSame('Edit Shaare - Shaarli', $assignedVariables['pagetitle']);
376 static::assertFalse($assignedVariables['link_is_new']);
377
378 static::assertSame($id, $assignedVariables['link']['id']);
379 static::assertSame($url, $assignedVariables['link']['url']);
380 static::assertSame($title, $assignedVariables['link']['title']);
381 static::assertSame($description, $assignedVariables['link']['description']);
382 static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
383 static::assertTrue($assignedVariables['link']['private']);
384 static::assertSame($createdAt, $assignedVariables['link']['created']);
385 }
386
387 /**
388 * Test save a new bookmark
389 */
390 public function testSaveBookmark(): void
391 {
392 $id = 21;
393 $parameters = [
394 'lf_url' => 'http://url.tld/other?part=3#hash',
395 'lf_title' => 'Provided Title',
396 'lf_description' => 'Provided description.',
397 'lf_tags' => 'abc def',
398 'lf_private' => '1',
399 'returnurl' => 'http://shaarli.tld/subfolder/add-shaare'
400 ];
401
402 $request = $this->createMock(Request::class);
403 $request
404 ->method('getParam')
405 ->willReturnCallback(function (string $key) use ($parameters): ?string {
406 return $parameters[$key] ?? null;
407 })
408 ;
409 $request->method('getUri')->willReturnCallback(function (): Uri {
410 $uri = $this->createMock(Uri::class);
411 $uri->method('getBasePath')->willReturn('/subfolder');
412
413 return $uri;
414 });
415 $response = new Response();
416
417 $checkBookmark = function (Bookmark $bookmark) use ($parameters) {
418 static::assertSame($parameters['lf_url'], $bookmark->getUrl());
419 static::assertSame($parameters['lf_title'], $bookmark->getTitle());
420 static::assertSame($parameters['lf_description'], $bookmark->getDescription());
421 static::assertSame($parameters['lf_tags'], $bookmark->getTagsString());
422 static::assertTrue($bookmark->isPrivate());
423 };
424
425 $this->container->bookmarkService
426 ->expects(static::once())
427 ->method('addOrSet')
428 ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
429 static::assertFalse($save);
430
431 $checkBookmark($bookmark);
432
433 $bookmark->setId($id);
434 })
435 ;
436 $this->container->bookmarkService
437 ->expects(static::once())
438 ->method('set')
439 ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
440 static::assertTrue($save);
441
442 $checkBookmark($bookmark);
443
444 static::assertSame($id, $bookmark->getId());
445 })
446 ;
447
448 // Make sure that PluginManager hook is triggered
449 $this->container->pluginManager
450 ->expects(static::at(0))
451 ->method('executeHooks')
452 ->willReturnCallback(function (string $hook, array $data) use ($parameters, $id): array {
453 static::assertSame('save_link', $hook);
454
455 static::assertSame($id, $data['id']);
456 static::assertSame($parameters['lf_url'], $data['url']);
457 static::assertSame($parameters['lf_title'], $data['title']);
458 static::assertSame($parameters['lf_description'], $data['description']);
459 static::assertSame($parameters['lf_tags'], $data['tags']);
460 static::assertTrue($data['private']);
461
462 return $data;
463 })
464 ;
465
466 $result = $this->controller->save($request, $response);
467
468 static::assertSame(302, $result->getStatusCode());
469 static::assertRegExp('@/subfolder/#\w{6}@', $result->getHeader('location')[0]);
470 }
471
472
473 /**
474 * Test save an existing bookmark
475 */
476 public function testSaveExistingBookmark(): void
477 {
478 $id = 21;
479 $parameters = [
480 'lf_id' => (string) $id,
481 'lf_url' => 'http://url.tld/other?part=3#hash',
482 'lf_title' => 'Provided Title',
483 'lf_description' => 'Provided description.',
484 'lf_tags' => 'abc def',
485 'lf_private' => '1',
486 'returnurl' => 'http://shaarli.tld/subfolder/?page=2'
487 ];
488
489 $request = $this->createMock(Request::class);
490 $request
491 ->method('getParam')
492 ->willReturnCallback(function (string $key) use ($parameters): ?string {
493 return $parameters[$key] ?? null;
494 })
495 ;
496 $request->method('getUri')->willReturnCallback(function (): Uri {
497 $uri = $this->createMock(Uri::class);
498 $uri->method('getBasePath')->willReturn('/subfolder');
499
500 return $uri;
501 });
502 $response = new Response();
503
504 $checkBookmark = function (Bookmark $bookmark) use ($parameters, $id) {
505 static::assertSame($id, $bookmark->getId());
506 static::assertSame($parameters['lf_url'], $bookmark->getUrl());
507 static::assertSame($parameters['lf_title'], $bookmark->getTitle());
508 static::assertSame($parameters['lf_description'], $bookmark->getDescription());
509 static::assertSame($parameters['lf_tags'], $bookmark->getTagsString());
510 static::assertTrue($bookmark->isPrivate());
511 };
512
513 $this->container->bookmarkService->expects(static::atLeastOnce())->method('exists')->willReturn(true);
514 $this->container->bookmarkService
515 ->expects(static::once())
516 ->method('get')
517 ->willReturn((new Bookmark())->setId($id)->setUrl('http://other.url'))
518 ;
519 $this->container->bookmarkService
520 ->expects(static::once())
521 ->method('addOrSet')
522 ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
523 static::assertFalse($save);
524
525 $checkBookmark($bookmark);
526 })
527 ;
528 $this->container->bookmarkService
529 ->expects(static::once())
530 ->method('set')
531 ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
532 static::assertTrue($save);
533
534 $checkBookmark($bookmark);
535
536 static::assertSame($id, $bookmark->getId());
537 })
538 ;
539
540 // Make sure that PluginManager hook is triggered
541 $this->container->pluginManager
542 ->expects(static::at(0))
543 ->method('executeHooks')
544 ->willReturnCallback(function (string $hook, array $data) use ($parameters, $id): array {
545 static::assertSame('save_link', $hook);
546
547 static::assertSame($id, $data['id']);
548 static::assertSame($parameters['lf_url'], $data['url']);
549 static::assertSame($parameters['lf_title'], $data['title']);
550 static::assertSame($parameters['lf_description'], $data['description']);
551 static::assertSame($parameters['lf_tags'], $data['tags']);
552 static::assertTrue($data['private']);
553
554 return $data;
555 })
556 ;
557
558 $result = $this->controller->save($request, $response);
559
560 static::assertSame(302, $result->getStatusCode());
561 static::assertRegExp('@/subfolder/\?page=2#\w{6}@', $result->getHeader('location')[0]);
562 }
563
564 /**
565 * Test save a bookmark - try to retrieve the thumbnail
566 */
567 public function testSaveBookmarkWithThumbnail(): void
568 {
569 $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
570
571 $request = $this->createMock(Request::class);
572 $request
573 ->method('getParam')
574 ->willReturnCallback(function (string $key) use ($parameters): ?string {
575 return $parameters[$key] ?? null;
576 })
577 ;
578 $request->method('getUri')->willReturnCallback(function (): Uri {
579 $uri = $this->createMock(Uri::class);
580 $uri->method('getBasePath')->willReturn('/subfolder');
581
582 return $uri;
583 });
584 $response = new Response();
585
586 $this->container->conf = $this->createMock(ConfigManager::class);
587 $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
588 return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default;
589 });
590
591 $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
592 $this->container->thumbnailer
593 ->expects(static::once())
594 ->method('get')
595 ->with($parameters['lf_url'])
596 ->willReturn($thumb = 'http://thumb.url')
597 ;
598
599 $this->container->bookmarkService
600 ->expects(static::once())
601 ->method('addOrSet')
602 ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($thumb): void {
603 static::assertSame($thumb, $bookmark->getThumbnail());
604 })
605 ;
606
607 $result = $this->controller->save($request, $response);
608
609 static::assertSame(302, $result->getStatusCode());
610 }
611
612 /**
613 * Change the password with a wrong existing password
614 */
615 public function testSaveBookmarkFromBookmarklet(): void
616 {
617 $parameters = ['source' => 'bookmarklet'];
618
619 $request = $this->createMock(Request::class);
620 $request
621 ->method('getParam')
622 ->willReturnCallback(function (string $key) use ($parameters): ?string {
623 return $parameters[$key] ?? null;
624 })
625 ;
626 $response = new Response();
627
628 $result = $this->controller->save($request, $response);
629
630 static::assertSame(200, $result->getStatusCode());
631 static::assertSame('<script>self.close();</script>', (string) $result->getBody());
632 }
633
634 /**
635 * Change the password with a wrong existing password
636 */
637 public function testSaveBookmarkWrongToken(): void
638 {
639 $this->container->sessionManager = $this->createMock(SessionManager::class);
640 $this->container->sessionManager->method('checkToken')->willReturn(false);
641
642 $this->container->bookmarkService->expects(static::never())->method('addOrSet');
643 $this->container->bookmarkService->expects(static::never())->method('set');
644
645 $request = $this->createMock(Request::class);
646 $response = new Response();
647
648 $this->expectException(WrongTokenException::class);
649
650 $this->controller->save($request, $response);
651 }
652}
diff --git a/tpl/default/addlink.html b/tpl/default/addlink.html
index b4b4a0ec..999d2f4d 100644
--- a/tpl/default/addlink.html
+++ b/tpl/default/addlink.html
@@ -9,7 +9,7 @@
9 <div class="pure-u-lg-1-3 pure-u-1-24"></div> 9 <div class="pure-u-lg-1-3 pure-u-1-24"></div>
10 <div id="addlink-form" class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24"> 10 <div id="addlink-form" class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
11 <h2 class="window-title">{"Shaare a new link"|t}</h2> 11 <h2 class="window-title">{"Shaare a new link"|t}</h2>
12 <form method="GET" action="#" name="addform" class="addform"> 12 <form method="GET" action="./shaare" name="addform" class="addform">
13 <div> 13 <div>
14 <label for="shaare">{'URL or leave empty to post a note'|t}</label> 14 <label for="shaare">{'URL or leave empty to post a note'|t}</label>
15 <input type="text" name="post" id="shaare" class="autofocus"> 15 <input type="text" name="post" id="shaare" class="autofocus">
diff --git a/tpl/default/editlink.html b/tpl/default/editlink.html
index d16059a3..9f6d6b74 100644
--- a/tpl/default/editlink.html
+++ b/tpl/default/editlink.html
@@ -7,7 +7,11 @@
7 {include="page.header"} 7 {include="page.header"}
8 <div id="editlinkform" class="edit-link-container" class="pure-g"> 8 <div id="editlinkform" class="edit-link-container" class="pure-g">
9 <div class="pure-u-lg-1-5 pure-u-1-24"></div> 9 <div class="pure-u-lg-1-5 pure-u-1-24"></div>
10 <form method="post" name="linkform" class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light"> 10 <form method="post"
11 name="linkform"
12 action="./shaare"
13 class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light"
14 >
11 <h2 class="window-title"> 15 <h2 class="window-title">
12 {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if} 16 {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
13 </h2> 17 </h2>
diff --git a/tpl/default/page.header.html b/tpl/default/page.header.html
index bde5036d..cf59e89d 100644
--- a/tpl/default/page.header.html
+++ b/tpl/default/page.header.html
@@ -21,7 +21,7 @@
21 </li> 21 </li>
22 {if="$is_logged_in || $openshaarli"} 22 {if="$is_logged_in || $openshaarli"}
23 <li class="pure-menu-item"> 23 <li class="pure-menu-item">
24 <a href="./?do=addlink" class="pure-menu-link" id="shaarli-menu-shaare"> 24 <a href="./add-shaare" class="pure-menu-link" id="shaarli-menu-shaare">
25 <i class="fa fa-plus" aria-hidden="true"></i> {'Shaare'|t} 25 <i class="fa fa-plus" aria-hidden="true"></i> {'Shaare'|t}
26 </a> 26 </a>
27 </li> 27 </li>
diff --git a/tpl/vintage/addlink.html b/tpl/vintage/addlink.html
index da50f45e..13dbb36e 100644
--- a/tpl/vintage/addlink.html
+++ b/tpl/vintage/addlink.html
@@ -5,7 +5,7 @@
5<div id="pageheader"> 5<div id="pageheader">
6 {include="page.header"} 6 {include="page.header"}
7 <div id="headerform"> 7 <div id="headerform">
8 <form method="GET" action="" name="addform" class="addform"> 8 <form method="GET" action="./shaare" name="addform" class="addform">
9 <input type="text" name="post" class="linkurl"> 9 <input type="text" name="post" class="linkurl">
10 <input type="submit" value="Add link" class="bigbutton"> 10 <input type="submit" value="Add link" class="bigbutton">
11 </form> 11 </form>
diff --git a/tpl/vintage/page.header.html b/tpl/vintage/page.header.html
index c265d6d0..8f9b6cc5 100644
--- a/tpl/vintage/page.header.html
+++ b/tpl/vintage/page.header.html
@@ -20,10 +20,10 @@
20 {if="$is_logged_in"} 20 {if="$is_logged_in"}
21 <li><a href="./logout">Logout</a></li> 21 <li><a href="./logout">Logout</a></li>
22 <li><a href="./tools">Tools</a></li> 22 <li><a href="./tools">Tools</a></li>
23 <li><a href="?do=addlink">Add link</a></li> 23 <li><a href="./add-shaare">Add link</a></li>
24 {elseif="$openshaarli"} 24 {elseif="$openshaarli"}
25 <li><a href="./tools">Tools</a></li> 25 <li><a href="./tools">Tools</a></li>
26 <li><a href="./?do=addlink">Add link</a></li> 26 <li><a href="./add-shaare">Add link</a></li>
27 {else} 27 {else}
28 <li><a href="./login">Login</a></li> 28 <li><a href="./login">Login</a></li>
29 {/if} 29 {/if}