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", "text/markdown", "<p>hello world</p>"), + ("hello world\n", "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