diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1b6582c..400f20c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,7 @@ repos: rev: v0.991 hooks: - id: mypy + exclude: "^tests/" additional_dependencies: [ types-pyopenssl, diff --git a/activities/models/emoji.py b/activities/models/emoji.py index 8068881..5a0c0c8 100644 --- a/activities/models/emoji.py +++ b/activities/models/emoji.py @@ -1,10 +1,11 @@ import re from functools import partial -from typing import ClassVar, cast +from typing import ClassVar import httpx import urlman from asgiref.sync import sync_to_async +from cachetools import TTLCache, cached from django.conf import settings from django.core.exceptions import ValidationError from django.db import models @@ -50,17 +51,18 @@ class EmojiStates(StateGraph): class EmojiQuerySet(models.QuerySet): def usable(self, domain: Domain | None = None): - if domain is None or domain.local: - visible_q = models.Q(local=True) - else: - visible_q = models.Q(public=True) - if Config.system.emoji_unreviewed_are_public: - visible_q |= models.Q(public__isnull=True) - + """ + Returns all usable emoji, optionally filtering by domain too. + """ + visible_q = models.Q(local=True) | models.Q(public=True) + if Config.system.emoji_unreviewed_are_public: + visible_q |= models.Q(public__isnull=True) qs = self.filter(visible_q) + if domain: if not domain.local: qs = qs.filter(domain=domain) + return qs @@ -136,6 +138,13 @@ class Emoji(StatorModel): def load_locals(cls) -> dict[str, "Emoji"]: return {x.shortcode: x for x in Emoji.objects.usable().filter(local=True)} + @classmethod + @cached(cache=TTLCache(maxsize=1000, ttl=60)) + def for_domain(cls, domain: Domain | None) -> list["Emoji"]: + if not domain: + return list(cls.locals.values()) + return list(cls.objects.usable(domain)) + @property def fullcode(self): return f":{self.shortcode}:" @@ -164,41 +173,6 @@ class Emoji(StatorModel): ) return self.fullcode - @classmethod - def imageify_emojis( - cls, - content: str, - *, - emojis: list["Emoji"] | EmojiQuerySet | None = None, - include_local: bool = True, - ): - """ - Find :emoji: in content and convert to . If include_local is True, - the local emoji will be used as a fallback for any shortcodes not defined - by emojis. - """ - emoji_set = ( - cast(list[Emoji], list(cls.locals.values())) if include_local else [] - ) - - if emojis: - if isinstance(emojis, (EmojiQuerySet, list)): - emoji_set.extend(list(emojis)) - else: - raise TypeError("Unsupported type for emojis") - - possible_matches = { - emoji.shortcode: emoji.as_html() for emoji in emoji_set if emoji.is_usable - } - - def replacer(match): - fullcode = match.group(1).lower() - if fullcode in possible_matches: - return possible_matches[fullcode] - return match.group() - - return mark_safe(Emoji.emoji_regex.sub(replacer, content)) - @classmethod def emojis_from_content(cls, content: str, domain: Domain | None) -> list[str]: """ diff --git a/activities/models/hashtag.py b/activities/models/hashtag.py index 078f110..afe2d94 100644 --- a/activities/models/hashtag.py +++ b/activities/models/hashtag.py @@ -5,7 +5,6 @@ import urlman from asgiref.sync import sync_to_async from django.db import models from django.utils import timezone -from django.utils.safestring import mark_safe from core.html import strip_html from core.models import Config @@ -176,19 +175,6 @@ class Hashtag(StatorModel): hashtags = sorted({tag.lower() for tag in hashtag_hits}) return list(hashtags) - @classmethod - def linkify_hashtags(cls, content, domain=None) -> str: - def replacer(match): - hashtag = match.group(1) - if domain: - return f'#{hashtag}' - else: - return ( - f'#{hashtag}' - ) - - return mark_safe(Hashtag.hashtag_regex.sub(replacer, content)) - def to_mastodon_json(self): return { "name": self.hashtag, diff --git a/activities/models/post.py b/activities/models/post.py index 51e1013..cce8af1 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -20,8 +20,7 @@ from activities.models.post_types import ( PostTypeDataDecoder, PostTypeDataEncoder, ) -from activities.templatetags.emoji_tags import imageify_emojis -from core.html import sanitize_post, strip_html +from core.html import ContentRenderer, strip_html from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date from stator.exceptions import TryAgainLater from stator.models import State, StateField, StateGraph, StatorModel @@ -383,13 +382,7 @@ class Post(StatorModel): return mark_safe(self.mention_regex.sub(replacer, content)) def _safe_content_note(self, *, local: bool = True): - content = Hashtag.linkify_hashtags( - self.linkify_mentions(sanitize_post(self.content), local=local), - domain=None if local else self.author.domain, - ) - if local: - content = imageify_emojis(content, self.author.domain) - return content + return ContentRenderer(local=local).render_post(self.content, self) # def _safe_content_question(self, *, local: bool = True): # context = { @@ -432,12 +425,6 @@ class Post(StatorModel): """ return self.safe_content(local=False) - def safe_content_plain(self): - """ - Returns the content formatted as plain text - """ - return self.linkify_mentions(sanitize_post(self.content)) - ### Async helpers ### async def afetch_full(self) -> "Post": @@ -914,7 +901,7 @@ class Post(StatorModel): "poll": None, "card": None, "language": None, - "text": self.safe_content_plain(), + "text": self.safe_content_remote(), "edited_at": format_ld_date(self.edited) if self.edited else None, } if interactions: diff --git a/activities/templatetags/activity_tags.py b/activities/templatetags/activity_tags.py index fb822f6..571e2d6 100644 --- a/activities/templatetags/activity_tags.py +++ b/activities/templatetags/activity_tags.py @@ -3,8 +3,6 @@ import datetime from django import template from django.utils import timezone -from activities.models import Hashtag - register = template.Library() @@ -33,14 +31,3 @@ def timedeltashort(value: datetime.datetime): years = max(days // 365.25, 1) text = f"{years:0n}y" return text - - -@register.filter -def linkify_hashtags(value: str): - """ - Convert hashtags in content in to /tags// links. - """ - if not value: - return "" - - return Hashtag.linkify_hashtags(value) diff --git a/activities/templatetags/emoji_tags.py b/activities/templatetags/emoji_tags.py deleted file mode 100644 index ad221db..0000000 --- a/activities/templatetags/emoji_tags.py +++ /dev/null @@ -1,27 +0,0 @@ -from cachetools import TTLCache, cached -from django import template - -from activities.models import Emoji -from users.models import Domain - -register = template.Library() - - -@cached(cache=TTLCache(maxsize=1000, ttl=60)) -def emoji_from_domain(domain: Domain | None) -> list[Emoji]: - if not domain: - return list(Emoji.locals.values()) - return list(Emoji.objects.usable(domain)) - - -@register.filter -def imageify_emojis(value: str, arg: Domain | None = None): - """ - Convert hashtags in content in to /tags// links. - """ - if not value: - return "" - - emojis = emoji_from_domain(arg) - - return Emoji.imageify_emojis(value, emojis=emojis) diff --git a/activities/views/posts.py b/activities/views/posts.py index 967352e..36f8fb3 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -40,6 +40,11 @@ class Individual(TemplateView): ancestors, descendants = PostService(self.post_obj).context( self.request.identity ) + print( + self.post_obj.to_mastodon_json(), + self.post_obj.emojis.all(), + self.post_obj.emojis.usable(), + ) return { "identity": self.identity, "post": self.post_obj, diff --git a/api/views/emoji.py b/api/views/emoji.py index c4e7558..a0ffbc3 100644 --- a/api/views/emoji.py +++ b/api/views/emoji.py @@ -5,4 +5,4 @@ from api.views.base import api_router @api_router.get("/v1/custom_emojis", response=list[CustomEmoji]) def emojis(request): - return [e.to_mastodon_json() for e in Emoji.objects.usable()] + return [e.to_mastodon_json() for e in Emoji.objects.usable().filter(local=True)] diff --git a/core/html.py b/core/html.py index 0ab6ace..5af476e 100644 --- a/core/html.py +++ b/core/html.py @@ -15,12 +15,12 @@ def allow_a(tag: str, name: str, value: str): return False -def sanitize_post(post_html: str) -> str: +def sanitize_html(post_html: str) -> str: """ Only allows a, br, p and span tags, and class attributes. """ cleaner = bleach.Cleaner( - tags=["br", "p", "a"], + tags=["br", "p"], attributes={ # type:ignore "a": allow_a, "p": ["class"], @@ -50,3 +50,117 @@ def html_to_plaintext(post_html: str) -> str: # 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) + return mark_safe(html) + + def render_identity(self, html: str, identity, strip: bool = False) -> str: + """ + Given identity field 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) + 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() + possible_matches[mention.username] = url + possible_matches[f"{mention.username}@{mention.domain_id}"] = url + + collapse_name: dict[str, str] = {} + + def replacer(match): + precursor = match.group(1) + handle = match.group(2).lower() + if "@" in handle: + short_handle = handle.split("@", 1)[0] + else: + short_handle = handle + if handle in possible_matches: + if short_handle not in collapse_name: + collapse_name[short_handle] = handle + elif collapse_name.get(short_handle) != handle: + short_handle = handle + return f'{precursor}@{short_handle}' + 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(match): + hashtag = match.group(1) + if self.local: + return ( + f'#{hashtag}' + ) + else: + return f'#{hashtag}' + + return Hashtag.hashtag_regex.sub(replacer, html) + + def imageify_emojis(self, html: str, identity, include_local: bool = True): + """ + Find :emoji: in content and convert to . 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 + + emoji_set = Emoji.for_domain(identity.domain) + if include_local: + emoji_set.extend(Emoji.for_domain(None)) + + possible_matches = { + emoji.shortcode: emoji.as_html() for emoji in emoji_set if emoji.is_usable + } + + def replacer(match): + fullcode = match.group(1).lower() + if fullcode in possible_matches: + return possible_matches[fullcode] + return match.group() + + return Emoji.emoji_regex.sub(replacer, html) diff --git a/setup.cfg b/setup.cfg index 3503f61..0d26527 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,6 +15,7 @@ filterwarnings = [mypy] warn_unused_ignores = True +exclude = tests [mypy-django.*] ignore_missing_imports = True diff --git a/templates/identity/view.html b/templates/identity/view.html index e68ebc1..e6e37e6 100644 --- a/templates/identity/view.html +++ b/templates/identity/view.html @@ -1,5 +1,4 @@ {% extends "base.html" %} -{% load emoji_tags %} {% block title %}{{ identity }}{% endblock %} diff --git a/tests/activities/models/test_hashtag.py b/tests/activities/models/test_hashtag.py index 91af45c..f54cd4a 100644 --- a/tests/activities/models/test_hashtag.py +++ b/tests/activities/models/test_hashtag.py @@ -1,4 +1,5 @@ from activities.models import Hashtag +from core.html import ContentRenderer def test_hashtag_from_content(): @@ -19,7 +20,7 @@ def test_hashtag_from_content(): def test_linkify_hashtag(): - linkify = Hashtag.linkify_hashtags + linkify = lambda html: ContentRenderer(local=True).linkify_hashtags(html, None) assert linkify("# hashtag") == "# hashtag" assert ( diff --git a/tests/activities/models/test_post.py b/tests/activities/models/test_post.py index ff74a9e..65ca303 100644 --- a/tests/activities/models/test_post.py +++ b/tests/activities/models/test_post.py @@ -9,6 +9,16 @@ def test_fetch_post(httpx_mock: HTTPXMock, config_system): """ Tests that a post we don't have locally can be fetched by by_object_uri """ + httpx_mock.add_response( + url="https://example.com/test-actor", + json={ + "@context": [ + "https://www.w3.org/ns/activitystreams", + ], + "id": "https://example.com/test-actor", + "type": "Person", + }, + ) httpx_mock.add_response( url="https://example.com/test-post", json={ diff --git a/tests/activities/models/test_post_types.py b/tests/activities/models/test_post_types.py index 857d495..30fd1ad 100644 --- a/tests/activities/models/test_post_types.py +++ b/tests/activities/models/test_post_types.py @@ -6,19 +6,19 @@ from core.ld import canonicalise @pytest.mark.django_db -def test_question_post(config_system, identity, remote_identity): +def test_question_post(config_system, identity, remote_identity, httpx_mock): data = { "cc": [], - "id": "https://fosstodon.org/users/manfre/statuses/109519951621804608/activity", + "id": "https://remote.test/test-actor/statuses/109519951621804608/activity", "to": identity.absolute_profile_uri(), "type": "Create", - "actor": "https://fosstodon.org/users/manfre", + "actor": "https://remote.test/test-actor/", "object": { "cc": [], - "id": "https://fosstodon.org/users/manfre/statuses/109519951621804608", + "id": "https://remote.test/test-actor/statuses/109519951621804608", "to": identity.absolute_profile_uri(), "tag": [], - "url": "https://fosstodon.org/@manfre/109519951621804608", + "url": "https://remote.test/test-actor/109519951621804608", "type": "Question", "oneOf": [ { @@ -35,13 +35,13 @@ def test_question_post(config_system, identity, remote_identity): "content": '

