HTMLPurifier и контент тега PRE

HTMLPurifier

Недавно мы познакомились с использованием HTMLPurifier. Этот компонент позволяет отфильтровать вредные элементы из HTML кода, обработать ссылки, закрыть незакрытые теги. Большой список возможностей позволяет использовать его для фильтрации полученного от пользователя контента. Вместо использования BBCode мы попробуем доработать HTMLPurifier для удобной работы тега <pre> в комментариях пользователей.

Непосредственно в любых проектах данную библиотеку можно использовать так:

require_once '/path/to/HTMLPurifier.auto.php';
 
$config = HTMLPurifier_Config::createDefault();
 
$config->set('AutoFormat', 'AutoParagraph', true);
$config->set('HTML', 'Allowed', 'p,ul,li,b,i,a[href],pre');
$config->set('AutoFormat', 'Linkify', true);
$config->set('HTML', 'Nofollow', true);
$config->set('Core', 'EscapeInvalidTags', true);
 
$purifier = new HTMLPurifier($config);
$clean_html = $purifier->purify($dirty_html);

В проектах на Yii можно воспользоваться встроенным классом CHtmlPurifier. Процесс фильтрации достаточно сильно нагружает систему и долго обрабатывает насыщенные тегами тексты, поэтому его лучше не использовать для фильтрации в представлении непосредственно при выводе текста. Лучше фильтровать текст из поля $text и сохранять его в поле $purified_text таблицы нашей модели при каждом сохранении записи:

/*
 * $param string $text;
 * $param string $purified_text;
 */
class Comment extends CActiveRecord
{
    ...
 
    protected function beforeSave()
    {
        if (parent::beforeSave()){
            $this->purified_text = $this->purify($this->text);
            return true;
        }
        return false;
    }
 
    protected function purify($text)
    {
        $p = new CHtmlPurifier;
        $p->options = array(
            'AutoFormat.AutoParagraph' => true,
            'HTML.Allowed' => 'p,ul,li,b,i,a[href],pre',
            'AutoFormat.Linkify' => true,
            'HTML.Nofollow' => true,
            'Core.EscapeInvalidTags' => true,
        );
        return $p->purify($text);
    }
}

Это модель нашего комментария. В нём мы разрешим комментаторам использовать некоторые теги.

Данные параметры передают компоненту HtmlPurifier разрешённый список тегов, указывают на необходимость преобразовывать переносы строки в параграфы и URL адреса в ссылки, закрывать забытые комментатором теги, а также добавлять атрибут rel="nofollow" во все внешние ссылки.

Теперь если пользователь напишет комментарий:

Вступление в комментарий

Завершение <b>комментария

он сохранится в поле $text, а в поле $purified_text запишется обработанный результат:

<p>Вступление в комментарий</p>
 
<p>Завершение <b>комментария</b></p>

Его и нужно будет выводить в представлении:

<?php echo $model->purified_text; ?>

Теперь перейдём именно к проблеме.

Фильтрация контента тегов pre и code

Предположим, что на блоге о программировании мы разрешили комментаторам использовать тег <pre>. Пусть комментатор приводит в комментарии фрагмент HTML кода, обрамив его блоком <pre></pre>:

Вступление в комментарий

<pre>
<div class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</div>
</pre>

Завершение <b>комментария

Мы видим два предложения и блок исходного кода. Также комментатор забыл закрыть тег <b>.

Он ожидает увидеть свой код в первозданном виде:

Вступление в комментарий

<div class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</div>

Завершение комментария

То есть в поле $purified_text содержимое тега <pre> должно быть просто переконвертировано в HTML сущности:

<p>Вступление в комментарий</p>
<pre>
&lt;div class="menu"&gt;
    &lt;ul&gt;
        &lt;li class="active"&gt;&lt;a href="#"&gt;Пункт &lt;span&gt;1&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href="#"&gt;Пункт &lt;span&gt;2&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
&lt;/div&gt;
</pre>
<p>Завершение <b>комментария</b></p>

Но вместо этого кода там будет совершенно другой результат:

<p>Вступление в комментарий</p>
<pre>
    <ul>
        <li><a href="#">Пункт 1</a></li>
        <li><a href="#">Пункт 2</a></li>
    </ul>
</pre>
<p>Завершение <b>комментария</b></p>

HTMLPurifier не «знает» об особенностях тегов <pre> и <code> и рассматривает их как и все остальные. Следовательно он «зашёл» внутрь и отфильтровал их контент, а именно удалил классы, теги <div> и <span>.

Дабы избежать такого поведения, нужно попросить пользователей кодировать все спецсимволы в своих комментариях, то есть записывать комментарии так:

Вступление в комментарий

