Skip to content
Snippets Groups Projects
Forked from funkwhale / funkwhale
7376 commits behind the upstream repository.
actors.py 11.50 KiB
import logging
import uuid
import xml

from django.conf import settings
from django.db import transaction
from django.urls import reverse
from django.utils import timezone

from rest_framework.exceptions import PermissionDenied

from dynamic_preferences.registries import global_preferences_registry

from funkwhale_api.common import session

from . import activity
from . import keys
from . import models
from . import serializers
from . import signing
from . import utils

logger = logging.getLogger(__name__)


def remove_tags(text):
    logger.debug('Removing tags from %s', text)
    return ''.join(xml.etree.ElementTree.fromstring('<div>{}</div>'.format(text)).itertext())


def get_actor_data(actor_url):
    response = session.get_session().get(
        actor_url,
        timeout=5,
        headers={
            'Accept': 'application/activity+json',
        }
    )
    response.raise_for_status()
    try:
        return response.json()
    except:
        raise ValueError(
            'Invalid actor payload: {}'.format(response.text))


def get_actor(actor_url):
    data = get_actor_data(actor_url)
    serializer = serializers.ActorSerializer(data=data)
    serializer.is_valid(raise_exception=True)

    return serializer.build()


class SystemActor(object):
    additional_attributes = {}
    manually_approves_followers = False

    def get_request_auth(self):
        actor = self.get_actor_instance()
        return signing.get_auth(
            actor.private_key, actor.private_key_id)

    def serialize(self):
        actor = self.get_actor_instance()
        serializer = serializers.ActorSerializer(actor)
        return serializer.data

    def get_actor_instance(self):
        try:
            return models.Actor.objects.get(url=self.get_actor_url())
        except models.Actor.DoesNotExist:
            pass
        private, public = keys.get_key_pair()
        args = self.get_instance_argument(
            self.id,
            name=self.name,
            summary=self.summary,
            **self.additional_attributes
        )
        args['private_key'] = private.decode('utf-8')
        args['public_key'] = public.decode('utf-8')
        return models.Actor.objects.create(**args)

    def get_actor_url(self):
        return utils.full_url(
            reverse(
                'federation:instance-actors-detail',
                kwargs={'actor': self.id}))

    def get_instance_argument(self, id, name, summary, **kwargs):
        p = {
            'preferred_username': id,
            'domain': settings.FEDERATION_HOSTNAME,
            'type': 'Person',
            'name': name.format(host=settings.FEDERATION_HOSTNAME),
            'manually_approves_followers': True,
            'url': self.get_actor_url(),
            'shared_inbox_url': utils.full_url(
                reverse(
                    'federation:instance-actors-inbox',
                    kwargs={'actor': id})),
            'inbox_url': utils.full_url(
                reverse(
                    'federation:instance-actors-inbox',
                    kwargs={'actor': id})),
            'outbox_url': utils.full_url(
                reverse(
                    'federation:instance-actors-outbox',
                    kwargs={'actor': id})),
            'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
        }
        p.update(kwargs)
        return p

    def get_inbox(self, data, actor=None):
        raise NotImplementedError

    def post_inbox(self, data, actor=None):
        return self.handle(data, actor=actor)

    def get_outbox(self, data, actor=None):
        raise NotImplementedError

    def post_outbox(self, data, actor=None):
        raise NotImplementedError

    def handle(self, data, actor=None):
        """
        Main entrypoint for handling activities posted to the
        actor's inbox
        """
        logger.info('Received activity on %s inbox', self.id)

        if actor is None:
            raise PermissionDenied('Actor not authenticated')

        serializer = serializers.ActivitySerializer(
            data=data, context={'actor': actor})
        serializer.is_valid(raise_exception=True)

        ac = serializer.data
        try:
            handler = getattr(
                self, 'handle_{}'.format(ac['type'].lower()))
        except (KeyError, AttributeError):
            logger.debug(
                'No handler for activity %s', ac['type'])
            return

        return handler(data, actor)

    def handle_follow(self, ac, sender):
        system_actor = self.get_actor_instance()
        if self.manually_approves_followers:
            fr, created = models.FollowRequest.objects.get_or_create(
                actor=sender,
                target=system_actor,
                approved=None,
            )
            return fr

        return activity.accept_follow(
            system_actor, ac, sender
        )

    def handle_undo_follow(self, ac, sender):
        actor = self.get_actor_instance()
        models.Follow.objects.filter(
            actor=sender,
            target=actor,
        ).delete()

    def handle_undo(self, ac, sender):
        if ac['object']['type'] != 'Follow':
            return

        if ac['object']['actor'] != sender.url:
            # not the same actor, permission issue
            return

        self.handle_undo_follow(ac, sender)


