Merge branch 'release/0.8'
This commit is contained in:
commit
78d0de0e7d
6
.env.dev
6
.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
|
||||
|
|
|
@ -86,3 +86,4 @@ front/selenium-debug.log
|
|||
docs/_build
|
||||
|
||||
data/
|
||||
.env
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
94
CHANGELOG
94
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)
|
||||
----------------
|
||||
|
||||
|
|
13
README.rst
13
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
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.5
|
||||
FROM python:3.6
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
if [ $1 = "pytest" ]; then
|
||||
# let pytest.ini handle it
|
||||
unset DJANGO_SETTINGS_MODULE
|
||||
fi
|
||||
exec "$@"
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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=[])
|
||||
|
|
|
@ -72,6 +72,10 @@ LOGGING = {
|
|||
'handlers':['console'],
|
||||
'propagate': True,
|
||||
'level':'DEBUG',
|
||||
}
|
||||
},
|
||||
'': {
|
||||
'level': 'DEBUG',
|
||||
'handlers': ['console'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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')),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.5
|
||||
FROM python:3.6
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
|
@ -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)
|
|
@ -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'
|
||||
]
|
||||
})
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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('<div>{}</div>'.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(),
|
||||
}
|
|
@ -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)
|
|
@ -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)'
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
|
||||
class MalformedPayload(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class MissingSignature(KeyError):
|
||||
pass
|
|
@ -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
|
|
@ -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<id>.*)\"')
|
||||
|
||||
|
||||
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
|
|
@ -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'))
|
||||
)
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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)
|
|
@ -0,0 +1,5 @@
|
|||
from rest_framework import parsers
|
||||
|
||||
|
||||
class ActivityParser(parsers.JSONParser):
|
||||
media_type = 'application/activity+json'
|
|
@ -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'
|
|
@ -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
|
||||
}
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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__
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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]
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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'
|
|
@ -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': '<p><a>@mention</a> /ping</p>'
|
||||
}
|
||||
}
|
||||
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()
|
||||
)
|
|
@ -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
|
|
@ -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'
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,502 +0,0 @@
|
|||
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'}]}}
|
File diff suppressed because one or more lines are too long
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
# -----------
|
||||
|
|
|
@ -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
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue