diff --git a/api/config/api_urls.py b/api/config/api_urls.py index ab01e623c..93138e9a5 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -5,6 +5,7 @@ from rest_framework.urlpatterns import format_suffix_patterns from rest_framework_jwt import views as jwt_views from funkwhale_api.activity import views as activity_views +from funkwhale_api.common import views as common_views from funkwhale_api.music import views from funkwhale_api.playlists import views as playlists_views from funkwhale_api.subsonic.views import SubsonicViewSet @@ -24,6 +25,7 @@ router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists") router.register( r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks" ) +router.register(r"mutations", common_views.MutationViewSet, "mutations") v1_patterns = router.urls subsonic_router = routers.SimpleRouter(trailing_slash=False) diff --git a/api/config/asgi.py b/api/config/asgi.py index 886178cc2..b4a8105de 100644 --- a/api/config/asgi.py +++ b/api/config/asgi.py @@ -1,9 +1,9 @@ import os -import django +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +import django # noqa django.setup() from .routing import application # noqa - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 91691f2a5..5f69c36d5 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -29,7 +29,6 @@ env_file = env("ENV_FILE", default=None) if env_file: # we have an explicitely specified env file # so we try to load and it fail loudly if it does not exist - print("ENV_FILE", env_file) env.read_env(env_file) else: # we try to load from .env and config/.env @@ -150,7 +149,7 @@ if RAVEN_ENABLED: # Apps specific for this project go here. LOCAL_APPS = ( - "funkwhale_api.common", + "funkwhale_api.common.apps.CommonConfig", "funkwhale_api.activity.apps.ActivityConfig", "funkwhale_api.users", # custom users app # Your stuff: custom apps go here diff --git a/api/config/settings/local.py b/api/config/settings/local.py index d6a8ce484..632eb3201 100644 --- a/api/config/settings/local.py +++ b/api/config/settings/local.py @@ -62,19 +62,6 @@ CELERY_TASK_ALWAYS_EAGER = False # Your local stuff: Below this line define 3rd party library settings -LOGGING = { - "version": 1, - "handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}}, - "loggers": { - "django.request": { - "handlers": ["console"], - "propagate": True, - "level": "DEBUG", - }, - "django_auth_ldap": {"handlers": ["console"], "level": "DEBUG"}, - "": {"level": "DEBUG", "handlers": ["console"]}, - }, -} CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS] diff --git a/api/funkwhale_api/common/admin.py b/api/funkwhale_api/common/admin.py index 4124a69b8..3ec6f1f44 100644 --- a/api/funkwhale_api/common/admin.py +++ b/api/funkwhale_api/common/admin.py @@ -1,6 +1,9 @@ from django.contrib.admin import register as initial_register, site, ModelAdmin # noqa from django.db.models.fields.related import RelatedField +from . import models +from . import tasks + def register(model): """ @@ -17,3 +20,28 @@ def register(model): return initial_register(model)(modeladmin) return decorator + + +def apply(modeladmin, request, queryset): + queryset.update(is_approved=True) + for id in queryset.values_list("id", flat=True): + tasks.apply_mutation.delay(mutation_id=id) + + +apply.short_description = "Approve and apply" + + +@register(models.Mutation) +class MutationAdmin(ModelAdmin): + list_display = [ + "uuid", + "type", + "created_by", + "creation_date", + "applied_date", + "is_approved", + "is_applied", + ] + search_fields = ["created_by__preferred_username"] + list_filter = ["type", "is_approved", "is_applied"] + actions = [apply] diff --git a/api/funkwhale_api/common/apps.py b/api/funkwhale_api/common/apps.py new file mode 100644 index 000000000..cd671be29 --- /dev/null +++ b/api/funkwhale_api/common/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig, apps + +from . import mutations + + +class CommonConfig(AppConfig): + name = "funkwhale_api.common" + + def ready(self): + super().ready() + + app_names = [app.name for app in apps.app_configs.values()] + mutations.registry.autodiscover(app_names) diff --git a/api/funkwhale_api/common/decorators.py b/api/funkwhale_api/common/decorators.py index 71992eff3..b93f149f0 100644 --- a/api/funkwhale_api/common/decorators.py +++ b/api/funkwhale_api/common/decorators.py @@ -1,5 +1,17 @@ -from rest_framework import response +from django.db import transaction + from rest_framework import decorators +from rest_framework import exceptions +from rest_framework import response +from rest_framework import status + +from . import filters +from . import models +from . import mutations as common_mutations +from . import serializers +from . import signals +from . import tasks +from . import utils def action_route(serializer_class): @@ -12,3 +24,67 @@ def action_route(serializer_class): return response.Response(result, status=200) return action + + +def mutations_route(types): + """ + Given a queryset and a list of mutation types, return a view + that can be included in any viewset, and serve: + + GET /{id}/mutations/ - list of mutations for the given object + POST /{id}/mutations/ - create a mutation for the given object + """ + + @transaction.atomic + def mutations(self, request, *args, **kwargs): + obj = self.get_object() + if request.method == "GET": + queryset = models.Mutation.objects.get_for_target(obj).filter( + type__in=types + ) + queryset = queryset.order_by("-creation_date") + filterset = filters.MutationFilter(request.GET, queryset=queryset) + page = self.paginate_queryset(filterset.qs) + if page is not None: + serializer = serializers.APIMutationSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = serializers.APIMutationSerializer(queryset, many=True) + return response.Response(serializer.data) + if request.method == "POST": + if not request.user.is_authenticated: + raise exceptions.NotAuthenticated() + serializer = serializers.APIMutationSerializer( + data=request.data, context={"registry": common_mutations.registry} + ) + serializer.is_valid(raise_exception=True) + if not common_mutations.registry.has_perm( + actor=request.user.actor, + type=serializer.validated_data["type"], + obj=obj, + perm="approve" + if serializer.validated_data.get("is_approved", False) + else "suggest", + ): + raise exceptions.PermissionDenied() + + final_payload = common_mutations.registry.get_validated_payload( + type=serializer.validated_data["type"], + payload=serializer.validated_data["payload"], + obj=obj, + ) + mutation = serializer.save( + created_by=request.user.actor, + target=obj, + payload=final_payload, + is_approved=serializer.validated_data.get("is_approved", None), + ) + if mutation.is_approved: + utils.on_commit(tasks.apply_mutation.delay, mutation_id=mutation.pk) + + utils.on_commit( + signals.mutation_created.send, sender=None, mutation=mutation + ) + return response.Response(serializer.data, status=status.HTTP_201_CREATED) + + return decorators.action(methods=["get", "post"], detail=True)(mutations) diff --git a/api/funkwhale_api/common/factories.py b/api/funkwhale_api/common/factories.py new file mode 100644 index 000000000..6919f9c37 --- /dev/null +++ b/api/funkwhale_api/common/factories.py @@ -0,0 +1,25 @@ +import factory + +from funkwhale_api.factories import registry, NoUpdateOnCreate + +from funkwhale_api.federation import factories as federation_factories + + +@registry.register +class MutationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): + fid = factory.Faker("federation_url") + uuid = factory.Faker("uuid4") + created_by = factory.SubFactory(federation_factories.ActorFactory) + summary = factory.Faker("paragraph") + type = "update" + + class Meta: + model = "common.Mutation" + + @factory.post_generation + def target(self, create, extracted, **kwargs): + if not create: + # Simple build, do nothing. + return + self.target = extracted + self.save() diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index a0f10efe3..47e673cb5 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -1,4 +1,5 @@ import django_filters +from django import forms from django.db import models from . import search @@ -46,5 +47,8 @@ class SmartSearchFilter(django_filters.CharFilter): def filter(self, qs, value): if not value: return qs - cleaned = self.config.clean(value) + try: + cleaned = self.config.clean(value) + except forms.ValidationError: + return qs.none() return search.apply(qs, cleaned) diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py new file mode 100644 index 000000000..4825d3b5d --- /dev/null +++ b/api/funkwhale_api/common/filters.py @@ -0,0 +1,126 @@ +from django import forms +from django.db.models import Q + +from django_filters import widgets +from django_filters import rest_framework as filters + +from . import fields +from . import models +from . import search + + +class NoneObject(object): + def __eq__(self, other): + return other.__class__ == NoneObject + + +NONE = NoneObject() +NULL_BOOLEAN_CHOICES = [ + (True, True), + ("true", True), + ("True", True), + ("1", True), + ("yes", True), + (False, False), + ("false", False), + ("False", False), + ("0", False), + ("no", False), + ("None", NONE), + ("none", NONE), + ("Null", NONE), + ("null", NONE), +] + + +class CoerceChoiceField(forms.ChoiceField): + """ + Same as forms.ChoiceField but will return the second value + in the choices tuple instead of the user provided one + """ + + def clean(self, value): + if value is None: + return value + v = super().clean(value) + try: + return [b for a, b in self.choices if v == a][0] + except IndexError: + raise forms.ValidationError("Invalid value {}".format(value)) + + +class NullBooleanFilter(filters.ChoiceFilter): + field_class = CoerceChoiceField + + def __init__(self, *args, **kwargs): + self.choices = NULL_BOOLEAN_CHOICES + kwargs["choices"] = self.choices + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + if value in ["", None]: + return qs + if value == NONE: + value = None + qs = self.get_method(qs)( + **{"%s__%s" % (self.field_name, self.lookup_expr): value} + ) + return qs.distinct() if self.distinct else qs + + +def clean_null_boolean_filter(v): + v = CoerceChoiceField(choices=NULL_BOOLEAN_CHOICES).clean(v) + if v == NONE: + v = None + + return v + + +def get_null_boolean_filter(name): + return {"handler": lambda v: Q(**{name: clean_null_boolean_filter(v)})} + + +class DummyTypedMultipleChoiceField(forms.TypedMultipleChoiceField): + def valid_value(self, value): + return True + + +class QueryArrayWidget(widgets.QueryArrayWidget): + """ + Until https://github.com/carltongibson/django-filter/issues/1047 is fixed + """ + + def value_from_datadict(self, data, files, name): + data = data.copy() + return super().value_from_datadict(data, files, name) + + +class MultipleQueryFilter(filters.TypedMultipleChoiceFilter): + field_class = DummyTypedMultipleChoiceField + + def __init__(self, *args, **kwargs): + kwargs["widget"] = QueryArrayWidget() + super().__init__(*args, **kwargs) + self.lookup_expr = "in" + + +class MutationFilter(filters.FilterSet): + is_approved = NullBooleanFilter("is_approved") + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "summary": {"to": "summary"}, + "fid": {"to": "fid"}, + "type": {"to": "type"}, + }, + filter_fields={ + "domain": {"to": "created_by__domain__name__iexact"}, + "is_approved": get_null_boolean_filter("is_approved"), + "is_applied": {"to": "is_applied"}, + }, + ) + ) + + class Meta: + model = models.Mutation + fields = ["is_approved", "is_applied", "type"] diff --git a/api/funkwhale_api/common/migrations/0002_mutation.py b/api/funkwhale_api/common/migrations/0002_mutation.py new file mode 100644 index 000000000..f1f756fd3 --- /dev/null +++ b/api/funkwhale_api/common/migrations/0002_mutation.py @@ -0,0 +1,91 @@ +# Generated by Django 2.1.5 on 2019-01-31 15:44 + +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): + + initial = True + + dependencies = [ + ("federation", "0017_auto_20190130_0926"), + ("contenttypes", "0002_remove_content_type_name"), + ("common", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Mutation", + 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)), + ( + "uuid", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ("type", models.CharField(db_index=True, max_length=100)), + ("is_approved", models.NullBooleanField(default=None)), + ("is_applied", models.NullBooleanField(default=None)), + ( + "creation_date", + models.DateTimeField( + db_index=True, default=django.utils.timezone.now + ), + ), + ( + "applied_date", + models.DateTimeField(blank=True, db_index=True, null=True), + ), + ("summary", models.TextField(max_length=2000, blank=True, null=True)), + ("payload", django.contrib.postgres.fields.jsonb.JSONField()), + ( + "previous_state", + django.contrib.postgres.fields.jsonb.JSONField( + null=True, default=None + ), + ), + ("target_id", models.IntegerField(null=True)), + ( + "approved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="approved_mutations", + to="federation.Actor", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_mutations", + to="federation.Actor", + ), + ), + ( + "target_content_type", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="targeting_mutations", + to="contenttypes.ContentType", + ), + ), + ], + ) + ] diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py new file mode 100644 index 000000000..1b9cc1e57 --- /dev/null +++ b/api/funkwhale_api/common/models.py @@ -0,0 +1,89 @@ +import uuid + +from django.contrib.postgres.fields import JSONField +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models, transaction +from django.utils import timezone +from django.urls import reverse + +from funkwhale_api.federation import utils as federation_utils + + +class MutationQuerySet(models.QuerySet): + def get_for_target(self, target): + content_type = ContentType.objects.get_for_model(target) + return self.filter(target_content_type=content_type, target_id=target.pk) + + +class Mutation(models.Model): + fid = models.URLField(unique=True, max_length=500, db_index=True) + uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) + created_by = models.ForeignKey( + "federation.Actor", + related_name="created_mutations", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + approved_by = models.ForeignKey( + "federation.Actor", + related_name="approved_mutations", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + type = models.CharField(max_length=100, db_index=True) + # None = no choice, True = approved, False = refused + is_approved = models.NullBooleanField(default=None) + + # None = not applied, True = applied, False = failed + is_applied = models.NullBooleanField(default=None) + creation_date = models.DateTimeField(default=timezone.now, db_index=True) + applied_date = models.DateTimeField(null=True, blank=True, db_index=True) + summary = models.TextField(max_length=2000, null=True, blank=True) + + payload = JSONField() + previous_state = JSONField(null=True, default=None) + + target_id = models.IntegerField(null=True) + target_content_type = models.ForeignKey( + ContentType, + null=True, + on_delete=models.CASCADE, + related_name="targeting_mutations", + ) + target = GenericForeignKey("target_content_type", "target_id") + + objects = MutationQuerySet.as_manager() + + def get_federation_id(self): + if self.fid: + return self.fid + + return federation_utils.full_url( + reverse("federation:edits-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) + + @transaction.atomic + def apply(self): + from . import mutations + + if self.is_applied: + raise ValueError("Mutation was already applied") + + previous_state = mutations.registry.apply( + type=self.type, obj=self.target, payload=self.payload + ) + self.previous_state = previous_state + self.is_applied = True + self.applied_date = timezone.now() + self.save(update_fields=["is_applied", "applied_date", "previous_state"]) + return previous_state diff --git a/api/funkwhale_api/common/mutations.py b/api/funkwhale_api/common/mutations.py new file mode 100644 index 000000000..11624e9f6 --- /dev/null +++ b/api/funkwhale_api/common/mutations.py @@ -0,0 +1,150 @@ +import persisting_theory + +from rest_framework import serializers + +from django.db import models + + +class ConfNotFound(KeyError): + pass + + +class Registry(persisting_theory.Registry): + look_into = "mutations" + + def connect(self, type, klass, perm_checkers=None): + def decorator(serializer_class): + t = self.setdefault(type, {}) + t[klass] = { + "serializer_class": serializer_class, + "perm_checkers": perm_checkers or {}, + } + return serializer_class + + return decorator + + def apply(self, type, obj, payload): + conf = self.get_conf(type, obj) + serializer = conf["serializer_class"](obj, data=payload) + serializer.is_valid(raise_exception=True) + previous_state = serializer.get_previous_state(obj, serializer.validated_data) + serializer.apply(obj, serializer.validated_data) + return previous_state + + def is_valid(self, type, obj, payload): + conf = self.get_conf(type, obj) + serializer = conf["serializer_class"](obj, data=payload) + return serializer.is_valid(raise_exception=True) + + def get_validated_payload(self, type, obj, payload): + conf = self.get_conf(type, obj) + serializer = conf["serializer_class"](obj, data=payload) + serializer.is_valid(raise_exception=True) + return serializer.payload_serialize(serializer.validated_data) + + def has_perm(self, perm, type, obj, actor): + if perm not in ["approve", "suggest"]: + raise ValueError("Invalid permission {}".format(perm)) + conf = self.get_conf(type, obj) + checker = conf["perm_checkers"].get(perm) + if not checker: + return False + return checker(obj=obj, actor=actor) + + def get_conf(self, type, obj): + try: + type_conf = self[type] + except KeyError: + raise ConfNotFound("{} is not a registered mutation".format(type)) + + try: + conf = type_conf[obj.__class__] + except KeyError: + try: + conf = type_conf[None] + except KeyError: + raise ConfNotFound( + "No mutation configuration found for {}".format(obj.__class__) + ) + return conf + + +class MutationSerializer(serializers.Serializer): + def apply(self, obj, validated_data): + raise NotImplementedError() + + def get_previous_state(self, obj, validated_data): + return + + def payload_serialize(self, data): + return data + + +class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): + serialized_relations = {} + + def __init__(self, *args, **kwargs): + # we force partial mode, because update mutations are partial + kwargs.setdefault("partial", True) + super().__init__(*args, **kwargs) + + def apply(self, obj, validated_data): + return self.update(obj, validated_data) + + def validate(self, validated_data): + if not validated_data: + raise serializers.ValidationError("You must update at least one field") + + return super().validate(validated_data) + + def db_serialize(self, validated_data): + data = {} + # ensure model fields are serialized properly + for key, value in list(validated_data.items()): + if not isinstance(value, models.Model): + data[key] = value + continue + field = self.serialized_relations[key] + data[key] = getattr(value, field) + return data + + def payload_serialize(self, data): + data = super().payload_serialize(data) + # we use our serialized_relations configuration + # to ensure we store ids instead of model instances in our json + # payload + for field, attr in self.serialized_relations.items(): + data[field] = getattr(data[field], attr) + return data + + def create(self, validated_data): + validated_data = self.db_serialize(validated_data) + return super().create(validated_data) + + def get_previous_state(self, obj, validated_data): + return get_update_previous_state( + obj, + *list(validated_data.keys()), + serialized_relations=self.serialized_relations + ) + + +def get_update_previous_state(obj, *fields, serialized_relations={}): + if not fields: + raise ValueError("You need to provide at least one field") + + state = {} + for field in fields: + value = getattr(obj, field) + if isinstance(value, models.Model): + # we store the related object id and repr for better UX + id_field = serialized_relations[field] + related_value = getattr(value, id_field) + state[field] = {"value": related_value, "repr": str(value)} + else: + state[field] = {"value": value} + + return state + + +registry = Registry() diff --git a/api/funkwhale_api/common/search.py b/api/funkwhale_api/common/search.py index 70aecd632..622cb29dd 100644 --- a/api/funkwhale_api/common/search.py +++ b/api/funkwhale_api/common/search.py @@ -103,9 +103,7 @@ class SearchConfig: return matching = [t for t in tokens if t["key"] in self.filter_fields] - queries = [ - Q(**{self.filter_fields[t["key"]]["to"]: t["value"]}) for t in matching - ] + queries = [self.get_filter_query(token) for token in matching] query = None for q in queries: if not query: @@ -114,6 +112,26 @@ class SearchConfig: query = query & q return query + def get_filter_query(self, token): + raw_value = token["value"] + try: + field = self.filter_fields[token["key"]]["field"] + value = field.clean(raw_value) + except KeyError: + # no cleaning to apply + value = raw_value + try: + query_field = self.filter_fields[token["key"]]["to"] + return Q(**{query_field: value}) + except KeyError: + pass + + # we don't have a basic filter -> field mapping, this likely means we + # have a dynamic handler in the config + handler = self.filter_fields[token["key"]]["handler"] + value = handler(value) + return value + def clean_types(self, tokens): if not self.types: return [] diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index fafa6152d..59b513f37 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -10,6 +10,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ +from . import models + class RelatedField(serializers.RelatedField): default_error_messages = { @@ -216,3 +218,57 @@ class StripExifImageField(serializers.ImageField): return SimpleUploadedFile( file_obj.name, content, content_type=file_obj.content_type ) + + +from funkwhale_api.federation import serializers as federation_serializers # noqa + +TARGET_ID_TYPE_MAPPING = { + "music.Track": ("id", "track"), + "music.Artist": ("id", "artist"), + "music.Album": ("id", "album"), +} + + +class APIMutationSerializer(serializers.ModelSerializer): + created_by = federation_serializers.APIActorSerializer(read_only=True) + target = serializers.SerializerMethodField() + + class Meta: + model = models.Mutation + fields = [ + "fid", + "uuid", + "type", + "creation_date", + "applied_date", + "is_approved", + "is_applied", + "created_by", + "approved_by", + "summary", + "payload", + "previous_state", + "target", + ] + read_only_fields = [ + "uuid", + "creation_date", + "fid", + "is_applied", + "created_by", + "approved_by", + "previous_state", + ] + + def get_target(self, obj): + target = obj.target + if not target: + return + + id_field, type = TARGET_ID_TYPE_MAPPING[target._meta.label] + return {"type": type, "id": getattr(target, id_field), "repr": str(target)} + + def validate_type(self, value): + if value not in self.context["registry"]: + raise serializers.ValidationError("Invalid mutation type {}".format(value)) + return value diff --git a/api/funkwhale_api/common/signals.py b/api/funkwhale_api/common/signals.py new file mode 100644 index 000000000..1d8e953cc --- /dev/null +++ b/api/funkwhale_api/common/signals.py @@ -0,0 +1,6 @@ +import django.dispatch + +mutation_created = django.dispatch.Signal(providing_args=["mutation"]) +mutation_updated = django.dispatch.Signal( + providing_args=["mutation", "old_is_approved", "new_is_approved"] +) diff --git a/api/funkwhale_api/common/tasks.py b/api/funkwhale_api/common/tasks.py new file mode 100644 index 000000000..994b0bdff --- /dev/null +++ b/api/funkwhale_api/common/tasks.py @@ -0,0 +1,59 @@ +from django.db import transaction +from django.dispatch import receiver + + +from funkwhale_api.common import channels +from funkwhale_api.taskapp import celery + +from . import models +from . import serializers +from . import signals + + +@celery.app.task(name="common.apply_mutation") +@transaction.atomic +@celery.require_instance( + models.Mutation.objects.exclude(is_applied=True).select_for_update(), "mutation" +) +def apply_mutation(mutation): + mutation.apply() + + +@receiver(signals.mutation_created) +def broadcast_mutation_created(mutation, **kwargs): + group = "instance_activity" + channels.group_send( + group, + { + "type": "event.send", + "text": "", + "data": { + "type": "mutation.created", + "mutation": serializers.APIMutationSerializer(mutation).data, + "pending_review_count": models.Mutation.objects.filter( + is_approved=None + ).count(), + }, + }, + ) + + +@receiver(signals.mutation_updated) +def broadcast_mutation_update(mutation, old_is_approved, new_is_approved, **kwargs): + group = "instance_activity" + channels.group_send( + group, + { + "type": "event.send", + "text": "", + "data": { + "type": "mutation.updated", + "mutation": serializers.APIMutationSerializer(mutation).data, + "pending_review_count": models.Mutation.objects.filter( + is_approved=None + ).count(), + "old_is_approved": old_is_approved, + "new_is_approved": new_is_approved, + }, + }, + ) diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py index fe7d6733a..743c95095 100644 --- a/api/funkwhale_api/common/views.py +++ b/api/funkwhale_api/common/views.py @@ -1,3 +1,21 @@ +from django.db import transaction + +from rest_framework.decorators import action +from rest_framework import exceptions +from rest_framework import mixins +from rest_framework import permissions +from rest_framework import response +from rest_framework import viewsets + +from . import filters +from . import models +from . import mutations +from . import serializers +from . import signals +from . import tasks +from . import utils + + class SkipFilterForGetObject: def get_object(self, *args, **kwargs): setattr(self.request, "_skip_filters", True) @@ -7,3 +25,98 @@ class SkipFilterForGetObject: if getattr(self.request, "_skip_filters", False): return queryset return super().filter_queryset(queryset) + + +class MutationViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + queryset = ( + models.Mutation.objects.all() + .order_by("-creation_date") + .select_related("created_by", "approved_by") + .prefetch_related("target") + ) + serializer_class = serializers.APIMutationSerializer + permission_classes = [permissions.IsAuthenticated] + ordering_fields = ("creation_date",) + filterset_class = filters.MutationFilter + + def perform_destroy(self, instance): + if instance.is_applied: + raise exceptions.PermissionDenied("You cannot delete an applied mutation") + + actor = self.request.user.actor + is_owner = actor == instance.created_by + + if not any( + [ + is_owner, + mutations.registry.has_perm( + perm="approve", type=instance.type, obj=instance.target, actor=actor + ), + ] + ): + raise exceptions.PermissionDenied() + + return super().perform_destroy(instance) + + @action(detail=True, methods=["post"]) + @transaction.atomic + def approve(self, request, *args, **kwargs): + instance = self.get_object() + if instance.is_applied: + return response.Response( + {"error": "This mutation was already applied"}, status=403 + ) + actor = self.request.user.actor + can_approve = mutations.registry.has_perm( + perm="approve", type=instance.type, obj=instance.target, actor=actor + ) + + if not can_approve: + raise exceptions.PermissionDenied() + previous_is_approved = instance.is_approved + instance.approved_by = actor + instance.is_approved = True + instance.save(update_fields=["approved_by", "is_approved"]) + utils.on_commit(tasks.apply_mutation.delay, mutation_id=instance.id) + utils.on_commit( + signals.mutation_updated.send, + sender=None, + mutation=instance, + old_is_approved=previous_is_approved, + new_is_approved=instance.is_approved, + ) + return response.Response({}, status=200) + + @action(detail=True, methods=["post"]) + @transaction.atomic + def reject(self, request, *args, **kwargs): + instance = self.get_object() + if instance.is_applied: + return response.Response( + {"error": "This mutation was already applied"}, status=403 + ) + actor = self.request.user.actor + can_approve = mutations.registry.has_perm( + perm="approve", type=instance.type, obj=instance.target, actor=actor + ) + + if not can_approve: + raise exceptions.PermissionDenied() + previous_is_approved = instance.is_approved + instance.approved_by = actor + instance.is_approved = False + instance.save(update_fields=["approved_by", "is_approved"]) + utils.on_commit( + signals.mutation_updated.send, + sender=None, + mutation=instance, + old_is_approved=previous_is_approved, + new_is_approved=instance.is_approved, + ) + return response.Response({}, status=200) diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py index f8347d1eb..f7d5006da 100644 --- a/api/funkwhale_api/federation/urls.py +++ b/api/funkwhale_api/federation/urls.py @@ -8,6 +8,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".well-known", views.WellKnownViewSet, "well-known") music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries") diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 3b322e915..13791ec21 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -69,6 +69,15 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV return response.Response({}) +class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + lookup_field = "uuid" + authentication_classes = [authentication.SignatureAuthentication] + permission_classes = [] + renderer_classes = [renderers.ActivityPubRenderer] + # queryset = common_models.Mutation.objects.local().select_related() + # serializer_class = serializers.ActorSerializer + + class WellKnownViewSet(viewsets.GenericViewSet): authentication_classes = [] permission_classes = [] diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 3134ae19c..fa5a10f6d 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -1,6 +1,7 @@ from django_filters import rest_framework as filters from funkwhale_api.common import fields +from funkwhale_api.common import filters as common_filters from funkwhale_api.common import search from funkwhale_api.moderation import filters as moderation_filters @@ -28,12 +29,14 @@ class ArtistFilter(moderation_filters.HiddenContentFilterSet): class TrackFilter(moderation_filters.HiddenContentFilterSet): q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) playable = filters.BooleanFilter(field_name="_", method="filter_playable") + id = common_filters.MultipleQueryFilter(coerce=int) class Meta: model = models.Track fields = { "title": ["exact", "iexact", "startswith", "icontains"], "playable": ["exact"], + "id": ["exact"], "artist": ["exact"], "album": ["exact"], "license": ["exact"], diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py new file mode 100644 index 000000000..51efa0ab8 --- /dev/null +++ b/api/funkwhale_api/music/mutations.py @@ -0,0 +1,24 @@ +from funkwhale_api.common import mutations + +from . import models + + +def can_suggest(obj, actor): + return True + + +def can_approve(obj, actor): + return actor.user and actor.user.get_permissions()["library"] + + +@mutations.registry.connect( + "update", + models.Track, + perm_checkers={"suggest": can_suggest, "approve": can_approve}, +) +class TrackMutationSerializer(mutations.UpdateMutationSerializer): + serialized_relations = {"license": "code"} + + class Meta: + model = models.Track + fields = ["license", "title", "position"] diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index d07fd27ec..f6bed500c 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -15,6 +15,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from taggit.models import Tag +from funkwhale_api.common import decorators as common_decorators from funkwhale_api.common import permissions as common_permissions from funkwhale_api.common import preferences from funkwhale_api.common import utils as common_utils @@ -186,6 +187,8 @@ class TrackViewSet( "artist__name", ) + mutations = common_decorators.mutations_route(types=["update"]) + def get_queryset(self): queryset = super().get_queryset() filter_favorites = self.request.GET.get("favorites", None) diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index c75604f6e..79ef04561 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -94,6 +94,7 @@ class UserWriteSerializer(serializers.ModelSerializer): class UserReadSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField() + full_username = serializers.SerializerMethodField() avatar = avatar_field class Meta: @@ -101,6 +102,7 @@ class UserReadSerializer(serializers.ModelSerializer): fields = [ "id", "username", + "full_username", "name", "email", "is_staff", @@ -114,6 +116,10 @@ class UserReadSerializer(serializers.ModelSerializer): def get_permissions(self, o): return o.get_permissions() + def get_full_username(self, o): + if o.actor: + return o.actor.full_username + class MeSerializer(UserReadSerializer): quota_status = serializers.SerializerMethodField() diff --git a/api/tests/common/test_decorators.py b/api/tests/common/test_decorators.py new file mode 100644 index 000000000..66e692585 --- /dev/null +++ b/api/tests/common/test_decorators.py @@ -0,0 +1,122 @@ +import pytest + +from rest_framework import viewsets + +from funkwhale_api.common import decorators +from funkwhale_api.common import models +from funkwhale_api.common import mutations +from funkwhale_api.common import serializers +from funkwhale_api.common import signals +from funkwhale_api.common import tasks +from funkwhale_api.music import models as music_models +from funkwhale_api.music import licenses + + +class V(viewsets.ModelViewSet): + queryset = music_models.Track.objects.all() + mutations = decorators.mutations_route(types=["update"]) + permission_classes = [] + + +def test_mutations_route_list(factories, api_request): + track = factories["music.Track"]() + mutation = factories["common.Mutation"](target=track, type="update", payload="") + factories["common.Mutation"](target=track, type="noop", payload="") + + view = V.as_view({"get": "mutations"}) + expected = { + "next": None, + "previous": None, + "count": 1, + "results": [serializers.APIMutationSerializer(mutation).data], + } + + request = api_request.get("/") + response = view(request, pk=track.pk) + + assert response.status_code == 200 + assert response.data == expected + + +@pytest.mark.parametrize("is_approved", [False, True]) +def test_mutations_route_create_success(factories, api_request, is_approved, mocker): + licenses.load(licenses.LICENSES) + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + user = factories["users.User"](permission_library=True) + actor = user.create_actor() + track = factories["music.Track"](title="foo") + view = V.as_view({"post": "mutations"}) + + request = api_request.post( + "/", + { + "type": "update", + "payload": {"title": "bar", "unknown": "test", "license": "cc-by-nc-4.0"}, + "summary": "hello", + "is_approved": is_approved, + }, + format="json", + ) + setattr(request, "user", user) + setattr(request, "session", {}) + response = view(request, pk=track.pk) + + assert response.status_code == 201 + + mutation = models.Mutation.objects.get_for_target(track).latest("id") + + assert mutation.type == "update" + assert mutation.payload == {"title": "bar", "license": "cc-by-nc-4.0"} + assert mutation.created_by == actor + assert mutation.is_approved is is_approved + assert mutation.is_applied is None + assert mutation.target == track + assert mutation.summary == "hello" + + if is_approved: + on_commit.assert_any_call(tasks.apply_mutation.delay, mutation_id=mutation.pk) + expected = serializers.APIMutationSerializer(mutation).data + assert response.data == expected + on_commit.assert_any_call( + signals.mutation_created.send, mutation=mutation, sender=None + ) + + +def test_mutations_route_create_no_auth(factories, api_request): + track = factories["music.Track"](title="foo") + view = V.as_view({"post": "mutations"}) + + request = api_request.post("/", {}, format="json") + response = view(request, pk=track.pk) + + assert response.status_code == 401 + + +@pytest.mark.parametrize("is_approved", [False, True]) +def test_mutations_route_create_no_perm(factories, api_request, mocker, is_approved): + track = factories["music.Track"](title="foo") + view = V.as_view({"post": "mutations"}) + user = factories["users.User"]() + actor = user.create_actor() + has_perm = mocker.patch.object(mutations.registry, "has_perm", return_value=False) + request = api_request.post( + "/", + { + "type": "update", + "payload": {"title": "bar", "unknown": "test"}, + "summary": "hello", + "is_approved": is_approved, + }, + format="json", + ) + setattr(request, "user", user) + setattr(request, "session", {}) + response = view(request, pk=track.pk) + + assert response.status_code == 403 + has_perm.assert_called_once_with( + actor=actor, + obj=track, + type="update", + perm="approve" if is_approved else "suggest", + ) diff --git a/api/tests/common/test_filters.py b/api/tests/common/test_filters.py new file mode 100644 index 000000000..2e89dfa37 --- /dev/null +++ b/api/tests/common/test_filters.py @@ -0,0 +1,38 @@ +import pytest + +from funkwhale_api.common import filters + + +@pytest.mark.parametrize( + "value, expected", + [ + (True, True), + ("True", True), + ("true", True), + ("1", True), + ("yes", True), + (False, False), + ("False", False), + ("false", False), + ("0", False), + ("no", False), + ("None", None), + ("none", None), + ("Null", None), + ("null", None), + ], +) +def test_mutation_filter_is_approved(value, expected, factories): + mutations = { + True: factories["common.Mutation"](is_approved=True, payload={}), + False: factories["common.Mutation"](is_approved=False, payload={}), + None: factories["common.Mutation"](is_approved=None, payload={}), + } + + qs = mutations[True].__class__.objects.all() + + filterset = filters.MutationFilter( + {"q": "is_approved:{}".format(value)}, queryset=qs + ) + + assert list(filterset.qs) == [mutations[expected]] diff --git a/api/tests/common/test_models.py b/api/tests/common/test_models.py new file mode 100644 index 000000000..25c9befda --- /dev/null +++ b/api/tests/common/test_models.py @@ -0,0 +1,17 @@ +import pytest + +from django.urls import reverse + +from funkwhale_api.federation import utils as federation_utils + + +@pytest.mark.parametrize( + "model,factory_args,namespace", + [("common.Mutation", {"created_by__local": True}, "federation:edits-detail")], +) +def test_mutation_fid_is_populated(factories, model, factory_args, namespace): + instance = factories[model](**factory_args, fid=None, payload={}) + + assert instance.fid == federation_utils.full_url( + reverse(namespace, kwargs={"uuid": instance.uuid}) + ) diff --git a/api/tests/common/test_mutations.py b/api/tests/common/test_mutations.py new file mode 100644 index 000000000..bb2a08500 --- /dev/null +++ b/api/tests/common/test_mutations.py @@ -0,0 +1,141 @@ +import pytest + +from funkwhale_api.common import mutations + +from rest_framework import serializers + + +@pytest.fixture +def mutations_registry(): + return mutations.Registry() + + +def test_apply_mutation(mutations_registry): + class Obj: + pass + + obj = Obj() + + @mutations_registry.connect("foo", Obj) + class S(mutations.MutationSerializer): + foo = serializers.ChoiceField(choices=["bar", "baz"]) + + def apply(self, obj, validated_data): + setattr(obj, "foo", validated_data["foo"]) + + with pytest.raises(mutations.ConfNotFound): + mutations_registry.apply("foo", object(), payload={"foo": "nope"}) + + with pytest.raises(serializers.ValidationError): + mutations_registry.apply("foo", obj, payload={"foo": "nope"}) + + mutations_registry.apply("foo", obj, payload={"foo": "bar"}) + + assert obj.foo == "bar" + + +def test_apply_update_mutation(factories, mutations_registry, mocker): + user = factories["users.User"](email="hello@test.email") + get_update_previous_state = mocker.patch.object( + mutations, "get_update_previous_state" + ) + + @mutations_registry.connect("update", user.__class__) + class S(mutations.UpdateMutationSerializer): + class Meta: + model = user.__class__ + fields = ["username", "email"] + + previous_state = mutations_registry.apply( + "update", user, payload={"username": "foo"} + ) + assert previous_state == get_update_previous_state.return_value + get_update_previous_state.assert_called_once_with( + user, "username", serialized_relations={} + ) + user.refresh_from_db() + + assert user.username == "foo" + assert user.email == "hello@test.email" + + +def test_db_serialize_update_mutation(factories, mutations_registry, mocker): + user = factories["users.User"](email="hello@test.email", with_actor=True) + + class S(mutations.UpdateMutationSerializer): + serialized_relations = {"actor": "full_username"} + + class Meta: + model = user.__class__ + fields = ["actor"] + + expected = {"actor": user.actor.full_username} + assert S().db_serialize({"actor": user.actor}) == expected + + +def test_is_valid_mutation(factories, mutations_registry): + user = factories["users.User"].build() + + @mutations_registry.connect("update", user.__class__) + class S(mutations.UpdateMutationSerializer): + class Meta: + model = user.__class__ + fields = ["email"] + + with pytest.raises(serializers.ValidationError): + mutations_registry.is_valid("update", user, payload={"email": "foo"}) + mutations_registry.is_valid("update", user, payload={"email": "foo@bar.com"}) + + +@pytest.mark.parametrize("perm", ["approve", "suggest"]) +def test_permissions(perm, factories, mutations_registry, mocker): + actor = factories["federation.Actor"].build() + user = factories["users.User"].build() + + class S(mutations.UpdateMutationSerializer): + class Meta: + model = user.__class__ + fields = ["email"] + + mutations_registry.connect("update", user.__class__)(S) + + assert mutations_registry.has_perm(perm, "update", obj=user, actor=actor) is False + + checker = mocker.Mock(return_value=True) + mutations_registry.connect("update", user.__class__, perm_checkers={perm: checker})( + S + ) + + assert mutations_registry.has_perm(perm, "update", obj=user, actor=actor) is True + checker.assert_called_once_with(obj=user, actor=actor) + + +def test_model_apply(factories, mocker, now): + target = factories["music.Artist"]() + mutation = factories["common.Mutation"](type="noop", target=target, payload="hello") + + apply = mocker.patch.object( + mutations.registry, "apply", return_value={"previous": "state"} + ) + + mutation.apply() + apply.assert_called_once_with(type="noop", obj=target, payload="hello") + mutation.refresh_from_db() + + assert mutation.is_applied is True + assert mutation.previous_state == {"previous": "state"} + assert mutation.applied_date == now + + +def test_get_previous_state(factories): + obj = factories["music.Track"]() + expected = { + "title": {"value": obj.title}, + "album": {"value": obj.album.pk, "repr": str(obj.album)}, + } + assert ( + mutations.get_update_previous_state( + obj, "title", "album", serialized_relations={"album": "pk"} + ) + == expected + ) diff --git a/api/tests/common/test_search.py b/api/tests/common/test_search.py index e5be7bc90..887229802 100644 --- a/api/tests/common/test_search.py +++ b/api/tests/common/test_search.py @@ -1,6 +1,7 @@ import pytest from django.db.models import Q +from django import forms from funkwhale_api.common import search from funkwhale_api.music import models as music_models @@ -45,6 +46,24 @@ def test_search_config_query(query, expected): assert cleaned["search_query"] == expected +def test_search_config_query_filter_field_handler(): + s = search.SearchConfig( + filter_fields={"account": {"handler": lambda v: Q(hello="world")}} + ) + + cleaned = s.clean("account:noop") + assert cleaned["filter_query"] == Q(hello="world") + + +def test_search_config_query_filter_field(): + s = search.SearchConfig( + filter_fields={"account": {"to": "noop", "field": forms.BooleanField()}} + ) + + cleaned = s.clean("account:true") + assert cleaned["filter_query"] == Q(noop=True) + + @pytest.mark.parametrize( "query,expected", [ diff --git a/api/tests/common/test_tasks.py b/api/tests/common/test_tasks.py new file mode 100644 index 000000000..f097c4423 --- /dev/null +++ b/api/tests/common/test_tasks.py @@ -0,0 +1,65 @@ +import pytest + +from funkwhale_api.common import serializers +from funkwhale_api.common import signals +from funkwhale_api.common import tasks + + +def test_apply_migration(factories, mocker): + mutation = factories["common.Mutation"](payload={}) + apply = mocker.patch.object(mutation.__class__, "apply") + tasks.apply_mutation(mutation_id=mutation.pk) + + apply.assert_called_once_with() + + +def test_broadcast_mutation_created(factories, mocker): + mutation = factories["common.Mutation"](payload={}) + factories["common.Mutation"](payload={}, is_approved=True) + group_send = mocker.patch("funkwhale_api.common.channels.group_send") + expected = serializers.APIMutationSerializer(mutation).data + + signals.mutation_created.send(sender=None, mutation=mutation) + group_send.assert_called_with( + "instance_activity", + { + "type": "event.send", + "text": "", + "data": { + "type": "mutation.created", + "mutation": expected, + "pending_review_count": 1, + }, + }, + ) + + +def test_broadcast_mutation_updated(factories, mocker): + mutation = factories["common.Mutation"](payload={}, is_approved=True) + factories["common.Mutation"](payload={}) + group_send = mocker.patch("funkwhale_api.common.channels.group_send") + expected = serializers.APIMutationSerializer(mutation).data + + signals.mutation_updated.send( + sender=None, mutation=mutation, old_is_approved=False, new_is_approved=True + ) + group_send.assert_called_with( + "instance_activity", + { + "type": "event.send", + "text": "", + "data": { + "type": "mutation.updated", + "mutation": expected, + "old_is_approved": False, + "new_is_approved": True, + "pending_review_count": 1, + }, + }, + ) + + +def test_cannot_apply_already_applied_migration(factories): + mutation = factories["common.Mutation"](payload={}, is_applied=True) + with pytest.raises(mutation.__class__.DoesNotExist): + tasks.apply_mutation(mutation_id=mutation.pk) diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py new file mode 100644 index 000000000..9a03fb429 --- /dev/null +++ b/api/tests/common/test_views.py @@ -0,0 +1,161 @@ +import pytest +from django.urls import reverse + +from funkwhale_api.common import serializers +from funkwhale_api.common import signals +from funkwhale_api.common import tasks + + +def test_can_detail_mutation(logged_in_api_client, factories): + mutation = factories["common.Mutation"](payload={}) + url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) + + response = logged_in_api_client.get(url) + + expected = serializers.APIMutationSerializer(mutation).data + + assert response.status_code == 200 + assert response.data == expected + + +def test_can_list_mutations(logged_in_api_client, factories): + mutation = factories["common.Mutation"](payload={}) + url = reverse("api:v1:mutations-list") + + response = logged_in_api_client.get(url) + + expected = serializers.APIMutationSerializer(mutation).data + + assert response.status_code == 200 + assert response.data["results"] == [expected] + + +def test_can_destroy_mutation_creator(logged_in_api_client, factories): + actor = logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"]( + target=track, type="update", payload={}, created_by=actor + ) + url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + + +def test_can_destroy_mutation_not_creator(logged_in_api_client, factories): + logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"](type="update", target=track, payload={}) + url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 403 + + mutation.refresh_from_db() + + +def test_can_destroy_mutation_has_perm(logged_in_api_client, factories, mocker): + actor = logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"](target=track, type="update", payload={}) + has_perm = mocker.patch( + "funkwhale_api.common.mutations.registry.has_perm", return_value=True + ) + url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + has_perm.assert_called_once_with( + obj=mutation.target, type=mutation.type, perm="approve", actor=actor + ) + + +@pytest.mark.parametrize("endpoint, expected", [("approve", True), ("reject", False)]) +def test_can_approve_reject_mutation_with_perm( + endpoint, expected, logged_in_api_client, factories, mocker +): + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + actor = logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"](target=track, type="update", payload={}) + has_perm = mocker.patch( + "funkwhale_api.common.mutations.registry.has_perm", return_value=True + ) + url = reverse( + "api:v1:mutations-{}".format(endpoint), kwargs={"uuid": mutation.uuid} + ) + + response = logged_in_api_client.post(url) + + assert response.status_code == 200 + has_perm.assert_called_once_with( + obj=mutation.target, type=mutation.type, perm="approve", actor=actor + ) + + if expected: + on_commit.assert_any_call(tasks.apply_mutation.delay, mutation_id=mutation.id) + mutation.refresh_from_db() + + assert mutation.is_approved == expected + assert mutation.approved_by == actor + + on_commit.assert_any_call( + signals.mutation_updated.send, + mutation=mutation, + sender=None, + new_is_approved=expected, + old_is_approved=None, + ) + + +@pytest.mark.parametrize("endpoint, expected", [("approve", True), ("reject", False)]) +def test_cannot_approve_reject_applied_mutation( + endpoint, expected, logged_in_api_client, factories, mocker +): + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"]( + target=track, type="update", payload={}, is_applied=True + ) + mocker.patch("funkwhale_api.common.mutations.registry.has_perm", return_value=True) + url = reverse( + "api:v1:mutations-{}".format(endpoint), kwargs={"uuid": mutation.uuid} + ) + + response = logged_in_api_client.post(url) + + assert response.status_code == 403 + on_commit.assert_not_called() + + mutation.refresh_from_db() + + assert mutation.is_approved is None + assert mutation.approved_by is None + + +@pytest.mark.parametrize("endpoint, expected", [("approve", True), ("reject", False)]) +def test_cannot_approve_reject_without_perm( + endpoint, expected, logged_in_api_client, factories, mocker +): + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"](target=track, type="update", payload={}) + mocker.patch("funkwhale_api.common.mutations.registry.has_perm", return_value=False) + url = reverse( + "api:v1:mutations-{}".format(endpoint), kwargs={"uuid": mutation.uuid} + ) + + response = logged_in_api_client.post(url) + + assert response.status_code == 403 + on_commit.assert_not_called() + + mutation.refresh_from_db() + + assert mutation.is_approved is None + assert mutation.approved_by is None diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py new file mode 100644 index 000000000..d6b8223d4 --- /dev/null +++ b/api/tests/music/test_mutations.py @@ -0,0 +1,35 @@ +from funkwhale_api.music import licenses + + +def test_track_license_mutation(factories, now): + track = factories["music.Track"](license=None) + mutation = factories["common.Mutation"]( + type="update", target=track, payload={"license": "cc-by-sa-4.0"} + ) + licenses.load(licenses.LICENSES) + mutation.apply() + track.refresh_from_db() + + assert track.license.code == "cc-by-sa-4.0" + + +def test_track_title_mutation(factories, now): + track = factories["music.Track"](title="foo") + mutation = factories["common.Mutation"]( + type="update", target=track, payload={"title": "bar"} + ) + mutation.apply() + track.refresh_from_db() + + assert track.title == "bar" + + +def test_track_position_mutation(factories): + track = factories["music.Track"](position=4) + mutation = factories["common.Mutation"]( + type="update", target=track, payload={"position": 12} + ) + mutation.apply() + track.refresh_from_db() + + assert track.position == 12 diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 741fe9b29..b11f9b006 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -70,6 +70,19 @@ def test_track_list_serializer(api_request, factories, logged_in_api_client): assert response.data == expected +def test_track_list_filter_id(api_request, factories, logged_in_api_client): + track1 = factories["music.Track"]() + track2 = factories["music.Track"]() + factories["music.Track"]() + url = reverse("api:v1:tracks-list") + response = logged_in_api_client.get(url, {"id[]": [track1.id, track2.id]}) + + assert response.status_code == 200 + assert response.data["count"] == 2 + assert response.data["results"][0]["id"] == track2.id + assert response.data["results"][1]["id"] == track1.id + + @pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")]) def test_artist_view_filter_playable(param, expected, factories, api_request): artists = { diff --git a/front/package.json b/front/package.json index adeb1fb19..22b5f3bb5 100644 --- a/front/package.json +++ b/front/package.json @@ -13,6 +13,7 @@ "dependencies": { "axios": "^0.18.0", "dateformat": "^3.0.3", + "diff": "^4.0.1", "django-channels": "^1.1.6", "howler": "^2.0.14", "js-logger": "^1.4.1", diff --git a/front/src/App.vue b/front/src/App.vue index 1cc6a2d3c..fd94a9f46 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -92,6 +92,16 @@ export default { id: 'sidebarCount', handler: this.incrementNotificationCountInSidebar }) + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'mutation.created', + id: 'sidebarReviewEditCount', + handler: this.incrementReviewEditCountInSidebar + }) + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'mutation.updated', + id: 'sidebarReviewEditCount', + handler: this.incrementReviewEditCountInSidebar + }) }, mounted () { let self = this @@ -110,12 +120,23 @@ export default { eventName: 'inbox.item_added', id: 'sidebarCount', }) + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'mutation.created', + id: 'sidebarReviewEditCount', + }) + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'mutation.updated', + id: 'sidebarReviewEditCount', + }) this.disconnect() }, methods: { incrementNotificationCountInSidebar (event) { this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1}) }, + incrementReviewEditCountInSidebar (event) { + this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewEdits', value: event.pending_review_count}) + }, fetchNodeInfo () { let self = this axios.get('instance/nodeinfo/2.0/').then(response => { @@ -179,7 +200,6 @@ export default { }), suggestedInstances () { let instances = this.$store.state.instance.knownInstances.slice(0) - console.log('instance', instances) if (this.$store.state.instance.frontSettings.defaultServerUrl) { let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl if (!serverUrl.endsWith('/')) { @@ -188,7 +208,6 @@ export default { instances.push(serverUrl) } instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/') - console.log('HELLO', instances) return _.uniq(instances.filter((e) => {return e})) }, version () { diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 865618b12..77b9c02f5 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -97,6 +97,17 @@ :to="{name: 'manage.moderation.domains.list'}"> Moderation + + Library +
+ {{ $store.state.ui.notifications.pendingReviewEdits }}
+
@@ -210,10 +221,12 @@ export default { let mainMenu = this.$gettext("Main menu") let selectTrack = this.$gettext("Play this track") let pendingFollows = this.$gettext("Pending follow requests") + let pendingReviewEdits = this.$gettext("Pending review edits") return { pendingFollows, mainMenu, - selectTrack + selectTrack, + pendingReviewEdits } }, tracks: { diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue index 61be521b5..6a1b65cd3 100644 --- a/front/src/components/auth/Login.vue +++ b/front/src/components/auth/Login.vue @@ -97,7 +97,6 @@ export default { username: this.credentials.username, password: this.credentials.password } - console.log('NEXT', this.next) this.$store .dispatch("auth/login", { credentials, diff --git a/front/src/components/common/EmptyState.vue b/front/src/components/common/EmptyState.vue new file mode 100644 index 000000000..ddd9066f0 --- /dev/null +++ b/front/src/components/common/EmptyState.vue @@ -0,0 +1,40 @@ + + + + diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue index eed245ea6..fde04f141 100644 --- a/front/src/components/common/HumanDate.vue +++ b/front/src/components/common/HumanDate.vue @@ -1,10 +1,16 @@ diff --git a/front/src/components/library/EditDetail.vue b/front/src/components/library/EditDetail.vue new file mode 100644 index 000000000..4a0c89434 --- /dev/null +++ b/front/src/components/library/EditDetail.vue @@ -0,0 +1,52 @@ + + + diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue new file mode 100644 index 000000000..e98a4a656 --- /dev/null +++ b/front/src/components/library/EditForm.vue @@ -0,0 +1,192 @@ + + + + diff --git a/front/src/components/library/EditList.vue b/front/src/components/library/EditList.vue new file mode 100644 index 000000000..2ff1fc72a --- /dev/null +++ b/front/src/components/library/EditList.vue @@ -0,0 +1,74 @@ + + + diff --git a/front/src/components/library/Track.vue b/front/src/components/library/TrackBase.vue similarity index 51% rename from front/src/components/library/Track.vue rename to front/src/components/library/TrackBase.vue index 66b17d04b..c0209732d 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/TrackBase.vue @@ -64,99 +64,15 @@ + + + Edit… + -
-

- Track information -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Copyright - {{ track.copyright|truncate(50) }} - No copyright information available for this track -
- License - - {{ license.name }} - - No licensing information for this track -
- Duration - {{ time.parse(upload.duration) }} - N/A -
- Size - {{ upload.size | humanSize }} - N/A -
- Bitrate - {{ upload.bitrate | humanSize }}/s - N/A -
- Type - {{ upload.extension }} - N/A -
-
-
-

- Lyrics -

-
-
-
-
- -
-
-

- User libraries -

- - This track is present in the following libraries: - -
+ @@ -169,7 +85,6 @@ import logger from "@/logging" import PlayButton from "@/components/audio/PlayButton" import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon" import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon" -import LibraryWidget from "@/components/federation/LibraryWidget" import Modal from '@/components/semantic/Modal' import EmbedWizard from "@/components/audio/EmbedWizard" @@ -181,7 +96,6 @@ export default { PlayButton, TrackPlaylistIcon, TrackFavoriteIcon, - LibraryWidget, Modal, EmbedWizard }, @@ -189,17 +103,13 @@ export default { return { time, isLoadingTrack: true, - isLoadingLyrics: true, track: null, - lyrics: null, - licenseData: null, - libraries: [], - showEmbedModal: false + showEmbedModal: false, + libraries: [] } }, created() { this.fetchData() - this.fetchLyrics() }, methods: { fetchData() { @@ -212,29 +122,6 @@ export default { self.isLoadingTrack = false }) }, - fetchLicenseData(licenseId) { - var self = this - let url = `licenses/${licenseId}/` - axios.get(url).then(response => { - self.licenseData = response.data - }) - }, - fetchLyrics() { - var self = this - this.isLoadingLyrics = true - let url = FETCH_URL + this.id + "/lyrics/" - logger.default.debug('Fetching lyrics for track "' + this.id + '"') - axios.get(url).then( - response => { - self.lyrics = response.data - self.isLoadingLyrics = false - }, - response => { - console.error("No lyrics available") - self.isLoadingLyrics = false - } - ) - } }, computed: { publicLibraries () { @@ -242,16 +129,16 @@ export default { return l.privacy_level === 'everyone' }) }, - labels() { - return { - title: this.$pgettext('Head/Track/Title', "Track") - } - }, upload() { if (this.track.uploads) { return this.track.uploads[0] } }, + labels() { + return { + title: this.$pgettext('Head/Track/Title', "Track") + } + }, wikipediaUrl() { return ( "https://en.wikipedia.org/w/index.php?search=" + @@ -276,11 +163,6 @@ export default { } return u }, - lyricsSearchUrl() { - let base = "http://lyrics.wikia.com/wiki/Special:Search?query=" - let query = this.track.artist.name + ":" + this.track.title - return base + encodeURI(query) - }, cover() { return null }, @@ -302,30 +184,11 @@ export default { ")" ) }, - license() { - if (!this.track || !this.track.license) { - return null - } - return this.licenseData - } }, watch: { id() { this.fetchData() }, - track (v) { - if (v && v.license) { - this.fetchLicenseData(v.license) - } - } } } - - - diff --git a/front/src/components/library/TrackDetail.vue b/front/src/components/library/TrackDetail.vue new file mode 100644 index 000000000..7f6d27dc1 --- /dev/null +++ b/front/src/components/library/TrackDetail.vue @@ -0,0 +1,191 @@ + + + + + + diff --git a/front/src/components/library/TrackEdit.vue b/front/src/components/library/TrackEdit.vue new file mode 100644 index 000000000..40178e6ed --- /dev/null +++ b/front/src/components/library/TrackEdit.vue @@ -0,0 +1,34 @@ + + + diff --git a/front/src/components/manage/library/EditsCardList.vue b/front/src/components/manage/library/EditsCardList.vue new file mode 100644 index 000000000..8e51ff0f0 --- /dev/null +++ b/front/src/components/manage/library/EditsCardList.vue @@ -0,0 +1,231 @@ + + + diff --git a/front/src/components/manage/library/FilesTable.vue b/front/src/components/manage/library/FilesTable.vue deleted file mode 100644 index 4716e361a..000000000 --- a/front/src/components/manage/library/FilesTable.vue +++ /dev/null @@ -1,216 +0,0 @@ - - - diff --git a/front/src/components/moderation/FilterModal.vue b/front/src/components/moderation/FilterModal.vue index 39a876546..5dcd47825 100644 --- a/front/src/components/moderation/FilterModal.vue +++ b/front/src/components/moderation/FilterModal.vue @@ -94,7 +94,6 @@ export default { date: new Date() }) }, error => { - console.log('error', error) logger.default.error(`Error while hiding ${self.type} ${self.target.id}`) self.errors = error.backendErrors self.isLoading = false diff --git a/front/src/edits.js b/front/src/edits.js new file mode 100644 index 000000000..a57680bac --- /dev/null +++ b/front/src/edits.js @@ -0,0 +1,81 @@ +export default { + getConfigs () { + return { + track: { + fields: [ + { + id: 'title', + type: 'text', + required: true, + label: this.$pgettext('*/*/*/Short, Noun', 'Title'), + getValue: (obj) => { return obj.title } + }, + { + id: 'license', + type: 'text', + required: false, + label: this.$pgettext('*/*/*/Short, Noun', 'License'), + getValue: (obj) => { return obj.license } + }, + { + id: 'position', + type: 'text', + inputType: 'number', + required: false, + label: this.$pgettext('*/*/*/Short, Noun', 'Position'), + getValue: (obj) => { return obj.position } + } + ] + } + } + }, + + getConfig () { + return this.configs[this.objectType] + }, + + getCurrentState () { + let self = this + let s = {} + this.config.fields.forEach(f => { + s[f.id] = {value: f.getValue(self.object)} + }) + return s + }, + getCurrentStateForObj (obj, config) { + let s = {} + config.fields.forEach(f => { + s[f.id] = {value: f.getValue(obj)} + }) + return s + }, + + getCanDelete () { + if (this.obj.is_applied || this.obj.is_approved) { + return false + } + if (!this.$store.state.auth.authenticated) { + return false + } + return ( + this.obj.created_by.full_username === this.$store.state.auth.fullUsername + || this.$store.state.auth.availablePermissions['library'] + ) + }, + getCanApprove () { + if (this.obj.is_applied) { + return false + } + if (!this.$store.state.auth.authenticated) { + return false + } + return this.$store.state.auth.availablePermissions['library'] + }, + getCanEdit () { + if (!this.$store.state.auth.authenticated) { + return false + } + return this.$store.state.auth.availablePermissions['library'] + }, + +} diff --git a/front/src/router/index.js b/front/src/router/index.js index e6e5b2870..9320b74b8 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -17,7 +17,10 @@ import LibraryArtist from '@/components/library/Artist' import LibraryArtists from '@/components/library/Artists' import LibraryAlbums from '@/components/library/Albums' import LibraryAlbum from '@/components/library/Album' -import LibraryTrack from '@/components/library/Track' +import LibraryTrackDetail from '@/components/library/TrackDetail' +import LibraryTrackEdit from '@/components/library/TrackEdit' +import EditDetail from '@/components/library/EditDetail' +import LibraryTrackDetailBase from '@/components/library/TrackBase' import LibraryRadios from '@/components/library/Radios' import RadioBuilder from '@/components/library/radios/Builder' import RadioDetail from '@/views/radios/Detail' @@ -26,7 +29,7 @@ import PlaylistList from '@/views/playlists/List' import Favorites from '@/components/favorites/List' import AdminSettings from '@/views/admin/Settings' import AdminLibraryBase from '@/views/admin/library/Base' -import AdminLibraryFilesList from '@/views/admin/library/FilesList' +import AdminLibraryEditsList from '@/views/admin/library/EditsList' import AdminUsersBase from '@/views/admin/users/Base' import AdminUsersList from '@/views/admin/users/UsersList' import AdminInvitationsList from '@/views/admin/users/InvitationsList' @@ -206,9 +209,14 @@ export default new Router({ component: AdminLibraryBase, children: [ { - path: 'files', - name: 'manage.library.files', - component: AdminLibraryFilesList + path: 'edits', + name: 'manage.library.edits', + component: AdminLibraryEditsList, + props: (route) => { + return { + defaultQuery: route.query.q, + } + } } ] }, @@ -324,7 +332,29 @@ export default new Router({ }, { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true }, { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true }, - { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true }, + { + path: 'tracks/:id', + component: LibraryTrackDetailBase, + props: true, + children: [ + { + path: '', + name: 'library.tracks.detail', + component: LibraryTrackDetail + }, + { + path: 'edit', + name: 'library.tracks.edit', + component: LibraryTrackEdit + }, + { + path: 'edit/:editId', + name: 'library.tracks.edit.detail', + component: EditDetail, + props: true, + } + ] + }, ] }, { path: '*', component: PageNotFound } diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 8893bcb49..29c4c5d71 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -8,6 +8,7 @@ export default { state: { authenticated: false, username: '', + fullUsername: '', availablePermissions: { settings: false, library: false, @@ -27,6 +28,7 @@ export default { state.authenticated = false state.profile = null state.username = '' + state.fullUsername = '' state.token = '' state.tokenData = {} state.availablePermissions = { @@ -43,6 +45,7 @@ export default { state.authenticated = value if (value === false) { state.username = null + state.fullUsername = null state.token = null state.tokenData = null state.profile = null @@ -52,6 +55,9 @@ export default { username: (state, value) => { state.username = value }, + fullUsername: (state, value) => { + state.fullUsername = value + }, avatar: (state, value) => { if (state.profile) { state.profile.avatar = value @@ -124,6 +130,7 @@ export default { resolve(response.data) }) dispatch('ui/fetchUnreadNotifications', null, { root: true }) + dispatch('ui/fetchPendingReviewEdits', null, { root: true }) dispatch('favorites/fetch', null, { root: true }) dispatch('moderation/fetchContentFilters', null, { root: true }) dispatch('playlists/fetchOwn', null, { root: true }) @@ -138,6 +145,7 @@ export default { commit("authenticated", true) commit("profile", data) commit("username", data.username) + commit("fullUsername", data.full_username) Object.keys(data.permissions).forEach(function(key) { // this makes it easier to check for permissions in templates commit("permission", { diff --git a/front/src/store/ui.js b/front/src/store/ui.js index fa4624c70..cec9ef9c5 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -12,10 +12,13 @@ export default { messages: [], notifications: { inbox: 0, + pendingReviewEdits: 0, }, websocketEventsHandlers: { 'inbox.item_added': {}, 'import.status_updated': {}, + 'mutation.created': {}, + 'mutation.updated': {}, } }, mutations: { @@ -44,8 +47,12 @@ export default { notifications (state, {type, count}) { state.notifications[type] = count }, - incrementNotifications (state, {type, count}) { - state.notifications[type] = Math.max(0, state.notifications[type] + count) + incrementNotifications (state, {type, count, value}) { + if (value != undefined) { + state.notifications[type] = Math.max(0, value) + } else { + state.notifications[type] = Math.max(0, state.notifications[type] + count) + } } }, actions: { @@ -54,6 +61,11 @@ export default { commit('notifications', {type: 'inbox', count: response.data.count}) }) }, + fetchPendingReviewEdits ({commit, rootState}, payload) { + axios.get('mutations/', {params: {is_approved: 'null', page_size: 1}}).then((response) => { + commit('notifications', {type: 'pendingReviewEdits', count: response.data.count}) + }) + }, websocketEvent ({state}, event) { let handlers = state.websocketEventsHandlers[event.type] console.log('Dispatching websocket event', event, handlers) diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 152881e30..55e23f320 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -276,3 +276,12 @@ canvas.color-thief { .ui.dropdown .item[disabled] { display: none; } + +span.diff.added { + background-color:rgba(0, 255, 0, 0.25); +} + + +span.diff.removed { + background-color: rgba(255, 0, 0, 0.25); +} diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index 072225a5e..293e569ef 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -3,7 +3,7 @@ diff --git a/front/src/views/admin/library/EditsList.vue b/front/src/views/admin/library/EditsList.vue new file mode 100644 index 000000000..b38732f4a --- /dev/null +++ b/front/src/views/admin/library/EditsList.vue @@ -0,0 +1,33 @@ + + + + + + diff --git a/front/src/views/admin/library/FilesList.vue b/front/src/views/admin/library/FilesList.vue deleted file mode 100644 index e23745912..000000000 --- a/front/src/views/admin/library/FilesList.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - diff --git a/front/yarn.lock b/front/yarn.lock index db05be080..5d20f4ac1 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -2960,6 +2960,11 @@ diff@3.5.0, diff@^3.5.0: resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== +diff@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff" + integrity sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"