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

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

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

## DBに保存する前にエスケープ
まず、エスケープするタイミングは主に以下の２箇所が多いと思います。
1. DBに保存する前
2. 画面に表示する前

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

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

#### ソースコード
上記のことを踏まえて以下が簡単な実例です。

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

```python
# forms.py
class ArticleForm(forms.ModelForm):

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

```

```python
# views.py

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

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

```python
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からは分離して管理しやすいようにもしました。

#### 修正後ソースコード

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

```python
# 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として保持します。

```sh
>>> 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())が今回使えなかったので、
汎用的な親クラスを定義しました。（使い回すか分からないですが）

```python
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の方は許容するタグを登録し、
それ以外のタグはエスケープするという処理ができます。

```python
# 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に追加なりで自由度が効いた制御が効くようになったと思います。

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