Invision Power Services, Inc.
* @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc.
* @license http://www.invisionpower.com/legal/standards/
* @package IPS Social Suite
* @since 12 Jun 2013
* @version SVN_VERSION_NUMBER
*/
namespace IPS\Text;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* Text Parser
*/
class _Parser
{
/**
* @brief Cached permissions
*/
protected static $permissions = array();
/**
* @brief File object classes
*/
protected static $fileObjectClasses = array();
/**
* @brief Emoticons
*/
protected static $emoticons = NULL;
/**
* @brief Any error messages generated during the parse routine
* @li URL_NOT_ALLOWED
*/
public $errors = array();
/**
* Parse statically
*
* @param string $value The value to parse
* @param bool $bbcode Parse BBCode?
* @param array|null $attachIds array of ID numbers to idenfity content for attachments if the content has been saved - the first two must be int or null, the third must be string or null. If content has not been saved yet, an MD5 hash used to claim attachments after saving.
* @param \IPS\Member|null $member If parsing BBCode or attachments, the member posting. NULL will use currently logged in member.
* @param string|bool $area If parsing BBCode or attachments, the Editor area we're parsing in. e.g. "core_Signatures". A boolean value will allow or disallow all BBCodes that are dependant on area.
* @param bool $filterProfanity Remove profanity?
* @param bool $cleanHtml If TRUE, HTML will be cleaned through HTMLPurifier
* @param callback $htmlPurifierConfig A function which will be passed the HTMLPurifier_Config object to customise it - see example
* @param int $requestTimeout NULL uses \IPS\DEFAULT_REQUEST_TIMEOUT. Can be set manually to avoid large timeouts on rebuild processes
* @return string
* @see __construct
*/
public static function parseStatic( $value, $bbcode=FALSE, $attachIds=NULL, $member=NULL, $area=FALSE, $filterProfanity=TRUE, $cleanHtml=TRUE, $htmlPurifierConfig=NULL, $requestTimeout=NULL )
{
$obj = new self( $bbcode, $attachIds, $member, $area, $filterProfanity, $cleanHtml, $htmlPurifierConfig, $requestTimeout );
return $obj->parse( $value );
}
/**
* Can use plugin?
*
* @param \IPS\Member $member The member
* @param string $key Plugin key
* @param string $area The Editor area
* @return bool
*/
public static function canUse( \IPS\Member $member, $key, $area )
{
$permissionSettings = json_decode( \IPS\Settings::i()->ckeditor_permissions, TRUE );
if ( !isset( static::$permissions[ $member->member_id ][ $key ] ) )
{
if ( !isset( $permissionSettings[ $key ] ) )
{
static::$permissions[ $member->member_id ][ $key ] = TRUE;
}
else
{
$val = TRUE;
if ( $permissionSettings[ $key ]['groups'] !== '*' )
{
if ( !$member->inGroup( $permissionSettings[ $key ]['groups'] ) )
{
$val = FALSE;
}
}
if ( $permissionSettings[ $key ]['areas'] !== '*' )
{
if ( !in_array( $area, $permissionSettings[ $key ]['areas'] ) )
{
$val = FALSE;
}
}
static::$permissions[ $member->member_id ][ $key ] = $val;
}
}
return static::$permissions[ $member->member_id ][ $key ];
}
/**
* Get BBCode Tags
* Even though we no longer support Custom BBCode in favour of CKEditor plugins, this
* method is separated so that hooks can continue to provide additional BBCodes if
* administrators really want them.
*
* @param \IPS\Member $member The member
* @param string|bool $area The Editor area we're parsing in. e.g. "core_Signatures". A boolean value will allow or disallow all BBCodes that are dependant on area.
* @code
return array(
'font' => array( // Key represents the BBCode tag (e.g. [font])
'tag' => 'span', // The HTML tag to use
'attributes' => array( ... ) // Key/Value pairs of attributes to use (optional) - can use {option} to get the [tag=option] value
'callback' => function( $node, $matches, $document ) // A callback to modify the DOMNode object
{
...
},
'block' => FALSE, // If this is a block-level tag (optional, default false)
'single' => FALSE, // If this is a single tag, with no content (optional, default false)
)
* @endcode
* @return array
*/
public function bbcodeTags( \IPS\Member $member, $area )
{
$return = array();
/* Acronym */
$return['acronym'] = array( 'tag' => 'abbr', 'attributes' => array( 'title' => '{option}' ) );
/* Background */
if ( static::canUse( $member, 'BGColor', $area ) )
{
$return['background'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'background-color:{option}' ) );
}
/* Bold */
if ( static::canUse( $member, 'Bold', $area ) )
{
$return['b'] = array( 'tag' => 'strong' );
}
/* Code */
if ( static::canUse( $member, 'pbckcode', $area ) )
{
$code = array( 'tag' => 'pre', 'attributes' => array( 'class' => 'ipsCode' ), 'block' => TRUE, 'noParse' => TRUE, 'noChildren' => TRUE );
$return['code'] = $code;
$return['codebox'] = $code;
$return['html'] = $code;
$return['php'] = $code;
$return['sql'] = $code;
$return['xml'] = $code;
}
/* Color */
if ( static::canUse( $member, 'TextColor', $area ) )
{
$return['color'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'color:{option}' ) );
}
/* Font */
if ( static::canUse( $member, 'Font', $area ) )
{
$return['font'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'font-family:{option}' ) );
}
/* HR */
$return['hr'] = array( 'tag' => 'hr', 'single' => TRUE );
/* Image */
if ( static::canUse( $member, 'ipsImage', $area ) )
{
$return['img'] = array( 'tag' => 'img', 'attributes' => array( 'src' => '{option}', 'class' => 'ipsImage' ), 'single' => TRUE );
}
/* Indent */
if ( static::canUse( $member, 'Indent', $area ) )
{
$return['indent'] = array( 'tag' => 'div', 'attributes' => array( 'style' => 'margin-left:{option}px' ), 'block' => FALSE );
}
/* Italics */
if ( static::canUse( $member, 'Italic', $area ) )
{
$return['i'] = array( 'tag' => 'em' );
}
/* Justify */
if ( static::canUse( $member, 'JustifyLeft', $area ) )
{
$return['left'] = array( 'tag' => 'div', 'attributes' => array( 'style' => 'text-align:left' ), 'block' => TRUE );
}
if ( static::canUse( $member, 'JustifyCenter', $area ) )
{
$return['center'] = array( 'tag' => 'div', 'attributes' => array( 'style' => 'text-align:center' ), 'block' => TRUE );
}
if ( static::canUse( $member, 'JustifyRight', $area ) )
{
$return['right'] = array( 'tag' => 'div', 'attributes' => array( 'style' => 'text-align:right' ), 'block' => TRUE );
}
/* Links */
if ( static::canUse( $member, 'ipsLink', $area ) )
{
/* Global */
$return['email'] = array( 'tag' => 'a', 'attributes' => array( 'href' => 'mailto:{option}' ) );
$return['member'] = array(
'tag' => 'a',
'callback' => function( $node, $matches, $document )
{
$memberName = trim( $matches[2], '\'"' );
try
{
$member = \IPS\Member::load( $memberName, 'name' );
if ( $member->member_id != 0)
{
$node->setAttribute( 'href', $member->url() );
$node->appendChild( $document->createTextNode( $member->name ) );
}
}
catch ( \Exception $e ) {}
return $node;
},
'single' => TRUE,
);
$return['url'] = array(
'tag' => 'a',
'attributes' => array( 'href' => '{option}' ),
'callback' => function( $node, $matches, $document )
{
if( isset( $matches[2] ) )
{
$node->setAttribute( 'href', trim( $matches[2], "'\"" ) );
}
return $node;
}
);
/* Blog */
if ( \IPS\Application::appIsEnabled('blog') )
{
$return['blog'] = array(
'tag' => 'a',
'callback' => function( $node, $matches, $document )
{
$node->setAttribute( 'href', \IPS\Http\Url::internal( "app=blog&showblog=" . urlencode( $matches[2] ), 'front' ) );
}
);
$return['entry'] = array(
'tag' => 'a',
'callback' => function( $node, $matches, $document )
{
$node->setAttribute( 'href', \IPS\Http\Url::internal( "app=blog&showentry" . urlencode( $matches[2] ), 'front' ) );
}
);
}
/* Forums */
if ( \IPS\Application::appIsEnabled('forums') )
{
$return['post'] = array(
'tag' => 'a',
'callback' => function( $node, $matches, $document )
{
$node->setAttribute( 'href', \IPS\Http\Url::internal( "act=findpost&pid=" . urlencode( $matches[2] ), 'front' ) );
}
);
$return['snapback'] = array(
'tag' => 'a',
'callback' => function( $node, $matches, $document )
{
$node->setAttribute( 'href', \IPS\Http\Url::internal( "act=findpost&pid=" . urlencode( $matches[2] ), 'front' ) );
$node->appendChild( $document->createEntityReference( 'larr' ) );
return $node;
},
'single' => TRUE,
);
$return['topic'] = array(
'tag' => 'a',
'callback' => function( $node, $matches, $document )
{
try
{
$topic = \IPS\forums\Topic::load( intval( $matches[2] ) );
$node->setAttribute( 'href', \IPS\Http\Url::internal( "app=forums&module=forums&controller=topic&id=" . urlencode( $matches[2] ), 'front', 'forums_topic', array( $topic->title_seo ) ) );
}
catch( \Exception $e )
{
$node->setAttribute( 'href', \IPS\Http\Url::internal( "app=forums&module=forums&controller=topic&id=" . urlencode( $matches[2] ), 'front' ) );
}
return $node;
}
);
}
}
/* List */
if ( static::canUse( $member, 'BulletedList', $area ) or static::canUse( $member, 'NumberedList', $area ) )
{
$return['list'] = array(
'tag' => 'ul',
'attributes' => array( 'data-ipsBBCode-list' => 'true' ),
'callback' => function( $node, $matches, $document )
{
if ( isset( $matches[2] ) )
{
$node = $document->createElement( 'ol' );
$node->setAttribute( 'data-ipsBBCode-list', 'true' );
switch ( $matches[2] )
{
case '1':
$node->setAttribute( 'style', 'list-style-type: decimal' );
break;
case '0':
$node->setAttribute( 'style', 'list-style-type: decimal-leading-zero' );
break;
case 'a':
$node->setAttribute( 'style', 'list-style-type: lower-alpha' );
break;
case 'A':
$node->setAttribute( 'style', 'list-style-type: upper-alpha' );
break;
case 'i':
$node->setAttribute( 'style', 'list-style-type: lower-roman' );
break;
case 'I':
$node->setAttribute( 'style', 'list-style-type: upper-roman' );
break;
}
}
return $node;
},
'block' => TRUE
);
}
/* Media tags can be stripped as we'll automatically act on the URL */
$return['blogmedia'] = NULL;
$return['flash'] = NULL;
$return['media'] = NULL;
$return['movie'] = NULL;
$return['video'] = NULL;
$return['youtube'] = NULL;
/* Quote */
if ( static::canUse( $member, 'ipsQuote', $area ) )
{
$return['quote'] = array(
'tag' => 'blockquote',
'callback' => function( $node, $matches )
{
$node->setAttribute( 'class', 'ipsQuote' );
$node->setAttribute( 'data-ipsQuote', '' );
$node->setAttribute( 'data-cite', isset( $matches[2] ) ? $matches[2] : NULL );
return $node;
},
'block' => TRUE
);
}
/* Size */
if ( static::canUse( $member, 'FontSize', $area ) )
{
$return['size'] = array(
'tag' => 'span',
'callback' => function( $node, $matches )
{
switch ( $matches[2] )
{
case 1:
$node->setAttribute( 'style', 'font-size:8px' );
break;
case 2:
$node->setAttribute( 'style', 'font-size:10px' );
break;
case 3:
$node->setAttribute( 'style', 'font-size:12px' );
break;
case 4:
$node->setAttribute( 'style', 'font-size:14px' );
break;
case 5:
$node->setAttribute( 'style', 'font-size:18px' );
break;
case 6:
$node->setAttribute( 'style', 'font-size:24px' );
break;
case 7:
$node->setAttribute( 'style', 'font-size:36px' );
break;
case 8:
$node->setAttribute( 'style', 'font-size:48px' );
break;
}
return $node;
}
);
}
/* Spoiler */
if ( static::canUse( $member, 'ipsSpoiler', $area ) )
{
$return['spoiler'] = array( 'tag' => 'blockquote', 'attributes' => array( 'class' => 'ipsStyle_spoiler', 'data-ipsSpoiler' => '', 'tabindex' => '0' ), 'block' => TRUE );
}
/* Strike */
if ( static::canUse( $member, 'Strike', $area ) )
{
$return['s'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'text-decoration:line-through' ) );
$return['strike'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'text-decoration:line-through' ) );
}
/* Subscript */
if ( static::canUse( $member, 'Subscript', $area ) )
{
$return['sub'] = array( 'tag' => 'sub' );
}
/* Superscript */
if ( static::canUse( $member, 'Superscript', $area ) )
{
$return['sup'] = array( 'tag' => 'sup' );
}
/* Underline */
if ( static::canUse( $member, 'Underline', $area ) )
{
$return['u'] = array( 'tag' => 'span', 'attributes' => array( 'style' => 'text-decoration:underline' ) );
}
/* IP.Content "page" and the necessary links */
if ( static::canUse( $member, 'Page', $area ) )
{
$return['page'] = array(
'tag' => 'div',
'callback' => function( $node, $matches, $document )
{
$node->setAttribute( 'data-role', 'contentPage' );
$break = $document->createElement( 'hr' );
$break->setAttribute( 'data-role', 'contentPageBreak' );
$node->appendChild( $break );
return $node;
},
'attributes' => array(),
'block' => TRUE,
);
}
return $return;
}
/**
* Get OEmbed Services
* Implemented in this way so it's easy for hook authors to override if they wanted to
*
* @see oEmbed
* @return array
*/
protected static function oembedServices()
{
return array(
'youtube.com' => 'https://www.youtube.com/oembed',
'youtu.be' => 'https://www.youtube.com/oembed',
'flickr.com' => 'http://www.flickr.com/services/oembed/',
'flic.kr' => 'http://www.flickr.com/services/oembed/',
'hulu.com' => 'http://www.hulu.com/api/oembed.json',
'vimeo.com' => 'http://vimeo.com/api/oembed.json',
'collegehumor.com' => 'http://www.collegehumor.com/oembed.json',
'twitter.com' => 'https://api.twitter.com/1/statuses/oembed.json',
'instagr.am' => 'http://api.instagram.com/oembed',
'instagram.com' => 'http://api.instagram.com/oembed',
'soundcloud.com' => 'http://soundcloud.com/oembed',
'open.spotify.com' => 'https://embed.spotify.com/oembed/',
'play.spotify.com' => 'https://embed.spotify.com/oembed/',
'ted.com' => 'http://www.ted.com/talks/oembed.json',
'vine.co' => 'https://vine.co/oembed.json',
);
}
/**
* Get URL bases (whout schema) that we'll allow iframes from
*
* @return array
*/
protected static function allowedIFrameBases()
{
$return = array();
/* The community itself (for internal embeds) */
$return[] = str_replace( array( 'http://', 'https://' ), '', \IPS\Settings::i()->base_url );
/* Our default embed options */
$return = array_merge( $return, array(
'www.youtube.com/embed/',
'player.vimeo.com/video/',
'www.hulu.com/embed.html',
'www.collegehumor.com/e/',
'embed-ssl.ted.com/',
'vine.co/',
'gfycat.com/',
'embed.spotify.com/'
) );
/* Extra admin-defined options */
if ( \IPS\Settings::i()->editor_allowed_iframe_bases )
{
$return = array_merge( $return, explode( ',', \IPS\Settings::i()->editor_allowed_iframe_bases ) );
}
return $return;
}
/**
* Convert URL to embed HTML
*
* @param string $url The URL
* @param bool $iframe Some services need to be in iFrames so they cannot be edited in the editor. If TRUE, will return contents for iframe, if FALSE, return the iframe.
* @return string|null HTML embded code, or NULL if URL is not embeddable
*/
public static function embeddableMedia( $url, $iframe=FALSE )
{
/* Init */
if ( filter_var( $url, FILTER_VALIDATE_URL ) === FALSE )
{
return NULL;
}
$parsedUrl = parse_url( $url );
$domain = $parsedUrl['host'];
if ( mb_substr( $domain, 0, 4 ) === 'www.' )
{
$domain = mb_substr( $domain, 4 );
}
if( !$domain )
{
return null;
}
/* oEmbed services */
$oembedServices = static::oembedServices();
if ( array_key_exists( $domain, $oembedServices ) )
{
try
{
$oembedUrl = \IPS\Http\Url::external( $oembedServices[ $domain ] )->setQueryString( array( 'format' => 'json', 'url' => $url, 'scheme' => ( $parsedUrl['scheme'] === 'https' or \IPS\Request::i()->isSecure() ) ? 'https' : null ) );
$response = $oembedUrl->request( static::$requestTimeout )->get()->decodeJson();
/* Ignore errors - this will be caught by the catch statement below */
if( !isset( $response['type'] ) )
{
throw new \RuntimeException( 'NO_EMBED_TYPE' );
}
/* The "type" parameter is a way for services to indicate the type of content they are retruning. It is not strict, but we use it to identify the best styles to apply. */
switch ( $response['type'] )
{
/* Static photo - show an tag, linked if necessary, using .ipsImage to be responsive. Similar outcome to if a user had used the "insert image from URL" button */
case 'photo':
return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->photo( $response['url'], $url, $response['title'] );
/* Video - insert the provided HTML directly (it will be a video so there's nothing we need to prevent from being edited), using .ipsEmbeddedVideo to make it responsive */
case 'video':
$response['html'] = str_replace( 'allowfullscreen', 'allowfullscreen="true"', $response['html'] );
return \IPS\Theme::i()->getTemplate( 'embed', 'core', 'global' )->video( $response['html'] );
/* Other - show an
', '
', $v );
$v = str_replace( array( '
', '
' ), '', $v ); $v = str_replace( '[*]', '[*]", $text );
$text = str_ireplace( "[list]
", "
[list]", $text );
/* Little hacky way of fixing newlines inside center tags */
$text = preg_replace( "/\[center\](.*?)
(.*?)\[\/center\]/i", "[center]$1
$2[/center]", $text ); /* Little hacky way of fixing newlines inside code tags */ while( preg_match( "/\[code\](.*?)<(br|p)>(.*?)\[\/code\]/ims", $text ) ) { $text = preg_replace_callback( "/\[code\](.*?)<(?:br|p)>(.*?)\[\/code\]/ims", function( $matches ) { return "[code]" . str_replace( '
', '', "{$matches[1]}\n{$matches[2]}[/code]" ); }, $text ); } /* Little hacky way of supporting [tag]content[/tag] where our preference is [tag=content] */ if ( array_key_exists( 'img', $this->bbcode ) ) { $text = preg_replace( '/\[img\]([^\s]+)\[\/img\]/i', '[img=$1]', $text ); } if ( array_key_exists( 'snapback', $this->bbcode ) ) { $text = preg_replace( '/\[snapback\]([^\s]+)\[\/snapback\]/i', '[snapback=$1]', $text ); } /* If this is raw text immediately inside a quote, we need to fix that */ if( trim( $text ) and $parent instanceof \DOMElement and $parent->tagName === 'blockquote' and mb_strpos( $parent->getAttribute('class'), 'ipsStyle_spoiler' ) === FALSE ) { $wrapper = $document->createElement( 'p', $text ); $parent->appendChild( $wrapper ); return true; } if ( !is_null( $text ) ) { /* Note that we've not added any text yet */ $addedText = FALSE; $textToGo = $text; /* Construct break points */ $breakPoints = array(); if ( count( $this->bbcode ) ) { $breakPoints[] = '(\[\/?(?:' . implode( '|', array_map( 'preg_quote', array_keys( $this->bbcode ) ) ) . '|\*)(?:=.+?)?\])'; } if ( count( $this->acronyms ) or count( $this->exactProfanity ) ) { $array = array(); foreach( array_merge( array_keys( $this->acronyms ), array_keys( $this->exactProfanity ) ) as $entry ) { $array[] = preg_quote( $entry, '/' ); } /* @note: We use \b because of http://community.invisionpower.com/resources/bugs.html/_/4-0-0/anacronym-doesnt-work-in-middle-of-a-phrase-r47612 */ $breakPoints[] = '((?=<^|\b)(?:' . implode( '|', $array ) . ')(?=\b|$))'; } /* Split on BBCode tags, profanity and acronyms */ if ( count( $breakPoints ) ) { foreach( preg_split( '/' . implode( '|', $breakPoints ) . '/iu', $text, null, PREG_SPLIT_DELIM_CAPTURE ) as $section ) { /* Deduct it from $textToGo */ $textToGo = mb_substr( $textToGo, mb_strlen( $section ) ); /* Replace exact profanity */ if ( isset( $this->exactProfanity[ mb_strtolower( $section ) ] ) ) { $profanityNode = $document->createTextNode( $this->exactProfanity[ mb_strtolower( $section ) ] ); $parent->appendChild( $profanityNode ); $addedText = TRUE; } /* Replace acronym */ elseif ( isset( $this->acronyms[ mb_strtolower($section) ] ) ) { if ( ( !$this->acronyms[ mb_strtolower( $section ) ]['a_casesensitive'] or $this->acronyms[ mb_strtolower( $section ) ]['a_short'] == $section ) and $parent->tagName !== 'abbr' ) { $acronymNode = $document->createElement( 'abbr', htmlspecialchars( $section, \IPS\HTMLENTITIES, 'UTF-8', FALSE ) ); $acronymNode->setAttribute( 'title', $this->acronyms[ mb_strtolower($section) ]['a_long'] ); } else { $acronymNode = $document->createTextNode( $section ); } $parent->appendChild( $acronymNode ); $addedText = TRUE; } /* If this is a start BBCode tag, do stuff */ elseif ( $this->parseOn === TRUE and preg_match( '/^\[([a-z]+?)(?:=(.+?))?\]$/i', $section, $matches ) and array_key_exists( mb_strtolower( $matches[1] ), $this->bbcode ) ) { $_tag = mb_strtolower( $matches[1] ); /* Strip it? */ if ( $this->bbcode[ $_tag ] === NULL ) { continue; } /* Create the node */ $bbcodeNode = $document->createElement( $this->bbcode[ $_tag ]['tag'] ); /* Add attributes */ if ( isset( $this->bbcode[ $_tag ]['attributes'] ) ) { foreach ( $this->bbcode[ $_tag ]['attributes'] as $k => $v ) { if ( isset( $matches[2] ) ) { $v = str_replace( '{option}', $matches[2], $v ); } $bbcodeNode->setAttribute( $k, $v ); } } /* Callback */ if ( isset( $this->bbcode[ $_tag ]['callback'] ) ) { $bbcodeNode = call_user_func( $this->bbcode[ $_tag ]['callback'], $bbcodeNode, $matches, $document ); } /* No parse? */ if ( isset( $this->bbcode[ $_tag ]['noParse'] ) and $this->bbcode[ $_tag ]['noParse'] ) { $this->parseOn = "[/{$_tag}]"; } /* Block */ if ( isset( $this->bbcode[ $_tag ]['block'] ) and $this->bbcode[ $_tag ]['block'] ) { /* Insert it */ $this->openBlock = $bbcodeNode; $this->openBlocks[] = $this->openBlock; try { if( $parent->parentNode ) { $parent->parentNode->appendChild( $this->openBlock ); } else { $parent->appendChild( $this->openBlock ); } } catch( \ErrorException $e ) { /* 'DOMNode::appendChild(): Couldn't fetch DOMElement' */ if( mb_strpos( $e->getMessage(), "fetch DOMElement" ) ) { throw new \InvalidArgumentException( $e->getMessage(), 103014 ); } throw $e; } /* If we have text to go, create a placeholder for it */ if ( $textToGo ) { if( !isset( $this->bbcode[ $_tag ]['noChildren'] ) OR !$this->bbcode[ $_tag ]['noChildren'] ) { $parent = $document->createElement( $parent->tagName ); /* Sometimes bad data in earlier versions can cause parsing issues */ try { $this->openBlock->appendChild( $parent ); } catch( \ErrorException $e ) { /* 'DOMNode::appendChild(): Couldn't fetch DOMElement' */ if( mb_strpos( $e->getMessage(), "fetch DOMElement" ) ) { throw new \InvalidArgumentException( $e->getMessage(), 103014 ); } throw $e; } } else { $parent = $this->openBlock; } } } /* Single */ elseif ( isset( $this->bbcode[ $_tag ]['single'] ) and $this->bbcode[ $_tag ]['single'] === TRUE ) { $nodeToAppend = $bbcodeNode; foreach ( $this->inlineNodes as $element ) { $_node = $element->cloneNode( TRUE ); $_node->appendChild( $nodeToAppend ); $nodeToAppend = $_node; } try { $parent->appendChild( $nodeToAppend ); } catch( \ErrorException $e ) { /* 'DOMNode::appendChild(): Couldn't fetch DOMElement' */ if( mb_strpos( $e->getMessage(), "fetch DOMElement" ) ) { throw new \InvalidArgumentException( $e->getMessage(), 103014 ); } throw $e; } $addedText = TRUE; } /* Inline */ else { if( $bbcodeNode !== null ) { $this->inlineNodes[] = $bbcodeNode; } } } /* If this is a close BBCode tag, do stuff */ elseif ( ( $this->parseOn === TRUE and in_array( mb_strtolower( $section ), array_map( function( $v ) { return "[/{$v}]"; }, array_keys( $this->bbcode ) ) ) ) or $this->parseOn === mb_strtolower( $section ) ) { $_tag = mb_strtolower( $section ); $_bTag = mb_substr( $_tag, 2, -1 ); /* Strip it? */ if ( $this->bbcode[ $_bTag ] === NULL ) { continue; } /* Turn parsing back on */ $this->parseOn = TRUE; /* Block */ if ( isset( $this->bbcode[ $_bTag ]['block'] ) and $this->bbcode[ $_bTag ]['block'] ) { /* If we have text to go, insert a placeholder for it */ if ( $textToGo ) { $parent = $document->createElement( $parent->tagName ); if( $this->openBlock === NULL ) { $this->openBlock = $document->createElement( 'div' ); $document->appendChild( $this->openBlock ); } if ( $this->openBlock->nextSibling ) { $this->openBlock->parentNode->insertBefore( $parent, $this->openBlock->nextSibling ); } elseif( $this->openBlock->parentNode ) { try { $this->openBlock->parentNode->appendChild( $parent ); } catch( \ErrorException $e ) { /* 'DOMNode::appendChild(): Couldn't fetch DOMElement' */ if( mb_strpos( $e->getMessage(), "fetch DOMElement" ) ) { throw new \InvalidArgumentException( $e->getMessage(), 103014 ); } throw $e; } } } /* Remove it */ foreach ( array_reverse( $this->openBlocks, TRUE ) as $k => $element ) { /* Severely badly formatted bbcode can throw Couldn't fetch DOMElement. Node no longer exists */ try { if ( $element->tagName == $this->bbcode[ $_bTag ]['tag'] ) { unset( $this->openBlocks[ $k ] ); } } catch( \ErrorException $e ) { unset( $this->openBlocks[ $k ] ); } } /* Work out the new open block */ $this->openBlock = NULL; if ( !empty( $this->openBlocks ) ) { foreach ( $this->openBlocks as $element ) { if ( !$this->openBlock ) { $this->openBlock = $element; } else { try { $this->openBlock->appendChild( $element ); $this->openBlock = $element; } catch( \ErrorException $e ) { /* 'DOMNode::appendChild(): Couldn't fetch DOMElement' */ if( mb_strpos( $e->getMessage(), "fetch DOMElement" ) ) { throw new \InvalidArgumentException( $e->getMessage(), 103014 ); } throw $e; } } } } } /* Inline */ else { foreach ( array_reverse( $this->inlineNodes, TRUE ) as $k => $element ) { if ( $element->tagName == $this->bbcode[ $_bTag ]['tag'] ) { unset( $this->inlineNodes[ $k ] ); } } } } /* If this is normal text, add it to the new node */ elseif ( !is_null( $section ) and $section !== '' ) { /* Replace loose profanity */ $section = str_ireplace( array_keys( $this->looseProfanity ), array_values( $this->looseProfanity ), $section ); /* First create a text node */ $node = $document->createTextNode( $section ); try { /* Then put it in whatever elements are necessary */ foreach ( $this->inlineNodes as $element ) { $_node = $element->cloneNode( TRUE ); $_node->appendChild( $node ); $node = $_node; } /* And insert it */ $parent->appendChild( $node ); } catch( \ErrorException $e ) { /* 'DOMNode::appendChild(): Couldn't fetch DOMElement' */ if( mb_strpos( $e->getMessage(), "fetch DOMElement" ) ) { throw new \InvalidArgumentException( $e->getMessage(), 103014 ); } throw $e; } /* Note we did so */ $addedText = TRUE; } } /* Return */ return $addedText; } else { /* First create a text node */ $node = $document->createTextNode( $text ); try { $newNode = $document->importNode( $node ); $parent->appendChild( $newNode ); } catch( \ErrorException $e ) { /* 'DOMNode::appendChild(): Couldn't fetch DOMElement' */ if( mb_strpos( $e->getMessage(), "fetch DOMElement" ) ) { throw new \InvalidArgumentException( $e->getMessage(), 103014 ); } throw $e; } return TRUE; } } else { /* First create a text node */ $node = $document->createTextNode( $text ); try { $newNode = $document->importNode( $node ); $parent->appendChild( $newNode ); } catch( \ErrorException $e ) { /* 'DOMNode::appendChild(): Couldn't fetch DOMElement' */ if( mb_strpos( $e->getMessage(), "fetch DOMElement" ) ) { throw new \InvalidArgumentException( $e->getMessage(), 103014 ); } throw $e; } return TRUE; } return FALSE; } /* Nope, normal node - just import and loop over it's children */ else { $added = FALSE; /* DOMComment for instance does not even have a tagName property, so just return if that's the case */ if( !isset( $node->tagName ) ) { return $added; } /* If this is the head of the document, just skip as we added that to force UTF-8 mode */ if( $node->tagName == 'head' ) { return $added; } /* Remove Negative Margins or nowraps */ if( !( $node instanceof \DOMComment ) and $node->nodeType == XML_ELEMENT_NODE and $node->hasAttribute('style') ) { $style = $node->getAttribute('style'); $_styles = explode( ';', rtrim( $style, ';' ) ); $_saved = array(); foreach( $_styles as $_style ) { $_inlineStyle = explode( ':', $_style ); if( mb_strpos( trim( $_inlineStyle[0] ), 'margin' ) === 0 ) { if( mb_substr( trim( $_inlineStyle[1] ), 0, 1 ) === '-' ) { continue; } } if( mb_strpos( trim( $_inlineStyle[0] ), 'white-space' ) === 0 and mb_strpos( trim( $_inlineStyle[1] ), 'nowrap' ) === 0 ) { continue; } $_saved[] = $_style; } if( count( $_saved ) != count( $_styles ) ) { $node->setAttribute( 'style', implode( ';', $_saved ) ); $added = TRUE; } } /* Is it a link to an attachment? */ if ( $node->tagName === 'img' and preg_match( '#^' . preg_quote( rtrim( \IPS\Settings::i()->base_url, '/' ), '#' ) . '/applications/core/interface/file/attachment\.php\?id=(\d+)$#', $node->getAttribute('data-fileid'), $matches ) ) { $node->setAttribute( 'data-fileid', $matches[1] ); } if ( ( $node->tagName === 'a' and preg_match( '#^' . preg_quote( rtrim( \IPS\Settings::i()->base_url, '/' ), '#' ) . '/applications/core/interface/file/attachment\.php\?id=(\d+)$#', $node->getAttribute('href'), $matches ) ) or ( $node->tagName === 'img' and $matches[1] = $node->getAttribute('data-fileid') ) ) { if ( isset( $matches[1] ) ) { try { $attachment = \IPS\Db::i()->select( '*', 'core_attachments', array( 'attach_id=?', $matches[1] ) )->first(); if ( isset( $this->existingAttachments[ $attachment['attach_id'] ] ) ) { unset( $this->existingAttachments[ $attachment['attach_id'] ] ); } elseif ( $attachment['attach_member_id'] === $this->member->member_id and !in_array( $attachment['attach_id'], $this->mappedAttachments ) ) { \IPS\Db::i()->replace( 'core_attachments_map', array( 'attachment_id' => $attachment['attach_id'], 'location_key' => $this->area, 'id1' => ( is_array( $this->attachIds ) and isset( $this->attachIds[0] ) ) ? $this->attachIds[0] : NULL, 'id2' => ( is_array( $this->attachIds ) and isset( $this->attachIds[1] ) ) ? $this->attachIds[1] : NULL, 'id3' => ( is_array( $this->attachIds ) and isset( $this->attachIds[2] ) ) ? $this->attachIds[2] : NULL, 'temp' => is_string( $this->attachIds ) ? $this->attachIds : NULL ) ); $this->mappedAttachments[] = $attachment['attach_id']; } $added = TRUE; } catch ( \UnderflowException $e ) { } } } /* Is it a quote? */ if ( $node->tagName === 'blockquote' && mb_strpos( $node->getAttribute('class'), 'ipsStyle_spoiler' ) === FALSE ) { /* Do we need to change the relative time? */ if ( $node->getAttribute('data-cite') and $node->getAttribute('data-ipsquote-username') ) { $node->setAttribute( 'data-cite', $node->getAttribute('data-ipsquote-username') ); // That's only actually ever shown if JS is disabled. } } /* Check URL blacklist/whitelist */ if( $node->tagName === 'a' ) { if( !$this->isAllowedUrl( $node->getAttribute('href') ) ) { $node = $document->createTextNode( $node->getAttribute('href') ); $node->tagName = NULL; } else { /* Skipping VigLink? */ if ( \IPS\Settings::i()->viglink_norewrite and \IPS\Member::loggedIn()->inGroup( explode( ',', \IPS\Settings::i()->viglink_norewrite ) ) ) { $rels[] = 'norewrite'; } /* Internal or External */ try { $link = new \IPS\Http\Url( $node->getAttribute('href') ); if ( !$link->isInternal ) { $rels[] = 'external'; /* Do we also want to add nofollow? */ if( \IPS\Settings::i()->posts_add_nofollow and ( !\IPS\Settings::i()->posts_add_nofollow_exclude or ( isset( $link->data['host'] ) and !in_array( preg_replace( '/^www\./', '', $link->data['host'] ), json_decode( \IPS\Settings::i()->posts_add_nofollow_exclude ) ) ) ) ) { $rels[] = 'nofollow'; } } if ( !empty( $rels ) ) { $node->setAttribute( 'rel', implode( ' ', $rels) ); } } catch( \ErrorException $e ) { $node = $document->createTextNode( $node->getAttribute('href') ); $node->tagName = NULL; } } } /* Act */ if ( !( $node instanceof \DOMComment ) and $node->nodeType == XML_ELEMENT_NODE and ( $node->tagName === 'img' OR $node->tagName === 'iframe' ) AND $node->hasAttribute( 'data-munge-src' ) ) { $node->removeAttribute( 'src' ); $node->setAttribute( 'src', $node->getAttribute('data-munge-src') ); $node->removeAttribute( 'data-munge-src' ); $added = TRUE; } /* Fix missing alts or html purifier might try to do a non-UTF8 friendly approach */ if ( !( $node instanceof \DOMComment ) and $node->nodeType == XML_ELEMENT_NODE and $node->tagName === 'img' AND ( !$node->hasAttribute('alt') OR !$node->getAttribute('alt') ) ) { $node->setAttribute( 'alt', mb_substr( basename( $node->getAttribute( 'src' ) ), 0, 40 ) ); $added = TRUE; } /* We can't allow data-role="commentContent" or this causes a lot of things to be broken when attempting to edit a post more than once (it duplicates the form fields multiple times with a broken editor and nothing will save) */ if ( !( $node instanceof \DOMComment ) and $node->nodeType == XML_ELEMENT_NODE and $node->hasAttribute( 'data-role' ) ) { $roles = explode( ',', $node->getAttribute('data-role') ); $saveRoles = array(); foreach( $roles as $role ) { if( $role != 'commentContent' ) { $saveRoles[] = $role; } } if( !count( $saveRoles ) ) { $node->removeAttribute( 'data-role' ); } else { $node->setAttribute( 'data-role', implode( ',', $saveRoles ) ); } $added = TRUE; } /* Figure out what data-controllers we will allow */ $allowedDataControllers = array( 'core.front.core.articlePages', 'core.front.core.autoSizeIframe', 'core.front.core.lightboxedImages' ); if( \IPS\Settings::i()->editor_allowed_datacontrollers ) { $allowedDataControllers = array_merge( $allowedDataControllers, explode( ',', \IPS\Settings::i()->editor_allowed_datacontrollers ) ); } if ( !( $node instanceof \DOMComment ) and $node->nodeType == XML_ELEMENT_NODE and $node->hasAttribute( 'data-controller' ) ) { $controllers = explode( ',', $node->getAttribute('data-controller') ); $saveControllers = array(); foreach( $controllers as $controller ) { if( in_array( $controller, $allowedDataControllers ) ) { $saveControllers[] = $controller; } } if( !count( $saveControllers ) ) { $node->removeAttribute( 'data-controller' ); } else { $node->setAttribute( 'data-controller', implode( ',', $saveControllers ) ); } $added = TRUE; } if( $node->tagName === 'img' ) { $imageSrc = $node->getAttribute('src'); if ( !$this->isAllowedUrl( $imageSrc ) ) { return; } if ( \IPS\Settings::i()->remote_image_proxy ) { $imageSrc = new \IPS\Http\Url( $imageSrc ); if ( !$imageSrc->isInternal ) { $newUrl = new \IPS\Http\Url( \IPS\Settings::i()->base_url . "applications/core/interface/imageproxy/imageproxy.php" ); $newUrl = $newUrl->setQueryString( array( 'img' => (string) $imageSrc, 'key' => hash_hmac( "sha256", (string) $imageSrc, md5( \IPS\Settings::i()->sql_pass . \IPS\Settings::i()->board_url . \IPS\Settings::i()->sql_database ) ) ) ); $node->setAttribute( 'src', (string) $newUrl ); } } } /* If we have an open block node, change the parent to that */ $blockOpened = FALSE; if ( $this->openBlock and !$this->openBlockDepth ) { $parent = $this->openBlock; $blockOpened = TRUE; $this->openBlockDepth++; } if ( ! isset( static::$fileObjectClasses['core_Attachment'] ) ) { static::$fileObjectClasses['core_Attachment'] = \IPS\File::getClass('core_Attachment' ); } if ( ! isset( static::$fileObjectClasses['core_Emoticons'] ) ) { static::$fileObjectClasses['core_Emoticons'] = \IPS\File::getClass('core_Emoticons' ); } if ( static::$emoticons === NULL ) { static::$emoticons = static::getEmoticons(); } $nodeChecked = FALSE; if ( ( $node->tagName === 'a' or $node->tagName === 'img' ) and $node->getAttribute('data-extension') ) { $extension = $node->getAttribute('data-extension'); if ( ! isset( static::$fileObjectClasses[ $extension ] ) ) { try { static::$fileObjectClasses[ $extension ] = \IPS\File::getClass( $extension ); } catch ( \Exception $e ) { } } $url = ( $node->tagName === 'a' ) ? $node->getAttribute('href') : $node->getAttribute('src'); if ( isset( static::$fileObjectClasses[ $extension ] ) and preg_match( '#^(' . preg_quote( rtrim( static::$fileObjectClasses[ $extension ]->baseUrl(), '/' ), '#' ) . ')/(.+?)$#', $url, $matches ) ) { $nodeChecked = TRUE; $added = TRUE; $node->setAttribute( ( $node->tagName === 'a' ? 'href' : 'src' ), '{fileStore.' . $extension . '}/' . $matches[2] ); } else { $nodeChecked = FALSE; } } /* A fall back for earlier posts */ if ( ! $nodeChecked and ( ( $node->tagName === 'a' and preg_match( '#^(' . preg_quote( rtrim( static::$fileObjectClasses['core_Attachment']->baseUrl(), '/' ), '#' ) . ')/(.+?)$#', $node->getAttribute('href'), $matches ) ) ) ) { $aHref = $matches[2]; $isAttachLink = FALSE; if ( $node->hasChildNodes() ) { foreach ( $node->childNodes as $child ) { if ( !( $child instanceof \DOMComment ) and $child->nodeType == XML_ELEMENT_NODE AND ( $child->tagName === 'img' and preg_match( '#^(' . preg_quote( rtrim( static::$fileObjectClasses['core_Attachment']->baseUrl(), '/' ), '#' ) . ')/(.+?)$#', $child->getAttribute('src'), $matches ) ) and $child->getAttribute('data-fileid') ) { $isAttachLink = TRUE; } } } if ( $isAttachLink ) { $added = TRUE; $node->setAttribute( 'href', '{fileStore.core_Attachment}/' . $aHref ); } } if ( ! $nodeChecked and ( ( $node->tagName === 'img' and preg_match( '#^(' . preg_quote( rtrim( static::$fileObjectClasses['core_Attachment']->baseUrl(), '/' ), '#' ) . ')/(.+?)$#', $node->getAttribute('src'), $matches ) ) and $node->getAttribute('data-fileid') ) ) { $added = TRUE; $node->setAttribute( 'src', '{fileStore.core_Attachment}/' . $matches[2] ); $nodeChecked = TRUE; } /* Emoticons */ if ( ! $nodeChecked and ( ( $node->tagName === 'img' and preg_match( '#^(' . preg_quote( rtrim( static::$fileObjectClasses['core_Emoticons']->baseUrl(), '/' ), '#' ) . ')/(.+?)$#', $node->getAttribute('src'), $matches ) ) ) ) { foreach( static::$emoticons as $emo ) { if ( $emo['image'] == $matches[2] ) { $added = TRUE; $node->setAttribute( 'src', '{fileStore.core_Emoticons}/' . $matches[2] ); if( $emo['image_2x'] && $emo['width'] && $emo['height'] ) { $node->setAttribute( 'srcset', '%7BfileStore.core_Emoticons%7D/' . $emo['image_2x'] . ' 2x' ); /* Retina emoticons require a width/height for proper scaling */ $node->setAttribute( 'width', $emo['width'] ); $node->setAttribute( 'height', $emo['height'] ); } $nodeChecked = TRUE; } } } /* Anything else that has a URL */ if ( ! $nodeChecked and ( $node->tagName === 'a' or $node->tagName === 'img' ) ) { $url = ( $node->tagName === 'a' ) ? $node->getAttribute('href') : $node->getAttribute('src'); if ( preg_match( '#^(' . preg_quote( rtrim( \IPS\Settings::i()->base_url, '/' ), '#' ) . ')/(.+?)$#', $url, $matches ) ) { $added = TRUE; $node->setAttribute( ( $node->tagName === 'a' ? 'href' : 'src' ), '{___base_url___}/' . $matches[2] ); } if ( $node->getAttribute('data-fileid') ) { if ( preg_match( '#^(' . preg_quote( rtrim( \IPS\Settings::i()->base_url, '/' ), '#' ) . ')/(.+?)$#', $node->getAttribute('data-fileid'), $matches ) ) { $added = TRUE; $node->setAttribute( 'data-fileid', '{___base_url___}/' . $matches[2] ); } } } /* Import */ /* Sometimes bad data in earlier versions can cause parsing issues */ try { $newNode = $document->importNode( $node ); $parent->appendChild( $newNode ); } catch( \ErrorException $e ) { /* 'DOMNode::appendChild(): Couldn't fetch DOMElement' */ if( mb_strpos( $e->getMessage(), "fetch DOMElement" ) ) { throw new \InvalidArgumentException( $e->getMessage(), 103014 ); } throw $e; } /* Loop children */ if ( $node->hasChildNodes() ) { foreach ( $node->childNodes as $child ) { if ( $this->parseNode( $document, $child, $newNode ) ) { $added = TRUE; } } } /* Decrease block depth */ if ( $blockOpened ) { $this->openBlockDepth--; } /* Return */ return $added; } } /** * Is allowed URL * * @param string $url The URL * @return bool */ public function isAllowedUrl( $url ) { if ( \IPS\Settings::i()->ipb_url_filter_option != 'none' ) { $links = \IPS\Settings::i()->ipb_url_filter_option == "black" ? \IPS\Settings::i()->ipb_url_blacklist : \IPS\Settings::i()->ipb_url_whitelist; if( $links ) { $linkValues = array(); $linkValues = explode( "," , $links ); if( \IPS\Settings::i()->ipb_url_filter_option == 'white' ) { $linkValues[] = "http://" . parse_url( \IPS\Settings::i()->base_url, PHP_URL_HOST ) . "*"; $linkValues[] = "https://" . parse_url( \IPS\Settings::i()->base_url, PHP_URL_HOST ) . "*"; } if ( !empty( $linkValues ) ) { $goodUrl = FALSE; foreach( $linkValues as $link ) { if( !trim($link) ) { continue; } $link = preg_quote( $link, '/' ); $link = str_replace( '\*', "(.*?)", $link ); if ( \IPS\Settings::i()->ipb_url_filter_option == "black" ) { if( preg_match( '/' . $link . '/i', $url ) ) { return false; } } else { if ( preg_match( '/' . $link . '/i', $url ) ) { $goodUrl = TRUE; } } } if ( ! $goodUrl AND \IPS\Settings::i()->ipb_url_filter_option == "white" ) { return false; } } } } return true; } /** * Remove specific elements, useful for cleaning up content for display or truncating * * @param string $value The value to parse * @param array|string $elements Element to remove, or array of elements to remove * @return string */ public static function removeElements( $value, $elements=array( 'blockquote', 'img', 'a' ) ) { /* Initiate a DOMDocument, force it to use UTF-8 */ libxml_use_internal_errors(TRUE); $source = new \DOMDocument( '1.0', 'UTF-8' ); @$source->loadHTML( "" . $value ); /* And create a new */ $document = new \DOMDocument( '1.0', 'UTF-8' ); $document->loadHTML( "" ); $elements = is_string( $elements ) ? array( $elements ) : $elements; /* Loop */ foreach ( $source->childNodes as $node ) { if ( $node instanceof \DOMElement ) { static::removeElementsNode( $document, $node, $elements ); } } /* Set value */ $value = preg_replace( '/^/', '', str_replace( array( '', '', '', '', '', '' ), '', $document->saveHTML() ) ); $value = preg_replace( '/(