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 @@ Login Browse library Favorites + Activity
diff --git a/front/src/components/activity/Like.vue b/front/src/components/activity/Like.vue new file mode 100644 index 000000000..ffb831278 --- /dev/null +++ b/front/src/components/activity/Like.vue @@ -0,0 +1,35 @@ + + + + + + diff --git a/front/src/components/activity/Listen.vue b/front/src/components/activity/Listen.vue new file mode 100644 index 000000000..7c8ee8a69 --- /dev/null +++ b/front/src/components/activity/Listen.vue @@ -0,0 +1,35 @@ + + + + + + diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 4e8f33289..8cd03d755 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -2,12 +2,35 @@
-

Change my password

-
-
+

Account settings

+ +
+
Settings updated
+
+
+
We cannot save your settings
+
    +
  • {{ error }}
  • +
+
+
+ +

{{ f.help }}

+ +
+ + +
+ +
+

Change my password

+
+
Cannot change your password
    -
  • Please double-check your password is correct
  • +
  • Please double-check your password is correct
@@ -36,22 +59,68 @@ diff --git a/front/src/components/common/Username.vue b/front/src/components/common/Username.vue new file mode 100644 index 000000000..17fb34925 --- /dev/null +++ b/front/src/components/common/Username.vue @@ -0,0 +1,8 @@ + + diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 40315bc47..b1d7d6104 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -4,4 +4,8 @@ import HumanDate from '@/components/common/HumanDate' Vue.component('human-date', HumanDate) +import Username from '@/components/common/Username' + +Vue.component('username', Username) + export default {} diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue index 417fd55c2..0864d2464 100644 --- a/front/src/components/library/import/BatchDetail.vue +++ b/front/src/components/library/import/BatchDetail.vue @@ -62,12 +62,18 @@ export default { data () { return { isLoading: true, - batch: null + batch: null, + timeout: null } }, created () { this.fetchData() }, + destroyed () { + if (this.timeout) { + clearTimeout(this.timeout) + } + }, methods: { fetchData () { var self = this @@ -78,7 +84,7 @@ export default { self.batch = response.data self.isLoading = false if (self.batch.status === 'pending') { - setTimeout( + self.timeout = setTimeout( self.fetchData, 5000 ) diff --git a/front/src/main.js b/front/src/main.js index caf924188..f20af42bf 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -47,11 +47,28 @@ axios.interceptors.request.use(function (config) { axios.interceptors.response.use(function (response) { return response }, function (error) { + error.backendErrors = [] if (error.response.status === 401) { store.commit('auth/authenticated', false) logger.default.warn('Received 401 response from API, redirecting to login form') router.push({name: 'login', query: {next: router.currentRoute.fullPath}}) } + if (error.response.status === 404) { + error.backendErrors.push('Resource not found') + } else if (error.response.status === 500) { + error.backendErrors.push('A server error occured') + } else if (error.response.data) { + for (var field in error.response.data) { + if (error.response.data.hasOwnProperty(field)) { + error.response.data[field].forEach(e => { + error.backendErrors.push(e) + }) + } + } + } + if (error.backendErrors.length === 0) { + error.backendErrors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running') + } // Do something with response error return Promise.reject(error) }) diff --git a/front/src/router/index.js b/front/src/router/index.js index 827afc218..ba9aadd98 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -3,6 +3,7 @@ import Router from 'vue-router' import PageNotFound from '@/components/PageNotFound' import About from '@/components/About' import Home from '@/components/Home' +import InstanceTimeline from '@/views/instance/Timeline' import Login from '@/components/auth/Login' import Signup from '@/components/auth/Signup' import Profile from '@/components/auth/Profile' @@ -39,6 +40,11 @@ export default new Router({ name: 'about', component: About }, + { + path: '/activity', + name: 'activity', + component: InstanceTimeline + }, { path: '/login', name: 'login', diff --git a/front/src/store/index.js b/front/src/store/index.js index 74f9d42b1..2453c0e71 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -8,11 +8,13 @@ import instance from './instance' import queue from './queue' import radios from './radios' import player from './player' +import ui from './ui' Vue.use(Vuex) export default new Vuex.Store({ modules: { + ui, auth, favorites, instance, @@ -28,6 +30,10 @@ export default new Vuex.Store({ return mutation.type.startsWith('auth/') } }), + createPersistedState({ + key: 'instance', + paths: ['instance.events'] + }), createPersistedState({ key: 'radios', paths: ['radios'], diff --git a/front/src/store/instance.js b/front/src/store/instance.js index a4dfcada6..2436eab07 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -5,6 +5,8 @@ import _ from 'lodash' export default { namespaced: true, state: { + maxEvents: 200, + events: [], settings: { instance: { name: { @@ -35,6 +37,12 @@ export default { mutations: { settings: (state, value) => { _.merge(state.settings, value) + }, + event: (state, value) => { + state.events.unshift(value) + if (state.events.length > state.maxEvents) { + state.events = state.events.slice(0, state.maxEvents) + } } }, actions: { diff --git a/front/src/store/ui.js b/front/src/store/ui.js new file mode 100644 index 000000000..f0935e491 --- /dev/null +++ b/front/src/store/ui.js @@ -0,0 +1,12 @@ + +export default { + namespaced: true, + state: { + lastDate: new Date() + }, + mutations: { + computeLastDate: (state) => { + state.lastDate = new Date() + } + } +} diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue new file mode 100644 index 000000000..b959c25d6 --- /dev/null +++ b/front/src/views/instance/Timeline.vue @@ -0,0 +1,52 @@ + + + + + +