From 70b9e3b900e6efcedfa36bfffe5adcf9ba1a31c4 Mon Sep 17 00:00:00 2001 From: Henri Dickson <90480431+alphatownsman@users.noreply.github.com> Date: Fri, 18 Aug 2023 02:19:45 -0400 Subject: [PATCH] Support follow requests (#625) --- activities/migrations/0001_initial.py | 1 + activities/models/timeline_event.py | 29 ++- api/urls.py | 2 + api/views/accounts.py | 3 + api/views/follow_requests.py | 46 +++- .../activities/models/test_timeline_event.py | 3 +- tests/users/models/test_follow.py | 3 +- users/migrations/0022_follow_request.py | 22 ++ users/models/follow.py | 237 +++++++++++------- users/models/identity.py | 2 +- users/services/identity.py | 34 ++- 11 files changed, 283 insertions(+), 99 deletions(-) create mode 100644 users/migrations/0022_follow_request.py diff --git a/activities/migrations/0001_initial.py b/activities/migrations/0001_initial.py index 437a580..242d133 100644 --- a/activities/migrations/0001_initial.py +++ b/activities/migrations/0001_initial.py @@ -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"), diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py index fe1be09..852c93f 100644 --- a/activities/models/timeline_event.py +++ b/activities/models/timeline_event.py @@ -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: diff --git a/api/urls.py b/api/urls.py index 5aed32d..842aec8 100644 --- a/api/urls.py +++ b/api/urls.py @@ -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//authorize", follow_requests.accept_follow_request), + path("v1/follow_requests//reject", follow_requests.reject_follow_request), # Instance path("v1/instance", instance.instance_info_v1), path("v1/instance/activity", instance.activity), diff --git a/api/views/accounts.py b/api/views/accounts.py index c6d83fe..629051f 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -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 = { diff --git a/api/views/follow_requests.py b/api/views/follow_requests.py index e188ba5..cf54b92 100644 --- a/api/views/follow_requests.py +++ b/api/views/follow_requests.py @@ -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) diff --git a/tests/activities/models/test_timeline_event.py b/tests/activities/models/test_timeline_event.py index 75af52f..ef0dde0 100644 --- a/tests/activities/models/test_timeline_event.py +++ b/tests/activities/models/test_timeline_event.py @@ -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 diff --git a/tests/users/models/test_follow.py b/tests/users/models/test_follow.py index ff66801..73f189b 100644 --- a/tests/users/models/test_follow.py +++ b/tests/users/models/test_follow.py @@ -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 diff --git a/users/migrations/0022_follow_request.py b/users/migrations/0022_follow_request.py new file mode 100644 index 0000000..f041bef --- /dev/null +++ b/users/migrations/0022_follow_request.py @@ -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');" + ), + ] diff --git a/users/models/follow.py b/users/models/follow.py index 139ba20..cd1d0ba 100644 --- a/users/models/follow.py +++ b/users/models/follow.py @@ -7,92 +7,140 @@ 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: + # 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 + # Sign it and send it + try: + instance.source.signed_request( + method="post", + uri=instance.target.inbox_uri, + body=canonicalise(instance.to_ap()), + ) + except httpx.RequestError: + return + 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_accepting(cls, instance: "Follow"): if not instance.source.local: - return cls.remote_requested - if instance.target.local: - return cls.accepted - # Don't try if the other identity didn't fetch yet - if not instance.target.inbox_uri: - return - # Sign it and send it - try: - instance.source.signed_request( - method="post", - uri=instance.target.inbox_uri, - body=canonicalise(instance.to_ap()), - ) - except httpx.RequestError: - return - return cls.local_requested + # send an Accept object to the source server + try: + instance.target.signed_request( + method="post", + uri=instance.source.inbox_uri, + body=canonicalise(instance.to_accept_ap()), + ) + except httpx.RequestError: + return + from activities.models import TimelineEvent - @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. - """ - try: - instance.target.signed_request( - method="post", - uri=instance.source.inbox_uri, - body=canonicalise(instance.to_accept_ap()), - ) - except httpx.RequestError: - return + 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: - instance.source.signed_request( - method="post", - uri=instance.target.inbox_uri, - body=canonicalise(instance.to_undo_ap()), - ) + if not instance.target.local: + instance.source.signed_request( + method="post", + uri=instance.target.inbox_uri, + body=canonicalise(instance.to_undo_ap()), + ) 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) diff --git a/users/models/identity.py b/users/models/identity.py index 0857ed2..97f4fcb 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -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": ( [ { diff --git a/users/services/identity.py b/users/services/identity.py index 66c45f2..2a29171 100644 --- a/users/services/identity.py +++ b/users/services/identity.py @@ -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": (