From b351ea67e27002be4e72f3f52650721250e58e25 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 7 Feb 2020 10:48:17 +0100 Subject: [PATCH] See #170: expose/store actor URL over federation --- api/funkwhale_api/audio/factories.py | 1 + .../audio/migrations/0003_channel_rss_url.py | 18 ++++++ api/funkwhale_api/audio/models.py | 4 ++ api/funkwhale_api/audio/serializers.py | 2 + api/funkwhale_api/federation/jsonld.py | 27 +++++---- api/funkwhale_api/federation/models.py | 11 ++++ api/funkwhale_api/federation/serializers.py | 55 +++++++++++++++++++ api/tests/audio/test_models.py | 18 ++++++ api/tests/audio/test_serializers.py | 1 + api/tests/federation/test_serializers.py | 37 ++++++++++++- 10 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 api/funkwhale_api/audio/migrations/0003_channel_rss_url.py diff --git a/api/funkwhale_api/audio/factories.py b/api/funkwhale_api/audio/factories.py index 9629b2a1e..6a7c56745 100644 --- a/api/funkwhale_api/audio/factories.py +++ b/api/funkwhale_api/audio/factories.py @@ -25,6 +25,7 @@ class ChannelFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): music_factories.ArtistFactory, attributed_to=factory.SelfAttribute("..attributed_to"), ) + rss_url = factory.Faker("url") metadata = factory.LazyAttribute(lambda o: {}) class Meta: diff --git a/api/funkwhale_api/audio/migrations/0003_channel_rss_url.py b/api/funkwhale_api/audio/migrations/0003_channel_rss_url.py new file mode 100644 index 000000000..9936883f7 --- /dev/null +++ b/api/funkwhale_api/audio/migrations/0003_channel_rss_url.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.10 on 2020-02-06 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audio', '0002_channel_metadata'), + ] + + operations = [ + migrations.AddField( + model_name='channel', + name='rss_url', + field=models.URLField(blank=True, max_length=500, null=True), + ), + ] diff --git a/api/funkwhale_api/audio/models.py b/api/funkwhale_api/audio/models.py index 7acca926a..e95bb5d66 100644 --- a/api/funkwhale_api/audio/models.py +++ b/api/funkwhale_api/audio/models.py @@ -36,6 +36,7 @@ class Channel(models.Model): "music.Library", on_delete=models.CASCADE, related_name="channel" ) creation_date = models.DateTimeField(default=timezone.now) + rss_url = models.URLField(max_length=500, null=True, blank=True) # metadata to enhance rss feed metadata = JSONField( @@ -46,6 +47,9 @@ class Channel(models.Model): return federation_utils.full_url("/channels/{}".format(self.uuid)) def get_rss_url(self): + if not self.artist.is_local: + return self.rss_url + return federation_utils.full_url( reverse("api:v1:channels-rss", kwargs={"uuid": self.uuid}) ) diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py index 2d46b8cce..3f953436c 100644 --- a/api/funkwhale_api/audio/serializers.py +++ b/api/funkwhale_api/audio/serializers.py @@ -191,6 +191,7 @@ class ChannelSerializer(serializers.ModelSerializer): artist = serializers.SerializerMethodField() actor = federation_serializers.APIActorSerializer() attributed_to = federation_serializers.APIActorSerializer() + rss_url = serializers.CharField(source="get_rss_url") class Meta: model = models.Channel @@ -201,6 +202,7 @@ class ChannelSerializer(serializers.ModelSerializer): "actor", "creation_date", "metadata", + "rss_url", ] def get_artist(self, obj): diff --git a/api/funkwhale_api/federation/jsonld.py b/api/funkwhale_api/federation/jsonld.py index c0170b235..450490c91 100644 --- a/api/funkwhale_api/federation/jsonld.py +++ b/api/funkwhale_api/federation/jsonld.py @@ -167,7 +167,7 @@ def prepare_for_serializer(payload, config, fallbacks={}): attr=field_config.get("attr"), ) except (IndexError, KeyError): - aliases = field_config.get("aliases", []) + aliases = field_config.get("aliases", {}) noop = object() value = noop if not aliases: @@ -176,9 +176,7 @@ def prepare_for_serializer(payload, config, fallbacks={}): for a in aliases: try: value = get_value( - payload[a], - keep=field_config.get("keep"), - attr=field_config.get("attr"), + payload[a["property"]], keep=a.get("keep"), attr=a.get("attr"), ) except (IndexError, KeyError): continue @@ -231,14 +229,20 @@ def get_default_context_fw(): class JsonLdSerializer(serializers.Serializer): + def __init__(self, *args, **kwargs): + self.jsonld_expand = kwargs.pop("jsonld_expand", True) + super().__init__(*args, **kwargs) + def run_validation(self, data=empty): - if data and data is not empty and self.context.get("expand", True): - try: - data = expand(data) - except ValueError: - raise serializers.ValidationError( - "{} is not a valid jsonld document".format(data) - ) + if data and data is not empty: + + if self.context.get("expand", self.jsonld_expand): + try: + data = expand(data) + except ValueError: + raise serializers.ValidationError( + "{} is not a valid jsonld document".format(data) + ) try: config = self.Meta.jsonld_mapping except AttributeError: @@ -247,6 +251,7 @@ class JsonLdSerializer(serializers.Serializer): fallbacks = self.Meta.jsonld_fallbacks except AttributeError: fallbacks = {} + data = prepare_for_serializer(data, config, fallbacks=fallbacks) dereferenced_fields = [ k diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 016e712c1..c200e4f6e 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -254,6 +254,17 @@ class Actor(models.Model): except ObjectDoesNotExist: return None + def get_channel(self): + try: + return self.channel + except ObjectDoesNotExist: + return None + + def get_absolute_url(self): + if self.is_local: + return federation_utils.full_url("/@{}".format(self.preferred_username)) + return self.url or self.fid + def get_current_usage(self): actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get() data = {} diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 6501e93cf..9b53c3298 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -91,6 +91,17 @@ class ImageSerializer(MediaSerializer): return validated_data +class URLSerializer(jsonld.JsonLdSerializer): + href = serializers.URLField(max_length=500) + mediaType = serializers.CharField(required=False) + + class Meta: + jsonld_mapping = { + "href": jsonld.first_id(contexts.AS.href, aliases=[jsonld.raw("@id")]), + "mediaType": jsonld.first_val(contexts.AS.mediaType), + } + + class EndpointsSerializer(jsonld.JsonLdSerializer): sharedInbox = serializers.URLField(max_length=500, required=False) @@ -105,10 +116,19 @@ class PublicKeySerializer(jsonld.JsonLdSerializer): jsonld_mapping = {"publicKeyPem": jsonld.first_val(contexts.SEC.publicKeyPem)} +def get_by_media_type(urls, media_type): + for url in urls: + if url.get("mediaType", "text/html") == media_type: + return url + + class ActorSerializer(jsonld.JsonLdSerializer): id = serializers.URLField(max_length=500) outbox = serializers.URLField(max_length=500, required=False) inbox = serializers.URLField(max_length=500, required=False) + url = serializers.ListField( + child=URLSerializer(jsonld_expand=False), required=False, min_length=0 + ) type = serializers.ChoiceField( choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES] ) @@ -144,6 +164,7 @@ class ActorSerializer(jsonld.JsonLdSerializer): "mediaType": jsonld.first_val(contexts.AS.mediaType), "endpoints": jsonld.first_obj(contexts.AS.endpoints), "icon": jsonld.first_obj(contexts.AS.icon), + "url": jsonld.raw(contexts.AS.url), } def to_representation(self, instance): @@ -165,6 +186,36 @@ class ActorSerializer(jsonld.JsonLdSerializer): if instance.summary_obj_id: ret["summary"] = instance.summary_obj.rendered + urls = [] + if instance.url: + urls.append( + {"type": "Link", "href": instance.url, "mediaType": "text/html"} + ) + + channel = instance.get_channel() + if channel: + ret["url"] = [ + { + "type": "Link", + "href": instance.channel.get_absolute_url() + if instance.channel.artist.is_local + else instance.get_absolute_url(), + "mediaType": "text/html", + }, + { + "type": "Link", + "href": instance.channel.get_rss_url(), + "mediaType": "application/rss+xml", + }, + ] + else: + ret["url"] = [ + { + "type": "Link", + "href": instance.get_absolute_url(), + "mediaType": "text/html", + } + ] ret["@context"] = jsonld.get_default_context() if instance.public_key: @@ -192,6 +243,10 @@ class ActorSerializer(jsonld.JsonLdSerializer): "name": self.validated_data.get("name"), "preferred_username": self.validated_data["preferredUsername"], } + url = get_by_media_type(self.validated_data.get("url", []), "text/html") + if url: + kwargs["url"] = url["href"] + maf = self.validated_data.get("manuallyApprovesFollowers") if maf is not None: kwargs["manually_approves_followers"] = maf diff --git a/api/tests/audio/test_models.py b/api/tests/audio/test_models.py index 5992e41ed..8a0d6e4b8 100644 --- a/api/tests/audio/test_models.py +++ b/api/tests/audio/test_models.py @@ -1,3 +1,8 @@ +from django.urls import reverse + +from funkwhale_api.federation import utils as federation_utils + + def test_channel(factories, now): channel = factories["audio.Channel"]() assert channel.artist is not None @@ -5,3 +10,16 @@ def test_channel(factories, now): assert channel.attributed_to is not None assert channel.library is not None assert channel.creation_date >= now + + +def test_channel_get_rss_url_local(factories): + channel = factories["audio.Channel"](artist__local=True) + expected = federation_utils.full_url( + reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid}) + ) + assert channel.get_rss_url() == expected + + +def test_channel_get_rss_url_remote(factories): + channel = factories["audio.Channel"]() + assert channel.get_rss_url() == channel.rss_url diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py index 9ef01c908..430673d63 100644 --- a/api/tests/audio/test_serializers.py +++ b/api/tests/audio/test_serializers.py @@ -134,6 +134,7 @@ def test_channel_serializer_representation(factories, to_api_date): channel.attributed_to ).data, "metadata": {}, + "rss_url": channel.get_rss_url(), } expected["artist"]["description"] = common_serializers.ContentSerializer( content diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index d5a781bd6..c6f7eb060 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -30,6 +30,7 @@ def test_actor_serializer_from_ap(db): "name": "Test", "summary": "Hello world", "manuallyApprovesFollowers": True, + "url": "http://hello.world/path", "publicKey": { "publicKeyPem": public.decode("utf-8"), "owner": actor_url, @@ -48,7 +49,7 @@ def test_actor_serializer_from_ap(db): actor = serializer.save() assert actor.fid == actor_url - assert actor.url is None + assert actor.url == payload["url"] assert actor.inbox_url == payload["inbox"] assert actor.shared_inbox_url == payload["endpoints"]["sharedInbox"] assert actor.outbox_url == payload["outbox"] @@ -108,6 +109,7 @@ def test_actor_serializer_to_ap(factories): "outbox": "https://test.federation/user/outbox", "preferredUsername": "user", "name": "Real User", + "url": [{"type": "Link", "href": "https://test.url", "mediaType": "text/html"}], "manuallyApprovesFollowers": False, "publicKey": { "id": "https://test.federation/user#main-key", @@ -120,13 +122,14 @@ def test_actor_serializer_to_ap(factories): fid=expected["id"], inbox_url=expected["inbox"], outbox_url=expected["outbox"], + url=expected["url"][0]["href"], 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"), + domain=models.Domain.objects.create(pk="test.domain"), type="Person", manually_approves_followers=False, attachment_icon=factories["common.Attachment"](), @@ -1112,7 +1115,7 @@ def test_activity_pub_audio_serializer_to_ap(factories): assert serializer.data == expected -def test_local_actor_serializer_to_ap(factories): +def test_local_actor_serializer_to_ap(factories, settings): expected = { "@context": jsonld.get_default_context(), "id": "https://test.federation/user", @@ -1155,6 +1158,15 @@ def test_local_actor_serializer_to_ap(factories): user.save() ac.refresh_from_db() expected["summary"] = content.rendered + expected["url"] = [ + { + "type": "Link", + "href": "https://{}/@{}".format( + settings.FUNKWHALE_HOSTNAME, ac.preferred_username + ), + "mediaType": "text/html", + } + ] expected["icon"] = { "type": "Image", "mediaType": "image/jpeg", @@ -1197,6 +1209,25 @@ def test_track_serializer_update_license(factories): assert obj.license_id == "cc-by-2.0" +def test_channel_actor_serializer(factories): + channel = factories["audio.Channel"]() + serializer = serializers.ActorSerializer(channel.actor) + expected_url = [ + { + "type": "Link", + "href": channel.actor.get_absolute_url(), + "mediaType": "text/html", + }, + { + "type": "Link", + "href": channel.get_rss_url(), + "mediaType": "application/rss+xml", + }, + ] + + assert serializer.data["url"] == expected_url + + def test_channel_actor_outbox_serializer(factories): channel = factories["audio.Channel"]() uploads = factories["music.Upload"].create_batch(