From 20e63023bb0d3c7e4cb36b91b73e79f51889cc90 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Tue, 15 Nov 2022 18:30:30 -0700 Subject: Get outbound likes/boosts and their undos working --- activities/models/__init__.py | 6 +- activities/models/fan_out.py | 35 +++++++++++ activities/models/post.py | 50 ++++++++++----- activities/models/post_interaction.py | 112 +++++++++++++++++++++++++++++++--- activities/models/timeline_event.py | 17 ++++++ 5 files changed, 195 insertions(+), 25 deletions(-) (limited to 'activities/models') diff --git a/activities/models/__init__.py b/activities/models/__init__.py index a0680ad..48ba879 100644 --- a/activities/models/__init__.py +++ b/activities/models/__init__.py @@ -1,4 +1,4 @@ -from .fan_out import FanOut # noqa -from .post import Post # noqa -from .post_interaction import PostInteraction # noqa +from .fan_out import FanOut, FanOutStates # noqa +from .post import Post, PostStates # noqa +from .post_interaction import PostInteraction, PostInteractionStates # noqa from .timeline_event import TimelineEvent # noqa diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index dbe86c0..771be19 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -38,6 +38,40 @@ class FanOutStates(StateGraph): key_id=post.author.public_key_id, ) return cls.sent + # Handle boosts/likes + elif fan_out.type == FanOut.Types.interaction: + interaction = await fan_out.subject_post_interaction.afetch_full() + if fan_out.identity.local: + # Make a timeline event directly + await sync_to_async(TimelineEvent.add_post_interaction)( + identity=fan_out.identity, + interaction=interaction, + ) + else: + # Send it to the remote inbox + await HttpSignature.signed_request( + uri=fan_out.identity.inbox_uri, + body=canonicalise(interaction.to_ap()), + private_key=interaction.identity.private_key, + key_id=interaction.identity.public_key_id, + ) + # Handle undoing boosts/likes + elif fan_out.type == FanOut.Types.undo_interaction: + interaction = await fan_out.subject_post_interaction.afetch_full() + if fan_out.identity.local: + # Delete any local timeline events + await sync_to_async(TimelineEvent.delete_post_interaction)( + identity=fan_out.identity, + interaction=interaction, + ) + else: + # Send an undo to the remote inbox + await HttpSignature.signed_request( + uri=fan_out.identity.inbox_uri, + body=canonicalise(interaction.to_undo_ap()), + private_key=interaction.identity.private_key, + key_id=interaction.identity.public_key_id, + ) else: raise ValueError(f"Cannot fan out with type {fan_out.type}") @@ -50,6 +84,7 @@ class FanOut(StatorModel): class Types(models.TextChoices): post = "post" interaction = "interaction" + undo_interaction = "undo_interaction" state = StateField(FanOutStates) diff --git a/activities/models/post.py b/activities/models/post.py index 74b335b..22e6412 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -2,7 +2,7 @@ from typing import Dict, Optional import httpx import urlman -from django.db import models +from django.db import models, transaction from django.utils import timezone from activities.models.fan_out import FanOut @@ -99,7 +99,12 @@ class Post(StatorModel): class urls(urlman.Urls): view = "{self.author.urls.view}posts/{self.id}/" - object_uri = "{self.author.urls.actor}posts/{self.id}/" + view_nice = "{self.author.urls.view_nice}posts/{self.id}/" + object_uri = "{self.author.actor_uri}posts/{self.id}/" + action_like = "{view}like/" + action_unlike = "{view}unlike/" + action_boost = "{view}boost/" + action_unboost = "{view}unboost/" def get_scheme(self, url): return "https" @@ -130,16 +135,17 @@ class Post(StatorModel): def create_local( cls, author: Identity, content: str, summary: Optional[str] = None ) -> "Post": - post = cls.objects.create( - author=author, - content=content, - summary=summary or None, - sensitive=bool(summary), - local=True, - ) - post.object_uri = post.author.actor_uri + f"posts/{post.id}/" - post.url = post.object_uri - post.save() + with transaction.atomic(): + post = cls.objects.create( + author=author, + content=content, + summary=summary or None, + sensitive=bool(summary), + local=True, + ) + post.object_uri = post.urls.object_uri + post.url = post.urls.view_nice + post.save() return post ### ActivityPub (outbound) ### @@ -179,7 +185,7 @@ class Post(StatorModel): "content": self.safe_content, "to": "as:Public", "as:sensitive": self.sensitive, - "url": self.urls.view.full(), # type: ignore + "url": self.urls.view_nice if self.local else self.url, } if self.summary: value["summary"] = self.summary @@ -257,7 +263,7 @@ class Post(StatorModel): create=True, update=True, ) - raise ValueError(f"Cannot find Post with URI {object_uri}") + raise cls.DoesNotExist(f"Cannot find Post with URI {object_uri}") @classmethod def handle_create_ap(cls, data): @@ -275,6 +281,22 @@ class Post(StatorModel): # Force it into fanned_out as it's not ours post.transition_perform(PostStates.fanned_out) + @classmethod + def handle_delete_ap(cls, data): + """ + Handles an incoming create request + """ + # Find our post by ID if we have one + try: + post = cls.by_object_uri(data["object"]["id"]) + except cls.DoesNotExist: + # It's already been deleted + return + # Ensure the actor on the request authored the post + if not post.author.actor_uri == data["actor"]: + raise ValueError("Actor on delete does not match object") + post.delete() + def debug_fetch(self): """ Fetches the Post from its original URL again and updates us with it diff --git a/activities/models/post_interaction.py b/activities/models/post_interaction.py index 151ab45..ea95cdf 100644 --- a/activities/models/post_interaction.py +++ b/activities/models/post_interaction.py @@ -14,9 +14,13 @@ from users.models.identity import Identity class PostInteractionStates(StateGraph): new = State(try_interval=300) - fanned_out = State() + fanned_out = State(externally_progressed=True) + undone = State(try_interval=300) + undone_fanned_out = State() new.transitions_to(fanned_out) + fanned_out.transitions_to(undone) + undone.transitions_to(undone_fanned_out) @classmethod async def handle_new(cls, instance: "PostInteraction"): @@ -31,26 +35,74 @@ class PostInteractionStates(StateGraph): ): if follow.source.local or follow.target.local: await FanOut.objects.acreate( - identity_id=follow.source_id, type=FanOut.Types.interaction, - subject_post=interaction, + identity_id=follow.source_id, + subject_post=interaction.post, + subject_post_interaction=interaction, ) # Like: send a copy to the original post author only elif interaction.type == interaction.Types.like: await FanOut.objects.acreate( - identity_id=interaction.post.author_id, type=FanOut.Types.interaction, - subject_post=interaction, + identity_id=interaction.post.author_id, + subject_post=interaction.post, + subject_post_interaction=interaction, ) else: raise ValueError("Cannot fan out unknown type") - # And one for themselves if they're local - if interaction.identity.local: + # And one for themselves if they're local and it's a boost + if ( + interaction.type == PostInteraction.Types.boost + and interaction.identity.local + ): await FanOut.objects.acreate( identity_id=interaction.identity_id, type=FanOut.Types.interaction, - subject_post=interaction, + subject_post=interaction.post, + subject_post_interaction=interaction, + ) + return cls.fanned_out + + @classmethod + async def handle_undone(cls, instance: "PostInteraction"): + """ + Creates all needed fan-out objects to undo a PostInteraction. + """ + interaction = await instance.afetch_full() + # Undo Boost: send a copy to all people who follow this user + if interaction.type == interaction.Types.boost: + async for follow in interaction.identity.inbound_follows.select_related( + "source", "target" + ): + if follow.source.local or follow.target.local: + await FanOut.objects.acreate( + type=FanOut.Types.undo_interaction, + identity_id=follow.source_id, + subject_post=interaction.post, + subject_post_interaction=interaction, + ) + # Undo Like: send a copy to the original post author only + elif interaction.type == interaction.Types.like: + await FanOut.objects.acreate( + type=FanOut.Types.undo_interaction, + identity_id=interaction.post.author_id, + subject_post=interaction.post, + subject_post_interaction=interaction, + ) + else: + raise ValueError("Cannot fan out unknown type") + # And one for themselves if they're local and it's a boost + if ( + interaction.type == PostInteraction.Types.boost + and interaction.identity.local + ): + await FanOut.objects.acreate( + identity_id=interaction.identity_id, + type=FanOut.Types.undo_interaction, + subject_post=interaction.post, + subject_post_interaction=interaction, ) + return cls.undone_fanned_out class PostInteraction(StatorModel): @@ -95,6 +147,35 @@ class PostInteraction(StatorModel): class Meta: index_together = [["type", "identity", "post"]] + ### Display helpers ### + + @classmethod + def get_post_interactions(cls, posts, identity): + """ + Returns a dict of {interaction_type: set(post_ids)} for all the posts + and the given identity, for use in templates. + """ + # Bulk-fetch any interactions + ids_with_interaction_type = cls.objects.filter( + identity=identity, + post_id__in=[post.pk for post in posts], + type__in=[cls.Types.like, cls.Types.boost], + state__in=[PostInteractionStates.new, PostInteractionStates.fanned_out], + ).values_list("post_id", "type") + # Make it into the return dict + result = {} + for post_id, interaction_type in ids_with_interaction_type: + result.setdefault(interaction_type, set()).add(post_id) + return result + + @classmethod + def get_event_interactions(cls, events, identity): + """ + Returns a dict of {interaction_type: set(post_ids)} for all the posts + within the events and the given identity, for use in templates. + """ + return cls.get_post_interactions([e.subject_post for e in events], identity) + ### Async helpers ### async def afetch_full(self): @@ -111,6 +192,9 @@ class PostInteraction(StatorModel): """ Returns the AP JSON for this object """ + # Create an object URI if we don't have one + if self.object_uri is None: + self.object_uri = self.identity.actor_uri + f"#{self.type}/{self.id}" if self.type == self.Types.boost: value = { "type": "Announce", @@ -132,6 +216,18 @@ class PostInteraction(StatorModel): raise ValueError("Cannot turn into AP") return value + def to_undo_ap(self) -> Dict: + """ + Returns the AP JSON to undo this object + """ + object = self.to_ap() + return { + "id": object["id"] + "/undo", + "type": "Undo", + "actor": self.identity.actor_uri, + "object": object, + } + ### ActivityPub (inbound) ### @classmethod diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py index 6dba32c..29dec19 100644 --- a/activities/models/timeline_event.py +++ b/activities/models/timeline_event.py @@ -114,3 +114,20 @@ class TimelineEvent(models.Model): subject_identity_id=interaction.identity_id, subject_post_interaction=interaction, )[0] + + @classmethod + def delete_post_interaction(cls, identity, interaction): + if interaction.type == interaction.Types.like: + cls.objects.filter( + identity=identity, + type=cls.Types.liked, + subject_post_id=interaction.post_id, + subject_identity_id=interaction.identity_id, + ).delete() + elif interaction.type == interaction.Types.boost: + cls.objects.filter( + identity=identity, + type__in=[cls.Types.boosted, cls.Types.boost], + subject_post_id=interaction.post_id, + subject_identity_id=interaction.identity_id, + ).delete() -- cgit v1.2.3