0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/app/serializers/reviewable_score_serializer.rb
Régis Hanol 97c241a718
FIX: Preserve safe formatting in reviewable reasons (#42164)
Previously, reviewable reasons from custom flag producers were treated
as inline text, and pre-escaping prevented Markdown and line-break
formatting from being recognized.

This change cooks a constrained Markdown subset at serialization so
every producer shares one sanitization boundary, renders cooked block
HTML directly, and keeps watched-word matches literal because their
Markdown metacharacters are data rather than formatting.
2026-07-30 17:07:08 +02:00

171 lines
4.7 KiB
Ruby
Vendored

# frozen_string_literal: true
class ReviewableScoreSerializer < ApplicationSerializer
REASON_COOK_OPTIONS = {
features_override: [].freeze,
markdown_it_rules: %w[
autolink
list
backticks
newline
code
fence
linkify
link
strikethrough
blockquote
emphasis
escape
entity
html_block
html_inline
].freeze,
}.freeze
REASONS_AND_SETTINGS = {
post_count: "approve_post_count",
trust_level: "approve_unless_trust_level",
group: "approve_unless_allowed_groups",
new_topics_unless_trust_level: "approve_new_topics_unless_trust_level",
new_topics_unless_allowed_groups: "approve_new_topics_unless_allowed_groups",
fast_typer: "first_post_typing_time",
auto_silence_regex: "auto_silence_first_post_regex",
staged: "approve_unless_staged",
must_approve_users: "must_approve_users",
invite_only: "invite_only",
email_spam: "email_in_spam_header",
suspect_user: "approve_suspect_users",
contains_media: "skip_media_review_groups",
}
attributes :id,
:score,
:agree_stats,
:reason,
:reason_type,
:reason_data,
:created_at,
:reviewed_at
attribute :status_for_database, key: :status
has_one :user, serializer: BasicUserSerializer, root: "users"
has_one :score_type, serializer: ReviewableScoreTypeSerializer
has_one :reviewable_conversation, serializer: ReviewableConversationSerializer
has_one :reviewed_by, serializer: BasicUserSerializer, root: "users"
def include_reviewable_conversation?
return false if object.meta_topic.blank?
scope&.can_see?(object.meta_topic) || object.notify_moderators_flag_message?
end
def agree_stats
{
agreed: user.user_stat.flags_agreed,
disagreed: user.user_stat.flags_disagreed,
ignored: user.user_stat.flags_ignored,
}
end
def reason
return @reason if defined?(@reason)
return unless object.reason
link_text = setting_name_for_reason(object.reason)
link_text = I18n.t("reviewables.reasons.links.#{object.reason}", default: nil) if link_text.nil?
if link_text
link = build_link_for(object.reason, link_text)
if object.reason == "watched_word"
return @reason = PrettyText.sanitize("<p>#{watched_word_reason(link)}</p>")
end
text = I18n.t("reviewables.reasons.#{object.reason}", link:, default: object.reason)
else
text = I18n.t("reviewables.reasons.#{object.reason}", default: object.reason)
end
@reason = PrettyText.cook(text, REASON_COOK_OPTIONS)
end
def reason_type
object.reason
end
def reason_data
watched_words_found if object.reason == "watched_word"
end
def include_reason?
reason.present?
end
def setting_name_for_reason(reason)
setting_name = REASONS_AND_SETTINGS[reason.to_sym]
if setting_name.nil?
plugin_options = DiscoursePluginRegistry.reviewable_score_links
option = plugin_options.detect { |o| o[:reason] == reason.to_sym }
setting_name = option[:setting] if option
end
setting_name
end
private
def watched_word_reason(link)
words = watched_words_found
default = "watched_word"
if words.nil? || words.empty?
I18n.t("reviewables.reasons.no_context.watched_word", link:, default:)
else
I18n.t(
"reviewables.reasons.watched_word",
link:,
words: words.map { |word| CGI.escapeHTML(word) }.join(", "),
count: words.size,
default:,
)
end
end
def watched_words_found
if object.context.nil?
# If the words weren't recorded, try to guess them based on current settings.
if object.reviewable.respond_to?(:post)
s = object.reviewable.post.raw.clone
s << " #{object.reviewable.post.topic.title}" if object.reviewable.post.post_number == 1
elsif object.reviewable.respond_to?(:payload)
s = object.reviewable.payload["raw"].clone
s << " #{object.reviewable.payload["title"]}" if object.reviewable.payload.key?("title")
end
words = WordWatcher.new(s).word_matches_across_all_actions
else
words = object.context.split(",")
end
words.map(&:downcase).uniq
end
def url_for(reason, text)
case reason
when "watched_word"
"#{Discourse.base_url}/admin/customize/watched_words"
when "category"
"#{Discourse.base_url}/c/#{object.reviewable.category&.slug}/edit/settings"
else
"#{Discourse.base_url}/admin/site_settings/category/all_results?filter=#{text}"
end
end
def build_link_for(reason, text)
return text.gsub("_", " ") unless scope.is_staff?
"<a href=\"#{url_for(reason, text)}\">#{text.gsub("_", " ")}</a>"
end
end