目的
highlight.jsで文字をハイライトしつつ、scriptタグとかはエスケープしたい
という問題をなかなか解決できずにいましたが、自分なりの解決方法を見つけましたので共有したいと思います。
もっと便利なライブラリとかやり方があったら教えて欲しいです。
DBに保存する前にエスケープ
まず、エスケープするタイミングは主に以下の2箇所が多いと思います。
- DBに保存する前
- 画面に表示する前
自分の場合、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('<', '<')
form.instance.text = escaped_text.replace('>', '>')
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({
'<': '<',
'>': '>'
}))
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 = '<' + accept_text + '>'
unescaped_start_tag = '<' + accept_text + '>'
self.setgroup(escaped_start_tag, unescaped_start_tag)
# 終了タグを作成し登録する
escaped_end_tag = '<' + '/' + accept_text + '>'
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')
{'<br>': '<br>', '</br>': '</br>', '<ul>': '<ul>', '</ul>': '</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 = { '<': '<' }
"""
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に追加なりで自由度が効いた制御が効くようになったと思います。
冒頭にも述べましたが、他に良いライブラリがありましたらぜひ教えて頂きたいです。