diff --git a/api/config/settings/common.py b/api/config/settings/common.py index df0c0a0a0..64d682885 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -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", diff --git a/api/funkwhale_api/common/authentication.py b/api/funkwhale_api/common/authentication.py index 415b84cb2..373501de1 100644 --- a/api/funkwhale_api/common/authentication.py +++ b/api/funkwhale_api/common/authentication.py @@ -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 but Authorization: Bearer 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 diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py index 8da4e67cd..5978338d3 100644 --- a/api/funkwhale_api/history/models.py +++ b/api/funkwhale_api/history/models.py @@ -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) diff --git a/api/funkwhale_api/history/signals.py b/api/funkwhale_api/history/signals.py new file mode 100644 index 000000000..ad33d878c --- /dev/null +++ b/api/funkwhale_api/history/signals.py @@ -0,0 +1,6 @@ +from funkwhale_api import plugins + + +plugins.hooks.register( + plugins.Hook("history.listening.created", providing_args=["listening"]) +) diff --git a/api/funkwhale_api/plugins/__init__.py b/api/funkwhale_api/plugins/__init__.py index 2f733d481..d15fc2f0f 100644 --- a/api/funkwhale_api/plugins/__init__.py +++ b/api/funkwhale_api/plugins/__init__.py @@ -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) diff --git a/api/funkwhale_api/plugins/authentication.py b/api/funkwhale_api/plugins/authentication.py new file mode 100644 index 000000000..6ebd9559a --- /dev/null +++ b/api/funkwhale_api/plugins/authentication.py @@ -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] + ) diff --git a/api/funkwhale_api/plugins/middleware.py b/api/funkwhale_api/plugins/middleware.py new file mode 100644 index 000000000..60b2382c7 --- /dev/null +++ b/api/funkwhale_api/plugins/middleware.py @@ -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) diff --git a/api/funkwhale_api/subsonic/authentication.py b/api/funkwhale_api/subsonic/authentication.py index 2d1e04f17..fdfe08a18 100644 --- a/api/funkwhale_api/subsonic/authentication.py +++ b/api/funkwhale_api/subsonic/authentication.py @@ -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) diff --git a/api/tests/plugins/test_app.py b/api/tests/plugins/test_app.py index baef2de17..4731e29c7 100644 --- a/api/tests/plugins/test_app.py +++ b/api/tests/plugins/test_app.py @@ -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 diff --git a/api/tests/plugins/test_authentication.py b/api/tests/plugins/test_authentication.py new file mode 100644 index 000000000..fba1aedc4 --- /dev/null +++ b/api/tests/plugins/test_authentication.py @@ -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) diff --git a/api/tests/plugins/test_middleware.py b/api/tests/plugins/test_middleware.py new file mode 100644 index 000000000..177cd2b40 --- /dev/null +++ b/api/tests/plugins/test_middleware.py @@ -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) diff --git a/api/tests/plugins/test_signals.py b/api/tests/plugins/test_signals.py index 67ea2e96a..36d4ff265 100644 --- a/api/tests/plugins/test_signals.py +++ b/api/tests/plugins/test_signals.py @@ -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()