<pre>
&lt;div class="menu"&gt;
    &lt;ul&gt;
        &lt;li class="active"&gt;&lt;a href="#"&gt;Пункт 1&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href="#"&gt;Пункт 2&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
&lt;/div&gt;
</pre>

Завершение <b>комментария

Но это не очень удобно для пользователей.

Мы пришли к выводу, что необходимо не впускать HTMLPurifier внутрь тегов <pre>. Вместо этого необходимо конвертировать их контент с использованием функции htmspecialchars().

Способы решения проблемы

Проблема, на самом деле, не нова, и в сети можно найти несколько путей её решения.

Воспользуемся простым способом:

$string = str_replace(array("<pre>", "</pre>"), array("<pre><![CDATA[", "]]></pre>"), $string);

То есть перед запуском процесса фильтрации необходимо обрамить нужный участок кода конструкцией <![CDATA[...]]>:

/*
 * $param string $text;
 * $param string $purified_text;
 */
class Comment extends CActiveRecord
{
    protected function beforeSave()
    {
        if (parent::beforeSave()){
            $this->purified_text = $this->purify($this->text);
            return true;
        }
        return false;
    }
 
    protected function purify($text)
    {
        $p = new CHtmlPurifier;
 
        $p->options = array(
            'AutoFormat.AutoParagraph' => true,
            'HTML.Allowed' => 'p,ul,li,b,i,a[href],pre',
            'AutoFormat.Linkify' => true,
            'HTML.Nofollow' => true,
            'Core.EscapeInvalidTags' => true,
        );
 
        $text = str_replace(array("<pre>", "</pre>"), array("<pre><![CDATA[", "]]></pre>"), $text);
 
        $text = $p->purify(trim($text));
        return $text;
    }    
}

и код комментария

Вступление в комментарий

<pre>
<div class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</div>
</pre>

Завершение <b>комментария

внутри тега <pre> будет автоматически переконвертирован в HTML сущности:

<p>Вступление в комментарий</p>
<pre>
&lt;div class="menu"&gt;
    &lt;ul&gt;
        &lt;li class="active"&gt;&lt;a href="#">Пункт &lt;span&gt;1&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href="#"&gt;Пункт &lt;span&gt;2&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
&lt;/div&gt;
</pre>
<p>Завершение <b>комментария</b></p>

Вроде теперь наш код работает верно. Но что если внутри кода, скопированного комментатором, будет свой элемент <![CDATA[...]]>?

Протестируем работу на таком примере:

<pre>
<nav class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</nav>
<script type="text/javascript">
<![CDATA[
    $('.menu > ul > li').click(function(){
        $(this).find('ul').slideToggle();
    });
]]>
</script>
<section class="content">
    <!-- // content -->
</section>
</pre>

После сохранения наш комментарий выведется так:

<nav class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</nav>
<script type="text/javascript">
<![CDATA[
    $('.menu > ul > li').click(function(){
        $(this).find('ul').slideToggle();
    });


<section class="content"></section>
]]>

Мы видим, что HTMLPurifier некорректно обработал вложенность элементов <![CDATA[...]]>. Наш внешний элемент <![CDATA[ закрылся в середине текста первым же внутренним ]]>.

Воспользуемся первым и более сложным способом:

$text = preg_replace_callback('/<pre>(.*)<\/pre>/ismU', 'store', $text);

Функция preg_replace_callback с помощью регулярного выражения для каждого тега <pre> вызовет функцию store($matches) и заменит фрагмент текста на её результат.

Передадим этой конструкции наш метод encodePreContent:

/*
 * $param string $text;
 * $param string $purified_text;
 */
class Comment extends CActiveRecord
{
    protected function beforeSave()
    {
        if (parent::beforeSave()){
            $this->purified_text = $this->purify($this->text);
            return true;
        }
        return false;
    }
 
    protected function purify($text)
    {
        $p = new CHtmlPurifier;
 
        $p->options = array(
            'AutoFormat.AutoParagraph' => true,
            'HTML.Allowed' => 'p,ul,li,b,i,a[href],pre',
            'AutoFormat.Linkify' => true,
            'HTML.Nofollow' => true,
            'Core.EscapeInvalidTags' => true,
        );
 
        $text = preg_replace_callback('|<pre>(.*)</pre>|ismU', array($this, 'encodePreContent'), $text);
 
        $text = $p->purify(trim($text));
 
        return $text;
    } 
 
    private function encodePreContent($matches)
    {
        return '<pre>' . CHtml::encode($matches[1]) . '<pre>';
    }
}

В методе мы просто перекодируем спецсимволы с помощью вызова метода CHtml::encode. Теперь комментарий будет выводиться именно так, как нам нужно:

<nav class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</nav>
<script type="text/javascript">
<![CDATA[
    $('.menu > ul > li').click(function(){
        $(this).find('ul').slideToggle();
    });
]]>
</script>
<section class="content">
    <!-- // content -->
