takahe/users/models/follow.py

373 lines
12 KiB
Python

from typing import Optional
import httpx
from django.db import models, transaction
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.identity import Identity
class FollowStates(StateGraph):
unrequested = State(try_interval=600)
local_requested = State(try_interval=24 * 60 * 60)
remote_requested = 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()
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)
accepted.transitions_to(undone)
undone.transitions_to(undone_remotely)
@classmethod
def group_active(cls):
return [cls.unrequested, cls.local_requested, cls.accepted]
@classmethod
def handle_unrequested(cls, instance: "Follow"):
"""
Follows that are unrequested need us to deliver the Follow object
to the target server.
"""
# Remote follows should not be here
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
@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
return cls.accepted
@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()),
)
except httpx.RequestError:
return
return cls.undone_remotely
class FollowQuerySet(models.QuerySet):
def active(self):
query = self.filter(state__in=FollowStates.group_active())
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()
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).
"""
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.boosts = boosts
follow.save()
except Follow.DoesNotExist:
with transaction.atomic():
follow = Follow.objects.create(
source=source,
target=target,
boosts=boosts,
uri="",
state=(
FollowStates.accepted
if target.local
else 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()
### 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_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 remote_requested state
if follow is None:
if create:
return cls.objects.create(
source=source,
target=target,
uri=data["id"],
state=FollowStates.remote_requested,
)
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
"""
from activities.models import TimelineEvent
with transaction.atomic():
try:
follow = cls.by_ap(data, create=True)
except Identity.DoesNotExist:
capture_message(
"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)
@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):
capture_message(
"Follow or Identity not found for incoming Accept",
extras={"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 in [
FollowStates.unrequested,
FollowStates.local_requested,
]:
follow.transition_perform(FollowStates.accepted)
@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):
capture_message(
"Follow or Identity not found for incoming Reject",
extras={"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)
# Mark the follow rejected
follow.transition_perform(FollowStates.rejected)
@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):
capture_message(
"Follow or Identity not found for incoming Undo", extras={"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.delete()