2019-05-03 06:17:27 +08:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2013-06-11 03:33:37 +08:00
|
|
|
#
|
|
|
|
# HTML emails don't support CSS, so we can use nokogiri to inline attributes based on
|
|
|
|
# matchers.
|
|
|
|
#
|
|
|
|
module Email
|
|
|
|
class Styles
|
2020-10-22 10:25:09 +08:00
|
|
|
MAX_IMAGE_DIMENSION = 400
|
2020-11-02 07:52:21 +08:00
|
|
|
ONEBOX_IMAGE_BASE_STYLE =
|
|
|
|
"max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;"
|
|
|
|
ONEBOX_IMAGE_THUMBNAIL_STYLE = "width: 60px;"
|
2020-11-16 07:58:40 +08:00
|
|
|
ONEBOX_INLINE_AVATAR_STYLE = "width: 20px; height: 20px; float: none; vertical-align: middle;"
|
2020-10-22 10:25:09 +08:00
|
|
|
|
2014-08-21 18:54:05 +08:00
|
|
|
@@plugin_callbacks = []
|
2013-06-11 03:33:37 +08:00
|
|
|
|
2016-05-22 02:13:00 +08:00
|
|
|
attr_accessor :fragment
|
|
|
|
|
|
|
|
delegate :css, to: :fragment
|
|
|
|
|
2015-10-23 01:10:07 +08:00
|
|
|
def initialize(html, opts = nil)
|
2013-06-11 03:33:37 +08:00
|
|
|
@html = html
|
2015-10-23 01:10:07 +08:00
|
|
|
@opts = opts || {}
|
2020-05-05 11:46:57 +08:00
|
|
|
@fragment = Nokogiri::HTML5.parse(@html)
|
2019-07-31 03:05:08 +08:00
|
|
|
@custom_styles = nil
|
2013-06-11 03:33:37 +08:00
|
|
|
end
|
|
|
|
|
2014-08-21 18:54:05 +08:00
|
|
|
def self.register_plugin_style(&block)
|
|
|
|
@@plugin_callbacks.push(block)
|
|
|
|
end
|
|
|
|
|
2014-05-10 02:39:09 +08:00
|
|
|
def add_styles(node, new_styles)
|
|
|
|
existing = node["style"]
|
|
|
|
if existing.present?
|
2014-11-08 05:42:57 +08:00
|
|
|
# merge styles
|
2024-12-16 19:16:17 +08:00
|
|
|
node["style"] = "#{new_styles}; #{existing}"
|
2014-05-10 02:39:09 +08:00
|
|
|
else
|
|
|
|
node["style"] = new_styles
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-07-31 03:05:08 +08:00
|
|
|
def custom_styles
|
|
|
|
return @custom_styles unless @custom_styles.nil?
|
|
|
|
|
2019-10-24 03:41:58 +08:00
|
|
|
css = EmailStyle.new.compiled_css
|
2019-07-31 03:05:08 +08:00
|
|
|
@custom_styles = {}
|
|
|
|
|
|
|
|
if !css.blank?
|
2019-08-15 15:16:41 +08:00
|
|
|
# there is a minor race condition here, CssParser could be
|
|
|
|
# loaded by ::CssParser::Parser not loaded
|
|
|
|
require "css_parser" unless defined?(::CssParser::Parser)
|
2019-07-31 03:05:08 +08:00
|
|
|
|
2019-08-15 15:16:41 +08:00
|
|
|
parser = ::CssParser::Parser.new(import: false)
|
2019-07-31 03:05:08 +08:00
|
|
|
parser.load_string!(css)
|
|
|
|
parser.each_selector do |selector, value|
|
|
|
|
@custom_styles[selector] ||= +""
|
|
|
|
@custom_styles[selector] << value
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
@custom_styles
|
|
|
|
end
|
|
|
|
|
2013-06-14 00:15:05 +08:00
|
|
|
def format_basic
|
2014-06-14 05:11:04 +08:00
|
|
|
uri = URI(Discourse.base_url)
|
|
|
|
|
2019-10-17 17:54:04 +08:00
|
|
|
# Remove SVGs
|
|
|
|
@fragment.css('svg, img[src$=".svg"]').remove
|
|
|
|
|
2014-10-28 02:21:55 +08:00
|
|
|
# images
|
2016-01-29 18:13:59 +08:00
|
|
|
@fragment
|
|
|
|
.css("img")
|
|
|
|
.each do |img|
|
2013-11-29 06:20:56 +08:00
|
|
|
next if img["class"] == "site-logo"
|
2023-01-09 20:10:19 +08:00
|
|
|
|
2017-01-31 01:06:48 +08:00
|
|
|
if (img["class"] && img["class"]["emoji"]) || (img["src"] && img["src"][%r{/_?emoji/}])
|
|
|
|
img["width"] = img["height"] = 20
|
2013-06-14 00:15:05 +08:00
|
|
|
else
|
2014-11-15 09:33:42 +08:00
|
|
|
# use dimensions of original iPhone screen for 'too big, let device rescale'
|
|
|
|
if img["width"].to_i > (320) || img["height"].to_i > (480)
|
2017-01-31 01:06:48 +08:00
|
|
|
img["width"] = img["height"] = "auto"
|
2023-01-09 20:10:19 +08:00
|
|
|
end
|
2014-11-15 08:23:52 +08:00
|
|
|
end
|
2013-06-14 00:15:05 +08:00
|
|
|
|
2017-01-31 01:06:48 +08:00
|
|
|
if img["src"]
|
|
|
|
# ensure all urls are absolute
|
2023-01-21 02:52:49 +08:00
|
|
|
img["src"] = "#{Discourse.base_url}#{img["src"]}" if img["src"][%r{\A/[^/]}]
|
2017-01-31 01:06:48 +08:00
|
|
|
# ensure no schemaless urls
|
2023-01-21 02:52:49 +08:00
|
|
|
img["src"] = "#{uri.scheme}:#{img["src"]}" if img["src"][%r{\A//}]
|
2023-01-09 20:10:19 +08:00
|
|
|
end
|
2013-08-27 06:08:38 +08:00
|
|
|
end
|
2014-10-28 02:21:55 +08:00
|
|
|
|
2015-11-04 19:38:39 +08:00
|
|
|
# add max-width to big images
|
2016-01-29 18:13:59 +08:00
|
|
|
big_images =
|
|
|
|
@fragment.css('img[width="auto"][height="auto"]') - @fragment.css("aside.onebox img") -
|
|
|
|
@fragment.css("img.site-logo, img.emoji")
|
|
|
|
big_images.each { |img| add_styles(img, "max-width: 100%;") if img["style"] !~ /max-width/ }
|
2015-11-04 19:38:39 +08:00
|
|
|
|
2016-12-05 20:31:43 +08:00
|
|
|
# topic featured link
|
|
|
|
@fragment
|
|
|
|
.css("a.topic-featured-link")
|
|
|
|
.each do |e|
|
|
|
|
e[
|
|
|
|
"style"
|
|
|
|
] = "color:#858585;padding:2px 8px;border:1px solid #e6e6e6;border-radius:2px;box-shadow:0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);"
|
|
|
|
end
|
|
|
|
|
2014-10-28 02:21:55 +08:00
|
|
|
# attachments
|
2016-01-29 18:13:59 +08:00
|
|
|
@fragment
|
|
|
|
.css("a.attachment")
|
|
|
|
.each do |a|
|
2014-10-28 02:21:55 +08:00
|
|
|
# ensure all urls are absolute
|
2023-01-21 02:52:49 +08:00
|
|
|
a["href"] = "#{Discourse.base_url}#{a["href"]}" if a["href"] =~ %r{\A/[^/]}
|
2014-10-28 02:21:55 +08:00
|
|
|
|
|
|
|
# ensure no schemaless urls
|
|
|
|
a["href"] = "#{uri.scheme}:#{a["href"]}" if a["href"] && a["href"].starts_with?("//")
|
|
|
|
end
|
2013-07-26 15:27:46 +08:00
|
|
|
end
|
2013-07-23 03:06:37 +08:00
|
|
|
|
2014-05-10 02:39:09 +08:00
|
|
|
def onebox_styles
|
|
|
|
# Links to other topics
|
2017-01-02 16:03:03 +08:00
|
|
|
style("aside.quote", "padding: 12px 25px 2px 12px; margin-bottom: 10px;")
|
2014-05-10 02:39:09 +08:00
|
|
|
style("aside.quote div.info-line", "color: #666; margin: 10px 0")
|
2017-01-03 05:49:00 +08:00
|
|
|
style(
|
|
|
|
"aside.quote .avatar",
|
|
|
|
"margin-right: 5px; width:20px; height:20px; vertical-align:middle;",
|
|
|
|
)
|
2021-03-22 20:09:38 +08:00
|
|
|
style("aside.quote", "border-left: 5px solid #e9e9e9; background-color: #f8f8f8; margin: 0;")
|
2014-05-10 02:39:09 +08:00
|
|
|
|
2021-06-26 02:13:46 +08:00
|
|
|
style(
|
|
|
|
"blockquote",
|
|
|
|
"border-left: 5px solid #e9e9e9; background-color: #f8f8f8; margin-left: 0; padding: 12px;",
|
|
|
|
)
|
2015-10-23 04:08:52 +08:00
|
|
|
|
2014-05-10 02:39:09 +08:00
|
|
|
# Oneboxes
|
2021-03-22 20:09:38 +08:00
|
|
|
style(
|
|
|
|
"aside.onebox",
|
|
|
|
"border: 5px solid #e9e9e9; padding: 12px 25px 12px 12px; margin-bottom: 10px;",
|
|
|
|
)
|
2017-12-05 16:38:30 +08:00
|
|
|
style("aside.onebox header img.site-icon", "width: 16px; height: 16px; margin-right: 3px;")
|
2016-12-05 19:00:04 +08:00
|
|
|
style("aside.onebox header a[href]", "color: #222222; text-decoration: none;")
|
|
|
|
style("aside.onebox .onebox-body", "clear: both")
|
2020-11-02 07:52:21 +08:00
|
|
|
style("aside.onebox .onebox-body img:not(.onebox-avatar-inline)", ONEBOX_IMAGE_BASE_STYLE)
|
|
|
|
style("aside.onebox .onebox-body img.thumbnail", ONEBOX_IMAGE_THUMBNAIL_STYLE)
|
2016-12-05 19:00:04 +08:00
|
|
|
style(
|
|
|
|
"aside.onebox .onebox-body h3, aside.onebox .onebox-body h4",
|
|
|
|
"font-size: 1.17em; margin: 10px 0;",
|
|
|
|
)
|
|
|
|
style(".onebox-metadata", "color: #919191")
|
2019-10-17 17:54:04 +08:00
|
|
|
style(".github-info", "margin-top: 10px;")
|
2021-03-22 20:09:38 +08:00
|
|
|
style(".github-info .added", "color: #090;")
|
|
|
|
style(".github-info .removed", "color: #e45735;")
|
2019-10-17 17:54:04 +08:00
|
|
|
style(".github-info div", "display: inline; margin-right: 10px;")
|
2021-03-22 20:09:38 +08:00
|
|
|
style(".github-icon-container", "float: left;")
|
|
|
|
style(".github-icon-container *", "fill: #646464; width: 40px; height: 40px;")
|
2021-05-31 19:03:19 +08:00
|
|
|
style(
|
|
|
|
".github-body-container",
|
|
|
|
'font-family: Consolas, Menlo, Monaco, "Lucida Console", "Liberation Mono", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Courier New", monospace; margin-top: 1em !important;',
|
|
|
|
)
|
2020-11-16 07:58:40 +08:00
|
|
|
style(".onebox-avatar-inline", ONEBOX_INLINE_AVATAR_STYLE)
|
2023-01-09 20:10:19 +08:00
|
|
|
|
2021-05-31 19:03:19 +08:00
|
|
|
@fragment.css(".github-body-container .excerpt").remove
|
2023-01-09 20:10:19 +08:00
|
|
|
|
2017-01-02 16:03:03 +08:00
|
|
|
@fragment.css("aside.quote blockquote > p").each { |p| p["style"] = "padding: 0;" }
|
|
|
|
|
|
|
|
# Convert all `aside.quote` tags to `blockquote`s
|
|
|
|
@fragment
|
|
|
|
.css("aside.quote")
|
|
|
|
.each do |n|
|
|
|
|
original_node = n.dup
|
|
|
|
original_node.search("div.quote-controls").remove
|
|
|
|
blockquote =
|
2023-01-09 20:10:19 +08:00
|
|
|
(
|
2017-01-02 16:03:03 +08:00
|
|
|
if original_node.css("blockquote").inner_html.strip.start_with?("<p")
|
|
|
|
original_node.css("blockquote").inner_html
|
2023-01-09 20:10:19 +08:00
|
|
|
else
|
2017-01-02 16:03:03 +08:00
|
|
|
"<p style='padding: 0;'>#{original_node.css("blockquote").inner_html}</p>"
|
2023-01-09 20:10:19 +08:00
|
|
|
end
|
|
|
|
)
|
2017-01-02 16:03:03 +08:00
|
|
|
n.inner_html = original_node.css("div.title").inner_html + blockquote
|
|
|
|
n.name = "blockquote"
|
|
|
|
end
|
|
|
|
|
2014-05-14 02:44:40 +08:00
|
|
|
# Finally, convert all `aside` tags to `div`s
|
2016-01-29 18:13:59 +08:00
|
|
|
@fragment.css("aside, article, header").each { |n| n.name = "div" }
|
2014-07-15 04:41:05 +08:00
|
|
|
|
|
|
|
# iframes can't go in emails, so replace them with clickable links
|
2016-01-29 18:13:59 +08:00
|
|
|
@fragment
|
|
|
|
.css("iframe")
|
|
|
|
.each do |i|
|
2014-07-15 04:41:05 +08:00
|
|
|
begin
|
2020-07-27 08:23:54 +08:00
|
|
|
# sometimes, iframes are blocklisted...
|
2016-10-21 18:37:03 +08:00
|
|
|
if i["src"].blank?
|
2023-01-09 20:10:19 +08:00
|
|
|
i.remove
|
|
|
|
next
|
|
|
|
end
|
|
|
|
|
2019-04-24 16:20:27 +08:00
|
|
|
src_uri =
|
|
|
|
i["data-original-href"].present? ? URI(i["data-original-href"]) : URI(i["src"])
|
2014-07-15 04:41:05 +08:00
|
|
|
# If an iframe is protocol relative, use SSL when displaying it
|
2016-07-04 17:29:12 +08:00
|
|
|
display_src =
|
|
|
|
"#{src_uri.scheme || "https"}://#{src_uri.host}#{src_uri.path}#{src_uri.query.nil? ? "" : "?" + src_uri.query}#{src_uri.fragment.nil? ? "" : "#" + src_uri.fragment}"
|
2020-05-05 11:46:57 +08:00
|
|
|
i.replace(
|
|
|
|
Nokogiri::HTML5.fragment(
|
2023-12-07 06:25:00 +08:00
|
|
|
"<p><a href='#{src_uri}'>#{CGI.escapeHTML(display_src)}</a><p>",
|
2023-01-09 20:10:19 +08:00
|
|
|
),
|
|
|
|
)
|
2018-08-14 18:23:32 +08:00
|
|
|
rescue URI::Error
|
2016-10-21 18:37:03 +08:00
|
|
|
# If the URL is weird, remove the iframe
|
|
|
|
i.remove
|
|
|
|
end
|
2014-07-15 04:41:05 +08:00
|
|
|
end
|
2013-06-14 00:15:05 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
def format_html
|
2019-07-31 04:46:20 +08:00
|
|
|
correct_first_body_margin
|
|
|
|
correct_footer_style
|
2021-06-26 00:05:50 +08:00
|
|
|
correct_footer_style_highlight_first
|
2023-01-24 12:40:24 +08:00
|
|
|
strip_hashtag_link_icons
|
2019-07-31 04:46:20 +08:00
|
|
|
reset_tables
|
|
|
|
|
2019-07-31 03:05:08 +08:00
|
|
|
html_lang = SiteSetting.default_locale.sub("_", "-")
|
|
|
|
style("html", nil, :lang => html_lang, "xml:lang" => html_lang)
|
2021-03-22 20:09:38 +08:00
|
|
|
style("body", "line-height: 1.4; text-align:#{Rtl.new(nil).enabled? ? "right" : "left"};")
|
2019-07-31 03:05:08 +08:00
|
|
|
style("body", nil, dir: Rtl.new(nil).enabled? ? "rtl" : "ltr")
|
2023-01-09 20:10:19 +08:00
|
|
|
|
|
|
|
style(
|
2022-04-12 01:27:50 +08:00
|
|
|
".with-dir",
|
2019-07-31 03:05:08 +08:00
|
|
|
"text-align:#{Rtl.new(nil).enabled? ? "right" : "left"};",
|
|
|
|
dir: Rtl.new(nil).enabled? ? "rtl" : "ltr",
|
2023-01-09 20:10:19 +08:00
|
|
|
)
|
2019-07-31 03:05:08 +08:00
|
|
|
|
|
|
|
style("blockquote > :first-child", "margin-top: 0;")
|
2021-06-26 02:13:46 +08:00
|
|
|
style("blockquote > :last-child", "margin-bottom: 0;")
|
2019-07-31 03:05:08 +08:00
|
|
|
style("blockquote > p", "padding: 0;")
|
2023-01-09 20:10:19 +08:00
|
|
|
|
|
|
|
style(
|
2019-07-31 03:05:08 +08:00
|
|
|
".with-accent-colors",
|
2016-12-20 00:19:10 +08:00
|
|
|
"background-color: #{SiteSetting.email_accent_bg_color}; color: #{SiteSetting.email_accent_fg_color};",
|
2023-01-09 20:10:19 +08:00
|
|
|
)
|
2016-02-27 16:07:15 +08:00
|
|
|
style("h4", "color: #222;")
|
2021-06-26 02:13:46 +08:00
|
|
|
style("h3", "margin: 30px 0 10px;")
|
2019-07-31 03:05:08 +08:00
|
|
|
style("hr", "background-color: #ddd; height: 1px; border: 1px;")
|
2023-01-09 20:10:19 +08:00
|
|
|
style(
|
|
|
|
"a",
|
2019-07-31 03:05:08 +08:00
|
|
|
"text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color};",
|
2023-01-09 20:10:19 +08:00
|
|
|
)
|
2013-07-26 15:27:46 +08:00
|
|
|
style("ul", "margin: 0 0 0 10px; padding: 0 0 0 20px;")
|
|
|
|
style("li", "padding-bottom: 10px")
|
2019-07-31 04:46:20 +08:00
|
|
|
style("div.summary-footer", "color:#666; font-size:95%; text-align:center; padding-top:15px;")
|
2013-11-30 02:00:10 +08:00
|
|
|
style("span.post-count", "margin: 0 5px; color: #777;")
|
2019-07-31 03:05:08 +08:00
|
|
|
style("pre", "word-wrap: break-word; max-width: 694px;")
|
|
|
|
style("code", "background-color: #f9f9f9; padding: 2px 5px;")
|
|
|
|
style("pre code", "display: block; background-color: #f9f9f9; overflow: auto; padding: 5px;")
|
|
|
|
style("pre.onebox code", "white-space: normal;")
|
2021-08-25 21:34:01 +08:00
|
|
|
style("pre code li", "white-space: pre;")
|
2023-01-09 20:10:19 +08:00
|
|
|
style(
|
2019-07-31 03:05:08 +08:00
|
|
|
".featured-topic a",
|
|
|
|
"text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color}; line-height:1.5em;",
|
2023-01-09 20:10:19 +08:00
|
|
|
)
|
2019-07-31 03:05:08 +08:00
|
|
|
style(
|
2021-02-06 06:01:21 +08:00
|
|
|
".summary-email",
|
2019-07-31 03:05:08 +08:00
|
|
|
"-moz-box-sizing:border-box;-ms-text-size-adjust:100%;-webkit-box-sizing:border-box;-webkit-text-size-adjust:100%;box-sizing:border-box;color:#0a0a0a;font-family:Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.3;margin:0;min-width:100%;padding:0;width:100%",
|
|
|
|
)
|
|
|
|
|
2016-12-20 00:19:10 +08:00
|
|
|
style(".previous-discussion", "font-size: 17px; color: #444; margin-bottom:10px;")
|
2023-01-09 20:10:19 +08:00
|
|
|
style(
|
2016-12-20 00:19:10 +08:00
|
|
|
".notification-date",
|
2019-07-31 04:46:20 +08:00
|
|
|
"text-align:right;color:#999999;padding-right:5px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;font-size:11px",
|
|
|
|
)
|
|
|
|
style(
|
2021-08-26 17:55:46 +08:00
|
|
|
".username",
|
2016-12-20 00:19:10 +08:00
|
|
|
"font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;font-weight:bold",
|
2023-01-09 20:10:19 +08:00
|
|
|
)
|
2016-12-20 00:19:10 +08:00
|
|
|
style(".username-link", "color:#{SiteSetting.email_link_color};")
|
2021-02-06 06:01:21 +08:00
|
|
|
style(".username-title", "color:#777;margin-left:5px;")
|
2019-07-31 03:05:08 +08:00
|
|
|
style(
|
2020-02-20 20:15:14 +08:00
|
|
|
".user-title",
|
|
|
|
"font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:5px;color: #999;",
|
|
|
|
)
|
2019-07-31 03:05:08 +08:00
|
|
|
style(".post-wrapper", "margin-bottom:25px;")
|
|
|
|
style(".user-avatar", "vertical-align:top;width:55px;")
|
|
|
|
style(".user-avatar img", nil, width: "45", height: "45")
|
|
|
|
style("hr", "background-color: #ddd; height: 1px; border: 1px;")
|
|
|
|
style(".rtl", "direction: rtl;")
|
|
|
|
style("div.body", "padding-top:5px;")
|
|
|
|
style(".whisper div.body", "font-style: italic; color: #9c9c9c;")
|
|
|
|
style(".lightbox-wrapper .meta", "display: none")
|
|
|
|
style("div.undecorated-link-footer a", "font-weight: normal;")
|
2020-05-05 02:07:03 +08:00
|
|
|
style(
|
|
|
|
".mso-accent-link",
|
|
|
|
"mso-border-alt: 6px solid #{SiteSetting.email_accent_bg_color}; background-color: #{SiteSetting.email_accent_bg_color};",
|
|
|
|
)
|
2021-06-28 08:42:06 +08:00
|
|
|
style(
|
|
|
|
".reply-above-line",
|
|
|
|
"font-size: 10px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;color: #b5b5b5;padding: 5px 0px 20px;border-top: 1px dotted #ddd;",
|
|
|
|
)
|
2014-01-23 04:30:30 +08:00
|
|
|
|
2014-05-10 02:39:09 +08:00
|
|
|
onebox_styles
|
2014-08-21 18:54:05 +08:00
|
|
|
plugin_styles
|
2022-04-21 02:00:04 +08:00
|
|
|
dark_mode_styles
|
2016-12-20 06:05:49 +08:00
|
|
|
|
2020-10-22 10:25:09 +08:00
|
|
|
style(".post-excerpt img", "max-width: 50%; max-height: #{MAX_IMAGE_DIMENSION}px;")
|
2019-07-31 03:05:08 +08:00
|
|
|
|
|
|
|
format_custom
|
|
|
|
end
|
|
|
|
|
|
|
|
def format_custom
|
|
|
|
custom_styles.each { |selector, value| style(selector, value) }
|
2014-08-21 18:54:05 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
# this method is reserved for styles specific to plugin
|
|
|
|
def plugin_styles
|
2016-01-29 18:13:59 +08:00
|
|
|
@@plugin_callbacks.each { |block| block.call(@fragment, @opts) }
|
2013-07-26 15:27:46 +08:00
|
|
|
end
|
2013-06-11 03:33:37 +08:00
|
|
|
|
2023-10-17 12:08:21 +08:00
|
|
|
def stripped_media
|
|
|
|
@stripped_media ||=
|
|
|
|
@fragment.css("[data-stripped-secure-media], [data-stripped-secure-upload]")
|
|
|
|
end
|
2020-09-10 07:50:16 +08:00
|
|
|
|
2023-10-17 12:08:21 +08:00
|
|
|
def stripped_upload_sha_map
|
|
|
|
@stripped_upload_sha_map ||=
|
|
|
|
begin
|
|
|
|
upload_shas = {}
|
|
|
|
stripped_media.each do |div|
|
|
|
|
url = div["data-stripped-secure-media"] || div["data-stripped-secure-upload"]
|
|
|
|
upload_shas[url] = Upload.sha1_from_long_url(url)
|
|
|
|
end
|
|
|
|
upload_shas
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def stripped_secure_image_uploads
|
|
|
|
upload_shas = stripped_upload_sha_map
|
|
|
|
Upload.select(:original_filename, :sha1).where(sha1: upload_shas.values)
|
|
|
|
end
|
|
|
|
|
|
|
|
def inline_secure_images(attachments, attachments_index)
|
|
|
|
uploads = stripped_secure_image_uploads
|
|
|
|
upload_shas = stripped_upload_sha_map
|
2020-09-10 07:50:16 +08:00
|
|
|
stripped_media.each do |div|
|
2022-09-29 07:24:33 +08:00
|
|
|
upload =
|
|
|
|
uploads.find do |upl|
|
|
|
|
upl.sha1 ==
|
2023-10-17 12:08:21 +08:00
|
|
|
upload_shas[div["data-stripped-secure-media"] || div["data-stripped-secure-upload"]]
|
2022-09-29 07:24:33 +08:00
|
|
|
end
|
2020-09-10 07:50:16 +08:00
|
|
|
next if !upload
|
|
|
|
|
2021-08-03 23:58:34 +08:00
|
|
|
if attachments[attachments_index[upload.sha1]]
|
|
|
|
url = attachments[attachments_index[upload.sha1]].url
|
2020-09-10 07:50:16 +08:00
|
|
|
|
2020-11-16 07:58:40 +08:00
|
|
|
onebox_type = div["data-onebox-type"]
|
|
|
|
style =
|
|
|
|
if onebox_type
|
|
|
|
onebox_style =
|
2023-01-09 20:10:19 +08:00
|
|
|
(
|
2020-11-16 07:58:40 +08:00
|
|
|
if onebox_type == "avatar-inline"
|
|
|
|
ONEBOX_INLINE_AVATAR_STYLE
|
2023-01-09 20:10:19 +08:00
|
|
|
else
|
2020-11-16 07:58:40 +08:00
|
|
|
ONEBOX_IMAGE_THUMBNAIL_STYLE
|
2023-01-09 20:10:19 +08:00
|
|
|
end
|
|
|
|
)
|
2020-11-16 07:58:40 +08:00
|
|
|
"#{onebox_style} #{ONEBOX_IMAGE_BASE_STYLE}"
|
2020-11-02 07:52:21 +08:00
|
|
|
else
|
|
|
|
calculate_width_and_height_style(div)
|
|
|
|
end
|
|
|
|
|
|
|
|
div.add_next_sibling(<<~HTML)
|
|
|
|
<img src="#{url}" data-embedded-secure-image="true" style="#{style}" />
|
|
|
|
HTML
|
2020-09-10 07:50:16 +08:00
|
|
|
div.remove
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-11-30 23:38:45 +08:00
|
|
|
def deduplicate_style(style)
|
|
|
|
styles = {}
|
|
|
|
|
|
|
|
style
|
|
|
|
.split(";")
|
|
|
|
.select(&:present?)
|
|
|
|
.map { _1.split(":", 2).map(&:strip) }
|
|
|
|
.each { |k, v| styles[k] = v if k.present? && v.present? }
|
|
|
|
|
|
|
|
styles.map { |k, v| "#{k}:#{v}" }.join(";")
|
|
|
|
end
|
|
|
|
|
|
|
|
def deduplicate_styles
|
|
|
|
@fragment
|
|
|
|
.css("[style]")
|
|
|
|
.each { |element| element["style"] = deduplicate_style element["style"] }
|
|
|
|
end
|
|
|
|
|
2013-07-26 15:27:46 +08:00
|
|
|
def to_html
|
2020-09-10 07:50:16 +08:00
|
|
|
# needs to be before class + id strip because we need to style redacted
|
|
|
|
# media and also not double-redact already redacted from lower levels
|
2022-09-29 07:24:33 +08:00
|
|
|
replace_secure_uploads_urls if SiteSetting.secure_uploads?
|
2013-07-26 15:27:46 +08:00
|
|
|
strip_classes_and_ids
|
2014-06-14 05:11:04 +08:00
|
|
|
replace_relative_urls
|
2024-11-30 23:38:45 +08:00
|
|
|
deduplicate_styles
|
2020-07-06 18:45:39 +08:00
|
|
|
|
2020-10-19 19:19:30 +08:00
|
|
|
@fragment.to_html
|
2020-05-05 11:46:57 +08:00
|
|
|
end
|
|
|
|
|
2020-09-10 07:50:16 +08:00
|
|
|
def to_s
|
|
|
|
@fragment.to_s
|
|
|
|
end
|
|
|
|
|
2014-09-13 13:26:31 +08:00
|
|
|
def strip_avatars_and_emojis
|
2016-01-29 18:13:59 +08:00
|
|
|
@fragment
|
|
|
|
.search("img")
|
|
|
|
.each do |img|
|
2017-06-15 22:36:11 +08:00
|
|
|
next unless img["src"]
|
2023-01-09 20:10:19 +08:00
|
|
|
|
2017-01-31 01:06:48 +08:00
|
|
|
if img["src"][/_avatar/]
|
2017-06-15 22:36:11 +08:00
|
|
|
img.parent["style"] = "vertical-align: top;" if img.parent&.name == "td"
|
2014-09-13 13:26:31 +08:00
|
|
|
img.remove
|
|
|
|
end
|
|
|
|
|
2017-01-31 01:06:48 +08:00
|
|
|
if img["title"] && img["src"][%r{/_?emoji/}]
|
2015-08-19 07:12:08 +08:00
|
|
|
img.add_previous_sibling(img["title"] || "emoji")
|
|
|
|
img.remove
|
2023-01-09 20:10:19 +08:00
|
|
|
end
|
2015-08-19 07:12:08 +08:00
|
|
|
end
|
2014-09-13 13:26:31 +08:00
|
|
|
end
|
|
|
|
|
2023-01-24 12:40:24 +08:00
|
|
|
def strip_hashtag_link_icons
|
FEATURE: Generic hashtag autocomplete lookup and markdown cooking (#18937)
This commit fleshes out and adds functionality for the new `#hashtag` search and
lookup system, still hidden behind the `enable_experimental_hashtag_autocomplete`
feature flag.
**Serverside**
We have two plugin API registration methods that are used to define data sources
(`register_hashtag_data_source`) and hashtag result type priorities depending on
the context (`register_hashtag_type_in_context`). Reading the comments in plugin.rb
should make it clear what these are doing. Reading the `HashtagAutocompleteService`
in full will likely help a lot as well.
Each data source is responsible for providing its own **lookup** and **search**
method that returns hashtag results based on the arguments provided. For example,
the category hashtag data source has to take into account parent categories and
how they relate, and each data source has to define their own icon to use for the
hashtag, and so on.
The `Site` serializer has two new attributes that source data from `HashtagAutocompleteService`.
There is `hashtag_icons` that is just a simple array of all the different icons that
can be used for allowlisting in our markdown pipeline, and there is `hashtag_context_configurations`
that is used to store the type priority orders for each registered context.
When sending emails, we cannot render the SVG icons for hashtags, so
we need to change the HTML hashtags to the normal `#hashtag` text.
**Markdown**
The `hashtag-autocomplete.js` file is where I have added the new `hashtag-autocomplete`
markdown rule, and like all of our rules this is used to cook the raw text on both the clientside
and on the serverside using MiniRacer. Only on the server side do we actually reach out to
the database with the `hashtagLookup` function, on the clientside we just render a plainer
version of the hashtag HTML. Only in the composer preview do we do further lookups based
on this.
This rule is the first one (that I can find) that uses the `currentUser` based on a passed
in `user_id` for guardian checks in markdown rendering code. This is the `last_editor_id`
for both the post and chat message. In some cases we need to cook without a user present,
so the `Discourse.system_user` is used in this case.
**Chat Channels**
This also contains the changes required for chat so that chat channels can be used
as a data source for hashtag searches and lookups. This data source will only be
used when `enable_experimental_hashtag_autocomplete` is `true`, so we don't have
to worry about channel results suddenly turning up.
------
**Known Rough Edges**
- Onebox excerpts will not render the icon svg/use tags, I plan to address that in a follow up PR
- Selecting a hashtag + pressing the Quote button will result in weird behaviour, I plan to address that in a follow up PR
- Mixed hashtag contexts for hashtags without a type suffix will not work correctly, e.g. #ux which is both a category and a channel slug will resolve to a category when used inside a post or within a [chat] transcript in that post. Users can get around this manually by adding the correct suffix, for example ::channel. We may get to this at some point in future
- Icons will not show for the hashtags in emails since SVG support is so terrible in email (this is not likely to be resolved, but still noting for posterity)
- Additional refinements and review fixes wil
2022-11-21 06:37:06 +08:00
|
|
|
@fragment
|
|
|
|
.search(".hashtag-cooked")
|
|
|
|
.each do |hashtag|
|
2022-12-01 17:48:24 +08:00
|
|
|
hashtag.children.each(&:remove)
|
|
|
|
hashtag.add_child(<<~HTML)
|
FEATURE: Generic hashtag autocomplete lookup and markdown cooking (#18937)
This commit fleshes out and adds functionality for the new `#hashtag` search and
lookup system, still hidden behind the `enable_experimental_hashtag_autocomplete`
feature flag.
**Serverside**
We have two plugin API registration methods that are used to define data sources
(`register_hashtag_data_source`) and hashtag result type priorities depending on
the context (`register_hashtag_type_in_context`). Reading the comments in plugin.rb
should make it clear what these are doing. Reading the `HashtagAutocompleteService`
in full will likely help a lot as well.
Each data source is responsible for providing its own **lookup** and **search**
method that returns hashtag results based on the arguments provided. For example,
the category hashtag data source has to take into account parent categories and
how they relate, and each data source has to define their own icon to use for the
hashtag, and so on.
The `Site` serializer has two new attributes that source data from `HashtagAutocompleteService`.
There is `hashtag_icons` that is just a simple array of all the different icons that
can be used for allowlisting in our markdown pipeline, and there is `hashtag_context_configurations`
that is used to store the type priority orders for each registered context.
When sending emails, we cannot render the SVG icons for hashtags, so
we need to change the HTML hashtags to the normal `#hashtag` text.
**Markdown**
The `hashtag-autocomplete.js` file is where I have added the new `hashtag-autocomplete`
markdown rule, and like all of our rules this is used to cook the raw text on both the clientside
and on the serverside using MiniRacer. Only on the server side do we actually reach out to
the database with the `hashtagLookup` function, on the clientside we just render a plainer
version of the hashtag HTML. Only in the composer preview do we do further lookups based
on this.
This rule is the first one (that I can find) that uses the `currentUser` based on a passed
in `user_id` for guardian checks in markdown rendering code. This is the `last_editor_id`
for both the post and chat message. In some cases we need to cook without a user present,
so the `Discourse.system_user` is used in this case.
**Chat Channels**
This also contains the changes required for chat so that chat channels can be used
as a data source for hashtag searches and lookups. This data source will only be
used when `enable_experimental_hashtag_autocomplete` is `true`, so we don't have
to worry about channel results suddenly turning up.
------
**Known Rough Edges**
- Onebox excerpts will not render the icon svg/use tags, I plan to address that in a follow up PR
- Selecting a hashtag + pressing the Quote button will result in weird behaviour, I plan to address that in a follow up PR
- Mixed hashtag contexts for hashtags without a type suffix will not work correctly, e.g. #ux which is both a category and a channel slug will resolve to a category when used inside a post or within a [chat] transcript in that post. Users can get around this manually by adding the correct suffix, for example ::channel. We may get to this at some point in future
- Icons will not show for the hashtags in emails since SVG support is so terrible in email (this is not likely to be resolved, but still noting for posterity)
- Additional refinements and review fixes wil
2022-11-21 06:37:06 +08:00
|
|
|
<span>##{hashtag["data-slug"]}</span>
|
|
|
|
HTML
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2016-05-21 21:17:54 +08:00
|
|
|
def make_all_links_absolute
|
|
|
|
site_uri = URI(Discourse.base_url)
|
|
|
|
@fragment
|
|
|
|
.css("a")
|
|
|
|
.each do |link|
|
|
|
|
begin
|
2024-05-27 18:27:13 +08:00
|
|
|
link["href"] = "#{site_uri}#{link["href"]}" if URI(link["href"].to_s).host.blank?
|
2018-08-14 18:23:32 +08:00
|
|
|
rescue URI::Error
|
2016-05-21 21:17:54 +08:00
|
|
|
# leave it
|
2023-01-09 20:10:19 +08:00
|
|
|
end
|
2016-05-21 21:17:54 +08:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2013-07-26 15:27:46 +08:00
|
|
|
private
|
2013-06-11 03:33:37 +08:00
|
|
|
|
2022-04-12 01:27:50 +08:00
|
|
|
def dark_mode_styles
|
|
|
|
# When we ship the email template and its styles we strip all css classes so to give our
|
|
|
|
# dark mode styles we are including in the template a selector we add a data-attr of 'dm=value' to
|
|
|
|
# the appropriate place
|
2022-04-15 03:03:06 +08:00
|
|
|
style(
|
|
|
|
".digest-header, .digest-topic, .digest-topic-title-wrapper, .digest-topic-stats, .popular-post-excerpt",
|
|
|
|
nil,
|
|
|
|
dm: "header",
|
|
|
|
)
|
2022-04-12 01:27:50 +08:00
|
|
|
style(
|
|
|
|
".digest-content, .header-popular-posts, .spacer, .popular-post-spacer, .popular-post-meta, .digest-new-header, .digest-new-topic, .body",
|
|
|
|
nil,
|
|
|
|
dm: "body",
|
|
|
|
)
|
|
|
|
style(".with-accent-colors, .digest-content-header", nil, dm: "body_primary")
|
2022-04-15 03:03:06 +08:00
|
|
|
style(".digest-topic-body", nil, dm: "topic-body")
|
2022-04-12 01:27:50 +08:00
|
|
|
style(".summary-footer", nil, dm: "text-color")
|
2022-04-15 03:03:06 +08:00
|
|
|
style("code, pre code, blockquote", nil, dm: "bg")
|
2022-04-12 01:27:50 +08:00
|
|
|
end
|
|
|
|
|
2014-06-14 05:11:04 +08:00
|
|
|
def replace_relative_urls
|
|
|
|
forum_uri = URI(Discourse.base_url)
|
|
|
|
host = forum_uri.host
|
|
|
|
scheme = forum_uri.scheme
|
|
|
|
|
2016-01-29 18:13:59 +08:00
|
|
|
@fragment
|
|
|
|
.css("[href]")
|
|
|
|
.each do |element|
|
2014-06-14 05:11:04 +08:00
|
|
|
href = element["href"]
|
2017-05-03 12:08:14 +08:00
|
|
|
element["href"] = "#{scheme}:#{href}" if href.start_with?("\/\/#{host}")
|
2014-06-14 05:11:04 +08:00
|
|
|
end
|
|
|
|
end
|
2020-10-22 10:25:09 +08:00
|
|
|
|
|
|
|
def calculate_width_and_height_style(div)
|
|
|
|
width = div["data-width"]
|
|
|
|
height = div["data-height"]
|
|
|
|
if width.present? && height.present? && height.to_i < MAX_IMAGE_DIMENSION &&
|
|
|
|
width.to_i < MAX_IMAGE_DIMENSION
|
|
|
|
"width: #{width}px; height: #{height}px;"
|
|
|
|
else
|
|
|
|
"max-width: 50%; max-height: #{MAX_IMAGE_DIMENSION}px;"
|
|
|
|
end
|
|
|
|
end
|
2014-06-14 05:11:04 +08:00
|
|
|
|
2022-09-29 07:24:33 +08:00
|
|
|
def replace_secure_uploads_urls
|
2020-09-10 07:50:16 +08:00
|
|
|
# strip again, this can be done at a lower level like in the user
|
|
|
|
# notification template but that may not catch everything
|
2022-09-29 07:24:33 +08:00
|
|
|
PrettyText.strip_secure_uploads(@fragment)
|
2019-11-18 09:25:42 +08:00
|
|
|
|
2022-09-29 07:24:33 +08:00
|
|
|
style(
|
|
|
|
"div.secure-upload-notice",
|
|
|
|
"border: 5px solid #e9e9e9; padding: 5px; display: inline-block;",
|
|
|
|
)
|
|
|
|
style("div.secure-upload-notice a", "color: #{SiteSetting.email_link_color}")
|
2019-11-18 09:25:42 +08:00
|
|
|
end
|
|
|
|
|
2013-07-27 06:08:58 +08:00
|
|
|
def correct_first_body_margin
|
2014-06-10 03:28:03 +08:00
|
|
|
@fragment.css("div.body p").each { |element| element["style"] = "margin-top:0; border: 0;" }
|
2013-07-27 06:08:58 +08:00
|
|
|
end
|
|
|
|
|
2013-07-26 15:27:46 +08:00
|
|
|
def correct_footer_style
|
2016-01-29 18:13:59 +08:00
|
|
|
@fragment
|
|
|
|
.css(".footer")
|
|
|
|
.each do |element|
|
2013-07-29 14:00:02 +08:00
|
|
|
element["style"] = "color:#666;"
|
2018-06-12 06:54:39 +08:00
|
|
|
element.css("a").each { |inner| inner["style"] = "color:#666;" }
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2021-06-26 00:05:50 +08:00
|
|
|
def correct_footer_style_highlight_first
|
2018-06-12 06:54:39 +08:00
|
|
|
footernum = 0
|
2021-06-26 00:05:50 +08:00
|
|
|
@fragment
|
|
|
|
.css(".footer.highlight")
|
|
|
|
.each do |element|
|
2016-01-08 18:14:58 +08:00
|
|
|
linknum = 0
|
2013-07-26 15:27:46 +08:00
|
|
|
element
|
|
|
|
.css("a")
|
|
|
|
.each do |inner|
|
2016-01-08 18:14:58 +08:00
|
|
|
# we want the first footer link to be specially highlighted as IMPORTANT
|
|
|
|
if footernum == (0) && linknum == (0)
|
2018-07-17 00:30:37 +08:00
|
|
|
bg_color = SiteSetting.email_accent_bg_color
|
|
|
|
inner[
|
|
|
|
"style"
|
|
|
|
] = "background-color: #{bg_color}; color: #{SiteSetting.email_accent_fg_color}; border-top: 4px solid #{bg_color}; border-right: 6px solid #{bg_color}; border-bottom: 4px solid #{bg_color}; border-left: 6px solid #{bg_color}; display: inline-block; font-weight: bold;"
|
2023-01-09 20:10:19 +08:00
|
|
|
end
|
2023-12-07 06:25:00 +08:00
|
|
|
# rubocop:disable Lint/NonLocalExitFromIterator
|
2023-01-09 20:10:19 +08:00
|
|
|
return
|
2016-01-08 18:14:58 +08:00
|
|
|
end
|
2018-06-12 06:54:39 +08:00
|
|
|
return
|
2023-12-07 06:25:00 +08:00
|
|
|
# rubocop:enable Lint/NonLocalExitFromIterator
|
2013-07-26 15:27:46 +08:00
|
|
|
end
|
|
|
|
end
|
2013-06-11 03:33:37 +08:00
|
|
|
|
2013-07-26 15:27:46 +08:00
|
|
|
def strip_classes_and_ids
|
2016-01-29 18:13:59 +08:00
|
|
|
@fragment
|
|
|
|
.css("*")
|
|
|
|
.each do |element|
|
2020-04-30 14:48:34 +08:00
|
|
|
element.delete("class")
|
|
|
|
element.delete("id")
|
2013-06-12 00:27:11 +08:00
|
|
|
end
|
2013-06-14 00:15:05 +08:00
|
|
|
end
|
2013-06-12 00:27:11 +08:00
|
|
|
|
2013-07-26 15:27:46 +08:00
|
|
|
def reset_tables
|
2014-09-25 13:26:23 +08:00
|
|
|
style("table", nil, cellspacing: "0", cellpadding: "0", border: "0")
|
2013-06-11 03:33:37 +08:00
|
|
|
end
|
|
|
|
|
2013-07-26 15:27:46 +08:00
|
|
|
def style(selector, style, attribs = {})
|
2016-01-29 18:13:59 +08:00
|
|
|
@fragment
|
|
|
|
.css(selector)
|
|
|
|
.each do |element|
|
2014-05-10 02:39:09 +08:00
|
|
|
add_styles(element, style) if style
|
2016-01-29 18:13:59 +08:00
|
|
|
attribs.each { |k, v| element[k] = v }
|
|
|
|
end
|
2013-07-26 15:27:46 +08:00
|
|
|
end
|
2013-06-11 03:33:37 +08:00
|
|
|
end
|
2013-07-26 15:27:46 +08:00
|
|
|
end
|