Skip to content
Snippets Groups Projects
Verified Commit a2520513 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

PoC with receiving /ping from Mastodon and replying pong

parent 3cf1a170
No related branches found
No related tags found
1 merge request!121Federation inbox
Pipeline #649 failed with stage
in 2 minutes and 6 seconds
import logging
import json
import requests
import requests_http_signature
from . import signing
logger = logging.getLogger(__name__)
ACTIVITY_TYPES = [ ACTIVITY_TYPES = [
'Accept', 'Accept',
...@@ -47,5 +54,32 @@ OBJECT_TYPES = [ ...@@ -47,5 +54,32 @@ OBJECT_TYPES = [
'Video', 'Video',
] ]
def deliver(content, on_behalf_of, to=[]): def deliver(activity, on_behalf_of, to=[]):
pass 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)
import logging
import requests import requests
import xml import xml
from django.urls import reverse
from django.conf import settings from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from . import activity from . import activity
from . import factories
from . import models from . import models
from . import serializers from . import serializers
from . import utils from . import utils
logger = logging.getLogger(__name__)
def remove_tags(text): 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): def get_actor_data(actor_url):
...@@ -32,6 +38,13 @@ def get_actor_data(actor_url): ...@@ -32,6 +38,13 @@ def get_actor_data(actor_url):
raise ValueError( raise ValueError(
'Invalid actor payload: {}'.format(response.text)) '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): class SystemActor(object):
additional_attributes = {} additional_attributes = {}
...@@ -73,6 +86,7 @@ class SystemActor(object): ...@@ -73,6 +86,7 @@ class SystemActor(object):
'federation:instance-actors-outbox', 'federation:instance-actors-outbox',
kwargs={'actor': id})), kwargs={'actor': id})),
'public_key': preferences['federation__public_key'], 'public_key': preferences['federation__public_key'],
'private_key': preferences['federation__private_key'],
'summary': summary.format(host=settings.FEDERATION_HOSTNAME) 'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
} }
p.update(kwargs) p.update(kwargs)
...@@ -136,14 +150,13 @@ class TestActor(SystemActor): ...@@ -136,14 +150,13 @@ class TestActor(SystemActor):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
ac = serializer.validated_data ac = serializer.validated_data
logger.info('Received activity on %s inbox', self.id)
if ac['type'] == 'Create' and ac['object']['type'] == 'Note': if ac['type'] == 'Create' and ac['object']['type'] == 'Note':
# we received a toot \o/ # we received a toot \o/
command = self.parse_command(ac['object']['content']) command = self.parse_command(ac['object']['content'])
logger.debug('Parsed command: %s', command)
if command == 'ping': if command == 'ping':
activity.deliver( self.handle_ping(ac, actor)
content='Pong!',
to=[ac['actor']],
on_behalf_of=self.get_actor_instance())
def parse_command(self, message): def parse_command(self, message):
""" """
...@@ -156,6 +169,67 @@ class TestActor(SystemActor): ...@@ -156,6 +169,67 @@ class TestActor(SystemActor):
except IndexError: except IndexError:
return 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 = { SYSTEM_ACTORS = {
'library': LibraryActor(), 'library': LibraryActor(),
......
...@@ -2,6 +2,8 @@ import factory ...@@ -2,6 +2,8 @@ import factory
import requests import requests
import requests_http_signature import requests_http_signature
from django.utils import timezone
from funkwhale_api.factories import registry from funkwhale_api.factories import registry
from . import keys from . import keys
...@@ -64,8 +66,24 @@ class ActorFactory(factory.DjangoModelFactory): ...@@ -64,8 +66,24 @@ class ActorFactory(factory.DjangoModelFactory):
@classmethod @classmethod
def _generate(cls, create, attrs): def _generate(cls, create, attrs):
has_public = attrs.get('public_key') is None has_public = attrs.get('public_key') is not None
has_private = attrs.get('private_key') is None has_private = attrs.get('private_key') is not None
if not has_public and not has_private: 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) 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
...@@ -38,3 +38,7 @@ class Actor(models.Model): ...@@ -38,3 +38,7 @@ class Actor(models.Model):
self.preferred_username, self.preferred_username,
settings.FEDERATION_HOSTNAME, settings.FEDERATION_HOSTNAME,
) )
@property
def private_key_id(self):
return '{}#main-key'.format(self.url)
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'
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from rest_framework import exceptions from rest_framework import exceptions
from funkwhale_api.federation import actors from funkwhale_api.federation import actors
...@@ -128,6 +130,8 @@ def test_test_post_outbox_handles_create_note(mocker, factories): ...@@ -128,6 +130,8 @@ def test_test_post_outbox_handles_create_note(mocker, factories):
deliver = mocker.patch( deliver = mocker.patch(
'funkwhale_api.federation.activity.deliver') 'funkwhale_api.federation.activity.deliver')
actor = factories['federation.Actor']() actor = factories['federation.Actor']()
now = timezone.now()
mocker.patch('django.utils.timezone.now', return_value=now)
data = { data = {
'actor': actor.url, 'actor': actor.url,
'type': 'Create', 'type': 'Create',
...@@ -138,9 +142,27 @@ def test_test_post_outbox_handles_create_note(mocker, factories): ...@@ -138,9 +142,27 @@ def test_test_post_outbox_handles_create_note(mocker, factories):
'content': '<p><a>@mention</a> /ping</p>' '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) actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
deliver.assert_called_once_with( deliver.assert_called_once_with(
content='Pong!', expected_activity,
to=[actor.url], to=[actor.url],
on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance() on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance()
) )
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment