Handle post edits, follow undos
This commit is contained in:
parent
5b34ea46c3
commit
b13c239213
11
README.md
11
README.md
@ -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
|
||||||
|
18
activities/migrations/0007_post_edited.py
Normal file
18
activities/migrations/0007_post_edited.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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
|
||||||
"""
|
"""
|
||||||
|
@ -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()
|
||||||
|
@ -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":
|
||||||
|
31
users/tests/test_activitypub.py
Normal file
31
users/tests/test_activitypub.py
Normal 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/"
|
@ -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",
|
||||||
|
@ -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)
|
||||||
|
Reference in New Issue
Block a user