From 54e77550801f24422bd2d5590e1101e6cd45eda9 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Mon, 16 Jan 2023 11:53:40 -0700 Subject: [PATCH] Unfollowing or blocking users purges timeline Fixes #366 --- activities/models/post.py | 15 ++--- activities/models/timeline_event.py | 24 +++++++ .../activities/models/test_timeline_event.py | 65 +++++++++++++++++++ users/models/inbox_message.py | 20 +++++- users/services/identity.py | 19 +++++- 5 files changed, 131 insertions(+), 12 deletions(-) diff --git a/activities/models/post.py b/activities/models/post.py index aeb2a25..c469cbc 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -928,14 +928,11 @@ class Post(StatorModel): try: cls.by_object_uri(object_uri) except cls.DoesNotExist: - InboxMessage.objects.create( - message={ - "type": "__internal__", - "object": { - "type": "FetchPost", - "object": object_uri, - "reason": reason, - }, + InboxMessage.create_internal( + { + "type": "FetchPost", + "object": object_uri, + "reason": reason, } ) @@ -995,7 +992,7 @@ class Post(StatorModel): Handles an internal fetch-request inbox message """ try: - uri = data["object"]["object"] + uri = data["object"] if "://" in uri: cls.by_object_uri(uri, fetch=True) except (cls.DoesNotExist, KeyError): diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py index 8f25fd7..eda9472 100644 --- a/activities/models/timeline_event.py +++ b/activities/models/timeline_event.py @@ -166,6 +166,30 @@ class TimelineEvent(models.Model): subject_identity_id=interaction.identity_id, ).delete() + ### Background tasks ### + + @classmethod + def handle_clear_timeline(cls, message): + """ + Internal stator handler for clearing all events by a user off another + user's timeline. + """ + actor_id = message["actor"] + object_id = message["object"] + full_erase = message.get("fullErase", False) + + if full_erase: + q = ( + models.Q(subject_post__author_id=object_id) + | models.Q(subject_post_interaction__identity_id=object_id) + | models.Q(subject_identity_id=object_id) + ) + else: + q = models.Q( + type=cls.Types.post, subject_post__author_id=object_id + ) | models.Q(type=cls.Types.boost, subject_identity_id=object_id) + TimelineEvent.objects.filter(q, identity_id=actor_id).delete() + ### Mastodon Client API ### def to_mastodon_notification_json(self, interactions=None): diff --git a/tests/activities/models/test_timeline_event.py b/tests/activities/models/test_timeline_event.py index 3e3e7d8..b0fff39 100644 --- a/tests/activities/models/test_timeline_event.py +++ b/tests/activities/models/test_timeline_event.py @@ -5,6 +5,7 @@ from activities.models import Post, TimelineEvent from activities.services import PostService from core.ld import format_ld_date from users.models import Block, Follow, Identity, InboxMessage +from users.services import IdentityService @pytest.mark.django_db @@ -192,3 +193,67 @@ def test_old_new_post( ).first() assert event assert "Hello " in event.subject_post.content + + +@pytest.mark.django_db +@pytest.mark.parametrize("full", [True, False]) +def test_clear_timeline( + identity: Identity, + remote_identity: Identity, + stator, + full: bool, +): + """ + Ensures that timeline clearing works as expected. + """ + # Follow the remote user + service = IdentityService(remote_identity) + service.follow_from(identity) + # Create an inbound new post message mentioning us + message = { + "id": "test", + "type": "Create", + "actor": remote_identity.actor_uri, + "object": { + "id": "https://remote.test/test-post", + "type": "Note", + "published": format_ld_date(timezone.now()), + "attributedTo": remote_identity.actor_uri, + "content": f"Hello @{identity.handle}!", + "tag": { + "type": "Mention", + "href": identity.actor_uri, + "name": f"@{identity.handle}", + }, + }, + } + InboxMessage.objects.create(message=message) + + # Run stator twice - to make fanouts and then process them + stator.run_single_cycle_sync() + stator.run_single_cycle_sync() + + # Make sure it appeared on our timeline as a post and a mentioned + assert TimelineEvent.objects.filter( + type=TimelineEvent.Types.post, identity=identity + ).exists() + assert TimelineEvent.objects.filter( + type=TimelineEvent.Types.mentioned, identity=identity + ).exists() + + # Now, submit either a user block (for full clear) or unfollow (for post clear) + if full: + service.block_from(identity) + else: + service.unfollow_from(identity) + + # Run stator once to process the timeline clear message + stator.run_single_cycle_sync() + + # Verify that the right things vanished + assert not TimelineEvent.objects.filter( + type=TimelineEvent.Types.post, identity=identity + ).exists() + assert TimelineEvent.objects.filter( + type=TimelineEvent.Types.mentioned, identity=identity + ).exists() == (not full) diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py index 79d4f4e..d65495d 100644 --- a/users/models/inbox_message.py +++ b/users/models/inbox_message.py @@ -14,7 +14,7 @@ class InboxMessageStates(StateGraph): @classmethod async def handle_received(cls, instance: "InboxMessage"): - from activities.models import Post, PostInteraction + from activities.models import Post, PostInteraction, TimelineEvent from users.models import Block, Follow, Identity, Report match instance.message_type: @@ -148,7 +148,11 @@ class InboxMessageStates(StateGraph): match instance.message_object_type: case "fetchpost": await sync_to_async(Post.handle_fetch_internal)( - instance.message + instance.message["object"] + ) + case "cleartimeline": + await sync_to_async(TimelineEvent.handle_clear_timeline)( + instance.message["object"] ) case unknown: raise ValueError( @@ -171,6 +175,18 @@ class InboxMessage(StatorModel): state = StateField(InboxMessageStates) + @classmethod + def create_internal(cls, payload): + """ + Creates an internal action message + """ + cls.objects.create( + message={ + "type": "__internal__", + "object": payload, + } + ) + @property def message_type(self): return self.message["type"].lower() diff --git a/users/services/identity.py b/users/services/identity.py index 3016165..1927b50 100644 --- a/users/services/identity.py +++ b/users/services/identity.py @@ -11,6 +11,7 @@ from users.models import ( Follow, FollowStates, Identity, + InboxMessage, User, ) @@ -85,13 +86,29 @@ class IdentityService: existing_follow = Follow.maybe_get(from_identity, self.identity) if existing_follow: existing_follow.transition_perform(FollowStates.undone) + InboxMessage.create_internal( + { + "type": "ClearTimeline", + "actor": from_identity.pk, + "object": self.identity.pk, + } + ) def block_from(self, from_identity: Identity) -> Block: """ Blocks a user. """ self.unfollow_from(from_identity) - return Block.create_local_block(from_identity, self.identity) + block = Block.create_local_block(from_identity, self.identity) + InboxMessage.create_internal( + { + "type": "ClearTimeline", + "actor": from_identity.pk, + "object": self.identity.pk, + "fullErase": True, + } + ) + return block def unblock_from(self, from_identity: Identity): """