summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-16 22:23:32 -0700
committerAndrew Godwin2022-11-16 22:23:32 -0700
commitb13c239213147b7acae4060aff35640d625b5169 (patch)
tree16c76dc20b3cc28403371c4b7817f636e22b13c1
parent5b34ea46c3f458a174c5443714ade43c21defdac (diff)
downloadtakahe-b13c239213147b7acae4060aff35640d625b5169.tar.gz
takahe-b13c239213147b7acae4060aff35640d625b5169.tar.bz2
takahe-b13c239213147b7acae4060aff35640d625b5169.zip
Handle post edits, follow undos
-rw-r--r--README.md11
-rw-r--r--activities/migrations/0007_post_edited.py18
-rw-r--r--activities/models/post.py22
-rw-r--r--users/models/follow.py8
-rw-r--r--users/models/identity.py22
-rw-r--r--users/models/inbox_message.py12
-rw-r--r--users/tests/test_activitypub.py31
-rw-r--r--users/views/activitypub.py4
-rw-r--r--users/views/identity.py24
9 files changed, 132 insertions, 20 deletions
diff --git a/README.md b/README.md
index 5ee9c32..5b0b0b9 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ the less sure I am about it.
- [x] Receive posts
- [x] Handle received post visibility (unlisted vs public only)
- [x] Receive post deletions
-- [ ] Receive post edits
+- [x] Receive post edits
- [x] Set content warnings on posts
- [x] Show content warnings on posts
- [ ] Receive images on posts
@@ -49,10 +49,10 @@ the less sure I am about it.
- [x] Create likes
- [x] Receive likes
- [x] Create follows
-- [ ] Undo follows
+- [x] Undo follows
- [x] Receive and accept follows
- [x] Receive follow undos
-- [ ] Do mentions properly
+- [ ] Do outgoing mentions properly
- [x] Home timeline (posts and boosts from follows)
- [ ] Notifications page (followed, boosted, liked)
- [x] Local timeline
@@ -66,7 +66,7 @@ the less sure I am about it.
- [x] Serverless-friendly worker subsystem
- [x] Settings subsystem
- [x] Server management page
-- [ ] Domain management page
+- [x] Domain management page
- [ ] Email subsystem
- [ ] Signup flow
- [ ] Password change flow
@@ -75,7 +75,10 @@ the less sure I am about it.
### Beta
- [ ] Attach images to posts
+- [ ] Edit posts
- [ ] Delete posts
+- [ ] Show follow pending states
+- [ ] Manual approval of followers
- [ ] Reply threading on post creation
- [ ] Display posts with reply threads
- [ ] Create polls on posts
diff --git a/activities/migrations/0007_post_edited.py b/activities/migrations/0007_post_edited.py
new file mode 100644
index 0000000..d4a661f
--- /dev/null
+++ b/activities/migrations/0007_post_edited.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.3 on 2022-11-17 04:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("activities", "0006_alter_post_hashtags"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="post",
+ name="edited",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/activities/models/post.py b/activities/models/post.py
index 3847b63..473755b 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -28,7 +28,7 @@ class PostStates(StateGraph):
post = await instance.afetch_full()
# Non-local posts should not be here
if not post.local:
- raise ValueError("Trying to run handle_new on a non-local post!")
+ raise ValueError(f"Trying to run handle_new on a non-local post {post.pk}!")
# Build list of targets - mentions always included
targets = set()
async for mention in post.mentions.all():
@@ -122,6 +122,9 @@ class Post(StatorModel):
# When the post was originally created (as opposed to when we received it)
published = models.DateTimeField(default=timezone.now)
+ # If the post has been edited after initial publication
+ edited = models.DateTimeField(blank=True, null=True)
+
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@@ -245,7 +248,7 @@ class Post(StatorModel):
post.sensitive = data.get("as:sensitive", False)
post.url = data.get("url", None)
post.published = parse_ld_date(data.get("published", None))
- # TODO: to
+ post.edited = parse_ld_date(data.get("updated", None))
# Mentions and hashtags
post.hashtags = []
for tag in get_list(data, "tag"):
@@ -254,6 +257,9 @@ class Post(StatorModel):
post.mentions.add(mention_identity)
elif tag["type"].lower() == "as:hashtag":
post.hashtags.append(tag["name"].lstrip("#"))
+ elif tag["type"].lower() == "http://joinmastodon.org/ns#emoji":
+ # TODO: Handle incoming emoji
+ pass
else:
raise ValueError(f"Unknown tag type {tag['type']}")
# Visibility and to
@@ -313,6 +319,18 @@ class Post(StatorModel):
post.transition_perform(PostStates.fanned_out)
@classmethod
+ def handle_update_ap(cls, data):
+ """
+ Handles an incoming update request
+ """
+ with transaction.atomic():
+ # Ensure the Create actor is the Post's attributedTo
+ if data["actor"] != data["object"]["attributedTo"]:
+ raise ValueError("Create actor does not match its Post object", data)
+ # Find it and update it
+ cls.by_ap(data["object"], create=False, update=True)
+
+ @classmethod
def handle_delete_ap(cls, data):
"""
Handles an incoming create request
diff --git a/users/models/follow.py b/users/models/follow.py
index 0236d19..defe399 100644
--- a/users/models/follow.py
+++ b/users/models/follow.py
@@ -13,15 +13,15 @@ class FollowStates(StateGraph):
local_requested = State(try_interval=24 * 60 * 60)
remote_requested = State(try_interval=24 * 60 * 60)
accepted = State(externally_progressed=True)
- undone_locally = State(try_interval=60 * 60)
+ undone = State(try_interval=60 * 60)
undone_remotely = State()
unrequested.transitions_to(local_requested)
unrequested.transitions_to(remote_requested)
local_requested.transitions_to(accepted)
remote_requested.transitions_to(accepted)
- accepted.transitions_to(undone_locally)
- undone_locally.transitions_to(undone_remotely)
+ accepted.transitions_to(undone)
+ undone.transitions_to(undone_remotely)
@classmethod
async def handle_unrequested(cls, instance: "Follow"):
@@ -63,7 +63,7 @@ class FollowStates(StateGraph):
return cls.accepted
@classmethod
- async def handle_undone_locally(cls, instance: "Follow"):
+ async def handle_undone(cls, instance: "Follow"):
"""
Delivers the Undo object to the target server
"""
diff --git a/users/models/identity.py b/users/models/identity.py
index d97f5f0..ba8559b 100644
--- a/users/models/identity.py
+++ b/users/models/identity.py
@@ -162,7 +162,7 @@ class Identity(StatorModel):
if create:
return cls.objects.create(actor_uri=uri, local=False)
else:
- raise KeyError(f"No identity found matching {uri}")
+ raise cls.DoesNotExist(f"No identity found with actor_uri {uri}")
### Dynamic properties ###
@@ -192,7 +192,7 @@ class Identity(StatorModel):
# TODO: Setting
return self.data_age > 60 * 24 * 24
- ### ActivityPub (boutbound) ###
+ ### ActivityPub (outbound) ###
def to_ap(self):
response = {
@@ -206,7 +206,7 @@ class Identity(StatorModel):
"publicKeyPem": self.public_key,
},
"published": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
- "url": self.urls.view_nice,
+ "url": str(self.urls.view_nice),
}
if self.name:
response["name"] = self.name
@@ -214,6 +214,21 @@ class Identity(StatorModel):
response["summary"] = self.summary
return response
+ ### ActivityPub (inbound) ###
+
+ @classmethod
+ def handle_update_ap(cls, data):
+ """
+ Takes an incoming update.person message and just forces us to add it
+ to our fetch queue (don't want to bother with two load paths right now)
+ """
+ # Find by actor
+ try:
+ actor = cls.by_actor_uri(data["actor"])
+ actor.transition_perform(IdentityStates.outdated)
+ except cls.DoesNotExist:
+ pass
+
### Actor/Webfinger fetching ###
@classmethod
@@ -314,4 +329,5 @@ class Identity(StatorModel):
)
.decode("ascii")
)
+ self.public_key_id = self.actor_uri + "#main-key"
self.save()
diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py
index 55eb3cb..ee23ae6 100644
--- a/users/models/inbox_message.py
+++ b/users/models/inbox_message.py
@@ -13,7 +13,7 @@ class InboxMessageStates(StateGraph):
@classmethod
async def handle_received(cls, instance: "InboxMessage"):
from activities.models import Post, PostInteraction
- from users.models import Follow
+ from users.models import Follow, Identity
match instance.message_type:
case "follow":
@@ -30,6 +30,16 @@ class InboxMessageStates(StateGraph):
raise ValueError(
f"Cannot handle activity of type create.{unknown}"
)
+ case "update":
+ match instance.message_object_type:
+ case "note":
+ await sync_to_async(Post.handle_update_ap)(instance.message)
+ case "person":
+ await sync_to_async(Identity.handle_update_ap)(instance.message)
+ case unknown:
+ raise ValueError(
+ f"Cannot handle activity of type update.{unknown}"
+ )
case "accept":
match instance.message_object_type:
case "follow":
diff --git a/users/tests/test_activitypub.py b/users/tests/test_activitypub.py
new file mode 100644
index 0000000..5df46a4
--- /dev/null
+++ b/users/tests/test_activitypub.py
@@ -0,0 +1,31 @@
+import pytest
+
+from users.models import Domain, Identity, User
+
+
+@pytest.mark.django_db
+def test_webfinger_actor(client):
+ """
+ Ensures the webfinger and actor URLs are working properly
+ """
+ # Make a user
+ user = User.objects.create(email="test@example.com")
+ # Make a domain
+ domain = Domain.objects.create(domain="example.com", local=True)
+ domain.users.add(user)
+ # Make an identity for them
+ identity = Identity.objects.create(
+ actor_uri="https://example.com/@test@example.com/actor/",
+ username="test",
+ domain=domain,
+ name="Test User",
+ local=True,
+ )
+ identity.generate_keypair()
+ # Fetch their webfinger
+ data = client.get("/.well-known/webfinger?resource=acct:test@example.com").json()
+ assert data["subject"] == "acct:test@example.com"
+ assert data["aliases"][0] == "https://example.com/@test/"
+ # Fetch their actor
+ data = client.get("/@test@example.com/actor/").json()
+ assert data["id"] == "https://example.com/@test@example.com/actor/"
diff --git a/users/views/activitypub.py b/users/views/activitypub.py
index f1abb06..4660d7a 100644
--- a/users/views/activitypub.py
+++ b/users/views/activitypub.py
@@ -52,13 +52,13 @@ class Webfinger(View):
{
"subject": f"acct:{identity.handle}",
"aliases": [
- identity.view_url,
+ str(identity.urls.view_nice),
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
- "href": identity.view_url,
+ "href": str(identity.urls.view_nice),
},
{
"rel": "self",
diff --git a/users/views/identity.py b/users/views/identity.py
index 4b92e14..b9298b7 100644
--- a/users/views/identity.py
+++ b/users/views/identity.py
@@ -9,7 +9,7 @@ from django.views.generic import FormView, TemplateView, View
from core.models import Config
from users.decorators import identity_required
-from users.models import Domain, Follow, Identity, IdentityStates
+from users.models import Domain, Follow, FollowStates, Identity, IdentityStates
from users.shortcuts import by_handle_or_404
@@ -27,12 +27,19 @@ class ViewIdentity(TemplateView):
posts = identity.posts.all()[:100]
if identity.data_age > Config.system.identity_max_age:
identity.transition_perform(IdentityStates.outdated)
+ follow = None
+ if self.request.identity:
+ follow = Follow.maybe_get(self.request.identity, identity)
+ if follow and follow.state not in [
+ FollowStates.unrequested,
+ FollowStates.local_requested,
+ FollowStates.accepted,
+ ]:
+ follow = None
return {
"identity": identity,
"posts": posts,
- "follow": Follow.maybe_get(self.request.identity, identity)
- if self.request.identity
- else None,
+ "follow": follow,
}
@@ -46,6 +53,15 @@ class ActionIdentity(View):
existing_follow = Follow.maybe_get(self.request.identity, identity)
if not existing_follow:
Follow.create_local(self.request.identity, identity)
+ elif existing_follow.state in [
+ FollowStates.undone,
+ FollowStates.undone_remotely,
+ ]:
+ existing_follow.transition_perform(FollowStates.unrequested)
+ elif action == "unfollow":
+ existing_follow = Follow.maybe_get(self.request.identity, identity)
+ if existing_follow:
+ existing_follow.transition_perform(FollowStates.undone)
else:
raise ValueError(f"Cannot handle identity action {action}")
return redirect(identity.urls.view)