diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0fa450c46c763c54153855a1733a1e92690933b3..e4accd722d66823a067fe8f2c1212746bc18b076 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/api/Dockerfile b/api/Dockerfile index 5d4e858574a0063b756a168f48b529e904bc427b..9296785eef25d9b5a14cd9e140b8461ba188eeff 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 0000000000000000000000000000000000000000..16b4d50b800d8177bafe5027aa571509c0d2c149 --- /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 014f173e335808e24af6ff776d0c2cb7512978f0..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..b976a02ebdde045933f4a2dd1997abf2ebdadddb --- /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 0000000000000000000000000000000000000000..574d5a18e2eba402b7b3c7620f195d83767b632e --- /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 f5ddec00b1da2f591f1a651e1b9c60427d1f4da1..bff43b233481b45a91c1e2ff53d1e235c6743838 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 df15d325f22d8d78616c937a3142b4a11b34ded8..f238c2d20b9e2de5a2baf8136aa7eca1f6fe40d8 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 069b89c2f83de1628d485374545f908fe52ec93c..00638e9dd3cb5d290d336698a842f826d73583e9 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/activity/__init__.py b/api/funkwhale_api/activity/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/activity/apps.py b/api/funkwhale_api/activity/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..0c66cbf50cf2542499ccce1fbdb262a13600324d --- /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 0000000000000000000000000000000000000000..fa55c0e85288318acd2f3d41ab02de9805eb8632 --- /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 0000000000000000000000000000000000000000..325d1e820db5699abca69b57b3421b0e0ca1d68b --- /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 0000000000000000000000000000000000000000..6f99b3bba26b36fbce61dd03fb608cb2cb61f6ba --- /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 0000000000000000000000000000000000000000..a009ab5abf4bce698897cbacef0b263a49d5208e --- /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 0000000000000000000000000000000000000000..300ce5e26e50068c88e91edd5dcedef5ce3de412 --- /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 0000000000000000000000000000000000000000..a2dbc4e2fa69afae2b8677f166ea72d8a9118f6a --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/favorites/models.py b/api/funkwhale_api/favorites/models.py index 899ed9cff7f403bc2089e34cb3ff313ebaceff55..0c6a6b11c6e86083c63a49dfbdf45021554e380c 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 57af4570e0df7669bfce087433cdcab245495d5e..276b0f6bde6d19120998a21f58f44a5dd3ffbd01 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 08ae00b684c30bcf5df91d5438dae1c03c790259..d874c9e1e626b9b30101088217336735d544a0e1 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 0000000000000000000000000000000000000000..e478f9b7f677a4d4e524e343e84b0a08420bbcb3 --- /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 f7f62de62a2f59a08edd9f0a9be28bed65c102fb..56310ddc0d2546784bed02952fe6142a2d139858 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 64bdf41c67164b3e8cd18551be557771909845f2..7a2280cea2a236357982f309b7218c3e0d073299 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 59dcbd26b8449abef324273219a961d44f39f489..d5cbe316ba88b455755cdbaf843086f7c99c09f3 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 0000000000000000000000000000000000000000..eee5f7f0e38641bfd2d9c16fce49c8cd10d8c8a9 --- /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 97992fc8f12cafa7af7cb99c7cbfb1cee2b42192..7138dcdd6d51f52568649d06fc34eb655d64e4d0 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 db6298a9e446eddc64bc17f95e3a6b75bf126573..48419bbe45675aad4e6ad271e9be420219f7d671 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 0000000000000000000000000000000000000000..81891eb0f0abc85657e6e16ec185c697c257cfe5 --- /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 3a0baf11a30c73397b2c31d14fe1ec29d9557a7c..a5478656b178c94037ca232834cc15d36c6b1f9d 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 8c218b1c28acd2811b208e0a3f4b4f0d788c1aea..b21aa69355b2ca4acea883b52e9401055382b6b3 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 b7c1df28f9dba34739756c7c4b666b8cdca88929..7c58363a3ed7fcbdccdd86138d2081a17b564631 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 462a5a705c75b319636449490c6e3c29b089a7f1..224ff955ae45daaf7677d9297bf7c0ee8aba3a84 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 133fcc0cb65f0deb430d73ff5259d05efc215cc2..d402d359137126ae87bb255aa091ca548a61a681 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 42b66eb1535792ae0bb5a8407c0c140df1b90049..4ad8edf940dc4524f77c6ac9b339e55c7b1c5a7c 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 e892dfb178221ccd5ba8ed83558f3a6436b20dad..5e785cb1ac3e20eba418a5d89673a207a84519de 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 0000000000000000000000000000000000000000..41846ba6f109cdc94b24c4e1ab01fb9812065310 --- /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 0000000000000000000000000000000000000000..a2b7eaf0ca685c1c197ed339a5963aeceea215c2 --- /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 0000000000000000000000000000000000000000..f1648efb3a614fb2f74b8e941083f99491043dd0 --- /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 10d7c323512c684ff495bda2a2a7a7ae581213f8..2d655f23f28dd2ea1ad56d4073df1147451c603c 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 0000000000000000000000000000000000000000..63174f9e2693a3fb0923c1aa0c1b682a06b2226a --- /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 8165722eacc969a9ea651dd70815d3fa1d9c75ea..f4a045af825e9431f308903569ac16b07d609692 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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py new file mode 100644 index 0000000000000000000000000000000000000000..b5ab07b8235f12045f5ee1b9fba3fce0c3da57d5 --- /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 113e5ff6405f60665305f04b379997e8b9343d9c..ec8689e9637ca2686452e72d9628a581251e014f 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 0000000000000000000000000000000000000000..f604874c14e75442b9ba50170107a4aa127497a2 --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/users/test_activity.py b/api/tests/users/test_activity.py new file mode 100644 index 0000000000000000000000000000000000000000..26d0b11f8ab42ff7644e688a92e4693f755ff054 --- /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 569acbd15ee5138150dd7de4112cd7ebd2d5523a..02b903bf41ea6bb9169987bd5ed1d147aff2122e 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/changes/changelog.d/23.feature b/changes/changelog.d/23.feature new file mode 100644 index 0000000000000000000000000000000000000000..fbfac0d919543d2bd0bfa05ce4c66f1038bc6509 --- /dev/null +++ b/changes/changelog.d/23.feature @@ -0,0 +1,16 @@ +Basic activity stream for listening and favorites (#23) + +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. diff --git a/changes/changelog.d/34.feature b/changes/changelog.d/34.feature new file mode 100644 index 0000000000000000000000000000000000000000..1403f3dc149cc3e71abba871f6207ab765f3b2d0 --- /dev/null +++ b/changes/changelog.d/34.feature @@ -0,0 +1,59 @@ +Switched to django-channels and daphne for serving HTTP and websocket (#34) + +Upgrade notes +^^^^^^^^^^^^^ + +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. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0c3b565474355632257694bfe84d729a376301da..e6292812e0470156da0f131e735e9ab64c2a9c17 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 6a4b15b67cf04857b87296c30a23060e650f2d7c..037dc4651aae2cace9375b34c563691d572cd984 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 7ef6e389748bac6c972fbd0644412ceb375cb8e5..0027a80ab8e73802f9efa3e2b4febf792052b1bc 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 cf3b34056bc463e56ff9f306c333a908fdca6f60..125fbc6d4190b98e35032b98b0e7ad0c07644892 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 e3cd50da7ab29b8de22783ed686313dc79dac6cd..2c102f3aea33e790f292ac1ec9755648690cc584 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 29c04fc6643c17a8918c9ad4546993d49587336e..9847c2dcbcc71bf3040802d6289c7336ff40c340 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 cdf92446b1c5fde91d3e8511ba5dec9e1743527c..60b03c9b8e3c9ff54cf7dee5f7e4e96273a749b6 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 7ce6e26e1c63ad12fc681a5317158856d30a99c8..14cbe3e4388ea6a5e18a6053a1611ffd0cd6ab38 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 d6bdb8c56caaeb8da80213800537ed255b5c960a..201694e43648e08c6bd23b2fa869ca83c29c7e2d 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 3e39d7262ccec891711e7d6a16c8454a53559aaa..b26959fe7006a72c45cac37e72d468c8f3c614ff 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -33,6 +33,9 @@ </template> <script> +import { WebSocketBridge } from 'django-channels' + +import logger from '@/logging' import Sidebar from '@/components/Sidebar' import Raven from '@/components/Raven' @@ -44,6 +47,31 @@ export default { }, created () { this.$store.dispatch('instance/fetchSettings') + this.openWebsocket() + let self = this + setInterval(() => { + // used to redraw ago dates every minute + self.$store.commit('ui/computeLastDate') + }, 1000 * 60) + }, + methods: { + openWebsocket () { + let self = this + let token = this.$store.state.auth.token + // let token = 'test' + const bridge = new WebSocketBridge() + bridge.connect( + `/api/v1/instance/activity?token=${token}`, + null, + {reconnectInterval: 5000}) + bridge.listen(function (event) { + logger.default.info('Received timeline update', event) + self.$store.commit('instance/event', event) + }) + bridge.socket.addEventListener('open', function () { + console.log('Connected to WebSocket') + }) + } } } </script> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 86ec578194df2d3b5d3dddc28fd4dd085884e5d1..f5229b2075c1811c24fcd758dc8db3611b2724a7 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -36,6 +36,9 @@ <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link> <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link> <router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link> + <router-link + v-if="$store.state.auth.authenticated" + class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link> </div> </div> <div v-if="queue.previousQueue " class="ui black icon message"> diff --git a/front/src/components/activity/Like.vue b/front/src/components/activity/Like.vue new file mode 100644 index 0000000000000000000000000000000000000000..ffb8312787fa366490e0172a0f71dcdbffbfc77d --- /dev/null +++ b/front/src/components/activity/Like.vue @@ -0,0 +1,35 @@ +<template> + <div class="event"> + <div class="label"> + <i class="pink heart icon"></i> + </div> + <div class="content"> + <div class="summary"> + <slot name="user"></slot> + favorited a track + <slot name="date"></slot> + </div> + <div class="extra text"> + <router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link> + <template v-if="event.object.album">from album {{ event.object.album }}, by <em>{{ event.object.artist }}</em> + </template> + <template v-else>, by <em>{{ event.object.artist }}</em> + </template> + + </div> + </div> + </div> + +</template> + +<script> + +export default { + props: ['event'] +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped lang="scss"> + +</style> diff --git a/front/src/components/activity/Listen.vue b/front/src/components/activity/Listen.vue new file mode 100644 index 0000000000000000000000000000000000000000..7c8ee8a69e30666b7fc09e1264e75e6b49ade867 --- /dev/null +++ b/front/src/components/activity/Listen.vue @@ -0,0 +1,35 @@ +<template> + <div class="event"> + <div class="label"> + <i class="orange sound icon"></i> + </div> + <div class="content"> + <div class="summary"> + <slot name="user"></slot> + listened to a track + <slot name="date"></slot> + </div> + <div class="extra text"> + <router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link> + <template v-if="event.object.album">from album {{ event.object.album }}, by <em>{{ event.object.artist }}</em> + </template> + <template v-else>, by <em>{{ event.object.artist }}</em> + </template> + + </div> + </div> + </div> + +</template> + +<script> + +export default { + props: ['event'] +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped lang="scss"> + +</style> diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 4e8f33289b470526539e71ff40f6de32a2857dcb..8cd03d75580aa52f4e7e382a5a6ec0278cb3db92 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -2,12 +2,35 @@ <div class="main pusher"> <div class="ui vertical stripe segment"> <div class="ui small text container"> - <h2>Change my password</h2> - <form class="ui form" @submit.prevent="submit()"> - <div v-if="error" class="ui negative message"> + <h2 class="ui header">Account settings</h2> + <form class="ui form" @submit.prevent="submitSettings()"> + <div v-if="settings.success" class="ui positive message"> + <div class="header">Settings updated</div> + </div> + <div v-if="settings.errors.length > 0" class="ui negative message"> + <div class="header">We cannot save your settings</div> + <ul class="list"> + <li v-for="error in settings.errors">{{ error }}</li> + </ul> + </div> + <div class="field" v-for="f in orderedSettingsFields"> + <label :for="f.id">{{ f.label }}</label> + <p v-if="f.help">{{ f.help }}</p> + <select v-if="f.type === 'dropdown'" class="ui dropdown" v-model="f.value"> + <option :value="c.value" v-for="c in f.choices">{{ c.label }}</option> + </select> + </div> + <button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Update settings</button> + </form> + </div> + <div class="ui hidden divider"></div> + <div class="ui small text container"> + <h2 class="ui header">Change my password</h2> + <form class="ui form" @submit.prevent="submitPassword()"> + <div v-if="passwordError" class="ui negative message"> <div class="header">Cannot change your password</div> <ul class="list"> - <li v-if="error == 'invalid_credentials'">Please double-check your password is correct</li> + <li v-if="passwordError == 'invalid_credentials'">Please double-check your password is correct</li> </ul> </div> <div class="field"> @@ -36,22 +59,68 @@ </template> <script> +import $ from 'jquery' import axios from 'axios' import logger from '@/logging' export default { data () { - return { + let d = { // We need to initialize the component with any // properties that will be used in it old_password: '', new_password: '', - error: '', - isLoading: false + passwordError: '', + isLoading: false, + settings: { + success: false, + errors: [], + order: ['privacy_level'], + fields: { + 'privacy_level': { + type: 'dropdown', + initial: this.$store.state.auth.profile.privacy_level, + label: 'Activity visibility', + help: 'Determine the visibility level of your activity', + choices: [ + { + value: 'me', + label: 'Nobody except me' + }, + { + value: 'instance', + label: 'Everyone on this instance' + } + ] + } + } + } } + d.settings.order.forEach(id => { + d.settings.fields[id].value = d.settings.fields[id].initial + }) + return d + }, + mounted () { + $('select.dropdown').dropdown() }, methods: { - submit () { + submitSettings () { + this.settings.success = false + this.settings.errors = [] + let self = this + let payload = this.settingsValues + let url = `users/users/${this.$store.state.auth.username}/` + return axios.patch(url, payload).then(response => { + logger.default.info('Updated settings successfully') + self.settings.success = true + }, error => { + logger.default.error('Error while updating settings') + self.isLoading = false + self.settings.errors = error.backendErrors + }) + }, + submitPassword () { var self = this self.isLoading = true this.error = '' @@ -70,13 +139,30 @@ export default { }}) }, error => { if (error.response.status === 400) { - self.error = 'invalid_credentials' + self.passwordError = 'invalid_credentials' } else { - self.error = 'unknown_error' + self.passwordError = 'unknown_error' } self.isLoading = false }) } + }, + computed: { + orderedSettingsFields () { + let self = this + return this.settings.order.map(id => { + return self.settings.fields[id] + }) + }, + settingsValues () { + let self = this + let s = {} + this.settings.order.forEach(setting => { + let conf = self.settings.fields[setting] + s[setting] = conf.value + }) + return s + } } } diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue index 13b723d201437933d9f6fd46bcaccfb2d316f5ed..749d2eb0254a5d55b163532afff252302b4d50e6 100644 --- a/front/src/components/auth/Signup.vue +++ b/front/src/components/auth/Signup.vue @@ -100,24 +100,9 @@ export default { username: this.username }}) }, error => { - self.errors = this.getErrors(error.response) + self.errors = error.backendErrors self.isLoading = false }) - }, - getErrors (response) { - let errors = [] - if (response.status !== 400) { - errors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running') - return errors - } - for (var field in response.data) { - if (response.data.hasOwnProperty(field)) { - response.data[field].forEach(e => { - errors.push(e) - }) - } - } - return errors } }, computed: { diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue index ff6ff5c71efa2df05e1467751235ddb15dd67222..9ff8e48bdce8a8d366d7f8394571a7c00a8ec6f8 100644 --- a/front/src/components/common/HumanDate.vue +++ b/front/src/components/common/HumanDate.vue @@ -1,8 +1,20 @@ <template> - <time :datetime="date" :title="date | moment">{{ date | ago }}</time> + <time :datetime="date" :title="date | moment">{{ realDate | ago }}</time> </template> <script> +import {mapState} from 'vuex' export default { - props: ['date'] + props: ['date'], + computed: { + ...mapState({ + lastDate: state => state.ui.lastDate + }), + realDate () { + if (this.lastDate) { + // dummy code to trigger a recompute to update the ago render + } + return this.date + } + } } </script> diff --git a/front/src/components/common/Username.vue b/front/src/components/common/Username.vue new file mode 100644 index 0000000000000000000000000000000000000000..17fb34925c51249bb09f237c582ffbb4a9451c8d --- /dev/null +++ b/front/src/components/common/Username.vue @@ -0,0 +1,8 @@ +<template> + <span>{{ username }}</span> +</template> +<script> +export default { + props: ['username'] +} +</script> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 40315bc47d8efa9731c7133354181e09bbd6cb59..b1d7d61041d31df9f9e47076e6ae273c8119c33b 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 417fd55c2bc6f208208bfb16963d6b1d4d6f5d44..0864d2464abf9a769e9c15d1d6d32b3ebf21f219 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 caf924188086270a8d0e598c9a5307d3fa57c1e2..f20af42bfa7ed7ab3850cc8ebf76d6e4ae0916e7 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 827afc21823687dc266a69c601435015757a9045..ba9aadd981d844972a874a5fe0870a640b2f95e8 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 74f9d42b195be8d38aaa8f6a5745c895f029a346..2453c0e7134124e3f9d9dc3ffac47d2558a9827d 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 a4dfcada65028f27a7fd4c61741b1f14d43d6dcd..2436eab079cd72f11fe48ecf2f64e857bc4f1e58 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 0000000000000000000000000000000000000000..f0935e491bb1a48abc5f90d3482e3d5eab7ee9a5 --- /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 0000000000000000000000000000000000000000..b959c25d66d397fc24c3c4b9d2ab62f26cdfb9fb --- /dev/null +++ b/front/src/views/instance/Timeline.vue @@ -0,0 +1,52 @@ +<template> + <div class="main pusher"> + <div class="ui vertical center aligned stripe segment"> + <div class="ui text container"> + <h1 class="ui header">Recent activity on this instance</h1> + <div class="ui feed"> + <component + class="event" + v-for="(event, index) in events" + :key="event.id + index" + v-if="components[event.type]" + :is="components[event.type]" + :event="event"> + <username + class="user" + :username="event.actor.local_id" + slot="user"></username> + {{ event.published }} + <human-date class="date" :date="event.published" slot="date"></human-date> + </component> + </div> + </div> + </div> + </div> +</template> + +<script> +import {mapState} from 'vuex' + +import Like from '@/components/activity/Like' +import Listen from '@/components/activity/Listen' + +export default { + data () { + return { + components: { + 'Like': Like, + 'Listen': Listen + } + } + }, + computed: { + ...mapState({ + events: state => state.instance.events + }) + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>