From 3cf1a1708709e8006fa2315a9c78093c521c7976 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 1 Apr 2018 22:11:46 +0200 Subject: [PATCH] We are now able to receive a toot and react to it --- api/config/settings/common.py | 7 +- api/funkwhale_api/federation/activity.py | 51 ++++++ api/funkwhale_api/federation/actors.py | 170 ++++++++++++++---- .../federation/authentication.py | 1 + api/funkwhale_api/federation/parsers.py | 5 + api/funkwhale_api/federation/serializers.py | 68 +++++++ api/funkwhale_api/federation/views.py | 31 +++- api/funkwhale_api/federation/webfinger.py | 3 +- api/tests/federation/test_activity.py | 0 api/tests/federation/test_actors.py | 102 ++++++++++- api/tests/federation/test_views.py | 6 +- api/tests/federation/test_webfinger.py | 2 +- 12 files changed, 398 insertions(+), 48 deletions(-) create mode 100644 api/funkwhale_api/federation/activity.py create mode 100644 api/funkwhale_api/federation/parsers.py create mode 100644 api/tests/federation/test_activity.py diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 01fb19e6c..fbe3b7045 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -344,7 +344,12 @@ REST_FRAMEWORK = { ), 'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination', 'PAGE_SIZE': 25, - + 'DEFAULT_PARSER_CLASSES': ( + 'rest_framework.parsers.JSONParser', + 'rest_framework.parsers.FormParser', + 'rest_framework.parsers.MultiPartParser', + 'funkwhale_api.federation.parsers.ActivityParser', + ), 'DEFAULT_AUTHENTICATION_CLASSES': ( 'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS', 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py new file mode 100644 index 000000000..00140fe53 --- /dev/null +++ b/api/funkwhale_api/federation/activity.py @@ -0,0 +1,51 @@ + + +ACTIVITY_TYPES = [ + 'Accept', + 'Add', + 'Announce', + 'Arrive', + 'Block', + 'Create', + 'Delete', + 'Dislike', + 'Flag', + 'Follow', + 'Ignore', + 'Invite', + 'Join', + 'Leave', + 'Like', + 'Listen', + 'Move', + 'Offer', + 'Question', + 'Reject', + 'Read', + 'Remove', + 'TentativeReject', + 'TentativeAccept', + 'Travel', + 'Undo', + 'Update', + 'View', +] + + +OBJECT_TYPES = [ + 'Article', + 'Audio', + 'Document', + 'Event', + 'Image', + 'Note', + 'Page', + 'Place', + 'Profile', + 'Relationship', + 'Tombstone', + 'Video', +] + +def deliver(content, on_behalf_of, to=[]): + pass diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py index 56a3fc1fa..1832b08bc 100644 --- a/api/funkwhale_api/federation/actors.py +++ b/api/funkwhale_api/federation/actors.py @@ -1,14 +1,23 @@ import requests +import xml from django.urls import reverse from django.conf import settings +from rest_framework.exceptions import PermissionDenied + from dynamic_preferences.registries import global_preferences_registry +from . import activity from . import models +from . import serializers from . import utils +def remove_tags(text): + return ''.join(xml.etree.ElementTree.fromstring(text).itertext()) + + def get_actor_data(actor_url): response = requests.get( actor_url, @@ -23,39 +32,132 @@ def get_actor_data(actor_url): raise ValueError( 'Invalid actor payload: {}'.format(response.text)) + +class SystemActor(object): + additional_attributes = {} + + def get_actor_instance(self): + a = models.Actor( + **self.get_instance_argument( + self.id, + name=self.name, + summary=self.summary, + **self.additional_attributes + ) + ) + a.pk = self.id + return a + + def get_instance_argument(self, id, name, summary, **kwargs): + preferences = global_preferences_registry.manager() + p = { + 'preferred_username': id, + 'domain': settings.FEDERATION_HOSTNAME, + 'type': 'Person', + 'name': name.format(host=settings.FEDERATION_HOSTNAME), + 'manually_approves_followers': True, + 'url': utils.full_url( + reverse( + 'federation:instance-actors-detail', + kwargs={'actor': id})), + '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})), + 'public_key': preferences['federation__public_key'], + '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): + raise NotImplementedError + + def get_outbox(self, data, actor=None): + raise NotImplementedError + + def post_outbox(self, data, actor=None): + raise NotImplementedError + + +class LibraryActor(SystemActor): + id = 'library' + name = '{host}\'s library' + summary = 'Bot account to federate with {host}\'s library' + additional_attributes = { + 'manually_approves_followers': True + } + + +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 + } + + 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 post_inbox(self, data, actor=None): + 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.validated_data + if ac['type'] == 'Create' and ac['object']['type'] == 'Note': + # we received a toot \o/ + command = self.parse_command(ac['object']['content']) + if command == 'ping': + activity.deliver( + content='Pong!', + to=[ac['actor']], + on_behalf_of=self.get_actor_instance()) + + 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 + + SYSTEM_ACTORS = { - 'library': { - 'get_actor': lambda: models.Actor(**get_base_system_actor_arguments('library')), - } + 'library': LibraryActor(), + 'test': TestActor(), } - - -def get_base_system_actor_arguments(name): - preferences = global_preferences_registry.manager() - return { - 'preferred_username': name, - 'domain': settings.FEDERATION_HOSTNAME, - 'type': 'Person', - 'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME), - 'manually_approves_followers': True, - 'url': utils.full_url( - reverse( - 'federation:instance-actors-detail', - kwargs={'actor': name})), - 'shared_inbox_url': utils.full_url( - reverse( - 'federation:instance-actors-inbox', - kwargs={'actor': name})), - 'inbox_url': utils.full_url( - reverse( - 'federation:instance-actors-inbox', - kwargs={'actor': name})), - 'outbox_url': utils.full_url( - reverse( - 'federation:instance-actors-outbox', - kwargs={'actor': name})), - 'public_key': preferences['federation__public_key'], - 'summary': 'Bot account to federate with {}\'s library'.format( - settings.FEDERATION_HOSTNAME - ), - } diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py index 980b7006b..e199ef134 100644 --- a/api/funkwhale_api/federation/authentication.py +++ b/api/funkwhale_api/federation/authentication.py @@ -45,6 +45,7 @@ class SignatureAuthentication(authentication.BaseAuthentication): return serializer.build() def authenticate(self, request): + setattr(request, 'actor', None) actor = self.authenticate_actor(request) user = AnonymousUser() setattr(request, 'actor', actor) diff --git a/api/funkwhale_api/federation/parsers.py b/api/funkwhale_api/federation/parsers.py new file mode 100644 index 000000000..874d808f9 --- /dev/null +++ b/api/funkwhale_api/federation/parsers.py @@ -0,0 +1,5 @@ +from rest_framework import parsers + + +class ActivityParser(parsers.JSONParser): + media_type = 'application/activity+json' diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 6b12d51ca..2137e8d91 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -6,6 +6,7 @@ from django.conf import settings from rest_framework import serializers from dynamic_preferences.registries import global_preferences_registry +from . import activity from . import models from . import utils @@ -105,3 +106,70 @@ class ActorWebfingerSerializer(serializers.ModelSerializer): instance.url ] return data + + +class ActivitySerializer(serializers.Serializer): + actor = serializers.URLField() + id = serializers.URLField() + type = serializers.ChoiceField( + choices=[(c, c) for c in activity.ACTIVITY_TYPES]) + object = serializers.JSONField() + + def validate_object(self, value): + try: + type = value['type'] + except KeyError: + raise serializers.ValidationError('Missing object type') + + try: + object_serializer = OBJECT_SERIALIZERS[type] + except KeyError: + raise serializers.ValidationError( + 'Unsupported type {}'.format(type)) + + serializer = object_serializer(data=value) + serializer.is_valid(raise_exception=True) + return serializer.data + + def validate_actor(self, value): + request_actor = self.context.get('actor') + if request_actor and request_actor.url != value: + raise serializers.ValidationError( + 'The actor making the request do not match' + ' the activity actor' + ) + return value + + +class ObjectSerializer(serializers.Serializer): + id = serializers.URLField() + url = serializers.URLField(required=False, allow_null=True) + type = serializers.ChoiceField( + choices=[(c, c) for c in activity.OBJECT_TYPES]) + content = serializers.CharField( + required=False, allow_null=True) + summary = serializers.CharField( + required=False, allow_null=True) + name = serializers.CharField( + required=False, allow_null=True) + published = serializers.DateTimeField( + required=False, allow_null=True) + updated = serializers.DateTimeField( + required=False, allow_null=True) + to = serializers.ListField( + child=serializers.URLField(), + required=False, allow_null=True) + cc = serializers.ListField( + child=serializers.URLField(), + required=False, allow_null=True) + bto = serializers.ListField( + child=serializers.URLField(), + required=False, allow_null=True) + bcc = serializers.ListField( + child=serializers.URLField(), + required=False, allow_null=True) + +OBJECT_SERIALIZERS = { + t: ObjectSerializer + for t in activity.OBJECT_TYPES +} diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 95e421b59..2e3feb8d0 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -36,18 +36,35 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet): raise Http404 def retrieve(self, request, *args, **kwargs): - actor_conf = self.get_object() - actor = actor_conf['get_actor']() + system_actor = self.get_object() + actor = system_actor.get_actor_instance() serializer = serializers.ActorSerializer(actor) return response.Response(serializer.data, status=200) - @detail_route(methods=['get']) + @detail_route(methods=['get', 'post']) def inbox(self, request, *args, **kwargs): - raise NotImplementedError() + system_actor = self.get_object() + handler = getattr(system_actor, '{}_inbox'.format( + request.method.lower() + )) - @detail_route(methods=['get']) + try: + data = handler(request.data, actor=request.actor) + except NotImplementedError: + return response.Response(status=405) + return response.Response(data, status=200) + + @detail_route(methods=['get', 'post']) def outbox(self, request, *args, **kwargs): - raise NotImplementedError() + system_actor = self.get_object() + handler = getattr(system_actor, '{}_outbox'.format( + request.method.lower() + )) + try: + data = handler(request.data, actor=request.actor) + except NotImplementedError: + return response.Response(status=405) + return response.Response(data, status=200) class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet): @@ -82,5 +99,5 @@ class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet): def handler_acct(self, clean_result): username, hostname = clean_result - actor = actors.SYSTEM_ACTORS[username]['get_actor']() + actor = actors.SYSTEM_ACTORS[username].get_actor_instance() return serializers.ActorWebfingerSerializer(actor).data diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py index d698114f1..95a51e1c0 100644 --- a/api/funkwhale_api/federation/webfinger.py +++ b/api/funkwhale_api/federation/webfinger.py @@ -30,7 +30,8 @@ def clean_acct(acct_string): raise forms.ValidationError('Invalid format') if hostname != settings.FEDERATION_HOSTNAME: - raise forms.ValidationError('Invalid hostname') + raise forms.ValidationError( + 'Invalid hostname {}'.format(hostname)) if username not in actors.SYSTEM_ACTORS: raise forms.ValidationError('Invalid username') diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py index 00e214bd1..a239f4961 100644 --- a/api/tests/federation/test_actors.py +++ b/api/tests/federation/test_actors.py @@ -1,6 +1,10 @@ +import pytest + from django.urls import reverse +from rest_framework import exceptions from funkwhale_api.federation import actors +from funkwhale_api.federation import serializers from funkwhale_api.federation import utils @@ -37,10 +41,106 @@ def test_get_library(settings, preferences): reverse( 'federation:instance-actors-inbox', kwargs={'actor': 'library'})), + 'outbox_url': utils.full_url( + reverse( + 'federation:instance-actors-outbox', + kwargs={'actor': 'library'})), 'public_key': 'public_key', 'summary': 'Bot account to federate with {}\'s library'.format( settings.FEDERATION_HOSTNAME), } - actor = actors.SYSTEM_ACTORS['library']['get_actor']() + actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() for key, value in expected.items(): assert getattr(actor, key) == value + + +def test_get_test(settings, preferences): + preferences['federation__public_key'] = 'public_key' + expected = { + 'preferred_username': 'test', + 'domain': settings.FEDERATION_HOSTNAME, + 'type': 'Person', + 'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME), + 'manually_approves_followers': False, + 'url': utils.full_url( + reverse( + 'federation:instance-actors-detail', + kwargs={'actor': 'test'})), + 'shared_inbox_url': utils.full_url( + reverse( + 'federation:instance-actors-inbox', + kwargs={'actor': 'test'})), + 'inbox_url': utils.full_url( + reverse( + 'federation:instance-actors-inbox', + kwargs={'actor': 'test'})), + 'outbox_url': utils.full_url( + reverse( + 'federation:instance-actors-outbox', + kwargs={'actor': 'test'})), + 'public_key': 'public_key', + 'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format( + settings.FEDERATION_HOSTNAME), + } + actor = actors.SYSTEM_ACTORS['test'].get_actor_instance() + for key, value in expected.items(): + assert getattr(actor, key) == value + + +def test_test_get_outbox(): + expected = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {} + ], + "id": utils.full_url( + reverse( + 'federation:instance-actors-outbox', + kwargs={'actor': 'test'})), + "type": "OrderedCollection", + "totalItems": 0, + "orderedItems": [] + } + + data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None) + + assert data == expected + + +def test_test_post_inbox_requires_authenticated_actor(): + with pytest.raises(exceptions.PermissionDenied): + actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None) + + +def test_test_post_outbox_validates_actor(nodb_factories): + actor = nodb_factories['federation.Actor']() + data = { + 'actor': 'noop' + } + with pytest.raises(exceptions.ValidationError) as exc_info: + actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor) + msg = 'The actor making the request do not match' + assert msg in exc_info.value + + +def test_test_post_outbox_handles_create_note(mocker, factories): + deliver = mocker.patch( + 'funkwhale_api.federation.activity.deliver') + actor = factories['federation.Actor']() + data = { + 'actor': actor.url, + 'type': 'Create', + 'id': 'http://test.federation/activity', + 'object': { + 'type': 'Note', + 'id': 'http://test.federation/object', + 'content': '

@mention /ping

' + } + } + actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor) + deliver.assert_called_once_with( + content='Pong!', + to=[actor.url], + on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance() + ) diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 5ec53279a..0d2ac882f 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -10,7 +10,7 @@ from funkwhale_api.federation import webfinger @pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys()) def test_instance_actors(system_actor, db, settings, api_client): - actor = actors.SYSTEM_ACTORS[system_actor]['get_actor']() + actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance() url = reverse( 'federation:instance-actors-detail', kwargs={'actor': system_actor}) @@ -27,7 +27,7 @@ def test_instance_actors(system_actor, db, settings, api_client): ('instance-actors-detail', {'actor': 'library'}), ('well-known-webfinger', {}), ]) -def test_instance_inbox_405_if_federation_disabled( +def test_instance_endpoints_405_if_federation_disabled( authenticated_actor, db, settings, api_client, route, kwargs): settings.FEDERATION_ENABLED = False url = reverse('federation:{}'.format(route), kwargs=kwargs) @@ -53,7 +53,7 @@ def test_wellknown_webfinger_validates_resource( @pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys()) def test_wellknown_webfinger_system( system_actor, db, api_client, settings, mocker): - actor = actors.SYSTEM_ACTORS[system_actor]['get_actor']() + actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance() url = reverse('federation:well-known-webfinger') response = api_client.get( url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)}) diff --git a/api/tests/federation/test_webfinger.py b/api/tests/federation/test_webfinger.py index fd1cb1d05..96258455a 100644 --- a/api/tests/federation/test_webfinger.py +++ b/api/tests/federation/test_webfinger.py @@ -32,7 +32,7 @@ def test_webfinger_clean_acct(settings): @pytest.mark.parametrize('resource,message', [ ('service', 'Invalid format'), - ('service@test.com', 'Invalid hostname'), + ('service@test.com', 'Invalid hostname test.com'), ('noop@test.federation', 'Invalid account'), ]) def test_webfinger_clean_acct_errors(resource, message, settings):