diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index b8e217ba4eca9ef0954236124a577a88cf4e72d5..d86283fe58dc5658421993a68b1ec86b1b784346 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -1,7 +1,10 @@ import django_filters from django import forms +from django.core.serializers.json import DjangoJSONEncoder from django.db import models +from rest_framework import serializers + from . import search PRIVACY_LEVEL_CHOICES = [ @@ -52,3 +55,58 @@ class SmartSearchFilter(django_filters.CharFilter): except (forms.ValidationError): return qs.none() return search.apply(qs, cleaned) + + +class GenericRelation(serializers.JSONField): + def __init__(self, choices, *args, **kwargs): + self.choices = choices + self.encoder = kwargs.setdefault("encoder", DjangoJSONEncoder) + super().__init__(*args, **kwargs) + + def to_representation(self, value): + if not value: + return + type = None + id = None + for key, choice in self.choices.items(): + if isinstance(value, choice["queryset"].model): + type = key + id = getattr(value, choice.get("id_attr", "id")) + break + + if type: + return {"type": type, "id": id} + + def to_internal_value(self, v): + v = super().to_internal_value(v) + + if not v or not isinstance(v, dict): + raise serializers.ValidationError("Invalid data") + + try: + type = v["type"] + field = serializers.ChoiceField(choices=list(self.choices.keys())) + type = field.to_internal_value(type) + except (TypeError, KeyError, serializers.ValidationError): + raise serializers.ValidationError("Invalid type") + + conf = self.choices[type] + id_attr = conf.get("id_attr", "id") + id_field = conf.get("id_field", serializers.IntegerField(min_value=1)) + queryset = conf["queryset"] + try: + id_value = v[id_attr] + id_value = id_field.to_internal_value(id_value) + except (TypeError, KeyError, serializers.ValidationError): + raise serializers.ValidationError("Invalid {}".format(id_attr)) + + query_getter = conf.get( + "get_query", lambda attr, value: models.Q(**{attr: value}) + ) + query = query_getter(id_attr, id_value) + try: + obj = queryset.get(query) + except queryset.model.DoesNotExist: + raise serializers.ValidationError("Object not found") + + return obj diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 7763e9b7f810090018fb9845a7fb390df812c0d9..55e4fdfbd8f2ae282ca35e187bb2515da3af6902 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -154,7 +154,7 @@ def order_for_search(qs, field): def recursive_getattr(obj, key, permissive=False): """ - Given a dictionary such as {'user': {'name': 'Bob'}} and + Given a dictionary such as {'user': {'name': 'Bob'}} or and object and a dotted string such as user.name, returns 'Bob'. If the value is not present, returns None @@ -162,7 +162,10 @@ def recursive_getattr(obj, key, permissive=False): v = obj for k in key.split("."): try: - v = v.get(k) + if hasattr(v, "get"): + v = v.get(k) + else: + v = getattr(v, k) except (TypeError, AttributeError): if not permissive: raise diff --git a/api/funkwhale_api/federation/migrations/0020_auto_20190730_0846.py b/api/funkwhale_api/federation/migrations/0020_auto_20190730_0846.py new file mode 100644 index 0000000000000000000000000000000000000000..efed1d306afa2450b600d33fbc117b3e8420ce26 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0020_auto_20190730_0846.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.3 on 2019-07-30 08:46 + +import django.contrib.postgres.fields.jsonb +import django.core.serializers.json +from django.db import migrations +import funkwhale_api.federation.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('federation', '0019_auto_20190611_0851'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='payload', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000), + ), + migrations.AlterField( + model_name='fetch', + name='detail', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000), + ), + migrations.AlterField( + model_name='librarytrack', + name='metadata', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000), + ), + ] diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py index f7d5006da0cdf50df601da96e78fc5b8e97d00ca..a193087dbfcdd08eb2e4b14c9748da1eec36dd83 100644 --- a/api/funkwhale_api/federation/urls.py +++ b/api/funkwhale_api/federation/urls.py @@ -9,6 +9,7 @@ music_router = routers.SimpleRouter(trailing_slash=False) router.register(r"federation/shared", views.SharedViewSet, "shared") router.register(r"federation/actors", views.ActorViewSet, "actors") router.register(r"federation/edits", views.EditViewSet, "edits") +router.register(r"federation/reports", views.ReportViewSet, "reports") router.register(r".well-known", views.WellKnownViewSet, "well-known") music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries") diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index c66a97266515243afa1f0b31a08d97424f48f252..c2eacfe9d658ee8dd48b3bfe144e0d828de1a279 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -128,3 +128,32 @@ def is_local(url): return url.startswith("http://{}/".format(d)) or url.startswith( "https://{}/".format(d) ) + + +def get_actor_data_from_username(username): + + parts = username.split("@") + + return { + "username": parts[0], + "domain": parts[1] if len(parts) > 1 else settings.FEDERATION_HOSTNAME, + } + + +def get_actor_from_username_data_query(field, data): + if not data: + return Q(**{field: None}) + if field: + return Q( + **{ + "{}__preferred_username__iexact".format(field): data["username"], + "{}__domain__name__iexact".format(field): data["domain"], + } + ) + else: + return Q( + **{ + "preferred_username__iexact": data["username"], + "domain__name__iexact": data["domain"], + } + ) diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 85961e3229df1902999e6428138e237bee0b371e..85594e02bfb55a086328f08e546117490b9b6dc4 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -6,6 +6,7 @@ from rest_framework import exceptions, mixins, permissions, response, viewsets from rest_framework.decorators import action from funkwhale_api.common import preferences +from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models from funkwhale_api.music import utils as music_utils @@ -86,6 +87,15 @@ class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericVi # serializer_class = serializers.ActorSerializer +class ReportViewSet( + FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): + lookup_field = "uuid" + authentication_classes = [authentication.SignatureAuthentication] + renderer_classes = renderers.get_ap_renderers() + queryset = moderation_models.Report.objects.none() + + class WellKnownViewSet(viewsets.GenericViewSet): authentication_classes = [] permission_classes = [] diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index af9ded746d93488f4f5a673d32d507f449cc5c1a..6a6e7b99d2b94865c6389042866a5e8840f3fa23 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -1,6 +1,5 @@ from django import forms from django.db.models import Q -from django.conf import settings import django_filters from django_filters import rest_framework as filters @@ -22,24 +21,12 @@ class ActorField(forms.CharField): if not value: return value - parts = value.split("@") - - return { - "username": parts[0], - "domain": parts[1] if len(parts) > 1 else settings.FEDERATION_HOSTNAME, - } + return federation_utils.get_actor_data_from_username(value) def get_actor_filter(actor_field): def handler(v): - if not v: - return Q(**{actor_field: None}) - return Q( - **{ - "{}__preferred_username__iexact".format(actor_field): v["username"], - "{}__domain__name__iexact".format(actor_field): v["domain"], - } - ) + federation_utils.get_actor_from_username_data_query(actor_field, v) return {"field": ActorField(), "handler": handler} diff --git a/api/funkwhale_api/moderation/dynamic_preferences_registry.py b/api/funkwhale_api/moderation/dynamic_preferences_registry.py index 04a732f4d4863a97d52e1401d0d921cc042c9eeb..29390434197062fce2fb5a031c0b40c93367b4b0 100644 --- a/api/funkwhale_api/moderation/dynamic_preferences_registry.py +++ b/api/funkwhale_api/moderation/dynamic_preferences_registry.py @@ -1,6 +1,10 @@ from dynamic_preferences import types from dynamic_preferences.registries import global_preferences_registry +from funkwhale_api.common import preferences as common_preferences + +from . import models + moderation = types.Section("moderation") @@ -24,3 +28,15 @@ class AllowListPublic(types.BooleanPreference): "make your moderation policy public." ) default = False + + +@global_preferences_registry.register +class UnauthenticatedReportTypes(common_preferences.StringListPreference): + show_in_api = True + section = moderation + name = "unauthenticated_report_types" + default = ["takedown_request", "illegal_content"] + verbose_name = "Accountless report categories" + help_text = "A list of categories for which external users (without an account) can submit a report" + choices = models.REPORT_TYPES + field_kwargs = {"choices": choices, "required": False} diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py index 8829caa2bacf60b444b18984c71b24b5f8b788d0..4bf7ce5843c96af54df81752714ea43f2b5cb36a 100644 --- a/api/funkwhale_api/moderation/factories.py +++ b/api/funkwhale_api/moderation/factories.py @@ -37,3 +37,17 @@ class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory): for_artist = factory.Trait( target_artist=factory.SubFactory(music_factories.ArtistFactory) ) + + +@registry.register +class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): + submitter = factory.SubFactory(federation_factories.ActorFactory) + target = None + summary = factory.Faker("paragraph") + type = "other" + + class Meta: + model = "moderation.Report" + + class Params: + anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email")) diff --git a/api/funkwhale_api/moderation/migrations/0003_report.py b/api/funkwhale_api/moderation/migrations/0003_report.py new file mode 100644 index 0000000000000000000000000000000000000000..c560924587bcd9f53afdc55d34afaa98a6bf1d21 --- /dev/null +++ b/api/funkwhale_api/moderation/migrations/0003_report.py @@ -0,0 +1,100 @@ +# Generated by Django 2.2.3 on 2019-08-01 08:34 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("federation", "0020_auto_20190730_0846"), + ("moderation", "0002_auto_20190213_0927"), + ] + + operations = [ + migrations.CreateModel( + name="Report", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("fid", models.URLField(db_index=True, max_length=500, unique=True)), + ("url", models.URLField(blank=True, max_length=500, null=True)), + ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("summary", models.TextField(max_length=50000, null=True)), + ("handled_date", models.DateTimeField(null=True)), + ("is_handled", models.BooleanField(default=False)), + ( + "type", + models.CharField( + choices=[ + ("takedown_request", "Takedown request"), + ("invalid_metadata", "Invalid metadata"), + ("illegal_content", "Illegal content"), + ("offensive_content", "Offensive content"), + ("other", "Other"), + ], + max_length=40, + ), + ), + ("submitter_email", models.EmailField(max_length=254, null=True)), + ("target_id", models.IntegerField(null=True)), + ( + "target_state", + django.contrib.postgres.fields.jsonb.JSONField(null=True), + ), + ( + "submitter", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reports", + to="federation.Actor", + ), + ), + ( + "assigned_to", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="assigned_reports", + to="federation.Actor", + ), + ), + ( + "target_content_type", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.ContentType", + ), + ), + ( + "target_owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="federation.Actor", + ), + ), + ], + options={"abstract": False}, + ) + ] diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py index 7ade5d05a1e5a1ff0bfb0e259b5770b100a77489..ccc891e79e62f15864fffad308ce0920726db2f4 100644 --- a/api/funkwhale_api/moderation/models.py +++ b/api/funkwhale_api/moderation/models.py @@ -1,9 +1,17 @@ import urllib.parse import uuid + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import JSONField from django.db import models +from django.urls import reverse from django.utils import timezone +from funkwhale_api.federation import models as federation_models +from funkwhale_api.federation import utils as federation_utils + class InstancePolicyQuerySet(models.QuerySet): def active(self): @@ -92,3 +100,63 @@ class UserFilter(models.Model): def target(self): if self.target_artist: return {"type": "artist", "obj": self.target_artist} + + +REPORT_TYPES = [ + ("takedown_request", "Takedown request"), + ("invalid_metadata", "Invalid metadata"), + ("illegal_content", "Illegal content"), + ("offensive_content", "Offensive content"), + ("other", "Other"), +] + + +class Report(federation_models.FederationMixin): + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + creation_date = models.DateTimeField(default=timezone.now) + summary = models.TextField(null=True, max_length=50000) + handled_date = models.DateTimeField(null=True) + is_handled = models.BooleanField(default=False) + type = models.CharField(max_length=40, choices=REPORT_TYPES) + submitter_email = models.EmailField(null=True) + submitter = models.ForeignKey( + "federation.Actor", + related_name="reports", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + assigned_to = models.ForeignKey( + "federation.Actor", + related_name="assigned_reports", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + target_id = models.IntegerField(null=True) + target_content_type = models.ForeignKey( + ContentType, null=True, on_delete=models.CASCADE + ) + target = GenericForeignKey("target_content_type", "target_id") + target_owner = models.ForeignKey( + "federation.Actor", on_delete=models.SET_NULL, null=True, blank=True + ) + # frozen state of the target being reported, to ensure we still have info in the event of a + # delete + target_state = JSONField(null=True) + + def get_federation_id(self): + if self.fid: + return self.fid + + return federation_utils.full_url( + reverse("federation:reports-detail", kwargs={"uuid": self.uuid}) + ) + + def save(self, **kwargs): + if not self.pk and not self.fid: + self.fid = self.get_federation_id() + + return super().save(**kwargs) diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py index 20c34242102d4f302ac29a9781a059c08fef2fb9..c52a9e913636089ce2bc143e72b1051cc14f1f41 100644 --- a/api/funkwhale_api/moderation/serializers.py +++ b/api/funkwhale_api/moderation/serializers.py @@ -1,6 +1,14 @@ +import persisting_theory + from rest_framework import serializers +from funkwhale_api.common import fields as common_fields +from funkwhale_api.common import preferences +from funkwhale_api.federation import models as federation_models +from funkwhale_api.federation import utils as federation_utils from funkwhale_api.music import models as music_models +from funkwhale_api.playlists import models as playlists_models + from . import models @@ -43,3 +51,179 @@ class UserFilterSerializer(serializers.ModelSerializer): data["target_artist"] = target["obj"] return data + + +state_serializers = persisting_theory.Registry() + + +TAGS_FIELD = serializers.ListField(source="get_tags") + + +@state_serializers.register(name="music.Artist") +class ArtistStateSerializer(serializers.ModelSerializer): + tags = TAGS_FIELD + + class Meta: + model = music_models.Artist + fields = ["id", "name", "mbid", "fid", "creation_date", "uuid", "tags"] + + +@state_serializers.register(name="music.Album") +class AlbumStateSerializer(serializers.ModelSerializer): + tags = TAGS_FIELD + artist = ArtistStateSerializer() + + class Meta: + model = music_models.Album + fields = [ + "id", + "title", + "mbid", + "fid", + "creation_date", + "uuid", + "artist", + "release_date", + "tags", + ] + + +@state_serializers.register(name="music.Track") +class TrackStateSerializer(serializers.ModelSerializer): + tags = TAGS_FIELD + artist = ArtistStateSerializer() + album = AlbumStateSerializer() + + class Meta: + model = music_models.Track + fields = [ + "id", + "title", + "mbid", + "fid", + "creation_date", + "uuid", + "artist", + "album", + "disc_number", + "position", + "license", + "copyright", + "tags", + ] + + +@state_serializers.register(name="music.Library") +class LibraryStateSerializer(serializers.ModelSerializer): + class Meta: + model = music_models.Library + fields = ["id", "fid", "name", "description", "creation_date", "privacy_level"] + + +@state_serializers.register(name="playlists.Playlist") +class PlaylistStateSerializer(serializers.ModelSerializer): + class Meta: + model = playlists_models.Playlist + fields = ["id", "name", "creation_date", "privacy_level"] + + +@state_serializers.register(name="federation.Actor") +class ActorStateSerializer(serializers.ModelSerializer): + class Meta: + model = federation_models.Actor + fields = [ + "fid", + "name", + "preferred_username", + "summary", + "domain", + "type", + "creation_date", + ] + + +def get_actor_query(attr, value): + data = federation_utils.get_actor_data_from_username(value) + return federation_utils.get_actor_from_username_data_query(None, data) + + +def get_target_owner(target): + mapping = { + music_models.Artist: lambda t: t.attributed_to, + music_models.Album: lambda t: t.attributed_to, + music_models.Track: lambda t: t.attributed_to, + music_models.Library: lambda t: t.actor, + playlists_models.Playlist: lambda t: t.user.actor, + federation_models.Actor: lambda t: t, + } + + return mapping[target.__class__](target) + + +class ReportSerializer(serializers.ModelSerializer): + target = common_fields.GenericRelation( + { + "artist": {"queryset": music_models.Artist.objects.all()}, + "album": {"queryset": music_models.Album.objects.all()}, + "track": {"queryset": music_models.Track.objects.all()}, + "library": { + "queryset": music_models.Library.objects.all(), + "id_attr": "uuid", + "id_field": serializers.UUIDField(), + }, + "playlist": {"queryset": playlists_models.Playlist.objects.all()}, + "account": { + "queryset": federation_models.Actor.objects.all(), + "id_attr": "full_username", + "id_field": serializers.EmailField(), + "get_query": get_actor_query, + }, + } + ) + + class Meta: + model = models.Report + fields = [ + "uuid", + "summary", + "creation_date", + "handled_date", + "is_handled", + "submitter_email", + "target", + "type", + ] + read_only_fields = ["uuid", "is_handled", "creation_date", "handled_date"] + + def validate(self, validated_data): + validated_data = super().validate(validated_data) + submitter = self.context.get("submitter") + if submitter: + # we have an authenticated actor so no need to check further + return validated_data + + unauthenticated_report_types = preferences.get( + "moderation__unauthenticated_report_types" + ) + if validated_data["type"] not in unauthenticated_report_types: + raise serializers.ValidationError( + "You need an account to submit this report" + ) + + if not validated_data.get("submitter_email"): + raise serializers.ValidationError( + "You need to provide an email address to submit this report" + ) + + return validated_data + + def create(self, validated_data): + target_state_serializer = state_serializers[ + validated_data["target"]._meta.label + ] + + validated_data["target_state"] = target_state_serializer( + validated_data["target"] + ).data + validated_data["target_owner"] = get_target_owner(validated_data["target"]) + return super().create(validated_data) diff --git a/api/funkwhale_api/moderation/urls.py b/api/funkwhale_api/moderation/urls.py index cd3e7bc2d9b2475ec65df6f8e1fd2fbfe77cb9da..597aacadbccb5e7a37afb0bd46e8d1cc6a881a1d 100644 --- a/api/funkwhale_api/moderation/urls.py +++ b/api/funkwhale_api/moderation/urls.py @@ -4,5 +4,6 @@ from . import views router = routers.OptionalSlashRouter() router.register(r"content-filters", views.UserFilterViewSet, "content-filters") +router.register(r"reports", views.ReportsViewSet, "reports") urlpatterns = router.urls diff --git a/api/funkwhale_api/moderation/views.py b/api/funkwhale_api/moderation/views.py index 4d4e3e039abdd68858fe96df79ad965a02e49771..e3725107831b7f87d0810c156cc1f9cf2541acbd 100644 --- a/api/funkwhale_api/moderation/views.py +++ b/api/funkwhale_api/moderation/views.py @@ -39,3 +39,25 @@ class UserFilterViewSet( def perform_create(self, serializer): serializer.save(user=self.request.user) + + +class ReportsViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): + lookup_field = "uuid" + queryset = models.Report.objects.all().order_by("-creation_date") + serializer_class = serializers.ReportSerializer + required_scope = "reports" + ordering_fields = ("creation_date",) + anonymous_policy = "setting" + anonymous_scopes = {"write:reports"} + + def get_serializer_context(self): + context = super().get_serializer_context() + if self.request.user.is_authenticated: + context["submitter"] = self.request.user.actor + return context + + def perform_create(self, serializer): + submitter = None + if self.request.user.is_authenticated: + submitter = self.request.user.actor + serializer.save(submitter=submitter) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 3e6d892ae78667861a71a0d52feb70abf0e36eda..fc4118a98d964e73b5da5b86dd57e337d9d24560 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -127,6 +127,9 @@ class APIModelMixin(models.Model): parsed = urllib.parse.urlparse(self.fid) return parsed.hostname + def get_tags(self): + return list(sorted(self.tagged_items.values_list("tag__name", flat=True))) + class License(models.Model): code = models.CharField(primary_key=True, max_length=100) diff --git a/api/funkwhale_api/users/oauth/permissions.py b/api/funkwhale_api/users/oauth/permissions.py index ebd44a937a6dee21e462d3321dfdf1dfcc7149b8..54b3c2627bb28a1263b537e5ebda51ca5c7933b3 100644 --- a/api/funkwhale_api/users/oauth/permissions.py +++ b/api/funkwhale_api/users/oauth/permissions.py @@ -96,8 +96,9 @@ class ScopePermission(permissions.BasePermission): ): return False - # we use default anonymous scopes - user_scopes = scopes.ANONYMOUS_SCOPES + user_scopes = ( + getattr(view, "anonymous_scopes", set()) | scopes.ANONYMOUS_SCOPES + ) return should_allow( required_scope=required_scope, request_scopes=user_scopes ) diff --git a/api/funkwhale_api/users/oauth/scopes.py b/api/funkwhale_api/users/oauth/scopes.py index 61b07098383e832dcacd87f5da11fa843f821b29..8cf91192c7e939b4ce6fb103347214457142f189 100644 --- a/api/funkwhale_api/users/oauth/scopes.py +++ b/api/funkwhale_api/users/oauth/scopes.py @@ -22,6 +22,7 @@ BASE_SCOPES = [ Scope("playlists", "Access playlists"), Scope("notifications", "Access personal notifications"), Scope("security", "Access security settings"), + Scope("reports", "Access reports"), # Privileged scopes that require specific user permissions Scope("instance:settings", "Access instance settings"), Scope("instance:users", "Access local user accounts"), @@ -72,6 +73,8 @@ COMMON_SCOPES = ANONYMOUS_SCOPES | { "write:edits", "read:filters", "write:filters", + "read:reports", + "write:reports", "write:listenings", } diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py index 72aa8b4c35601e87555843a843566369c61e2375..21e85b700a63de9016082f2daed399d6e9582b01 100644 --- a/api/tests/common/test_fields.py +++ b/api/tests/common/test_fields.py @@ -20,3 +20,50 @@ from funkwhale_api.users.factories import UserFactory def test_privacy_level_query(user, expected): query = fields.privacy_level_query(user) assert query == expected + + +def test_generic_relation_field(factories): + obj = factories["users.User"]() + f = fields.GenericRelation( + { + "user": { + "queryset": obj.__class__.objects.all(), + "id_attr": "username", + "id_field": fields.serializers.CharField(), + } + } + ) + + data = {"type": "user", "username": obj.username} + + assert f.to_internal_value(data) == obj + + +@pytest.mark.parametrize( + "payload, expected_error", + [ + ({}, r".*Invalid data.*"), + (1, r".*Invalid data.*"), + (False, r".*Invalid data.*"), + ("test", r".*Invalid data.*"), + ({"missing": "type"}, r".*Invalid type.*"), + ({"type": "noop"}, r".*Invalid type.*"), + ({"type": "user"}, r".*Invalid username.*"), + ({"type": "user", "username": {}}, r".*Invalid username.*"), + ({"type": "user", "username": "not_found"}, r".*Object not found.*"), + ], +) +def test_generic_relation_field_validation_error(payload, expected_error, factories): + obj = factories["users.User"]() + f = fields.GenericRelation( + { + "user": { + "queryset": obj.__class__.objects.all(), + "id_attr": "username", + "id_field": fields.serializers.CharField(), + } + } + ) + + with pytest.raises(fields.serializers.ValidationError, match=expected_error): + f.to_internal_value(payload) diff --git a/api/tests/moderation/test_serializers.py b/api/tests/moderation/test_serializers.py index a38214143db36f97aa349f712681a3dab2fcbf1b..37c95c78d22b70322b0656459a5b55b25ffea791 100644 --- a/api/tests/moderation/test_serializers.py +++ b/api/tests/moderation/test_serializers.py @@ -1,3 +1,7 @@ +import pytest + +from funkwhale_api.common import utils as common_utils +from funkwhale_api.federation import models as federation_models from funkwhale_api.moderation import serializers @@ -28,3 +32,145 @@ def test_user_filter_serializer_save(factories): content_filter = serializer.save(user=user) assert content_filter.target_artist == artist + + +@pytest.mark.parametrize( + "factory_name, target_type, id_field, state_serializer", + [ + ("music.Artist", "artist", "id", serializers.ArtistStateSerializer), + ("music.Album", "album", "id", serializers.AlbumStateSerializer), + ("music.Track", "track", "id", serializers.TrackStateSerializer), + ("music.Library", "library", "uuid", serializers.LibraryStateSerializer), + ("playlists.Playlist", "playlist", "id", serializers.PlaylistStateSerializer), + ( + "federation.Actor", + "account", + "full_username", + serializers.ActorStateSerializer, + ), + ], +) +def test_report_serializer_save( + factory_name, target_type, id_field, state_serializer, factories, mocker +): + target = factories[factory_name]() + target_owner = factories["federation.Actor"]() + submitter = factories["federation.Actor"]() + target_data = {"type": target_type, id_field: getattr(target, id_field)} + payload = { + "summary": "Report content", + "type": "illegal_content", + "target": target_data, + } + serializer = serializers.ReportSerializer( + data=payload, context={"submitter": submitter} + ) + get_target_owner = mocker.patch.object( + serializers, "get_target_owner", return_value=target_owner + ) + assert serializer.is_valid(raise_exception=True) is True + + report = serializer.save() + + assert report.target == target + assert report.type == payload["type"] + assert report.summary == payload["summary"] + assert report.target_state == state_serializer(target).data + assert report.target_owner == target_owner + get_target_owner.assert_called_once_with(target) + + +def test_report_serializer_save_anonymous(factories, mocker): + target = factories["music.Artist"]() + payload = { + "summary": "Report content", + "type": "illegal_content", + "target": {"type": "artist", "id": target.pk}, + "submitter_email": "test@submitter.example", + } + serializer = serializers.ReportSerializer(data=payload) + + assert serializer.is_valid(raise_exception=True) is True + + report = serializer.save() + + assert report.target == target + assert report.type == payload["type"] + assert report.summary == payload["summary"] + assert report.submitter_email == payload["submitter_email"] + + +@pytest.mark.parametrize( + "factory_name, factory_kwargs, owner_field", + [ + ("music.Artist", {"attributed": True}, "attributed_to"), + ("music.Album", {"attributed": True}, "attributed_to"), + ("music.Track", {"attributed": True}, "attributed_to"), + ("music.Library", {}, "actor"), + ("playlists.Playlist", {"user__with_actor": True}, "user.actor"), + ("federation.Actor", {}, "self"), + ], +) +def test_get_target_owner(factory_name, factory_kwargs, owner_field, factories): + target = factories[factory_name](**factory_kwargs) + if owner_field == "self": + expected_owner = target + else: + expected_owner = common_utils.recursive_getattr(target, owner_field) + + assert isinstance(expected_owner, federation_models.Actor) + assert serializers.get_target_owner(target) == expected_owner + + +def test_report_serializer_repr(factories, to_api_date): + target = factories["music.Artist"]() + report = factories["moderation.Report"](target=target) + expected = { + "uuid": str(report.uuid), + "summary": report.summary, + "type": report.type, + "target": {"type": "artist", "id": target.pk}, + "creation_date": to_api_date(report.creation_date), + "handled_date": None, + "is_handled": False, + "submitter_email": None, + } + serializer = serializers.ReportSerializer(report) + assert serializer.data == expected + + +@pytest.mark.parametrize( + "preference, context, payload, is_valid", + [ + # anonymous reports not enabled for the category + ( + ["illegal_content"], + {}, + {"type": "other", "submitter_email": "hello@example.test"}, + False, + ), + # anonymous reports enabled for the category, but invalid email + (["other"], {}, {"type": "other", "submitter_email": "hello@"}, False), + # anonymous reports enabled for the category, no email + (["other"], {}, {"type": "other"}, False), + # anonymous reports enabled for the category, actor object is empty + (["other"], {"submitter": None}, {"type": "other"}, False), + # valid examples + ( + ["other"], + {}, + {"type": "other", "submitter_email": "hello@example.test"}, + True, + ), + ], +) +def test_report_serializer_save_unauthenticated_validation( + preference, context, payload, is_valid, factories, preferences +): + preferences["moderation__unauthenticated_report_types"] = preference + target = factories["music.Artist"]() + target_data = {"type": "artist", "id": target.id} + payload["summary"] = "Test" + payload["target"] = target_data + serializer = serializers.ReportSerializer(data=payload, context=context) + assert serializer.is_valid() is is_valid diff --git a/api/tests/moderation/test_views.py b/api/tests/moderation/test_views.py index 3d53f4565a315b84c40fccd7d8b6c226a6883a24..dba2281720d3a0e3c82214ee001ecdfec5bf9683 100644 --- a/api/tests/moderation/test_views.py +++ b/api/tests/moderation/test_views.py @@ -1,5 +1,7 @@ from django.urls import reverse +from funkwhale_api.moderation import models + def test_restrict_to_own_filters(factories, logged_in_api_client): cf = factories["moderation.UserFilter"]( @@ -22,3 +24,35 @@ def test_create_filter(factories, logged_in_api_client): cf = logged_in_api_client.user.content_filters.latest("id") assert cf.target_artist == artist assert response.status_code == 201 + + +def test_create_report_logged_in(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + target = factories["music.Artist"]() + url = reverse("api:v1:moderation:reports-list") + data = { + "target": {"type": "artist", "id": target.pk}, + "summary": "Test report", + "type": "other", + } + response = logged_in_api_client.post(url, data, format="json") + + assert response.status_code == 201 + report = actor.reports.latest("id") + assert report.target == target + + +def test_create_report_anonymous(factories, api_client, no_api_auth): + target = factories["music.Artist"]() + url = reverse("api:v1:moderation:reports-list") + data = { + "target": {"type": "artist", "id": target.pk}, + "summary": "Test report", + "type": "illegal_content", + "submitter_email": "test@example.test", + } + response = api_client.post(url, data, format="json") + + assert response.status_code == 201 + report = models.Report.objects.latest("id") + assert report.submitter_email == data["submitter_email"] diff --git a/api/tests/users/oauth/test_permissions.py b/api/tests/users/oauth/test_permissions.py index 65974fbf645f92af478457fadd7138f7067a3a01..a5cd12034fd2aa4e07b0e5bfa24405d68b2baf7e 100644 --- a/api/tests/users/oauth/test_permissions.py +++ b/api/tests/users/oauth/test_permissions.py @@ -59,7 +59,9 @@ def test_scope_permission_anonymous_policy( policy, preference, expected, preferences, mocker, anonymous_user ): preferences["common__api_authentication_required"] = preference - view = mocker.Mock(required_scope="libraries", anonymous_policy=policy) + view = mocker.Mock( + required_scope="libraries", anonymous_policy=policy, anonymous_scopes=set() + ) request = mocker.Mock(method="GET", user=anonymous_user, actor=None) p = permissions.ScopePermission() @@ -72,6 +74,7 @@ def test_scope_permission_dict_no_required(mocker, anonymous_user): required_scope={"read": None, "write": "write:profile"}, anonymous_policy=True, action="read", + anonymous_scopes=set(), ) request = mocker.Mock(method="GET", user=anonymous_user, actor=None) @@ -164,7 +167,9 @@ def test_scope_permission_token_anonymous_user_auth_not_required( preferences["common__api_authentication_required"] = False should_allow = mocker.patch.object(permissions, "should_allow") request = mocker.Mock(method="POST", user=anonymous_user, actor=None) - view = mocker.Mock(required_scope="profile", anonymous_policy="setting") + view = mocker.Mock( + required_scope="profile", anonymous_policy="setting", anonymous_scopes=set() + ) p = permissions.ScopePermission() diff --git a/api/tests/users/oauth/test_scopes.py b/api/tests/users/oauth/test_scopes.py index 3d12cb664e7dadcdc078706b0e79fae1764fe221..384e6ee8f62fdeebd1794616e672085d9c185a94 100644 --- a/api/tests/users/oauth/test_scopes.py +++ b/api/tests/users/oauth/test_scopes.py @@ -28,6 +28,8 @@ from funkwhale_api.users.oauth import scopes "write:edits", "read:filters", "write:filters", + "read:reports", + "write:reports", "read:listenings", "write:listenings", "read:security", @@ -71,6 +73,8 @@ from funkwhale_api.users.oauth import scopes "write:edits", "read:filters", "write:filters", + "read:reports", + "write:reports", "read:listenings", "write:listenings", "read:security", @@ -110,6 +114,8 @@ from funkwhale_api.users.oauth import scopes "write:edits", "read:filters", "write:filters", + "read:reports", + "write:reports", "read:listenings", "write:listenings", "read:security", @@ -143,6 +149,8 @@ from funkwhale_api.users.oauth import scopes "write:edits", "read:filters", "write:filters", + "read:reports", + "write:reports", "read:listenings", "write:listenings", "read:security",