From 097707dec45a5f023875a52f8f03c7f4ad36c417 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 8 Apr 2018 13:35:37 +0200 Subject: [PATCH] Added remote library scanning logic end endpoint --- api/config/api_urls.py | 4 + api/funkwhale_api/federation/api_urls.py | 11 +++ api/funkwhale_api/federation/library.py | 97 +++++++++++++++++++++ api/funkwhale_api/federation/serializers.py | 32 ++++++- api/funkwhale_api/federation/views.py | 22 ++++- api/funkwhale_api/federation/webfinger.py | 2 +- api/tests/federation/test_library.py | 66 ++++++++++++++ api/tests/federation/test_views.py | 15 ++++ 8 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 api/funkwhale_api/federation/api_urls.py create mode 100644 api/funkwhale_api/federation/library.py create mode 100644 api/tests/federation/test_library.py diff --git a/api/config/api_urls.py b/api/config/api_urls.py index cab6805b6..cf5b03744 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -32,6 +32,10 @@ v1_patterns += [ include( ('funkwhale_api.instance.urls', 'instance'), namespace='instance')), + url(r'^federation/', + include( + ('funkwhale_api.federation.api_urls', 'federation'), + namespace='federation')), url(r'^providers/', include( ('funkwhale_api.providers.urls', 'providers'), diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py new file mode 100644 index 000000000..ecb5c38f1 --- /dev/null +++ b/api/funkwhale_api/federation/api_urls.py @@ -0,0 +1,11 @@ +from rest_framework import routers + +from . import views + +router = routers.SimpleRouter() +router.register( + r'libraries', + views.LibraryViewSet, + 'libraries') + +urlpatterns = router.urls diff --git a/api/funkwhale_api/federation/library.py b/api/funkwhale_api/federation/library.py new file mode 100644 index 000000000..13608098b --- /dev/null +++ b/api/funkwhale_api/federation/library.py @@ -0,0 +1,97 @@ +import requests + +from funkwhale_api.common import session + +from . import actors +from . import serializers +from . import signing +from . import webfinger + + +def scan_from_account_name(account_name): + """ + Given an account name such as library@test.library, will: + + 1. Perform the webfinger lookup + 2. Perform the actor lookup + 3. Perform the library's collection lookup + + and return corresponding data in a dictionary. + """ + + data = {} + try: + data['webfinger'] = webfinger.get_resource( + 'acct:{}'.format(account_name)) + except requests.ConnectionError: + return { + 'webfinger': { + 'errors': ['This webfinger resource is not reachable'] + } + } + except requests.HTTPError as e: + return { + 'webfinger': { + 'errors': [ + 'Error {} during webfinger request'.format( + e.response.status_code)] + } + } + + try: + data['actor'] = actors.get_actor_data(data['webfinger']['actor_url']) + except requests.ConnectionError: + data['actor'] = { + 'errors': ['This actor is not reachable'] + } + return data + except requests.HTTPError as e: + data['actor'] = { + 'errors': [ + 'Error {} during actor request'.format( + e.response.status_code)] + } + return data + + serializer = serializers.LibraryActorSerializer(data=data['actor']) + serializer.is_valid(raise_exception=True) + data['library'] = get_library_data( + serializer.validated_data['library_url']) + + return data + + +def get_library_data(library_url): + actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + auth = signing.get_auth(actor.private_key, actor.private_key_id) + try: + response = session.get_session().get( + library_url, + auth=auth, + timeout=5, + headers={ + 'Content-Type': 'application/activity+json' + } + ) + except requests.ConnectionError: + return { + 'errors': ['This library is not reachable'] + } + scode = response.status_code + if scode == 401: + return { + 'errors': ['This library requires authentication'] + } + elif scode == 403: + return { + 'errors': ['Permission denied while scanning library'] + } + elif scode >= 400: + return { + 'errors': ['Error {} while fetching the library'.format(scode)] + } + serializer = serializers.PaginatedCollectionSerializer( + data=response.json(), + ) + serializer.is_valid(raise_exception=True) + return serializer.validated_data diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 7e84e575a..704ad6364 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -27,8 +27,10 @@ class ActorSerializer(serializers.ModelSerializer): id = serializers.URLField(source='url') outbox = serializers.URLField(source='outbox_url') inbox = serializers.URLField(source='inbox_url') - following = serializers.URLField(source='following_url', required=False) - followers = serializers.URLField(source='followers_url', required=False) + following = serializers.URLField( + source='following_url', required=False, allow_null=True) + followers = serializers.URLField( + source='followers_url', required=False, allow_null=True) preferredUsername = serializers.CharField( source='preferred_username', required=False) publicKey = serializers.JSONField(source='public_key', required=False) @@ -94,6 +96,31 @@ class ActorSerializer(serializers.ModelSerializer): return value[:500] +class LibraryActorSerializer(ActorSerializer): + url = serializers.ListField( + child=serializers.JSONField()) + + class Meta(ActorSerializer.Meta): + fields = ActorSerializer.Meta.fields + ['url'] + + def validate(self, validated_data): + try: + urls = validated_data['url'] + except KeyError: + raise serializers.ValidationError('Missing URL field') + + for u in urls: + try: + if u['name'] != 'library': + continue + validated_data['library_url'] = u['href'] + break + except KeyError: + continue + + return validated_data + + class FollowSerializer(serializers.ModelSerializer): # left maps to activitypub fields, right to our internal models id = serializers.URLField(source='get_federation_url') @@ -226,7 +253,6 @@ OBJECT_SERIALIZERS = { class PaginatedCollectionSerializer(serializers.Serializer): type = serializers.ChoiceField(choices=['Collection']) totalItems = serializers.IntegerField(min_value=0) - items = serializers.ListField() actor = serializers.URLField() id = serializers.URLField() diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index da2b193a2..aaab343e4 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -4,15 +4,18 @@ from django.core import paginator from django.http import HttpResponse from django.urls import reverse -from rest_framework import viewsets -from rest_framework import views +from rest_framework import permissions as rest_permissions from rest_framework import response +from rest_framework import views +from rest_framework import viewsets from rest_framework.decorators import list_route, detail_route from funkwhale_api.music.models import TrackFile from . import actors from . import authentication +from . import library +from . import models from . import permissions from . import renderers from . import serializers @@ -154,3 +157,18 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet): return response.Response(status=404) return response.Response(data) + + +class LibraryViewSet(viewsets.GenericViewSet): + permission_classes = [rest_permissions.DjangoModelPermissions] + queryset = models.Library.objects.all() + + @list_route(methods=['get']) + def scan(self, request, *args, **kwargs): + account = request.GET.get('account') + if not account: + return response.Response( + {'account': 'This field is mandatory'}, status=400) + + data = library.scan_from_account_name(account) + return response.Response(data) diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py index 444998b94..d4170a431 100644 --- a/api/funkwhale_api/federation/webfinger.py +++ b/api/funkwhale_api/federation/webfinger.py @@ -36,7 +36,7 @@ def clean_acct(acct_string, ensure_local=True): raise forms.ValidationError( 'Invalid hostname {}'.format(hostname)) - if username not in actors.SYSTEM_ACTORS: + if ensure_local and username not in actors.SYSTEM_ACTORS: raise forms.ValidationError('Invalid username') return username, hostname diff --git a/api/tests/federation/test_library.py b/api/tests/federation/test_library.py new file mode 100644 index 000000000..714a0c306 --- /dev/null +++ b/api/tests/federation/test_library.py @@ -0,0 +1,66 @@ +from funkwhale_api.federation import library +from funkwhale_api.federation import serializers + + +def test_library_scan_from_account_name(mocker, factories): + actor = factories['federation.Actor']( + preferred_username='library', + domain='test.library' + ) + get_resource_result = {'actor_url': actor.url} + get_resource = mocker.patch( + 'funkwhale_api.federation.webfinger.get_resource', + return_value=get_resource_result) + + actor_data = serializers.ActorSerializer(actor).data + actor_data['manuallyApprovesFollowers'] = False + actor_data['url'] = [{ + 'type': 'Link', + 'name': 'library', + 'mediaType': 'application/activity+json', + 'href': 'https://test.library' + }] + get_actor_data = mocker.patch( + 'funkwhale_api.federation.actors.get_actor_data', + return_value=actor_data) + + get_library_data_result = {'test': 'test'} + get_library_data = mocker.patch( + 'funkwhale_api.federation.library.get_library_data', + return_value=get_library_data_result) + + result = library.scan_from_account_name('library@test.actor') + + get_resource.assert_called_once_with('acct:library@test.actor') + get_actor_data.assert_called_once_with(actor.url) + get_library_data.assert_called_once_with(actor_data['url'][0]['href']) + + assert result == { + 'webfinger': get_resource_result, + 'actor': actor_data, + 'library': get_library_data_result, + } + + +def test_get_library_data(r_mock, factories): + actor = factories['federation.Actor']() + url = 'https://test.library' + conf = { + 'id': url, + 'items': [], + 'actor': actor, + 'page_size': 5, + } + data = serializers.PaginatedCollectionSerializer(conf).data + r_mock.get(url, json=data) + + result = library.get_library_data(url) + for f in ['totalItems', 'actor', 'id', 'type']: + assert result[f] == data[f] + + +def test_get_library_data_requires_authentication(r_mock, factories): + url = 'https://test.library' + r_mock.get(url, status_code=403) + result = library.get_library_data(url) + assert result['errors'] == ['This library requires authentication'] diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index b3fd85910..0b58e20f1 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -164,3 +164,18 @@ def test_library_actor_includes_library_link(db, settings, api_client): ] assert response.status_code == 200 assert response.data['url'] == expected_links + + +def test_can_scan_library(superuser_api_client, mocker): + result = {'test': 'test'} + scan = mocker.patch( + 'funkwhale_api.federation.library.scan_from_account_name', + return_value=result) + + url = reverse('api:v1:federation:libraries-scan') + response = superuser_api_client.get( + url, data={'account': 'test@test.library'}) + + assert response.status_code == 200 + assert response.data == result + scan.assert_called_once_with('test@test.library')