From 7850ca3e1ca6facd87a9665a5f3a32020a6622d0 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Thu, 6 Feb 2020 15:52:08 +0100
Subject: [PATCH] Fix #923: Use same markdown widget for all content fields
 (rules, description, reports, notes, etc.)

---
 api/funkwhale_api/common/utils.py             | 52 ++++++++--------
 api/funkwhale_api/common/views.py             |  7 ++-
 .../instance/dynamic_preferences_registry.py  | 10 +---
 api/tests/common/test_utils.py                | 27 ++++++---
 api/tests/common/test_views.py                | 12 ++++
 changes/changelog.d/923.enhancement           |  1 +
 front/src/components/admin/SettingsGroup.vue  |  9 +--
 front/src/components/common/ContentForm.vue   | 24 ++++++--
 front/src/components/library/EditForm.vue     |  2 +-
 .../components/manage/moderation/NoteForm.vue |  2 +-
 .../src/components/moderation/ReportModal.vue |  2 +-
 front/src/views/admin/Settings.vue            | 59 +++++++++++--------
 12 files changed, 130 insertions(+), 77 deletions(-)
 create mode 100644 changes/changelog.d/923.enhancement

diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py
index 5b9b5bf2d7..ca870e1411 100644
--- a/api/funkwhale_api/common/utils.py
+++ b/api/funkwhale_api/common/utils.py
@@ -250,36 +250,42 @@ def join_queries_or(left, right):
 
 
 def render_markdown(text):
