summaryrefslogtreecommitdiffstats
path: root/users/models
diff options
context:
space:
mode:
Diffstat (limited to 'users/models')
-rw-r--r--users/models/domain.py6
-rw-r--r--users/models/follow.py205
-rw-r--r--users/models/identity.py22
-rw-r--r--users/models/inbox_message.py81
4 files changed, 186 insertions, 128 deletions
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()