From e450807b1cce2e9501e47adf8367598d7e4c5528 Mon Sep 17 00:00:00 2001
From: Ingo Schommer
Date: Thu, 21 Feb 2013 16:39:57 +0100
Subject: [PATCH 1/2] NEW Optionally allow (sanitized) HTML in comments
---
code/Commenting.php | 4 +-
code/dataobjects/Comment.php | 41 +++++++++++-
composer.json | 3 +
docs/en/Configuration.md | 27 +++++++-
templates/CommentsInterface_singlecomment.ss | 6 +-
tests/CommentsTest.php | 67 ++++++++++++++++++++
6 files changed, 141 insertions(+), 7 deletions(-)
diff --git a/code/Commenting.php b/code/Commenting.php
index 2de8072..1339754 100644
--- a/code/Commenting.php
+++ b/code/Commenting.php
@@ -31,7 +31,9 @@ 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')
);
/**
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..2e68462 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,9 @@ 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')
);
If you want to customize any of the configuration options after you have added the extension (or
@@ -25,4 +29,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);
+ }
}
From 0cf5f66783ac43ae161311f29f471c499e14a62b Mon Sep 17 00:00:00 2001
From: Ingo Schommer
Date: Mon, 4 Mar 2013 11:37:18 +0100
Subject: [PATCH 2/2] NEW Comment previews
---
code/Commenting.php | 3 +-
code/controllers/CommentingController.php | 38 ++++++++++++++++++++--
docs/en/Configuration.md | 3 +-
javascript/CommentsInterface.js | 39 +++++++++++++++++++++--
4 files changed, 76 insertions(+), 7 deletions(-)
diff --git a/code/Commenting.php b/code/Commenting.php
index 1339754..95e2928 100644
--- a/code/Commenting.php
+++ b/code/Commenting.php
@@ -33,7 +33,8 @@ class Commenting {
'comment_permalink_prefix' => "comment-", // id prefix for each comment. If needed make this different
'require_moderation' => false,
'html_allowed' => false, // allow for sanitized HTML in comments
- 'html_allowed_elements' => array('p', 'br', 'a', 'img', 'i', 'b')
+ '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/docs/en/Configuration.md b/docs/en/Configuration.md
index 2e68462..5a56210 100644
--- a/docs/en/Configuration.md
+++ b/docs/en/Configuration.md
@@ -17,7 +17,8 @@ The module provides a number of built in configuration settings below are the de
'comment_permalink_prefix' => "comment-",
'require_moderation' => false,
'html_allowed' => false, // allow for sanitized HTML in comments
- 'html_allowed_elements' => array('p', 'br', 'a', 'img', 'i', 'b')
+ '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
diff --git a/javascript/CommentsInterface.js b/javascript/CommentsInterface.js
index 3336a4d..a3f0f49 100755
--- a/javascript/CommentsInterface.js
+++ b/javascript/CommentsInterface.js
@@ -9,8 +9,14 @@
commentsList = $('.comments-list', commentsHolder),
pagination = $('.comments-pagination'),
noCommentsYet = $('.no-comments-yet', commentsHolder),
- form = $('form', container);
+ form = $('form', container),
+ previewEl = form.find('#PreviewComment');
+ /**
+ * Init
+ */
+ previewEl.hide();
+ $(':submit[name=action_doPreviewComment]').show();
/**
* Validate
@@ -70,7 +76,6 @@
* this inclues the spam and approve links
*/
form.submit(function (e) {
-
// trigger validation
if(!form.validate().valid()){
return false;
@@ -103,6 +108,36 @@
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