0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Django, markdownx, highlight.jsを使った場合のタグのエスケープ

Last updated at Posted at 2019-07-13

目的

highlight.jsで文字をハイライトしつつ、scriptタグとかはエスケープしたい
という問題をなかなか解決できずにいましたが、自分なりの解決方法を見つけましたので共有したいと思います。

もっと便利なライブラリとかやり方があったら教えて欲しいです。

DBに保存する前にエスケープ

まず、エスケープするタイミングは主に以下の2箇所が多いと思います。

  1. DBに保存する前
  2. 画面に表示する前

自分の場合、2をやってしまうと、highlight.jsが上手く働かず、
文字をハイライトしてくれませんでした。

なので1でエスケープする方法を考えます。

ソースコード

上記のことを踏まえて以下が簡単な実例です。

# models.py
class Article(models.Model):
    """記事"""
    title = models.CharField('タイトル', max_length=255)
    # Markdown形式
    text = MarkdownxField('本文')
# forms.py
class ArticleForm(forms.ModelForm):

    class Meta:
        model = Article
        fields = ('title', 'text')
        widgets = {
            'text': MarkdownxWidget(attrs={'class': 'textarea'}),
        }

# views.py

class ArticleCreateView(generic.CreateView):
    """記事を追加するview"""
    model = Article
    form_class = ArticleForm
    template_name = 'blogs/create.html'

上記はblogアプリの記事を投稿する機能の例です。
ArticleCreateViewが記事をDBに保存するviewなので、
ここにエスケープ処理を加えます。

class ArticleCreateView(generic.CreateView):
    """記事を追加するview"""
    model = Article
    form_class = ArticleForm
    template_name = 'blogs/create.html'

    def form_valid(self, form):
        # タグをエスケープ
        escaped_text = form.instance.text.replace('<', '&lt;')
        form.instance.text = escaped_text.replace('>', '&gt;')

        return super(generic.CreateView, self).form_valid(form)

はい簡単。

と最初は思いました。

ですが上記はタグ全てを無効にしていますので、
無害で便利なhtmlタグもエスケープされてしまいます。(<br>とか)

なので、タグはエスケープしたいけど、許可したタグだけはエスケープしないというように修正します。
また、このような処理はviewからは分離して管理しやすいようにもしました。

修正後ソースコード

# escape.py
def escape_tag(un_escaped_text, *accept_texts):
    """タグをエスケープする関数

    エスケープしないタグがあれば無効とする
    """
    escaped_text = un_escaped_text.translate(str.maketrans({
        '<': '&lt;',
        '>': '&gt;'
    }))
    if accept_texts:
        accepter = HtmlAccepter()
        accepter.accepts(*accept_texts)
        escaped_text = accepter.unescape_html_filter(escaped_text)
    return escaped_text

最初は全てのタグをエスケープし、後で引数で受け取った文字列をアンエスケープします。
以下がアンエスケープ処理を行うクラスの実装です。

# escape.py

class HtmlAccepter(Translater):
    """HTMLタグのアンエスケープを行うクラス"""
    def __init__(self):
        super().__init__()

    def accepts(self, *args):
        """エスケープを無効したいタグを登録

        引数にstr, list, tupleを受け取って、
        その数だけタグを生成し、permutation_groupに保存する
        """
        if not isinstance(args, tuple):
            raise ValueError('input string type')

        accept_texts = []
        if len(args) > 1:
            # 可変長引数で渡された場合
            accept_texts += args
        elif isinstance(args[0], str):
            # 引数が1つの場合
            accept_texts.append(args[0])
        elif isinstance(args[0], list):
            # 引数がリストの場合
            accept_texts += args[0]
        else:
            raise ValueError('input string type')

        for accept_text in accept_texts:
            # 開始タグを作成し登録する
            escaped_start_tag = '&lt;' + accept_text + '&gt;'
            unescaped_start_tag = '<' + accept_text + '>'
            self.setgroup(escaped_start_tag, unescaped_start_tag)
            # 終了タグを作成し登録する
            escaped_end_tag = '&lt;' + '/' + accept_text + '&gt;'
            unescaped_end_tag = '<' + '/' + accept_text + '>'
            self.setgroup(escaped_end_tag, unescaped_end_tag)

        return self._permutation_group

    def unescape_html_filter(self, escaped_text):
        """エスケープを無効にしたhtmlタグをアンエスケープする"""
        unescaped_text = self.translate(escaped_text)
        return unescaped_text

このクラスは以下のように
エスケープタグをkey、アンエスケープタグをvalueとして保持します。

>>> accepter = HtmlAccepter()
>>> accepter.accepts('br', 'ul')
{'&lt;br&gt;': '<br>', '&lt;/br&gt;': '</br>', '&lt;ul&gt;': '<ul>', '&lt;/ul&gt;': '</ul>'}

最後にs.translate(str.maketrans())が今回使えなかったので、
汎用的な親クラスを定義しました。(使い回すか分からないですが)

class Translater:
    """文字の置換を行うクラス

    Attributes:
        permutation_group: 置換前と置換後を保存するためのdict型変数

    Methods:
        group: permutation_groupを返す
        setgroup: permutation_groupに保存
        translate: permutation_groupに保存されている文字を変換
    """
    def __init__(self):
        """Constructor.置換前と置換後を保存するためのdictを作成

        permutation_group: 置換前がkeyで、置換後がvalue
                 ex) permutation_group = { '&lt;': '<' }
        """
        self._permutation_group = {}

    @property
    def group(self):
        return self._permutation_group

    def setgroup(self, key, value):
        """置換前と置換後を保存する"""
        self._permutation_group.setdefault(key, value)

    def translate(self, text):
        """置換を行う"""
        for escaped_text, accepted_text in self._permutation_group.items():
            text = text.replace(escaped_text, accepted_text)
        return text

これでviews.pyの方は許容するタグを登録し、
それ以外のタグはエスケープするという処理ができます。

# views.py

from blogs.escape import escape_tag


ACCEPT_TAGS = [
    'b', 'blockquote', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    'li', 'ol', 'ol start="42"', 'p', 'pre', 'sub', 'sup', 'strong',
    'strike', 'ul', 'br', 'hr',
]


class ArticleCreateView(generic.CreateView):
    """記事を追加するview"""
    model = Article
    form_class = ArticleForm
    template_name = 'blogs/create.html'

    def form_valid(self, form):
        # ACCEPT_TAGSに登録していないタグをエスケープ
        escaped_text = escape_tag(form.instance.text, ACCEPT_TAGS)
        form.instance.text = escaped_text
        return super(generic.CreateView, self).form_valid(form)

以上です。

まとめ

ちょっと面倒でしたが、一度作ってしまえばescape.pyの修正なり、
ACCEPT_TAGSに追加なりで自由度が効いた制御が効くようになったと思います。

冒頭にも述べましたが、他に良いライブラリがありましたらぜひ教えて頂きたいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?