mirror of
https://github.com/discourse/discourse.git
synced 2024-11-30 01:24:48 +08:00
61c87fb59f
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.
480 lines
18 KiB
Ruby
480 lines
18 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 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 "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
|