Added remote library scanning logic end endpoint
This commit is contained in:
parent
836e813955
commit
097707dec4
|
@ -32,6 +32,10 @@ v1_patterns += [
|
||||||
include(
|
include(
|
||||||
('funkwhale_api.instance.urls', 'instance'),
|
('funkwhale_api.instance.urls', 'instance'),
|
||||||
namespace='instance')),
|
namespace='instance')),
|
||||||
|
url(r'^federation/',
|
||||||
|
include(
|
||||||
|
('funkwhale_api.federation.api_urls', 'federation'),
|
||||||
|
namespace='federation')),
|
||||||
url(r'^providers/',
|
url(r'^providers/',
|
||||||
include(
|
include(
|
||||||
('funkwhale_api.providers.urls', 'providers'),
|
('funkwhale_api.providers.urls', 'providers'),
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -27,8 +27,10 @@ class ActorSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.URLField(source='url')
|
id = serializers.URLField(source='url')
|
||||||
outbox = serializers.URLField(source='outbox_url')
|
outbox = serializers.URLField(source='outbox_url')
|
||||||
inbox = serializers.URLField(source='inbox_url')
|
inbox = serializers.URLField(source='inbox_url')
|
||||||
following = serializers.URLField(source='following_url', required=False)
|
following = serializers.URLField(
|
||||||
followers = serializers.URLField(source='followers_url', required=False)
|
source='following_url', required=False, allow_null=True)
|
||||||
|
followers = serializers.URLField(
|
||||||
|
source='followers_url', required=False, allow_null=True)
|
||||||
preferredUsername = serializers.CharField(
|
preferredUsername = serializers.CharField(
|
||||||
source='preferred_username', required=False)
|
source='preferred_username', required=False)
|
||||||
publicKey = serializers.JSONField(source='public_key', required=False)
|
publicKey = serializers.JSONField(source='public_key', required=False)
|
||||||
|
@ -94,6 +96,31 @@ class ActorSerializer(serializers.ModelSerializer):
|
||||||
return value[:500]
|
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):
|
class FollowSerializer(serializers.ModelSerializer):
|
||||||
# left maps to activitypub fields, right to our internal models
|
# left maps to activitypub fields, right to our internal models
|
||||||
id = serializers.URLField(source='get_federation_url')
|
id = serializers.URLField(source='get_federation_url')
|
||||||
|
@ -226,7 +253,6 @@ OBJECT_SERIALIZERS = {
|
||||||
class PaginatedCollectionSerializer(serializers.Serializer):
|
class PaginatedCollectionSerializer(serializers.Serializer):
|
||||||
type = serializers.ChoiceField(choices=['Collection'])
|
type = serializers.ChoiceField(choices=['Collection'])
|
||||||
totalItems = serializers.IntegerField(min_value=0)
|
totalItems = serializers.IntegerField(min_value=0)
|
||||||
items = serializers.ListField()
|
|
||||||
actor = serializers.URLField()
|
actor = serializers.URLField()
|
||||||
id = serializers.URLField()
|
id = serializers.URLField()
|
||||||
|
|
||||||
|
|
|
@ -4,15 +4,18 @@ from django.core import paginator
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from rest_framework import viewsets
|
from rest_framework import permissions as rest_permissions
|
||||||
from rest_framework import views
|
|
||||||
from rest_framework import response
|
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 rest_framework.decorators import list_route, detail_route
|
||||||
|
|
||||||
from funkwhale_api.music.models import TrackFile
|
from funkwhale_api.music.models import TrackFile
|
||||||
|
|
||||||
from . import actors
|
from . import actors
|
||||||
from . import authentication
|
from . import authentication
|
||||||
|
from . import library
|
||||||
|
from . import models
|
||||||
from . import permissions
|
from . import permissions
|
||||||
from . import renderers
|
from . import renderers
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
@ -154,3 +157,18 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||||
return response.Response(status=404)
|
return response.Response(status=404)
|
||||||
|
|
||||||
return response.Response(data)
|
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)
|
||||||
|
|
|
@ -36,7 +36,7 @@ def clean_acct(acct_string, ensure_local=True):
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
'Invalid hostname {}'.format(hostname))
|
'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')
|
raise forms.ValidationError('Invalid username')
|
||||||
|
|
||||||
return username, hostname
|
return username, hostname
|
||||||
|
|
|
@ -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']
|
|
@ -164,3 +164,18 @@ def test_library_actor_includes_library_link(db, settings, api_client):
|
||||||
]
|
]
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data['url'] == expected_links
|
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')
|
||||||
|
|
Loading…
Reference in New Issue