class LibraryActor(SystemActor):
    id = 'library'
    name = '{host}\'s library'
    summary = 'Bot account to federate with {host}\'s library'
    additional_attributes = {
        'manually_approves_followers': True
    }

    def serialize(self):
        data = super().serialize()
        urls = data.setdefault('url', [])
        urls.append({
            'type': 'Link',
            'mediaType': 'application/activity+json',
            'name': 'library',
            'href': utils.full_url(reverse('federation:music:files-list'))
        })
        return data

    @property
    def manually_approves_followers(self):
        return settings.FEDERATION_MUSIC_NEEDS_APPROVAL

    def handle_follow(self, ac, sender):
        system_actor = self.get_actor_instance()
        if self.manually_approves_followers:
            fr, created = models.FollowRequest.objects.get_or_create(
                actor=sender,
                target=system_actor,
                approved=None,
            )
            return fr

        return activity.accept_follow(
            system_actor, ac, sender
        )

    @transaction.atomic
    def handle_create(self, ac, sender):
        try:
            remote_library = models.Library.objects.get(
                actor=sender,
                federation_enabled=True,
            )
        except models.Library.DoesNotExist:
            logger.info(
                'Skipping import, we\'re not following %s', sender.url)
            return

        if ac['object']['type'] != 'Collection':
            return

        if ac['object']['totalItems'] <= 0:
            return

        try:
            items = ac['object']['items']
        except KeyError:
            logger.warning('No items in collection!')
            return

        item_serializers = [
            serializers.AudioSerializer(
                data=i, context={'library': remote_library})
            for i in items
        ]

        valid_serializers = []
        for s in item_serializers:
            if s.is_valid():
                valid_serializers.append(s)
            else:
                logger.debug(
                    'Skipping invalid item %s, %s', s.initial_data, s.errors)

        for s in valid_serializers:
            s.save()


class TestActor(SystemActor):
    id = 'test'
    name = '{host}\'s test account'
    summary = (
        'Bot account to test federation with {host}. '
        'Send me /ping and I\'ll answer you.'
    )
    additional_attributes = {
        'manually_approves_followers': False
    }
    manually_approves_followers = False

    def get_outbox(self, data, actor=None):
        return {
            "@context": [
                "https://www.w3.org/ns/activitystreams",
                "https://w3id.org/security/v1",
                {}
            ],
            "id": utils.full_url(
                reverse(
                    'federation:instance-actors-outbox',
                    kwargs={'actor': self.id})),
            "type": "OrderedCollection",
            "totalItems": 0,
            "orderedItems": []
        }

    def parse_command(self, message):
        """
        Remove any links or fancy markup to extract /command from
        a note message.
        """
        raw = remove_tags(message)
        try:
            return raw.split('/')[1]
        except IndexError:
            return

    def handle_create(self, ac, sender):
        if ac['object']['type'] != 'Note':
            return

        # we received a toot \o/
        command = self.parse_command(ac['object']['content'])
        logger.debug('Parsed command: %s', command)
        if command != 'ping':
            return

        now = timezone.now()
        test_actor = self.get_actor_instance()
        reply_url = 'https://{}/activities/note/{}'.format(
            settings.FEDERATION_HOSTNAME, now.timestamp()
        )
        reply_content = '{} Pong!'.format(
            sender.mention_username
        )
        reply_activity = {
            "@context": [
                "https://www.w3.org/ns/activitystreams",
                "https://w3id.org/security/v1",
                {}
            ],
            'type': 'Create',
            'actor': test_actor.url,
            'id': '{}/activity'.format(reply_url),
            'published': now.isoformat(),
            'to': ac['actor'],
            'cc': [],
            'object': {
                'type': 'Note',
                'content': 'Pong!',
                'summary': None,
                'published': now.isoformat(),
                'id': reply_url,
                'inReplyTo': ac['object']['id'],
                'sensitive': False,
                'url': reply_url,
                'to': [ac['actor']],
                'attributedTo': test_actor.url,
                'cc': [],
                'attachment': [],
                'tag': [{
                    "type": "Mention",
                    "href": ac['actor'],
                    "name": sender.mention_username
                }]
            }
        }
        activity.deliver(
            reply_activity,
            to=[ac['actor']],
            on_behalf_of=test_actor)

    def handle_follow(self, ac, sender):
        super().handle_follow(ac, sender)
        # also, we follow back
        test_actor = self.get_actor_instance()
        follow_uuid = uuid.uuid4()
        follow = activity.get_follow(
            follow_id=follow_uuid,
            follower=test_actor,
            followed=sender)
        activity.deliver(
            follow,
            to=[ac['actor']],
            on_behalf_of=test_actor)

    def handle_undo_follow(self, ac, sender):
        super().handle_undo_follow(ac, sender)
        actor = self.get_actor_instance()
        # we also unfollow the sender, if possible
        try:
            follow = models.Follow.objects.get(
                target=sender,
                actor=actor,
            )
        except models.Follow.DoesNotExist:
            return
        undo = activity.get_undo(
            id=follow.get_federation_url(),
            actor=actor,
            object=serializers.FollowSerializer(follow).data,
        )
        follow.delete()
        activity.deliver(
            undo,
            to=[sender.url],
            on_behalf_of=actor)


SYSTEM_ACTORS = {
    'library': LibraryActor(),
    'test': TestActor(),
}