This is a poll :python:

@mike

', "endTime": "2022-12-18T22:03:59Z", "replies": { - "id": "https://fosstodon.org/users/manfre/statuses/109519951621804608/replies", + "id": "https://remote.test/test-actor/statuses/109519951621804608/replies", "type": "Collection", "first": { - "next": "https://fosstodon.org/users/manfre/statuses/109519951621804608/replies?only_other_accounts=true&page=true", + "next": "https://remote.test/test-actor/109519951621804608/replies?only_other_accounts=true&page=true", "type": "CollectionPage", "items": [], - "partOf": "https://fosstodon.org/users/manfre/statuses/109519951621804608/replies", + "partOf": "https://remote.test/test-actor/109519951621804608/replies", }, }, "published": "2022-12-15T22:03:59Z", @@ -50,15 +50,9 @@ def test_question_post(config_system, identity, remote_identity): "en": '

This is a poll :python:

@mike

' }, "as:sensitive": False, - "attributedTo": "https://fosstodon.org/users/manfre", - "http://ostatus.org#atomUri": "https://fosstodon.org/users/manfre/statuses/109519951621804608", - "http://ostatus.org#conversation": "tag:fosstodon.org,2022-12-15:objectId=69494364:objectType=Conversation", - "http://joinmastodon.org/ns#votersCount": 0, + "attributedTo": "https://remote.test/test-actor/", + "toot:votersCount": 0, }, - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - ], "published": "2022-12-15T22:03:59Z", } diff --git a/tests/activities/services/test_post.py b/tests/activities/services/test_post.py index 7069de1..4453fc8 100644 --- a/tests/activities/services/test_post.py +++ b/tests/activities/services/test_post.py @@ -6,7 +6,7 @@ from users.models import Identity @pytest.mark.django_db -def test_post_context(identity: Identity): +def test_post_context(identity: Identity, config_system): """ Tests that post context fetching works correctly """ diff --git a/tests/activities/templatetags/test_activity_tags.py b/tests/activities/templatetags/test_activity_tags.py index 9426337..0c481a2 100644 --- a/tests/activities/templatetags/test_activity_tags.py +++ b/tests/activities/templatetags/test_activity_tags.py @@ -2,7 +2,7 @@ from datetime import timedelta from django.utils import timezone -from activities.templatetags.activity_tags import linkify_hashtags, timedeltashort +from activities.templatetags.activity_tags import timedeltashort def test_timedeltashort(): @@ -22,17 +22,3 @@ def test_timedeltashort(): assert timedeltashort(value - timedelta(days=364)) == "364d" assert timedeltashort(value - timedelta(days=365)) == "1y" assert timedeltashort(value - timedelta(days=366)) == "1y" - - -def test_linkify_hashtags(): - """ - Tests that linkify_hashtags works correctly - """ - - assert linkify_hashtags(None) == "" - assert linkify_hashtags("") == "" - - assert ( - linkify_hashtags("#Takahe") - == '#Takahe' - ) diff --git a/tests/core/test_html.py b/tests/core/test_html.py index d4c74dc..5d798ac 100644 --- a/tests/core/test_html.py +++ b/tests/core/test_html.py @@ -1,4 +1,4 @@ -from core.html import html_to_plaintext, sanitize_post +from core.html import html_to_plaintext, sanitize_html def test_html_to_plaintext(): @@ -17,5 +17,5 @@ def test_html_to_plaintext(): def test_sanitize_post(): - assert sanitize_post("

