diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 7c10a39be..74fe79ed0 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -156,6 +156,7 @@ LOCAL_APPS = ( "funkwhale_api.requests", "funkwhale_api.favorites", "funkwhale_api.federation", + "funkwhale_api.moderation", "funkwhale_api.radios", "funkwhale_api.history", "funkwhale_api.playlists", diff --git a/api/config/settings/local.py b/api/config/settings/local.py index 91a202b64..bea2dd5f6 100644 --- a/api/config/settings/local.py +++ b/api/config/settings/local.py @@ -38,7 +38,7 @@ DEBUG_TOOLBAR_CONFIG = { "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], "SHOW_TEMPLATE_CONTEXT": True, "SHOW_TOOLBAR_CALLBACK": lambda request: True, - "JQUERY_URL": "", + "JQUERY_URL": "/staticfiles/admin/js/vendor/jquery/jquery.js", } # django-extensions diff --git a/api/funkwhale_api/common/decorators.py b/api/funkwhale_api/common/decorators.py new file mode 100644 index 000000000..5ecedc512 --- /dev/null +++ b/api/funkwhale_api/common/decorators.py @@ -0,0 +1,14 @@ +from rest_framework import response +from rest_framework.decorators import list_route + + +def action_route(serializer_class): + @list_route(methods=["post"]) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializer_class(request.data, queryset=queryset) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) + + return action diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index e7bbf8f1f..fafa6152d 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -123,7 +123,7 @@ class ActionSerializer(serializers.Serializer): if type(value) in [list, tuple]: return self.queryset.filter( **{"{}__in".format(self.pk_field): value} - ).order_by("id") + ).order_by(self.pk_field) raise serializers.ValidationError( "{} is not a valid value for objects. You must provide either a " diff --git a/api/funkwhale_api/common/validators.py b/api/funkwhale_api/common/validators.py index b5f26cac5..78a4b4c7c 100644 --- a/api/funkwhale_api/common/validators.py +++ b/api/funkwhale_api/common/validators.py @@ -1,6 +1,7 @@ import mimetypes from os.path import splitext +from django.core import validators from django.core.exceptions import ValidationError from django.core.files.images import get_image_dimensions from django.template.defaultfilters import filesizeformat @@ -150,3 +151,17 @@ class FileValidator(object): } raise ValidationError(message) + + +class DomainValidator(validators.URLValidator): + message = "Enter a valid domain name." + + def __call__(self, value): + """ + This is a bit hackish but since we don't have any built-in domain validator, + we use the url one, and prepend http:// in front of it. + + If it fails, we know the domain is not valid. + """ + super().__call__("http://{}".format(value)) + return value diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 211b8230a..86c79107a 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -80,6 +80,30 @@ OBJECT_TYPES = ( BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"] +def should_reject(id, actor_id=None, payload={}): + from funkwhale_api.moderation import models as moderation_models + + policies = moderation_models.InstancePolicy.objects.active() + + media_types = ["Audio", "Artist", "Album", "Track", "Library", "Image"] + relevant_values = [ + recursive_gettattr(payload, "type", permissive=True), + recursive_gettattr(payload, "object.type", permissive=True), + recursive_gettattr(payload, "target.type", permissive=True), + ] + # if one of the payload types match our internal media types, then + # we apply policies that reject media + if set(media_types) & set(relevant_values): + policy_type = Q(block_all=True) | Q(reject_media=True) + else: + policy_type = Q(block_all=True) + + query = policies.matching_url_query(id) & policy_type + if actor_id: + query |= policies.matching_url_query(actor_id) & policy_type + return policies.filter(query).exists() + + @transaction.atomic def receive(activity, on_behalf_of): from . import models @@ -92,6 +116,16 @@ def receive(activity, on_behalf_of): data=activity, context={"actor": on_behalf_of, "local_recipients": True} ) serializer.is_valid(raise_exception=True) + if should_reject( + id=serializer.validated_data["id"], + actor_id=serializer.validated_data["actor"].fid, + payload=activity, + ): + logger.info( + "[federation] Discarding activity due to instance policies %s", + serializer.validated_data.get("id"), + ) + return try: copy = serializer.save() except IntegrityError: @@ -283,7 +317,7 @@ class OutboxRouter(Router): return activities -def recursive_gettattr(obj, key): +def recursive_gettattr(obj, key, permissive=False): """ Given a dictionary such as {'user': {'name': 'Bob'}} and a dotted string such as user.name, returns 'Bob'. @@ -292,7 +326,12 @@ def recursive_gettattr(obj, key): """ v = obj for k in key.split("."): - v = v.get(k) + try: + v = v.get(k) + except (TypeError, AttributeError): + if not permissive: + raise + return if v is None: return diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index 75ffad0b2..4c5aaf92a 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -13,6 +13,7 @@ from funkwhale_api.music import models as music_models from . import activity from . import api_serializers +from . import exceptions from . import filters from . import models from . import routes @@ -128,11 +129,16 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): except KeyError: return response.Response({"fid": ["This field is required"]}) try: - library = utils.retrieve( + library = utils.retrieve_ap_object( fid, queryset=self.queryset, serializer_class=serializers.LibrarySerializer, ) + except exceptions.BlockedActorOrDomain: + return response.Response( + {"detail": "This domain/account is blocked on your instance."}, + status=400, + ) except requests.exceptions.RequestException as e: return response.Response( {"detail": "Error while fetching the library: {}".format(str(e))}, diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py index f32c78ff3..adfb8a181 100644 --- a/api/funkwhale_api/federation/authentication.py +++ b/api/funkwhale_api/federation/authentication.py @@ -1,8 +1,14 @@ import cryptography -from django.contrib.auth.models import AnonymousUser -from rest_framework import authentication, exceptions +import logging -from . import actors, keys, signing, utils +from django.contrib.auth.models import AnonymousUser +from rest_framework import authentication, exceptions as rest_exceptions + +from funkwhale_api.moderation import models as moderation_models +from . import actors, exceptions, keys, signing, utils + + +logger = logging.getLogger(__name__) class SignatureAuthentication(authentication.BaseAuthentication): @@ -14,20 +20,36 @@ class SignatureAuthentication(authentication.BaseAuthentication): except KeyError: return except ValueError as e: - raise exceptions.AuthenticationFailed(str(e)) + raise rest_exceptions.AuthenticationFailed(str(e)) try: - actor = actors.get_actor(key_id.split("#")[0]) + actor_url = key_id.split("#")[0] + except (TypeError, IndexError, AttributeError): + raise rest_exceptions.AuthenticationFailed("Invalid key id") + + policies = ( + moderation_models.InstancePolicy.objects.active() + .filter(block_all=True) + .matching_url(actor_url) + ) + if policies.exists(): + raise exceptions.BlockedActorOrDomain() + + try: + actor = actors.get_actor(actor_url) except Exception as e: - raise exceptions.AuthenticationFailed(str(e)) + logger.info( + "Discarding HTTP request from blocked actor/domain %s", actor_url + ) + raise rest_exceptions.AuthenticationFailed(str(e)) if not actor.public_key: - raise exceptions.AuthenticationFailed("No public key found") + raise rest_exceptions.AuthenticationFailed("No public key found") try: signing.verify_django(request, actor.public_key.encode("utf-8")) except cryptography.exceptions.InvalidSignature: - raise exceptions.AuthenticationFailed("Invalid signature") + raise rest_exceptions.AuthenticationFailed("Invalid signature") return actor diff --git a/api/funkwhale_api/federation/exceptions.py b/api/funkwhale_api/federation/exceptions.py index b3fb73ab8..eb2bd2161 100644 --- a/api/funkwhale_api/federation/exceptions.py +++ b/api/funkwhale_api/federation/exceptions.py @@ -1,6 +1,13 @@ +from rest_framework import exceptions + + class MalformedPayload(ValueError): pass class MissingSignature(KeyError): pass + + +class BlockedActorOrDomain(exceptions.AuthenticationFailed): + pass diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index 331a5f5d6..f54f68678 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -67,7 +67,7 @@ def create_user(actor): @registry.register -class Domain(NoUpdateOnCreate, factory.django.DjangoModelFactory): +class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): name = factory.Faker("domain_name") class Meta: @@ -81,7 +81,7 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): private_key = None preferred_username = factory.Faker("user_name") summary = factory.Faker("paragraph") - domain = factory.SubFactory(Domain) + domain = factory.SubFactory(DomainFactory) fid = factory.LazyAttribute( lambda o: "https://{}/users/{}".format(o.domain.name, o.preferred_username) ) diff --git a/api/funkwhale_api/federation/fields.py b/api/funkwhale_api/federation/fields.py new file mode 100644 index 000000000..3523396db --- /dev/null +++ b/api/funkwhale_api/federation/fields.py @@ -0,0 +1,18 @@ +from rest_framework import serializers + +from . import models + + +class ActorRelatedField(serializers.EmailField): + def to_representation(self, value): + return value.full_username + + def to_internal_value(self, value): + value = super().to_internal_value(value) + username, domain = value.split("@") + try: + return models.Actor.objects.get( + preferred_username=username, domain_id=domain + ) + except models.Actor.DoesNotExist: + raise serializers.ValidationError("Invalid actor name") diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 2fdeaaa76..59360aea1 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -13,6 +13,7 @@ from django.urls import reverse from funkwhale_api.common import session from funkwhale_api.common import utils as common_utils +from funkwhale_api.common import validators as common_validators from funkwhale_api.music import utils as music_utils from . import utils as federation_utils @@ -83,7 +84,11 @@ class DomainQuerySet(models.QuerySet): class Domain(models.Model): - name = models.CharField(primary_key=True, max_length=255) + name = models.CharField( + primary_key=True, + max_length=255, + validators=[common_validators.DomainValidator()], + ) creation_date = models.DateTimeField(default=timezone.now) nodeinfo_fetch_date = models.DateTimeField(default=None, null=True, blank=True) nodeinfo = JSONField(default=empty_dict, max_length=50000, blank=True) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 1cece3b97..d0e07cd85 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -567,7 +567,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): return r def create(self, validated_data): - actor = utils.retrieve( + actor = utils.retrieve_ap_object( validated_data["actor"], queryset=models.Actor, serializer_class=ActorSerializer, diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index 4ed07aa25..d7c48957a 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -186,3 +186,46 @@ def update_domain_nodeinfo(domain): domain.nodeinfo_fetch_date = now domain.nodeinfo = nodeinfo domain.save(update_fields=["nodeinfo", "nodeinfo_fetch_date"]) + + +def delete_qs(qs): + label = qs.model._meta.label + result = qs.delete() + related = sum(result[1].values()) + + logger.info( + "Purged %s %s objects (and %s related entities)", result[0], label, related + ) + + +def handle_purge_actors(ids, only=[]): + """ + Empty only means we purge everything + Otherwise, we purge only the requested bits: media + """ + # purge follows (received emitted) + if not only: + delete_qs(models.LibraryFollow.objects.filter(target__actor_id__in=ids)) + delete_qs(models.Follow.objects.filter(actor_id__in=ids)) + + # purge audio content + if not only or "media" in only: + delete_qs(models.LibraryFollow.objects.filter(actor_id__in=ids)) + delete_qs(models.Follow.objects.filter(target_id__in=ids)) + delete_qs(music_models.Upload.objects.filter(library__actor_id__in=ids)) + delete_qs(music_models.Library.objects.filter(actor_id__in=ids)) + + # purge remaining activities / deliveries + if not only: + delete_qs(models.InboxItem.objects.filter(actor_id__in=ids)) + delete_qs(models.Activity.objects.filter(actor_id__in=ids)) + + +@celery.app.task(name="federation.purge_actors") +def purge_actors(ids=[], domains=[], only=[]): + actors = models.Actor.objects.filter( + Q(id__in=ids) | Q(domain_id__in=domains) + ).order_by("id") + found_ids = list(actors.values_list("id", flat=True)) + logger.info("Starting purging %s accounts", len(found_ids)) + handle_purge_actors(ids=found_ids, only=only) diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index d02c8bf68..e49a4dd63 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -3,7 +3,9 @@ import re from django.conf import settings from funkwhale_api.common import session +from funkwhale_api.moderation import models as moderation_models +from . import exceptions from . import signing @@ -58,7 +60,14 @@ def slugify_username(username): return re.sub(r"[-\s]+", "_", value) -def retrieve(fid, actor=None, serializer_class=None, queryset=None): +def retrieve_ap_object( + fid, actor=None, serializer_class=None, queryset=None, apply_instance_policies=True +): + from . import activity + + policies = moderation_models.InstancePolicy.objects.active().filter(block_all=True) + if apply_instance_policies and policies.matching_url(fid): + raise exceptions.BlockedActorOrDomain() if queryset: try: # queryset can also be a Model class @@ -83,6 +92,16 @@ def retrieve(fid, actor=None, serializer_class=None, queryset=None): ) response.raise_for_status() data = response.json() + + # we match against moderation policies here again, because the FID of the returned + # object may not be the same as the URL used to access it + try: + id = data["id"] + except KeyError: + pass + else: + if apply_instance_policies and activity.should_reject(id=id, payload=data): + raise exceptions.BlockedActorOrDomain() if not serializer_class: return data serializer = serializer_class(data=data) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 51648298a..b2088b5a6 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -4,6 +4,7 @@ from funkwhale_api.common import fields from funkwhale_api.common import search from funkwhale_api.federation import models as federation_models +from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models @@ -87,3 +88,24 @@ class ManageInvitationFilterSet(filters.FilterSet): if value is None: return queryset return queryset.open(value) + + +class ManageInstancePolicyFilterSet(filters.FilterSet): + q = fields.SearchFilter( + search_fields=[ + "summary", + "target_domain__name", + "target_actor__username", + "target_actor__domain__name", + ] + ) + + class Meta: + model = moderation_models.InstancePolicy + fields = [ + "q", + "block_all", + "silence_activity", + "silence_notifications", + "reject_media", + ] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 76d0cf05f..ed50d8677 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -3,7 +3,11 @@ from django.db import transaction from rest_framework import serializers from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import models as federation_models +from funkwhale_api.federation import fields as federation_fields +from funkwhale_api.federation import tasks as federation_tasks +from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models @@ -185,6 +189,13 @@ class ManageDomainSerializer(serializers.ModelSerializer): "outbox_activities_count", "nodeinfo", "nodeinfo_fetch_date", + "instance_policy", + ] + read_only_fields = [ + "creation_date", + "instance_policy", + "nodeinfo", + "nodeinfo_fetch_date", ] def get_actors_count(self, o): @@ -194,6 +205,17 @@ class ManageDomainSerializer(serializers.ModelSerializer): return getattr(o, "outbox_activities_count", 0) +class ManageDomainActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("purge", allow_all=False)] + filterset_class = filters.ManageDomainFilterSet + pk_field = "name" + + @transaction.atomic + def handle_purge(self, objects): + ids = objects.values_list("pk", flat=True) + common_utils.on_commit(federation_tasks.purge_actors.delay, domains=list(ids)) + + class ManageActorSerializer(serializers.ModelSerializer): uploads_count = serializers.SerializerMethodField() user = ManageUserSerializer() @@ -218,7 +240,102 @@ class ManageActorSerializer(serializers.ModelSerializer): "manually_approves_followers", "uploads_count", "user", + "instance_policy", ] + read_only_fields = ["creation_date", "instance_policy"] def get_uploads_count(self, o): return getattr(o, "uploads_count", 0) + + +class ManageActorActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("purge", allow_all=False)] + filterset_class = filters.ManageActorFilterSet + + @transaction.atomic + def handle_purge(self, objects): + ids = objects.values_list("id", flat=True) + common_utils.on_commit(federation_tasks.purge_actors.delay, ids=list(ids)) + + +class TargetSerializer(serializers.Serializer): + type = serializers.ChoiceField(choices=["domain", "actor"]) + id = serializers.CharField() + + def to_representation(self, value): + if value["type"] == "domain": + return {"type": "domain", "id": value["obj"].name} + if value["type"] == "actor": + return {"type": "actor", "id": value["obj"].full_username} + + def to_internal_value(self, value): + if value["type"] == "domain": + field = serializers.PrimaryKeyRelatedField( + queryset=federation_models.Domain.objects.external() + ) + if value["type"] == "actor": + field = federation_fields.ActorRelatedField() + value["obj"] = field.to_internal_value(value["id"]) + return value + + +class ManageInstancePolicySerializer(serializers.ModelSerializer): + target = TargetSerializer() + actor = federation_fields.ActorRelatedField(read_only=True) + + class Meta: + model = moderation_models.InstancePolicy + fields = [ + "id", + "uuid", + "target", + "creation_date", + "actor", + "summary", + "is_active", + "block_all", + "silence_activity", + "silence_notifications", + "reject_media", + ] + + read_only_fields = ["uuid", "id", "creation_date", "actor", "target"] + + def validate(self, data): + try: + target = data.pop("target") + except KeyError: + # partial update + return data + if target["type"] == "domain": + data["target_domain"] = target["obj"] + if target["type"] == "actor": + data["target_actor"] = target["obj"] + + return data + + @transaction.atomic + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + need_purge = self.instance.is_active and ( + self.instance.block_all or self.instance.reject_media + ) + if need_purge: + only = [] + if self.instance.reject_media: + only.append("media") + target = instance.target + if target["type"] == "domain": + common_utils.on_commit( + federation_tasks.purge_actors.delay, + domains=[target["obj"].pk], + only=only, + ) + if target["type"] == "actor": + common_utils.on_commit( + federation_tasks.purge_actors.delay, + ids=[target["obj"].pk], + only=only, + ) + + return instance diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 232b88711..4c220fe0e 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -5,8 +5,15 @@ from . import views federation_router = routers.SimpleRouter() federation_router.register(r"domains", views.ManageDomainViewSet, "domains") + library_router = routers.SimpleRouter() library_router.register(r"uploads", views.ManageUploadViewSet, "uploads") + +moderation_router = routers.SimpleRouter() +moderation_router.register( + r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies" +) + users_router = routers.SimpleRouter() users_router.register(r"users", views.ManageUserViewSet, "users") users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations") @@ -20,5 +27,9 @@ urlpatterns = [ include((federation_router.urls, "federation"), namespace="federation"), ), url(r"^library/", include((library_router.urls, "instance"), namespace="library")), + url( + r"^moderation/", + include((moderation_router.urls, "moderation"), namespace="moderation"), + ), url(r"^users/", include((users_router.urls, "instance"), namespace="users")), ] + other_router.urls diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 0697c6c14..e42915eb5 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -2,10 +2,11 @@ from rest_framework import mixins, response, viewsets from rest_framework.decorators import detail_route, list_route from django.shortcuts import get_object_or_404 -from funkwhale_api.common import preferences +from funkwhale_api.common import preferences, decorators from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.music import models as music_models +from funkwhale_api.moderation import models as moderation_models from funkwhale_api.users import models as users_models from funkwhale_api.users.permissions import HasUserPermission @@ -98,13 +99,17 @@ class ManageInvitationViewSet( class ManageDomainViewSet( - mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, ): lookup_value_regex = r"[a-zA-Z0-9\-\.]+" queryset = ( federation_models.Domain.objects.external() .with_actors_count() .with_outbox_activities_count() + .prefetch_related("instance_policy") .order_by("name") ) serializer_class = serializers.ManageDomainSerializer @@ -117,6 +122,7 @@ class ManageDomainViewSet( "nodeinfo_fetch_date", "actors_count", "outbox_activities_count", + "instance_policy", ] @detail_route(methods=["get"]) @@ -131,6 +137,8 @@ class ManageDomainViewSet( domain = self.get_object() return response.Response(domain.get_stats(), status=200) + action = decorators.action_route(serializers.ManageDomainActionSerializer) + class ManageActorViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet @@ -141,6 +149,7 @@ class ManageActorViewSet( .with_uploads_count() .order_by("-creation_date") .select_related("user") + .prefetch_related("instance_policy") ) serializer_class = serializers.ManageActorSerializer filter_class = filters.ManageActorFilterSet @@ -155,6 +164,7 @@ class ManageActorViewSet( "last_fetch_date", "uploads_count", "outbox_activities_count", + "instance_policy", ] def get_object(self): @@ -170,3 +180,28 @@ class ManageActorViewSet( def stats(self, request, *args, **kwargs): domain = self.get_object() return response.Response(domain.get_stats(), status=200) + + action = decorators.action_route(serializers.ManageActorActionSerializer) + + +class ManageInstancePolicyViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + moderation_models.InstancePolicy.objects.all() + .order_by("-creation_date") + .select_related() + ) + serializer_class = serializers.ManageInstancePolicySerializer + filter_class = filters.ManageInstancePolicyFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["moderation"] + ordering_fields = ["id", "creation_date"] + + def perform_create(self, serializer): + serializer.save(actor=self.request.user.actor) diff --git a/api/funkwhale_api/moderation/__init__.py b/api/funkwhale_api/moderation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/moderation/admin.py b/api/funkwhale_api/moderation/admin.py new file mode 100644 index 000000000..5e421255e --- /dev/null +++ b/api/funkwhale_api/moderation/admin.py @@ -0,0 +1,30 @@ +from funkwhale_api.common import admin + +from . import models + + +@admin.register(models.InstancePolicy) +class InstancePolicyAdmin(admin.ModelAdmin): + list_display = [ + "actor", + "target_domain", + "target_actor", + "creation_date", + "block_all", + "reject_media", + "silence_activity", + "silence_notifications", + ] + list_filter = [ + "block_all", + "reject_media", + "silence_activity", + "silence_notifications", + ] + search_fields = [ + "actor__fid", + "target_domain__name", + "target_domain__actor__fid", + "summary", + ] + list_select_related = True diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py new file mode 100644 index 000000000..aba5256c9 --- /dev/null +++ b/api/funkwhale_api/moderation/factories.py @@ -0,0 +1,23 @@ +import factory + +from funkwhale_api.factories import registry, NoUpdateOnCreate +from funkwhale_api.federation import factories as federation_factories + + +@registry.register +class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory): + summary = factory.Faker("paragraph") + actor = factory.SubFactory(federation_factories.ActorFactory) + block_all = True + is_active = True + + class Meta: + model = "moderation.InstancePolicy" + + class Params: + for_domain = factory.Trait( + target_domain=factory.SubFactory(federation_factories.DomainFactory) + ) + for_actor = factory.Trait( + target_actor=factory.SubFactory(federation_factories.ActorFactory) + ) diff --git a/api/funkwhale_api/moderation/migrations/0001_initial.py b/api/funkwhale_api/moderation/migrations/0001_initial.py new file mode 100644 index 000000000..33151e00f --- /dev/null +++ b/api/funkwhale_api/moderation/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 2.0.9 on 2019-01-07 06:06 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('federation', '0016_auto_20181227_1605'), + ] + + operations = [ + migrations.CreateModel( + name='InstancePolicy', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('is_active', models.BooleanField(default=True)), + ('summary', models.TextField(blank=True, max_length=10000, null=True)), + ('block_all', models.BooleanField(default=False)), + ('silence_activity', models.BooleanField(default=False)), + ('silence_notifications', models.BooleanField(default=False)), + ('reject_media', models.BooleanField(default=False)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_instance_policies', to='federation.Actor')), + ('target_actor', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='instance_policy', to='federation.Actor')), + ('target_domain', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='instance_policy', to='federation.Domain')), + ], + ), + ] diff --git a/api/funkwhale_api/moderation/migrations/__init__.py b/api/funkwhale_api/moderation/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py new file mode 100644 index 000000000..c184bbda8 --- /dev/null +++ b/api/funkwhale_api/moderation/models.py @@ -0,0 +1,75 @@ +import urllib.parse +import uuid + +from django.db import models +from django.utils import timezone + + +class InstancePolicyQuerySet(models.QuerySet): + def active(self): + return self.filter(is_active=True) + + def matching_url(self, *urls): + if not urls: + return self.none() + query = None + for url in urls: + new_query = self.matching_url_query(url) + if query: + query = query | new_query + else: + query = new_query + return self.filter(query) + + def matching_url_query(self, url): + parsed = urllib.parse.urlparse(url) + return models.Q(target_domain_id=parsed.hostname) | models.Q( + target_actor__fid=url + ) + + +class InstancePolicy(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + actor = models.ForeignKey( + "federation.Actor", + related_name="created_instance_policies", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + target_domain = models.OneToOneField( + "federation.Domain", + related_name="instance_policy", + on_delete=models.CASCADE, + null=True, + blank=True, + ) + target_actor = models.OneToOneField( + "federation.Actor", + related_name="instance_policy", + on_delete=models.CASCADE, + null=True, + blank=True, + ) + creation_date = models.DateTimeField(default=timezone.now) + + is_active = models.BooleanField(default=True) + # a summary explaining why the policy is in place + summary = models.TextField(max_length=10000, null=True, blank=True) + # either block everything (simpler, but less granularity) + block_all = models.BooleanField(default=False) + # or pick individual restrictions below + # do not show in timelines/notifications, except for actual followers + silence_activity = models.BooleanField(default=False) + silence_notifications = models.BooleanField(default=False) + # do not download any media from the target + reject_media = models.BooleanField(default=False) + + objects = InstancePolicyQuerySet.as_manager() + + @property + def target(self): + if self.target_actor: + return {"type": "actor", "obj": self.target_actor} + if self.target_domain_id: + return {"type": "domain", "obj": self.target_domain} diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index a65b7b0cc..67f60b486 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -46,6 +46,69 @@ def test_receive_validates_basic_attributes_and_stores_activity(factories, now, assert ii.is_read is False +def test_receive_calls_should_reject(factories, now, mocker): + should_reject = mocker.patch.object(activity, "should_reject", return_value=True) + local_to_actor = factories["users.User"]().create_actor() + remote_actor = factories["federation.Actor"]() + a = { + "@context": [], + "actor": remote_actor.fid, + "type": "Noop", + "id": "https://test.activity", + "to": [local_to_actor.fid, remote_actor.fid], + } + + copy = activity.receive(activity=a, on_behalf_of=remote_actor) + should_reject.assert_called_once_with( + id=a["id"], actor_id=remote_actor.fid, payload=a + ) + assert copy is None + + +@pytest.mark.parametrize( + "params, policy_kwargs, expected", + [ + ({"id": "https://ok.test"}, {"target_domain__name": "notok.test"}, False), + ( + {"id": "https://ok.test"}, + {"target_domain__name": "ok.test", "is_active": False}, + False, + ), + ( + {"id": "https://ok.test"}, + {"target_domain__name": "ok.test", "block_all": False}, + False, + ), + # id match blocked domain + ({"id": "http://notok.test"}, {"target_domain__name": "notok.test"}, True), + # actor id match blocked domain + ( + {"id": "http://ok.test", "actor_id": "https://notok.test"}, + {"target_domain__name": "notok.test"}, + True, + ), + # reject media + ( + { + "payload": {"type": "Library"}, + "id": "http://ok.test", + "actor_id": "http://notok.test", + }, + { + "target_domain__name": "notok.test", + "block_all": False, + "reject_media": True, + }, + True, + ), + ], +) +def test_should_reject(factories, params, policy_kwargs, expected): + factories["moderation.InstancePolicy"](for_domain=True, **policy_kwargs) + + assert activity.should_reject(**params) is expected + + def test_get_actors_from_audience_urls(settings, db): settings.FEDERATION_HOSTNAME = "federation.hostname" library_uuid1 = uuid.uuid4() diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index c2d695184..feb2ea246 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -23,7 +23,7 @@ def test_user_can_list_their_library_follows(factories, logged_in_api_client): def test_user_can_fetch_library_using_url(mocker, factories, logged_in_api_client): library = factories["music.Library"]() mocked_retrieve = mocker.patch( - "funkwhale_api.federation.utils.retrieve", return_value=library + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=library ) url = reverse("api:v1:federation:libraries-fetch") response = logged_in_api_client.post(url, {"fid": library.fid}) diff --git a/api/tests/federation/test_authentication.py b/api/tests/federation/test_authentication.py index 100971a3b..2888588b8 100644 --- a/api/tests/federation/test_authentication.py +++ b/api/tests/federation/test_authentication.py @@ -1,4 +1,6 @@ -from funkwhale_api.federation import authentication, keys +import pytest + +from funkwhale_api.federation import authentication, exceptions, keys def test_authenticate(factories, mocker, api_request): @@ -38,3 +40,89 @@ def test_authenticate(factories, mocker, api_request): assert user.is_anonymous is True assert actor.public_key == public.decode("utf-8") assert actor.fid == actor_url + + +def test_authenticate_skips_blocked_domain(factories, api_request): + policy = factories["moderation.InstancePolicy"](block_all=True, for_domain=True) + private, public = keys.get_key_pair() + actor_url = "https://{}/actor".format(policy.target_domain.name) + + signed_request = factories["federation.SignedRequest"]( + auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"] + ) + prepared = signed_request.prepare() + django_request = api_request.get( + "/", + **{ + "HTTP_DATE": prepared.headers["date"], + "HTTP_SIGNATURE": prepared.headers["signature"], + } + ) + authenticator = authentication.SignatureAuthentication() + + with pytest.raises(exceptions.BlockedActorOrDomain): + authenticator.authenticate(django_request) + + +def test_authenticate_skips_blocked_actor(factories, api_request): + policy = factories["moderation.InstancePolicy"](block_all=True, for_actor=True) + private, public = keys.get_key_pair() + actor_url = policy.target_actor.fid + + signed_request = factories["federation.SignedRequest"]( + auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"] + ) + prepared = signed_request.prepare() + django_request = api_request.get( + "/", + **{ + "HTTP_DATE": prepared.headers["date"], + "HTTP_SIGNATURE": prepared.headers["signature"], + } + ) + authenticator = authentication.SignatureAuthentication() + + with pytest.raises(exceptions.BlockedActorOrDomain): + authenticator.authenticate(django_request) + + +def test_authenticate_ignore_inactive_policy(factories, api_request, mocker): + policy = factories["moderation.InstancePolicy"]( + block_all=True, for_domain=True, is_active=False + ) + private, public = keys.get_key_pair() + actor_url = "https://{}/actor".format(policy.target_domain.name) + + signed_request = factories["federation.SignedRequest"]( + auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"] + ) + mocker.patch( + "funkwhale_api.federation.actors.get_actor_data", + return_value={ + "id": actor_url, + "type": "Person", + "outbox": "https://test.com", + "inbox": "https://test.com", + "followers": "https://test.com", + "preferredUsername": "test", + "publicKey": { + "publicKeyPem": public.decode("utf-8"), + "owner": actor_url, + "id": actor_url + "#main-key", + }, + }, + ) + prepared = signed_request.prepare() + django_request = api_request.get( + "/", + **{ + "HTTP_DATE": prepared.headers["date"], + "HTTP_SIGNATURE": prepared.headers["signature"], + } + ) + authenticator = authentication.SignatureAuthentication() + authenticator.authenticate(django_request) + actor = django_request.actor + + assert actor.public_key == public.decode("utf-8") + assert actor.fid == actor_url diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index fb151b2d7..207a8fbe5 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -507,7 +507,7 @@ def test_music_library_serializer_to_ap(factories): def test_music_library_serializer_from_public(factories, mocker): actor = factories["federation.Actor"]() retrieve = mocker.patch( - "funkwhale_api.federation.utils.retrieve", return_value=actor + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor ) data = { "@context": [ @@ -550,7 +550,7 @@ def test_music_library_serializer_from_public(factories, mocker): def test_music_library_serializer_from_private(factories, mocker): actor = factories["federation.Actor"]() retrieve = mocker.patch( - "funkwhale_api.federation.utils.retrieve", return_value=actor + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor ) data = { "@context": [ diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py index ad7a577ef..5e10dfa50 100644 --- a/api/tests/federation/test_tasks.py +++ b/api/tests/federation/test_tasks.py @@ -190,3 +190,79 @@ def test_update_domain_nodeinfo_error(factories, r_mock, now): "status": "error", "error": "500 Server Error: None for url: {}".format(wellknown_url), } + + +def test_handle_purge_actors(factories, mocker): + to_purge = factories["federation.Actor"]() + keeped = [ + factories["music.Upload"](), + factories["federation.Activity"](), + factories["federation.InboxItem"](), + factories["federation.Follow"](), + factories["federation.LibraryFollow"](), + ] + + library = factories["music.Library"](actor=to_purge) + deleted = [ + library, + factories["music.Upload"](library=library), + factories["federation.Activity"](actor=to_purge), + factories["federation.InboxItem"](actor=to_purge), + factories["federation.Follow"](actor=to_purge), + factories["federation.LibraryFollow"](actor=to_purge), + ] + + tasks.handle_purge_actors([to_purge.pk]) + + for k in keeped: + # this should not be deleted + k.refresh_from_db() + + for d in deleted: + with pytest.raises(d.__class__.DoesNotExist): + d.refresh_from_db() + + +def test_handle_purge_actors_restrict_media(factories, mocker): + to_purge = factories["federation.Actor"]() + keeped = [ + factories["music.Upload"](), + factories["federation.Activity"](), + factories["federation.InboxItem"](), + factories["federation.Follow"](), + factories["federation.LibraryFollow"](), + factories["federation.Activity"](actor=to_purge), + factories["federation.InboxItem"](actor=to_purge), + factories["federation.Follow"](actor=to_purge), + ] + + library = factories["music.Library"](actor=to_purge) + deleted = [ + library, + factories["music.Upload"](library=library), + factories["federation.LibraryFollow"](actor=to_purge), + ] + + tasks.handle_purge_actors([to_purge.pk], only=["media"]) + + for k in keeped: + # this should not be deleted + k.refresh_from_db() + + for d in deleted: + with pytest.raises(d.__class__.DoesNotExist): + d.refresh_from_db() + + +def test_purge_actors(factories, mocker): + handle_purge_actors = mocker.spy(tasks, "handle_purge_actors") + factories["federation.Actor"]() + to_delete = factories["federation.Actor"]() + to_delete_domain = factories["federation.Actor"]() + tasks.purge_actors( + ids=[to_delete.pk], domains=[to_delete_domain.domain.name], only=["hello"] + ) + + handle_purge_actors.assert_called_once_with( + ids=[to_delete.pk, to_delete_domain.pk], only=["hello"] + ) diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py index e89c52543..a8d02cdb9 100644 --- a/api/tests/federation/test_utils.py +++ b/api/tests/federation/test_utils.py @@ -1,7 +1,7 @@ from rest_framework import serializers import pytest -from funkwhale_api.federation import utils +from funkwhale_api.federation import exceptions, utils @pytest.mark.parametrize( @@ -53,20 +53,43 @@ def test_extract_headers_from_meta(): assert cleaned_headers == expected -def test_retrieve(r_mock): +def test_retrieve_ap_object(db, r_mock): fid = "https://some.url" m = r_mock.get(fid, json={"hello": "world"}) - result = utils.retrieve(fid) + result = utils.retrieve_ap_object(fid) assert result == {"hello": "world"} assert m.request_history[-1].headers["Accept"] == "application/activity+json" +def test_retrieve_ap_object_honor_instance_policy_domain(factories): + domain = factories["moderation.InstancePolicy"]( + block_all=True, for_domain=True + ).target_domain + fid = "https://{}/test".format(domain.name) + + with pytest.raises(exceptions.BlockedActorOrDomain): + utils.retrieve_ap_object(fid) + + +def test_retrieve_ap_object_honor_instance_policy_different_url_and_id( + r_mock, factories +): + domain = factories["moderation.InstancePolicy"]( + block_all=True, for_domain=True + ).target_domain + fid = "https://ok/test" + r_mock.get(fid, json={"id": "http://{}/test".format(domain.name)}) + + with pytest.raises(exceptions.BlockedActorOrDomain): + utils.retrieve_ap_object(fid) + + def test_retrieve_with_actor(r_mock, factories): actor = factories["federation.Actor"]() fid = "https://some.url" m = r_mock.get(fid, json={"hello": "world"}) - result = utils.retrieve(fid, actor=actor) + result = utils.retrieve_ap_object(fid, actor=actor) assert result == {"hello": "world"} assert m.request_history[-1].headers["Accept"] == "application/activity+json" @@ -76,16 +99,16 @@ def test_retrieve_with_actor(r_mock, factories): def test_retrieve_with_queryset(factories): actor = factories["federation.Actor"]() - assert utils.retrieve(actor.fid, queryset=actor.__class__) + assert utils.retrieve_ap_object(actor.fid, queryset=actor.__class__) -def test_retrieve_with_serializer(r_mock): +def test_retrieve_with_serializer(db, r_mock): class S(serializers.Serializer): def create(self, validated_data): return {"persisted": "object"} fid = "https://some.url" r_mock.get(fid, json={"hello": "world"}) - result = utils.retrieve(fid, serializer_class=S) + result = utils.retrieve_ap_object(fid, serializer_class=S) assert result == {"persisted": "object"} diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 803820b48..53bc2504b 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -1,4 +1,7 @@ +import pytest + from funkwhale_api.manage import serializers +from funkwhale_api.federation import tasks as federation_tasks def test_manage_upload_action_delete(factories): @@ -47,12 +50,20 @@ def test_manage_domain_serializer(factories, now): "outbox_activities_count": 23, "nodeinfo": {}, "nodeinfo_fetch_date": None, + "instance_policy": None, } s = serializers.ManageDomainSerializer(domain) assert s.data == expected +def test_manage_domain_serializer_validates_hostname(db): + s = serializers.ManageDomainSerializer(data={"name": "hello world"}) + + with pytest.raises(serializers.serializers.ValidationError): + s.is_valid(raise_exception=True) + + def test_manage_actor_serializer(factories, now): actor = factories["federation.Actor"]() setattr(actor, "uploads_count", 66) @@ -74,7 +85,175 @@ def test_manage_actor_serializer(factories, now): "manually_approves_followers": actor.manually_approves_followers, "full_username": actor.full_username, "user": None, + "instance_policy": None, } s = serializers.ManageActorSerializer(actor) assert s.data == expected + + +@pytest.mark.parametrize( + "factory_kwargs,expected", + [ + ( + {"for_domain": True, "target_domain__name": "test.federation"}, + {"target": {"type": "domain", "id": "test.federation"}}, + ), + ( + { + "for_actor": True, + "target_actor__domain__name": "test.federation", + "target_actor__preferred_username": "hello", + }, + {"target": {"type": "actor", "id": "hello@test.federation"}}, + ), + ], +) +def test_instance_policy_serializer_repr(factories, factory_kwargs, expected): + policy = factories["moderation.InstancePolicy"](block_all=True, **factory_kwargs) + + e = { + "id": policy.id, + "uuid": str(policy.uuid), + "creation_date": policy.creation_date.isoformat().split("+")[0] + "Z", + "actor": policy.actor.full_username, + "block_all": True, + "silence_activity": False, + "silence_notifications": False, + "reject_media": False, + "is_active": policy.is_active, + "summary": policy.summary, + } + e.update(expected) + + assert serializers.ManageInstancePolicySerializer(policy).data == e + + +def test_instance_policy_serializer_save_domain(factories): + domain = factories["federation.Domain"]() + + data = {"target": {"id": domain.name, "type": "domain"}, "block_all": True} + + serializer = serializers.ManageInstancePolicySerializer(data=data) + serializer.is_valid(raise_exception=True) + policy = serializer.save() + + assert policy.target_domain == domain + + +def test_instance_policy_serializer_save_actor(factories): + actor = factories["federation.Actor"]() + + data = {"target": {"id": actor.full_username, "type": "actor"}, "block_all": True} + + serializer = serializers.ManageInstancePolicySerializer(data=data) + serializer.is_valid(raise_exception=True) + policy = serializer.save() + + assert policy.target_actor == actor + + +def test_manage_actor_action_purge(factories, mocker): + actors = factories["federation.Actor"].create_batch(size=3) + s = serializers.ManageActorActionSerializer(queryset=None) + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + + s.handle_purge(actors[0].__class__.objects.all()) + on_commit.assert_called_once_with( + federation_tasks.purge_actors.delay, ids=[a.pk for a in actors] + ) + + +def test_manage_domain_action_purge(factories, mocker): + domains = factories["federation.Domain"].create_batch(size=3) + s = serializers.ManageDomainActionSerializer(queryset=None) + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + + s.handle_purge(domains[0].__class__.objects.all()) + on_commit.assert_called_once_with( + federation_tasks.purge_actors.delay, domains=[d.pk for d in domains] + ) + + +@pytest.mark.parametrize( + "param,expected_only", [("block_all", []), ("reject_media", ["media"])] +) +def test_instance_policy_serializer_purges_target_domain( + factories, mocker, param, expected_only +): + params = {param: False} + if param != "block_all": + params["block_all"] = False + policy = factories["moderation.InstancePolicy"](for_domain=True, **params) + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + + serializer = serializers.ManageInstancePolicySerializer( + policy, data={param: True}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + policy.refresh_from_db() + + assert getattr(policy, param) is True + on_commit.assert_called_once_with( + federation_tasks.purge_actors.delay, + domains=[policy.target_domain_id], + only=expected_only, + ) + + on_commit.reset_mock() + + # setting to false should have no effect + serializer = serializers.ManageInstancePolicySerializer( + policy, data={param: False}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + policy.refresh_from_db() + + assert getattr(policy, param) is False + assert on_commit.call_count == 0 + + +@pytest.mark.parametrize( + "param,expected_only", [("block_all", []), ("reject_media", ["media"])] +) +def test_instance_policy_serializer_purges_target_actor( + factories, mocker, param, expected_only +): + params = {param: False} + if param != "block_all": + params["block_all"] = False + policy = factories["moderation.InstancePolicy"](for_actor=True, **params) + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + + serializer = serializers.ManageInstancePolicySerializer( + policy, data={param: True}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + policy.refresh_from_db() + + assert getattr(policy, param) is True + on_commit.assert_called_once_with( + federation_tasks.purge_actors.delay, + ids=[policy.target_actor_id], + only=expected_only, + ) + + on_commit.reset_mock() + + # setting to false should have no effect + serializer = serializers.ManageInstancePolicySerializer( + policy, data={param: False}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + policy.refresh_from_db() + + assert getattr(policy, param) is False + assert on_commit.call_count == 0 diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index 72e945bca..6402fb650 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -1,6 +1,7 @@ import pytest from django.urls import reverse +from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.manage import serializers, views @@ -13,6 +14,7 @@ from funkwhale_api.manage import serializers, views (views.ManageInvitationViewSet, ["settings"], "and"), (views.ManageDomainViewSet, ["moderation"], "and"), (views.ManageActorViewSet, ["moderation"], "and"), + (views.ManageInstancePolicyViewSet, ["moderation"], "and"), ], ) def test_permissions(assert_user_permission, view, permissions, operator): @@ -90,6 +92,14 @@ def test_domain_detail(factories, superuser_api_client): assert response.data["name"] == d.pk +def test_domain_create(superuser_api_client): + url = reverse("api:v1:manage:federation:domains-list") + response = superuser_api_client.post(url, {"name": "test.federation"}) + + assert response.status_code == 201 + assert federation_models.Domain.objects.filter(pk="test.federation").exists() + + def test_domain_nodeinfo(factories, superuser_api_client, mocker): domain = factories["federation.Domain"]() url = reverse( @@ -133,3 +143,19 @@ def test_actor_detail(factories, superuser_api_client): assert response.status_code == 200 assert response.data["id"] == actor.id + + +def test_instance_policy_create(superuser_api_client, factories): + domain = factories["federation.Domain"]() + actor = superuser_api_client.user.create_actor() + url = reverse("api:v1:manage:moderation:instance-policies-list") + response = superuser_api_client.post( + url, + {"target": {"type": "domain", "id": domain.name}, "block_all": True}, + format="json", + ) + + assert response.status_code == 201 + + policy = domain.instance_policy + assert policy.actor == actor diff --git a/changes/changelog.d/moderation-tools.feature b/changes/changelog.d/moderation-tools.feature new file mode 100644 index 000000000..4b3e670ca --- /dev/null +++ b/changes/changelog.d/moderation-tools.feature @@ -0,0 +1,20 @@ + +First set of instance level moderation tools (#580, !521) + + +Instance-level moderation tools +------------------------------- + +This release includes a first set of moderation tools that will give more control +to admins about the way their instance federate with other instance and accounts on the network. +Using these tools, it's now possible to: + +- Browse known accounts and domains, and associated data (storage size, software version, etc.) +- Purge data belonging to given accounts and domains +- Block or partially restrict interactions with any account or domain + +All those features are usable using a brand new "moderation" permission, meaning +you can appoints one or nultiple moderators to help with this task. + +I'd like to thank all Mastodon contributors, because some of the these tools are heavily +inspired from what's being done in Mastodon. Thank you so much! diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index 5b04d3cab..d6d19076c 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -27,14 +27,8 @@
-
+
+
+