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 = [
'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)
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(),
......
......@@ -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
......@@ -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)
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
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()
)
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