takahe/users/models/follow.py

432 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
class FollowStates(StateGraph):
unrequested = State(try_interval=600)
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=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:
# 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": f"{self.target.actor_uri}#accept/{self.id}",
"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:
logging.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):
logging.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):
logging.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):
logging.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)