diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 9b51a534df506169be39b66d75becca94bb5d90c..ef581408c2e187bf32f3922c651b903f3583884e 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -85,13 +85,31 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet): return response.Response({}, status=200) -class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet): +class WellKnownViewSet(viewsets.GenericViewSet): authentication_classes = [] permission_classes = [] renderer_classes = [renderers.WebfingerRenderer] + @list_route(methods=['get']) + def nodeinfo(self, request, *args, **kwargs): + if not preferences.get('instance__nodeinfo_enabled'): + return HttpResponse(status=404) + data = { + 'links': [ + { + 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', + 'href': utils.full_url( + reverse('api:v1:instance:nodeinfo-2.0') + ) + } + ] + } + return response.Response(data) + @list_route(methods=['get']) def webfinger(self, request, *args, **kwargs): + if not preferences.get('federation__enabled'): + return HttpResponse(status=405) try: resource_type, resource = webfinger.clean_resource( request.GET['resource']) diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py index 1d11a2988ca1d76da7b61d8ef8657ac6cdee74df..03555b0be57bde18f4fe13ba1b62d852ffd93ae4 100644 --- a/api/funkwhale_api/instance/dynamic_preferences_registry.py +++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py @@ -68,3 +68,31 @@ class RavenEnabled(types.BooleanPreference): 'Wether error reporting to a Sentry instance using raven is enabled' ' for front-end errors' ) + + +@global_preferences_registry.register +class InstanceNodeinfoEnabled(types.BooleanPreference): + show_in_api = False + section = instance + name = 'nodeinfo_enabled' + default = True + verbose_name = 'Enable nodeinfo endpoint' + help_text = ( + 'This endpoint is needed for your about page to work.' + 'It\'s also helpful for the various monitoring ' + 'tools that map and analyzize the fediverse, ' + 'but you can disable it completely if needed.' + ) + + +@global_preferences_registry.register +class InstanceNodeinfoStatsEnabled(types.BooleanPreference): + show_in_api = False + section = instance + name = 'nodeinfo_stats_enabled' + default = True + verbose_name = 'Enable usage and library stats in nodeinfo endpoint' + help_text = ( + 'Disable this f you don\'t want to share usage and library statistics' + 'in the nodeinfo endpoint but don\'t want to disable it completely.' + ) diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py new file mode 100644 index 0000000000000000000000000000000000000000..e267f197d17ce7c317128c684f1db8f72a1a47ba --- /dev/null +++ b/api/funkwhale_api/instance/nodeinfo.py @@ -0,0 +1,73 @@ +import memoize.djangocache + +import funkwhale_api +from funkwhale_api.common import preferences + +from . import stats + + +store = memoize.djangocache.Cache('default') +memo = memoize.Memoizer(store, namespace='instance:stats') + + +def get(): + share_stats = preferences.get('instance__nodeinfo_stats_enabled') + data = { + 'version': '2.0', + 'software': { + 'name': 'funkwhale', + 'version': funkwhale_api.__version__ + }, + 'protocols': ['activitypub'], + 'services': { + 'inbound': [], + 'outbound': [] + }, + 'openRegistrations': preferences.get('users__registration_enabled'), + 'usage': { + 'users': { + 'total': 0, + } + }, + 'metadata': { + 'shortDescription': preferences.get('instance__short_description'), + 'longDescription': preferences.get('instance__long_description'), + 'nodeName': preferences.get('instance__name'), + 'library': { + 'federationEnabled': preferences.get('federation__enabled'), + 'federationNeedsApproval': preferences.get('federation__music_needs_approval'), + 'anonymousCanListen': preferences.get('common__api_authentication_required'), + }, + } + } + if share_stats: + getter = memo( + lambda: stats.get(), + max_age=600 + ) + statistics = getter() + data['usage']['users']['total'] = statistics['users'] + data['metadata']['library']['tracks'] = { + 'total': statistics['tracks'], + } + data['metadata']['library']['artists'] = { + 'total': statistics['artists'], + } + data['metadata']['library']['albums'] = { + 'total': statistics['albums'], + } + data['metadata']['library']['music'] = { + 'hours': statistics['music_duration'] + } + + data['metadata']['usage'] = { + 'favorites': { + 'tracks': { + 'total': statistics['track_favorites'], + } + }, + 'listenings': { + 'total': statistics['listenings'] + } + } + return data diff --git a/api/funkwhale_api/instance/urls.py b/api/funkwhale_api/instance/urls.py index af23e7e08433b97c6c10f07e1b929892e5dbe32c..f506488fc4db7819da6aae5d5c79fe33e8a9af5c 100644 --- a/api/funkwhale_api/instance/urls.py +++ b/api/funkwhale_api/instance/urls.py @@ -1,11 +1,9 @@ from django.conf.urls import url -from django.views.decorators.cache import cache_page from . import views urlpatterns = [ + url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'), url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'), - url(r'^stats/$', - cache_page(60 * 5)(views.InstanceStats.as_view()), name='stats'), ] diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py index 7f8f393c964e24bfc7a5ee29bf6be2e30f337188..5953ca555a3081d5e58a1d60da1d3dec58279e3b 100644 --- a/api/funkwhale_api/instance/views.py +++ b/api/funkwhale_api/instance/views.py @@ -4,9 +4,17 @@ from rest_framework.response import Response from dynamic_preferences.api import serializers from dynamic_preferences.registries import global_preferences_registry +from funkwhale_api.common import preferences + +from . import nodeinfo from . import stats +NODEINFO_2_CONTENT_TYPE = ( + 'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8' # noqa +) + + class InstanceSettings(views.APIView): permission_classes = [] authentication_classes = [] @@ -27,10 +35,13 @@ class InstanceSettings(views.APIView): return Response(data, status=200) -class InstanceStats(views.APIView): +class NodeInfo(views.APIView): permission_classes = [] authentication_classes = [] def get(self, request, *args, **kwargs): - data = stats.get() - return Response(data, status=200) + if not preferences.get('instance__nodeinfo_enabled'): + return Response(status=404) + data = nodeinfo.get() + return Response( + data, status=200, content_type=NODEINFO_2_CONTENT_TYPE) diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 09ecfc8ff7f6d192808890b78eb9d3226ad8cc7e..cc81f0657754960d1389bfe36bb6c02787f4c5a3 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -70,6 +70,32 @@ def test_wellknown_webfinger_system( assert response.data == serializer.data +def test_wellknown_nodeinfo(db, preferences, api_client, settings): + expected = { + 'links': [ + { + 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', + 'href': '{}{}'.format( + settings.FUNKWHALE_URL, + reverse('api:v1:instance:nodeinfo-2.0') + ) + } + ] + } + url = reverse('federation:well-known-nodeinfo') + response = api_client.get(url) + assert response.status_code == 200 + assert response['Content-Type'] == 'application/jrd+json' + assert response.data == expected + + +def test_wellknown_nodeinfo_disabled(db, preferences, api_client): + preferences['instance__nodeinfo_enabled'] = False + url = reverse('federation:well-known-nodeinfo') + response = api_client.get(url) + assert response.status_code == 404 + + def test_audio_file_list_requires_authenticated_actor( db, preferences, api_client): preferences['federation__music_needs_approval'] = True diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py new file mode 100644 index 0000000000000000000000000000000000000000..4ca1c43a5835f94a6db20018a76e456c5e1b7e28 --- /dev/null +++ b/api/tests/instance/test_nodeinfo.py @@ -0,0 +1,105 @@ +from django.urls import reverse + +import funkwhale_api + +from funkwhale_api.instance import nodeinfo + + +def test_nodeinfo_dump(preferences, mocker): + preferences['instance__nodeinfo_stats_enabled'] = True + stats = { + 'users': 1, + 'tracks': 2, + 'albums': 3, + 'artists': 4, + 'track_favorites': 5, + 'music_duration': 6, + 'listenings': 7, + } + mocker.patch('funkwhale_api.instance.stats.get', return_value=stats) + + expected = { + 'version': '2.0', + 'software': { + 'name': 'funkwhale', + 'version': funkwhale_api.__version__ + }, + 'protocols': ['activitypub'], + 'services': { + 'inbound': [], + 'outbound': [] + }, + 'openRegistrations': preferences['users__registration_enabled'], + 'usage': { + 'users': { + 'total': stats['users'], + } + }, + 'metadata': { + 'shortDescription': preferences['instance__short_description'], + 'longDescription': preferences['instance__long_description'], + 'nodeName': preferences['instance__name'], + 'library': { + 'federationEnabled': preferences['federation__enabled'], + 'federationNeedsApproval': preferences['federation__music_needs_approval'], + 'anonymousCanListen': preferences['common__api_authentication_required'], + 'tracks': { + 'total': stats['tracks'], + }, + 'artists': { + 'total': stats['artists'], + }, + 'albums': { + 'total': stats['albums'], + }, + 'music': { + 'hours': stats['music_duration'] + }, + }, + 'usage': { + 'favorites': { + 'tracks': { + 'total': stats['track_favorites'], + } + }, + 'listenings': { + 'total': stats['listenings'] + } + } + } + } + assert nodeinfo.get() == expected + + +def test_nodeinfo_dump_stats_disabled(preferences, mocker): + preferences['instance__nodeinfo_stats_enabled'] = False + + expected = { + 'version': '2.0', + 'software': { + 'name': 'funkwhale', + 'version': funkwhale_api.__version__ + }, + 'protocols': ['activitypub'], + 'services': { + 'inbound': [], + 'outbound': [] + }, + 'openRegistrations': preferences['users__registration_enabled'], + 'usage': { + 'users': { + 'total': 0, + } + }, + 'metadata': { + 'shortDescription': preferences['instance__short_description'], + 'longDescription': preferences['instance__long_description'], + 'nodeName': preferences['instance__name'], + 'library': { + 'federationEnabled': preferences['federation__enabled'], + 'federationNeedsApproval': preferences['federation__music_needs_approval'], + 'anonymousCanListen': preferences['common__api_authentication_required'], + }, + } + } + assert nodeinfo.get() == expected diff --git a/api/tests/instance/test_stats.py b/api/tests/instance/test_stats.py index 6eaad76f7f9d8292211965a462f473c8bb41745a..6063e9300512819a9cc3e6d6bebcc477af9a59d7 100644 --- a/api/tests/instance/test_stats.py +++ b/api/tests/instance/test_stats.py @@ -3,16 +3,6 @@ from django.urls import reverse from funkwhale_api.instance import stats -def test_can_get_stats_via_api(db, api_client, mocker): - stats = { - 'foo': 'bar' - } - mocker.patch('funkwhale_api.instance.stats.get', return_value=stats) - url = reverse('api:v1:instance:stats') - response = api_client.get(url) - assert response.data == stats - - def test_get_users(mocker): mocker.patch( 'funkwhale_api.users.models.User.objects.count', return_value=42) diff --git a/api/tests/instance/test_views.py b/api/tests/instance/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..468c0ddae9de440b3edce7fd65fdc57c6ead8fff --- /dev/null +++ b/api/tests/instance/test_views.py @@ -0,0 +1,23 @@ +from django.urls import reverse + + +def test_nodeinfo_endpoint(db, api_client, mocker): + payload = { + 'test': 'test' + } + mocked_nodeinfo = mocker.patch( + 'funkwhale_api.instance.nodeinfo.get', return_value=payload) + url = reverse('api:v1:instance:nodeinfo-2.0') + response = api_client.get(url) + ct = 'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8' # noqa + assert response.status_code == 200 + assert response['Content-Type'] == ct + assert response.data == payload + + +def test_nodeinfo_endpoint_disabled(db, api_client, preferences): + preferences['instance__nodeinfo_enabled'] = False + url = reverse('api:v1:instance:nodeinfo-2.0') + response = api_client.get(url) + + assert response.status_code == 404 diff --git a/changes/changelog.d/192.feature b/changes/changelog.d/192.feature new file mode 100644 index 0000000000000000000000000000000000000000..caa8e60c15121f81ca594fcd9beeab55ab30c1bb --- /dev/null +++ b/changes/changelog.d/192.feature @@ -0,0 +1,76 @@ +Use nodeinfo standard for publishing instance information (#192) + +Nodeinfo standard for instance information and stats +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. warning:: + + The ``/api/v1/instance/stats/`` endpoint which was used to display + instance data in the about page is removed in favor of the new + ``/api/v1/instance/nodeinfo/2.0/`` endpoint. + +In earlier version, we where using a custom endpoint and format for +our instance information and statistics. While this was working, +this was not compatible with anything else on the fediverse. + +We now offer a nodeinfo 2.0 endpoint which provides, in a single place, +all the instance information such as library and user activity statistics, +public instance settings (description, registration and federation status, etc.). + +We offer two settings to manage nodeinfo in your Funkwhale instance: + +1. One setting to completely disable nodeinfo, but this is not recommended + as the exposed data may be needed to make some parts of the front-end + work (especially the about page). +2. One setting to disable only usage and library statistics in the nodeinfo + endpoint. This is useful if you want the nodeinfo endpoint to work, + but don't feel comfortable sharing aggregated statistics about your library + and user activity. + +To make your instance fully compatible with the nodeinfo protocol, you need to +to edit your nginx configuration file: + +.. code-block:: + + # before + ... + location /.well-known/webfinger { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/.well-known/webfinger; + } + ... + + # after + ... + location /.well-known/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/.well-known/; + } + ... + +You can do the same if you use apache: + +.. code-block:: + + # before + ... + <Location "/.well-known/webfinger"> + ProxyPass ${funkwhale-api}/.well-known/webfinger + ProxyPassReverse ${funkwhale-api}/.well-known/webfinger + </Location> + ... + + # after + ... + <Location "/.well-known/"> + ProxyPass ${funkwhale-api}/.well-known/ + ProxyPassReverse ${funkwhale-api}/.well-known/ + </Location> + ... + +This will ensure all well-known endpoints are proxied to funkwhale, and +not just webfinger one. + +Links: + +- About nodeinfo: https://github.com/jhass/nodeinfo diff --git a/deploy/apache.conf b/deploy/apache.conf index 8d5a5e1f7ee45c7a02f4f8654309744f841751c7..5bfcbce04587e96fe949f21e6c50b4e05c77360e 100644 --- a/deploy/apache.conf +++ b/deploy/apache.conf @@ -84,9 +84,9 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music ProxyPassReverse ${funkwhale-api}/federation </Location> - <Location "/.well-known/webfinger"> - ProxyPass ${funkwhale-api}/.well-known/webfinger - ProxyPassReverse ${funkwhale-api}/.well-known/webfinger + <Location "/.well-known/"> + ProxyPass ${funkwhale-api}/.well-known/ + ProxyPassReverse ${funkwhale-api}/.well-known/ </Location> Alias /media /srv/funkwhale/data/media diff --git a/deploy/nginx.conf b/deploy/nginx.conf index b3a4c6aaf762830b533774689cb26266ca6b65e8..7d344408b67ad8c46e2d7a161180ba928ebb31aa 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -67,9 +67,9 @@ server { proxy_pass http://funkwhale-api/federation/; } - location /.well-known/webfinger { + location /.well-known/ { include /etc/nginx/funkwhale_proxy.conf; - proxy_pass http://funkwhale-api/.well-known/webfinger; + proxy_pass http://funkwhale-api/.well-known/; } location /media/ { diff --git a/front/src/components/instance/Stats.vue b/front/src/components/instance/Stats.vue index 7da9fc6ede056c4174f5dc6004f5c6233664008b..ac144ceb3a910e50e59ea9f9efd0575d2c037a0a 100644 --- a/front/src/components/instance/Stats.vue +++ b/front/src/components/instance/Stats.vue @@ -3,7 +3,7 @@ <div v-if="stats" class="ui stackable two column grid"> <div class="column"> <h3 class="ui left aligned header"><i18next path="User activity"/></h3> - <div class="ui mini horizontal statistics"> + <div v-if="stats" class="ui mini horizontal statistics"> <div class="statistic"> <div class="value"> <i class="green user icon"></i> @@ -19,7 +19,7 @@ </div> <div class="statistic"> <div class="value"> - <i class="pink heart icon"></i> {{ stats.track_favorites }} + <i class="pink heart icon"></i> {{ stats.trackFavorites }} </div> <i18next tag="div" class="label" path="Tracks favorited"/> </div> @@ -30,7 +30,7 @@ <div class="ui mini horizontal statistics"> <div class="statistic"> <div class="value"> - {{ parseInt(stats.music_duration) }} + {{ parseInt(stats.musicDuration) }} </div> <i18next tag="div" class="label" path="hours of music"/> </div> @@ -59,6 +59,7 @@ </template> <script> +import _ from 'lodash' import axios from 'axios' import logger from '@/logging' @@ -76,8 +77,16 @@ export default { var self = this this.isLoading = true logger.default.debug('Fetching instance stats...') - axios.get('instance/stats/').then((response) => { - self.stats = response.data + axios.get('instance/nodeinfo/2.0/').then((response) => { + let d = response.data + self.stats = {} + self.stats.users = _.get(d, 'usage.users.total') + self.stats.listenings = _.get(d, 'metadata.usage.listenings.total') + self.stats.trackFavorites = _.get(d, 'metadata.usage.favorites.tracks.total') + self.stats.musicDuration = _.get(d, 'metadata.library.music.hours') + self.stats.artists = _.get(d, 'metadata.library.artists.total') + self.stats.albums = _.get(d, 'metadata.library.albums.total') + self.stats.tracks = _.get(d, 'metadata.library.tracks.total') self.isLoading = false }) }