Support follow requests (#625)

This commit is contained in:
Henri Dickson 2023-08-18 02:19:45 -04:00 committed by GitHub
parent faa181807c
commit 70b9e3b900
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 283 additions and 99 deletions

View file

@ -324,6 +324,7 @@ class Migration(migrations.Migration):
("mentioned", "Mentioned"),
("liked", "Liked"),
("followed", "Followed"),
("follow_requested", "Follow Requested"),
("boosted", "Boosted"),
("announcement", "Announcement"),
("identity_created", "Identity Created"),

View file

@ -16,6 +16,7 @@ class TimelineEvent(models.Model):
mentioned = "mentioned"
liked = "liked" # Someone liking one of our posts
followed = "followed"
follow_requested = "follow_requested"
boosted = "boosted" # Someone boosting one of our posts
announcement = "announcement" # Server announcement
identity_created = "identity_created" # New identity created
@ -74,14 +75,30 @@ class TimelineEvent(models.Model):
@classmethod
def add_follow(cls, identity, source_identity):
"""
Adds a follow to the timeline if it's not there already
Adds a follow to the timeline if it's not there already, remove follow request if any
"""
cls.objects.filter(
type=cls.Types.follow_requested,
identity=identity,
subject_identity=source_identity,
).delete()
return cls.objects.get_or_create(
identity=identity,
type=cls.Types.followed,
subject_identity=source_identity,
)[0]
@classmethod
def add_follow_request(cls, identity, source_identity):
"""
Adds a follow request to the timeline if it's not there already
"""
return cls.objects.get_or_create(
identity=identity,
type=cls.Types.follow_requested,
subject_identity=source_identity,
)[0]
@classmethod
def add_post(cls, identity, post):
"""
@ -169,6 +186,14 @@ class TimelineEvent(models.Model):
subject_identity_id=interaction.identity_id,
).delete()
@classmethod
def delete_follow(cls, target, source):
TimelineEvent.objects.filter(
type__in=[cls.Types.followed, cls.Types.follow_requested],
identity=target,
subject_identity=source,
).delete()
### Background tasks ###
@classmethod
@ -218,6 +243,8 @@ class TimelineEvent(models.Model):
)
elif self.type == self.Types.followed:
result["type"] = "follow"
elif self.type == self.Types.follow_requested:
result["type"] = "follow_request"
elif self.type == self.Types.identity_created:
result["type"] = "admin.sign_up"
else:

View file

@ -58,6 +58,8 @@ urlpatterns = [
path("v1/filters", filters.list_filters),
# Follow requests
path("v1/follow_requests", follow_requests.follow_requests),
path("v1/follow_requests/<id>/authorize", follow_requests.accept_follow_request),
path("v1/follow_requests/<id>/reject", follow_requests.reject_follow_request),
# Instance
path("v1/instance", instance.instance_info_v1),
path("v1/instance/activity", instance.activity),

View file

@ -29,6 +29,7 @@ def update_credentials(
display_name: QueryOrBody[str | None] = None,
note: QueryOrBody[str | None] = None,
discoverable: QueryOrBody[bool | None] = None,
locked: QueryOrBody[bool | None] = None,
source: QueryOrBody[dict[str, Any] | None] = None,
fields_attributes: QueryOrBody[dict[str, dict[str, str]] | None] = None,
avatar: File | None = None,
@ -42,6 +43,8 @@ def update_credentials(
service.set_summary(note)
if discoverable is not None:
identity.discoverable = discoverable
if locked is not None:
identity.manually_approves_followers = locked
if source:
if "privacy" in source:
privacy_map = {

View file

@ -1,8 +1,12 @@
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from hatchway import api_view
from api import schemas
from api.decorators import scope_required
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
from users.models.identity import Identity
from users.services.identity import IdentityService
@scope_required("read:follows")
@ -14,5 +18,43 @@ def follow_requests(
min_id: str | None = None,
limit: int = 40,
) -> list[schemas.Account]:
# We don't implement this yet
return []
service = IdentityService(request.identity)
paginator = MastodonPaginator(max_limit=80)
pager: PaginationResult[Identity] = paginator.paginate(
service.follow_requests(),
min_id=min_id,
max_id=max_id,
since_id=since_id,
limit=limit,
)
return PaginatingApiResponse(
[schemas.Account.from_identity(i) for i in pager.results],
request=request,
include_params=["limit"],
)
@scope_required("write:follows")
@api_view.post
def accept_follow_request(
request: HttpRequest,
id: str | None = None,
) -> schemas.Relationship:
source_identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
IdentityService(request.identity).accept_follow_request(source_identity)
return IdentityService(source_identity).mastodon_json_relationship(request.identity)
@scope_required("write:follows")
@api_view.post
def reject_follow_request(
request: HttpRequest,
id: str | None = None,
) -> schemas.Relationship:
source_identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
IdentityService(request.identity).reject_follow_request(source_identity)
return IdentityService(source_identity).mastodon_json_relationship(request.identity)

View file

@ -251,7 +251,8 @@ def test_clear_timeline(
else:
service.unfollow(remote_identity)
# Run stator once to process the timeline clear message
# Run stator twice to process the timeline clear message
stator.run_single_cycle()
stator.run_single_cycle()
# Verify that the right things vanished

View file

@ -33,7 +33,7 @@ def test_follow(
assert outbound_data["actor"] == identity.actor_uri
assert outbound_data["object"] == remote_identity.actor_uri
assert outbound_data["id"] == f"{identity.actor_uri}follow/{follow.pk}/"
assert Follow.objects.get(pk=follow.pk).state == FollowStates.local_requested
assert Follow.objects.get(pk=follow.pk).state == FollowStates.pending_approval
# Come in with an inbox message of either a reference type or an embedded type
if ref_only:
message = {
@ -53,4 +53,5 @@ def test_follow(
InboxMessage.objects.create(message=message)
# Run stator and ensure that accepted our follow
stator.run_single_cycle()
stator.run_single_cycle()
assert Follow.objects.get(pk=follow.pk).state == FollowStates.accepted

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.3 on 2023-08-04 01:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("users", "0021_identity_aliases"),
]
operations = [
migrations.RunSQL(
"UPDATE users_follow SET state = 'pending_approval' WHERE state = 'local_requested';"
),
migrations.RunSQL(
"UPDATE users_follow SET state = 'accepting' WHERE state = 'remote_requested';"
),
migrations.RunSQL(
"DELETE FROM users_follow WHERE state not in ('accepted', 'accepting', 'pending_approval', 'unrequested');"
),
]

View file

@ -7,43 +7,65 @@ from core.exceptions import capture_message
from core.ld import canonicalise, get_str_or_id
from core.snowflake import Snowflake
from stator.models import State, StateField, StateGraph, StatorModel
from users.models.block import Block
from users.models.identity import Identity
from users.models.inbox_message import InboxMessage
class FollowStates(StateGraph):
unrequested = State(try_interval=600)
local_requested = State(try_interval=24 * 60 * 60)
remote_requested = State(try_interval=24 * 60 * 60)
pending_approval = State(externally_progressed=True)
accepting = State(try_interval=24 * 60 * 60)
rejecting = State(try_interval=24 * 60 * 60)
accepted = State(externally_progressed=True)
undone = State(try_interval=60 * 60)
undone_remotely = State(delete_after=24 * 60 * 60)
failed = State()
rejected = State()
undone = State(try_interval=24 * 60 * 60)
pending_removal = State(try_interval=60 * 60)
removed = State(delete_after=1)
unrequested.transitions_to(local_requested)
unrequested.transitions_to(remote_requested)
unrequested.times_out_to(failed, seconds=86400 * 7)
local_requested.transitions_to(accepted)
local_requested.transitions_to(rejected)
remote_requested.transitions_to(accepted)
unrequested.transitions_to(pending_approval)
unrequested.transitions_to(accepting)
unrequested.transitions_to(rejecting)
unrequested.times_out_to(removed, seconds=24 * 60 * 60)
pending_approval.transitions_to(accepting)
pending_approval.transitions_to(rejecting)
pending_approval.transitions_to(pending_removal)
accepting.transitions_to(accepted)
accepting.times_out_to(accepted, seconds=7 * 24 * 60 * 60)
rejecting.transitions_to(pending_removal)
rejecting.times_out_to(pending_removal, seconds=24 * 60 * 60)
accepted.transitions_to(rejecting)
accepted.transitions_to(undone)
undone.transitions_to(undone_remotely)
undone.transitions_to(pending_removal)
pending_removal.transitions_to(removed)
@classmethod
def group_active(cls):
return [cls.unrequested, cls.local_requested, cls.accepted]
"""
Follows that are active means they are being handled and no need to re-request
"""
return [cls.unrequested, cls.pending_approval, cls.accepting, cls.accepted]
@classmethod
def group_accepted(cls):
"""
Follows that are accepting/accepted means they should be consider accepted when deliver to followers
"""
return [cls.accepting, cls.accepted]
@classmethod
def handle_unrequested(cls, instance: "Follow"):
"""
Follows that are unrequested need us to deliver the Follow object
to the target server.
Follows start unrequested as their initial state regardless of local/remote
"""
# Remote follows should not be here
if Block.maybe_get(
source=instance.target, target=instance.source, require_active=True
):
return cls.rejecting
if not instance.target.local:
if not instance.source.local:
return cls.remote_requested
if instance.target.local:
return cls.accepted
# remote follow remote, invalid case
return cls.removed
# local follow remote, send Follow to target server
# Don't try if the other identity didn't fetch yet
if not instance.target.inbox_uri:
return
@ -56,19 +78,19 @@ class FollowStates(StateGraph):
)
except httpx.RequestError:
return
return cls.local_requested
return cls.pending_approval
# local/remote follow local, check manually_approve
if instance.target.manually_approves_followers:
from activities.models import TimelineEvent
TimelineEvent.add_follow_request(instance.target, instance.source)
return cls.pending_approval
return cls.accepting
@classmethod
def handle_local_requested(cls, instance: "Follow"):
# TODO: Resend follow requests occasionally
pass
@classmethod
def handle_remote_requested(cls, instance: "Follow"):
"""
Items in remote_requested need us to send an Accept object to the
source server.
"""
def handle_accepting(cls, instance: "Follow"):
if not instance.source.local:
# send an Accept object to the source server
try:
instance.target.signed_request(
method="post",
@ -77,14 +99,32 @@ class FollowStates(StateGraph):
)
except httpx.RequestError:
return
from activities.models import TimelineEvent
TimelineEvent.add_follow(instance.target, instance.source)
return cls.accepted
@classmethod
def handle_rejecting(cls, instance: "Follow"):
if not instance.source.local:
# send a Reject object to the source server
try:
instance.target.signed_request(
method="post",
uri=instance.source.inbox_uri,
body=canonicalise(instance.to_reject_ap()),
)
except httpx.RequestError:
return
return cls.pending_removal
@classmethod
def handle_undone(cls, instance: "Follow"):
"""
Delivers the Undo object to the target server
"""
try:
if not instance.target.local:
instance.source.signed_request(
method="post",
uri=instance.target.inbox_uri,
@ -92,7 +132,15 @@ class FollowStates(StateGraph):
)
except httpx.RequestError:
return
return cls.undone_remotely
return cls.pending_removal
@classmethod
def handle_pending_removal(cls, instance: "Follow"):
if instance.target.local:
from activities.models import TimelineEvent
TimelineEvent.delete_follow(instance.target, instance.source)
return cls.removed
class FollowQuerySet(models.QuerySet):
@ -100,6 +148,10 @@ class FollowQuerySet(models.QuerySet):
query = self.filter(state__in=FollowStates.group_active())
return query
def accepted(self):
query = self.filter(state__in=FollowStates.group_accepted())
return query
class FollowManager(models.Manager):
def get_queryset(self):
@ -108,6 +160,9 @@ class FollowManager(models.Manager):
def active(self):
return self.get_queryset().active()
def accepted(self):
return self.get_queryset().accepted()
class Follow(StatorModel):
"""
@ -169,16 +224,13 @@ class Follow(StatorModel):
Creates a Follow from a local Identity to the target
(which can be local or remote).
"""
from activities.models import TimelineEvent
if not source.local:
raise ValueError("You cannot initiate follows from a remote Identity")
try:
follow = Follow.objects.get(source=source, target=target)
if not follow.active:
follow.state = (
FollowStates.accepted if target.local else FollowStates.unrequested
)
follow.state = FollowStates.unrequested
follow.boosts = boosts
follow.save()
except Follow.DoesNotExist:
@ -188,29 +240,22 @@ class Follow(StatorModel):
target=target,
boosts=boosts,
uri="",
state=(
FollowStates.accepted
if target.local
else FollowStates.unrequested
),
state=FollowStates.unrequested,
)
follow.uri = source.actor_uri + f"follow/{follow.pk}/"
# TODO: Local follow approvals
if target.local:
TimelineEvent.add_follow(follow.target, follow.source)
follow.save()
return follow
### Properties ###
@property
def pending(self):
return self.state in [FollowStates.unrequested, FollowStates.local_requested]
@property
def active(self):
return self.state in FollowStates.group_active()
@property
def accepted(self):
return self.state in FollowStates.group_accepted()
### ActivityPub (outbound) ###
def to_ap(self):
@ -235,6 +280,17 @@ class Follow(StatorModel):
"object": self.to_ap(),
}
def to_reject_ap(self):
"""
Returns the AP JSON for this objects' rejection.
"""
return {
"type": "Reject",
"id": self.uri + "#reject",
"actor": self.target.actor_uri,
"object": self.to_ap(),
}
def to_undo_ap(self):
"""
Returns the AP JSON for this objects' undo.
@ -268,14 +324,14 @@ class Follow(StatorModel):
source = Identity.by_actor_uri(data["actor"], create=create)
target = Identity.by_actor_uri(get_str_or_id(data["object"]))
follow = cls.maybe_get(source=source, target=target)
# If it doesn't exist, create one in the remote_requested state
# If it doesn't exist, create one in the unrequested state
if follow is None:
if create:
return cls.objects.create(
source=source,
target=target,
uri=data["id"],
state=FollowStates.remote_requested,
state=FollowStates.unrequested,
)
else:
raise cls.DoesNotExist(
@ -289,7 +345,6 @@ class Follow(StatorModel):
"""
Handles an incoming follow request
"""
from activities.models import TimelineEvent
with transaction.atomic():
try:
@ -299,11 +354,9 @@ class Follow(StatorModel):
"Identity not found for incoming Follow", extras={"data": data}
)
return
# Force it into remote_requested so we send an accept
follow.transition_perform(FollowStates.remote_requested)
# Add a timeline event
TimelineEvent.add_follow(follow.target, follow.source)
if follow.state == FollowStates.accepted:
# Likely the source server missed the Accept, send it back again
follow.transition_perform(FollowStates.accepting)
@classmethod
def handle_accept_ap(cls, data):
@ -324,11 +377,8 @@ class Follow(StatorModel):
if data["actor"] != follow.target.actor_uri:
raise ValueError("Accept actor does not match its Follow object", 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)
if follow and follow.state == FollowStates.pending_approval:
follow.transition_perform(FollowStates.accepting)
@classmethod
def handle_reject_ap(cls, data):
@ -348,8 +398,17 @@ class Follow(StatorModel):
# Ensure the Accept actor is the Follow's target
if data["actor"] != follow.target.actor_uri:
raise ValueError("Reject actor does not match its Follow object", data)
# Clear timeline if remote target remove local source from their previously accepted follows
if follow.accepted:
InboxMessage.create_internal(
{
"type": "ClearTimeline",
"object": follow.target.pk,
"actor": follow.source.pk,
}
)
# Mark the follow rejected
follow.transition_perform(FollowStates.rejected)
follow.transition_perform(FollowStates.rejecting)
@classmethod
def handle_undo_ap(cls, data):
@ -369,4 +428,4 @@ class Follow(StatorModel):
if data["actor"] != follow.source.actor_uri:
raise ValueError("Accept actor does not match its Follow object", data)
# Delete the follow
follow.delete()
follow.transition_perform(FollowStates.pending_removal)

View file

@ -991,7 +991,7 @@ class Identity(StatorModel):
"avatar_static": self.local_icon_url().absolute,
"header": header_image.absolute if header_image else missing,
"header_static": header_image.absolute if header_image else missing,
"locked": False,
"locked": bool(self.manually_approves_followers),
"fields": (
[
{

View file

@ -73,7 +73,7 @@ class IdentityService:
return (
Identity.objects.filter(
outbound_follows__target=self.identity,
inbound_follows__state__in=FollowStates.group_active(),
outbound_follows__state=FollowStates.accepted,
)
.not_deleted()
.distinct()
@ -81,6 +81,28 @@ class IdentityService:
.select_related("domain")
)
def follow_requests(self) -> models.QuerySet[Identity]:
return (
Identity.objects.filter(
outbound_follows__target=self.identity,
outbound_follows__state=FollowStates.pending_approval,
)
.not_deleted()
.distinct()
.order_by("username")
.select_related("domain")
)
def accept_follow_request(self, source_identity):
existing_follow = Follow.maybe_get(source_identity, self.identity)
if existing_follow:
existing_follow.transition_perform(FollowStates.accepting)
def reject_follow_request(self, source_identity):
existing_follow = Follow.maybe_get(source_identity, self.identity)
if existing_follow:
existing_follow.transition_perform(FollowStates.rejecting)
def follow(self, target_identity: Identity, boosts=True) -> Follow:
"""
Follows a user (or does nothing if already followed).
@ -114,6 +136,7 @@ class IdentityService:
if target_identity == self.identity:
raise ValueError("You cannot block yourself")
self.unfollow(target_identity)
self.reject_follow_request(target_identity)
block = Block.create_local_block(self.identity, target_identity)
InboxMessage.create_internal(
{
@ -221,8 +244,10 @@ class IdentityService:
relationships = self.relationships(from_identity)
return {
"id": self.identity.pk,
"following": relationships["outbound_follow"] is not None,
"followed_by": relationships["inbound_follow"] is not None,
"following": relationships["outbound_follow"] is not None
and relationships["outbound_follow"].accepted,
"followed_by": relationships["inbound_follow"] is not None
and relationships["inbound_follow"].accepted,
"showing_reblogs": (
relationships["outbound_follow"]
and relationships["outbound_follow"].boosts
@ -233,7 +258,8 @@ class IdentityService:
"blocked_by": relationships["inbound_block"] is not None,
"muting": relationships["outbound_mute"] is not None,
"muting_notifications": False,
"requested": False,
"requested": relationships["outbound_follow"] is not None
and relationships["outbound_follow"].state == FollowStates.pending_approval,
"domain_blocking": False,
"endorsed": False,
"note": (