Merge branch 'release/0.6'
This commit is contained in:
commit
37b6dd406f
|
@ -22,7 +22,7 @@ test_api:
|
||||||
variables:
|
variables:
|
||||||
DJANGO_ALLOWED_HOSTS: "localhost"
|
DJANGO_ALLOWED_HOSTS: "localhost"
|
||||||
DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||||
|
FUNKWHALE_URL: "https://funkwhale.ci"
|
||||||
before_script:
|
before_script:
|
||||||
- cd api
|
- cd api
|
||||||
- pip install -r requirements/base.txt
|
- pip install -r requirements/base.txt
|
||||||
|
|
91
CHANGELOG
91
CHANGELOG
|
@ -3,6 +3,97 @@ Changelog
|
||||||
|
|
||||||
.. towncrier
|
.. 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)
|
0.5.4 (2018-02-28)
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,11 @@ ENV PYTHONUNBUFFERED 1
|
||||||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
# 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
|
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
|
||||||
COPY ./requirements.apt /requirements.apt
|
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
|
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
|
COPY ./requirements/base.txt /requirements/base.txt
|
||||||
RUN pip install -r /requirements/base.txt
|
RUN pip install -r /requirements/base.txt
|
||||||
|
@ -20,3 +24,4 @@ RUN pip install --upgrade youtube-dl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENTRYPOINT ["./compose/django/entrypoint.sh"]
|
ENTRYPOINT ["./compose/django/entrypoint.sh"]
|
||||||
|
CMD ["./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
|
|
@ -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
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import django
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||||
|
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from .routing import application
|
|
@ -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),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
|
@ -25,11 +25,12 @@ except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
|
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
|
||||||
|
FUNKWHALE_URL = env('FUNKWHALE_URL')
|
||||||
|
|
||||||
# APP CONFIGURATION
|
# APP CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
DJANGO_APPS = (
|
DJANGO_APPS = (
|
||||||
|
'channels',
|
||||||
# Default Django apps:
|
# Default Django apps:
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
|
@ -82,6 +83,7 @@ if RAVEN_ENABLED:
|
||||||
# Apps specific for this project go here.
|
# Apps specific for this project go here.
|
||||||
LOCAL_APPS = (
|
LOCAL_APPS = (
|
||||||
'funkwhale_api.common',
|
'funkwhale_api.common',
|
||||||
|
'funkwhale_api.activity.apps.ActivityConfig',
|
||||||
'funkwhale_api.users', # custom users app
|
'funkwhale_api.users', # custom users app
|
||||||
# Your stuff: custom apps go here
|
# Your stuff: custom apps go here
|
||||||
'funkwhale_api.instance',
|
'funkwhale_api.instance',
|
||||||
|
@ -253,9 +255,9 @@ MEDIA_URL = env("MEDIA_URL", default='/media/')
|
||||||
# URL Configuration
|
# URL Configuration
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
ROOT_URLCONF = 'config.urls'
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
|
||||||
WSGI_APPLICATION = 'config.wsgi.application'
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
ASGI_APPLICATION = "config.routing.application"
|
||||||
|
|
||||||
# AUTHENTICATION CONFIGURATION
|
# AUTHENTICATION CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
@ -284,6 +286,17 @@ CACHES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
|
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"] = {
|
CACHES["default"]["OPTIONS"] = {
|
||||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||||
"IGNORE_EXCEPTIONS": True, # mimics memcache behavior.
|
"IGNORE_EXCEPTIONS": True, # mimics memcache behavior.
|
||||||
|
|
|
@ -58,8 +58,6 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
||||||
|
|
||||||
# END SITE CONFIGURATION
|
# END SITE CONFIGURATION
|
||||||
|
|
||||||
INSTALLED_APPS += ("gunicorn", )
|
|
||||||
|
|
||||||
# STORAGE CONFIGURATION
|
# STORAGE CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Uploaded Media Files
|
# Uploaded Media Files
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
FROM python:3.5
|
FROM python:3.5
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED 1
|
ENV PYTHONUNBUFFERED 1
|
||||||
ENV PYTHONDONTWRITEBYTECODE 1
|
|
||||||
|
|
||||||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
# 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
|
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
|
||||||
COPY ./requirements.apt /requirements.apt
|
COPY ./requirements.apt /requirements.apt
|
||||||
COPY ./install_os_dependencies.sh /install_os_dependencies.sh
|
RUN apt-get update; \
|
||||||
RUN bash install_os_dependencies.sh install
|
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 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
|
RUN mkdir /requirements
|
||||||
|
@ -18,4 +21,5 @@ RUN pip install -r /requirements/local.txt
|
||||||
COPY ./requirements/test.txt /requirements/test.txt
|
COPY ./requirements/test.txt /requirements/test.txt
|
||||||
RUN pip install -r /requirements/test.txt
|
RUN pip install -r /requirements/test.txt
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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('.')])
|
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
|
||||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
||||||
|
})
|
|
@ -1,8 +1,10 @@
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from funkwhale_api.music.models import Track
|
from funkwhale_api.music.models import Track
|
||||||
|
|
||||||
|
|
||||||
class TrackFavorite(models.Model):
|
class TrackFavorite(models.Model):
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
|
@ -18,3 +20,7 @@ class TrackFavorite(models.Model):
|
||||||
def add(cls, track, user):
|
def add(cls, track, user):
|
||||||
favorite, created = cls.objects.get_or_create(user=user, track=track)
|
favorite, created = cls.objects.get_or_create(user=user, track=track)
|
||||||
return favorite
|
return favorite
|
||||||
|
|
||||||
|
def get_activity_url(self):
|
||||||
|
return '{}/favorites/tracks/{}'.format(
|
||||||
|
self.user.get_activity_url(), self.pk)
|
||||||
|
|
|
@ -1,10 +1,39 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from rest_framework import serializers
|
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 TrackSerializerNested
|
||||||
|
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||||
|
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||||
|
|
||||||
from . import models
|
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):
|
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
|
||||||
# track = TrackSerializerNested(read_only=True)
|
# track = TrackSerializerNested(read_only=True)
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -4,6 +4,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework import pagination
|
from rest_framework import pagination
|
||||||
from rest_framework.decorators import list_route
|
from rest_framework.decorators import list_route
|
||||||
|
|
||||||
|
from funkwhale_api.activity import record
|
||||||
from funkwhale_api.music.models import Track
|
from funkwhale_api.music.models import Track
|
||||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||||
|
|
||||||
|
@ -33,6 +34,7 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin,
|
||||||
instance = self.perform_create(serializer)
|
instance = self.perform_create(serializer)
|
||||||
serializer = self.get_serializer(instance=instance)
|
serializer = self.get_serializer(instance=instance)
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
record.send(instance)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|
|
@ -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
|
||||||
|
})
|
|
@ -25,3 +25,8 @@ class Listening(models.Model):
|
||||||
raise ValidationError('Cannot have both session_key and user empty for listening')
|
raise ValidationError('Cannot have both session_key and user empty for listening')
|
||||||
|
|
||||||
super().save(**kwargs)
|
super().save(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_url(self):
|
||||||
|
return '{}/listenings/tracks/{}'.format(
|
||||||
|
self.user.get_activity_url(), self.pk)
|
||||||
|
|
|
@ -1,9 +1,37 @@
|
||||||
from rest_framework import serializers
|
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 TrackSerializerNested
|
||||||
|
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||||
|
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||||
|
|
||||||
from . import models
|
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 ListeningSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -3,8 +3,9 @@ from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.decorators import detail_route
|
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.common.permissions import ConditionalAuthentication
|
||||||
|
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
@ -17,6 +18,12 @@ class ListeningViewSet(mixins.CreateModelMixin,
|
||||||
queryset = models.Listening.objects.all()
|
queryset = models.Listening.objects.all()
|
||||||
permission_classes = [ConditionalAuthentication]
|
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):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
|
|
|
@ -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'])
|
|
@ -360,6 +360,12 @@ class Track(APIModelMixin):
|
||||||
self.title,
|
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):
|
class TrackFile(models.Model):
|
||||||
track = models.ForeignKey(
|
track = models.ForeignKey(
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
|
from funkwhale_api.activity import serializers as activity_serializers
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
@ -127,3 +129,24 @@ class ImportBatchSerializer(serializers.ModelSerializer):
|
||||||
model = models.ImportBatch
|
model = models.ImportBatch
|
||||||
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
|
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
|
||||||
read_only_fields = ('creation_date',)
|
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'
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -3,6 +3,7 @@ from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.db import models
|
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 _
|
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
|
@python_2_unicode_compatible
|
||||||
class User(AbstractUser):
|
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):
|
def __str__(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
@ -43,3 +55,6 @@ class User(AbstractUser):
|
||||||
def set_password(self, raw_password):
|
def set_password(self, raw_password):
|
||||||
super().set_password(raw_password)
|
super().set_password(raw_password)
|
||||||
self.update_secret_key()
|
self.update_secret_key()
|
||||||
|
|
||||||
|
def get_activity_url(self):
|
||||||
|
return settings.FUNKWHALE_URL + '/@{}'.format(self.username)
|
||||||
|
|
|
@ -1,15 +1,44 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from funkwhale_api.activity import serializers as activity_serializers
|
||||||
|
|
||||||
from . import models
|
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 UserBasicSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ['id', 'username', 'name', 'date_joined']
|
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()
|
permissions = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@ -24,6 +53,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||||
'is_superuser',
|
'is_superuser',
|
||||||
'permissions',
|
'permissions',
|
||||||
'date_joined',
|
'date_joined',
|
||||||
|
'privacy_level'
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_permissions(self, o):
|
def get_permissions(self, o):
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import mixins
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
from rest_framework.decorators import list_route
|
from rest_framework.decorators import list_route
|
||||||
|
|
||||||
|
@ -23,12 +24,25 @@ class RegisterView(BaseRegisterView):
|
||||||
return get_adapter().is_open_for_signup(request)
|
return get_adapter().is_open_for_signup(request)
|
||||||
|
|
||||||
|
|
||||||
class UserViewSet(viewsets.GenericViewSet):
|
class UserViewSet(
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
|
viewsets.GenericViewSet):
|
||||||
queryset = models.User.objects.all()
|
queryset = models.User.objects.all()
|
||||||
serializer_class = serializers.UserSerializer
|
serializer_class = serializers.UserWriteSerializer
|
||||||
|
lookup_field = 'username'
|
||||||
|
|
||||||
@list_route(methods=['get'])
|
@list_route(methods=['get'])
|
||||||
def me(self, request, *args, **kwargs):
|
def me(self, request, *args, **kwargs):
|
||||||
"""Return information about the current user"""
|
"""Return information about the current user"""
|
||||||
serializer = self.serializer_class(request.user)
|
serializer = serializers.UserReadSerializer(request.user)
|
||||||
return Response(serializer.data)
|
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)
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
build-essential
|
build-essential
|
||||||
gettext
|
curl
|
||||||
zlib1g-dev
|
ffmpeg
|
||||||
libjpeg-dev
|
libjpeg-dev
|
||||||
zlib1g-dev
|
libmagic-dev
|
||||||
libpq-dev
|
libpq-dev
|
||||||
postgresql-client
|
postgresql-client
|
||||||
libmagic-dev
|
|
||||||
ffmpeg
|
|
||||||
python3-dev
|
python3-dev
|
||||||
curl
|
|
||||||
|
|
|
@ -59,3 +59,5 @@ pyacoustid>=1.1.5,<1.2
|
||||||
raven>=6.5,<7
|
raven>=6.5,<7
|
||||||
python-magic==0.4.15
|
python-magic==0.4.15
|
||||||
ffmpeg-python==0.1.10
|
ffmpeg-python==0.1.10
|
||||||
|
channels>=2,<2.1
|
||||||
|
channels_redis>=2.1,<2.2
|
||||||
|
|
|
@ -4,7 +4,4 @@
|
||||||
# WSGI Handler
|
# WSGI Handler
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
|
|
||||||
# there's no python 3 support in stable, have to use the latest release candidate for gevent
|
daphne==2.0.4
|
||||||
gevent==1.1rc1
|
|
||||||
|
|
||||||
gunicorn==19.4.1
|
|
||||||
|
|
|
@ -12,5 +12,6 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- "DJANGO_ALLOWED_HOSTS=localhost"
|
- "DJANGO_ALLOWED_HOSTS=localhost"
|
||||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||||
|
- "FUNKWHALE_URL=https://funkwhale.test"
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres
|
image: postgres
|
||||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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()
|
|
@ -5,6 +5,7 @@ from django.core.cache import cache as django_cache
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from funkwhale_api.activity import record
|
||||||
from funkwhale_api.taskapp import celery
|
from funkwhale_api.taskapp import celery
|
||||||
|
|
||||||
|
|
||||||
|
@ -81,3 +82,28 @@ def superuser_client(db, factories, client):
|
||||||
setattr(client, 'user', user)
|
setattr(client, 'user', user)
|
||||||
yield client
|
yield client
|
||||||
delattr(client, 'user')
|
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')
|
||||||
|
|
|
@ -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()
|
|
@ -33,7 +33,8 @@ def test_user_can_get_his_favorites(factories, logged_in_client, client):
|
||||||
assert expected == parsed_json['results']
|
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']()
|
track = factories['music.Track']()
|
||||||
url = reverse('api:v1:favorites:tracks-list')
|
url = reverse('api:v1:favorites:tracks-list')
|
||||||
response = logged_in_client.post(url, {'track': track.pk})
|
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
|
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):
|
def test_user_can_remove_favorite_via_api(logged_in_client, factories, client):
|
||||||
favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
|
favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
|
||||||
url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})
|
url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})
|
|
@ -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()
|
|
@ -28,7 +28,8 @@ def test_anonymous_user_can_create_listening_via_api(client, factories, settings
|
||||||
assert listening.session_key == client.session.session_key
|
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']()
|
track = factories['music.Track']()
|
||||||
|
|
||||||
url = reverse('api:v1:history:listenings-list')
|
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.track == track
|
||||||
assert listening.user == logged_in_client.user
|
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)
|
|
@ -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
|
|
@ -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
|
|
@ -1,4 +1,5 @@
|
||||||
import json
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
from django.test import RequestFactory
|
from django.test import RequestFactory
|
||||||
from django.urls import reverse
|
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.secret_key != secret_key
|
||||||
assert user.password != password
|
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
|
||||||
|
|
|
@ -43,7 +43,6 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
|
image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
|
||||||
env_file: .env
|
env_file: .env
|
||||||
command: ./compose/django/gunicorn.sh
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/music:/music:ro
|
- ./data/music:/music:ro
|
||||||
- ./data/media:/app/funkwhale_api/media
|
- ./data/media:/app/funkwhale_api/media
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
# following variables:
|
# following variables:
|
||||||
# - DJANGO_SECRET_KEY
|
# - DJANGO_SECRET_KEY
|
||||||
# - DJANGO_ALLOWED_HOSTS
|
# - DJANGO_ALLOWED_HOSTS
|
||||||
|
# - FUNKWHALE_URL
|
||||||
|
|
||||||
# Additionaly, on non-docker setup, you'll also have to tweak/uncomment those
|
# Additionaly, on non-docker setup, you'll also have to tweak/uncomment those
|
||||||
# variables:
|
# variables:
|
||||||
|
@ -28,6 +29,9 @@ FUNKWHALE_VERSION=latest
|
||||||
FUNKWHALE_API_IP=127.0.0.1
|
FUNKWHALE_API_IP=127.0.0.1
|
||||||
FUNKWHALE_API_PORT=5000
|
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
|
# API/Django configuration
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ User=funkwhale
|
||||||
# adapt this depending on the path of your funkwhale installation
|
# adapt this depending on the path of your funkwhale installation
|
||||||
WorkingDirectory=/srv/funkwhale/api
|
WorkingDirectory=/srv/funkwhale/api
|
||||||
EnvironmentFile=/srv/funkwhale/config/.env
|
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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
|
@ -19,6 +19,12 @@ server {
|
||||||
location / { return 301 https://$host$request_uri; }
|
location / { return 301 https://$host$request_uri; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# required for websocket support
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl http2;
|
listen [::]:443 ssl http2;
|
||||||
|
@ -51,6 +57,11 @@ server {
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
|
# websocket support
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ @rewrites;
|
try_files $uri $uri/ @rewrites;
|
||||||
}
|
}
|
||||||
|
|
2
dev.yml
2
dev.yml
|
@ -36,6 +36,7 @@ services:
|
||||||
- C_FORCE_ROOT=true
|
- C_FORCE_ROOT=true
|
||||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||||
- "CACHE_URL=redis://redis:6379/0"
|
- "CACHE_URL=redis://redis:6379/0"
|
||||||
|
- "FUNKWHALE_URL=http://funkwhale.test"
|
||||||
volumes:
|
volumes:
|
||||||
- ./api:/app
|
- ./api:/app
|
||||||
- ./data/music:/music
|
- ./data/music:/music
|
||||||
|
@ -54,6 +55,7 @@ services:
|
||||||
- "DJANGO_SECRET_KEY=dev"
|
- "DJANGO_SECRET_KEY=dev"
|
||||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||||
- "CACHE_URL=redis://redis:6379/0"
|
- "CACHE_URL=redis://redis:6379/0"
|
||||||
|
- "FUNKWHALE_URL=http://funkwhale.test"
|
||||||
links:
|
links:
|
||||||
- postgres
|
- postgres
|
||||||
- redis
|
- redis
|
||||||
|
|
|
@ -28,6 +28,11 @@ http {
|
||||||
#gzip on;
|
#gzip on;
|
||||||
proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off;
|
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 {
|
server {
|
||||||
listen 6001;
|
listen 6001;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
@ -40,6 +45,9 @@ http {
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host localhost:8080;
|
proxy_set_header X-Forwarded-Host localhost:8080;
|
||||||
proxy_set_header X-Forwarded-Port 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;
|
proxy_redirect off;
|
||||||
|
|
||||||
location /_protected/media {
|
location /_protected/media {
|
||||||
|
|
|
@ -3,8 +3,7 @@ FROM node:9
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
WORKDIR /app/
|
WORKDIR /app/
|
||||||
ADD package.json .
|
ADD package.json .
|
||||||
RUN yarn install --only=production
|
RUN yarn install
|
||||||
RUN yarn install --only=dev
|
|
||||||
VOLUME ["/app/node_modules"]
|
VOLUME ["/app/node_modules"]
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,7 @@ module.exports = {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://nginx:6001',
|
target: 'http://nginx:6001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true
|
||||||
},
|
},
|
||||||
'/media': {
|
'/media': {
|
||||||
target: 'http://nginx:6001',
|
target: 'http://nginx:6001',
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.17.1",
|
"axios": "^0.17.1",
|
||||||
"dateformat": "^2.0.0",
|
"dateformat": "^2.0.0",
|
||||||
|
"django-channels": "^1.1.6",
|
||||||
"js-logger": "^1.3.0",
|
"js-logger": "^1.3.0",
|
||||||
"jwt-decode": "^2.2.0",
|
"jwt-decode": "^2.2.0",
|
||||||
"lodash": "^4.17.4",
|
"lodash": "^4.17.4",
|
||||||
|
|
|
@ -33,6 +33,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { WebSocketBridge } from 'django-channels'
|
||||||
|
|
||||||
|
import logger from '@/logging'
|
||||||
import Sidebar from '@/components/Sidebar'
|
import Sidebar from '@/components/Sidebar'
|
||||||
import Raven from '@/components/Raven'
|
import Raven from '@/components/Raven'
|
||||||
|
|
||||||
|
@ -44,6 +47,31 @@ export default {
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.$store.dispatch('instance/fetchSettings')
|
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>
|
</script>
|
||||||
|
|
|
@ -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" 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: '/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 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>
|
</div>
|
||||||
<div v-if="queue.previousQueue " class="ui black icon message">
|
<div v-if="queue.previousQueue " class="ui black icon message">
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -2,12 +2,35 @@
|
||||||
<div class="main pusher">
|
<div class="main pusher">
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<div class="ui small text container">
|
<div class="ui small text container">
|
||||||
<h2>Change my password</h2>
|
<h2 class="ui header">Account settings</h2>
|
||||||
<form class="ui form" @submit.prevent="submit()">
|
<form class="ui form" @submit.prevent="submitSettings()">
|
||||||
<div v-if="error" class="ui negative message">
|
<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>
|
<div class="header">Cannot change your password</div>
|
||||||
<ul class="list">
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
@ -36,22 +59,68 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import $ from 'jquery'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data () {
|
data () {
|
||||||
return {
|
let d = {
|
||||||
// We need to initialize the component with any
|
// We need to initialize the component with any
|
||||||
// properties that will be used in it
|
// properties that will be used in it
|
||||||
old_password: '',
|
old_password: '',
|
||||||
new_password: '',
|
new_password: '',
|
||||||
error: '',
|
passwordError: '',
|
||||||
isLoading: false
|
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: {
|
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
|
var self = this
|
||||||
self.isLoading = true
|
self.isLoading = true
|
||||||
this.error = ''
|
this.error = ''
|
||||||
|
@ -70,13 +139,30 @@ export default {
|
||||||
}})
|
}})
|
||||||
}, error => {
|
}, error => {
|
||||||
if (error.response.status === 400) {
|
if (error.response.status === 400) {
|
||||||
self.error = 'invalid_credentials'
|
self.passwordError = 'invalid_credentials'
|
||||||
} else {
|
} else {
|
||||||
self.error = 'unknown_error'
|
self.passwordError = 'unknown_error'
|
||||||
}
|
}
|
||||||
self.isLoading = false
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -100,24 +100,9 @@ export default {
|
||||||
username: this.username
|
username: this.username
|
||||||
}})
|
}})
|
||||||
}, error => {
|
}, error => {
|
||||||
self.errors = this.getErrors(error.response)
|
self.errors = error.backendErrors
|
||||||
self.isLoading = false
|
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: {
|
computed: {
|
||||||
|
|
|
@ -1,8 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<time :datetime="date" :title="date | moment">{{ date | ago }}</time>
|
<time :datetime="date" :title="date | moment">{{ realDate | ago }}</time>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
|
import {mapState} from 'vuex'
|
||||||
export default {
|
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>
|
</script>
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
<template>
|
||||||
|
<span>{{ username }}</span>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['username']
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -4,4 +4,8 @@ import HumanDate from '@/components/common/HumanDate'
|
||||||
|
|
||||||
Vue.component('human-date', HumanDate)
|
Vue.component('human-date', HumanDate)
|
||||||
|
|
||||||
|
import Username from '@/components/common/Username'
|
||||||
|
|
||||||
|
Vue.component('username', Username)
|
||||||
|
|
||||||
export default {}
|
export default {}
|
||||||
|
|
|
@ -62,12 +62,18 @@ export default {
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
batch: null
|
batch: null,
|
||||||
|
timeout: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
},
|
},
|
||||||
|
destroyed () {
|
||||||
|
if (this.timeout) {
|
||||||
|
clearTimeout(this.timeout)
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
fetchData () {
|
fetchData () {
|
||||||
var self = this
|
var self = this
|
||||||
|
@ -78,7 +84,7 @@ export default {
|
||||||
self.batch = response.data
|
self.batch = response.data
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
if (self.batch.status === 'pending') {
|
if (self.batch.status === 'pending') {
|
||||||
setTimeout(
|
self.timeout = setTimeout(
|
||||||
self.fetchData,
|
self.fetchData,
|
||||||
5000
|
5000
|
||||||
)
|
)
|
||||||
|
|
|
@ -47,11 +47,28 @@ axios.interceptors.request.use(function (config) {
|
||||||
axios.interceptors.response.use(function (response) {
|
axios.interceptors.response.use(function (response) {
|
||||||
return response
|
return response
|
||||||
}, function (error) {
|
}, function (error) {
|
||||||
|
error.backendErrors = []
|
||||||
if (error.response.status === 401) {
|
if (error.response.status === 401) {
|
||||||
store.commit('auth/authenticated', false)
|
store.commit('auth/authenticated', false)
|
||||||
logger.default.warn('Received 401 response from API, redirecting to login form')
|
logger.default.warn('Received 401 response from API, redirecting to login form')
|
||||||
router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
|
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
|
// Do something with response error
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
})
|
})
|
||||||
|
|
|
@ -3,6 +3,7 @@ import Router from 'vue-router'
|
||||||
import PageNotFound from '@/components/PageNotFound'
|
import PageNotFound from '@/components/PageNotFound'
|
||||||
import About from '@/components/About'
|
import About from '@/components/About'
|
||||||
import Home from '@/components/Home'
|
import Home from '@/components/Home'
|
||||||
|
import InstanceTimeline from '@/views/instance/Timeline'
|
||||||
import Login from '@/components/auth/Login'
|
import Login from '@/components/auth/Login'
|
||||||
import Signup from '@/components/auth/Signup'
|
import Signup from '@/components/auth/Signup'
|
||||||
import Profile from '@/components/auth/Profile'
|
import Profile from '@/components/auth/Profile'
|
||||||
|
@ -39,6 +40,11 @@ export default new Router({
|
||||||
name: 'about',
|
name: 'about',
|
||||||
component: About
|
component: About
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/activity',
|
||||||
|
name: 'activity',
|
||||||
|
component: InstanceTimeline
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
|
|
|
@ -8,11 +8,13 @@ import instance from './instance'
|
||||||
import queue from './queue'
|
import queue from './queue'
|
||||||
import radios from './radios'
|
import radios from './radios'
|
||||||
import player from './player'
|
import player from './player'
|
||||||
|
import ui from './ui'
|
||||||
|
|
||||||
Vue.use(Vuex)
|
Vue.use(Vuex)
|
||||||
|
|
||||||
export default new Vuex.Store({
|
export default new Vuex.Store({
|
||||||
modules: {
|
modules: {
|
||||||
|
ui,
|
||||||
auth,
|
auth,
|
||||||
favorites,
|
favorites,
|
||||||
instance,
|
instance,
|
||||||
|
@ -28,6 +30,10 @@ export default new Vuex.Store({
|
||||||
return mutation.type.startsWith('auth/')
|
return mutation.type.startsWith('auth/')
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
createPersistedState({
|
||||||
|
key: 'instance',
|
||||||
|
paths: ['instance.events']
|
||||||
|
}),
|
||||||
createPersistedState({
|
createPersistedState({
|
||||||
key: 'radios',
|
key: 'radios',
|
||||||
paths: ['radios'],
|
paths: ['radios'],
|
||||||
|
|
|
@ -5,6 +5,8 @@ import _ from 'lodash'
|
||||||
export default {
|
export default {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
state: {
|
state: {
|
||||||
|
maxEvents: 200,
|
||||||
|
events: [],
|
||||||
settings: {
|
settings: {
|
||||||
instance: {
|
instance: {
|
||||||
name: {
|
name: {
|
||||||
|
@ -35,6 +37,12 @@ export default {
|
||||||
mutations: {
|
mutations: {
|
||||||
settings: (state, value) => {
|
settings: (state, value) => {
|
||||||
_.merge(state.settings, 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: {
|
actions: {
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
lastDate: new Date()
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
computeLastDate: (state) => {
|
||||||
|
state.lastDate = new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
Loading…
Reference in New Issue