diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index c94b08d0022ef2ac43f2320c94e1f0765fe10dd3..e253906de71c77020b483b9af6d3cd9b974dd3e8 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -159,3 +159,34 @@ class ActionSerializer(serializers.Serializer): "result": result, } return payload + + +def track_fields_for_update(*fields): + """ + Apply this decorator to serializer to call function when specific values + are updated on an object: + + .. code-block:: python + + @track_fields_for_update('privacy_level') + class LibrarySerializer(serializers.ModelSerializer): + def on_updated_privacy_level(self, obj, old_value, new_value): + print('Do someting') + """ + + def decorator(serializer_class): + original_update = serializer_class.update + + def new_update(self, obj, validated_data): + tracked_fields_before = {f: getattr(obj, f) for f in fields} + obj = original_update(self, obj, validated_data) + tracked_fields_after = {f: getattr(obj, f) for f in fields} + + if tracked_fields_before != tracked_fields_after: + self.on_updated_fields(obj, tracked_fields_before, tracked_fields_after) + return obj + + serializer_class.update = new_update + return serializer_class + + return decorator diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index d8877de1fd1c7bbcf81859d27ed6a9725895cc06..0295aa46ce4004648075371be181b64072c773e9 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -195,6 +195,45 @@ def outbox_delete_library(context): } +@outbox.register({"type": "Update", "object.type": "Library"}) +def outbox_update_library(context): + library = context["library"] + serializer = serializers.ActivitySerializer( + {"type": "Update", "object": serializers.LibrarySerializer(library).data} + ) + + yield { + "type": "Update", + "actor": library.actor, + "payload": with_recipients( + serializer.data, to=[{"type": "followers", "target": library}] + ), + } + + +@inbox.register({"type": "Update", "object.type": "Library"}) +def inbox_update_library(payload, context): + actor = context["actor"] + library_id = payload["object"].get("id") + if not library_id: + logger.debug("Discarding deletion of empty library") + return + + if not actor.libraries.filter(fid=library_id).exists(): + logger.debug("Discarding deletion of unkwnown library %s", library_id) + return + + serializer = serializers.LibrarySerializer(data=payload["object"]) + if serializer.is_valid(): + serializer.save() + else: + logger.debug( + "Discarding update of library %s because of payload errors: %s", + library_id, + serializer.errors, + ) + + @inbox.register({"type": "Delete", "object.type": "Audio"}) def inbox_delete_audio(payload, context): actor = context["actor"] diff --git a/api/funkwhale_api/federation/signing.py b/api/funkwhale_api/federation/signing.py index 1bb2b9d5de04fc54e5557a6097b2f3215cd73efe..5b7a9b8d4f67e65d5d027d0b58413fd68a94b48b 100644 --- a/api/funkwhale_api/federation/signing.py +++ b/api/funkwhale_api/federation/signing.py @@ -85,7 +85,7 @@ def verify_django(django_request, public_key): def get_auth(private_key, private_key_id): return requests_http_signature.HTTPSignatureAuth( use_auth_header=False, - headers=["(request-target)", "user-agent", "host", "date", "content-type"], + headers=["(request-target)", "user-agent", "host", "date"], algorithm="rsa-sha256", key=private_key.encode("utf-8"), key_id=private_key_id, diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 5d1dab00696fef6614c73371a4419c42cfbf2b6e..12f41f2d7acb404dfc673558629f214d68ef3149 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -192,6 +192,7 @@ class TrackSerializer(serializers.ModelSerializer): return TrackUploadSerializer(uploads, many=True).data +@common_serializers.track_fields_for_update("name", "description", "privacy_level") class LibraryForOwnerSerializer(serializers.ModelSerializer): uploads_count = serializers.SerializerMethodField() size = serializers.SerializerMethodField() @@ -216,6 +217,11 @@ class LibraryForOwnerSerializer(serializers.ModelSerializer): def get_size(self, o): return getattr(o, "_size", 0) + def on_updated_fields(self, obj, before, after): + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Library"}}, context={"library": obj} + ) + class UploadSerializer(serializers.ModelSerializer): track = TrackSerializer(required=False, allow_null=True) diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py index e07bf8e826bfec8ebe77de5be57b1b7ce9f2d553..bf04e8d2ae015533dadabbf88e96ac1ed6cdf2b2 100644 --- a/api/tests/common/test_serializers.py +++ b/api/tests/common/test_serializers.py @@ -134,3 +134,32 @@ def test_action_serializers_can_require_filter(factories): assert serializer.is_valid(raise_exception=True) is True assert list(serializer.validated_data["objects"]) == [user1] + + +def test_track_fields_for_update(mocker): + @serializers.track_fields_for_update("field1", "field2") + class S(serializers.serializers.Serializer): + field1 = serializers.serializers.CharField() + field2 = serializers.serializers.CharField() + + def update(self, obj, validated_data): + for key, value in validated_data.items(): + setattr(obj, key, value) + return obj + + on_updated_fields = mocker.stub() + + class Obj(object): + field1 = "value1" + field2 = "value2" + + obj = Obj() + serializer = S(obj, data={"field1": "newvalue1", "field2": "newvalue2"}) + assert serializer.is_valid(raise_exception=True) + serializer.save() + + serializer.on_updated_fields.assert_called_once_with( + obj, + {"field1": "value1", "field2": "value2"}, + {"field1": "newvalue1", "field2": "newvalue2"}, + ) diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 79d194f5636c6ea396174946428d1953c7e8dbbf..7232b746cfa0768992ced9ca0bec818c58246bfa 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -9,6 +9,7 @@ from funkwhale_api.federation import routes, serializers ({"type": "Follow"}, routes.inbox_follow), ({"type": "Accept"}, routes.inbox_accept), ({"type": "Create", "object.type": "Audio"}, routes.inbox_create_audio), + ({"type": "Update", "object.type": "Library"}, routes.inbox_update_library), ({"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), @@ -29,6 +30,7 @@ def test_inbox_routes(route, handler): ({"type": "Accept"}, routes.outbox_accept), ({"type": "Follow"}, routes.outbox_follow), ({"type": "Create", "object.type": "Audio"}, routes.outbox_create_audio), + ({"type": "Update", "object.type": "Library"}, routes.outbox_update_library), ({"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), @@ -262,6 +264,55 @@ def test_outbox_delete_library(factories): assert activity["actor"] == library.actor +def test_outbox_update_library(factories): + library = factories["music.Library"]() + activity = list(routes.outbox_update_library({"library": library}))[0] + expected = serializers.ActivitySerializer( + {"type": "Update", "object": serializers.LibrarySerializer(library).data} + ).data + + expected["to"] = [{"type": "followers", "target": library}] + + assert dict(activity["payload"]) == dict(expected) + assert activity["actor"] == library.actor + + +def test_inbox_update_library(factories): + activity = factories["federation.Activity"]() + + library = factories["music.Library"]() + data = serializers.LibrarySerializer(library).data + data["name"] = "New name" + payload = {"type": "Update", "actor": library.actor.fid, "object": data} + + routes.inbox_update_library( + payload, + context={"actor": library.actor, "raise_exception": True, "activity": activity}, + ) + + library.refresh_from_db() + assert library.name == "New name" + + +# def test_inbox_update_library_impostor(factories): +# activity = factories["federation.Activity"]() +# impostor = factories["federation.Actor"]() +# library = factories["music.Library"]() +# payload = { +# "type": "Delete", +# "actor": library.actor.fid, +# "object": {"type": "Library", "id": library.fid}, +# } + +# routes.inbox_update_library( +# payload, +# context={"actor": impostor, "raise_exception": True, "activity": activity}, +# ) + +# # not deleted, should still be here +# library.refresh_from_db() + + def test_inbox_delete_audio(factories): activity = factories["federation.Activity"]() diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 3bd13a599150ee8a32af3e00932bd58454853df9..92b924486c5114c574994df400bc1e7af2d5a0fd 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -1,3 +1,5 @@ +import pytest + from funkwhale_api.music import models from funkwhale_api.music import serializers from funkwhale_api.music import tasks @@ -274,3 +276,28 @@ def test_track_upload_serializer(factories): serializer = serializers.TrackUploadSerializer(upload) assert serializer.data == expected + + +@pytest.mark.parametrize( + "field,before,after", + [ + ("privacy_level", "me", "everyone"), + ("name", "Before", "After"), + ("description", "Before", "After"), + ], +) +def test_update_library_privacy_level_broadcasts_to_followers( + factories, field, before, after, mocker +): + dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + library = factories["music.Library"](**{field: before}) + + serializer = serializers.LibraryForOwnerSerializer( + library, data={field: after}, partial=True + ) + assert serializer.is_valid(raise_exception=True) + serializer.save() + + dispatch.assert_called_once_with( + {"type": "Update", "object": {"type": "Library"}}, context={"library": library} + ) diff --git a/changes/changelog.d/library-update-federation.enhancement b/changes/changelog.d/library-update-federation.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..4c777ad99eb724487941fde097b34df0383d8c1d --- /dev/null +++ b/changes/changelog.d/library-update-federation.enhancement @@ -0,0 +1 @@ +Broadcast library updates (name, description, visibility) over federation