]> git.mxchange.org Git - friendica.git/blobdiff - src/Content/Text/BBCode.php
Merge pull request #6580 from MrPetovan/bug/6575-escaped-field-input-attributes
[friendica.git] / src / Content / Text / BBCode.php
index 8d5da11cdaae4b8477346642bb98dfe106924e25..a8b5ec2025546e7ffa0e694597d2f38524ccd8b1 100644 (file)
@@ -11,11 +11,13 @@ use Exception;
 use Friendica\BaseObject;
 use Friendica\Content\OEmbed;
 use Friendica\Content\Smilies;
-use Friendica\Core\Addon;
 use Friendica\Core\Cache;
 use Friendica\Core\Config;
+use Friendica\Core\Hook;
 use Friendica\Core\L10n;
+use Friendica\Core\Logger;
 use Friendica\Core\Protocol;
+use Friendica\Core\Renderer;
 use Friendica\Core\System;
 use Friendica\Model\Contact;
 use Friendica\Model\Event;
@@ -25,6 +27,8 @@ use Friendica\Util\Map;
 use Friendica\Util\Network;
 use Friendica\Util\ParseUrl;
 use Friendica\Util\Proxy as ProxyUtils;
+use Friendica\Util\Strings;
+use Friendica\Util\XML;
 
 class BBCode extends BaseObject
 {
@@ -33,13 +37,14 @@ class BBCode extends BaseObject
         *
         * @param string $body Message body
         * @return array
-        * 'type' -> Message type ("link", "video", "photo")
-        * 'text' -> Text before the shared message
-        * 'after' -> Text after the shared message
-        * 'image' -> Preview image of the message
-        * 'url' -> Url to the attached message
-        * 'title' -> Title of the attachment
-        * 'description' -> Description of the attachment
+        *                     'type' -> Message type ("link", "video", "photo")
+        *                     'text' -> Text before the shared message
+        *                     'after' -> Text after the shared message
+        *                     'image' -> Preview image of the message
+        *                     'url' -> Url to the attached message
+        *                     'title' -> Title of the attachment
+        *                     'description' -> Description of the attachment
+        * @throws \Friendica\Network\HTTPException\InternalServerErrorException
         */
        private static function getOldAttachmentData($body)
        {
@@ -104,13 +109,14 @@ class BBCode extends BaseObject
         *
         * @param string $body Message body
         * @return array
-        * 'type' -> Message type ("link", "video", "photo")
-        * 'text' -> Text before the shared message
-        * 'after' -> Text after the shared message
-        * 'image' -> Preview image of the message
-        * 'url' -> Url to the attached message
-        * 'title' -> Title of the attachment
-        * 'description' -> Description of the attachment
+        *                     'type' -> Message type ("link", "video", "photo")
+        *                     'text' -> Text before the shared message
+        *                     'after' -> Text after the shared message
+        *                     'image' -> Preview image of the message
+        *                     'url' -> Url to the attached message
+        *                     'title' -> Title of the attachment
+        *                     'description' -> Description of the attachment
+        * @throws \Friendica\Network\HTTPException\InternalServerErrorException
         */
        public static function getAttachmentData($body)
        {
@@ -126,12 +132,12 @@ class BBCode extends BaseObject
 
                $type = "";
                preg_match("/type='(.*?)'/ism", $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $type = strtolower($matches[1]);
                }
 
                preg_match('/type="(.*?)"/ism', $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $type = strtolower($matches[1]);
                }
 
@@ -149,12 +155,12 @@ class BBCode extends BaseObject
 
                $url = "";
                preg_match("/url='(.*?)'/ism", $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $url = $matches[1];
                }
 
                preg_match('/url="(.*?)"/ism', $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $url = $matches[1];
                }
 
@@ -164,12 +170,12 @@ class BBCode extends BaseObject
 
                $title = "";
                preg_match("/title='(.*?)'/ism", $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $title = $matches[1];
                }
 
                preg_match('/title="(.*?)"/ism', $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $title = $matches[1];
                }
 
@@ -182,12 +188,12 @@ class BBCode extends BaseObject
 
                $image = "";
                preg_match("/image='(.*?)'/ism", $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $image = $matches[1];
                }
 
                preg_match('/image="(.*?)"/ism', $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $image = $matches[1];
                }
 
@@ -197,12 +203,12 @@ class BBCode extends BaseObject
 
                $preview = "";
                preg_match("/preview='(.*?)'/ism", $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $preview = $matches[1];
                }
 
                preg_match('/preview="(.*?)"/ism', $attributes, $matches);
-               if (x($matches, 1)) {
+               if (!empty($matches[1])) {
                        $preview = $matches[1];
                }
 
@@ -230,7 +236,7 @@ class BBCode extends BaseObject
                */
 
                $has_title = !empty($item['title']);
-               $plink = (!empty($item['plink']) ? $item['plink'] : '');
+               $plink = defaults($item, 'plink', '');
                $post = self::getAttachmentData($body);
 
                // if nothing is found, it maybe having an image.
@@ -341,6 +347,7 @@ class BBCode extends BaseObject
        /**
         * @brief Converts a BBCode text into plaintext
         *
+        * @param      $text
         * @param bool $keep_urls Whether to keep URLs in the resulting plaintext
         *
         * @return string
@@ -379,7 +386,7 @@ class BBCode extends BaseObject
                $c = preg_match_all('/\[img.*?\](.*?)\[\/img\]/ism', $s, $matches, PREG_SET_ORDER);
                if ($c) {
                        foreach ($matches as $mtch) {
-                               logger('scale_external_image: ' . $mtch[1]);
+                               Logger::log('scale_external_image: ' . $mtch[1]);
 
                                $hostname = str_replace('www.', '', substr(System::baseUrl(), strpos(System::baseUrl(), '://') + 3));
                                if (stristr($mtch[1], $hostname)) {
@@ -414,7 +421,7 @@ class BBCode extends BaseObject
                                                        $Image->scaleDown(640);
                                                        $new_width = $Image->getWidth();
                                                        $new_height = $Image->getHeight();
-                                                       logger('scale_external_images: ' . $orig_width . '->' . $new_width . 'w ' . $orig_height . '->' . $new_height . 'h' . ' match: ' . $mtch[0], LOGGER_DEBUG);
+                                                       Logger::log('scale_external_images: ' . $orig_width . '->' . $new_width . 'w ' . $orig_height . '->' . $new_height . 'h' . ' match: ' . $mtch[0], Logger::DEBUG);
                                                        $s = str_replace(
                                                                $mtch[0],
                                                                '[img=' . $new_width . 'x' . $new_height. ']' . $scaled . '[/img]'
@@ -423,7 +430,7 @@ class BBCode extends BaseObject
                                                                        : ''),
                                                                $s
                                                        );
-                                                       logger('scale_external_images: new string: ' . $s, LOGGER_DEBUG);
+                                                       Logger::log('scale_external_images: new string: ' . $s, Logger::DEBUG);
                                                }
                                        }
                                }
@@ -442,6 +449,7 @@ class BBCode extends BaseObject
         * @brief Truncates imported message body string length to max_import_size
         * @param string $body
         * @return string
+        * @throws \Friendica\Network\HTTPException\InternalServerErrorException
         */
        public static function limitBodySize($body)
        {
@@ -451,7 +459,7 @@ class BBCode extends BaseObject
                // than the maximum, then don't waste time looking for the images
                if ($maxlen && (strlen($body) > $maxlen)) {
 
-                       logger('the total body length exceeds the limit', LOGGER_DEBUG);
+                       Logger::log('the total body length exceeds the limit', Logger::DEBUG);
 
                        $orig_body = $body;
                        $new_body = '';
@@ -471,7 +479,7 @@ class BBCode extends BaseObject
 
                                        if (($textlen + $img_start) > $maxlen) {
                                                if ($textlen < $maxlen) {
-                                                       logger('the limit happens before an embedded image', LOGGER_DEBUG);
+                                                       Logger::log('the limit happens before an embedded image', Logger::DEBUG);
                                                        $new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen);
                                                        $textlen = $maxlen;
                                                }
@@ -485,7 +493,7 @@ class BBCode extends BaseObject
 
                                        if (($textlen + $img_end) > $maxlen) {
                                                if ($textlen < $maxlen) {
-                                                       logger('the limit happens before the end of a non-embedded image', LOGGER_DEBUG);
+                                                       Logger::log('the limit happens before the end of a non-embedded image', Logger::DEBUG);
                                                        $new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen);
                                                        $textlen = $maxlen;
                                                }
@@ -508,11 +516,11 @@ class BBCode extends BaseObject
 
                        if (($textlen + strlen($orig_body)) > $maxlen) {
                                if ($textlen < $maxlen) {
-                                       logger('the limit happens after the end of the last image', LOGGER_DEBUG);
+                                       Logger::log('the limit happens after the end of the last image', Logger::DEBUG);
                                        $new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen);
                                }
                        } else {
-                               logger('the text size with embedded images extracted did not violate the limit', LOGGER_DEBUG);
+                               Logger::log('the text size with embedded images extracted did not violate the limit', Logger::DEBUG);
                                $new_body = $new_body . $orig_body;
                        }
 
@@ -528,10 +536,11 @@ class BBCode extends BaseObject
         * Note: Can produce a [bookmark] tag in the returned string
         *
         * @brief Processes [attachment] tags
-        * @param string $return
+        * @param string   $return
         * @param bool|int $simplehtml
-        * @param bool $tryoembed
+        * @param bool     $tryoembed
         * @return string
+        * @throws \Friendica\Network\HTTPException\InternalServerErrorException
         */
        private static function convertAttachment($return, $simplehtml = false, $tryoembed = true)
        {
@@ -622,7 +631,7 @@ class BBCode extends BaseObject
                        $data["title"] = $data["url"];
                }
 
-               if (($data["text"] == "") && ($data["title"] != "") && ($data["url"] == "")) {
+               if (empty($data["text"]) && !empty($data["title"]) && empty($data["url"])) {
                        return $data["title"] . $data["after"];
                }
 
@@ -773,10 +782,10 @@ class BBCode extends BaseObject
        /**
         * Performs a preg_replace within the boundaries of all named BBCode tags in a text
         *
-        * @param type $pattern Preg pattern string
-        * @param type $replace Preg replace string
-        * @param type $name    BBCode tag name
-        * @param type $text    Text to search
+        * @param string $pattern Preg pattern string
+        * @param string $replace Preg replace string
+        * @param string $name    BBCode tag name
+        * @param string $text    Text to search
         * @return string
         */
        public static function pregReplaceInTag($pattern, $replace, $name, $text)
@@ -926,6 +935,7 @@ class BBCode extends BaseObject
         * @param boolean $is_quote_share Whether there is content before the [share] block
         * @param integer $simplehtml     Mysterious integer value depending on the target network/formatting style
         * @return string
+        * @throws \Friendica\Network\HTTPException\InternalServerErrorException
         */
        private static function convertShareCallback(array $attributes, array $author_contact, $content, $is_quote_share, $simplehtml)
        {
@@ -941,7 +951,7 @@ class BBCode extends BaseObject
                        case 3: // Diaspora
                                $headline = '<p><b>' . html_entity_decode('&#x2672; ', ENT_QUOTES, 'UTF-8') . $mention . ':</b></p>' . "\n";
 
-                               if (stripos(normalise_link($attributes['link']), 'http://twitter.com/') === 0) {
+                               if (stripos(Strings::normaliseLink($attributes['link']), 'http://twitter.com/') === 0) {
                                        $text = ($is_quote_share? '<hr />' : '') . '<p><a href="' . $attributes['link'] . '">' . $attributes['link'] . '</a></p>' . "\n";
                                } else {
                                        $text = ($is_quote_share? '<hr />' : '') . $headline . '<blockquote>' . trim($content) . '</blockquote>' . "\n";
@@ -976,7 +986,7 @@ class BBCode extends BaseObject
                                break;
                        default:
                                // Transforms quoted tweets in rich attachments to avoid nested tweets
-                               if (stripos(normalise_link($attributes['link']), 'http://twitter.com/') === 0 && OEmbed::isAllowedURL($attributes['link'])) {
+                               if (stripos(Strings::normaliseLink($attributes['link']), 'http://twitter.com/') === 0 && OEmbed::isAllowedURL($attributes['link'])) {
                                        try {
                                                $text = ($is_quote_share? '<br />' : '') . OEmbed::getHTML($attributes['link']);
                                        } catch (Exception $e) {
@@ -985,8 +995,8 @@ class BBCode extends BaseObject
                                } else {
                                        $text = ($is_quote_share? "\n" : '');
 
-                                       $tpl = get_markup_template('shared_content.tpl');
-                                       $text .= replace_macros($tpl, [
+                                       $tpl = Renderer::getMarkupTemplate('shared_content.tpl');
+                                       $text .= Renderer::replaceMacros($tpl, [
                                                '$profile' => $attributes['profile'],
                                                '$avatar'  => $attributes['avatar'],
                                                '$author'  => $attributes['author'],
@@ -1138,8 +1148,9 @@ class BBCode extends BaseObject
         * @param int    $simple_html
         * @param bool   $for_plaintext
         * @return string
+        * @throws \Friendica\Network\HTTPException\InternalServerErrorException
         */
-       public static function convert($text, $try_oembed = true, $simple_html = false, $for_plaintext = false)
+       public static function convert($text, $try_oembed = true, $simple_html = 0, $for_plaintext = false)
        {
                $a = self::getApp();
 
@@ -1167,7 +1178,7 @@ class BBCode extends BaseObject
                // Extracting multi-line code blocks before the whitespace processing
                $codeblocks = [];
 
-               $text = preg_replace_callback("#\[code(?:=([^\]]*))?\](.*?)\[\/code\]#is",
+               $text = preg_replace_callback("#\[code(?:=([^\]]*))?\](.*?)\[\/code\]#ism",
                        function ($matches) use (&$codeblocks) {
                                $return = $matches[0];
                                if (strpos($matches[2], "\n") !== false) {
@@ -1282,7 +1293,7 @@ class BBCode extends BaseObject
                                $text);
                } elseif ($simple_html == 7) {
                        $text = preg_replace("/([@!])\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
-                               '$1<span class="vcard"><a href="$2" class="url" title="$3"><span class="fn nickname mention">$3</span></a></span>',
+                               '$1<span class="vcard"><a href="$2" class="url u-url mention" title="$3"><span class="fn nickname mention">$3</span></a></span>',
                                $text);
                } elseif (!$simple_html) {
                        $text = preg_replace("/([@!])\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
@@ -1319,7 +1330,7 @@ class BBCode extends BaseObject
 
                // Handle Diaspora posts
                $text = preg_replace_callback(
-                       "&\[url=/posts/([^\[\]]*)\](.*)\[\/url\]&Usi",
+                       "&\[url=/?posts/([^\[\]]*)\](.*)\[\/url\]&Usi",
                        function ($match) {
                                return "[url=" . System::baseUrl() . "/display/" . $match[1] . "]" . $match[2] . "[/url]";
                        }, $text
@@ -1337,15 +1348,27 @@ class BBCode extends BaseObject
                $expression = "=diaspora://.*?/post/([0-9A-Za-z\-_@.:]{15,254}[0-9A-Za-z])=ism";
                $text = preg_replace($expression, System::baseUrl()."/display/$1", $text);
 
-               $text = preg_replace("/([#])\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
-                                       '$1<a href="' . System::baseUrl() . '/search?tag=$3" class="tag" title="$3">$3</a>', $text);
-
-               $text = preg_replace("/\[url\=([$URLSearchString]*)\]#(.*?)\[\/url\]/ism",
-                                       '#<a href="' . System::baseUrl() . '/search?tag=$2" class="tag" title="$2">$2</a>', $text);
+               /* Tag conversion
+                * Supports:
+                * - #[url=<anything>]<term>[/url]
+                * - [url=<anything>]#<term>[/url]
+                */
+               $text = preg_replace_callback("/(?:#\[url\=[$URLSearchString]*\]|\[url\=[$URLSearchString]*\]#)(.*?)\[\/url\]/ism", function($matches) {
+                       return '#<a href="'
+                               . System::baseUrl()     . '/search?tag=' . rawurlencode($matches[1])
+                               . '" class="tag" title="' . XML::escape($matches[1]) . '">'
+                               . XML::escape($matches[1])
+                               . '</a>';
+               }, $text);
+
+               // We need no target="_blank" for local links
+               // convert links start with System::baseUrl() as local link without the target="_blank" attribute
+               $escapedBaseUrl = preg_quote(System::baseUrl(), '/');
+               $text = preg_replace("/\[url\](".$escapedBaseUrl."[$URLSearchString]*)\[\/url\]/ism", '<a href="$1">$1</a>', $text);
+               $text = preg_replace("/\[url\=(".$escapedBaseUrl."[$URLSearchString]*)\](.*?)\[\/url\]/ism", '<a href="$1">$2</a>', $text);             
 
                $text = preg_replace("/\[url\]([$URLSearchString]*)\[\/url\]/ism", '<a href="$1" target="_blank">$1</a>', $text);
                $text = preg_replace("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", '<a href="$1" target="_blank">$2</a>', $text);
-               //$Text = preg_replace("/\[url\=([$URLSearchString]*)\]([$URLSearchString]*)\[\/url\]/ism", '<a href="$1" target="_blank">$2</a>', $Text);
 
                // Red compatibility, though the link can't be authenticated on Friendica
                $text = preg_replace("/\[zrl\=([$URLSearchString]*)\](.*?)\[\/zrl\]/ism", '<a href="$1" target="_blank">$2</a>', $text);
@@ -1361,7 +1384,7 @@ class BBCode extends BaseObject
                $text = preg_replace("/\[mail\=([$MAILSearchString]*)\](.*?)\[\/mail\]/", '<a href="mailto:$1">$2</a>', $text);
 
                // leave open the posibility of [map=something]
-               // this is replaced in prepare_body() which has knowledge of the item location
+               // this is replaced in Item::prepareBody() which has knowledge of the item location
 
                if (strpos($text, '[/map]') !== false) {
                        $text = preg_replace_callback(
@@ -1472,7 +1495,7 @@ class BBCode extends BaseObject
 
                $text = str_replace('[hr]', '<hr />', $text);
 
-               // This is actually executed in prepare_body()
+               // This is actually executed in Item::prepareBody()
 
                $text = str_replace('[nosmile]', '', $text);
 
@@ -1652,7 +1675,7 @@ class BBCode extends BaseObject
                // Summary (e.g. title) is required, earlier revisions only required description (in addition to
                // start which is always required). Allow desc with a missing summary for compatibility.
 
-               if ((x($ev, 'desc') || x($ev, 'summary')) && x($ev, 'start')) {
+               if ((!empty($ev['desc']) || !empty($ev['summary'])) && !empty($ev['start'])) {
                        $sub = Event::getHTML($ev, $simple_html);
 
                        $text = preg_replace("/\[event\-summary\](.*?)\[\/event\-summary\]/ism", '', $text);
@@ -1666,7 +1689,7 @@ class BBCode extends BaseObject
 
                // Replace non graphical smilies for external posts
                if ($simple_html) {
-                       $text = Smilies::replace($text, false, true);
+                       $text = Smilies::replace($text);
                }
 
                // Unhide all [noparse] contained bbtags unspacefying them
@@ -1720,7 +1743,7 @@ class BBCode extends BaseObject
 
                // Clean up the HTML by loading and saving the HTML with the DOM.
                // Bad structured html can break a whole page.
-               // For performance reasons do it only with ativated item cache or at export.
+               // For performance reasons do it only with activated item cache or at export.
                if (!$try_oembed || (get_itemcachepath() != "")) {
                        $doc = new DOMDocument();
                        $doc->preserveWhiteSpace = false;
@@ -1746,7 +1769,7 @@ class BBCode extends BaseObject
                //$Text = str_replace('<br /><li>', '<li>', $Text);
                //$Text = str_replace('<br /><ul', '<ul ', $Text);
 
-               Addon::callHooks('bbcode', $text);
+               Hook::callAll('bbcode', $text);
 
                return trim($text);
        }
@@ -1799,7 +1822,12 @@ class BBCode extends BaseObject
         * @brief Callback function to replace a Friendica style mention in a mention for Diaspora
         *
         * @param array $match Matching values for the callback
+        *                     [1] = Mention type (! or @)
+        *                     [2] = Name
+        *                     [3] = Address
         * @return string Replaced mention
+        * @throws \Friendica\Network\HTTPException\InternalServerErrorException
+        * @throws \ImagickException
         */
        private static function bbCodeMention2DiasporaCallback($match)
        {
@@ -1813,7 +1841,7 @@ class BBCode extends BaseObject
                        return $match[0];
                }
 
-               $mention = '@{' . $match[2] . '; ' . $contact['addr'] . '}';
+               $mention = $match[1] . '{' . $match[2] . '; ' . $contact['addr'] . '}';
                return $mention;
        }
 
@@ -1826,6 +1854,7 @@ class BBCode extends BaseObject
         * @param string $text
         * @param bool   $for_diaspora Diaspora requires more changes than Libertree
         * @return string
+        * @throws \Friendica\Network\HTTPException\InternalServerErrorException
         */
        public static function toMarkdown($text, $for_diaspora = true)
        {
@@ -1898,14 +1927,88 @@ class BBCode extends BaseObject
                if ($for_diaspora) {
                        $url_search_string = "^\[\]";
                        $text = preg_replace_callback(
-                               "/([@]\[(.*?)\])\(([$url_search_string]*?)\)/ism",
+                               "/([@!])\[(.*?)\]\(([$url_search_string]*?)\)/ism",
                                ['self', 'bbCodeMention2DiasporaCallback'],
                                $text
                        );
                }
 
-               Addon::callHooks('bb2diaspora', $text);
+               Hook::callAll('bb2diaspora', $text);
 
                return $text;
        }
+
+       /**
+     * @brief Pull out all #hashtags and @person tags from $string.
+     *
+     * We also get @person@domain.com - which would make
+     * the regex quite complicated as tags can also
+     * end a sentence. So we'll run through our results
+     * and strip the period from any tags which end with one.
+     * Returns array of tags found, or empty array.
+     *
+     * @param string $string Post content
+     * 
+     * @return array List of tag and person names
+     */
+    public static function getTags($string)
+    {
+        $ret = [];
+
+        // Convert hashtag links to hashtags
+        $string = preg_replace('/#\[url\=([^\[\]]*)\](.*?)\[\/url\]/ism', '#$2', $string);
+
+        // ignore anything in a code block
+        $string = preg_replace('/\[code.*?\].*?\[\/code\]/sm', '', $string);
+
+        // Force line feeds at bbtags
+        $string = str_replace(['[', ']'], ["\n[", "]\n"], $string);
+
+        // ignore anything in a bbtag
+        $string = preg_replace('/\[(.*?)\]/sm', '', $string);
+
+        // Match full names against @tags including the space between first and last
+        // We will look these up afterward to see if they are full names or not recognisable.
+
+        if (preg_match_all('/(@[^ \x0D\x0A,:?]+ [^ \x0D\x0A@,:?]+)([ \x0D\x0A@,:?]|$)/', $string, $matches)) {
+            foreach ($matches[1] as $match) {
+                if (strstr($match, ']')) {
+                    // we might be inside a bbcode color tag - leave it alone
+                    continue;
+                }
+
+                if (substr($match, -1, 1) === '.') {
+                    $ret[] = substr($match, 0, -1);
+                } else {
+                    $ret[] = $match;
+                }
+            }
+        }
+
+        // Otherwise pull out single word tags. These can be @nickname, @first_last
+        // and #hash tags.
+
+        if (preg_match_all('/([!#@][^\^ \x0D\x0A,;:?]+)([ \x0D\x0A,;:?]|$)/', $string, $matches)) {
+            foreach ($matches[1] as $match) {
+                if (strstr($match, ']')) {
+                    // we might be inside a bbcode color tag - leave it alone
+                    continue;
+                }
+                if (substr($match, -1, 1) === '.') {
+                    $match = substr($match,0,-1);
+                }
+                // ignore strictly numeric tags like #1
+                if ((strpos($match, '#') === 0) && ctype_digit(substr($match, 1))) {
+                    continue;
+                }
+                // try not to catch url fragments
+                if (strpos($string, $match) && preg_match('/[a-zA-z0-9\/]/', substr($string, strpos($string, $match) - 1, 1))) {
+                    continue;
+                }
+                $ret[] = $match;
+            }
+        }
+
+        return $ret;
+    }
 }