Your notifications
+
+
diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 2e7407800..e73321943 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -1,7 +1,13 @@ import uuid +import logging +from django.db import transaction, IntegrityError +from django.utils import timezone + +from funkwhale_api.common import channels from funkwhale_api.common import utils as funkwhale_utils +logger = logging.getLogger(__name__) PUBLIC_ADDRESS = "https://www.w3.org/ns/activitystreams#Public" ACTIVITY_TYPES = [ @@ -54,19 +60,10 @@ OBJECT_TYPES = [ ] + ACTIVITY_TYPES -def deliver(activity, on_behalf_of, to=[]): - from . import tasks - - return tasks.send.delay(activity=activity, actor_id=on_behalf_of.pk, to=to) - - -def accept_follow(follow): - from . import serializers - - serializer = serializers.AcceptFollowSerializer(follow) - return deliver(serializer.data, to=[follow.actor.fid], on_behalf_of=follow.target) +BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"] +@transaction.atomic def receive(activity, on_behalf_of): from . import models from . import serializers @@ -78,7 +75,14 @@ def receive(activity, on_behalf_of): data=activity, context={"actor": on_behalf_of, "local_recipients": True} ) serializer.is_valid(raise_exception=True) - copy = serializer.save() + try: + copy = serializer.save() + except IntegrityError: + logger.warning( + "[federation] Discarding already elivered activity %s", + serializer.validated_data.get("id"), + ) + return # we create inbox items for further delivery items = [ models.InboxItem(activity=copy, actor=r, type="to") @@ -93,7 +97,7 @@ def receive(activity, on_behalf_of): models.InboxItem.objects.bulk_create(items) # at this point, we have the activity in database. Even if we crash, it's # okay, as we can retry later - tasks.dispatch_inbox.delay(activity_id=copy.pk) + funkwhale_utils.on_commit(tasks.dispatch_inbox.delay, activity_id=copy.pk) return copy @@ -113,17 +117,64 @@ class Router: class InboxRouter(Router): + @transaction.atomic def dispatch(self, payload, context): """ Receives an Activity payload and some context and trigger our business logic """ + from . import api_serializers + from . import models + for route, handler in self.routes: if match_route(route, payload): - return handler(payload, context=context) + r = handler(payload, context=context) + activity_obj = context.get("activity") + if activity_obj and r: + # handler returned additional data we can use + # to update the activity target + for key, value in r.items(): + setattr(activity_obj, key, value) + + update_fields = [] + for k in r.keys(): + if k in ["object", "target", "related_object"]: + update_fields += [ + "{}_id".format(k), + "{}_content_type".format(k), + ] + else: + update_fields.append(k) + activity_obj.save(update_fields=update_fields) + + if payload["type"] not in BROADCAST_TO_USER_ACTIVITIES: + return + + inbox_items = context.get( + "inbox_items", models.InboxItem.objects.none() + ) + for ii in inbox_items: + user = ii.actor.get_user() + if not user: + continue + group = "user.{}.inbox".format(user.pk) + channels.group_send( + group, + { + "type": "event.send", + "text": "", + "data": { + "type": "inbox.item_added", + "item": api_serializers.InboxItemSerializer(ii).data, + }, + }, + ) + inbox_items.update(is_delivered=True, last_delivery_date=timezone.now()) + return class OutboxRouter(Router): + @transaction.atomic def dispatch(self, routing, context): """ Receives a routing payload and some business objects in the context @@ -140,12 +191,11 @@ class OutboxRouter(Router): # a route can yield zero, one or more activity payloads if e: activities_data.append(e) - inbox_items_by_activity_uuid = {} prepared_activities = [] for activity_data in activities_data: - to = activity_data.pop("to", []) - cc = activity_data.pop("cc", []) + to = activity_data["payload"].pop("to", []) + cc = activity_data["payload"].pop("cc", []) a = models.Activity(**activity_data) a.uuid = uuid.uuid4() to_items, new_to = prepare_inbox_items(to, "to") @@ -160,7 +210,6 @@ class OutboxRouter(Router): prepared_activities.append(a) activities = models.Activity.objects.bulk_create(prepared_activities) - activities = [a for a in activities if a] final_inbox_items = [] for a in activities: diff --git a/api/funkwhale_api/federation/admin.py b/api/funkwhale_api/federation/admin.py index 4d8af0bcf..81bf653ed 100644 --- a/api/funkwhale_api/federation/admin.py +++ b/api/funkwhale_api/federation/admin.py @@ -1,6 +1,38 @@ from django.contrib import admin from . import models +from . import tasks + + +def redeliver_inbox_items(modeladmin, request, queryset): + for id in set( + queryset.filter(activity__actor__user__isnull=False).values_list( + "activity", flat=True + ) + ): + tasks.dispatch_outbox.delay(activity_id=id) + + +redeliver_inbox_items.short_description = "Redeliver" + + +def redeliver_activities(modeladmin, request, queryset): + for id in set( + queryset.filter(actor__user__isnull=False).values_list("id", flat=True) + ): + tasks.dispatch_outbox.delay(activity_id=id) + + +redeliver_activities.short_description = "Redeliver" + + +@admin.register(models.Activity) +class ActivityAdmin(admin.ModelAdmin): + list_display = ["type", "fid", "url", "actor", "creation_date"] + search_fields = ["payload", "fid", "url", "actor__domain"] + list_filter = ["type", "actor__domain"] + actions = [redeliver_activities] + list_select_related = True @admin.register(models.Actor) @@ -25,24 +57,24 @@ class FollowAdmin(admin.ModelAdmin): list_select_related = True -@admin.register(models.Library) -class LibraryAdmin(admin.ModelAdmin): - list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"] - search_fields = ["actor__fid", "url"] - list_filter = ["federation_enabled", "download_files", "autoimport"] +@admin.register(models.LibraryFollow) +class LibraryFollowAdmin(admin.ModelAdmin): + list_display = ["actor", "target", "approved", "creation_date"] + list_filter = ["approved"] + search_fields = ["actor__fid", "target__fid"] list_select_related = True -@admin.register(models.LibraryTrack) -class LibraryTrackAdmin(admin.ModelAdmin): +@admin.register(models.InboxItem) +class InboxItemAdmin(admin.ModelAdmin): list_display = [ - "title", - "artist_name", - "album_title", - "url", - "library", - "creation_date", - "published_date", + "actor", + "activity", + "type", + "last_delivery_date", + "delivery_attempts", ] - search_fields = ["library__url", "url", "artist_name", "title", "album_title"] + list_filter = ["type"] + search_fields = ["actor__fid", "activity__fid"] list_select_related = True + actions = [redeliver_inbox_items] diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py index e8c30db0c..da6622f23 100644 --- a/api/funkwhale_api/federation/api_serializers.py +++ b/api/funkwhale_api/federation/api_serializers.py @@ -3,8 +3,9 @@ from rest_framework import serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import models as music_models -from . import serializers as federation_serializers +from . import filters from . import models +from . import serializers as federation_serializers class NestedLibraryFollowSerializer(serializers.ModelSerializer): @@ -44,14 +45,79 @@ class LibrarySerializer(serializers.ModelSerializer): class LibraryFollowSerializer(serializers.ModelSerializer): target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True) + actor = serializers.SerializerMethodField() class Meta: model = models.LibraryFollow - fields = ["creation_date", "uuid", "target", "approved"] - read_only_fields = ["uuid", "approved", "creation_date"] + fields = ["creation_date", "actor", "uuid", "target", "approved"] + read_only_fields = ["uuid", "actor", "approved", "creation_date"] def validate_target(self, v): actor = self.context["actor"] if v.received_follows.filter(actor=actor).exists(): raise serializers.ValidationError("You are already following this library") return v + + def get_actor(self, o): + return federation_serializers.APIActorSerializer(o.actor).data + + +def serialize_generic_relation(activity, obj): + data = {"uuid": obj.uuid, "type": obj._meta.label} + if data["type"] == "music.Library": + data["name"] = obj.name + if data["type"] == "federation.LibraryFollow": + data["approved"] = obj.approved + + return data + + +class ActivitySerializer(serializers.ModelSerializer): + actor = federation_serializers.APIActorSerializer() + object = serializers.SerializerMethodField() + target = serializers.SerializerMethodField() + related_object = serializers.SerializerMethodField() + + class Meta: + model = models.Activity + fields = [ + "uuid", + "fid", + "actor", + "payload", + "object", + "target", + "related_object", + "actor", + "creation_date", + "type", + ] + + def get_object(self, o): + if o.object: + return serialize_generic_relation(o, o.object) + + def get_related_object(self, o): + if o.related_object: + return serialize_generic_relation(o, o.related_object) + + def get_target(self, o): + if o.target: + return serialize_generic_relation(o, o.target) + + +class InboxItemSerializer(serializers.ModelSerializer): + activity = ActivitySerializer() + + class Meta: + model = models.InboxItem + fields = ["id", "type", "activity", "is_read"] + read_only_fields = ["id", "type", "activity"] + + +class InboxItemActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("read", allow_all=True)] + filterset_class = filters.InboxItemFilter + + def handle_read(self, objects): + return objects.update(is_read=True) diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py index 831bc6630..e1e451bff 100644 --- a/api/funkwhale_api/federation/api_urls.py +++ b/api/funkwhale_api/federation/api_urls.py @@ -4,6 +4,7 @@ from . import api_views router = routers.SimpleRouter() router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows") +router.register(r"inbox", api_views.InboxItemViewSet, "inbox") router.register(r"libraries", api_views.LibraryViewSet, "libraries") urlpatterns = router.urls diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index 75ca89609..079a4f3b8 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -1,5 +1,6 @@ import requests.exceptions +from django.db import transaction from django.db.models import Count from rest_framework import decorators @@ -10,6 +11,7 @@ from rest_framework import viewsets from funkwhale_api.music import models as music_models +from . import activity from . import api_serializers from . import filters from . import models @@ -18,6 +20,13 @@ from . import serializers from . import utils +@transaction.atomic +def update_follow(follow, approved): + follow.approved = approved + follow.save(update_fields=["approved"]) + routes.outbox.dispatch({"type": "Accept"}, context={"follow": follow}) + + class LibraryFollowViewSet( mixins.CreateModelMixin, mixins.ListModelMixin, @@ -48,6 +57,29 @@ class LibraryFollowViewSet( context["actor"] = self.request.user.actor return context + @decorators.detail_route(methods=["post"]) + def accept(self, request, *args, **kwargs): + try: + follow = self.queryset.get( + target__actor=self.request.user.actor, uuid=kwargs["uuid"] + ) + except models.LibraryFollow.DoesNotExist: + return response.Response({}, status=404) + update_follow(follow, approved=True) + return response.Response(status=204) + + @decorators.detail_route(methods=["post"]) + def reject(self, request, *args, **kwargs): + try: + follow = self.queryset.get( + target__actor=self.request.user.actor, uuid=kwargs["uuid"] + ) + except models.LibraryFollow.DoesNotExist: + return response.Response({}, status=404) + + update_follow(follow, approved=False) + return response.Response(status=204) + class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = "uuid" @@ -59,8 +91,6 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ) serializer_class = api_serializers.LibrarySerializer permission_classes = [permissions.IsAuthenticated] - filter_class = filters.LibraryFollowFilter - ordering_fields = ("creation_date",) def get_queryset(self): qs = super().get_queryset() @@ -90,3 +120,36 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ) serializer = self.serializer_class(library) return response.Response({"count": 1, "results": [serializer.data]}) + + +class InboxItemViewSet( + mixins.UpdateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + + queryset = ( + models.InboxItem.objects.select_related("activity__actor") + .prefetch_related("activity__object", "activity__target") + .filter(activity__type__in=activity.BROADCAST_TO_USER_ACTIVITIES, type="to") + .order_by("-activity__creation_date") + ) + serializer_class = api_serializers.InboxItemSerializer + permission_classes = [permissions.IsAuthenticated] + filter_class = filters.InboxItemFilter + ordering_fields = ("activity__creation_date",) + + def get_queryset(self): + qs = super().get_queryset() + return qs.filter(actor=self.request.user.actor) + + @decorators.list_route(methods=["post"]) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = api_serializers.InboxItemActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py index 658bd411c..3a8b76cee 100644 --- a/api/funkwhale_api/federation/filters.py +++ b/api/funkwhale_api/federation/filters.py @@ -1,4 +1,4 @@ -import django_filters +import django_filters.widgets from funkwhale_api.common import fields @@ -32,3 +32,17 @@ class LibraryFollowFilter(django_filters.FilterSet): class Meta: model = models.LibraryFollow fields = ["approved"] + + +class InboxItemFilter(django_filters.FilterSet): + is_read = django_filters.BooleanFilter( + "is_read", widget=django_filters.widgets.BooleanWidget() + ) + before = django_filters.NumberFilter(method="filter_before") + + class Meta: + model = models.InboxItem + fields = ["is_read", "activity__type", "activity__actor"] + + def filter_before(self, queryset, field_name, value): + return queryset.filter(pk__lte=value) diff --git a/api/funkwhale_api/federation/migrations/0011_auto_20180910_1902.py b/api/funkwhale_api/federation/migrations/0011_auto_20180910_1902.py new file mode 100644 index 000000000..feeeaff86 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0011_auto_20180910_1902.py @@ -0,0 +1,61 @@ +# Generated by Django 2.0.8 on 2018-09-10 19:02 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('federation', '0010_auto_20180904_2011'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='object_content_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='activity', + name='object_id', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='activity', + name='related_object_content_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='activity', + name='related_object_id', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='activity', + name='target_content_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='activity', + name='target_id', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='activity', + name='type', + field=models.CharField(db_index=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='inboxitem', + name='is_read', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='activity', + name='creation_date', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now), + ), + ] diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 8dbf63916..32c4cae11 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -3,6 +3,8 @@ import uuid from django.conf import settings from django.contrib.postgres.fields import JSONField +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.core.serializers.json import DjangoJSONEncoder from django.db import models @@ -173,6 +175,7 @@ class InboxItem(models.Model): type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")]) last_delivery_date = models.DateTimeField(null=True, blank=True) delivery_attempts = models.PositiveIntegerField(default=0) + is_read = models.BooleanField(default=False) objects = InboxItemQuerySet.as_manager() @@ -188,7 +191,36 @@ class Activity(models.Model): fid = models.URLField(unique=True, max_length=500, null=True, blank=True) url = models.URLField(max_length=500, null=True, blank=True) payload = JSONField(default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder) - creation_date = models.DateTimeField(default=timezone.now) + creation_date = models.DateTimeField(default=timezone.now, db_index=True) + type = models.CharField(db_index=True, null=True, max_length=100) + + # generic relations + object_id = models.IntegerField(null=True) + object_content_type = models.ForeignKey( + ContentType, + null=True, + on_delete=models.SET_NULL, + related_name="objecting_activities", + ) + object = GenericForeignKey("object_content_type", "object_id") + target_id = models.IntegerField(null=True) + target_content_type = models.ForeignKey( + ContentType, + null=True, + on_delete=models.SET_NULL, + related_name="targeting_activities", + ) + target = GenericForeignKey("target_content_type", "target_id") + related_object_id = models.IntegerField(null=True) + related_object_content_type = models.ForeignKey( + ContentType, + null=True, + on_delete=models.SET_NULL, + related_name="related_objecting_activities", + ) + related_object = GenericForeignKey( + "related_object_content_type", "related_object_id" + ) class AbstractFollow(models.Model): diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index d247b3d99..41f13801f 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -33,10 +33,10 @@ def inbox_follow(payload, context): autoapprove = serializer.validated_data["object"].should_autoapprove_follow( context["actor"] ) - follow = serializer.save(approved=autoapprove) - - if autoapprove: - activity.accept_follow(follow) + follow = serializer.save(approved=True if autoapprove else None) + if follow.approved: + outbox.dispatch({"type": "Accept"}, context={"follow": follow}) + return {"object": follow.target, "related_object": follow} @inbox.register({"type": "Accept"}) @@ -54,6 +54,8 @@ def inbox_accept(payload, context): return serializer.save() + obj = serializer.validated_data["follow"] + return {"object": obj, "related_object": obj.target} @outbox.register({"type": "Accept"}) @@ -64,7 +66,13 @@ def outbox_accept(context): else: actor = follow.target payload = serializers.AcceptFollowSerializer(follow, context={"actor": actor}).data - yield {"actor": actor, "payload": with_recipients(payload, to=[follow.actor])} + yield { + "actor": actor, + "type": "Accept", + "payload": with_recipients(payload, to=[follow.actor]), + "object": follow, + "related_object": follow.target, + } @outbox.register({"type": "Follow"}) @@ -75,4 +83,10 @@ def outbox_follow(context): else: target = follow.target payload = serializers.FollowSerializer(follow, context={"actor": follow.actor}).data - yield {"actor": follow.actor, "payload": with_recipients(payload, to=[target])} + yield { + "type": "Follow", + "actor": follow.actor, + "payload": with_recipients(payload, to=[target]), + "object": follow.target, + "related_object": follow, + } diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 30b5dad4d..91df2bbbb 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -155,6 +155,7 @@ class BaseActivitySerializer(serializers.Serializer): fid=validated_data.get("id"), actor=validated_data["actor"], payload=self.initial_data, + type=validated_data["type"], ) def validate(self, data): diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index 2d7e37a17..d5876174d 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -74,8 +74,14 @@ def dispatch_inbox(activity): routes.inbox.dispatch( activity.payload, context={ + "activity": activity, "actor": activity.actor, - "inbox_items": list(activity.inbox_items.local().select_related()), + "inbox_items": ( + activity.inbox_items.local() + .select_related() + .select_related("actor__user") + .prefetch_related("activity__object", "activity__target") + ), }, ) except Exception: diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 30fdb9a9f..ddc6a2fa5 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -37,15 +37,12 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV @detail_route(methods=["get", "post"]) def inbox(self, request, *args, **kwargs): - actor = self.get_object() if request.method.lower() == "post" and request.actor is None: raise exceptions.AuthenticationFailed( "You need a valid signature to send an activity" ) if request.method.lower() == "post": - activity.receive( - activity=request.data, on_behalf_of=request.actor, recipient=actor - ) + activity.receive(activity=request.data, on_behalf_of=request.actor) return response.Response({}, status=200) @detail_route(methods=["get", "post"]) diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index e8ddd00a7..30ead2398 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -18,6 +18,7 @@ from taggit.models import Tag from funkwhale_api.common import utils as common_utils from funkwhale_api.common import permissions as common_permissions from funkwhale_api.federation.authentication import SignatureAuthentication +from funkwhale_api.federation import api_serializers as federation_api_serializers from . import filters, models, serializers, tasks, utils @@ -94,6 +95,25 @@ class LibraryViewSet( def perform_create(self, serializer): serializer.save(actor=self.request.user.actor) + @detail_route(methods=["get"]) + @transaction.non_atomic_requests + def follows(self, request, *args, **kwargs): + library = self.get_object() + queryset = ( + library.received_follows.filter(target__actor=self.request.user.actor) + .select_related("actor", "target__actor") + .order_by("-creation_date") + ) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = federation_api_serializers.LibraryFollowSerializer( + page, many=True + ) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): """ diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index d2e1ac65c..89abefb48 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -206,7 +206,7 @@ class User(AbstractUser): } def get_channels_groups(self): - groups = ["imports"] + groups = ["imports", "inbox"] return ["user.{}.{}".format(self.pk, g) for g in groups] diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 8ad554d81..cf6a3082e 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -15,6 +15,7 @@ from django.core.cache import cache as django_cache from django.core.files import uploadedfile from django.utils import timezone from django.test import client +from django.db.models import QuerySet from dynamic_preferences.registries import global_preferences_registry from rest_framework import fields as rest_fields from rest_framework.test import APIClient, APIRequestFactory @@ -23,6 +24,43 @@ from funkwhale_api.activity import record from funkwhale_api.users.permissions import HasUserPermission +@pytest.fixture +def queryset_equal_queries(): + """ + Unitting querysets is hard because we have to compare queries + by hand. Let's monkey patch querysets to do that for us. + """ + + def __eq__(self, other): + if isinstance(other, QuerySet): + return str(other.query) == str(self.query) + else: + return False + + setattr(QuerySet, "__eq__", __eq__) + yield __eq__ + delattr(QuerySet, "__eq__") + + +@pytest.fixture +def queryset_equal_list(): + """ + Unitting querysets is hard because we usually simply wants to ensure + a querysets contains the same objects as a list, let's monkey patch + querysets to to that for us. + """ + + def __eq__(self, other): + if isinstance(other, (list, tuple)): + return list(self) == list(other) + else: + return False + + setattr(QuerySet, "__eq__", __eq__) + yield __eq__ + delattr(QuerySet, "__eq__") + + @pytest.fixture(scope="session", autouse=True) def factories_autodiscover(): from django.apps import apps diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index 7ca337194..592de3b15 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -1,23 +1,11 @@ import pytest -from funkwhale_api.federation import activity, serializers, tasks - - -def test_accept_follow(mocker, factories): - deliver = mocker.patch("funkwhale_api.federation.activity.deliver") - follow = factories["federation.Follow"](approved=None) - expected_accept = serializers.AcceptFollowSerializer(follow).data - activity.accept_follow(follow) - deliver.assert_called_once_with( - expected_accept, to=[follow.actor.fid], on_behalf_of=follow.target - ) +from funkwhale_api.federation import activity, api_serializers, serializers, tasks def test_receive_validates_basic_attributes_and_stores_activity(factories, now, mocker): - mocked_dispatch = mocker.patch( - "funkwhale_api.federation.tasks.dispatch_inbox.delay" - ) + mocked_dispatch = mocker.patch("funkwhale_api.common.utils.on_commit") local_actor = factories["users.User"]().create_actor() remote_actor = factories["federation.Actor"]() another_actor = factories["federation.Actor"]() @@ -36,7 +24,10 @@ def test_receive_validates_basic_attributes_and_stores_activity(factories, now, assert copy.creation_date >= now assert copy.actor == remote_actor assert copy.fid == a["id"] - mocked_dispatch.assert_called_once_with(activity_id=copy.pk) + assert copy.type == "Noop" + mocked_dispatch.assert_called_once_with( + tasks.dispatch_inbox.delay, activity_id=copy.pk + ) inbox_item = copy.inbox_items.get(actor__fid=local_actor.fid) assert inbox_item.is_delivered is False @@ -63,16 +54,62 @@ def test_receive_actor_mismatch(factories): activity.receive(activity=a, on_behalf_of=remote_actor) -def test_inbox_routing(mocker): +def test_inbox_routing(factories, mocker): + object = factories["music.Artist"]() + target = factories["music.Artist"]() router = activity.InboxRouter() + a = factories["federation.Activity"](type="Follow") + + handler_payload = {} + handler_context = {} + + def handler(payload, context): + handler_payload.update(payload) + handler_context.update(context) + return {"target": target, "object": object} - handler = mocker.stub(name="handler") router.connect({"type": "Follow"}, handler) good_message = {"type": "Follow"} - router.dispatch(good_message, context={}) + router.dispatch(good_message, context={"activity": a}) - handler.assert_called_once_with(good_message, context={}) + assert handler_payload == good_message + assert handler_context == {"activity": a} + + a.refresh_from_db() + + assert a.object == object + assert a.target == target + + +def test_inbox_routing_send_to_channel(factories, mocker): + group_send = mocker.patch("funkwhale_api.common.channels.group_send") + a = factories["federation.Activity"](type="Follow") + ii = factories["federation.InboxItem"](actor__local=True) + + router = activity.InboxRouter() + handler = mocker.stub() + router.connect({"type": "Follow"}, handler) + good_message = {"type": "Follow"} + router.dispatch( + good_message, context={"activity": a, "inbox_items": ii.__class__.objects.all()} + ) + + ii.refresh_from_db() + + assert ii.is_delivered is True + + group_send.assert_called_once_with( + "user.{}.inbox".format(ii.actor.user.pk), + { + "type": "event.send", + "text": "", + "data": { + "type": "inbox.item_added", + "item": api_serializers.InboxItemSerializer(ii).data, + }, + }, + ) @pytest.mark.parametrize( @@ -101,10 +138,10 @@ def test_outbox_router_dispatch(mocker, factories, now): "type": "Noop", "actor": actor.fid, "summary": context["summary"], + "to": [r1], + "cc": [r2, activity.PUBLIC_ADDRESS], }, "actor": actor, - "to": [r1], - "cc": [r2, activity.PUBLIC_ADDRESS], } router.connect({"type": "Noop"}, handler) diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py index f7d70f177..71c1b047b 100644 --- a/api/tests/federation/test_actors.py +++ b/api/tests/federation/test_actors.py @@ -1,6 +1,5 @@ import pytest from django.urls import reverse -from django.utils import timezone from rest_framework import exceptions from funkwhale_api.federation import actors, models, serializers, utils @@ -120,58 +119,6 @@ def test_test_post_outbox_validates_actor(nodb_factories): assert msg in exc_info.value -def test_test_post_inbox_handles_create_note(settings, mocker, factories): - deliver = mocker.patch("funkwhale_api.federation.activity.deliver") - actor = factories["federation.Actor"]() - now = timezone.now() - mocker.patch("django.utils.timezone.now", return_value=now) - data = { - "actor": actor.fid, - "type": "Create", - "id": "http://test.federation/activity", - "object": { - "type": "Note", - "id": "http://test.federation/object", - "content": "
@mention /ping
", - }, - } - test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() - expected_note = factories["federation.Note"]( - id="https://test.federation/activities/note/{}".format(now.timestamp()), - content="Pong!", - published=now.isoformat(), - inReplyTo=data["object"]["id"], - cc=[], - summary=None, - sensitive=False, - attributedTo=test_actor.fid, - attachment=[], - to=[actor.fid], - url="https://{}/activities/note/{}".format( - settings.FEDERATION_HOSTNAME, now.timestamp() - ), - tag=[{"href": actor.fid, "name": actor.full_username, "type": "Mention"}], - ) - expected_activity = { - "@context": serializers.AP_CONTEXT, - "actor": test_actor.fid, - "id": "https://{}/activities/note/{}/activity".format( - settings.FEDERATION_HOSTNAME, now.timestamp() - ), - "to": actor.fid, - "type": "Create", - "published": now.isoformat(), - "object": expected_note, - "cc": [], - } - actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) - deliver.assert_called_once_with( - expected_activity, - to=[actor.fid], - on_behalf_of=actors.SYSTEM_ACTORS["test"].get_actor_instance(), - ) - - def test_getting_actor_instance_persists_in_db(db): test = actors.SYSTEM_ACTORS["test"].get_actor_instance() from_db = models.Actor.objects.get(fid=test.fid) @@ -220,57 +167,3 @@ def test_system_actor_handle(mocker, nodb_factories): assert serializer.is_valid() actors.SYSTEM_ACTORS["test"].handle(activity, actor) handler.assert_called_once_with(activity, actor) - - -def test_test_actor_handles_follow(settings, mocker, factories): - deliver = mocker.patch("funkwhale_api.federation.activity.deliver") - actor = factories["federation.Actor"]() - accept_follow = mocker.patch("funkwhale_api.federation.activity.accept_follow") - test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() - data = { - "actor": actor.fid, - "type": "Follow", - "id": "http://test.federation/user#follows/267", - "object": test_actor.fid, - } - actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) - follow = models.Follow.objects.get(target=test_actor, approved=True) - follow_back = models.Follow.objects.get(actor=test_actor, approved=None) - accept_follow.assert_called_once_with(follow) - deliver.assert_called_once_with( - serializers.FollowSerializer(follow_back).data, - on_behalf_of=test_actor, - to=[actor.fid], - ) - - -def test_test_actor_handles_undo_follow(settings, mocker, factories): - deliver = mocker.patch("funkwhale_api.federation.activity.deliver") - test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() - follow = factories["federation.Follow"](target=test_actor) - reverse_follow = factories["federation.Follow"]( - actor=test_actor, target=follow.actor - ) - follow_serializer = serializers.FollowSerializer(follow) - reverse_follow_serializer = serializers.FollowSerializer(reverse_follow) - undo = { - "@context": serializers.AP_CONTEXT, - "type": "Undo", - "id": follow_serializer.data["id"] + "/undo", - "actor": follow.actor.fid, - "object": follow_serializer.data, - } - expected_undo = { - "@context": serializers.AP_CONTEXT, - "type": "Undo", - "id": reverse_follow_serializer.data["id"] + "/undo", - "actor": reverse_follow.actor.fid, - "object": reverse_follow_serializer.data, - } - - actors.SYSTEM_ACTORS["test"].post_inbox(undo, actor=follow.actor) - deliver.assert_called_once_with( - expected_undo, to=[follow.actor.fid], on_behalf_of=test_actor - ) - - assert models.Follow.objects.count() == 0 diff --git a/api/tests/federation/test_api_filters.py b/api/tests/federation/test_api_filters.py new file mode 100644 index 000000000..c6e70b617 --- /dev/null +++ b/api/tests/federation/test_api_filters.py @@ -0,0 +1,9 @@ +from funkwhale_api.federation import filters +from funkwhale_api.federation import models + + +def test_inbox_item_filter_before(factories): + expected = models.InboxItem.objects.filter(pk__lte=12) + f = filters.InboxItemFilter({"before": 12}, queryset=models.InboxItem.objects.all()) + + assert str(f.qs.query) == str(expected.query) diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py index f3acc8731..32cbab523 100644 --- a/api/tests/federation/test_api_serializers.py +++ b/api/tests/federation/test_api_serializers.py @@ -51,3 +51,12 @@ def test_library_serializer_validates_existing_follow(factories): assert serializer.is_valid() is False assert "target" in serializer.errors + + +def test_manage_track_file_action_read(factories): + ii = factories["federation.InboxItem"]() + s = api_serializers.InboxItemActionSerializer(queryset=None) + + s.handle_read(ii.__class__.objects.all()) + + assert ii.__class__.objects.filter(is_read=False).count() == 0 diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index 44c7a882d..d99b85003 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -1,3 +1,5 @@ +import pytest + from django.urls import reverse from funkwhale_api.federation import api_serializers @@ -49,3 +51,82 @@ def test_can_follow_library(factories, logged_in_api_client, mocker): assert follow.actor == actor dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow}) + + +@pytest.mark.parametrize("action", ["accept", "reject"]) +def test_user_cannot_edit_someone_else_library_follow( + factories, logged_in_api_client, action +): + logged_in_api_client.user.create_actor() + follow = factories["federation.LibraryFollow"]() + url = reverse( + "api:v1:federation:library-follows-{}".format(action), + kwargs={"uuid": follow.uuid}, + ) + response = logged_in_api_client.post(url) + + assert response.status_code == 404 + + +@pytest.mark.parametrize("action,expected", [("accept", True), ("reject", False)]) +def test_user_can_accept_or_reject_own_follows( + factories, logged_in_api_client, action, expected, mocker +): + mocked_dispatch = mocker.patch( + "funkwhale_api.federation.activity.OutboxRouter.dispatch" + ) + actor = logged_in_api_client.user.create_actor() + follow = factories["federation.LibraryFollow"](target__actor=actor) + url = reverse( + "api:v1:federation:library-follows-{}".format(action), + kwargs={"uuid": follow.uuid}, + ) + response = logged_in_api_client.post(url) + + assert response.status_code == 204 + + follow.refresh_from_db() + + assert follow.approved is expected + + mocked_dispatch.assert_called_once_with( + {"type": "Accept"}, context={"follow": follow} + ) + + +def test_user_can_list_inbox_items(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + ii = factories["federation.InboxItem"]( + activity__type="Follow", actor=actor, type="to" + ) + + factories["federation.InboxItem"](activity__type="Follow", actor=actor, type="cc") + factories["federation.InboxItem"](activity__type="Follow", type="to") + + url = reverse("api:v1:federation:inbox-list") + + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == { + "count": 1, + "results": [api_serializers.InboxItemSerializer(ii).data], + "next": None, + "previous": None, + } + + +def test_user_can_update_read_status_of_inbox_item(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + ii = factories["federation.InboxItem"]( + activity__type="Follow", actor=actor, type="to" + ) + + url = reverse("api:v1:federation:inbox-detail", kwargs={"pk": ii.pk}) + + response = logged_in_api_client.patch(url, {"is_read": True}) + assert response.status_code == 200 + + ii.refresh_from_db() + + assert ii.is_read is True diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index e7ac88bba..8e8a50b5f 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -36,8 +36,8 @@ def test_outbox_routes(route, handler): def test_inbox_follow_library_autoapprove(factories, mocker): - mocked_accept_follow = mocker.patch( - "funkwhale_api.federation.activity.accept_follow" + mocked_outbox_dispatch = mocker.patch( + "funkwhale_api.federation.activity.OutboxRouter.dispatch" ) local_actor = factories["users.User"]().create_actor() @@ -52,23 +52,27 @@ def test_inbox_follow_library_autoapprove(factories, mocker): "object": library.fid, } - routes.inbox_follow( + result = routes.inbox_follow( payload, context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, ) - follow = library.received_follows.latest("id") + assert result["object"] == library + assert result["related_object"] == follow + assert follow.fid == payload["id"] assert follow.actor == remote_actor assert follow.approved is True - mocked_accept_follow.assert_called_once_with(follow) + mocked_outbox_dispatch.assert_called_once_with( + {"type": "Accept"}, context={"follow": follow} + ) def test_inbox_follow_library_manual_approve(factories, mocker): - mocked_accept_follow = mocker.patch( - "funkwhale_api.federation.activity.accept_follow" + mocked_outbox_dispatch = mocker.patch( + "funkwhale_api.federation.activity.OutboxRouter.dispatch" ) local_actor = factories["users.User"]().create_actor() @@ -83,18 +87,20 @@ def test_inbox_follow_library_manual_approve(factories, mocker): "object": library.fid, } - routes.inbox_follow( + result = routes.inbox_follow( payload, context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, ) - follow = library.received_follows.latest("id") + assert result["object"] == library + assert result["related_object"] == follow + assert follow.fid == payload["id"] assert follow.actor == remote_actor - assert follow.approved is False + assert follow.approved is None - mocked_accept_follow.assert_not_called() + mocked_outbox_dispatch.assert_not_called() def test_outbox_accept(factories, mocker): @@ -111,6 +117,7 @@ def test_outbox_accept(factories, mocker): assert activity["payload"] == expected assert activity["actor"] == follow.target.actor + assert activity["object"] == follow def test_inbox_accept(factories, mocker): @@ -125,10 +132,12 @@ def test_inbox_accept(factories, mocker): follow, context={"actor": remote_actor} ) ii = factories["federation.InboxItem"](actor=local_actor) - routes.inbox_accept( + result = routes.inbox_accept( serializer.data, context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, ) + assert result["object"] == follow + assert result["related_object"] == follow.target follow.refresh_from_db() @@ -145,3 +154,4 @@ def test_outbox_follow_library(factories, mocker): assert activity["payload"] == expected assert activity["actor"] == follow.actor + assert activity["object"] == follow.target diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py index 216163277..9bf25f697 100644 --- a/api/tests/federation/test_tasks.py +++ b/api/tests/federation/test_tasks.py @@ -59,7 +59,7 @@ def test_clean_federation_music_cache_orphaned(settings, preferences, factories) assert os.path.exists(remove_path) is False -def test_handle_in(factories, mocker, now): +def test_handle_in(factories, mocker, now, queryset_equal_list): mocked_dispatch = mocker.patch("funkwhale_api.federation.routes.inbox.dispatch") r1 = factories["users.User"](with_actor=True).actor @@ -70,7 +70,7 @@ def test_handle_in(factories, mocker, now): tasks.dispatch_inbox(activity_id=a.pk) mocked_dispatch.assert_called_once_with( - a.payload, context={"actor": a.actor, "inbox_items": [ii1, ii2]} + a.payload, context={"actor": a.actor, "activity": a, "inbox_items": [ii1, ii2]} ) ii1.refresh_from_db() diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 54534e93b..58c8f1540 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -105,9 +105,7 @@ def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_act assert response.status_code == 200 patched_receive.assert_called_once_with( - activity={"hello": "world"}, - on_behalf_of=authenticated_actor, - recipient=user.actor, + activity={"hello": "world"}, on_behalf_of=authenticated_actor ) diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index ff49f2464..cd2651e72 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -6,6 +6,7 @@ from django.urls import reverse from django.utils import timezone from funkwhale_api.music import serializers, tasks, views +from funkwhale_api.federation import api_serializers as federation_api_serializers DATA_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -384,3 +385,22 @@ def test_user_can_create_track_file( assert tf.import_reference == "test" assert tf.track is None m.assert_called_once_with(tasks.import_track_file.delay, track_file_id=tf.pk) + + +def test_user_can_list_own_library_follows(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + library = factories["music.Library"](actor=actor) + another_library = factories["music.Library"](actor=actor) + follow = factories["federation.LibraryFollow"](target=library) + factories["federation.LibraryFollow"](target=another_library) + + url = reverse("api:v1:libraries-follows", kwargs={"uuid": library.uuid}) + + response = logged_in_api_client.get(url) + + assert response.data == { + "count": 1, + "next": None, + "previous": None, + "results": [federation_api_serializers.LibraryFollowSerializer(follow).data], + } diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index 5ec0cc0d2..2bde816f5 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -170,7 +170,10 @@ def test_creating_actor_from_user(factories, settings): def test_get_channels_groups(factories): user = factories["users.User"]() - assert user.get_channels_groups() == ["user.{}.imports".format(user.pk)] + assert user.get_channels_groups() == [ + "user.{}.imports".format(user.pk), + "user.{}.inbox".format(user.pk), + ] def test_user_quota_default_to_preference(factories, preferences): diff --git a/front/src/App.vue b/front/src/App.vue index 514f52d1c..d96e91d92 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -94,6 +94,8 @@ import axios from 'axios' import _ from 'lodash' import {mapState} from 'vuex' +import { WebSocketBridge } from 'django-channels' + import translations from '@/translations' @@ -113,11 +115,13 @@ export default { }, data () { return { + bridge: null, nodeinfo: null, instanceUrl: null } }, created () { + this.openWebsocket() let self = this this.autodetectLanguage() setInterval(() => { @@ -134,8 +138,23 @@ export default { this.$store.dispatch('auth/check') this.$store.dispatch('instance/fetchSettings') this.fetchNodeInfo() + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'inbox.item_added', + id: 'sidebarCount', + handler: this.incrementNotificationCountInSidebar + }) + }, + destroyed () { + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'inbox.item_added', + id: 'sidebarCount', + }) + this.disconnect() }, methods: { + incrementNotificationCountInSidebar (event) { + this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1}) + }, fetchNodeInfo () { let self = this axios.get('instance/nodeinfo/2.0/').then(response => { @@ -162,6 +181,36 @@ export default { } else if (almostMatching.length > 0) { this.$language.current = almostMatching[0] } + }, + disconnect () { + if (!this.bridge) { + return + } + this.bridge.socket.close(1000, 'goodbye', {keepClosed: true}) + }, + openWebsocket () { + if (!this.$store.state.auth.authenticated) { + return + } + this.disconnect() + let self = this + let token = this.$store.state.auth.token + // let token = 'test' + const bridge = new WebSocketBridge() + this.bridge = bridge + let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity?token=${token}`) + url = url.replace('http://', 'ws://') + url = url.replace('https://', 'wss://') + bridge.connect( + url, + null, + {reconnectInterval: 5000}) + bridge.listen(function (event) { + self.$store.dispatch('ui/websocketEvent', event) + }) + bridge.socket.addEventListener('open', function () { + console.log('Connected to WebSocket') + }) } }, computed: { @@ -189,6 +238,13 @@ export default { this.$store.dispatch('instance/fetchSettings') this.fetchNodeInfo() }, + '$store.state.auth.authenticated' (newValue) { + if (!newValue) { + this.disconnect() + } else { + this.openWebsocket() + } + }, '$language.current' (newValue) { this.$store.commit('ui/currentLanguage', newValue) } @@ -299,9 +355,11 @@ html, body { } } -.discrete.link { - color: rgba(0, 0, 0, 0.87); - cursor: pointer; +.discrete { + color: rgba(0, 0, 0, 0.87); +} +.link { + cursor: pointer; } .floated.buttons .button ~ .dropdown { @@ -321,5 +379,8 @@ html, body { a { cursor: pointer; } +.segment.hidden { + display: none; +} diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 1688c37eb..8d4afacf3 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -42,6 +42,15 @@