diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 0fa450c46..e4accd722 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -22,7 +22,7 @@ test_api:
variables:
DJANGO_ALLOWED_HOSTS: "localhost"
DATABASE_URL: "postgresql://postgres@postgres/postgres"
-
+ FUNKWHALE_URL: "https://funkwhale.ci"
before_script:
- cd api
- pip install -r requirements/base.txt
diff --git a/CHANGELOG b/CHANGELOG
index 6e8494425..19cd704a1 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -3,6 +3,97 @@ Changelog
.. towncrier
+0.6 (2018-03-04)
+----------------
+
+Features:
+
+- Basic activity stream for listening and favorites (#23)
+- Switched to django-channels and daphne for serving HTTP and websocket (#34)
+
+Upgrades notes
+**************
+
+This version contains breaking changes in the way funkwhale is deployed,
+please read the notes carefully.
+
+Instance timeline
+^^^^^^^^^^^^^^^^^
+
+A new "Activity" page is now available from the sidebar, where you can
+browse your instance activity. At the moment, this includes other users
+favorites and listening, but more activity types will be implemented in the
+future.
+
+Internally, we implemented those events by following the Activity Stream
+specification, which will help us to be compatible with other networks
+in the long-term.
+
+A new settings page has been added to control the visibility of your activity.
+By default, your activity will be browsable by anyone on your instance,
+but you can switch to a full private mode where nothing is shared.
+
+The setting form is available in your profile.
+
+Switch from gunicorn to daphne
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This release include an important change in the way we serve the HTTP API.
+To prepare for new realtime features and enable websocket support in Funkwhale,
+we are now using django-channels and daphne to serve HTTP and websocket traffic.
+
+This replaces gunicorn and the switch should be easy assuming you
+follow the upgrade process described bellow.
+
+If you are using docker, please remove the command instruction inside the
+api service, as the up-to-date command is now included directly in the image
+as the default entry point:
+
+.. code-block:: yaml
+
+ api:
+ restart: unless-stopped
+ image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
+ command: ./compose/django/gunicorn.sh # You can remove this line
+
+On non docker setups, you'll have to update the [Service] block of your
+funkwhale-server systemd unit file to launch the application server using daphne instead of gunicorn.
+
+The new configuration should be similar to this:
+
+.. code-block:: ini
+
+ [Service]
+ User=funkwhale
+ # adapt this depending on the path of your funkwhale installation
+ WorkingDirectory=/srv/funkwhale/api
+ EnvironmentFile=/srv/funkwhale/config/.env
+ ExecStart=/usr/local/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
+
+Ensure you update funkwhale's dependencies as usual to install the required
+packages.
+
+On both docker and non-docker setup, you'll also have to update your nginx
+configuration for websocket support. Ensure you have the following blocks
+included in your virtualhost file:
+
+.. code-block:: txt
+
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ server {
+ ...
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+
+Remember to reload your nginx server after the edit.
+
+
0.5.4 (2018-02-28)
------------------
diff --git a/api/Dockerfile b/api/Dockerfile
index 5d4e85857..9296785ee 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -5,7 +5,11 @@ ENV PYTHONUNBUFFERED 1
# Requirements have to be pulled and installed here, otherwise caching won't work
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
COPY ./requirements.apt /requirements.apt
-RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y
+RUN apt-get update; \
+ grep "^[^#;]" requirements.apt | \
+ grep -Fv "python3-dev" | \
+ xargs apt-get install -y --no-install-recommends; \
+ rm -rf /usr/share/doc/* /usr/share/locale/*
RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
COPY ./requirements/base.txt /requirements/base.txt
RUN pip install -r /requirements/base.txt
@@ -20,3 +24,4 @@ RUN pip install --upgrade youtube-dl
WORKDIR /app
ENTRYPOINT ["./compose/django/entrypoint.sh"]
+CMD ["./compose/django/daphne.sh"]
diff --git a/api/compose/django/daphne.sh b/api/compose/django/daphne.sh
new file mode 100755
index 000000000..16b4d50b8
--- /dev/null
+++ b/api/compose/django/daphne.sh
@@ -0,0 +1,3 @@
+#!/bin/bash -eux
+python /app/manage.py collectstatic --noinput
+/usr/local/bin/daphne --root-path=/app -b 0.0.0.0 -p 5000 config.asgi:application
diff --git a/api/compose/django/gunicorn.sh b/api/compose/django/gunicorn.sh
deleted file mode 100755
index 014f173e3..000000000
--- a/api/compose/django/gunicorn.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/sh
-python /app/manage.py collectstatic --noinput
-/usr/local/bin/gunicorn config.wsgi -w 4 -b 0.0.0.0:5000 --chdir=/app
\ No newline at end of file
diff --git a/api/config/asgi.py b/api/config/asgi.py
new file mode 100644
index 000000000..b976a02eb
--- /dev/null
+++ b/api/config/asgi.py
@@ -0,0 +1,8 @@
+import django
+import os
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
+
+django.setup()
+
+from .routing import application
diff --git a/api/config/routing.py b/api/config/routing.py
new file mode 100644
index 000000000..574d5a18e
--- /dev/null
+++ b/api/config/routing.py
@@ -0,0 +1,18 @@
+from django.conf.urls import url
+
+from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
+
+from funkwhale_api.common.auth import TokenAuthMiddleware
+from funkwhale_api.instance import consumers
+
+
+application = ProtocolTypeRouter({
+ # Empty for now (http->django views is added by default)
+ "websocket": TokenAuthMiddleware(
+ URLRouter([
+ url("^api/v1/instance/activity$",
+ consumers.InstanceActivityConsumer),
+ ])
+ ),
+})
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index f5ddec00b..bff43b233 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -25,11 +25,12 @@ except FileNotFoundError:
pass
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
-
+FUNKWHALE_URL = env('FUNKWHALE_URL')
# APP CONFIGURATION
# ------------------------------------------------------------------------------
DJANGO_APPS = (
+ 'channels',
# Default Django apps:
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -82,6 +83,7 @@ if RAVEN_ENABLED:
# Apps specific for this project go here.
LOCAL_APPS = (
'funkwhale_api.common',
+ 'funkwhale_api.activity.apps.ActivityConfig',
'funkwhale_api.users', # custom users app
# Your stuff: custom apps go here
'funkwhale_api.instance',
@@ -253,9 +255,9 @@ MEDIA_URL = env("MEDIA_URL", default='/media/')
# URL Configuration
# ------------------------------------------------------------------------------
ROOT_URLCONF = 'config.urls'
-
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = 'config.wsgi.application'
+ASGI_APPLICATION = "config.routing.application"
# AUTHENTICATION CONFIGURATION
# ------------------------------------------------------------------------------
@@ -284,6 +286,17 @@ CACHES = {
}
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
+from urllib.parse import urlparse
+cache_url = urlparse(CACHES['default']['LOCATION'])
+CHANNEL_LAYERS = {
+ "default": {
+ "BACKEND": "channels_redis.core.RedisChannelLayer",
+ "CONFIG": {
+ "hosts": [(cache_url.hostname, cache_url.port)],
+ },
+ },
+}
+
CACHES["default"]["OPTIONS"] = {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"IGNORE_EXCEPTIONS": True, # mimics memcache behavior.
diff --git a/api/config/settings/production.py b/api/config/settings/production.py
index df15d325f..f238c2d20 100644
--- a/api/config/settings/production.py
+++ b/api/config/settings/production.py
@@ -58,8 +58,6 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# END SITE CONFIGURATION
-INSTALLED_APPS += ("gunicorn", )
-
# STORAGE CONFIGURATION
# ------------------------------------------------------------------------------
# Uploaded Media Files
diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test
index 069b89c2f..00638e9dd 100644
--- a/api/docker/Dockerfile.test
+++ b/api/docker/Dockerfile.test
@@ -1,13 +1,16 @@
FROM python:3.5
ENV PYTHONUNBUFFERED 1
-ENV PYTHONDONTWRITEBYTECODE 1
# Requirements have to be pulled and installed here, otherwise caching won't work
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
COPY ./requirements.apt /requirements.apt
-COPY ./install_os_dependencies.sh /install_os_dependencies.sh
-RUN bash install_os_dependencies.sh install
+RUN apt-get update; \
+ grep "^[^#;]" requirements.apt | \
+ grep -Fv "python3-dev" | \
+ xargs apt-get install -y --no-install-recommends; \
+ rm -rf /usr/share/doc/* /usr/share/locale/*
+
RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
RUN mkdir /requirements
@@ -18,4 +21,5 @@ RUN pip install -r /requirements/local.txt
COPY ./requirements/test.txt /requirements/test.txt
RUN pip install -r /requirements/test.txt
+COPY . /app
WORKDIR /app
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index c1f45ffb6..c384dc521 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
-__version__ = '0.5.4'
+__version__ = '0.6'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
diff --git a/api/funkwhale_api/activity/__init__.py b/api/funkwhale_api/activity/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/funkwhale_api/activity/apps.py b/api/funkwhale_api/activity/apps.py
new file mode 100644
index 000000000..0c66cbf50
--- /dev/null
+++ b/api/funkwhale_api/activity/apps.py
@@ -0,0 +1,12 @@
+from django.apps import AppConfig, apps
+
+from . import record
+
+class ActivityConfig(AppConfig):
+ name = 'funkwhale_api.activity'
+
+ def ready(self):
+ super(ActivityConfig, self).ready()
+
+ app_names = [app.name for app in apps.app_configs.values()]
+ record.registry.autodiscover(app_names)
diff --git a/api/funkwhale_api/activity/record.py b/api/funkwhale_api/activity/record.py
new file mode 100644
index 000000000..fa55c0e85
--- /dev/null
+++ b/api/funkwhale_api/activity/record.py
@@ -0,0 +1,38 @@
+import persisting_theory
+
+
+class ActivityRegistry(persisting_theory.Registry):
+ look_into = 'activities'
+
+ def _register_for_model(self, model, attr, value):
+ key = model._meta.label
+ d = self.setdefault(key, {'consumers': []})
+ d[attr] = value
+
+ def register_serializer(self, serializer_class):
+ model = serializer_class.Meta.model
+ self._register_for_model(model, 'serializer', serializer_class)
+ return serializer_class
+
+ def register_consumer(self, label):
+ def decorator(func):
+ consumers = self[label]['consumers']
+ if func not in consumers:
+ consumers.append(func)
+ return func
+ return decorator
+
+
+registry = ActivityRegistry()
+
+
+
+
+def send(obj):
+ conf = registry[obj.__class__._meta.label]
+ consumers = conf['consumers']
+ if not consumers:
+ return
+ serializer = conf['serializer'](obj)
+ for consumer in consumers:
+ consumer(data=serializer.data, obj=obj)
diff --git a/api/funkwhale_api/activity/serializers.py b/api/funkwhale_api/activity/serializers.py
new file mode 100644
index 000000000..325d1e820
--- /dev/null
+++ b/api/funkwhale_api/activity/serializers.py
@@ -0,0 +1,10 @@
+from rest_framework import serializers
+
+
+class ModelSerializer(serializers.ModelSerializer):
+ id = serializers.CharField(source='get_activity_url')
+ local_id = serializers.IntegerField(source='id')
+ # url = serializers.SerializerMethodField()
+
+ def get_url(self, obj):
+ return self.get_id(obj)
diff --git a/api/funkwhale_api/common/auth.py b/api/funkwhale_api/common/auth.py
new file mode 100644
index 000000000..6f99b3bba
--- /dev/null
+++ b/api/funkwhale_api/common/auth.py
@@ -0,0 +1,47 @@
+from urllib.parse import parse_qs
+
+import jwt
+
+from django.contrib.auth.models import AnonymousUser
+from django.utils.encoding import smart_text
+
+from rest_framework import exceptions
+from rest_framework_jwt.settings import api_settings
+from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication
+
+
+
+class TokenHeaderAuth(BaseJSONWebTokenAuthentication):
+ def get_jwt_value(self, request):
+
+ try:
+ qs = request.get('query_string', b'').decode('utf-8')
+ parsed = parse_qs(qs)
+ token = parsed['token'][0]
+ except KeyError:
+ raise exceptions.AuthenticationFailed('No token')
+
+ if not token:
+ raise exceptions.AuthenticationFailed('Empty token')
+
+ return token
+
+
+class TokenAuthMiddleware:
+ """
+ Custom middleware (insecure) that takes user IDs from the query string.
+ """
+
+ def __init__(self, inner):
+ # Store the ASGI application we were passed
+ self.inner = inner
+
+ def __call__(self, scope):
+ auth = TokenHeaderAuth()
+ try:
+ user, token = auth.authenticate(scope)
+ except exceptions.AuthenticationFailed:
+ user = AnonymousUser()
+
+ scope['user'] = user
+ return self.inner(scope)
diff --git a/api/funkwhale_api/common/channels.py b/api/funkwhale_api/common/channels.py
new file mode 100644
index 000000000..a009ab5ab
--- /dev/null
+++ b/api/funkwhale_api/common/channels.py
@@ -0,0 +1,6 @@
+from asgiref.sync import async_to_sync
+from channels.layers import get_channel_layer
+
+channel_layer = get_channel_layer()
+group_send = async_to_sync(channel_layer.group_send)
+group_add = async_to_sync(channel_layer.group_add)
diff --git a/api/funkwhale_api/common/consumers.py b/api/funkwhale_api/common/consumers.py
new file mode 100644
index 000000000..300ce5e26
--- /dev/null
+++ b/api/funkwhale_api/common/consumers.py
@@ -0,0 +1,17 @@
+from channels.generic.websocket import JsonWebsocketConsumer
+from funkwhale_api.common import channels
+
+
+class JsonAuthConsumer(JsonWebsocketConsumer):
+ def connect(self):
+ try:
+ assert self.scope['user'].pk is not None
+ except (AssertionError, AttributeError, KeyError):
+ return self.close()
+
+ return self.accept()
+
+ def accept(self):
+ super().accept()
+ for group in self.groups:
+ channels.group_add(group, self.channel_name)
diff --git a/api/funkwhale_api/favorites/activities.py b/api/funkwhale_api/favorites/activities.py
new file mode 100644
index 000000000..a2dbc4e2f
--- /dev/null
+++ b/api/funkwhale_api/favorites/activities.py
@@ -0,0 +1,19 @@
+from funkwhale_api.common import channels
+from funkwhale_api.activity import record
+
+from . import serializers
+
+record.registry.register_serializer(
+ serializers.TrackFavoriteActivitySerializer)
+
+
+@record.registry.register_consumer('favorites.TrackFavorite')
+def broadcast_track_favorite_to_instance_activity(data, obj):
+ if obj.user.privacy_level not in ['instance', 'everyone']:
+ return
+
+ channels.group_send('instance_activity', {
+ 'type': 'event.send',
+ 'text': '',
+ 'data': data
+ })
diff --git a/api/funkwhale_api/favorites/consumers.py b/api/funkwhale_api/favorites/consumers.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/funkwhale_api/favorites/models.py b/api/funkwhale_api/favorites/models.py
index 899ed9cff..0c6a6b11c 100644
--- a/api/funkwhale_api/favorites/models.py
+++ b/api/funkwhale_api/favorites/models.py
@@ -1,8 +1,10 @@
+from django.conf import settings
from django.db import models
from django.utils import timezone
from funkwhale_api.music.models import Track
+
class TrackFavorite(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey(
@@ -18,3 +20,7 @@ class TrackFavorite(models.Model):
def add(cls, track, user):
favorite, created = cls.objects.get_or_create(user=user, track=track)
return favorite
+
+ def get_activity_url(self):
+ return '{}/favorites/tracks/{}'.format(
+ self.user.get_activity_url(), self.pk)
diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py
index 57af4570e..276b0f6bd 100644
--- a/api/funkwhale_api/favorites/serializers.py
+++ b/api/funkwhale_api/favorites/serializers.py
@@ -1,10 +1,39 @@
+from django.conf import settings
+
from rest_framework import serializers
+from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.music.serializers import TrackSerializerNested
+from funkwhale_api.music.serializers import TrackActivitySerializer
+from funkwhale_api.users.serializers import UserActivitySerializer
from . import models
+class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
+ type = serializers.SerializerMethodField()
+ object = TrackActivitySerializer(source='track')
+ actor = UserActivitySerializer(source='user')
+ published = serializers.DateTimeField(source='creation_date')
+
+ class Meta:
+ model = models.TrackFavorite
+ fields = [
+ 'id',
+ 'local_id',
+ 'object',
+ 'type',
+ 'actor',
+ 'published'
+ ]
+
+ def get_actor(self, obj):
+ return UserActivitySerializer(obj.user).data
+
+ def get_type(self, obj):
+ return 'Like'
+
+
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
# track = TrackSerializerNested(read_only=True)
class Meta:
diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py
index 08ae00b68..d874c9e1e 100644
--- a/api/funkwhale_api/favorites/views.py
+++ b/api/funkwhale_api/favorites/views.py
@@ -4,6 +4,7 @@ from rest_framework.response import Response
from rest_framework import pagination
from rest_framework.decorators import list_route
+from funkwhale_api.activity import record
from funkwhale_api.music.models import Track
from funkwhale_api.common.permissions import ConditionalAuthentication
@@ -33,6 +34,7 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin,
instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data)
+ record.send(instance)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_queryset(self):
diff --git a/api/funkwhale_api/history/activities.py b/api/funkwhale_api/history/activities.py
new file mode 100644
index 000000000..e478f9b7f
--- /dev/null
+++ b/api/funkwhale_api/history/activities.py
@@ -0,0 +1,19 @@
+from funkwhale_api.common import channels
+from funkwhale_api.activity import record
+
+from . import serializers
+
+record.registry.register_serializer(
+ serializers.ListeningActivitySerializer)
+
+
+@record.registry.register_consumer('history.Listening')
+def broadcast_listening_to_instance_activity(data, obj):
+ if obj.user.privacy_level not in ['instance', 'everyone']:
+ return
+
+ channels.group_send('instance_activity', {
+ 'type': 'event.send',
+ 'text': '',
+ 'data': data
+ })
diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py
index f7f62de62..56310ddc0 100644
--- a/api/funkwhale_api/history/models.py
+++ b/api/funkwhale_api/history/models.py
@@ -25,3 +25,8 @@ class Listening(models.Model):
raise ValidationError('Cannot have both session_key and user empty for listening')
super().save(**kwargs)
+
+
+ def get_activity_url(self):
+ return '{}/listenings/tracks/{}'.format(
+ self.user.get_activity_url(), self.pk)
diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py
index 64bdf41c6..7a2280cea 100644
--- a/api/funkwhale_api/history/serializers.py
+++ b/api/funkwhale_api/history/serializers.py
@@ -1,9 +1,37 @@
from rest_framework import serializers
+from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.music.serializers import TrackSerializerNested
+from funkwhale_api.music.serializers import TrackActivitySerializer
+from funkwhale_api.users.serializers import UserActivitySerializer
+
from . import models
+class ListeningActivitySerializer(activity_serializers.ModelSerializer):
+ type = serializers.SerializerMethodField()
+ object = TrackActivitySerializer(source='track')
+ actor = UserActivitySerializer(source='user')
+ published = serializers.DateTimeField(source='end_date')
+
+ class Meta:
+ model = models.Listening
+ fields = [
+ 'id',
+ 'local_id',
+ 'object',
+ 'type',
+ 'actor',
+ 'published'
+ ]
+
+ def get_actor(self, obj):
+ return UserActivitySerializer(obj.user).data
+
+ def get_type(self, obj):
+ return 'Listen'
+
+
class ListeningSerializer(serializers.ModelSerializer):
class Meta:
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index 59dcbd26b..d5cbe316b 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -3,8 +3,9 @@ from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import detail_route
-from funkwhale_api.music.serializers import TrackSerializerNested
+from funkwhale_api.activity import record
from funkwhale_api.common.permissions import ConditionalAuthentication
+from funkwhale_api.music.serializers import TrackSerializerNested
from . import models
from . import serializers
@@ -17,6 +18,12 @@ class ListeningViewSet(mixins.CreateModelMixin,
queryset = models.Listening.objects.all()
permission_classes = [ConditionalAuthentication]
+ def perform_create(self, serializer):
+ r = super().perform_create(serializer)
+ if self.request.user.is_authenticated:
+ record.send(serializer.instance)
+ return r
+
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated:
diff --git a/api/funkwhale_api/instance/consumers.py b/api/funkwhale_api/instance/consumers.py
new file mode 100644
index 000000000..eee5f7f0e
--- /dev/null
+++ b/api/funkwhale_api/instance/consumers.py
@@ -0,0 +1,8 @@
+from funkwhale_api.common.consumers import JsonAuthConsumer
+
+
+class InstanceActivityConsumer(JsonAuthConsumer):
+ groups = ["instance_activity"]
+
+ def event_send(self, message):
+ self.send_json(message['data'])
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 97992fc8f..7138dcdd6 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -360,6 +360,12 @@ class Track(APIModelMixin):
self.title,
)
+ def get_activity_url(self):
+ if self.mbid:
+ return 'https://musicbrainz.org/recording/{}'.format(
+ self.mbid)
+ return settings.FUNKWHALE_URL + '/tracks/{}'.format(self.pk)
+
class TrackFile(models.Model):
track = models.ForeignKey(
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index db6298a9e..48419bbe4 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -1,6 +1,8 @@
from rest_framework import serializers
from taggit.models import Tag
+from funkwhale_api.activity import serializers as activity_serializers
+
from . import models
@@ -127,3 +129,24 @@ class ImportBatchSerializer(serializers.ModelSerializer):
model = models.ImportBatch
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
read_only_fields = ('creation_date',)
+
+
+class TrackActivitySerializer(activity_serializers.ModelSerializer):
+ type = serializers.SerializerMethodField()
+ name = serializers.CharField(source='title')
+ artist = serializers.CharField(source='artist.name')
+ album = serializers.CharField(source='album.title')
+
+ class Meta:
+ model = models.Track
+ fields = [
+ 'id',
+ 'local_id',
+ 'name',
+ 'type',
+ 'artist',
+ 'album',
+ ]
+
+ def get_type(self, obj):
+ return 'Audio'
diff --git a/api/funkwhale_api/users/migrations/0004_user_privacy_level.py b/api/funkwhale_api/users/migrations/0004_user_privacy_level.py
new file mode 100644
index 000000000..81891eb0f
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0004_user_privacy_level.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.2 on 2018-03-01 19:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0003_auto_20171226_1357'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='privacy_level',
+ field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30),
+ ),
+ ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 3a0baf11a..a5478656b 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals, absolute_import
import uuid
+from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.urls import reverse
from django.db import models
@@ -10,6 +11,14 @@ from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
+
+PRIVACY_LEVEL_CHOICES = [
+ ('me', 'Only me'),
+ ('followers', 'Me and my followers'),
+ ('instance', 'Everyone on my instance, and my followers'),
+ ('everyone', 'Everyone, including people on other instances'),
+]
+
@python_2_unicode_compatible
class User(AbstractUser):
@@ -30,6 +39,9 @@ class User(AbstractUser):
},
}
+ privacy_level = models.CharField(
+ max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
+
def __str__(self):
return self.username
@@ -43,3 +55,6 @@ class User(AbstractUser):
def set_password(self, raw_password):
super().set_password(raw_password)
self.update_secret_key()
+
+ def get_activity_url(self):
+ return settings.FUNKWHALE_URL + '/@{}'.format(self.username)
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index 8c218b1c2..b21aa6935 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -1,15 +1,44 @@
from rest_framework import serializers
+from funkwhale_api.activity import serializers as activity_serializers
+
from . import models
+class UserActivitySerializer(activity_serializers.ModelSerializer):
+ type = serializers.SerializerMethodField()
+ name = serializers.CharField(source='username')
+ local_id = serializers.CharField(source='username')
+
+ class Meta:
+ model = models.User
+ fields = [
+ 'id',
+ 'local_id',
+ 'name',
+ 'type'
+ ]
+
+ def get_type(self, obj):
+ return 'Person'
+
+
class UserBasicSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ['id', 'username', 'name', 'date_joined']
-class UserSerializer(serializers.ModelSerializer):
+class UserWriteSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = models.User
+ fields = [
+ 'name',
+ 'privacy_level'
+ ]
+
+
+class UserReadSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
@@ -24,6 +53,7 @@ class UserSerializer(serializers.ModelSerializer):
'is_superuser',
'permissions',
'date_joined',
+ 'privacy_level'
]
def get_permissions(self, o):
diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py
index b7c1df28f..7c58363a3 100644
--- a/api/funkwhale_api/users/views.py
+++ b/api/funkwhale_api/users/views.py
@@ -1,4 +1,5 @@
from rest_framework.response import Response
+from rest_framework import mixins
from rest_framework import viewsets
from rest_framework.decorators import list_route
@@ -23,12 +24,25 @@ class RegisterView(BaseRegisterView):
return get_adapter().is_open_for_signup(request)
-class UserViewSet(viewsets.GenericViewSet):
+class UserViewSet(
+ mixins.UpdateModelMixin,
+ viewsets.GenericViewSet):
queryset = models.User.objects.all()
- serializer_class = serializers.UserSerializer
+ serializer_class = serializers.UserWriteSerializer
+ lookup_field = 'username'
@list_route(methods=['get'])
def me(self, request, *args, **kwargs):
"""Return information about the current user"""
- serializer = self.serializer_class(request.user)
+ serializer = serializers.UserReadSerializer(request.user)
return Response(serializer.data)
+
+ def update(self, request, *args, **kwargs):
+ if not self.request.user.username == kwargs.get('username'):
+ return Response(status=403)
+ return super().update(request, *args, **kwargs)
+
+ def partial_update(self, request, *args, **kwargs):
+ if not self.request.user.username == kwargs.get('username'):
+ return Response(status=403)
+ return super().partial_update(request, *args, **kwargs)
diff --git a/api/requirements.apt b/api/requirements.apt
index 462a5a705..224ff955a 100644
--- a/api/requirements.apt
+++ b/api/requirements.apt
@@ -1,11 +1,8 @@
build-essential
-gettext
-zlib1g-dev
+curl
+ffmpeg
libjpeg-dev
-zlib1g-dev
+libmagic-dev
libpq-dev
postgresql-client
-libmagic-dev
-ffmpeg
python3-dev
-curl
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index 133fcc0cb..d402d3591 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -59,3 +59,5 @@ pyacoustid>=1.1.5,<1.2
raven>=6.5,<7
python-magic==0.4.15
ffmpeg-python==0.1.10
+channels>=2,<2.1
+channels_redis>=2.1,<2.2
diff --git a/api/requirements/production.txt b/api/requirements/production.txt
index 42b66eb15..4ad8edf94 100644
--- a/api/requirements/production.txt
+++ b/api/requirements/production.txt
@@ -4,7 +4,4 @@
# WSGI Handler
# ------------------------------------------------
-# there's no python 3 support in stable, have to use the latest release candidate for gevent
-gevent==1.1rc1
-
-gunicorn==19.4.1
+daphne==2.0.4
diff --git a/api/test.yml b/api/test.yml
index e892dfb17..5e785cb1a 100644
--- a/api/test.yml
+++ b/api/test.yml
@@ -12,5 +12,6 @@ services:
environment:
- "DJANGO_ALLOWED_HOSTS=localhost"
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
+ - "FUNKWHALE_URL=https://funkwhale.test"
postgres:
image: postgres
diff --git a/api/tests/activity/test_record.py b/api/tests/activity/test_record.py
new file mode 100644
index 000000000..41846ba6f
--- /dev/null
+++ b/api/tests/activity/test_record.py
@@ -0,0 +1,45 @@
+import pytest
+
+from django.db import models
+from rest_framework import serializers
+
+from funkwhale_api.activity import record
+
+
+class FakeModel(models.Model):
+ class Meta:
+ app_label = 'tests'
+
+
+class FakeSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = FakeModel
+ fields = ['id']
+
+
+
+
+def test_can_bind_serializer_to_model(activity_registry):
+ activity_registry.register_serializer(FakeSerializer)
+
+ assert activity_registry['tests.FakeModel']['serializer'] == FakeSerializer
+
+
+def test_can_bind_consumer_to_model(activity_registry):
+ activity_registry.register_serializer(FakeSerializer)
+ @activity_registry.register_consumer('tests.FakeModel')
+ def propagate(data, obj):
+ return True
+
+ assert activity_registry['tests.FakeModel']['consumers'] == [propagate]
+
+
+def test_record_object_calls_consumer(activity_registry, mocker):
+ activity_registry.register_serializer(FakeSerializer)
+ stub = mocker.stub()
+ activity_registry.register_consumer('tests.FakeModel')(stub)
+ o = FakeModel(id=1)
+ data = FakeSerializer(o).data
+ record.send(o)
+
+ stub.assert_called_once_with(data=data, obj=o)
diff --git a/api/tests/channels/test_auth.py b/api/tests/channels/test_auth.py
new file mode 100644
index 000000000..a2b7eaf0c
--- /dev/null
+++ b/api/tests/channels/test_auth.py
@@ -0,0 +1,37 @@
+import pytest
+
+from rest_framework_jwt.settings import api_settings
+
+from funkwhale_api.common.auth import TokenAuthMiddleware
+
+jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
+jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
+
+
+@pytest.mark.parametrize('query_string', [
+ b'token=wrong',
+ b'',
+])
+def test_header_anonymous(query_string, factories):
+ def callback(scope):
+ assert scope['user'].is_anonymous
+
+ scope = {
+ 'query_string': query_string
+ }
+ consumer = TokenAuthMiddleware(callback)
+ consumer(scope)
+
+
+def test_header_correct_token(factories):
+ user = factories['users.User']()
+ payload = jwt_payload_handler(user)
+ token = jwt_encode_handler(payload)
+ def callback(scope):
+ assert scope['user'] == user
+
+ scope = {
+ 'query_string': 'token={}'.format(token).encode('utf-8')
+ }
+ consumer = TokenAuthMiddleware(callback)
+ consumer(scope)
diff --git a/api/tests/channels/test_consumers.py b/api/tests/channels/test_consumers.py
new file mode 100644
index 000000000..f1648efb3
--- /dev/null
+++ b/api/tests/channels/test_consumers.py
@@ -0,0 +1,26 @@
+from funkwhale_api.common import consumers
+
+
+def test_auth_consumer_requires_valid_user(mocker):
+ m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close')
+ scope = {'user': None}
+ consumer = consumers.JsonAuthConsumer(scope=scope)
+ consumer.connect()
+ m.assert_called_once_with()
+
+
+def test_auth_consumer_requires_user_in_scope(mocker):
+ m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close')
+ scope = {}
+ consumer = consumers.JsonAuthConsumer(scope=scope)
+ consumer.connect()
+ m.assert_called_once_with()
+
+
+def test_auth_consumer_accepts_connection(mocker, factories):
+ user = factories['users.User']()
+ m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.accept')
+ scope = {'user': user}
+ consumer = consumers.JsonAuthConsumer(scope=scope)
+ consumer.connect()
+ m.assert_called_once_with()
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 10d7c3235..2d655f23f 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -5,6 +5,7 @@ from django.core.cache import cache as django_cache
from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient
+from funkwhale_api.activity import record
from funkwhale_api.taskapp import celery
@@ -81,3 +82,28 @@ def superuser_client(db, factories, client):
setattr(client, 'user', user)
yield client
delattr(client, 'user')
+
+
+@pytest.fixture
+def activity_registry():
+ r = record.registry
+ state = list(record.registry.items())
+ yield record.registry
+ record.registry.clear()
+ for key, value in state:
+ record.registry[key] = value
+
+
+@pytest.fixture
+def activity_registry():
+ r = record.registry
+ state = list(record.registry.items())
+ yield record.registry
+ record.registry.clear()
+ for key, value in state:
+ record.registry[key] = value
+
+
+@pytest.fixture
+def activity_muted(activity_registry, mocker):
+ yield mocker.patch.object(record, 'send')
diff --git a/api/tests/favorites/test_activity.py b/api/tests/favorites/test_activity.py
new file mode 100644
index 000000000..63174f9e2
--- /dev/null
+++ b/api/tests/favorites/test_activity.py
@@ -0,0 +1,75 @@
+from funkwhale_api.users.serializers import UserActivitySerializer
+from funkwhale_api.music.serializers import TrackActivitySerializer
+from funkwhale_api.favorites import serializers
+from funkwhale_api.favorites import activities
+
+
+def test_get_favorite_activity_url(settings, factories):
+ favorite = factories['favorites.TrackFavorite']()
+ user_url = favorite.user.get_activity_url()
+ expected = '{}/favorites/tracks/{}'.format(
+ user_url, favorite.pk)
+ assert favorite.get_activity_url() == expected
+
+
+def test_activity_favorite_serializer(factories):
+ favorite = factories['favorites.TrackFavorite']()
+
+ actor = UserActivitySerializer(favorite.user).data
+ field = serializers.serializers.DateTimeField()
+ expected = {
+ "type": "Like",
+ "local_id": favorite.pk,
+ "id": favorite.get_activity_url(),
+ "actor": actor,
+ "object": TrackActivitySerializer(favorite.track).data,
+ "published": field.to_representation(favorite.creation_date),
+ }
+
+ data = serializers.TrackFavoriteActivitySerializer(favorite).data
+
+ assert data == expected
+
+
+def test_track_favorite_serializer_is_connected(activity_registry):
+ conf = activity_registry['favorites.TrackFavorite']
+ assert conf['serializer'] == serializers.TrackFavoriteActivitySerializer
+
+
+def test_track_favorite_serializer_instance_activity_consumer(
+ activity_registry):
+ conf = activity_registry['favorites.TrackFavorite']
+ consumer = activities.broadcast_track_favorite_to_instance_activity
+ assert consumer in conf['consumers']
+
+
+def test_broadcast_track_favorite_to_instance_activity(
+ factories, mocker):
+ p = mocker.patch('funkwhale_api.common.channels.group_send')
+ favorite = factories['favorites.TrackFavorite']()
+ data = serializers.TrackFavoriteActivitySerializer(favorite).data
+ consumer = activities.broadcast_track_favorite_to_instance_activity
+ message = {
+ "type": 'event.send',
+ "text": '',
+ "data": data
+ }
+ consumer(data=data, obj=favorite)
+ p.assert_called_once_with('instance_activity', message)
+
+
+def test_broadcast_track_favorite_to_instance_activity_private(
+ factories, mocker):
+ p = mocker.patch('funkwhale_api.common.channels.group_send')
+ favorite = factories['favorites.TrackFavorite'](
+ user__privacy_level='me'
+ )
+ data = serializers.TrackFavoriteActivitySerializer(favorite).data
+ consumer = activities.broadcast_track_favorite_to_instance_activity
+ message = {
+ "type": 'event.send',
+ "text": '',
+ "data": data
+ }
+ consumer(data=data, obj=favorite)
+ p.assert_not_called()
diff --git a/api/tests/test_favorites.py b/api/tests/favorites/test_favorites.py
similarity index 79%
rename from api/tests/test_favorites.py
rename to api/tests/favorites/test_favorites.py
index 8165722ea..f4a045af8 100644
--- a/api/tests/test_favorites.py
+++ b/api/tests/favorites/test_favorites.py
@@ -33,7 +33,8 @@ def test_user_can_get_his_favorites(factories, logged_in_client, client):
assert expected == parsed_json['results']
-def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
+def test_user_can_add_favorite_via_api(
+ factories, logged_in_client, activity_muted):
track = factories['music.Track']()
url = reverse('api:v1:favorites:tracks-list')
response = logged_in_client.post(url, {'track': track.pk})
@@ -51,6 +52,27 @@ def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
assert favorite.user == logged_in_client.user
+def test_adding_favorites_calls_activity_record(
+ factories, logged_in_client, activity_muted):
+ track = factories['music.Track']()
+ url = reverse('api:v1:favorites:tracks-list')
+ response = logged_in_client.post(url, {'track': track.pk})
+
+ favorite = TrackFavorite.objects.latest('id')
+ expected = {
+ 'track': track.pk,
+ 'id': favorite.id,
+ 'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
+ }
+ parsed_json = json.loads(response.content.decode('utf-8'))
+
+ assert expected == parsed_json
+ assert favorite.track == track
+ assert favorite.user == logged_in_client.user
+
+ activity_muted.assert_called_once_with(favorite)
+
+
def test_user_can_remove_favorite_via_api(logged_in_client, factories, client):
favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})
diff --git a/api/tests/history/__init__.py b/api/tests/history/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py
new file mode 100644
index 000000000..b5ab07b82
--- /dev/null
+++ b/api/tests/history/test_activity.py
@@ -0,0 +1,75 @@
+from funkwhale_api.users.serializers import UserActivitySerializer
+from funkwhale_api.music.serializers import TrackActivitySerializer
+from funkwhale_api.history import serializers
+from funkwhale_api.history import activities
+
+
+def test_get_listening_activity_url(settings, factories):
+ listening = factories['history.Listening']()
+ user_url = listening.user.get_activity_url()
+ expected = '{}/listenings/tracks/{}'.format(
+ user_url, listening.pk)
+ assert listening.get_activity_url() == expected
+
+
+def test_activity_listening_serializer(factories):
+ listening = factories['history.Listening']()
+
+ actor = UserActivitySerializer(listening.user).data
+ field = serializers.serializers.DateTimeField()
+ expected = {
+ "type": "Listen",
+ "local_id": listening.pk,
+ "id": listening.get_activity_url(),
+ "actor": actor,
+ "object": TrackActivitySerializer(listening.track).data,
+ "published": field.to_representation(listening.end_date),
+ }
+
+ data = serializers.ListeningActivitySerializer(listening).data
+
+ assert data == expected
+
+
+def test_track_listening_serializer_is_connected(activity_registry):
+ conf = activity_registry['history.Listening']
+ assert conf['serializer'] == serializers.ListeningActivitySerializer
+
+
+def test_track_listening_serializer_instance_activity_consumer(
+ activity_registry):
+ conf = activity_registry['history.Listening']
+ consumer = activities.broadcast_listening_to_instance_activity
+ assert consumer in conf['consumers']
+
+
+def test_broadcast_listening_to_instance_activity(
+ factories, mocker):
+ p = mocker.patch('funkwhale_api.common.channels.group_send')
+ listening = factories['history.Listening']()
+ data = serializers.ListeningActivitySerializer(listening).data
+ consumer = activities.broadcast_listening_to_instance_activity
+ message = {
+ "type": 'event.send',
+ "text": '',
+ "data": data
+ }
+ consumer(data=data, obj=listening)
+ p.assert_called_once_with('instance_activity', message)
+
+
+def test_broadcast_listening_to_instance_activity_private(
+ factories, mocker):
+ p = mocker.patch('funkwhale_api.common.channels.group_send')
+ listening = factories['history.Listening'](
+ user__privacy_level='me'
+ )
+ data = serializers.ListeningActivitySerializer(listening).data
+ consumer = activities.broadcast_listening_to_instance_activity
+ message = {
+ "type": 'event.send',
+ "text": '',
+ "data": data
+ }
+ consumer(data=data, obj=listening)
+ p.assert_not_called()
diff --git a/api/tests/test_history.py b/api/tests/history/test_history.py
similarity index 70%
rename from api/tests/test_history.py
rename to api/tests/history/test_history.py
index 113e5ff64..ec8689e96 100644
--- a/api/tests/test_history.py
+++ b/api/tests/history/test_history.py
@@ -28,7 +28,8 @@ def test_anonymous_user_can_create_listening_via_api(client, factories, settings
assert listening.session_key == client.session.session_key
-def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories):
+def test_logged_in_user_can_create_listening_via_api(
+ logged_in_client, factories, activity_muted):
track = factories['music.Track']()
url = reverse('api:v1:history:listenings-list')
@@ -40,3 +41,17 @@ def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories
assert listening.track == track
assert listening.user == logged_in_client.user
+
+
+def test_adding_listening_calls_activity_record(
+ factories, logged_in_client, activity_muted):
+ track = factories['music.Track']()
+
+ url = reverse('api:v1:history:listenings-list')
+ response = logged_in_client.post(url, {
+ 'track': track.pk,
+ })
+
+ listening = models.Listening.objects.latest('id')
+
+ activity_muted.assert_called_once_with(listening)
diff --git a/api/tests/music/test_activity.py b/api/tests/music/test_activity.py
new file mode 100644
index 000000000..f604874c1
--- /dev/null
+++ b/api/tests/music/test_activity.py
@@ -0,0 +1,17 @@
+from funkwhale_api.users.serializers import UserActivitySerializer
+from funkwhale_api.favorites import serializers
+
+
+
+def test_get_track_activity_url_mbid(factories):
+ track = factories['music.Track']()
+ expected = 'https://musicbrainz.org/recording/{}'.format(
+ track.mbid)
+ assert track.get_activity_url() == expected
+
+
+def test_get_track_activity_url_no_mbid(settings, factories):
+ track = factories['music.Track'](mbid=None)
+ expected = settings.FUNKWHALE_URL + '/tracks/{}'.format(
+ track.pk)
+ assert track.get_activity_url() == expected
diff --git a/api/tests/requests/__init__.py b/api/tests/requests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/users/test_activity.py b/api/tests/users/test_activity.py
new file mode 100644
index 000000000..26d0b11f8
--- /dev/null
+++ b/api/tests/users/test_activity.py
@@ -0,0 +1,22 @@
+from funkwhale_api.users import serializers
+
+
+def test_get_user_activity_url(settings, factories):
+ user = factories['users.User']()
+ assert user.get_activity_url() == '{}/@{}'.format(
+ settings.FUNKWHALE_URL, user.username)
+
+
+def test_activity_user_serializer(factories):
+ user = factories['users.User']()
+
+ expected = {
+ "type": "Person",
+ "id": user.get_activity_url(),
+ "local_id": user.username,
+ "name": user.username,
+ }
+
+ data = serializers.UserActivitySerializer(user).data
+
+ assert data == expected
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 569acbd15..02b903bf4 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -1,4 +1,5 @@
import json
+import pytest
from django.test import RequestFactory
from django.urls import reverse
@@ -116,3 +117,37 @@ def test_changing_password_updates_secret_key(logged_in_client):
assert user.secret_key != secret_key
assert user.password != password
+
+
+def test_user_can_patch_his_own_settings(logged_in_api_client):
+ user = logged_in_api_client.user
+ payload = {
+ 'privacy_level': 'me',
+ }
+ url = reverse(
+ 'api:v1:users:users-detail',
+ kwargs={'username': user.username})
+
+ response = logged_in_api_client.patch(url, payload)
+
+ assert response.status_code == 200
+ user.refresh_from_db()
+
+ assert user.privacy_level == 'me'
+
+
+@pytest.mark.parametrize('method', ['put', 'patch'])
+def test_user_cannot_patch_another_user(
+ method, logged_in_api_client, factories):
+ user = factories['users.User']()
+ payload = {
+ 'privacy_level': 'me',
+ }
+ url = reverse(
+ 'api:v1:users:users-detail',
+ kwargs={'username': user.username})
+
+ handler = getattr(logged_in_api_client, method)
+ response = handler(url, payload)
+
+ assert response.status_code == 403
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 0c3b56547..e6292812e 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -43,7 +43,6 @@ services:
restart: unless-stopped
image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
env_file: .env
- command: ./compose/django/gunicorn.sh
volumes:
- ./data/music:/music:ro
- ./data/media:/app/funkwhale_api/media
diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample
index 6a4b15b67..037dc4651 100644
--- a/deploy/env.prod.sample
+++ b/deploy/env.prod.sample
@@ -2,6 +2,7 @@
# following variables:
# - DJANGO_SECRET_KEY
# - DJANGO_ALLOWED_HOSTS
+# - FUNKWHALE_URL
# Additionaly, on non-docker setup, you'll also have to tweak/uncomment those
# variables:
@@ -28,6 +29,9 @@ FUNKWHALE_VERSION=latest
FUNKWHALE_API_IP=127.0.0.1
FUNKWHALE_API_PORT=5000
+# Replace this by the definitive, public domain you will use for
+# your instance
+FUNKWHALE_URL=https.//yourdomain.funwhale
# API/Django configuration
diff --git a/deploy/funkwhale-server.service b/deploy/funkwhale-server.service
index 7ef6e3897..0027a80ab 100644
--- a/deploy/funkwhale-server.service
+++ b/deploy/funkwhale-server.service
@@ -8,7 +8,7 @@ User=funkwhale
# adapt this depending on the path of your funkwhale installation
WorkingDirectory=/srv/funkwhale/api
EnvironmentFile=/srv/funkwhale/config/.env
-ExecStart=/srv/funkwhale/virtualenv/bin/gunicorn config.wsgi:application -b ${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT}
+ExecStart=/usr/local/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
[Install]
WantedBy=multi-user.target
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index cf3b34056..125fbc6d4 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -19,6 +19,12 @@ server {
location / { return 301 https://$host$request_uri; }
}
+# required for websocket support
+map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+}
+
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
@@ -51,6 +57,11 @@ server {
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
+ # websocket support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
location / {
try_files $uri $uri/ @rewrites;
}
diff --git a/dev.yml b/dev.yml
index e3cd50da7..2c102f3ae 100644
--- a/dev.yml
+++ b/dev.yml
@@ -36,6 +36,7 @@ services:
- C_FORCE_ROOT=true
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
+ - "FUNKWHALE_URL=http://funkwhale.test"
volumes:
- ./api:/app
- ./data/music:/music
@@ -54,6 +55,7 @@ services:
- "DJANGO_SECRET_KEY=dev"
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
+ - "FUNKWHALE_URL=http://funkwhale.test"
links:
- postgres
- redis
diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev
index 29c04fc66..9847c2dcb 100644
--- a/docker/nginx/conf.dev
+++ b/docker/nginx/conf.dev
@@ -28,6 +28,11 @@ http {
#gzip on;
proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off;
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
server {
listen 6001;
charset utf-8;
@@ -40,6 +45,9 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host localhost:8080;
proxy_set_header X-Forwarded-Port 8080;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
location /_protected/media {
diff --git a/front/Dockerfile b/front/Dockerfile
index cdf92446b..60b03c9b8 100644
--- a/front/Dockerfile
+++ b/front/Dockerfile
@@ -3,8 +3,7 @@ FROM node:9
EXPOSE 8080
WORKDIR /app/
ADD package.json .
-RUN yarn install --only=production
-RUN yarn install --only=dev
+RUN yarn install
VOLUME ["/app/node_modules"]
COPY . .
diff --git a/front/config/index.js b/front/config/index.js
index 7ce6e26e1..14cbe3e43 100644
--- a/front/config/index.js
+++ b/front/config/index.js
@@ -32,6 +32,7 @@ module.exports = {
'/api': {
target: 'http://nginx:6001',
changeOrigin: true,
+ ws: true
},
'/media': {
target: 'http://nginx:6001',
diff --git a/front/package.json b/front/package.json
index d6bdb8c56..201694e43 100644
--- a/front/package.json
+++ b/front/package.json
@@ -17,6 +17,7 @@
"dependencies": {
"axios": "^0.17.1",
"dateformat": "^2.0.0",
+ "django-channels": "^1.1.6",
"js-logger": "^1.3.0",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.4",
diff --git a/front/src/App.vue b/front/src/App.vue
index 3e39d7262..b26959fe7 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -33,6 +33,9 @@
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 86ec57819..f5229b207 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -36,6 +36,9 @@