From eb66d4e3d2b5a0aa60a0122923cf416296e874b6 Mon Sep 17 00:00:00 2001 From: Agate Date: Tue, 28 Jul 2020 14:21:15 +0200 Subject: [PATCH] Expose public libraries and channels in standard API --- .../dynamic_preferences_registry.py | 10 + api/funkwhale_api/federation/serializers.py | 37 +++- api/funkwhale_api/federation/urls.py | 8 +- api/funkwhale_api/federation/views.py | 172 +++++++++++++----- api/funkwhale_api/instance/nodeinfo.py | 11 +- api/tests/federation/test_views.py | 110 +++++++++++ api/tests/instance/test_nodeinfo.py | 17 +- front/src/views/admin/Settings.vue | 1 + 8 files changed, 311 insertions(+), 55 deletions(-) diff --git a/api/funkwhale_api/federation/dynamic_preferences_registry.py b/api/funkwhale_api/federation/dynamic_preferences_registry.py index f51362d95..6f7d71719 100644 --- a/api/funkwhale_api/federation/dynamic_preferences_registry.py +++ b/api/funkwhale_api/federation/dynamic_preferences_registry.py @@ -53,3 +53,13 @@ class ActorFetchDelay(preferences.DefaultFromSettingMixin, types.IntPreference): "request authentication." ) field_kwargs = {"required": False} + + +@global_preferences_registry.register +class PublicIndex(types.BooleanPreference): + show_in_api = True + section = federation + name = "public_index" + default = True + verbose_name = "Enable public index" + help_text = "If this is enabled, public channels and libraries will be crawlable by other pods and bots" diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 299119e1f..00d5d1e4d 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -1096,9 +1096,6 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer): d = { "id": id, "partOf": conf["id"], - # XXX Stable release: remove the obsolete actor field - "actor": conf["actor"].fid, - "attributedTo": conf["actor"].fid, "totalItems": page.paginator.count, "type": "CollectionPage", "first": first, @@ -1110,6 +1107,10 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer): for i in page.object_list ], } + if conf["actor"]: + # XXX Stable release: remove the obsolete actor field + d["actor"] = conf["actor"].fid + d["attributedTo"] = conf["actor"].fid if page.has_previous(): d["prev"] = common_utils.set_query_parameter( @@ -2030,3 +2031,33 @@ class DeleteSerializer(jsonld.JsonLdSerializer): ): raise serializers.ValidationError("You cannot delete this object") return validated_data + + +class IndexSerializer(jsonld.JsonLdSerializer): + type = serializers.ChoiceField( + choices=[contexts.AS.Collection, contexts.AS.OrderedCollection] + ) + totalItems = serializers.IntegerField(min_value=0) + id = serializers.URLField(max_length=500) + first = serializers.URLField(max_length=500) + last = serializers.URLField(max_length=500) + + class Meta: + jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING + + def to_representation(self, conf): + paginator = Paginator(conf["items"], conf["page_size"]) + first = common_utils.set_query_parameter(conf["id"], page=1) + current = first + last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages) + d = { + "id": conf["id"], + "totalItems": paginator.count, + "type": "OrderedCollection", + "current": current, + "first": first, + "last": last, + } + if self.context.get("include_ap_context", True): + d["@context"] = jsonld.get_default_context() + return d diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py index a193087db..7bb6fc8a6 100644 --- a/api/funkwhale_api/federation/urls.py +++ b/api/funkwhale_api/federation/urls.py @@ -5,6 +5,7 @@ from . import views router = routers.SimpleRouter(trailing_slash=False) music_router = routers.SimpleRouter(trailing_slash=False) +index_router = routers.SimpleRouter(trailing_slash=False) router.register(r"federation/shared", views.SharedViewSet, "shared") router.register(r"federation/actors", views.ActorViewSet, "actors") @@ -17,6 +18,11 @@ music_router.register(r"uploads", views.MusicUploadViewSet, "uploads") music_router.register(r"artists", views.MusicArtistViewSet, "artists") music_router.register(r"albums", views.MusicAlbumViewSet, "albums") music_router.register(r"tracks", views.MusicTrackViewSet, "tracks") + + +index_router.register(r"index", views.IndexViewSet, "index") + urlpatterns = router.urls + [ - url("federation/music/", include((music_router.urls, "music"), namespace="music")) + url("federation/music/", include((music_router.urls, "music"), namespace="music")), + url("federation/", include((index_router.urls, "index"), namespace="index")), ] diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 2a26555fa..10a6ca972 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -9,6 +9,7 @@ from rest_framework.decorators import action from funkwhale_api.common import preferences from funkwhale_api.common import utils as common_utils +from funkwhale_api.federation import utils as federation_utils from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models from funkwhale_api.music import utils as music_utils @@ -31,6 +32,34 @@ def redirect_to_html(public_url): return response +def get_collection_response( + conf, querystring, collection_serializer, page_access_check=None +): + page = querystring.get("page") + if page is None: + data = collection_serializer.data + else: + if page_access_check and not page_access_check(): + raise exceptions.AuthenticationFailed( + "You do not have access to this resource" + ) + try: + page_number = int(page) + except Exception: + return response.Response({"page": ["Invalid page number"]}, status=400) + conf["page_size"] = preferences.get("federation__collection_page_size") + p = paginator.Paginator(conf["items"], conf["page_size"]) + try: + page = p.page(page_number) + conf["page"] = page + serializer = serializers.CollectionPageSerializer(conf) + data = serializer.data + except paginator.EmptyPage: + return response.Response(status=404) + + return response.Response(data) + + class AuthenticatedIfAllowListEnabled(permissions.BasePermission): def has_permission(self, request, view): allow_list_enabled = preferences.get("moderation__allow_list_enabled") @@ -128,26 +157,11 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV .prefetch_related("library__channel__actor", "track__artist"), "item_serializer": serializers.ChannelCreateUploadSerializer, } - page = request.GET.get("page") - if page is None: - serializer = serializers.ChannelOutboxSerializer(channel) - data = serializer.data - else: - try: - page_number = int(page) - except Exception: - return response.Response({"page": ["Invalid page number"]}, status=400) - conf["page_size"] = preferences.get("federation__collection_page_size") - p = paginator.Paginator(conf["items"], conf["page_size"]) - try: - page = p.page(page_number) - conf["page"] = page - serializer = serializers.CollectionPageSerializer(conf) - data = serializer.data - except paginator.EmptyPage: - return response.Response(status=404) - - return response.Response(data) + return get_collection_response( + conf=conf, + querystring=request.GET, + collection_serializer=serializers.ChannelOutboxSerializer(channel), + ) @action(methods=["get"], detail=True) def followers(self, request, *args, **kwargs): @@ -290,32 +304,13 @@ class MusicLibraryViewSet( ), "item_serializer": serializers.UploadSerializer, } - page = request.GET.get("page") - if page is None: - serializer = serializers.LibrarySerializer(lb) - data = serializer.data - else: - # if actor is requesting a specific page, we ensure library is public - # or readable by the actor - if not has_library_access(request, lb): - raise exceptions.AuthenticationFailed( - "You do not have access to this library" - ) - try: - page_number = int(page) - except Exception: - return response.Response({"page": ["Invalid page number"]}, status=400) - conf["page_size"] = preferences.get("federation__collection_page_size") - p = paginator.Paginator(conf["items"], conf["page_size"]) - try: - page = p.page(page_number) - conf["page"] = page - serializer = serializers.CollectionPageSerializer(conf) - data = serializer.data - except paginator.EmptyPage: - return response.Response(status=404) - return response.Response(data) + return get_collection_response( + conf=conf, + querystring=request.GET, + collection_serializer=serializers.LibrarySerializer(lb), + page_access_check=lambda: has_library_access(request, lb), + ) @action(methods=["get"], detail=True) def followers(self, request, *args, **kwargs): @@ -436,3 +431,90 @@ class MusicTrackViewSet( serializer = self.get_serializer(instance) return response.Response(serializer.data) + + +class ChannelViewSet( + FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): + authentication_classes = [authentication.SignatureAuthentication] + renderer_classes = renderers.get_ap_renderers() + queryset = music_models.Artist.objects.local().select_related( + "description", "attachment_cover" + ) + serializer_class = serializers.ArtistSerializer + lookup_field = "uuid" + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + if utils.should_redirect_ap_to_html(request.headers.get("accept")): + return redirect_to_html(instance.get_absolute_url()) + + serializer = self.get_serializer(instance) + return response.Response(serializer.data) + + +class IndexViewSet(FederationMixin, viewsets.GenericViewSet): + authentication_classes = [authentication.SignatureAuthentication] + renderer_classes = renderers.get_ap_renderers() + + def dispatch(self, request, *args, **kwargs): + if not preferences.get("federation__public_index"): + return HttpResponse(status=405) + return super().dispatch(request, *args, **kwargs) + + @action( + methods=["get"], detail=False, + ) + def libraries(self, request, *args, **kwargs): + libraries = ( + music_models.Library.objects.local() + .filter(channel=None, privacy_level="everyone") + .prefetch_related("actor") + .order_by("creation_date") + ) + conf = { + "id": federation_utils.full_url( + reverse("federation:index:index-libraries") + ), + "items": libraries, + "item_serializer": serializers.LibrarySerializer, + "page_size": 100, + "actor": None, + } + return get_collection_response( + conf=conf, + querystring=request.GET, + collection_serializer=serializers.IndexSerializer(conf), + ) + + return response.Response({}, status=200) + + @action( + methods=["get"], detail=False, + ) + def channels(self, request, *args, **kwargs): + actors = ( + models.Actor.objects.local() + .exclude(channel=None) + .order_by("channel__creation_date") + .prefetch_related( + "channel__attributed_to", + "channel__artist", + "channel__artist__description", + "channel__artist__attachment_cover", + ) + ) + conf = { + "id": federation_utils.full_url(reverse("federation:index:index-channels")), + "items": actors, + "item_serializer": serializers.ActorSerializer, + "page_size": 100, + "actor": None, + } + return get_collection_response( + conf=conf, + querystring=request.GET, + collection_serializer=serializers.IndexSerializer(conf), + ) + + return response.Response({}, status=200) diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py index 6c86c9b2b..b671680f5 100644 --- a/api/funkwhale_api/instance/nodeinfo.py +++ b/api/funkwhale_api/instance/nodeinfo.py @@ -67,7 +67,7 @@ def get(): "instance__funkwhale_support_message_enabled" ), "instanceSupportMessage": all_preferences.get("instance__support_message"), - "knownNodesListUrl": None, + "endpoints": {"knownNodes": None, "channels": None, "libraries": None}, }, } @@ -90,7 +90,14 @@ def get(): "downloads": {"total": statistics["downloads"]}, } if not auth_required: - data["metadata"]["knownNodesListUrl"] = federation_utils.full_url( + data["metadata"]["endpoints"]["knownNodes"] = federation_utils.full_url( reverse("api:v1:federation:domains-list") ) + if not auth_required and preferences.get("federation__public_index"): + data["metadata"]["endpoints"]["libraries"] = federation_utils.full_url( + reverse("federation:index:index-libraries") + ) + data["metadata"]["endpoints"]["channels"] = federation_utils.full_url( + reverse("federation:index:index-channels") + ) return data diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 10da31b3c..f6528bab2 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -517,3 +517,113 @@ def test_artist_retrieve_redirects_to_html_if_header_set( ) assert response.status_code == 302 assert response["Location"] == expected_url + + +@pytest.mark.parametrize("index", ["channels", "libraries"]) +def test_public_index_disabled(index, api_client, preferences): + preferences["federation__public_index"] = False + url = reverse("federation:index:index-{}".format(index)) + response = api_client.get(url) + + assert response.status_code == 405 + + +def test_index_channels_retrieve(factories, api_client): + channels = [ + factories["audio.Channel"](actor__local=True), + factories["audio.Channel"](actor__local=True), + factories["audio.Channel"](actor__local=True), + ] + expected = serializers.IndexSerializer( + { + "id": federation_utils.full_url(reverse("federation:index:index-channels")), + "items": channels[0].__class__.objects.order_by("creation_date"), + "page_size": 100, + }, + ).data + + url = reverse("federation:index:index-channels",) + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected + + +def test_index_channels_page(factories, api_client, preferences): + preferences["federation__collection_page_size"] = 1 + remote_actor = factories["federation.Actor"]() + channels = [ + factories["audio.Channel"](actor__local=True), + factories["audio.Channel"](actor__local=True), + factories["audio.Channel"](actor__local=True), + factories["audio.Channel"](actor=remote_actor), + ] + id = federation_utils.full_url(reverse("federation:index:index-channels")) + expected = serializers.CollectionPageSerializer( + { + "id": id, + "item_serializer": serializers.ActorSerializer, + "page": Paginator([c.actor for c in channels][:3], 1).page(1), + "actor": None, + } + ).data + + url = reverse("federation:index:index-channels") + response = api_client.get(url, {"page": 1}) + + assert response.status_code == 200 + assert response.data == expected + + +def test_index_libraries_retrieve(factories, api_client): + remote_actor = factories["federation.Actor"]() + libraries = [ + factories["music.Library"](actor__local=True, privacy_level="everyone"), + factories["music.Library"](actor__local=True, privacy_level="everyone"), + factories["music.Library"](actor__local=True, privacy_level="me"), + factories["music.Library"](actor=remote_actor, privacy_level="everyone"), + ] + expected = serializers.IndexSerializer( + { + "id": federation_utils.full_url( + reverse("federation:index:index-libraries") + ), + "items": libraries[0] + .__class__.objects.local() + .filter(privacy_level="everyone") + .order_by("creation_date"), + "page_size": 100, + }, + ).data + + url = reverse("federation:index:index-libraries") + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected + + +def test_index_libraries_page(factories, api_client, preferences): + preferences["federation__collection_page_size"] = 1 + remote_actor = factories["federation.Actor"]() + libraries = [ + factories["music.Library"](actor__local=True, privacy_level="everyone"), + factories["music.Library"](actor__local=True, privacy_level="everyone"), + factories["music.Library"](actor__local=True, privacy_level="me"), + factories["music.Library"](actor=remote_actor, privacy_level="everyone"), + ] + id = federation_utils.full_url(reverse("federation:index:index-libraries")) + expected = serializers.CollectionPageSerializer( + { + "id": id, + "item_serializer": serializers.LibrarySerializer, + "page": Paginator(libraries[:2], 1).page(1), + "actor": None, + } + ).data + + url = reverse("federation:index:index-libraries") + response = api_client.get(url, {"page": 1}) + + assert response.status_code == 200 + assert response.data == expected diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py index 04bdfe6f5..0f820e691 100644 --- a/api/tests/instance/test_nodeinfo.py +++ b/api/tests/instance/test_nodeinfo.py @@ -93,9 +93,17 @@ def test_nodeinfo_dump(preferences, mocker, avatar): "instance__funkwhale_support_message_enabled" ], "instanceSupportMessage": preferences["instance__support_message"], - "knownNodesListUrl": federation_utils.full_url( - reverse("api:v1:federation:domains-list") - ), + "endpoints": { + "knownNodes": federation_utils.full_url( + reverse("api:v1:federation:domains-list") + ), + "libraries": federation_utils.full_url( + reverse("federation:index:index-libraries") + ), + "channels": federation_utils.full_url( + reverse("federation:index:index-channels") + ), + }, }, } assert nodeinfo.get() == expected @@ -103,6 +111,7 @@ def test_nodeinfo_dump(preferences, mocker, avatar): def test_nodeinfo_dump_stats_disabled(preferences, mocker): preferences["instance__nodeinfo_stats_enabled"] = False + preferences["federation__public_index"] = False preferences["moderation__unauthenticated_report_types"] = [ "takedown_request", "other", @@ -161,7 +170,7 @@ def test_nodeinfo_dump_stats_disabled(preferences, mocker): "instance__funkwhale_support_message_enabled" ], "instanceSupportMessage": preferences["instance__support_message"], - "knownNodesListUrl": None, + "endpoints": {"knownNodes": None, "libraries": None, "channels": None}, }, } assert nodeinfo.get() == expected diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue index 4381e07ac..30f79f829 100644 --- a/front/src/views/admin/Settings.vue +++ b/front/src/views/admin/Settings.vue @@ -159,6 +159,7 @@ export default { id: "federation", settings: [ {name: "federation__enabled"}, + {name: "federation__public_index"}, {name: "federation__collection_page_size"}, {name: "federation__music_cache_duration"}, {name: "federation__actor_fetch_delay"},