-    return markdown.markdown(text, extensions=["nl2br"])
-
-
-HTMl_CLEANER = bleach.sanitizer.Cleaner(
+    return markdown.markdown(text, extensions=["nl2br", "extra"])
+
+
+SAFE_TAGS = [
+    "p",
+    "a",
+    "abbr",
+    "acronym",
+    "b",
+    "blockquote",
+    "code",
+    "em",
+    "i",
+    "li",
+    "ol",
+    "strong",
+    "ul",
+]
+HTMl_CLEANER = bleach.sanitizer.Cleaner(strip=True, tags=SAFE_TAGS)
+
+HTML_PERMISSIVE_CLEANER = bleach.sanitizer.Cleaner(
     strip=True,
-    tags=[
-        "p",
-        "a",
-        "abbr",
-        "acronym",
-        "b",
-        "blockquote",
-        "code",
-        "em",
-        "i",
-        "li",
-        "ol",
-        "strong",
-        "ul",
-    ],
+    tags=SAFE_TAGS + ["h1", "h2", "h3", "h4", "h5", "h6", "div", "section", "article"],
+    attributes=["class", "rel", "alt", "title"],
 )
 
 HTML_LINKER = bleach.linkifier.Linker()
 
 
-def clean_html(html):
-    return HTMl_CLEANER.clean(html)
+def clean_html(html, permissive=False):
+    return (
+        HTML_PERMISSIVE_CLEANER.clean(html) if permissive else HTMl_CLEANER.clean(html)
+    )
 
 
-def render_html(text, content_type):
+def render_html(text, content_type, permissive=False):
     rendered = render_markdown(text)
     if content_type == "text/html":
         rendered = text
@@ -288,7 +294,7 @@ def render_html(text, content_type):
     else:
         rendered = render_markdown(text)
     rendered = HTML_LINKER.linkify(rendered)
-    return clean_html(rendered).strip().replace("\n", "")
+    return clean_html(rendered, permissive=permissive).strip().replace("\n", "")
 
 
 def render_plain_text(html):
diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py
index 1611d8e63e..05cb025c39 100644
--- a/api/funkwhale_api/common/views.py
+++ b/api/funkwhale_api/common/views.py
@@ -191,5 +191,10 @@ class TextPreviewView(views.APIView):
         if "text" not in payload:
             return response.Response({"detail": "Invalid input"}, status=400)
 
-        data = {"rendered": utils.render_html(payload["text"], "text/markdown")}
+        permissive = payload.get("permissive", False)
+        data = {
+            "rendered": utils.render_html(
+                payload["text"], "text/markdown", permissive=permissive
+            )
+        }
         return response.Response(data, status=200)
diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py
index 66d8211c15..c4340d4b88 100644
--- a/api/funkwhale_api/instance/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py
@@ -38,9 +38,7 @@ class InstanceLongDescription(types.StringPreference):
     name = "long_description"
     verbose_name = "Long description"
     default = ""
-    help_text = (
-        "Instance long description, displayed in the about page (markdown allowed)."
-    )
+    help_text = "Instance long description, displayed in the about page."
     widget = widgets.Textarea
     field_kwargs = {"required": False}
 
@@ -52,9 +50,7 @@ class InstanceTerms(types.StringPreference):
     name = "terms"
     verbose_name = "Terms of service"
     default = ""
-    help_text = (
-        "Terms of service and privacy policy for your instance (markdown allowed)."
-    )
+    help_text = "Terms of service and privacy policy for your instance."
     widget = widgets.Textarea
     field_kwargs = {"required": False}
 
@@ -66,7 +62,7 @@ class InstanceRules(types.StringPreference):
     name = "rules"
     verbose_name = "Rules"
     default = ""
-    help_text = "Rules/Code of Conduct (markdown allowed)."
+    help_text = "Rules/Code of Conduct."
     widget = widgets.Textarea
     field_kwargs = {"required": False}
 
diff --git a/api/tests/common/test_utils.py b/api/tests/common/test_utils.py
index 478a9e8cc0..f5b3836aba 100644
--- a/api/tests/common/test_utils.py
+++ b/api/tests/common/test_utils.py
@@ -103,27 +103,40 @@ def test_join_url(start, end, expected):
 
 
 @pytest.mark.parametrize(
-    "text, content_type, expected",
+    "text, content_type, permissive, expected",
     [
-        ("hello world", "text/markdown", "<p>hello world</p>"),
-        ("hello world", "text/plain", "<p>hello world</p>"),
-        ("<strong>hello world</strong>", "text/html", "<strong>hello world</strong>"),
+        ("hello world", "text/markdown", False, "<p>hello world</p>"),
+        ("hello world", "text/plain", False, "<p>hello world</p>"),
+        (
+            "<strong>hello world</strong>",
+            "text/html",
+            False,
+            "<strong>hello world</strong>",
+        ),
         # images and other non whitelisted html should be removed
-        ("hello world\n![img](src)", "text/markdown", "<p>hello world</p>"),
+        ("hello world\n![img](src)", "text/markdown", False, "<p>hello world</p>"),
         (
             "hello world\n\n<script></script>\n\n<style></style>",
             "text/markdown",
+            False,
             "<p>hello world</p>",
         ),
         (
             "<p>hello world</p><script></script>\n\n<style></style>",
             "text/html",
+            False,
             "<p>hello world</p>",
         ),
+        (
+            '<p class="foo">hello world</p><script></script>\n\n<style></style>',
+            "text/markdown",
+            True,
+            '<p class="foo">hello world</p>',
+        ),
     ],
 )
-def test_render_html(text, content_type, expected):
-    result = utils.render_html(text, content_type)
+def test_render_html(text, content_type, permissive, expected):
+    result = utils.render_html(text, content_type, permissive=permissive)
     assert result == expected
 
 
diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py
index 761a2940e6..3e1255fcc3 100644
--- a/api/tests/common/test_views.py
+++ b/api/tests/common/test_views.py
@@ -281,3 +281,15 @@ def test_can_render_text_preview(api_client, db):
     expected = {"rendered": utils.render_html(payload["text"], "text/markdown")}
     assert response.status_code == 200
     assert response.data == expected
+
+
+def test_can_render_text_preview_permissive(api_client, db):
+    payload = {"text": "Hello world", "permissive": True}
+    url = reverse("api:v1:text-preview")
+    response = api_client.post(url, payload)
+
+    expected = {
+        "rendered": utils.render_html(payload["text"], "text/markdown", permissive=True)
+    }
+    assert response.status_code == 200
+    assert response.data == expected
diff --git a/changes/changelog.d/923.enhancement b/changes/changelog.d/923.enhancement
new file mode 100644
index 0000000000..44b9d60e8f
--- /dev/null
+++ b/changes/changelog.d/923.enhancement
@@ -0,0 +1 @@
+Use same markdown widget for all content fields (rules, description, reports, notes, etc.)
diff --git a/front/src/components/admin/SettingsGroup.vue b/front/src/components/admin/SettingsGroup.vue
index 70894c7d6d..cbc07d9491 100644
--- a/front/src/components/admin/SettingsGroup.vue
+++ b/front/src/components/admin/SettingsGroup.vue
@@ -17,24 +17,25 @@
         <label :for="setting.identifier">{{ setting.verbose_name }}</label>
         <p v-if="setting.help_text">{{ setting.help_text }}</p>
       </template>
+      <content-form v-if="setting.fieldType === 'markdown'" v-model="values[setting.identifier]" v-bind="setting.fieldParams" />
       <input
         :id="setting.identifier"
         :name="setting.identifier"
-        v-if="setting.field.widget.class === 'PasswordInput'"
+        v-else-if="setting.field.widget.class === 'PasswordInput'"
         type="password"
         class="ui input"
         v-model="values[setting.identifier]" />
       <input
         :id="setting.identifier"
         :name="setting.identifier"
-        v-if="setting.field.widget.class === 'TextInput'"
+        v-else-if="setting.field.widget.class === 'TextInput'"
         type="text"
         class="ui input"
         v-model="values[setting.identifier]" />
       <input
         :id="setting.identifier"
         :name="setting.identifier"
-        v-if="setting.field.class === 'IntegerField'"
+        v-else-if="setting.field.class === 'IntegerField'"
         type="number"
         class="ui input"
         v-model.number="values[setting.identifier]" />
@@ -149,7 +150,7 @@ export default {
         byIdentifier[e.identifier] = e
       })
       return this.group.settings.map(e => {
-        return byIdentifier[e]
+        return {...byIdentifier[e.name], fieldType: e.fieldType, fieldParams: e.fieldParams || {}}
       })
     },
     fileSettings () {
diff --git a/front/src/components/common/ContentForm.vue b/front/src/components/common/ContentForm.vue
index 0ec2fa9413..f47daa6fc2 100644
--- a/front/src/components/common/ContentForm.vue
+++ b/front/src/components/common/ContentForm.vue
@@ -26,13 +26,20 @@
       </template>
       <template v-else>
         <div class="ui transparent input">
-          <textarea ref="textarea" :name="fieldId" :id="fieldId" rows="5" v-model="newValue" :placeholder="labels.placeholder"></textarea>
+          <textarea
+            ref="textarea"
+            :name="fieldId"
+            :id="fieldId"
+            :rows="rows"
+            v-model="newValue"
+            :required="required"
+            :placeholder="placeholder || labels.placeholder"></textarea>
         </div>
         <div class="ui very small hidden divider"></div>
       </template>
     </div>
     <div class="ui bottom attached segment">
-      <span :class="['right', 'floated', {'ui red text': remainingChars < 0}]">
+      <span :class="['right', 'floated', {'ui red text': remainingChars < 0}]" v-if="charLimit">
         {{ remainingChars }}
       </span>
       <p>
@@ -49,7 +56,12 @@ export default {
   props: {
     value: {type: String, default: ""},
     fieldId: {type: String, default: "change-content"},
+    placeholder: {type: String, default: null},
     autofocus: {type: Boolean, default: false},
+    charLimit: {type: Number, default: 5000, required: false},
+    rows: {type: Number, default: 5, required: false},
+    permissive: {type: Boolean, default: false},
+    required: {type: Boolean, default: false},
   },
   data () {
     return {
@@ -57,7 +69,6 @@ export default {
       preview: null,
       newValue: this.value,
       isLoadingPreview: false,
-      charLimit: 5000,
     }
   },
   mounted () {
@@ -71,7 +82,7 @@ export default {
     async loadPreview () {
       this.isLoadingPreview = true
       try {
-        let response = await axios.post('text-preview/', {text: this.value})
+        let response = await axios.post('text-preview/', {text: this.newValue, permissive: this.permissive})
         this.preview = response.data.rendered
       } catch {
 
@@ -86,11 +97,12 @@ export default {
       }
     },
     remainingChars () {
-      return this.charLimit - this.value.length
+      return this.charLimit - (this.value || "").length
     }
   },
   watch: {
     newValue (v) {
+      this.preview = null
       this.$emit('input', v)
     },
     value: {
@@ -104,7 +116,7 @@ export default {
       immediate: true,
     },
     async isPreviewing (v) {
-      if (v && !!this.value && this.preview === null) {
+      if (v && !!this.value && this.preview === null && !this.isLoadingPreview) {
         await this.loadPreview()
       }
       if (!v) {
diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue
index 70f83d0496..17b781e599 100644
--- a/front/src/components/library/EditForm.vue
+++ b/front/src/components/library/EditForm.vue
@@ -79,7 +79,7 @@
         </template>
         <template v-else-if="fieldConfig.type === 'content'">
           <label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
-          <textarea v-model="values[fieldConfig.id].text" :name="fieldConfig.id" :id="fieldConfig.id" rows="3"></textarea>
+          <content-form v-model="values[fieldConfig.id].text" :field-id="fieldConfig.id" :rows="3"></content-form>
         </template>
         <template v-else-if="fieldConfig.type === 'attachment'">
           <label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
diff --git a/front/src/components/manage/moderation/NoteForm.vue b/front/src/components/manage/moderation/NoteForm.vue
index bef549771a..eaf4d96174 100644
--- a/front/src/components/manage/moderation/NoteForm.vue
+++ b/front/src/components/manage/moderation/NoteForm.vue
@@ -7,7 +7,7 @@
       </ul>
     </div>
     <div class="field">
-      <textarea name="change-summary" required v-model="summary" id="change-summary" rows="3" :placeholder="labels.summaryPlaceholder"></textarea>
+      <content-form field-id="change-summary" :required="true" v-model="summary" :rows="3" :placeholder="labels.summaryPlaceholder"></content-form>
     </div>
     <button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" type="submit" :disabled="isLoading">
       <translate translate-context="Content/Moderation/Button.Label/Verb">Add note</translate>
diff --git a/front/src/components/moderation/ReportModal.vue b/front/src/components/moderation/ReportModal.vue
index a0ca73e553..6fa1ba8e02 100644
--- a/front/src/components/moderation/ReportModal.vue
+++ b/front/src/components/moderation/ReportModal.vue
@@ -44,7 +44,7 @@
           <p>
             <translate translate-context="*/*/Field,Help">Use this field to provide additional context to the moderator that will handle your report.</translate>
           </p>
-          <textarea name="report-summary" id="report-summary" rows="8" v-model="summary"></textarea>
+          <content-form field-id="report-summary" :rows="8" v-model="summary"></content-form>
         </div>
       </form>
       <div v-else-if="isLoadingReportTypes" class="ui inline active loader">
diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue
index 99bf622404..fdab614eaf 100644
--- a/front/src/views/admin/Settings.vue
+++ b/front/src/views/admin/Settings.vue
@@ -92,74 +92,81 @@ export default {
           label: instanceLabel,
           id: "instance",
           settings: [
-            "instance__name",
-            "instance__short_description",
-            "instance__long_description",
-            "instance__contact_email",
-            "instance__rules",
-            "instance__terms",
-            "instance__banner",
-            "instance__support_message"
+            {name: "instance__name"},
+            {name: "instance__short_description"},
+            {name: "instance__long_description", fieldType: 'markdown', fieldParams: {charLimit: null, permissive: true}},
+            {name: "instance__contact_email"},
+            {name: "instance__rules", fieldType: 'markdown', fieldParams: {charLimit: null, permissive: true}},
+            {name: "instance__terms", fieldType: 'markdown', fieldParams: {charLimit: null, permissive: true}},
+            {name: "instance__banner"},
+            {name: "instance__support_message", fieldType: 'markdown', fieldParams: {charLimit: null, permissive: true}},
           ]
         },
         {
           label: usersLabel,
           id: "users",
           settings: [
-            "users__registration_enabled",
-            "common__api_authentication_required",
-            "users__default_permissions",
-            "users__upload_quota"
+            {name: "users__registration_enabled"},
+            {name: "common__api_authentication_required"},
+            {name: "users__default_permissions"},
+            {name: "users__upload_quota"},
           ]
         },
         {
           label: musicLabel,
           id: "music",
           settings: [
-            "music__transcoding_enabled",
-            "music__transcoding_cache_duration"
+            {name: "music__transcoding_enabled"},
+            {name: "music__transcoding_cache_duration"},
           ]
         },
         {
           label: playlistsLabel,
           id: "playlists",
-          settings: ["playlists__max_tracks"]
+          settings: [
+            {name: "playlists__max_tracks"},
+          ]
         },
         {
           label: moderationLabel,
           id: "moderation",
           settings: [
-            "moderation__allow_list_enabled",
-            "moderation__allow_list_public",
-            "moderation__unauthenticated_report_types",
+            {name: "moderation__allow_list_enabled"},
+            {name: "moderation__allow_list_public"},
+            {name: "moderation__unauthenticated_report_types"},
           ]
         },
         {
           label: federationLabel,
           id: "federation",
           settings: [
-            "federation__enabled",
-            "federation__collection_page_size",
-            "federation__music_cache_duration",
-            "federation__actor_fetch_delay"
+            {name: "federation__enabled"},
+            {name: "federation__collection_page_size"},
+            {name: "federation__music_cache_duration"},
+            {name: "federation__actor_fetch_delay"},
           ]
         },
         {
           label: subsonicLabel,
           id: "subsonic",
-          settings: ["subsonic__enabled"]
+          settings: [
+            {name: "subsonic__enabled"},
+          ]
         },
         {
           label: uiLabel,
           id: "ui",
-          settings: ["ui__custom_css", "instance__funkwhale_support_message_enabled"]
+          settings: [
+            {name: "ui__custom_css"},
+            {name: "instance__funkwhale_support_message_enabled"},
+          ]
         },
         {
           label: statisticsLabel,
           id: "statistics",
           settings: [
-            "instance__nodeinfo_stats_enabled",
-            "instance__nodeinfo_private"
+            {name: "instance__nodeinfo_stats_enabled"},
+            {name: "instance__nodeinfo_private"},
           ]
         }
       ]
-- 
GitLab