.
*
*/
namespace Friendica\Content\Text;
use DOMDocument;
use DOMXPath;
use Exception;
use Friendica\Content\ContactSelector;
use Friendica\Content\Item;
use Friendica\Content\OEmbed;
use Friendica\Content\PageInfo;
use Friendica\Content\Smilies;
use Friendica\Core\Hook;
use Friendica\Core\Logger;
use Friendica\Core\Protocol;
use Friendica\Core\Renderer;
use Friendica\DI;
use Friendica\Model\Contact;
use Friendica\Model\Event;
use Friendica\Model\Post;
use Friendica\Model\Tag;
use Friendica\Network\HTTPClient\Client\HttpClientAccept;
use Friendica\Network\HTTPClient\Client\HttpClientOptions;
use Friendica\Util\Map;
use Friendica\Util\ParseUrl;
use Friendica\Util\Proxy;
use Friendica\Util\Strings;
use Friendica\Util\XML;
class BBCode
{
// Update this value to the current date whenever changes are made to BBCode::convert
const VERSION = '2021-07-28';
const INTERNAL = 0;
const EXTERNAL = 1;
const MASTODON_API = 2;
const DIASPORA = 3;
const CONNECTORS = 4;
const TWITTER_API = 5;
const NPF = 6;
const OSTATUS = 7;
const TWITTER = 8;
const BACKLINK = 8;
const ACTIVITYPUB = 9;
const TOP_ANCHOR = '
';
const BOTTOM_ANCHOR = '
';
const PREVIEW_NONE = 0;
const PREVIEW_NO_IMAGE = 1;
const PREVIEW_LARGE = 2;
const PREVIEW_SMALL = 3;
/**
* Fetches attachment data that were generated with the "attachment" element
*
* @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
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
public static function getAttachmentData(string $body): array
{
DI::profiler()->startRecording('rendering');
$data = [
'type' => '',
'text' => '',
'after' => '',
'image' => null,
'url' => '',
'author_name' => '',
'author_url' => '',
'provider_name' => '',
'provider_url' => '',
'title' => '',
'description' => '',
];
if (!preg_match("/(.*)\[attachment(.*?)\](.*?)\[\/attachment\](.*)/ism", $body, $match)) {
DI::profiler()->stopRecording();
return [];
}
$attributes = $match[2];
$data['text'] = trim($match[1]);
foreach (['type', 'url', 'title', 'image', 'preview', 'publisher_name', 'publisher_url', 'author_name', 'author_url'] as $field) {
preg_match('/' . preg_quote($field, '/') . '=("|\')(.*?)\1/ism', $attributes, $matches);
$value = $matches[2] ?? '';
if ($value != '') {
switch ($field) {
case 'publisher_name':
$data['provider_name'] = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
break;
case 'publisher_url':
$data['provider_url'] = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
break;
case 'author_name':
$data['author_name'] = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
if ($data['provider_name'] == $data['author_name']) {
$data['author_name'] = '';
}
break;
case 'author_url':
$data['author_url'] = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
if ($data['provider_url'] == $data['author_url']) {
$data['author_url'] = '';
}
break;
case 'title':
$value = self::convert(html_entity_decode($value, ENT_QUOTES, 'UTF-8'), false, true);
$value = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
$value = str_replace(['[', ']'], ['[', ']'], $value);
$data['title'] = $value;
default:
$data[$field] = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
break;
}
}
}
if (!in_array($data['type'], ['link', 'audio', 'photo', 'video'])) {
DI::profiler()->stopRecording();
return [];
}
$data['description'] = trim($match[3]);
$data['after'] = trim($match[4]);
$parts = parse_url($data['url']);
if (!empty($parts['scheme']) && !empty($parts['host'])) {
if (empty($data['provider_name'])) {
$data['provider_name'] = $parts['host'];
}
if (empty($data['provider_url']) || empty(parse_url($data['provider_url'], PHP_URL_SCHEME))) {
$data['provider_url'] = $parts['scheme'] . '://' . $parts['host'];
if (!empty($parts['port'])) {
$data['provider_url'] .= ':' . $parts['port'];
}
}
}
DI::profiler()->stopRecording();
return $data;
}
/**
* Remove [attachment] BBCode and replaces it with a regular [url]
*
* @param string $body
* @param boolean $no_link_desc No link description
* @return string with replaced body
*/
public static function replaceAttachment(string $body, bool $no_link_desc = false): string
{
return preg_replace_callback(
"/\s*\[attachment (.*?)\](.*?)\[\/attachment\]\s*/ism",
function ($match) use ($body, $no_link_desc) {
$attach_data = self::getAttachmentData($match[0]);
if (empty($attach_data['url'])) {
return $match[0];
} elseif (strpos(str_replace($match[0], '', $body), $attach_data['url']) !== false) {
return '';
} elseif (empty($attach_data['title']) || $no_link_desc) {
return " \n[url]" . $attach_data['url'] . "[/url]\n";
} else {
return " \n[url=" . $attach_data['url'] . ']' . $attach_data['title'] . "[/url]\n";
}
},
$body
);
}
/**
* Remove [attachment] BBCode
*
* @param string $body
* @return string with removed attachment
*/
public static function removeAttachment(string $body): string
{
return trim(preg_replace("/\s*\[attachment .*?\].*?\[\/attachment\]\s*/ism", '', $body));
}
/**
* Converts a BBCode text into plaintext
*
* @param string $text
* @param bool $keep_urls Whether to keep URLs in the resulting plaintext
* @return string
*/
public static function toPlaintext(string $text, bool $keep_urls = true): string
{
DI::profiler()->startRecording('rendering');
// Remove pictures in advance to avoid unneeded proxy calls
$text = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", ' $2 ', $text);
$text = preg_replace("/\[img.*?\[\/img\]/ism", ' ', $text);
// Remove attachment
$text = self::replaceAttachment($text);
$naked_text = HTML::toPlaintext(self::convert($text, false, 0, true), 0, !$keep_urls);
DI::profiler()->stopRecording();
return $naked_text;
}
private static function proxyUrl(string $image, int $simplehtml = self::INTERNAL, int $uriid = 0, string $size = ''): string
{
// Only send proxied pictures to API and for internal display
if (!in_array($simplehtml, [self::INTERNAL, self::MASTODON_API, self::TWITTER_API])) {
return $image;
} elseif ($uriid > 0) {
return Post\Link::getByLink($uriid, $image, $size);
} else {
return Proxy::proxifyUrl($image, $size);
}
}
/**
* Truncates imported message body string length to max_import_size
*
* The purpose of this function is to apply system message length limits to
* imported messages without including any embedded photos in the length
*
* @param string $body
* @return string
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
public static function limitBodySize(string $body): string
{
DI::profiler()->startRecording('rendering');
$maxlen = DI::config()->get('config', 'max_import_size', 0);
// If the length of the body, including the embedded images, is smaller
// than the maximum, then don't waste time looking for the images
if ($maxlen && (strlen($body) > $maxlen)) {
Logger::info('the total body length exceeds the limit', ['maxlen' => $maxlen, 'body_len' => strlen($body)]);
$orig_body = $body;
$new_body = '';
$textlen = 0;
$img_start = strpos($orig_body, '[img');
$img_st_close = ($img_start !== false ? strpos(substr($orig_body, $img_start), ']') : false);
$img_end = ($img_start !== false ? strpos(substr($orig_body, $img_start), '[/img]') : false);
while (($img_st_close !== false) && ($img_end !== false)) {
$img_st_close++; // make it point to AFTER the closing bracket
$img_end += $img_start;
$img_end += strlen('[/img]');
if (!strcmp(substr($orig_body, $img_start + $img_st_close, 5), 'data:')) {
// This is an embedded image
if (($textlen + $img_start) > $maxlen) {
if ($textlen < $maxlen) {
Logger::debug('the limit happens before an embedded image');
$new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen);
$textlen = $maxlen;
}
} else {
$new_body = $new_body . substr($orig_body, 0, $img_start);
$textlen += $img_start;
}
$new_body = $new_body . substr($orig_body, $img_start, $img_end - $img_start);
} else {
if (($textlen + $img_end) > $maxlen) {
if ($textlen < $maxlen) {
Logger::debug('the limit happens before the end of a non-embedded image');
$new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen);
$textlen = $maxlen;
}
} else {
$new_body = $new_body . substr($orig_body, 0, $img_end);
$textlen += $img_end;
}
}
$orig_body = substr($orig_body, $img_end);
if ($orig_body === false) {
// in case the body ends on a closing image tag
$orig_body = '';
}
$img_start = strpos($orig_body, '[img');
$img_st_close = ($img_start !== false ? strpos(substr($orig_body, $img_start), ']') : false);
$img_end = ($img_start !== false ? strpos(substr($orig_body, $img_start), '[/img]') : false);
}
if (($textlen + strlen($orig_body)) > $maxlen) {
if ($textlen < $maxlen) {
Logger::debug('the limit happens after the end of the last image');
$new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen);
}
} else {
Logger::debug('the text size with embedded images extracted did not violate the limit');
$new_body = $new_body . $orig_body;
}
DI::profiler()->stopRecording();
return $new_body;
} else {
DI::profiler()->stopRecording();
return $body;
}
}
/**
* Processes [attachment] tags
*
* Note: Can produce a [bookmark] tag in the returned string
*
* @param string $text
* @param integer $simplehtml
* @param bool $tryoembed
* @param array $data
* @param int $uriid
* @return string
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
public static function convertAttachment(string $text, int $simplehtml = self::INTERNAL, bool $tryoembed = true, array $data = [], int $uriid = 0, int $preview_mode = self::PREVIEW_LARGE): string
{
DI::profiler()->startRecording('rendering');
$data = $data ?: self::getAttachmentData($text);
if (empty($data) || empty($data['url'])) {
DI::profiler()->stopRecording();
return $text;
}
if (isset($data['title'])) {
$data['title'] = strip_tags($data['title']);
$data['title'] = str_replace(['http://', 'https://'], '', $data['title']);
} else {
$data['title'] = '';
}
if (((strpos($data['text'], '[img=') !== false) || (strpos($data['text'], '[img]') !== false) || DI::config()->get('system', 'always_show_preview')) && !empty($data['image'])) {
$data['preview'] = $data['image'];
$data['image'] = '';
}
$return = '';
try {
if ($tryoembed && OEmbed::isAllowedURL($data['url'])) {
$return = OEmbed::getHTML($data['url'], $data['title']);
} else {
throw new Exception('OEmbed is disabled for this attachment.');
}
} catch (Exception $e) {
$data['title'] = ($data['title'] ?? '') ?: $data['url'];
if ($simplehtml != self::CONNECTORS) {
$return = sprintf('
%s', trim(HTML::purify($data['description']))); } if (!empty($data['provider_url']) && !empty($data['provider_name'])) { if (!empty($data['author_name'])) { $return .= sprintf('%s (%s)', $data['provider_url'], $data['author_name'], $data['provider_name']); } else { $return .= sprintf('%s', $data['provider_url'], $data['provider_name']); } } if ($simplehtml != self::CONNECTORS) { $return .= '
' . $content . ''; break; case self::DIASPORA: if (stripos(Strings::normaliseLink($attributes['link']), 'http://twitter.com/') === 0) { $text = ($is_quote_share ? '
♲ ' . $attributes['author'] . ':
' . "\n"; if (!empty($attributes['posted']) && !empty($attributes['link'])) { $headline = '♲ ' . $attributes['author'] . ' - ' . $attributes['posted'] . ' GMT
' . "\n"; } $text = ($is_quote_share ? '' . trim($content) . '' . "\n"; if (empty($attributes['posted']) && !empty($attributes['link'])) { $text .= '' . "\n"; } } break; case self::CONNECTORS: $headline = '
' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8'); $headline .= DI::l10n()->t('%2$s %3$s', $attributes['link'], $mention, $attributes['posted']); $headline .= ':
' . "\n"; $text = ($is_quote_share ? '' . trim($content) . '' . "\n"; break; case self::OSTATUS: $text = ($is_quote_share ? '
' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8') . ' @' . $author_contact['addr'] . ': ' . $content . '
' . "\n"; break; case self::ACTIVITYPUB: $author = '@' . $author_contact['addr'] . ':'; $text = '' . $content . '
' . Map::byLocation($match[1], $simple_html) . '
', $match[0]); }, $text ); } if (strpos($text, '[map=') !== false) { $text = preg_replace_callback( "/\[map=(.*?)\]/ism", function ($match) use ($simple_html) { return str_replace($match[0], '' . Map::byCoordinates(str_replace('/', ' ', $match[1]), $simple_html) . '
', $match[0]); }, $text ); } if (strpos($text, '[map]') !== false) { $text = preg_replace("/\[map\]/", '', $text); } // Check for headers $text = preg_replace("(\[h1\](.*?)\[\/h1\])ism", '', $text); $text = preg_replace("(\[h2\](.*?)\[\/h2\])ism", '
', $text); $text = preg_replace("(\[h3\](.*?)\[\/h3\])ism", '
', $text); $text = preg_replace("(\[h4\](.*?)\[\/h4\])ism", '
', $text); $text = preg_replace("(\[h5\](.*?)\[\/h5\])ism", '
', $text); $text = preg_replace("(\[h6\](.*?)\[\/h6\])ism", '
', $text); // Check for paragraph $text = preg_replace("(\[p\](.*?)\[\/p\])ism", '
$1
', $text); // Check for bold text $text = preg_replace("(\[b\](.*?)\[\/b\])ism", '$1', $text); // Check for Italics text $text = preg_replace("(\[i\](.*?)\[\/i\])ism", '$1', $text); // Check for Underline text $text = preg_replace("(\[u\](.*?)\[\/u\])ism", '$1', $text); // Check for strike-through text $text = preg_replace("(\[s\](.*?)\[\/s\])ism", '', $text);
$text = str_replace("\n", '
', $text);
// handle nested lists
$endlessloop = 0;
while ((((strpos($text, "[/list]") !== false) && (strpos($text, "[list") !== false)) ||
((strpos($text, "[/ol]") !== false) && (strpos($text, "[ol]") !== false)) ||
((strpos($text, "[/ul]") !== false) && (strpos($text, "[ul]") !== false)) ||
((strpos($text, "[/li]") !== false) && (strpos($text, "[li]") !== false))) && (++$endlessloop < 20)) {
$text = preg_replace("/\[list\](.*?)\[\/list\]/ism", '
', $text); $text = preg_replace("/\[list=\](.*?)\[\/list\]/ism", '
', $text); $text = preg_replace("/\[list=1\](.*?)\[\/list\]/ism", '
', $text); $text = preg_replace("/\[list=((?-i)i)\](.*?)\[\/list\]/ism", '
', $text); $text = preg_replace("/\[list=((?-i)I)\](.*?)\[\/list\]/ism", '
', $text); $text = preg_replace("/\[list=((?-i)a)\](.*?)\[\/list\]/ism", '
', $text); $text = preg_replace("/\[list=((?-i)A)\](.*?)\[\/list\]/ism", '
', $text); $text = preg_replace("/\[ul\](.*?)\[\/ul\]/ism", '
', $text); $text = preg_replace("/\[ol\](.*?)\[\/ol\]/ism", '
', $text); $text = preg_replace("/\[li\](.*?)\[\/li\]/ism", '
', $text); $text = preg_replace("/\[table border=1\](.*?)\[\/table\]/sm", '
', $text); $text = preg_replace("/\[table border=0\](.*?)\[\/table\]/sm", '
', $text); $text = str_replace('[hr]', '
', $text);
if (!$for_plaintext) {
$text = self::performWithEscapedTags($text, ['url', 'img', 'audio', 'video', 'youtube', 'vimeo', 'share', 'attachment', 'iframe', 'bookmark'], function ($text) {
return preg_replace(Strings::autoLinkRegEx(), '[url]$1[/url]', $text);
});
}
// Check for font change text
$text = preg_replace("/\[font=(.*?)\](.*?)\[\/font\]/sm", "$2", $text);
// Declare the format for [spoiler] layout
$SpoilerLayout = '' . DI::l10n()->t('Click to open/close') . '
$1$1
$2
$1
'; // Check for [quote] text // handle nested quotes $endlessloop = 0; while ((strpos($text, "[/quote]") !== false) && (strpos($text, "[quote]") !== false) && (++$endlessloop < 20)) { $text = preg_replace("/\[quote\](.*?)\[\/quote\]/ism", "$QuoteLayout", $text); } // Check for [quote=Author] text $t_wrote = DI::l10n()->t('$1 wrote:'); // handle nested quotes $endlessloop = 0; while ((strpos($text, "[/quote]") !== false) && (strpos($text, "[quote=") !== false) && (++$endlessloop < 20)) { $text = preg_replace( "/\[quote=[\"\']*(.*?)[\"\']*\](.*?)\[\/quote\]/ism", "
" . $t_wrote . "
$2", $text ); } // [img=widthxheight]image source[/img] $text = preg_replace_callback( "/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", function ($matches) use ($simple_html, $uriid) { if (strpos($matches[3], "data:image/") === 0) { return $matches[0]; } $matches[3] = self::proxyUrl($matches[3], $simple_html, $uriid); return "[img=" . $matches[1] . "x" . $matches[2] . "]" . $matches[3] . "[/img]"; }, $text ); $text = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '', $text); $text = preg_replace("/\[zmg\=([0-9]*)x([0-9]*)\](.*?)\[\/zmg\]/ism", '', $text); $text = preg_replace_callback( "/\[[iz]mg\=(.*?)\](.*?)\[\/[iz]mg\]/ism", function ($matches) use ($simple_html, $uriid) { $matches[1] = self::proxyUrl($matches[1], $simple_html, $uriid); $alt = htmlspecialchars($matches[2], ENT_COMPAT); // Fix for Markdown problems with Diaspora, see issue #12701 if (($simple_html != self::DIASPORA) || strpos($matches[2], '"') === false) { return ''; } else { return ''; } }, $text ); // Images // [img]pathtoimage[/img] $text = preg_replace_callback( "/\[[iz]mg\](.*?)\[\/[iz]mg\]/ism", function ($matches) use ($simple_html, $uriid) { if (strpos($matches[1], "data:image/") === 0) { return $matches[0]; } $matches[1] = self::proxyUrl($matches[1], $simple_html, $uriid); return "[img]" . $matches[1] . "[/img]"; }, $text ); $text = preg_replace("/\[img\](.*?)\[\/img\]/ism", '', $text); $text = preg_replace("/\[zmg\](.*?)\[\/zmg\]/ism", '', $text); $text = self::convertImages($text, $simple_html, $uriid); $text = preg_replace("/\[crypt\](.*?)\[\/crypt\]/ism", '
', $text ); $text = preg_replace( "/\[audio\](.*?)\[\/audio\]/ism", '
',
$text
);
} elseif ($try_oembed) {
// html5 video and audio
$text = preg_replace(
"/\[video\](.*?\.(ogg|ogv|oga|ogm|webm|mp4).*?)\[\/video\]/ism",
'',
$text
);
$text = preg_replace_callback("/\[video\](.*?)\[\/video\]/ism", $try_oembed_callback, $text);
$text = preg_replace_callback("/\[audio\](.*?)\[\/audio\]/ism", $try_oembed_callback, $text);
$text = preg_replace(
"/\[video\](.*?)\[\/video\]/ism",
'$1',
$text
);
$text = preg_replace("/\[audio\](.*?)\[\/audio\]/ism", '', $text);
} else {
$text = preg_replace(
"/\[video\](.*?)\[\/video\]/ism",
'$1',
$text
);
$text = preg_replace(
"/\[audio\](.*?)\[\/audio\]/ism",
'$1',
$text
);
}
// Backward compatibility, [iframe] support has been removed in version 2020.12
$text = preg_replace("/\[iframe\](.*?)\[\/iframe\]/ism", '$1', $text);
// Youtube extensions
if ($try_oembed) {
$text = preg_replace_callback("/\[youtube\](https?:\/\/www.youtube.com\/watch\?v\=.*?)\[\/youtube\]/ism", $try_oembed_callback, $text);
$text = preg_replace_callback("/\[youtube\](www.youtube.com\/watch\?v\=.*?)\[\/youtube\]/ism", $try_oembed_callback, $text);
$text = preg_replace_callback("/\[youtube\](https?:\/\/www.youtube.com\/shorts\/.*?)\[\/youtube\]/ism", $try_oembed_callback, $text);
$text = preg_replace_callback("/\[youtube\](https?:\/\/youtu.be\/.*?)\[\/youtube\]/ism", $try_oembed_callback, $text);
}
$text = preg_replace("/\[youtube\]https?:\/\/www.youtube.com\/watch\?v\=(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text);
$text = preg_replace("/\[youtube\]https?:\/\/www.youtube.com\/embed\/(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text);
$text = preg_replace("/\[youtube\]https?:\/\/www.youtube.com\/shorts\/(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text);
$text = preg_replace("/\[youtube\]https?:\/\/youtu.be\/(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text);
if ($try_oembed) {
$text = preg_replace("/\[youtube\]([A-Za-z0-9\-_=]+)(.*?)\[\/youtube\]/ism", '', $text);
} else {
$text = preg_replace(
"/\[youtube\]([A-Za-z0-9\-_=]+)(.*?)\[\/youtube\]/ism",
'https://www.youtube.com/watch?v=$1',
$text
);
}
if ($try_oembed) {
$text = preg_replace_callback("/\[vimeo\](https?:\/\/player.vimeo.com\/video\/[0-9]+).*?\[\/vimeo\]/ism", $try_oembed_callback, $text);
$text = preg_replace_callback("/\[vimeo\](https?:\/\/vimeo.com\/[0-9]+).*?\[\/vimeo\]/ism", $try_oembed_callback, $text);
}
$text = preg_replace("/\[vimeo\]https?:\/\/player.vimeo.com\/video\/([0-9]+)(.*?)\[\/vimeo\]/ism", '[vimeo]$1[/vimeo]', $text);
$text = preg_replace("/\[vimeo\]https?:\/\/vimeo.com\/([0-9]+)(.*?)\[\/vimeo\]/ism", '[vimeo]$1[/vimeo]', $text);
if ($try_oembed) {
$text = preg_replace("/\[vimeo\]([0-9]+)(.*?)\[\/vimeo\]/ism", '', $text);
} else {
$text = preg_replace(
"/\[vimeo\]([0-9]+)(.*?)\[\/vimeo\]/ism",
'https://vimeo.com/$1',
$text
);
}
// oembed tag
$text = OEmbed::BBCode2HTML($text);
// Avoid triple linefeeds through oembed
$text = str_replace(" ') !== false || strpos($text, ' ' . $text . '
", "
", $text);
// If we found an event earlier, strip out all the event code and replace with a reformatted version.
// Replace the event-start section with the entire formatted event. The other bbcode is stripped.
// 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 ((!empty($ev['desc']) || !empty($ev['summary'])) && !empty($ev['start'])) {
$sub = Event::getHTML($ev, $simple_html, $uriid);
$text = preg_replace("/\[event\-summary\](.*?)\[\/event\-summary\]/ism", '', $text);
$text = preg_replace("/\[event\-description\](.*?)\[\/event\-description\]/ism", '', $text);
$text = preg_replace("/\[event\-start\](.*?)\[\/event\-start\]/ism", $sub, $text);
$text = preg_replace("/\[event\-finish\](.*?)\[\/event\-finish\]/ism", '', $text);
$text = preg_replace("/\[event\-location\](.*?)\[\/event\-location\]/ism", '', $text);
$text = preg_replace("/\[event\-id\](.*?)\[\/event\-id\]/ism", '', $text);
}
if (!$for_plaintext && DI::config()->get('system', 'big_emojis') && ($simple_html != self::DIASPORA)) {
$conv = html_entity_decode(str_replace([' ', "\n", "\r"], '', $text));
// Emojis are always 4 byte Unicode characters
if (!empty($conv) && (strlen($conv) / mb_strlen($conv) == 4)) {
$text = '' . $text . '';
}
}
// Handle mentions and hashtag links
if ($simple_html == self::DIASPORA) {
// The ! is converted to @ since Diaspora only understands the @
$text = preg_replace(
"/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism",
'@$3',
$text
);
} elseif (in_array($simple_html, [self::OSTATUS, self::ACTIVITYPUB])) {
$text = preg_replace(
"/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism",
'$1$3',
$text
);
$text = preg_replace(
"/([#])\[url\=(.*?)\](.*?)\[\/url\]/ism",
'$1$3',
$text
);
} elseif (in_array($simple_html, [self::INTERNAL, self::EXTERNAL, self::TWITTER_API])) {
$text = preg_replace(
"/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism",
'$1$3',
$text
);
} elseif ($simple_html == self::MASTODON_API) {
$text = preg_replace(
"/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism",
'$1$3',
$text
);
$text = preg_replace(
"/([#])\[url\=(.*?)\](.*?)\[\/url\]/ism",
'$1$3',
$text
);
} else {
$text = preg_replace("/([#@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", '$1$3', $text);
}
if (!$for_plaintext) {
if (in_array($simple_html, [self::OSTATUS, self::MASTODON_API, self::TWITTER_API, self::ACTIVITYPUB])) {
$text = preg_replace_callback("/\[url\](.*?)\[\/url\]/ism", [self::class, 'convertUrlForActivityPubCallback'], $text);
$text = preg_replace_callback("/\[url\=(.*?)\](.*?)\[\/url\]/ism", [self::class, 'convertUrlForActivityPubCallback'], $text);
}
} else {
$text = preg_replace("(\[url\](.*?)\[\/url\])ism", " $1 ", $text);
$text = preg_replace_callback("&\[url=([^\[\]]*)\]\[img\](.*)\[\/img\]\[\/url\]&Usi", [self::class, 'removePictureLinksCallback'], $text);
}
// Bookmarks in red - will be converted to bookmarks in friendica
$text = preg_replace("/#\^\[url\](.*?)\[\/url\]/ism", '[bookmark=$1]$1[/bookmark]', $text);
$text = preg_replace("/#\^\[url\=(.*?)\](.*?)\[\/url\]/ism", '[bookmark=$1]$2[/bookmark]', $text);
$text = preg_replace(
"/#\[url\=.*?\]\^\[\/url\]\[url\=(.*?)\](.*?)\[\/url\]/i",
"[bookmark=$1]$2[/bookmark]",
$text
);
if (in_array($simple_html, [self::OSTATUS, self::TWITTER])) {
$text = preg_replace_callback("/([^#@!])\[url\=([^\]]*)\](.*?)\[\/url\]/ism", [self::class, 'expandLinksCallback'], $text);
//$text = preg_replace("/[^#@!]\[url\=([^\]]*)\](.*?)\[\/url\]/ism", ' $2 [url]$1[/url]', $text);
$text = preg_replace("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", ' $2 [url]$1[/url]', $text);
}
// Perform URL Search
if ($try_oembed) {
$text = preg_replace_callback("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", $try_oembed_callback, $text);
}
$text = preg_replace("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", '[url=$1]$2[/url]', $text);
// Handle Diaspora posts
$text = preg_replace_callback(
"&\[url=/?posts/([^\[\]]*)\](.*)\[\/url\]&Usi",
function ($match) {
return "[url=" . DI::baseUrl() . "/display/" . $match[1] . "]" . $match[2] . "[/url]";
},
$text
);
$text = preg_replace_callback(
"&\[url=/people\?q\=(.*)\](.*)\[\/url\]&Usi",
function ($match) {
return "[url=" . DI::baseUrl() . "/search?search=%40" . $match[1] . "]" . $match[2] . "[/url]";
},
$text
);
// Server independent link to posts and comments
// See issue: https://github.com/diaspora/diaspora_federation/issues/75
$expression = "=diaspora://.*?/post/([0-9A-Za-z\-_@.:]{15,254}[0-9A-Za-z])=ism";
$text = preg_replace($expression, DI::baseUrl() . "/display/$1", $text);
/* Tag conversion
* Supports:
* - #[url=
", $match[2]);
}, $text);
// Additionally, [pre] tags preserve spaces
$text = preg_replace_callback("/\[pre\](.*?)\[\/pre\]/ism", function ($match) {
return str_replace([' ', "\n"], [' ', "
"], htmlentities($match[1], ENT_NOQUOTES, 'UTF-8'));
}, $text);
return $text;
}); // Escaped code
$text = preg_replace_callback(
"#\[code(?:=([^\]]*))?\](.*?)\[\/code\]#ism",
function ($matches) {
if (strpos($matches[2], "\n") !== false) {
$return = '
';
} else {
$return = '' . htmlentities(trim($matches[2], "\n\r"), ENT_NOQUOTES, 'UTF-8') . '
' . htmlentities($matches[2], ENT_NOQUOTES, 'UTF-8') . '
';
}
return $return;
},
$text
);
// Default iframe allowed domains/path
$allowedIframeDomains = [
DI::baseUrl()->getHost()
. (DI::baseUrl()->getPath() ? '/' . DI::baseUrl()->getPath() : '')
. '/oembed/', # The path part has to change with the source in Content\Oembed::iframe
'www.youtube.com/embed/',
'player.vimeo.com/video/',
];
$allowedIframeDomains = array_merge(
$allowedIframeDomains,
DI::config()->get('system', 'allowed_oembed') ?
explode(',', DI::config()->get('system', 'allowed_oembed'))
: []
);
if (strpos($text, ''], ['
'], $text);
// Now convert HTML to Markdown
$text = HTML::toMarkdown($text);
// Libertree has a problem with escaped hashtags.
$text = str_replace(['\#'], ['#'], $text);
// Remove any leading or trailing whitespace, as this will mess up
// the Diaspora signature verification and cause the item to disappear
$text = trim($text);
if ($for_diaspora) {
$url_search_string = "^\[\]";
$text = preg_replace_callback(
"/([@!])\[(.*?)\]\(([$url_search_string]*?)\)/ism",
[self::class, 'bbCodeMention2DiasporaCallback'],
$text
);
}
Hook::callAll('bb2diaspora', $text);
DI::profiler()->stopRecording();
return $text;
}
/**
* 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 $string): array
{
DI::profiler()->startRecording('rendering');
$ret = [];
self::performWithEscapedTags($string, ['noparse', 'pre', 'code', 'img', 'attachment'], function ($string) use (&$ret) {
// Convert hashtag links to hashtags
$string = preg_replace('/#\[url\=([^\[\]]*)\](.*?)\[\/url\]/ism', '#$2 ', $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;
}
// 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;
}
}
});
DI::profiler()->stopRecording();
return array_unique($ret);
}
/**
* Expand tags to URLs, checks the tag is at the start of a line or preceded by a non-word character
*
* @param string $body HTML/BBCode
* @return string body with expanded tags
*/
public static function expandTags(string $body): string
{
return preg_replace_callback(
"/(?<=\W|^)([!#@])([^\^ \x0D\x0A,;:?'\"]*[^\^ \x0D\x0A,;:?!'\".])/",
function (array $match) {
switch ($match[1]) {
case '!':
case '@':
$contact = Contact::getByURL($match[2]);
if (!empty($contact)) {
return $match[1] . '[url=' . $contact['url'] . ']' . $contact['name'] . '[/url]';
} else {
return $match[1] . $match[2];
}
break;
case '#':
default:
return $match[1] . '[url=' . DI::baseUrl() . '/search?tag=' . $match[2] . ']' . $match[2] . '[/url]';
}
},
$body
);
}
/**
* Perform a custom function on a text after having escaped blocks enclosed in the provided tag list.
*
* @param string $text HTML/BBCode
* @param array $tagList A list of tag names, e.g ['noparse', 'nobb', 'pre']
* @param callable $callback
* @return string
* @see Strings::performWithEscapedBlocks
*/
public static function performWithEscapedTags(string $text, array $tagList, callable $callback): string
{
$tagList = array_map('preg_quote', $tagList);
return Strings::performWithEscapedBlocks($text, '#\[(?:' . implode('|', $tagList) . ').*?\[/(?:' . implode('|', $tagList) . ')]#ism', $callback);
}
/**
* Replaces mentions in the provided message body in BBCode links for the provided user and network if any
*
* @param string $body HTML/BBCode
* @param int $profile_uid Profile user id
* @param string $network Network name
* @return string HTML/BBCode with inserted images
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
* @throws \ImagickException
*/
public static function setMentions(string $body, $profile_uid = 0, $network = '')
{
DI::profiler()->startRecording('rendering');
$body = self::performWithEscapedTags($body, ['noparse', 'pre', 'code', 'img'], function ($body) use ($profile_uid, $network) {
$tags = self::getTags($body);
$tagged = [];
foreach ($tags as $tag) {
$tag_type = substr($tag, 0, 1);
if ($tag_type == Tag::TAG_CHARACTER[Tag::HASHTAG]) {
continue;
}
/*
* If we already tagged 'Robert Johnson', don't try and tag 'Robert'.
* Robert Johnson should be first in the $tags array
*/
foreach ($tagged as $nextTag) {
if (stristr($nextTag, $tag . ' ')) {
continue 2;
}
}
if (($success = Item::replaceTag($body, $profile_uid, $tag, $network)) && $success['replaced']) {
$tagged[] = $tag;
}
}
return $body;
});
DI::profiler()->stopRecording();
return $body;
}
/**
* @param string $author Author display name
* @param string $profile Author profile URL
* @param string $avatar Author profile picture URL
* @param string $link Post source URL
* @param string $posted Post created date
* @param string|null $guid Post guid (if any)
* @param string|null $uri Post uri (if any)
* @return string
* @TODO Rewrite to handle over whole record array
*/
public static function getShareOpeningTag(string $author, string $profile, string $avatar, string $link, string $posted, string $guid = null, string $uri = null): string
{
DI::profiler()->startRecording('rendering');
$header = "[share author='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $author) .
"' profile='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $profile) .
"' avatar='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $avatar) .
"' link='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $link) .
"' posted='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $posted);
if ($guid) {
$header .= "' guid='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $guid);
}
if ($uri) {
$header .= "' message_id='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $uri);
}
$header .= "']";
DI::profiler()->stopRecording();
return $header;
}
/**
* Returns the BBCode relevant to embed the provided URL in a post body.
* For media type, it will return [img], [video] and [audio] tags.
* For regular web pages, it will either output a [bookmark] tag if title and description were provided,
* an [attachment] tag or a simple [url] tag depending on $tryAttachment.
*
* @param string $url
* @param bool $tryAttachment
* @param string|null $title
* @param string|null $description
* @param string|null $tags
* @return string
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
* @see ParseUrl::getSiteinfoCached
*/
public static function embedURL(string $url, bool $tryAttachment = true, string $title = null, string $description = null, string $tags = null): string
{
DI::profiler()->startRecording('rendering');
DI::logger()->info($url);
// If there is already some content information submitted we don't
// need to parse the url for content.
if (!empty($title) && !empty($description)) {
$title = str_replace(["\r", "\n"], ['', ''], $title);
$description = '[quote]' . trim($description) . '[/quote]' . "\n";
$str_tags = '';
if (!empty($tags)) {
$arr_tags = ParseUrl::convertTagsToArray($tags);
if (count($arr_tags)) {
$str_tags = "\n" . implode(' ', $arr_tags) . "\n";
}
}
$result = sprintf('[bookmark=%s]%s[/bookmark]%s', $url, ($title) ? $title : $url, $description) . $str_tags;
DI::logger()->info('(unparsed): returns: ' . $result);
DI::profiler()->stopRecording();
return $result;
}
$siteinfo = ParseUrl::getSiteinfoCached($url);
if (in_array($siteinfo['type'], ['image', 'video', 'audio'])) {
switch ($siteinfo['type']) {
case 'video':
$bbcode = "\n" . '[video]' . $url . '[/video]' . "\n";
break;
case 'audio':
$bbcode = "\n" . '[audio]' . $url . '[/audio]' . "\n";
break;
default:
$bbcode = "\n" . '[img]' . $url . '[/img]' . "\n";
break;
}
DI::profiler()->stopRecording();
return $bbcode;
}
unset($siteinfo['keywords']);
// Bypass attachment if parse url for a comment
if (!$tryAttachment) {
DI::profiler()->stopRecording();
return "\n" . '[url=' . $url . ']' . ($siteinfo['title'] ?? $url) . '[/url]';
}
// Format it as BBCode attachment
$bbcode = "\n" . PageInfo::getFooterFromData($siteinfo);
DI::profiler()->stopRecording();
return $bbcode;
}
}