diff --git a/.env.dev b/.env.dev
index 9923c3148..2e8834143 100644
--- a/.env.dev
+++ b/.env.dev
@@ -1,3 +1,9 @@
API_AUTHENTICATION_REQUIRED=True
RAVEN_ENABLED=false
RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
+DJANGO_ALLOWED_HOSTS=localhost,nginx
+DJANGO_SETTINGS_MODULE=config.settings.local
+DJANGO_SECRET_KEY=dev
+C_FORCE_ROOT=true
+FUNKWHALE_URL=http://localhost
+PYTHONDONTWRITEBYTECODE=true
diff --git a/.gitignore b/.gitignore
index 1e1017c8d..8b5117034 100644
--- a/.gitignore
+++ b/.gitignore
@@ -86,3 +86,4 @@ front/selenium-debug.log
docs/_build
data/
+.env
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 38492f614..94b40bed3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -13,6 +13,7 @@ stages:
test_api:
services:
- postgres:9.4
+ - redis:3
stage: test
image: funkwhale/funkwhale:latest
cache:
@@ -24,6 +25,7 @@ test_api:
DATABASE_URL: "postgresql://postgres@postgres/postgres"
FUNKWHALE_URL: "https://funkwhale.ci"
CACHEOPS_ENABLED: "false"
+ DJANGO_SETTINGS_MODULE: config.settings.local
before_script:
- cd api
@@ -31,7 +33,7 @@ test_api:
- pip install -r requirements/local.txt
- pip install -r requirements/test.txt
script:
- - pytest
+ - pytest --cov=funkwhale_api tests/
tags:
- docker
diff --git a/CHANGELOG b/CHANGELOG
index 0b9198723..0f01b5825 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -3,6 +3,100 @@ Changelog
.. towncrier
+0.8 (2018-04-02)
+----------------
+
+Features:
+
+- Add a detail page for radios (#64)
+- Implemented page title binding (#1)
+- Previous Track button restart playback after 3 seconds (#146)
+
+
+Enhancements:
+
+- Added credits to Francis Gading for the logotype (#101)
+- API endpoint for fetching instance activity and updated timeline to use this
+ new endpoint (#141)
+- Better error messages in case of missing environment variables (#140)
+- Implemented a @test@yourfunkwhaledomain bot to ensure federation works
+ properly. Send it "/ping" and it will answer back :)
+- Queue shuffle now apply only to tracks after the current one (#97)
+- Removed player from queue tab and consistently show current track in queue
+ (#131)
+- We now restrict some usernames from being used during signup (#139)
+
+
+Bugfixes:
+
+- Better error handling during file import (#120)
+- Better handling of utf-8 filenames during file import (#138)
+- Converted favicon from .ico to .png (#130)
+- Upgraded to Python 3.6 to fix weird but harmless weakref error on django task
+ (#121)
+
+
+Documentation:
+
+- Documented the upgrade process (#127)
+
+
+Preparing for federation
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+Federation of music libraries is one of the most asked feature.
+While there is still a lot of work to do, this version includes
+the foundation that will enable funkwhale servers to communicate
+between each others, and with other federated software, such as
+Mastodon.
+
+Funkwhale will use ActivityPub as it's federation protocol.
+
+In order to prepare for federation (see #136 and #137), new API endpoints
+have been added under /federation and /.well-known/webfinger.
+
+For these endpoints to work, you will need to update your nginx configuration,
+and add the following snippets::
+
+ location /federation/ {
+ include /etc/nginx/funkwhale_proxy.conf;
+ proxy_pass http://funkwhale-api/federation/;
+ }
+
+ location /.well-known/webfinger {
+ include /etc/nginx/funkwhale_proxy.conf;
+ proxy_pass http://funkwhale-api/.well-known/webfinger;
+ }
+
+This will ensure federation endpoints will be reachable in the future.
+You can of course skip this part if you know you will not federate your instance.
+
+A new ``FEDERATION_ENABLED`` env var have also been added to control wether
+federation is enabled or not on the application side. This settings defaults
+to True, which should have no consequencies at the moment, since actual
+federation is not implemented and the only available endpoints are for
+testing purposes.
+
+Add ``FEDERATION_ENABLED=false`` to your .env file to disable federation
+on the application side.
+
+The last step involves generating RSA private and public keys for signing
+your instance requests on the federation. This can be done via::
+
+ # on docker setups
+ docker-compose run --rm api python manage.py generate_keys --no-input
+
+ # on non-docker setups
+ source /srv/funkwhale/virtualenv/bin/activate
+ source /srv/funkwhale/load_env
+ python manage.py generate_keys --no-input
+
+To test and troobleshoot federation, we've added a bot account. This bot is available at @test@yourinstancedomain,
+and sending it "/ping", for example, via Mastodon, should trigger
+a response.
+
+
+
0.7 (2018-03-21)
----------------
diff --git a/README.rst b/README.rst
index 93281d26f..2d5d2011d 100644
--- a/README.rst
+++ b/README.rst
@@ -73,6 +73,19 @@ via the following command::
docker-compose -f dev.yml build
+Creating your env file
+^^^^^^^^^^^^^^^^^^^^^^
+
+We provide a working .env.dev configuration file that is suitable for
+development. However, to enable customization on your machine, you should
+also create a .env file that will hold your personal environment
+variables (those will not be commited to the project).
+
+Create it like this::
+
+ touch .env
+
+
Database management
^^^^^^^^^^^^^^^^^^^
diff --git a/api/Dockerfile b/api/Dockerfile
index 9296785ee..6acdaac56 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.5
+FROM python:3.6
ENV PYTHONUNBUFFERED 1
diff --git a/api/compose/django/dev-entrypoint.sh b/api/compose/django/dev-entrypoint.sh
index 416207b43..6deeebb00 100755
--- a/api/compose/django/dev-entrypoint.sh
+++ b/api/compose/django/dev-entrypoint.sh
@@ -1,7 +1,3 @@
#!/bin/bash
set -e
-if [ $1 = "pytest" ]; then
- # let pytest.ini handle it
- unset DJANGO_SETTINGS_MODULE
-fi
exec "$@"
diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index ff6db0d06..cab6805b6 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -1,5 +1,6 @@
from rest_framework import routers
from django.conf.urls import include, url
+from funkwhale_api.activity import views as activity_views
from funkwhale_api.instance import views as instance_views
from funkwhale_api.music import views
from funkwhale_api.playlists import views as playlists_views
@@ -10,6 +11,7 @@ from dynamic_preferences.users.viewsets import UserPreferencesViewSet
router = routers.SimpleRouter()
router.register(r'settings', GlobalPreferencesViewSet, base_name='settings')
+router.register(r'activity', activity_views.ActivityViewSet, 'activity')
router.register(r'tags', views.TagViewSet, 'tags')
router.register(r'tracks', views.TrackViewSet, 'tracks')
router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles')
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 077566d1c..fbe3b7045 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/dev/ref/settings/
"""
from __future__ import absolute_import, unicode_literals
+from urllib.parse import urlsplit
import os
import environ
from funkwhale_api import __version__
@@ -24,8 +25,13 @@ try:
except FileNotFoundError:
pass
-ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
FUNKWHALE_URL = env('FUNKWHALE_URL')
+FUNKWHALE_HOSTNAME = urlsplit(FUNKWHALE_URL).netloc
+
+FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
+FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
+
+ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
# APP CONFIGURATION
# ------------------------------------------------------------------------------
@@ -89,6 +95,7 @@ LOCAL_APPS = (
'funkwhale_api.music',
'funkwhale_api.requests',
'funkwhale_api.favorites',
+ 'funkwhale_api.federation',
'funkwhale_api.radios',
'funkwhale_api.history',
'funkwhale_api.playlists',
@@ -231,6 +238,7 @@ STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR('staticfiles')))
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url
STATIC_URL = env("STATIC_URL", default='/staticfiles/')
+DEFAULT_FILE_STORAGE = 'funkwhale_api.common.storage.ASCIIFileSystemStorage'
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
STATICFILES_DIRS = (
@@ -336,7 +344,12 @@ REST_FRAMEWORK = {
),
'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination',
'PAGE_SIZE': 25,
-
+ 'DEFAULT_PARSER_CLASSES': (
+ 'rest_framework.parsers.JSONParser',
+ 'rest_framework.parsers.FormParser',
+ 'rest_framework.parsers.MultiPartParser',
+ 'funkwhale_api.federation.parsers.ActivityParser',
+ ),
'DEFAULT_AUTHENTICATION_CLASSES': (
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
@@ -385,3 +398,16 @@ CSRF_USE_SESSIONS = True
# Playlist settings
PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
+
+ACCOUNT_USERNAME_BLACKLIST = [
+ 'funkwhale',
+ 'library',
+ 'test',
+ 'status',
+ 'root',
+ 'admin',
+ 'owner',
+ 'superuser',
+ 'staff',
+ 'service',
+] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[])
diff --git a/api/config/settings/local.py b/api/config/settings/local.py
index 24ad871f7..dcbea66d2 100644
--- a/api/config/settings/local.py
+++ b/api/config/settings/local.py
@@ -72,6 +72,10 @@ LOGGING = {
'handlers':['console'],
'propagate': True,
'level':'DEBUG',
- }
+ },
+ '': {
+ 'level': 'DEBUG',
+ 'handlers': ['console'],
+ },
},
}
diff --git a/api/config/settings/test.py b/api/config/settings/test.py
deleted file mode 100644
index aff29c657..000000000
--- a/api/config/settings/test.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from .common import * # noqa
-SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
-
-# Mail settings
-# ------------------------------------------------------------------------------
-EMAIL_HOST = 'localhost'
-EMAIL_PORT = 1025
-EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
- default='django.core.mail.backends.console.EmailBackend')
-
-# CACHING
-# ------------------------------------------------------------------------------
-CACHES = {
- 'default': {
- 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
- 'LOCATION': ''
- }
-}
-
-CELERY_BROKER_URL = 'memory://'
-
-########## CELERY
-# In development, all tasks will be executed locally by blocking until the task returns
-CELERY_TASK_ALWAYS_EAGER = True
-########## END CELERY
-
-# Your local stuff: Below this line define 3rd party library settings
-API_AUTHENTICATION_REQUIRED = False
-CACHEOPS_ENABLED = False
diff --git a/api/config/urls.py b/api/config/urls.py
index 8f7e37bc2..90598ea84 100644
--- a/api/config/urls.py
+++ b/api/config/urls.py
@@ -13,6 +13,9 @@ urlpatterns = [
url(settings.ADMIN_URL, admin.site.urls),
url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
+ url(r'^', include(
+ ('funkwhale_api.federation.urls', 'federation'),
+ namespace="federation")),
url(r'^api/v1/auth/', include('rest_auth.urls')),
url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
url(r'^accounts/', include('allauth.urls')),
diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test
index 0990efa51..963e3ab20 100644
--- a/api/docker/Dockerfile.test
+++ b/api/docker/Dockerfile.test
@@ -1,4 +1,4 @@
-FROM python:3.5
+FROM python:3.6
ENV PYTHONUNBUFFERED 1
diff --git a/api/funkwhale_api/activity/serializers.py b/api/funkwhale_api/activity/serializers.py
index 325d1e820..fd9b185cf 100644
--- a/api/funkwhale_api/activity/serializers.py
+++ b/api/funkwhale_api/activity/serializers.py
@@ -1,5 +1,7 @@
from rest_framework import serializers
+from funkwhale_api.activity import record
+
class ModelSerializer(serializers.ModelSerializer):
id = serializers.CharField(source='get_activity_url')
@@ -8,3 +10,15 @@ class ModelSerializer(serializers.ModelSerializer):
def get_url(self, obj):
return self.get_id(obj)
+
+
+class AutoSerializer(serializers.Serializer):
+ """
+ A serializer that will automatically use registered activity serializers
+ to serialize an henerogeneous list of objects (favorites, listenings, etc.)
+ """
+ def to_representation(self, instance):
+ serializer = record.registry[instance._meta.label]['serializer'](
+ instance
+ )
+ return serializer.data
diff --git a/api/funkwhale_api/activity/utils.py b/api/funkwhale_api/activity/utils.py
new file mode 100644
index 000000000..46336930e
--- /dev/null
+++ b/api/funkwhale_api/activity/utils.py
@@ -0,0 +1,64 @@
+from django.db import models
+
+from funkwhale_api.common import fields
+from funkwhale_api.favorites.models import TrackFavorite
+from funkwhale_api.history.models import Listening
+
+
+def combined_recent(limit, **kwargs):
+ datetime_field = kwargs.pop('datetime_field', 'creation_date')
+ source_querysets = {
+ qs.model._meta.label: qs for qs in kwargs.pop('querysets')
+ }
+ querysets = {
+ k: qs.annotate(
+ __type=models.Value(
+ qs.model._meta.label, output_field=models.CharField()
+ )
+ ).values('pk', datetime_field, '__type')
+ for k, qs in source_querysets.items()
+ }
+ _qs_list = list(querysets.values())
+ union_qs = _qs_list[0].union(*_qs_list[1:])
+ records = []
+ for row in union_qs.order_by('-{}'.format(datetime_field))[:limit]:
+ records.append({
+ 'type': row['__type'],
+ 'when': row[datetime_field],
+ 'pk': row['pk']
+ })
+ # Now we bulk-load each object type in turn
+ to_load = {}
+ for record in records:
+ to_load.setdefault(record['type'], []).append(record['pk'])
+ fetched = {}
+
+ for key, pks in to_load.items():
+ for item in source_querysets[key].filter(pk__in=pks):
+ fetched[(key, item.pk)] = item
+
+ # Annotate 'records' with loaded objects
+ for record in records:
+ record['object'] = fetched[(record['type'], record['pk'])]
+ return records
+
+
+def get_activity(user, limit=20):
+ query = fields.privacy_level_query(
+ user, lookup_field='user__privacy_level')
+ querysets = [
+ Listening.objects.filter(query).select_related(
+ 'track',
+ 'user',
+ 'track__artist',
+ 'track__album__artist',
+ ),
+ TrackFavorite.objects.filter(query).select_related(
+ 'track',
+ 'user',
+ 'track__artist',
+ 'track__album__artist',
+ ),
+ ]
+ records = combined_recent(limit=limit, querysets=querysets)
+ return [r['object'] for r in records]
diff --git a/api/funkwhale_api/activity/views.py b/api/funkwhale_api/activity/views.py
new file mode 100644
index 000000000..e66de1ccf
--- /dev/null
+++ b/api/funkwhale_api/activity/views.py
@@ -0,0 +1,20 @@
+from rest_framework import viewsets
+from rest_framework.response import Response
+
+from funkwhale_api.common.permissions import ConditionalAuthentication
+from funkwhale_api.favorites.models import TrackFavorite
+
+from . import serializers
+from . import utils
+
+
+class ActivityViewSet(viewsets.GenericViewSet):
+
+ serializer_class = serializers.AutoSerializer
+ permission_classes = [ConditionalAuthentication]
+ queryset = TrackFavorite.objects.none()
+
+ def list(self, request, *args, **kwargs):
+ activity = utils.get_activity(user=request.user)
+ serializer = self.serializer_class(activity, many=True)
+ return Response({'results': serializer.data}, status=200)
diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py
index ef9f840dc..1a18b5f27 100644
--- a/api/funkwhale_api/common/fields.py
+++ b/api/funkwhale_api/common/fields.py
@@ -22,6 +22,6 @@ def privacy_level_query(user, lookup_field='privacy_level'):
return models.Q(**{
'{}__in'.format(lookup_field): [
- 'me', 'followers', 'instance', 'everyone'
+ 'followers', 'instance', 'everyone'
]
})
diff --git a/api/funkwhale_api/common/storage.py b/api/funkwhale_api/common/storage.py
new file mode 100644
index 000000000..658ce795a
--- /dev/null
+++ b/api/funkwhale_api/common/storage.py
@@ -0,0 +1,12 @@
+import unicodedata
+
+from django.core.files.storage import FileSystemStorage
+
+
+class ASCIIFileSystemStorage(FileSystemStorage):
+ """
+ Convert unicode characters in name to ASCII characters.
+ """
+ def get_valid_name(self, name):
+ name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore')
+ return super().get_valid_name(name)
diff --git a/api/funkwhale_api/federation/__init__.py b/api/funkwhale_api/federation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
new file mode 100644
index 000000000..4eeb193b1
--- /dev/null
+++ b/api/funkwhale_api/federation/activity.py
@@ -0,0 +1,85 @@
+import logging
+import json
+import requests
+import requests_http_signature
+
+from . import signing
+
+logger = logging.getLogger(__name__)
+
+ACTIVITY_TYPES = [
+ 'Accept',
+ 'Add',
+ 'Announce',
+ 'Arrive',
+ 'Block',
+ 'Create',
+ 'Delete',
+ 'Dislike',
+ 'Flag',
+ 'Follow',
+ 'Ignore',
+ 'Invite',
+ 'Join',
+ 'Leave',
+ 'Like',
+ 'Listen',
+ 'Move',
+ 'Offer',
+ 'Question',
+ 'Reject',
+ 'Read',
+ 'Remove',
+ 'TentativeReject',
+ 'TentativeAccept',
+ 'Travel',
+ 'Undo',
+ 'Update',
+ 'View',
+]
+
+
+OBJECT_TYPES = [
+ 'Article',
+ 'Audio',
+ 'Document',
+ 'Event',
+ 'Image',
+ 'Note',
+ 'Page',
+ 'Place',
+ 'Profile',
+ 'Relationship',
+ 'Tombstone',
+ 'Video',
+]
+
+def deliver(activity, on_behalf_of, to=[]):
+ from . import actors
+ logger.info('Preparing activity delivery to %s', to)
+ auth = requests_http_signature.HTTPSignatureAuth(
+ use_auth_header=False,
+ 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:
+ recipient_actor = actors.get_actor(url)
+ logger.debug('delivering to %s', recipient_actor.inbox_url)
+ logger.debug('activity content: %s', json.dumps(activity))
+ response = requests.post(
+ auth=auth,
+ json=activity,
+ url=recipient_actor.inbox_url,
+ headers={
+ 'Content-Type': 'application/activity+json'
+ }
+ )
+ response.raise_for_status()
+ logger.debug('Remote answered with %s', response.status_code)
diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py
new file mode 100644
index 000000000..69033f5ca
--- /dev/null
+++ b/api/funkwhale_api/federation/actors.py
@@ -0,0 +1,220 @@
+import logging
+import requests
+import xml
+
+from django.conf import settings
+from django.urls import reverse
+from django.utils import timezone
+
+from rest_framework.exceptions import PermissionDenied
+
+from dynamic_preferences.registries import global_preferences_registry
+
+from . import activity
+from . import models
+from . import serializers
+from . import utils
+
+logger = logging.getLogger(__name__)
+
+
+def remove_tags(text):
+ logger.debug('Removing tags from %s', text)
+ return ''.join(xml.etree.ElementTree.fromstring('
{}
'.format(text)).itertext())
+
+
+def get_actor_data(actor_url):
+ response = requests.get(
+ actor_url,
+ headers={
+ 'Accept': 'application/activity+json',
+ }
+ )
+ response.raise_for_status()
+ try:
+ return response.json()
+ except:
+ raise ValueError(
+ 'Invalid actor payload: {}'.format(response.text))
+
+def get_actor(actor_url):
+ data = get_actor_data(actor_url)
+ serializer = serializers.ActorSerializer(data=data)
+ serializer.is_valid(raise_exception=True)
+
+ return serializer.build()
+
+
+class SystemActor(object):
+ additional_attributes = {}
+
+ def get_actor_instance(self):
+ a = models.Actor(
+ **self.get_instance_argument(
+ self.id,
+ name=self.name,
+ summary=self.summary,
+ **self.additional_attributes
+ )
+ )
+ a.pk = self.id
+ return a
+
+ def get_instance_argument(self, id, name, summary, **kwargs):
+ preferences = global_preferences_registry.manager()
+ p = {
+ 'preferred_username': id,
+ 'domain': settings.FEDERATION_HOSTNAME,
+ 'type': 'Person',
+ 'name': name.format(host=settings.FEDERATION_HOSTNAME),
+ 'manually_approves_followers': True,
+ 'url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-detail',
+ kwargs={'actor': id})),
+ 'shared_inbox_url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-inbox',
+ kwargs={'actor': id})),
+ 'inbox_url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-inbox',
+ kwargs={'actor': id})),
+ 'outbox_url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-outbox',
+ kwargs={'actor': id})),
+ 'public_key': preferences['federation__public_key'],
+ 'private_key': preferences['federation__private_key'],
+ 'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
+ }
+ p.update(kwargs)
+ return p
+
+ def get_inbox(self, data, actor=None):
+ raise NotImplementedError
+
+ def post_inbox(self, data, actor=None):
+ raise NotImplementedError
+
+ def get_outbox(self, data, actor=None):
+ raise NotImplementedError
+
+ def post_outbox(self, data, actor=None):
+ raise NotImplementedError
+
+
+class LibraryActor(SystemActor):
+ id = 'library'
+ name = '{host}\'s library'
+ summary = 'Bot account to federate with {host}\'s library'
+ additional_attributes = {
+ 'manually_approves_followers': True
+ }
+
+
+class TestActor(SystemActor):
+ id = 'test'
+ name = '{host}\'s test account'
+ summary = (
+ 'Bot account to test federation with {host}. '
+ 'Send me /ping and I\'ll answer you.'
+ )
+ additional_attributes = {
+ 'manually_approves_followers': False
+ }
+
+ def get_outbox(self, data, actor=None):
+ return {
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {}
+ ],
+ "id": utils.full_url(
+ reverse(
+ 'federation:instance-actors-outbox',
+ kwargs={'actor': self.id})),
+ "type": "OrderedCollection",
+ "totalItems": 0,
+ "orderedItems": []
+ }
+
+ def post_inbox(self, data, actor=None):
+ if actor is None:
+ raise PermissionDenied('Actor not authenticated')
+
+ serializer = serializers.ActivitySerializer(
+ data=data, context={'actor': actor})
+ serializer.is_valid(raise_exception=True)
+
+ ac = serializer.validated_data
+ 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):
+ """
+ Remove any links or fancy markup to extract /command from
+ a note message.
+ """
+ raw = remove_tags(message)
+ try:
+ return raw.split('/')[1]
+ except IndexError:
+ return
+
+ def handle_ping(self, ac, sender):
+ now = timezone.now()
+ test_actor = self.get_actor_instance()
+ reply_url = 'https://{}/activities/note/{}'.format(
+ settings.FEDERATION_HOSTNAME, now.timestamp()
+ )
+ reply_content = '{} Pong!'.format(
+ sender.mention_username
+ )
+ reply_activity = {
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {}
+ ],
+ 'type': 'Create',
+ 'actor': test_actor.url,
+ 'id': '{}/activity'.format(reply_url),
+ 'published': now.isoformat(),
+ 'to': ac['actor'],
+ 'cc': [],
+ 'object': {
+ 'type': 'Note',
+ 'content': 'Pong!',
+ 'summary': None,
+ 'published': now.isoformat(),
+ 'id': reply_url,
+ 'inReplyTo': ac['object']['id'],
+ 'sensitive': False,
+ 'url': reply_url,
+ 'to': [ac['actor']],
+ 'attributedTo': test_actor.url,
+ 'cc': [],
+ 'attachment': [],
+ 'tag': [{
+ "type": "Mention",
+ "href": ac['actor'],
+ "name": sender.mention_username
+ }]
+ }
+ }
+ activity.deliver(
+ reply_activity,
+ to=[ac['actor']],
+ on_behalf_of=test_actor)
+
+SYSTEM_ACTORS = {
+ 'library': LibraryActor(),
+ 'test': TestActor(),
+}
diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py
new file mode 100644
index 000000000..e199ef134
--- /dev/null
+++ b/api/funkwhale_api/federation/authentication.py
@@ -0,0 +1,52 @@
+import cryptography
+
+from django.contrib.auth.models import AnonymousUser
+
+from rest_framework import authentication
+from rest_framework import exceptions
+
+from . import actors
+from . import keys
+from . import serializers
+from . import signing
+from . import utils
+
+
+class SignatureAuthentication(authentication.BaseAuthentication):
+ def authenticate_actor(self, request):
+ headers = utils.clean_wsgi_headers(request.META)
+ try:
+ signature = headers['Signature']
+ key_id = keys.get_key_id_from_signature_header(signature)
+ except KeyError:
+ return
+ except ValueError as e:
+ raise exceptions.AuthenticationFailed(str(e))
+
+ try:
+ actor_data = actors.get_actor_data(key_id)
+ except Exception as e:
+ raise exceptions.AuthenticationFailed(str(e))
+
+ try:
+ public_key = actor_data['publicKey']['publicKeyPem']
+ except KeyError:
+ raise exceptions.AuthenticationFailed('No public key found')
+
+ serializer = serializers.ActorSerializer(data=actor_data)
+ if not serializer.is_valid():
+ raise exceptions.AuthenticationFailed('Invalid actor payload: {}'.format(serializer.errors))
+
+ try:
+ signing.verify_django(request, public_key.encode('utf-8'))
+ except cryptography.exceptions.InvalidSignature:
+ raise exceptions.AuthenticationFailed('Invalid signature')
+
+ return serializer.build()
+
+ def authenticate(self, request):
+ setattr(request, 'actor', None)
+ actor = self.authenticate_actor(request)
+ user = AnonymousUser()
+ setattr(request, 'actor', actor)
+ return (user, None)
diff --git a/api/funkwhale_api/federation/dynamic_preferences_registry.py b/api/funkwhale_api/federation/dynamic_preferences_registry.py
new file mode 100644
index 000000000..83d0285be
--- /dev/null
+++ b/api/funkwhale_api/federation/dynamic_preferences_registry.py
@@ -0,0 +1,34 @@
+from django.forms import widgets
+
+from dynamic_preferences import types
+from dynamic_preferences.registries import global_preferences_registry
+
+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)'
+ )
diff --git a/api/funkwhale_api/federation/exceptions.py b/api/funkwhale_api/federation/exceptions.py
new file mode 100644
index 000000000..31d864b36
--- /dev/null
+++ b/api/funkwhale_api/federation/exceptions.py
@@ -0,0 +1,8 @@
+
+
+class MalformedPayload(ValueError):
+ pass
+
+
+class MissingSignature(KeyError):
+ pass
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
new file mode 100644
index 000000000..88c86f791
--- /dev/null
+++ b/api/funkwhale_api/federation/factories.py
@@ -0,0 +1,91 @@
+import factory
+import requests
+import requests_http_signature
+
+from django.utils import timezone
+
+from funkwhale_api.factories import registry
+
+from . import keys
+from . import models
+
+
+registry.register(keys.get_key_pair, name='federation.KeyPair')
+
+
+@registry.register(name='federation.SignatureAuth')
+class SignatureAuthFactory(factory.Factory):
+ algorithm = 'rsa-sha256'
+ key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
+ key_id = factory.Faker('url')
+ use_auth_header = False
+ headers = [
+ '(request-target)',
+ 'user-agent',
+ 'host',
+ 'date',
+ 'content-type',]
+ class Meta:
+ model = requests_http_signature.HTTPSignatureAuth
+
+
+@registry.register(name='federation.SignedRequest')
+class SignedRequestFactory(factory.Factory):
+ url = factory.Faker('url')
+ method = 'get'
+ auth = factory.SubFactory(SignatureAuthFactory)
+
+ class Meta:
+ model = requests.Request
+
+ @factory.post_generation
+ def headers(self, create, extracted, **kwargs):
+ default_headers = {
+ 'User-Agent': 'Test',
+ 'Host': 'test.host',
+ 'Date': 'Right now',
+ 'Content-Type': 'application/activity+json'
+ }
+ if extracted:
+ default_headers.update(extracted)
+ self.headers.update(default_headers)
+
+
+@registry.register
+class ActorFactory(factory.DjangoModelFactory):
+
+ public_key = None
+ private_key = None
+ preferred_username = factory.Faker('user_name')
+ summary = factory.Faker('paragraph')
+ domain = factory.Faker('domain_name')
+ url = factory.LazyAttribute(lambda o: 'https://{}/users/{}'.format(o.domain, o.preferred_username))
+ inbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/inbox'.format(o.domain, o.preferred_username))
+ outbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/outbox'.format(o.domain, o.preferred_username))
+
+ class Meta:
+ model = models.Actor
+
+ @classmethod
+ def _generate(cls, create, attrs):
+ has_public = attrs.get('public_key') is not None
+ has_private = attrs.get('private_key') is not None
+ if not has_public and not has_private:
+ private, public = keys.get_key_pair()
+ attrs['private_key'] = private.decode('utf-8')
+ attrs['public_key'] = public.decode('utf-8')
+ return super()._generate(create, attrs)
+
+
+@registry.register(name='federation.Note')
+class NoteFactory(factory.Factory):
+ type = 'Note'
+ id = factory.Faker('url')
+ published = factory.LazyFunction(
+ lambda: timezone.now().isoformat()
+ )
+ inReplyTo = None
+ content = factory.Faker('sentence')
+
+ class Meta:
+ model = dict
diff --git a/api/funkwhale_api/federation/keys.py b/api/funkwhale_api/federation/keys.py
new file mode 100644
index 000000000..08d4034ea
--- /dev/null
+++ b/api/funkwhale_api/federation/keys.py
@@ -0,0 +1,49 @@
+from cryptography.hazmat.primitives import serialization as crypto_serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.backends import default_backend as crypto_default_backend
+
+import re
+import requests
+import urllib.parse
+
+from . import exceptions
+
+KEY_ID_REGEX = re.compile(r'keyId=\"(?P.*)\"')
+
+
+def get_key_pair(size=2048):
+ key = rsa.generate_private_key(
+ backend=crypto_default_backend(),
+ public_exponent=65537,
+ key_size=size
+ )
+ private_key = key.private_bytes(
+ crypto_serialization.Encoding.PEM,
+ crypto_serialization.PrivateFormat.PKCS8,
+ crypto_serialization.NoEncryption())
+ public_key = key.public_key().public_bytes(
+ crypto_serialization.Encoding.PEM,
+ crypto_serialization.PublicFormat.PKCS1
+ )
+
+ return private_key, public_key
+
+
+def get_key_id_from_signature_header(header_string):
+ parts = header_string.split(',')
+ try:
+ raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
+ except IndexError:
+ raise ValueError('Missing key id')
+
+ match = KEY_ID_REGEX.match(raw_key_id)
+ if not match:
+ raise ValueError('Invalid key id')
+
+ key_id = match.groups()[0]
+ url = urllib.parse.urlparse(key_id)
+ if not url.scheme or not url.netloc:
+ raise ValueError('Invalid url')
+ if url.scheme not in ['http', 'https']:
+ raise ValueError('Invalid shceme')
+ return key_id
diff --git a/api/funkwhale_api/federation/management/__init__.py b/api/funkwhale_api/federation/management/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/funkwhale_api/federation/management/commands/__init__.py b/api/funkwhale_api/federation/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/funkwhale_api/federation/management/commands/generate_keys.py b/api/funkwhale_api/federation/management/commands/generate_keys.py
new file mode 100644
index 000000000..eafe9aae3
--- /dev/null
+++ b/api/funkwhale_api/federation/management/commands/generate_keys.py
@@ -0,0 +1,53 @@
+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'))
+ )
diff --git a/api/funkwhale_api/federation/migrations/0001_initial.py b/api/funkwhale_api/federation/migrations/0001_initial.py
new file mode 100644
index 000000000..a9157e57e
--- /dev/null
+++ b/api/funkwhale_api/federation/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 2.0.3 on 2018-03-31 13:43
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Actor',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('url', models.URLField(db_index=True, max_length=500, unique=True)),
+ ('outbox_url', models.URLField(max_length=500)),
+ ('inbox_url', models.URLField(max_length=500)),
+ ('following_url', models.URLField(blank=True, max_length=500, null=True)),
+ ('followers_url', models.URLField(blank=True, max_length=500, null=True)),
+ ('shared_inbox_url', models.URLField(blank=True, max_length=500, null=True)),
+ ('type', models.CharField(choices=[('Person', 'Person'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25)),
+ ('name', models.CharField(blank=True, max_length=200, null=True)),
+ ('domain', models.CharField(max_length=1000)),
+ ('summary', models.CharField(blank=True, max_length=500, null=True)),
+ ('preferred_username', models.CharField(blank=True, max_length=200, null=True)),
+ ('public_key', models.CharField(blank=True, max_length=5000, null=True)),
+ ('private_key', models.CharField(blank=True, max_length=5000, null=True)),
+ ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+ ('last_fetch_date', models.DateTimeField(default=django.utils.timezone.now)),
+ ('manually_approves_followers', models.NullBooleanField(default=None)),
+ ],
+ ),
+ ]
diff --git a/api/funkwhale_api/federation/migrations/__init__.py b/api/funkwhale_api/federation/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
new file mode 100644
index 000000000..d76ad173b
--- /dev/null
+++ b/api/funkwhale_api/federation/models.py
@@ -0,0 +1,59 @@
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+
+TYPE_CHOICES = [
+ ('Person', 'Person'),
+ ('Application', 'Application'),
+ ('Group', 'Group'),
+ ('Organization', 'Organization'),
+ ('Service', 'Service'),
+]
+
+
+class Actor(models.Model):
+ url = models.URLField(unique=True, max_length=500, db_index=True)
+ outbox_url = models.URLField(max_length=500)
+ inbox_url = models.URLField(max_length=500)
+ following_url = models.URLField(max_length=500, null=True, blank=True)
+ followers_url = models.URLField(max_length=500, null=True, blank=True)
+ shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
+ type = models.CharField(
+ choices=TYPE_CHOICES, default='Person', max_length=25)
+ name = models.CharField(max_length=200, null=True, blank=True)
+ domain = models.CharField(max_length=1000)
+ summary = models.CharField(max_length=500, null=True, blank=True)
+ preferred_username = models.CharField(
+ max_length=200, null=True, blank=True)
+ public_key = models.CharField(max_length=5000, null=True, blank=True)
+ private_key = models.CharField(max_length=5000, null=True, blank=True)
+ creation_date = models.DateTimeField(default=timezone.now)
+ last_fetch_date = models.DateTimeField(
+ default=timezone.now)
+ manually_approves_followers = models.NullBooleanField(default=None)
+
+ @property
+ def webfinger_subject(self):
+ return '{}@{}'.format(
+ self.preferred_username,
+ settings.FEDERATION_HOSTNAME,
+ )
+
+ @property
+ def private_key_id(self):
+ return '{}#main-key'.format(self.url)
+
+ @property
+ def mention_username(self):
+ return '@{}@{}'.format(self.preferred_username, self.domain)
+
+ def save(self, **kwargs):
+ lowercase_fields = [
+ 'domain',
+ ]
+ for field in lowercase_fields:
+ v = getattr(self, field, None)
+ if v:
+ setattr(self, field, v.lower())
+
+ super().save(**kwargs)
diff --git a/api/funkwhale_api/federation/parsers.py b/api/funkwhale_api/federation/parsers.py
new file mode 100644
index 000000000..874d808f9
--- /dev/null
+++ b/api/funkwhale_api/federation/parsers.py
@@ -0,0 +1,5 @@
+from rest_framework import parsers
+
+
+class ActivityParser(parsers.JSONParser):
+ media_type = 'application/activity+json'
diff --git a/api/funkwhale_api/federation/renderers.py b/api/funkwhale_api/federation/renderers.py
new file mode 100644
index 000000000..642b63462
--- /dev/null
+++ b/api/funkwhale_api/federation/renderers.py
@@ -0,0 +1,9 @@
+from rest_framework.renderers import JSONRenderer
+
+
+class ActivityPubRenderer(JSONRenderer):
+ media_type = 'application/activity+json'
+
+
+class WebfingerRenderer(JSONRenderer):
+ media_type = 'application/jrd+json'
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
new file mode 100644
index 000000000..2137e8d91
--- /dev/null
+++ b/api/funkwhale_api/federation/serializers.py
@@ -0,0 +1,175 @@
+import urllib.parse
+
+from django.urls import reverse
+from django.conf import settings
+
+from rest_framework import serializers
+from dynamic_preferences.registries import global_preferences_registry
+
+from . import activity
+from . import models
+from . import utils
+
+
+class ActorSerializer(serializers.ModelSerializer):
+ # left maps to activitypub fields, right to our internal models
+ id = serializers.URLField(source='url')
+ outbox = serializers.URLField(source='outbox_url')
+ inbox = serializers.URLField(source='inbox_url')
+ following = serializers.URLField(source='following_url', required=False)
+ followers = serializers.URLField(source='followers_url', required=False)
+ preferredUsername = serializers.CharField(
+ source='preferred_username', required=False)
+ publicKey = serializers.JSONField(source='public_key', required=False)
+ manuallyApprovesFollowers = serializers.NullBooleanField(
+ source='manually_approves_followers', required=False)
+ summary = serializers.CharField(max_length=None, required=False)
+
+ class Meta:
+ model = models.Actor
+ fields = [
+ 'id',
+ 'type',
+ 'name',
+ 'summary',
+ 'preferredUsername',
+ 'publicKey',
+ 'inbox',
+ 'outbox',
+ 'following',
+ 'followers',
+ 'manuallyApprovesFollowers',
+ ]
+
+ def to_representation(self, instance):
+ ret = super().to_representation(instance)
+ ret['@context'] = [
+ 'https://www.w3.org/ns/activitystreams',
+ 'https://w3id.org/security/v1',
+ {},
+ ]
+ if instance.public_key:
+ ret['publicKey'] = {
+ 'owner': instance.url,
+ 'publicKeyPem': instance.public_key,
+ 'id': '{}#main-key'.format(instance.url)
+ }
+ ret['endpoints'] = {}
+ if instance.shared_inbox_url:
+ ret['endpoints']['sharedInbox'] = instance.shared_inbox_url
+ return ret
+
+ def prepare_missing_fields(self):
+ kwargs = {}
+ domain = urllib.parse.urlparse(self.validated_data['url']).netloc
+ kwargs['domain'] = domain
+ for endpoint, url in self.initial_data.get('endpoints', {}).items():
+ if endpoint == 'sharedInbox':
+ kwargs['shared_inbox_url'] = url
+ break
+ try:
+ kwargs['public_key'] = self.initial_data['publicKey']['publicKeyPem']
+ except KeyError:
+ pass
+ return kwargs
+
+ def build(self):
+ d = self.validated_data.copy()
+ d.update(self.prepare_missing_fields())
+ return self.Meta.model(**d)
+
+ def save(self, **kwargs):
+ kwargs.update(self.prepare_missing_fields())
+ return super().save(**kwargs)
+
+ def validate_summary(self, value):
+ if value:
+ return value[:500]
+
+
+class ActorWebfingerSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = models.Actor
+ fields = ['url']
+
+ def to_representation(self, instance):
+ data = {}
+ data['subject'] = 'acct:{}'.format(instance.webfinger_subject)
+ data['links'] = [
+ {
+ 'rel': 'self',
+ 'href': instance.url,
+ 'type': 'application/activity+json'
+ }
+ ]
+ data['aliases'] = [
+ instance.url
+ ]
+ return data
+
+
+class ActivitySerializer(serializers.Serializer):
+ actor = serializers.URLField()
+ id = serializers.URLField()
+ type = serializers.ChoiceField(
+ choices=[(c, c) for c in activity.ACTIVITY_TYPES])
+ object = serializers.JSONField()
+
+ def validate_object(self, value):
+ try:
+ type = value['type']
+ except KeyError:
+ raise serializers.ValidationError('Missing object type')
+
+ try:
+ object_serializer = OBJECT_SERIALIZERS[type]
+ except KeyError:
+ raise serializers.ValidationError(
+ 'Unsupported type {}'.format(type))
+
+ serializer = object_serializer(data=value)
+ serializer.is_valid(raise_exception=True)
+ return serializer.data
+
+ def validate_actor(self, value):
+ request_actor = self.context.get('actor')
+ if request_actor and request_actor.url != value:
+ raise serializers.ValidationError(
+ 'The actor making the request do not match'
+ ' the activity actor'
+ )
+ return value
+
+
+class ObjectSerializer(serializers.Serializer):
+ id = serializers.URLField()
+ url = serializers.URLField(required=False, allow_null=True)
+ type = serializers.ChoiceField(
+ choices=[(c, c) for c in activity.OBJECT_TYPES])
+ content = serializers.CharField(
+ required=False, allow_null=True)
+ summary = serializers.CharField(
+ required=False, allow_null=True)
+ name = serializers.CharField(
+ required=False, allow_null=True)
+ published = serializers.DateTimeField(
+ required=False, allow_null=True)
+ updated = serializers.DateTimeField(
+ required=False, allow_null=True)
+ to = serializers.ListField(
+ child=serializers.URLField(),
+ required=False, allow_null=True)
+ cc = serializers.ListField(
+ child=serializers.URLField(),
+ required=False, allow_null=True)
+ bto = serializers.ListField(
+ child=serializers.URLField(),
+ required=False, allow_null=True)
+ bcc = serializers.ListField(
+ child=serializers.URLField(),
+ required=False, allow_null=True)
+
+OBJECT_SERIALIZERS = {
+ t: ObjectSerializer
+ for t in activity.OBJECT_TYPES
+}
diff --git a/api/funkwhale_api/federation/signing.py b/api/funkwhale_api/federation/signing.py
new file mode 100644
index 000000000..7e4d2aa5a
--- /dev/null
+++ b/api/funkwhale_api/federation/signing.py
@@ -0,0 +1,55 @@
+import logging
+import requests
+import requests_http_signature
+
+from . import exceptions
+from . import utils
+
+logger = logging.getLogger(__name__)
+
+
+def verify(request, public_key):
+ return requests_http_signature.HTTPSignatureAuth.verify(
+ request,
+ key_resolver=lambda **kwargs: public_key,
+ use_auth_header=False,
+ )
+
+
+def verify_django(django_request, public_key):
+ """
+ Given a django WSGI request, create an underlying requests.PreparedRequest
+ instance we can verify
+ """
+ headers = utils.clean_wsgi_headers(django_request.META)
+ for h, v in list(headers.items()):
+ # we include lower-cased version of the headers for compatibility
+ # with requests_http_signature
+ headers[h.lower()] = v
+ try:
+ signature = headers['Signature']
+ except KeyError:
+ raise exceptions.MissingSignature
+ url = 'http://noop{}'.format(django_request.path)
+ query = django_request.META['QUERY_STRING']
+ if query:
+ url += '?{}'.format(query)
+ signature_headers = signature.split('headers="')[1].split('",')[0]
+ expected = signature_headers.split(' ')
+ logger.debug('Signature expected headers: %s', expected)
+ for header in expected:
+ try:
+ headers[header]
+ except KeyError:
+ logger.debug('Missing header: %s', header)
+ request = requests.Request(
+ method=django_request.method,
+ url=url,
+ data=django_request.body,
+ headers=headers)
+ for h in request.headers.keys():
+ v = request.headers[h]
+ if v:
+ request.headers[h] = str(v)
+ prepared_request = request.prepare()
+ return verify(request, public_key)
diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py
new file mode 100644
index 000000000..f2c6f4c78
--- /dev/null
+++ b/api/funkwhale_api/federation/urls.py
@@ -0,0 +1,15 @@
+from rest_framework import routers
+
+from . import views
+
+router = routers.SimpleRouter(trailing_slash=False)
+router.register(
+ r'federation/instance/actors',
+ views.InstanceActorViewSet,
+ 'instance-actors')
+router.register(
+ r'.well-known',
+ views.WellKnownViewSet,
+ 'well-known')
+
+urlpatterns = router.urls
diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py
new file mode 100644
index 000000000..df093add8
--- /dev/null
+++ b/api/funkwhale_api/federation/utils.py
@@ -0,0 +1,35 @@
+from django.conf import settings
+
+
+def full_url(path):
+ """
+ Given a relative path, return a full url usable for federation purpose
+ """
+ root = settings.FUNKWHALE_URL
+ if path.startswith('/') and root.endswith('/'):
+ return root + path[1:]
+ elif not path.startswith('/') and not root.endswith('/'):
+ return root + '/' + path
+ else:
+ return root + path
+
+
+def clean_wsgi_headers(raw_headers):
+ """
+ Convert WSGI headers from CONTENT_TYPE to Content-Type notation
+ """
+ cleaned = {}
+ non_prefixed = [
+ 'content_type',
+ 'content_length',
+ ]
+ for raw_header, value in raw_headers.items():
+ h = raw_header.lower()
+ if not h.startswith('http_') and h not in non_prefixed:
+ continue
+
+ words = h.replace('http_', '', 1).split('_')
+ cleaned_header = '-'.join([w.capitalize() for w in words])
+ cleaned[cleaned_header] = value
+
+ return cleaned
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
new file mode 100644
index 000000000..2e3feb8d0
--- /dev/null
+++ b/api/funkwhale_api/federation/views.py
@@ -0,0 +1,103 @@
+from django import forms
+from django.conf import settings
+from django.http import HttpResponse
+
+from rest_framework import viewsets
+from rest_framework import views
+from rest_framework import response
+from rest_framework.decorators import list_route, detail_route
+
+from . import actors
+from . import authentication
+from . import renderers
+from . import serializers
+from . import webfinger
+
+
+class FederationMixin(object):
+ def dispatch(self, request, *args, **kwargs):
+ if not settings.FEDERATION_ENABLED:
+ return HttpResponse(status=405)
+ return super().dispatch(request, *args, **kwargs)
+
+
+class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
+ lookup_field = 'actor'
+ lookup_value_regex = '[a-z]*'
+ authentication_classes = [
+ authentication.SignatureAuthentication]
+ permission_classes = []
+ renderer_classes = [renderers.ActivityPubRenderer]
+
+ def get_object(self):
+ try:
+ return actors.SYSTEM_ACTORS[self.kwargs['actor']]
+ except KeyError:
+ raise Http404
+
+ def retrieve(self, request, *args, **kwargs):
+ system_actor = self.get_object()
+ actor = system_actor.get_actor_instance()
+ serializer = serializers.ActorSerializer(actor)
+ return response.Response(serializer.data, status=200)
+
+ @detail_route(methods=['get', 'post'])
+ def inbox(self, request, *args, **kwargs):
+ system_actor = self.get_object()
+ handler = getattr(system_actor, '{}_inbox'.format(
+ request.method.lower()
+ ))
+
+ try:
+ data = handler(request.data, actor=request.actor)
+ except NotImplementedError:
+ return response.Response(status=405)
+ return response.Response(data, status=200)
+
+ @detail_route(methods=['get', 'post'])
+ def outbox(self, request, *args, **kwargs):
+ system_actor = self.get_object()
+ handler = getattr(system_actor, '{}_outbox'.format(
+ request.method.lower()
+ ))
+ try:
+ data = handler(request.data, actor=request.actor)
+ except NotImplementedError:
+ return response.Response(status=405)
+ return response.Response(data, status=200)
+
+
+class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
+ authentication_classes = []
+ permission_classes = []
+ renderer_classes = [renderers.WebfingerRenderer]
+
+ @list_route(methods=['get'])
+ def webfinger(self, request, *args, **kwargs):
+ try:
+ resource_type, resource = webfinger.clean_resource(
+ request.GET['resource'])
+ cleaner = getattr(webfinger, 'clean_{}'.format(resource_type))
+ result = cleaner(resource)
+ except forms.ValidationError as e:
+ return response.Response({
+ 'errors': {
+ 'resource': e.message
+ }
+ }, status=400)
+ except KeyError:
+ return response.Response({
+ 'errors': {
+ 'resource': 'This field is required',
+ }
+ }, status=400)
+
+ handler = getattr(self, 'handler_{}'.format(resource_type))
+ data = handler(result)
+
+ return response.Response(data)
+
+ def handler_acct(self, clean_result):
+ username, hostname = clean_result
+ actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
+ return serializers.ActorWebfingerSerializer(actor).data
diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py
new file mode 100644
index 000000000..4e9753385
--- /dev/null
+++ b/api/funkwhale_api/federation/webfinger.py
@@ -0,0 +1,39 @@
+from django import forms
+from django.conf import settings
+from django.urls import reverse
+
+from . import actors
+from . import utils
+
+VALID_RESOURCE_TYPES = ['acct']
+
+
+def clean_resource(resource_string):
+ if not resource_string:
+ raise forms.ValidationError('Invalid resource string')
+
+ try:
+ resource_type, resource = resource_string.split(':', 1)
+ except ValueError:
+ raise forms.ValidationError('Missing webfinger resource type')
+
+ if resource_type not in VALID_RESOURCE_TYPES:
+ raise forms.ValidationError('Invalid webfinger resource type')
+
+ return resource_type, resource
+
+
+def clean_acct(acct_string):
+ try:
+ username, hostname = acct_string.split('@')
+ except ValueError:
+ raise forms.ValidationError('Invalid format')
+
+ if hostname.lower() != settings.FEDERATION_HOSTNAME:
+ raise forms.ValidationError(
+ 'Invalid hostname {}'.format(hostname))
+
+ if username not in actors.SYSTEM_ACTORS:
+ raise forms.ValidationError('Invalid username')
+
+ return username, hostname
diff --git a/api/funkwhale_api/history/admin.py b/api/funkwhale_api/history/admin.py
index 6d0480e73..5ddfb8998 100644
--- a/api/funkwhale_api/history/admin.py
+++ b/api/funkwhale_api/history/admin.py
@@ -4,7 +4,7 @@ from . import models
@admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin):
- list_display = ['track', 'end_date', 'user', 'session_key']
+ list_display = ['track', 'creation_date', 'user', 'session_key']
search_fields = ['track__name', 'user__username']
list_select_related = [
'user',
diff --git a/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py
new file mode 100644
index 000000000..d83dbb0a4
--- /dev/null
+++ b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py
@@ -0,0 +1,22 @@
+# Generated by Django 2.0.3 on 2018-03-25 14:33
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('history', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='listening',
+ options={'ordering': ('-creation_date',)},
+ ),
+ migrations.RenameField(
+ model_name='listening',
+ old_name='end_date',
+ new_name='creation_date',
+ ),
+ ]
diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py
index 56310ddc0..762d5bf7b 100644
--- a/api/funkwhale_api/history/models.py
+++ b/api/funkwhale_api/history/models.py
@@ -6,7 +6,8 @@ from funkwhale_api.music.models import Track
class Listening(models.Model):
- end_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
+ creation_date = models.DateTimeField(
+ default=timezone.now, null=True, blank=True)
track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE)
user = models.ForeignKey(
@@ -18,7 +19,7 @@ class Listening(models.Model):
session_key = models.CharField(max_length=100, null=True, blank=True)
class Meta:
- ordering = ('-end_date',)
+ ordering = ('-creation_date',)
def save(self, **kwargs):
if not self.user and not self.session_key:
diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py
index 7a2280cea..8fe6fa6e0 100644
--- a/api/funkwhale_api/history/serializers.py
+++ b/api/funkwhale_api/history/serializers.py
@@ -12,7 +12,7 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source='track')
actor = UserActivitySerializer(source='user')
- published = serializers.DateTimeField(source='end_date')
+ published = serializers.DateTimeField(source='creation_date')
class Meta:
model = models.Listening
@@ -36,7 +36,7 @@ class ListeningSerializer(serializers.ModelSerializer):
class Meta:
model = models.Listening
- fields = ('id', 'user', 'session_key', 'track', 'end_date')
+ fields = ('id', 'user', 'session_key', 'track', 'creation_date')
def create(self, validated_data):
diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py
index 31d13d495..3748d5573 100644
--- a/api/funkwhale_api/music/metadata.py
+++ b/api/funkwhale_api/music/metadata.py
@@ -121,7 +121,13 @@ class Metadata(object):
def __init__(self, path):
self._file = mutagen.File(path)
- self._conf = CONF[self.get_file_type(self._file)]
+ if self._file is None:
+ raise ValueError('Cannot parse metadata from {}'.format(path))
+ ft = self.get_file_type(self._file)
+ try:
+ self._conf = CONF[ft]
+ except KeyError:
+ raise ValueError('Unsupported format {}'.format(ft))
def get_file_type(self, f):
return f.__class__.__name__
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 0d33855a6..5ac3143f9 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -328,7 +328,7 @@ class SubmitViewSet(viewsets.ViewSet):
job = models.ImportJob.objects.create(mbid=request.POST['mbid'], batch=batch, source=request.POST['import_url'])
tasks.import_job_run.delay(import_job_id=job.pk)
serializer = serializers.ImportBatchSerializer(batch)
- return Response(serializer.data)
+ return Response(serializer.data, status=201)
def get_import_request(self, data):
try:
diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py
index 2fa5e464c..dbc01289f 100644
--- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py
+++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py
@@ -34,6 +34,13 @@ class Command(BaseCommand):
default=False,
help='Will launch celery tasks for each file to import instead of doing it synchronously and block the CLI',
)
+ parser.add_argument(
+ '--exit', '-x',
+ action='store_true',
+ dest='exit_on_failure',
+ default=False,
+ help='use this flag to disable error catching',
+ )
parser.add_argument(
'--no-acoustid',
action='store_true',
@@ -106,20 +113,27 @@ class Command(BaseCommand):
async = options['async']
import_handler = tasks.import_job_run.delay if async else tasks.import_job_run
for path in matching:
- job = batch.jobs.create(
- source='file://' + path,
- )
- name = os.path.basename(path)
- with open(path, 'rb') as f:
- job.audio_file.save(name, File(f))
-
- job.save()
try:
- utils.on_commit(
- import_handler,
- import_job_id=job.pk,
- use_acoustid=not options['no_acoustid'])
+ self.stdout.write(message.format(path))
+ self.import_file(path, batch, import_handler, options)
except Exception as e:
- self.stdout.write('Error: {}'.format(e))
-
+ if options['exit_on_failure']:
+ raise
+ m = 'Error while importing {}: {} {}'.format(
+ path, e.__class__.__name__, e)
+ self.stderr.write(m)
return batch
+
+ def import_file(self, path, batch, import_handler, options):
+ job = batch.jobs.create(
+ source='file://' + path,
+ )
+ name = os.path.basename(path)
+ with open(path, 'rb') as f:
+ job.audio_file.save(name, File(f))
+
+ job.save()
+ utils.on_commit(
+ import_handler,
+ import_job_id=job.pk,
+ use_acoustid=not options['no_acoustid'])
diff --git a/api/funkwhale_api/radios/serializers.py b/api/funkwhale_api/radios/serializers.py
index 520e98652..2e7e6a409 100644
--- a/api/funkwhale_api/radios/serializers.py
+++ b/api/funkwhale_api/radios/serializers.py
@@ -1,6 +1,7 @@
from rest_framework import serializers
from funkwhale_api.music.serializers import TrackSerializerNested
+from funkwhale_api.users.serializers import UserBasicSerializer
from . import filters
from . import models
@@ -15,6 +16,8 @@ class FilterSerializer(serializers.Serializer):
class RadioSerializer(serializers.ModelSerializer):
+ user = UserBasicSerializer(read_only=True)
+
class Meta:
model = models.Radio
fields = (
diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py
index 426526442..ffd1d1659 100644
--- a/api/funkwhale_api/radios/views.py
+++ b/api/funkwhale_api/radios/views.py
@@ -20,6 +20,7 @@ class RadioViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
+ mixins.DestroyModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.RadioSerializer
@@ -40,6 +41,16 @@ class RadioViewSet(
raise Http404
return serializer.save(user=self.request.user)
+ @detail_route(methods=['get'])
+ def tracks(self, request, *args, **kwargs):
+ radio = self.get_object()
+ tracks = radio.get_candidates().for_nested_serialization()
+
+ page = self.paginate_queryset(tracks)
+ if page is not None:
+ serializer = TrackSerializerNested(page, many=True)
+ return self.get_paginated_response(serializer.data)
+
@list_route(methods=['get'])
def filters(self, request, *args, **kwargs):
serializer = serializers.FilterSerializer(
diff --git a/api/manage.py b/api/manage.py
index d99574ebe..c8db95ede 100755
--- a/api/manage.py
+++ b/api/manage.py
@@ -1,4 +1,5 @@
#!/usr/bin/env python
+import django
import os
import sys
@@ -7,6 +8,12 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
+ # we're doing this here since otherwise, missing environment
+ # files in settings result in AttributeError being raised, generating
+ # a cryptic django.core.exceptions.AppRegistryNotReady error.
+ # To prevent that, we explicitely load settings here before anything
+ # else, so we fail fast with a relevant error. See #140 for more details.
+ django.setup()
from django.core.management import execute_from_command_line
diff --git a/api/requirements.txt b/api/requirements.txt
index d1197135e..00be27c53 100644
--- a/api/requirements.txt
+++ b/api/requirements.txt
@@ -1,3 +1,4 @@
# This file is here because many Platforms as a Service look for
# requirements.txt in the root directory of a project.
+-r requirements/base.txt
-r requirements/production.txt
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index efcc4eea4..b66e297a9 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -14,7 +14,7 @@ django-allauth>=0.34,<0.35
# Python-PostgreSQL Database Adapter
-psycopg2>=2.7,<=2.8
+psycopg2-binary>=2.7,<=2.8
# Time zones support
pytz==2017.3
@@ -60,3 +60,7 @@ channels_redis>=2.1,<2.2
django-cacheops>=4,<4.1
daphne==2.0.4
+cryptography>=2,<3
+# requests-http-signature==0.0.3
+# clone until the branch is merged and released upstream
+git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support
diff --git a/api/requirements/test.txt b/api/requirements/test.txt
index c12b44827..20a14abea 100644
--- a/api/requirements/test.txt
+++ b/api/requirements/test.txt
@@ -9,3 +9,6 @@ git+https://github.com/pytest-dev/pytest-django.git@d3d9bb3ef6f0377cb5356eb36899
pytest-mock
pytest-sugar
pytest-xdist
+pytest-cov
+pytest-env
+requests-mock
diff --git a/api/setup.cfg b/api/setup.cfg
index 34daa8c68..a2b8b92c6 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -7,6 +7,12 @@ max-line-length = 120
exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
[tool:pytest]
-DJANGO_SETTINGS_MODULE=config.settings.test
python_files = tests.py test_*.py *_tests.py
testpaths = tests
+env =
+ SECRET_KEY=test
+ DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
+ CELERY_BROKER_URL=memory://
+ CELERY_TASK_ALWAYS_EAGER=True
+ CACHEOPS_ENABLED=False
+ FEDERATION_HOSTNAME=test.federation
diff --git a/api/tests/activity/__init__.py b/api/tests/activity/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/activity/test_serializers.py b/api/tests/activity/test_serializers.py
new file mode 100644
index 000000000..792fa74b9
--- /dev/null
+++ b/api/tests/activity/test_serializers.py
@@ -0,0 +1,17 @@
+from funkwhale_api.activity import serializers
+from funkwhale_api.favorites.serializers import TrackFavoriteActivitySerializer
+from funkwhale_api.history.serializers import \
+ ListeningActivitySerializer
+
+
+def test_autoserializer(factories):
+ favorite = factories['favorites.TrackFavorite']()
+ listening = factories['history.Listening']()
+ objects = [favorite, listening]
+ serializer = serializers.AutoSerializer(objects, many=True)
+ expected = [
+ TrackFavoriteActivitySerializer(favorite).data,
+ ListeningActivitySerializer(listening).data,
+ ]
+
+ assert serializer.data == expected
diff --git a/api/tests/activity/test_utils.py b/api/tests/activity/test_utils.py
new file mode 100644
index 000000000..43bb45df8
--- /dev/null
+++ b/api/tests/activity/test_utils.py
@@ -0,0 +1,21 @@
+from funkwhale_api.activity import utils
+
+
+def test_get_activity(factories):
+ user = factories['users.User']()
+ listening = factories['history.Listening']()
+ favorite = factories['favorites.TrackFavorite']()
+
+ objects = list(utils.get_activity(user))
+ assert objects == [favorite, listening]
+
+
+def test_get_activity_honors_privacy_level(factories, anonymous_user):
+ listening = factories['history.Listening'](user__privacy_level='me')
+ favorite1 = factories['favorites.TrackFavorite'](
+ user__privacy_level='everyone')
+ favorite2 = factories['favorites.TrackFavorite'](
+ user__privacy_level='instance')
+
+ objects = list(utils.get_activity(anonymous_user))
+ assert objects == [favorite1]
diff --git a/api/tests/activity/test_views.py b/api/tests/activity/test_views.py
new file mode 100644
index 000000000..bdc3c6339
--- /dev/null
+++ b/api/tests/activity/test_views.py
@@ -0,0 +1,18 @@
+from django.urls import reverse
+
+from funkwhale_api.activity import serializers
+from funkwhale_api.activity import utils
+
+
+def test_activity_view(factories, api_client, settings, anonymous_user):
+ settings.API_AUTHENTICATION_REQUIRED = False
+ favorite = factories['favorites.TrackFavorite'](
+ user__privacy_level='everyone')
+ listening = factories['history.Listening']()
+ url = reverse('api:v1:activity-list')
+ objects = utils.get_activity(anonymous_user)
+ serializer = serializers.AutoSerializer(objects, many=True)
+ response = api_client.get(url)
+
+ assert response.status_code == 200
+ assert response.data['results'] == serializer.data
diff --git a/api/tests/channels/__init__.py b/api/tests/channels/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/common/__init__.py b/api/tests/common/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py
index 7c63431a3..29a8fb05c 100644
--- a/api/tests/common/test_fields.py
+++ b/api/tests/common/test_fields.py
@@ -10,7 +10,7 @@ from funkwhale_api.users.factories import UserFactory
@pytest.mark.parametrize('user,expected', [
(AnonymousUser(), Q(privacy_level='everyone')),
(UserFactory.build(pk=1),
- Q(privacy_level__in=['me', 'followers', 'instance', 'everyone'])),
+ Q(privacy_level__in=['followers', 'instance', 'everyone'])),
])
def test_privacy_level_query(user,expected):
query = fields.privacy_level_query(user)
diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py
index b5c5160f8..f04f12e0b 100644
--- a/api/tests/common/test_permissions.py
+++ b/api/tests/common/test_permissions.py
@@ -2,7 +2,6 @@ import pytest
from rest_framework.views import APIView
-from django.contrib.auth.models import AnonymousUser
from django.http import Http404
from funkwhale_api.common import permissions
@@ -19,24 +18,26 @@ def test_owner_permission_owner_field_ok(nodb_factories, api_request):
assert check is True
-def test_owner_permission_owner_field_not_ok(nodb_factories, api_request):
+def test_owner_permission_owner_field_not_ok(
+ anonymous_user, nodb_factories, api_request):
playlist = nodb_factories['playlists.Playlist']()
view = APIView.as_view()
permission = permissions.OwnerPermission()
request = api_request.get('/')
- setattr(request, 'user', AnonymousUser())
+ setattr(request, 'user', anonymous_user)
with pytest.raises(Http404):
permission.has_object_permission(request, view, playlist)
-def test_owner_permission_read_only(nodb_factories, api_request):
+def test_owner_permission_read_only(
+ anonymous_user, nodb_factories, api_request):
playlist = nodb_factories['playlists.Playlist']()
view = APIView.as_view()
setattr(view, 'owner_checks', ['write'])
permission = permissions.OwnerPermission()
request = api_request.get('/')
- setattr(request, 'user', AnonymousUser())
+ setattr(request, 'user', anonymous_user)
check = permission.has_object_permission(request, view, playlist)
assert check is True
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 62bc5ada6..d5bb56565 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -1,9 +1,13 @@
import factory
-import tempfile
-import shutil
import pytest
+import requests_mock
+import shutil
+import tempfile
+from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache
+from django.test import client
+
from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient
@@ -31,7 +35,11 @@ def cache():
def factories(db):
from funkwhale_api import factories
for v in factories.registry.values():
- v._meta.strategy = factory.CREATE_STRATEGY
+ try:
+ v._meta.strategy = factory.CREATE_STRATEGY
+ except AttributeError:
+ # probably not a class based factory
+ pass
yield factories.registry
@@ -39,12 +47,16 @@ def factories(db):
def nodb_factories():
from funkwhale_api import factories
for v in factories.registry.values():
- v._meta.strategy = factory.BUILD_STRATEGY
+ try:
+ v._meta.strategy = factory.BUILD_STRATEGY
+ except AttributeError:
+ # probably not a class based factory
+ pass
yield factories.registry
@pytest.fixture
-def preferences(db):
+def preferences(db, cache):
manager = global_preferences_registry.manager()
manager.all()
yield manager
@@ -66,6 +78,11 @@ def logged_in_client(db, factories, client):
delattr(client, 'user')
+@pytest.fixture
+def anonymous_user():
+ return AnonymousUser()
+
+
@pytest.fixture
def api_client(client):
return APIClient()
@@ -103,6 +120,11 @@ def api_request():
return APIRequestFactory()
+@pytest.fixture
+def fake_request():
+ return client.RequestFactory()
+
+
@pytest.fixture
def activity_registry():
r = record.registry
@@ -126,3 +148,17 @@ def activity_registry():
@pytest.fixture
def activity_muted(activity_registry, mocker):
yield mocker.patch.object(record, 'send')
+
+
+@pytest.fixture(autouse=True)
+def media_root(settings):
+ tmp_dir = tempfile.mkdtemp()
+ settings.MEDIA_ROOT = tmp_dir
+ yield settings.MEDIA_ROOT
+ shutil.rmtree(tmp_dir)
+
+
+@pytest.fixture
+def r_mock():
+ with requests_mock.mock() as m:
+ yield m
diff --git a/api/tests/favorites/__init__.py b/api/tests/favorites/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/federation/__init__.py b/api/tests/federation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/federation/conftest.py b/api/tests/federation/conftest.py
new file mode 100644
index 000000000..c5831914b
--- /dev/null
+++ b/api/tests/federation/conftest.py
@@ -0,0 +1,10 @@
+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
diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py
new file mode 100644
index 000000000..a6e1d28aa
--- /dev/null
+++ b/api/tests/federation/test_activity.py
@@ -0,0 +1,32 @@
+from funkwhale_api.federation import activity
+
+def test_deliver(nodb_factories, r_mock, mocker):
+ to = nodb_factories['federation.Actor']()
+ mocker.patch(
+ 'funkwhale_api.federation.actors.get_actor',
+ return_value=to)
+ sender = nodb_factories['federation.Actor']()
+ ac = {
+ 'id': 'http://test.federation/activity',
+ 'type': 'Create',
+ 'actor': sender.url,
+ 'object': {
+ 'id': 'http://test.federation/note',
+ 'type': 'Note',
+ 'content': 'Hello',
+ }
+ }
+
+ r_mock.post(to.inbox_url)
+
+ activity.deliver(
+ ac,
+ to=[to.url],
+ on_behalf_of=sender,
+ )
+ request = r_mock.request_history[0]
+
+ assert r_mock.called is True
+ assert r_mock.call_count == 1
+ assert request.url == to.inbox_url
+ assert request.headers['content-type'] == 'application/activity+json'
diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py
new file mode 100644
index 000000000..b3b0f8df0
--- /dev/null
+++ b/api/tests/federation/test_actors.py
@@ -0,0 +1,190 @@
+import pytest
+
+from django.urls import reverse
+from django.utils import timezone
+
+from rest_framework import exceptions
+
+from funkwhale_api.federation import actors
+from funkwhale_api.federation import serializers
+from funkwhale_api.federation import utils
+
+
+def test_actor_fetching(r_mock):
+ payload = {
+ 'id': 'https://actor.mock/users/actor#main-key',
+ 'owner': 'test',
+ 'publicKeyPem': 'test_pem',
+ }
+ actor_url = 'https://actor.mock/'
+ r_mock.get(actor_url, json=payload)
+ r = actors.get_actor_data(actor_url)
+
+ assert r == payload
+
+
+def test_get_library(settings, preferences):
+ preferences['federation__public_key'] = 'public_key'
+ expected = {
+ 'preferred_username': 'library',
+ 'domain': settings.FEDERATION_HOSTNAME,
+ 'type': 'Person',
+ 'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
+ 'manually_approves_followers': True,
+ 'url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-detail',
+ kwargs={'actor': 'library'})),
+ 'shared_inbox_url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-inbox',
+ kwargs={'actor': 'library'})),
+ 'inbox_url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-inbox',
+ kwargs={'actor': 'library'})),
+ 'outbox_url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-outbox',
+ kwargs={'actor': 'library'})),
+ 'public_key': 'public_key',
+ 'summary': 'Bot account to federate with {}\'s library'.format(
+ settings.FEDERATION_HOSTNAME),
+ }
+ actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
+ for key, value in expected.items():
+ assert getattr(actor, key) == value
+
+
+def test_get_test(settings, preferences):
+ preferences['federation__public_key'] = 'public_key'
+ expected = {
+ 'preferred_username': 'test',
+ 'domain': settings.FEDERATION_HOSTNAME,
+ 'type': 'Person',
+ 'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME),
+ 'manually_approves_followers': False,
+ 'url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-detail',
+ kwargs={'actor': 'test'})),
+ 'shared_inbox_url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-inbox',
+ kwargs={'actor': 'test'})),
+ 'inbox_url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-inbox',
+ kwargs={'actor': 'test'})),
+ 'outbox_url': utils.full_url(
+ reverse(
+ 'federation:instance-actors-outbox',
+ kwargs={'actor': 'test'})),
+ 'public_key': 'public_key',
+ 'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format(
+ settings.FEDERATION_HOSTNAME),
+ }
+ actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
+ for key, value in expected.items():
+ assert getattr(actor, key) == value
+
+
+def test_test_get_outbox():
+ expected = {
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {}
+ ],
+ "id": utils.full_url(
+ reverse(
+ 'federation:instance-actors-outbox',
+ kwargs={'actor': 'test'})),
+ "type": "OrderedCollection",
+ "totalItems": 0,
+ "orderedItems": []
+ }
+
+ data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None)
+
+ assert data == expected
+
+
+def test_test_post_inbox_requires_authenticated_actor():
+ with pytest.raises(exceptions.PermissionDenied):
+ actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None)
+
+
+def test_test_post_outbox_validates_actor(nodb_factories):
+ actor = nodb_factories['federation.Actor']()
+ data = {
+ 'actor': 'noop'
+ }
+ with pytest.raises(exceptions.ValidationError) as exc_info:
+ actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
+ msg = 'The actor making the request do not match'
+ assert msg in exc_info.value
+
+
+def test_test_post_outbox_handles_create_note(
+ 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)
+ data = {
+ 'actor': actor.url,
+ 'type': 'Create',
+ 'id': 'http://test.federation/activity',
+ 'object': {
+ 'type': 'Note',
+ 'id': 'http://test.federation/object',
+ 'content': '@mention /ping
'
+ }
+ }
+ test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
+ expected_note = factories['federation.Note'](
+ id='https://test.federation/activities/note/{}'.format(
+ now.timestamp()
+ ),
+ content='Pong!',
+ published=now.isoformat(),
+ inReplyTo=data['object']['id'],
+ cc=[],
+ summary=None,
+ sensitive=False,
+ attributedTo=test_actor.url,
+ attachment=[],
+ to=[actor.url],
+ url='https://{}/activities/note/{}'.format(
+ settings.FEDERATION_HOSTNAME, now.timestamp()
+ ),
+ tag=[{
+ 'href': actor.url,
+ 'name': actor.mention_username,
+ 'type': 'Mention',
+ }]
+ )
+ expected_activity = {
+ '@context': [
+ 'https://www.w3.org/ns/activitystreams',
+ 'https://w3id.org/security/v1',
+ {}
+ ],
+ 'actor': test_actor.url,
+ 'id': 'https://{}/activities/note/{}/activity'.format(
+ settings.FEDERATION_HOSTNAME, now.timestamp()
+ ),
+ 'to': actor.url,
+ 'type': 'Create',
+ 'published': now.isoformat(),
+ 'object': expected_note,
+ 'cc': [],
+ }
+ actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
+ deliver.assert_called_once_with(
+ expected_activity,
+ to=[actor.url],
+ on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance()
+ )
diff --git a/api/tests/federation/test_authentication.py b/api/tests/federation/test_authentication.py
new file mode 100644
index 000000000..1837b3950
--- /dev/null
+++ b/api/tests/federation/test_authentication.py
@@ -0,0 +1,42 @@
+from funkwhale_api.federation import authentication
+from funkwhale_api.federation import keys
+from funkwhale_api.federation import signing
+
+
+def test_authenticate(nodb_factories, mocker, api_request):
+ private, public = keys.get_key_pair()
+ actor_url = 'https://test.federation/actor'
+ mocker.patch(
+ 'funkwhale_api.federation.actors.get_actor_data',
+ return_value={
+ 'id': actor_url,
+ 'outbox': 'https://test.com',
+ 'inbox': 'https://test.com',
+ 'publicKey': {
+ 'publicKeyPem': public.decode('utf-8'),
+ 'owner': actor_url,
+ 'id': actor_url + '#main-key',
+ }
+ })
+ signed_request = nodb_factories['federation.SignedRequest'](
+ auth__key=private,
+ auth__key_id=actor_url + '#main-key',
+ auth__headers=[
+ 'date',
+ ]
+ )
+ prepared = signed_request.prepare()
+ django_request = api_request.get(
+ '/',
+ **{
+ 'HTTP_DATE': prepared.headers['date'],
+ 'HTTP_SIGNATURE': prepared.headers['signature'],
+ }
+ )
+ authenticator = authentication.SignatureAuthentication()
+ user, _ = authenticator.authenticate(django_request)
+ actor = django_request.actor
+
+ assert user.is_anonymous is True
+ assert actor.public_key == public.decode('utf-8')
+ assert actor.url == actor_url
diff --git a/api/tests/federation/test_commands.py b/api/tests/federation/test_commands.py
new file mode 100644
index 000000000..7c5333068
--- /dev/null
+++ b/api/tests/federation/test_commands.py
@@ -0,0 +1,14 @@
+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'
diff --git a/api/tests/federation/test_keys.py b/api/tests/federation/test_keys.py
new file mode 100644
index 000000000..9dd71be09
--- /dev/null
+++ b/api/tests/federation/test_keys.py
@@ -0,0 +1,25 @@
+import pytest
+
+from funkwhale_api.federation import keys
+
+
+@pytest.mark.parametrize('raw, expected', [
+ ('algorithm="test",keyId="https://test.com"', 'https://test.com'),
+ ('keyId="https://test.com",algorithm="test"', 'https://test.com'),
+])
+def test_get_key_from_header(raw, expected):
+ r = keys.get_key_id_from_signature_header(raw)
+ assert r == expected
+
+
+@pytest.mark.parametrize('raw', [
+ 'algorithm="test",keyid="badCase"',
+ 'algorithm="test",wrong="wrong"',
+ 'keyId = "wrong"',
+ 'keyId=\'wrong\'',
+ 'keyId="notanurl"',
+ 'keyId="wrong://test.com"',
+])
+def test_get_key_from_header_invalid(raw):
+ with pytest.raises(ValueError):
+ keys.get_key_id_from_signature_header(raw)
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
new file mode 100644
index 000000000..efa92b16a
--- /dev/null
+++ b/api/tests/federation/test_serializers.py
@@ -0,0 +1,146 @@
+from django.urls import reverse
+
+from funkwhale_api.federation import keys
+from funkwhale_api.federation import models
+from funkwhale_api.federation import serializers
+
+
+def test_actor_serializer_from_ap(db):
+ payload = {
+ 'id': 'https://test.federation/user',
+ 'type': 'Person',
+ 'following': 'https://test.federation/user/following',
+ 'followers': 'https://test.federation/user/followers',
+ 'inbox': 'https://test.federation/user/inbox',
+ 'outbox': 'https://test.federation/user/outbox',
+ 'preferredUsername': 'user',
+ 'name': 'Real User',
+ 'summary': 'Hello world',
+ 'url': 'https://test.federation/@user',
+ 'manuallyApprovesFollowers': False,
+ 'publicKey': {
+ 'id': 'https://test.federation/user#main-key',
+ 'owner': 'https://test.federation/user',
+ 'publicKeyPem': 'yolo'
+ },
+ 'endpoints': {
+ 'sharedInbox': 'https://test.federation/inbox'
+ },
+ }
+
+ serializer = serializers.ActorSerializer(data=payload)
+ assert serializer.is_valid()
+
+ actor = serializer.build()
+
+ assert actor.url == payload['id']
+ assert actor.inbox_url == payload['inbox']
+ assert actor.outbox_url == payload['outbox']
+ assert actor.shared_inbox_url == payload['endpoints']['sharedInbox']
+ assert actor.followers_url == payload['followers']
+ assert actor.following_url == payload['following']
+ assert actor.public_key == payload['publicKey']['publicKeyPem']
+ assert actor.preferred_username == payload['preferredUsername']
+ assert actor.name == payload['name']
+ assert actor.domain == 'test.federation'
+ assert actor.summary == payload['summary']
+ assert actor.type == 'Person'
+ assert actor.manually_approves_followers == payload['manuallyApprovesFollowers']
+
+
+def test_actor_serializer_only_mandatory_field_from_ap(db):
+ payload = {
+ 'id': 'https://test.federation/user',
+ 'type': 'Person',
+ 'following': 'https://test.federation/user/following',
+ 'followers': 'https://test.federation/user/followers',
+ 'inbox': 'https://test.federation/user/inbox',
+ 'outbox': 'https://test.federation/user/outbox',
+ 'preferredUsername': 'user',
+ }
+
+ serializer = serializers.ActorSerializer(data=payload)
+ assert serializer.is_valid()
+
+ actor = serializer.build()
+
+ assert actor.url == payload['id']
+ assert actor.inbox_url == payload['inbox']
+ assert actor.outbox_url == payload['outbox']
+ assert actor.followers_url == payload['followers']
+ assert actor.following_url == payload['following']
+ assert actor.preferred_username == payload['preferredUsername']
+ assert actor.domain == 'test.federation'
+ assert actor.type == 'Person'
+ assert actor.manually_approves_followers is None
+
+
+def test_actor_serializer_to_ap():
+ expected = {
+ '@context': [
+ 'https://www.w3.org/ns/activitystreams',
+ 'https://w3id.org/security/v1',
+ {},
+ ],
+ 'id': 'https://test.federation/user',
+ 'type': 'Person',
+ 'following': 'https://test.federation/user/following',
+ 'followers': 'https://test.federation/user/followers',
+ 'inbox': 'https://test.federation/user/inbox',
+ 'outbox': 'https://test.federation/user/outbox',
+ 'preferredUsername': 'user',
+ 'name': 'Real User',
+ 'summary': 'Hello world',
+ 'manuallyApprovesFollowers': False,
+ 'publicKey': {
+ 'id': 'https://test.federation/user#main-key',
+ 'owner': 'https://test.federation/user',
+ 'publicKeyPem': 'yolo'
+ },
+ 'endpoints': {
+ 'sharedInbox': 'https://test.federation/inbox'
+ },
+ }
+ ac = models.Actor(
+ url=expected['id'],
+ inbox_url=expected['inbox'],
+ outbox_url=expected['outbox'],
+ shared_inbox_url=expected['endpoints']['sharedInbox'],
+ followers_url=expected['followers'],
+ following_url=expected['following'],
+ public_key=expected['publicKey']['publicKeyPem'],
+ preferred_username=expected['preferredUsername'],
+ name=expected['name'],
+ domain='test.federation',
+ summary=expected['summary'],
+ type='Person',
+ manually_approves_followers=False,
+
+ )
+ serializer = serializers.ActorSerializer(ac)
+
+ assert serializer.data == expected
+
+
+def test_webfinger_serializer():
+ expected = {
+ 'subject': 'acct:service@test.federation',
+ 'links': [
+ {
+ 'rel': 'self',
+ 'href': 'https://test.federation/federation/instance/actor',
+ 'type': 'application/activity+json',
+ }
+ ],
+ 'aliases': [
+ 'https://test.federation/federation/instance/actor',
+ ]
+ }
+ actor = models.Actor(
+ url=expected['links'][0]['href'],
+ preferred_username='service',
+ domain='test.federation',
+ )
+ serializer = serializers.ActorWebfingerSerializer(actor)
+
+ assert serializer.data == expected
diff --git a/api/tests/federation/test_signing.py b/api/tests/federation/test_signing.py
new file mode 100644
index 000000000..0c1ec2e0b
--- /dev/null
+++ b/api/tests/federation/test_signing.py
@@ -0,0 +1,132 @@
+import cryptography.exceptions
+import io
+import pytest
+import requests_http_signature
+
+from funkwhale_api.federation import signing
+from funkwhale_api.federation import keys
+
+
+def test_can_sign_and_verify_request(nodb_factories):
+ private, public = nodb_factories['federation.KeyPair']()
+ auth = nodb_factories['federation.SignatureAuth'](key=private)
+ request = nodb_factories['federation.SignedRequest'](
+ auth=auth
+ )
+ prepared_request = request.prepare()
+ assert 'date' in prepared_request.headers
+ assert 'signature' in prepared_request.headers
+ assert signing.verify(
+ prepared_request, public) is None
+
+
+def test_can_sign_and_verify_request_digest(nodb_factories):
+ private, public = nodb_factories['federation.KeyPair']()
+ auth = nodb_factories['federation.SignatureAuth'](key=private)
+ request = nodb_factories['federation.SignedRequest'](
+ auth=auth,
+ method='post',
+ data=b'hello=world'
+ )
+ prepared_request = request.prepare()
+ assert 'date' in prepared_request.headers
+ assert 'digest' in prepared_request.headers
+ assert 'signature' in prepared_request.headers
+ assert signing.verify(prepared_request, public) is None
+
+
+def test_verify_fails_with_wrong_key(nodb_factories):
+ wrong_private, wrong_public = nodb_factories['federation.KeyPair']()
+ request = nodb_factories['federation.SignedRequest']()
+ prepared_request = request.prepare()
+
+ with pytest.raises(cryptography.exceptions.InvalidSignature):
+ signing.verify(prepared_request, wrong_public)
+
+
+def test_can_verify_django_request(factories, fake_request):
+ private_key, public_key = keys.get_key_pair()
+ signed_request = factories['federation.SignedRequest'](
+ auth__key=private_key,
+ auth__headers=[
+ 'date',
+ ]
+ )
+ prepared = signed_request.prepare()
+ django_request = fake_request.get(
+ '/',
+ **{
+ 'HTTP_DATE': prepared.headers['date'],
+ 'HTTP_SIGNATURE': prepared.headers['signature'],
+ }
+ )
+ assert signing.verify_django(django_request, public_key) is None
+
+
+def test_can_verify_django_request_digest(factories, fake_request):
+ private_key, public_key = keys.get_key_pair()
+ signed_request = factories['federation.SignedRequest'](
+ auth__key=private_key,
+ method='post',
+ data=b'hello=world',
+ auth__headers=[
+ 'date',
+ 'digest',
+ ]
+ )
+ prepared = signed_request.prepare()
+ django_request = fake_request.post(
+ '/',
+ **{
+ 'HTTP_DATE': prepared.headers['date'],
+ 'HTTP_DIGEST': prepared.headers['digest'],
+ 'HTTP_SIGNATURE': prepared.headers['signature'],
+ }
+ )
+
+ assert signing.verify_django(django_request, public_key) is None
+
+
+def test_can_verify_django_request_digest_failure(factories, fake_request):
+ private_key, public_key = keys.get_key_pair()
+ signed_request = factories['federation.SignedRequest'](
+ auth__key=private_key,
+ method='post',
+ data=b'hello=world',
+ auth__headers=[
+ 'date',
+ 'digest',
+ ]
+ )
+ prepared = signed_request.prepare()
+ django_request = fake_request.post(
+ '/',
+ **{
+ 'HTTP_DATE': prepared.headers['date'],
+ 'HTTP_DIGEST': prepared.headers['digest'] + 'noop',
+ 'HTTP_SIGNATURE': prepared.headers['signature'],
+ }
+ )
+
+ with pytest.raises(cryptography.exceptions.InvalidSignature):
+ signing.verify_django(django_request, public_key)
+
+
+def test_can_verify_django_request_failure(factories, fake_request):
+ private_key, public_key = keys.get_key_pair()
+ signed_request = factories['federation.SignedRequest'](
+ auth__key=private_key,
+ auth__headers=[
+ 'date',
+ ]
+ )
+ prepared = signed_request.prepare()
+ django_request = fake_request.get(
+ '/',
+ **{
+ 'HTTP_DATE': 'Wrong',
+ 'HTTP_SIGNATURE': prepared.headers['signature'],
+ }
+ )
+ with pytest.raises(cryptography.exceptions.InvalidSignature):
+ signing.verify_django(django_request, public_key)
diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py
new file mode 100644
index 000000000..dc371ad9e
--- /dev/null
+++ b/api/tests/federation/test_utils.py
@@ -0,0 +1,48 @@
+import pytest
+
+from funkwhale_api.federation import utils
+
+
+@pytest.mark.parametrize('url,path,expected', [
+ ('http://test.com', '/hello', 'http://test.com/hello'),
+ ('http://test.com/', 'hello', 'http://test.com/hello'),
+ ('http://test.com/', '/hello', 'http://test.com/hello'),
+ ('http://test.com', 'hello', 'http://test.com/hello'),
+])
+def test_full_url(settings, url, path, expected):
+ settings.FUNKWHALE_URL = url
+ assert utils.full_url(path) == expected
+
+
+def test_extract_headers_from_meta():
+ wsgi_headers = {
+ 'HTTP_HOST': 'nginx',
+ 'HTTP_X_REAL_IP': '172.20.0.4',
+ 'HTTP_X_FORWARDED_FOR': '188.165.228.227, 172.20.0.4',
+ 'HTTP_X_FORWARDED_PROTO': 'http',
+ 'HTTP_X_FORWARDED_HOST': 'localhost:80',
+ 'HTTP_X_FORWARDED_PORT': '80',
+ 'HTTP_CONNECTION': 'close',
+ 'CONTENT_LENGTH': '1155',
+ 'CONTENT_TYPE': 'txt/application',
+ 'HTTP_SIGNATURE': 'Hello',
+ 'HTTP_DATE': 'Sat, 31 Mar 2018 13:53:55 GMT',
+ 'HTTP_USER_AGENT': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'}
+
+ cleaned_headers = utils.clean_wsgi_headers(wsgi_headers)
+
+ expected = {
+ 'Host': 'nginx',
+ 'X-Real-Ip': '172.20.0.4',
+ 'X-Forwarded-For': '188.165.228.227, 172.20.0.4',
+ 'X-Forwarded-Proto': 'http',
+ 'X-Forwarded-Host': 'localhost:80',
+ 'X-Forwarded-Port': '80',
+ 'Connection': 'close',
+ 'Content-Length': '1155',
+ 'Content-Type': 'txt/application',
+ 'Signature': 'Hello',
+ 'Date': 'Sat, 31 Mar 2018 13:53:55 GMT',
+ 'User-Agent': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'
+ }
+ assert cleaned_headers == expected
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
new file mode 100644
index 000000000..0d2ac882f
--- /dev/null
+++ b/api/tests/federation/test_views.py
@@ -0,0 +1,64 @@
+from django.urls import reverse
+
+import pytest
+
+from funkwhale_api.federation import actors
+from funkwhale_api.federation import serializers
+from funkwhale_api.federation import webfinger
+
+
+
+@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
+def test_instance_actors(system_actor, db, settings, api_client):
+ actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
+ url = reverse(
+ 'federation:instance-actors-detail',
+ kwargs={'actor': system_actor})
+ response = api_client.get(url)
+ serializer = serializers.ActorSerializer(actor)
+
+ assert response.status_code == 200
+ assert response.data == serializer.data
+
+
+@pytest.mark.parametrize('route,kwargs', [
+ ('instance-actors-outbox', {'actor': 'library'}),
+ ('instance-actors-inbox', {'actor': 'library'}),
+ ('instance-actors-detail', {'actor': 'library'}),
+ ('well-known-webfinger', {}),
+])
+def test_instance_endpoints_405_if_federation_disabled(
+ authenticated_actor, db, settings, api_client, route, kwargs):
+ settings.FEDERATION_ENABLED = False
+ url = reverse('federation:{}'.format(route), kwargs=kwargs)
+ response = api_client.get(url)
+
+ assert response.status_code == 405
+
+
+def test_wellknown_webfinger_validates_resource(
+ db, api_client, settings, mocker):
+ clean = mocker.spy(webfinger, 'clean_resource')
+ url = reverse('federation:well-known-webfinger')
+ response = api_client.get(url, data={'resource': 'something'})
+
+ clean.assert_called_once_with('something')
+ assert url == '/.well-known/webfinger'
+ assert response.status_code == 400
+ assert response.data['errors']['resource'] == (
+ 'Missing webfinger resource type'
+ )
+
+
+@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
+def test_wellknown_webfinger_system(
+ system_actor, db, api_client, settings, mocker):
+ actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
+ url = reverse('federation:well-known-webfinger')
+ response = api_client.get(
+ url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)})
+ serializer = serializers.ActorWebfingerSerializer(actor)
+
+ assert response.status_code == 200
+ assert response['Content-Type'] == 'application/jrd+json'
+ assert response.data == serializer.data
diff --git a/api/tests/federation/test_webfinger.py b/api/tests/federation/test_webfinger.py
new file mode 100644
index 000000000..96258455a
--- /dev/null
+++ b/api/tests/federation/test_webfinger.py
@@ -0,0 +1,42 @@
+import pytest
+
+from django import forms
+from django.urls import reverse
+
+from funkwhale_api.federation import webfinger
+
+
+def test_webfinger_clean_resource():
+ t, r = webfinger.clean_resource('acct:service@test.federation')
+ assert t == 'acct'
+ assert r == 'service@test.federation'
+
+
+@pytest.mark.parametrize('resource,message', [
+ ('', 'Invalid resource string'),
+ ('service@test.com', 'Missing webfinger resource type'),
+ ('noop:service@test.com', 'Invalid webfinger resource type'),
+])
+def test_webfinger_clean_resource_errors(resource, message):
+ with pytest.raises(forms.ValidationError) as excinfo:
+ webfinger.clean_resource(resource)
+
+ assert message == str(excinfo)
+
+
+def test_webfinger_clean_acct(settings):
+ username, hostname = webfinger.clean_acct('library@test.federation')
+ assert username == 'library'
+ assert hostname == 'test.federation'
+
+
+@pytest.mark.parametrize('resource,message', [
+ ('service', 'Invalid format'),
+ ('service@test.com', 'Invalid hostname test.com'),
+ ('noop@test.federation', 'Invalid account'),
+])
+def test_webfinger_clean_acct_errors(resource, message, settings):
+ with pytest.raises(forms.ValidationError) as excinfo:
+ webfinger.clean_resource(resource)
+
+ assert message == str(excinfo)
diff --git a/api/tests/files/utf8-éà◌.ogg b/api/tests/files/utf8-éà◌.ogg
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py
index b5ab07b82..04000604b 100644
--- a/api/tests/history/test_activity.py
+++ b/api/tests/history/test_activity.py
@@ -23,7 +23,7 @@ def test_activity_listening_serializer(factories):
"id": listening.get_activity_url(),
"actor": actor,
"object": TrackActivitySerializer(listening.track).data,
- "published": field.to_representation(listening.end_date),
+ "published": field.to_representation(listening.creation_date),
}
data = serializers.ListeningActivitySerializer(listening).data
diff --git a/api/tests/instance/__init__.py b/api/tests/instance/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/music/conftest.py b/api/tests/music/conftest.py
new file mode 100644
index 000000000..1d0fa4e38
--- /dev/null
+++ b/api/tests/music/conftest.py
@@ -0,0 +1,566 @@
+import pytest
+
+
+_artists = {'search': {}, 'get': {}}
+
+_artists['search']['adhesive_wombat'] = {
+ 'artist-list': [
+ {
+ 'type': 'Person',
+ 'ext:score': '100',
+ 'id': '62c3befb-6366-4585-b256-809472333801',
+ 'disambiguation': 'George Shaw',
+ 'gender': 'male',
+ 'area': {'sort-name': 'Raleigh', 'id': '3f8828b9-ba93-4604-9b92-1f616fa1abd1', 'name': 'Raleigh'},
+ 'sort-name': 'Wombat, Adhesive',
+ 'life-span': {'ended': 'false'},
+ 'name': 'Adhesive Wombat'
+ },
+ {
+ 'country': 'SE',
+ 'type': 'Group',
+ 'ext:score': '42',
+ 'id': '61b34e69-7573-4208-bc89-7061bca5a8fc',
+ 'area': {'sort-name': 'Sweden', 'id': '23d10872-f5ae-3f0c-bf55-332788a16ecb', 'name': 'Sweden'},
+ 'sort-name': 'Adhesive',
+ 'life-span': {'end': '2002-07-12', 'begin': '1994', 'ended': 'true'},
+ 'name': 'Adhesive',
+ 'begin-area': {
+ 'sort-name': 'Katrineholm',
+ 'id': '02390d96-b5a3-4282-a38f-e64a95d08b7f',
+ 'name': 'Katrineholm'
+ },
+ },
+ ]
+}
+_artists['get']['adhesive_wombat'] = {'artist': _artists['search']['adhesive_wombat']['artist-list'][0]}
+
+_artists['get']['soad'] = {
+ 'artist': {
+ 'country': 'US',
+ 'isni-list': ['0000000121055332'],
+ 'type': 'Group',
+ 'area': {
+ 'iso-3166-1-code-list': ['US'],
+ 'sort-name': 'United States',
+ 'id': '489ce91b-6658-3307-9877-795b68554c98',
+ 'name': 'United States'
+ },
+ 'begin-area': {
+ 'sort-name': 'Glendale',
+ 'id': '6db2e45d-d7f3-43da-ac0b-7ba5ca627373',
+ 'name': 'Glendale'
+ },
+ 'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
+ 'life-span': {'begin': '1994'},
+ 'sort-name': 'System of a Down',
+ 'name': 'System of a Down'
+ }
+}
+
+_albums = {'search': {}, 'get': {}, 'get_with_includes': {}}
+_albums['search']['hypnotize'] = {
+ 'release-list': [
+ {
+ "artist-credit": [
+ {
+ "artist": {
+ "alias-list": [
+ {
+ "alias": "SoaD",
+ "sort-name": "SoaD",
+ "type": "Search hint"
+ },
+ {
+ "alias": "S.O.A.D.",
+ "sort-name": "S.O.A.D.",
+ "type": "Search hint"
+ },
+ {
+ "alias": "System Of Down",
+ "sort-name": "System Of Down",
+ "type": "Search hint"
+ }
+ ],
+ "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
+ "name": "System of a Down",
+ "sort-name": "System of a Down"
+ }
+ }
+ ],
+ "artist-credit-phrase": "System of a Down",
+ "barcode": "",
+ "country": "US",
+ "date": "2005",
+ "ext:score": "100",
+ "id": "47ae093f-1607-49a3-be11-a15d335ccc94",
+ "label-info-list": [
+ {
+ "catalog-number": "8-2796-93871-2",
+ "label": {
+ "id": "f5be9cfe-e1af-405c-a074-caeaed6797c0",
+ "name": "American Recordings"
+ }
+ },
+ {
+ "catalog-number": "D162990",
+ "label": {
+ "id": "9a7d39a4-a887-40f3-a645-a9a136d1f13f",
+ "name": "BMG Direct Marketing, Inc."
+ }
+ }
+ ],
+ "medium-count": 1,
+ "medium-list": [
+ {
+ "disc-count": 1,
+ "disc-list": [],
+ "format": "CD",
+ "track-count": 12,
+ "track-list": []
+ }
+ ],
+ "medium-track-count": 12,
+ "packaging": "Digipak",
+ "release-event-list": [
+ {
+ "area": {
+ "id": "489ce91b-6658-3307-9877-795b68554c98",
+ "iso-3166-1-code-list": [
+ "US"
+ ],
+ "name": "United States",
+ "sort-name": "United States"
+ },
+ "date": "2005"
+ }
+ ],
+ "release-group": {
+ "id": "72035143-d6ec-308b-8ee5-070b8703902a",
+ "primary-type": "Album",
+ "type": "Album"
+ },
+ "status": "Official",
+ "text-representation": {
+ "language": "eng",
+ "script": "Latn"
+ },
+ "title": "Hypnotize"
+ },
+ {
+ "artist-credit": [
+ {
+ "artist": {
+ "alias-list": [
+ {
+ "alias": "SoaD",
+ "sort-name": "SoaD",
+ "type": "Search hint"
+ },
+ {
+ "alias": "S.O.A.D.",
+ "sort-name": "S.O.A.D.",
+ "type": "Search hint"
+ },
+ {
+ "alias": "System Of Down",
+ "sort-name": "System Of Down",
+ "type": "Search hint"
+ }
+ ],
+ "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
+ "name": "System of a Down",
+ "sort-name": "System of a Down"
+ }
+ }
+ ],
+ "artist-credit-phrase": "System of a Down",
+ "asin": "B000C6NRY8",
+ "barcode": "827969387115",
+ "country": "US",
+ "date": "2005-12-20",
+ "ext:score": "100",
+ "id": "8a4034a9-7834-3b7e-a6f0-d0791e3731fb",
+ "medium-count": 1,
+ "medium-list": [
+ {
+ "disc-count": 0,
+ "disc-list": [],
+ "format": "Vinyl",
+ "track-count": 12,
+ "track-list": []
+ }
+ ],
+ "medium-track-count": 12,
+ "release-event-list": [
+ {
+ "area": {
+ "id": "489ce91b-6658-3307-9877-795b68554c98",
+ "iso-3166-1-code-list": [
+ "US"
+ ],
+ "name": "United States",
+ "sort-name": "United States"
+ },
+ "date": "2005-12-20"
+ }
+ ],
+ "release-group": {
+ "id": "72035143-d6ec-308b-8ee5-070b8703902a",
+ "primary-type": "Album",
+ "type": "Album"
+ },
+ "status": "Official",
+ "text-representation": {
+ "language": "eng",
+ "script": "Latn"
+ },
+ "title": "Hypnotize"
+ },
+ ]
+}
+_albums['get']['hypnotize'] = {'release': _albums['search']['hypnotize']['release-list'][0]}
+_albums['get_with_includes']['hypnotize'] = {
+ 'release': {
+ 'artist-credit': [
+ {'artist': {'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
+ 'name': 'System of a Down',
+ 'sort-name': 'System of a Down'}}],
+ 'artist-credit-phrase': 'System of a Down',
+ 'barcode': '',
+ 'country': 'US',
+ 'cover-art-archive': {'artwork': 'true',
+ 'back': 'false',
+ 'count': '1',
+ 'front': 'true'},
+ 'date': '2005',
+ 'id': '47ae093f-1607-49a3-be11-a15d335ccc94',
+ 'medium-count': 1,
+ 'medium-list': [{'format': 'CD',
+ 'position': '1',
+ 'track-count': 12,
+ 'track-list': [{'id': '59f5cf9a-75b2-3aa3-abda-6807a87107b3',
+ 'length': '186000',
+ 'number': '1',
+ 'position': '1',
+ 'recording': {'id': '76d03fc5-758c-48d0-a354-a67de086cc68',
+ 'length': '186000',
+ 'title': 'Attack'},
+ 'track_or_recording_length': '186000'},
+ {'id': '3aaa28c1-12b1-3c2a-b90a-82e09e355608',
+ 'length': '239000',
+ 'number': '2',
+ 'position': '2',
+ 'recording': {'id': '327543b0-9193-48c5-83c9-01c7b36c8c0a',
+ 'length': '239000',
+ 'title': 'Dreaming'},
+ 'track_or_recording_length': '239000'},
+ {'id': 'a34fef19-e637-3436-b7eb-276ff2814d6f',
+ 'length': '147000',
+ 'number': '3',
+ 'position': '3',
+ 'recording': {'id': '6e27866c-07a1-425d-bb4f-9d9e728db344',
+ 'length': '147000',
+ 'title': 'Kill Rock ’n Roll'},
+ 'track_or_recording_length': '147000'},
+ {'id': '72a4e5c0-c150-3ba1-9ceb-3ab82648af25',
+ 'length': '189000',
+ 'number': '4',
+ 'position': '4',
+ 'recording': {'id': '7ff8a67d-c8e2-4b3a-a045-7ad3561d0605',
+ 'length': '189000',
+ 'title': 'Hypnotize'},
+ 'track_or_recording_length': '189000'},
+ {'id': 'a748fa6e-b3b7-3b22-89fb-a038ec92ac32',
+ 'length': '178000',
+ 'number': '5',
+ 'position': '5',
+ 'recording': {'id': '19b6eb6a-0e76-4ef7-b63f-959339dbd5d2',
+ 'length': '178000',
+ 'title': 'Stealing Society'},
+ 'track_or_recording_length': '178000'},
+ {'id': '5c5a8d4e-e21a-317e-a719-6e2dbdefa5d2',
+ 'length': '216000',
+ 'number': '6',
+ 'position': '6',
+ 'recording': {'id': 'c3c2afe1-ee9a-47cb-b3c6-ff8100bc19d5',
+ 'length': '216000',
+ 'title': 'Tentative'},
+ 'track_or_recording_length': '216000'},
+ {'id': '265718ba-787f-3193-947b-3b6fa69ffe96',
+ 'length': '175000',
+ 'number': '7',
+ 'position': '7',
+ 'recording': {'id': '96f804e1-f600-4faa-95a6-ce597e7db120',
+ 'length': '175000',
+ 'title': 'U‐Fig'},
+ 'title': 'U-Fig',
+ 'track_or_recording_length': '175000'},
+ {'id': 'cdcf8572-3060-31ca-a72c-1ded81ca1f7a',
+ 'length': '328000',
+ 'number': '8',
+ 'position': '8',
+ 'recording': {'id': '26ba38f0-b26b-48b7-8e77-226b22a55f79',
+ 'length': '328000',
+ 'title': 'Holy Mountains'},
+ 'track_or_recording_length': '328000'},
+ {'id': 'f9f00cb0-5635-3217-a2a0-bd61917eb0df',
+ 'length': '171000',
+ 'number': '9',
+ 'position': '9',
+ 'recording': {'id': '039f3379-3a69-4e75-a882-df1c4e1608aa',
+ 'length': '171000',
+ 'title': 'Vicinity of Obscenity'},
+ 'track_or_recording_length': '171000'},
+ {'id': 'cdd45914-6741-353e-bbb5-d281048ff24f',
+ 'length': '164000',
+ 'number': '10',
+ 'position': '10',
+ 'recording': {'id': 'c24d541a-a9a8-4a22-84c6-5e6419459cf8',
+ 'length': '164000',
+ 'title': 'She’s Like Heroin'},
+ 'track_or_recording_length': '164000'},
+ {'id': 'cfcf12ac-6831-3dd6-a2eb-9d0bfeee3f6d',
+ 'length': '167000',
+ 'number': '11',
+ 'position': '11',
+ 'recording': {'id': '0aff4799-849f-4f83-84f4-22cabbba2378',
+ 'length': '167000',
+ 'title': 'Lonely Day'},
+ 'track_or_recording_length': '167000'},
+ {'id': '7e38bb38-ff62-3e41-a670-b7d77f578a1f',
+ 'length': '220000',
+ 'number': '12',
+ 'position': '12',
+ 'recording': {'id': 'e1b4d90f-2f44-4fe6-a826-362d4e3d9b88',
+ 'length': '220000',
+ 'title': 'Soldier Side'},
+ 'track_or_recording_length': '220000'}]}],
+ 'packaging': 'Digipak',
+ 'quality': 'normal',
+ 'release-event-count': 1,
+ 'release-event-list': [{'area': {'id': '489ce91b-6658-3307-9877-795b68554c98',
+ 'iso-3166-1-code-list': ['US'],
+ 'name': 'United States',
+ 'sort-name': 'United States'},
+ 'date': '2005'}],
+ 'status': 'Official',
+ 'text-representation': {'language': 'eng', 'script': 'Latn'},
+ 'title': 'Hypnotize'}}
+
+_albums['get']['marsupial'] = {
+ 'release': {
+ "artist-credit": [
+ {
+ "artist": {
+ "disambiguation": "George Shaw",
+ "id": "62c3befb-6366-4585-b256-809472333801",
+ "name": "Adhesive Wombat",
+ "sort-name": "Wombat, Adhesive"
+ }
+ }
+ ],
+ "artist-credit-phrase": "Adhesive Wombat",
+ "country": "XW",
+ "cover-art-archive": {
+ "artwork": "true",
+ "back": "false",
+ "count": "1",
+ "front": "true"
+ },
+ "date": "2013-06-05",
+ "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
+ "packaging": "None",
+ "quality": "normal",
+ "release-event-count": 1,
+ "release-event-list": [
+ {
+ "area": {
+ "id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
+ "iso-3166-1-code-list": [
+ "XW"
+ ],
+ "name": "[Worldwide]",
+ "sort-name": "[Worldwide]"
+ },
+ "date": "2013-06-05"
+ }
+ ],
+ "status": "Official",
+ "text-representation": {
+ "language": "eng",
+ "script": "Latn"
+ },
+ "title": "Marsupial Madness"
+ }
+}
+
+_tracks = {'search': {}, 'get': {}}
+
+_tracks['search']['8bitadventures'] = {
+ 'recording-list': [
+ {
+ "artist-credit": [
+ {
+ "artist": {
+ "disambiguation": "George Shaw",
+ "id": "62c3befb-6366-4585-b256-809472333801",
+ "name": "Adhesive Wombat",
+ "sort-name": "Wombat, Adhesive"
+ }
+ }
+ ],
+ "artist-credit-phrase": "Adhesive Wombat",
+ "ext:score": "100",
+ "id": "9968a9d6-8d92-4051-8f76-674e157b6eed",
+ "length": "271000",
+ "release-list": [
+ {
+ "country": "XW",
+ "date": "2013-06-05",
+ "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
+ "medium-list": [
+ {
+ "format": "Digital Media",
+ "position": "1",
+ "track-count": 11,
+ "track-list": [
+ {
+ "id": "64d43604-c1ee-4f45-a02c-030672d2fe27",
+ "length": "271000",
+ "number": "1",
+ "title": "8-Bit Adventure",
+ "track_or_recording_length": "271000"
+ }
+ ]
+ }
+ ],
+ "medium-track-count": 11,
+ "release-event-list": [
+ {
+ "area": {
+ "id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
+ "iso-3166-1-code-list": [
+ "XW"
+ ],
+ "name": "[Worldwide]",
+ "sort-name": "[Worldwide]"
+ },
+ "date": "2013-06-05"
+ }
+ ],
+ "release-group": {
+ "id": "447b4979-2178-405c-bfe6-46bf0b09e6c7",
+ "primary-type": "Album",
+ "type": "Album"
+ },
+ "status": "Official",
+ "title": "Marsupial Madness"
+ }
+ ],
+ "title": "8-Bit Adventure",
+ "tag-list": [
+ {
+ "count": "2",
+ "name": "techno"
+ },
+ {
+ "count": "2",
+ "name": "good-music"
+ },
+ ],
+ },
+ ]
+}
+
+_tracks['get']['8bitadventures'] = {'recording': _tracks['search']['8bitadventures']['recording-list'][0]}
+_tracks['get']['chop_suey'] = {
+ 'recording': {
+ 'id': '46c7368a-013a-47b6-97cc-e55e7ab25213',
+ 'length': '210240',
+ 'title': 'Chop Suey!',
+ 'work-relation-list': [{'target': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
+ 'type': 'performance',
+ 'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0',
+ 'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
+ 'language': 'eng',
+ 'title': 'Chop Suey!'}}]}}
+
+_works = {'search': {}, 'get': {}}
+_works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
+ 'language': 'eng',
+ 'recording-relation-list': [{'direction': 'backward',
+ 'recording': {'disambiguation': 'edit',
+ 'id': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
+ 'length': '170893',
+ 'title': 'Chop Suey!'},
+ 'target': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
+ 'type': 'performance',
+ 'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0'},
+ ],
+ 'title': 'Chop Suey!',
+ 'type': 'Song',
+ 'url-relation-list': [{'direction': 'backward',
+ 'target': 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!',
+ 'type': 'lyrics',
+ 'type-id': 'e38e65aa-75e0-42ba-ace0-072aeb91a538'}]}}
+
+
+@pytest.fixture()
+def artists():
+ return _artists
+
+
+@pytest.fixture()
+def albums():
+ return _albums
+
+
+@pytest.fixture()
+def tracks():
+ return _tracks
+
+
+@pytest.fixture()
+def works():
+ return _works
+
+
+@pytest.fixture()
+def lyricswiki_content():
+ return """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia
+
+
+
+We're rolling "Suicide". Wake up (wake up) Grab a brush and put on a little makeup Hide the scars to fade away the shakeup (hide the scars to fade away the) Why'd you leave the keys upon the table? Here you go, create another fable You wanted to Grab a brush and put a little makeup You wanted to Hide the scars to fade away the shakeup You wanted to Why'd you leave the keys upon the table? You wanted to I don't think you trust In my self-righteous suicide I cry when angels deserve to die Wake up (wake up) Grab a brush and put on a little makeup Hide the scars to fade away the (hide the scars to fade away the) Why'd you leave the keys upon the table? Here you go, create another fable You wanted to Grab a brush and put a little makeup You wanted to Hide the scars to fade away the shakeup You wanted to Why'd you leave the keys upon the table? You wanted to I don't think you trust In my self-righteous suicide I cry when angels deserve to die In my self-righteous suicide I cry when angels deserve to die Father (father) Father (father) Father (father) Father (father) Father, into your hands I commit my spirit Father, into your hands Why have you forsaken me? In your eyes forsaken me In your thoughts forsaken me In your heart forsaken me, oh Trust in my self-righteous suicide I cry when angels deserve to die In my self-righteous suicide I cry when angels deserve to die
+
+
+"""
+
+
+@pytest.fixture()
+def binary_cover():
+ return b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xed\x08\xaePhotoshop 3.0\x008BIM\x03\xe9\x00\x00\x00\x00\x00x\x00\x03\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\xff\xe1\xff\xe2\x02\xf9\x02F\x03G\x05(\x03\xfc\x00\x02\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\x00\x01\x00\x00\x00d\x00\x00\x00\x01\x00\x03\x03\x03\x00\x00\x00\x01\'\x0f\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x08\x00\x19\x01\x90\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x008BIM\x03\xed\x00\x00\x00\x00\x00\x10\x00H\x00\x00\x00\x01\x00\x01\x00H\x00\x00\x00\x01\x00\x018BIM\x03\xf3\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x008BIM\x04\n\x00\x00\x00\x00\x00\x01\x00\x008BIM\'\x10\x00\x00\x00\x00\x00\n\x00\x01\x00\x00\x00\x00\x00\x00\x00\x028BIM\x03\xf5\x00\x00\x00\x00\x00H\x00/ff\x00\x01\x00lff\x00\x06\x00\x00\x00\x00\x00\x01\x00/ff\x00\x01\x00\xa1\x99\x9a\x00\x06\x00\x00\x00\x00\x00\x01\x002\x00\x00\x00\x01\x00Z\x00\x00\x00\x06\x00\x00\x00\x00\x00\x01\x005\x00\x00\x00\x01\x00-\x00\x00\x00\x06\x00\x00\x00\x00\x00\x018BIM\x03\xf8\x00\x00\x00\x00\x00p\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x008BIM\x04\x00\x00\x00\x00\x00\x00\x02\x00\x018BIM\x04\x02\x00\x00\x00\x00\x00\x04\x00\x00\x00\x008BIM\x04\x08\x00\x00\x00\x00\x00\x10\x00\x00\x00\x01\x00\x00\x02@\x00\x00\x02@\x00\x00\x00\x008BIM\x04\t\x00\x00\x00\x00\x06\x9b\x00\x00\x00\x01\x00\x00\x00\x80\x00\x00\x00\x80\x00\x00\x01\x80\x00\x00\xc0\x00\x00\x00\x06\x7f\x00\x18\x00\x01\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x80\x00\x00\x00\x01\xff\xdb\x00\x84\x00\x0c\x08\x08\x08\t\x08\x0c\t\t\x0c\x11\x0b\n\x0b\x11\x15\x0f\x0c\x0c\x0f\x15\x18\x13\x13\x15\x13\x13\x18\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\r\x0b\x0b\r\x0e\r\x10\x0e\x0e\x10\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\x80\x00\x80\x03\x01"\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x08\xff\xc4\x01?\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x01\x02\x04\x05\x06\x07\x08\t\n\x0b\x01\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x01\x04\x01\x03\x02\x04\x02\x05\x07\x06\x08\x05\x03\x0c3\x01\x00\x02\x11\x03\x04!\x121\x05AQa\x13"q\x812\x06\x14\x91\xa1\xb1B#$\x15R\xc1b34r\x82\xd1C\x07%\x92S\xf0\xe1\xf1cs5\x16\xa2\xb2\x83&D\x93TdE\xc2\xa3t6\x17\xd2U\xe2e\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\'\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf7\x11\x00\x02\x02\x01\x02\x04\x04\x03\x04\x05\x06\x07\x07\x06\x055\x01\x00\x02\x11\x03!1\x12\x04AQaq"\x13\x052\x81\x91\x14\xa1\xb1B#\xc1R\xd1\xf03$b\xe1r\x82\x92CS\x15cs4\xf1%\x06\x16\xa2\xb2\x83\x07&5\xc2\xd2D\x93T\xa3\x17dEU6te\xe2\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6\'7GWgw\x87\x97\xa7\xb7\xc7\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd0\xf5T\x92I%)$\x92IJI%\xe7\xff\x00Z\x7f\xc6\xbf\xfc\xde\xeb\xb9]\x1f\xf6_\xda~\xcd\xe9\xfe\x9b\xed\x1e\x9e\xefR\xba\xef\xfeo\xec\xf6\xed\xdb\xea\xec\xfeq%>\x80\x92\xf2\xaf\xfc}?\xf3I\xff\x00\xb3_\xfb\xe8\x97\xfe>\x9f\xf9\xa4\xff\x00\xd9\xaf\xfd\xf4IO\xaa\xa4\xbc\xab\xff\x00\x1fO\xfc\xd2\x7f\xec\xd7\xfe\xfa%\xff\x00\x8f\xa7\xfei?\xf6k\xff\x00}\x12S\xea\xa9.+\xeaW\xf8\xc8\xff\x00\x9d}V\xde\x9d\xfb;\xec~\x96;\xb2=O[\xd5\x9d\xaf\xaa\xad\x9b=\n\x7f\xd3}-\xeb\xb5IJI$\x92R\x92I$\x94\xff\x00\xff\xd1\xf5T\x92I%)$\x97\x9f\xff\x00\x8d\x7f\xad=w\xea\xf7\xec\xbf\xd8\xf9_f\xfbO\xda=o\xd1\xd7f\xefO\xec\xfe\x9f\xf3\xf5\xdb\xb7o\xabg\xd0IO\xa0/\x9f\xff\x00\xc6\x97\xfe.\xfa\x9f\xfdc\xff\x00m\xf1\xd2\xff\x00\xc7K\xeb\xdf\xfeY\xff\x00\xe0\x18\xff\x00\xfb\xce\xb9\xfe\xa9\xd53\xfa\xbe}\xbdG\xa8\xdb\xeb\xe5\xdf\xb7\xd4\xb3kY;\x1a\xda\x99\xec\xa9\xac\xaf\xf9\xb63\xf3\x12SU$\x92IJI$\x92S\xdf\xff\x00\x89O\xfcUe\x7f\xe1\x0b?\xf3\xf6*\xf6\xb5\xf3/D\xeb\xfd[\xa0\xe5?3\xa4\xdf\xf6l\x8b+59\xfb\x18\xf9a-\xb1\xcd\xdb{-g\xd3\xa9\x8bk\xff\x00\x1d/\xaf\x7f\xf9g\xff\x00\x80c\xff\x00\xef:J~\x80Iq\xff\x00\xe2\xbf\xaf\xf5n\xbd\xd023:\xb5\xff\x00i\xc8\xaf-\xf55\xfb\x18\xc8`\xae\x8b\x1a\xdd\xb42\xa6};^\xbb\x04\x94\xa4\x92I%?\xff\xd2\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd3\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd4\xf5T\x92I%)q_\xe3#\xeaWU\xfa\xd7\xfb;\xf6u\xb8\xf5}\x8f\xd6\xf5>\xd0\xe7\xb6}_Cf\xcfJ\xab\xbf\xd0\xbfr\xedRIO\x8a\x7f\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*\xe4:\xff\x00D\xca\xe8=Z\xfe\x93\x98\xfa\xec\xc8\xc6\xd9\xbd\xd5\x12Xw\xb1\x97\xb7k\xacmO\xfa\x16\xfe\xe2\xfai|\xff\x00\xfe4\xbf\xf1w\xd4\xff\x00\xeb\x1f\xfbo\x8e\x92\x9eU$\x92IJI$\x92S\xb1\xf5_\xea\xbfP\xfa\xd1\xd4,\xc0\xc0\xb2\x9a\xad\xaa\x93{\x9dys[\xb5\xae\xae\xa2\x01\xaa\xbb\x9d\xbfu\xcd\xfc\xd5\xd3\xff\x00\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*_\xe2S\xff\x00\x15Y_\xf8B\xcf\xfc\xfd\x8a\xbd\xad%<\xbf\xf8\xbc\xfa\xaf\xd4>\xab\xf4[\xb03\xec\xa6\xdbm\xc9u\xedu\x05\xcen\xd7WM@\x13mt\xbb~\xea]\xf9\xab\xa8I$\x94\xa4\x92I%?\xff\xd5\xf5T\x92I%)$\x92IJ\\\x7f_\xff\x00\x15\xfd\x03\xafuk\xfa\xb6fF]y\x19;7\xb6\xa7\xd6\x1861\x947kl\xa2\xd7\xfd\n\xbf}v\t$\xa7\xcf\xff\x00\xf1\x94\xfa\xab\xff\x00r\xb3\xff\x00\xed\xca\x7f\xf7\x95/\xfce>\xaa\xff\x00\xdc\xac\xff\x00\xfbr\x9f\xfd\xe5^\x80\x92J|\xff\x00\xff\x00\x19O\xaa\xbf\xf7+?\xfe\xdc\xa7\xff\x00yR\xff\x00\xc6S\xea\xaf\xfd\xca\xcf\xff\x00\xb7)\xff\x00\xdeU\xe8\t$\xa7\x97\xfa\xaf\xfe/:/\xd5~\xa1f~\x05\xd96\xdbm&\x876\xf7V\xe6\xeds\xab\xb4\x90*\xa6\x97o\xddK\x7f9u\t$\x92\x94\x92I$\xa5$\x92I)\xff\xd6\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd9\x008BIM\x04\x06\x00\x00\x00\x00\x00\x07\x00\x03\x00\x00\x00\x01\x01\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x00\x00\x00\x00\x01\xff\xdb\x00\x84\x00\n\x07\x07\x07\x08\x07\n\x08\x08\n\x0f\n\x08\n\x0f\x12\r\n\n\r\x12\x14\x10\x10\x12\x10\x10\x14\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\x0b\x0c\x0c\x15\x13\x15"\x18\x18"\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\t\x00\t\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x02\xff\xc4\x01\xa2\x00\x00\x00\x07\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x04\x05\x03\x02\x06\x01\x00\x07\x08\t\n\x0b\x01\x00\x02\x02\x03\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x02\x01\x03\x03\x02\x04\x02\x06\x07\x03\x04\x02\x06\x02s\x01\x02\x03\x11\x04\x00\x05!\x121AQ\x06\x13a"q\x81\x142\x91\xa1\x07\x15\xb1B#\xc1R\xd1\xe13\x16b\xf0$r\x82\xf1%C4S\x92\xa2\xb2cs\xc25D\'\x93\xa3\xb36\x17Tdt\xc3\xd2\xe2\x08&\x83\t\n\x18\x19\x84\x94EF\xa4\xb4V\xd3U(\x1a\xf2\xe3\xf3\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5fv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf8)9IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\x11\x00\x02\x02\x01\x02\x03\x05\x05\x04\x05\x06\x04\x08\x03\x03m\x01\x00\x02\x11\x03\x04!\x121A\x05Q\x13a"\x06q\x81\x912\xa1\xb1\xf0\x14\xc1\xd1\xe1#B\x15Rbr\xf13$4C\x82\x16\x92S%\xa2c\xb2\xc2\x07s\xd25\xe2D\x83\x17T\x93\x08\t\n\x18\x19&6E\x1a\'dtU7\xf2\xa3\xb3\xc3()\xd3\xe3\xf3\x84\x94\xa4\xb4\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5FVfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf89IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\x91\xea\xfa\xbf\xe6D_\x99\x16\x96\x16\x16\x8c\xdeWf\x84;\x88U\xa1hY\x7f\xd3\'\x9e\xf3\xedCq\x0bz\xfe\x94^\xbc?\xdc\xdb\xff\x00\xa3\xcd\xeb\x7f\xa4\xaa\xf4\x80\x92\xf2\xaf\xfc}?\xf3I\xff\x00\xb3_\xfb\xe8\x97\xfe>\x9f\xf9\xa4\xff\x00\xd9\xaf\xfd\xf4IO\xaa\xa4\xbc\xab\xff\x00\x1fO\xfc\xd2\x7f\xec\xd7\xfe\xfa%\xff\x00\x8f\xa7\xfei?\xf6k\xff\x00}\x12S\xea\xa9.+\xeaW\xf8\xc8\xff\x00\x9d}V\xde\x9d\xfb;\xec~\x96;\xb2=O[\xd5\x9d\xaf\xaa\xad\x9b=\n\x7f\xd3}-\xeb\xb5IJI$\x92R\x92I$\x94\xff\x00\xff\xd1\xf5T\x92I%)$\x97\x9f\xff\x00\x8d\x7f\xad=w\xea\xf7\xec\xbf\xd8\xf9_f\xfbO\xda=o\xd1\xd7f\xefO\xec\xfe\x9f\xf3\xf5\xdb\xb7o\xabg\xd0IO\xa0/\x9f\xff\x00\xc6\x97\xfe.\xfa\x9f\xfdc\xff\x00m\xf1\xd2\xff\x00\xc7K\xeb\xdf\xfeY\xff\x00\xe0\x18\xff\x00\xfb\xce\xb9\xfe\xa9\xd53\xfa\xbe}\xbdG\xa8\xdb\xeb\xe5\xdf\xb7\xd4\xb3kY;\x1a\xda\x99\xec\xa9\xac\xaf\xf9\xb63\xf3\x12SU$\x92IJI$\x92S\xdf\xff\x00\x89O\xfcUe\x7f\xe1\x0b?\xf3\xf6*\xf6\xb5\xf3/D\xeb\xfd[\xa0\xe5?3\xa4\xdf\xf6l\x8b+59\xfb\x18\xf9a-\xb1\xcd\xdb{-g\xd3\xa9\x8bk\xff\x00\x1d/\xaf\x7f\xf9g\xff\x00\x80c\xff\x00\xef:J~\x80Iq\xff\x00\xe2\xbf\xaf\xf5n\xbd\xd023:\xb5\xff\x00i\xc8\xaf-\xf55\xfb\x18\xc8`\xae\x8b\x1a\xdd\xb42\xa6};^\xbb\x04\x94\xa4\x92I%?\xff\xd2\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd3\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd4\xf5T\x92I%)q_\xe3#\xeaWU\xfa\xd7\xfb;\xf6u\xb8\xf5}\x8f\xd6\xf5>\xd0\xe7\xb6}_Cf\xcfJ\xab\xbf\xd0\xbfr\xedRIO\x8a\x7f\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*\xe4:\xff\x00D\xca\xe8=Z\xfe\x93\x98\xfa\xec\xc8\xc6\xd9\xbd\xd5\x12Xw\xb1\x97\xb7k\xacmO\xfa\x16\xfe\xe2\xfai|\xff\x00\xfe4\xbf\xf1w\xd4\xff\x00\xeb\x1f\xfbo\x8e\x92\x9eU$\x92IJI$\x92S\xb1\xf5_\xea\xbfP\xfa\xd1\xd4,\xc0\xc0\xb2\x9a\xad\xaa\x93{\x9dys[\xb5\xae\xae\xa2\x01\xaa\xbb\x9d\xbfu\xcd\xfc\xd5\xd3\xff\x00\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*_\xe2S\xff\x00\x15Y_\xf8B\xcf\xfc\xfd\x8a\xbd\xad%<\xbf\xf8\xbc\xfa\xaf\xd4>\xab\xf4[\xb03\xec\xa6\xdbm\xc9u\xedu\x05\xcen\xd7WM@\x13mt\xbb~\xea]\xf9\xab\xa8I$\x94\xa4\x92I%?\xff\xd5\xf5T\x92I%)$\x92IJ\\\x7f_\xff\x00\x15\xfd\x03\xafuk\xfa\xb6fF]y\x19;7\xb6\xa7\xd6\x1861\x947kl\xa2\xd7\xfd\n\xbf}v\t$\xa7\xcf\xff\x00\xf1\x94\xfa\xab\xff\x00r\xb3\xff\x00\xed\xca\x7f\xf7\x95/\xfce>\xaa\xff\x00\xdc\xac\xff\x00\xfbr\x9f\xfd\xe5^\x80\x92J|\xff\x00\xff\x00\x19O\xaa\xbf\xf7+?\xfe\xdc\xa7\xff\x00yR\xff\x00\xc6S\xea\xaf\xfd\xca\xcf\xff\x00\xb7)\xff\x00\xdeU\xe8\t$\xa7\x97\xfa\xaf\xfe/:/\xd5~\xa1f~\x05\xd96\xdbm&\x876\xf7V\xe6\xeds\xab\xb4\x90*\xa6\x97o\xddK\x7f9u\t$\x92\x94\x92I$\xa5$\x92I)\xff\xd6\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd9\x008BIM\x04\x06\x00\x00\x00\x00\x00\x07\x00\x03\x00\x00\x00\x01\x01\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x00\x00\x00\x00\x01\xff\xdb\x00\x84\x00\n\x07\x07\x07\x08\x07\n\x08\x08\n\x0f\n\x08\n\x0f\x12\r\n\n\r\x12\x14\x10\x10\x12\x10\x10\x14\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\x0b\x0c\x0c\x15\x13\x15"\x18\x18"\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\t\x00\t\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x02\xff\xc4\x01\xa2\x00\x00\x00\x07\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x04\x05\x03\x02\x06\x01\x00\x07\x08\t\n\x0b\x01\x00\x02\x02\x03\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x02\x01\x03\x03\x02\x04\x02\x06\x07\x03\x04\x02\x06\x02s\x01\x02\x03\x11\x04\x00\x05!\x121AQ\x06\x13a"q\x81\x142\x91\xa1\x07\x15\xb1B#\xc1R\xd1\xe13\x16b\xf0$r\x82\xf1%C4S\x92\xa2\xb2cs\xc25D\'\x93\xa3\xb36\x17Tdt\xc3\xd2\xe2\x08&\x83\t\n\x18\x19\x84\x94EF\xa4\xb4V\xd3U(\x1a\xf2\xe3\xf3\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5fv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf8)9IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\x11\x00\x02\x02\x01\x02\x03\x05\x05\x04\x05\x06\x04\x08\x03\x03m\x01\x00\x02\x11\x03\x04!\x121A\x05Q\x13a"\x06q\x81\x912\xa1\xb1\xf0\x14\xc1\xd1\xe1#B\x15Rbr\xf13$4C\x82\x16\x92S%\xa2c\xb2\xc2\x07s\xd25\xe2D\x83\x17T\x93\x08\t\n\x18\x19&6E\x1a\'dtU7\xf2\xa3\xb3\xc3()\xd3\xe3\xf3\x84\x94\xa4\xb4\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5FVfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf89IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\x91\xea\xfa\xbf\xe6D_\x99\x16\x96\x16\x16\x8c\xdeWf\x84;\x88U\xa1hY\x7f\xd3\'\x9e\xf3\xedCq\x0bz\xfe\x94^\xbc?\xdc\xdb\xff\x00\xa3\xcd\xeb\x7f\xa4\xaa\xf4
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia
-
-
-
-We're rolling "Suicide". Wake up (wake up) Grab a brush and put on a little makeup Hide the scars to fade away the shakeup (hide the scars to fade away the) Why'd you leave the keys upon the table? Here you go, create another fable You wanted to Grab a brush and put a little makeup You wanted to Hide the scars to fade away the shakeup You wanted to Why'd you leave the keys upon the table? You wanted to I don't think you trust In my self-righteous suicide I cry when angels deserve to die Wake up (wake up) Grab a brush and put on a little makeup Hide the scars to fade away the (hide the scars to fade away the) Why'd you leave the keys upon the table? Here you go, create another fable You wanted to Grab a brush and put a little makeup You wanted to Hide the scars to fade away the shakeup You wanted to Why'd you leave the keys upon the table? You wanted to I don't think you trust In my self-righteous suicide I cry when angels deserve to die In my self-righteous suicide I cry when angels deserve to die Father (father) Father (father) Father (father) Father (father) Father, into your hands I commit my spirit Father, into your hands Why have you forsaken me? In your eyes forsaken me In your thoughts forsaken me In your heart forsaken me, oh Trust in my self-righteous suicide I cry when angels deserve to die In my self-righteous suicide I cry when angels deserve to die
-
-
-
-"""
diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py
index 8196d3c09..606720e13 100644
--- a/api/tests/music/test_api.py
+++ b/api/tests/music/test_api.py
@@ -8,34 +8,40 @@ from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers
from funkwhale_api.music import tasks
-from . import data as api_data
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
-def test_can_submit_youtube_url_for_track_import(mocker, superuser_client):
+def test_can_submit_youtube_url_for_track_import(
+ settings, artists, albums, tracks, mocker, superuser_client):
+ mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['adhesive_wombat'])
+ return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
- return_value=api_data.albums['get']['marsupial'])
+ return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.get',
- return_value=api_data.tracks['get']['8bitadventures'])
+ return_value=tracks['get']['8bitadventures'])
mocker.patch(
'funkwhale_api.music.models.TrackFile.download_file',
return_value=None)
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
video_id = 'tPEE9ZwTmy0'
url = reverse('api:v1:submit-single')
+ video_url = 'https://www.youtube.com/watch?v={0}'.format(video_id)
response = superuser_client.post(
url,
- {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id),
+ {'import_url': video_url,
'mbid': mbid})
- track = models.Track.objects.get(mbid=mbid)
- assert track.artist.name == 'Adhesive Wombat'
- assert track.album.title == 'Marsupial Madness'
+
+ assert response.status_code == 201
+ batch = superuser_client.user.imports.latest('id')
+ job = batch.jobs.latest('id')
+ assert job.status == 'pending'
+ assert str(job.mbid) == mbid
+ assert job.source == video_url
def test_import_creates_an_import_with_correct_data(mocker, superuser_client):
@@ -58,17 +64,18 @@ def test_import_creates_an_import_with_correct_data(mocker, superuser_client):
assert job.source == 'https://www.youtube.com/watch?v={0}'.format(video_id)
-def test_can_import_whole_album(mocker, superuser_client):
+def test_can_import_whole_album(
+ artists, albums, mocker, superuser_client):
mocker.patch('funkwhale_api.music.tasks.import_job_run')
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['soad'])
+ return_value=artists['get']['soad'])
mocker.patch(
'funkwhale_api.musicbrainz.api.images.get_front',
return_value=b'')
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
- return_value=api_data.albums['get_with_includes']['hypnotize'])
+ return_value=albums['get_with_includes']['hypnotize'])
payload = {
'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
'tracks': [
@@ -97,7 +104,7 @@ def test_can_import_whole_album(mocker, superuser_client):
album = models.Album.objects.latest('id')
assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94'
- medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
+ medium_data = albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
assert int(medium_data['track-count']) == album.tracks.all().count()
for track in medium_data['track-list']:
@@ -113,17 +120,18 @@ def test_can_import_whole_album(mocker, superuser_client):
assert job.source == row['source']
-def test_can_import_whole_artist(mocker, superuser_client):
+def test_can_import_whole_artist(
+ artists, albums, mocker, superuser_client):
mocker.patch('funkwhale_api.music.tasks.import_job_run')
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['soad'])
+ return_value=artists['get']['soad'])
mocker.patch(
'funkwhale_api.musicbrainz.api.images.get_front',
return_value=b'')
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
- return_value=api_data.albums['get_with_includes']['hypnotize'])
+ return_value=albums['get_with_includes']['hypnotize'])
payload = {
'artistId': 'mbid',
'albums': [
@@ -157,7 +165,7 @@ def test_can_import_whole_artist(mocker, superuser_client):
album = models.Album.objects.latest('id')
assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94'
- medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
+ medium_data = albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
assert int(medium_data['track-count']) == album.tracks.all().count()
for track in medium_data['track-list']:
@@ -173,55 +181,57 @@ def test_can_import_whole_artist(mocker, superuser_client):
assert job.source == row['source']
-def test_user_can_query_api_for_his_own_batches(client, factories):
- user1 = factories['users.SuperUser']()
- user2 = factories['users.SuperUser']()
-
- job = factories['music.ImportJob'](batch__submitted_by=user1)
+def test_user_can_query_api_for_his_own_batches(
+ superuser_api_client, factories):
+ factories['music.ImportJob']()
+ job = factories['music.ImportJob'](
+ batch__submitted_by=superuser_api_client.user)
url = reverse('api:v1:import-batches-list')
- client.login(username=user2.username, password='test')
- response2 = client.get(url)
- results = json.loads(response2.content.decode('utf-8'))
- assert results['count'] == 0
- client.logout()
-
- client.login(username=user1.username, password='test')
- response1 = client.get(url)
- results = json.loads(response1.content.decode('utf-8'))
+ response = superuser_api_client.get(url)
+ results = response.data
assert results['count'] == 1
assert results['results'][0]['jobs'][0]['mbid'] == job.mbid
-def test_user_can_create_an_empty_batch(client, factories):
- user = factories['users.SuperUser']()
+def test_user_cannnot_access_other_batches(
+ superuser_api_client, factories):
+ factories['music.ImportJob']()
+ job = factories['music.ImportJob']()
url = reverse('api:v1:import-batches-list')
- client.login(username=user.username, password='test')
- response = client.post(url)
+
+ response = superuser_api_client.get(url)
+ results = response.data
+ assert results['count'] == 0
+
+
+def test_user_can_create_an_empty_batch(superuser_api_client, factories):
+ url = reverse('api:v1:import-batches-list')
+ response = superuser_api_client.post(url)
assert response.status_code == 201
- batch = user.imports.latest('id')
+ batch = superuser_api_client.user.imports.latest('id')
- assert batch.submitted_by == user
+ assert batch.submitted_by == superuser_api_client.user
assert batch.source == 'api'
-def test_user_can_create_import_job_with_file(client, factories, mocker):
+def test_user_can_create_import_job_with_file(
+ superuser_api_client, factories, mocker):
path = os.path.join(DATA_DIR, 'test.ogg')
m = mocker.patch('funkwhale_api.common.utils.on_commit')
- user = factories['users.SuperUser']()
- batch = factories['music.ImportBatch'](submitted_by=user)
+ batch = factories['music.ImportBatch'](
+ submitted_by=superuser_api_client.user)
url = reverse('api:v1:import-jobs-list')
- client.login(username=user.username, password='test')
with open(path, 'rb') as f:
content = f.read()
f.seek(0)
- response = client.post(url, {
+ response = superuser_api_client.post(url, {
'batch': batch.pk,
'audio_file': f,
'source': 'file://'
- }, format='multipart')
+ })
assert response.status_code == 201
@@ -237,16 +247,16 @@ def test_user_can_create_import_job_with_file(client, factories, mocker):
import_job_id=job.pk)
-def test_can_search_artist(factories, client):
+def test_can_search_artist(factories, logged_in_client):
artist1 = factories['music.Artist']()
artist2 = factories['music.Artist']()
expected = [serializers.ArtistSerializerNested(artist1).data]
url = reverse('api:v1:artists-search')
- response = client.get(url, {'query': artist1.name})
- assert json.loads(response.content.decode('utf-8')) == expected
+ response = logged_in_client.get(url, {'query': artist1.name})
+ assert response.data == expected
-def test_can_search_artist_by_name_start(factories, client):
+def test_can_search_artist_by_name_start(factories, logged_in_client):
artist1 = factories['music.Artist'](name='alpha')
artist2 = factories['music.Artist'](name='beta')
expected = {
@@ -256,20 +266,20 @@ def test_can_search_artist_by_name_start(factories, client):
'results': [serializers.ArtistSerializerNested(artist1).data]
}
url = reverse('api:v1:artists-list')
- response = client.get(url, {'name__startswith': 'a'})
+ response = logged_in_client.get(url, {'name__startswith': 'a'})
- assert expected == json.loads(response.content.decode('utf-8'))
+ assert expected == response.data
-def test_can_search_tracks(factories, client):
+def test_can_search_tracks(factories, logged_in_client):
track1 = factories['music.Track'](title="test track 1")
track2 = factories['music.Track']()
query = 'test track 1'
expected = [serializers.TrackSerializerNested(track1).data]
url = reverse('api:v1:tracks-search')
- response = client.get(url, {'query': query})
+ response = logged_in_client.get(url, {'query': query})
- assert expected == json.loads(response.content.decode('utf-8'))
+ assert expected == response.data
@pytest.mark.parametrize('route,method', [
@@ -278,24 +288,31 @@ def test_can_search_tracks(factories, client):
('api:v1:artists-list', 'get'),
('api:v1:albums-list', 'get'),
])
-def test_can_restrict_api_views_to_authenticated_users(db, route, method, settings, client):
+def test_can_restrict_api_views_to_authenticated_users(
+ db, route, method, settings, client):
url = reverse(route)
settings.API_AUTHENTICATION_REQUIRED = True
response = getattr(client, method)(url)
assert response.status_code == 401
-def test_track_file_url_is_restricted_to_authenticated_users(client, factories, settings):
+def test_track_file_url_is_restricted_to_authenticated_users(
+ api_client, factories, settings):
settings.API_AUTHENTICATION_REQUIRED = True
f = factories['music.TrackFile']()
assert f.audio_file is not None
url = f.path
- response = client.get(url)
+ response = api_client.get(url)
assert response.status_code == 401
- user = factories['users.SuperUser']()
- client.login(username=user.username, password='test')
- response = client.get(url)
+
+def test_track_file_url_is_accessible_to_authenticated_users(
+ logged_in_api_client, factories, settings):
+ settings.API_AUTHENTICATION_REQUIRED = True
+ f = factories['music.TrackFile']()
+ assert f.audio_file is not None
+ url = f.path
+ response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response['X-Accel-Redirect'] == '/_protected{}'.format(f.audio_file.url)
diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py
index f2ca1abbd..0f709e81f 100644
--- a/api/tests/music/test_import.py
+++ b/api/tests/music/test_import.py
@@ -2,23 +2,21 @@ import json
from django.urls import reverse
-from . import data as api_data
-
def test_create_import_can_bind_to_request(
- mocker, factories, superuser_api_client):
+ artists, albums, mocker, factories, superuser_api_client):
request = factories['requests.ImportRequest']()
mocker.patch('funkwhale_api.music.tasks.import_job_run')
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['soad'])
+ return_value=artists['get']['soad'])
mocker.patch(
'funkwhale_api.musicbrainz.api.images.get_front',
return_value=b'')
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
- return_value=api_data.albums['get_with_includes']['hypnotize'])
+ return_value=albums['get_with_includes']['hypnotize'])
payload = {
'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
'importRequest': request.pk,
diff --git a/api/tests/music/test_lyrics.py b/api/tests/music/test_lyrics.py
index d10d113d7..3aee368c0 100644
--- a/api/tests/music/test_lyrics.py
+++ b/api/tests/music/test_lyrics.py
@@ -7,15 +7,12 @@ from funkwhale_api.music import serializers
from funkwhale_api.music import tasks
from funkwhale_api.music import lyrics as lyrics_utils
-from .mocking import lyricswiki
-from . import data as api_data
-
-
-def test_works_import_lyrics_if_any(mocker, factories):
+def test_works_import_lyrics_if_any(
+ lyricswiki_content, mocker, factories):
mocker.patch(
'funkwhale_api.music.lyrics._get_html',
- return_value=lyricswiki.content)
+ return_value=lyricswiki_content)
lyrics = factories['music.Lyrics'](
url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
@@ -48,16 +45,22 @@ Is it me you're looking for?"""
assert expected == l.content_rendered
-def test_works_import_lyrics_if_any(mocker, factories, logged_in_client):
+def test_works_import_lyrics_if_any(
+ lyricswiki_content,
+ works,
+ tracks,
+ mocker,
+ factories,
+ logged_in_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.works.get',
- return_value=api_data.works['get']['chop_suey'])
+ return_value=works['get']['chop_suey'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.get',
- return_value=api_data.tracks['get']['chop_suey'])
+ return_value=tracks['get']['chop_suey'])
mocker.patch(
'funkwhale_api.music.lyrics._get_html',
- return_value=lyricswiki.content)
+ return_value=lyricswiki_content)
track = factories['music.Track'](
work=None,
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
diff --git a/api/tests/music/test_music.py b/api/tests/music/test_music.py
index 076ad2bd0..4162912e4 100644
--- a/api/tests/music/test_music.py
+++ b/api/tests/music/test_music.py
@@ -2,14 +2,11 @@ import pytest
from funkwhale_api.music import models
import datetime
-from . import data as api_data
-from .cover import binary_data
-
-def test_can_create_artist_from_api(mocker, db):
+def test_can_create_artist_from_api(artists, mocker, db):
mocker.patch(
'musicbrainzngs.search_artists',
- return_value=api_data.artists['search']['adhesive_wombat'])
+ return_value=artists['search']['adhesive_wombat'])
artist = models.Artist.create_from_api(query="Adhesive wombat")
data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0]
@@ -19,13 +16,13 @@ def test_can_create_artist_from_api(mocker, db):
assert artist.name, 'Adhesive Wombat'
-def test_can_create_album_from_api(mocker, db):
+def test_can_create_album_from_api(artists, albums, mocker, db):
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.search',
- return_value=api_data.albums['search']['hypnotize'])
+ return_value=albums['search']['hypnotize'])
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['soad'])
+ return_value=artists['get']['soad'])
album = models.Album.create_from_api(query="Hypnotize", artist='system of a down', type='album')
data = models.Album.api.search(query='Hypnotize', artist='system of a down', type='album')['release-list'][0]
@@ -38,16 +35,16 @@ def test_can_create_album_from_api(mocker, db):
assert album.artist.mbid, data['artist-credit'][0]['artist']['id']
-def test_can_create_track_from_api(mocker, db):
+def test_can_create_track_from_api(artists, albums, tracks, mocker, db):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['adhesive_wombat'])
+ return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
- return_value=api_data.albums['get']['marsupial'])
+ return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
- return_value=api_data.tracks['search']['8bitadventures'])
+ return_value=tracks['search']['8bitadventures'])
track = models.Track.create_from_api(query="8-bit adventure")
data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
assert int(data['ext:score']) == 100
@@ -60,16 +57,17 @@ def test_can_create_track_from_api(mocker, db):
assert track.album.title == 'Marsupial Madness'
-def test_can_create_track_from_api_with_corresponding_tags(mocker, db):
+def test_can_create_track_from_api_with_corresponding_tags(
+ artists, albums, tracks, mocker, db):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['adhesive_wombat'])
+ return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
- return_value=api_data.albums['get']['marsupial'])
+ return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.get',
- return_value=api_data.tracks['get']['8bitadventures'])
+ return_value=tracks['get']['8bitadventures'])
track = models.Track.create_from_api(id='9968a9d6-8d92-4051-8f76-674e157b6eed')
expected_tags = ['techno', 'good-music']
track_tags = [tag.slug for tag in track.tags.all()]
@@ -77,16 +75,17 @@ def test_can_create_track_from_api_with_corresponding_tags(mocker, db):
assert tag in track_tags
-def test_can_get_or_create_track_from_api(mocker, db):
+def test_can_get_or_create_track_from_api(
+ artists, albums, tracks, mocker, db):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['adhesive_wombat'])
+ return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
- return_value=api_data.albums['get']['marsupial'])
+ return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
- return_value=api_data.tracks['search']['8bitadventures'])
+ return_value=tracks['search']['8bitadventures'])
track = models.Track.create_from_api(query="8-bit adventure")
data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
assert int(data['ext:score']) == 100
@@ -126,13 +125,13 @@ def test_artist_tags_deduced_from_album_tags(factories, django_assert_num_querie
assert tag in artist.tags
-def test_can_download_image_file_for_album(mocker, factories):
+def test_can_download_image_file_for_album(binary_cover, mocker, factories):
mocker.patch(
'funkwhale_api.musicbrainz.api.images.get_front',
- return_value=binary_data)
+ return_value=binary_cover)
# client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed')
album = factories['music.Album'](mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed')
album.get_image()
album.save()
- assert album.cover.file.read() == binary_data
+ assert album.cover.file.read() == binary_cover
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index 5ecf9b9e4..ddbc4ba9a 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -4,8 +4,6 @@ import pytest
from funkwhale_api.providers.acoustid import get_acoustid_client
from funkwhale_api.music import tasks
-from . import data as api_data
-
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -50,7 +48,7 @@ def test_set_acoustid_on_track_file_required_high_score(factories, mocker):
def test_import_job_can_run_with_file_and_acoustid(
- preferences, factories, mocker):
+ artists, albums, tracks, preferences, factories, mocker):
preferences['providers_acoustid__api_key'] = 'test'
path = os.path.join(DATA_DIR, 'test.ogg')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
@@ -66,13 +64,13 @@ def test_import_job_can_run_with_file_and_acoustid(
}
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['adhesive_wombat'])
+ return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
- return_value=api_data.albums['get']['marsupial'])
+ return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
- return_value=api_data.tracks['search']['8bitadventures'])
+ return_value=tracks['search']['8bitadventures'])
mocker.patch('acoustid.match', return_value=acoustid_payload)
job = factories['music.FileImportJob'](audio_file__path=path)
@@ -129,7 +127,8 @@ def test__do_import_skipping_accoustid_if_no_key(
m.assert_called_once_with(p)
-def test_import_job_can_be_skipped(factories, mocker, preferences):
+def test_import_job_can_be_skipped(
+ artists, albums, tracks, factories, mocker, preferences):
preferences['providers_acoustid__api_key'] = 'test'
path = os.path.join(DATA_DIR, 'test.ogg')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
@@ -146,13 +145,13 @@ def test_import_job_can_be_skipped(factories, mocker, preferences):
}
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['adhesive_wombat'])
+ return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
- return_value=api_data.albums['get']['marsupial'])
+ return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
- return_value=api_data.tracks['search']['8bitadventures'])
+ return_value=tracks['search']['8bitadventures'])
mocker.patch('acoustid.match', return_value=acoustid_payload)
job = factories['music.FileImportJob'](audio_file__path=path)
diff --git a/api/tests/music/test_works.py b/api/tests/music/test_works.py
index 9b72768ad..13f6447be 100644
--- a/api/tests/music/test_works.py
+++ b/api/tests/music/test_works.py
@@ -5,13 +5,11 @@ from funkwhale_api.music import models
from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers
-from . import data as api_data
-
-def test_can_import_work(factories, mocker):
+def test_can_import_work(factories, mocker, works):
mocker.patch(
'funkwhale_api.musicbrainz.api.works.get',
- return_value=api_data.works['get']['chop_suey'])
+ return_value=works['get']['chop_suey'])
recording = factories['music.Track'](
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
@@ -28,13 +26,13 @@ def test_can_import_work(factories, mocker):
assert recording.work == work
-def test_can_get_work_from_recording(factories, mocker):
+def test_can_get_work_from_recording(factories, mocker, works, tracks):
mocker.patch(
'funkwhale_api.musicbrainz.api.works.get',
- return_value=api_data.works['get']['chop_suey'])
+ return_value=works['get']['chop_suey'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.get',
- return_value=api_data.tracks['get']['chop_suey'])
+ return_value=tracks['get']['chop_suey'])
recording = factories['music.Track'](
work=None,
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
@@ -53,10 +51,10 @@ def test_can_get_work_from_recording(factories, mocker):
assert recording.work == work
-def test_works_import_lyrics_if_any(db, mocker):
+def test_works_import_lyrics_if_any(db, mocker, works):
mocker.patch(
'funkwhale_api.musicbrainz.api.works.get',
- return_value=api_data.works['get']['chop_suey'])
+ return_value=works['get']['chop_suey'])
mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
work = models.Work.create_from_api(id=mbid)
diff --git a/api/tests/musicbrainz/data.py b/api/tests/musicbrainz/conftest.py
similarity index 96%
rename from api/tests/musicbrainz/data.py
rename to api/tests/musicbrainz/conftest.py
index 1d7b9a3de..505d6e553 100644
--- a/api/tests/musicbrainz/data.py
+++ b/api/tests/musicbrainz/conftest.py
@@ -1,5 +1,7 @@
-artists = {'search': {}, 'get': {}}
-artists['search']['lost fingers'] = {
+import pytest
+
+_artists = {'search': {}, 'get': {}}
+_artists['search']['lost fingers'] = {
'artist-count': 696,
'artist-list': [
{
@@ -21,7 +23,7 @@ artists['search']['lost fingers'] = {
},
]
}
-artists['get']['lost fingers'] = {
+_artists['get']['lost fingers'] = {
"artist": {
"life-span": {
"begin": "2008"
@@ -102,8 +104,8 @@ artists['get']['lost fingers'] = {
}
-release_groups = {'browse': {}}
-release_groups['browse']["lost fingers"] = {
+_release_groups = {'browse': {}}
+_release_groups['browse']["lost fingers"] = {
"release-group-list": [
{
"first-release-date": "2010",
@@ -165,8 +167,8 @@ release_groups['browse']["lost fingers"] = {
"release-group-count": 8
}
-recordings = {'search': {}, 'get': {}}
-recordings['search']['brontide matador'] = {
+_recordings = {'search': {}, 'get': {}}
+_recordings['search']['brontide matador'] = {
"recording-count": 1044,
"recording-list": [
{
@@ -217,8 +219,8 @@ recordings['search']['brontide matador'] = {
]
}
-releases = {'search': {}, 'get': {}, 'browse': {}}
-releases['search']['brontide matador'] = {
+_releases = {'search': {}, 'get': {}, 'browse': {}}
+_releases['search']['brontide matador'] = {
"release-count": 116, "release-list": [
{
"ext:score": "100",
@@ -283,7 +285,7 @@ releases['search']['brontide matador'] = {
]
}
-releases['browse']['Lost in the 80s'] = {
+_releases['browse']['Lost in the 80s'] = {
"release-count": 3,
"release-list": [
{
@@ -476,3 +478,23 @@ releases['browse']['Lost in the 80s'] = {
},
]
}
+
+
+@pytest.fixture()
+def releases():
+ return _releases
+
+
+@pytest.fixture()
+def release_groups():
+ return _release_groups
+
+
+@pytest.fixture()
+def artists():
+ return _artists
+
+
+@pytest.fixture()
+def recordings():
+ return _recordings
diff --git a/api/tests/musicbrainz/test_api.py b/api/tests/musicbrainz/test_api.py
index bbade3400..fdd1dbdb0 100644
--- a/api/tests/musicbrainz/test_api.py
+++ b/api/tests/musicbrainz/test_api.py
@@ -2,64 +2,65 @@ import json
from django.urls import reverse
from funkwhale_api.musicbrainz import api
-from . import data as api_data
-def test_can_search_recording_in_musicbrainz_api(db, mocker, client):
+def test_can_search_recording_in_musicbrainz_api(
+ recordings, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
- return_value=api_data.recordings['search']['brontide matador'])
+ return_value=recordings['search']['brontide matador'])
query = 'brontide matador'
url = reverse('api:v1:providers:musicbrainz:search-recordings')
- expected = api_data.recordings['search']['brontide matador']
- response = client.get(url, data={'query': query})
+ expected = recordings['search']['brontide matador']
+ response = logged_in_api_client.get(url, data={'query': query})
- assert expected == json.loads(response.content.decode('utf-8'))
+ assert expected == response.data
-def test_can_search_release_in_musicbrainz_api(db, mocker, client):
+def test_can_search_release_in_musicbrainz_api(releases, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.search',
- return_value=api_data.releases['search']['brontide matador'])
+ return_value=releases['search']['brontide matador'])
query = 'brontide matador'
url = reverse('api:v1:providers:musicbrainz:search-releases')
- expected = api_data.releases['search']['brontide matador']
- response = client.get(url, data={'query': query})
+ expected = releases['search']['brontide matador']
+ response = logged_in_api_client.get(url, data={'query': query})
- assert expected == json.loads(response.content.decode('utf-8'))
+ assert expected == response.data
-def test_can_search_artists_in_musicbrainz_api(db, mocker, client):
+def test_can_search_artists_in_musicbrainz_api(artists, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.search',
- return_value=api_data.artists['search']['lost fingers'])
+ return_value=artists['search']['lost fingers'])
query = 'lost fingers'
url = reverse('api:v1:providers:musicbrainz:search-artists')
- expected = api_data.artists['search']['lost fingers']
- response = client.get(url, data={'query': query})
+ expected = artists['search']['lost fingers']
+ response = logged_in_api_client.get(url, data={'query': query})
- assert expected == json.loads(response.content.decode('utf-8'))
+ assert expected == response.data
-def test_can_get_artist_in_musicbrainz_api(db, mocker, client):
+def test_can_get_artist_in_musicbrainz_api(artists, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
- return_value=api_data.artists['get']['lost fingers'])
+ return_value=artists['get']['lost fingers'])
uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
url = reverse('api:v1:providers:musicbrainz:artist-detail', kwargs={
'uuid': uuid,
})
- response = client.get(url)
- expected = api_data.artists['get']['lost fingers']
+ response = logged_in_api_client.get(url)
+ expected = artists['get']['lost fingers']
- assert expected == json.loads(response.content.decode('utf-8'))
+ assert expected == response.data
-def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client):
+def test_can_broswe_release_group_using_musicbrainz_api(
+ release_groups, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.release_groups.browse',
- return_value=api_data.release_groups['browse']['lost fingers'])
+ return_value=release_groups['browse']['lost fingers'])
uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
url = reverse(
'api:v1:providers:musicbrainz:release-group-browse',
@@ -67,16 +68,17 @@ def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client):
'artist_uuid': uuid,
}
)
- response = client.get(url)
- expected = api_data.release_groups['browse']['lost fingers']
+ response = logged_in_api_client.get(url)
+ expected = release_groups['browse']['lost fingers']
- assert expected == json.loads(response.content.decode('utf-8'))
+ assert expected == response.data
-def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client):
+def test_can_broswe_releases_using_musicbrainz_api(
+ releases, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.browse',
- return_value=api_data.releases['browse']['Lost in the 80s'])
+ return_value=releases['browse']['Lost in the 80s'])
uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1'
url = reverse(
'api:v1:providers:musicbrainz:release-browse',
@@ -84,7 +86,7 @@ def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client):
'release_group_uuid': uuid,
}
)
- response = client.get(url)
- expected = api_data.releases['browse']['Lost in the 80s']
+ response = logged_in_api_client.get(url)
+ expected = releases['browse']['Lost in the 80s']
- assert expected == json.loads(response.content.decode('utf-8'))
+ assert expected == response.data
diff --git a/api/tests/playlists/__init__.py b/api/tests/playlists/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py
index 5bf834888..f0fb6d0fd 100644
--- a/api/tests/playlists/test_views.py
+++ b/api/tests/playlists/test_views.py
@@ -106,7 +106,9 @@ def test_deleting_plt_updates_indexes(
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
-def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
+def test_playlist_privacy_respected_in_list_anon(
+ settings, level, factories, api_client):
+ settings.API_AUTHENTICATION_REQUIRED = False
factories['playlists.Playlist'](privacy_level=level)
url = reverse('api:v1:playlists-list')
response = api_client.get(url)
@@ -115,26 +117,28 @@ def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
-def test_only_owner_can_edit_playlist(method, factories, api_client):
+def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client):
playlist = factories['playlists.Playlist']()
url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
- response = api_client.get(url)
+ response = getattr(logged_in_api_client, method.lower())(url)
assert response.status_code == 404
@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
-def test_only_owner_can_edit_playlist_track(method, factories, api_client):
+def test_only_owner_can_edit_playlist_track(
+ method, factories, logged_in_api_client):
plt = factories['playlists.PlaylistTrack']()
url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
- response = api_client.get(url)
+ response = getattr(logged_in_api_client, method.lower())(url)
assert response.status_code == 404
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
def test_playlist_track_privacy_respected_in_list_anon(
- level, factories, api_client):
+ level, factories, api_client, settings):
+ settings.API_AUTHENTICATION_REQUIRED = False
factories['playlists.PlaylistTrack'](playlist__privacy_level=level)
url = reverse('api:v1:playlist-tracks-list')
response = api_client.get(url)
diff --git a/api/tests/radios/__init__.py b/api/tests/radios/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py
index b731e3024..c8038a4db 100644
--- a/api/tests/radios/test_radios.py
+++ b/api/tests/radios/test_radios.py
@@ -151,14 +151,18 @@ def test_can_start_radio_for_logged_in_user(logged_in_client):
assert session.user == logged_in_client.user
-def test_can_start_radio_for_anonymous_user(client, db):
+def test_can_start_radio_for_anonymous_user(api_client, db, settings):
+ settings.API_AUTHENTICATION_REQUIRED = False
url = reverse('api:v1:radios:sessions-list')
- response = client.post(url, {'radio_type': 'random'})
+ response = api_client.post(url, {'radio_type': 'random'})
+
+ assert response.status_code == 201
+
session = models.RadioSession.objects.latest('id')
assert session.radio_type == 'random'
assert session.user is None
- assert session.session_key == client.session.session_key
+ assert session.session_key == api_client.session.session_key
def test_can_get_track_for_session_from_api(factories, logged_in_client):
@@ -228,13 +232,18 @@ def test_can_start_tag_radio(factories):
assert radio.pick() in good_tracks
-def test_can_start_artist_radio_from_api(client, factories):
+def test_can_start_artist_radio_from_api(api_client, settings, factories):
+ settings.API_AUTHENTICATION_REQUIRED = False
artist = factories['music.Artist']()
url = reverse('api:v1:radios:sessions-list')
- response = client.post(
+ response = api_client.post(
url, {'radio_type': 'artist', 'related_object_id': artist.id})
+
+ assert response.status_code == 201
+
session = models.RadioSession.objects.latest('id')
+
assert session.radio_type, 'artist'
assert session.related_object, artist
diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py
index 4f3de27db..67263e66d 100644
--- a/api/tests/test_import_audio_file.py
+++ b/api/tests/test_import_audio_file.py
@@ -98,3 +98,27 @@ def test_import_files_skip_acoustid(factories, mocker):
music_tasks.import_job_run.delay,
import_job_id=job.pk,
use_acoustid=False)
+
+
+def test_import_files_works_with_utf8_file_name(factories, mocker):
+ m = mocker.patch('funkwhale_api.common.utils.on_commit')
+ user = factories['users.User'](username='me')
+ path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg')
+ call_command(
+ 'import_files',
+ path,
+ username='me',
+ async=True,
+ no_acoustid=True,
+ interactive=False)
+ batch = user.imports.latest('id')
+ job = batch.jobs.first()
+ m.assert_called_once_with(
+ music_tasks.import_job_run.delay,
+ import_job_id=job.pk,
+ use_acoustid=False)
+
+
+def test_storage_rename_utf_8_files(factories):
+ tf = factories['music.TrackFile'](audio_file__filename='été.ogg')
+ assert tf.audio_file.name.endswith('ete.ogg')
diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py
index 017d742ef..441179095 100644
--- a/api/tests/test_youtube.py
+++ b/api/tests/test_youtube.py
@@ -17,13 +17,15 @@ def test_can_get_search_results_from_youtube(mocker):
assert results[0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo'
-def test_can_get_search_results_from_funkwhale(mocker, client, db):
+def test_can_get_search_results_from_funkwhale(
+ settings, mocker, api_client, db):
+ settings.API_AUTHENTICATION_REQUIRED = False
mocker.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'])
query = '8 bit adventure'
url = reverse('api:v1:providers:youtube:search')
- response = client.get(url, {'query': query})
+ response = api_client.get(url, {'query': query})
# we should cast the youtube result to something more generic
expected = {
"id": "0HxZn6CzOIo",
@@ -37,7 +39,7 @@ def test_can_get_search_results_from_funkwhale(mocker, client, db):
"cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
}
- assert json.loads(response.content.decode('utf-8'))[0] == expected
+ assert response.data[0] == expected
def test_can_send_multiple_queries_at_once(mocker):
@@ -67,7 +69,9 @@ def test_can_send_multiple_queries_at_once(mocker):
assert results['2'][0]['full_url'] == 'https://www.youtube.com/watch?v=BorYwGi2SJc'
-def test_can_send_multiple_queries_at_once_from_funwkhale(mocker, db, client):
+def test_can_send_multiple_queries_at_once_from_funwkhale(
+ settings, mocker, db, api_client):
+ settings.API_AUTHENTICATION_REQUIRED = False
mocker.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'])
@@ -89,7 +93,6 @@ def test_can_send_multiple_queries_at_once_from_funwkhale(mocker, db, client):
}
url = reverse('api:v1:providers:youtube:searchs')
- response = client.post(
- url, json.dumps(queries), content_type='application/json')
+ response = api_client.post(url, queries, format='json')
- assert expected == json.loads(response.content.decode('utf-8'))['1'][0]
+ assert expected == response.data['1'][0]
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 02b903bf4..4be586965 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -23,6 +23,23 @@ def test_can_create_user_via_api(preferences, client, db):
assert u.username == 'test1'
+def test_can_restrict_usernames(settings, preferences, db, client):
+ url = reverse('rest_register')
+ preferences['users__registration_enabled'] = True
+ settings.USERNAME_BLACKLIST = ['funkwhale']
+ data = {
+ 'username': 'funkwhale',
+ 'email': 'contact@funkwhale.io',
+ 'password1': 'testtest',
+ 'password2': 'testtest',
+ }
+
+ response = client.post(url, data)
+
+ assert response.status_code == 400
+ assert 'username' in response.data
+
+
def test_can_disable_registration_view(preferences, client, db):
url = reverse('rest_register')
data = {
diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample
index e1a381b94..a016b34c7 100644
--- a/deploy/env.prod.sample
+++ b/deploy/env.prod.sample
@@ -4,12 +4,13 @@
# - DJANGO_ALLOWED_HOSTS
# - FUNKWHALE_URL
-# Additionaly, on non-docker setup, you'll also have to tweak/uncomment those
-# variables:
+# Additionaly, on non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# - DATABASE_URL
# - CACHE_URL
# - STATIC_ROOT
# - MEDIA_ROOT
+#
+# You **don't** need to update those variables on pure docker setups.
# Docker only
# -----------
diff --git a/deploy/funkwhale_proxy.conf b/deploy/funkwhale_proxy.conf
index 1b1dd0d20..312986f43 100644
--- a/deploy/funkwhale_proxy.conf
+++ b/deploy/funkwhale_proxy.conf
@@ -3,8 +3,8 @@ proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
-proxy_set_header X-Forwarded-Host $host:$server_port;
-proxy_set_header X-Forwarded-Port $server_port;
+proxy_set_header X-Forwarded-Host $host:$server_port;
+proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
# websocket support
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index 1c7b9ae83..1c304b493 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -62,6 +62,16 @@ server {
proxy_pass http://funkwhale-api/api/;
}
+ location /federation/ {
+ include /etc/nginx/funkwhale_proxy.conf;
+ proxy_pass http://funkwhale-api/federation/;
+ }
+
+ location /.well-known/webfinger {
+ include /etc/nginx/funkwhale_proxy.conf;
+ proxy_pass http://funkwhale-api/.well-known/webfinger;
+ }
+
location /media/ {
alias /srv/funkwhale/data/media/;
}
diff --git a/dev.yml b/dev.yml
index 8d2129bef..c0470a2ab 100644
--- a/dev.yml
+++ b/dev.yml
@@ -1,27 +1,35 @@
-version: '2'
+version: '3'
services:
-
front:
build: front
- env_file: .env.dev
+ env_file:
+ - .env.dev
+ - .env
environment:
- "HOST=0.0.0.0"
+ - "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
ports:
- - "8080:8080"
+ - "${WEBPACK_DEVSERVER_PORT-8080}:${WEBPACK_DEVSERVER_PORT-8080}"
volumes:
- './front:/app'
postgres:
- env_file: .env.dev
+ env_file:
+ - .env.dev
+ - .env
image: postgres
redis:
- env_file: .env.dev
+ env_file:
+ - .env.dev
+ - .env
image: redis:3.0
celeryworker:
- env_file: .env.dev
+ env_file:
+ - .env.dev
+ - .env
build:
context: ./api
dockerfile: docker/Dockerfile.test
@@ -30,18 +38,15 @@ services:
- redis
command: celery -A funkwhale_api.taskapp worker -l debug
environment:
- - "DJANGO_ALLOWED_HOSTS=localhost"
- - "DJANGO_SETTINGS_MODULE=config.settings.local"
- - "DJANGO_SECRET_KEY=dev"
- - C_FORCE_ROOT=true
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
- - "FUNKWHALE_URL=http://funkwhale.test"
volumes:
- ./api:/app
- ./data/music:/music
api:
- env_file: .env.dev
+ env_file:
+ - .env.dev
+ - .env
build:
context: ./api
dockerfile: docker/Dockerfile.test
@@ -50,24 +55,27 @@ services:
- ./api:/app
- ./data/music:/music
environment:
- - "DJANGO_ALLOWED_HOSTS=localhost,nginx"
- - "DJANGO_SETTINGS_MODULE=config.settings.local"
- - "DJANGO_SECRET_KEY=dev"
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
- - "FUNKWHALE_URL=http://funkwhale.test"
links:
- postgres
- redis
nginx:
- env_file: .env.dev
+ command: /entrypoint.sh
+ env_file:
+ - .env.dev
+ - .env
image: nginx
+ environment:
+ - "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
links:
- api
- front
volumes:
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
+ - ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
+ - ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
- ./api/funkwhale_api/media:/protected/media
ports:
- "0.0.0.0:6001:6001"
diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev
index 9847c2dcb..e832a5ae3 100644
--- a/docker/nginx/conf.dev
+++ b/docker/nginx/conf.dev
@@ -37,19 +37,7 @@ http {
listen 6001;
charset utf-8;
client_max_body_size 20M;
-
- # global proxy pass config
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-Host localhost:8080;
- proxy_set_header X-Forwarded-Port 8080;
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection $connection_upgrade;
- proxy_redirect off;
-
+ include /etc/nginx/funkwhale_proxy.conf;
location /_protected/media {
internal;
alias /protected/media;
@@ -63,8 +51,7 @@ http {
if ($request_uri ~* "[^\?]+\?(.*)$") {
set $query $1;
}
- proxy_set_header X-Forwarded-Host localhost:8080;
- proxy_set_header X-Forwarded-Port 8080;
+ include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
@@ -78,6 +65,7 @@ http {
if ($args ~ (.*)jwt=[^&]*(.*)) {
set $cleaned_args $1$2;
}
+ include /etc/nginx/funkwhale_proxy.conf;
proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args";
proxy_cache transcode;
proxy_cache_valid 200 7d;
@@ -87,6 +75,7 @@ http {
proxy_pass http://api:12081;
}
location / {
+ include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://api:12081/;
}
}
diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh
new file mode 100755
index 000000000..1819acf1c
--- /dev/null
+++ b/docker/nginx/entrypoint.sh
@@ -0,0 +1,10 @@
+#!/bin/bash -eux
+FIRST_HOST=$(echo ${DJANGO_ALLOWED_HOSTS} | cut -d, -f1)
+echo "Copying template file..."
+cp /etc/nginx/funkwhale_proxy.conf{.template,}
+sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FIRST_HOST}:${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
+sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FIRST_HOST}/" /etc/nginx/funkwhale_proxy.conf
+sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
+
+cat /etc/nginx/funkwhale_proxy.conf
+nginx -g "daemon off;"
diff --git a/docs/index.rst b/docs/index.rst
index 17e9fe7f0..f5acecce5 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -15,6 +15,7 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
installation/index
configuration
importing-music
+ upgrading
changelog
Indices and tables
diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst
index 86ccb4dd3..c4e54218d 100644
--- a/docs/installation/debian.rst
+++ b/docs/installation/debian.rst
@@ -89,7 +89,7 @@ First, we'll download the latest api release.
curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/|version|/download?job=build_api"
unzip "api-|version|.zip" -d extracted
- mv extracted/api api
+ mv extracted/api/* api/
rmdir extracted
@@ -100,7 +100,7 @@ Then we'll download the frontend files:
curl -L -o "front-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/|version|/download?job=build_front"
unzip "front-|version|.zip" -d extracted
mv extracted/front .
- rmdir extracted
+ rm -rf extracted
You can leave the ZIP archives in the directory, this will help you know
which version you've installed next time you want to upgrade your installation.
diff --git a/docs/installation/docker.rst b/docs/installation/docker.rst
index 34e8187c5..dc031caed 100644
--- a/docs/installation/docker.rst
+++ b/docs/installation/docker.rst
@@ -17,7 +17,9 @@ Create your env file:
.. parsed-literal::
+ export FUNKWHALE_VERSION="|version|"
curl -L -o .env "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/env.prod.sample"
+ sed -i "s/FUNKWHALE_VERSION=latest/FUNKWHALE_VERSION=$FUNKWHALE_VERSION/" .env
Ensure to edit it to match your needs (this file is heavily commented)
diff --git a/docs/upgrading.rst b/docs/upgrading.rst
new file mode 100644
index 000000000..674878ba7
--- /dev/null
+++ b/docs/upgrading.rst
@@ -0,0 +1,94 @@
+Upgrading your funkwhale instance to a newer version
+====================================================
+
+.. note::
+
+ Before upgrading your instance, we strongly advise you to make at least a database backup. Ideally, you should make a full backup, including
+ the database and the media files.
+
+ We're commited to make upgrade as easy and straightforward as possible,
+ however, funkwhale is still in development and you'll be safer with a backup.
+
+
+Reading the release notes
+-------------------------
+
+Please take a few minutes to read the :doc:`changelog`: updates should work
+similarly from version to version, but some of them may require additional steps.
+Those steps would be described in the version release notes.
+
+Upgrade the static files
+------------------------
+
+Regardless of your deployment choice (docker/non-docker) the front-end app
+is updated separately from the API. This is as simple as downloading
+the zip with the static files and extracting it in the correct place.
+
+The following example assume your setup match :ref:`frontend-setup`.
+
+.. parsed-literal::
+
+ # this assumes you want to upgrade to version "|version|"
+ export FUNKWHALE_VERSION="|version|"
+ cd /srv/funkwhale
+ curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
+ unzip -o front.zip
+ rm front.zip
+
+Upgrading the API
+-----------------
+
+Docker setup
+^^^^^^^^^^^^
+
+If you've followed the setup instructions in :doc:`Docker`, upgrade path is
+easy:
+
+.. parsed-literal::
+
+ cd /srv/funkwhale
+ # hardcode the targeted version your env file
+ # (look for the FUNKWHALE_VERSION variable)
+ nano .env
+ # Pull the new version containers
+ docker-compose pull
+ # Apply the database migrations
+ docker-compose run --rm api python manage.py migrate
+ # Relaunch the containers
+ docker-compose up -d
+
+Non-docker setup
+^^^^^^^^^^^^^^^^
+
+On non docker-setup, upgrade involves a few more commands. We assume your setup
+match what is described in :doc:`debian`:
+
+.. parsed-literal::
+
+ # stop the services
+ sudo systemctl stop funkwhale.target
+
+ # this assumes you want to upgrade to version "|version|"
+ export FUNKWALE_VERSION="|version|"
+ cd /srv/funkwhale
+
+ # download more recent API files
+ curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWALE_VERSION/download?job=build_api"
+ unzip "api-$FUNKWALE_VERSION.zip" -d extracted
+ rm -rf api/ && mv extracted/api .
+ rm -rf extracted
+
+ # update os dependencies
+ sudo api/install_os_dependencies.sh install
+ # update python dependencies
+ source /srv/funkwhale/load_env
+ source /srv/funkwhale/virtualenv/bin/activate
+ pip install -r api/requirements.txt
+
+ # apply database migrations
+ python api/manage.py migrate
+ # collect static files
+ python api/manage.py collectstatic --no-input
+
+ # restart the services
+ sudo systemctl restart funkwhale.target
diff --git a/front/config/index.js b/front/config/index.js
index 14cbe3e43..669ce54f3 100644
--- a/front/config/index.js
+++ b/front/config/index.js
@@ -23,25 +23,37 @@ module.exports = {
},
dev: {
env: require('./dev.env'),
- port: 8080,
+ port: parseInt(process.env.WEBPACK_DEVSERVER_PORT),
host: '127.0.0.1',
autoOpenBrowser: true,
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
- '/api': {
+ '**': {
target: 'http://nginx:6001',
changeOrigin: true,
- ws: true
+ ws: true,
+ filter: function (pathname, req) {
+ let proxified = ['.well-known', 'staticfiles', 'media', 'federation', 'api']
+ let matches = proxified.filter(e => {
+ return pathname.match(`^/${e}`)
+ })
+ return matches.length > 0
+ }
},
- '/media': {
- target: 'http://nginx:6001',
- changeOrigin: true,
- },
- '/staticfiles': {
- target: 'http://nginx:6001',
- changeOrigin: true,
- }
+ // '/.well-known': {
+ // target: 'http://nginx:6001',
+ // changeOrigin: true
+ // },
+ // '/media': {
+ // target: 'http://nginx:6001',
+ // changeOrigin: true,
+ // },
+ // '/staticfiles': {
+ // target: 'http://nginx:6001',
+ // changeOrigin: true,
+ // },
+
},
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README
diff --git a/front/package.json b/front/package.json
index 201694e43..d67375f7e 100644
--- a/front/package.json
+++ b/front/package.json
@@ -41,7 +41,7 @@
"autoprefixer": "^6.7.2",
"babel-core": "^6.22.1",
"babel-eslint": "^7.1.1",
- "babel-loader": "^6.2.10",
+ "babel-loader": "7",
"babel-plugin-istanbul": "^4.1.1",
"babel-plugin-transform-runtime": "^6.22.0",
"babel-preset-env": "^1.3.2",
@@ -101,7 +101,7 @@
"vue-loader": "^12.1.0",
"vue-style-loader": "^3.0.1",
"vue-template-compiler": "^2.3.3",
- "webpack": "^2.6.1",
+ "webpack": "3",
"webpack-bundle-analyzer": "^2.2.1",
"webpack-dev-middleware": "^1.10.0",
"webpack-hot-middleware": "^2.18.0",
diff --git a/front/src/App.vue b/front/src/App.vue
index d15eebdba..e8ab18694 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -21,6 +21,7 @@
Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!
+
The funkwhale logo was kindly designed and provided by Francis Gading.
@@ -35,9 +36,6 @@
diff --git a/front/src/components/About.vue b/front/src/components/About.vue
index 92bafd7af..09a5ee24c 100644
--- a/front/src/components/About.vue
+++ b/front/src/components/About.vue
@@ -1,5 +1,5 @@
-