</section>

И в базе данных весь контент будет переконвертирован:

<pre>
&lt;nav class=&quot;menu&quot;&gt;
    &lt;ul&gt;
        &lt;li class=&quot;active&quot;&gt;&lt;a href=&quot;#&quot;&gt;Пункт &lt;span&gt;1&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;#&quot;&gt;Пункт &lt;span&gt;2&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
&lt;/nav&gt;
&lt;script type=&quot;text/javascript&quot;&gt;
&lt;![CDATA[
    $(&#039;.menu &gt; ul &gt; li&#039;).click(function(){
        $(this).find(&#039;ul&#039;).slideToggle();
    });
]]&gt;
&lt;/script&gt;
&lt;section class=&quot;content&quot;&gt;
    &lt;!-- // content --&gt;
&lt;/section&gt;
</pre>

Но и это ещё не всё. Мы среди настроек HTMLPurifier указали

'AutoFormat.Linkify' => true,

Этот параметр включает преобразование URL адресов в тексте в ссылки. Это значит, что если в тексте внутри тега <pre> окажутся URL адреса, то они всё равно будут преобразованы в активные ссылки. Аналогично могут произойти какие-либо другие казусы после прохождения перекодированного контента через HTMLPurifier.

Итак, чтобы HTMLPurifier ни при каких обстоятельствах не смог повредить наш код, можно поступить так:

  • Вырезать контент из всех тегов <pre> (заменив на уникальные маркеры);
  • Произвести фильтрацию оставшегося текста с помощью HTMLPurifier;
  • Вернуть контент на место, пропустив через CHtml::encode.

Чтобы не было коллизий, в качестве маркеров будем использовать, например, хэши md5:

/*
 * $param string $text;
 * $param string $purified_text;
 */
class Comment extends CActiveRecord
{
    protected function beforeSave()
    {
        if (parent::beforeSave()){
            $this->purified_text = $this->purify($this->text);
            return true;
        }
        return false;
    }
 
    private function purify($text)
    {
        $p = new CHtmlPurifier;
 
        $p->options = array(
            'AutoFormat.AutoParagraph' => true,
            'HTML.Allowed' => 'p,ul,li,b,i,a[href],pre',
            'AutoFormat.Linkify' => true,
            'HTML.Nofollow' => true,
            'Core.EscapeInvalidTags' => true,
        );
 
        $text = preg_replace_callback('|<pre([^>]*)>(.*)</pre>|ismU', array($this, 'storePreContent'), $text);
 
        $text = $p->purify(trim($text));
 
        $text = preg_replace_callback('|<pre([^>]*)>(.*)</pre>|ismU', array($this, 'resumePreContent'), $text);
 
        return $text;
    } 
 
    private $_preContents = array();
 
    private function storePreContent($matches)
    {
        $attrs = $matches[1];
        $content = $matches[2];
        do {
            $id = md5(rand(0, 100000));
        } while (isset($this->_preContents[$id]));
        $this->_preContents[$id] = $content;
        return '<pre' . $attrs . '>' . $id . '</pre>';
    }
 
    private  function resumePreContent($matches)
    {
        $attrs = $matches[1];
        $id = $matches[2];
        $content = isset($this->_preContents[$id]) ? CHtml::encode($this->_preContents[$id]) : '';
        return '<pre' . $attrs . '>' . $content . '</pre>';
    }    
}

Используя данный подход, мы полностью заменили обработку контента тега <pre> на ручную и исключили его модификацию парсером конпонента.

Этот функционал для тегов <pre> и <code> теперь добавлен в DPurifyTextBehavior. Для активации необходимо в параметры поведения добавить строку

'encodePreContent'=>true,

По умолчанию этот параметр отключён.

На этом всё. Данный метод корректно справится с блоками <![CDATA[...]]> в тексте. Проблемы могут возникнуть только с использованием вложенного тега <pre>, но частота использования этого тега в исходниках в отличие от блока CDATA крайне мала.

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

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

Для вывода ленты записей на страницу в Yii имеется очень удобный готовый виджет CListView. Совместно с провайдером данных он позволяет выводить элементы с разбивкой на страницы и сортировкой. Но при разработке некоторых интернет-магазинов и всевозможных каталогов часто возникает необходимость в переключении числа элементов на странице. Попробуем добавить меню «Выводить по: 10 20 30» в нашу ленту записей.

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

Это продолжение статьи Мультиязычный сайт на Yii: Интерфейс и URL, в которой мы рассмотрели способы указания языка в URL адресе страницы, переопределив всего два метода пары стандартных компонентов Yii, и использование многоязычности. В этой части мы коснёмся непосредственно перевода текста наших динамических страниц и статей.

Комментарии

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


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



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