Can now serve track from remote library
This commit is contained in:
parent
b29ca44797
commit
9612b1bace
|
@ -58,21 +58,12 @@ OBJECT_TYPES = [
|
||||||
'Video',
|
'Video',
|
||||||
] + ACTIVITY_TYPES
|
] + 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)
|
||||||
|
|
|
@ -13,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__)
|
||||||
|
@ -51,24 +53,37 @@ class SystemActor(object):
|
||||||
additional_attributes = {}
|
additional_attributes = {}
|
||||||
manually_approves_followers = False
|
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):
|
def serialize(self):
|
||||||
actor = self.get_actor_instance()
|
actor = self.get_actor_instance()
|
||||||
serializer = serializers.ActorSerializer(actor)
|
serializer = serializers.ActorSerializer(actor)
|
||||||
return serializer.data
|
return serializer.data
|
||||||
|
|
||||||
def get_actor_instance(self):
|
def get_actor_instance(self):
|
||||||
|
try:
|
||||||
|
return models.Actor.objects.get(url=self.get_actor_url())
|
||||||
|
except models.Actor.DoesNotExist:
|
||||||
|
pass
|
||||||
|
private, public = keys.get_key_pair()
|
||||||
args = self.get_instance_argument(
|
args = self.get_instance_argument(
|
||||||
self.id,
|
self.id,
|
||||||
name=self.name,
|
name=self.name,
|
||||||
summary=self.summary,
|
summary=self.summary,
|
||||||
**self.additional_attributes
|
**self.additional_attributes
|
||||||
)
|
)
|
||||||
url = args.pop('url')
|
args['private_key'] = private.decode('utf-8')
|
||||||
a, created = models.Actor.objects.get_or_create(
|
args['public_key'] = public.decode('utf-8')
|
||||||
url=url,
|
return models.Actor.objects.create(**args)
|
||||||
defaults=args,
|
|
||||||
)
|
def get_actor_url(self):
|
||||||
return a
|
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()
|
preferences = global_preferences_registry.manager()
|
||||||
|
@ -78,10 +93,7 @@ class SystemActor(object):
|
||||||
'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',
|
||||||
|
|
|
@ -51,6 +51,8 @@ class SignatureAuthentication(authentication.BaseAuthentication):
|
||||||
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)
|
||||||
|
|
|
@ -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'))
|
|
||||||
)
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 2.0.3 on 2018-04-07 08:52
|
# Generated by Django 2.0.3 on 2018-04-07 10:10
|
||||||
|
|
||||||
import django.contrib.postgres.fields.jsonb
|
import django.contrib.postgres.fields.jsonb
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
@ -10,7 +10,6 @@ import uuid
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('music', '0022_importbatch_import_request'),
|
|
||||||
('federation', '0002_auto_20180403_1620'),
|
('federation', '0002_auto_20180403_1620'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -70,7 +69,6 @@ class Migration(migrations.Migration):
|
||||||
('title', models.CharField(max_length=500)),
|
('title', models.CharField(max_length=500)),
|
||||||
('metadata', django.contrib.postgres.fields.jsonb.JSONField(default={}, max_length=10000)),
|
('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')),
|
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracks', to='federation.Library')),
|
||||||
('local_track_file', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='library_track', to='music.TrackFile')),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
|
@ -192,13 +192,6 @@ class LibraryTrack(models.Model):
|
||||||
published_date = models.DateTimeField(null=True, blank=True)
|
published_date = models.DateTimeField(null=True, blank=True)
|
||||||
library = models.ForeignKey(
|
library = models.ForeignKey(
|
||||||
Library, related_name='tracks', on_delete=models.CASCADE)
|
Library, related_name='tracks', on_delete=models.CASCADE)
|
||||||
local_track_file = models.OneToOneField(
|
|
||||||
'music.TrackFile',
|
|
||||||
related_name='library_track',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
artist_name = models.CharField(max_length=500)
|
artist_name = models.CharField(max_length=500)
|
||||||
album_title = models.CharField(max_length=500)
|
album_title = models.CharField(max_length=500)
|
||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
@ -56,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):
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 2.0.3 on 2018-04-07 08:52
|
# Generated by Django 2.0.3 on 2018-04-07 10:10
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
@ -10,7 +10,7 @@ import uuid
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('federation', '0003_auto_20180407_0852'),
|
('federation', '0003_auto_20180407_1010'),
|
||||||
('music', '0022_importbatch_import_request'),
|
('music', '0022_importbatch_import_request'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -55,6 +55,11 @@ class Migration(migrations.Migration):
|
||||||
name='creation_date',
|
name='creation_date',
|
||||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
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(
|
migrations.AddField(
|
||||||
model_name='trackfile',
|
model_name='trackfile',
|
||||||
name='modification_date',
|
name='modification_date',
|
|
@ -419,6 +419,14 @@ class TrackFile(models.Model):
|
||||||
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
|
||||||
|
@ -441,10 +449,8 @@ class TrackFile(models.Model):
|
||||||
|
|
||||||
@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):
|
||||||
|
|
|
@ -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()
|
|
@ -112,6 +112,7 @@ def _do_import(import_job, replace, use_acoustid=True):
|
||||||
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:
|
elif import_job.library_track:
|
||||||
|
track_file.library_track = import_job.library_track
|
||||||
track_file.mimetype = import_job.library_track.audio_mimetype
|
track_file.mimetype = import_job.library_track.audio_mimetype
|
||||||
if import_job.library_track.library.download_files:
|
if import_job.library_track.library.download_files:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
@ -121,10 +122,6 @@ def _do_import(import_job, replace, use_acoustid=True):
|
||||||
else:
|
else:
|
||||||
track_file.download_file()
|
track_file.download_file()
|
||||||
track_file.save()
|
track_file.save()
|
||||||
if import_job.library_track:
|
|
||||||
import_job.library_track.local_track_file = track_file
|
|
||||||
import_job.library_track.save(
|
|
||||||
update_fields=['modification_date', 'local_track_file'])
|
|
||||||
import_job.status = 'finished'
|
import_job.status = 'finished'
|
||||||
import_job.track_file = track_file
|
import_job.track_file = track_file
|
||||||
if import_job.audio_file:
|
if import_job.audio_file:
|
||||||
|
|
|
@ -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
|
|
|
@ -26,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',
|
||||||
|
@ -50,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),
|
||||||
}
|
}
|
||||||
|
@ -59,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',
|
||||||
|
@ -83,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),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,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
|
||||||
|
|
Loading…
Reference in New Issue