From 7897c8ac7f933d887ada8a061f25dafc8bdb0e75 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Wed, 3 Jul 2019 11:06:13 +0200
Subject: [PATCH] Fix #879: Admins can now add custom CSS from their pod
 settings

---
 api/funkwhale_api/common/middleware.py        | 17 ++++++-
 .../instance/dynamic_preferences_registry.py  | 17 +++++++
 api/tests/common/test_middleware.py           | 44 +++++++++++++++++++
 changes/changelog.d/879.feature               |  1 +
 front/src/views/admin/Settings.vue            |  6 +++
 5 files changed, 84 insertions(+), 1 deletion(-)
 create mode 100644 changes/changelog.d/879.feature

diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py
index 59d50b30e..c7bcf4b19 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 844fbb08b..9d1f1c8f7 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 dd7cd7634..9361748f3 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: &lt;/style&gt; &amp; 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 000000000..3763b1b58
--- /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 7102fb311..e7075bcc2 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",
-- 
GitLab