mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-29 00:44:05 +09:00
auto_link finds URLs and email addresses with regular expressions over the rendered html, which can't tell an attribute value from text, so it could put a link inside an attribute. Hand it each text node on its own, escaped the way the serializer writes text, after the final sanitize, so links only ever land between tags. Write the result with preserve_newline so a code block keeps its leading blank lines. Bump the presentation cache version so nothing rendered the old way is served again. Backport of7ea05e1,4e29c6eand9b271d0from #364. 1.5.2 has no plain-text fragment cache, so only the message partial's version moves.
137 lines
5.2 KiB
Ruby
137 lines
5.2 KiB
Ruby
module MessagesHelper
|
||
# Rendered text is re-sanitized before linking. Rails' default safe list
|
||
# lacks the formatting the rich text editor produces
|
||
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
|
||
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
|
||
|
||
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
|
||
LINKABLE_TEXT = /:|@|www\./i
|
||
|
||
def message_area_tag(room, &)
|
||
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
||
controller: "messages presence drop-target",
|
||
action: [ messages_actions, drop_target_actions, presence_actions ].join(" "),
|
||
messages_first_of_day_class: "message--first-of-day",
|
||
messages_formatted_class: "message--formatted",
|
||
messages_me_class: "message--me",
|
||
messages_mentioned_class: "message--mentioned",
|
||
messages_threaded_class: "message--threaded",
|
||
messages_page_url_value: room_messages_url(room)
|
||
}, &
|
||
end
|
||
|
||
def messages_tag(room, &)
|
||
tag.div id: dom_id(room, :messages), class: "messages", data: {
|
||
controller: "maintain-scroll refresh-room",
|
||
action: [ maintain_scroll_actions, refresh_room_actions ].join(" "),
|
||
messages_target: "messages",
|
||
refresh_room_loaded_at_value: room.updated_at.to_fs(:epoch),
|
||
refresh_room_url_value: room_refresh_url(room)
|
||
}, &
|
||
end
|
||
|
||
def message_tag(message, &)
|
||
message_timestamp_milliseconds = message.created_at.to_fs(:epoch)
|
||
|
||
tag.div id: dom_id(message),
|
||
class: "message #{"message--emoji" if message.plain_text_body.all_emoji?}",
|
||
data: {
|
||
controller: "reply",
|
||
user_id: message.creator_id,
|
||
message_id: message.id,
|
||
client_message_id: message.client_message_id,
|
||
message_timestamp: message_timestamp_milliseconds,
|
||
message_updated_at: message.updated_at.to_fs(:epoch),
|
||
sort_value: message_timestamp_milliseconds,
|
||
messages_target: "message",
|
||
search_results_target: "message",
|
||
refresh_room_target: "message",
|
||
reply_composer_outlet: "#composer"
|
||
}, &
|
||
rescue Exception => e
|
||
Sentry.capture_exception(e, extra: { message: message })
|
||
Rails.logger.error "Exception while rendering message #{message.class.name}##{message.id}, failed with: #{e.class} `#{e.message}`"
|
||
|
||
render "messages/unrenderable"
|
||
end
|
||
|
||
def message_timestamp(message, **attributes)
|
||
local_datetime_tag message.created_at, **attributes
|
||
end
|
||
|
||
def message_presentation(message)
|
||
case message.content_type
|
||
when "attachment"
|
||
message_attachment_presentation(message)
|
||
when "sound"
|
||
message_sound_presentation(message)
|
||
else
|
||
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
|
||
end
|
||
rescue Exception => e
|
||
Sentry.capture_exception(e, extra: { message: message })
|
||
Rails.logger.error "Exception while generating message representation for #{message.class.name}##{message.id}, failed with: #{e.class} `#{e.message}`"
|
||
|
||
""
|
||
end
|
||
|
||
private
|
||
# auto_link finds URLs and email addresses with regular expressions, which
|
||
# can't tell an attribute value from text. Hand it one text node at a time,
|
||
# escaped as the serializer writes text, so a link can only land between tags.
|
||
def auto_link_text(html)
|
||
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
|
||
|
||
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
|
||
if node.text.match?(LINKABLE_TEXT)
|
||
text = node.to_html
|
||
linked = auto_link(text, html: { target: "_blank" })
|
||
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
|
||
end
|
||
end
|
||
|
||
# A <pre> starting with a newline needs an extra one written out to survive the next parse
|
||
fragment.to_html(preserve_newline: true).html_safe
|
||
end
|
||
|
||
def messages_actions
|
||
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
||
end
|
||
|
||
def maintain_scroll_actions
|
||
"turbo:before-stream-render@document->maintain-scroll#beforeStreamRender"
|
||
end
|
||
|
||
def refresh_room_actions
|
||
"visibilitychange@document->refresh-room#visibilityChanged online@window->refresh-room#online"
|
||
end
|
||
|
||
def presence_actions
|
||
"visibilitychange@document->presence#visibilityChanged"
|
||
end
|
||
|
||
def message_attachment_presentation(message)
|
||
Messages::AttachmentPresentation.new(message, context: self).render
|
||
end
|
||
|
||
def message_sound_presentation(message)
|
||
sound = message.sound
|
||
|
||
tag.div class: "sound", data: { controller: "sound", action: "messages:play->sound#play", sound_url_value: asset_path(sound.asset_path) } do
|
||
play_button + (sound.image ? sound_image_tag(sound.image) : sound.text)
|
||
end
|
||
end
|
||
|
||
def play_button
|
||
tag.button "🔊", class: "btn btn--plain", data: { action: "sound#play" }
|
||
end
|
||
|
||
def sound_image_tag(image)
|
||
image_tag image.asset_path, width: image.width, height: image.height, class: "align--middle"
|
||
end
|
||
|
||
def message_author_title(author)
|
||
[ author.name, author.bio ].compact_blank.join(" – ")
|
||
end
|
||
end
|