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:
commit
38e047e443
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 "
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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))},
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
from rest_framework import exceptions
|
||||
|
||||
|
||||
class MalformedPayload(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class MissingSignature(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
class BlockedActorOrDomain(exceptions.AuthenticationFailed):
|
||||
pass
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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")
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
)
|
|
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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}
|
|
@ -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()
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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"]
|
||||
)
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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 {}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<div>
|
||||
<slot></slot>
|
||||
<p>
|
||||
<i class="clock outline icon"></i><human-date :date="object.creation_date" />
|
||||
<i class="user icon"></i>{{ object.actor }}
|
||||
<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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
</template>
|
||||
<a :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
|
||||
<translate>Open profile</translate>
|
||||
<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>
|
||||
|
||||
</template>
|
||||
<a :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
|
||||
<translate>Open profile</translate>
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue