diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 00140fe53ffad4d89ffa3ceb099bc66be44e1ea2..4eeb193b183007ad6c7c093cd20b881a3a06b89b 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -1,4 +1,11 @@ +import logging +import json +import requests +import requests_http_signature +from . import signing + +logger = logging.getLogger(__name__) ACTIVITY_TYPES = [ 'Accept', @@ -47,5 +54,32 @@ OBJECT_TYPES = [ 'Video', ] -def deliver(content, on_behalf_of, to=[]): - pass +def deliver(activity, on_behalf_of, to=[]): + from . import actors + logger.info('Preparing activity delivery to %s', to) + auth = requests_http_signature.HTTPSignatureAuth( + use_auth_header=False, + headers=[ + '(request-target)', + 'user-agent', + 'host', + 'date', + 'content-type',], + algorithm='rsa-sha256', + key=on_behalf_of.private_key.encode('utf-8'), + key_id=on_behalf_of.private_key_id, + ) + for url in to: + recipient_actor = actors.get_actor(url) + logger.debug('delivering to %s', recipient_actor.inbox_url) + logger.debug('activity content: %s', json.dumps(activity)) + response = requests.post( + auth=auth, + json=activity, + url=recipient_actor.inbox_url, + headers={ + 'Content-Type': 'application/activity+json' + } + ) + response.raise_for_status() + logger.debug('Remote answered with %s', response.status_code) diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py index 1832b08bcc0adf4cc605037d6ca311ad395ec1f0..7a9b47d183eb77783a32296133702b6f443076cc 100644 --- a/api/funkwhale_api/federation/actors.py +++ b/api/funkwhale_api/federation/actors.py @@ -1,21 +1,27 @@ +import logging import requests import xml -from django.urls import reverse from django.conf import settings +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 . import activity +from . import factories from . import models from . import serializers from . import utils +logger = logging.getLogger(__name__) + def remove_tags(text): - return ''.join(xml.etree.ElementTree.fromstring(text).itertext()) + logger.debug('Removing tags from %s', text) + return ''.join(xml.etree.ElementTree.fromstring('<div>{}</div>'.format(text)).itertext()) def get_actor_data(actor_url): @@ -32,6 +38,13 @@ def get_actor_data(actor_url): 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 = {} @@ -73,6 +86,7 @@ class SystemActor(object): 'federation:instance-actors-outbox', kwargs={'actor': id})), 'public_key': preferences['federation__public_key'], + 'private_key': preferences['federation__private_key'], 'summary': summary.format(host=settings.FEDERATION_HOSTNAME) } p.update(kwargs) @@ -136,14 +150,13 @@ class TestActor(SystemActor): serializer.is_valid(raise_exception=True) ac = serializer.validated_data + logger.info('Received activity on %s inbox', self.id) if ac['type'] == 'Create' and ac['object']['type'] == 'Note': # we received a toot \o/ command = self.parse_command(ac['object']['content']) + logger.debug('Parsed command: %s', command) if command == 'ping': - activity.deliver( - content='Pong!', - to=[ac['actor']], - on_behalf_of=self.get_actor_instance()) + self.handle_ping(ac, actor) def parse_command(self, message): """ @@ -156,6 +169,67 @@ class TestActor(SystemActor): except IndexError: return + def handle_ping(self, ac, sender): + now = timezone.now() + test_actor = self.get_actor_instance() + reply_url = 'https://{}/activities/note/{}'.format( + settings.FEDERATION_HOSTNAME, now.timestamp() + ) + mention = '@{}@{}'.format( + sender.preferred_username, + sender.domain + ) + reply_content = '{} Pong!'.format( + mention + ) + reply_activity = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": "as:movedTo", + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji" + } + ], + 'type': 'Create', + 'actor': test_actor.url, + 'id': '{}/activity'.format(reply_url), + 'published': now.isoformat(), + 'to': ac['actor'], + 'cc': [], + 'object': factories.NoteFactory( + 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": mention + } + ] + ) + } + activity.deliver( + reply_activity, + to=[ac['actor']], + on_behalf_of=test_actor) SYSTEM_ACTORS = { 'library': LibraryActor(), diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index d34242fc8e983d928b05e6731d854a374ca4358e..ebd6b2fd51712b4fa6ebef26f4734c4764e8ad83 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -2,6 +2,8 @@ import factory import requests import requests_http_signature +from django.utils import timezone + from funkwhale_api.factories import registry from . import keys @@ -64,8 +66,24 @@ class ActorFactory(factory.DjangoModelFactory): @classmethod def _generate(cls, create, attrs): - has_public = attrs.get('public_key') is None - has_private = attrs.get('private_key') is None + has_public = attrs.get('public_key') is not None + has_private = attrs.get('private_key') is not None if not has_public and not has_private: - attrs['private_key'], attrs['public'] = keys.get_key_pair() + private, public = keys.get_key_pair() + attrs['private_key'] = private.decode('utf-8') + attrs['public_key'] = public.decode('utf-8') return super()._generate(create, attrs) + + +@registry.register(name='federation.Note') +class NoteFactory(factory.Factory): + type = 'Note' + id = factory.Faker('url') + published = factory.LazyFunction( + lambda: timezone.now().isoformat() + ) + inReplyTo = None + content = factory.Faker('sentence') + + class Meta: + model = dict diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 201307d46c93214f9cd7f0066eb60bff2a45bd88..fa38678e9d4b2bb2619a38fd8d312a8fc2d1913d 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -38,3 +38,7 @@ class Actor(models.Model): self.preferred_username, settings.FEDERATION_HOSTNAME, ) + + @property + def private_key_id(self): + return '{}#main-key'.format(self.url) diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..a6e1d28aa23623251a1ab26661d2814c14704f00 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -0,0 +1,32 @@ +from funkwhale_api.federation import activity + +def test_deliver(nodb_factories, r_mock, mocker): + to = nodb_factories['federation.Actor']() + mocker.patch( + 'funkwhale_api.federation.actors.get_actor', + return_value=to) + sender = nodb_factories['federation.Actor']() + ac = { + 'id': 'http://test.federation/activity', + 'type': 'Create', + 'actor': sender.url, + 'object': { + 'id': 'http://test.federation/note', + 'type': 'Note', + 'content': 'Hello', + } + } + + r_mock.post(to.inbox_url) + + activity.deliver( + ac, + to=[to.url], + on_behalf_of=sender, + ) + request = r_mock.request_history[0] + + assert r_mock.called is True + assert r_mock.call_count == 1 + assert request.url == to.inbox_url + assert request.headers['content-type'] == 'application/activity+json' diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py index a239f4961bb7917937dd92780eaa4811a3a5f5ed..88a94e56222468155e4b3929d7e40ed3392e3d07 100644 --- a/api/tests/federation/test_actors.py +++ b/api/tests/federation/test_actors.py @@ -1,6 +1,8 @@ import pytest from django.urls import reverse +from django.utils import timezone + from rest_framework import exceptions from funkwhale_api.federation import actors @@ -128,6 +130,8 @@ def test_test_post_outbox_handles_create_note(mocker, factories): deliver = mocker.patch( 'funkwhale_api.federation.activity.deliver') actor = factories['federation.Actor']() + now = timezone.now() + mocker.patch('django.utils.timezone.now', return_value=now) data = { 'actor': actor.url, 'type': 'Create', @@ -138,9 +142,27 @@ def test_test_post_outbox_handles_create_note(mocker, factories): 'content': '<p><a>@mention</a> /ping</p>' } } + expected_note = factories['federation.Note']( + id='https://test.federation/activities/note/{}'.format( + now.timestamp() + ), + content='Pong!', + published=now.isoformat(), + inReplyTo=data['object']['id'], + ) + test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance() + expected_activity = { + 'actor': test_actor.url, + 'id': 'https://test.federation/activities/note/{}/activity'.format( + now.timestamp() + ), + 'type': 'Create', + 'published': now.isoformat(), + 'object': expected_note + } actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor) deliver.assert_called_once_with( - content='Pong!', + expected_activity, to=[actor.url], on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance() )