mirror of
https://github.com/discourse/discourse.git
synced 2024-12-25 04:13:43 +08:00
7d58793759
In order to limit issues with duplicate inline CSS definitions, this will now deduplicate inline CSS styles with the "last-to-be-defined-wins" strategy. Also removes unecessary whitespaces in inline styles. Context - https://meta.discourse.org/t/resolve-final-styles-in-email-notifications/310219 Co-authored-by: Thomas Kalka <thomas.kalka@gmail.com>
494 lines
19 KiB
Ruby
494 lines
19 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "email"
|
|
|
|
RSpec.describe Email::Styles do
|
|
let(:attachments) { {} }
|
|
|
|
def basic_fragment(html)
|
|
styler = Email::Styles.new(html)
|
|
styler.format_basic
|
|
Nokogiri::HTML5.fragment(styler.to_html)
|
|
end
|
|
|
|
def html_fragment(html)
|
|
styler = Email::Styles.new(html)
|
|
styler.format_basic
|
|
styler.format_html
|
|
Nokogiri::HTML5.fragment(styler.to_html)
|
|
end
|
|
|
|
describe "basic formatter" do
|
|
it "adds a max-width to large images" do
|
|
frag = basic_fragment("<img height='auto' width='auto' src='gigantic.jpg'>")
|
|
expect(frag.at("img")["style"]).to match("max-width")
|
|
end
|
|
|
|
it "adds a width and height to emojis" do
|
|
frag = basic_fragment("<img src='/images/emoji/fish.png' class='emoji'>")
|
|
expect(frag.at("img")["width"]).to eq("20")
|
|
expect(frag.at("img")["height"]).to eq("20")
|
|
end
|
|
|
|
it "adds a width and height to custom emojis" do
|
|
frag =
|
|
basic_fragment("<img src='/uploads/default/_emoji/fish.png' class='emoji emoji-custom'>")
|
|
expect(frag.at("img")["width"]).to eq("20")
|
|
expect(frag.at("img")["height"]).to eq("20")
|
|
end
|
|
|
|
it "converts relative paths to absolute paths" do
|
|
frag = basic_fragment("<img src='/some-image.png'>")
|
|
expect(frag.at("img")["src"]).to eq("#{Discourse.base_url}/some-image.png")
|
|
end
|
|
|
|
it "strips classes and ids" do
|
|
frag = basic_fragment("<div class='foo' id='bar'><div class='foo' id='bar'></div></div>")
|
|
expect(frag.to_html).to eq("<div><div></div></div>")
|
|
end
|
|
end
|
|
|
|
describe "html template formatter" do
|
|
it "attaches a style to h3 tags" do
|
|
frag = html_fragment("<h3>hello</h3>")
|
|
expect(frag.at("h3")["style"]).to be_present
|
|
end
|
|
|
|
it "attaches a style to hr tags" do
|
|
frag = html_fragment("hello<hr>")
|
|
expect(frag.at("hr")["style"]).to be_present
|
|
end
|
|
|
|
it "attaches a style to a tags" do
|
|
frag = html_fragment("<a href>wat</a>")
|
|
expect(frag.at("a")["style"]).to be_present
|
|
end
|
|
|
|
it "attaches a style to ul and li tags" do
|
|
frag = html_fragment("<ul><li>hello</li></ul>")
|
|
expect(frag.at("ul")["style"]).to be_present
|
|
expect(frag.at("li")["style"]).to be_present
|
|
end
|
|
|
|
it "converts iframes to links" do
|
|
iframe_url = "http://www.youtube.com/embed/7twifrxOTQY?feature=oembed&wmode=opaque"
|
|
frag = html_fragment("<iframe src=\"#{iframe_url}\"></iframe>")
|
|
expect(frag.at("iframe")).to be_blank
|
|
expect(frag.at("a")).to be_present
|
|
expect(frag.at("a")["href"]).to eq(iframe_url)
|
|
end
|
|
|
|
it "won't allow non URLs in iframe src, strips them with no link" do
|
|
iframe_url = "alert('xss hole')"
|
|
frag = html_fragment("<iframe src=\"#{iframe_url}\"></iframe>")
|
|
expect(frag.at("iframe")).to be_blank
|
|
expect(frag.at("a")).to be_blank
|
|
end
|
|
|
|
it "won't allow empty iframe src, strips them with no link" do
|
|
frag = html_fragment("<iframe src=''></iframe>")
|
|
expect(frag.at("iframe")).to be_blank
|
|
expect(frag.at("a")).to be_blank
|
|
end
|
|
|
|
it "prefers data-original-href attribute to get iframe link" do
|
|
original_url = "https://vimeo.com/329875646/85f1546a42"
|
|
iframe_url = "https://player.vimeo.com/video/329875646"
|
|
frag =
|
|
html_fragment(
|
|
"<iframe src=\"#{iframe_url}\" data-original-href=\"#{original_url}\"></iframe>",
|
|
)
|
|
expect(frag.at("iframe")).to be_blank
|
|
expect(frag.at("a")).to be_present
|
|
expect(frag.at("a")["href"]).to eq(original_url)
|
|
end
|
|
|
|
it "replaces hashtag-cooked text with raw #hashtag" do
|
|
category = Fabricate(:category, name: "dev", slug: "dev")
|
|
post = Fabricate(:post, raw: "this is #dev")
|
|
post.rebake!
|
|
hashtag_html = post.cooked
|
|
frag = html_fragment(hashtag_html)
|
|
expect(frag.at("a").text.chomp).to eq("#dev")
|
|
end
|
|
end
|
|
|
|
describe "rewriting protocol relative URLs to the forum" do
|
|
it "doesn't rewrite a url to another site" do
|
|
frag = html_fragment('<a href="//youtube.com/discourse">hello</a>')
|
|
expect(frag.at("a")["href"]).to eq("//youtube.com/discourse")
|
|
end
|
|
|
|
context "without https" do
|
|
before { SiteSetting.force_https = false }
|
|
|
|
it "rewrites the href to have http" do
|
|
frag = html_fragment('<a href="//test.localhost/discourse">hello</a>')
|
|
expect(frag.at("a")["href"]).to eq("http://test.localhost/discourse")
|
|
end
|
|
|
|
it "rewrites the href for attachment files to have http" do
|
|
frag =
|
|
html_fragment(
|
|
'<a class="attachment" href="//try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt">attachment_file.txt</a>',
|
|
)
|
|
expect(frag.at("a")["href"]).to eq(
|
|
"http://try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt",
|
|
)
|
|
end
|
|
|
|
it "rewrites the src to have http" do
|
|
frag = html_fragment('<img src="//test.localhost/blah.jpg">')
|
|
expect(frag.at("img")["src"]).to eq("http://test.localhost/blah.jpg")
|
|
end
|
|
end
|
|
|
|
context "with https" do
|
|
before { SiteSetting.force_https = true }
|
|
|
|
it "rewrites the forum URL to have https" do
|
|
frag = html_fragment('<a href="//test.localhost/discourse">hello</a>')
|
|
expect(frag.at("a")["href"]).to eq("https://test.localhost/discourse")
|
|
end
|
|
|
|
it "rewrites the href for attachment files to have https" do
|
|
frag =
|
|
html_fragment(
|
|
'<a class="attachment" href="//try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt">attachment_file.txt</a>',
|
|
)
|
|
expect(frag.at("a")["href"]).to eq(
|
|
"https://try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt",
|
|
)
|
|
end
|
|
|
|
it "rewrites the src to have https" do
|
|
frag = html_fragment('<img src="//test.localhost/blah.jpg">')
|
|
expect(frag.at("img")["src"]).to eq("https://test.localhost/blah.jpg")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "deduplicate styles" do
|
|
it "removes double definitions" do
|
|
frag = "<test style='color:green;color:red'>hello</test>"
|
|
|
|
styler = Email::Styles.new(frag)
|
|
styled = styler.to_html
|
|
styled = Nokogiri::HTML5.fragment(styled)
|
|
expect(styled.at("test")["style"]).to eq("color:red")
|
|
end
|
|
it "handles whitespace correctly" do
|
|
frag =
|
|
"<test style=' color : green ; ; ; color : red; background:white; background:yellow '>hello</test>"
|
|
styler = Email::Styles.new(frag)
|
|
styled = styler.to_html
|
|
styled = Nokogiri::HTML5.fragment(styled)
|
|
expect(styled.at("test")["style"]).to eq("color:red;background:yellow")
|
|
end
|
|
end
|
|
|
|
describe "dark mode emails" do
|
|
it "adds dark_mode_styles when site setting active" do
|
|
frag = html_fragment('<div class="body">test</div>')
|
|
styler = Email::Styles.new(frag)
|
|
styler.format_basic
|
|
styler.format_html
|
|
@frag = Nokogiri::HTML5.fragment(styler.to_s)
|
|
|
|
# dark mode attribute
|
|
expect(@frag.css('[dm="body"]')).to be_present
|
|
end
|
|
end
|
|
|
|
describe "strip_avatars_and_emojis" do
|
|
it "works for lonesome emoji with no title" do
|
|
emoji = "<img src='/images/emoji/twitter/crying_cat_face.png'>"
|
|
style = Email::Styles.new(emoji)
|
|
style.strip_avatars_and_emojis
|
|
expect(style.to_html).to match_html(emoji)
|
|
end
|
|
|
|
it "works for lonesome emoji with title" do
|
|
emoji = "<img title='cry_cry' src='/images/emoji/twitter/crying_cat_face.png'>"
|
|
style = Email::Styles.new(emoji)
|
|
style.strip_avatars_and_emojis
|
|
expect(style.to_html).to match_html("cry_cry")
|
|
end
|
|
|
|
it "works if img tag has no attrs" do
|
|
cooked =
|
|
"Create a method for click on image and use ng-click in <img> in your slide box...it is simple"
|
|
style = Email::Styles.new(cooked)
|
|
style.strip_avatars_and_emojis
|
|
expect(style.to_html).to include(cooked)
|
|
end
|
|
end
|
|
|
|
describe "onebox_styles" do
|
|
it "renders quote as <blockquote>" do
|
|
fragment =
|
|
html_fragment(
|
|
'<aside class="quote"> <div class="title"> <div class="quote-controls"> <i class="fa fa-chevron-down" title="expand/collapse"></i><a href="/t/xyz/123" title="go to the quoted post" class="back"></a> </div> <img alt="" width="20" height="20" src="https://cdn-enterprise.discourse.org/boingboing/user_avatar/bbs.boingboing.net/techapj/40/54379_1.png" class="avatar">techAPJ: </div> <blockquote> <p>lorem ipsum</p> </blockquote> </aside>',
|
|
)
|
|
expect(fragment.to_s.squish).to match(%r{^<blockquote.+</blockquote>$})
|
|
end
|
|
|
|
it "removes GitHub excerpts" do
|
|
stub_request(:head, "https://github.com/discourse/discourse/pull/1253").to_return(
|
|
status: 200,
|
|
body: "",
|
|
headers: {
|
|
},
|
|
)
|
|
stub_request(:get, "https://api.github.com/repos/discourse/discourse/pulls/1253").to_return(
|
|
status: 200,
|
|
body: onebox_response("githubpullrequest"),
|
|
)
|
|
|
|
onebox = Oneboxer.onebox("https://github.com/discourse/discourse/pull/1253")
|
|
fragment = html_fragment(onebox)
|
|
expect(fragment.css(".github-body-container .excerpt")).to be_empty
|
|
end
|
|
end
|
|
|
|
describe "replace_secure_uploads_urls" do
|
|
before do
|
|
setup_s3
|
|
SiteSetting.secure_uploads = true
|
|
end
|
|
|
|
let(:attachments) { { "testimage.png" => stub(url: "email/test.png") } }
|
|
it "replaces secure uploads within a link with a placeholder" do
|
|
frag =
|
|
html_fragment(
|
|
"<a href=\"#{Discourse.base_url}\/secure-uploads/original/1X/testimage.png\"><img src=\"/secure-uploads/original/1X/testimage.png\"></a>",
|
|
)
|
|
expect(frag.at("img")).not_to be_present
|
|
expect(frag.to_s).to include("Redacted")
|
|
end
|
|
|
|
it "replaces secure images with a placeholder" do
|
|
frag = html_fragment("<img src=\"/secure-uploads/original/1X/testimage.png\">")
|
|
expect(frag.at("img")).not_to be_present
|
|
expect(frag.to_s).to include("Redacted")
|
|
end
|
|
|
|
it "does not replace topic links with secure-uploads in the name" do
|
|
frag =
|
|
html_fragment("<a href=\"#{Discourse.base_url}\/t/secure-uploads/235723\">Visit Topic</a>")
|
|
expect(frag.to_s).not_to include("Redacted")
|
|
end
|
|
|
|
it "works in lightboxes with missing srcset attribute" do
|
|
frag =
|
|
html_fragment(
|
|
"<a href=\"#{Discourse.base_url}\/secure-uploads/original/1X/testimage.png\" class=\"lightbox\"><img src=\"/secure-uploads/original/1X/testimage.png\"></a>",
|
|
)
|
|
expect(frag.at("img")).not_to be_present
|
|
expect(frag.to_s).to include("Redacted")
|
|
end
|
|
|
|
it "works in lightboxes with srcset attribute set" do
|
|
frag = html_fragment(<<~HTML)
|
|
<a href="#{Discourse.base_url}/secure-uploads/original/1X/testimage.png" class="lightbox">
|
|
<img src="/secure-uploads/original/1X/testimage.png" srcset="/secure-uploads/optimized/1X/testimage.png, /secure-uploads/original/1X/testimage.png 1.5x" />
|
|
</a>
|
|
HTML
|
|
|
|
expect(frag.at("img")).not_to be_present
|
|
expect(frag.to_s).to include("Redacted")
|
|
end
|
|
|
|
it "skips links with no images as children" do
|
|
frag =
|
|
html_fragment(
|
|
"<a href=\"#{Discourse.base_url}\/secure-uploads/original/1X/testimage.png\"><span>Clearly not an image</span></a>",
|
|
)
|
|
expect(frag.to_s).to include("not an image")
|
|
end
|
|
end
|
|
|
|
describe "#inline_secure_images" do
|
|
before do
|
|
setup_s3
|
|
SiteSetting.secure_uploads = true
|
|
end
|
|
|
|
fab!(:upload) do
|
|
Fabricate(:upload, original_filename: "testimage.png", secure: true, sha1: "123456")
|
|
end
|
|
let(:attachments) { [stub(url: "cid:email/test.png")] }
|
|
let(:attachments_index) { { upload.sha1 => 0 } }
|
|
let(:html) do
|
|
"<a href=\"#{Discourse.base_url}\/secure-uploads/original/1X/123456.png\"><img src=\"/secure-uploads/original/1X/123456.png\" width=\"20\" height=\"30\"></a>"
|
|
end
|
|
|
|
def strip_and_inline
|
|
# strip out the secure uploads
|
|
styler = Email::Styles.new(html)
|
|
styler.format_basic
|
|
styler.format_html
|
|
html = styler.to_html
|
|
|
|
# pass in the attachments to match uploads based on sha + original filename
|
|
styler = Email::Styles.new(html)
|
|
styler.inline_secure_images(attachments, attachments_index)
|
|
@frag = Nokogiri::HTML5.fragment(styler.to_s)
|
|
end
|
|
|
|
it "inlines attachments where stripped-secure-media data attr is present" do
|
|
strip_and_inline
|
|
expect(@frag.to_s).to include("cid:email/test.png")
|
|
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
|
|
expect(@frag.children.attr("style").value).to eq("width: 20px; height: 30px;")
|
|
end
|
|
|
|
it "does not inline anything if the upload cannot be found" do
|
|
upload.update(sha1: "blah12")
|
|
strip_and_inline
|
|
|
|
expect(@frag.to_s).not_to include("cid:email/test.png")
|
|
expect(@frag.css("[data-stripped-secure-upload]")).to be_present
|
|
end
|
|
|
|
context "when an optimized image is used instead of the original" do
|
|
let(:html) do
|
|
"<a href=\"#{Discourse.base_url}\/secure-uploads/optimized/2X/1/123456_2_20x30.png\"><img src=\"/secure-uploads/optimized/2X/1/123456_2_20x30.png\" width=\"20\" height=\"30\"></a>"
|
|
end
|
|
|
|
it "inlines attachments where the stripped-secure-media data attr is present" do
|
|
optimized = Fabricate(:optimized_image, upload: upload, width: 20, height: 30)
|
|
strip_and_inline
|
|
expect(@frag.to_s).to include("cid:email/test.png")
|
|
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
|
|
expect(@frag.children.attr("style").value).to eq("width: 20px; height: 30px;")
|
|
end
|
|
end
|
|
|
|
context "when inlining an originally oneboxed image" do
|
|
before { SiteSetting.authorized_extensions = "*" }
|
|
|
|
let(:siteicon) { Fabricate(:upload, original_filename: "siteicon.ico") }
|
|
let(:attachments) { [stub(url: "cid:email/test.png"), stub(url: "cid:email/test2.ico")] }
|
|
let(:attachments_index) { { upload.sha1 => 0, siteicon.sha1 => 1 } }
|
|
let(:html) { <<~HTML }
|
|
<aside class="onebox allowlistedgeneric">
|
|
<header class="source">
|
|
<img src="#{Discourse.base_url}/secure-uploads/original/1X/#{siteicon.sha1}.ico" class="site-icon" width="64" height="64">
|
|
<a href="https://test.com/article" target="_blank" rel="noopener" title="02:33PM - 24 October 2020">Test</a>
|
|
</header>
|
|
<article class="onebox-body">
|
|
<div class="aspect-image" style="--aspect-ratio:20/30;"><img src="#{Discourse.base_url}/secure-uploads/optimized/2X/1/123456_2_20x30.png" class="thumbnail d-lazyload" width="20" height="30" srcset="#{Discourse.base_url}/secure-uploads/optimized/2X/1/123456_2_20x30.png"></div>
|
|
|
|
<h3><a href="https://test.com/article" target="_blank" rel="noopener">Test</a></h3>
|
|
|
|
<p>This is a test onebox.</p>
|
|
|
|
</article>
|
|
<div class="onebox-metadata">
|
|
</div>
|
|
<div style="clear: both"></div>
|
|
</aside>
|
|
HTML
|
|
|
|
it "keeps the special site icon width and height and onebox styles" do
|
|
optimized = Fabricate(:optimized_image, upload: upload, width: 20, height: 30)
|
|
strip_and_inline
|
|
expect(@frag.to_s).to include("cid:email/test.png")
|
|
expect(@frag.to_s).to include("cid:email/test2.ico")
|
|
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
|
|
expect(@frag.css("[data-embedded-secure-image]")[0].attr("style")).to eq(
|
|
"width: 16px; height: 16px;",
|
|
)
|
|
expect(@frag.css("[data-embedded-secure-image]")[1].attr("style")).to eq(
|
|
"width: 60px; max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;",
|
|
)
|
|
end
|
|
|
|
context "when inlining a oneboxed image with a direct parent of onebox-body" do
|
|
let(:html) { <<~HTML }
|
|
<aside class="onebox allowlistedgeneric">
|
|
<header class="source">
|
|
<img src="#{Discourse.base_url}/secure-uploads/original/1X/#{siteicon.sha1}.ico" class="site-icon" width="64" height="64">
|
|
<a href="https://test.com/article" target="_blank" rel="noopener" title="02:33PM - 24 October 2020">Test</a>
|
|
</header>
|
|
<article class="onebox-body">
|
|
<img src="#{Discourse.base_url}/secure-uploads/original/1X/123456.png" class="thumbnail onebox-avatar" width="20" height="30">
|
|
|
|
<h3><a href="https://test.com/article" target="_blank" rel="noopener">Test</a></h3>
|
|
|
|
<p>This is a test onebox.</p>
|
|
|
|
</article>
|
|
<div class="onebox-metadata">
|
|
</div>
|
|
<div style="clear: both"></div>
|
|
</aside>
|
|
HTML
|
|
|
|
it "keeps the special onebox styles" do
|
|
strip_and_inline
|
|
expect(@frag.to_s).to include("cid:email/test.png")
|
|
expect(@frag.to_s).to include("cid:email/test2.ico")
|
|
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
|
|
expect(@frag.css("[data-embedded-secure-image]")[1].attr("style")).to eq(
|
|
"width: 60px; max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;",
|
|
)
|
|
end
|
|
end
|
|
|
|
context "when there is an inline-avatar in the onebox" do
|
|
let(:html) { <<~HTML }
|
|
<p><a class="mention" href="/u/martin">@martin</a> check this out:</p>
|
|
<aside class="onebox githubpullrequest">
|
|
<header class="source">
|
|
<a href="https://github.com/discourse/discourse/pull/11140" target="_blank" rel="noopener">github.com/discourse/discourse</a>
|
|
</header>
|
|
<article class="onebox-body">
|
|
<div class="github-row">
|
|
<div class="github-info-container">
|
|
<h4>
|
|
<a href="https://github.com/discourse/discourse/pull/11140" target="_blank" rel="noopener">FEATURE: Implement edit functionality for post notices</a>
|
|
</h4>
|
|
<div class="branches">
|
|
<code>discourse:master</code> ← <code>discourse:feature/post_notices_edit</code>
|
|
</div>
|
|
|
|
<div class="github-info">
|
|
<div class="date">
|
|
opened <span class="discourse-local-date" data-format="ll" data-date="2020-11-05" data-time="20:33:53" data-timezone="UTC">08:33PM - 05 Nov 20 UTC</span>
|
|
</div>
|
|
<div class="user">
|
|
<a href="https://github.com/udan11" target="_blank" rel="noopener">
|
|
<img alt="udan11" src="#{Discourse.base_url}/secure-uploads/original/1X/123456.png" class="onebox-avatar-inline" width="20" height="20">
|
|
udan11
|
|
</a>
|
|
</div>
|
|
<div class="lines" title="2 commits changed 27 files with 250 additions and 224 deletions">
|
|
<a href="https://github.com/discourse/discourse/pull/11140/files" target="_blank" rel="noopener">
|
|
<span class="added">+250</span>
|
|
<span class="removed">-224</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
<div class="onebox-metadata">
|
|
</div>
|
|
<div style="clear: both"></div>
|
|
</aside>
|
|
HTML
|
|
it "keeps the special onebox styles" do
|
|
strip_and_inline
|
|
expect(@frag.to_s).to include("cid:email/test.png")
|
|
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
|
|
expect(@frag.css("[data-embedded-secure-image]")[0].attr("style")).to eq(
|
|
"width: 20px; height: 20px; float: none; vertical-align: middle; max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;",
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|