diff --git a/api/funkwhale_api/common/mutations.py b/api/funkwhale_api/common/mutations.py index c3e92c15b..dfc8ba85e 100644 --- a/api/funkwhale_api/common/mutations.py +++ b/api/funkwhale_api/common/mutations.py @@ -2,7 +2,7 @@ import persisting_theory from rest_framework import serializers -from django.db import models +from django.db import models, transaction class ConfNotFound(KeyError): @@ -23,6 +23,7 @@ class Registry(persisting_theory.Registry): return decorator + @transaction.atomic def apply(self, type, obj, payload): conf = self.get_conf(type, obj) serializer = conf["serializer_class"](obj, data=payload) @@ -73,6 +74,9 @@ class MutationSerializer(serializers.Serializer): def apply(self, obj, validated_data): raise NotImplementedError() + def post_apply(self, obj, validated_data): + pass + def get_previous_state(self, obj, validated_data): return @@ -88,8 +92,11 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): kwargs.setdefault("partial", True) super().__init__(*args, **kwargs) + @transaction.atomic def apply(self, obj, validated_data): - return self.update(obj, validated_data) + r = self.update(obj, validated_data) + self.post_apply(r, validated_data) + return r def validate(self, validated_data): if not validated_data: diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 6a21517f6..57bcba932 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -201,3 +201,30 @@ def concat_dicts(*dicts): n.update(d) return n + + +def get_updated_fields(conf, data, obj): + """ + Given a list of fields, a dict and an object, will return the dict keys/values + that differ from the corresponding fields on the object. + """ + final_conf = [] + for c in conf: + if isinstance(c, str): + final_conf.append((c, c)) + else: + final_conf.append(c) + + final_data = {} + + for data_field, obj_field in final_conf: + try: + data_value = data[data_field] + except KeyError: + continue + + obj_value = getattr(obj, obj_field) + if obj_value != data_value: + final_data[obj_field] = data_value + + return final_data diff --git a/api/funkwhale_api/factories.py b/api/funkwhale_api/factories.py index 472ff3feb..3517ea007 100644 --- a/api/funkwhale_api/factories.py +++ b/api/funkwhale_api/factories.py @@ -2,6 +2,8 @@ import uuid import factory import persisting_theory +from django.conf import settings + from faker.providers import internet as internet_provider @@ -50,11 +52,11 @@ class FunkwhaleProvider(internet_provider.Provider): not random enough """ - def federation_url(self, prefix=""): + def federation_url(self, prefix="", local=False): def path_generator(): return "{}/{}".format(prefix, uuid.uuid4()) - domain = self.domain_name() + domain = settings.FEDERATION_HOSTNAME if local else self.domain_name() protocol = "https" path = path_generator() return "{}://{}/{}".format(protocol, domain, path) diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 4e6e82369..979b8aa1b 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -365,27 +365,6 @@ class OutboxRouter(Router): return activities -def recursive_getattr(obj, key, permissive=False): - """ - Given a dictionary such as {'user': {'name': 'Bob'}} and - a dotted string such as user.name, returns 'Bob'. - - If the value is not present, returns None - """ - v = obj - for k in key.split("."): - try: - v = v.get(k) - except (TypeError, AttributeError): - if not permissive: - raise - return - if v is None: - return - - return v - - def match_route(route, payload): for key, value in route.items(): payload_value = recursive_getattr(payload, key, permissive=True) @@ -432,6 +411,27 @@ def prepare_deliveries_and_inbox_items(recipient_list, type): remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url) urls.append(r["target"].followers_url) + elif isinstance(r, dict) and r["type"] == "instances_with_followers": + # we want to broadcast the activity to other instances service actors + # when we have at least one follower from this instance + follows = ( + models.LibraryFollow.objects.filter(approved=True) + .exclude(actor__domain_id=settings.FEDERATION_HOSTNAME) + .exclude(actor__domain=None) + .union( + models.Follow.objects.filter(approved=True) + .exclude(actor__domain_id=settings.FEDERATION_HOSTNAME) + .exclude(actor__domain=None) + ) + ) + actors = models.Actor.objects.filter( + managed_domains__name__in=follows.values_list( + "actor__domain_id", flat=True + ) + ) + values = actors.values("shared_inbox_url", "inbox_url") + for v in values: + remote_inbox_urls.add(v["shared_inbox_url"] or v["inbox_url"]) deliveries = [models.Delivery(inbox_url=url) for url in remote_inbox_urls] inbox_items = [ models.InboxItem(actor=actor, type=type) for actor in local_recipients diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index e9a51779e..cf9546447 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -75,6 +75,15 @@ class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): model = "federation.Domain" django_get_or_create = ("name",) + @factory.post_generation + def with_service_actor(self, create, extracted, **kwargs): + if not create or not extracted: + return + + self.service_actor = ActorFactory(domain=self) + self.save(update_fields=["service_actor"]) + return self.service_actor + @registry.register class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): diff --git a/api/funkwhale_api/federation/jsonld.py b/api/funkwhale_api/federation/jsonld.py index 319ab3b6b..ad67323f2 100644 --- a/api/funkwhale_api/federation/jsonld.py +++ b/api/funkwhale_api/federation/jsonld.py @@ -57,7 +57,9 @@ def insert_context(ctx, doc): existing = doc["@context"] if isinstance(existing, list): if ctx not in existing: + existing = existing[:] existing.append(ctx) + doc["@context"] = existing else: doc["@context"] = [existing, ctx] return doc @@ -215,6 +217,15 @@ def get_default_context(): return ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", {}] +def get_default_context_fw(): + return [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {}, + "https://funkwhale.audio/ns", + ] + + class JsonLdSerializer(serializers.Serializer): def run_validation(self, data=empty): if data and data is not empty and self.context.get("expand", True): diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index ba1ae3e08..caf8c7db6 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -264,6 +264,25 @@ class Actor(models.Model): self.private_key = v[0].decode("utf-8") self.public_key = v[1].decode("utf-8") + def can_manage(self, obj): + attributed_to = getattr(obj, "attributed_to_id", None) + if attributed_to is not None and attributed_to == self.pk: + # easiest case, the obj is attributed to the actor + return True + + if self.domain.service_actor_id != self.pk: + # actor is not system actor, so there is no way the actor can manage + # the object + return False + + # actor is service actor of its domain, so if the fid domain + # matches, we consider the actor has the permission to manage + # the object + domain = self.domain_id + return obj.fid.startswith("http://{}/".format(domain)) or obj.fid.startswith( + "https://{}/".format(domain) + ) + class InboxItem(models.Model): """ diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index 0295aa46c..9f14fd110 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -3,6 +3,7 @@ import logging from funkwhale_api.music import models as music_models from . import activity +from . import actors from . import serializers logger = logging.getLogger(__name__) @@ -269,3 +270,79 @@ def outbox_delete_audio(context): serializer.data, to=[{"type": "followers", "target": library}] ), } + + +def handle_library_entry_update(payload, context, queryset, serializer_class): + actor = context["actor"] + obj_id = payload["object"].get("id") + if not obj_id: + logger.debug("Discarding update of empty obj") + return + + try: + obj = queryset.select_related("attributed_to").get(fid=obj_id) + except queryset.model.DoesNotExist: + logger.debug("Discarding update of unkwnown obj %s", obj_id) + return + if not actor.can_manage(obj): + logger.debug( + "Discarding unauthorize update of obj %s from %s", obj_id, actor.fid + ) + return + + serializer = serializer_class(obj, data=payload["object"]) + if serializer.is_valid(): + serializer.save() + else: + logger.debug( + "Discarding update of obj %s because of payload errors: %s", + obj_id, + serializer.errors, + ) + + +@inbox.register({"type": "Update", "object.type": "Track"}) +def inbox_update_track(payload, context): + return handle_library_entry_update( + payload, + context, + queryset=music_models.Track.objects.all(), + serializer_class=serializers.TrackSerializer, + ) + + +@inbox.register({"type": "Update", "object.type": "Artist"}) +def inbox_update_artist(payload, context): + return handle_library_entry_update( + payload, + context, + queryset=music_models.Artist.objects.all(), + serializer_class=serializers.ArtistSerializer, + ) + + +@inbox.register({"type": "Update", "object.type": "Album"}) +def inbox_update_album(payload, context): + return handle_library_entry_update( + payload, + context, + queryset=music_models.Album.objects.all(), + serializer_class=serializers.AlbumSerializer, + ) + + +@outbox.register({"type": "Update", "object.type": "Track"}) +def outbox_update_track(context): + track = context["track"] + serializer = serializers.ActivitySerializer( + {"type": "Update", "object": serializers.TrackSerializer(track).data} + ) + + yield { + "type": "Update", + "actor": actors.get_service_actor(), + "payload": with_recipients( + serializer.data, + to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}], + ), + } diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 0c5ce5497..666fde092 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -7,9 +7,11 @@ from django.core.paginator import Paginator from rest_framework import serializers from funkwhale_api.common import utils as funkwhale_utils +from funkwhale_api.music import licenses from funkwhale_api.music import models as music_models +from funkwhale_api.music import tasks as music_tasks -from . import activity, contexts, jsonld, models, utils +from . import activity, actors, contexts, jsonld, models, utils AP_CONTEXT = jsonld.get_default_context() @@ -670,7 +672,7 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer): "first": jsonld.first_id(contexts.AS.first), "last": jsonld.first_id(contexts.AS.last), "next": jsonld.first_id(contexts.AS.next), - "prev": jsonld.first_id(contexts.AS.next), + "prev": jsonld.first_id(contexts.AS.prev), "partOf": jsonld.first_id(contexts.AS.partOf), } @@ -731,6 +733,7 @@ MUSIC_ENTITY_JSONLD_MAPPING = { "name": jsonld.first_val(contexts.AS.name), "published": jsonld.first_val(contexts.AS.published), "musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId), + "attributedTo": jsonld.first_id(contexts.AS.attributedTo), } @@ -739,9 +742,29 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): published = serializers.DateTimeField() musicbrainzId = serializers.UUIDField(allow_null=True, required=False) name = serializers.CharField(max_length=1000) + attributedTo = serializers.URLField(max_length=500, allow_null=True, required=False) + updateable_fields = [] + + def update(self, instance, validated_data): + attributed_to_fid = validated_data.get("attributedTo") + if attributed_to_fid: + validated_data["attributedTo"] = actors.get_actor(attributed_to_fid) + updated_fields = funkwhale_utils.get_updated_fields( + self.updateable_fields, validated_data, instance + ) + if updated_fields: + return music_tasks.update_library_entity(instance, updated_fields) + + return instance class ArtistSerializer(MusicEntitySerializer): + updateable_fields = [ + ("name", "name"), + ("musicbrainzId", "mbid"), + ("attributedTo", "attributed_to"), + ] + class Meta: jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING @@ -752,6 +775,9 @@ class ArtistSerializer(MusicEntitySerializer): "name": instance.name, "published": instance.creation_date.isoformat(), "musicbrainzId": str(instance.mbid) if instance.mbid else None, + "attributedTo": instance.attributed_to.fid + if instance.attributed_to + else None, } if self.context.get("include_ap_context", self.parent is None): @@ -765,6 +791,12 @@ class AlbumSerializer(MusicEntitySerializer): cover = LinkSerializer( allowed_mimetypes=["image/*"], allow_null=True, required=False ) + updateable_fields = [ + ("name", "title"), + ("musicbrainzId", "mbid"), + ("attributedTo", "attributed_to"), + ("released", "release_date"), + ] class Meta: jsonld_mapping = funkwhale_utils.concat_dicts( @@ -791,6 +823,9 @@ class AlbumSerializer(MusicEntitySerializer): instance.artist, context={"include_ap_context": False} ).data ], + "attributedTo": instance.attributed_to.fid + if instance.attributed_to + else None, } if instance.cover: d["cover"] = { @@ -812,6 +847,16 @@ class TrackSerializer(MusicEntitySerializer): license = serializers.URLField(allow_null=True, required=False) copyright = serializers.CharField(allow_null=True, required=False) + updateable_fields = [ + ("name", "title"), + ("musicbrainzId", "mbid"), + ("attributedTo", "attributed_to"), + ("disc", "disc_number"), + ("position", "position"), + ("copyright", "copyright"), + ("license", "license"), + ] + class Meta: jsonld_mapping = funkwhale_utils.concat_dicts( MUSIC_ENTITY_JSONLD_MAPPING, @@ -846,6 +891,9 @@ class TrackSerializer(MusicEntitySerializer): "album": AlbumSerializer( instance.album, context={"include_ap_context": False} ).data, + "attributedTo": instance.attributed_to.fid + if instance.attributed_to + else None, } if self.context.get("include_ap_context", self.parent is None): @@ -855,13 +903,53 @@ class TrackSerializer(MusicEntitySerializer): def create(self, validated_data): from funkwhale_api.music import tasks as music_tasks - metadata = music_tasks.federation_audio_track_to_metadata(validated_data) + references = {} + actors_to_fetch = set() + actors_to_fetch.add( + funkwhale_utils.recursive_getattr( + validated_data, "attributedTo", permissive=True + ) + ) + actors_to_fetch.add( + funkwhale_utils.recursive_getattr( + validated_data, "album.attributedTo", permissive=True + ) + ) + artists = ( + funkwhale_utils.recursive_getattr( + validated_data, "artists", permissive=True + ) + or [] + ) + album_artists = ( + funkwhale_utils.recursive_getattr( + validated_data, "album.artists", permissive=True + ) + or [] + ) + for artist in artists + album_artists: + actors_to_fetch.add(artist.get("attributedTo")) + + for url in actors_to_fetch: + if not url: + continue + references[url] = actors.get_actor(url) + + metadata = music_tasks.federation_audio_track_to_metadata( + validated_data, references + ) + from_activity = self.context.get("activity") if from_activity: metadata["from_activity_id"] = from_activity.pk track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True) return track + def update(self, obj, validated_data): + if validated_data.get("license"): + validated_data["license"] = licenses.match(validated_data["license"]) + return super().update(obj, validated_data) + class UploadSerializer(jsonld.JsonLdSerializer): type = serializers.ChoiceField(choices=[contexts.AS.Audio]) diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index cd2a91ccb..2060ac13a 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -64,6 +64,12 @@ class ArtistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class Meta: model = "music.Artist" + class Params: + attributed = factory.Trait( + attributed_to=factory.SubFactory(federation_factories.ActorFactory) + ) + local = factory.Trait(fid=factory.Faker("federation_url", local=True)) + @registry.register class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): @@ -79,6 +85,15 @@ class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class Meta: model = "music.Album" + class Params: + attributed = factory.Trait( + attributed_to=factory.SubFactory(federation_factories.ActorFactory) + ) + + local = factory.Trait( + fid=factory.Faker("federation_url", local=True), artist__local=True + ) + @registry.register class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): @@ -94,6 +109,15 @@ class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class Meta: model = "music.Track" + class Params: + attributed = factory.Trait( + attributed_to=factory.SubFactory(federation_factories.ActorFactory) + ) + + local = factory.Trait( + fid=factory.Faker("federation_url", local=True), album__local=True + ) + @factory.post_generation def license(self, created, extracted, **kwargs): if not created: diff --git a/api/funkwhale_api/music/migrations/0038_attributed_to.py b/api/funkwhale_api/music/migrations/0038_attributed_to.py new file mode 100644 index 000000000..d1ac8cfd4 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0038_attributed_to.py @@ -0,0 +1,48 @@ +# Generated by Django 2.1.7 on 2019-04-09 09:33 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("federation", "0017_auto_20190130_0926"), + ("music", "0037_auto_20190103_1757"), + ] + + operations = [ + migrations.AddField( + model_name="artist", + name="attributed_to", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="attributed_artists", + to="federation.Actor", + ), + ), + migrations.AddField( + model_name="album", + name="attributed_to", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="attributed_albums", + to="federation.Actor", + ), + ), + migrations.AddField( + model_name="track", + name="attributed_to", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="attributed_tracks", + to="federation.Actor", + ), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index fb7af3adb..b88a8daea 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -114,6 +114,16 @@ class APIModelMixin(models.Model): return super().save(**kwargs) + @property + def is_local(self): + if not self.fid: + return True + + d = settings.FEDERATION_HOSTNAME + return self.fid.startswith("http://{}/".format(d)) or self.fid.startswith( + "https://{}/".format(d) + ) + class License(models.Model): code = models.CharField(primary_key=True, max_length=100) @@ -178,6 +188,16 @@ class Artist(APIModelMixin): "mbid": {"musicbrainz_field_name": "id"}, "name": {"musicbrainz_field_name": "name"}, } + # Music entities are attributed to actors, to validate that updates occur + # from an authorized account. On top of that, we consider the instance actor + # can update anything under it's own domain + attributed_to = models.ForeignKey( + "federation.Actor", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="attributed_artists", + ) api = musicbrainz.api.artists objects = ArtistQuerySet.as_manager() @@ -254,6 +274,16 @@ class Album(APIModelMixin): TYPE_CHOICES = (("album", "Album"),) type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album") + # Music entities are attributed to actors, to validate that updates occur + # from an authorized account. On top of that, we consider the instance actor + # can update anything under it's own domain + attributed_to = models.ForeignKey( + "federation.Actor", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="attributed_albums", + ) api_includes = ["artist-credits", "recordings", "media", "release-groups"] api = musicbrainz.api.releases federation_namespace = "albums" @@ -476,6 +506,16 @@ class Track(APIModelMixin): on_delete=models.DO_NOTHING, related_name="tracks", ) + # Music entities are attributed to actors, to validate that updates occur + # from an authorized account. On top of that, we consider the instance actor + # can update anything under it's own domain + attributed_to = models.ForeignKey( + "federation.Actor", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="attributed_tracks", + ) copyright = models.CharField(max_length=500, null=True, blank=True) federation_namespace = "tracks" musicbrainz_model = "recording" diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py index 4d78b8ea9..fdbb7c11c 100644 --- a/api/funkwhale_api/music/mutations.py +++ b/api/funkwhale_api/music/mutations.py @@ -1,14 +1,15 @@ from funkwhale_api.common import mutations +from funkwhale_api.federation import routes from . import models def can_suggest(obj, actor): - return True + return obj.is_local def can_approve(obj, actor): - return actor.user and actor.user.get_permissions()["library"] + return obj.is_local and actor.user and actor.user.get_permissions()["library"] @mutations.registry.connect( @@ -22,3 +23,8 @@ class TrackMutationSerializer(mutations.UpdateMutationSerializer): class Meta: model = models.Track fields = ["license", "title", "position", "copyright"] + + def post_apply(self, obj, validated_data): + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Track"}}, context={"track": obj} + ) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 8a8c4c5c2..ee79938f3 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -43,6 +43,7 @@ class ArtistAlbumSerializer(serializers.ModelSerializer): model = models.Album fields = ( "id", + "fid", "mbid", "title", "artist", @@ -51,6 +52,7 @@ class ArtistAlbumSerializer(serializers.ModelSerializer): "creation_date", "tracks_count", "is_playable", + "is_local", ) def get_tracks_count(self, o): @@ -68,13 +70,13 @@ class ArtistWithAlbumsSerializer(serializers.ModelSerializer): class Meta: model = models.Artist - fields = ("id", "mbid", "name", "creation_date", "albums") + fields = ("id", "fid", "mbid", "name", "creation_date", "albums", "is_local") class ArtistSimpleSerializer(serializers.ModelSerializer): class Meta: model = models.Artist - fields = ("id", "mbid", "name", "creation_date") + fields = ("id", "fid", "mbid", "name", "creation_date", "is_local") class AlbumTrackSerializer(serializers.ModelSerializer): @@ -87,6 +89,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer): model = models.Track fields = ( "id", + "fid", "mbid", "title", "album", @@ -99,6 +102,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer): "duration", "copyright", "license", + "is_local", ) def get_uploads(self, obj): @@ -125,6 +129,7 @@ class AlbumSerializer(serializers.ModelSerializer): model = models.Album fields = ( "id", + "fid", "mbid", "title", "artist", @@ -133,6 +138,7 @@ class AlbumSerializer(serializers.ModelSerializer): "cover", "creation_date", "is_playable", + "is_local", ) def get_tracks(self, o): @@ -156,12 +162,14 @@ class TrackAlbumSerializer(serializers.ModelSerializer): model = models.Album fields = ( "id", + "fid", "mbid", "title", "artist", "release_date", "cover", "creation_date", + "is_local", ) @@ -190,6 +198,7 @@ class TrackSerializer(serializers.ModelSerializer): model = models.Track fields = ( "id", + "fid", "mbid", "title", "album", @@ -202,6 +211,7 @@ class TrackSerializer(serializers.ModelSerializer): "listen_url", "copyright", "license", + "is_local", ) def get_lyrics(self, obj): diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 3f0d87e7a..8f3142629 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -206,7 +206,9 @@ def process_upload(upload): ) additional_data["upload_source"] = upload.source try: - track = get_track_from_import_metadata(final_metadata) + track = get_track_from_import_metadata( + final_metadata, attributed_to=upload.library.actor + ) except UploadImportError as e: return fail_import(upload, e.code) except Exception: @@ -282,7 +284,7 @@ def process_upload(upload): ) -def federation_audio_track_to_metadata(payload): +def federation_audio_track_to_metadata(payload, references): """ Given a valid payload as returned by federation.serializers.TrackSerializer.validated_data, returns a correct metadata payload for use with get_track_from_import_metadata. @@ -293,6 +295,7 @@ def federation_audio_track_to_metadata(payload): "disc_number": payload.get("disc"), "license": payload.get("license"), "copyright": payload.get("copyright"), + "attributed_to": references.get(payload.get("attributedTo")), "mbid": str(payload.get("musicbrainzId")) if payload.get("musicbrainzId") else None, @@ -300,6 +303,7 @@ def federation_audio_track_to_metadata(payload): "title": payload["album"]["name"], "fdate": payload["album"]["published"], "fid": payload["album"]["id"], + "attributed_to": references.get(payload["album"].get("attributedTo")), "mbid": str(payload["album"]["musicbrainzId"]) if payload["album"].get("musicbrainzId") else None, @@ -309,6 +313,7 @@ def federation_audio_track_to_metadata(payload): "fid": a["id"], "name": a["name"], "fdate": a["published"], + "attributed_to": references.get(a.get("attributedTo")), "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, } for a in payload["album"]["artists"] @@ -319,6 +324,7 @@ def federation_audio_track_to_metadata(payload): "fid": a["id"], "name": a["name"], "fdate": a["published"], + "attributed_to": references.get(a.get("attributedTo")), "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, } for a in payload["artists"] @@ -393,8 +399,8 @@ def sort_candidates(candidates, important_fields): @transaction.atomic -def get_track_from_import_metadata(data, update_cover=False): - track = _get_track(data) +def get_track_from_import_metadata(data, update_cover=False, attributed_to=None): + track = _get_track(data, attributed_to=attributed_to) if update_cover and track and not track.album.cover: update_album_cover( track.album, @@ -404,7 +410,7 @@ def get_track_from_import_metadata(data, update_cover=False): return track -def _get_track(data): +def _get_track(data, attributed_to=None): track_uuid = getter(data, "funkwhale", "track", "uuid") if track_uuid: @@ -458,6 +464,7 @@ def _get_track(data): "mbid": artist_mbid, "fid": artist_fid, "from_activity_id": from_activity_id, + "attributed_to": artist.get("attributed_to", attributed_to), } if artist.get("fdate"): defaults["creation_date"] = artist.get("fdate") @@ -484,6 +491,7 @@ def _get_track(data): "mbid": album_artist_mbid, "fid": album_artist_fid, "from_activity_id": from_activity_id, + "attributed_to": album_artist.get("attributed_to", attributed_to), } if album_artist.get("fdate"): defaults["creation_date"] = album_artist.get("fdate") @@ -511,6 +519,7 @@ def _get_track(data): "release_date": album.get("release_date"), "fid": album_fid, "from_activity_id": from_activity_id, + "attributed_to": album.get("attributed_to", attributed_to), } if album.get("fdate"): defaults["creation_date"] = album.get("fdate") @@ -536,6 +545,7 @@ def _get_track(data): "disc_number": data.get("disc_number"), "fid": track_fid, "from_activity_id": from_activity_id, + "attributed_to": data.get("attributed_to", attributed_to), "license": licenses.match(data.get("license"), data.get("copyright")), "copyright": data.get("copyright"), } @@ -613,3 +623,18 @@ def get_prunable_albums(): def get_prunable_artists(): return models.Artist.objects.filter(tracks__isnull=True, albums__isnull=True) + + +def update_library_entity(obj, data): + """ + Given an obj and some updated fields, will persist the changes on the obj + and also check if the entity need to be aliased with existing objs (i.e + if a mbid was added on the obj, and match another entity with the same mbid) + """ + for key, value in data.items(): + setattr(obj, key, value) + + # Todo: handle integrity error on unique fields (such as MBID) + obj.save(update_fields=list(data.keys())) + + return obj diff --git a/api/tests/common/test_decorators.py b/api/tests/common/test_decorators.py index 66e692585..f8fef7904 100644 --- a/api/tests/common/test_decorators.py +++ b/api/tests/common/test_decorators.py @@ -44,7 +44,7 @@ def test_mutations_route_create_success(factories, api_request, is_approved, moc 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") + track = factories["music.Track"](title="foo", local=True) view = V.as_view({"post": "mutations"}) request = api_request.post( diff --git a/api/tests/common/test_mutations.py b/api/tests/common/test_mutations.py index bb2a08500..3c0d869a1 100644 --- a/api/tests/common/test_mutations.py +++ b/api/tests/common/test_mutations.py @@ -10,7 +10,7 @@ def mutations_registry(): return mutations.Registry() -def test_apply_mutation(mutations_registry): +def test_apply_mutation(mutations_registry, db): class Obj: pass diff --git a/api/tests/common/test_utils.py b/api/tests/common/test_utils.py index c5c4a8e23..ea64ed9d2 100644 --- a/api/tests/common/test_utils.py +++ b/api/tests/common/test_utils.py @@ -1,3 +1,5 @@ +import pytest + from funkwhale_api.common import utils @@ -42,3 +44,44 @@ def test_update_prefix(factories): old = n.fid n.refresh_from_db() assert n.fid == old.replace("http://", "https://") + + +@pytest.mark.parametrize( + "conf, mock_args, data, expected", + [ + ( + ["field1", "field2"], + {"field1": "foo", "field2": "test"}, + {"field1": "bar"}, + {"field1": "bar"}, + ), + ( + ["field1", "field2"], + {"field1": "foo", "field2": "test"}, + {"field1": "foo"}, + {}, + ), + ( + ["field1", "field2"], + {"field1": "foo", "field2": "test"}, + {"field1": "foo", "field2": "test"}, + {}, + ), + ( + ["field1", "field2"], + {"field1": "foo", "field2": "test"}, + {"field1": "bar", "field2": "test1"}, + {"field1": "bar", "field2": "test1"}, + ), + ( + [("field1", "Hello"), ("field2", "World")], + {"Hello": "foo", "World": "test"}, + {"field1": "bar", "field2": "test1"}, + {"Hello": "bar", "World": "test1"}, + ), + ], +) +def test_get_updated_fields(conf, mock_args, data, expected, mocker): + obj = mocker.Mock(**mock_args) + + assert utils.get_updated_fields(conf, data, obj) == expected diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index c69ac5d74..aaeebbb87 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -436,6 +436,53 @@ def test_prepare_deliveries_and_inbox_items(factories): assert inbox_item.type == "to" +def test_prepare_deliveries_and_inbox_items_instances_with_followers(factories): + + domain1 = factories["federation.Domain"](with_service_actor=True) + domain2 = factories["federation.Domain"](with_service_actor=True) + library = factories["music.Library"](actor__local=True) + + factories["federation.LibraryFollow"]( + target=library, actor__local=True, approved=True + ).actor + library_follower_remote = factories["federation.LibraryFollow"]( + target=library, actor__domain=domain1, approved=True + ).actor + + followed_actor = factories["federation.Actor"](local=True) + factories["federation.Follow"]( + target=followed_actor, actor__local=True, approved=True + ).actor + actor_follower_remote = factories["federation.Follow"]( + target=followed_actor, actor__domain=domain2, approved=True + ).actor + + recipients = [activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}] + + inbox_items, deliveries, urls = activity.prepare_deliveries_and_inbox_items( + recipients, "to" + ) + + expected_deliveries = sorted( + [ + models.Delivery( + inbox_url=library_follower_remote.domain.service_actor.inbox_url + ), + models.Delivery( + inbox_url=actor_follower_remote.domain.service_actor.inbox_url + ), + ], + key=lambda v: v.inbox_url, + ) + assert inbox_items == [] + assert len(expected_deliveries) == len(deliveries) + + for delivery, expected_delivery in zip( + sorted(deliveries, key=lambda v: v.inbox_url), expected_deliveries + ): + assert delivery.inbox_url == expected_delivery.inbox_url + + def test_should_rotate_actor_key(settings, cache, now): actor_id = 42 settings.ACTOR_KEY_ROTATION_DELAY = 10 diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index 6eeebd660..68a457142 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -134,3 +134,33 @@ def test_actor_stats(factories): actor = factories["federation.Actor"]() assert actor.get_stats() == expected + + +def test_actor_can_manage_false(mocker, factories): + obj = mocker.Mock() + actor = factories["federation.Actor"]() + + assert actor.can_manage(obj) is False + + +def test_actor_can_manage_attributed_to(mocker, factories): + actor = factories["federation.Actor"]() + obj = mocker.Mock(attributed_to_id=actor.pk) + + assert actor.can_manage(obj) is True + + +def test_actor_can_manage_domain_not_service_actor(mocker, factories): + actor = factories["federation.Actor"]() + obj = mocker.Mock(fid="https://{}/hello".format(actor.domain_id)) + + assert actor.can_manage(obj) is False + + +def test_actor_can_manage_domain_service_actor(mocker, factories): + actor = factories["federation.Actor"]() + actor.domain.service_actor = actor + actor.domain.save() + obj = mocker.Mock(fid="https://{}/hello".format(actor.domain_id)) + + assert actor.can_manage(obj) is True diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 438f45c22..10b580829 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -1,6 +1,6 @@ import pytest -from funkwhale_api.federation import jsonld, routes, serializers +from funkwhale_api.federation import actors, contexts, jsonld, routes, serializers @pytest.mark.parametrize( @@ -13,6 +13,9 @@ from funkwhale_api.federation import jsonld, routes, serializers ({"type": "Delete", "object.type": "Library"}, routes.inbox_delete_library), ({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio), ({"type": "Undo", "object.type": "Follow"}, routes.inbox_undo_follow), + ({"type": "Update", "object.type": "Artist"}, routes.inbox_update_artist), + ({"type": "Update", "object.type": "Album"}, routes.inbox_update_album), + ({"type": "Update", "object.type": "Track"}, routes.inbox_update_track), ], ) def test_inbox_routes(route, handler): @@ -34,6 +37,7 @@ def test_inbox_routes(route, handler): ({"type": "Delete", "object.type": "Library"}, routes.outbox_delete_library), ({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio), ({"type": "Undo", "object.type": "Follow"}, routes.outbox_undo_follow), + ({"type": "Update", "object.type": "Track"}, routes.outbox_update_track), ], ) def test_outbox_routes(route, handler): @@ -405,3 +409,89 @@ def test_outbox_delete_follow_library(factories): assert activity["actor"] == follow.actor assert activity["object"] == follow assert activity["related_object"] == follow.target + + +def test_handle_library_entry_update_can_manage(factories, mocker): + update_library_entity = mocker.patch( + "funkwhale_api.music.tasks.update_library_entity" + ) + activity = factories["federation.Activity"]() + obj = factories["music.Artist"]() + actor = factories["federation.Actor"]() + mocker.patch.object(actor, "can_manage", return_value=False) + data = serializers.ArtistSerializer(obj).data + data["name"] = "New name" + payload = {"type": "Update", "actor": actor, "object": data} + + routes.inbox_update_artist( + payload, context={"actor": actor, "raise_exception": True, "activity": activity} + ) + + update_library_entity.assert_not_called() + + +def test_inbox_update_artist(factories, mocker): + update_library_entity = mocker.patch( + "funkwhale_api.music.tasks.update_library_entity" + ) + activity = factories["federation.Activity"]() + obj = factories["music.Artist"](attributed=True) + actor = obj.attributed_to + data = serializers.ArtistSerializer(obj).data + data["name"] = "New name" + payload = {"type": "Update", "actor": actor, "object": data} + + routes.inbox_update_artist( + payload, context={"actor": actor, "raise_exception": True, "activity": activity} + ) + + update_library_entity.assert_called_once_with(obj, {"name": "New name"}) + + +def test_inbox_update_album(factories, mocker): + update_library_entity = mocker.patch( + "funkwhale_api.music.tasks.update_library_entity" + ) + activity = factories["federation.Activity"]() + obj = factories["music.Album"](attributed=True) + actor = obj.attributed_to + data = serializers.AlbumSerializer(obj).data + data["name"] = "New title" + payload = {"type": "Update", "actor": actor, "object": data} + + routes.inbox_update_album( + payload, context={"actor": actor, "raise_exception": True, "activity": activity} + ) + + update_library_entity.assert_called_once_with(obj, {"title": "New title"}) + + +def test_inbox_update_track(factories, mocker): + update_library_entity = mocker.patch( + "funkwhale_api.music.tasks.update_library_entity" + ) + activity = factories["federation.Activity"]() + obj = factories["music.Track"](attributed=True) + actor = obj.attributed_to + data = serializers.TrackSerializer(obj).data + data["name"] = "New title" + payload = {"type": "Update", "actor": actor, "object": data} + + routes.inbox_update_track( + payload, context={"actor": actor, "raise_exception": True, "activity": activity} + ) + + update_library_entity.assert_called_once_with(obj, {"title": "New title"}) + + +def test_outbox_update_track(factories): + track = factories["music.Track"]() + activity = list(routes.outbox_update_track({"track": track}))[0] + expected = serializers.ActivitySerializer( + {"type": "Update", "object": serializers.TrackSerializer(track).data} + ).data + + expected["to"] = [contexts.AS.Public, {"type": "instances_with_followers"}] + + assert dict(activity["payload"]) == dict(expected) + assert activity["actor"] == actors.get_service_actor() diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index bde9128cb..6872947f7 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -1,13 +1,23 @@ +import io +import pytest +import uuid + +from django.core.paginator import Paginator +from django.utils import timezone + from funkwhale_api.federation import keys from funkwhale_api.federation import jsonld +from funkwhale_api.federation import models from funkwhale_api.federation import serializers +from funkwhale_api.federation import utils +from funkwhale_api.music import licenses def test_actor_serializer_from_ap(db): private, public = keys.get_key_pair() actor_url = "https://test.federation/actor" payload = { - "@context": jsonld.get_default_context(), + "@context": jsonld.get_default_context_fw(), "id": actor_url, "type": "Person", "outbox": "https://test.com/outbox", @@ -47,3 +57,864 @@ def test_actor_serializer_from_ap(db): assert actor.private_key is None assert actor.public_key == payload["publicKey"]["publicKeyPem"] assert actor.domain_id == "test.federation" + + +def test_actor_serializer_only_mandatory_field_from_ap(db): + payload = { + "@context": jsonld.get_default_context(), + "id": "https://test.federation/user", + "type": "Person", + "following": "https://test.federation/user/following", + "followers": "https://test.federation/user/followers", + "inbox": "https://test.federation/user/inbox", + "outbox": "https://test.federation/user/outbox", + "preferredUsername": "user", + } + + serializer = serializers.ActorSerializer(data=payload) + assert serializer.is_valid(raise_exception=True) + + actor = serializer.build() + + assert actor.fid == payload["id"] + assert actor.inbox_url == payload["inbox"] + assert actor.outbox_url == payload["outbox"] + assert actor.followers_url == payload["followers"] + assert actor.following_url == payload["following"] + assert actor.preferred_username == payload["preferredUsername"] + assert actor.domain.pk == "test.federation" + assert actor.type == "Person" + assert actor.manually_approves_followers is None + + +def test_actor_serializer_to_ap(): + expected = { + "@context": jsonld.get_default_context(), + "id": "https://test.federation/user", + "type": "Person", + "following": "https://test.federation/user/following", + "followers": "https://test.federation/user/followers", + "inbox": "https://test.federation/user/inbox", + "outbox": "https://test.federation/user/outbox", + "preferredUsername": "user", + "name": "Real User", + "summary": "Hello world", + "manuallyApprovesFollowers": False, + "publicKey": { + "id": "https://test.federation/user#main-key", + "owner": "https://test.federation/user", + "publicKeyPem": "yolo", + }, + "endpoints": {"sharedInbox": "https://test.federation/inbox"}, + } + ac = models.Actor( + fid=expected["id"], + inbox_url=expected["inbox"], + outbox_url=expected["outbox"], + shared_inbox_url=expected["endpoints"]["sharedInbox"], + followers_url=expected["followers"], + following_url=expected["following"], + public_key=expected["publicKey"]["publicKeyPem"], + preferred_username=expected["preferredUsername"], + name=expected["name"], + domain=models.Domain(pk="test.federation"), + summary=expected["summary"], + type="Person", + manually_approves_followers=False, + ) + serializer = serializers.ActorSerializer(ac) + + assert serializer.data == expected + + +def test_webfinger_serializer(): + expected = { + "subject": "acct:service@test.federation", + "links": [ + { + "rel": "self", + "href": "https://test.federation/federation/instance/actor", + "type": "application/activity+json", + } + ], + "aliases": ["https://test.federation/federation/instance/actor"], + } + actor = models.Actor( + fid=expected["links"][0]["href"], + preferred_username="service", + domain=models.Domain(pk="test.federation"), + ) + serializer = serializers.ActorWebfingerSerializer(actor) + + assert serializer.data == expected + + +def test_follow_serializer_to_ap(factories): + follow = factories["federation.Follow"](local=True) + serializer = serializers.FollowSerializer(follow) + + expected = { + "@context": jsonld.get_default_context(), + "id": follow.get_federation_id(), + "type": "Follow", + "actor": follow.actor.fid, + "object": follow.target.fid, + } + + assert serializer.data == expected + + +def test_follow_serializer_save(factories): + actor = factories["federation.Actor"]() + target = factories["federation.Actor"]() + + data = { + "id": "https://test.follow", + "type": "Follow", + "actor": actor.fid, + "object": target.fid, + } + serializer = serializers.FollowSerializer(data=data) + + assert serializer.is_valid(raise_exception=True) + + follow = serializer.save() + + assert follow.pk is not None + assert follow.actor == actor + assert follow.target == target + assert follow.approved is None + + +def test_follow_serializer_save_validates_on_context(factories): + actor = factories["federation.Actor"]() + target = factories["federation.Actor"]() + impostor = factories["federation.Actor"]() + + data = { + "id": "https://test.follow", + "type": "Follow", + "actor": actor.fid, + "object": target.fid, + } + serializer = serializers.FollowSerializer( + data=data, context={"follow_actor": impostor, "follow_target": impostor} + ) + + assert serializer.is_valid() is False + + assert "actor" in serializer.errors + assert "object" in serializer.errors + + +def test_accept_follow_serializer_representation(factories): + follow = factories["federation.Follow"](approved=None) + + expected = { + "@context": jsonld.get_default_context(), + "id": follow.get_federation_id() + "/accept", + "type": "Accept", + "actor": follow.target.fid, + "object": serializers.FollowSerializer(follow).data, + } + + serializer = serializers.AcceptFollowSerializer(follow) + + assert serializer.data == expected + + +def test_accept_follow_serializer_save(factories): + follow = factories["federation.Follow"](approved=None) + + data = { + "@context": jsonld.get_default_context_fw(), + "id": follow.get_federation_id() + "/accept", + "type": "Accept", + "actor": follow.target.fid, + "object": serializers.FollowSerializer(follow).data, + } + + serializer = serializers.AcceptFollowSerializer(data=data) + assert serializer.is_valid(raise_exception=True) + serializer.save() + + follow.refresh_from_db() + + assert follow.approved is True + + +def test_accept_follow_serializer_validates_on_context(factories): + follow = factories["federation.Follow"](approved=None) + impostor = factories["federation.Actor"]() + data = { + "@context": jsonld.get_default_context_fw(), + "id": follow.get_federation_id() + "/accept", + "type": "Accept", + "actor": impostor.url, + "object": serializers.FollowSerializer(follow).data, + } + + serializer = serializers.AcceptFollowSerializer( + data=data, context={"follow_actor": impostor, "follow_target": impostor} + ) + + assert serializer.is_valid() is False + assert "actor" in serializer.errors["object"] + assert "object" in serializer.errors["object"] + + +def test_undo_follow_serializer_representation(factories): + follow = factories["federation.Follow"](approved=True) + + expected = { + "@context": jsonld.get_default_context(), + "id": follow.get_federation_id() + "/undo", + "type": "Undo", + "actor": follow.actor.fid, + "object": serializers.FollowSerializer(follow).data, + } + + serializer = serializers.UndoFollowSerializer(follow) + + assert serializer.data == expected + + +def test_undo_follow_serializer_save(factories): + follow = factories["federation.Follow"](approved=True) + + data = { + "@context": jsonld.get_default_context_fw(), + "id": follow.get_federation_id() + "/undo", + "type": "Undo", + "actor": follow.actor.fid, + "object": serializers.FollowSerializer(follow).data, + } + + serializer = serializers.UndoFollowSerializer(data=data) + assert serializer.is_valid(raise_exception=True) + serializer.save() + + with pytest.raises(models.Follow.DoesNotExist): + follow.refresh_from_db() + + +def test_undo_follow_serializer_validates_on_context(factories): + follow = factories["federation.Follow"](approved=True) + impostor = factories["federation.Actor"]() + data = { + "@context": jsonld.get_default_context_fw(), + "id": follow.get_federation_id() + "/undo", + "type": "Undo", + "actor": impostor.url, + "object": serializers.FollowSerializer(follow).data, + } + + serializer = serializers.UndoFollowSerializer( + data=data, context={"follow_actor": impostor, "follow_target": impostor} + ) + + assert serializer.is_valid() is False + assert "actor" in serializer.errors["object"] + assert "object" in serializer.errors["object"] + + +def test_paginated_collection_serializer(factories): + uploads = factories["music.Upload"].create_batch(size=5) + actor = factories["federation.Actor"](local=True) + + conf = { + "id": "https://test.federation/test", + "items": uploads, + "item_serializer": serializers.UploadSerializer, + "actor": actor, + "page_size": 2, + } + expected = { + "@context": jsonld.get_default_context(), + "type": "Collection", + "id": conf["id"], + "actor": actor.fid, + "totalItems": len(uploads), + "current": conf["id"] + "?page=1", + "last": conf["id"] + "?page=3", + "first": conf["id"] + "?page=1", + } + + serializer = serializers.PaginatedCollectionSerializer(conf) + + assert serializer.data == expected + + +def test_paginated_collection_serializer_validation(): + data = { + "@context": jsonld.get_default_context_fw(), + "type": "Collection", + "id": "https://test.federation/test", + "totalItems": 5, + "actor": "http://test.actor", + "first": "https://test.federation/test?page=1", + "last": "https://test.federation/test?page=1", + "items": [], + } + + serializer = serializers.PaginatedCollectionSerializer(data=data) + + assert serializer.is_valid(raise_exception=True) is True + assert serializer.validated_data["totalItems"] == 5 + assert serializer.validated_data["id"] == data["id"] + assert serializer.validated_data["actor"] == data["actor"] + + +def test_collection_page_serializer_validation(): + base = "https://test.federation/test" + data = { + "@context": jsonld.get_default_context(), + "type": "CollectionPage", + "id": base + "?page=2", + "totalItems": 5, + "actor": "https://test.actor", + "items": [], + "first": "https://test.federation/test?page=1", + "last": "https://test.federation/test?page=3", + "prev": base + "?page=1", + "next": base + "?page=3", + "partOf": base, + } + + serializer = serializers.CollectionPageSerializer(data=data) + + assert serializer.is_valid(raise_exception=True) is True + assert serializer.validated_data["totalItems"] == 5 + assert serializer.validated_data["id"] == data["id"] + assert serializer.validated_data["actor"] == data["actor"] + assert serializer.validated_data["items"] == [] + assert serializer.validated_data["prev"] == data["prev"] + assert serializer.validated_data["next"] == data["next"] + assert serializer.validated_data["partOf"] == data["partOf"] + + +def test_collection_page_serializer_can_validate_child(): + data = { + "@context": jsonld.get_default_context(), + "type": "CollectionPage", + "id": "https://test.page?page=2", + "actor": "https://test.actor", + "first": "https://test.page?page=1", + "last": "https://test.page?page=3", + "partOf": "https://test.page", + "totalItems": 1, + "items": [{"in": "valid"}], + } + + serializer = serializers.CollectionPageSerializer( + data=data, context={"item_serializer": serializers.UploadSerializer} + ) + + # child are validated but not included in data if not valid + assert serializer.is_valid(raise_exception=True) is True + assert len(serializer.validated_data["items"]) == 0 + + +def test_collection_page_serializer(factories): + uploads = factories["music.Upload"].create_batch(size=5) + actor = factories["federation.Actor"](local=True) + + conf = { + "id": "https://test.federation/test", + "item_serializer": serializers.UploadSerializer, + "actor": actor, + "page": Paginator(uploads, 2).page(2), + } + expected = { + "@context": jsonld.get_default_context(), + "type": "CollectionPage", + "id": conf["id"] + "?page=2", + "actor": actor.fid, + "totalItems": len(uploads), + "partOf": conf["id"], + "prev": conf["id"] + "?page=1", + "next": conf["id"] + "?page=3", + "first": conf["id"] + "?page=1", + "last": conf["id"] + "?page=3", + "items": [ + conf["item_serializer"]( + i, context={"actor": actor, "include_ap_context": False} + ).data + for i in conf["page"].object_list + ], + } + + serializer = serializers.CollectionPageSerializer(conf) + + assert serializer.data == expected + + +def test_music_library_serializer_to_ap(factories): + library = factories["music.Library"](privacy_level="everyone") + # pending, errored and skippednot included + factories["music.Upload"](import_status="pending") + factories["music.Upload"](import_status="errored") + factories["music.Upload"](import_status="finished") + serializer = serializers.LibrarySerializer(library) + expected = { + "@context": jsonld.get_default_context(), + "audience": "https://www.w3.org/ns/activitystreams#Public", + "type": "Library", + "id": library.fid, + "name": library.name, + "summary": library.description, + "actor": library.actor.fid, + "totalItems": 0, + "current": library.fid + "?page=1", + "last": library.fid + "?page=1", + "first": library.fid + "?page=1", + "followers": library.followers_url, + } + + assert serializer.data == expected + + +def test_music_library_serializer_from_public(factories, mocker): + actor = factories["federation.Actor"]() + retrieve = mocker.patch( + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor + ) + data = { + "@context": jsonld.get_default_context(), + "audience": "https://www.w3.org/ns/activitystreams#Public", + "name": "Hello", + "summary": "World", + "type": "Library", + "id": "https://library.id", + "followers": "https://library.id/followers", + "actor": actor.fid, + "totalItems": 12, + "first": "https://library.id?page=1", + "last": "https://library.id?page=2", + } + serializer = serializers.LibrarySerializer(data=data) + + assert serializer.is_valid(raise_exception=True) + + library = serializer.save() + + assert library.actor == actor + assert library.fid == data["id"] + assert library.uploads_count == data["totalItems"] + assert library.privacy_level == "everyone" + assert library.name == "Hello" + assert library.description == "World" + assert library.followers_url == data["followers"] + + retrieve.assert_called_once_with( + actor.fid, + actor=None, + queryset=actor.__class__, + serializer_class=serializers.ActorSerializer, + ) + + +def test_music_library_serializer_from_private(factories, mocker): + actor = factories["federation.Actor"]() + retrieve = mocker.patch( + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor + ) + data = { + "@context": jsonld.get_default_context_fw(), + "audience": "", + "name": "Hello", + "summary": "World", + "type": "Library", + "id": "https://library.id", + "followers": "https://library.id/followers", + "actor": actor.fid, + "totalItems": 12, + "first": "https://library.id?page=1", + "last": "https://library.id?page=2", + } + serializer = serializers.LibrarySerializer(data=data) + + assert serializer.is_valid(raise_exception=True) + + library = serializer.save() + + assert library.actor == actor + assert library.fid == data["id"] + assert library.uploads_count == data["totalItems"] + assert library.privacy_level == "me" + assert library.name == "Hello" + assert library.description == "World" + assert library.followers_url == data["followers"] + retrieve.assert_called_once_with( + actor.fid, + actor=None, + queryset=actor.__class__, + serializer_class=serializers.ActorSerializer, + ) + + +def test_activity_pub_artist_serializer_to_ap(factories): + artist = factories["music.Artist"](attributed=True) + expected = { + "@context": serializers.AP_CONTEXT, + "type": "Artist", + "id": artist.fid, + "name": artist.name, + "musicbrainzId": artist.mbid, + "published": artist.creation_date.isoformat(), + "attributedTo": artist.attributed_to.fid, + } + serializer = serializers.ArtistSerializer(artist) + + assert serializer.data == expected + + +def test_activity_pub_album_serializer_to_ap(factories): + album = factories["music.Album"](attributed=True) + + expected = { + "@context": serializers.AP_CONTEXT, + "type": "Album", + "id": album.fid, + "name": album.title, + "cover": { + "type": "Link", + "mediaType": "image/jpeg", + "href": utils.full_url(album.cover.url), + }, + "musicbrainzId": album.mbid, + "published": album.creation_date.isoformat(), + "released": album.release_date.isoformat(), + "artists": [ + serializers.ArtistSerializer( + album.artist, context={"include_ap_context": False} + ).data + ], + "attributedTo": album.attributed_to.fid, + } + serializer = serializers.AlbumSerializer(album) + + assert serializer.data == expected + + +def test_activity_pub_track_serializer_to_ap(factories): + track = factories["music.Track"]( + license="cc-by-4.0", copyright="test", disc_number=3, attributed=True + ) + expected = { + "@context": serializers.AP_CONTEXT, + "published": track.creation_date.isoformat(), + "type": "Track", + "musicbrainzId": track.mbid, + "id": track.fid, + "name": track.title, + "position": track.position, + "disc": track.disc_number, + "license": track.license.conf["identifiers"][0], + "copyright": "test", + "artists": [ + serializers.ArtistSerializer( + track.artist, context={"include_ap_context": False} + ).data + ], + "album": serializers.AlbumSerializer( + track.album, context={"include_ap_context": False} + ).data, + "attributedTo": track.attributed_to.fid, + } + serializer = serializers.TrackSerializer(track) + + assert serializer.data == expected + + +def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): + track_attributed_to = factories["federation.Actor"]() + album_attributed_to = factories["federation.Actor"]() + album_artist_attributed_to = factories["federation.Actor"]() + artist_attributed_to = factories["federation.Actor"]() + + activity = factories["federation.Activity"]() + published = timezone.now() + released = timezone.now().date() + data = { + "@context": jsonld.get_default_context(), + "type": "Track", + "id": "http://hello.track", + "published": published.isoformat(), + "musicbrainzId": str(uuid.uuid4()), + "name": "Black in back", + "position": 5, + "disc": 1, + "attributedTo": track_attributed_to.fid, + "album": { + "type": "Album", + "id": "http://hello.album", + "name": "Purple album", + "musicbrainzId": str(uuid.uuid4()), + "published": published.isoformat(), + "released": released.isoformat(), + "attributedTo": album_attributed_to.fid, + "cover": { + "type": "Link", + "href": "https://cover.image/test.png", + "mediaType": "image/png", + }, + "artists": [ + { + "type": "Artist", + "id": "http://hello.artist", + "name": "John Smith", + "musicbrainzId": str(uuid.uuid4()), + "published": published.isoformat(), + "attributedTo": album_artist_attributed_to.fid, + } + ], + }, + "artists": [ + { + "type": "Artist", + "id": "http://hello.trackartist", + "name": "Bob Smith", + "musicbrainzId": str(uuid.uuid4()), + "attributedTo": artist_attributed_to.fid, + "published": published.isoformat(), + } + ], + } + r_mock.get(data["album"]["cover"]["href"], body=io.BytesIO(b"coucou")) + serializer = serializers.TrackSerializer(data=data, context={"activity": activity}) + assert serializer.is_valid(raise_exception=True) + + track = serializer.save() + album = track.album + artist = track.artist + album_artist = track.album.artist + + assert track.from_activity == activity + assert track.fid == data["id"] + assert track.title == data["name"] + assert track.position == data["position"] + assert track.disc_number == data["disc"] + assert track.creation_date == published + assert track.attributed_to == track_attributed_to + assert str(track.mbid) == data["musicbrainzId"] + + assert album.from_activity == activity + assert album.cover.read() == b"coucou" + assert album.cover.path.endswith(".png") + assert album.title == data["album"]["name"] + assert album.fid == data["album"]["id"] + assert str(album.mbid) == data["album"]["musicbrainzId"] + assert album.creation_date == published + assert album.release_date == released + assert album.attributed_to == album_attributed_to + + assert artist.from_activity == activity + assert artist.name == data["artists"][0]["name"] + assert artist.fid == data["artists"][0]["id"] + assert str(artist.mbid) == data["artists"][0]["musicbrainzId"] + assert artist.creation_date == published + assert artist.attributed_to == artist_attributed_to + + assert album_artist.from_activity == activity + assert album_artist.name == data["album"]["artists"][0]["name"] + assert album_artist.fid == data["album"]["artists"][0]["id"] + assert str(album_artist.mbid) == data["album"]["artists"][0]["musicbrainzId"] + assert album_artist.creation_date == published + assert album_artist.attributed_to == album_artist_attributed_to + + +def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock): + activity = factories["federation.Activity"]() + library = factories["music.Library"]() + + published = timezone.now() + updated = timezone.now() + released = timezone.now().date() + data = { + "@context": serializers.AP_CONTEXT, + "type": "Audio", + "id": "https://track.file", + "name": "Ignored", + "published": published.isoformat(), + "updated": updated.isoformat(), + "duration": 43, + "bitrate": 42, + "size": 66, + "url": {"href": "https://audio.file", "type": "Link", "mediaType": "audio/mp3"}, + "library": library.fid, + "track": { + "type": "Track", + "id": "http://hello.track", + "published": published.isoformat(), + "musicbrainzId": str(uuid.uuid4()), + "name": "Black in back", + "position": 5, + "album": { + "type": "Album", + "id": "http://hello.album", + "name": "Purple album", + "musicbrainzId": str(uuid.uuid4()), + "published": published.isoformat(), + "released": released.isoformat(), + "cover": { + "type": "Link", + "href": "https://cover.image/test.png", + "mediaType": "image/png", + }, + "artists": [ + { + "type": "Artist", + "id": "http://hello.artist", + "name": "John Smith", + "musicbrainzId": str(uuid.uuid4()), + "published": published.isoformat(), + } + ], + }, + "artists": [ + { + "type": "Artist", + "id": "http://hello.trackartist", + "name": "Bob Smith", + "musicbrainzId": str(uuid.uuid4()), + "published": published.isoformat(), + } + ], + }, + } + r_mock.get(data["track"]["album"]["cover"]["href"], body=io.BytesIO(b"coucou")) + + serializer = serializers.UploadSerializer(data=data, context={"activity": activity}) + assert serializer.is_valid(raise_exception=True) + track_create = mocker.spy(serializers.TrackSerializer, "create") + upload = serializer.save() + + assert upload.track.from_activity == activity + assert upload.from_activity == activity + assert track_create.call_count == 1 + assert upload.fid == data["id"] + assert upload.track.fid == data["track"]["id"] + assert upload.duration == data["duration"] + assert upload.size == data["size"] + assert upload.bitrate == data["bitrate"] + assert upload.source == data["url"]["href"] + assert upload.mimetype == data["url"]["mediaType"] + assert upload.creation_date == published + assert upload.import_status == "finished" + assert upload.modification_date == updated + + +def test_activity_pub_upload_serializer_validtes_library_actor(factories, mocker): + library = factories["music.Library"]() + usurpator = factories["federation.Actor"]() + + serializer = serializers.UploadSerializer(data={}, context={"actor": usurpator}) + + with pytest.raises(serializers.serializers.ValidationError): + serializer.validate_library(library.fid) + + +def test_activity_pub_audio_serializer_to_ap(factories): + upload = factories["music.Upload"]( + mimetype="audio/mp3", bitrate=42, duration=43, size=44 + ) + expected = { + "@context": serializers.AP_CONTEXT, + "type": "Audio", + "id": upload.fid, + "name": upload.track.full_name, + "published": upload.creation_date.isoformat(), + "updated": upload.modification_date.isoformat(), + "duration": upload.duration, + "bitrate": upload.bitrate, + "size": upload.size, + "url": { + "href": utils.full_url(upload.listen_url), + "type": "Link", + "mediaType": "audio/mp3", + }, + "library": upload.library.fid, + "track": serializers.TrackSerializer( + upload.track, context={"include_ap_context": False} + ).data, + } + + serializer = serializers.UploadSerializer(upload) + + assert serializer.data == expected + + +def test_local_actor_serializer_to_ap(factories): + expected = { + "@context": jsonld.get_default_context(), + "id": "https://test.federation/user", + "type": "Person", + "following": "https://test.federation/user/following", + "followers": "https://test.federation/user/followers", + "inbox": "https://test.federation/user/inbox", + "outbox": "https://test.federation/user/outbox", + "preferredUsername": "user", + "name": "Real User", + "summary": "Hello world", + "manuallyApprovesFollowers": False, + "publicKey": { + "id": "https://test.federation/user#main-key", + "owner": "https://test.federation/user", + "publicKeyPem": "yolo", + }, + "endpoints": {"sharedInbox": "https://test.federation/inbox"}, + } + ac = models.Actor.objects.create( + fid=expected["id"], + inbox_url=expected["inbox"], + outbox_url=expected["outbox"], + shared_inbox_url=expected["endpoints"]["sharedInbox"], + followers_url=expected["followers"], + following_url=expected["following"], + public_key=expected["publicKey"]["publicKeyPem"], + preferred_username=expected["preferredUsername"], + name=expected["name"], + domain=models.Domain.objects.create(pk="test.federation"), + summary=expected["summary"], + type="Person", + manually_approves_followers=False, + ) + user = factories["users.User"]() + user.actor = ac + user.save() + ac.refresh_from_db() + expected["icon"] = { + "type": "Image", + "mediaType": "image/jpeg", + "url": utils.full_url(user.avatar.crop["400x400"].url), + } + serializer = serializers.ActorSerializer(ac) + + assert serializer.data == expected + + +def test_activity_serializer_validate_recipients_empty(db): + s = serializers.BaseActivitySerializer() + + with pytest.raises(serializers.serializers.ValidationError): + s.validate_recipients({}) + + with pytest.raises(serializers.serializers.ValidationError): + s.validate_recipients({"to": []}) + + with pytest.raises(serializers.serializers.ValidationError): + s.validate_recipients({"cc": []}) + + +def test_track_serializer_update_license(factories): + licenses.load(licenses.LICENSES) + + obj = factories["music.Track"](license=None) + + serializer = serializers.TrackSerializer() + serializer.update(obj, {"license": "http://creativecommons.org/licenses/by/2.0/"}) + + obj.refresh_from_db() + + assert obj.license_id == "cc-by-2.0" diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index cc73e85a3..4446de7dd 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -533,3 +533,18 @@ def test_queryset_local_entities(factories, settings, factory): factories[factory](fid="https://noope/3") assert list(obj1.__class__.objects.local().order_by("id")) == [obj1, obj2] + + +@pytest.mark.parametrize( + "federation_hostname, fid, expected", + [ + ("test.domain", "http://test.domain/", True), + ("test.domain", None, True), + ("test.domain", "https://test.domain/", True), + ("test.otherdomain", "http://test.domain/", False), + ], +) +def test_api_model_mixin_is_local(federation_hostname, fid, expected, settings): + settings.FEDERATION_HOSTNAME = federation_hostname + obj = models.Track(fid=fid) + assert obj.is_local is expected diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py index bc9e81f8e..a8a529798 100644 --- a/api/tests/music/test_mutations.py +++ b/api/tests/music/test_mutations.py @@ -56,3 +56,16 @@ def test_track_position_mutation(factories): track.refresh_from_db() assert track.position == 12 + + +def test_track_mutation_apply_outbox(factories, mocker): + dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + track = factories["music.Track"](position=4) + mutation = factories["common.Mutation"]( + type="update", target=track, payload={"position": 12} + ) + mutation.apply() + + dispatch.assert_called_once_with( + {"type": "Update", "object": {"type": "Track"}}, context={"track": track} + ) diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 155f99890..e01b54451 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -34,6 +34,7 @@ def test_artist_album_serializer(factories, to_api_date): album = album.__class__.objects.with_tracks_count().get(pk=album.pk) expected = { "id": album.id, + "fid": album.fid, "mbid": str(album.mbid), "title": album.title, "artist": album.artist.id, @@ -47,6 +48,7 @@ def test_artist_album_serializer(factories, to_api_date): "small_square_crop": album.cover.crop["50x50"].url, }, "release_date": to_api_date(album.release_date), + "is_local": album.is_local, } serializer = serializers.ArtistAlbumSerializer(album) @@ -61,8 +63,10 @@ def test_artist_with_albums_serializer(factories, to_api_date): expected = { "id": artist.id, + "fid": artist.fid, "mbid": str(artist.mbid), "name": artist.name, + "is_local": artist.is_local, "creation_date": to_api_date(artist.creation_date), "albums": [serializers.ArtistAlbumSerializer(album).data], } @@ -79,6 +83,7 @@ def test_album_track_serializer(factories, to_api_date): expected = { "id": track.id, + "fid": track.fid, "artist": serializers.ArtistSimpleSerializer(track.artist).data, "album": track.album.id, "mbid": str(track.mbid), @@ -91,6 +96,7 @@ def test_album_track_serializer(factories, to_api_date): "duration": None, "license": track.license.code, "copyright": track.copyright, + "is_local": track.is_local, } serializer = serializers.AlbumTrackSerializer(track) assert serializer.data == expected @@ -154,6 +160,7 @@ def test_album_serializer(factories, to_api_date): album = track1.album expected = { "id": album.id, + "fid": album.fid, "mbid": str(album.mbid), "title": album.title, "artist": serializers.ArtistSimpleSerializer(album.artist).data, @@ -167,6 +174,7 @@ def test_album_serializer(factories, to_api_date): }, "release_date": to_api_date(album.release_date), "tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data, + "is_local": album.is_local, } serializer = serializers.AlbumSerializer(album) @@ -181,6 +189,7 @@ def test_track_serializer(factories, to_api_date): setattr(track, "playable_uploads", [upload]) expected = { "id": track.id, + "fid": track.fid, "artist": serializers.ArtistSimpleSerializer(track.artist).data, "album": serializers.TrackAlbumSerializer(track.album).data, "mbid": str(track.mbid), @@ -193,6 +202,7 @@ def test_track_serializer(factories, to_api_date): "listen_url": track.listen_url, "license": upload.track.license.code, "copyright": upload.track.copyright, + "is_local": upload.track.is_local, } serializer = serializers.TrackSerializer(track) assert serializer.data == expected diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index d2bb3a903..d897c1a5f 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -42,9 +42,38 @@ def test_can_create_track_from_file_metadata_no_mbid(db, mocker): assert track.album.release_date == datetime.date(2012, 8, 15) assert track.artist.name == metadata["artists"][0]["name"] assert track.artist.mbid is None + assert track.artist.attributed_to is None match_license.assert_called_once_with(metadata["license"], metadata["copyright"]) +def test_can_create_track_from_file_metadata_attributed_to(factories, mocker): + actor = factories["federation.Actor"]() + metadata = { + "title": "Test track", + "artists": [{"name": "Test artist"}], + "album": {"title": "Test album", "release_date": datetime.date(2012, 8, 15)}, + "position": 4, + "disc_number": 2, + "copyright": "2018 Someone", + } + + track = tasks.get_track_from_import_metadata(metadata, attributed_to=actor) + + assert track.title == metadata["title"] + assert track.mbid is None + assert track.position == 4 + assert track.disc_number == 2 + assert track.copyright == metadata["copyright"] + assert track.attributed_to == actor + assert track.album.title == metadata["album"]["title"] + assert track.album.mbid is None + assert track.album.release_date == datetime.date(2012, 8, 15) + assert track.album.attributed_to == actor + assert track.artist.name == metadata["artists"][0]["name"] + assert track.artist.mbid is None + assert track.artist.attributed_to == actor + + def test_can_create_track_from_file_metadata_mbid(factories, mocker): metadata = { "title": "Test track", @@ -229,6 +258,7 @@ def test_upload_import(now, factories, temp_signal, mocker): outbox = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") update_album_cover = mocker.patch("funkwhale_api.music.tasks.update_album_cover") get_picture = mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture") + get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata") track = factories["music.Track"](album__cover="") upload = factories["music.Upload"]( track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}} @@ -246,6 +276,10 @@ def test_upload_import(now, factories, temp_signal, mocker): update_album_cover.assert_called_once_with( upload.track.album, cover_data=get_picture.return_value, source=upload.source ) + assert ( + get_track_from_import_metadata.call_args[-1]["attributed_to"] + == upload.library.actor + ) handler.assert_called_once_with( upload=upload, old_status="pending", @@ -478,9 +512,15 @@ def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, m ) -def test_federation_audio_track_to_metadata(now): +def test_federation_audio_track_to_metadata(now, mocker): published = now released = now.date() + references = { + "http://track.attributed": mocker.Mock(), + "http://album.attributed": mocker.Mock(), + "http://album-artist.attributed": mocker.Mock(), + "http://artist.attributed": mocker.Mock(), + } payload = { "@context": jsonld.get_default_context(), "type": "Track", @@ -492,6 +532,7 @@ def test_federation_audio_track_to_metadata(now): "published": published.isoformat(), "license": "http://creativecommons.org/licenses/by-sa/4.0/", "copyright": "2018 Someone", + "attributedTo": "http://track.attributed", "album": { "published": published.isoformat(), "type": "Album", @@ -499,6 +540,7 @@ def test_federation_audio_track_to_metadata(now): "name": "Purple album", "musicbrainzId": str(uuid.uuid4()), "released": released.isoformat(), + "attributedTo": "http://album.attributed", "artists": [ { "type": "Artist", @@ -506,6 +548,7 @@ def test_federation_audio_track_to_metadata(now): "id": "http://hello.artist", "name": "John Smith", "musicbrainzId": str(uuid.uuid4()), + "attributedTo": "http://album-artist.attributed", } ], "cover": { @@ -521,6 +564,7 @@ def test_federation_audio_track_to_metadata(now): "id": "http://hello.trackartist", "name": "Bob Smith", "musicbrainzId": str(uuid.uuid4()), + "attributedTo": "http://artist.attributed", } ], } @@ -535,8 +579,10 @@ def test_federation_audio_track_to_metadata(now): "mbid": payload["musicbrainzId"], "fdate": serializer.validated_data["published"], "fid": payload["id"], + "attributed_to": references["http://track.attributed"], "album": { "title": payload["album"]["name"], + "attributed_to": references["http://album.attributed"], "release_date": released, "mbid": payload["album"]["musicbrainzId"], "fid": payload["album"]["id"], @@ -546,6 +592,7 @@ def test_federation_audio_track_to_metadata(now): "name": a["name"], "mbid": a["musicbrainzId"], "fid": a["id"], + "attributed_to": references["http://album-artist.attributed"], "fdate": serializer.validated_data["album"]["artists"][i][ "published" ], @@ -561,6 +608,7 @@ def test_federation_audio_track_to_metadata(now): "mbid": a["musicbrainzId"], "fid": a["id"], "fdate": serializer.validated_data["artists"][i]["published"], + "attributed_to": references["http://artist.attributed"], } for i, a in enumerate(payload["artists"]) ], @@ -570,7 +618,9 @@ def test_federation_audio_track_to_metadata(now): }, } - result = tasks.federation_audio_track_to_metadata(serializer.validated_data) + result = tasks.federation_audio_track_to_metadata( + serializer.validated_data, references + ) assert result == expected @@ -747,3 +797,14 @@ def test_get_prunable_artists(factories): factories["music.Track"](album__artist=non_prunable_album_artist) assert list(tasks.get_prunable_artists()) == [prunable_artist] + + +def test_update_library_entity(factories, mocker): + artist = factories["music.Artist"]() + save = mocker.spy(artist, "save") + + tasks.update_library_entity(artist, {"name": "Hello"}) + save.assert_called_once_with(update_fields=["name"]) + + artist.refresh_from_db() + assert artist.name == "Hello" diff --git a/docs/federation/index.rst b/docs/federation/index.rst index e54de3fe4..3123455ff 100644 --- a/docs/federation/index.rst +++ b/docs/federation/index.rst @@ -88,8 +88,7 @@ to posting an activity to an outbox, we create an object, with the proper payloa Receiving an activity from a remote actor in a local inbox is basically the same, but we skip step 2. Funkwhale does not support all activities, and we have a basic routing logic to handle -specific activities, and discard unsupported ones. Unsupported activities are still -received and stored though. +specific activities, and discard unsupported ones. If a delivered activity matches one of our routes, a dedicated handler is called, which can trigger additional logic. For instance, if we receive a :ref:`activity-create` activity @@ -102,6 +101,24 @@ Links: - `Delivery logic for activities `_ +.. _service-actor: + +Service actor +------------- + +In some situations, we will send messages or authenticate our fetches using what we call +the service actor. A service actor is an ActivityPub actor object that acts on behalf +of a Funkwhale server. + +The actor id usually looks like ``https://yourdomain.com/federation/actors/service``, but +the reliable way to determine it is to query the nodeinfo endpoint and use the value +available in the ``metadata > actorId`` field. + +Funkwhale generally considers that the service actor has authority to send activities +associated with any object on the same domain. For instance, the service actor +could send a :ref:`activity-delete` activity linked to another users' library on the same domain. + + Supported activities -------------------- @@ -305,6 +322,59 @@ the audio library's actor are the same. If no local actor follows the audio's library, the activity will be discarded. +.. _activity-update: + + +Update +^^^^^^ + +Supported on +************ + +- :ref:`object-library` objects +- :ref:`object-track` objects + +Example +******* + +.. code-block:: json + + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {} + ], + "to": [ + "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers" + ], + "type": "Update", + "actor": "https://awesome.music/federation/actors/Bob", + "object": {} + } + +.. note:: + + Refer to :ref:`object-library` or :ref:`object-track` to see the structure of the ``object`` attribute. + +Internal logic +************** + +When a :ref:`activity-update` is received with a :ref:`object-library` or :ref:`object-track` object, +Funkwhale will try to update the local copy of the corresponding object in it's database. + + +Checks +****** + +Checks vary depending of the type of object associated with the update. + +For :ref:`object-library` objects, we ensure the actor sending the message is the owner of the library. + +For musical entities such as :ref:`object-track`, we ensure the actor sending the message +matches the :ref:`property-attributedTo` property declared on the local copy on the object, +or the :ref:`service-actor`. + .. _activity-delete: Delete @@ -613,3 +683,19 @@ For :ref:`object-audio` url objects: - If the audio's library is public, audio file can be accessed without restriction - Otherwise, the HTTP request must be signed by an actor with an approved follow on the audio's library + + +Properties +---------- + +.. _property-attributedTo: + +attributedTo +------------ + +Funkwhale will generally use the ``attributedTo`` property to communicate +who is responsible for a given object. When an object has the ``attributedTo`` attribute, +the associated actor has the permission to :ref:`activity-update`, :ref:`activity-delete` or +more generally apply any kind of activity on the object. + +In addition, Funkwhale consider all the objects of a domain as attributed to its corresponding :ref:`service-actor`. diff --git a/docs/swagger.yml b/docs/swagger.yml index a60b233df..2a4baeb8b 100644 --- a/docs/swagger.yml +++ b/docs/swagger.yml @@ -662,12 +662,20 @@ definitions: type: "integer" format: "int64" example: 42 + fid: + type: string + format: uri + description: "The artist Federation ID (unique accross federation)" name: type: "string" example: "System of a Down" creation_date: type: "string" format: "date-time" + is_local: + type: "boolean" + description: "Indicates if the object was initally created locally or on another server" + Artist: type: "object" allOf: @@ -689,6 +697,10 @@ definitions: type: "integer" format: "int64" example: 16 + fid: + type: string + format: uri + description: "The album Federation ID (unique accross federation)" artist: type: "integer" format: "int64" @@ -708,6 +720,9 @@ definitions: type: "boolean" cover: $ref: "#/definitions/Image" + is_local: + type: "boolean" + description: "Indicates if the object was initally created locally or on another server" Album: type: "object" @@ -819,6 +834,10 @@ definitions: type: "integer" format: "int64" example: 66 + fid: + type: string + format: uri + description: "The track Federation ID (unique accross federation)" artist: type: "integer" format: "int64" @@ -853,6 +872,9 @@ definitions: type: "string" description: "Identifier of the license that is linked to the track" example: "cc-by-nc-nd-4.0" + is_local: + type: "boolean" + description: "Indicates if the object was initally created locally or on another server" AlbumTrack: type: "object" diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index 8d885e72a..cd4572f5b 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -65,6 +65,7 @@ diff --git a/front/src/components/library/TrackDetail.vue b/front/src/components/library/TrackDetail.vue index 8438a97c6..08a3a4d3e 100644 --- a/front/src/components/library/TrackDetail.vue +++ b/front/src/components/library/TrackDetail.vue @@ -63,6 +63,16 @@ N/A + + + Federation ID + + + + {{ track.fid|truncate(65)}} + + + diff --git a/front/src/components/library/TrackEdit.vue b/front/src/components/library/TrackEdit.vue index 945bae961..18e71e8fa 100644 --- a/front/src/components/library/TrackEdit.vue +++ b/front/src/components/library/TrackEdit.vue @@ -6,8 +6,11 @@ Edit this track Suggest an edit on this track +
+ This object is managed by another server, you cannot edit it. +