From 336a28fa4a09b968ce4705900bf57693e672f0bf Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 25 May 2019 15:46:47 +0200 Subject: Introduce Bookmark object and Service layer to retrieve them See https://github.com/shaarli/Shaarli/issues/1307 for details --- application/formatter/BookmarkDefaultFormatter.php | 81 +++++++ application/formatter/BookmarkFormatter.php | 256 +++++++++++++++++++++ .../formatter/BookmarkMarkdownFormatter.php | 198 ++++++++++++++++ application/formatter/BookmarkRawFormatter.php | 13 ++ application/formatter/FormatterFactory.php | 46 ++++ 5 files changed, 594 insertions(+) create mode 100644 application/formatter/BookmarkDefaultFormatter.php create mode 100644 application/formatter/BookmarkFormatter.php create mode 100644 application/formatter/BookmarkMarkdownFormatter.php create mode 100644 application/formatter/BookmarkRawFormatter.php create mode 100644 application/formatter/FormatterFactory.php (limited to 'application/formatter') diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php new file mode 100644 index 00000000..7550c556 --- /dev/null +++ b/application/formatter/BookmarkDefaultFormatter.php @@ -0,0 +1,81 @@ +getTitle()); + } + + /** + * @inheritdoc + */ + public function formatDescription($bookmark) + { + $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; + return format_description(escape($bookmark->getDescription()), $indexUrl); + } + + /** + * @inheritdoc + */ + protected function formatTagList($bookmark) + { + return escape($bookmark->getTags()); + } + + /** + * @inheritdoc + */ + public function formatTagString($bookmark) + { + return implode(' ', $this->formatTagList($bookmark)); + } + + /** + * @inheritdoc + */ + public function formatUrl($bookmark) + { + if (! empty($this->contextData['index_url']) && ( + startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') + )) { + return $this->contextData['index_url'] . escape($bookmark->getUrl()); + } + return escape($bookmark->getUrl()); + } + + /** + * @inheritdoc + */ + protected function formatRealUrl($bookmark) + { + if (! empty($this->contextData['index_url']) && ( + startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') + )) { + return $this->contextData['index_url'] . escape($bookmark->getUrl()); + } + return escape($bookmark->getUrl()); + } + + /** + * @inheritdoc + */ + protected function formatThumbnail($bookmark) + { + return escape($bookmark->getThumbnail()); + } +} diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php new file mode 100644 index 00000000..c82c3452 --- /dev/null +++ b/application/formatter/BookmarkFormatter.php @@ -0,0 +1,256 @@ +conf = $conf; + } + + /** + * Convert a Bookmark into an array usable by templates and plugins. + * + * All Bookmark attributes are formatted through a format method + * that can be overridden in a formatter extending this class. + * + * @param Bookmark $bookmark instance + * + * @return array formatted representation of a Bookmark + */ + public function format($bookmark) + { + $out['id'] = $this->formatId($bookmark); + $out['shorturl'] = $this->formatShortUrl($bookmark); + $out['url'] = $this->formatUrl($bookmark); + $out['real_url'] = $this->formatRealUrl($bookmark); + $out['title'] = $this->formatTitle($bookmark); + $out['description'] = $this->formatDescription($bookmark); + $out['thumbnail'] = $this->formatThumbnail($bookmark); + $out['taglist'] = $this->formatTagList($bookmark); + $out['tags'] = $this->formatTagString($bookmark); + $out['sticky'] = $bookmark->isSticky(); + $out['private'] = $bookmark->isPrivate(); + $out['class'] = $this->formatClass($bookmark); + $out['created'] = $this->formatCreated($bookmark); + $out['updated'] = $this->formatUpdated($bookmark); + $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); + $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); + return $out; + } + + /** + * Add additional data available to formatters. + * This is used for example to add `index_url` in description's links. + * + * @param string $key Context data key + * @param string $value Context data value + */ + public function addContextData($key, $value) + { + $this->contextData[$key] = $value; + } + + /** + * Format ID + * + * @param Bookmark $bookmark instance + * + * @return int formatted ID + */ + protected function formatId($bookmark) + { + return $bookmark->getId(); + } + + /** + * Format ShortUrl + * + * @param Bookmark $bookmark instance + * + * @return string formatted ShortUrl + */ + protected function formatShortUrl($bookmark) + { + return $bookmark->getShortUrl(); + } + + /** + * Format Url + * + * @param Bookmark $bookmark instance + * + * @return string formatted Url + */ + protected function formatUrl($bookmark) + { + return $bookmark->getUrl(); + } + + /** + * Format RealUrl + * Legacy: identical to Url + * + * @param Bookmark $bookmark instance + * + * @return string formatted RealUrl + */ + protected function formatRealUrl($bookmark) + { + return $bookmark->getUrl(); + } + + /** + * Format Title + * + * @param Bookmark $bookmark instance + * + * @return string formatted Title + */ + protected function formatTitle($bookmark) + { + return $bookmark->getTitle(); + } + + /** + * Format Description + * + * @param Bookmark $bookmark instance + * + * @return string formatted Description + */ + protected function formatDescription($bookmark) + { + return $bookmark->getDescription(); + } + + /** + * Format Thumbnail + * + * @param Bookmark $bookmark instance + * + * @return string formatted Thumbnail + */ + protected function formatThumbnail($bookmark) + { + return $bookmark->getThumbnail(); + } + + /** + * Format Tags + * + * @param Bookmark $bookmark instance + * + * @return array formatted Tags + */ + protected function formatTagList($bookmark) + { + return $bookmark->getTags(); + } + + /** + * Format TagString + * + * @param Bookmark $bookmark instance + * + * @return string formatted TagString + */ + protected function formatTagString($bookmark) + { + return implode(' ', $bookmark->getTags()); + } + + /** + * Format Class + * Used to add specific CSS class for a link + * + * @param Bookmark $bookmark instance + * + * @return string formatted Class + */ + protected function formatClass($bookmark) + { + return $bookmark->isPrivate() ? 'private' : ''; + } + + /** + * Format Created + * + * @param Bookmark $bookmark instance + * + * @return DateTime instance + */ + protected function formatCreated(Bookmark $bookmark) + { + return $bookmark->getCreated(); + } + + /** + * Format Updated + * + * @param Bookmark $bookmark instance + * + * @return DateTime instance + */ + protected function formatUpdated(Bookmark $bookmark) + { + return $bookmark->getUpdated(); + } + + /** + * Format CreatedTimestamp + * + * @param Bookmark $bookmark instance + * + * @return int formatted CreatedTimestamp + */ + protected function formatCreatedTimestamp(Bookmark $bookmark) + { + if (! empty($bookmark->getCreated())) { + return $bookmark->getCreated()->getTimestamp(); + } + return 0; + } + + /** + * Format UpdatedTimestamp + * + * @param Bookmark $bookmark instance + * + * @return int formatted UpdatedTimestamp + */ + protected function formatUpdatedTimestamp(Bookmark $bookmark) + { + if (! empty($bookmark->getUpdated())) { + return $bookmark->getUpdated()->getTimestamp(); + } + return 0; + } +} diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php new file mode 100644 index 00000000..f60c61f4 --- /dev/null +++ b/application/formatter/BookmarkMarkdownFormatter.php @@ -0,0 +1,198 @@ +parsedown = new \Parsedown(); + $this->escape = $conf->get('security.markdown_escape', true); + $this->allowedProtocols = $conf->get('security.allowed_protocols', []); + } + + /** + * @inheritdoc + */ + public function formatDescription($bookmark) + { + if (in_array(self::NO_MD_TAG, $bookmark->getTags())) { + return parent::formatDescription($bookmark); + } + + $processedDescription = $bookmark->getDescription(); + $processedDescription = $this->filterProtocols($processedDescription); + $processedDescription = $this->formatHashTags($processedDescription); + $processedDescription = $this->parsedown + ->setMarkupEscaped($this->escape) + ->setBreaksEnabled(true) + ->text($processedDescription); + $processedDescription = $this->sanitizeHtml($processedDescription); + + if (!empty($processedDescription)) { + $processedDescription = '
'. $processedDescription . '
'; + } + + return $processedDescription; + } + + /** + * Remove the NO markdown tag if it is present + * + * @inheritdoc + */ + protected function formatTagList($bookmark) + { + $out = parent::formatTagList($bookmark); + if (($pos = array_search(self::NO_MD_TAG, $out)) !== false) { + unset($out[$pos]); + return array_values($out); + } + return $out; + } + + /** + * Replace not whitelisted protocols with http:// in given description. + * Also adds `index_url` to relative links if it's specified + * + * @param string $description input description text. + * + * @return string $description without malicious link. + */ + protected function filterProtocols($description) + { + $allowedProtocols = $this->allowedProtocols; + $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; + + return preg_replace_callback( + '#]\((.*?)\)#is', + function ($match) use ($allowedProtocols, $indexUrl) { + $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : ''; + $link .= whitelist_protocols($match[1], $allowedProtocols); + return ']('. $link.')'; + }, + $description + ); + } + + /** + * Replace hashtag in Markdown links format + * E.g. `#hashtag` becomes `[#hashtag](?addtag=hashtag)` + * It includes the index URL if specified. + * + * @param string $description + * + * @return string + */ + protected function formatHashTags($description) + { + $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; + + /* + * To support unicode: http://stackoverflow.com/a/35498078/1484919 + * \p{Pc} - to match underscore + * \p{N} - numeric character in any script + * \p{L} - letter from any language + * \p{Mn} - any non marking space (accents, umlauts, etc) + */ + $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; + $replacement = '$1[#$2]('. $indexUrl .'?addtag=$2)'; + + $descriptionLines = explode(PHP_EOL, $description); + $descriptionOut = ''; + $codeBlockOn = false; + $lineCount = 0; + + foreach ($descriptionLines as $descriptionLine) { + // Detect line of code: starting with 4 spaces, + // except lists which can start with +/*/- or `2.` after spaces. + $codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0; + // Detect and toggle block of code + if (!$codeBlockOn) { + $codeBlockOn = preg_match('/^```/', $descriptionLine) > 0; + } elseif (preg_match('/^```/', $descriptionLine) > 0) { + $codeBlockOn = false; + } + + if (!$codeBlockOn && !$codeLineOn) { + $descriptionLine = preg_replace($regex, $replacement, $descriptionLine); + } + + $descriptionOut .= $descriptionLine; + if ($lineCount++ < count($descriptionLines) - 1) { + $descriptionOut .= PHP_EOL; + } + } + + return $descriptionOut; + } + + /** + * Remove dangerous HTML tags (tags, iframe, etc.). + * Doesn't affect content (already escaped by Parsedown). + * + * @param string $description input description text. + * + * @return string given string escaped. + */ + protected function sanitizeHtml($description) + { + $escapeTags = array( + 'script', + 'style', + 'link', + 'iframe', + 'frameset', + 'frame', + ); + foreach ($escapeTags as $tag) { + $description = preg_replace_callback( + '#<\s*'. $tag .'[^>]*>(.*]*>)?#is', + function ($match) { + return escape($match[0]); + }, + $description + ); + } + $description = preg_replace( + '#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is', + '$1', + $description + ); + return $description; + } +} 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 @@ +conf = $conf; + } + + /** + * Instanciate a BookmarkFormatter depending on the configuration or provided formatter type. + * + * @param string|null $type force a specific type regardless of the configuration + * + * @return BookmarkFormatter instance. + */ + public function getFormatter($type = null) + { + $type = $type ? $type : $this->conf->get('formatter', 'default'); + $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; + if (!class_exists($className)) { + $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter'; + } + + return new $className($this->conf); + } +} -- cgit v1.2.3