Skip to content
Snippets Groups Projects
Verified Commit 7850ca3e authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Fix #923: Use same markdown widget for all content fields (rules, description,...

Fix #923: Use same markdown widget for all content fields (rules, description, reports, notes, etc.)
parent 8d29adf6
No related branches found
No related tags found
No related merge requests found
...@@ -250,36 +250,42 @@ def join_queries_or(left, right): ...@@ -250,36 +250,42 @@ def join_queries_or(left, right):
def render_markdown(text): def render_markdown(text):
return markdown.markdown(text, extensions=["nl2br"]) return markdown.markdown(text, extensions=["nl2br", "extra"])
HTMl_CLEANER = bleach.sanitizer.Cleaner( 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, strip=True,
tags=[ tags=SAFE_TAGS + ["h1", "h2", "h3", "h4", "h5", "h6", "div", "section", "article"],
"p", attributes=["class", "rel", "alt", "title"],
"a",
"abbr",
"acronym",
"b",
"blockquote",
"code",
"em",
"i",
"li",
"ol",
"strong",
"ul",
],
) )
HTML_LINKER = bleach.linkifier.Linker() HTML_LINKER = bleach.linkifier.Linker()
def clean_html(html): def clean_html(html, permissive=False):
return HTMl_CLEANER.clean(html) 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) rendered = render_markdown(text)
if content_type == "text/html": if content_type == "text/html":
rendered = text rendered = text
...@@ -288,7 +294,7 @@ def render_html(text, content_type): ...@@ -288,7 +294,7 @@ def render_html(text, content_type):
else: else:
rendered = render_markdown(text) rendered = render_markdown(text)
rendered = HTML_LINKER.linkify(rendered) 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): def render_plain_text(html):
......
...@@ -191,5 +191,10 @@ class TextPreviewView(views.APIView): ...@@ -191,5 +191,10 @@ class TextPreviewView(views.APIView):
if "text" not in payload: if "text" not in payload:
return response.Response({"detail": "Invalid input"}, status=400) 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) return response.Response(data, status=200)
...@@ -38,9 +38,7 @@ class InstanceLongDescription(types.StringPreference): ...@@ -38,9 +38,7 @@ class InstanceLongDescription(types.StringPreference):
name = "long_description" name = "long_description"
verbose_name = "Long description" verbose_name = "Long description"
default = "" default = ""
help_text = ( help_text = "Instance long description, displayed in the about page."
"Instance long description, displayed in the about page (markdown allowed)."
)
widget = widgets.Textarea widget = widgets.Textarea
field_kwargs = {"required": False} field_kwargs = {"required": False}
...@@ -52,9 +50,7 @@ class InstanceTerms(types.StringPreference): ...@@ -52,9 +50,7 @@ class InstanceTerms(types.StringPreference):
name = "terms" name = "terms"
verbose_name = "Terms of service" verbose_name = "Terms of service"
default = "" default = ""
help_text = ( help_text = "Terms of service and privacy policy for your instance."
"Terms of service and privacy policy for your instance (markdown allowed)."
)
widget = widgets.Textarea widget = widgets.Textarea
field_kwargs = {"required": False} field_kwargs = {"required": False}
...@@ -66,7 +62,7 @@ class InstanceRules(types.StringPreference): ...@@ -66,7 +62,7 @@ class InstanceRules(types.StringPreference):
name = "rules" name = "rules"
verbose_name = "Rules" verbose_name = "Rules"
default = "" default = ""
help_text = "Rules/Code of Conduct (markdown allowed)." help_text = "Rules/Code of Conduct."
widget = widgets.Textarea widget = widgets.Textarea
field_kwargs = {"required": False} field_kwargs = {"required": False}
......
...@@ -103,27 +103,40 @@ def test_join_url(start, end, expected): ...@@ -103,27 +103,40 @@ def test_join_url(start, end, expected):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"text, content_type, expected", "text, content_type, permissive, expected",
[ [
("hello world", "text/markdown", "<p>hello world</p>"), ("hello world", "text/markdown", False, "<p>hello world</p>"),
("hello world", "text/plain", "<p>hello world</p>"), ("hello world", "text/plain", False, "<p>hello world</p>"),
("<strong>hello world</strong>", "text/html", "<strong>hello world</strong>"), (
"<strong>hello world</strong>",
"text/html",
False,
"<strong>hello world</strong>",
),
# images and other non whitelisted html should be removed # 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>", "hello world\n\n<script></script>\n\n<style></style>",
"text/markdown", "text/markdown",
False,
"<p>hello world</p>", "<p>hello world</p>",
), ),
( (
"<p>hello world</p><script></script>\n\n<style></style>", "<p>hello world</p><script></script>\n\n<style></style>",
"text/html", "text/html",
False,
"<p>hello world</p>", "<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): def test_render_html(text, content_type, permissive, expected):
result = utils.render_html(text, content_type) result = utils.render_html(text, content_type, permissive=permissive)
assert result == expected assert result == expected
......
...@@ -281,3 +281,15 @@ def test_can_render_text_preview(api_client, db): ...@@ -281,3 +281,15 @@ def test_can_render_text_preview(api_client, db):
expected = {"rendered": utils.render_html(payload["text"], "text/markdown")} expected = {"rendered": utils.render_html(payload["text"], "text/markdown")}
assert response.status_code == 200 assert response.status_code == 200
assert response.data == expected 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
Use same markdown widget for all content fields (rules, description, reports, notes, etc.)
...@@ -17,24 +17,25 @@ ...@@ -17,24 +17,25 @@
<label :for="setting.identifier">{{ setting.verbose_name }}</label> <label :for="setting.identifier">{{ setting.verbose_name }}</label>
<p v-if="setting.help_text">{{ setting.help_text }}</p> <p v-if="setting.help_text">{{ setting.help_text }}</p>
</template> </template>
<content-form v-if="setting.fieldType === 'markdown'" v-model="values[setting.identifier]" v-bind="setting.fieldParams" />
<input <input
:id="setting.identifier" :id="setting.identifier"
:name="setting.identifier" :name="setting.identifier"
v-if="setting.field.widget.class === 'PasswordInput'" v-else-if="setting.field.widget.class === 'PasswordInput'"
type="password" type="password"
class="ui input" class="ui input"
v-model="values[setting.identifier]" /> v-model="values[setting.identifier]" />
<input <input
:id="setting.identifier" :id="setting.identifier"
:name="setting.identifier" :name="setting.identifier"
v-if="setting.field.widget.class === 'TextInput'" v-else-if="setting.field.widget.class === 'TextInput'"
type="text" type="text"
class="ui input" class="ui input"
v-model="values[setting.identifier]" /> v-model="values[setting.identifier]" />
<input <input
:id="setting.identifier" :id="setting.identifier"
:name="setting.identifier" :name="setting.identifier"
v-if="setting.field.class === 'IntegerField'" v-else-if="setting.field.class === 'IntegerField'"
type="number" type="number"
class="ui input" class="ui input"
v-model.number="values[setting.identifier]" /> v-model.number="values[setting.identifier]" />
...@@ -149,7 +150,7 @@ export default { ...@@ -149,7 +150,7 @@ export default {
byIdentifier[e.identifier] = e byIdentifier[e.identifier] = e
}) })
return this.group.settings.map(e => { return this.group.settings.map(e => {
return byIdentifier[e] return {...byIdentifier[e.name], fieldType: e.fieldType, fieldParams: e.fieldParams || {}}
}) })
}, },
fileSettings () { fileSettings () {
......
...@@ -26,13 +26,20 @@ ...@@ -26,13 +26,20 @@
</template> </template>
<template v-else> <template v-else>
<div class="ui transparent input"> <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>
<div class="ui very small hidden divider"></div> <div class="ui very small hidden divider"></div>
</template> </template>
</div> </div>
<div class="ui bottom attached segment"> <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 }} {{ remainingChars }}
</span> </span>
<p> <p>
...@@ -49,7 +56,12 @@ export default { ...@@ -49,7 +56,12 @@ export default {
props: { props: {
value: {type: String, default: ""}, value: {type: String, default: ""},
fieldId: {type: String, default: "change-content"}, fieldId: {type: String, default: "change-content"},
placeholder: {type: String, default: null},
autofocus: {type: Boolean, default: false}, 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 () { data () {
return { return {
...@@ -57,7 +69,6 @@ export default { ...@@ -57,7 +69,6 @@ export default {
preview: null, preview: null,
newValue: this.value, newValue: this.value,
isLoadingPreview: false, isLoadingPreview: false,
charLimit: 5000,
} }
}, },
mounted () { mounted () {
...@@ -71,7 +82,7 @@ export default { ...@@ -71,7 +82,7 @@ export default {
async loadPreview () { async loadPreview () {
this.isLoadingPreview = true this.isLoadingPreview = true
try { 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 this.preview = response.data.rendered
} catch { } catch {
...@@ -86,11 +97,12 @@ export default { ...@@ -86,11 +97,12 @@ export default {
} }
}, },
remainingChars () { remainingChars () {
return this.charLimit - this.value.length return this.charLimit - (this.value || "").length
} }
}, },
watch: { watch: {
newValue (v) { newValue (v) {
this.preview = null
this.$emit('input', v) this.$emit('input', v)
}, },
value: { value: {
...@@ -104,7 +116,7 @@ export default { ...@@ -104,7 +116,7 @@ export default {
immediate: true, immediate: true,
}, },
async isPreviewing (v) { async isPreviewing (v) {
if (v && !!this.value && this.preview === null) { if (v && !!this.value && this.preview === null && !this.isLoadingPreview) {
await this.loadPreview() await this.loadPreview()
} }
if (!v) { if (!v) {
......
...@@ -79,7 +79,7 @@ ...@@ -79,7 +79,7 @@
</template> </template>
<template v-else-if="fieldConfig.type === 'content'"> <template v-else-if="fieldConfig.type === 'content'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label> <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>
<template v-else-if="fieldConfig.type === 'attachment'"> <template v-else-if="fieldConfig.type === 'attachment'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label> <label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
</ul> </ul>
</div> </div>
<div class="field"> <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> </div>
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" type="submit" :disabled="isLoading"> <button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" type="submit" :disabled="isLoading">
<translate translate-context="Content/Moderation/Button.Label/Verb">Add note</translate> <translate translate-context="Content/Moderation/Button.Label/Verb">Add note</translate>
......
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
<p> <p>
<translate translate-context="*/*/Field,Help">Use this field to provide additional context to the moderator that will handle your report.</translate> <translate translate-context="*/*/Field,Help">Use this field to provide additional context to the moderator that will handle your report.</translate>
</p> </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> </div>
</form> </form>
<div v-else-if="isLoadingReportTypes" class="ui inline active loader"> <div v-else-if="isLoadingReportTypes" class="ui inline active loader">
......
...@@ -92,74 +92,81 @@ export default { ...@@ -92,74 +92,81 @@ export default {
label: instanceLabel, label: instanceLabel,
id: "instance", id: "instance",
settings: [ settings: [
"instance__name", {name: "instance__name"},
"instance__short_description", {name: "instance__short_description"},
"instance__long_description", {name: "instance__long_description", fieldType: 'markdown', fieldParams: {charLimit: null, permissive: true}},
"instance__contact_email", {name: "instance__contact_email"},
"instance__rules", {name: "instance__rules", fieldType: 'markdown', fieldParams: {charLimit: null, permissive: true}},
"instance__terms", {name: "instance__terms", fieldType: 'markdown', fieldParams: {charLimit: null, permissive: true}},
"instance__banner", {name: "instance__banner"},
"instance__support_message" {name: "instance__support_message", fieldType: 'markdown', fieldParams: {charLimit: null, permissive: true}},
] ]
}, },
{ {
label: usersLabel, label: usersLabel,
id: "users", id: "users",
settings: [ settings: [
"users__registration_enabled", {name: "users__registration_enabled"},
"common__api_authentication_required", {name: "common__api_authentication_required"},
"users__default_permissions", {name: "users__default_permissions"},
"users__upload_quota" {name: "users__upload_quota"},
] ]
}, },
{ {
label: musicLabel, label: musicLabel,
id: "music", id: "music",
settings: [ settings: [
"music__transcoding_enabled", {name: "music__transcoding_enabled"},
"music__transcoding_cache_duration" {name: "music__transcoding_cache_duration"},
] ]
}, },
{ {
label: playlistsLabel, label: playlistsLabel,
id: "playlists", id: "playlists",
settings: ["playlists__max_tracks"] settings: [
{name: "playlists__max_tracks"},
]
}, },
{ {
label: moderationLabel, label: moderationLabel,
id: "moderation", id: "moderation",
settings: [ settings: [
"moderation__allow_list_enabled", {name: "moderation__allow_list_enabled"},
"moderation__allow_list_public", {name: "moderation__allow_list_public"},
"moderation__unauthenticated_report_types", {name: "moderation__unauthenticated_report_types"},
] ]
}, },
{ {
label: federationLabel, label: federationLabel,
id: "federation", id: "federation",
settings: [ settings: [
"federation__enabled", {name: "federation__enabled"},
"federation__collection_page_size", {name: "federation__collection_page_size"},
"federation__music_cache_duration", {name: "federation__music_cache_duration"},
"federation__actor_fetch_delay" {name: "federation__actor_fetch_delay"},
] ]
}, },
{ {
label: subsonicLabel, label: subsonicLabel,
id: "subsonic", id: "subsonic",
settings: ["subsonic__enabled"] settings: [
{name: "subsonic__enabled"},
]
}, },
{ {
label: uiLabel, label: uiLabel,
id: "ui", id: "ui",
settings: ["ui__custom_css", "instance__funkwhale_support_message_enabled"] settings: [
{name: "ui__custom_css"},
{name: "instance__funkwhale_support_message_enabled"},
]
}, },
{ {
label: statisticsLabel, label: statisticsLabel,
id: "statistics", id: "statistics",
settings: [ settings: [
"instance__nodeinfo_stats_enabled", {name: "instance__nodeinfo_stats_enabled"},
"instance__nodeinfo_private" {name: "instance__nodeinfo_private"},
] ]
} }
] ]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment