0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/plugins/discourse-calendar/lib/discourse_post_event/event_parser.rb
Régis Hanol f91d7a9976
FIX: Cook event location and description as inline markdown (#41994)
Previously, a markdown link in an event's location rendered correctly in
email notifications but showed as raw `[text](url)` markup on the event
card. Every surface (card, email, topic excerpt) re-implemented its own
rendering of the same fields, and `extract_events` HTML-escaped the
stored location one level further on every edit.

This change cooks `location` and `description` once, server side, with a
restricted inline pipeline (links and emoji only, so onebox embeds
cannot return), and every surface renders through that shared cook — the
card via new `location_html`/`description_html` serializer attributes.
It stops escaping the stored location, with a migration healing existing
rows, and moves the composer's `[event]` parsing onto core's
`parseBBCodeTag` so quoted attribute values containing `]` survive
round-trips.

Ref - t/188447
2026-07-28 18:59:23 +02:00

142 lines
4.1 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscoursePostEvent
class EventParser
VALID_OPTIONS = [
:start,
:end,
:status,
:"allowed-groups",
:url,
:location,
:name,
:reminders,
:recurrence,
:"recurrence-until",
:timezone,
:"show-local-time",
:minimal,
:closed,
:"chat-enabled",
:livestream,
:"max-attendees",
:"all-day",
:image,
]
LEGACY_ESCAPED_ATTRS = %w[data-location]
def self.extract_events(post)
cooked = PrettyText.cook(post.raw, topic_id: post.topic_id, user_id: post.user_id)
valid_options = valid_option_attributes
valid_custom_fields =
SiteSetting
.discourse_post_event_allowed_custom_fields
.split("|")
.map do |setting|
{ original: "data-#{setting}", normalized: custom_field_data_attribute(setting) }
end
Nokogiri
.HTML(cooked)
.css("div.discourse-post-event")
.map do |doc|
event = nil
doc.attributes.values.each do |attribute|
name = attribute.name
value = attribute.value
if value && valid_options.include?(name)
event ||= {}
value = CGI.unescapeHTML(value) if LEGACY_ESCAPED_ATTRS.include?(name)
event[name.sub("data-", "").to_sym] = case name
when "data-name", "data-url", "data-image", "data-location"
value
else
CGI.escapeHTML(value)
end
end
valid_custom_fields.each do |valid_custom_field|
if value && valid_custom_field[:normalized] == name
event ||= {}
event[valid_custom_field[:original].sub("data-", "").to_sym] = CGI.escapeHTML(value)
end
end
end
event[:description] = to_markdown(doc) if event
event
end
.compact
end
def self.valid_option_attributes
VALID_OPTIONS.map { |option| "data-#{option}" }
end
def self.custom_field_data_attribute(setting)
camelized = setting.downcase.gsub(/[-.]/, "_").gsub(/_(.)/) { $1.upcase }
dasherized = camelized.gsub(/[A-Z]/) { |char| "-#{char.downcase}" }.sub(/\A-/, "")
"data-#{dasherized}"
end
INLINE_MARKDOWN_FEATURES = %w[emoji linkify]
INLINE_MARKDOWN_IT_RULES = %w[link linkify entity escape newline]
INLINE_CACHE_VERSION = 2
def self.cook_inline(text, post: nil)
text = text.to_s
add_nofollow = post.nil? || post.add_nofollow?
Discourse
.cache
.fetch(
"post-event-inline:#{INLINE_CACHE_VERSION}:#{Digest::SHA1.hexdigest(text)}:#{add_nofollow}",
expires_in: 1.week,
) do
cooked =
PrettyText.cook(
text,
features_override: INLINE_MARKDOWN_FEATURES,
markdown_it_rules: INLINE_MARKDOWN_IT_RULES,
omit_nofollow: !add_nofollow,
)
fragment = Nokogiri::HTML5.fragment(cooked)
content = fragment.children.reject(&:blank?)
if content.length == 1 && content.first.name == "p"
content.first.inner_html
else
fragment.to_html
end
end
end
def self.inline_text(text)
cooked = cook_inline(text)
PrettyText.excerpt(cooked, cooked.length, strip_links: true, text_entities: true)
end
def self.linkable_url?(url)
url = url.to_s.downcase
schemes = %w[http https mailto] | SiteSetting.allowed_href_schemes.split("|")
schemes.any? { |scheme| url.start_with?("#{scheme}:") }
end
def self.to_markdown(node)
fragment = node.dup
fragment
.css("img.emoji")
.each { |img| img.replace(img.document.create_text_node(img["alt"].to_s)) }
fragment
.css("a[href]")
.each do |a|
href = a["href"].to_s
next if !linkable_url?(href)
markdown = a.text == href ? href : "[#{a.text}](#{href})"
a.replace(a.document.create_text_node(markdown))
end
fragment.text.strip
end
end
end