437 lines
14 KiB
Python
437 lines
14 KiB
Python
import logging
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
from django.db import models, transaction
|
|
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FollowStates(StateGraph):
|
|
unrequested = State(try_interval=600)
|
|
pending_approval = State(externally_progressed=True)
|
|
accepting = State(try_interval=600)
|
|
rejecting = State(try_interval=24 * 60 * 60)
|
|
accepted = State(externally_progressed=True)
|
|
undone = State(try_interval=24 * 60 * 60)
|
|
pending_removal = State(try_interval=60 * 60)
|
|
removed = State(delete_after=1)
|
|
|
|
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(pending_removal)
|
|
pending_removal.transitions_to(removed)
|
|
|
|
@classmethod
|
|
def group_active(cls):
|
|
"""
|
|
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 start unrequested as their initial state regardless of local/remote
|
|
"""
|
|
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:
|
|
# Don't send Accept if remote identity wasn't fetch yet
|
|
if not instance.source.inbox_uri:
|
|
return
|
|
# 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
|
|
|
|
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,
|
|
body=canonicalise(instance.to_undo_ap()),
|
|
)
|
|
except httpx.RequestError:
|
|
return
|
|
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):
|
|
def active(self):
|
|
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):
|
|
return FollowQuerySet(self.model, using=self._db)
|
|
|
|
def active(self):
|
|
return self.get_queryset().active()
|
|
|
|
def accepted(self):
|
|
return self.get_queryset().accepted()
|
|
|
|
|
|
class Follow(StatorModel):
|
|
"""
|
|
When one user (the source) follows other (the target)
|
|
"""
|
|
|
|
id = models.BigIntegerField(primary_key=True, default=Snowflake.generate_follow)
|
|
|
|
source = models.ForeignKey(
|
|
"users.Identity",
|
|
on_delete=models.CASCADE,
|
|
related_name="outbound_follows",
|
|
)
|
|
target = models.ForeignKey(
|
|
"users.Identity",
|
|
on_delete=models.CASCADE,
|
|
related_name="inbound_follows",
|
|
)
|
|
|
|
boosts = models.BooleanField(
|
|
default=True, help_text="Also follow boosts from this user"
|
|
)
|
|
|
|
uri = models.CharField(blank=True, null=True, max_length=500)
|
|
note = models.TextField(blank=True, null=True)
|
|
|
|
state = StateField(FollowStates)
|
|
|
|
created = models.DateTimeField(auto_now_add=True)
|
|
updated = models.DateTimeField(auto_now=True)
|
|
|
|
objects = FollowManager()
|
|
|
|
class Meta:
|
|
unique_together = [("source", "target")]
|
|
indexes: list = [] # We need this so Stator can add its own
|
|
|
|
def __str__(self):
|
|
return f"#{self.id}: {self.source} → {self.target}"
|
|
|
|
### Alternate fetchers/constructors ###
|
|
|
|
@classmethod
|
|
def maybe_get(cls, source, target, require_active=False) -> Optional["Follow"]:
|
|
"""
|
|
Returns a follow if it exists between source and target
|
|
"""
|
|
try:
|
|
if require_active:
|
|
return Follow.objects.active().get(source=source, target=target)
|
|
else:
|
|
return Follow.objects.get(source=source, target=target)
|
|
except Follow.DoesNotExist:
|
|
return None
|
|
|
|
@classmethod
|
|
def create_local(cls, source, target, boosts=True):
|
|
"""
|
|
Creates a Follow from a local Identity to the target
|
|
(which can be local or remote).
|
|
"""
|
|
|
|
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.unrequested
|
|
follow.boosts = boosts
|
|
follow.save()
|
|
except Follow.DoesNotExist:
|
|
with transaction.atomic():
|
|
follow = Follow.objects.create(
|
|
source=source,
|
|
target=target,
|
|
boosts=boosts,
|
|
uri="",
|
|
state=FollowStates.unrequested,
|
|
)
|
|
follow.uri = source.actor_uri + f"follow/{follow.pk}/"
|
|
follow.save()
|
|
return follow
|
|
|
|
### Properties ###
|
|
|
|
@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):
|
|
"""
|
|
Returns the AP JSON for this object
|
|
"""
|
|
return {
|
|
"type": "Follow",
|
|
"id": self.uri,
|
|
"actor": self.source.actor_uri,
|
|
"object": self.target.actor_uri,
|
|
}
|
|
|
|
def to_accept_ap(self):
|
|
"""
|
|
Returns the AP JSON for this objects' accept.
|
|
"""
|
|
return {
|
|
"type": "Accept",
|
|
"id": self.uri + "#accept",
|
|
"actor": self.target.actor_uri,
|
|
"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.
|
|
"""
|
|
return {
|
|
"type": "Undo",
|
|
"id": self.uri + "#undo",
|
|
"actor": self.source.actor_uri,
|
|
"object": self.to_ap(),
|
|
}
|
|
|
|
### ActivityPub (inbound) ###
|
|
|
|
@classmethod
|
|
def by_ap(cls, data: str | dict, create=False) -> "Follow":
|
|
"""
|
|
Retrieves a Follow instance by its ActivityPub JSON object or its URI.
|
|
|
|
Optionally creates one if it's not present.
|
|
Raises DoesNotExist if it's not found and create is False.
|
|
"""
|
|
# If it's a string, do the reference resolve
|
|
if isinstance(data, str):
|
|
bits = data.strip("/").split("/")
|
|
if bits[-2] != "follow":
|
|
raise ValueError(f"Unknown Follow object URI: {data}")
|
|
return Follow.objects.get(pk=bits[-1])
|
|
# Otherwise, do object resolve
|
|
else:
|
|
# Resolve source and target and see if a Follow exists
|
|
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 unrequested state
|
|
if follow is None:
|
|
if create:
|
|
return cls.objects.create(
|
|
source=source,
|
|
target=target,
|
|
uri=data["id"],
|
|
state=FollowStates.unrequested,
|
|
)
|
|
else:
|
|
raise cls.DoesNotExist(
|
|
f"No follow with source {source} and target {target}", data
|
|
)
|
|
else:
|
|
return follow
|
|
|
|
@classmethod
|
|
def handle_request_ap(cls, data):
|
|
"""
|
|
Handles an incoming follow request
|
|
"""
|
|
|
|
with transaction.atomic():
|
|
try:
|
|
follow = cls.by_ap(data, create=True)
|
|
except Identity.DoesNotExist:
|
|
logger.info(
|
|
"Identity not found for incoming Follow", extra={"data": data}
|
|
)
|
|
return
|
|
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):
|
|
"""
|
|
Handles an incoming Follow Accept for one of our follows
|
|
"""
|
|
# Resolve source and target and see if a Follow exists (it really should)
|
|
try:
|
|
follow = cls.by_ap(data["object"])
|
|
except (cls.DoesNotExist, Identity.DoesNotExist):
|
|
logger.info(
|
|
"Follow or Identity not found for incoming Accept",
|
|
extra={"data": data},
|
|
)
|
|
return
|
|
|
|
# Ensure the Accept actor is the Follow's target
|
|
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 == FollowStates.pending_approval:
|
|
follow.transition_perform(FollowStates.accepting)
|
|
|
|
@classmethod
|
|
def handle_reject_ap(cls, data):
|
|
"""
|
|
Handles an incoming Follow Reject for one of our follows
|
|
"""
|
|
# Resolve source and target and see if a Follow exists (it really should)
|
|
try:
|
|
follow = cls.by_ap(data["object"])
|
|
except (cls.DoesNotExist, Identity.DoesNotExist):
|
|
logger.info(
|
|
"Follow or Identity not found for incoming Reject",
|
|
extra={"data": data},
|
|
)
|
|
return
|
|
|
|
# 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.rejecting)
|
|
|
|
@classmethod
|
|
def handle_undo_ap(cls, data):
|
|
"""
|
|
Handles an incoming Follow Undo for one of our follows
|
|
"""
|
|
# Resolve source and target and see if a Follow exists (it hopefully does)
|
|
try:
|
|
follow = cls.by_ap(data["object"])
|
|
except (cls.DoesNotExist, Identity.DoesNotExist):
|
|
logger.info(
|
|
"Follow or Identity not found for incoming Undo", extra={"data": data}
|
|
)
|
|
return
|
|
|
|
# Ensure the Undo actor is the Follow's source
|
|
if data["actor"] != follow.source.actor_uri:
|
|
raise ValueError("Accept actor does not match its Follow object", data)
|
|
# Delete the follow
|
|
follow.transition_perform(FollowStates.pending_removal)
|