334 lines
11 KiB
Python
334 lines
11 KiB
Python
import re
|
|
from functools import partial
|
|
|
|
import bleach
|
|
import bleach.callbacks
|
|
from bleach.html5lib_shim import Filter
|
|
from bleach.linkifier import LinkifyFilter
|
|
from django.utils.safestring import mark_safe
|
|
|
|
url_regex = re.compile(
|
|
r"""\(* # Match any opening parentheses.
|
|
\b(?<![@.])(?:https?://(?:(?:\w+:)?\w+@)?) # http://
|
|
([\w-]+\.)+(?:[\w-]+)(?:\:[0-9]+)?(?!\.\w)\b # xx.yy.tld(:##)?
|
|
(?:[/?][^\s\{{\}}\|\\\^\[\]`<>"]*)?
|
|
# /path/zz (excluding "unsafe" chars from RFC 1738,
|
|
# except for # and ~, which happen in practice)
|
|
""",
|
|
re.IGNORECASE | re.VERBOSE | re.UNICODE,
|
|
)
|
|
|
|
ALLOWED_TAGS = ["br", "p", "a"]
|
|
REWRITTEN_TAGS = [
|
|
"h1",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"h5",
|
|
"h6",
|
|
"blockquote",
|
|
"pre",
|
|
"ul",
|
|
"ol",
|
|
"li",
|
|
]
|
|
|
|
|
|
class MastodonStrictTagFilter(Filter):
|
|
"""
|
|
Implements Python equivalent of Mastodon tag rewriter
|
|
|
|
Clone of https://github.com/mastodon/mastodon/blob/main/lib/sanitize_ext/sanitize_config.rb#L55
|
|
|
|
Broadly this replaces all REWRITTEN_TAGS with `p` except for lists where it formats it into `<br>` lists
|
|
"""
|
|
|
|
def __iter__(self):
|
|
li_pending_break = False
|
|
break_token = {
|
|
"name": "br",
|
|
"data": {},
|
|
"type": "StartTag",
|
|
}
|
|
|
|
for token in Filter.__iter__(self):
|
|
if token.get("name") not in REWRITTEN_TAGS or token["type"] not in [
|
|
"StartTag",
|
|
"EndTag",
|
|
]:
|
|
yield token
|
|
continue
|
|
|
|
if token["type"] == "StartTag":
|
|
if token["name"] == "li":
|
|
if li_pending_break:
|
|
# Another `li` appeared, so break after the last one
|
|
yield break_token
|
|
continue
|
|
token["name"] = "p"
|
|
elif token["type"] == "EndTag":
|
|
if token["name"] == "li":
|
|
# Track that an `li` closed so we know a break should be considered
|
|
li_pending_break = True
|
|
continue
|
|
if token["name"] == "ul":
|
|
# If the last `li` happened, then don't add a break because Mastodon doesn't
|
|
li_pending_break = False
|
|
token["name"] = "p"
|
|
|
|
yield token
|
|
|
|
|
|
def allow_a(tag: str, name: str, value: str):
|
|
if name in ["href", "title", "class"]:
|
|
return True
|
|
elif name == "rel":
|
|
# Only allow rel attributes with a small subset of values
|
|
# (we're defending against, for example, rel=me)
|
|
rel_values = value.split()
|
|
if all(v in ["nofollow", "noopener", "noreferrer", "tag"] for v in rel_values):
|
|
return True
|
|
return False
|
|
|
|
|
|
def shorten_link_text(attrs, new=False):
|
|
"""
|
|
Applies Mastodon's link shortening behavior where URL text links are
|
|
shortened by removing the scheme and only showing the first 30 chars.
|
|
|
|
Orig:
|
|
<a>https://social.example.com/a-long/path/2023/01/16/that-should-be-shortened</a>
|
|
|
|
Becomes:
|
|
<a>social.example.com/a-long/path</a>
|
|
|
|
"""
|
|
text = attrs.get("_text")
|
|
if not text:
|
|
text = attrs.get((None, "href"))
|
|
if text and "://" in text and len(text) > 30:
|
|
text = text.split("://", 1)[-1]
|
|
attrs["_text"] = text[:30]
|
|
if len(text) > 30:
|
|
attrs[(None, "class")] = " ".join(
|
|
filter(None, [attrs.pop((None, "class"), ""), "ellipsis"])
|
|
)
|
|
# Add the full URL in to title for easier user inspection
|
|
attrs[(None, "title")] = attrs.get((None, "href"))
|
|
|
|
return attrs
|
|
|
|
|
|
linkify_callbacks = [bleach.callbacks.nofollow, shorten_link_text]
|
|
|
|
|
|
def sanitize_html(post_html: str) -> str:
|
|
"""
|
|
Only allows a, br, p and span tags, and class attributes.
|
|
"""
|
|
cleaner = bleach.Cleaner(
|
|
tags=ALLOWED_TAGS + REWRITTEN_TAGS,
|
|
attributes={ # type:ignore
|
|
"a": allow_a,
|
|
"p": ["class"],
|
|
},
|
|
filters=[
|
|
partial(LinkifyFilter, url_re=url_regex, callbacks=linkify_callbacks),
|
|
MastodonStrictTagFilter,
|
|
],
|
|
strip=True,
|
|
)
|
|
return mark_safe(cleaner.clean(post_html))
|
|
|
|
|
|
def strip_html(post_html: str, *, linkify: bool = True) -> str:
|
|
"""
|
|
Strips all tags from the text, then linkifies it.
|
|
"""
|
|
cleaner = bleach.Cleaner(
|
|
tags=[],
|
|
strip=True,
|
|
filters=[partial(LinkifyFilter, url_re=url_regex, callbacks=linkify_callbacks)]
|
|
if linkify
|
|
else [],
|
|
)
|
|
return mark_safe(cleaner.clean(post_html))
|
|
|
|
|
|
def html_to_plaintext(post_html: str) -> str:
|
|
"""
|
|
Tries to do the inverse of the linebreaks filter.
|
|
"""
|
|
# TODO: Handle HTML entities
|
|
# Remove all newlines, then replace br with a newline and /p with two (one comes from bleach)
|
|
post_html = post_html.replace("\n", "").replace("<br>", "\n").replace("</p>", "\n")
|
|
# Remove all other HTML and return
|
|
cleaner = bleach.Cleaner(tags=[], strip=True, filters=[])
|
|
return cleaner.clean(post_html).strip()
|
|
|
|
|
|
class ContentRenderer:
|
|
"""
|
|
Renders HTML for posts, identity fields, and more.
|
|
|
|
The `local` parameter affects whether links are absolute (False) or relative (True)
|
|
"""
|
|
|
|
def __init__(self, local: bool):
|
|
self.local = local
|
|
|
|
def render_post(self, html: str, post) -> str:
|
|
"""
|
|
Given post HTML, normalises it and renders it for presentation.
|
|
"""
|
|
if not html:
|
|
return ""
|
|
html = sanitize_html(html)
|
|
html = self.linkify_mentions(html, post=post)
|
|
html = self.linkify_hashtags(html, identity=post.author)
|
|
if self.local:
|
|
html = self.imageify_emojis(
|
|
html,
|
|
identity=post.author,
|
|
emojis=post.emojis.all(),
|
|
)
|
|
html = self.remove_extra_newlines(html)
|
|
return mark_safe(html)
|
|
|
|
def render_identity_summary(self, html: str, identity, strip: bool = False) -> str:
|
|
"""
|
|
Given identity summary HTML, normalises it and renders it for presentation.
|
|
"""
|
|
if not html:
|
|
return ""
|
|
if strip:
|
|
html = strip_html(html)
|
|
else:
|
|
html = sanitize_html(html)
|
|
html = self.linkify_hashtags(html, identity=identity)
|
|
if self.local:
|
|
html = self.imageify_emojis(html, identity=identity)
|
|
html = self.remove_extra_newlines(html)
|
|
return mark_safe(html)
|
|
|
|
def render_identity_data(self, html: str, identity, strip: bool = False) -> str:
|
|
"""
|
|
Given name/basic value HTML, normalises it and renders it for presentation.
|
|
"""
|
|
if not html:
|
|
return ""
|
|
if strip:
|
|
html = strip_html(html)
|
|
else:
|
|
html = sanitize_html(html)
|
|
if self.local:
|
|
html = self.imageify_emojis(html, identity=identity)
|
|
html = self.remove_extra_newlines(html)
|
|
return mark_safe(html)
|
|
|
|
def linkify_mentions(self, html: str, post) -> str:
|
|
"""
|
|
Links mentions _in the context of the post_ - as in, using the mentions
|
|
property as the only source (as we might be doing this without other
|
|
DB access allowed)
|
|
"""
|
|
from activities.models import Post
|
|
|
|
possible_matches = {}
|
|
for mention in post.mentions.all():
|
|
if self.local:
|
|
url = str(mention.urls.view)
|
|
else:
|
|
url = mention.absolute_profile_uri()
|
|
# Might not have fetched it (yet)
|
|
if mention.username:
|
|
username = mention.username.lower()
|
|
possible_matches[username] = url
|
|
possible_matches[f"{username}@{mention.domain_id}"] = url
|
|
|
|
collapse_name: dict[str, str] = {}
|
|
|
|
def replacer(match):
|
|
precursor = match.group(1)
|
|
handle = match.group(2)
|
|
if "@" in handle:
|
|
short_handle = handle.split("@", 1)[0]
|
|
else:
|
|
short_handle = handle
|
|
handle_hash = handle.lower()
|
|
short_hash = short_handle.lower()
|
|
if handle_hash in possible_matches:
|
|
if short_hash not in collapse_name:
|
|
collapse_name[short_hash] = handle_hash
|
|
elif collapse_name.get(short_hash) != handle_hash:
|
|
short_handle = handle
|
|
return f'{precursor}<a href="{possible_matches[handle_hash]}">@{short_handle}</a>'
|
|
else:
|
|
return match.group()
|
|
|
|
return Post.mention_regex.sub(replacer, html)
|
|
|
|
def linkify_hashtags(self, html, identity) -> str:
|
|
from activities.models import Hashtag
|
|
|
|
def replacer(attrs, new=False):
|
|
# See if the text in this link looks like a hashtag
|
|
if not Hashtag.hashtag_regex.match(attrs.get("_text", "")):
|
|
return attrs
|
|
hashtag = attrs["_text"].strip().lstrip("#")
|
|
attrs[None, "class"] = "hashtag"
|
|
if (None, "rel") in attrs:
|
|
del attrs[None, "rel"]
|
|
if self.local:
|
|
attrs[None, "href"] = f"/tags/{hashtag.lower()}/"
|
|
else:
|
|
attrs[
|
|
None, "href"
|
|
] = f"https://{identity.domain.uri_domain}/tags/{hashtag.lower()}/"
|
|
return attrs
|
|
|
|
linker = bleach.linkifier.Linker(
|
|
url_re=Hashtag.hashtag_regex, callbacks=[replacer]
|
|
)
|
|
return linker.linkify(html)
|
|
|
|
def imageify_emojis(
|
|
self, html: str, identity, include_local: bool = True, emojis=None
|
|
):
|
|
"""
|
|
Find :emoji: in content and convert to <img>. If include_local is True,
|
|
the local emoji will be used as a fallback for any shortcodes not defined
|
|
by emojis.
|
|
"""
|
|
from activities.models import Emoji
|
|
|
|
# If precached emojis were passed, prep them
|
|
cached_emojis = {}
|
|
if emojis:
|
|
for emoji in emojis:
|
|
cached_emojis[emoji.shortcode] = emoji
|
|
|
|
def replacer(match):
|
|
shortcode = match.group(1).lower()
|
|
if shortcode in cached_emojis:
|
|
return cached_emojis[shortcode].as_html()
|
|
|
|
emoji = Emoji.get_by_domain(shortcode, identity.domain)
|
|
if emoji and emoji.is_usable:
|
|
return emoji.as_html()
|
|
elif not emoji and include_local:
|
|
emoji = Emoji.get_by_domain(shortcode, None)
|
|
if emoji:
|
|
return emoji.as_html()
|
|
|
|
return match.group()
|
|
|
|
return Emoji.emoji_regex.sub(replacer, html)
|
|
|
|
def remove_extra_newlines(self, html: str) -> str:
|
|
"""
|
|
Some clients are sensitive to extra newlines even though it's HTML
|
|
"""
|
|
# TODO: More intelligent way to strip these?
|
|
return html.replace("\n", "")
|