From feb5d9b74fa1e8454eaaf29afae3643c6d7c81f1 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Fri, 11 Nov 2022 22:02:43 -0700 Subject: Got up to incoming posts working --- users/admin.py | 4 +- users/migrations/0001_initial.py | 11 ++- users/models/domain.py | 6 +- users/models/follow.py | 205 ++++++++++++++++++++++++++------------- users/models/identity.py | 22 ++--- users/models/inbox_message.py | 81 +++++++--------- users/views/identity.py | 2 +- 7 files changed, 197 insertions(+), 134 deletions(-) (limited to 'users') diff --git a/users/admin.py b/users/admin.py index d8f2931..a4cea27 100644 --- a/users/admin.py +++ b/users/admin.py @@ -21,16 +21,18 @@ class UserEventAdmin(admin.ModelAdmin): @admin.register(Identity) class IdentityAdmin(admin.ModelAdmin): list_display = ["id", "handle", "actor_uri", "state", "local"] + raw_id_fields = ["users"] @admin.register(Follow) class FollowAdmin(admin.ModelAdmin): list_display = ["id", "source", "target", "state"] + raw_id_fields = ["source", "target"] @admin.register(InboxMessage) class InboxMessageAdmin(admin.ModelAdmin): - list_display = ["id", "state", "message_type"] + list_display = ["id", "state", "state_attempted", "message_type"] actions = ["reset_state"] @admin.action(description="Reset State") diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index 2f64337..c4f4774 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.3 on 2022-11-10 05:58 +# Generated by Django 4.1.3 on 2022-11-11 20:02 import functools @@ -296,11 +296,14 @@ class Migration(migrations.Migration): "state", stator.models.StateField( choices=[ - ("pending", "pending"), - ("requested", "requested"), + ("unrequested", "unrequested"), + ("local_requested", "local_requested"), + ("remote_requested", "remote_requested"), ("accepted", "accepted"), + ("undone_locally", "undone_locally"), + ("undone_remotely", "undone_remotely"), ], - default="pending", + default="unrequested", graph=users.models.follow.FollowStates, max_length=100, ), diff --git a/users/models/domain.py b/users/models/domain.py index a3815ee..d2b17e2 100644 --- a/users/models/domain.py +++ b/users/models/domain.py @@ -49,10 +49,7 @@ class Domain(models.Model): @classmethod def get_remote_domain(cls, domain: str) -> "Domain": - try: - return cls.objects.get(domain=domain, local=False) - except cls.DoesNotExist: - return cls.objects.create(domain=domain, local=False) + return cls.objects.get_or_create(domain=domain, local=False)[0] @classmethod def get_domain(cls, domain: str) -> Optional["Domain"]: @@ -93,3 +90,4 @@ class Domain(models.Model): raise ValueError( f"Service domain {self.service_domain} is already a domain elsewhere!" ) + super().save(*args, **kwargs) diff --git a/users/models/follow.py b/users/models/follow.py index 94ad40f..81ffcd9 100644 --- a/users/models/follow.py +++ b/users/models/follow.py @@ -5,10 +5,11 @@ from django.db import models from core.ld import canonicalise from core.signatures import HttpSignature from stator.models import State, StateField, StateGraph, StatorModel +from users.models.identity import Identity class FollowStates(StateGraph): - unrequested = State(try_interval=30) + unrequested = State(try_interval=300) local_requested = State(try_interval=24 * 60 * 60) remote_requested = State(try_interval=24 * 60 * 60) accepted = State(externally_progressed=True) @@ -24,26 +25,19 @@ class FollowStates(StateGraph): @classmethod async def handle_unrequested(cls, instance: "Follow"): - # Re-retrieve the follow with more things linked - follow = await Follow.objects.select_related( - "source", "source__domain", "target" - ).aget(pk=instance.pk) + """ + Follows that are unrequested need us to deliver the Follow object + to the target server. + """ + follow = await instance.afetch_full() # Remote follows should not be here if not follow.source.local: return cls.remote_requested - # Construct the request - request = canonicalise( - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": follow.uri, - "type": "Follow", - "actor": follow.source.actor_uri, - "object": follow.target.actor_uri, - } - ) # Sign it and send it await HttpSignature.signed_request( - follow.target.inbox_uri, request, follow.source + uri=follow.target.inbox_uri, + body=canonicalise(follow.to_ap()), + identity=follow.source, ) return cls.local_requested @@ -54,56 +48,28 @@ class FollowStates(StateGraph): @classmethod async def handle_remote_requested(cls, instance: "Follow"): - # Re-retrieve the follow with more things linked - follow = await Follow.objects.select_related( - "source", "source__domain", "target" - ).aget(pk=instance.pk) - # Send an accept - request = canonicalise( - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": follow.target.actor_uri + f"follow/{follow.pk}/#accept", - "type": "Follow", - "actor": follow.source.actor_uri, - "object": { - "id": follow.uri, - "type": "Follow", - "actor": follow.source.actor_uri, - "object": follow.target.actor_uri, - }, - } - ) - # Sign it and send it + """ + Items in remote_requested need us to send an Accept object to the + source server. + """ + follow = await instance.afetch_full() await HttpSignature.signed_request( - follow.source.inbox_uri, - request, + uri=follow.source.inbox_uri, + body=canonicalise(follow.to_accept_ap()), identity=follow.target, ) return cls.accepted @classmethod async def handle_undone_locally(cls, instance: "Follow"): - follow = Follow.objects.select_related( - "source", "source__domain", "target" - ).get(pk=instance.pk) - # Construct the request - request = canonicalise( - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": follow.uri + "#undo", - "type": "Undo", - "actor": follow.source.actor_uri, - "object": { - "id": follow.uri, - "type": "Follow", - "actor": follow.source.actor_uri, - "object": follow.target.actor_uri, - }, - } - ) - # Sign it and send it + """ + Delivers the Undo object to the target server + """ + follow = await instance.afetch_full() await HttpSignature.signed_request( - follow.target.inbox_uri, request, follow.source + uri=follow.target.inbox_uri, + body=canonicalise(follow.to_undo_ap()), + identity=follow.source, ) return cls.undone_remotely @@ -135,6 +101,11 @@ class Follow(StatorModel): class Meta: unique_together = [("source", "target")] + def __str__(self): + return f"#{self.id}: {self.source} → {self.target}" + + ### Alternate fetchers/constructors ### + @classmethod def maybe_get(cls, source, target) -> Optional["Follow"]: """ @@ -164,22 +135,122 @@ class Follow(StatorModel): follow.save() return follow + ### Async helpers ### + + async def afetch_full(self): + """ + Returns a version of the object with all relations pre-loaded + """ + return await Follow.objects.select_related( + "source", "source__domain", "target" + ).aget(pk=self.pk) + + ### ActivityPub (outbound) ### + + def to_ap(self): + """ + Returns the AP JSON for this object + """ + return { + "type": "Follow", + "id": self.uri, + "actor": self.source.actor_uri, + "object": self.target.actor_uri, + } + + def to_accept_ap(self): + """ + Returns the AP JSON for this objects' accept. + """ + return { + "type": "Accept", + "id": self.uri + "#accept", + "actor": self.target.actor_uri, + "object": self.to_ap(), + } + + def to_undo_ap(self): + """ + Returns the AP JSON for this objects' undo. + """ + return { + "type": "Undo", + "id": self.uri + "#undo", + "actor": self.source.actor_uri, + "object": self.to_ap(), + } + + ### ActivityPub (inbound) ### + @classmethod - def remote_created(cls, source, target, uri): + def by_ap(cls, data, create=False) -> "Follow": + """ + Retrieves a Follow instance by its ActivityPub JSON object. + + Optionally creates one if it's not present. + Raises KeyError if it's not found and create is False. + """ + # Resolve source and target and see if a Follow exists + source = Identity.by_actor_uri(data["actor"], create=create) + target = Identity.by_actor_uri(data["object"]) follow = cls.maybe_get(source=source, target=target) + # If it doesn't exist, create one in the remote_requested state if follow is None: - follow = Follow.objects.create(source=source, target=target, uri=uri) - if follow.state == FollowStates.unrequested: - follow.transition_perform(FollowStates.remote_requested) + if create: + return cls.objects.create( + source=source, + target=target, + uri=data["id"], + state=FollowStates.remote_requested, + ) + else: + raise KeyError( + f"No follow with source {source} and target {target}", data + ) + else: + return follow @classmethod - def remote_accepted(cls, source, target): - print(f"accepted follow source {source} target {target}") - follow = cls.maybe_get(source=source, target=target) - print(f"accepting follow {follow}") + def handle_request_ap(cls, data): + """ + Handles an incoming follow request + """ + follow = cls.by_ap(data, create=True) + # Force it into remote_requested so we send an accept + follow.transition_perform(FollowStates.remote_requested) + + @classmethod + def handle_accept_ap(cls, data): + """ + Handles an incoming Follow Accept for one of our follows + """ + # Ensure the Accept actor is the Follow's object + if data["actor"] != data["object"]["object"]: + raise ValueError("Accept actor does not match its Follow object", data) + # Resolve source and target and see if a Follow exists (it really should) + try: + follow = cls.by_ap(data["object"]) + except KeyError: + raise ValueError("No Follow locally for incoming Accept", data) + # If the follow was waiting to be accepted, transition it if follow and follow.state in [ FollowStates.unrequested, FollowStates.local_requested, ]: follow.transition_perform(FollowStates.accepted) - print("accepted") + + @classmethod + def handle_undo_ap(cls, data): + """ + Handles an incoming Follow Undo for one of our follows + """ + # Ensure the Undo actor is the Follow's actor + if data["actor"] != data["object"]["actor"]: + raise ValueError("Undo actor does not match its Follow object", data) + # Resolve source and target and see if a Follow exists (it hopefully does) + try: + follow = cls.by_ap(data["object"]) + except KeyError: + raise ValueError("No Follow locally for incoming Undo", data) + # Delete the follow + follow.delete() diff --git a/users/models/identity.py b/users/models/identity.py index 7dff492..4ec0342 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -55,7 +55,11 @@ class Identity(StatorModel): state = StateField(IdentityStates) local = models.BooleanField() - users = models.ManyToManyField("users.User", related_name="identities") + users = models.ManyToManyField( + "users.User", + related_name="identities", + blank=True, + ) username = models.CharField(max_length=500, blank=True, null=True) # Must be a display domain if present @@ -141,18 +145,14 @@ class Identity(StatorModel): return None @classmethod - def by_actor_uri(cls, uri) -> Optional["Identity"]: + def by_actor_uri(cls, uri, create=False) -> "Identity": try: return cls.objects.get(actor_uri=uri) except cls.DoesNotExist: - return None - - @classmethod - def by_actor_uri_with_create(cls, uri) -> "Identity": - try: - return cls.objects.get(actor_uri=uri) - except cls.DoesNotExist: - return cls.objects.create(actor_uri=uri, local=False) + if create: + return cls.objects.create(actor_uri=uri, local=False) + else: + raise KeyError(f"No identity found matching {uri}") ### Dynamic properties ### @@ -236,7 +236,7 @@ class Identity(StatorModel): self.outbox_uri = document.get("outbox") self.summary = document.get("summary") self.username = document.get("preferredUsername") - if "@value" in self.username: + if self.username and "@value" in self.username: self.username = self.username["@value"] self.manually_approves_followers = document.get( "as:manuallyApprovesFollowers" diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py index 54b05e9..ea55b17 100644 --- a/users/models/inbox_message.py +++ b/users/models/inbox_message.py @@ -2,7 +2,6 @@ from asgiref.sync import sync_to_async from django.db import models from stator.models import State, StateField, StateGraph, StatorModel -from users.models import Follow, Identity class InboxMessageStates(StateGraph): @@ -13,23 +12,38 @@ class InboxMessageStates(StateGraph): @classmethod async def handle_received(cls, instance: "InboxMessage"): - type = instance.message_type - if type == "follow": - await instance.follow_request() - elif type == "accept": - inner_type = instance.message["object"]["type"].lower() - if inner_type == "follow": - await instance.follow_accepted() - else: - raise ValueError(f"Cannot handle activity of type accept.{inner_type}") - elif type == "undo": - inner_type = instance.message["object"]["type"].lower() - if inner_type == "follow": - await instance.follow_undo() - else: - raise ValueError(f"Cannot handle activity of type undo.{inner_type}") - else: - raise ValueError(f"Cannot handle activity of type {type}") + from activities.models import Post + from users.models import Follow + + match instance.message_type: + case "follow": + await sync_to_async(Follow.handle_request_ap)(instance.message) + case "create": + match instance.message_object_type: + case "note": + await sync_to_async(Post.handle_create_ap)(instance.message) + case unknown: + raise ValueError( + f"Cannot handle activity of type create.{unknown}" + ) + case "accept": + match instance.message_object_type: + case "follow": + await sync_to_async(Follow.handle_accept_ap)(instance.message) + case unknown: + raise ValueError( + f"Cannot handle activity of type accept.{unknown}" + ) + case "undo": + match instance.message_object_type: + case "follow": + await sync_to_async(Follow.handle_undo_ap)(instance.message) + case unknown: + raise ValueError( + f"Cannot handle activity of type undo.{unknown}" + ) + case unknown: + raise ValueError(f"Cannot handle activity of type {unknown}") return cls.processed @@ -45,35 +59,10 @@ class InboxMessage(StatorModel): state = StateField(InboxMessageStates) - @sync_to_async - def follow_request(self): - """ - Handles an incoming follow request - """ - Follow.remote_created( - source=Identity.by_actor_uri_with_create(self.message["actor"]), - target=Identity.by_actor_uri(self.message["object"]), - uri=self.message["id"], - ) - - @sync_to_async - def follow_accepted(self): - """ - Handles an incoming acceptance of one of our follow requests - """ - target = Identity.by_actor_uri_with_create(self.message["actor"]) - source = Identity.by_actor_uri(self.message["object"]["actor"]) - if source is None: - raise ValueError( - f"Follow-Accept has invalid source {self.message['object']['actor']}" - ) - Follow.remote_accepted(source=source, target=target) - @property def message_type(self): return self.message["type"].lower() - async def follow_undo(self): - """ - Handles an incoming follow undo - """ + @property + def message_object_type(self): + return self.message["object"]["type"].lower() diff --git a/users/views/identity.py b/users/views/identity.py index 0aed7fa..6205145 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -222,7 +222,7 @@ class Inbox(View): # Find the Identity by the actor on the incoming item # This ensures that the signature used for the headers matches the actor # described in the payload. - identity = Identity.by_actor_uri_with_create(document["actor"]) + identity = Identity.by_actor_uri(document["actor"], create=True) if not identity.public_key: # See if we can fetch it right now async_to_sync(identity.fetch_actor)() -- cgit v1.2.3