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
* @return string
* @see __construct
*/
public static function parseStatic( $value, $bbcode=FALSE, $attachIds=NULL, $member=NULL, $area=FALSE, $filterProfanity=TRUE, $cleanHtml=TRUE, $htmlPurifierConfig=NULL )
{
$obj = new self( $bbcode, $attachIds, $member, $area, $filterProfanity, $cleanHtml, $htmlPurifierConfig );
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(
str_replace( array( 'http://', 'https://' ), '', \IPS\Settings::i()->base_url ),
'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/'
);
}
/**
* 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()->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