From eb66d4e3d2b5a0aa60a0122923cf416296e874b6 Mon Sep 17 00:00:00 2001
From: Agate <me@agate.blue>
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 f51362d953..6f7d717195 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 299119e1f5..00d5d1e4d1 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 a193087dbf..7bb6fc8a6c 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 2a26555fa5..10a6ca9726 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 6c86c9b2b5..b671680f52 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 10da31b3ce..f6528bab2a 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 04bdfe6f57..0f820e691f 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 4381e07acd..30f79f8293 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"},
-- 
GitLab