diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
index 00140fe53..4eeb193b1 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 1832b08bc..7a9b47d18 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('
{}
'.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 d34242fc8..ebd6b2fd5 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 201307d46..fa38678e9 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 e69de29bb..a6e1d28aa 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 a239f4961..88a94e562 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': '@mention /ping
'
}
}
+ 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()
)