From b13c239213147b7acae4060aff35640d625b5169 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Wed, 16 Nov 2022 22:23:32 -0700 Subject: [PATCH] Handle post edits, follow undos --- README.md | 11 +++++--- activities/migrations/0007_post_edited.py | 18 +++++++++++++ activities/models/post.py | 22 ++++++++++++++-- users/models/follow.py | 8 +++--- users/models/identity.py | 22 +++++++++++++--- users/models/inbox_message.py | 12 ++++++++- users/tests/test_activitypub.py | 31 +++++++++++++++++++++++ users/views/activitypub.py | 4 +-- users/views/identity.py | 24 +++++++++++++++--- 9 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 activities/migrations/0007_post_edited.py create mode 100644 users/tests/test_activitypub.py 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 @@ -312,6 +318,18 @@ class Post(StatorModel): # Force it into fanned_out as it's not ours 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): """ 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)