DPurifyTextBehavior: Используем HTML Purifier в Yii

При выводе новостей и комментариев на публичном или личном сайте возникает необходимость фильтрации HTML-кода от опасных XSS элементов. Компонент HTML Purifier, поставляемый в комплекте с Yii Framework, может сильно облегчить задачу фильтрации. Это действительно мощный и гибко настраиваемый инструмент. Рассмотрим способы работы с ним.
В представлении для фильтрации вывода достаточно использовать CHtmlPurifier как виджет:
<?php $this->beginWidget('CHtmlPurifier'); echo $model->text; $this->endWidget();
По умолчанию он очистит текст от скриптов, тегов iframe и object, то есть отфильтрует все опасные элементы. Дополнительными параметрами можно настроить правила форматирования и фильтрации на любой вкус. Кроме того, опции AutoFormat.AutoParagraph и AutoFormat.Linkify позволят виджету автоматически преобразовывать переносы в параграфы и преобразовывать адреса сайтов в ссылки. Настройки передаются виджету через параметр options. Чтобы разрешить вставку Flash-видео, нужно в представлении сделать так:
<?php $this->beginWidget('CHtmlPurifier', array('options'=>array( 'HTML.SafeObject'=>true, 'Output.FlashCompat'=>true, ))); echo $model->text; $this->endWidget(); ?
Также, например, параметром HTML.AllowedTags можно указать список разрешённых тегов.
Кроме обрамления виджетом этот компонент CHtmlPurifier можно использовать и напрямую в любом месте приложения:
$p = new CHtmlPurifier; $p->options = array( 'HTML.SafeObject'=>true, 'Output.FlashCompat'=>true, ); echo $p->purify($text);
HTML Purifier очень удобный, но в связи со своей универсальностью достаточно медлительный. Процесс фильтрации одной страницы в представлении может каждый раз занимать 30 мс. Соответственно, вывод ленты из 20 постов или поста с десятками комментариев в блоге с фильтрацией может довести время генерации страницы до 0,5 с. Для разгрузки сервера можно фильтровать текст при записи в базу данных. Перезаписывать его в то же поле не очень удобно, так как вернуть исходный текст не удастся. Поэтому лучше хранить отфильтрованный текст в базе рядом с исходным и производить фильтрацию в момент записи в базу. Для этого добавим момент фильтрации в метод beforeSave нашей модели:
/* * $param string $text; * $param string $purified_text; */ class Post extends CActiveRecord { protected function beforeSave() { if(parent::beforeSave()) { $this->purified_text = $this->purify($this->text); return true; } else return false; } protected function purify($text) { $p = new CHtmlPurifier; $p->options = array( 'HTML.SafeObject'=>true, 'Output.FlashCompat'=>true, ); return $p->purify($text); } }
Теперь в представлении достаточно вывести отфильтрованный текст:
<?php echo $model->purified_text;
Если Вы переделываете уже текущий проект и не хотите вручную пересохранять все записи, то можно поместить автоматический обработчик в метод afterFind():
class Post extends CActiveRecord { protected function afterFind() { if(!$this->purified_text && $this->text) { $this->purified_text = $this->purify($this->text); $this->updateByPk($this->id, array( 'purified_text' => $this->purified_text; )); } parent::afterFind(); } }
Если поле purified_text он найдёт пустым, то произведёт фильтрацию и сохранит результат в базу. Теперь при экспериментировании с опциями достаточно выполнить SQL запрос:
UPDATE `posts` SET `purified_text` = '';
...и при первом же выводе все тексты незаметно обновятся.
Для использования фильтрации в разных моделях целесообразно вынести этот код в поведение. В моём варианте также добавлен парсинг Markdown синтаксиса, который можно включить при желании.
Теперь это поведение можно просто подключить к модели. Например, для модели поста в блоге мы разрешим вставлять Flash-видео и использовать атрибут rel="nofollow" у ссылок:
class Post extends CActiveRecord { public function behaviors() { return array( 'PurifyText'=>array( 'class'=>'DPurifyTextBehavior', 'sourceAttribute'=>'text', 'destinationAttribute'=>'purified_text', // 'enableMarkdown'=>true, 'purifierOptions'=> array( 'Attr.AllowedRel'=>array('nofollow'), 'HTML.SafeObject'=>true, 'Output.FlashCompat'=>true, ), ), ); } }
Для комментариев мы зададим более жёсткие правила. Фильтр будет автоматически расставлять параграфы, закрывать незакрытые теги, удалять все теги кроме разрешённых, преобразовывать адреса в ссылки и автоматически присваивать им атрибут rel="nofollow":
class Comment extends CActiveRecord { public function behaviors() { return array( 'PurifyText'=>array( 'class'=>'DPurifyTextBehavior', 'sourceAttribute'=>'text', 'destinationAttribute'=>'purified_text', 'purifierOptions'=>array( 'AutoFormat.AutoParagraph' => true, 'HTML.Allowed'=>'p,ul,li,b,i,a[href],pre', 'AutoFormat.Linkify'=>true, 'HTML.Nofollow'=>true, 'Core.EscapeInvalidTags'=>true, ), ) ); } }
Всю работу поведение возьмёт на себя. Если же Вы не хотите сохранять результат в БД во время разработки, то нужно отменить автосохранение дописав параметр 'updateOnAfterFind'=>false.
В версии 1.2 добавлен функционал кодирования содержимого для тегов <pre> и <code>. Для активации необходимо в параметры поведения добавить строку
'encodePreContent'=>true,
P.S. При использовании Markdown не забудьте в представлении для вывода статьи подключить стили для подсветки синтаксиса, то есть добавьте в любое место строку
CTextHighlighter::registerCssFile();
Иначе программный код не будет подсвечиваться при выводе.
 
        
TranceSmileА можно ли внешние ссылки как нибудь им поймать?
Елисеев ДмитрийВ каком смысле «поймать»?
TranceSmileСсылки ведущее на другой сайт перенаправлять через промежуточную страницу.
Елисеев ДмитрийНаписал развёрнутый ответ про замену внешних ссылок.
Влад – habrahabr.ruDPurifyTextBehavior.php падает, если в таблице нет поля id, например, при использовании comments ext.
Легко победимо, если 142-ю строку исправить на:
Дмитрий ЕлисеевСпасибо, исправил.
Виталий ИвановНашел еще одну проблемку...
Если несколько полей для "пурификации", то не получается подключить 2 поведения с одним классом для разных полей.
Переписал немного. $sourceAttribute и $destinationAttribute сделал массивами...
Дмитрий ЕлисеевНе знаю, у меня везде по два подключено и нормально работает:
public function behaviors() { return array( 'PurifyShort'=>array( 'class'=>'DPurifyTextBehavior', 'sourceAttribute'=>'short', 'destinationAttribute'=>'short_purified', 'purifierOptions'=> array(), ), 'PurifyText'=>array( 'class'=>'DPurifyTextBehavior', 'sourceAttribute'=>'text', 'destinationAttribute'=>'text_purified', 'purifierOptions'=> array( 'Attr.AllowedRel'=>array('nofollow'), 'HTML.SafeObject'=>true, 'Output.FlashCompat'=>true, ), ), ); }
АлександрСпасибо за статью! Но, может я ошибаюсь, но разве не нужно в beforeSave делать проверку вроде null !== text? Ведь если text не был загружен, в purified_text запишется пустое значение.
Дмитрий ЕлисеевВрядли это нужно, так как редко кто выбирает запись не целиком findByPk(array('select'=>'id, title')), а потом сохраняет через $model->save(). А если рассматривать без таких нюансов, то при удалении текста должен удаляться и результат.
ВадимКлассная статья у Вас получилась, спасибо! Хотелось еще уточнить у вас как использовать параметр «Markdown» для подсветки синтаксиса кода. Даже если указать в представлении «CTextHighlighter::registerCssFile();» содержимое тега «pre» не подсвечивается?
Дмитрий ЕлисеевДля кода нужно использовать синтаксис Markdown с указанием языка в квадратных скобках:
~~~
[php]
class Post extends CActiveRecord {
....
}
~~~
ВсеволодЗдравствуйте. А разве не правильнее будет фильтровать текст перед сохранением в БД?
Дмитрий ЕлисеевОн и фильтруется перед сохранением. Только из одного поля в другое.
ВсеволодПрощу прощения. Решил написать комментарий не дочитав до конца :)
АлексейПодскажите почему не написать код перед save(), вместо beforesave? в чем различие?
Дмитрий ЕлисеевВ контроллере записывать? Или как?
АлексейНу да :)
Дмитрий ЕлисеевДля приличной модели из жизни обычно надо автоматом кучу полей заполнять:
if ($model->isNewRecord) { $model->created_at = time(); } else { $model->updated_at = time(); } if (empty($model->slug)) { $model->slug = Inflector::slug($model->title); } $model->preview_html = $purifier->purify($model->text); $model->text_html = $purifier->purify($model->text); if (empty($model->meta_title)) { $model->meta_title = $model->title; } if (empty($model->meta_description)) { $model->meta_description = strip_tags($model->preview); }Вставлять эту простыню в каждый контроллер перед $model->save() и следить за тем, что нигде ничего не забыл – весьма убийственное занятие. Легче переместить это из контроллера в саму модель в beforeSave(), чтобы всё работало автоматически.
А если нужно одно и то же делать в каждой модели, то удобно этот повторяющийся блок вынести в поведение (что мы потом и сделали в статье) и подключать в behaviors().
АлексейСпасибо. В моей голове все проясняется.
"Перезаписывать его в то же поле не очень удобно, так как вернуть исходный текст не удастся. "
Зачем нам может понадобится исходный текст? Я хотел в тоже поле записывать :) Как сделано на этом сайте?
АлексейТочнее почему перезаписывать? Мы ведь создаем новую запись в базе, а перед записью обрабатываем?
Дмитрий ЕлисеевИ при создании обрабатываем, и при редактировании.
Дмитрий ЕлисеевСтатьи на этом сайте вообще написаны в Markdown-синтаксисе и из этих оригиналов конвертируются в HTML с подсветкой кода. Поэтому и два поля.
А если у Вас только HTML, то можно и одно поле использовать.
Но комментарии на этом сайте - другое дело. Комментаторы при вставке кода то его тегом pre окружить забудут, то сам тег не закроют. В итоге фильтр не понимает, что это код и вычищает все теги из листинга. Если бы исходный текст не сохранялся, то я бы никак "битые" комментарии не восстановил.
АлексейСпасибо за ответы.
Алексейпытаюсь сделать поведение для yii2. ничего не работает :(
namespace app\behaviors; use Yii; use yii\helpers\HtmlPurifier; use yii\base\Behavior; class PurifierBehavior extends Behavior { public function beforeSave($insert) { if (parent::beforeSave($insert)) { $this->owner->text = HtmlPurifier::process($this->owner->text); return true; } return false; } }Хотя просто написанное в модели работает:
public function beforeSave($insert) { if (parent::beforeSave($insert)) { $this->text = HtmlPurifier::process($this->text); return true; } return false; }