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

HTML Purifier

При выводе новостей и комментариев на публичном или личном сайте возникает необходимость фильтрации HTML-кода от опасных XSS элементов. Компонент HTML Purifier, поставляемый в комплекте с Yii Framework, может сильно облегчить задачу фильтрации. Это действительно мощный и гибко настраиваемый инструмент. Рассмотрим способы работы с ним.

В представлении для фильтрации вывода достаточно использовать CHtmlPurifier как виджет:

<?php $this->beginWidget('CHtmlPurifier'); ?>
    <?php echo $model->text; ?>
<?php $this->endWidget(); ?>

По умолчанию он очистит текст от скриптов, тегов iframe и object, то есть отфильтрует все опасные элементы. Дополнительными параметрами можно настроить правила форматирования и фильтрации на любой вкус. Кроме того, опции AutoFormat.AutoParagraph и AutoFormat.Linkify позволят виджету автоматически преобразовывать переносы в параграфы и преобразовывать адреса сайтов в ссылки. Настройки передаются виджету через параметр options. Чтобы разрешить вставку Flash-видео, нужно в представлении сделать так:

<?php $this->beginWidget('CHtmlPurifier', array('options'=>array(
    'HTML.SafeObject'=>true,
    'Output.FlashCompat'=>true,
))); ?>
    <?php echo $model->text; ?>
<?php $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 синтаксиса, который можно включить при желании.

Код на GitHub

Теперь это поведение можно просто подключить к модели. Например, для модели поста в блоге мы разрешим вставлять 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();

Иначе программный код не будет подсвечиваться при выводе.

Другие статьи

В теме о событиях натолкнулся на вопрос о том, как имея объект комментария на сайте получить ссылку на материал, к которому этот комментарий оставлен, при условии, что комментарии могут иметь привязку к разным сущностям. Сам я тоже использую в своих проектах комментарии к разнородным сущностям, поэтому хочу поделиться своей реализацией.

Давным давно в тридевя... в шаблонизаторе моей второй по счёту старой CMS вставка виджетов в шаблон была релизована посредством использования старой доброй клинописи вида {{WidgetName|param1=val1;param2=val2}}. Этот код можно было встявлять даже в текст, что давало неоценимую возможность компоновать страницы любой сложности из виджетов прямо в текстовом редакторе админки.

На официальном форуме Yii Framework встретил вопрос об организации работы с сайдбарами на сайте. Вопрос возник в связи с требованиями автора выводить разное содержимое панелей в разных разделах сайта. Автор пробует выделить место для сайдбара в главном шаблоне views/layouts/main.php и генерировать содержимое сайдбаров в самих конкретных представлениях. Поделюсь своей организацией шаблонов.

В одном из проектов потребовалось ввести в форму изменения профиля пользователя поле «Введите текущий пароль». Сначала был быстро сделан валидирующий метод в самой модели пользователя. В последующем для повторного использования в разных проектах было решено написать полноценный внешний валидатор.

Комментарии

 

TranceSmile

А можно ли внешние ссылки как нибудь им поймать?

Ответить

 

Елисеев Дмитрий

В каком смысле «поймать»?

Ответить

 

TranceSmile

Ссылки ведущее на другой сайт перенаправлять через промежуточную страницу.

Ответить

 

Влад

DPurifyTextBehavior.php падает, если в таблице нет поля id, например, при использовании comments ext.
Легко победимо, если 142-ю строку исправить на:

$model->updateByPk($model->primaryKey, ...
Ответить

 

Дмитрий Елисеев

Спасибо, исправил.

Ответить

 

Виталий Иванов

Нашел еще одну проблемку...
Если несколько полей для "пурификации", то не получается подключить 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;
    }
Ответить

Оставить комментарий

Войти | Завести аккаунт


(никто не увидит)



Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>