discourse/lib/email/styles.rb
Martin Brennan 61c87fb59f
FIX: Properly attach secure images to email for non-secure uploads (#23865)
There are cases where a user can copy image markdown from a public
post (such as via the discourse-templates plugin) into a PM which
is then sent via an email. Since a PM is a secure context (via the
.with_secure_uploads? check on Post), the image will get a secure
URL in the PM post even though the backing upload is not secure.

This fixes the bug in that case where the image would be stripped
from the email (since it had a /secure-uploads/ URL) but not re-attached
further down the line using the secure_uploads_allow_embed_images_in_emails
setting because the upload itself was not secure.

The flow in Email::Sender for doing this is still not ideal, but
there are chicken and egg problems around when to strip the images,
how to fit in with other attachments and email size limits, and
when to apply the images inline via Email::Styles. It's convoluted,
but at least this fixes the Template use case for now.
2023-10-17 14:08:21 +10:00

549 lines
19 KiB
Ruby

# frozen_string_literal: true
#
# HTML emails don't support CSS, so we can use nokogiri to inline attributes based on
# matchers.
#
module Email
class Styles
MAX_IMAGE_DIMENSION = 400
ONEBOX_IMAGE_BASE_STYLE =
"max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;"
ONEBOX_IMAGE_THUMBNAIL_STYLE = "width: 60px;"
ONEBOX_INLINE_AVATAR_STYLE = "width: 20px; height: 20px; float: none; vertical-align: middle;"
@@plugin_callbacks = []
attr_accessor :fragment
delegate :css, to: :fragment
def initialize(html, opts = nil)
@html = html
@opts = opts || {}
@fragment = Nokogiri::HTML5.parse(@html)
@custom_styles = nil
end
def self.register_plugin_style(&block)
@@plugin_callbacks.push(block)
end
def add_styles(node, new_styles)
existing = node["style"]
if existing.present?
# merge styles
node["style"] = "#{new_styles}; #{existing}"
else
node["style"] = new_styles
end
end
def custom_styles
return @custom_styles unless @custom_styles.nil?
css = EmailStyle.new.compiled_css
@custom_styles = {}
if !css.blank?
# there is a minor race condition here, CssParser could be
# loaded by ::CssParser::Parser not loaded
require "css_parser" unless defined?(::CssParser::Parser)
parser = ::CssParser::Parser.new(import: false)
parser.load_string!(css)
parser.each_selector do |selector, value|
@custom_styles[selector] ||= +""
@custom_styles[selector] << value
end
end
@custom_styles
end
def format_basic
uri = URI(Discourse.base_url)
# Remove SVGs
@fragment.css('svg, img[src$=".svg"]').remove
# images
@fragment
.css("img")
.each do |img|
next if img["class"] == "site-logo"
if (img["class"] && img["class"]["emoji"]) || (img["src"] && img["src"][%r{/_?emoji/}])
img["width"] = img["height"] = 20
else
# use dimensions of original iPhone screen for 'too big, let device rescale'
if img["width"].to_i > (320) || img["height"].to_i > (480)
img["width"] = img["height"] = "auto"
end
end
if img["src"]
# ensure all urls are absolute
img["src"] = "#{Discourse.base_url}#{img["src"]}" if img["src"][%r{\A/[^/]}]
# ensure no schemaless urls
img["src"] = "#{uri.scheme}:#{img["src"]}" if img["src"][%r{\A//}]
end
end
# add max-width to big images
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/ }
# 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
# attachments
@fragment
.css("a.attachment")
.each do |a|
# ensure all urls are absolute
a["href"] = "#{Discourse.base_url}#{a["href"]}" if a["href"] =~ %r{\A/[^/]}
# ensure no schemaless urls
a["href"] = "#{uri.scheme}:#{a["href"]}" if a["href"] && a["href"].starts_with?("//")
end
end
def onebox_styles
# Links to other topics
style("aside.quote", "padding: 12px 25px 2px 12px; margin-bottom: 10px;")
style("aside.quote div.info-line", "color: #666; margin: 10px 0")
style(
"aside.quote .avatar",
"margin-right: 5px; width:20px; height:20px; vertical-align:middle;",
)
style("aside.quote", "border-left: 5px solid #e9e9e9; background-color: #f8f8f8; margin: 0;")
style(
"blockquote",
"border-left: 5px solid #e9e9e9; background-color: #f8f8f8; margin-left: 0; padding: 12px;",
)
# Oneboxes
style(
"aside.onebox",
"border: 5px solid #e9e9e9; padding: 12px 25px 12px 12px; margin-bottom: 10px;",
)
style("aside.onebox header img.site-icon", "width: 16px; height: 16px; margin-right: 3px;")
style("aside.onebox header a[href]", "color: #222222; text-decoration: none;")
style("aside.onebox .onebox-body", "clear: both")
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)
style(
"aside.onebox .onebox-body h3, aside.onebox .onebox-body h4",
"font-size: 1.17em; margin: 10px 0;",
)
style(".onebox-metadata", "color: #919191")
style(".github-info", "margin-top: 10px;")
style(".github-info .added", "color: #090;")
style(".github-info .removed", "color: #e45735;")
style(".github-info div", "display: inline; margin-right: 10px;")
style(".github-icon-container", "float: left;")
style(".github-icon-container *", "fill: #646464; width: 40px; height: 40px;")
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;',
)
style(".onebox-avatar-inline", ONEBOX_INLINE_AVATAR_STYLE)
@fragment.css(".github-body-container .excerpt").remove
@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 =
(
if original_node.css("blockquote").inner_html.strip.start_with?("<p")
original_node.css("blockquote").inner_html
else
"<p style='padding: 0;'>#{original_node.css("blockquote").inner_html}</p>"
end
)
n.inner_html = original_node.css("div.title").inner_html + blockquote
n.name = "blockquote"
end
# Finally, convert all `aside` tags to `div`s
@fragment.css("aside, article, header").each { |n| n.name = "div" }
# iframes can't go in emails, so replace them with clickable links
@fragment
.css("iframe")
.each do |i|
begin
# sometimes, iframes are blocklisted...
if i["src"].blank?
i.remove
next
end
src_uri =
i["data-original-href"].present? ? URI(i["data-original-href"]) : URI(i["src"])
# If an iframe is protocol relative, use SSL when displaying it
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}"
i.replace(
Nokogiri::HTML5.fragment(
"<p><a href='#{src_uri.to_s}'>#{CGI.escapeHTML(display_src)}</a><p>",
),
)
rescue URI::Error
# If the URL is weird, remove the iframe
i.remove
end
end
end
def format_html
correct_first_body_margin
correct_footer_style
correct_footer_style_highlight_first
strip_hashtag_link_icons
reset_tables
html_lang = SiteSetting.default_locale.sub("_", "-")
style("html", nil, :lang => html_lang, "xml:lang" => html_lang)
style("body", "line-height: 1.4; text-align:#{Rtl.new(nil).enabled? ? "right" : "left"};")
style("body", nil, dir: Rtl.new(nil).enabled? ? "rtl" : "ltr")
style(
".with-dir",
"text-align:#{Rtl.new(nil).enabled? ? "right" : "left"};",
dir: Rtl.new(nil).enabled? ? "rtl" : "ltr",
)
style("blockquote > :first-child", "margin-top: 0;")
style("blockquote > :last-child", "margin-bottom: 0;")
style("blockquote > p", "padding: 0;")
style(
".with-accent-colors",
"background-color: #{SiteSetting.email_accent_bg_color}; color: #{SiteSetting.email_accent_fg_color};",
)
style("h4", "color: #222;")
style("h3", "margin: 30px 0 10px;")
style("hr", "background-color: #ddd; height: 1px; border: 1px;")
style(
"a",
"text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color};",
)
style("ul", "margin: 0 0 0 10px; padding: 0 0 0 20px;")
style("li", "padding-bottom: 10px")
style("div.summary-footer", "color:#666; font-size:95%; text-align:center; padding-top:15px;")
style("span.post-count", "margin: 0 5px; color: #777;")
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;")
style("pre code li", "white-space: pre;")
style(
".featured-topic a",
"text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color}; line-height:1.5em;",
)
style(
".summary-email",
"-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%",
)
style(".previous-discussion", "font-size: 17px; color: #444; margin-bottom:10px;")
style(
".notification-date",
"text-align:right;color:#999999;padding-right:5px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;font-size:11px",
)
style(
".username",
"font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;font-weight:bold",
)
style(".username-link", "color:#{SiteSetting.email_link_color};")
style(".username-title", "color:#777;margin-left:5px;")
style(
".user-title",
"font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:5px;color: #999;",
)
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;")
style(
".mso-accent-link",
"mso-border-alt: 6px solid #{SiteSetting.email_accent_bg_color}; background-color: #{SiteSetting.email_accent_bg_color};",
)
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;",
)
onebox_styles
plugin_styles
dark_mode_styles
style(".post-excerpt img", "max-width: 50%; max-height: #{MAX_IMAGE_DIMENSION}px;")
format_custom
end
def format_custom
custom_styles.each { |selector, value| style(selector, value) }
end
# this method is reserved for styles specific to plugin
def plugin_styles
@@plugin_callbacks.each { |block| block.call(@fragment, @opts) }
end
def stripped_media
@stripped_media ||=
@fragment.css("[data-stripped-secure-media], [data-stripped-secure-upload]")
end
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
stripped_media.each do |div|
upload =
uploads.find do |upl|
upl.sha1 ==
upload_shas[div["data-stripped-secure-media"] || div["data-stripped-secure-upload"]]
end
next if !upload
if attachments[attachments_index[upload.sha1]]
url = attachments[attachments_index[upload.sha1]].url
onebox_type = div["data-onebox-type"]
style =
if onebox_type
onebox_style =
(
if onebox_type == "avatar-inline"
ONEBOX_INLINE_AVATAR_STYLE
else
ONEBOX_IMAGE_THUMBNAIL_STYLE
end
)
"#{onebox_style} #{ONEBOX_IMAGE_BASE_STYLE}"
else
calculate_width_and_height_style(div)
end
div.add_next_sibling(<<~HTML)
<img src="#{url}" data-embedded-secure-image="true" style="#{style}" />
HTML
div.remove
end
end
end
def to_html
# needs to be before class + id strip because we need to style redacted
# media and also not double-redact already redacted from lower levels
replace_secure_uploads_urls if SiteSetting.secure_uploads?
strip_classes_and_ids
replace_relative_urls
@fragment.to_html
end
def to_s
@fragment.to_s
end
def strip_avatars_and_emojis
@fragment
.search("img")
.each do |img|
next unless img["src"]
if img["src"][/_avatar/]
img.parent["style"] = "vertical-align: top;" if img.parent&.name == "td"
img.remove
end
if img["title"] && img["src"][%r{/_?emoji/}]
img.add_previous_sibling(img["title"] || "emoji")
img.remove
end
end
end
def strip_hashtag_link_icons
@fragment
.search(".hashtag-cooked")
.each do |hashtag|
hashtag.children.each(&:remove)
hashtag.add_child(<<~HTML)
<span>##{hashtag["data-slug"]}</span>
HTML
end
end
def make_all_links_absolute
site_uri = URI(Discourse.base_url)
@fragment
.css("a")
.each do |link|
begin
link["href"] = "#{site_uri}#{link["href"]}" unless URI(link["href"].to_s).host.present?
rescue URI::Error
# leave it
end
end
end
private
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
style(
".digest-header, .digest-topic, .digest-topic-title-wrapper, .digest-topic-stats, .popular-post-excerpt",
nil,
dm: "header",
)
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")
style(".digest-topic-body", nil, dm: "topic-body")
style(".summary-footer", nil, dm: "text-color")
style("code, pre code, blockquote", nil, dm: "bg")
end
def replace_relative_urls
forum_uri = URI(Discourse.base_url)
host = forum_uri.host
scheme = forum_uri.scheme
@fragment
.css("[href]")
.each do |element|
href = element["href"]
element["href"] = "#{scheme}:#{href}" if href.start_with?("\/\/#{host}")
end
end
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
def replace_secure_uploads_urls
# strip again, this can be done at a lower level like in the user
# notification template but that may not catch everything
PrettyText.strip_secure_uploads(@fragment)
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}")
end
def correct_first_body_margin
@fragment.css("div.body p").each { |element| element["style"] = "margin-top:0; border: 0;" }
end
def correct_footer_style
@fragment
.css(".footer")
.each do |element|
element["style"] = "color:#666;"
element.css("a").each { |inner| inner["style"] = "color:#666;" }
end
end
def correct_footer_style_highlight_first
footernum = 0
@fragment
.css(".footer.highlight")
.each do |element|
linknum = 0
element
.css("a")
.each do |inner|
# we want the first footer link to be specially highlighted as IMPORTANT
if footernum == (0) && linknum == (0)
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;"
end
return
end
return
end
end
def strip_classes_and_ids
@fragment
.css("*")
.each do |element|
element.delete("class")
element.delete("id")
end
end
def reset_tables
style("table", nil, cellspacing: "0", cellpadding: "0", border: "0")
end
def style(selector, style, attribs = {})
@fragment
.css(selector)
.each do |element|
add_styles(element, style) if style
attribs.each { |k, v| element[k] = v }
end
end
end
end