Skip to content
Snippets Groups Projects
Commit 2ef87234 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '192-nodeinfo' into 'develop'

Resolve "Use nodeinfo schema for instance statistics"

Closes #192

See merge request funkwhale/funkwhale!187
parents 00c71740 cdc83881
No related branches found
No related tags found
No related merge requests found
......@@ -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'])
......
......@@ -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.'
)
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
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'),
]
......@@ -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)
......@@ -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
......
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
......@@ -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)
......
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
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
......@@ -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
......
......@@ -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/ {
......
......@@ -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
})
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment