Merge branch 'federation-follow-request' into 'develop'
Federation follow request See merge request funkwhale/funkwhale!123
This commit is contained in:
commit
99200ad077
|
@ -30,7 +30,12 @@ FUNKWHALE_HOSTNAME = urlsplit(FUNKWHALE_URL).netloc
|
||||||
|
|
||||||
FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
|
FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
|
||||||
FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
|
FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
|
||||||
|
FEDERATION_COLLECTION_PAGE_SIZE = env.int(
|
||||||
|
'FEDERATION_COLLECTION_PAGE_SIZE', default=50
|
||||||
|
)
|
||||||
|
FEDERATION_MUSIC_NEEDS_APPROVAL = env.bool(
|
||||||
|
'FEDERATION_MUSIC_NEEDS_APPROVAL', default=True
|
||||||
|
)
|
||||||
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
|
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
|
||||||
|
|
||||||
# APP CONFIGURATION
|
# APP CONFIGURATION
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from urllib.parse import urlencode, parse_qs, urlsplit, urlunsplit
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
@ -25,3 +26,20 @@ def on_commit(f, *args, **kwargs):
|
||||||
return transaction.on_commit(
|
return transaction.on_commit(
|
||||||
lambda: f(*args, **kwargs)
|
lambda: f(*args, **kwargs)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_query_parameter(url, **kwargs):
|
||||||
|
"""Given a URL, set or replace a query parameter and return the
|
||||||
|
modified URL.
|
||||||
|
|
||||||
|
>>> set_query_parameter('http://example.com?foo=bar&biz=baz', 'foo', 'stuff')
|
||||||
|
'http://example.com?foo=stuff&biz=baz'
|
||||||
|
"""
|
||||||
|
scheme, netloc, path, query_string, fragment = urlsplit(url)
|
||||||
|
query_params = parse_qs(query_string)
|
||||||
|
|
||||||
|
for param_name, param_value in kwargs.items():
|
||||||
|
query_params[param_name] = [param_value]
|
||||||
|
new_query_string = urlencode(query_params, doseq=True)
|
||||||
|
|
||||||
|
return urlunsplit((scheme, netloc, path, new_query_string, fragment))
|
||||||
|
|
|
@ -2,7 +2,9 @@ import logging
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import requests_http_signature
|
import requests_http_signature
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from . import models
|
||||||
from . import signing
|
from . import signing
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -42,33 +44,26 @@ ACTIVITY_TYPES = [
|
||||||
OBJECT_TYPES = [
|
OBJECT_TYPES = [
|
||||||
'Article',
|
'Article',
|
||||||
'Audio',
|
'Audio',
|
||||||
|
'Collection',
|
||||||
'Document',
|
'Document',
|
||||||
'Event',
|
'Event',
|
||||||
'Image',
|
'Image',
|
||||||
'Note',
|
'Note',
|
||||||
|
'OrderedCollection',
|
||||||
'Page',
|
'Page',
|
||||||
'Place',
|
'Place',
|
||||||
'Profile',
|
'Profile',
|
||||||
'Relationship',
|
'Relationship',
|
||||||
'Tombstone',
|
'Tombstone',
|
||||||
'Video',
|
'Video',
|
||||||
]
|
] + ACTIVITY_TYPES
|
||||||
|
|
||||||
|
|
||||||
def deliver(activity, on_behalf_of, to=[]):
|
def deliver(activity, on_behalf_of, to=[]):
|
||||||
from . import actors
|
from . import actors
|
||||||
logger.info('Preparing activity delivery to %s', to)
|
logger.info('Preparing activity delivery to %s', to)
|
||||||
auth = requests_http_signature.HTTPSignatureAuth(
|
auth = signing.get_auth(
|
||||||
use_auth_header=False,
|
on_behalf_of.private_key, on_behalf_of.private_key_id)
|
||||||
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:
|
for url in to:
|
||||||
recipient_actor = actors.get_actor(url)
|
recipient_actor = actors.get_actor(url)
|
||||||
logger.debug('delivering to %s', recipient_actor.inbox_url)
|
logger.debug('delivering to %s', recipient_actor.inbox_url)
|
||||||
|
@ -83,3 +78,68 @@ def deliver(activity, on_behalf_of, to=[]):
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger.debug('Remote answered with %s', response.status_code)
|
logger.debug('Remote answered with %s', response.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def get_follow(follow_id, follower, followed):
|
||||||
|
return {
|
||||||
|
'@context': [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
'https://w3id.org/security/v1',
|
||||||
|
{}
|
||||||
|
],
|
||||||
|
'actor': follower.url,
|
||||||
|
'id': follower.url + '#follows/{}'.format(follow_id),
|
||||||
|
'object': followed.url,
|
||||||
|
'type': 'Follow'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_undo(id, actor, object):
|
||||||
|
return {
|
||||||
|
'@context': [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
'https://w3id.org/security/v1',
|
||||||
|
{}
|
||||||
|
],
|
||||||
|
'type': 'Undo',
|
||||||
|
'id': id + '/undo',
|
||||||
|
'actor': actor.url,
|
||||||
|
'object': object,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_accept_follow(accept_id, accept_actor, follow, follow_actor):
|
||||||
|
return {
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
"https://w3id.org/security/v1",
|
||||||
|
{}
|
||||||
|
],
|
||||||
|
"id": accept_actor.url + '#accepts/follows/{}'.format(
|
||||||
|
accept_id),
|
||||||
|
"type": "Accept",
|
||||||
|
"actor": accept_actor.url,
|
||||||
|
"object": {
|
||||||
|
"id": follow['id'],
|
||||||
|
"type": "Follow",
|
||||||
|
"actor": follow_actor.url,
|
||||||
|
"object": accept_actor.url
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def accept_follow(target, follow, actor):
|
||||||
|
accept_uuid = uuid.uuid4()
|
||||||
|
accept = get_accept_follow(
|
||||||
|
accept_id=accept_uuid,
|
||||||
|
accept_actor=target,
|
||||||
|
follow=follow,
|
||||||
|
follow_actor=actor)
|
||||||
|
deliver(
|
||||||
|
accept,
|
||||||
|
to=[actor.url],
|
||||||
|
on_behalf_of=target)
|
||||||
|
return models.Follow.objects.get_or_create(
|
||||||
|
actor=actor,
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
|
import uuid
|
||||||
import xml
|
import xml
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
@ -11,8 +13,10 @@ 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 keys
|
||||||
from . import models
|
from . import models
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
from . import signing
|
||||||
from . import utils
|
from . import utils
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -47,31 +51,48 @@ def get_actor(actor_url):
|
||||||
|
|
||||||
class SystemActor(object):
|
class SystemActor(object):
|
||||||
additional_attributes = {}
|
additional_attributes = {}
|
||||||
|
manually_approves_followers = False
|
||||||
|
|
||||||
|
def get_request_auth(self):
|
||||||
|
actor = self.get_actor_instance()
|
||||||
|
return signing.get_auth(
|
||||||
|
actor.private_key, actor.private_key_id)
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
actor = self.get_actor_instance()
|
||||||
|
serializer = serializers.ActorSerializer(actor)
|
||||||
|
return serializer.data
|
||||||
|
|
||||||
def get_actor_instance(self):
|
def get_actor_instance(self):
|
||||||
a = models.Actor(
|
try:
|
||||||
**self.get_instance_argument(
|
return models.Actor.objects.get(url=self.get_actor_url())
|
||||||
self.id,
|
except models.Actor.DoesNotExist:
|
||||||
name=self.name,
|
pass
|
||||||
summary=self.summary,
|
private, public = keys.get_key_pair()
|
||||||
**self.additional_attributes
|
args = self.get_instance_argument(
|
||||||
)
|
self.id,
|
||||||
|
name=self.name,
|
||||||
|
summary=self.summary,
|
||||||
|
**self.additional_attributes
|
||||||
)
|
)
|
||||||
a.pk = self.id
|
args['private_key'] = private.decode('utf-8')
|
||||||
return a
|
args['public_key'] = public.decode('utf-8')
|
||||||
|
return models.Actor.objects.create(**args)
|
||||||
|
|
||||||
|
def get_actor_url(self):
|
||||||
|
return utils.full_url(
|
||||||
|
reverse(
|
||||||
|
'federation:instance-actors-detail',
|
||||||
|
kwargs={'actor': self.id}))
|
||||||
|
|
||||||
def get_instance_argument(self, id, name, summary, **kwargs):
|
def get_instance_argument(self, id, name, summary, **kwargs):
|
||||||
preferences = global_preferences_registry.manager()
|
|
||||||
p = {
|
p = {
|
||||||
'preferred_username': id,
|
'preferred_username': id,
|
||||||
'domain': settings.FEDERATION_HOSTNAME,
|
'domain': settings.FEDERATION_HOSTNAME,
|
||||||
'type': 'Person',
|
'type': 'Person',
|
||||||
'name': name.format(host=settings.FEDERATION_HOSTNAME),
|
'name': name.format(host=settings.FEDERATION_HOSTNAME),
|
||||||
'manually_approves_followers': True,
|
'manually_approves_followers': True,
|
||||||
'url': utils.full_url(
|
'url': self.get_actor_url(),
|
||||||
reverse(
|
|
||||||
'federation:instance-actors-detail',
|
|
||||||
kwargs={'actor': id})),
|
|
||||||
'shared_inbox_url': utils.full_url(
|
'shared_inbox_url': utils.full_url(
|
||||||
reverse(
|
reverse(
|
||||||
'federation:instance-actors-inbox',
|
'federation:instance-actors-inbox',
|
||||||
|
@ -84,8 +105,6 @@ class SystemActor(object):
|
||||||
reverse(
|
reverse(
|
||||||
'federation:instance-actors-outbox',
|
'federation:instance-actors-outbox',
|
||||||
kwargs={'actor': id})),
|
kwargs={'actor': id})),
|
||||||
'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)
|
||||||
|
@ -95,7 +114,7 @@ class SystemActor(object):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def post_inbox(self, data, actor=None):
|
def post_inbox(self, data, actor=None):
|
||||||
raise NotImplementedError
|
return self.handle(data, actor=actor)
|
||||||
|
|
||||||
def get_outbox(self, data, actor=None):
|
def get_outbox(self, data, actor=None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
@ -103,6 +122,62 @@ class SystemActor(object):
|
||||||
def post_outbox(self, data, actor=None):
|
def post_outbox(self, data, actor=None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def handle(self, data, actor=None):
|
||||||
|
"""
|
||||||
|
Main entrypoint for handling activities posted to the
|
||||||
|
actor's inbox
|
||||||
|
"""
|
||||||
|
logger.info('Received activity on %s inbox', self.id)
|
||||||
|
|
||||||
|
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.data
|
||||||
|
try:
|
||||||
|
handler = getattr(
|
||||||
|
self, 'handle_{}'.format(ac['type'].lower()))
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
logger.debug(
|
||||||
|
'No handler for activity %s', ac['type'])
|
||||||
|
return
|
||||||
|
|
||||||
|
return handler(data, actor)
|
||||||
|
|
||||||
|
def handle_follow(self, ac, sender):
|
||||||
|
system_actor = self.get_actor_instance()
|
||||||
|
if self.manually_approves_followers:
|
||||||
|
fr, created = models.FollowRequest.objects.get_or_create(
|
||||||
|
actor=sender,
|
||||||
|
target=system_actor,
|
||||||
|
approved=None,
|
||||||
|
)
|
||||||
|
return fr
|
||||||
|
|
||||||
|
return activity.accept_follow(
|
||||||
|
system_actor, ac, sender
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_undo_follow(self, ac, sender):
|
||||||
|
actor = self.get_actor_instance()
|
||||||
|
models.Follow.objects.filter(
|
||||||
|
actor=sender,
|
||||||
|
target=actor,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
def handle_undo(self, ac, sender):
|
||||||
|
if ac['object']['type'] != 'Follow':
|
||||||
|
return
|
||||||
|
|
||||||
|
if ac['object']['actor'] != sender.url:
|
||||||
|
# not the same actor, permission issue
|
||||||
|
return
|
||||||
|
|
||||||
|
self.handle_undo_follow(ac, sender)
|
||||||
|
|
||||||
|
|
||||||
class LibraryActor(SystemActor):
|
class LibraryActor(SystemActor):
|
||||||
id = 'library'
|
id = 'library'
|
||||||
|
@ -112,6 +187,62 @@ class LibraryActor(SystemActor):
|
||||||
'manually_approves_followers': True
|
'manually_approves_followers': True
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
data = super().serialize()
|
||||||
|
urls = data.setdefault('url', [])
|
||||||
|
urls.append({
|
||||||
|
'type': 'Link',
|
||||||
|
'mediaType': 'application/activity+json',
|
||||||
|
'name': 'library',
|
||||||
|
'href': utils.full_url(reverse('federation:music:files-list'))
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def manually_approves_followers(self):
|
||||||
|
return settings.FEDERATION_MUSIC_NEEDS_APPROVAL
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle_create(self, ac, sender):
|
||||||
|
try:
|
||||||
|
remote_library = models.Library.objects.get(
|
||||||
|
actor=sender,
|
||||||
|
federation_enabled=True,
|
||||||
|
)
|
||||||
|
except models.Library.DoesNotExist:
|
||||||
|
logger.info(
|
||||||
|
'Skipping import, we\'re not following %s', sender.url)
|
||||||
|
return
|
||||||
|
|
||||||
|
if ac['object']['type'] != 'Collection':
|
||||||
|
return
|
||||||
|
|
||||||
|
if ac['object']['totalItems'] <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = ac['object']['items']
|
||||||
|
except KeyError:
|
||||||
|
logger.warning('No items in collection!')
|
||||||
|
return
|
||||||
|
|
||||||
|
item_serializers = [
|
||||||
|
serializers.AudioSerializer(
|
||||||
|
data=i, context={'library': remote_library})
|
||||||
|
for i in items
|
||||||
|
]
|
||||||
|
|
||||||
|
valid_serializers = []
|
||||||
|
for s in item_serializers:
|
||||||
|
if s.is_valid():
|
||||||
|
valid_serializers.append(s)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
'Skipping invalid item %s, %s', s.initial_data, s.errors)
|
||||||
|
|
||||||
|
for s in valid_serializers:
|
||||||
|
s.save()
|
||||||
|
|
||||||
|
|
||||||
class TestActor(SystemActor):
|
class TestActor(SystemActor):
|
||||||
id = 'test'
|
id = 'test'
|
||||||
|
@ -123,40 +254,24 @@ class TestActor(SystemActor):
|
||||||
additional_attributes = {
|
additional_attributes = {
|
||||||
'manually_approves_followers': False
|
'manually_approves_followers': False
|
||||||
}
|
}
|
||||||
|
manually_approves_followers = False
|
||||||
|
|
||||||
def get_outbox(self, data, actor=None):
|
def get_outbox(self, data, actor=None):
|
||||||
return {
|
return {
|
||||||
"@context": [
|
"@context": [
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
"https://w3id.org/security/v1",
|
"https://w3id.org/security/v1",
|
||||||
{}
|
{}
|
||||||
],
|
],
|
||||||
"id": utils.full_url(
|
"id": utils.full_url(
|
||||||
reverse(
|
reverse(
|
||||||
'federation:instance-actors-outbox',
|
'federation:instance-actors-outbox',
|
||||||
kwargs={'actor': self.id})),
|
kwargs={'actor': self.id})),
|
||||||
"type": "OrderedCollection",
|
"type": "OrderedCollection",
|
||||||
"totalItems": 0,
|
"totalItems": 0,
|
||||||
"orderedItems": []
|
"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
|
|
||||||
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':
|
|
||||||
self.handle_ping(ac, actor)
|
|
||||||
|
|
||||||
def parse_command(self, message):
|
def parse_command(self, message):
|
||||||
"""
|
"""
|
||||||
Remove any links or fancy markup to extract /command from
|
Remove any links or fancy markup to extract /command from
|
||||||
|
@ -168,7 +283,16 @@ class TestActor(SystemActor):
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return
|
return
|
||||||
|
|
||||||
def handle_ping(self, ac, sender):
|
def handle_create(self, ac, sender):
|
||||||
|
if ac['object']['type'] != 'Note':
|
||||||
|
return
|
||||||
|
|
||||||
|
# we received a toot \o/
|
||||||
|
command = self.parse_command(ac['object']['content'])
|
||||||
|
logger.debug('Parsed command: %s', command)
|
||||||
|
if command != 'ping':
|
||||||
|
return
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
test_actor = self.get_actor_instance()
|
test_actor = self.get_actor_instance()
|
||||||
reply_url = 'https://{}/activities/note/{}'.format(
|
reply_url = 'https://{}/activities/note/{}'.format(
|
||||||
|
@ -179,10 +303,10 @@ class TestActor(SystemActor):
|
||||||
)
|
)
|
||||||
reply_activity = {
|
reply_activity = {
|
||||||
"@context": [
|
"@context": [
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
"https://w3id.org/security/v1",
|
"https://w3id.org/security/v1",
|
||||||
{}
|
{}
|
||||||
],
|
],
|
||||||
'type': 'Create',
|
'type': 'Create',
|
||||||
'actor': test_actor.url,
|
'actor': test_actor.url,
|
||||||
'id': '{}/activity'.format(reply_url),
|
'id': '{}/activity'.format(reply_url),
|
||||||
|
@ -214,6 +338,43 @@ class TestActor(SystemActor):
|
||||||
to=[ac['actor']],
|
to=[ac['actor']],
|
||||||
on_behalf_of=test_actor)
|
on_behalf_of=test_actor)
|
||||||
|
|
||||||
|
def handle_follow(self, ac, sender):
|
||||||
|
super().handle_follow(ac, sender)
|
||||||
|
# also, we follow back
|
||||||
|
test_actor = self.get_actor_instance()
|
||||||
|
follow_uuid = uuid.uuid4()
|
||||||
|
follow = activity.get_follow(
|
||||||
|
follow_id=follow_uuid,
|
||||||
|
follower=test_actor,
|
||||||
|
followed=sender)
|
||||||
|
activity.deliver(
|
||||||
|
follow,
|
||||||
|
to=[ac['actor']],
|
||||||
|
on_behalf_of=test_actor)
|
||||||
|
|
||||||
|
def handle_undo_follow(self, ac, sender):
|
||||||
|
super().handle_undo_follow(ac, sender)
|
||||||
|
actor = self.get_actor_instance()
|
||||||
|
# we also unfollow the sender, if possible
|
||||||
|
try:
|
||||||
|
follow = models.Follow.objects.get(
|
||||||
|
target=sender,
|
||||||
|
actor=actor,
|
||||||
|
)
|
||||||
|
except models.Follow.DoesNotExist:
|
||||||
|
return
|
||||||
|
undo = activity.get_undo(
|
||||||
|
id=follow.get_federation_url(),
|
||||||
|
actor=actor,
|
||||||
|
object=serializers.FollowSerializer(follow).data,
|
||||||
|
)
|
||||||
|
follow.delete()
|
||||||
|
activity.deliver(
|
||||||
|
undo,
|
||||||
|
to=[sender.url],
|
||||||
|
on_behalf_of=actor)
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_ACTORS = {
|
SYSTEM_ACTORS = {
|
||||||
'library': LibraryActor(),
|
'library': LibraryActor(),
|
||||||
'test': TestActor(),
|
'test': TestActor(),
|
||||||
|
|
|
@ -7,6 +7,7 @@ from rest_framework import exceptions
|
||||||
|
|
||||||
from . import actors
|
from . import actors
|
||||||
from . import keys
|
from . import keys
|
||||||
|
from . import models
|
||||||
from . import serializers
|
from . import serializers
|
||||||
from . import signing
|
from . import signing
|
||||||
from . import utils
|
from . import utils
|
||||||
|
@ -42,11 +43,16 @@ class SignatureAuthentication(authentication.BaseAuthentication):
|
||||||
except cryptography.exceptions.InvalidSignature:
|
except cryptography.exceptions.InvalidSignature:
|
||||||
raise exceptions.AuthenticationFailed('Invalid signature')
|
raise exceptions.AuthenticationFailed('Invalid signature')
|
||||||
|
|
||||||
return serializer.build()
|
try:
|
||||||
|
return models.Actor.objects.get(url=actor_data['id'])
|
||||||
|
except models.Actor.DoesNotExist:
|
||||||
|
return serializer.save()
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
setattr(request, 'actor', None)
|
setattr(request, 'actor', None)
|
||||||
actor = self.authenticate_actor(request)
|
actor = self.authenticate_actor(request)
|
||||||
|
if not actor:
|
||||||
|
return
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
setattr(request, 'actor', actor)
|
setattr(request, 'actor', actor)
|
||||||
return (user, None)
|
return (user, None)
|
||||||
|
|
|
@ -4,31 +4,3 @@ from dynamic_preferences import types
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
federation = types.Section('federation')
|
federation = types.Section('federation')
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
|
||||||
class FederationPrivateKey(types.StringPreference):
|
|
||||||
show_in_api = False
|
|
||||||
section = federation
|
|
||||||
name = 'private_key'
|
|
||||||
default = ''
|
|
||||||
help_text = (
|
|
||||||
'Instance private key, used for signing federation HTTP requests'
|
|
||||||
)
|
|
||||||
verbose_name = (
|
|
||||||
'Instance private key (keep it secret, do not change it)'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
|
||||||
class FederationPublicKey(types.StringPreference):
|
|
||||||
show_in_api = False
|
|
||||||
section = federation
|
|
||||||
name = 'public_key'
|
|
||||||
default = ''
|
|
||||||
help_text = (
|
|
||||||
'Instance public key, used for signing federation HTTP requests'
|
|
||||||
)
|
|
||||||
verbose_name = (
|
|
||||||
'Instance public key (do not change it)'
|
|
||||||
)
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import factory
|
import factory
|
||||||
import requests
|
import requests
|
||||||
import requests_http_signature
|
import requests_http_signature
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from funkwhale_api.factories import registry
|
from funkwhale_api.factories import registry
|
||||||
|
|
||||||
|
@ -51,9 +53,23 @@ class SignedRequestFactory(factory.Factory):
|
||||||
self.headers.update(default_headers)
|
self.headers.update(default_headers)
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register(name='federation.Link')
|
||||||
|
class LinkFactory(factory.Factory):
|
||||||
|
type = 'Link'
|
||||||
|
href = factory.Faker('url')
|
||||||
|
mediaType = 'text/html'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = dict
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
audio = factory.Trait(
|
||||||
|
mediaType=factory.Iterator(['audio/mp3', 'audio/ogg'])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
class ActorFactory(factory.DjangoModelFactory):
|
class ActorFactory(factory.DjangoModelFactory):
|
||||||
|
|
||||||
public_key = None
|
public_key = None
|
||||||
private_key = None
|
private_key = None
|
||||||
preferred_username = factory.Faker('user_name')
|
preferred_username = factory.Faker('user_name')
|
||||||
|
@ -66,6 +82,12 @@ class ActorFactory(factory.DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Actor
|
model = models.Actor
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
local = factory.Trait(
|
||||||
|
domain=factory.LazyAttribute(
|
||||||
|
lambda o: settings.FEDERATION_HOSTNAME)
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _generate(cls, create, attrs):
|
def _generate(cls, create, attrs):
|
||||||
has_public = attrs.get('public_key') is not None
|
has_public = attrs.get('public_key') is not None
|
||||||
|
@ -77,6 +99,102 @@ class ActorFactory(factory.DjangoModelFactory):
|
||||||
return super()._generate(create, attrs)
|
return super()._generate(create, attrs)
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
class FollowFactory(factory.DjangoModelFactory):
|
||||||
|
target = factory.SubFactory(ActorFactory)
|
||||||
|
actor = factory.SubFactory(ActorFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Follow
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
local = factory.Trait(
|
||||||
|
actor=factory.SubFactory(ActorFactory, local=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
class FollowRequestFactory(factory.DjangoModelFactory):
|
||||||
|
target = factory.SubFactory(ActorFactory)
|
||||||
|
actor = factory.SubFactory(ActorFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.FollowRequest
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
class LibraryFactory(factory.DjangoModelFactory):
|
||||||
|
actor = factory.SubFactory(ActorFactory)
|
||||||
|
url = factory.Faker('url')
|
||||||
|
federation_enabled = True
|
||||||
|
download_files = False
|
||||||
|
autoimport = False
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Library
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistMetadataFactory(factory.Factory):
|
||||||
|
name = factory.Faker('name')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = dict
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
musicbrainz = factory.Trait(
|
||||||
|
musicbrainz_id=factory.Faker('uuid4')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseMetadataFactory(factory.Factory):
|
||||||
|
title = factory.Faker('sentence')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = dict
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
musicbrainz = factory.Trait(
|
||||||
|
musicbrainz_id=factory.Faker('uuid4')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingMetadataFactory(factory.Factory):
|
||||||
|
title = factory.Faker('sentence')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = dict
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
musicbrainz = factory.Trait(
|
||||||
|
musicbrainz_id=factory.Faker('uuid4')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register(name='federation.LibraryTrackMetadata')
|
||||||
|
class LibraryTrackMetadataFactory(factory.Factory):
|
||||||
|
artist = factory.SubFactory(ArtistMetadataFactory)
|
||||||
|
recording = factory.SubFactory(RecordingMetadataFactory)
|
||||||
|
release = factory.SubFactory(ReleaseMetadataFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = dict
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
class LibraryTrackFactory(factory.DjangoModelFactory):
|
||||||
|
library = factory.SubFactory(LibraryFactory)
|
||||||
|
url = factory.Faker('url')
|
||||||
|
title = factory.Faker('sentence')
|
||||||
|
artist_name = factory.Faker('sentence')
|
||||||
|
album_title = factory.Faker('sentence')
|
||||||
|
audio_url = factory.Faker('url')
|
||||||
|
audio_mimetype = 'audio/ogg'
|
||||||
|
metadata = factory.SubFactory(LibraryTrackMetadataFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.LibraryTrack
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='federation.Note')
|
@registry.register(name='federation.Note')
|
||||||
class NoteFactory(factory.Factory):
|
class NoteFactory(factory.Factory):
|
||||||
type = 'Note'
|
type = 'Note'
|
||||||
|
@ -89,3 +207,51 @@ class NoteFactory(factory.Factory):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = dict
|
model = dict
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register(name='federation.Activity')
|
||||||
|
class ActivityFactory(factory.Factory):
|
||||||
|
type = 'Create'
|
||||||
|
id = factory.Faker('url')
|
||||||
|
published = factory.LazyFunction(
|
||||||
|
lambda: timezone.now().isoformat()
|
||||||
|
)
|
||||||
|
actor = factory.Faker('url')
|
||||||
|
object = factory.SubFactory(
|
||||||
|
NoteFactory,
|
||||||
|
actor=factory.SelfAttribute('..actor'),
|
||||||
|
published=factory.SelfAttribute('..published'))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = dict
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register(name='federation.AudioMetadata')
|
||||||
|
class AudioMetadataFactory(factory.Factory):
|
||||||
|
recording = factory.LazyAttribute(
|
||||||
|
lambda o: 'https://musicbrainz.org/recording/{}'.format(uuid.uuid4())
|
||||||
|
)
|
||||||
|
artist = factory.LazyAttribute(
|
||||||
|
lambda o: 'https://musicbrainz.org/artist/{}'.format(uuid.uuid4())
|
||||||
|
)
|
||||||
|
release = factory.LazyAttribute(
|
||||||
|
lambda o: 'https://musicbrainz.org/release/{}'.format(uuid.uuid4())
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = dict
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register(name='federation.Audio')
|
||||||
|
class AudioFactory(factory.Factory):
|
||||||
|
type = 'Audio'
|
||||||
|
id = factory.Faker('url')
|
||||||
|
published = factory.LazyFunction(
|
||||||
|
lambda: timezone.now().isoformat()
|
||||||
|
)
|
||||||
|
actor = factory.Faker('url')
|
||||||
|
url = factory.SubFactory(LinkFactory, audio=True)
|
||||||
|
metadata = factory.SubFactory(LibraryTrackMetadataFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = dict
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
|
||||||
|
|
||||||
from funkwhale_api.federation import keys
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = (
|
|
||||||
'Generate a public/private key pair for your instance,'
|
|
||||||
' for federation purposes. If a key pair already exists, does nothing.'
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
'--replace',
|
|
||||||
action='store_true',
|
|
||||||
dest='replace',
|
|
||||||
default=False,
|
|
||||||
help='Replace existing key pair, if any',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--noinput', '--no-input', action='store_false', dest='interactive',
|
|
||||||
help="Do NOT prompt the user for input of any kind.",
|
|
||||||
)
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
preferences = global_preferences_registry.manager()
|
|
||||||
existing_public = preferences['federation__public_key']
|
|
||||||
existing_private = preferences['federation__public_key']
|
|
||||||
|
|
||||||
if existing_public or existing_private and not options['replace']:
|
|
||||||
raise CommandError(
|
|
||||||
'Keys are already present! '
|
|
||||||
'Replace them with --replace if you know what you are doing.')
|
|
||||||
|
|
||||||
if options['interactive']:
|
|
||||||
message = (
|
|
||||||
'Are you sure you want to do this?\n\n'
|
|
||||||
"Type 'yes' to continue, or 'no' to cancel: "
|
|
||||||
)
|
|
||||||
if input(''.join(message)) != 'yes':
|
|
||||||
raise CommandError("Operation cancelled.")
|
|
||||||
private, public = keys.get_key_pair()
|
|
||||||
preferences['federation__public_key'] = public.decode('utf-8')
|
|
||||||
preferences['federation__private_key'] = private.decode('utf-8')
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
'Your new key pair was generated.'
|
|
||||||
'Your public key is now:\n\n{}'.format(public.decode('utf-8'))
|
|
||||||
)
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 2.0.3 on 2018-04-03 16:20
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('federation', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='actor',
|
||||||
|
unique_together={('domain', 'preferred_username')},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Generated by Django 2.0.3 on 2018-04-07 10:10
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields.jsonb
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def delete_system_actors(apps, schema_editor):
|
||||||
|
"""Revert site domain and name to default."""
|
||||||
|
Actor = apps.get_model("federation", "Actor")
|
||||||
|
Actor.objects.filter(preferred_username__in=['test', 'library']).delete()
|
||||||
|
|
||||||
|
|
||||||
|
def backward(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('federation', '0002_auto_20180403_1620'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(delete_system_actors, backward),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Follow',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('modification_date', models.DateTimeField(auto_now=True)),
|
||||||
|
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emitted_follows', to='federation.Actor')),
|
||||||
|
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_follows', to='federation.Actor')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FollowRequest',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('modification_date', models.DateTimeField(auto_now=True)),
|
||||||
|
('approved', models.NullBooleanField(default=None)),
|
||||||
|
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emmited_follow_requests', to='federation.Actor')),
|
||||||
|
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_follow_requests', to='federation.Actor')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Library',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('modification_date', models.DateTimeField(auto_now=True)),
|
||||||
|
('fetched_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4)),
|
||||||
|
('url', models.URLField()),
|
||||||
|
('federation_enabled', models.BooleanField()),
|
||||||
|
('download_files', models.BooleanField()),
|
||||||
|
('autoimport', models.BooleanField()),
|
||||||
|
('tracks_count', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('actor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='library', to='federation.Actor')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LibraryTrack',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('url', models.URLField(unique=True)),
|
||||||
|
('audio_url', models.URLField()),
|
||||||
|
('audio_mimetype', models.CharField(max_length=200)),
|
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('modification_date', models.DateTimeField(auto_now=True)),
|
||||||
|
('fetched_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('published_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('artist_name', models.CharField(max_length=500)),
|
||||||
|
('album_title', models.CharField(max_length=500)),
|
||||||
|
('title', models.CharField(max_length=500)),
|
||||||
|
('metadata', django.contrib.postgres.fields.jsonb.JSONField(default={}, max_length=10000)),
|
||||||
|
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracks', to='federation.Library')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='actor',
|
||||||
|
name='followers',
|
||||||
|
field=models.ManyToManyField(related_name='following', through='federation.Follow', to='federation.Actor'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='follow',
|
||||||
|
unique_together={('actor', 'target')},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,4 +1,7 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.postgres.fields import JSONField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
@ -12,6 +15,8 @@ TYPE_CHOICES = [
|
||||||
|
|
||||||
|
|
||||||
class Actor(models.Model):
|
class Actor(models.Model):
|
||||||
|
ap_type = 'Actor'
|
||||||
|
|
||||||
url = models.URLField(unique=True, max_length=500, db_index=True)
|
url = models.URLField(unique=True, max_length=500, db_index=True)
|
||||||
outbox_url = models.URLField(max_length=500)
|
outbox_url = models.URLField(max_length=500)
|
||||||
inbox_url = models.URLField(max_length=500)
|
inbox_url = models.URLField(max_length=500)
|
||||||
|
@ -31,6 +36,16 @@ class Actor(models.Model):
|
||||||
last_fetch_date = models.DateTimeField(
|
last_fetch_date = models.DateTimeField(
|
||||||
default=timezone.now)
|
default=timezone.now)
|
||||||
manually_approves_followers = models.NullBooleanField(default=None)
|
manually_approves_followers = models.NullBooleanField(default=None)
|
||||||
|
followers = models.ManyToManyField(
|
||||||
|
to='self',
|
||||||
|
symmetrical=False,
|
||||||
|
through='Follow',
|
||||||
|
through_fields=('target', 'actor'),
|
||||||
|
related_name='following',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ['domain', 'preferred_username']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def webfinger_subject(self):
|
def webfinger_subject(self):
|
||||||
|
@ -57,3 +72,127 @@ class Actor(models.Model):
|
||||||
setattr(self, field, v.lower())
|
setattr(self, field, v.lower())
|
||||||
|
|
||||||
super().save(**kwargs)
|
super().save(**kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_local(self):
|
||||||
|
return self.domain == settings.FEDERATION_HOSTNAME
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_system(self):
|
||||||
|
from . import actors
|
||||||
|
return all([
|
||||||
|
settings.FEDERATION_HOSTNAME == self.domain,
|
||||||
|
self.preferred_username in actors.SYSTEM_ACTORS
|
||||||
|
])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def system_conf(self):
|
||||||
|
from . import actors
|
||||||
|
if self.is_system:
|
||||||
|
return actors.SYSTEM_ACTORS[self.preferred_username]
|
||||||
|
|
||||||
|
|
||||||
|
class Follow(models.Model):
|
||||||
|
ap_type = 'Follow'
|
||||||
|
|
||||||
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||||
|
actor = models.ForeignKey(
|
||||||
|
Actor,
|
||||||
|
related_name='emitted_follows',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
target = models.ForeignKey(
|
||||||
|
Actor,
|
||||||
|
related_name='received_follows',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
modification_date = models.DateTimeField(
|
||||||
|
auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ['actor', 'target']
|
||||||
|
|
||||||
|
def get_federation_url(self):
|
||||||
|
return '{}#follows/{}'.format(self.actor.url, self.uuid)
|
||||||
|
|
||||||
|
|
||||||
|
class FollowRequest(models.Model):
|
||||||
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||||
|
actor = models.ForeignKey(
|
||||||
|
Actor,
|
||||||
|
related_name='emmited_follow_requests',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
target = models.ForeignKey(
|
||||||
|
Actor,
|
||||||
|
related_name='received_follow_requests',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
modification_date = models.DateTimeField(
|
||||||
|
auto_now=True)
|
||||||
|
approved = models.NullBooleanField(default=None)
|
||||||
|
|
||||||
|
def approve(self):
|
||||||
|
from . import activity
|
||||||
|
from . import serializers
|
||||||
|
self.approved = True
|
||||||
|
self.save(update_fields=['approved'])
|
||||||
|
Follow.objects.get_or_create(
|
||||||
|
target=self.target,
|
||||||
|
actor=self.actor
|
||||||
|
)
|
||||||
|
if self.target.is_local:
|
||||||
|
follow = {
|
||||||
|
'@context': serializers.AP_CONTEXT,
|
||||||
|
'actor': self.actor.url,
|
||||||
|
'id': self.actor.url + '#follows/{}'.format(uuid.uuid4()),
|
||||||
|
'object': self.target.url,
|
||||||
|
'type': 'Follow'
|
||||||
|
}
|
||||||
|
activity.accept_follow(
|
||||||
|
self.target, follow, self.actor
|
||||||
|
)
|
||||||
|
|
||||||
|
def refuse(self):
|
||||||
|
self.approved = False
|
||||||
|
self.save(update_fields=['approved'])
|
||||||
|
|
||||||
|
|
||||||
|
class Library(models.Model):
|
||||||
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
modification_date = models.DateTimeField(
|
||||||
|
auto_now=True)
|
||||||
|
fetched_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
actor = models.OneToOneField(
|
||||||
|
Actor,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='library')
|
||||||
|
uuid = models.UUIDField(default=uuid.uuid4)
|
||||||
|
url = models.URLField()
|
||||||
|
|
||||||
|
# use this flag to disable federation with a library
|
||||||
|
federation_enabled = models.BooleanField()
|
||||||
|
# should we mirror files locally or hotlink them?
|
||||||
|
download_files = models.BooleanField()
|
||||||
|
# should we automatically import new files from this library?
|
||||||
|
autoimport = models.BooleanField()
|
||||||
|
tracks_count = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryTrack(models.Model):
|
||||||
|
url = models.URLField(unique=True)
|
||||||
|
audio_url = models.URLField()
|
||||||
|
audio_mimetype = models.CharField(max_length=200)
|
||||||
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
modification_date = models.DateTimeField(
|
||||||
|
auto_now=True)
|
||||||
|
fetched_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
published_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
library = models.ForeignKey(
|
||||||
|
Library, related_name='tracks', on_delete=models.CASCADE)
|
||||||
|
artist_name = models.CharField(max_length=500)
|
||||||
|
album_title = models.CharField(max_length=500)
|
||||||
|
title = models.CharField(max_length=500)
|
||||||
|
metadata = JSONField(default={}, max_length=10000)
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
|
from . import actors
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryFollower(BasePermission):
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not settings.FEDERATION_MUSIC_NEEDS_APPROVAL:
|
||||||
|
return True
|
||||||
|
|
||||||
|
actor = getattr(request, 'actor', None)
|
||||||
|
if actor is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
return library.followers.filter(url=actor.url).exists()
|
|
@ -2,15 +2,25 @@ import urllib.parse
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
|
from funkwhale_api.common.utils import set_query_parameter
|
||||||
|
|
||||||
from . import activity
|
from . import activity
|
||||||
from . import models
|
from . import models
|
||||||
from . import utils
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
|
AP_CONTEXT = [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
'https://w3id.org/security/v1',
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
|
||||||
class ActorSerializer(serializers.ModelSerializer):
|
class ActorSerializer(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='url')
|
id = serializers.URLField(source='url')
|
||||||
|
@ -43,11 +53,7 @@ class ActorSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
ret = super().to_representation(instance)
|
ret = super().to_representation(instance)
|
||||||
ret['@context'] = [
|
ret['@context'] = AP_CONTEXT
|
||||||
'https://www.w3.org/ns/activitystreams',
|
|
||||||
'https://w3id.org/security/v1',
|
|
||||||
{},
|
|
||||||
]
|
|
||||||
if instance.public_key:
|
if instance.public_key:
|
||||||
ret['publicKey'] = {
|
ret['publicKey'] = {
|
||||||
'owner': instance.url,
|
'owner': instance.url,
|
||||||
|
@ -87,6 +93,28 @@ class ActorSerializer(serializers.ModelSerializer):
|
||||||
return value[:500]
|
return value[:500]
|
||||||
|
|
||||||
|
|
||||||
|
class FollowSerializer(serializers.ModelSerializer):
|
||||||
|
# left maps to activitypub fields, right to our internal models
|
||||||
|
id = serializers.URLField(source='get_federation_url')
|
||||||
|
object = serializers.URLField(source='target.url')
|
||||||
|
actor = serializers.URLField(source='actor.url')
|
||||||
|
type = serializers.CharField(source='ap_type')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Actor
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'object',
|
||||||
|
'actor',
|
||||||
|
'type'
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
ret = super().to_representation(instance)
|
||||||
|
ret['@context'] = AP_CONTEXT
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
class ActorWebfingerSerializer(serializers.ModelSerializer):
|
class ActorWebfingerSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Actor
|
model = models.Actor
|
||||||
|
@ -120,7 +148,9 @@ class ActivitySerializer(serializers.Serializer):
|
||||||
type = value['type']
|
type = value['type']
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise serializers.ValidationError('Missing object type')
|
raise serializers.ValidationError('Missing object type')
|
||||||
|
except TypeError:
|
||||||
|
# probably a URL
|
||||||
|
return value
|
||||||
try:
|
try:
|
||||||
object_serializer = OBJECT_SERIALIZERS[type]
|
object_serializer = OBJECT_SERIALIZERS[type]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -173,3 +203,212 @@ OBJECT_SERIALIZERS = {
|
||||||
t: ObjectSerializer
|
t: ObjectSerializer
|
||||||
for t in activity.OBJECT_TYPES
|
for t in activity.OBJECT_TYPES
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedCollectionSerializer(serializers.Serializer):
|
||||||
|
|
||||||
|
def to_representation(self, conf):
|
||||||
|
paginator = Paginator(
|
||||||
|
conf['items'],
|
||||||
|
conf.get('page_size', 20)
|
||||||
|
)
|
||||||
|
first = set_query_parameter(conf['id'], page=1)
|
||||||
|
current = first
|
||||||
|
last = set_query_parameter(conf['id'], page=paginator.num_pages)
|
||||||
|
d = {
|
||||||
|
'id': conf['id'],
|
||||||
|
'actor': conf['actor'].url,
|
||||||
|
'totalItems': paginator.count,
|
||||||
|
'type': 'Collection',
|
||||||
|
'current': current,
|
||||||
|
'first': first,
|
||||||
|
'last': last,
|
||||||
|
}
|
||||||
|
if self.context.get('include_ap_context', True):
|
||||||
|
d['@context'] = AP_CONTEXT
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionPageSerializer(serializers.Serializer):
|
||||||
|
|
||||||
|
def to_representation(self, conf):
|
||||||
|
page = conf['page']
|
||||||
|
first = set_query_parameter(conf['id'], page=1)
|
||||||
|
last = set_query_parameter(conf['id'], page=page.paginator.num_pages)
|
||||||
|
id = set_query_parameter(conf['id'], page=page.number)
|
||||||
|
d = {
|
||||||
|
'id': id,
|
||||||
|
'partOf': conf['id'],
|
||||||
|
'actor': conf['actor'].url,
|
||||||
|
'totalItems': page.paginator.count,
|
||||||
|
'type': 'CollectionPage',
|
||||||
|
'first': first,
|
||||||
|
'last': last,
|
||||||
|
'items': [
|
||||||
|
conf['item_serializer'](
|
||||||
|
i,
|
||||||
|
context={
|
||||||
|
'actor': conf['actor'],
|
||||||
|
'include_ap_context': False}
|
||||||
|
).data
|
||||||
|
for i in page.object_list
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if page.has_previous():
|
||||||
|
d['prev'] = set_query_parameter(
|
||||||
|
conf['id'], page=page.previous_page_number())
|
||||||
|
|
||||||
|
if page.has_previous():
|
||||||
|
d['next'] = set_query_parameter(
|
||||||
|
conf['id'], page=page.next_page_number())
|
||||||
|
|
||||||
|
if self.context.get('include_ap_context', True):
|
||||||
|
d['@context'] = AP_CONTEXT
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistMetadataSerializer(serializers.Serializer):
|
||||||
|
musicbrainz_id = serializers.UUIDField(required=False)
|
||||||
|
name = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseMetadataSerializer(serializers.Serializer):
|
||||||
|
musicbrainz_id = serializers.UUIDField(required=False)
|
||||||
|
title = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingMetadataSerializer(serializers.Serializer):
|
||||||
|
musicbrainz_id = serializers.UUIDField(required=False)
|
||||||
|
title = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class AudioMetadataSerializer(serializers.Serializer):
|
||||||
|
artist = ArtistMetadataSerializer()
|
||||||
|
release = ReleaseMetadataSerializer()
|
||||||
|
recording = RecordingMetadataSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSerializer(serializers.Serializer):
|
||||||
|
type = serializers.CharField()
|
||||||
|
id = serializers.URLField()
|
||||||
|
url = serializers.JSONField()
|
||||||
|
published = serializers.DateTimeField()
|
||||||
|
updated = serializers.DateTimeField(required=False)
|
||||||
|
metadata = AudioMetadataSerializer()
|
||||||
|
|
||||||
|
def validate_type(self, v):
|
||||||
|
if v != 'Audio':
|
||||||
|
raise serializers.ValidationError('Invalid type for audio')
|
||||||
|
return v
|
||||||
|
|
||||||
|
def validate_url(self, v):
|
||||||
|
try:
|
||||||
|
url = v['href']
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
raise serializers.ValidationError('Missing href')
|
||||||
|
|
||||||
|
try:
|
||||||
|
media_type = v['mediaType']
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
raise serializers.ValidationError('Missing mediaType')
|
||||||
|
|
||||||
|
if not media_type.startswith('audio/'):
|
||||||
|
raise serializers.ValidationError('Invalid mediaType')
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
def validate_url(self, v):
|
||||||
|
try:
|
||||||
|
url = v['href']
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
raise serializers.ValidationError('Missing href')
|
||||||
|
|
||||||
|
try:
|
||||||
|
media_type = v['mediaType']
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
raise serializers.ValidationError('Missing mediaType')
|
||||||
|
|
||||||
|
if not media_type.startswith('audio/'):
|
||||||
|
raise serializers.ValidationError('Invalid mediaType')
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
defaults = {
|
||||||
|
'audio_mimetype': validated_data['url']['mediaType'],
|
||||||
|
'audio_url': validated_data['url']['href'],
|
||||||
|
'metadata': validated_data['metadata'],
|
||||||
|
'artist_name': validated_data['metadata']['artist']['name'],
|
||||||
|
'album_title': validated_data['metadata']['release']['title'],
|
||||||
|
'title': validated_data['metadata']['recording']['title'],
|
||||||
|
'published_date': validated_data['published'],
|
||||||
|
'modification_date': validated_data.get('updated'),
|
||||||
|
}
|
||||||
|
return models.LibraryTrack.objects.get_or_create(
|
||||||
|
library=self.context['library'],
|
||||||
|
url=validated_data['id'],
|
||||||
|
defaults=defaults
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
track = instance.track
|
||||||
|
album = instance.track.album
|
||||||
|
artist = instance.track.artist
|
||||||
|
|
||||||
|
d = {
|
||||||
|
'type': 'Audio',
|
||||||
|
'id': instance.get_federation_url(),
|
||||||
|
'name': instance.track.full_name,
|
||||||
|
'published': instance.creation_date.isoformat(),
|
||||||
|
'updated': instance.modification_date.isoformat(),
|
||||||
|
'metadata': {
|
||||||
|
'artist': {
|
||||||
|
'musicbrainz_id': str(artist.mbid) if artist.mbid else None,
|
||||||
|
'name': artist.name,
|
||||||
|
},
|
||||||
|
'release': {
|
||||||
|
'musicbrainz_id': str(album.mbid) if album.mbid else None,
|
||||||
|
'title': album.title,
|
||||||
|
},
|
||||||
|
'recording': {
|
||||||
|
'musicbrainz_id': str(track.mbid) if track.mbid else None,
|
||||||
|
'title': track.title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'url': {
|
||||||
|
'href': utils.full_url(instance.path),
|
||||||
|
'type': 'Link',
|
||||||
|
'mediaType': instance.mimetype
|
||||||
|
},
|
||||||
|
'attributedTo': [
|
||||||
|
self.context['actor'].url
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if self.context.get('include_ap_context', True):
|
||||||
|
d['@context'] = AP_CONTEXT
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionSerializer(serializers.Serializer):
|
||||||
|
|
||||||
|
def to_representation(self, conf):
|
||||||
|
d = {
|
||||||
|
'id': conf['id'],
|
||||||
|
'actor': conf['actor'].url,
|
||||||
|
'totalItems': len(conf['items']),
|
||||||
|
'type': 'Collection',
|
||||||
|
'items': [
|
||||||
|
conf['item_serializer'](
|
||||||
|
i,
|
||||||
|
context={
|
||||||
|
'actor': conf['actor'],
|
||||||
|
'include_ap_context': False}
|
||||||
|
).data
|
||||||
|
for i in conf['items']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.context.get('include_ap_context', True):
|
||||||
|
d['@context'] = AP_CONTEXT
|
||||||
|
return d
|
||||||
|
|
|
@ -53,3 +53,18 @@ def verify_django(django_request, public_key):
|
||||||
request.headers[h] = str(v)
|
request.headers[h] = str(v)
|
||||||
prepared_request = request.prepare()
|
prepared_request = request.prepare()
|
||||||
return verify(request, public_key)
|
return verify(request, public_key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth(private_key, private_key_id):
|
||||||
|
return requests_http_signature.HTTPSignatureAuth(
|
||||||
|
use_auth_header=False,
|
||||||
|
headers=[
|
||||||
|
'(request-target)',
|
||||||
|
'user-agent',
|
||||||
|
'host',
|
||||||
|
'date',
|
||||||
|
'content-type'],
|
||||||
|
algorithm='rsa-sha256',
|
||||||
|
key=private_key.encode('utf-8'),
|
||||||
|
key_id=private_key_id,
|
||||||
|
)
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
from rest_framework import routers
|
from django.conf.urls import include, url
|
||||||
|
|
||||||
|
from rest_framework import routers
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
router = routers.SimpleRouter(trailing_slash=False)
|
router = routers.SimpleRouter(trailing_slash=False)
|
||||||
|
music_router = routers.SimpleRouter(trailing_slash=False)
|
||||||
router.register(
|
router.register(
|
||||||
r'federation/instance/actors',
|
r'federation/instance/actors',
|
||||||
views.InstanceActorViewSet,
|
views.InstanceActorViewSet,
|
||||||
|
@ -12,4 +14,11 @@ router.register(
|
||||||
views.WellKnownViewSet,
|
views.WellKnownViewSet,
|
||||||
'well-known')
|
'well-known')
|
||||||
|
|
||||||
urlpatterns = router.urls
|
music_router.register(
|
||||||
|
r'files',
|
||||||
|
views.MusicFilesViewSet,
|
||||||
|
'files',
|
||||||
|
)
|
||||||
|
urlpatterns = router.urls + [
|
||||||
|
url('federation/music/', include((music_router.urls, 'music'), namespace='music'))
|
||||||
|
]
|
||||||
|
|
|
@ -1,16 +1,22 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core import paginator
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
from rest_framework import views
|
from rest_framework import views
|
||||||
from rest_framework import response
|
from rest_framework import response
|
||||||
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 . import actors
|
from . import actors
|
||||||
from . import authentication
|
from . import authentication
|
||||||
|
from . import permissions
|
||||||
from . import renderers
|
from . import renderers
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
from . import utils
|
||||||
from . import webfinger
|
from . import webfinger
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,8 +44,8 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
system_actor = self.get_object()
|
system_actor = self.get_object()
|
||||||
actor = system_actor.get_actor_instance()
|
actor = system_actor.get_actor_instance()
|
||||||
serializer = serializers.ActorSerializer(actor)
|
data = actor.system_conf.serialize()
|
||||||
return response.Response(serializer.data, status=200)
|
return response.Response(data, status=200)
|
||||||
|
|
||||||
@detail_route(methods=['get', 'post'])
|
@detail_route(methods=['get', 'post'])
|
||||||
def inbox(self, request, *args, **kwargs):
|
def inbox(self, request, *args, **kwargs):
|
||||||
|
@ -101,3 +107,50 @@ class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||||
username, hostname = clean_result
|
username, hostname = clean_result
|
||||||
actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
|
actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
|
||||||
return serializers.ActorWebfingerSerializer(actor).data
|
return serializers.ActorWebfingerSerializer(actor).data
|
||||||
|
|
||||||
|
|
||||||
|
class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||||
|
authentication_classes = [
|
||||||
|
authentication.SignatureAuthentication]
|
||||||
|
permission_classes = [permissions.LibraryFollower]
|
||||||
|
renderer_classes = [renderers.ActivityPubRenderer]
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
page = request.GET.get('page')
|
||||||
|
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
qs = TrackFile.objects.order_by('-creation_date').select_related(
|
||||||
|
'track__artist',
|
||||||
|
'track__album__artist'
|
||||||
|
)
|
||||||
|
if page is None:
|
||||||
|
conf = {
|
||||||
|
'id': utils.full_url(reverse('federation:music:files-list')),
|
||||||
|
'page_size': settings.FEDERATION_COLLECTION_PAGE_SIZE,
|
||||||
|
'items': qs,
|
||||||
|
'item_serializer': serializers.AudioSerializer,
|
||||||
|
'actor': library,
|
||||||
|
}
|
||||||
|
serializer = serializers.PaginatedCollectionSerializer(conf)
|
||||||
|
data = serializer.data
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
page_number = int(page)
|
||||||
|
except:
|
||||||
|
return response.Response(
|
||||||
|
{'page': ['Invalid page number']}, status=400)
|
||||||
|
p = paginator.Paginator(
|
||||||
|
qs, settings.FEDERATION_COLLECTION_PAGE_SIZE)
|
||||||
|
try:
|
||||||
|
page = p.page(page_number)
|
||||||
|
conf = {
|
||||||
|
'id': utils.full_url(reverse('federation:music:files-list')),
|
||||||
|
'page': page,
|
||||||
|
'item_serializer': serializers.AudioSerializer,
|
||||||
|
'actor': library,
|
||||||
|
}
|
||||||
|
serializer = serializers.CollectionPageSerializer(conf)
|
||||||
|
data = serializer.data
|
||||||
|
except paginator.EmptyPage:
|
||||||
|
return response.Response(status=404)
|
||||||
|
|
||||||
|
return response.Response(data)
|
||||||
|
|
|
@ -2,6 +2,9 @@ import factory
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from funkwhale_api.factories import registry, ManyToManyFromList
|
from funkwhale_api.factories import registry, ManyToManyFromList
|
||||||
|
from funkwhale_api.federation.factories import (
|
||||||
|
LibraryTrackFactory,
|
||||||
|
)
|
||||||
from funkwhale_api.users.factories import UserFactory
|
from funkwhale_api.users.factories import UserFactory
|
||||||
|
|
||||||
SAMPLES_PATH = os.path.join(
|
SAMPLES_PATH = os.path.join(
|
||||||
|
@ -53,6 +56,18 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'music.TrackFile'
|
model = 'music.TrackFile'
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
federation = factory.Trait(
|
||||||
|
audio_file=None,
|
||||||
|
library_track=factory.SubFactory(LibraryTrackFactory),
|
||||||
|
mimetype=factory.LazyAttribute(
|
||||||
|
lambda o: o.library_track.audio_mimetype
|
||||||
|
),
|
||||||
|
source=factory.LazyAttribute(
|
||||||
|
lambda o: o.library_track.audio_url
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
class ImportBatchFactory(factory.django.DjangoModelFactory):
|
class ImportBatchFactory(factory.django.DjangoModelFactory):
|
||||||
|
@ -61,6 +76,12 @@ class ImportBatchFactory(factory.django.DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'music.ImportBatch'
|
model = 'music.ImportBatch'
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
federation = factory.Trait(
|
||||||
|
submitted_by=None,
|
||||||
|
source='federation',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
class ImportJobFactory(factory.django.DjangoModelFactory):
|
class ImportJobFactory(factory.django.DjangoModelFactory):
|
||||||
|
@ -71,6 +92,13 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'music.ImportJob'
|
model = 'music.ImportJob'
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
federation = factory.Trait(
|
||||||
|
mbid=None,
|
||||||
|
library_track=factory.SubFactory(LibraryTrackFactory),
|
||||||
|
batch=factory.SubFactory(ImportBatchFactory, federation=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='music.FileImportJob')
|
@registry.register(name='music.FileImportJob')
|
||||||
class FileImportJobFactory(ImportJobFactory):
|
class FileImportJobFactory(ImportJobFactory):
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Generated by Django 2.0.3 on 2018-04-07 10:10
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('federation', '0003_auto_20180407_1010'),
|
||||||
|
('music', '0022_importbatch_import_request'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='artist',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='importbatch',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='importjob',
|
||||||
|
name='library_track',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='import_jobs', to='federation.LibraryTrack'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='importjob',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lyrics',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='track',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='trackfile',
|
||||||
|
name='creation_date',
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='trackfile',
|
||||||
|
name='library_track',
|
||||||
|
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='local_track_file', to='federation.LibraryTrack'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='trackfile',
|
||||||
|
name='modification_date',
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='trackfile',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='work',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='importbatch',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(choices=[('api', 'api'), ('shell', 'shell'), ('federation', 'federation')], default='api', max_length=30),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='importbatch',
|
||||||
|
name='submitted_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='imports', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
|
@ -5,6 +5,7 @@ import datetime
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
import markdown
|
import markdown
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
@ -20,12 +21,15 @@ from versatileimagefield.fields import VersatileImageField
|
||||||
|
|
||||||
from funkwhale_api import downloader
|
from funkwhale_api import downloader
|
||||||
from funkwhale_api import musicbrainz
|
from funkwhale_api import musicbrainz
|
||||||
|
from funkwhale_api.federation import utils as federation_utils
|
||||||
from . import importers
|
from . import importers
|
||||||
from . import utils
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
class APIModelMixin(models.Model):
|
class APIModelMixin(models.Model):
|
||||||
mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True)
|
mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True)
|
||||||
|
uuid = models.UUIDField(
|
||||||
|
unique=True, db_index=True, default=uuid.uuid4)
|
||||||
api_includes = []
|
api_includes = []
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
import_hooks = []
|
import_hooks = []
|
||||||
|
@ -65,6 +69,13 @@ class APIModelMixin(models.Model):
|
||||||
pass
|
pass
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def musicbrainz_url(self):
|
||||||
|
if self.mbid:
|
||||||
|
return 'https://musicbrainz.org/{}/{}'.format(
|
||||||
|
self.musicbrainz_model, self.mbid)
|
||||||
|
|
||||||
|
|
||||||
class Artist(APIModelMixin):
|
class Artist(APIModelMixin):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
@ -90,10 +101,19 @@ class Artist(APIModelMixin):
|
||||||
t.append(tag)
|
t.append(tag)
|
||||||
return set(t)
|
return set(t)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create_from_name(cls, name, **kwargs):
|
||||||
|
kwargs.update({'name': name})
|
||||||
|
return cls.objects.get_or_create(
|
||||||
|
name__iexact=name,
|
||||||
|
defaults=kwargs)[0]
|
||||||
|
|
||||||
|
|
||||||
def import_artist(v):
|
def import_artist(v):
|
||||||
a = Artist.get_or_create_from_api(mbid=v[0]['artist']['id'])[0]
|
a = Artist.get_or_create_from_api(mbid=v[0]['artist']['id'])[0]
|
||||||
return a
|
return a
|
||||||
|
|
||||||
|
|
||||||
def parse_date(v):
|
def parse_date(v):
|
||||||
if len(v) == 4:
|
if len(v) == 4:
|
||||||
return datetime.date(int(v), 1, 1)
|
return datetime.date(int(v), 1, 1)
|
||||||
|
@ -108,6 +128,7 @@ def import_tracks(instance, cleaned_data, raw_data):
|
||||||
track_cleaned_data['position'] = int(track_data['position'])
|
track_cleaned_data['position'] = int(track_data['position'])
|
||||||
track = importers.load(Track, track_cleaned_data, track_data, Track.import_hooks)
|
track = importers.load(Track, track_cleaned_data, track_data, Track.import_hooks)
|
||||||
|
|
||||||
|
|
||||||
class Album(APIModelMixin):
|
class Album(APIModelMixin):
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
artist = models.ForeignKey(
|
artist = models.ForeignKey(
|
||||||
|
@ -170,6 +191,14 @@ class Album(APIModelMixin):
|
||||||
t.append(tag)
|
t.append(tag)
|
||||||
return set(t)
|
return set(t)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create_from_title(cls, title, **kwargs):
|
||||||
|
kwargs.update({'title': title})
|
||||||
|
return cls.objects.get_or_create(
|
||||||
|
title__iexact=title,
|
||||||
|
defaults=kwargs)[0]
|
||||||
|
|
||||||
|
|
||||||
def import_tags(instance, cleaned_data, raw_data):
|
def import_tags(instance, cleaned_data, raw_data):
|
||||||
MINIMUM_COUNT = 2
|
MINIMUM_COUNT = 2
|
||||||
tags_to_add = []
|
tags_to_add = []
|
||||||
|
@ -182,6 +211,7 @@ def import_tags(instance, cleaned_data, raw_data):
|
||||||
tags_to_add.append(tag_data['name'])
|
tags_to_add.append(tag_data['name'])
|
||||||
instance.tags.add(*tags_to_add)
|
instance.tags.add(*tags_to_add)
|
||||||
|
|
||||||
|
|
||||||
def import_album(v):
|
def import_album(v):
|
||||||
a = Album.get_or_create_from_api(mbid=v[0]['id'])[0]
|
a = Album.get_or_create_from_api(mbid=v[0]['id'])[0]
|
||||||
return a
|
return a
|
||||||
|
@ -248,6 +278,8 @@ class Work(APIModelMixin):
|
||||||
|
|
||||||
|
|
||||||
class Lyrics(models.Model):
|
class Lyrics(models.Model):
|
||||||
|
uuid = models.UUIDField(
|
||||||
|
unique=True, db_index=True, default=uuid.uuid4)
|
||||||
work = models.ForeignKey(
|
work = models.ForeignKey(
|
||||||
Work,
|
Work,
|
||||||
related_name='lyrics',
|
related_name='lyrics',
|
||||||
|
@ -328,7 +360,7 @@ class Track(APIModelMixin):
|
||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
try:
|
try:
|
||||||
self.artist
|
self.artist
|
||||||
except Artist.DoesNotExist:
|
except Artist.DoesNotExist:
|
||||||
self.artist = self.album.artist
|
self.artist = self.album.artist
|
||||||
super().save(**kwargs)
|
super().save(**kwargs)
|
||||||
|
|
||||||
|
@ -366,16 +398,35 @@ class Track(APIModelMixin):
|
||||||
self.mbid)
|
self.mbid)
|
||||||
return settings.FUNKWHALE_URL + '/tracks/{}'.format(self.pk)
|
return settings.FUNKWHALE_URL + '/tracks/{}'.format(self.pk)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create_from_title(cls, title, **kwargs):
|
||||||
|
kwargs.update({'title': title})
|
||||||
|
return cls.objects.get_or_create(
|
||||||
|
title__iexact=title,
|
||||||
|
defaults=kwargs)[0]
|
||||||
|
|
||||||
|
|
||||||
class TrackFile(models.Model):
|
class TrackFile(models.Model):
|
||||||
|
uuid = models.UUIDField(
|
||||||
|
unique=True, db_index=True, default=uuid.uuid4)
|
||||||
track = models.ForeignKey(
|
track = models.ForeignKey(
|
||||||
Track, related_name='files', on_delete=models.CASCADE)
|
Track, related_name='files', on_delete=models.CASCADE)
|
||||||
audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255)
|
audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255)
|
||||||
source = models.URLField(null=True, blank=True)
|
source = models.URLField(null=True, blank=True)
|
||||||
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
modification_date = models.DateTimeField(auto_now=True)
|
||||||
duration = models.IntegerField(null=True, blank=True)
|
duration = models.IntegerField(null=True, blank=True)
|
||||||
acoustid_track_id = models.UUIDField(null=True, blank=True)
|
acoustid_track_id = models.UUIDField(null=True, blank=True)
|
||||||
mimetype = models.CharField(null=True, blank=True, max_length=200)
|
mimetype = models.CharField(null=True, blank=True, max_length=200)
|
||||||
|
|
||||||
|
library_track = models.OneToOneField(
|
||||||
|
'federation.LibraryTrack',
|
||||||
|
related_name='local_track_file',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
def download_file(self):
|
def download_file(self):
|
||||||
# import the track file, since there is not any
|
# import the track file, since there is not any
|
||||||
# we create a tmp dir for the download
|
# we create a tmp dir for the download
|
||||||
|
@ -391,12 +442,15 @@ class TrackFile(models.Model):
|
||||||
shutil.rmtree(tmp_dir)
|
shutil.rmtree(tmp_dir)
|
||||||
return self.audio_file
|
return self.audio_file
|
||||||
|
|
||||||
|
def get_federation_url(self):
|
||||||
|
return federation_utils.full_url(
|
||||||
|
'/federation/music/file/{}'.format(self.uuid)
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
if settings.PROTECT_AUDIO_FILES:
|
return reverse(
|
||||||
return reverse(
|
'api:v1:trackfiles-serve', kwargs={'pk': self.pk})
|
||||||
'api:v1:trackfiles-serve', kwargs={'pk': self.pk})
|
|
||||||
return self.audio_file.url
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
|
@ -417,10 +471,14 @@ IMPORT_STATUS_CHOICES = (
|
||||||
('skipped', 'Skipped'),
|
('skipped', 'Skipped'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImportBatch(models.Model):
|
class ImportBatch(models.Model):
|
||||||
|
uuid = models.UUIDField(
|
||||||
|
unique=True, db_index=True, default=uuid.uuid4)
|
||||||
IMPORT_BATCH_SOURCES = [
|
IMPORT_BATCH_SOURCES = [
|
||||||
('api', 'api'),
|
('api', 'api'),
|
||||||
('shell', 'shell')
|
('shell', 'shell'),
|
||||||
|
('federation', 'federation'),
|
||||||
]
|
]
|
||||||
source = models.CharField(
|
source = models.CharField(
|
||||||
max_length=30, default='api', choices=IMPORT_BATCH_SOURCES)
|
max_length=30, default='api', choices=IMPORT_BATCH_SOURCES)
|
||||||
|
@ -428,6 +486,8 @@ class ImportBatch(models.Model):
|
||||||
submitted_by = models.ForeignKey(
|
submitted_by = models.ForeignKey(
|
||||||
'users.User',
|
'users.User',
|
||||||
related_name='imports',
|
related_name='imports',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
on_delete=models.CASCADE)
|
on_delete=models.CASCADE)
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
|
choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
|
||||||
|
@ -437,6 +497,7 @@ class ImportBatch(models.Model):
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.CASCADE)
|
on_delete=models.CASCADE)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-creation_date']
|
ordering = ['-creation_date']
|
||||||
|
|
||||||
|
@ -449,6 +510,8 @@ class ImportBatch(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class ImportJob(models.Model):
|
class ImportJob(models.Model):
|
||||||
|
uuid = models.UUIDField(
|
||||||
|
unique=True, db_index=True, default=uuid.uuid4)
|
||||||
batch = models.ForeignKey(
|
batch = models.ForeignKey(
|
||||||
ImportBatch, related_name='jobs', on_delete=models.CASCADE)
|
ImportBatch, related_name='jobs', on_delete=models.CASCADE)
|
||||||
track_file = models.ForeignKey(
|
track_file = models.ForeignKey(
|
||||||
|
@ -465,6 +528,14 @@ class ImportJob(models.Model):
|
||||||
audio_file = models.FileField(
|
audio_file = models.FileField(
|
||||||
upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True)
|
upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True)
|
||||||
|
|
||||||
|
library_track = models.ForeignKey(
|
||||||
|
'federation.LibraryTrack',
|
||||||
|
related_name='import_jobs',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('id', )
|
ordering = ('id', )
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
|
from funkwhale_api.federation import actors
|
||||||
|
|
||||||
|
|
||||||
|
class Listen(BasePermission):
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not settings.PROTECT_AUDIO_FILES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
return True
|
||||||
|
|
||||||
|
actor = getattr(request, 'actor', None)
|
||||||
|
if actor is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
return library.followers.filter(url=actor.url).exists()
|
|
@ -1,7 +1,10 @@
|
||||||
|
from django.db import transaction
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
from funkwhale_api.activity import serializers as activity_serializers
|
from funkwhale_api.activity import serializers as activity_serializers
|
||||||
|
from funkwhale_api.federation.serializers import AP_CONTEXT
|
||||||
|
from funkwhale_api.federation import utils as federation_utils
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,48 @@ def set_acoustid_on_track_file(track_file):
|
||||||
return update(result['id'])
|
return update(result['id'])
|
||||||
|
|
||||||
|
|
||||||
|
def import_track_from_remote(library_track):
|
||||||
|
metadata = library_track.metadata
|
||||||
|
try:
|
||||||
|
track_mbid = metadata['recording']['musicbrainz_id']
|
||||||
|
assert track_mbid # for null/empty values
|
||||||
|
except (KeyError, AssertionError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return models.Track.get_or_create_from_api(mbid=track_mbid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
album_mbid = metadata['release']['musicbrainz_id']
|
||||||
|
assert album_mbid # for null/empty values
|
||||||
|
except (KeyError, AssertionError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
album = models.Album.get_or_create_from_api(mbid=album_mbid)
|
||||||
|
return models.Track.get_or_create_from_title(
|
||||||
|
library_track.title, artist=album.artist, album=album)
|
||||||
|
|
||||||
|
try:
|
||||||
|
artist_mbid = metadata['artist']['musicbrainz_id']
|
||||||
|
assert artist_mbid # for null/empty values
|
||||||
|
except (KeyError, AssertionError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
artist = models.Artist.get_or_create_from_api(mbid=artist_mbid)
|
||||||
|
album = models.Album.get_or_create_from_title(
|
||||||
|
library_track.album_title, artist=artist)
|
||||||
|
return models.Track.get_or_create_from_title(
|
||||||
|
library_track.title, artist=artist, album=album)
|
||||||
|
|
||||||
|
# worst case scenario, we have absolutely no way to link to a
|
||||||
|
# musicbrainz resource, we rely on the name/titles
|
||||||
|
artist = models.Artist.get_or_create_from_name(
|
||||||
|
library_track.artist_name)
|
||||||
|
album = models.Album.get_or_create_from_title(
|
||||||
|
library_track.album_title, artist=artist)
|
||||||
|
return models.Track.get_or_create_from_title(
|
||||||
|
library_track.title, artist=artist, album=album)
|
||||||
|
|
||||||
|
|
||||||
def _do_import(import_job, replace, use_acoustid=True):
|
def _do_import(import_job, replace, use_acoustid=True):
|
||||||
from_file = bool(import_job.audio_file)
|
from_file = bool(import_job.audio_file)
|
||||||
mbid = import_job.mbid
|
mbid = import_job.mbid
|
||||||
|
@ -43,8 +85,14 @@ def _do_import(import_job, replace, use_acoustid=True):
|
||||||
acoustid_track_id = match['id']
|
acoustid_track_id = match['id']
|
||||||
if mbid:
|
if mbid:
|
||||||
track, _ = models.Track.get_or_create_from_api(mbid=mbid)
|
track, _ = models.Track.get_or_create_from_api(mbid=mbid)
|
||||||
else:
|
elif import_job.audio_file:
|
||||||
track = import_track_data_from_path(import_job.audio_file.path)
|
track = import_track_data_from_path(import_job.audio_file.path)
|
||||||
|
elif import_job.library_track:
|
||||||
|
track = import_track_from_remote(import_job.library_track)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
'Not enough data to process import, '
|
||||||
|
'add a mbid, an audio file or a library track')
|
||||||
|
|
||||||
track_file = None
|
track_file = None
|
||||||
if replace:
|
if replace:
|
||||||
|
@ -63,6 +111,14 @@ def _do_import(import_job, replace, use_acoustid=True):
|
||||||
track_file.audio_file = ContentFile(import_job.audio_file.read())
|
track_file.audio_file = ContentFile(import_job.audio_file.read())
|
||||||
track_file.audio_file.name = import_job.audio_file.name
|
track_file.audio_file.name = import_job.audio_file.name
|
||||||
track_file.duration = duration
|
track_file.duration = duration
|
||||||
|
elif import_job.library_track:
|
||||||
|
track_file.library_track = import_job.library_track
|
||||||
|
track_file.mimetype = import_job.library_track.audio_mimetype
|
||||||
|
if import_job.library_track.library.download_files:
|
||||||
|
raise NotImplementedError()
|
||||||
|
else:
|
||||||
|
# no downloading, we hotlink
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
track_file.download_file()
|
track_file.download_file()
|
||||||
track_file.save()
|
track_file.save()
|
||||||
|
|
|
@ -60,3 +60,10 @@ def compute_status(jobs):
|
||||||
if pending:
|
if pending:
|
||||||
return 'pending'
|
return 'pending'
|
||||||
return 'finished'
|
return 'finished'
|
||||||
|
|
||||||
|
|
||||||
|
def get_ext_from_type(mimetype):
|
||||||
|
mapping = {
|
||||||
|
'audio/ogg': 'ogg',
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
|
}
|
||||||
|
|
|
@ -1,36 +1,42 @@
|
||||||
import ffmpeg
|
import ffmpeg
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import requests
|
||||||
import subprocess
|
import subprocess
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models.functions import Length
|
from django.db.models.functions import Length
|
||||||
from django.conf import settings
|
|
||||||
from django.http import StreamingHttpResponse
|
from django.http import StreamingHttpResponse
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
|
||||||
from rest_framework import viewsets, views, mixins
|
from rest_framework import viewsets, views, mixins
|
||||||
from rest_framework.decorators import detail_route, list_route
|
from rest_framework.decorators import detail_route, list_route
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import settings as rest_settings
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from musicbrainzngs import ResponseError
|
from musicbrainzngs import ResponseError
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
|
|
||||||
from funkwhale_api.common import utils as funkwhale_utils
|
from funkwhale_api.common import utils as funkwhale_utils
|
||||||
|
from funkwhale_api.federation import actors
|
||||||
from funkwhale_api.requests.models import ImportRequest
|
from funkwhale_api.requests.models import ImportRequest
|
||||||
from funkwhale_api.musicbrainz import api
|
from funkwhale_api.musicbrainz import api
|
||||||
from funkwhale_api.common.permissions import (
|
from funkwhale_api.common.permissions import (
|
||||||
ConditionalAuthentication, HasModelPermission)
|
ConditionalAuthentication, HasModelPermission)
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
from funkwhale_api.federation.authentication import SignatureAuthentication
|
||||||
|
|
||||||
from . import forms
|
|
||||||
from . import models
|
|
||||||
from . import serializers
|
|
||||||
from . import importers
|
|
||||||
from . import filters
|
from . import filters
|
||||||
|
from . import forms
|
||||||
|
from . import importers
|
||||||
|
from . import models
|
||||||
|
from . import permissions as music_permissions
|
||||||
|
from . import serializers
|
||||||
from . import tasks
|
from . import tasks
|
||||||
from . import utils
|
from . import utils
|
||||||
|
|
||||||
|
@ -45,6 +51,7 @@ class SearchMixin(object):
|
||||||
serializer = self.serializer_class(queryset, many=True)
|
serializer = self.serializer_class(queryset, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
class TagViewSetMixin(object):
|
class TagViewSetMixin(object):
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
@ -179,22 +186,54 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
queryset = (models.TrackFile.objects.all().order_by('-id'))
|
queryset = (models.TrackFile.objects.all().order_by('-id'))
|
||||||
serializer_class = serializers.TrackFileSerializer
|
serializer_class = serializers.TrackFileSerializer
|
||||||
permission_classes = [ConditionalAuthentication]
|
authentication_classes = rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [
|
||||||
|
SignatureAuthentication
|
||||||
|
]
|
||||||
|
permission_classes = [music_permissions.Listen]
|
||||||
|
|
||||||
@detail_route(methods=['get'])
|
@detail_route(methods=['get'])
|
||||||
def serve(self, request, *args, **kwargs):
|
def serve(self, request, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
f = models.TrackFile.objects.get(pk=kwargs['pk'])
|
f = models.TrackFile.objects.select_related(
|
||||||
|
'library_track',
|
||||||
|
'track__album__artist',
|
||||||
|
'track__artist',
|
||||||
|
).get(pk=kwargs['pk'])
|
||||||
except models.TrackFile.DoesNotExist:
|
except models.TrackFile.DoesNotExist:
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
|
|
||||||
response = Response()
|
mt = f.mimetype
|
||||||
|
try:
|
||||||
|
library_track = f.library_track
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
library_track = None
|
||||||
|
if library_track and not f.audio_file:
|
||||||
|
# we proxy the response to the remote library
|
||||||
|
# since we did not mirror the file locally
|
||||||
|
mt = library_track.audio_mimetype
|
||||||
|
file_extension = utils.get_ext_from_type(mt)
|
||||||
|
filename = '{}.{}'.format(f.track.full_name, file_extension)
|
||||||
|
auth = actors.SYSTEM_ACTORS['library'].get_request_auth()
|
||||||
|
remote_response = requests.get(
|
||||||
|
library_track.audio_url,
|
||||||
|
auth=auth,
|
||||||
|
stream=True,
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'application/activity+json'
|
||||||
|
})
|
||||||
|
response = StreamingHttpResponse(remote_response.iter_content())
|
||||||
|
else:
|
||||||
|
response = Response()
|
||||||
|
filename = f.filename
|
||||||
|
response['X-Accel-Redirect'] = "{}{}".format(
|
||||||
|
settings.PROTECT_FILES_PATH,
|
||||||
|
f.audio_file.url)
|
||||||
filename = "filename*=UTF-8''{}".format(
|
filename = "filename*=UTF-8''{}".format(
|
||||||
urllib.parse.quote(f.filename))
|
urllib.parse.quote(filename))
|
||||||
response["Content-Disposition"] = "attachment; {}".format(filename)
|
response["Content-Disposition"] = "attachment; {}".format(filename)
|
||||||
response['X-Accel-Redirect'] = "{}{}".format(
|
if mt:
|
||||||
settings.PROTECT_FILES_PATH,
|
response["Content-Type"] = mt
|
||||||
f.audio_file.url)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@list_route(methods=['get'])
|
@list_route(methods=['get'])
|
||||||
|
|
|
@ -162,3 +162,12 @@ def media_root(settings):
|
||||||
def r_mock():
|
def r_mock():
|
||||||
with requests_mock.mock() as m:
|
with requests_mock.mock() as m:
|
||||||
yield m
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authenticated_actor(factories, mocker):
|
||||||
|
actor = factories['federation.Actor']()
|
||||||
|
mocker.patch(
|
||||||
|
'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
|
||||||
|
return_value=actor)
|
||||||
|
yield actor
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def authenticated_actor(nodb_factories, mocker):
|
|
||||||
actor = nodb_factories['federation.Actor']()
|
|
||||||
mocker.patch(
|
|
||||||
'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
|
|
||||||
return_value=actor)
|
|
||||||
yield actor
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from funkwhale_api.federation import activity
|
from funkwhale_api.federation import activity
|
||||||
|
|
||||||
|
|
||||||
def test_deliver(nodb_factories, r_mock, mocker):
|
def test_deliver(nodb_factories, r_mock, mocker):
|
||||||
to = nodb_factories['federation.Actor']()
|
to = nodb_factories['federation.Actor']()
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
|
@ -30,3 +33,42 @@ def test_deliver(nodb_factories, r_mock, mocker):
|
||||||
assert r_mock.call_count == 1
|
assert r_mock.call_count == 1
|
||||||
assert request.url == to.inbox_url
|
assert request.url == to.inbox_url
|
||||||
assert request.headers['content-type'] == 'application/activity+json'
|
assert request.headers['content-type'] == 'application/activity+json'
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_follow(mocker, factories):
|
||||||
|
deliver = mocker.patch(
|
||||||
|
'funkwhale_api.federation.activity.deliver')
|
||||||
|
actor = factories['federation.Actor']()
|
||||||
|
target = factories['federation.Actor'](local=True)
|
||||||
|
follow = {
|
||||||
|
'actor': actor.url,
|
||||||
|
'type': 'Follow',
|
||||||
|
'id': 'http://test.federation/user#follows/267',
|
||||||
|
'object': target.url,
|
||||||
|
}
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
mocker.patch('uuid.uuid4', return_value=uid)
|
||||||
|
expected_accept = {
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
"https://w3id.org/security/v1",
|
||||||
|
{}
|
||||||
|
],
|
||||||
|
"id": target.url + '#accepts/follows/{}'.format(uid),
|
||||||
|
"type": "Accept",
|
||||||
|
"actor": target.url,
|
||||||
|
"object": {
|
||||||
|
"id": follow['id'],
|
||||||
|
"type": "Follow",
|
||||||
|
"actor": actor.url,
|
||||||
|
"object": target.url
|
||||||
|
},
|
||||||
|
}
|
||||||
|
activity.accept_follow(
|
||||||
|
target, follow, actor
|
||||||
|
)
|
||||||
|
deliver.assert_called_once_with(
|
||||||
|
expected_accept, to=[actor.url], on_behalf_of=target
|
||||||
|
)
|
||||||
|
follow_instance = actor.emitted_follows.first()
|
||||||
|
assert follow_instance.target == target
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
import arrow
|
||||||
import pytest
|
import pytest
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -6,6 +8,7 @@ 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
|
||||||
|
from funkwhale_api.federation import models
|
||||||
from funkwhale_api.federation import serializers
|
from funkwhale_api.federation import serializers
|
||||||
from funkwhale_api.federation import utils
|
from funkwhale_api.federation import utils
|
||||||
|
|
||||||
|
@ -23,14 +26,17 @@ def test_actor_fetching(r_mock):
|
||||||
assert r == payload
|
assert r == payload
|
||||||
|
|
||||||
|
|
||||||
def test_get_library(settings, preferences):
|
def test_get_library(db, settings, mocker):
|
||||||
preferences['federation__public_key'] = 'public_key'
|
get_key_pair = mocker.patch(
|
||||||
|
'funkwhale_api.federation.keys.get_key_pair',
|
||||||
|
return_value=(b'private', b'public'))
|
||||||
expected = {
|
expected = {
|
||||||
'preferred_username': 'library',
|
'preferred_username': 'library',
|
||||||
'domain': settings.FEDERATION_HOSTNAME,
|
'domain': settings.FEDERATION_HOSTNAME,
|
||||||
'type': 'Person',
|
'type': 'Person',
|
||||||
'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
|
'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
|
||||||
'manually_approves_followers': True,
|
'manually_approves_followers': True,
|
||||||
|
'public_key': 'public',
|
||||||
'url': utils.full_url(
|
'url': utils.full_url(
|
||||||
reverse(
|
reverse(
|
||||||
'federation:instance-actors-detail',
|
'federation:instance-actors-detail',
|
||||||
|
@ -47,7 +53,6 @@ def test_get_library(settings, preferences):
|
||||||
reverse(
|
reverse(
|
||||||
'federation:instance-actors-outbox',
|
'federation:instance-actors-outbox',
|
||||||
kwargs={'actor': 'library'})),
|
kwargs={'actor': 'library'})),
|
||||||
'public_key': 'public_key',
|
|
||||||
'summary': 'Bot account to federate with {}\'s library'.format(
|
'summary': 'Bot account to federate with {}\'s library'.format(
|
||||||
settings.FEDERATION_HOSTNAME),
|
settings.FEDERATION_HOSTNAME),
|
||||||
}
|
}
|
||||||
|
@ -56,14 +61,17 @@ def test_get_library(settings, preferences):
|
||||||
assert getattr(actor, key) == value
|
assert getattr(actor, key) == value
|
||||||
|
|
||||||
|
|
||||||
def test_get_test(settings, preferences):
|
def test_get_test(db, mocker, settings):
|
||||||
preferences['federation__public_key'] = 'public_key'
|
get_key_pair = mocker.patch(
|
||||||
|
'funkwhale_api.federation.keys.get_key_pair',
|
||||||
|
return_value=(b'private', b'public'))
|
||||||
expected = {
|
expected = {
|
||||||
'preferred_username': 'test',
|
'preferred_username': 'test',
|
||||||
'domain': settings.FEDERATION_HOSTNAME,
|
'domain': settings.FEDERATION_HOSTNAME,
|
||||||
'type': 'Person',
|
'type': 'Person',
|
||||||
'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME),
|
'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME),
|
||||||
'manually_approves_followers': False,
|
'manually_approves_followers': False,
|
||||||
|
'public_key': 'public',
|
||||||
'url': utils.full_url(
|
'url': utils.full_url(
|
||||||
reverse(
|
reverse(
|
||||||
'federation:instance-actors-detail',
|
'federation:instance-actors-detail',
|
||||||
|
@ -80,7 +88,6 @@ def test_get_test(settings, preferences):
|
||||||
reverse(
|
reverse(
|
||||||
'federation:instance-actors-outbox',
|
'federation:instance-actors-outbox',
|
||||||
kwargs={'actor': 'test'})),
|
kwargs={'actor': 'test'})),
|
||||||
'public_key': 'public_key',
|
|
||||||
'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format(
|
'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format(
|
||||||
settings.FEDERATION_HOSTNAME),
|
settings.FEDERATION_HOSTNAME),
|
||||||
}
|
}
|
||||||
|
@ -91,18 +98,18 @@ def test_get_test(settings, preferences):
|
||||||
|
|
||||||
def test_test_get_outbox():
|
def test_test_get_outbox():
|
||||||
expected = {
|
expected = {
|
||||||
"@context": [
|
"@context": [
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
"https://w3id.org/security/v1",
|
"https://w3id.org/security/v1",
|
||||||
{}
|
{}
|
||||||
],
|
],
|
||||||
"id": utils.full_url(
|
"id": utils.full_url(
|
||||||
reverse(
|
reverse(
|
||||||
'federation:instance-actors-outbox',
|
'federation:instance-actors-outbox',
|
||||||
kwargs={'actor': 'test'})),
|
kwargs={'actor': 'test'})),
|
||||||
"type": "OrderedCollection",
|
"type": "OrderedCollection",
|
||||||
"totalItems": 0,
|
"totalItems": 0,
|
||||||
"orderedItems": []
|
"orderedItems": []
|
||||||
}
|
}
|
||||||
|
|
||||||
data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None)
|
data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None)
|
||||||
|
@ -126,7 +133,7 @@ def test_test_post_outbox_validates_actor(nodb_factories):
|
||||||
assert msg in exc_info.value
|
assert msg in exc_info.value
|
||||||
|
|
||||||
|
|
||||||
def test_test_post_outbox_handles_create_note(
|
def test_test_post_inbox_handles_create_note(
|
||||||
settings, mocker, factories):
|
settings, mocker, factories):
|
||||||
deliver = mocker.patch(
|
deliver = mocker.patch(
|
||||||
'funkwhale_api.federation.activity.deliver')
|
'funkwhale_api.federation.activity.deliver')
|
||||||
|
@ -167,11 +174,7 @@ def test_test_post_outbox_handles_create_note(
|
||||||
}]
|
}]
|
||||||
)
|
)
|
||||||
expected_activity = {
|
expected_activity = {
|
||||||
'@context': [
|
'@context': serializers.AP_CONTEXT,
|
||||||
'https://www.w3.org/ns/activitystreams',
|
|
||||||
'https://w3id.org/security/v1',
|
|
||||||
{}
|
|
||||||
],
|
|
||||||
'actor': test_actor.url,
|
'actor': test_actor.url,
|
||||||
'id': 'https://{}/activities/note/{}/activity'.format(
|
'id': 'https://{}/activities/note/{}/activity'.format(
|
||||||
settings.FEDERATION_HOSTNAME, now.timestamp()
|
settings.FEDERATION_HOSTNAME, now.timestamp()
|
||||||
|
@ -188,3 +191,276 @@ def test_test_post_outbox_handles_create_note(
|
||||||
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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_getting_actor_instance_persists_in_db(db):
|
||||||
|
test = actors.SYSTEM_ACTORS['test'].get_actor_instance()
|
||||||
|
from_db = models.Actor.objects.get(url=test.url)
|
||||||
|
|
||||||
|
for f in test._meta.fields:
|
||||||
|
assert getattr(from_db, f.name) == getattr(test, f.name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('username,domain,expected', [
|
||||||
|
('test', 'wrongdomain.com', False),
|
||||||
|
('notsystem', '', False),
|
||||||
|
('test', '', True),
|
||||||
|
])
|
||||||
|
def test_actor_is_system(
|
||||||
|
username, domain, expected, nodb_factories, settings):
|
||||||
|
if not domain:
|
||||||
|
domain = settings.FEDERATION_HOSTNAME
|
||||||
|
|
||||||
|
actor = nodb_factories['federation.Actor'](
|
||||||
|
preferred_username=username,
|
||||||
|
domain=domain,
|
||||||
|
)
|
||||||
|
assert actor.is_system is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('username,domain,expected', [
|
||||||
|
('test', 'wrongdomain.com', None),
|
||||||
|
('notsystem', '', None),
|
||||||
|
('test', '', actors.SYSTEM_ACTORS['test']),
|
||||||
|
])
|
||||||
|
def test_actor_is_system(
|
||||||
|
username, domain, expected, nodb_factories, settings):
|
||||||
|
if not domain:
|
||||||
|
domain = settings.FEDERATION_HOSTNAME
|
||||||
|
actor = nodb_factories['federation.Actor'](
|
||||||
|
preferred_username=username,
|
||||||
|
domain=domain,
|
||||||
|
)
|
||||||
|
assert actor.system_conf == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', [False, True])
|
||||||
|
def test_library_actor_manually_approves_based_on_setting(
|
||||||
|
value, settings):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = value
|
||||||
|
library_conf = actors.SYSTEM_ACTORS['library']
|
||||||
|
assert library_conf.manually_approves_followers is value
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_actor_handle(mocker, nodb_factories):
|
||||||
|
handler = mocker.patch(
|
||||||
|
'funkwhale_api.federation.actors.TestActor.handle_create')
|
||||||
|
actor = nodb_factories['federation.Actor']()
|
||||||
|
activity = nodb_factories['federation.Activity'](
|
||||||
|
type='Create', actor=actor.url)
|
||||||
|
serializer = serializers.ActivitySerializer(
|
||||||
|
data=activity
|
||||||
|
)
|
||||||
|
assert serializer.is_valid()
|
||||||
|
actors.SYSTEM_ACTORS['test'].handle(activity, actor)
|
||||||
|
handler.assert_called_once_with(activity, actor)
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_actor_handles_follow(
|
||||||
|
settings, 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)
|
||||||
|
accept_follow = mocker.patch(
|
||||||
|
'funkwhale_api.federation.activity.accept_follow')
|
||||||
|
test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
|
||||||
|
data = {
|
||||||
|
'actor': actor.url,
|
||||||
|
'type': 'Follow',
|
||||||
|
'id': 'http://test.federation/user#follows/267',
|
||||||
|
'object': test_actor.url,
|
||||||
|
}
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
mocker.patch('uuid.uuid4', return_value=uid)
|
||||||
|
expected_follow = {
|
||||||
|
'@context': serializers.AP_CONTEXT,
|
||||||
|
'actor': test_actor.url,
|
||||||
|
'id': test_actor.url + '#follows/{}'.format(uid),
|
||||||
|
'object': actor.url,
|
||||||
|
'type': 'Follow'
|
||||||
|
}
|
||||||
|
|
||||||
|
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
|
||||||
|
accept_follow.assert_called_once_with(
|
||||||
|
test_actor, data, actor
|
||||||
|
)
|
||||||
|
expected_calls = [
|
||||||
|
mocker.call(
|
||||||
|
expected_follow,
|
||||||
|
to=[actor.url],
|
||||||
|
on_behalf_of=test_actor,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
deliver.assert_has_calls(expected_calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_actor_handles_undo_follow(
|
||||||
|
settings, mocker, factories):
|
||||||
|
deliver = mocker.patch(
|
||||||
|
'funkwhale_api.federation.activity.deliver')
|
||||||
|
test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
|
||||||
|
follow = factories['federation.Follow'](target=test_actor)
|
||||||
|
reverse_follow = factories['federation.Follow'](
|
||||||
|
actor=test_actor, target=follow.actor)
|
||||||
|
follow_serializer = serializers.FollowSerializer(follow)
|
||||||
|
reverse_follow_serializer = serializers.FollowSerializer(
|
||||||
|
reverse_follow)
|
||||||
|
undo = {
|
||||||
|
'@context': serializers.AP_CONTEXT,
|
||||||
|
'type': 'Undo',
|
||||||
|
'id': follow_serializer.data['id'] + '/undo',
|
||||||
|
'actor': follow.actor.url,
|
||||||
|
'object': follow_serializer.data,
|
||||||
|
}
|
||||||
|
expected_undo = {
|
||||||
|
'@context': serializers.AP_CONTEXT,
|
||||||
|
'type': 'Undo',
|
||||||
|
'id': reverse_follow_serializer.data['id'] + '/undo',
|
||||||
|
'actor': reverse_follow.actor.url,
|
||||||
|
'object': reverse_follow_serializer.data,
|
||||||
|
}
|
||||||
|
|
||||||
|
actors.SYSTEM_ACTORS['test'].post_inbox(undo, actor=follow.actor)
|
||||||
|
deliver.assert_called_once_with(
|
||||||
|
expected_undo,
|
||||||
|
to=[follow.actor.url],
|
||||||
|
on_behalf_of=test_actor,)
|
||||||
|
|
||||||
|
assert models.Follow.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_actor_handles_follow_manual_approval(
|
||||||
|
settings, mocker, factories):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
|
||||||
|
actor = factories['federation.Actor']()
|
||||||
|
now = timezone.now()
|
||||||
|
mocker.patch('django.utils.timezone.now', return_value=now)
|
||||||
|
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
data = {
|
||||||
|
'actor': actor.url,
|
||||||
|
'type': 'Follow',
|
||||||
|
'id': 'http://test.federation/user#follows/267',
|
||||||
|
'object': library_actor.url,
|
||||||
|
}
|
||||||
|
|
||||||
|
library_actor.system_conf.post_inbox(data, actor=actor)
|
||||||
|
fr = library_actor.received_follow_requests.first()
|
||||||
|
|
||||||
|
assert library_actor.received_follow_requests.count() == 1
|
||||||
|
assert fr.target == library_actor
|
||||||
|
assert fr.actor == actor
|
||||||
|
assert fr.approved is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_actor_handles_follow_auto_approval(
|
||||||
|
settings, mocker, factories):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
|
||||||
|
actor = factories['federation.Actor']()
|
||||||
|
accept_follow = mocker.patch(
|
||||||
|
'funkwhale_api.federation.activity.accept_follow')
|
||||||
|
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
data = {
|
||||||
|
'actor': actor.url,
|
||||||
|
'type': 'Follow',
|
||||||
|
'id': 'http://test.federation/user#follows/267',
|
||||||
|
'object': library_actor.url,
|
||||||
|
}
|
||||||
|
library_actor.system_conf.post_inbox(data, actor=actor)
|
||||||
|
|
||||||
|
assert library_actor.received_follow_requests.count() == 0
|
||||||
|
accept_follow.assert_called_once_with(
|
||||||
|
library_actor, data, actor
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_actor_handle_create_audio_no_library(mocker, factories):
|
||||||
|
# when we receive inbox create audio, we should not do anything
|
||||||
|
# if we don't have a configured library matching the sender
|
||||||
|
mocked_create = mocker.patch(
|
||||||
|
'funkwhale_api.federation.serializers.AudioSerializer.create'
|
||||||
|
)
|
||||||
|
actor = factories['federation.Actor']()
|
||||||
|
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
data = {
|
||||||
|
'actor': actor.url,
|
||||||
|
'type': 'Create',
|
||||||
|
'id': 'http://test.federation/audio/create',
|
||||||
|
'object': {
|
||||||
|
'id': 'https://batch.import',
|
||||||
|
'type': 'Collection',
|
||||||
|
'totalItems': 2,
|
||||||
|
'items': factories['federation.Audio'].create_batch(size=2)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
library_actor.system_conf.post_inbox(data, actor=actor)
|
||||||
|
|
||||||
|
mocked_create.assert_not_called()
|
||||||
|
models.LibraryTrack.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_actor_handle_create_audio_no_library_enabled(
|
||||||
|
mocker, factories):
|
||||||
|
# when we receive inbox create audio, we should not do anything
|
||||||
|
# if we don't have an enabled library
|
||||||
|
mocked_create = mocker.patch(
|
||||||
|
'funkwhale_api.federation.serializers.AudioSerializer.create'
|
||||||
|
)
|
||||||
|
disabled_library = factories['federation.Library'](
|
||||||
|
federation_enabled=False)
|
||||||
|
actor = disabled_library.actor
|
||||||
|
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
data = {
|
||||||
|
'actor': actor.url,
|
||||||
|
'type': 'Create',
|
||||||
|
'id': 'http://test.federation/audio/create',
|
||||||
|
'object': {
|
||||||
|
'id': 'https://batch.import',
|
||||||
|
'type': 'Collection',
|
||||||
|
'totalItems': 2,
|
||||||
|
'items': factories['federation.Audio'].create_batch(size=2)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
library_actor.system_conf.post_inbox(data, actor=actor)
|
||||||
|
|
||||||
|
mocked_create.assert_not_called()
|
||||||
|
models.LibraryTrack.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_actor_handle_create_audio(mocker, factories):
|
||||||
|
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
remote_library = factories['federation.Library'](
|
||||||
|
federation_enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'actor': remote_library.actor.url,
|
||||||
|
'type': 'Create',
|
||||||
|
'id': 'http://test.federation/audio/create',
|
||||||
|
'object': {
|
||||||
|
'id': 'https://batch.import',
|
||||||
|
'type': 'Collection',
|
||||||
|
'totalItems': 2,
|
||||||
|
'items': factories['federation.Audio'].create_batch(size=2)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
library_actor.system_conf.post_inbox(data, actor=remote_library.actor)
|
||||||
|
|
||||||
|
lts = list(remote_library.tracks.order_by('id'))
|
||||||
|
|
||||||
|
assert len(lts) == 2
|
||||||
|
|
||||||
|
for i, a in enumerate(data['object']['items']):
|
||||||
|
lt = lts[i]
|
||||||
|
assert lt.pk is not None
|
||||||
|
assert lt.url == a['id']
|
||||||
|
assert lt.library == remote_library
|
||||||
|
assert lt.audio_url == a['url']['href']
|
||||||
|
assert lt.audio_mimetype == a['url']['mediaType']
|
||||||
|
assert lt.metadata == a['metadata']
|
||||||
|
assert lt.title == a['metadata']['recording']['title']
|
||||||
|
assert lt.artist_name == a['metadata']['artist']['name']
|
||||||
|
assert lt.album_title == a['metadata']['release']['title']
|
||||||
|
assert lt.published_date == arrow.get(a['published'])
|
||||||
|
|
|
@ -3,7 +3,7 @@ from funkwhale_api.federation import keys
|
||||||
from funkwhale_api.federation import signing
|
from funkwhale_api.federation import signing
|
||||||
|
|
||||||
|
|
||||||
def test_authenticate(nodb_factories, mocker, api_request):
|
def test_authenticate(factories, mocker, api_request):
|
||||||
private, public = keys.get_key_pair()
|
private, public = keys.get_key_pair()
|
||||||
actor_url = 'https://test.federation/actor'
|
actor_url = 'https://test.federation/actor'
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
|
@ -18,7 +18,7 @@ def test_authenticate(nodb_factories, mocker, api_request):
|
||||||
'id': actor_url + '#main-key',
|
'id': actor_url + '#main-key',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
signed_request = nodb_factories['federation.SignedRequest'](
|
signed_request = factories['federation.SignedRequest'](
|
||||||
auth__key=private,
|
auth__key=private,
|
||||||
auth__key_id=actor_url + '#main-key',
|
auth__key_id=actor_url + '#main-key',
|
||||||
auth__headers=[
|
auth__headers=[
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
from django.core.management import call_command
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_instance_key_pair(preferences, mocker):
|
|
||||||
mocker.patch(
|
|
||||||
'funkwhale_api.federation.keys.get_key_pair',
|
|
||||||
return_value=(b'private', b'public'))
|
|
||||||
assert preferences['federation__public_key'] == ''
|
|
||||||
assert preferences['federation__private_key'] == ''
|
|
||||||
|
|
||||||
call_command('generate_keys', interactive=False)
|
|
||||||
|
|
||||||
assert preferences['federation__private_key'] == 'private'
|
|
||||||
assert preferences['federation__public_key'] == 'public'
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
import pytest
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django import db
|
||||||
|
|
||||||
|
from funkwhale_api.federation import models
|
||||||
|
from funkwhale_api.federation import serializers
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_duplicate_actor(factories):
|
||||||
|
actor = factories['federation.Actor']()
|
||||||
|
|
||||||
|
with pytest.raises(db.IntegrityError):
|
||||||
|
factories['federation.Actor'](
|
||||||
|
domain=actor.domain,
|
||||||
|
preferred_username=actor.preferred_username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_duplicate_follow(factories):
|
||||||
|
follow = factories['federation.Follow']()
|
||||||
|
|
||||||
|
with pytest.raises(db.IntegrityError):
|
||||||
|
factories['federation.Follow'](
|
||||||
|
target=follow.target,
|
||||||
|
actor=follow.actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_federation_url(factories):
|
||||||
|
follow = factories['federation.Follow'](local=True)
|
||||||
|
expected = '{}#follows/{}'.format(
|
||||||
|
follow.actor.url, follow.uuid)
|
||||||
|
|
||||||
|
assert follow.get_federation_url() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_request_approve(mocker, factories):
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
mocker.patch('uuid.uuid4', return_value=uid)
|
||||||
|
accept_follow = mocker.patch(
|
||||||
|
'funkwhale_api.federation.activity.accept_follow')
|
||||||
|
fr = factories['federation.FollowRequest'](target__local=True)
|
||||||
|
fr.approve()
|
||||||
|
|
||||||
|
follow = {
|
||||||
|
'@context': serializers.AP_CONTEXT,
|
||||||
|
'actor': fr.actor.url,
|
||||||
|
'id': fr.actor.url + '#follows/{}'.format(uid),
|
||||||
|
'object': fr.target.url,
|
||||||
|
'type': 'Follow'
|
||||||
|
}
|
||||||
|
|
||||||
|
assert fr.approved is True
|
||||||
|
assert list(fr.target.followers.all()) == [fr.actor]
|
||||||
|
accept_follow.assert_called_once_with(
|
||||||
|
fr.target, follow, fr.actor
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_request_approve_non_local(mocker, factories):
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
mocker.patch('uuid.uuid4', return_value=uid)
|
||||||
|
accept_follow = mocker.patch(
|
||||||
|
'funkwhale_api.federation.activity.accept_follow')
|
||||||
|
fr = factories['federation.FollowRequest']()
|
||||||
|
fr.approve()
|
||||||
|
|
||||||
|
assert fr.approved is True
|
||||||
|
assert list(fr.target.followers.all()) == [fr.actor]
|
||||||
|
accept_follow.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_request_refused(mocker, factories):
|
||||||
|
fr = factories['federation.FollowRequest']()
|
||||||
|
fr.refuse()
|
||||||
|
|
||||||
|
assert fr.approved is False
|
||||||
|
assert fr.target.followers.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_model_unique_per_actor(factories):
|
||||||
|
library = factories['federation.Library']()
|
||||||
|
with pytest.raises(db.IntegrityError):
|
||||||
|
factories['federation.Library'](actor=library.actor)
|
|
@ -0,0 +1,45 @@
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from funkwhale_api.federation import actors
|
||||||
|
from funkwhale_api.federation import permissions
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_follower(
|
||||||
|
factories, api_request, anonymous_user, settings):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
|
||||||
|
view = APIView.as_view()
|
||||||
|
permission = permissions.LibraryFollower()
|
||||||
|
request = api_request.get('/')
|
||||||
|
setattr(request, 'user', anonymous_user)
|
||||||
|
check = permission.has_permission(request, view)
|
||||||
|
|
||||||
|
assert check is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_follower_actor_non_follower(
|
||||||
|
factories, api_request, anonymous_user, settings):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
|
||||||
|
actor = factories['federation.Actor']()
|
||||||
|
view = APIView.as_view()
|
||||||
|
permission = permissions.LibraryFollower()
|
||||||
|
request = api_request.get('/')
|
||||||
|
setattr(request, 'user', anonymous_user)
|
||||||
|
setattr(request, 'actor', actor)
|
||||||
|
check = permission.has_permission(request, view)
|
||||||
|
|
||||||
|
assert check is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_follower_actor_follower(
|
||||||
|
factories, api_request, anonymous_user, settings):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
|
||||||
|
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
follow = factories['federation.Follow'](target=library)
|
||||||
|
view = APIView.as_view()
|
||||||
|
permission = permissions.LibraryFollower()
|
||||||
|
request = api_request.get('/')
|
||||||
|
setattr(request, 'user', anonymous_user)
|
||||||
|
setattr(request, 'actor', follow.actor)
|
||||||
|
check = permission.has_permission(request, view)
|
||||||
|
|
||||||
|
assert check is True
|
|
@ -1,31 +1,36 @@
|
||||||
from django.urls import reverse
|
import arrow
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
|
||||||
|
from funkwhale_api.federation import actors
|
||||||
from funkwhale_api.federation import keys
|
from funkwhale_api.federation import keys
|
||||||
from funkwhale_api.federation import models
|
from funkwhale_api.federation import models
|
||||||
from funkwhale_api.federation import serializers
|
from funkwhale_api.federation import serializers
|
||||||
|
from funkwhale_api.federation import utils
|
||||||
|
|
||||||
|
|
||||||
def test_actor_serializer_from_ap(db):
|
def test_actor_serializer_from_ap(db):
|
||||||
payload = {
|
payload = {
|
||||||
'id': 'https://test.federation/user',
|
'id': 'https://test.federation/user',
|
||||||
'type': 'Person',
|
'type': 'Person',
|
||||||
'following': 'https://test.federation/user/following',
|
'following': 'https://test.federation/user/following',
|
||||||
'followers': 'https://test.federation/user/followers',
|
'followers': 'https://test.federation/user/followers',
|
||||||
'inbox': 'https://test.federation/user/inbox',
|
'inbox': 'https://test.federation/user/inbox',
|
||||||
'outbox': 'https://test.federation/user/outbox',
|
'outbox': 'https://test.federation/user/outbox',
|
||||||
'preferredUsername': 'user',
|
'preferredUsername': 'user',
|
||||||
'name': 'Real User',
|
'name': 'Real User',
|
||||||
'summary': 'Hello world',
|
'summary': 'Hello world',
|
||||||
'url': 'https://test.federation/@user',
|
'url': 'https://test.federation/@user',
|
||||||
'manuallyApprovesFollowers': False,
|
'manuallyApprovesFollowers': False,
|
||||||
'publicKey': {
|
'publicKey': {
|
||||||
'id': 'https://test.federation/user#main-key',
|
'id': 'https://test.federation/user#main-key',
|
||||||
'owner': 'https://test.federation/user',
|
'owner': 'https://test.federation/user',
|
||||||
'publicKeyPem': 'yolo'
|
'publicKeyPem': 'yolo'
|
||||||
},
|
},
|
||||||
'endpoints': {
|
'endpoints': {
|
||||||
'sharedInbox': 'https://test.federation/inbox'
|
'sharedInbox': 'https://test.federation/inbox'
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
serializer = serializers.ActorSerializer(data=payload)
|
serializer = serializers.ActorSerializer(data=payload)
|
||||||
|
@ -50,13 +55,13 @@ def test_actor_serializer_from_ap(db):
|
||||||
|
|
||||||
def test_actor_serializer_only_mandatory_field_from_ap(db):
|
def test_actor_serializer_only_mandatory_field_from_ap(db):
|
||||||
payload = {
|
payload = {
|
||||||
'id': 'https://test.federation/user',
|
'id': 'https://test.federation/user',
|
||||||
'type': 'Person',
|
'type': 'Person',
|
||||||
'following': 'https://test.federation/user/following',
|
'following': 'https://test.federation/user/following',
|
||||||
'followers': 'https://test.federation/user/followers',
|
'followers': 'https://test.federation/user/followers',
|
||||||
'inbox': 'https://test.federation/user/inbox',
|
'inbox': 'https://test.federation/user/inbox',
|
||||||
'outbox': 'https://test.federation/user/outbox',
|
'outbox': 'https://test.federation/user/outbox',
|
||||||
'preferredUsername': 'user',
|
'preferredUsername': 'user',
|
||||||
}
|
}
|
||||||
|
|
||||||
serializer = serializers.ActorSerializer(data=payload)
|
serializer = serializers.ActorSerializer(data=payload)
|
||||||
|
@ -82,24 +87,24 @@ def test_actor_serializer_to_ap():
|
||||||
'https://w3id.org/security/v1',
|
'https://w3id.org/security/v1',
|
||||||
{},
|
{},
|
||||||
],
|
],
|
||||||
'id': 'https://test.federation/user',
|
'id': 'https://test.federation/user',
|
||||||
'type': 'Person',
|
'type': 'Person',
|
||||||
'following': 'https://test.federation/user/following',
|
'following': 'https://test.federation/user/following',
|
||||||
'followers': 'https://test.federation/user/followers',
|
'followers': 'https://test.federation/user/followers',
|
||||||
'inbox': 'https://test.federation/user/inbox',
|
'inbox': 'https://test.federation/user/inbox',
|
||||||
'outbox': 'https://test.federation/user/outbox',
|
'outbox': 'https://test.federation/user/outbox',
|
||||||
'preferredUsername': 'user',
|
'preferredUsername': 'user',
|
||||||
'name': 'Real User',
|
'name': 'Real User',
|
||||||
'summary': 'Hello world',
|
'summary': 'Hello world',
|
||||||
'manuallyApprovesFollowers': False,
|
'manuallyApprovesFollowers': False,
|
||||||
'publicKey': {
|
'publicKey': {
|
||||||
'id': 'https://test.federation/user#main-key',
|
'id': 'https://test.federation/user#main-key',
|
||||||
'owner': 'https://test.federation/user',
|
'owner': 'https://test.federation/user',
|
||||||
'publicKeyPem': 'yolo'
|
'publicKeyPem': 'yolo'
|
||||||
},
|
},
|
||||||
'endpoints': {
|
'endpoints': {
|
||||||
'sharedInbox': 'https://test.federation/inbox'
|
'sharedInbox': 'https://test.federation/inbox'
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ac = models.Actor(
|
ac = models.Actor(
|
||||||
url=expected['id'],
|
url=expected['id'],
|
||||||
|
@ -144,3 +149,229 @@ def test_webfinger_serializer():
|
||||||
serializer = serializers.ActorWebfingerSerializer(actor)
|
serializer = serializers.ActorWebfingerSerializer(actor)
|
||||||
|
|
||||||
assert serializer.data == expected
|
assert serializer.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_serializer_to_ap(factories):
|
||||||
|
follow = factories['federation.Follow'](local=True)
|
||||||
|
serializer = serializers.FollowSerializer(follow)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
'@context': [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
'https://w3id.org/security/v1',
|
||||||
|
{},
|
||||||
|
],
|
||||||
|
'id': follow.get_federation_url(),
|
||||||
|
'type': 'Follow',
|
||||||
|
'actor': follow.actor.url,
|
||||||
|
'object': follow.target.url,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert serializer.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_paginated_collection_serializer(factories):
|
||||||
|
tfs = factories['music.TrackFile'].create_batch(size=5)
|
||||||
|
actor = factories['federation.Actor'](local=True)
|
||||||
|
|
||||||
|
conf = {
|
||||||
|
'id': 'https://test.federation/test',
|
||||||
|
'items': tfs,
|
||||||
|
'item_serializer': serializers.AudioSerializer,
|
||||||
|
'actor': actor,
|
||||||
|
'page_size': 2,
|
||||||
|
}
|
||||||
|
expected = {
|
||||||
|
'@context': [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
'https://w3id.org/security/v1',
|
||||||
|
{},
|
||||||
|
],
|
||||||
|
'type': 'Collection',
|
||||||
|
'id': conf['id'],
|
||||||
|
'actor': actor.url,
|
||||||
|
'totalItems': len(tfs),
|
||||||
|
'current': conf['id'] + '?page=1',
|
||||||
|
'last': conf['id'] + '?page=3',
|
||||||
|
'first': conf['id'] + '?page=1',
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = serializers.PaginatedCollectionSerializer(conf)
|
||||||
|
|
||||||
|
assert serializer.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_page_serializer(factories):
|
||||||
|
tfs = factories['music.TrackFile'].create_batch(size=5)
|
||||||
|
actor = factories['federation.Actor'](local=True)
|
||||||
|
|
||||||
|
conf = {
|
||||||
|
'id': 'https://test.federation/test',
|
||||||
|
'item_serializer': serializers.AudioSerializer,
|
||||||
|
'actor': actor,
|
||||||
|
'page': Paginator(tfs, 2).page(2),
|
||||||
|
}
|
||||||
|
expected = {
|
||||||
|
'@context': [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
'https://w3id.org/security/v1',
|
||||||
|
{},
|
||||||
|
],
|
||||||
|
'type': 'CollectionPage',
|
||||||
|
'id': conf['id'] + '?page=2',
|
||||||
|
'actor': actor.url,
|
||||||
|
'totalItems': len(tfs),
|
||||||
|
'partOf': conf['id'],
|
||||||
|
'prev': conf['id'] + '?page=1',
|
||||||
|
'next': conf['id'] + '?page=3',
|
||||||
|
'first': conf['id'] + '?page=1',
|
||||||
|
'last': conf['id'] + '?page=3',
|
||||||
|
'items': [
|
||||||
|
conf['item_serializer'](
|
||||||
|
i,
|
||||||
|
context={'actor': actor, 'include_ap_context': False}
|
||||||
|
).data
|
||||||
|
for i in conf['page'].object_list
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = serializers.CollectionPageSerializer(conf)
|
||||||
|
|
||||||
|
assert serializer.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_pub_audio_serializer_to_library_track(factories):
|
||||||
|
remote_library = factories['federation.Library']()
|
||||||
|
audio = factories['federation.Audio']()
|
||||||
|
serializer = serializers.AudioSerializer(
|
||||||
|
data=audio, context={'library': remote_library})
|
||||||
|
|
||||||
|
assert serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
lt = serializer.save()
|
||||||
|
|
||||||
|
assert lt.pk is not None
|
||||||
|
assert lt.url == audio['id']
|
||||||
|
assert lt.library == remote_library
|
||||||
|
assert lt.audio_url == audio['url']['href']
|
||||||
|
assert lt.audio_mimetype == audio['url']['mediaType']
|
||||||
|
assert lt.metadata == audio['metadata']
|
||||||
|
assert lt.title == audio['metadata']['recording']['title']
|
||||||
|
assert lt.artist_name == audio['metadata']['artist']['name']
|
||||||
|
assert lt.album_title == audio['metadata']['release']['title']
|
||||||
|
assert lt.published_date == arrow.get(audio['published'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_pub_audio_serializer_to_ap(factories):
|
||||||
|
tf = factories['music.TrackFile'](mimetype='audio/mp3')
|
||||||
|
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
expected = {
|
||||||
|
'@context': serializers.AP_CONTEXT,
|
||||||
|
'type': 'Audio',
|
||||||
|
'id': tf.get_federation_url(),
|
||||||
|
'name': tf.track.full_name,
|
||||||
|
'published': tf.creation_date.isoformat(),
|
||||||
|
'updated': tf.modification_date.isoformat(),
|
||||||
|
'metadata': {
|
||||||
|
'artist': {
|
||||||
|
'musicbrainz_id': tf.track.artist.mbid,
|
||||||
|
'name': tf.track.artist.name,
|
||||||
|
},
|
||||||
|
'release': {
|
||||||
|
'musicbrainz_id': tf.track.album.mbid,
|
||||||
|
'title': tf.track.album.title,
|
||||||
|
},
|
||||||
|
'recording': {
|
||||||
|
'musicbrainz_id': tf.track.mbid,
|
||||||
|
'title': tf.track.title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'url': {
|
||||||
|
'href': utils.full_url(tf.path),
|
||||||
|
'type': 'Link',
|
||||||
|
'mediaType': 'audio/mp3'
|
||||||
|
},
|
||||||
|
'attributedTo': [
|
||||||
|
library.url
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = serializers.AudioSerializer(tf, context={'actor': library})
|
||||||
|
|
||||||
|
assert serializer.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_pub_audio_serializer_to_ap_no_mbid(factories):
|
||||||
|
tf = factories['music.TrackFile'](
|
||||||
|
mimetype='audio/mp3',
|
||||||
|
track__mbid=None,
|
||||||
|
track__album__mbid=None,
|
||||||
|
track__album__artist__mbid=None,
|
||||||
|
)
|
||||||
|
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
expected = {
|
||||||
|
'@context': serializers.AP_CONTEXT,
|
||||||
|
'type': 'Audio',
|
||||||
|
'id': tf.get_federation_url(),
|
||||||
|
'name': tf.track.full_name,
|
||||||
|
'published': tf.creation_date.isoformat(),
|
||||||
|
'updated': tf.modification_date.isoformat(),
|
||||||
|
'metadata': {
|
||||||
|
'artist': {
|
||||||
|
'name': tf.track.artist.name,
|
||||||
|
'musicbrainz_id': None,
|
||||||
|
},
|
||||||
|
'release': {
|
||||||
|
'title': tf.track.album.title,
|
||||||
|
'musicbrainz_id': None,
|
||||||
|
},
|
||||||
|
'recording': {
|
||||||
|
'title': tf.track.title,
|
||||||
|
'musicbrainz_id': None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'url': {
|
||||||
|
'href': utils.full_url(tf.path),
|
||||||
|
'type': 'Link',
|
||||||
|
'mediaType': 'audio/mp3'
|
||||||
|
},
|
||||||
|
'attributedTo': [
|
||||||
|
library.url
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = serializers.AudioSerializer(tf, context={'actor': library})
|
||||||
|
|
||||||
|
assert serializer.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_serializer_to_ap(factories):
|
||||||
|
tf1 = factories['music.TrackFile'](mimetype='audio/mp3')
|
||||||
|
tf2 = factories['music.TrackFile'](mimetype='audio/ogg')
|
||||||
|
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
expected = {
|
||||||
|
'@context': serializers.AP_CONTEXT,
|
||||||
|
'id': 'https://test.id',
|
||||||
|
'actor': library.url,
|
||||||
|
'totalItems': 2,
|
||||||
|
'type': 'Collection',
|
||||||
|
'items': [
|
||||||
|
serializers.AudioSerializer(
|
||||||
|
tf1, context={'actor': library, 'include_ap_context': False}
|
||||||
|
).data,
|
||||||
|
serializers.AudioSerializer(
|
||||||
|
tf2, context={'actor': library, 'include_ap_context': False}
|
||||||
|
).data,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
collection = {
|
||||||
|
'id': expected['id'],
|
||||||
|
'actor': library,
|
||||||
|
'items': [tf1, tf2],
|
||||||
|
'item_serializer': serializers.AudioSerializer
|
||||||
|
}
|
||||||
|
serializer = serializers.CollectionSerializer(
|
||||||
|
collection, context={'actor': library, 'id': 'https://test.id'})
|
||||||
|
|
||||||
|
assert serializer.data == expected
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from funkwhale_api.federation import actors
|
from funkwhale_api.federation import actors
|
||||||
from funkwhale_api.federation import serializers
|
from funkwhale_api.federation import serializers
|
||||||
|
from funkwhale_api.federation import utils
|
||||||
from funkwhale_api.federation import webfinger
|
from funkwhale_api.federation import webfinger
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
|
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
|
||||||
def test_instance_actors(system_actor, db, settings, api_client):
|
def test_instance_actors(system_actor, db, settings, api_client):
|
||||||
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
|
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
|
||||||
|
@ -17,6 +18,8 @@ def test_instance_actors(system_actor, db, settings, api_client):
|
||||||
response = api_client.get(url)
|
response = api_client.get(url)
|
||||||
serializer = serializers.ActorSerializer(actor)
|
serializer = serializers.ActorSerializer(actor)
|
||||||
|
|
||||||
|
if system_actor == 'library':
|
||||||
|
response.data.pop('url')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data == serializer.data
|
assert response.data == serializer.data
|
||||||
|
|
||||||
|
@ -62,3 +65,89 @@ def test_wellknown_webfinger_system(
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response['Content-Type'] == 'application/jrd+json'
|
assert response['Content-Type'] == 'application/jrd+json'
|
||||||
assert response.data == serializer.data
|
assert response.data == serializer.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_file_list_requires_authenticated_actor(
|
||||||
|
db, settings, api_client):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
|
||||||
|
url = reverse('federation:music:files-list')
|
||||||
|
response = api_client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_file_list_actor_no_page(
|
||||||
|
db, settings, api_client, factories):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
|
||||||
|
settings.FEDERATION_COLLECTION_PAGE_SIZE = 2
|
||||||
|
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
tfs = factories['music.TrackFile'].create_batch(size=5)
|
||||||
|
conf = {
|
||||||
|
'id': utils.full_url(reverse('federation:music:files-list')),
|
||||||
|
'page_size': 2,
|
||||||
|
'items': list(reversed(tfs)), # we order by -creation_date
|
||||||
|
'item_serializer': serializers.AudioSerializer,
|
||||||
|
'actor': library
|
||||||
|
}
|
||||||
|
expected = serializers.PaginatedCollectionSerializer(conf).data
|
||||||
|
url = reverse('federation:music:files-list')
|
||||||
|
response = api_client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_file_list_actor_page(
|
||||||
|
db, settings, api_client, factories):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
|
||||||
|
settings.FEDERATION_COLLECTION_PAGE_SIZE = 2
|
||||||
|
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
tfs = factories['music.TrackFile'].create_batch(size=5)
|
||||||
|
conf = {
|
||||||
|
'id': utils.full_url(reverse('federation:music:files-list')),
|
||||||
|
'page': Paginator(list(reversed(tfs)), 2).page(2),
|
||||||
|
'item_serializer': serializers.AudioSerializer,
|
||||||
|
'actor': library
|
||||||
|
}
|
||||||
|
expected = serializers.CollectionPageSerializer(conf).data
|
||||||
|
url = reverse('federation:music:files-list')
|
||||||
|
response = api_client.get(url, data={'page': 2})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_file_list_actor_page_error(
|
||||||
|
db, settings, api_client, factories):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
|
||||||
|
url = reverse('federation:music:files-list')
|
||||||
|
response = api_client.get(url, data={'page': 'nope'})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_file_list_actor_page_error_too_far(
|
||||||
|
db, settings, api_client, factories):
|
||||||
|
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
|
||||||
|
url = reverse('federation:music:files-list')
|
||||||
|
response = api_client.get(url, data={'page': 5000})
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_actor_includes_library_link(db, settings, api_client):
|
||||||
|
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
url = reverse(
|
||||||
|
'federation:instance-actors-detail',
|
||||||
|
kwargs={'actor': 'library'})
|
||||||
|
response = api_client.get(url)
|
||||||
|
expected_links = [
|
||||||
|
{
|
||||||
|
'type': 'Link',
|
||||||
|
'name': 'library',
|
||||||
|
'mediaType': 'application/activity+json',
|
||||||
|
'href': utils.full_url(reverse('federation:music:files-list'))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data['url'] == expected_links
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import json
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from funkwhale_api.music import tasks
|
||||||
|
|
||||||
|
|
||||||
def test_create_import_can_bind_to_request(
|
def test_create_import_can_bind_to_request(
|
||||||
artists, albums, mocker, factories, superuser_api_client):
|
artists, albums, mocker, factories, superuser_api_client):
|
||||||
|
@ -33,3 +36,111 @@ def test_create_import_can_bind_to_request(
|
||||||
batch = request.import_batches.latest('id')
|
batch = request.import_batches.latest('id')
|
||||||
|
|
||||||
assert batch.import_request == request
|
assert batch.import_request == request
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_job_from_federation_no_musicbrainz(factories):
|
||||||
|
lt = factories['federation.LibraryTrack'](
|
||||||
|
artist_name='Hello',
|
||||||
|
album_title='World',
|
||||||
|
title='Ping',
|
||||||
|
)
|
||||||
|
job = factories['music.ImportJob'](
|
||||||
|
federation=True,
|
||||||
|
library_track=lt,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.import_job_run(import_job_id=job.pk)
|
||||||
|
job.refresh_from_db()
|
||||||
|
|
||||||
|
tf = job.track_file
|
||||||
|
assert tf.mimetype == lt.audio_mimetype
|
||||||
|
assert tf.library_track == job.library_track
|
||||||
|
assert tf.track.title == 'Ping'
|
||||||
|
assert tf.track.artist.name == 'Hello'
|
||||||
|
assert tf.track.album.title == 'World'
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_job_from_federation_musicbrainz_recording(factories, mocker):
|
||||||
|
t = factories['music.Track']()
|
||||||
|
track_from_api = mocker.patch(
|
||||||
|
'funkwhale_api.music.models.Track.get_or_create_from_api',
|
||||||
|
return_value=t)
|
||||||
|
lt = factories['federation.LibraryTrack'](
|
||||||
|
metadata__recording__musicbrainz=True,
|
||||||
|
artist_name='Hello',
|
||||||
|
album_title='World',
|
||||||
|
)
|
||||||
|
job = factories['music.ImportJob'](
|
||||||
|
federation=True,
|
||||||
|
library_track=lt,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.import_job_run(import_job_id=job.pk)
|
||||||
|
job.refresh_from_db()
|
||||||
|
|
||||||
|
tf = job.track_file
|
||||||
|
assert tf.mimetype == lt.audio_mimetype
|
||||||
|
assert tf.library_track == job.library_track
|
||||||
|
assert tf.track == t
|
||||||
|
track_from_api.assert_called_once_with(
|
||||||
|
mbid=lt.metadata['recording']['musicbrainz_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_job_from_federation_musicbrainz_release(factories, mocker):
|
||||||
|
a = factories['music.Album']()
|
||||||
|
album_from_api = mocker.patch(
|
||||||
|
'funkwhale_api.music.models.Album.get_or_create_from_api',
|
||||||
|
return_value=a)
|
||||||
|
lt = factories['federation.LibraryTrack'](
|
||||||
|
metadata__release__musicbrainz=True,
|
||||||
|
artist_name='Hello',
|
||||||
|
title='Ping',
|
||||||
|
)
|
||||||
|
job = factories['music.ImportJob'](
|
||||||
|
federation=True,
|
||||||
|
library_track=lt,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.import_job_run(import_job_id=job.pk)
|
||||||
|
job.refresh_from_db()
|
||||||
|
|
||||||
|
tf = job.track_file
|
||||||
|
assert tf.mimetype == lt.audio_mimetype
|
||||||
|
assert tf.library_track == job.library_track
|
||||||
|
assert tf.track.title == 'Ping'
|
||||||
|
assert tf.track.artist == a.artist
|
||||||
|
assert tf.track.album == a
|
||||||
|
|
||||||
|
album_from_api.assert_called_once_with(
|
||||||
|
mbid=lt.metadata['release']['musicbrainz_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_job_from_federation_musicbrainz_artist(factories, mocker):
|
||||||
|
a = factories['music.Artist']()
|
||||||
|
artist_from_api = mocker.patch(
|
||||||
|
'funkwhale_api.music.models.Artist.get_or_create_from_api',
|
||||||
|
return_value=a)
|
||||||
|
lt = factories['federation.LibraryTrack'](
|
||||||
|
metadata__artist__musicbrainz=True,
|
||||||
|
album_title='World',
|
||||||
|
title='Ping',
|
||||||
|
)
|
||||||
|
job = factories['music.ImportJob'](
|
||||||
|
federation=True,
|
||||||
|
library_track=lt,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.import_job_run(import_job_id=job.pk)
|
||||||
|
job.refresh_from_db()
|
||||||
|
|
||||||
|
tf = job.track_file
|
||||||
|
assert tf.mimetype == lt.audio_mimetype
|
||||||
|
assert tf.library_track == job.library_track
|
||||||
|
|
||||||
|
assert tf.track.title == 'Ping'
|
||||||
|
assert tf.track.artist == a
|
||||||
|
assert tf.track.album.artist == a
|
||||||
|
assert tf.track.album.title == 'World'
|
||||||
|
|
||||||
|
artist_from_api.assert_called_once_with(
|
||||||
|
mbid=lt.metadata['artist']['musicbrainz_id'])
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from funkwhale_api.federation import actors
|
||||||
|
from funkwhale_api.music import permissions
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_permission_no_protect(anonymous_user, api_request, settings):
|
||||||
|
settings.PROTECT_AUDIO_FILES = False
|
||||||
|
view = APIView.as_view()
|
||||||
|
permission = permissions.Listen()
|
||||||
|
request = api_request.get('/')
|
||||||
|
assert permission.has_permission(request, view) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_permission_protect_anonymous(
|
||||||
|
anonymous_user, api_request, settings):
|
||||||
|
settings.PROTECT_AUDIO_FILES = True
|
||||||
|
view = APIView.as_view()
|
||||||
|
permission = permissions.Listen()
|
||||||
|
request = api_request.get('/')
|
||||||
|
assert permission.has_permission(request, view) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_permission_protect_authenticated(
|
||||||
|
factories, api_request, settings):
|
||||||
|
settings.PROTECT_AUDIO_FILES = True
|
||||||
|
user = factories['users.User']()
|
||||||
|
view = APIView.as_view()
|
||||||
|
permission = permissions.Listen()
|
||||||
|
request = api_request.get('/')
|
||||||
|
setattr(request, 'user', user)
|
||||||
|
assert permission.has_permission(request, view) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_permission_protect_not_following_actor(
|
||||||
|
factories, api_request, settings):
|
||||||
|
settings.PROTECT_AUDIO_FILES = True
|
||||||
|
actor = factories['federation.Actor']()
|
||||||
|
view = APIView.as_view()
|
||||||
|
permission = permissions.Listen()
|
||||||
|
request = api_request.get('/')
|
||||||
|
setattr(request, 'actor', actor)
|
||||||
|
assert permission.has_permission(request, view) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_permission_protect_following_actor(
|
||||||
|
factories, api_request, settings):
|
||||||
|
settings.PROTECT_AUDIO_FILES = True
|
||||||
|
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
follow = factories['federation.Follow'](target=library_actor)
|
||||||
|
view = APIView.as_view()
|
||||||
|
permission = permissions.Listen()
|
||||||
|
request = api_request.get('/')
|
||||||
|
setattr(request, 'actor', follow.actor)
|
||||||
|
|
||||||
|
assert permission.has_permission(request, view) is True
|
|
@ -1,6 +1,8 @@
|
||||||
|
import io
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from funkwhale_api.music import views
|
from funkwhale_api.music import views
|
||||||
|
from funkwhale_api.federation import actors
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('param,expected', [
|
@pytest.mark.parametrize('param,expected', [
|
||||||
|
@ -43,3 +45,41 @@ def test_album_view_filter_listenable(
|
||||||
queryset = view.filter_queryset(view.get_queryset())
|
queryset = view.filter_queryset(view.get_queryset())
|
||||||
|
|
||||||
assert list(queryset) == expected
|
assert list(queryset) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_serve_track_file_as_remote_library(
|
||||||
|
factories, authenticated_actor, settings, api_client):
|
||||||
|
settings.PROTECT_AUDIO_FILES = True
|
||||||
|
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||||
|
follow = factories['federation.Follow'](
|
||||||
|
actor=authenticated_actor, target=library_actor)
|
||||||
|
|
||||||
|
track_file = factories['music.TrackFile']()
|
||||||
|
response = api_client.get(track_file.path)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response['X-Accel-Redirect'] == "{}{}".format(
|
||||||
|
settings.PROTECT_FILES_PATH,
|
||||||
|
track_file.audio_file.url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_serve_track_file_as_remote_library_deny_not_following(
|
||||||
|
factories, authenticated_actor, settings, api_client):
|
||||||
|
settings.PROTECT_AUDIO_FILES = True
|
||||||
|
track_file = factories['music.TrackFile']()
|
||||||
|
response = api_client.get(track_file.path)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_proxy_remote_track(
|
||||||
|
factories, settings, api_client, r_mock):
|
||||||
|
settings.PROTECT_AUDIO_FILES = False
|
||||||
|
track_file = factories['music.TrackFile'](federation=True)
|
||||||
|
|
||||||
|
r_mock.get(track_file.library_track.audio_url, body=io.StringIO('test'))
|
||||||
|
response = api_client.get(track_file.path)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert list(response.streaming_content) == [b't', b'e', b's', b't']
|
||||||
|
assert response['Content-Type'] == track_file.library_track.audio_mimetype
|
||||||
|
|
|
@ -85,3 +85,12 @@ API_AUTHENTICATION_REQUIRED=True
|
||||||
# This will help us detect and correct bugs
|
# This will help us detect and correct bugs
|
||||||
RAVEN_ENABLED=false
|
RAVEN_ENABLED=false
|
||||||
RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
|
RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
|
||||||
|
|
||||||
|
# This settings enable/disable federation on the instance level
|
||||||
|
FEDERATION_ENABLED=True
|
||||||
|
# This setting decide wether music library is shared automatically
|
||||||
|
# to followers or if it requires manual approval before.
|
||||||
|
# FEDERATION_MUSIC_NEEDS_APPROVAL=False
|
||||||
|
# means anyone can subscribe to your library and import your file,
|
||||||
|
# use with caution.
|
||||||
|
FEDERATION_MUSIC_NEEDS_APPROVAL=True
|
||||||
|
|
Loading…
Reference in New Issue