Merge branch 'domain-actor-block' into 'develop'

Domains/actor blocking and instance-level moderation tools

See merge request funkwhale/funkwhale!521
This commit is contained in:
Eliot Berriot 2019-01-10 11:58:55 +01:00
commit 38e047e443
46 changed files with 1599 additions and 87 deletions

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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 "

View File

@ -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

View File

@ -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

View File

@ -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))},

View File

@ -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

View File

@ -1,6 +1,13 @@
from rest_framework import exceptions
class MalformedPayload(ValueError):
pass
class MissingSignature(KeyError):
pass
class BlockedActorOrDomain(exceptions.AuthenticationFailed):
pass

View File

@ -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)
)

View File

@ -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")

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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",
]

View File

@ -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

View File

@ -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

View File

@ -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)

View File

View File

@ -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

View File

@ -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)
)

View File

@ -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')),
],
),
]

View File

@ -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}

View File

@ -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()

View File

@ -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})

View File

@ -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

View File

@ -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": [

View File

@ -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"]
)

View File

@ -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"}

View File

@ -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

View File

@ -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

View File

@ -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!

View File

@ -27,14 +27,8 @@
</select>
</div>
<div class="field">
<div
v-if="!selectAll"
@click="launchAction"
:disabled="checked.length === 0"
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
<translate>Go</translate></div>
<dangerous-button
v-else :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
v-if="selectAll || currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
confirm-color="green"
color=""
@confirm="launchAction">
@ -42,17 +36,23 @@
<p slot="modal-header">
<translate
key="1"
:translate-n="objectsData.count"
:translate-params="{count: objectsData.count, action: currentActionName}"
:translate-n="checked.length"
:translate-params="{count: checked.length, action: currentActionName}"
translate-plural="Do you want to launch %{ action } on %{ count } elements?">
Do you want to launch %{ action } on %{ count } element?
</translate>
</p>
<p slot="modal-content">
<translate>This may affect a lot of elements, please double check this is really what you want.</translate>
<translate>This may affect a lot of elements or have irreversible consequences, please double check this is really what you want.</translate>
</p>
<p slot="modal-confirm"><translate>Launch</translate></p>
</dangerous-button>
<div
v-else
@click="launchAction"
:disabled="checked.length === 0"
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
<translate>Go</translate></div>
</div>
<div class="count field">
<translate

View File

@ -0,0 +1,12 @@
<template>
<span class="tooltip" :data-tooltip="content"><i class="question circle icon"></i></span>
</template>
<script>
export default {
props: {
content: {type: String, required: true},
}
}
</script>

View File

@ -40,5 +40,9 @@ import AjaxButton from '@/components/common/AjaxButton'
Vue.component('ajax-button', AjaxButton)
import Tooltip from '@/components/common/Tooltip'
Vue.component('tooltip', Tooltip)
export default {}

View File

@ -34,6 +34,7 @@
@action-launched="fetchData"
:objects-data="result"
:actions="actions"
action-url="manage/accounts/action/"
:filters="actionFilters">
<template slot="header-cells">
<th><translate>Name</translate></th>
@ -41,6 +42,7 @@
<th><translate>Uploads</translate></th>
<th><translate>First seen</translate></th>
<th><translate>Last seen</translate></th>
<th><translate>Under moderation rule</translate></th>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
@ -67,6 +69,9 @@
<td>
<human-date v-if="scope.obj.last_fetch_date" :date="scope.obj.last_fetch_date"></human-date>
</td>
<td>
<span v-if="scope.obj.instance_policy"><i class="shield icon"></i> <translate>Yes</translate></span>
</td>
</template>
</action-table>
</div>
@ -178,11 +183,11 @@ export default {
},
actions () {
return [
// {
// name: 'delete',
// label: this.$gettext('Delete'),
// isDangerous: true
// }
{
name: 'purge',
label: this.$gettext('Purge'),
isDangerous: true
}
]
}
},

View File

@ -32,12 +32,15 @@
@action-launched="fetchData"
:objects-data="result"
:actions="actions"
action-url="manage/federation/domains/action/"
idField="name"
:filters="actionFilters">
<template slot="header-cells">
<th><translate>Name</translate></th>
<th><translate>Users</translate></th>
<th><translate>Received messages</translate></th>
<th><translate>First seen</translate></th>
<th><translate>Under moderation rule</translate></th>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
@ -52,6 +55,9 @@
<td>
<human-date :date="scope.obj.creation_date"></human-date>
</td>
<td>
<span v-if="scope.obj.instance_policy"><i class="shield icon"></i> <translate>Yes</translate></span>
</td>
</template>
</action-table>
</div>
@ -157,11 +163,11 @@ export default {
},
actions () {
return [
// {
// name: 'delete',
// label: this.$gettext('Delete'),
// isDangerous: true
// }
{
name: 'purge',
label: this.$gettext('Purge'),
isDangerous: true
}
]
}
},

