discourse/spec/lib/cooked_post_processor_spec.rb
Martin Brennan 5dc45b5dcf
FIX: Secure upload post processing race condition (#23968)
* FIX: Secure upload post processing race condition

This commit fixes a couple of issues.

A little background -- when uploads are created in the composer
for posts, regardless of whether the upload will eventually be
marked secure or not, if secure_uploads is enabled we always mark
the upload secure at first. This is so the upload is by default
protected, regardless of post type (regular or PM) or category.

This was causing issues in some rare occasions though because
of the order of operations of our post creation and processing
pipeline. When creating a post, we enqueue a sidekiq job to
post-process the post which does various things including
converting images to lightboxes. We were also enqueuing a job
to update the secure status for all uploads in that post.

Sometimes the secure status job would run before the post process
job, marking uploads as _not secure_ in the background and changing
their ACL before the post processor ran, which meant the users
would see a broken image in their posts. This commit fixes that issue
by always running the upload security changes inline _within_ the
cooked_post_processor job.

The other issue was that the lightbox wrapper link for images in
the post would end up with a URL like this:

```
href="/secure-uploads/original/2X/4/4e1f00a40b6c952198bbdacae383ba77932fc542.jpeg"
```

Since we weren't actually using the `upload.url` to pass to
`UrlHelper.cook_url` here, we weren't converting this href to the CDN
URL if the post was not in a secure context (the UrlHelper does not
know how to convert a secure-uploads URL to a CDN one). Now we
always end up with the correct lightbox href. This was less of an issue
than the other one, since the secure-uploads URL works even when the
upload has become non-secure, but it was a good inconsistency to fix
anyway.
2023-10-18 23:48:01 +00:00

2190 lines
80 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
require "cooked_post_processor"
require "file_store/s3_store"
RSpec.describe CookedPostProcessor do
fab!(:upload) { Fabricate(:upload) }
fab!(:large_image_upload) { Fabricate(:large_image_upload) }
let(:upload_path) { Discourse.store.upload_path }
describe "#post_process" do
fab!(:post) { Fabricate(:post, raw: <<~RAW) }
<img src="#{upload.url}">
RAW
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
let(:post_process) { sequence("post_process") }
it "post process in sequence" do
cpp.expects(:post_process_oneboxes).in_sequence(post_process)
cpp.expects(:post_process_images).in_sequence(post_process)
cpp.expects(:optimize_urls).in_sequence(post_process)
cpp.post_process
expect(UploadReference.exists?(target: post, upload: upload)).to eq(true)
end
describe "when post contains oneboxes and inline oneboxes" do
let(:url_hostname) { "meta.discourse.org" }
let(:url) { "https://#{url_hostname}/t/mini-inline-onebox-support-rfc/66400" }
let(:not_oneboxed_url) { "https://#{url_hostname}/t/random-url" }
let(:title) { "some title" }
let(:post) { Fabricate(:post, raw: <<~RAW) }
#{url}
This is a #{url} with path
#{not_oneboxed_url}
This is a https://#{url_hostname}/t/another-random-url test
This is a #{url} with path
#{url}
RAW
before do
SiteSetting.enable_inline_onebox_on_all_domains = true
Oneboxer.stubs(:cached_onebox).with(url).returns <<~HTML
<aside class="onebox allowlistedgeneric" data-onebox-src="https://meta.discourse.org/t/mini-inline-onebox-support-rfc/66400">
<header class="source">
<a href="https://meta.discourse.org/t/mini-inline-onebox-support-rfc/66400" target="_blank" rel="noopener">meta.discourse.org</a>
</header>
<article class="onebox-body">
<h3><a href="https://meta.discourse.org/t/mini-inline-onebox-support-rfc/66400" target="_blank" rel="noopener">some title</a></h3>
<p>some description</p>
</article>
<div class="onebox-metadata"></div>
<div style="clear: both"></div>
</aside>
HTML
Oneboxer.stubs(:cached_onebox).with(not_oneboxed_url).returns(nil)
%i[head get].each do |method|
stub_request(method, url).to_return(status: 200, body: <<~RAW)
<html>
<head>
<title>#{title}</title>
<meta property='og:title' content="#{title}">
<meta property='og:description' content="some description">
</head>
</html>
RAW
end
end
after do
InlineOneboxer.invalidate(url)
Oneboxer.invalidate(url)
end
it "should respect SiteSetting.max_oneboxes_per_post" do
SiteSetting.max_oneboxes_per_post = 2
SiteSetting.add_rel_nofollow_to_user_content = false
cpp.post_process
expect(cpp.html).to have_tag(
"a",
with: {
href: url,
class: "inline-onebox",
},
text: title,
count: 2,
)
expect(cpp.html).to have_tag("aside.onebox a", text: title, count: 1)
expect(cpp.html).to have_tag("aside.onebox a", text: url_hostname, count: 1)
expect(cpp.html).to have_tag(
"a",
without: {
class: "inline-onebox-loading",
},
text: not_oneboxed_url,
count: 1,
)
expect(cpp.html).to have_tag(
"a",
without: {
class: "onebox",
},
text: not_oneboxed_url,
count: 1,
)
end
end
describe "when post contains inline oneboxes" do
before { SiteSetting.enable_inline_onebox_on_all_domains = true }
describe "internal links" do
fab!(:topic) { Fabricate(:topic) }
fab!(:post) { Fabricate(:post, raw: "Hello #{topic.url}") }
let(:url) { topic.url }
it "includes the topic title" do
cpp.post_process
expect(cpp.html).to have_tag(
"a",
with: {
href: UrlHelper.cook_url(url),
},
without: {
class: "inline-onebox-loading",
},
text: topic.title,
count: 1,
)
topic.update!(title: "Updated to something else")
cpp = CookedPostProcessor.new(post, invalidate_oneboxes: true)
cpp.post_process
expect(cpp.html).to have_tag(
"a",
with: {
href: UrlHelper.cook_url(url),
},
without: {
class: "inline-onebox-loading",
},
text: topic.title,
count: 1,
)
end
end
describe "external links" do
let(:url_with_path) { "https://meta.discourse.org/t/mini-inline-onebox-support-rfc/66400" }
let(:url_with_query_param) { "https://meta.discourse.org?a" }
let(:url_no_path) { "https://meta.discourse.org/" }
let(:urls) { [url_with_path, url_with_query_param, url_no_path] }
let(:title) { "<b>some title</b>" }
let(:escaped_title) { CGI.escapeHTML(title) }
let(:post) { Fabricate(:post, raw: <<~RAW) }
This is a #{url_with_path} topic
This should not be inline #{url_no_path} oneboxed
- #{url_with_path}
- #{url_with_query_param}
RAW
let(:staff_post) { Fabricate(:post, user: Fabricate(:admin), raw: <<~RAW) }
This is a #{url_with_path} topic
RAW
before do
urls.each do |url|
stub_request(:get, url).to_return(
status: 200,
body: "<html><head><title>#{escaped_title}</title></head></html>",
)
end
end
after { urls.each { |url| InlineOneboxer.invalidate(url) } }
it "should convert the right links to inline oneboxes" do
cpp.post_process
html = cpp.html
expect(html).to_not have_tag(
"a",
with: {
href: url_no_path,
},
without: {
class: "inline-onebox-loading",
},
text: title,
)
expect(html).to have_tag(
"a",
with: {
href: url_with_path,
},
without: {
class: "inline-onebox-loading",
},
text: title,
count: 2,
)
expect(html).to have_tag(
"a",
with: {
href: url_with_query_param,
},
without: {
class: "inline-onebox-loading",
},
text: title,
count: 1,
)
expect(html).to have_tag("a[rel='noopener nofollow ugc']")
end
it "removes nofollow if user is staff/tl3" do
cpp = CookedPostProcessor.new(staff_post, invalidate_oneboxes: true)
cpp.post_process
expect(cpp.html).to_not have_tag("a[rel='noopener nofollow ugc']")
end
end
end
context "when processing images" do
before { SiteSetting.responsive_post_image_sizes = "" }
context "with responsive images" do
before { SiteSetting.responsive_post_image_sizes = "1|1.5|3" }
it "includes responsive images on demand" do
upload.update!(width: 2000, height: 1500, filesize: 10_000, dominant_color: "FFFFFF")
post = Fabricate(:post, raw: "hello <img src='#{upload.url}'>")
# fake some optimized images
OptimizedImage.create!(
url: "/#{upload_path}/666x500.jpg",
width: 666,
height: 500,
upload_id: upload.id,
sha1: SecureRandom.hex,
extension: ".jpg",
filesize: 500,
version: OptimizedImage::VERSION,
)
# fake 3x optimized image, we lose 2 pixels here over original due to rounding on downsize
OptimizedImage.create!(
url: "/#{upload_path}/1998x1500.jpg",
width: 1998,
height: 1500,
upload_id: upload.id,
sha1: SecureRandom.hex,
extension: ".jpg",
filesize: 800,
)
cpp = CookedPostProcessor.new(post)
cpp.add_to_size_cache(upload.url, 2000, 1500)
cpp.post_process
html = cpp.html
expect(html).to include(%Q|data-dominant-color="FFFFFF"|)
# 1.5x is skipped cause we have a missing thumb
expect(html).to include(
"srcset=\"//test.localhost/#{upload_path}/666x500.jpg, //test.localhost/#{upload_path}/1998x1500.jpg 3x\"",
)
expect(html).to include("src=\"//test.localhost/#{upload_path}/666x500.jpg\"")
# works with CDN
set_cdn_url("http://cdn.localhost")
cpp = CookedPostProcessor.new(post)
cpp.add_to_size_cache(upload.url, 2000, 1500)
cpp.post_process
html = cpp.html
expect(html).to include(%Q|data-dominant-color="FFFFFF"|)
expect(html).to include(
"srcset=\"//cdn.localhost/#{upload_path}/666x500.jpg, //cdn.localhost/#{upload_path}/1998x1500.jpg 3x\"",
)
expect(html).to include("src=\"//cdn.localhost/#{upload_path}/666x500.jpg\"")
end
it "doesn't include response images for cropped images" do
upload.update!(width: 200, height: 4000, filesize: 12_345)
post = Fabricate(:post, raw: "hello <img src='#{upload.url}'>")
# fake some optimized images
OptimizedImage.create!(
url: "http://a.b.c/200x500.jpg",
width: 200,
height: 500,
upload_id: upload.id,
sha1: SecureRandom.hex,
extension: ".jpg",
filesize: 500,
)
cpp = CookedPostProcessor.new(post)
cpp.add_to_size_cache(upload.url, 200, 4000)
cpp.post_process
expect(cpp.html).to_not include('srcset="')
end
end
shared_examples "leave dimensions alone" do
it "doesn't use them" do
expect(cpp.html).to match(%r{src="http://foo.bar/image.png" width="" height=""})
expect(cpp.html).to match(%r{src="http://domain.com/picture.jpg" width="50" height="42"})
expect(cpp).to be_dirty
end
end
context "with image_sizes" do
fab!(:post) { Fabricate(:post_with_image_urls) }
let(:cpp) { CookedPostProcessor.new(post, image_sizes: image_sizes) }
before do
stub_image_size
cpp.post_process
end
context "when valid" do
let(:image_sizes) do
{ "http://foo.bar/image.png" => { "width" => 111, "height" => 222 } }
end
it "uses them" do
expect(cpp.html).to match(%r{src="http://foo.bar/image.png" width="111" height="222"})
expect(cpp.html).to match(
%r{src="http://domain.com/picture.jpg" width="50" height="42"},
)
expect(cpp).to be_dirty
end
end
context "with invalid width" do
let(:image_sizes) { { "http://foo.bar/image.png" => { "width" => 0, "height" => 222 } } }
include_examples "leave dimensions alone"
end
context "with invalid height" do
let(:image_sizes) { { "http://foo.bar/image.png" => { "width" => 111, "height" => 0 } } }
include_examples "leave dimensions alone"
end
context "with invalid width & height" do
let(:image_sizes) { { "http://foo.bar/image.png" => { "width" => 0, "height" => 0 } } }
include_examples "leave dimensions alone"
end
end
context "with unsized images" do
fab!(:upload) { Fabricate(:image_upload, width: 123, height: 456) }
fab!(:post) { Fabricate(:post, raw: <<~HTML) }
<img src="#{upload.url}">
HTML
let(:cpp) { CookedPostProcessor.new(post) }
it "adds the width and height to images that don't have them" do
cpp.post_process
expect(cpp.html).to match(/width="123" height="456"/)
expect(cpp).to be_dirty
end
end
context "with large images" do
fab!(:upload) { Fabricate(:image_upload, width: 1750, height: 2000) }
fab!(:post) { Fabricate(:post, raw: <<~HTML) }
<img src="#{upload.url}">
HTML
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
before do
SiteSetting.max_image_height = 2000
SiteSetting.create_thumbnails = true
end
it "generates overlay information" do
cpp.post_process
expect(cpp.html).to match_html <<~HTML
<p><div class="lightbox-wrapper"><a class="lightbox" href="//test.localhost#{upload.url}" data-download-href="//test.localhost/#{upload_path}/#{upload.sha1}" title="logo.png"><img src="//test.localhost/#{upload_path}/optimized/1X/#{upload.sha1}_#{OptimizedImage::VERSION}_690x788.png" width="690" height="788"><div class="meta"><svg class="fa d-icon d-icon-far-image svg-icon" aria-hidden="true"><use href="#far-image"></use></svg><span class="filename">logo.png</span><span class="informations">1750×2000 1.21 KB</span><svg class="fa d-icon d-icon-discourse-expand svg-icon" aria-hidden="true"><use href="#discourse-expand"></use></svg></div></a></div></p>
HTML
expect(cpp).to be_dirty
end
context "when image is inside onebox" do
let(:url) { "https://image.com/my-avatar" }
let(:post) { Fabricate(:post, raw: url) }
before do
Oneboxer
.stubs(:onebox)
.with(url, anything)
.returns(
"<img class='onebox' src='/#{upload_path}/original/1X/1234567890123456.jpg' />",
)
end
it "should not add lightbox" do
FastImage.expects(:size).returns([1750, 2000])
cpp.post_process
expect(cpp.html).to match_html <<~HTML
<p><img class="onebox" src="//test.localhost/#{upload_path}/original/1X/1234567890123456.jpg" width="690" height="788"></p>
HTML
end
end
context "when image is an svg" do
fab!(:post) do
Fabricate(
:post,
raw: "<img src=\"/#{Discourse.store.upload_path}/original/1X/1234567890123456.svg\">",
)
end
it "should not add lightbox" do
FastImage.expects(:size).returns([1750, 2000])
cpp.post_process
expect(cpp.html).to match_html <<~HTML
<p><img src="//test.localhost/#{upload_path}/original/1X/1234567890123456.svg" width="690" height="788"></p>
HTML
end
context "when image src is an URL" do
let(:post) do
Fabricate(
:post,
raw:
"<img src=\"http://test.discourse/#{upload_path}/original/1X/1234567890123456.svg?somepamas\">",
)
end
it "should not add lightbox" do
FastImage.expects(:size).returns([1750, 2000])
cpp.post_process
expect(cpp.html).to match_html(
"<p><img src=\"http://test.discourse/#{upload_path}/original/1X/1234567890123456.svg?somepamas\" width=\"690\"\ height=\"788\"></p>",
)
end
end
end
context "with s3_uploads" do
let(:upload) { Fabricate(:secure_upload_s3) }
before do
setup_s3
SiteSetting.s3_cdn_url = "https://s3.cdn.com"
SiteSetting.authorized_extensions = "png|jpg|gif|mov|ogg|"
stored_path = Discourse.store.get_path_for_upload(upload)
upload.update_column(:url, "#{SiteSetting.Upload.absolute_base_url}/#{stored_path}")
stub_upload(upload)
SiteSetting.login_required = true
SiteSetting.secure_uploads = true
end
let(:optimized_size) { "600x500" }
let(:post) do
Fabricate(:post, raw: "![large.png|#{optimized_size}](#{upload.short_url})")
end
let(:cooked_html) { <<~HTML }
<p><div class="lightbox-wrapper"><a class="lightbox" href="//test.localhost/secure-uploads/original/1X/#{upload.sha1}.png" data-download-href="//test.localhost/uploads/short-url/#{upload.base62_sha1}.unknown?dl=1" title="large.png"><img src="" alt="large.png" data-base62-sha1="#{upload.base62_sha1}" width="600" height="500"><div class="meta">
<svg class="fa d-icon d-icon-far-image svg-icon" aria-hidden="true"><use href="#far-image"></use></svg><span class="filename">large.png</span><span class="informations">1750×2000 1.21 KB</span><svg class="fa d-icon d-icon-discourse-expand svg-icon" aria-hidden="true"><use href="#discourse-expand"></use></svg>
</div></a></div></p>
HTML
context "when the upload is attached to the correct post" do
before do
Discourse
.store
.class
.any_instance
.expects(:has_been_uploaded?)
.at_least_once
.returns(true)
upload.update!(secure: true, access_control_post: post)
post.link_post_uploads
end
# TODO fix this spec, it is sometimes getting CDN links when it runs concurrently
xit "handles secure images with the correct lightbox link href" do
FastImage.expects(:size).returns([1750, 2000])
OptimizedImage.expects(:resize).returns(true)
cpp.post_process
expect(cpp.html).to match_html cooked_html
end
context "when the upload was not secure" do
before { upload.update!(secure: false) }
it "changes the secure status" do
cpp.post_process
expect(upload.reload.secure).to eq(true)
end
end
context "when the upload should no longer be considered secure" do
before { SiteSetting.login_required = false }
it "changes the secure status" do
cpp.post_process
expect(upload.reload.secure).to eq(false)
end
it "does not use a secure-uploads URL for the lightbox href" do
SiteSetting.create_thumbnails = false
SiteSetting.max_image_width = 10
SiteSetting.max_image_height = 10
cpp.post_process
expect(cpp.html).not_to have_tag(
"a",
with: {
class: "lightbox",
href: "//test.localhost/secure-uploads/original/1X/#{upload.sha1}.png",
},
)
end
end
end
context "when the upload is attached to a different post" do
before do
FastImage.size(upload.url)
upload.update(secure: true, access_control_post: Fabricate(:post))
end
it "does not create thumbnails or optimize images" do
CookedPostProcessor.any_instance.expects(:optimize_image!).never
Upload.any_instance.expects(:create_thumbnail!).never
stub_image_size
cpp.post_process
expect(cpp.html).not_to match_html cooked_html
end
end
end
end
context "with tall images > default aspect ratio" do
fab!(:upload) { Fabricate(:image_upload, width: 500, height: 2200) }
fab!(:post) { Fabricate(:post, raw: <<~HTML) }
<img src="#{upload.url}">
HTML
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
before { SiteSetting.create_thumbnails = true }
it "resizes the image instead of crop" do
cpp.post_process
expect(cpp.html).to match(/width="113" height="500">/)
expect(cpp).to be_dirty
end
end
context "with taller images < default aspect ratio" do
fab!(:upload) { Fabricate(:image_upload, width: 500, height: 2300) }
fab!(:post) { Fabricate(:post, raw: <<~HTML) }
<img src="#{upload.url}">
HTML
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
before { SiteSetting.create_thumbnails = true }
it "crops the image" do
cpp.post_process
expect(cpp.html).to match(/width="500" height="500">/)
expect(cpp).to be_dirty
end
end
context "with iPhone X screenshots" do
fab!(:upload) { Fabricate(:image_upload, width: 1125, height: 2436) }
fab!(:post) { Fabricate(:post, raw: <<~HTML) }
<img src="#{upload.url}">
HTML
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
before { SiteSetting.create_thumbnails = true }
it "crops the image" do
cpp.post_process
expect(cpp.html).to match_html <<~HTML
<p><div class="lightbox-wrapper"><a class="lightbox" href="//test.localhost#{upload.url}" data-download-href="//test.localhost/#{upload_path}/#{upload.sha1}" title="logo.png"><img src="//test.localhost/#{upload_path}/optimized/1X/#{upload.sha1}_#{OptimizedImage::VERSION}_230x500.png" width="230" height="500"><div class="meta"><svg class="fa d-icon d-icon-far-image svg-icon" aria-hidden="true"><use href="#far-image"></use></svg><span class="filename">logo.png</span><span class="informations">1125×2436 1.21 KB</span><svg class="fa d-icon d-icon-discourse-expand svg-icon" aria-hidden="true"><use href="#discourse-expand"></use></svg></div></a></div></p>
HTML
expect(cpp).to be_dirty
end
end
context "with large images when using subfolders" do
fab!(:upload) { Fabricate(:image_upload, width: 1750, height: 2000) }
fab!(:post) { Fabricate(:post, raw: <<~HTML) }
<img src="/subfolder#{upload.url}">
HTML
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
before do
set_subfolder "/subfolder"
stub_request(
:get,
"http://#{Discourse.current_hostname}/subfolder#{upload.url}",
).to_return(status: 200, body: File.new(Discourse.store.path_for(upload)))
SiteSetting.max_image_height = 2000
SiteSetting.create_thumbnails = true
end
it "generates overlay information" do
cpp.post_process
expect(cpp.html).to match_html <<~HTML
<p><div class="lightbox-wrapper"><a class="lightbox" href="//test.localhost/subfolder#{upload.url}" data-download-href="//test.localhost/subfolder/#{upload_path}/#{upload.sha1}" title="logo.png"><img src="//test.localhost/subfolder/#{upload_path}/optimized/1X/#{upload.sha1}_#{OptimizedImage::VERSION}_690x788.png" width="690" height="788"><div class="meta"><svg class="fa d-icon d-icon-far-image svg-icon" aria-hidden="true"><use href="#far-image"></use></svg><span class="filename">logo.png</span><span class="informations">1750×2000 1.21 KB</span><svg class="fa d-icon d-icon-discourse-expand svg-icon" aria-hidden="true"><use href="#discourse-expand"></use></svg></div></a></div></p>
HTML
expect(cpp).to be_dirty
end
it "should escape the filename" do
upload.update!(original_filename: "><img src=x onerror=alert('haha')>.png")
cpp.post_process
expect(cpp.html).to match_html <<~HTML
<p><div class="lightbox-wrapper"><a class="lightbox" href="//test.localhost/subfolder#{upload.url}" data-download-href="//test.localhost/subfolder/#{upload_path}/#{upload.sha1}" title="><img src=x onerror=alert('haha')>.png"><img src="//test.localhost/subfolder/#{upload_path}/optimized/1X/#{upload.sha1}_#{OptimizedImage::VERSION}_690x788.png" width="690" height="788"><div class="meta"><svg class="fa d-icon d-icon-far-image svg-icon" aria-hidden="true"><use href="#far-image"></use></svg><span class="filename">&gt;&lt;img src=x onerror=alert('haha')&gt;.png</span><span class="informations">1750×2000 1.21 KB</span><svg class="fa d-icon d-icon-discourse-expand svg-icon" aria-hidden="true"><use href="#discourse-expand"></use></svg></div></a></div></p>
HTML
end
end
context "with title and alt" do
fab!(:upload) { Fabricate(:image_upload, width: 1750, height: 2000) }
fab!(:post) { Fabricate(:post, raw: <<~HTML) }
<img src="#{upload.url}" title="WAT" alt="RED">
HTML
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
before do
SiteSetting.max_image_height = 2000
SiteSetting.create_thumbnails = true
end
it "generates overlay information using image title and ignores alt" do
cpp.post_process
expect(cpp.html).to match_html <<~HTML
<p><div class="lightbox-wrapper"><a class="lightbox" href="//test.localhost#{upload.url}" data-download-href="//test.localhost/#{upload_path}/#{upload.sha1}" title="WAT"><img src="//test.localhost/#{upload_path}/optimized/1X/#{upload.sha1}_#{OptimizedImage::VERSION}_690x788.png" title="WAT" alt="RED" width="690" height="788"><div class="meta"><svg class="fa d-icon d-icon-far-image svg-icon" aria-hidden="true"><use href="#far-image"></use></svg><span class="filename">WAT</span><span class="informations">1750×2000 1.21 KB</span><svg class="fa d-icon d-icon-discourse-expand svg-icon" aria-hidden="true"><use href="#discourse-expand"></use></svg></div></a></div></p>
HTML
expect(cpp).to be_dirty
end
end
context "with title only" do
fab!(:upload) { Fabricate(:image_upload, width: 1750, height: 2000) }
fab!(:post) { Fabricate(:post, raw: <<~HTML) }
<img src="#{upload.url}" title="WAT">
HTML
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
before do
SiteSetting.max_image_height = 2000
SiteSetting.create_thumbnails = true
end
it "generates overlay information using image title" do
cpp.post_process
expect(cpp.html).to match_html <<~HTML
<p><div class="lightbox-wrapper"><a class="lightbox" href="//test.localhost#{upload.url}" data-download-href="//test.localhost/#{upload_path}/#{upload.sha1}" title="WAT"><img src="//test.localhost/#{upload_path}/optimized/1X/#{upload.sha1}_#{OptimizedImage::VERSION}_690x788.png" title="WAT" width="690" height="788"><div class="meta"><svg class="fa d-icon d-icon-far-image svg-icon" aria-hidden="true"><use href="#far-image"></use></svg><span class="filename">WAT</span><span class="informations">1750×2000 1.21 KB</span><svg class="fa d-icon d-icon-discourse-expand svg-icon" aria-hidden="true"><use href="#discourse-expand"></use></svg></div></a></div></p>
HTML
expect(cpp).to be_dirty
end
end
context "with alt only" do
fab!(:upload) { Fabricate(:image_upload, width: 1750, height: 2000) }
fab!(:post) { Fabricate(:post, raw: <<~HTML) }
<img src="#{upload.url}" alt="RED">
HTML
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
before do
SiteSetting.max_image_height = 2000
SiteSetting.create_thumbnails = true
end
it "generates overlay information using image alt" do
cpp.post_process
expect(cpp.html).to match_html <<~HTML
<p><div class="lightbox-wrapper"><a class="lightbox" href="//test.localhost#{upload.url}" data-download-href="//test.localhost/#{upload_path}/#{upload.sha1}" title="RED"><img src="//test.localhost/#{upload_path}/optimized/1X/#{upload.sha1}_#{OptimizedImage::VERSION}_690x788.png" alt="RED" width="690" height="788"><div class="meta"><svg class="fa d-icon d-icon-far-image svg-icon" aria-hidden="true"><use href="#far-image"></use></svg><span class="filename">RED</span><span class="informations">1750×2000 1.21 KB</span><svg class="fa d-icon d-icon-discourse-expand svg-icon" aria-hidden="true"><use href="#discourse-expand"></use></svg></div></a></div></p>
HTML
expect(cpp).to be_dirty
end
end
context "with topic image" do
fab!(:post) { Fabricate(:post_with_uploaded_image) }
let(:cpp) { CookedPostProcessor.new(post) }
it "adds a topic image if there's one in the first post" do
FastImage.stubs(:size)
expect(post.topic.image_upload_id).to eq(nil)
cpp.post_process
post.topic.reload
expect(post.topic.image_upload_id).to be_present
end
it "removes image if post is edited and no longer has an image" do
FastImage.stubs(:size)
cpp.post_process
post.topic.reload
expect(post.topic.image_upload_id).to be_present
expect(post.image_upload_id).to be_present
post.update!(raw: "This post no longer has an image.")
CookedPostProcessor.new(post).post_process
post.topic.reload
expect(post.topic.image_upload_id).not_to be_present
expect(post.image_upload_id).not_to be_present
end
it "won't remove the original image if another post doesn't have an image" do
topic = post.topic
cpp.post_process
topic.reload
expect(topic.image_upload_id).to be_present
expect(post.image_upload_id).to be_present
post = Fabricate(:post, topic: topic, raw: "this post doesn't have an image")
CookedPostProcessor.new(post).post_process
topic.reload
expect(post.topic.image_upload_id).to be_present
expect(post.image_upload_id).to be_blank
end
it "generates thumbnails correctly" do
# image size in cooked is 1500*2000
topic = post.topic
cpp.post_process
topic.reload
expect(topic.image_upload_id).to be_present
expect(post.image_upload_id).to be_present
post = Fabricate(:post, topic: topic, raw: "this post doesn't have an image")
CookedPostProcessor.new(post).post_process
topic.reload
expect(post.topic.image_upload_id).to be_present
expect(post.image_upload_id).to be_blank
end
end
it "prioritizes data-thumbnail images" do
upload1 = Fabricate(:image_upload, width: 1750, height: 2000)
upload2 = Fabricate(:image_upload, width: 1750, height: 2000)
post = Fabricate(:post, raw: <<~MD)
![alttext|1750x2000](#{upload1.url})
![alttext|1750x2000|thumbnail](#{upload2.url})
MD
CookedPostProcessor.new(post, disable_dominant_color: true).post_process
expect(post.reload.image_upload_id).to eq(upload2.id)
end
context "with post image" do
let(:reply) { Fabricate(:post_with_uploaded_image, post_number: 2) }
let(:cpp) { CookedPostProcessor.new(reply) }
it "adds a post image if there's one in the post" do
FastImage.stubs(:size)
expect(reply.image_upload_id).to eq(nil)
cpp.post_process
reply.reload
expect(reply.image_upload_id).to be_present
end
end
end
end
describe "#extract_images" do
let(:post) { build(:post_with_plenty_of_images) }
let(:cpp) { CookedPostProcessor.new(post) }
it "does not extract emojis or images inside oneboxes or quotes" do
expect(cpp.extract_images.length).to eq(0)
end
end
describe "#get_size_from_attributes" do
let(:post) { build(:post) }
let(:cpp) { CookedPostProcessor.new(post) }
it "returns the size when width and height are specified" do
img = { "src" => "http://foo.bar/image3.png", "width" => 50, "height" => 70 }
expect(cpp.get_size_from_attributes(img)).to eq([50, 70])
end
it "returns the size when width and height are floats" do
img = { "src" => "http://foo.bar/image3.png", "width" => 50.2, "height" => 70.1 }
expect(cpp.get_size_from_attributes(img)).to eq([50, 70])
end
it "resizes when only width is specified" do
img = { "src" => "http://foo.bar/image3.png", "width" => 100 }
FastImage.expects(:size).returns([200, 400])
expect(cpp.get_size_from_attributes(img)).to eq([100, 200])
end
it "resizes when only height is specified" do
img = { "src" => "http://foo.bar/image3.png", "height" => 100 }
FastImage.expects(:size).returns([100, 300])
expect(cpp.get_size_from_attributes(img)).to eq([33, 100])
end
it "doesn't raise an error with a weird url" do
img = { "src" => nil, "height" => 100 }
expect(cpp.get_size_from_attributes(img)).to be_nil
end
end
describe "#get_size_from_image_sizes" do
let(:post) { build(:post) }
let(:cpp) { CookedPostProcessor.new(post) }
let(:image_sizes) do
{ "http://my.discourse.org/image.png" => { "width" => 111, "height" => 222 } }
end
it "returns the size" do
expect(cpp.get_size_from_image_sizes("/image.png", image_sizes)).to eq([111, 222])
end
it "returns nil whe img node has no src" do
expect(cpp.get_size_from_image_sizes(nil, image_sizes)).to eq(nil)
end
end
describe "#get_size" do
let(:post) { build(:post) }
let(:cpp) { CookedPostProcessor.new(post) }
it "ensures urls are absolute" do
cpp.expects(:is_valid_image_url?).with("http://test.localhost/relative/url/image.png")
cpp.get_size("/relative/url/image.png")
end
it "ensures urls have a default scheme" do
cpp.expects(:is_valid_image_url?).with("http://schemaless.url/image.jpg")
cpp.get_size("//schemaless.url/image.jpg")
end
it "caches the results" do
FastImage.expects(:size).returns([200, 400])
cpp.get_size("http://foo.bar/image3.png")
expect(cpp.get_size("http://foo.bar/image3.png")).to eq([200, 400])
end
end
describe "#is_valid_image_url?" do
let(:post) { build(:post) }
let(:cpp) { CookedPostProcessor.new(post) }
it "validates HTTP(s) urls" do
expect(cpp.is_valid_image_url?("http://domain.com")).to eq(true)
expect(cpp.is_valid_image_url?("https://domain.com")).to eq(true)
end
it "doesn't validate other urls" do
expect(cpp.is_valid_image_url?("ftp://domain.com")).to eq(false)
expect(cpp.is_valid_image_url?("ftps://domain.com")).to eq(false)
expect(cpp.is_valid_image_url?("/tmp/image.png")).to eq(false)
expect(cpp.is_valid_image_url?("//domain.com")).to eq(false)
end
it "doesn't throw an exception with a bad URI" do
expect(cpp.is_valid_image_url?("http://do<main.com")).to eq(nil)
end
end
describe "#get_filename" do
let(:post) { build(:post) }
let(:cpp) { CookedPostProcessor.new(post) }
it "returns the filename of the src when there is no upload" do
expect(cpp.get_filename(nil, "http://domain.com/image.png")).to eq("image.png")
end
it "returns the original filename of the upload when there is an upload" do
upload = build(:upload, original_filename: "upload.jpg")
expect(cpp.get_filename(upload, "http://domain.com/image.png")).to eq("upload.jpg")
end
it "returns a generic name for pasted images" do
upload = build(:upload, original_filename: "blob.png")
expect(cpp.get_filename(upload, "http://domain.com/image.png")).to eq(
I18n.t("upload.pasted_image_filename"),
)
end
end
describe "#convert_to_link" do
fab!(:thumbnail) { Fabricate(:optimized_image, upload: upload, width: 512, height: 384) }
it "adds lightbox and optimizes images" do
post = Fabricate(:post, raw: "![image|1024x768, 50%](#{large_image_upload.short_url})")
cpp = CookedPostProcessor.new(post, disable_dominant_color: true)
cpp.post_process
doc = Nokogiri::HTML5.fragment(cpp.html)
expect(doc.css(".lightbox-wrapper").size).to eq(1)
expect(doc.css("img").first["srcset"]).to_not eq(nil)
end
it "processes animated images correctly" do
# skips optimization
# skips lightboxing
# adds "animated" class to element
upload.update!(animated: true)
post = Fabricate(:post, raw: "![image|1024x768, 50%](#{upload.short_url})")
cpp = CookedPostProcessor.new(post, disable_dominant_color: true)
cpp.post_process
doc = Nokogiri::HTML5.fragment(cpp.html)
expect(doc.css(".lightbox-wrapper").size).to eq(0)
expect(doc.css("img").first["src"]).to include(upload.url)
expect(doc.css("img").first["srcset"]).to eq(nil)
expect(doc.css("img.animated").size).to eq(1)
end
context "with giphy/tenor images" do
before do
CookedPostProcessor
.any_instance
.stubs(:get_size)
.with("https://media2.giphy.com/media/7Oifk90VrCdNe/giphy.webp")
.returns([311, 280])
CookedPostProcessor
.any_instance
.stubs(:get_size)
.with("https://media1.tenor.com/images/20c7ddd5e84c7427954f430439c5209d/tenor.gif")
.returns([833, 104])
end
it "marks giphy images as animated" do
post =
Fabricate(
:post,
raw: "![tennis-gif|311x280](https://media2.giphy.com/media/7Oifk90VrCdNe/giphy.webp)",
)
cpp = CookedPostProcessor.new(post, disable_dominant_color: true)
cpp.post_process
doc = Nokogiri::HTML5.fragment(cpp.html)
expect(doc.css("img.animated").size).to eq(1)
end
it "marks giphy images as animated" do
post =
Fabricate(
:post,
raw:
"![cat](https://media1.tenor.com/images/20c7ddd5e84c7427954f430439c5209d/tenor.gif)",
)
cpp = CookedPostProcessor.new(post, disable_dominant_color: true)
cpp.post_process
doc = Nokogiri::HTML5.fragment(cpp.html)
expect(doc.css("img.animated").size).to eq(1)
end
end
it "optimizes and wraps images in quotes with lightbox wrapper" do
post = Fabricate(:post, raw: <<~MD)
[quote]
![image|1024x768, 50%](#{large_image_upload.short_url})
[/quote]
MD
cpp = CookedPostProcessor.new(post, disable_dominant_color: true)
cpp.post_process
doc = Nokogiri::HTML5.fragment(cpp.html)
expect(doc.css(".lightbox-wrapper").size).to eq(1)
expect(doc.css("img").first["srcset"]).to_not eq(nil)
end
it "optimizes images in Onebox" do
Oneboxer
.expects(:onebox)
.with("https://discourse.org", anything)
.returns(
"<aside class='onebox'><img src='#{large_image_upload.url}' width='512' height='384'></aside>",
)
post = Fabricate(:post, raw: "https://discourse.org")
cpp = CookedPostProcessor.new(post, disable_dominant_color: true)
cpp.post_process
doc = Nokogiri::HTML5.fragment(cpp.html)
expect(doc.css(".lightbox-wrapper").size).to eq(0)
expect(doc.css("img").first["srcset"]).to eq(nil)
expect(doc.css("img").first["src"]).to include("optimized")
expect(doc.css("img").first["src"]).to include("512x384")
end
end
describe "#post_process_oneboxes" do
let(:post) { build(:post_with_youtube, id: 123) }
let(:cpp) { CookedPostProcessor.new(post, invalidate_oneboxes: true) }
before do
Oneboxer
.expects(:onebox)
.with(
"http://www.youtube.com/watch?v=9bZkp7q19f0",
invalidate_oneboxes: true,
user_id: nil,
category_id: post.topic.category_id,
)
.returns("<div>GANGNAM STYLE</div>")
cpp.post_process_oneboxes
end
it "inserts the onebox without wrapping p" do
expect(cpp).to be_dirty
expect(cpp.html).to match_html "<div>GANGNAM STYLE</div>"
end
describe "replacing downloaded onebox image" do
let(:url) { "https://image.com/my-avatar" }
let(:image_url) { "https://image.com/avatar.png" }
it "successfully replaces the image" do
Oneboxer
.stubs(:onebox)
.with(url, anything)
.returns("<img class='onebox' src='#{image_url}' />")
post = Fabricate(:post, raw: url)
upload.update!(url: "https://test.s3.amazonaws.com/something.png", dominant_color: "00ffff")
PostHotlinkedMedia.create!(
url: "//image.com/avatar.png",
post: post,
status: "downloaded",
upload: upload,
)
cpp = CookedPostProcessor.new(post, invalidate_oneboxes: true)
stub_image_size(width: 100, height: 200)
cpp.post_process_oneboxes
expect(cpp.doc.to_s).to eq(
"<p><img class=\"onebox\" src=\"#{upload.url}\" data-dominant-color=\"00ffff\" width=\"100\" height=\"200\"></p>",
)
upload.destroy!
cpp = CookedPostProcessor.new(post, invalidate_oneboxes: true)
stub_image_size(width: 100, height: 200)
cpp.post_process_oneboxes
expect(cpp.doc.to_s).to eq(
"<p><img class=\"onebox\" src=\"#{image_url}\" width=\"100\" height=\"200\"></p>",
)
Oneboxer.unstub(:onebox)
end
context "when the post is with_secure_uploads and the upload is secure and secure uploads is enabled" do
before do
setup_s3
upload.update(secure: true)
SiteSetting.login_required = true
SiteSetting.secure_uploads = true
end
it "does not use the direct URL, uses the cooked URL instead (because of the private ACL preventing w/h fetch)" do
Oneboxer
.stubs(:onebox)
.with(url, anything)
.returns("<img class='onebox' src='#{image_url}' />")
post = Fabricate(:post, raw: url)
upload.update!(
url: "https://test.s3.amazonaws.com/something.png",
dominant_color: "00ffff",
)
PostHotlinkedMedia.create!(
url: "//image.com/avatar.png",
post: post,
status: "downloaded",
upload: upload,
)
cooked_url = "https://localhost/secure-uploads/test.png"
UrlHelper.expects(:cook_url).with(upload.url, secure: true).returns(cooked_url)
cpp = CookedPostProcessor.new(post, invalidate_oneboxes: true)
stub_image_size(width: 100, height: 200)
cpp.post_process_oneboxes
expect(cpp.doc.to_s).to eq(
"<p><img class=\"onebox\" src=\"#{cooked_url}\" data-dominant-color=\"00ffff\" width=\"100\" height=\"200\"></p>",
)
end
end
end
it "replaces large image placeholder" do
SiteSetting.max_image_size_kb = 4096
url = "https://image.com/avatar.png"
Oneboxer.stubs(:onebox).with(url, anything).returns <<~HTML
<a href="#{url}" target="_blank" rel="noopener" class="onebox">
<img class='onebox' src='#{url}' />
</a>
HTML
post = Fabricate(:post, raw: url)
PostHotlinkedMedia.create!(url: "//image.com/avatar.png", post: post, status: "too_large")
cpp = CookedPostProcessor.new(post, invalidate_oneboxes: true)
cpp.post_process
expect(cpp.doc.to_s).to match(/<div class="large-image-placeholder">/)
expect(cpp.doc.to_s).to include(
I18n.t("upload.placeholders.too_large_humanized", max_size: "4 MB"),
)
end
it "removes large images from onebox" do
url = "https://example.com/article"
Oneboxer.stubs(:onebox).with(url, anything).returns <<~HTML
<aside class="onebox allowlistedgeneric" data-onebox-src="https://example.com/article">
<header class="source">
<img src="https://example.com/favicon.ico" class="site-icon">
<a href="https://example.com/article" target="_blank" rel="nofollow ugc noopener">Example Site</a>
</header>
<article class="onebox-body">
<img src="https://example.com/article.jpeg" class="thumbnail">
<h3><a href="https://example.com/article" target="_blank" rel="nofollow ugc noopener">Lorem Ispum</a></h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tellus neque, malesuada ac neque ac, tempus tincidunt lectus.</p>
</article>
</aside>
HTML
post = Fabricate(:post, raw: url)
PostHotlinkedMedia.create!(url: "//example.com/favicon.ico", post: post, status: "too_large")
PostHotlinkedMedia.create!(url: "//example.com/article.jpeg", post: post, status: "too_large")
cpp = CookedPostProcessor.new(post, invalidate_oneboxes: true)
cpp.post_process
expect(cpp.doc).to match_html <<~HTML
<aside class="onebox allowlistedgeneric" data-onebox-src="https://example.com/article">
<header class="source">
<a href="https://example.com/article" target="_blank" rel="noopener nofollow ugc">Example Site</a>
</header>
<article class="onebox-body">
<h3><a href="https://example.com/article" target="_blank" rel="noopener nofollow ugc">Lorem Ispum</a></h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tellus neque, malesuada ac neque ac, tempus tincidunt lectus.</p>
</article>
</aside>
HTML
end
it "replaces broken image placeholder" do
url = "https://image.com/my-avatar"
image_url = "https://image.com/avatar.png"
Oneboxer
.stubs(:onebox)
.with(url, anything)
.returns("<img class='onebox' src='#{image_url}' />")
post = Fabricate(:post, raw: url)
PostHotlinkedMedia.create!(
url: "//image.com/avatar.png",
post: post,
status: "download_failed",
)
cpp = CookedPostProcessor.new(post, invalidate_oneboxes: true)
cpp.post_process
expect(cpp.doc.to_s).to have_tag("span.broken-image")
expect(cpp.doc.to_s).to include(I18n.t("post.image_placeholder.broken"))
end
it "removes broken images from onebox" do
url = "https://example.com/article"
Oneboxer.stubs(:onebox).with(url, anything).returns <<~HTML
<aside class="onebox allowlistedgeneric" data-onebox-src="https://example.com/article">
<header class="source">
<img src="https://example.com/favicon.ico" class="site-icon">
<a href="https://example.com/article" target="_blank" rel="nofollow ugc noopener">Example Site</a>
</header>
<article class="onebox-body">
<img src="https://example.com/article.jpeg" class="thumbnail">
<h3><a href="https://example.com/article" target="_blank" rel="nofollow ugc noopener">Lorem Ispum</a></h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tellus neque, malesuada ac neque ac, tempus tincidunt lectus.</p>
</article>
</aside>
HTML
post = Fabricate(:post, raw: url)
PostHotlinkedMedia.create!(
url: "//example.com/favicon.ico",
post: post,
status: "download_failed",
)
PostHotlinkedMedia.create!(
url: "//example.com/article.jpeg",
post: post,
status: "download_failed",
)
cpp = CookedPostProcessor.new(post, invalidate_oneboxes: true)
cpp.post_process
expect(cpp.doc).to match_html <<~HTML
<aside class="onebox allowlistedgeneric" data-onebox-src="https://example.com/article">
<header class="source">
<a href="https://example.com/article" target="_blank" rel="noopener nofollow ugc">Example Site</a>
</header>
<article class="onebox-body">
<h3><a href="https://example.com/article" target="_blank" rel="noopener nofollow ugc">Lorem Ispum</a></h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tellus neque, malesuada ac neque ac, tempus tincidunt lectus.</p>
</article>
</aside>
HTML
end
end
describe "#post_process_oneboxes removes nofollow if add_rel_nofollow_to_user_content is disabled" do
let(:post) { build(:post_with_youtube, id: 123) }
let(:cpp) { CookedPostProcessor.new(post, invalidate_oneboxes: true) }
before do
SiteSetting.add_rel_nofollow_to_user_content = false
Oneboxer
.expects(:onebox)
.with(
"http://www.youtube.com/watch?v=9bZkp7q19f0",
invalidate_oneboxes: true,
user_id: nil,
category_id: post.topic.category_id,
)
.returns(
'<aside class="onebox"><a href="https://www.youtube.com/watch?v=9bZkp7q19f0" rel="noopener nofollow ugc">GANGNAM STYLE</a></aside>',
)
cpp.post_process_oneboxes
end
it "removes nofollow noopener from links" do
expect(cpp).to be_dirty
expect(
cpp.html,
).to match_html '<aside class="onebox"><a href="https://www.youtube.com/watch?v=9bZkp7q19f0" rel="noopener">GANGNAM STYLE</a></aside>'
end
end
describe "#post_process_oneboxes removes nofollow if user is tl3" do
let(:post) { build(:post_with_youtube, id: 123) }
let(:cpp) { CookedPostProcessor.new(post, invalidate_oneboxes: true) }
before do
post.user.trust_level = TrustLevel[3]
post.user.save!
SiteSetting.add_rel_nofollow_to_user_content = true
SiteSetting.tl3_links_no_follow = false
Oneboxer
.expects(:onebox)
.with(
"http://www.youtube.com/watch?v=9bZkp7q19f0",
invalidate_oneboxes: true,
user_id: nil,
category_id: post.topic.category_id,
)
.returns(
'<aside class="onebox"><a href="https://www.youtube.com/watch?v=9bZkp7q19f0" rel="noopener nofollow ugc">GANGNAM STYLE</a></aside>',
)
cpp.post_process_oneboxes
end
it "removes nofollow ugc from links" do
expect(cpp).to be_dirty
expect(
cpp.html,
).to match_html '<aside class="onebox"><a href="https://www.youtube.com/watch?v=9bZkp7q19f0" rel="noopener">GANGNAM STYLE</a></aside>'
end
end
describe "#post_process_oneboxes with oneboxed image" do
let(:post) { build(:post_with_youtube, id: 123) }
let(:cpp) { CookedPostProcessor.new(post, invalidate_oneboxes: true) }
it "applies aspect ratio to container" do
Oneboxer
.expects(:onebox)
.with(
"http://www.youtube.com/watch?v=9bZkp7q19f0",
invalidate_oneboxes: true,
user_id: nil,
category_id: post.topic.category_id,
)
.returns(
"<aside class='onebox'><div class='scale-images'><img src='/img.jpg' width='400' height='500'/></div></div>",
)
cpp.post_process_oneboxes
expect(cpp.html).to match_html(
'<aside class="onebox"><div class="aspect-image-full-size" style="--aspect-ratio:400/500;"><img src="/img.jpg"></div></aside>',
)
end
it "applies aspect ratio when wrapped in link" do
Oneboxer
.expects(:onebox)
.with(
"http://www.youtube.com/watch?v=9bZkp7q19f0",
invalidate_oneboxes: true,
user_id: nil,
category_id: post.topic.category_id,
)
.returns(
"<aside class='onebox'><div class='scale-images'><a href='https://example.com'><img src='/img.jpg' width='400' height='500'/></a></div></div>",
)
cpp.post_process_oneboxes
expect(cpp.html).to match_html(
'<aside class="onebox"><div class="aspect-image-full-size" style="--aspect-ratio:400/500;"><a href="https://example.com"><img src="/img.jpg"></a></div></aside>',
)
end
end
describe "#post_process_oneboxes with square image" do
it "generates a onebox-avatar class" do
url = "https://square-image.com/onebox"
body = <<~HTML
<html>
<head>
<meta property='og:title' content="Page awesome">
<meta property='og:image' content="https://image.com/avatar.png">
<meta property='og:description' content="Page awesome desc">
</head>
</html>
HTML
stub_request(:head, url)
stub_request(:get, url).to_return(body: body)
# not an ideal stub but shipping the whole image to fast image can add
# a lot of cost to this test
stub_image_size(width: 200, height: 200)
post = Fabricate.build(:post, raw: url)
cpp = CookedPostProcessor.new(post, invalidate_oneboxes: true)
cpp.post_process_oneboxes
expect(cpp.doc.to_s).not_to include("aspect-image")
expect(cpp.doc.to_s).to include("onebox-avatar")
end
end
describe "#optimize_urls" do
let(:post) { build(:post_with_uploads_and_links) }
let(:cpp) { CookedPostProcessor.new(post) }
it "uses schemaless url for uploads" do
cpp.optimize_urls
expect(cpp.html).to match_html <<~HTML
<p><a href="//test.localhost/#{upload_path}/original/2X/2345678901234567.jpg">Link</a><br>
<img src="//test.localhost/#{upload_path}/original/1X/1234567890123456.jpg"><br>
<a href="http://www.google.com" rel="noopener nofollow ugc">Google</a><br>
<img src="http://foo.bar/image.png"><br>
<a class="attachment" href="//test.localhost/#{upload_path}/original/1X/af2c2618032c679333bebf745e75f9088748d737.txt">text.txt</a> (20 Bytes)<br>
<img src="//test.localhost/images/emoji/twitter/smile.png?v=#{Emoji::EMOJI_VERSION}" title=":smile:" class="emoji only-emoji" alt=":smile:" loading="lazy" width="20" height="20"></p>
HTML
end
context "when CDN is enabled" do
it "uses schemaless CDN url for http uploads" do
Rails.configuration.action_controller.stubs(:asset_host).returns("http://my.cdn.com")
cpp.optimize_urls
expect(cpp.html).to match_html <<~HTML
<p><a href="//my.cdn.com/#{upload_path}/original/2X/2345678901234567.jpg">Link</a><br>
<img src="//my.cdn.com/#{upload_path}/original/1X/1234567890123456.jpg"><br>
<a href="http://www.google.com" rel="noopener nofollow ugc">Google</a><br>
<img src="http://foo.bar/image.png"><br>
<a class="attachment" href="//my.cdn.com/#{upload_path}/original/1X/af2c2618032c679333bebf745e75f9088748d737.txt">text.txt</a> (20 Bytes)<br>
<img src="//my.cdn.com/images/emoji/twitter/smile.png?v=#{Emoji::EMOJI_VERSION}" title=":smile:" class="emoji only-emoji" alt=":smile:" loading="lazy" width="20" height="20"></p>
HTML
end
it "doesn't use schemaless CDN url for https uploads" do
Rails.configuration.action_controller.stubs(:asset_host).returns("https://my.cdn.com")
cpp.optimize_urls
expect(cpp.html).to match_html <<~HTML
<p><a href="https://my.cdn.com/#{upload_path}/original/2X/2345678901234567.jpg">Link</a><br>
<img src="https://my.cdn.com/#{upload_path}/original/1X/1234567890123456.jpg"><br>
<a href="http://www.google.com" rel="noopener nofollow ugc">Google</a><br>
<img src="http://foo.bar/image.png"><br>
<a class="attachment" href="https://my.cdn.com/#{upload_path}/original/1X/af2c2618032c679333bebf745e75f9088748d737.txt">text.txt</a> (20 Bytes)<br>
<img src="https://my.cdn.com/images/emoji/twitter/smile.png?v=#{Emoji::EMOJI_VERSION}" title=":smile:" class="emoji only-emoji" alt=":smile:" loading="lazy" width="20" height="20"></p>
HTML
end
it "doesn't use CDN when login is required" do
SiteSetting.login_required = true
Rails.configuration.action_controller.stubs(:asset_host).returns("http://my.cdn.com")
cpp.optimize_urls
expect(cpp.html).to match_html <<~HTML
<p><a href="//my.cdn.com/#{upload_path}/original/2X/2345678901234567.jpg">Link</a><br>
<img src="//my.cdn.com/#{upload_path}/original/1X/1234567890123456.jpg"><br>
<a href="http://www.google.com" rel="noopener nofollow ugc">Google</a><br>
<img src="http://foo.bar/image.png"><br>
<a class="attachment" href="//test.localhost/#{upload_path}/original/1X/af2c2618032c679333bebf745e75f9088748d737.txt">text.txt</a> (20 Bytes)<br>
<img src="//my.cdn.com/images/emoji/twitter/smile.png?v=#{Emoji::EMOJI_VERSION}" title=":smile:" class="emoji only-emoji" alt=":smile:" loading="lazy" width="20" height="20"></p>
HTML
end
it "doesn't use CDN when preventing anons from downloading files" do
SiteSetting.prevent_anons_from_downloading_files = true
Rails.configuration.action_controller.stubs(:asset_host).returns("http://my.cdn.com")
cpp.optimize_urls
expect(cpp.html).to match_html <<~HTML
<p><a href="//my.cdn.com/#{upload_path}/original/2X/2345678901234567.jpg">Link</a><br>
<img src="//my.cdn.com/#{upload_path}/original/1X/1234567890123456.jpg"><br>
<a href="http://www.google.com" rel="noopener nofollow ugc">Google</a><br>
<img src="http://foo.bar/image.png"><br>
<a class="attachment" href="//test.localhost/#{upload_path}/original/1X/af2c2618032c679333bebf745e75f9088748d737.txt">text.txt</a> (20 Bytes)<br>
<img src="//my.cdn.com/images/emoji/twitter/smile.png?v=#{Emoji::EMOJI_VERSION}" title=":smile:" class="emoji only-emoji" alt=":smile:" loading="lazy" width="20" height="20"></p>
HTML
end
context "with s3_uploads" do
before do
Rails.configuration.action_controller.stubs(:asset_host).returns("https://local.cdn.com")
setup_s3
SiteSetting.s3_cdn_url = "https://s3.cdn.com"
SiteSetting.authorized_extensions = "png|jpg|gif|mov|ogg|"
uploaded_file = file_from_fixtures("smallest.png")
upload_sha1 = Digest::SHA1.hexdigest(File.read(uploaded_file))
upload.update!(
original_filename: "smallest.png",
width: 10,
height: 20,
sha1: upload_sha1,
extension: "png",
)
end
it "uses the right CDN when uploads are on S3" do
stored_path = Discourse.store.get_path_for_upload(upload)
upload.update_column(:url, "#{SiteSetting.Upload.absolute_base_url}/#{stored_path}")
the_post =
Fabricate(
:post,
raw:
%Q{This post has a local emoji :+1: and an external upload\n\n![smallest.png|10x20](#{upload.short_url})},
)
cpp = CookedPostProcessor.new(the_post)
cpp.optimize_urls
expect(cpp.html).to match_html <<~HTML
<p>This post has a local emoji <img src="https://local.cdn.com/images/emoji/twitter/+1.png?v=#{Emoji::EMOJI_VERSION}" title=":+1:" class="emoji" alt=":+1:" loading="lazy" width="20" height="20"> and an external upload</p>
<p><img src="https://s3.cdn.com/#{stored_path}" alt="smallest.png" data-base62-sha1="#{upload.base62_sha1}" width="10" height="20"></p>
HTML
end
it "doesn't use CDN for secure uploads" do
SiteSetting.secure_uploads = true
stored_path = Discourse.store.get_path_for_upload(upload)
upload.update_column(:url, "#{SiteSetting.Upload.absolute_base_url}/#{stored_path}")
upload.update_column(:secure, true)
the_post =
Fabricate(
:post,
raw:
%Q{This post has a local emoji :+1: and an external upload\n\n![smallest.png|10x20](#{upload.short_url})},
)
cpp = CookedPostProcessor.new(the_post)
cpp.optimize_urls
expect(cpp.html).to match_html <<~HTML
<p>This post has a local emoji <img src="https://local.cdn.com/images/emoji/twitter/+1.png?v=#{Emoji::EMOJI_VERSION}" title=":+1:" class="emoji" alt=":+1:" loading="lazy" width="20" height="20"> and an external upload</p>
<p><img src="/secure-uploads/#{stored_path}" alt="smallest.png" data-base62-sha1="#{upload.base62_sha1}" width="10" height="20"></p>
HTML
end
it "doesn't use the secure uploads URL for custom emoji" do
CustomEmoji.create!(name: "trout", upload: upload)
Emoji.clear_cache
Emoji.load_custom
stored_path = Discourse.store.get_path_for_upload(upload)
upload.update_column(:url, "#{SiteSetting.Upload.absolute_base_url}/#{stored_path}")
upload.update_column(:secure, true)
the_post = Fabricate(:post, raw: "This post has a custom emoji :trout:")
the_post.cook(the_post.raw)
cpp = CookedPostProcessor.new(the_post)
cpp.optimize_urls
upload_url = upload.url.gsub(SiteSetting.Upload.absolute_base_url, "https://s3.cdn.com")
expect(cpp.html).to match_html <<~HTML
<p>This post has a custom emoji <img src="#{upload_url}?v=#{Emoji::EMOJI_VERSION}" title=":trout:" class="emoji emoji-custom" alt=":trout:" loading="lazy" width="20" height="20"></p>
HTML
end
context "with media uploads" do
fab!(:image_upload) { Fabricate(:upload) }
fab!(:audio_upload) { Fabricate(:upload, extension: "ogg") }
fab!(:video_upload) { Fabricate(:upload, extension: "mov") }
before do
video_upload.update!(
url: "#{SiteSetting.s3_cdn_url}/#{Discourse.store.get_path_for_upload(video_upload)}",
)
stub_request(:head, video_upload.url)
end
it "ignores prevent_anons_from_downloading_files and oneboxes video uploads" do
SiteSetting.prevent_anons_from_downloading_files = true
the_post =
Fabricate(:post, raw: "This post has an S3 video onebox:\n#{video_upload.url}")
cpp = CookedPostProcessor.new(the_post.reload)
cpp.post_process_oneboxes
expect(cpp.html).to match_html <<~HTML
<p>This post has an S3 video onebox:</p>
<div class="onebox video-onebox">
<video width="100%" height="100%" controls="">
<source src="#{video_upload.url}">
<a href="#{video_upload.url}" rel="nofollow ugc noopener">#{video_upload.url}</a>
</video>
</div>
HTML
end
it "oneboxes video using secure url when secure_uploads is enabled" do
SiteSetting.login_required = true
SiteSetting.secure_uploads = true
video_upload.update_column(:secure, true)
the_post =
Fabricate(:post, raw: "This post has an S3 video onebox:\n#{video_upload.url}")
cpp = CookedPostProcessor.new(the_post)
cpp.post_process_oneboxes
secure_url =
video_upload.url.sub(SiteSetting.s3_cdn_url, "#{Discourse.base_url}/secure-uploads")
expect(cpp.html).to match_html <<~HTML
<p>This post has an S3 video onebox:</p><div class="onebox video-onebox">
<video width="100%" height="100%" controls="">
<source src="#{secure_url}">
<a href="#{secure_url}">#{secure_url}</a>
</video>
</div>
HTML
end
it "oneboxes only audio/video and not images when secure_uploads is enabled" do
SiteSetting.login_required = true
SiteSetting.secure_uploads = true
video_upload.update_column(:secure, true)
audio_upload.update!(
url: "#{SiteSetting.s3_cdn_url}/#{Discourse.store.get_path_for_upload(audio_upload)}",
secure: true,
)
image_upload.update!(
url: "#{SiteSetting.s3_cdn_url}/#{Discourse.store.get_path_for_upload(image_upload)}",
secure: true,
)
stub_request(:head, audio_upload.url)
stub_request(:get, image_upload.url)
raw = <<~RAW.rstrip
This post has a video upload.
#{video_upload.url}
This post has an audio upload.
#{audio_upload.url}
And an image upload.
![logo.png](upload://#{image_upload.base62_sha1}.#{image_upload.extension})
RAW
the_post = Fabricate(:post, raw: raw)
cpp = CookedPostProcessor.new(the_post)
cpp.post_process_oneboxes
secure_video_url =
video_upload.url.sub(SiteSetting.s3_cdn_url, "#{Discourse.base_url}/secure-uploads")
secure_audio_url =
audio_upload.url.sub(SiteSetting.s3_cdn_url, "#{Discourse.base_url}/secure-uploads")
expect(cpp.html).to match_html <<~HTML
<p>This post has a video upload.</p><div class="onebox video-onebox">
<video width="100%" height="100%" controls="">
<source src="#{secure_video_url}">
<a href="#{secure_video_url}">
#{secure_video_url}
</a>
</video>
</div>
<p>This post has an audio upload.<br>
<audio controls="">
<source src="#{secure_audio_url}">
<a href="#{secure_audio_url}">
#{secure_audio_url}
</a>
</audio>
</p>
<p>And an image upload.<br>
<img src="#{image_upload.url}" alt="#{image_upload.original_filename}" data-base62-sha1="#{image_upload.base62_sha1}"></p>
HTML
end
end
end
end
end
describe "#remove_user_ids" do
let(:topic) { Fabricate(:topic) }
let(:post) { Fabricate(:post, raw: <<~RAW) }
link to a topic: #{topic.url}?u=foo
a tricky link to a topic: #{topic.url}?bob=bob;u=sam&jane=jane
link to an external topic: https://google.com/?u=bar
a malformed url: https://www.example.com/#123#4
RAW
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
it "does remove user ids" do
cpp.remove_user_ids
expect(cpp.html).to have_tag("a", with: { href: topic.url })
expect(cpp.html).to have_tag("a", with: { href: "#{topic.url}?bob=bob&jane=jane" })
expect(cpp.html).to have_tag("a", with: { href: "https://google.com/?u=bar" })
expect(cpp.html).to have_tag("a", with: { href: "https://www.example.com/#123#4" })
end
end
describe "#is_a_hyperlink?" do
let(:post) { build(:post) }
let(:cpp) { CookedPostProcessor.new(post) }
let(:doc) do
Nokogiri::HTML5.fragment(
'<body><div><a><img id="linked_image"></a><p><img id="standard_image"></p></div></body>',
)
end
it "is true when the image is inside a link" do
img = doc.css("img#linked_image").first
expect(cpp.is_a_hyperlink?(img)).to eq(true)
end
it "is false when the image is not inside a link" do
img = doc.css("img#standard_image").first
expect(cpp.is_a_hyperlink?(img)).to eq(false)
end
end
describe "grant badges" do
let(:cpp) { CookedPostProcessor.new(post) }
context "with emoji inside a quote" do
let(:post) do
Fabricate(:post, raw: "time to eat some sweet \n[quote]\n:candy:\n[/quote]\n mmmm")
end
it "doesn't award a badge when the emoji is in a quote" do
cpp.grant_badges
expect(post.user.user_badges.where(badge_id: Badge::FirstEmoji).exists?).to eq(false)
end
end
context "with emoji in the text" do
let(:post) { Fabricate(:post, raw: "time to eat some sweet :candy: mmmm") }
it "awards a badge for using an emoji" do
cpp.grant_badges
expect(post.user.user_badges.where(badge_id: Badge::FirstEmoji).exists?).to eq(true)
end
end
context "with onebox" do
before do
Oneboxer.stubs(:onebox).with(anything, anything).returns(nil)
Oneboxer
.stubs(:onebox)
.with("https://discourse.org", anything)
.returns("<aside class=\"onebox allowlistedgeneric\">the rest of the onebox</aside>")
end
it "awards the badge for using an onebox" do
post = Fabricate(:post, raw: "onebox me:\n\nhttps://discourse.org\n")
cpp = CookedPostProcessor.new(post)
cpp.post_process_oneboxes
cpp.grant_badges
expect(post.user.user_badges.where(badge_id: Badge::FirstOnebox).exists?).to eq(true)
end
it "does not award the badge when link is not oneboxed" do
post = Fabricate(:post, raw: "onebox me:\n\nhttp://example.com\n")
cpp = CookedPostProcessor.new(post)
cpp.post_process_oneboxes
cpp.grant_badges
expect(post.user.user_badges.where(badge_id: Badge::FirstOnebox).exists?).to eq(false)
end
it "does not award the badge when the badge is disabled" do
Badge.where(id: Badge::FirstOnebox).update_all(enabled: false)
post = Fabricate(:post, raw: "onebox me:\n\nhttps://discourse.org\n")
cpp = CookedPostProcessor.new(post)
cpp.post_process_oneboxes
cpp.grant_badges
expect(post.user.user_badges.where(badge_id: Badge::FirstOnebox).exists?).to eq(false)
end
end
context "with reply_by_email" do
let(:post) do
Fabricate(:post, raw: "This is a **reply** via email ;)", via_email: true, post_number: 2)
end
it "awards a badge for replying via email" do
cpp.grant_badges
expect(post.user.user_badges.where(badge_id: Badge::FirstReplyByEmail).exists?).to eq(true)
end
end
end
describe "quote processing" do
let(:cpp) { CookedPostProcessor.new(cp) }
let(:pp) { Fabricate(:post, raw: "This post is ripe for quoting!") }
context "with an unmodified quote" do
let(:cp) do
Fabricate(
:post,
raw:
"[quote=\"#{pp.user.username}, post: #{pp.post_number}, topic:#{pp.topic_id}]\nripe for quoting\n[/quote]\ntest",
)
end
it "should not be marked as modified" do
cpp.post_process_quotes
expect(cpp.doc.css("aside.quote.quote-modified")).to be_blank
end
end
context "with a modified quote" do
let(:cp) do
Fabricate(
:post,
raw:
"[quote=\"#{pp.user.username}, post: #{pp.post_number}, topic:#{pp.topic_id}]\nmodified\n[/quote]\ntest",
)
end
it "should be marked as modified" do
cpp.post_process_quotes
expect(cpp.doc.css("aside.quote.quote-modified")).to be_present
end
end
context "with external discourse instance quote" do
let(:external_raw) { <<~RAW.strip }
[quote="random_guy_not_from_our_discourse, post:2004, topic:401"]
this quote is not from our discourse
[/quote]
and this is a reply
RAW
let(:cp) { Fabricate(:post, raw: external_raw) }
it "it should be marked as missing" do
cpp.post_process_quotes
expect(cpp.doc.css("aside.quote.quote-post-not-found")).to be_present
end
end
end
describe "full quote on direct reply" do
fab!(:topic) { Fabricate(:topic) }
let!(:post) { Fabricate(:post, topic: topic, raw: 'this is the "first" post') }
let(:raw) { <<~RAW.strip }
[quote="#{post.user.username}, post:#{post.post_number}, topic:#{topic.id}"]
this is the “first” post
[/quote]
and this is the third reply
RAW
let(:raw2) { <<~RAW.strip }
and this is the third reply
[quote="#{post.user.username}, post:#{post.post_number}, topic:#{topic.id}"]
this is the first post
[/quote]
RAW
let(:raw3) { <<~RAW.strip }
[quote="#{post.user.username}, post:#{post.post_number}, topic:#{topic.id}"]
this is the “first” post
[/quote]
[quote="#{post.user.username}, post:#{post.post_number}, topic:#{topic.id}"]
this is the first post
[/quote]
and this is the third reply
RAW
before { SiteSetting.remove_full_quote = true }
it "works" do
hidden = Fabricate(:post, topic: topic, hidden: true, raw: "this is the second post after")
small_action = Fabricate(:post, topic: topic, post_type: Post.types[:small_action])
reply = Fabricate(:post, topic: topic, raw: raw)
freeze_time do
topic.bumped_at = 1.day.ago
CookedPostProcessor.new(reply).remove_full_quote_on_direct_reply
expect(topic.ordered_posts.pluck(:id)).to eq(
[post.id, hidden.id, small_action.id, reply.id],
)
expect(topic.bumped_at).to eq_time(1.day.ago)
expect(reply.raw).to eq("and this is the third reply")
expect(reply.revisions.count).to eq(1)
expect(reply.revisions.first.modifications["raw"]).to eq([raw, reply.raw])
expect(reply.revisions.first.modifications["edit_reason"][1]).to eq(
I18n.t(:removed_direct_reply_full_quotes),
)
end
end
it "does nothing if there are multiple quotes" do
reply = Fabricate(:post, topic: topic, raw: raw3)
CookedPostProcessor.new(reply).remove_full_quote_on_direct_reply
expect(topic.ordered_posts.pluck(:id)).to eq([post.id, reply.id])
expect(reply.raw).to eq(raw3)
end
it "does not delete quote if not first paragraph" do
reply = Fabricate(:post, topic: topic, raw: raw2)
CookedPostProcessor.new(reply).remove_full_quote_on_direct_reply
expect(topic.ordered_posts.pluck(:id)).to eq([post.id, reply.id])
expect(reply.raw).to eq(raw2)
end
it "does nothing when 'remove_full_quote' is disabled" do
SiteSetting.remove_full_quote = false
reply = Fabricate(:post, topic: topic, raw: raw)
CookedPostProcessor.new(reply).remove_full_quote_on_direct_reply
expect(reply.raw).to eq(raw)
end
it "does not generate a blank HTML document" do
post = Fabricate(:post, topic: topic, raw: "<sunday><monday>")
cp = CookedPostProcessor.new(post)
cp.post_process
expect(cp.html).to eq("<p></p>")
end
it "works only on new posts" do
Fabricate(:post, topic: topic, hidden: true, raw: "this is the second post after")
Fabricate(:post, topic: topic, post_type: Post.types[:small_action])
reply = PostCreator.create!(topic.user, topic_id: topic.id, raw: raw)
stub_image_size
CookedPostProcessor.new(reply).post_process
expect(reply.raw).to eq(raw)
PostRevisor.new(reply).revise!(
Discourse.system_user,
raw: raw,
edit_reason: "put back full quote",
)
stub_image_size
CookedPostProcessor.new(reply).post_process(new_post: true)
expect(reply.raw).to eq("and this is the third reply")
end
it "works with nested quotes" do
reply1 = Fabricate(:post, topic: topic, raw: raw)
reply2 = Fabricate(:post, topic: topic, raw: <<~RAW.strip)
[quote="#{reply1.user.username}, post:#{reply1.post_number}, topic:#{topic.id}"]
#{raw}
[/quote]
quoting a post with a quote
RAW
CookedPostProcessor.new(reply2).remove_full_quote_on_direct_reply
expect(reply2.raw).to eq("quoting a post with a quote")
end
end
describe "full quote on direct reply with full name prioritization" do
fab!(:user) { Fabricate(:user, name: "james, john, the third") }
fab!(:topic) { Fabricate(:topic) }
let!(:post) { Fabricate(:post, user: user, topic: topic, raw: 'this is the "first" post') }
let(:raw) { <<~RAW.strip }
[quote="#{post.user.name}, post:#{post.post_number}, topic:#{topic.id}, username:#{post.user.username}"]
this is the “first” post
[/quote]
and this is the third reply
RAW
let(:raw2) { <<~RAW.strip }
and this is the third reply
[quote="#{post.user.name}, post:#{post.post_number}, topic:#{topic.id}, username:#{post.user.username}"]
this is the first post
[/quote]
RAW
let(:raw3) { <<~RAW.strip }
[quote="#{post.user.name}, post:#{post.post_number}, topic:#{topic.id}, username:#{post.user.username}"]
this is the “first” post
[/quote]
[quote="#{post.user.name}, post:#{post.post_number}, topic:#{topic.id}, username:#{post.user.username}"]
this is the first post
[/quote]
and this is the third reply
RAW
before do
SiteSetting.remove_full_quote = true
SiteSetting.display_name_on_posts = true
SiteSetting.prioritize_username_in_ux = false
end
it "removes direct reply with full quotes" do
hidden = Fabricate(:post, topic: topic, hidden: true, raw: "this is the second post after")
small_action = Fabricate(:post, topic: topic, post_type: Post.types[:small_action])
reply = Fabricate(:post, topic: topic, raw: raw)
freeze_time do
topic.bumped_at = 1.day.ago
CookedPostProcessor.new(reply).remove_full_quote_on_direct_reply
expect(topic.ordered_posts.pluck(:id)).to eq(
[post.id, hidden.id, small_action.id, reply.id],
)
expect(topic.bumped_at).to eq_time(1.day.ago)
expect(reply.raw).to eq("and this is the third reply")
expect(reply.revisions.count).to eq(1)
expect(reply.revisions.first.modifications["raw"]).to eq([raw, reply.raw])
expect(reply.revisions.first.modifications["edit_reason"][1]).to eq(
I18n.t(:removed_direct_reply_full_quotes),
)
end
end
it "does nothing if there are multiple quotes" do
reply = Fabricate(:post, topic: topic, raw: raw3)
CookedPostProcessor.new(reply).remove_full_quote_on_direct_reply
expect(topic.ordered_posts.pluck(:id)).to eq([post.id, reply.id])
expect(reply.raw).to eq(raw3)
end
it "does not delete quote if not first paragraph" do
reply = Fabricate(:post, topic: topic, raw: raw2)
CookedPostProcessor.new(reply).remove_full_quote_on_direct_reply
expect(topic.ordered_posts.pluck(:id)).to eq([post.id, reply.id])
expect(reply.raw).to eq(raw2)
end
it "does nothing when 'remove_full_quote' is disabled" do
SiteSetting.remove_full_quote = false
reply = Fabricate(:post, topic: topic, raw: raw)
CookedPostProcessor.new(reply).remove_full_quote_on_direct_reply
expect(reply.raw).to eq(raw)
end
it "does not generate a blank HTML document" do
post = Fabricate(:post, topic: topic, raw: "<sunday><monday>")
cp = CookedPostProcessor.new(post)
cp.post_process
expect(cp.html).to eq("<p></p>")
end
it "works only on new posts" do
Fabricate(:post, topic: topic, hidden: true, raw: "this is the second post after")
Fabricate(:post, topic: topic, post_type: Post.types[:small_action])
reply = PostCreator.create!(topic.user, topic_id: topic.id, raw: raw)
stub_image_size
CookedPostProcessor.new(reply).post_process
expect(reply.raw).to eq(raw)
PostRevisor.new(reply).revise!(
Discourse.system_user,
raw: raw,
edit_reason: "put back full quote",
)
stub_image_size
CookedPostProcessor.new(reply).post_process(new_post: true)
expect(reply.raw).to eq("and this is the third reply")
end
it "works with nested quotes" do
reply1 = Fabricate(:post, user: user, topic: topic, raw: raw)
reply2 = Fabricate(:post, topic: topic, raw: <<~RAW.strip)
[quote="#{reply1.user.name}, post:#{reply1.post_number}, topic:#{topic.id}, username:#{reply1.user.username}"]
#{raw}
[/quote]
quoting a post with a quote
RAW
CookedPostProcessor.new(reply2).remove_full_quote_on_direct_reply
expect(reply2.raw).to eq("quoting a post with a quote")
end
end
describe "prioritizes full name in quotes" do
fab!(:user) { Fabricate(:user, name: "james, john, the third") }
fab!(:topic) { Fabricate(:topic) }
let!(:post) { Fabricate(:post, user: user, topic: topic, raw: 'this is the "first" post') }
before do
SiteSetting.display_name_on_posts = true
SiteSetting.prioritize_username_in_ux = false
end
it "maintains full name post processing" do
reply = Fabricate(:post, user: user, topic: topic, raw: <<~RAW.strip)
[quote="#{user.name}, post:#{post.id}, topic:#{topic.id}, username:#{user.username}"]
quoting a post with a quote
[/quote]
quoting a post with a quote
RAW
doc = Nokogiri::HTML5.fragment(CookedPostProcessor.new(reply).html)
expect(doc.css(".title").text).to eq("\n\n #{user.name}:")
end
end
describe "#html" do
it "escapes html entities in attributes per html5" do
post = Fabricate(:post, raw: '<img alt="&<something>">')
expect(post.cook(post.raw)).to eq('<p><img alt="&amp;<something>"></p>')
expect(CookedPostProcessor.new(post).html).to eq('<p><img alt="&amp;<something>"></p>')
end
end
end