PoC with receiving /ping from Mastodon and replying pong
This commit is contained in:
parent
3cf1a17087
commit
a252051351
|
@ -1,4 +1,11 @@
|
||||||
|
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 = [
|
||||||
'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)
|
||||||
|
|
|
@ -1,21 +1,27 @@
|
||||||
|
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):
|
||||||
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):
|
||||||
'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):
|
||||||
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):
|
||||||
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
|
||||||
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):
|
||||||
|
|
||||||
@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):
|
||||||
self.preferred_username,
|
self.preferred_username,
|
||||||
settings.FEDERATION_HOSTNAME,
|
settings.FEDERATION_HOSTNAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def private_key_id(self):
|
||||||
|
return '{}#main-key'.format(self.url)
|
||||||
|
|
|
@ -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'
|
|
@ -1,6 +1,8 @@
|
||||||
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):
|
||||||
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):
|
||||||
'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()
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue