diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py index 59d50b30ead3ea34f5219431475162889461b596..c7bcf4b19b97126bb8e4681f30873076cf8f9e00 100644 --- a/api/funkwhale_api/common/middleware.py +++ b/api/funkwhale_api/common/middleware.py @@ -1,5 +1,6 @@ import html import requests +import xml.sax.saxutils from django import http from django.conf import settings @@ -51,7 +52,13 @@ def serve_spa(request): # let's inject our meta tags in the HTML head += "\n" + "\n".join(render_tags(final_tags)) + "\n</head>" - + css = get_custom_css() or "" + if css: + # We add the style add the end of the body to ensure it has the highest + # priority (since it will come after other stylesheets) + body, tail = tail.split("</body>", 1) + css = "<style>{}</style>".format(css) + tail = body + "\n" + css + "\n</body>" + tail return http.HttpResponse(head + tail) @@ -128,6 +135,14 @@ def get_request_head_tags(request): return match.func(request, *match.args, **match.kwargs) +def get_custom_css(): + css = preferences.get("ui__custom_css").strip() + if not css: + return + + return xml.sax.saxutils.escape(css) + + class SPAFallbackMiddleware: def __init__(self, get_response): self.get_response = get_response diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py index 844fbb08b8b7d4816eb3ef9576d18ab9dc0c2fee..9d1f1c8f77706562fcdc8a2ecc1c863533a033f2 100644 --- a/api/funkwhale_api/instance/dynamic_preferences_registry.py +++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py @@ -4,6 +4,7 @@ from dynamic_preferences.registries import global_preferences_registry raven = types.Section("raven") instance = types.Section("instance") +ui = types.Section("ui") @global_preferences_registry.register @@ -98,3 +99,19 @@ class InstanceNodeinfoStatsEnabled(types.BooleanPreference): "Disable this if you don't want to share usage and library statistics " "in the nodeinfo endpoint but don't want to disable it completely." ) + + +@global_preferences_registry.register +class CustomCSS(types.StringPreference): + show_in_api = True + section = ui + name = "custom_css" + verbose_name = "Custom CSS code" + default = "" + help_text = ( + "Custom CSS code, to be included in a <style> tag on all pages. " + "Loading third-party resources such as fonts or images can affect the performance " + "of the app and the privacy of your users." + ) + widget = widgets.Textarea + field_kwargs = {"required": False} diff --git a/api/tests/common/test_middleware.py b/api/tests/common/test_middleware.py index dd7cd76341fc53571613f94e988899a9ca93352a..9361748f33cc89195374f6d2de98307b0d4d581d 100644 --- a/api/tests/common/test_middleware.py +++ b/api/tests/common/test_middleware.py @@ -141,3 +141,47 @@ def test_get_route_head_tags(mocker, settings): assert tags == match.func.return_value match.func.assert_called_once_with(request, *[], **{"pk": 42}) resolve.assert_called_once_with(request.path, urlconf=settings.SPA_URLCONF) + + +def test_serve_spa_includes_custom_css(mocker, no_api_auth): + request = mocker.Mock(path="/") + mocker.patch.object( + middleware, + "get_spa_html", + return_value="<html><head></head><body></body></html>", + ) + mocker.patch.object(middleware, "get_default_head_tags", return_value=[]) + mocker.patch.object(middleware, "get_request_head_tags", return_value=[]) + get_custom_css = mocker.patch.object( + middleware, "get_custom_css", return_value="body { background: black; }" + ) + response = middleware.serve_spa(request) + + assert response.status_code == 200 + expected = [ + "<html><head>\n\n</head><body>", + "<style>body { background: black; }</style>", + "</body></html>", + ] + get_custom_css.assert_called_once_with() + assert response.content == "\n".join(expected).encode() + + +@pytest.mark.parametrize( + "custom_css, expected", + [ + ("body { background: black; }", "body { background: black; }"), + ( + "body { injection: </style> & Hello", + "body { injection: </style> & Hello", + ), + ( + 'body { background: url("image/url"); }', + 'body { background: url("image/url"); }', + ), + ], +) +def test_get_custom_css(preferences, custom_css, expected): + preferences["ui__custom_css"] = custom_css + + assert middleware.get_custom_css() == expected diff --git a/changes/changelog.d/879.feature b/changes/changelog.d/879.feature new file mode 100644 index 0000000000000000000000000000000000000000..3763b1b585c9d6384244d87f0909564dd71049c4 --- /dev/null +++ b/changes/changelog.d/879.feature @@ -0,0 +1 @@ +Admins can now add custom CSS from their pod settings (#879) diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue index 7102fb311b1b64fa9c63f160d026659ebe06a087..e7075bcc231f9c7cb411a568eab0ea460ef6a3aa 100644 --- a/front/src/views/admin/Settings.vue +++ b/front/src/views/admin/Settings.vue @@ -84,6 +84,7 @@ export default { let federationLabel = this.$pgettext('Content/Admin/Menu', 'Federation') let subsonicLabel = this.$pgettext('Content/Admin/Menu', 'Subsonic') let statisticsLabel = this.$pgettext('Content/Admin/Menu', 'Statistics') + let uiLabel = this.$pgettext('Content/Admin/Menu', 'User Interface') let errorLabel = this.$pgettext('Content/Admin/Menu', 'Error reporting') return [ { @@ -134,6 +135,11 @@ export default { id: "subsonic", settings: ["subsonic__enabled"] }, + { + label: uiLabel, + id: "ui", + settings: ["ui__custom_css"] + }, { label: statisticsLabel, id: "statistics",