Hello!

") == "

Hello!

" - assert sanitize_post("

It's great

") == "

It's great

" + assert sanitize_html("

Hello!

") == "

Hello!

" + assert sanitize_html("

It's great

") == "

It's great

" diff --git a/users/models/identity.py b/users/models/identity.py index cdacf34..acea1ee 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -11,7 +11,7 @@ from django.utils import timezone from django.utils.functional import lazy from core.exceptions import ActorMismatchError -from core.html import sanitize_post, strip_html +from core.html import ContentRenderer, strip_html from core.ld import ( canonicalise, format_ld_date, @@ -192,20 +192,18 @@ class Identity(StatorModel): @property def safe_summary(self): - from activities.templatetags.emoji_tags import imageify_emojis - - return imageify_emojis(sanitize_post(self.summary), self.domain) + return ContentRenderer(local=True).render_identity(self.summary, self) @property def safe_metadata(self): - from activities.templatetags.emoji_tags import imageify_emojis + renderer = ContentRenderer(local=True) if not self.metadata: return [] return [ { - "name": imageify_emojis(strip_html(data["name"]), self.domain), - "value": imageify_emojis(strip_html(data["value"]), self.domain), + "name": renderer.render_identity(data["name"], self, strip=True), + "value": renderer.render_identity(data["value"], self, strip=True), } for data in self.metadata ] @@ -279,9 +277,9 @@ class Identity(StatorModel): """ Return the name_or_handle with any HTML substitutions made """ - from activities.templatetags.emoji_tags import imageify_emojis - - return imageify_emojis(self.name_or_handle, self.domain) + return ContentRenderer(local=True).render_identity( + self.name_or_handle, self, strip=True + ) @property def handle(self):