diff --git a/code/Commenting.php b/code/Commenting.php index 2de8072..95e2928 100644 --- a/code/Commenting.php +++ b/code/Commenting.php @@ -31,7 +31,10 @@ class Commenting { 'comments_per_page' => 10, 'comments_holder_id' => "comments-holder", // id for the comments holder 'comment_permalink_prefix' => "comment-", // id prefix for each comment. If needed make this different - 'require_moderation' => false + 'require_moderation' => false, + 'html_allowed' => false, // allow for sanitized HTML in comments + 'html_allowed_elements' => array('p', 'br', 'a', 'img', 'i', 'b'), + 'use_preview' => false, // preview formatted comment (when allowing HTML) ); /** diff --git a/code/controllers/CommentingController.php b/code/controllers/CommentingController.php index 9598576..79b4bf4 100644 --- a/code/controllers/CommentingController.php +++ b/code/controllers/CommentingController.php @@ -13,7 +13,8 @@ class CommentingController extends Controller { 'approve', 'rss', 'CommentsForm', - 'doPostComment' + 'doPostComment', + 'doPreviewComment' ); private $baseClass = ""; @@ -239,7 +240,7 @@ class CommentingController extends Controller { * @return Form */ public function CommentsForm() { - + $usePreview = Commenting::get_config_value($this->getBaseClass(), 'use_preview'); $member = Member::currentUser(); $fields = new FieldList( TextField::create("Name", _t('CommentInterface.YOURNAME', 'Your name')) @@ -263,10 +264,28 @@ class CommentingController extends Controller { HiddenField::create("BaseClass") ); + // Preview formatted comment. Makes most sense when shortcodes or + // limited HTML is allowed. Populated by JS/Ajax. + if($usePreview) { + $fields->insertAfter( + ReadonlyField::create('PreviewComment', _t('CommentInterface.PREVIEWLABEL', 'Preview')) + ->setAttribute('style', 'display: none'), // enable through JS + 'Comment' + ); + } + + // save actions $actions = new FieldList( new FormAction("doPostComment", _t('CommentInterface.POST', 'Post')) ); + if($usePreview) { + $actions->push( + FormAction::create('doPreviewComment', _t('CommentInterface.PREVIEW', 'Preview')) + ->addExtraClass('action-minor') + ->setAttribute('style', 'display: none') // enable through JS + ); + } // required fields for server side $required = new RequiredFields(array( @@ -340,6 +359,8 @@ class CommentingController extends Controller { */ public function doPostComment($data, $form) { $class = (isset($data['BaseClass'])) ? $data['BaseClass'] : $this->getBaseClass(); + $usePreview = Commenting::get_config_value($class, 'use_preview'); + $isPreview = ($usePreview && isset($data['preview']) && $data['preview']); // if no class then we cannot work out what controller or model they // are on so throw an error @@ -378,7 +399,13 @@ class CommentingController extends Controller { $form->saveInto($comment); $comment->Moderated = ($moderated) ? false : true; - $comment->write(); + + // Save into DB, or call pre-save hooks to give accurate preview + if($isPreview) { + $comment->onBeforeWrite(); + } else { + $comment->write(); + } // extend hook to allow extensions. Also see onBeforePostComment $this->extend('onAfterPostComment', $comment); @@ -401,4 +428,9 @@ class CommentingController extends Controller { return ($url) ? $this->redirect($url .'#'. $hash) : $this->redirectBack(); } + + public function doPreviewComment($data, $form) { + $data['IsPreview'] = 1; + return $this->doPostComment($data, $form); + } } diff --git a/code/dataobjects/Comment.php b/code/dataobjects/Comment.php index 14e3aee..70ceb93 100755 --- a/code/dataobjects/Comment.php +++ b/code/dataobjects/Comment.php @@ -9,7 +9,7 @@ class Comment extends DataObject { public static $db = array( "Name" => "Varchar(200)", - "Comment" => "Text", + "Comment" => "Text", // can contain sanitized HTML with 'html_allowed=true' config "Email" => "Varchar(200)", "URL" => "Varchar(255)", "BaseClass" => "Varchar(200)", @@ -55,7 +55,14 @@ class Comment extends DataObject { 'IsSpam' => 'Is Spam' ); + public function onBeforeWrite() { + parent::onBeforeWrite(); + // Sanitize HTML, because its expected to be passed to the template unescaped later + if($this->getAllowHtml()) { + $this->Comment = $this->purifyHtml($this->Comment); + } + } /** * Migrates the old {@link PageComment} objects to {@link Comment} @@ -144,7 +151,7 @@ class Comment extends DataObject { return ($parent->Title) ? $parent->Title : $parent->ClassName . " #" . $parent->ID; } - + /** * Comment-parent classnames obviousely vary, return the parent classname * @@ -330,4 +337,34 @@ class Comment extends DataObject { $fields->replaceField('BaseClass', $baseClassField); return $fields; } + + public function getAllowHtml() { + return ( + Commenting::has_commenting($this->BaseClass) + && Commenting::get_config_value($this->BaseClass, 'html_allowed') + ); + } + + /** + * @param String $dirtyHtml + * @return String + */ + public function purifyHtml($dirtyHtml) { + $purifier = $this->getHtmlPurifierService(); + return $purifier->purify($dirtyHtml); + } + + /** + * @return HTMLPurifier (or anything with a "purify()" method) + */ + public function getHtmlPurifierService() { + $config = HTMLPurifier_Config::createDefault(); + $config->set('HTML.AllowedElements', + Commenting::get_config_value($this->BaseClass, 'html_allowed_elements') + ); + $config->set('AutoFormat.AutoParagraph', true); + $config->set('AutoFormat.Linkify', true); + $config->set('URI.DisableExternalResources', true); + return new HTMLPurifier($config); + } } diff --git a/composer.json b/composer.json index 5d94af1..7183af7 100644 --- a/composer.json +++ b/composer.json @@ -12,5 +12,8 @@ "require": { "silverstripe/framework": "3.*" + }, + "suggest": { + "ezyang/htmlpurifier": "4.*" } } \ No newline at end of file diff --git a/docs/en/Configuration.md b/docs/en/Configuration.md index 31ee4de..5a56210 100644 --- a/docs/en/Configuration.md +++ b/docs/en/Configuration.md @@ -1,4 +1,6 @@ -## Configuration +# Configuration + +## Overview The module provides a number of built in configuration settings below are the default settings @@ -13,7 +15,10 @@ The module provides a number of built in configuration settings below are the de 'comments_per_page' => 10, 'comments_holder_id' => "comments-holder", 'comment_permalink_prefix' => "comment-", - 'require_moderation' => false + 'require_moderation' => false, + 'html_allowed' => false, // allow for sanitized HTML in comments + 'html_allowed_elements' => array('p', 'br', 'a', 'img', 'i', 'b'), + 'use_preview' => false, // preview formatted comment (when allowing HTML) ); If you want to customize any of the configuration options after you have added the extension (or @@ -25,4 +30,21 @@ on the built-in SiteTree commenting) use `set_config_value` // mysite/_config.php - Returns the setting Commenting::get_config_value('SiteTree', 'require_login'); - \ No newline at end of file +## HTML Comments + +Comments can be configured to contain a restricted set of HTML tags +through the `html_allowed` and `html_allowed_elements` settings. +Raw HTML is hardly user friendly, but combined with a rich-text editor +of your own choosing it can allow rich comment formatting. + +In order to use this feature, you need to install the +[HTMLPurifier](http://htmlpurifier.org/) library. +The easiest way to do this is through [Composer](http://getcomposer.org). + + { + "require": {"ezyang/htmlpurifier": "4.*"} + } + +**Important**: Rendering user-provided HTML on your website always risks +exposing your users to cross-site scripting (XSS) attacks, if the HTML +isn't properly sanitized. Don't allow tags like `my comment
'; + $comment1->write(); + $this->assertEquals( + 'my comment
', + $comment1->Comment, + 'Does not remove HTML tags with html_allowed=false, ' . + 'which is correct behaviour because the HTML will be escaped' + ); + + // With HTML allowed + Commenting::set_config_value('CommentableItem','html_allowed', true); + $comment2 = new Comment(); + $comment2->BaseClass = 'CommentableItem'; + $comment2->Comment = 'my comment
'; + $comment2->write(); + $this->assertEquals( + 'my comment
', + $comment2->Comment, + 'Removes HTML tags which are not on the whitelist' + ); + + Commenting::set_config_value('CommentableItem','html_allowed', $origAllowed); + } + + public function testDefaultTemplateRendersHtmlWithAllowHtml() { + if(!class_exists('HTMLPurifier')) { + $this->markTestSkipped('HTMLPurifier class not found'); + } + + $origAllowed = Commenting::get_config_value('CommentableItem', 'html_allowed'); + $item = new CommentableItem(); + $item->write(); + + // Without HTML allowed + $comment = new Comment(); + $comment->Comment = 'my comment
'; + $comment->ParentID = $item->ID; + $comment->BaseClass = 'CommentableItem'; + $comment->write(); + + $html = $item->customise(array('CommentsEnabled' => true))->renderWith('CommentsInterface'); + $this->assertContains( + '<p>my comment</p>', + $html + ); + + Commenting::set_config_value('CommentableItem','html_allowed', true); + $html = $item->customise(array('CommentsEnabled' => true))->renderWith('CommentsInterface'); + $this->assertContains( + 'my comment
', + $html + ); + + Commenting::set_config_value('CommentableItem','html_allowed', $origAllowed); + } }