From 0bc9bb65b04ea4b5d3b98d386d790f65295a2c19 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Mon, 7 Jan 2019 09:45:53 +0100
Subject: [PATCH] Model, view and serializer for instance-level policies

---
 api/config/settings/common.py                 |  1 +
 api/funkwhale_api/federation/factories.py     |  4 +-
 api/funkwhale_api/federation/fields.py        | 18 ++++++
 api/funkwhale_api/manage/filters.py           | 22 +++++++
 api/funkwhale_api/manage/serializers.py       | 64 +++++++++++++++++++
 api/funkwhale_api/manage/urls.py              | 11 ++++
 api/funkwhale_api/manage/views.py             | 24 +++++++
 api/funkwhale_api/moderation/__init__.py      |  0
 api/funkwhale_api/moderation/factories.py     | 23 +++++++
 .../moderation/migrations/0001_initial.py     | 35 ++++++++++
 .../moderation/migrations/__init__.py         |  0
 api/funkwhale_api/moderation/models.py        | 63 ++++++++++++++++++
 api/tests/manage/test_serializers.py          | 51 +++++++++++++++
 api/tests/manage/test_views.py                | 17 +++++
 14 files changed, 331 insertions(+), 2 deletions(-)
 create mode 100644 api/funkwhale_api/federation/fields.py
 create mode 100644 api/funkwhale_api/moderation/__init__.py
 create mode 100644 api/funkwhale_api/moderation/factories.py
 create mode 100644 api/funkwhale_api/moderation/migrations/0001_initial.py
 create mode 100644 api/funkwhale_api/moderation/migrations/__init__.py
 create mode 100644 api/funkwhale_api/moderation/models.py

diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 7c10a39b..74fe79ed 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/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index 331a5f5d..f54f6867 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 00000000..f23d0907
--- /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_interal_value(self, value):
+        value = super().to_interal_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/manage/filters.py b/api/funkwhale_api/manage/filters.py
index 51648298..b2088b5a 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 76d0cf05..009f5c31 100644
--- a/api/funkwhale_api/manage/serializers.py
+++ b/api/funkwhale_api/manage/serializers.py
@@ -4,6 +4,8 @@ from rest_framework import serializers
 
 from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.federation import models as federation_models
+from funkwhale_api.federation import fields as federation_fields
+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 +187,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):
@@ -218,7 +227,62 @@ 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 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):
+        target = data.pop("target")
+        if target["type"] == "domain":
+            data["target_domain"] = target["obj"]
+        if target["type"] == "actor":
+            data["target_actor"] = target["obj"]
+
+        return data
diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py
index 232b8871..4c220fe0 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 763b3749..f1fbf01a 100644
--- a/api/funkwhale_api/manage/views.py
+++ b/api/funkwhale_api/manage/views.py
@@ -6,6 +6,7 @@ from funkwhale_api.common import preferences
 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
 
@@ -173,3 +174,26 @@ class ManageActorViewSet(
     def stats(self, request, *args, **kwargs):
         domain = self.get_object()
         return response.Response(domain.get_stats(), status=200)
+
+
+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 00000000..e69de29b
diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py
new file mode 100644
index 00000000..aba5256c
--- /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 00000000..33151e00
--- /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 00000000..e69de29b
diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py
new file mode 100644
index 00000000..59b077ba
--- /dev/null
+++ b/api/funkwhale_api/moderation/models.py
@@ -0,0 +1,63 @@
+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, url):
+        parsed = urllib.parse.urlparse(url)
+        return self.filter(
+            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/manage/test_serializers.py b/api/tests/manage/test_serializers.py
index 74ba96ba..6dbd7ac3 100644
--- a/api/tests/manage/test_serializers.py
+++ b/api/tests/manage/test_serializers.py
@@ -49,6 +49,7 @@ def test_manage_domain_serializer(factories, now):
         "outbox_activities_count": 23,
         "nodeinfo": {},
         "nodeinfo_fetch_date": None,
+        "instance_policy": None,
     }
     s = serializers.ManageDomainSerializer(domain)
 
@@ -83,7 +84,57 @@ 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
diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py
index 4591f7b1..6402fb65 100644
--- a/api/tests/manage/test_views.py
+++ b/api/tests/manage/test_views.py
@@ -14,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):
@@ -142,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
-- 
GitLab