.
*
*/
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\Photo;
use Friendica\Model\Tag;
use Friendica\Object\Image;
use Friendica\Protocol\Activity;
use Friendica\Util\Images;
use Friendica\Util\Map;
use Friendica\Util\ParseUrl;
use Friendica\Util\Proxy as ProxyUtils;
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-05-21';
const INTERNAL = 0;
const EXTERNAL = 1;
const API = 2;
const DIASPORA = 3;
const CONNECTORS = 4;
const OSTATUS = 7;
const TWITTER = 8;
const BACKLINK = 8;
const ACTIVITYPUB = 9;
const TOP_ANCHOR = '
';
const BOTTOM_ANCHOR = '
';
/**
* Fetches attachment data that were generated the old way
*
* @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
*/
private static function getOldAttachmentData($body)
{
$post = [];
// Simplify image codes
$body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $body);
if (preg_match_all("(\[class=(.*?)\](.*?)\[\/class\])ism", $body, $attached, PREG_SET_ORDER)) {
foreach ($attached as $data) {
if (!in_array($data[1], ['type-link', 'type-video', 'type-photo'])) {
continue;
}
$post['type'] = substr($data[1], 5);
$pos = strpos($body, $data[0]);
if ($pos > 0) {
$post['text'] = trim(substr($body, 0, $pos));
$post['after'] = trim(substr($body, $pos + strlen($data[0])));
} else {
$post['text'] = trim(str_replace($data[0], '', $body));
$post['after'] = '';
}
$attacheddata = $data[2];
if (preg_match("/\[img\](.*?)\[\/img\]/ism", $attacheddata, $matches)) {
$picturedata = Images::getInfoFromURLCached($matches[1]);
if ($picturedata) {
if (($picturedata[0] >= 500) && ($picturedata[0] >= $picturedata[1])) {
$post['image'] = $matches[1];
} else {
$post['preview'] = $matches[1];
}
}
}
if (preg_match("/\[bookmark\=(.*?)\](.*?)\[\/bookmark\]/ism", $attacheddata, $matches)) {
$post['url'] = $matches[1];
$post['title'] = $matches[2];
}
if (!empty($post['url']) && (in_array($post['type'], ['link', 'video']))
&& preg_match("/\[url\=(.*?)\](.*?)\[\/url\]/ism", $attacheddata, $matches)) {
$post['url'] = $matches[1];
}
// Search for description
if (preg_match("/\[quote\](.*?)\[\/quote\]/ism", $attacheddata, $matches)) {
$post['description'] = $matches[1];
}
}
}
return $post;
}
/**
* 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($body)
{
$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)) {
return self::getOldAttachmentData($body);
}
$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'])) {
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'];
}
}
}
return $data;
}
public static function getAttachedData($body, $item = [])
{
/*
- text:
- type: link, video, photo
- title:
- url:
- image:
- description:
- (thumbnail)
*/
$has_title = !empty($item['title']);
$plink = $item['plink'] ?? '';
$post = self::getAttachmentData($body);
// Get all linked images with alternative image description
if (preg_match_all("/\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]/Usi", $body, $pictures, PREG_SET_ORDER)) {
foreach ($pictures as $picture) {
if (Photo::isLocal($picture[1])) {
$post['images'][] = ['url' => str_replace('-1.', '-0.', $picture[1]), 'description' => $picture[2]];
} else {
$post['remote_images'][] = ['url' => $picture[1], 'description' => $picture[2]];
}
}
if (!empty($post['images']) && !empty($post['images'][0]['description'])) {
$post['image_description'] = $post['images'][0]['description'];
}
}
if (preg_match_all("/\[img\]([^\[\]]*)\[\/img\]/Usi", $body, $pictures, PREG_SET_ORDER)) {
foreach ($pictures as $picture) {
if (Photo::isLocal($picture[1])) {
$post['images'][] = ['url' => str_replace('-1.', '-0.', $picture[1]), 'description' => ''];
} else {
$post['remote_images'][] = ['url' => $picture[1], 'description' => ''];
}
}
}
// if nothing is found, it maybe having an image.
if (!isset($post['type'])) {
// Simplify image codes
$body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $body);
$body = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", '[img]$1[/img]', $body);
$post['text'] = $body;
if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img\]([^\[]+?)\[/img\]\s*\[/url\]#ism", $body, $pictures, PREG_SET_ORDER)) {
if ((count($pictures) == 1) && !$has_title) {
if (!empty($item['object-type']) && ($item['object-type'] == Activity\ObjectType::IMAGE)) {
// Replace the preview picture with the real picture
$url = str_replace('-1.', '-0.', $pictures[0][2]);
$data = ['url' => $url, 'type' => 'photo'];
} else {
// Checking, if the link goes to a picture
$data = ParseUrl::getSiteinfoCached($pictures[0][1]);
}
// Workaround:
// Sometimes photo posts to the own album are not detected at the start.
// So we seem to cannot use the cache for these cases. That's strange.
if (($data['type'] != 'photo') && strstr($pictures[0][1], "/photos/")) {
$data = ParseUrl::getSiteinfo($pictures[0][1]);
}
if ($data['type'] == 'photo') {
$post['type'] = 'photo';
if (isset($data['images'][0])) {
$post['image'] = $data['images'][0]['src'];
$post['url'] = $data['url'];
} else {
$post['image'] = $data['url'];
}
$post['preview'] = $pictures[0][2];
$post['text'] = trim(str_replace($pictures[0][0], '', $body));
} else {
$imgdata = Images::getInfoFromURLCached($pictures[0][1]);
if ($imgdata && substr($imgdata['mime'], 0, 6) == 'image/') {
$post['type'] = 'photo';
$post['image'] = $pictures[0][1];
$post['preview'] = $pictures[0][2];
$post['text'] = trim(str_replace($pictures[0][0], '', $body));
}
}
} elseif (count($pictures) > 0) {
$post['type'] = 'link';
$post['url'] = $plink;
$post['image'] = $pictures[0][2];
$post['text'] = $body;
foreach ($pictures as $picture) {
$post['text'] = trim(str_replace($picture[0], '', $post['text']));
}
}
} elseif (preg_match_all("(\[img\](.*?)\[\/img\])ism", $body, $pictures, PREG_SET_ORDER)) {
if ($has_title) {
$post['type'] = 'link';
$post['url'] = $plink;
} else {
$post['type'] = 'photo';
}
$post['image'] = $pictures[0][1];
$post['text'] = $body;
foreach ($pictures as $picture) {
$post['text'] = trim(str_replace($picture[0], '', $post['text']));
}
}
// Test for the external links
preg_match_all("(\[url\](.*?)\[\/url\])ism", $post['text'], $links1, PREG_SET_ORDER);
preg_match_all("(\[url\=(.*?)\].*?\[\/url\])ism", $post['text'], $links2, PREG_SET_ORDER);
$links = array_merge($links1, $links2);
// If there is only a single one, then use it.
// This should cover link posts via API.
if ((count($links) == 1) && !isset($post['preview']) && !$has_title) {
$post['type'] = 'link';
$post['url'] = $links[0][1];
}
// Simplify "video" element
$post['text'] = preg_replace('(\[video.*?\ssrc\s?=\s?([^\s\]]+).*?\].*?\[/video\])ism', '[video]$1[/video]', $post['text']);
// Now count the number of external media links
preg_match_all("(\[vimeo\](.*?)\[\/vimeo\])ism", $post['text'], $links1, PREG_SET_ORDER);
preg_match_all("(\[youtube\\](.*?)\[\/youtube\\])ism", $post['text'], $links2, PREG_SET_ORDER);
preg_match_all("(\[video\\](.*?)\[\/video\\])ism", $post['text'], $links3, PREG_SET_ORDER);
preg_match_all("(\[audio\\](.*?)\[\/audio\\])ism", $post['text'], $links4, PREG_SET_ORDER);
// Add them to the other external links
$links = array_merge($links, $links1, $links2, $links3, $links4);
// Are there more than one?
if (count($links) > 1) {
// The post will be the type "text", which means a blog post
unset($post['type']);
$post['url'] = $plink;
}
if (!isset($post['type'])) {
$post['type'] = "text";
$post['text'] = trim($body);
}
if (($post['type'] == 'photo') && empty($post['images']) && !empty($post['remote_images'])) {
$post['images'] = $post['remote_images'];
$post['image'] = $post['images'][0]['url'];
if (!empty($post['images']) && !empty($post['images'][0]['description'])) {
$post['image_description'] = $post['images'][0]['description'];
}
}
unset($post['remote_images']);
} elseif (isset($post['url']) && ($post['type'] == 'video')) {
$data = ParseUrl::getSiteinfoCached($post['url']);
if (isset($data['images'][0])) {
$post['image'] = $data['images'][0]['src'];
}
}
return $post;
}
/**
* 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 removeAttachment($body, $no_link_desc = false)
{
return preg_replace_callback("/\s*\[attachment (.*?)\](.*?)\[\/attachment\]\s*/ism",
function ($match) use ($no_link_desc) {
$attach_data = self::getAttachmentData($match[0]);
if (empty($attach_data['url'])) {
return $match[0];
} 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);
}
/**
* Converts a BBCode text into plaintext
*
* @param $text
* @param bool $keep_urls Whether to keep URLs in the resulting plaintext
*
* @return string
*/
public static function toPlaintext($text, $keep_urls = true)
{
$naked_text = HTML::toPlaintext(self::convert($text, false, 0, true), 0, !$keep_urls);
return $naked_text;
}
private static function proxyUrl($image, $simplehtml = self::INTERNAL)
{
// Only send proxied pictures to API and for internal display
if (in_array($simplehtml, [self::INTERNAL, self::API])) {
return ProxyUtils::proxifyUrl($image);
} else {
return $image;
}
}
/**
* This function changing the visual size (not the real size) of images.
* The function does not work for pictures with an alternate text description.
* This could only be changed by using some new "img" BBCode format.
*
* @param string $srctext The body with images
* @return string The body with possibly scaled images
*/
public static function scaleExternalImages(string $srctext)
{
$s = $srctext;
// Simplify image links
$s = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $s);
$matches = null;
$c = preg_match_all('/\[img.*?\](.*?)\[\/img\]/ism', $s, $matches, PREG_SET_ORDER);
if ($c) {
foreach ($matches as $mtch) {
Logger::info('scale_external_image', ['image' => $mtch[1]]);
$hostname = str_replace('www.', '', substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3));
if (stristr($mtch[1], $hostname)) {
continue;
}
$curlResult = DI::httpRequest()->get($mtch[1]);
if (!$curlResult->isSuccess()) {
continue;
}
$i = $curlResult->getBody();
$type = $curlResult->getContentType();
$type = Images::getMimeTypeByData($i, $mtch[1], $type);
if ($i) {
$Image = new Image($i, $type);
if ($Image->isValid()) {
$orig_width = $Image->getWidth();
$orig_height = $Image->getHeight();
if ($orig_width > 640 || $orig_height > 640) {
$Image->scaleDown(640);
$new_width = $Image->getWidth();
$new_height = $Image->getHeight();
Logger::info('External images scaled', ['orig_width' => $orig_width, 'new_width' => $new_width, 'orig_height' => $orig_height, 'new_height' => $new_height, 'match' => $mtch[0]]);
$s = str_replace(
$mtch[0],
'[img=' . $new_width . 'x' . $new_height. ']' . $mtch[1] . '[/img]'
. "\n",
$s
);
Logger::info('New string', ['image' => $s]);
}
}
}
}
}
return $s;
}
/**
* 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($body)
{
$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::info('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::info('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::info('the limit happens after the end of the last image');
$new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen);
}
} else {
Logger::info('the text size with embedded images extracted did not violate the limit');
$new_body = $new_body . $orig_body;
}
return $new_body;
} else {
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
* @return string
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
public static function convertAttachment($text, $simplehtml = self::INTERNAL, $tryoembed = true, array $data = [])
{
$data = $data ?: self::getAttachmentData($text);
if (empty($data) || empty($data['url'])) {
return $text;
}
$stamp1 = microtime(true);
if (isset($data['title'])) {
$data['title'] = strip_tags($data['title']);
$data['title'] = str_replace(['http://', 'https://'], '', $data['title']);
} else {
$data['title'] = null;
}
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 .= '
' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8') . ' ' . $author_contact['addr'] . ":
\n" . '' . $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", '$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", '$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) { if (strpos($matches[3], "data:image/") === 0) { return $matches[0]; } $matches[3] = self::proxyUrl($matches[3], $simple_html); 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("/\[img\=(.*?)\](.*?)\[\/img\]/ism", function ($matches) use ($simple_html) { $matches[1] = self::proxyUrl($matches[1], $simple_html); $matches[2] = htmlspecialchars($matches[2], ENT_COMPAT); return ''; }, $text); // Images // [img]pathtoimage[/img] $text = preg_replace_callback( "/\[img\](.*?)\[\/img\]/ism", function ($matches) use ($simple_html) { if (strpos($matches[1], "data:image/") === 0) { return $matches[0]; } $matches[1] = self::proxyUrl($matches[1], $simple_html); return "[img]" . $matches[1] . "[/img]"; }, $text ); $text = preg_replace("/\[img\](.*?)\[\/img\]/ism", '', $text); $text = preg_replace("/\[zmg\](.*?)\[\/zmg\]/ism", '', $text); $text = preg_replace("/\[crypt\](.*?)\[\/crypt\]/ism", '
' . htmlentities(trim($matches[2], "\n\r"), ENT_NOQUOTES, 'UTF-8') . '
';
} else {
$return = '' . htmlentities($matches[2], ENT_NOQUOTES, 'UTF-8') . '
';
}
return $return;
},
$text
);
// Default iframe allowed domains/path
$allowedIframeDomains = [
DI::baseUrl()->getHostname()
. (DI::baseUrl()->getUrlPath() ? '/' . DI::baseUrl()->getUrlPath() : '')
. '/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'))
: []
);
$text = HTML::purify($text, $allowedIframeDomains);
return trim($text);
}
/**
* Strips the "abstract" tag from the provided text
*
* @param string $text The text with BBCode
* @return string The same text - but without "abstract" element
*/
public static function stripAbstract($text)
{
$text = preg_replace("/[\s|\n]*\[abstract\].*?\[\/abstract\][\s|\n]*/ism", '', $text);
$text = preg_replace("/[\s|\n]*\[abstract=.*?\].*?\[\/abstract][\s|\n]*/ism", '', $text);
return $text;
}
/**
* Returns the value of the "abstract" element
*
* @param string $text The text that maybe contains the element
* @param string $addon The addon for which the abstract is meant for
* @return string The abstract
*/
public static function getAbstract($text, $addon = '')
{
$abstract = '';
$abstracts = [];
$addon = strtolower($addon);
if (preg_match_all("/\[abstract=(.*?)\](.*?)\[\/abstract\]/ism", $text, $results, PREG_SET_ORDER)) {
foreach ($results AS $result) {
$abstracts[strtolower($result[1])] = $result[2];
}
}
if (isset($abstracts[$addon])) {
$abstract = $abstracts[$addon];
}
if ($abstract == '' && preg_match("/\[abstract\](.*?)\[\/abstract\]/ism", $text, $result)) {
$abstract = $result[1];
}
return $abstract;
}
/**
* 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)
{
$contact = Contact::getByURL($match[3], false, ['addr']);
if (empty($contact['addr'])) {
return $match[0];
}
$mention = $match[1] . '{' . $match[2] . '; ' . $contact['addr'] . '}';
return $mention;
}
/**
* Converts a BBCode text into Markdown
*
* This function converts a BBCode item body to be sent to Markdown-enabled
* systems like Diaspora and Libertree
*
* @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)
{
$original_text = $text;
// Since Diaspora is creating a summary for links, this function removes them before posting
if ($for_diaspora) {
$text = self::removeShareInformation($text);
}
/**
* Transform #tags, strip off the [url] and replace spaces with underscore
*/
$url_search_string = "^\[\]";
$text = preg_replace_callback("/#\[url\=([$url_search_string]*)\](.*?)\[\/url\]/i",
function ($matches) {
return '#' . str_replace(' ', '_', $matches[2]);
},
$text
);
// Converting images with size parameters to simple images. Markdown doesn't know it.
$text = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $text);
// Convert it to HTML - don't try oembed
if ($for_diaspora) {
$text = self::convert($text, false, self::DIASPORA);
// Add all tags that maybe were removed
if (preg_match_all("/#\[url\=([$url_search_string]*)\](.*?)\[\/url\]/ism", $original_text, $tags)) {
$tagline = '';
foreach ($tags[2] as $tag) {
$tag = html_entity_decode($tag, ENT_QUOTES, 'UTF-8');
if (!strpos(html_entity_decode($text, ENT_QUOTES, 'UTF-8'), '#' . $tag)) {
$tagline .= '#' . $tag . ' ';
}
}
$text = $text . " " . $tagline;
}
} else {
$text = self::convert($text, false, self::CONNECTORS);
}
// If a link is followed by a quote then there should be a newline before it
// Maybe we should make this newline at every time before a quote.
$text = str_replace([''], [''], $text); $stamp1 = microtime(true); // Now convert HTML to Markdown $text = HTML::toMarkdown($text); DI::profiler()->saveTimestamp($stamp1, "parser"); // 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', 'bbCodeMention2DiasporaCallback'], $text ); } Hook::callAll('bb2diaspora', $text); 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) { $ret = []; BBCode::performWithEscapedTags($string, ['noparse', 'pre', 'code', 'img'], 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; } } }); return array_unique($ret); } /** * Expand tags to URLs * * @param string $body * @return string body with expanded tags */ public static function expandTags(string $body) { return preg_replace_callback("/([!#@])([^\^ \x0D\x0A,;:?\']*[^\^ \x0D\x0A,;:?!\'.])/", function ($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 '#': return $match[1] . '[url=' . 'https://' . 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 * @param array $tagList A list of tag names, e.g ['noparse', 'nobb', 'pre'] * @param callable $callback * @return string * @throws Exception *@see Strings::performWithEscapedBlocks * */ public static function performWithEscapedTags(string $text, array $tagList, callable $callback) { $tagList = array_map('preg_quote', $tagList); return Strings::performWithEscapedBlocks($text, '#\[(?:' . implode('|', $tagList) . ').*?\[/(?:' . implode('|', $tagList) . ')]#ism', $callback); } /** * Replaces mentions in the provided message body for the provided user and network if any * * @param $body * @param $profile_uid * @param $network * @return string * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ public static function setMentions($body, $profile_uid = 0, $network = '') { BBCode::performWithEscapedTags($body, ['noparse', 'pre', 'code', 'img'], function ($body) use ($profile_uid, $network) { $tags = BBCode::getTags($body); $tagged = []; $inform = ''; 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, $inform, $profile_uid, $tag, $network)) && $success['replaced']) { $tagged[] = $tag; } } return $body; }); 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) * @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) { $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); } $header .= "']"; 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::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); 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; } return $bbcode; } unset($siteinfo['keywords']); // Bypass attachment if parse url for a comment if (!$tryAttachment) { return "\n" . '[url=' . $url . ']' . $siteinfo['title'] . '[/url]'; } // Format it as BBCode attachment return "\n" . PageInfo::getFooterFromData($siteinfo); } }