Merge pull request #49 from PutmanMedia/pulls/html-and-preview

This commit is contained in:
Will Rossiter 2013-03-04 23:38:44 -08:00
commit cc0ec9b224
8 changed files with 215 additions and 12 deletions

View File

@ -31,7 +31,10 @@ class Commenting {
'comments_per_page' => 10, 'comments_per_page' => 10,
'comments_holder_id' => "comments-holder", // id for the comments holder 'comments_holder_id' => "comments-holder", // id for the comments holder
'comment_permalink_prefix' => "comment-", // id prefix for each comment. If needed make this different '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)
); );
/** /**

View File

@ -13,7 +13,8 @@ class CommentingController extends Controller {
'approve', 'approve',
'rss', 'rss',
'CommentsForm', 'CommentsForm',
'doPostComment' 'doPostComment',
'doPreviewComment'
); );
private $baseClass = ""; private $baseClass = "";
@ -239,7 +240,7 @@ class CommentingController extends Controller {
* @return Form * @return Form
*/ */
public function CommentsForm() { public function CommentsForm() {
$usePreview = Commenting::get_config_value($this->getBaseClass(), 'use_preview');
$member = Member::currentUser(); $member = Member::currentUser();
$fields = new FieldList( $fields = new FieldList(
TextField::create("Name", _t('CommentInterface.YOURNAME', 'Your name')) TextField::create("Name", _t('CommentInterface.YOURNAME', 'Your name'))
@ -263,10 +264,28 @@ class CommentingController extends Controller {
HiddenField::create("BaseClass") 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 // save actions
$actions = new FieldList( $actions = new FieldList(
new FormAction("doPostComment", _t('CommentInterface.POST', 'Post')) 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 fields for server side
$required = new RequiredFields(array( $required = new RequiredFields(array(
@ -340,6 +359,8 @@ class CommentingController extends Controller {
*/ */
public function doPostComment($data, $form) { public function doPostComment($data, $form) {
$class = (isset($data['BaseClass'])) ? $data['BaseClass'] : $this->getBaseClass(); $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 // if no class then we cannot work out what controller or model they
// are on so throw an error // are on so throw an error
@ -378,7 +399,13 @@ class CommentingController extends Controller {
$form->saveInto($comment); $form->saveInto($comment);
$comment->Moderated = ($moderated) ? false : true; $comment->Moderated = ($moderated) ? false : true;
// Save into DB, or call pre-save hooks to give accurate preview
if($isPreview) {
$comment->onBeforeWrite();
} else {
$comment->write(); $comment->write();
}
// extend hook to allow extensions. Also see onBeforePostComment // extend hook to allow extensions. Also see onBeforePostComment
$this->extend('onAfterPostComment', $comment); $this->extend('onAfterPostComment', $comment);
@ -401,4 +428,9 @@ class CommentingController extends Controller {
return ($url) ? $this->redirect($url .'#'. $hash) : $this->redirectBack(); return ($url) ? $this->redirect($url .'#'. $hash) : $this->redirectBack();
} }
public function doPreviewComment($data, $form) {
$data['IsPreview'] = 1;
return $this->doPostComment($data, $form);
}
} }

View File

@ -9,7 +9,7 @@ class Comment extends DataObject {
public static $db = array( public static $db = array(
"Name" => "Varchar(200)", "Name" => "Varchar(200)",
"Comment" => "Text", "Comment" => "Text", // can contain sanitized HTML with 'html_allowed=true' config
"Email" => "Varchar(200)", "Email" => "Varchar(200)",
"URL" => "Varchar(255)", "URL" => "Varchar(255)",
"BaseClass" => "Varchar(200)", "BaseClass" => "Varchar(200)",
@ -55,7 +55,14 @@ class Comment extends DataObject {
'IsSpam' => 'Is Spam' '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} * Migrates the old {@link PageComment} objects to {@link Comment}
@ -330,4 +337,34 @@ class Comment extends DataObject {
$fields->replaceField('BaseClass', $baseClassField); $fields->replaceField('BaseClass', $baseClassField);
return $fields; 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);
}
} }

View File

@ -12,5 +12,8 @@
"require": "require":
{ {
"silverstripe/framework": "3.*" "silverstripe/framework": "3.*"
},
"suggest": {
"ezyang/htmlpurifier": "4.*"
} }
} }

View File

@ -1,4 +1,6 @@
## Configuration # Configuration
## Overview
The module provides a number of built in configuration settings below are the default settings 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_per_page' => 10,
'comments_holder_id' => "comments-holder", 'comments_holder_id' => "comments-holder",
'comment_permalink_prefix' => "comment-", '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 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 // mysite/_config.php - Returns the setting
Commenting::get_config_value('SiteTree', 'require_login'); Commenting::get_config_value('SiteTree', 'require_login');
## 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 `<script>` or arbitrary attributes.

View File

@ -9,8 +9,14 @@
commentsList = $('.comments-list', commentsHolder), commentsList = $('.comments-list', commentsHolder),
pagination = $('.comments-pagination'), pagination = $('.comments-pagination'),
noCommentsYet = $('.no-comments-yet', commentsHolder), noCommentsYet = $('.no-comments-yet', commentsHolder),
form = $('form', container); form = $('form', container),
previewEl = form.find('#PreviewComment');
/**
* Init
*/
previewEl.hide();
$(':submit[name=action_doPreviewComment]').show();
/** /**
* Validate * Validate
@ -70,7 +76,6 @@
* this inclues the spam and approve links * this inclues the spam and approve links
*/ */
form.submit(function (e) { form.submit(function (e) {
// trigger validation // trigger validation
if(!form.validate().valid()){ if(!form.validate().valid()){
return false; return false;
@ -103,6 +108,36 @@
return false; return false;
}); });
/**
* Preview comment by fetching it from the server via ajax.
*/
$(':submit[name=action_doPreviewComment]', form).click(function(e) {
e.preventDefault();
if(!form.validate().valid()) return false;
previewEl.show().addClass('loading').find('.middleColumn').html(' ');
form.ajaxSubmit({
success: function(response) {
var responseEl = $(response);
if(responseEl.is('form')) {
// Validation failed, renders form instead of single comment
form.replaceWith(responseEl);
} else {
// Default behaviour
previewEl.removeClass('loading').find('.middleColumn').html(responseEl);
}
},
data: {'action_doPreviewComment': 1}
});
});
/**
* Hide outdated preview on form changes
*/
$(':input', form).on('change keydown', function() {
previewEl.hide();
});
/** /**
* Clicking one of the metalinks performs the operation via ajax * Clicking one of the metalinks performs the operation via ajax

View File

@ -1,5 +1,9 @@
<div class="comment" id="$Permalink"> <div class="comment" id="$Permalink">
<% if AllowHtml %>
$Comment.RAW
<% else %>
<p>$Comment.XML</p> <p>$Comment.XML</p>
<% end_if %>
</div> </div>
<p class="info"> <p class="info">

View File

@ -155,6 +155,73 @@ class CommentsTest extends FunctionalTest {
$this->assertEquals($comment->CommenterURL, $protocol . $url, $protocol . ':// is a valid protocol'); $this->assertEquals($comment->CommenterURL, $protocol . $url, $protocol . ':// is a valid protocol');
} }
} }
public function testSanitizesWithAllowHtml() {
if(!class_exists('HTMLPurifier')) {
$this->markTestSkipped('HTMLPurifier class not found');
return;
}
$origAllowed = Commenting::get_config_value('CommentableItem','html_allowed');
// Without HTML allowed
$comment1 = new Comment();
$comment1->BaseClass = 'CommentableItem';
$comment1->Comment = '<p><script>alert("w00t")</script>my comment</p>';
$comment1->write();
$this->assertEquals(
'<p><script>alert("w00t")</script>my comment</p>',
$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 = '<p><script>alert("w00t")</script>my comment</p>';
$comment2->write();
$this->assertEquals(
'<p>my comment</p>',
$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 = '<p>my comment</p>';
$comment->ParentID = $item->ID;
$comment->BaseClass = 'CommentableItem';
$comment->write();
$html = $item->customise(array('CommentsEnabled' => true))->renderWith('CommentsInterface');
$this->assertContains(
'&lt;p&gt;my comment&lt;/p&gt;',
$html
);
Commenting::set_config_value('CommentableItem','html_allowed', true);
$html = $item->customise(array('CommentsEnabled' => true))->renderWith('CommentsInterface');
$this->assertContains(
'<p>my comment</p>',
$html
);
Commenting::set_config_value('CommentableItem','html_allowed', $origAllowed);
}
} }