takahe/users/services/identity.py
Osma Ahvenlampi 5549d21528
Fix inbox processing errors from pinned posts and non-Mastodon servers (#596)
If a post (interaction) comes in from AP inbox but no local author profile exists,
fetch_actor will pull in both the identity AND its pinned posts, which the incoming
post might have been. This would case a database integrity violation. We check
for post existing again after syncing the actor.

Post processing also barfed on posts where content didn't follow Mastodon specs.
For example, Kbin sets tag names in 'tag' attribute, instead of 'name' attribute.
2023-07-12 09:49:30 -06:00

295 lines
10 KiB
Python

from django.db import models, transaction
from django.template.defaultfilters import linebreaks_filter
from activities.models import FanOut, Post, PostInteraction, PostInteractionStates
from core.files import resize_image
from core.html import FediverseHtmlParser
from stator.exceptions import TryAgainLater
from users.models import (
Block,
BlockStates,
Domain,
Follow,
FollowStates,
Identity,
InboxMessage,
User,
)
class IdentityService:
"""
High-level helper methods for doing things to identities
"""
def __init__(self, identity: Identity):
self.identity = identity
@classmethod
def create(
cls,
user: User,
username: str,
domain: Domain,
name: str,
discoverable: bool = True,
) -> Identity:
identity = Identity.objects.create(
actor_uri=f"https://{domain.uri_domain}/@{username}@{domain.domain}/",
username=username,
domain=domain,
name=name,
local=True,
discoverable=discoverable,
)
identity.users.add(user)
identity.generate_keypair()
# Send fanouts to all admin identities
for admin_identity in cls.admin_identities():
FanOut.objects.create(
type=FanOut.Types.identity_created,
identity=admin_identity,
subject_identity=identity,
)
return identity
@classmethod
def admin_identities(cls) -> models.QuerySet[Identity]:
return Identity.objects.filter(users__admin=True).distinct()
def following(self) -> models.QuerySet[Identity]:
return (
Identity.objects.filter(
inbound_follows__source=self.identity,
inbound_follows__state__in=FollowStates.group_active(),
)
.not_deleted()
.distinct()
.order_by("username")
.select_related("domain")
)
def followers(self) -> models.QuerySet[Identity]:
return (
Identity.objects.filter(
outbound_follows__target=self.identity,
inbound_follows__state__in=FollowStates.group_active(),
)
.not_deleted()
.distinct()
.order_by("username")
.select_related("domain")
)
def follow(self, target_identity: Identity, boosts=True) -> Follow:
"""
Follows a user (or does nothing if already followed).
Returns the follow.
"""
if target_identity == self.identity:
raise ValueError("You cannot follow yourself")
return Follow.create_local(self.identity, target_identity, boosts=boosts)
def unfollow(self, target_identity: Identity):
"""
Unfollows a user (or does nothing if not followed).
"""
if target_identity == self.identity:
raise ValueError("You cannot unfollow yourself")
existing_follow = Follow.maybe_get(self.identity, target_identity)
if existing_follow:
existing_follow.transition_perform(FollowStates.undone)
InboxMessage.create_internal(
{
"type": "ClearTimeline",
"object": target_identity.pk,
"actor": self.identity.pk,
}
)
def block(self, target_identity: Identity) -> Block:
"""
Blocks a user.
"""
if target_identity == self.identity:
raise ValueError("You cannot block yourself")
self.unfollow(target_identity)
block = Block.create_local_block(self.identity, target_identity)
InboxMessage.create_internal(
{
"type": "ClearTimeline",
"actor": self.identity.pk,
"object": target_identity.pk,
"fullErase": True,
}
)
return block
def unblock(self, target_identity: Identity):
"""
Unlocks a user
"""
if target_identity == self.identity:
raise ValueError("You cannot unblock yourself")
existing_block = Block.maybe_get(self.identity, target_identity, mute=False)
if existing_block and existing_block.active:
existing_block.transition_perform(BlockStates.undone)
def mute(
self,
target_identity: Identity,
duration: int = 0,
include_notifications: bool = False,
) -> Block:
"""
Mutes a user.
"""
if target_identity == self.identity:
raise ValueError("You cannot mute yourself")
return Block.create_local_mute(
self.identity,
target_identity,
duration=duration or None,
include_notifications=include_notifications,
)
def unmute(self, target_identity: Identity):
"""
Unmutes a user
"""
if target_identity == self.identity:
raise ValueError("You cannot unmute yourself")
existing_block = Block.maybe_get(self.identity, target_identity, mute=True)
if existing_block and existing_block.active:
existing_block.transition_perform(BlockStates.undone)
def relationships(self, from_identity: Identity):
"""
Returns a dict of any active relationships from the given identity.
"""
return {
"outbound_follow": Follow.maybe_get(
from_identity, self.identity, require_active=True
),
"inbound_follow": Follow.maybe_get(
self.identity, from_identity, require_active=True
),
"outbound_block": Block.maybe_get(
from_identity, self.identity, mute=False, require_active=True
),
"inbound_block": Block.maybe_get(
self.identity, from_identity, mute=False, require_active=True
),
"outbound_mute": Block.maybe_get(
from_identity, self.identity, mute=True, require_active=True
),
}
def sync_pins(self, object_uris):
if not object_uris or self.identity.domain.blocked:
return
with transaction.atomic():
for object_uri in object_uris:
try:
post = Post.by_object_uri(object_uri, fetch=True)
PostInteraction.objects.get_or_create(
type=PostInteraction.Types.pin,
identity=self.identity,
post=post,
state__in=PostInteractionStates.group_active(),
)
except Post.DoesNotExist:
# ignore 404s...
pass
except TryAgainLater:
# when fetching a post -> author -> post we can
# get into a state. Ignore this round.
pass
for removed in PostInteraction.objects.filter(
type=PostInteraction.Types.pin,
identity=self.identity,
state__in=PostInteractionStates.group_active(),
).exclude(post__object_uri__in=object_uris):
removed.transition_perform(PostInteractionStates.undone_fanned_out)
def mastodon_json_relationship(self, from_identity: Identity):
"""
Returns a Relationship object for the from_identity's relationship
with this identity.
"""
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,
"showing_reblogs": (
relationships["outbound_follow"]
and relationships["outbound_follow"].boosts
or False
),
"notifying": False,
"blocking": relationships["outbound_block"] is not None,
"blocked_by": relationships["inbound_block"] is not None,
"muting": relationships["outbound_mute"] is not None,
"muting_notifications": False,
"requested": False,
"domain_blocking": False,
"endorsed": False,
"note": (
relationships["outbound_follow"]
and relationships["outbound_follow"].note
or ""
),
}
def set_summary(self, summary: str):
"""
Safely sets a summary and turns linebreaks into HTML
"""
if summary:
self.identity.summary = FediverseHtmlParser(linebreaks_filter(summary)).html
else:
self.identity.summary = None
self.identity.save()
def set_icon(self, file):
"""
Sets the user's avatar image
"""
self.identity.icon.save(
file.name,
resize_image(file, size=(400, 400)),
)
def set_image(self, file):
"""
Sets the user's header image
"""
self.identity.image.save(
file.name,
resize_image(file, size=(1500, 500)),
)
@classmethod
def handle_internal_add_follow(cls, payload):
"""
Handles an inbox message saying we need to follow a handle
Message format:
{
"type": "AddFollow",
"source": "90310938129083",
"target_handle": "andrew@aeracode.org",
"boosts": true,
}
"""
# Retrieve ourselves
self = cls(Identity.objects.get(pk=payload["source"]))
# Get the remote end (may need a fetch)
username, domain = payload["target_handle"].split("@")
target_identity = Identity.by_username_and_domain(username, domain, fetch=True)
if target_identity is None:
raise ValueError(f"Cannot find identity to follow: {target_identity}")
# Follow!
self.follow(target_identity=target_identity, boosts=payload.get("boosts", True))