summaryrefslogtreecommitdiffstats
path: root/users
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-11 22:02:43 -0700
committerAndrew Godwin2022-11-11 22:02:43 -0700
commitfeb5d9b74fa1e8454eaaf29afae3643c6d7c81f1 (patch)
tree3889a826dfc2c852aa4873daff2a27cb7c1a2b01 /users
parentfbfad9fbf5e061cb7c658dada3c4014c9796021c (diff)
downloadtakahe-feb5d9b74fa1e8454eaaf29afae3643c6d7c81f1.tar.gz
takahe-feb5d9b74fa1e8454eaaf29afae3643c6d7c81f1.tar.bz2
takahe-feb5d9b74fa1e8454eaaf29afae3643c6d7c81f1.zip
Got up to incoming posts working
Diffstat (limited to 'users')
-rw-r--r--users/admin.py4
-rw-r--r--users/migrations/0001_initial.py11
-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
-rw-r--r--users/views/identity.py2
7 files changed, 197 insertions, 134 deletions
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)()