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();
Иначе программный код не будет подсвечиваться при выводе.
А можно ли внешние ссылки как нибудь им поймать?
В каком смысле «поймать»?
Ссылки ведущее на другой сайт перенаправлять через промежуточную страницу.
Написал развёрнутый ответ про замену внешних ссылок.
DPurifyTextBehavior.php падает, если в таблице нет поля id, например, при использовании comments ext.
Легко победимо, если 142-ю строку исправить на:
Спасибо, исправил.
Нашел еще одну проблемку...
Если несколько полей для "пурификации", то не получается подключить 2 поведения с одним классом для разных полей.
Переписал немного. $sourceAttribute и $destinationAttribute сделал массивами...
Не знаю, у меня везде по два подключено и нормально работает:
Спасибо за статью! Но, может я ошибаюсь, но разве не нужно в 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? в чем различие?
В контроллере записывать? Или как?
Ну да :)
Для приличной модели из жизни обычно надо автоматом кучу полей заполнять:
Вставлять эту простыню в каждый контроллер перед $model->save() и следить за тем, что нигде ничего не забыл – весьма убийственное занятие. Легче переместить это из контроллера в саму модель в beforeSave(), чтобы всё работало автоматически.
А если нужно одно и то же делать в каждой модели, то удобно этот повторяющийся блок вынести в поведение (что мы потом и сделали в статье) и подключать в behaviors().
Спасибо. В моей голове все проясняется.
"Перезаписывать его в то же поле не очень удобно, так как вернуть исходный текст не удастся. "
Зачем нам может понадобится исходный текст? Я хотел в тоже поле записывать :) Как сделано на этом сайте?
Точнее почему перезаписывать? Мы ведь создаем новую запись в базе, а перед записью обрабатываем?
И при создании обрабатываем, и при редактировании.
Статьи на этом сайте вообще написаны в Markdown-синтаксисе и из этих оригиналов конвертируются в HTML с подсветкой кода. Поэтому и два поля.
А если у Вас только HTML, то можно и одно поле использовать.
Но комментарии на этом сайте - другое дело. Комментаторы при вставке кода то его тегом pre окружить забудут, то сам тег не закроют. В итоге фильтр не понимает, что это код и вычищает все теги из листинга. Если бы исходный текст не сохранялся, то я бы никак "битые" комментарии не восстановил.
Спасибо за ответы.
пытаюсь сделать поведение для yii2. ничего не работает :(
Хотя просто написанное в модели работает: