Handle post edits, follow undos

This commit is contained in:
Andrew Godwin 2022-11-16 22:23:32 -07:00
parent 5b34ea46c3
commit b13c239213
9 changed files with 132 additions and 20 deletions

View File

@ -40,7 +40,7 @@ the less sure I am about it.
- [x] Receive posts - [x] Receive posts
- [x] Handle received post visibility (unlisted vs public only) - [x] Handle received post visibility (unlisted vs public only)
- [x] Receive post deletions - [x] Receive post deletions
- [ ] Receive post edits - [x] Receive post edits
- [x] Set content warnings on posts - [x] Set content warnings on posts
- [x] Show content warnings on posts - [x] Show content warnings on posts
- [ ] Receive images on posts - [ ] Receive images on posts
@ -49,10 +49,10 @@ the less sure I am about it.
- [x] Create likes - [x] Create likes
- [x] Receive likes - [x] Receive likes
- [x] Create follows - [x] Create follows
- [ ] Undo follows - [x] Undo follows
- [x] Receive and accept follows - [x] Receive and accept follows
- [x] Receive follow undos - [x] Receive follow undos
- [ ] Do mentions properly - [ ] Do outgoing mentions properly
- [x] Home timeline (posts and boosts from follows) - [x] Home timeline (posts and boosts from follows)
- [ ] Notifications page (followed, boosted, liked) - [ ] Notifications page (followed, boosted, liked)
- [x] Local timeline - [x] Local timeline
@ -66,7 +66,7 @@ the less sure I am about it.
- [x] Serverless-friendly worker subsystem - [x] Serverless-friendly worker subsystem
- [x] Settings subsystem - [x] Settings subsystem
- [x] Server management page - [x] Server management page
- [ ] Domain management page - [x] Domain management page
- [ ] Email subsystem - [ ] Email subsystem
- [ ] Signup flow - [ ] Signup flow
- [ ] Password change flow - [ ] Password change flow
@ -75,7 +75,10 @@ the less sure I am about it.
### Beta ### Beta
- [ ] Attach images to posts - [ ] Attach images to posts
- [ ] Edit posts
- [ ] Delete posts - [ ] Delete posts
- [ ] Show follow pending states
- [ ] Manual approval of followers
- [ ] Reply threading on post creation - [ ] Reply threading on post creation
- [ ] Display posts with reply threads - [ ] Display posts with reply threads
- [ ] Create polls on posts - [ ] Create polls on posts

View File

@ -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),
),
]

View File

@ -28,7 +28,7 @@ class PostStates(StateGraph):
post = await instance.afetch_full() post = await instance.afetch_full()
# Non-local posts should not be here # Non-local posts should not be here
if not post.local: 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 # Build list of targets - mentions always included
targets = set() targets = set()
async for mention in post.mentions.all(): 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) # When the post was originally created (as opposed to when we received it)
published = models.DateTimeField(default=timezone.now) 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) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
@ -245,7 +248,7 @@ class Post(StatorModel):
post.sensitive = data.get("as:sensitive", False) post.sensitive = data.get("as:sensitive", False)
post.url = data.get("url", None) post.url = data.get("url", None)
post.published = parse_ld_date(data.get("published", None)) post.published = parse_ld_date(data.get("published", None))
# TODO: to post.edited = parse_ld_date(data.get("updated", None))
# Mentions and hashtags # Mentions and hashtags
post.hashtags = [] post.hashtags = []
for tag in get_list(data, "tag"): for tag in get_list(data, "tag"):
@ -254,6 +257,9 @@ class Post(StatorModel):
post.mentions.add(mention_identity) post.mentions.add(mention_identity)
elif tag["type"].lower() == "as:hashtag": elif tag["type"].lower() == "as:hashtag":
post.hashtags.append(tag["name"].lstrip("#")) post.hashtags.append(tag["name"].lstrip("#"))
elif tag["type"].lower() == "http://joinmastodon.org/ns#emoji":
# TODO: Handle incoming emoji
pass
else: else:
raise ValueError(f"Unknown tag type {tag['type']}") raise ValueError(f"Unknown tag type {tag['type']}")
# Visibility and to # Visibility and to
@ -312,6 +318,18 @@ class Post(StatorModel):
# Force it into fanned_out as it's not ours # Force it into fanned_out as it's not ours
post.transition_perform(PostStates.fanned_out) 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 @classmethod
def handle_delete_ap(cls, data): def handle_delete_ap(cls, data):
""" """

View File

@ -13,15 +13,15 @@ class FollowStates(StateGraph):
local_requested = State(try_interval=24 * 60 * 60) local_requested = State(try_interval=24 * 60 * 60)
remote_requested = State(try_interval=24 * 60 * 60) remote_requested = State(try_interval=24 * 60 * 60)
accepted = State(externally_progressed=True) accepted = State(externally_progressed=True)
undone_locally = State(try_interval=60 * 60) undone = State(try_interval=60 * 60)
undone_remotely = State() undone_remotely = State()
unrequested.transitions_to(local_requested) unrequested.transitions_to(local_requested)
unrequested.transitions_to(remote_requested) unrequested.transitions_to(remote_requested)
local_requested.transitions_to(accepted) local_requested.transitions_to(accepted)
remote_requested.transitions_to(accepted) remote_requested.transitions_to(accepted)
accepted.transitions_to(undone_locally) accepted.transitions_to(undone)
undone_locally.transitions_to(undone_remotely) undone.transitions_to(undone_remotely)
@classmethod @classmethod
async def handle_unrequested(cls, instance: "Follow"): async def handle_unrequested(cls, instance: "Follow"):
@ -63,7 +63,7 @@ class FollowStates(StateGraph):
return cls.accepted return cls.accepted
@classmethod @classmethod
async def handle_undone_locally(cls, instance: "Follow"): async def handle_undone(cls, instance: "Follow"):
""" """
Delivers the Undo object to the target server Delivers the Undo object to the target server
""" """

View File

@ -162,7 +162,7 @@ class Identity(StatorModel):
if create: if create:
return cls.objects.create(actor_uri=uri, local=False) return cls.objects.create(actor_uri=uri, local=False)
else: else:
raise KeyError(f"No identity found matching {uri}") raise cls.DoesNotExist(f"No identity found with actor_uri {uri}")
### Dynamic properties ### ### Dynamic properties ###
@ -192,7 +192,7 @@ class Identity(StatorModel):
# TODO: Setting # TODO: Setting
return self.data_age > 60 * 24 * 24 return self.data_age > 60 * 24 * 24
### ActivityPub (boutbound) ### ### ActivityPub (outbound) ###
def to_ap(self): def to_ap(self):
response = { response = {
@ -206,7 +206,7 @@ class Identity(StatorModel):
"publicKeyPem": self.public_key, "publicKeyPem": self.public_key,
}, },
"published": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"), "published": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
"url": self.urls.view_nice, "url": str(self.urls.view_nice),
} }
if self.name: if self.name:
response["name"] = self.name response["name"] = self.name
@ -214,6 +214,21 @@ class Identity(StatorModel):
response["summary"] = self.summary response["summary"] = self.summary
return response 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 ### ### Actor/Webfinger fetching ###
@classmethod @classmethod
@ -314,4 +329,5 @@ class Identity(StatorModel):
) )
.decode("ascii") .decode("ascii")
) )
self.public_key_id = self.actor_uri + "#main-key"
self.save() self.save()

View File

@ -13,7 +13,7 @@ class InboxMessageStates(StateGraph):
@classmethod @classmethod
async def handle_received(cls, instance: "InboxMessage"): async def handle_received(cls, instance: "InboxMessage"):
from activities.models import Post, PostInteraction from activities.models import Post, PostInteraction
from users.models import Follow from users.models import Follow, Identity
match instance.message_type: match instance.message_type:
case "follow": case "follow":
@ -30,6 +30,16 @@ class InboxMessageStates(StateGraph):
raise ValueError( raise ValueError(
f"Cannot handle activity of type create.{unknown}" 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": case "accept":
match instance.message_object_type: match instance.message_object_type:
case "follow": case "follow":

View File

@ -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/"

View File

@ -52,13 +52,13 @@ class Webfinger(View):
{ {
"subject": f"acct:{identity.handle}", "subject": f"acct:{identity.handle}",
"aliases": [ "aliases": [
identity.view_url, str(identity.urls.view_nice),
], ],
"links": [ "links": [
{ {
"rel": "http://webfinger.net/rel/profile-page", "rel": "http://webfinger.net/rel/profile-page",
"type": "text/html", "type": "text/html",
"href": identity.view_url, "href": str(identity.urls.view_nice),
}, },
{ {
"rel": "self", "rel": "self",

View File

@ -9,7 +9,7 @@ from django.views.generic import FormView, TemplateView, View
from core.models import Config from core.models import Config
from users.decorators import identity_required 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 from users.shortcuts import by_handle_or_404
@ -27,12 +27,19 @@ class ViewIdentity(TemplateView):
posts = identity.posts.all()[:100] posts = identity.posts.all()[:100]
if identity.data_age > Config.system.identity_max_age: if identity.data_age > Config.system.identity_max_age:
identity.transition_perform(IdentityStates.outdated) 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 { return {
"identity": identity, "identity": identity,
"posts": posts, "posts": posts,
"follow": Follow.maybe_get(self.request.identity, identity) "follow": follow,
if self.request.identity
else None,
} }
@ -46,6 +53,15 @@ class ActionIdentity(View):
existing_follow = Follow.maybe_get(self.request.identity, identity) existing_follow = Follow.maybe_get(self.request.identity, identity)
if not existing_follow: if not existing_follow:
Follow.create_local(self.request.identity, identity) 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: else:
raise ValueError(f"Cannot handle identity action {action}") raise ValueError(f"Cannot handle identity action {action}")
return redirect(identity.urls.view) return redirect(identity.urls.view)