View File

@ -0,0 +1,72 @@
<template>
<div>
<slot></slot>
<p>
<i class="clock outline icon"></i><human-date :date="object.creation_date" /> &nbsp;
<i class="user icon"></i>{{ object.actor }} &nbsp;
<template v-if="object.is_active">
<i class="play icon"></i>
<translate>Enabled</translate>
</template>
<template v-if="!object.is_active">
<i class="pause icon"></i>
<translate>Paused</translate>
</template>
</p>
<div>
<p><strong><translate>Rule</translate></strong></p>
<p v-if="object.block_all">
<i class="ban icon"></i>
<translate>Block everything</translate>
</p>
<div v-else class="ui list">
<div class="ui item" v-if="object.silence_activity">
<i class="feed icon"></i>
<div class="content"><translate>Silence activity</translate></div>
</div>
<div class="ui item" v-if="object.silence_notifications">
<i class="bell icon"></i>
<div class="content"><translate>Silence notifications</translate></div>
</div>
<div class="ui item" v-if="object.reject_media">
<i class="file icon"></i>
<div class="content"><translate>Reject media</translate></div>
</div>
</div>
</div>
<div v-if="markdown && object.summary">
<div class="ui hidden divider"></div>
<p><strong><translate>Reason</translate></strong></p>
<div v-html="markdown.makeHtml(object.summary)"></div>
</div>
<div class="ui hidden divider"></div>
<button @click="$emit('update')" class="ui right floated labeled icon button">
<i class="edit icon"></i>
<translate>Update</translate>
</button>
</div>
</template>
<script>
export default {
props: {
object: {type: Object, default: null},
},
data () {
return {
markdown: null
}
},
created () {
let self = this
import('showdown').then(module => {
self.markdown = new module.default.Converter({simplifiedAutoLink: true, openLinksInNewWindow: true})
})
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,214 @@
<template>
<form class="ui form" @submit.prevent="createOrUpdate">
<h3 class="ui header">
<translate v-if="object" key="1">Update moderation rule</translate>
<translate v-else key="2">Add a new moderation rule</translate>
</h3>
<div v-if="errors && errors.length > 0" class="ui negative message">
<div class="header"><translate>Error while creating rule</translate></div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div class="field" v-if="object">
<div class="ui toggle checkbox">
<input id="policy-is-active" v-model="current.isActive" type="checkbox">
<label for="policy-is-active">
<translate v-if="current.isActive" key="1">Enabled</translate>
<translate v-else key="2">Disabled</translate>
<tooltip :content="labels.isActiveHelp" />
</label>
</div>
</div>
<div class="field">
<label for="policy-summary">
<translate>Reason</translate>
<tooltip :content="labels.summaryHelp" />
</label>
<textarea name="policy-summary" id="policy-summary" rows="5" v-model="current.summary"></textarea>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input id="policy-is-active" v-model="current.blockAll" type="checkbox">
<label for="policy-is-active">
<translate>Block everything</translate>
<tooltip :content="labels.blockAllHelp" />
</label>
</div>
</div>
<div class="ui horizontal divider">
<translate>Or customize your rule</translate>
</div>
<div v-for="config in fieldConfig" :class="['field']">
<div class="ui toggle checkbox">
<input :id="'policy-' + config.id" v-model="current[config.id]" type="checkbox">
<label :for="'policy-' + config.id">
<i :class="[config.icon, 'icon']"></i>
{{ labels[config.id].label }}
<tooltip :content="labels[config.id].help" />
</label>
</div>
</div>
<div class="ui hidden divider"></div>
<button @click="$emit('cancel')" class="ui basic left floated button">
<translate>Cancel</translate>
</button>
<button :class="['ui', 'right', 'floated', 'green', {'disabled loading': isLoading}, 'button']" :disabled="isLoading">
<translate v-if="object" key="1">Update</translate>
<translate v-else key="2">Create</translate>
</button>
<dangerous-button v-if="object" class="right floated basic button" color='red' @confirm="remove">
<translate>Delete</translate>
<p slot="modal-header">
<translate>Delete this moderation rule?</translate>
</p>
<p slot="modal-content">
<translate>This action is irreversible.</translate>
</p>
<p slot="modal-confirm">
<translate>Delete moderation rule</translate>
</p>
</dangerous-button>
</form>
</template>
<script>
import axios from 'axios'
import _ from 'lodash'
export default {
props: {
type: {type: String, required: true},
object: {type: Object, default: null},
target: {type: String, required: true},
},
data () {
let current = this.object || {}
return {
isLoading: false,
errors: [],
current: {
summary: _.get(current, 'summary', ''),
isActive: _.get(current, 'is_active', true),
blockAll: _.get(current, 'block_all', true),
silenceActivity: _.get(current, 'silence_activity', false),
silenceNotifications: _.get(current, 'silence_notifications', false),
rejectMedia: _.get(current, 'reject_media', false),
},
fieldConfig: [
// we hide those until we actually have the related features implemented :)
// {id: "silenceActivity", icon: "feed"},
// {id: "silenceNotifications", icon: "bell"},
{id: "rejectMedia", icon: "file"},
]
}
},
computed: {
labels () {
return {
summaryHelp: this.$gettext("Explain why you're applying this policy. Depending on your instance configuration, this will help you remember why you acted on this account or domain, and may be displayed publicly to help users understand what moderation rules are in place."),
isActiveHelp: this.$gettext("Use this setting to temporarily enable/disable the policy without completely removing it."),
blockAllHelp: this.$gettext("Block everything from this account or domain. This will prevent any interaction with the entity, and purge related content (uploads, libraries, follows, etc.)"),
silenceActivity: {
help: this.$gettext("Hide account or domain content, except from followers."),
label: this.$gettext("Silence activity"),
},
silenceNotifications: {
help: this.$gettext("Prevent account or domain from triggering notifications, except from followers."),
label: this.$gettext("Silence notifications"),
},
rejectMedia: {
help: this.$gettext("Do not download any media file (audio, album cover, account avatar…) from this account or domain. This will purge existing content as well."),
label: this.$gettext("Reject media"),
}
}
}
},
methods: {
createOrUpdate () {
let self = this
this.isLoading = true
this.errors = []
let url, method
let data = {
summary: this.current.summary,
is_active: this.current.isActive,
block_all: this.current.blockAll,
silence_activity: this.current.silenceActivity,
silence_notifications: this.current.silenceNotifications,
reject_media: this.current.rejectMedia,
target: {
type: this.type,
id: this.target,
}
}
if (this.object) {
url = `manage/moderation/instance-policies/${this.object.id}/`
method = 'patch'
} else {
url = `manage/moderation/instance-policies/`
method = 'post'
}
axios[method](url, data).then((response) => {
this.isLoading = false
self.$emit('save', response.data)
}, (error) => {
self.isLoading = false
self.errors = error.backendErrors
})
},
remove () {
let self = this
this.isLoading = true
this.errors = []
let url = `manage/moderation/instance-policies/${this.object.id}/`
axios.delete(url).then((response) => {
this.isLoading = false
self.$emit('delete')
}, (error) => {
self.isLoading = false
self.errors = error.backendErrors
})
}
},
watch: {
'current.silenceActivity': function (v) {
if (v) {
this.current.blockAll = false
}
},
'current.silenceNotifications': function (v) {
if (v) {
this.current.blockAll = false
}
},
'current.rejectMedia': function (v) {
if (v) {
this.current.blockAll = false
}
},
'current.blockAll': function (v) {
if (v) {
let self = this
this.fieldConfig.forEach((f) => {
self.current[f.id] = false
})
}
}
}
}
</script>
<style scoped>
.ui.placeholder.segment .field,
.ui.placeholder.segment textarea,
.ui.placeholder.segment > .ui.input,
.ui.placeholder.segment .button {
max-width: 100%;
}
.segment .right.floated.button {
margin-left: 1em;
}
</style>

View File

@ -255,7 +255,11 @@ button.reset {
[data-tooltip]::after {
white-space: normal;
width: 300px;
max-width: 300px;
width: 500px;
max-width: 500px;
z-index: 999;
}
label .tooltip {
margin-left: 1em;
}

View File

@ -5,26 +5,68 @@
</div>
<template v-if="object">
<section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.full_username">
<div class="segment-content">
<h2 class="ui header">
<i class="circular inverted user icon"></i>
<div class="content">
{{ object.full_username }}
<div class="sub header">
<template v-if="object.user">
<span class="ui tiny teal icon label">
<i class="home icon"></i>
<translate>Local account</translate>
</span>
&nbsp;
</template>
<a :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
<translate>Open profile</translate>&nbsp;
<i class="external icon"></i>
</a>
</div>
<div class="ui stackable two column grid">
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<i class="circular inverted user icon"></i>
<div class="content">
{{ object.full_username }}
<div class="sub header">
<template v-if="object.user">
<span class="ui tiny teal icon label">
<i class="home icon"></i>
<translate>Local account</translate>
</span>
&nbsp;
</template>
<a :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
<translate>Open profile</translate>&nbsp;
<i class="external icon"></i>
</a>
</div>
</div>
</h2>
</div>
</h2>
</div>
<div class="ui column">
<div v-if="!object.user" class="ui compact clearing placeholder segment">
<template v-if="isLoadingPolicy">
<div class="paragraph">
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
</div>
</template>
<template v-else-if="!policy && !showPolicyForm">
<header class="ui header">
<h3>
<i class="shield icon"></i>
<translate>You don't have any rule in place for this account.</translate>
</h3>
</header>
<p><translate>Moderation policies help you control how your instance interact with a given domain or account.</translate></p>
<button @click="showPolicyForm = true" class="ui primary button">Add a moderation policy</button>
</template>
<instance-policy-card v-else-if="policy && !showPolicyForm" :object="policy" @update="showPolicyForm = true">
<header class="ui header">
<h3>
<translate>This domain is subject to specific moderation rules</translate>
</h3>
</header>
</instance-policy-card>
<instance-policy-form
v-else-if="showPolicyForm"
@cancel="showPolicyForm = false"
@save="updatePolicy"
@delete="policy = null; showPolicyForm = false"
:object="policy"
type="actor"
:target="object.full_username" />
</div>
</div>
</div>
</section>
<div class="ui vertical stripe segment">
@ -309,15 +351,24 @@ import logger from "@/logging"
import lodash from '@/lodash'
import $ from "jquery"
import InstancePolicyForm from "@/components/manage/moderation/InstancePolicyForm"
import InstancePolicyCard from "@/components/manage/moderation/InstancePolicyCard"
export default {
props: ["id"],
components: {
InstancePolicyForm,
InstancePolicyCard,
},
data() {
return {
lodash,
isLoading: true,
isLoadingStats: false,
isLoadingPolicy: false,
object: null,
stats: null,
showPolicyForm: false,
permissions: [],
}
},
@ -333,6 +384,9 @@ export default {
axios.get(url).then(response => {
self.object = response.data
self.isLoading = false
if (self.object.instance_policy) {
self.fetchPolicy(self.object.instance_policy)
}
if (response.data.user) {
self.allPermissions.forEach(p => {
if (self.object.user.permissions[p.code]) {
@ -342,6 +396,15 @@ export default {
}
})
},
fetchPolicy(id) {
var self = this
this.isLoadingPolicy = true
let url = `manage/moderation/instance-policies/${id}/`
axios.get(url).then(response => {
self.policy = response.data
self.isLoadingPolicy = false
})
},
fetchStats() {
var self = this
this.isLoadingStats = true
@ -423,4 +486,7 @@ export default {
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.placeholder.segment {
width: 100%;
}
</style>

View File

@ -5,19 +5,61 @@
</div>
<template v-if="object">
<section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.name">
<div class="segment-content">
<h2 class="ui header">
<i class="circular inverted cloud icon"></i>
<div class="content">
{{ object.name }}
<div class="sub header">
<a :href="externalUrl" target="_blank" rel="noopener noreferrer" class="logo-wrapper">
<translate>Open website</translate>&nbsp;
<i class="external icon"></i>
</a>
</div>
<div class="ui stackable two column grid">
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<i class="circular inverted cloud icon"></i>
<div class="content">
{{ object.name }}
<div class="sub header">
<a :href="externalUrl" target="_blank" rel="noopener noreferrer" class="logo-wrapper">
<translate>Open website</translate>&nbsp;
<i class="external icon"></i>
</a>
</div>
</div>
</h2>
</div>
</h2>
</div>
<div class="ui column">
<div class="ui compact clearing placeholder segment">
<template v-if="isLoadingPolicy">
<div class="paragraph">
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
</div>
</template>
<template v-else-if="!policy && !showPolicyForm">
<header class="ui header">
<h3>
<i class="shield icon"></i>
<translate>You don't have any rule in place for this domain.</translate>
</h3>
</header>
<p><translate>Moderation policies help you control how your instance interact with a given domain or account.</translate></p>
<button @click="showPolicyForm = true" class="ui primary button">Add a moderation policy</button>
</template>
<instance-policy-card v-else-if="policy && !showPolicyForm" :object="policy" @update="showPolicyForm = true">
<header class="ui header">
<h3>
<translate>This domain is subject to specific moderation rules</translate>
</h3>
</header>
</instance-policy-card>
<instance-policy-form
v-else-if="showPolicyForm"
@cancel="showPolicyForm = false"
@save="updatePolicy"
@delete="policy = null; showPolicyForm = false"
:object="policy"
type="domain"
:target="object.name" />
</div>
</div>
</div>
</section>
<div class="ui vertical stripe segment">
@ -244,15 +286,25 @@ import axios from "axios"
import logger from "@/logging"
import lodash from '@/lodash'
import InstancePolicyForm from "@/components/manage/moderation/InstancePolicyForm"
import InstancePolicyCard from "@/components/manage/moderation/InstancePolicyCard"
export default {
props: ["id"],
components: {
InstancePolicyForm,
InstancePolicyCard,
},
data() {
return {
lodash,
isLoading: true,
isLoadingStats: false,
isLoadingPolicy: false,
policy: null,
object: null,
stats: null,
showPolicyForm: false,
permissions: [],
}
},
@ -268,6 +320,9 @@ export default {
axios.get(url).then(response => {
self.object = response.data
self.isLoading = false
if (self.object.instance_policy) {
self.fetchPolicy(self.object.instance_policy)
}
})
},
fetchStats() {
@ -279,10 +334,23 @@ export default {
self.isLoadingStats = false
})
},
fetchPolicy(id) {
var self = this
this.isLoadingPolicy = true
let url = `manage/moderation/instance-policies/${id}/`
axios.get(url).then(response => {
self.policy = response.data
self.isLoadingPolicy = false
})
},
refreshNodeInfo (data) {
this.object.nodeinfo = data
this.object.nodeinfo_fetch_date = new Date()
},
updatePolicy (policy) {
this.policy = policy
this.showPolicyForm = false
}
},
computed: {
labels() {
@ -299,4 +367,7 @@ export default {
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.placeholder.segment {
width: 100%;
}
</style>

View File

@ -1,26 +1,70 @@
<template>
<main v-title="labels.domains">
<section class="ui vertical stripe segment">
<h2 class="ui header"><translate>Domains</translate></h2>
<div class="ui hidden divider"></div>
<h2 class="ui left floated header"><translate>Domains</translate></h2>
<form class="ui right floated form" @submit.prevent="createDomain">
<div v-if="errors && errors.length > 0" class="ui negative message">
<div class="header"><translate>Error while creating domain</translate></div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div class="inline fields">
<div class="field">
<label for="domain"><translate>Add a domain</translate></label>
<input type="text" id="domain" v-model="domainName">
</div>
<div class="field">
<button :class="['ui', {'loading': isCreating}, 'green', 'button']" type="submit" :disabled="isCreating">
<label for="domain"><translate>Add</translate></label>
</button>
</div>
</div>
</form>
<div class="ui clearing hidden divider"></div>
<domains-table></domains-table>
</section>
</main>
</template>
<script>
import DomainsTable from "@/components/manage/moderation/DomainsTable"
import axios from 'axios'
import DomainsTable from "@/components/manage/moderation/DomainsTable"
export default {
components: {
DomainsTable
},
data () {
return {
domainName: '',
isCreating: false,
errors: []
}
},
computed: {
labels() {
return {
domains: this.$gettext("Domains")
}
}
},
methods: {
createDomain () {
let self = this
this.isCreating = true
this.errors = []
axios.post('manage/federation/domains/', {name: this.domainName}).then((response) => {
this.isCreating = false
this.$router.push({
name: "manage.moderation.domains.detail",
params: {'id': response.data.name}
})
}, (error) => {
self.isCreating = false
self.errors = error.backendErrors
})
}
}
}
</script>

View File

@ -34,6 +34,8 @@ export default {
return
}
let self = this
self.errors = []
self.isLoading = true
axios.post('federation/libraries/fetch/', {fid: this.query}).then((response) => {
self.$emit('scanned', response.data)
self.isLoading = false