Verified Commit 3cf1a170 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

We are now able to receive a toot and react to it

parent 6fbf8fa4
......@@ -344,7 +344,12 @@ REST_FRAMEWORK = {
),
'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination',
'PAGE_SIZE': 25,
'DEFAULT_PARSER_CLASSES': (
'rest_framework.parsers.JSONParser',
'rest_framework.parsers.FormParser',
'rest_framework.parsers.MultiPartParser',
'funkwhale_api.federation.parsers.ActivityParser',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
......
ACTIVITY_TYPES = [
'Accept',
'Add',
'Announce',
'Arrive',
'Block',
'Create',
'Delete',
'Dislike',
'Flag',
'Follow',
'Ignore',
'Invite',
'Join',
'Leave',
'Like',
'Listen',
'Move',
'Offer',
'Question',
'Reject',
'Read',
'Remove',
'TentativeReject',
'TentativeAccept',
'Travel',
'Undo',
'Update',
'View',
]
OBJECT_TYPES = [
'Article',
'Audio',
'Document',
'Event',
'Image',
'Note',
'Page',
'Place',
'Profile',
'Relationship',
'Tombstone',
'Video',
]
def deliver(content, on_behalf_of, to=[]):
pass
import requests
import xml
from django.urls import reverse
from django.conf import settings
from rest_framework.exceptions import PermissionDenied
from dynamic_preferences.registries import global_preferences_registry
from . import activity
from . import models
from . import serializers
from . import utils
def remove_tags(text):
return ''.join(xml.etree.ElementTree.fromstring(text).itertext())
def get_actor_data(actor_url):
response = requests.get(
actor_url,
......@@ -23,39 +32,132 @@ def get_actor_data(actor_url):
raise ValueError(
'Invalid actor payload: {}'.format(response.text))
SYSTEM_ACTORS = {
'library': {
'get_actor': lambda: models.Actor(**get_base_system_actor_arguments('library')),
class SystemActor(object):
additional_attributes = {}
def get_actor_instance(self):
a = models.Actor(
**self.get_instance_argument(
self.id,
name=self.name,
summary=self.summary,
**self.additional_attributes
)
)
a.pk = self.id
return a
def get_instance_argument(self, id, name, summary, **kwargs):
preferences = global_preferences_registry.manager()
p = {
'preferred_username': id,
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': name.format(host=settings.FEDERATION_HOSTNAME),
'manually_approves_followers': True,
'url': utils.full_url(
reverse(
'federation:instance-actors-detail',
kwargs={'actor': id})),
'shared_inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': id})),
'inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': id})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': id})),
'public_key': preferences['federation__public_key'],
'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
}
p.update(kwargs)
return p
def get_inbox(self, data, actor=None):
raise NotImplementedError
def post_inbox(self, data, actor=None):
raise NotImplementedError
def get_outbox(self, data, actor=None):
raise NotImplementedError
def post_outbox(self, data, actor=None):
raise NotImplementedError
class LibraryActor(SystemActor):
id = 'library'
name = '{host}\'s library'
summary = 'Bot account to federate with {host}\'s library'
additional_attributes = {
'manually_approves_followers': True
}
}
def get_base_system_actor_arguments(name):
preferences = global_preferences_registry.manager()
return {
'preferred_username': name,
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
'manually_approves_followers': True,
'url': utils.full_url(
reverse(
'federation:instance-actors-detail',
kwargs={'actor': name})),
'shared_inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': name})),
'inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': name})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': name})),
'public_key': preferences['federation__public_key'],
'summary': 'Bot account to federate with {}\'s library'.format(
settings.FEDERATION_HOSTNAME
),
class TestActor(SystemActor):
id = 'test'
name = '{host}\'s test account'
summary = (
'Bot account to test federation with {host}. '
'Send me /ping and I\'ll answer you.'
)
additional_attributes = {
'manually_approves_followers': False
}
def get_outbox(self, data, actor=None):
return {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"id": utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': self.id})),
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []
}
def post_inbox(self, data, actor=None):
if actor is None:
raise PermissionDenied('Actor not authenticated')
serializer = serializers.ActivitySerializer(
data=data, context={'actor': actor})
serializer.is_valid(raise_exception=True)
ac = serializer.validated_data
if ac['type'] == 'Create' and ac['object']['type'] == 'Note':
# we received a toot \o/
command = self.parse_command(ac['object']['content'])
if command == 'ping':
activity.deliver(
content='Pong!',
to=[ac['actor']],
on_behalf_of=self.get_actor_instance())
def parse_command(self, message):
"""
Remove any links or fancy markup to extract /command from
a note message.
"""
raw = remove_tags(message)
try:
return raw.split('/')[1]
except IndexError:
return
SYSTEM_ACTORS = {
'library': LibraryActor(),
'test': TestActor(),
}
......@@ -45,6 +45,7 @@ class SignatureAuthentication(authentication.BaseAuthentication):
return serializer.build()
def authenticate(self, request):
setattr(request, 'actor', None)
actor = self.authenticate_actor(request)
user = AnonymousUser()
setattr(request, 'actor', actor)
......
from rest_framework import parsers
class ActivityParser(parsers.JSONParser):
media_type = 'application/activity+json'
......@@ -6,6 +6,7 @@ from django.conf import settings
from rest_framework import serializers
from dynamic_preferences.registries import global_preferences_registry
from . import activity
from . import models
from . import utils
......@@ -105,3 +106,70 @@ class ActorWebfingerSerializer(serializers.ModelSerializer):
instance.url
]
return data
class ActivitySerializer(serializers.Serializer):
actor = serializers.URLField()
id = serializers.URLField()
type = serializers.ChoiceField(
choices=[(c, c) for c in activity.ACTIVITY_TYPES])
object = serializers.JSONField()
def validate_object(self, value):
try:
type = value['type']
except KeyError:
raise serializers.ValidationError('Missing object type')
try:
object_serializer = OBJECT_SERIALIZERS[type]
except KeyError:
raise serializers.ValidationError(
'Unsupported type {}'.format(type))
serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True)
return serializer.data
def validate_actor(self, value):
request_actor = self.context.get('actor')
if request_actor and request_actor.url != value:
raise serializers.ValidationError(
'The actor making the request do not match'
' the activity actor'
)
return value
class ObjectSerializer(serializers.Serializer):
id = serializers.URLField()
url = serializers.URLField(required=False, allow_null=True)
type = serializers.ChoiceField(
choices=[(c, c) for c in activity.OBJECT_TYPES])
content = serializers.CharField(
required=False, allow_null=True)
summary = serializers.CharField(
required=False, allow_null=True)
name = serializers.CharField(
required=False, allow_null=True)
published = serializers.DateTimeField(
required=False, allow_null=True)
updated = serializers.DateTimeField(
required=False, allow_null=True)
to = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
cc = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
bto = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
bcc = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
OBJECT_SERIALIZERS = {
t: ObjectSerializer
for t in activity.OBJECT_TYPES
}
......@@ -36,18 +36,35 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
raise Http404
def retrieve(self, request, *args, **kwargs):
actor_conf = self.get_object()
actor = actor_conf['get_actor']()
system_actor = self.get_object()
actor = system_actor.get_actor_instance()
serializer = serializers.ActorSerializer(actor)
return response.Response(serializer.data, status=200)
@detail_route(methods=['get'])
@detail_route(methods=['get', 'post'])
def inbox(self, request, *args, **kwargs):
raise NotImplementedError()
system_actor = self.get_object()
handler = getattr(system_actor, '{}_inbox'.format(
request.method.lower()
))
@detail_route(methods=['get'])
try:
data = handler(request.data, actor=request.actor)
except NotImplementedError:
return response.Response(status=405)
return response.Response(data, status=200)
@detail_route(methods=['get', 'post'])
def outbox(self, request, *args, **kwargs):
raise NotImplementedError()
system_actor = self.get_object()
handler = getattr(system_actor, '{}_outbox'.format(
request.method.lower()
))
try:
data = handler(request.data, actor=request.actor)
except NotImplementedError:
return response.Response(status=405)
return response.Response(data, status=200)
class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
......@@ -82,5 +99,5 @@ class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
def handler_acct(self, clean_result):
username, hostname = clean_result
actor = actors.SYSTEM_ACTORS[username]['get_actor']()
actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
return serializers.ActorWebfingerSerializer(actor).data
......@@ -30,7 +30,8 @@ def clean_acct(acct_string):
raise forms.ValidationError('Invalid format')
if hostname != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError('Invalid hostname')
raise forms.ValidationError(
'Invalid hostname {}'.format(hostname))
if username not in actors.SYSTEM_ACTORS:
raise forms.ValidationError('Invalid username')
......
import pytest
from django.urls import reverse
from rest_framework import exceptions
from funkwhale_api.federation import actors
from funkwhale_api.federation import serializers
from funkwhale_api.federation import utils
......@@ -37,10 +41,106 @@ def test_get_library(settings, preferences):
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'library'})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': 'library'})),
'public_key': 'public_key',
'summary': 'Bot account to federate with {}\'s library'.format(
settings.FEDERATION_HOSTNAME),
}
actor = actors.SYSTEM_ACTORS['library']['get_actor']()
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
for key, value in expected.items():
assert getattr(actor, key) == value
def test_get_test(settings, preferences):
preferences['federation__public_key'] = 'public_key'
expected = {
'preferred_username': 'test',
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME),
'manually_approves_followers': False,
'url': utils.full_url(
reverse(
'federation:instance-actors-detail',
kwargs={'actor': 'test'})),
'shared_inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'test'})),
'inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'test'})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': 'test'})),
'public_key': 'public_key',
'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format(
settings.FEDERATION_HOSTNAME),
}
actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
for key, value in expected.items():
assert getattr(actor, key) == value
def test_test_get_outbox():
expected = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"id": utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': 'test'})),
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []
}
data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None)
assert data == expected
def test_test_post_inbox_requires_authenticated_actor():
with pytest.raises(exceptions.PermissionDenied):
actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None)
def test_test_post_outbox_validates_actor(nodb_factories):
actor = nodb_factories['federation.Actor']()
data = {
'actor': 'noop'
}
with pytest.raises(exceptions.ValidationError) as exc_info:
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
msg = 'The actor making the request do not match'
assert msg in exc_info.value
def test_test_post_outbox_handles_create_note(mocker, factories):
deliver = mocker.patch(
'funkwhale_api.federation.activity.deliver')
actor = factories['federation.Actor']()
data = {
'actor': actor.url,
'type': 'Create',
'id': 'http://test.federation/activity',
'object': {
'type': 'Note',
'id': 'http://test.federation/object',
'content': '<p><a>@mention</a> /ping</p>'
}
}
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
deliver.assert_called_once_with(
content='Pong!',
to=[actor.url],
on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance()
)
......@@ -10,7 +10,7 @@ from funkwhale_api.federation import webfinger
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
def test_instance_actors(system_actor, db, settings, api_client):
actor = actors.SYSTEM_ACTORS[system_actor]['get_actor']()
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
url = reverse(
'federation:instance-actors-detail',
kwargs={'actor': system_actor})
......@@ -27,7 +27,7 @@ def test_instance_actors(system_actor, db, settings, api_client):
('instance-actors-detail', {'actor': 'library'}),
('well-known-webfinger', {}),
])
def test_instance_inbox_405_if_federation_disabled(
def test_instance_endpoints_405_if_federation_disabled(
authenticated_actor, db, settings, api_client, route, kwargs):
settings.FEDERATION_ENABLED = False
url = reverse('federation:{}'.format(route), kwargs=kwargs)
......@@ -53,7 +53,7 @@ def test_wellknown_webfinger_validates_resource(
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
def test_wellknown_webfinger_system(
system_actor, db, api_client, settings, mocker):
actor = actors.SYSTEM_ACTORS[system_actor]['get_actor']()
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
url = reverse('federation:well-known-webfinger')
response = api_client.get(
url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)})
......
......@@ -32,7 +32,7 @@ def test_webfinger_clean_acct(settings):
@pytest.mark.parametrize('resource,message', [
('service', 'Invalid format'),
('service@test.com', 'Invalid hostname'),
('service@test.com', 'Invalid hostname test.com'),
('noop@test.federation', 'Invalid account'),
])
def test_webfinger_clean_acct_errors(resource, message, settings):
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment