Fix #923: Use same markdown widget for all content fields (rules, description, reports, notes, etc.)
This commit is contained in:
parent
8d29adf6be
commit
7850ca3e1c
|
@ -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):
|
||||||
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):
|
||||||
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):
|
||||||
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):
|
||||||
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):
|
||||||
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):
|
||||||
|
|
||||||
|
|
||||||
@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", "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>",
|
"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):
|
||||||
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
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Use same markdown widget for all content fields (rules, description, reports, notes, etc.)
|
|
@ -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 {
|
||||||
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 @@
|
||||||
</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 {
|
||||||
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 {
|
||||||
preview: null,
|
preview: null,
|
||||||
newValue: this.value,
|
newValue: this.value,
|
||||||
isLoadingPreview: false,
|
isLoadingPreview: false,
|
||||||
charLimit: 5000,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
|
@ -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 {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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 {
|
||||||
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 @@
|
||||||
</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 @@
|
||||||
</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 @@
|
||||||
<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 {
|
||||||
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"},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
Loading…
Reference in New Issue