diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index cf8596c29..fef0d2b9a 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -1,4 +1,5 @@ import logging +import uuid from django.db.models import Q @@ -333,6 +334,37 @@ def inbox_update_track(payload, context): ) +@inbox.register({"type": "Update", "object.type": "Audio"}) +def inbox_update_audio(payload, context): + serializer = serializers.ChannelCreateUploadSerializer( + data=payload, context=context + ) + + if not serializer.is_valid(raise_exception=context.get("raise_exception", False)): + logger.info("Skipped update, invalid payload") + return + serializer.save() + + +@outbox.register({"type": "Update", "object.type": "Audio"}) +def outbox_update_audio(context): + upload = context["upload"] + channel = upload.library.get_channel() + actor = channel.actor + serializer = serializers.ChannelCreateUploadSerializer( + upload, context={"type": "Update", "activity_id_suffix": str(uuid.uuid4())[:8]} + ) + + yield { + "type": "Update", + "actor": actor, + "payload": with_recipients( + serializer.data, + to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}], + ), + } + + @inbox.register({"type": "Update", "object.type": "Artist"}) def inbox_update_artist(payload, context): return handle_library_entry_update( @@ -437,7 +469,6 @@ def outbox_delete_actor(context): { "type": "Delete", "object.type": [ - "Tombstone", "Actor", "Person", "Application", @@ -464,6 +495,17 @@ def inbox_delete_actor(payload, context): actor.delete() +@inbox.register({"type": "Delete", "object.type": "Tombstone"}) +def inbox_delete(payload, context): + serializer = serializers.DeleteSerializer(data=payload, context=context) + if not serializer.is_valid(raise_exception=context.get("raise_exception", False)): + logger.info("Skipped deletion, invalid payload") + return + + to_delete = serializer.validated_data["object"] + to_delete.delete() + + @inbox.register({"type": "Flag"}) def inbox_flag(payload, context): serializer = serializers.FlagSerializer(data=payload, context=context) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 5893027f7..98df53548 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -1,4 +1,5 @@ import logging +import os import urllib.parse import uuid @@ -1967,7 +1968,6 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer): class ChannelCreateUploadSerializer(jsonld.JsonLdSerializer): - type = serializers.ChoiceField(choices=[contexts.AS.Create]) object = serializers.DictField() class Meta: @@ -1976,9 +1976,9 @@ class ChannelCreateUploadSerializer(jsonld.JsonLdSerializer): } def to_representation(self, upload): - return { + payload = { "@context": jsonld.get_default_context(), - "type": "Create", + "type": self.context.get("type", "Create"), "id": utils.full_url( reverse( "federation:music:uploads-activity", kwargs={"uuid": upload.uuid} @@ -1989,6 +1989,12 @@ class ChannelCreateUploadSerializer(jsonld.JsonLdSerializer): upload, context={"include_ap_context": False} ).data, } + if self.context.get("activity_id_suffix"): + payload["id"] = os.path.join( + payload["id"], self.context["activity_id_suffix"] + ) + + return payload def validate(self, validated_data): serializer = ChannelUploadSerializer( @@ -1999,3 +2005,28 @@ class ChannelCreateUploadSerializer(jsonld.JsonLdSerializer): def save(self, **kwargs): return self.validated_data["audio_serializer"].save(**kwargs) + + +class DeleteSerializer(jsonld.JsonLdSerializer): + object = serializers.URLField(max_length=500) + type = serializers.ChoiceField(choices=[contexts.AS.Delete]) + + class Meta: + jsonld_mapping = {"object": jsonld.first_id(contexts.AS.object)} + + def validate_object(self, url): + try: + obj = utils.get_object_by_fid(url) + except utils.ObjectDoesNotExist: + raise serializers.ValidationError("No object matching {}".format(url)) + if isinstance(obj, music_models.Upload): + obj = obj.track + + return obj + + def validate(self, validated_data): + if not utils.can_manage( + validated_data["object"].attributed_to, self.context["actor"] + ): + raise serializers.ValidationError("You cannot delete this object") + return validated_data diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index 58da7cbe6..d6a35df28 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -277,3 +277,18 @@ def get_object_by_fid(fid, local=None): return channel return instance + + +def can_manage(obj_owner, actor): + if not obj_owner: + return False + + if not actor: + return False + + if obj_owner == actor: + return True + if obj_owner.domain.service_actor == actor: + return True + + return False diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py index c26feaec7..7cab0d63e 100644 --- a/api/funkwhale_api/music/mutations.py +++ b/api/funkwhale_api/music/mutations.py @@ -127,9 +127,18 @@ class TrackMutationSerializer(CoverMutation, TagMutation, DescriptionMutation): return serialized_relations def post_apply(self, obj, validated_data): - routes.outbox.dispatch( - {"type": "Update", "object": {"type": "Track"}}, context={"track": obj} - ) + channel = obj.artist.get_channel() + if channel: + upload = channel.library.uploads.filter(track=obj).first() + if upload: + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Audio"}}, + context={"upload": upload}, + ) + else: + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Track"}}, context={"track": obj} + ) @mutations.registry.connect( diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 2da2bd238..1bfd5e088 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -31,7 +31,9 @@ from funkwhale_api.moderation import serializers as moderation_serializers ({"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), + ({"type": "Update", "object": {"type": "Audio"}}, routes.inbox_update_audio), ({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor), + ({"type": "Delete", "object": {"type": "Tombstone"}}, routes.inbox_delete), ({"type": "Flag"}, routes.inbox_flag), ], ) @@ -62,6 +64,7 @@ def test_inbox_routes(route, handler): ({"type": "Delete", "object": {"type": "Album"}}, routes.outbox_delete_album), ({"type": "Undo", "object": {"type": "Follow"}}, routes.outbox_undo_follow), ({"type": "Update", "object": {"type": "Track"}}, routes.outbox_update_track), + ({"type": "Update", "object": {"type": "Audio"}}, routes.outbox_update_audio), ( {"type": "Delete", "object": {"type": "Tombstone"}}, routes.outbox_delete_actor, @@ -354,7 +357,7 @@ def test_inbox_create_audio_channel(factories, mocker): "@context": jsonld.get_default_context(), "type": "Create", "actor": channel.actor.fid, - "object": serializers.ChannelCreateUploadSerializer(upload).data, + "object": serializers.ChannelUploadSerializer(upload).data, } upload.delete() init = mocker.spy(serializers.ChannelCreateUploadSerializer, "__init__") @@ -368,7 +371,7 @@ def test_inbox_create_audio_channel(factories, mocker): assert init.call_count == 1 args = init.call_args - assert args[1]["data"] == payload["object"] + assert args[1]["data"] == payload assert args[1]["context"] == {"channel": channel} assert save.call_count == 1 @@ -765,6 +768,46 @@ def test_inbox_update_track(factories, mocker): update_library_entity.assert_called_once_with(obj, {"title": "New title"}) +def test_inbox_update_audio(factories, mocker, r_mock): + channel = factories["audio.Channel"]() + upload = factories["music.Upload"]( + library=channel.library, + track__artist=channel.artist, + track__attributed_to=channel.actor, + ) + upload.track.fid = upload.fid + upload.track.save() + r_mock.get( + upload.track.album.fid, + json=serializers.AlbumSerializer(upload.track.album).data, + ) + data = serializers.ChannelCreateUploadSerializer(upload).data + data["object"]["name"] = "New title" + + routes.inbox_update_audio( + data, context={"actor": channel.actor, "raise_exception": True} + ) + + upload.track.refresh_from_db() + + assert upload.track.title == "New title" + + +def test_outbox_update_audio(factories, faker, mocker): + fake_uuid = faker.uuid4() + mocker.patch("uuid.uuid4", return_value=fake_uuid) + upload = factories["music.Upload"](channel=True) + activity = list(routes.outbox_update_audio({"upload": upload}))[0] + expected = serializers.ChannelCreateUploadSerializer(upload).data + + expected["type"] = "Update" + expected["id"] += "/" + fake_uuid[:8] + expected["to"] = [contexts.AS.Public, {"type": "instances_with_followers"}] + + assert dict(activity["payload"]) == dict(expected) + assert activity["actor"] == upload.library.channel.actor + + def test_outbox_update_track(factories): track = factories["music.Track"]() activity = list(routes.outbox_update_track({"track": track}))[0] diff --git a/api/tests/federation/test_third_party_activitypub.py b/api/tests/federation/test_third_party_activitypub.py index 83377660a..f50c36ddb 100644 --- a/api/tests/federation/test_third_party_activitypub.py +++ b/api/tests/federation/test_third_party_activitypub.py @@ -1,3 +1,6 @@ +import pytest + +from funkwhale_api.federation import routes from funkwhale_api.federation import serializers @@ -141,3 +144,93 @@ def test_reel2bits_channel_from_actor_ap(db, mocker): assert channel.rss_url == payload["url"][1]["href"] assert channel.artist.name == actor.name assert channel.artist.attributed_to == actor + + +def test_reel2bits_upload_create(factories): + channel = factories["audio.Channel"]() + payload = { + "id": "https://r2b.example/outbox/cb89c969224d7c9d", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "type": "Create", + "actor": "https://r2b.example/user/anna", + "object": { + "cc": ["https://r2b.example/user/anna/followers"], + "id": "https://r2b.example/outbox/cb89c969224d7c9d/activity", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "url": { + "href": "https://r2b.example/uploads/sounds/anna/test.mp3", + "type": "Link", + "mediaType": "audio/mpeg", + }, + "name": "nya", + "tag": [ + {"name": "#nya", "type": "Hashtag"}, + {"name": "#cat", "type": "Hashtag"}, + {"name": "#paws", "type": "Hashtag"}, + ], + "type": "Audio", + "genre": "cat", + "image": { + "url": "https://r2b.example/uploads/artwork_sounds/anna/test.jpg", + "type": "Image", + "mediaType": "image/jpeg", + }, + "content": "nya nya", + "licence": {"id": "0", "icon": "", "link": "", "name": "Not Specified"}, + "mediaType": "text/plain", + "published": "2020-04-08T12:47:29Z", + "attributedTo": "https://r2b.example/user/anna", + }, + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "toot": "http://joinmastodon.org/ns#", + "Hashtag": "as:Hashtag", + "featured": "toot:featured", + "sensitive": "as:sensitive", + }, + ], + "published": "2020-04-08T12:47:29Z", + } + serializer = serializers.ChannelCreateUploadSerializer( + data=payload, context={"channel": channel} + ) + assert serializer.is_valid(raise_exception=True) is True + + serializer.save() + + +def test_reel2bits_upload_delete(factories): + actor = factories["federation.Actor"]() + channel = factories["audio.Channel"](actor=actor, attributed_to=actor) + upload = factories["music.Upload"](channel=channel, track__attributed_to=actor) + payload = { + "id": "https://r2b.example/outbox/4987acc5b25f0aac", + "to": [ + "https://channels.tests.funkwhale.audio/federation/actors/demo", + "https://www.w3.org/ns/activitystreams#Public", + ], + "type": "Delete", + "actor": actor.fid, + "object": {"id": upload.fid, "type": "Tombstone"}, + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "toot": "http://joinmastodon.org/ns#", + "Hashtag": "as:Hashtag", + "featured": "toot:featured", + "sensitive": "as:sensitive", + }, + ], + } + + routes.inbox_delete( + payload, context={"actor": actor, "raise_exception": True, "activity": payload}, + ) + + with pytest.raises(upload.track.DoesNotExist): + upload.track.refresh_from_db() + with pytest.raises(upload.DoesNotExist): + upload.refresh_from_db() diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py index 93e77d114..1516473be 100644 --- a/api/tests/music/test_mutations.py +++ b/api/tests/music/test_mutations.py @@ -123,6 +123,19 @@ def test_track_mutation_apply_outbox(factories, mocker): ) +def test_channel_track_mutation_apply_outbox(factories, mocker): + dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + upload = factories["music.Upload"](channel=True, track__position=4) + mutation = factories["common.Mutation"]( + type="update", target=upload.track, payload={"position": 12} + ) + mutation.apply() + + dispatch.assert_called_once_with( + {"type": "Update", "object": {"type": "Audio"}}, context={"upload": upload} + ) + + @pytest.mark.parametrize("factory_name", ["music.Artist", "music.Album", "music.Track"]) def test_mutation_set_tags(factory_name, factories, now, mocker): tags = ["tag1", "tag2"]