aboutsummaryrefslogtreecommitdiffhomepage
path: root/application/formatter
diff options
context:
space:
mode:
Diffstat (limited to 'application/formatter')
-rw-r--r--application/formatter/BookmarkDefaultFormatter.php87
-rw-r--r--application/formatter/BookmarkFormatter.php313
-rw-r--r--application/formatter/BookmarkMarkdownFormatter.php206
-rw-r--r--application/formatter/BookmarkRawFormatter.php13
-rw-r--r--application/formatter/FormatterFactory.php51
5 files changed, 670 insertions, 0 deletions
diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php
new file mode 100644
index 00000000..9d4a0fa0
--- /dev/null
+++ b/application/formatter/BookmarkDefaultFormatter.php
@@ -0,0 +1,87 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5/**
6 * Class BookmarkDefaultFormatter
7 *
8 * Default bookmark formatter.
9 * Escape values for HTML display and automatically add link to URL and hashtags.
10 *
11 * @package Shaarli\Formatter
12 */
13class BookmarkDefaultFormatter extends BookmarkFormatter
14{
15 /**
16 * @inheritdoc
17 */
18 public function formatTitle($bookmark)
19 {
20 return escape($bookmark->getTitle());
21 }
22
23 /**
24 * @inheritdoc
25 */
26 public function formatDescription($bookmark)
27 {
28 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
29 return format_description(escape($bookmark->getDescription()), $indexUrl);
30 }
31
32 /**
33 * @inheritdoc
34 */
35 protected function formatTagList($bookmark)
36 {
37 return escape(parent::formatTagList($bookmark));
38 }
39
40 /**
41 * @inheritdoc
42 */
43 public function formatTagString($bookmark)
44 {
45 return implode(' ', $this->formatTagList($bookmark));
46 }
47
48 /**
49 * @inheritdoc
50 */
51 public function formatUrl($bookmark)
52 {
53 if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
54 return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
55 }
56
57 return escape($bookmark->getUrl());
58 }
59
60 /**
61 * @inheritdoc
62 */
63 protected function formatRealUrl($bookmark)
64 {
65 if ($bookmark->isNote()) {
66 if (isset($this->contextData['index_url'])) {
67 $prefix = rtrim($this->contextData['index_url'], '/') . '/';
68 }
69
70 if (isset($this->contextData['base_path'])) {
71 $prefix = rtrim($this->contextData['base_path'], '/') . '/';
72 }
73
74 return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl(), '/'));
75 }
76
77 return escape($bookmark->getUrl());
78 }
79
80 /**
81 * @inheritdoc
82 */
83 protected function formatThumbnail($bookmark)
84 {
85 return escape($bookmark->getThumbnail());
86 }
87}
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php
new file mode 100644
index 00000000..0042dafe
--- /dev/null
+++ b/application/formatter/BookmarkFormatter.php
@@ -0,0 +1,313 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use DateTime;
6use Shaarli\Bookmark\Bookmark;
7use Shaarli\Config\ConfigManager;
8
9/**
10 * Class BookmarkFormatter
11 *
12 * Abstract class processing all bookmark attributes through methods designed to be overridden.
13 *
14 * @package Shaarli\Formatter
15 */
16abstract class BookmarkFormatter
17{
18 /**
19 * @var ConfigManager
20 */
21 protected $conf;
22
23 /** @var bool */
24 protected $isLoggedIn;
25
26 /**
27 * @var array Additional parameters than can be used for specific formatting
28 * e.g. index_url for Feed formatting
29 */
30 protected $contextData = [];
31
32 /**
33 * LinkDefaultFormatter constructor.
34 * @param ConfigManager $conf
35 */
36 public function __construct(ConfigManager $conf, bool $isLoggedIn)
37 {
38 $this->conf = $conf;
39 $this->isLoggedIn = $isLoggedIn;
40 }
41
42 /**
43 * Convert a Bookmark into an array usable by templates and plugins.
44 *
45 * All Bookmark attributes are formatted through a format method
46 * that can be overridden in a formatter extending this class.
47 *
48 * @param Bookmark $bookmark instance
49 *
50 * @return array formatted representation of a Bookmark
51 */
52 public function format($bookmark)
53 {
54 $out['id'] = $this->formatId($bookmark);
55 $out['shorturl'] = $this->formatShortUrl($bookmark);
56 $out['url'] = $this->formatUrl($bookmark);
57 $out['real_url'] = $this->formatRealUrl($bookmark);
58 $out['title'] = $this->formatTitle($bookmark);
59 $out['description'] = $this->formatDescription($bookmark);
60 $out['thumbnail'] = $this->formatThumbnail($bookmark);
61 $out['urlencoded_taglist'] = $this->formatUrlEncodedTagList($bookmark);
62 $out['taglist'] = $this->formatTagList($bookmark);
63 $out['urlencoded_tags'] = $this->formatUrlEncodedTagString($bookmark);
64 $out['tags'] = $this->formatTagString($bookmark);
65 $out['sticky'] = $bookmark->isSticky();
66 $out['private'] = $bookmark->isPrivate();
67 $out['class'] = $this->formatClass($bookmark);
68 $out['created'] = $this->formatCreated($bookmark);
69 $out['updated'] = $this->formatUpdated($bookmark);
70 $out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
71 $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
72 return $out;
73 }
74
75 /**
76 * Add additional data available to formatters.
77 * This is used for example to add `index_url` in description's links.
78 *
79 * @param string $key Context data key
80 * @param string $value Context data value
81 */
82 public function addContextData($key, $value)
83 {
84 $this->contextData[$key] = $value;
85
86 return $this;
87 }
88
89 /**
90 * Format ID
91 *
92 * @param Bookmark $bookmark instance
93 *
94 * @return int formatted ID
95 */
96 protected function formatId($bookmark)
97 {
98 return $bookmark->getId();
99 }
100
101 /**
102 * Format ShortUrl
103 *
104 * @param Bookmark $bookmark instance
105 *
106 * @return string formatted ShortUrl
107 */
108 protected function formatShortUrl($bookmark)
109 {
110 return $bookmark->getShortUrl();
111 }
112
113 /**
114 * Format Url
115 *
116 * @param Bookmark $bookmark instance
117 *
118 * @return string formatted Url
119 */
120 protected function formatUrl($bookmark)
121 {
122 return $bookmark->getUrl();
123 }
124
125 /**
126 * Format RealUrl
127 * Legacy: identical to Url
128 *
129 * @param Bookmark $bookmark instance
130 *
131 * @return string formatted RealUrl
132 */
133 protected function formatRealUrl($bookmark)
134 {
135 return $this->formatUrl($bookmark);
136 }
137
138 /**
139 * Format Title
140 *
141 * @param Bookmark $bookmark instance
142 *
143 * @return string formatted Title
144 */
145 protected function formatTitle($bookmark)
146 {
147 return $bookmark->getTitle();
148 }
149
150 /**
151 * Format Description
152 *
153 * @param Bookmark $bookmark instance
154 *
155 * @return string formatted Description
156 */
157 protected function formatDescription($bookmark)
158 {
159 return $bookmark->getDescription();
160 }
161
162 /**
163 * Format Thumbnail
164 *
165 * @param Bookmark $bookmark instance
166 *
167 * @return string formatted Thumbnail
168 */
169 protected function formatThumbnail($bookmark)
170 {
171 return $bookmark->getThumbnail();
172 }
173
174 /**
175 * Format Tags
176 *
177 * @param Bookmark $bookmark instance
178 *
179 * @return array formatted Tags
180 */
181 protected function formatTagList($bookmark)
182 {
183 return $this->filterTagList($bookmark->getTags());
184 }
185
186 /**
187 * Format Url Encoded Tags
188 *
189 * @param Bookmark $bookmark instance
190 *
191 * @return array formatted Tags
192 */
193 protected function formatUrlEncodedTagList($bookmark)
194 {
195 return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
196 }
197
198 /**
199 * Format TagString
200 *
201 * @param Bookmark $bookmark instance
202 *
203 * @return string formatted TagString
204 */
205 protected function formatTagString($bookmark)
206 {
207 return implode(' ', $this->formatTagList($bookmark));
208 }
209
210 /**
211 * Format TagString
212 *
213 * @param Bookmark $bookmark instance
214 *
215 * @return string formatted TagString
216 */
217 protected function formatUrlEncodedTagString($bookmark)
218 {
219 return implode(' ', $this->formatUrlEncodedTagList($bookmark));
220 }
221
222 /**
223 * Format Class
224 * Used to add specific CSS class for a link
225 *
226 * @param Bookmark $bookmark instance
227 *
228 * @return string formatted Class
229 */
230 protected function formatClass($bookmark)
231 {
232 return $bookmark->isPrivate() ? 'private' : '';
233 }
234
235 /**
236 * Format Created
237 *
238 * @param Bookmark $bookmark instance
239 *
240 * @return DateTime instance
241 */
242 protected function formatCreated(Bookmark $bookmark)
243 {
244 return $bookmark->getCreated();
245 }
246
247 /**
248 * Format Updated
249 *
250 * @param Bookmark $bookmark instance
251 *
252 * @return DateTime instance
253 */
254 protected function formatUpdated(Bookmark $bookmark)
255 {
256 return $bookmark->getUpdated();
257 }
258
259 /**
260 * Format CreatedTimestamp
261 *
262 * @param Bookmark $bookmark instance
263 *
264 * @return int formatted CreatedTimestamp
265 */
266 protected function formatCreatedTimestamp(Bookmark $bookmark)
267 {
268 if (! empty($bookmark->getCreated())) {
269 return $bookmark->getCreated()->getTimestamp();
270 }
271 return 0;
272 }
273
274 /**
275 * Format UpdatedTimestamp
276 *
277 * @param Bookmark $bookmark instance
278 *
279 * @return int formatted UpdatedTimestamp
280 */
281 protected function formatUpdatedTimestamp(Bookmark $bookmark)
282 {
283 if (! empty($bookmark->getUpdated())) {
284 return $bookmark->getUpdated()->getTimestamp();
285 }
286 return 0;
287 }
288
289 /**
290 * Format tag list, e.g. remove private tags if the user is not logged in.
291 *
292 * @param array $tags
293 *
294 * @return array
295 */
296 protected function filterTagList(array $tags): array
297 {
298 if ($this->isLoggedIn === true) {
299 return $tags;
300 }
301
302 $out = [];
303 foreach ($tags as $tag) {
304 if (strpos($tag, '.') === 0) {
305 continue;
306 }
307
308 $out[] = $tag;
309 }
310
311 return $out;
312 }
313}
diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php
new file mode 100644
index 00000000..5d244d4c
--- /dev/null
+++ b/application/formatter/BookmarkMarkdownFormatter.php
@@ -0,0 +1,206 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use Shaarli\Config\ConfigManager;
6
7/**
8 * Class BookmarkMarkdownFormatter
9 *
10 * Format bookmark description into Markdown format.
11 *
12 * @package Shaarli\Formatter
13 */
14class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
15{
16 /**
17 * When this tag is present in a bookmark, its description should not be processed with Markdown
18 */
19 const NO_MD_TAG = 'nomarkdown';
20
21 /** @var \Parsedown instance */
22 protected $parsedown;
23
24 /** @var bool used to escape HTML in Markdown or not.
25 * It MUST be set to true for shared instance as HTML content can
26 * introduce XSS vulnerabilities.
27 */
28 protected $escape;
29
30 /**
31 * @var array List of allowed protocols for links inside bookmark's description.
32 */
33 protected $allowedProtocols;
34
35 /**
36 * LinkMarkdownFormatter constructor.
37 *
38 * @param ConfigManager $conf instance
39 * @param bool $isLoggedIn
40 */
41 public function __construct(ConfigManager $conf, bool $isLoggedIn)
42 {
43 parent::__construct($conf, $isLoggedIn);
44
45 $this->parsedown = new \Parsedown();
46 $this->escape = $conf->get('security.markdown_escape', true);
47 $this->allowedProtocols = $conf->get('security.allowed_protocols', []);
48 }
49
50 /**
51 * @inheritdoc
52 */
53 public function formatDescription($bookmark)
54 {
55 if (in_array(self::NO_MD_TAG, $bookmark->getTags())) {
56 return parent::formatDescription($bookmark);
57 }
58
59 $processedDescription = $bookmark->getDescription();
60 $processedDescription = $this->filterProtocols($processedDescription);
61 $processedDescription = $this->formatHashTags($processedDescription);
62 $processedDescription = $this->reverseEscapedHtml($processedDescription);
63 $processedDescription = $this->parsedown
64 ->setMarkupEscaped($this->escape)
65 ->setBreaksEnabled(true)
66 ->text($processedDescription);
67 $processedDescription = $this->sanitizeHtml($processedDescription);
68
69 if (!empty($processedDescription)) {
70 $processedDescription = '<div class="markdown">'. $processedDescription . '</div>';
71 }
72
73 return $processedDescription;
74 }
75
76 /**
77 * Remove the NO markdown tag if it is present
78 *
79 * @inheritdoc
80 */
81 protected function formatTagList($bookmark)
82 {
83 $out = parent::formatTagList($bookmark);
84 if ($this->isLoggedIn === false && ($pos = array_search(self::NO_MD_TAG, $out)) !== false) {
85 unset($out[$pos]);
86 return array_values($out);
87 }
88 return $out;
89 }
90
91 /**
92 * Replace not whitelisted protocols with http:// in given description.
93 * Also adds `index_url` to relative links if it's specified
94 *
95 * @param string $description input description text.
96 *
97 * @return string $description without malicious link.
98 */
99 protected function filterProtocols($description)
100 {
101 $allowedProtocols = $this->allowedProtocols;
102 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
103
104 return preg_replace_callback(
105 '#]\((.*?)\)#is',
106 function ($match) use ($allowedProtocols, $indexUrl) {
107 $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
108 $link .= whitelist_protocols($match[1], $allowedProtocols);
109 return ']('. $link.')';
110 },
111 $description
112 );
113 }
114
115 /**
116 * Replace hashtag in Markdown links format
117 * E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)`
118 * It includes the index URL if specified.
119 *
120 * @param string $description
121 *
122 * @return string
123 */
124 protected function formatHashTags($description)
125 {
126 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
127
128 /*
129 * To support unicode: http://stackoverflow.com/a/35498078/1484919
130 * \p{Pc} - to match underscore
131 * \p{N} - numeric character in any script
132 * \p{L} - letter from any language
133 * \p{Mn} - any non marking space (accents, umlauts, etc)
134 */
135 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
136 $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)';
137
138 $descriptionLines = explode(PHP_EOL, $description);
139 $descriptionOut = '';
140 $codeBlockOn = false;
141 $lineCount = 0;
142
143 foreach ($descriptionLines as $descriptionLine) {
144 // Detect line of code: starting with 4 spaces,
145 // except lists which can start with +/*/- or `2.` after spaces.
146 $codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0;
147 // Detect and toggle block of code
148 if (!$codeBlockOn) {
149 $codeBlockOn = preg_match('/^```/', $descriptionLine) > 0;
150 } elseif (preg_match('/^```/', $descriptionLine) > 0) {
151 $codeBlockOn = false;
152 }
153
154 if (!$codeBlockOn && !$codeLineOn) {
155 $descriptionLine = preg_replace($regex, $replacement, $descriptionLine);
156 }
157
158 $descriptionOut .= $descriptionLine;
159 if ($lineCount++ < count($descriptionLines) - 1) {
160 $descriptionOut .= PHP_EOL;
161 }
162 }
163
164 return $descriptionOut;
165 }
166
167 /**
168 * Remove dangerous HTML tags (tags, iframe, etc.).
169 * Doesn't affect <code> content (already escaped by Parsedown).
170 *
171 * @param string $description input description text.
172 *
173 * @return string given string escaped.
174 */
175 protected function sanitizeHtml($description)
176 {
177 $escapeTags = array(
178 'script',
179 'style',
180 'link',
181 'iframe',
182 'frameset',
183 'frame',
184 );
185 foreach ($escapeTags as $tag) {
186 $description = preg_replace_callback(
187 '#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is',
188 function ($match) {
189 return escape($match[0]);
190 },
191 $description
192 );
193 }
194 $description = preg_replace(
195 '#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is',
196 '$1',
197 $description
198 );
199 return $description;
200 }
201
202 protected function reverseEscapedHtml($description)
203 {
204 return unescape($description);
205 }
206}
diff --git a/application/formatter/BookmarkRawFormatter.php b/application/formatter/BookmarkRawFormatter.php
new file mode 100644
index 00000000..bc372273
--- /dev/null
+++ b/application/formatter/BookmarkRawFormatter.php
@@ -0,0 +1,13 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5/**
6 * Class BookmarkRawFormatter
7 *
8 * Used to retrieve bookmarks as array with raw values.
9 * Warning: Do NOT use this for HTML content as it can introduce XSS vulnerabilities.
10 *
11 * @package Shaarli\Formatter
12 */
13class BookmarkRawFormatter extends BookmarkFormatter {}
diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php
new file mode 100644
index 00000000..a029579f
--- /dev/null
+++ b/application/formatter/FormatterFactory.php
@@ -0,0 +1,51 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use Shaarli\Config\ConfigManager;
6
7/**
8 * Class FormatterFactory
9 *
10 * Helper class used to instantiate the proper BookmarkFormatter.
11 *
12 * @package Shaarli\Formatter
13 */
14class FormatterFactory
15{
16 /** @var ConfigManager instance */
17 protected $conf;
18
19 /** @var bool */
20 protected $isLoggedIn;
21
22 /**
23 * FormatterFactory constructor.
24 *
25 * @param ConfigManager $conf
26 * @param bool $isLoggedIn
27 */
28 public function __construct(ConfigManager $conf, bool $isLoggedIn)
29 {
30 $this->conf = $conf;
31 $this->isLoggedIn = $isLoggedIn;
32 }
33
34 /**
35 * Instanciate a BookmarkFormatter depending on the configuration or provided formatter type.
36 *
37 * @param string|null $type force a specific type regardless of the configuration
38 *
39 * @return BookmarkFormatter instance.
40 */
41 public function getFormatter(string $type = null): BookmarkFormatter
42 {
43 $type = $type ? $type : $this->conf->get('formatter', 'default');
44 $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter';
45 if (!class_exists($className)) {
46 $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
47 }
48
49 return new $className($this->conf, $this->isLoggedIn);
50 }
51}