Fixed plugin config

This commit is contained in:
Eliot Berriot 2019-09-24 12:13:43 +02:00
parent fffd5a6972
commit d8f70b137d
No known key found for this signature in database
GPG Key ID: 6B501DFD73514E14
12 changed files with 270 additions and 68 deletions

View File

@ -229,6 +229,7 @@ MIDDLEWARE = (
"django.middleware.security.SecurityMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"funkwhale_api.plugins.middleware.AttachPluginsConfMiddleware",
"funkwhale_api.common.middleware.SPAFallbackMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
@ -616,12 +617,12 @@ REST_FRAMEWORK = {
"funkwhale_api.federation.parsers.ActivityParser",
),
"DEFAULT_AUTHENTICATION_CLASSES": (
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
"funkwhale_api.common.authentication.OAuth2Authentication",
"funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS",
"funkwhale_api.common.authentication.BearerTokenHeaderAuth",
"funkwhale_api.common.authentication.JSONWebTokenAuthentication",
"rest_framework.authentication.BasicAuthentication",
"rest_framework.authentication.SessionAuthentication",
"funkwhale_api.common.authentication.BasicAuthentication",
"funkwhale_api.common.authentication.SessionAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"funkwhale_api.users.oauth.permissions.ScopePermission",

View File

@ -1,11 +1,24 @@
from django.utils.encoding import smart_text
from django.utils.translation import ugettext as _
from oauth2_provider.contrib.rest_framework import (
OAuth2Authentication as BaseOAuth2Authentication,
)
from rest_framework import exceptions
from rest_framework_jwt import authentication
from rest_framework_jwt.settings import api_settings
from rest_framework.authentication import BasicAuthentication as BaseBasicAuthentication
from rest_framework.authentication import (
SessionAuthentication as BaseSessionAuthentication,
)
class JSONWebTokenAuthenticationQS(authentication.BaseJSONWebTokenAuthentication):
from funkwhale_api.plugins import authentication as plugin_authentication
class JSONWebTokenAuthenticationQS(
plugin_authentication.AttachPluginsConfMixin,
authentication.BaseJSONWebTokenAuthentication,
):
www_authenticate_realm = "api"
@ -22,7 +35,10 @@ class JSONWebTokenAuthenticationQS(authentication.BaseJSONWebTokenAuthentication
)
class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication):
class BearerTokenHeaderAuth(
plugin_authentication.AttachPluginsConfMixin,
authentication.BaseJSONWebTokenAuthentication,
):
"""
For backward compatibility purpose, we used Authorization: JWT <token>
but Authorization: Bearer <token> is probably better.
@ -65,7 +81,10 @@ class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication):
return auth
class JSONWebTokenAuthentication(authentication.JSONWebTokenAuthentication):
class JSONWebTokenAuthentication(
plugin_authentication.AttachPluginsConfMixin,
authentication.JSONWebTokenAuthentication,
):
def authenticate(self, request):
auth = super().authenticate(request)
@ -73,3 +92,21 @@ class JSONWebTokenAuthentication(authentication.JSONWebTokenAuthentication):
if not auth[0].actor:
auth[0].create_actor()
return auth
class OAuth2Authentication(
plugin_authentication.AttachPluginsConfMixin, BaseOAuth2Authentication
):
pass
class BasicAuthentication(
plugin_authentication.AttachPluginsConfMixin, BaseBasicAuthentication
):
pass
class SessionAuthentication(
plugin_authentication.AttachPluginsConfMixin, BaseSessionAuthentication
):
pass

View File

@ -3,6 +3,8 @@ from django.utils import timezone
from funkwhale_api.music.models import Track
from . import signals
class Listening(models.Model):
creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)

View File

@ -0,0 +1,6 @@
from funkwhale_api import plugins
plugins.hooks.register(
plugins.Hook("history.listening.created", providing_args=["listening"])
)

View File

@ -5,6 +5,8 @@ from django import apps
import logging
from funkwhale_api.common import session
from . import config
logger = logging.getLogger(__name__)
@ -12,22 +14,28 @@ logger = logging.getLogger(__name__)
class Plugin(apps.AppConfig):
_is_funkwhale_plugin = True
version = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.hooks = HookRegistry()
self.settings = SettingRegistry()
self.user_settings = SettingRegistry()
self.logger = None
def ready(self):
super().ready()
logging.info("Loading plugin %s", self.label)
self.logger = logging.getLogger("funkwhale_api.plugin.{}".format(self.name))
self.load()
logging.info("Plugin %s loaded", self.label)
def load(self):
pass
def get_requests_session(self):
return session.get_session()
class FuncRegistry(persisting_theory.Registry):
def connect(self, hook_name):
@ -77,6 +85,12 @@ class SignalsRegistry(persisting_theory.Registry):
"""
Call all handlers connected to hook_name in turn.
"""
from django.conf import settings
if not plugins_conf:
logger.debug("[Plugin:hook:dispatch] No plugin configured")
return
if hook_name not in self:
raise LookupError(hook_name)
logger.debug("[Plugin:hook:%s] Dispatching hook", hook_name)
@ -105,14 +119,20 @@ class SignalsRegistry(persisting_theory.Registry):
handler(plugin_conf=row, **kwargs)
except Skip:
logger.debug("[Plugin:hook:%s] handler skipped", hook_name)
except Exception:
except Exception as e:
logger.exception(
"[Plugin:hook:%s] unknown exception with handler %s",
hook_name,
handler,
)
if settings.PLUGINS_FAIL_LOUDLY:
raise e
else:
logger.debug("[Plugin:hook:%s] handler %s called successfully", handler)
logger.debug(
"[Plugin:hook:%s] handler %s called successfully",
hook_name,
handler,
)
logger.debug("[Plugin:hook:%s] Done", hook_name)
@ -155,6 +175,14 @@ def generate_plugins_conf(plugins, user=None):
"settings": by_plugin_name[plugin.name] or {},
}
plugins_conf.append(conf)
return plugins_conf
def update_plugins_conf_with_user_settings(plugins_conf, user):
if not plugins_conf:
return
from . import models
if plugins_conf and user and user.is_authenticated:
qs = models.UserPlugin.objects.filter(
@ -170,12 +198,12 @@ def generate_plugins_conf(plugins, user=None):
return plugins_conf
def attach_plugins_conf(obj, user):
def attach_plugins_conf(obj):
from funkwhale_api.common import preferences
plugins_enabled = preferences.get("plugins__enabled")
if plugins_enabled:
conf = generate_plugins_conf(plugins=get_all_plugins(), user=user)
conf = generate_plugins_conf(plugins=get_all_plugins())
else:
conf = None
setattr(obj, "plugins_conf", conf)

View File

@ -0,0 +1,14 @@
from funkwhale_api import plugins
class AttachPluginsConfMixin(object):
def authenticate(self, request):
auth = super().authenticate(request)
self.update_plugins_conf(request, auth)
return auth
def update_plugins_conf(self, request, auth):
if auth:
plugins.update_plugins_conf_with_user_settings(
getattr(request, "plugins_conf", []), user=auth[0]
)

View File

@ -0,0 +1,10 @@
from funkwhale_api import plugins
class AttachPluginsConfMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
plugins.attach_plugins_conf(request)
return self.get_response(request)

View File

@ -5,6 +5,8 @@ from rest_framework import authentication, exceptions
from funkwhale_api.users.models import User
from funkwhale_api.plugins import authentication as plugin_authentication
def get_token(salt, password):
to_hash = password + salt
@ -41,8 +43,15 @@ def authenticate_salt(username, salt, token):
return (user, None)
class SubsonicAuthentication(authentication.BaseAuthentication):
class SubsonicAuthentication(
plugin_authentication.AttachPluginsConfMixin, authentication.BaseAuthentication
):
def authenticate(self, request):
auth = self.perform_authentication(request)
self.update_plugins_conf(request, auth)
return auth
def perform_authentication(self, request):
data = request.GET or request.POST
username = data.get("u")
if not username:
@ -56,5 +65,4 @@ class SubsonicAuthentication(authentication.BaseAuthentication):
if p:
return authenticate(username, p)
return authenticate_salt(username, s, t)

View File

@ -85,90 +85,81 @@ def test_get_all_plugins(mocker):
def test_generate_plugins_conf(factories, plugin_class):
plugin1 = plugin_class("test1", "test1")
plugin2 = plugin_class("test2", "test2")
plugin3 = plugin_class("test3", "test3")
plugin4 = plugin_class("test4", "test4")
plugin2 = plugin_class("test3", "test3")
plugin3 = plugin_class("test4", "test4")
user = factories["users.User"]()
# this one is enabled
plugin1_db_conf = factories["plugins.Plugin"](name=plugin1.name)
# this one is enabled, with additional user-level configuration (see below)
plugin2_db_conf = factories["plugins.Plugin"](name=plugin2.name)
# this one is disabled at the plugin level, so it shouldn't appear in the final conf
plugin3_db_conf = factories["plugins.Plugin"](name=plugin3.name, is_enabled=False)
# this one is enabled, but disabled at user level (see below)
plugin4_db_conf = factories["plugins.Plugin"](name=plugin4.name)
factories["plugins.Plugin"](name=plugin3.name, is_enabled=False)
# this one doesn't match any registered app
factories["plugins.Plugin"](name="noop")
# a user-level configuration but with a different user, so irrelevant
factories["plugins.UserPlugin"](plugin=plugin1_db_conf)
# a user-level configuration but the plugin is disabled
factories["plugins.UserPlugin"](user=user, plugin=plugin3_db_conf)
# a user-level configuration, plugin is enabled, should be reflected in the final conf
plugin2_user_db_conf = factories["plugins.UserPlugin"](
user=user, plugin=plugin2_db_conf
)
# a user-level configuration, plugin is enabled but disabled by user, should be reflected in the final conf
factories["plugins.UserPlugin"](user=user, plugin=plugin4_db_conf, is_enabled=False)
expected = [{"obj": plugin1, "settings": plugin1_db_conf.config, "user": None}]
expected = [
{"obj": plugin1, "settings": plugin1_db_conf.config, "user": None},
{
"obj": plugin2,
"settings": plugin2_db_conf.config,
"user": {"id": user.pk, "settings": plugin2_user_db_conf.config},
},
{"obj": plugin4, "settings": plugin4_db_conf.config, "user": None},
]
conf = plugins.generate_plugins_conf([plugin1, plugin2, plugin3, plugin4], user=user)
conf = plugins.generate_plugins_conf([plugin1, plugin2, plugin3])
assert conf == expected
def test_generate_plugins_conf_anonymous_user(factories, plugin_class):
def test_update_plugins_conf_with_user_settings(factories, plugin_class):
plugin1 = plugin_class("test1", "test1")
plugin2 = plugin_class("test2", "test2")
plugin3 = plugin_class("test3", "test3")
plugin4 = plugin_class("test4", "test4")
user = factories["users.User"]()
# this one is enabled
plugin1_db_conf = factories["plugins.Plugin"](name=plugin1.name)
# this one is enabled, with additional user-level configuration (see below)
plugin2_db_conf = factories["plugins.Plugin"](name=plugin2.name)
# this one is disabled at the plugin level, so it shouldn't appear in the final conf
plugin3_db_conf = factories["plugins.Plugin"](name=plugin3.name, is_enabled=False)
# this one is enabled, but disabled at user level (see below)
plugin4_db_conf = factories["plugins.Plugin"](name=plugin4.name)
# this one doesn't match any registered app
factories["plugins.Plugin"](name="noop")
# a user-level configuration but with a different user, so irrelevant
factories["plugins.UserPlugin"](plugin=plugin1_db_conf)
# a user-level configuration but the plugin is disabled
factories["plugins.UserPlugin"](user=user, plugin=plugin3_db_conf)
# user has enabled this plugin and has custom settings
plugin1_user_conf = factories["plugins.UserPlugin"](
plugin__name=plugin1.name, user=user
)
# plugin is disabled by user
plugin2_user_conf = factories["plugins.UserPlugin"](
plugin__name=plugin2.name, user=user, is_enabled=False
)
# Plugin is enabled by another user
plugin3_user_conf = factories["plugins.UserPlugin"](plugin__name=plugin3.name)
expected = [
{
"obj": plugin1,
"settings": plugin1_user_conf.plugin.config,
"user": {"id": user.pk, "settings": plugin1_user_conf.config},
},
{"obj": plugin2, "settings": plugin2_user_conf.plugin.config, "user": None},
{"obj": plugin3, "settings": plugin3_user_conf.plugin.config, "user": None},
]
conf = plugins.generate_plugins_conf([plugin1, plugin2, plugin3])
assert plugins.update_plugins_conf_with_user_settings(conf, user=user) == expected
def test_update_plugins_conf_with_user_settings_anonymous(factories, plugin_class):
plugin1 = plugin_class("test1", "test1")
plugin2 = plugin_class("test2", "test2")
plugin3 = plugin_class("test3", "test3")
plugin1_db_conf = factories["plugins.Plugin"](name=plugin1.name)
plugin2_db_conf = factories["plugins.Plugin"](name=plugin2.name)
plugin3_db_conf = factories["plugins.Plugin"](name=plugin3.name)
expected = [
{"obj": plugin1, "settings": plugin1_db_conf.config, "user": None},
{"obj": plugin2, "settings": plugin2_db_conf.config, "user": None},
{"obj": plugin4, "settings": plugin4_db_conf.config, "user": None},
{"obj": plugin3, "settings": plugin3_db_conf.config, "user": None},
]
conf = plugins.generate_plugins_conf([plugin1, plugin2, plugin3, plugin4], user=None)
assert conf == expected
conf = plugins.generate_plugins_conf([plugin1, plugin2, plugin3])
assert plugins.update_plugins_conf_with_user_settings(conf, user=None) == expected
def test_attach_plugins_conf(mocker):
request = mocker.Mock()
generate_plugins_conf = mocker.patch.object(plugins, "generate_plugins_conf")
get_all_plugins = mocker.patch.object(plugins, "get_all_plugins")
user = mocker.Mock()
plugins.attach_plugins_conf(request, user=user)
plugins.attach_plugins_conf(request)
generate_plugins_conf.assert_called_once_with(
plugins=get_all_plugins.return_value, user=user
)
generate_plugins_conf.assert_called_once_with(plugins=get_all_plugins.return_value)
assert request.plugins_conf == generate_plugins_conf.return_value
@ -176,6 +167,6 @@ def test_attach_plugin_noop_if_plugins_disabled(mocker, preferences):
preferences["plugins__enabled"] = False
request = mocker.Mock()
plugins.attach_plugins_conf(request, user=None)
plugins.attach_plugins_conf(request)
assert request.plugins_conf is None

View File

@ -0,0 +1,64 @@
import pytest
from funkwhale_api import plugins
from funkwhale_api.common import authentication as common_authentication
from funkwhale_api.subsonic import authentication as subsonic_authentication
@pytest.mark.parametrize(
"authentication_class, base_class, patched_method",
[
(
common_authentication.SessionAuthentication,
common_authentication.BaseSessionAuthentication,
"authenticate",
),
(
common_authentication.JSONWebTokenAuthentication,
common_authentication.authentication.JSONWebTokenAuthentication,
"authenticate",
),
(
common_authentication.JSONWebTokenAuthenticationQS,
common_authentication.authentication.BaseJSONWebTokenAuthentication,
"authenticate",
),
(
common_authentication.OAuth2Authentication,
common_authentication.BaseOAuth2Authentication,
"authenticate",
),
(
common_authentication.BearerTokenHeaderAuth,
common_authentication.authentication.BaseJSONWebTokenAuthentication,
"authenticate",
),
(
subsonic_authentication.SubsonicAuthentication,
subsonic_authentication.SubsonicAuthentication,
"perform_authentication",
),
],
)
def test_authentication_calls_update_plugins_conf_with_user_settings(
authentication_class, base_class, patched_method, mocker, api_request
):
request = api_request.get("/")
plugins_conf = mocker.Mock()
setattr(request, "plugins_conf", plugins_conf)
auth = (mocker.Mock(), None)
authentication = authentication_class()
base_class_authenticate = mocker.patch.object(
base_class, patched_method, return_value=auth
)
update_plugins_conf_with_user_settings = mocker.patch.object(
plugins, "update_plugins_conf_with_user_settings"
)
authentication.authenticate(request)
update_plugins_conf_with_user_settings.assert_called_once_with(
plugins_conf, user=auth[0]
)
base_class_authenticate.assert_called_once_with(request)

View File

@ -0,0 +1,15 @@
from django.http import HttpResponse
from funkwhale_api.plugins import middleware
def test_attach_plugins_conf_middleware(mocker):
attach_plugins_conf = mocker.patch("funkwhale_api.plugins.attach_plugins_conf")
get_response = mocker.Mock()
get_response.return_value = mocker.Mock(status_code=200)
request = mocker.Mock(path="/")
m = middleware.AttachPluginsConfMiddleware(get_response)
assert m(request) == get_response.return_value
attach_plugins_conf.assert_called_once_with(request)

View File

@ -1,3 +1,5 @@
import pytest
from funkwhale_api import plugins
@ -30,7 +32,8 @@ def test_hooks_dispatch(mocker, plugin_class):
handler2.assert_called_once_with(listening="test", plugin_conf=plugins_conf[1])
def test_hooks_dispatch_exception(mocker, plugin_class):
def test_hooks_dispatch_exception_fail_loudly_false(mocker, plugin_class, settings):
settings.PLUGINS_FAIL_LOUDLY = False
plugin1 = plugin_class("test1", "test1")
plugin2 = plugin_class("test2", "test2")
hook = plugins.Hook("history.listenings.created", providing_args=["listening"])
@ -49,3 +52,26 @@ def test_hooks_dispatch_exception(mocker, plugin_class):
)
handler1.assert_called_once_with(listening="test", plugin_conf=plugins_conf[0])
handler2.assert_called_once_with(listening="test", plugin_conf=plugins_conf[1])
def test_hooks_dispatch_exception_fail_loudly_true(mocker, plugin_class, settings):
settings.PLUGINS_FAIL_LOUDLY = True
plugin1 = plugin_class("test1", "test1")
plugin2 = plugin_class("test2", "test2")
hook = plugins.Hook("history.listenings.created", providing_args=["listening"])
plugins.hooks.register(hook)
handler1 = mocker.Mock(side_effect=Exception("hello"))
handler2 = mocker.stub()
plugin1.hooks.connect("history.listenings.created")(handler1)
plugin2.hooks.connect("history.listenings.created")(handler2)
plugins_conf = [
{"obj": plugin1, "user": {"hello": "world"}, "settings": {"foo": "bar"}},
{"obj": plugin2},
]
with pytest.raises(Exception, match=r".*hello.*"):
plugins.hooks.dispatch(
"history.listenings.created", listening="test", plugins_conf=plugins_conf
)
handler1.assert_called_once_with(listening="test", plugin_conf=plugins_conf[0])
handler2.assert_not_called()