Skip to content

Commit 2b233ef

Browse files
committed
ensure absolute URLs in emails
1 parent 92a329a commit 2b233ef

7 files changed

Lines changed: 131 additions & 45 deletions

File tree

decidim-blogs/spec/events/decidim/blogs/create_post_event_spec.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,32 @@
2121
expect(subject.resource_text).to eq translated(resource.body)
2222
end
2323
end
24+
25+
describe "email rendering with images" do
26+
let(:body_with_image) do
27+
{
28+
en: '<p>Check: <img src="/rails/active_storage/blobs/redirect/12345.JPG" alt="image" /></p>',
29+
ca: '<p>Mira: <img src="/rails/active_storage/blobs/redirect/12345.JPG" alt="image" /></p>',
30+
es: '<p>Mira: <img src="/rails/active_storage/blobs/redirect/12345.JPG" alt="image" /></p>'
31+
}
32+
end
33+
let(:resource) { create(:post, title: generate_localized_title(:blog_title), body: body_with_image) }
34+
let(:organization) { resource.component.organization }
35+
36+
it "includes transformed image URLs in notification email body" do
37+
mail = Decidim::NotificationMailer.event_received(
38+
event_name,
39+
"Decidim::Blogs::CreatePostEvent",
40+
resource,
41+
user,
42+
:follower,
43+
{}
44+
)
45+
46+
root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host: organization.host)[0..-2]
47+
expected_img = %(<img src="#{root_url}/rails/active_storage/blobs/redirect/12345.JPG" alt="image" />)
48+
49+
expect(mail.body.encoded).to include(expected_img)
50+
end
51+
end
2452
end

decidim-core/app/helpers/decidim/newsletters_helper.rb

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
module Decidim
44
# Helper that provides methods to render links with utm codes, and replaced name
55
module NewslettersHelper
6+
include Decidim::SanitizeHelper
7+
68
# If the newsletter body there are some links and the Decidim.track_newsletter_links = true
79
# it will be replaced with the utm_codes method described below.
810
# for example transform "https://es.lipsum.com/" to "https://es.lipsum.com/?utm_source=localhost&utm_campaign=newsletter_11"
@@ -19,7 +21,7 @@ def parse_interpolations(content, user = nil, id = nil)
1921

2022
content = interpret_name(content, user)
2123
content = track_newsletter_links(content, id, host)
22-
transform_image_urls(content, host)
24+
decidim_transform_image_urls(content, host)
2325
end
2426

2527
# this method is used to generate the root link on mail with the utm_codes
@@ -67,27 +69,6 @@ def interpret_name(content, user)
6769
content.gsub("%{name}", user.name)
6870
end
6971

70-
# Find each img HTML tag with relative path in src attribute
71-
# For each URL, prepends the decidim.root_url
72-
# If host is not defined it returns full content
73-
#
74-
# @param content [String] - the string to convert
75-
# @param host [String] - the Decidim::Organization host to replace
76-
#
77-
# @return [String] - the content converted
78-
#
79-
def transform_image_urls(content, host)
80-
return content if host.blank?
81-
82-
content.scan(/src\s*=\s*"([^"]*)"/).each do |src|
83-
root_url = decidim.root_url(host:)[0..-2]
84-
src_replaced = "#{root_url}#{src.first}"
85-
content = content.gsub(/src\s*=\s*"([^"]*#{src.first})"/, %(src="#{src_replaced}"))
86-
end
87-
88-
content
89-
end
90-
9172
# Add tracking query params to each links
9273
#
9374
# @param content [String] - the string to convert

decidim-core/app/helpers/decidim/sanitize_helper.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,5 +139,31 @@ def render_sanitized_content(resource, method, presenter_class: nil)
139139

140140
decidim_sanitize_editor(content)
141141
end
142+
143+
# Transforms relative image URLs in HTML content to absolute URLs using the provided host.
144+
# This is used in emails (newsletters and notifications) to ensure images display correctly
145+
# in email clients.
146+
#
147+
# @param content [String] - HTML content with img tags
148+
# @param host [String] - the Decidim::Organization host to use for the root URL
149+
#
150+
# @return [String] - the content with transformed image URLs
151+
def decidim_transform_image_urls(content, host)
152+
return content if host.blank? || content.blank?
153+
154+
root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host:).chomp("/")
155+
156+
content.gsub(/src\s*=\s*(['"])([^'"]*)\1/) do
157+
quote = Regexp.last_match(1)
158+
src_value = Regexp.last_match(2)
159+
160+
if src_value.blank? || src_value.start_with?("http://", "https://", "data:", "//", "cid:")
161+
%(src=#{quote}#{src_value}#{quote})
162+
else
163+
normalized_src = src_value.start_with?("/") ? src_value : "/#{src_value}"
164+
%(src=#{quote}#{root_url}#{normalized_src}#{quote})
165+
end
166+
end
167+
end
142168
end
143169
end

decidim-core/app/mailers/decidim/application_mailer.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ class ApplicationMailer < ActionMailer::Base
88
include MultitenantAssetHost
99
include Decidim::SanitizeHelper
1010
include Decidim::OrganizationHelper
11-
helper_method :organization_name, :decidim_escape_translated, :decidim_sanitize_translated, :translated_attribute, :decidim_sanitize, :decidim_sanitize_newsletter
11+
12+
helper Decidim::SanitizeHelper
13+
helper_method :organization_name, :current_locale, :decidim_escape_translated, :decidim_sanitize_translated, :translated_attribute, :decidim_sanitize,
14+
:decidim_sanitize_newsletter
1215

1316
after_action :set_smtp
1417
after_action :set_from

decidim-core/app/views/decidim/notification_mailer/event_received.html.erb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
<blockquote>
1717
<p>
18-
<%= @event_instance.safe_resource_text %>
18+
<%= decidim_transform_image_urls(@event_instance.safe_resource_text, @organization.host).html_safe %>
1919
</p>
2020
</blockquote>
2121
<% end %>
@@ -28,7 +28,7 @@
2828
<p style="font-weight: bold"><%= t(".translated_text") %></p>
2929
<blockquote>
3030
<p>
31-
<%= @event_instance.safe_resource_translated_text %>
31+
<%= decidim_transform_image_urls(@event_instance.safe_resource_translated_text, @organization.host).html_safe %>
3232
</p>
3333
</blockquote>
3434
<% end %>
@@ -40,7 +40,7 @@
4040
<table>
4141
<tr>
4242
<td>
43-
<%= link_to @event_instance.button_text, @event_instance.button_url, target: :blank %>
43+
<%= link_to decidim_sanitize(@event_instance.button_text, strip_tags: true), @event_instance.button_url, target: :blank %>
4444
</td>
4545
</tr>
4646
</table>

decidim-core/spec/helpers/decidim/newsletters_helper_spec.rb

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ module Decidim
3131
end
3232

3333
it "transforms image URLs with the host" do
34-
expect(subject).to include('<img src="http://localhost/rails/active_storage/blobs/redirect/12345.JPG"')
35-
expect(subject).to include('<img src="http://localhost/rails/active_storage/blobs/redirect/56789.JPG"')
34+
root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host: organization.host)[0..-2]
35+
expect(subject).to include(%(<img src="#{root_url}/rails/active_storage/blobs/redirect/12345.JPG"))
36+
expect(subject).to include(%(<img src="#{root_url}/rails/active_storage/blobs/redirect/56789.JPG"))
3637
end
3738

3839
context "when track_newsletter_links is false" do
@@ -99,22 +100,5 @@ module Decidim
99100
it { is_expected.to include("<p>Hello, </p>") }
100101
end
101102
end
102-
103-
describe "#transform_image_urls" do
104-
subject { helper.send(:transform_image_urls, text, organization.host) }
105-
106-
it "transforms image URLs with the host" do
107-
expect(subject).to include('<img src="http://localhost/rails/active_storage/blobs/redirect/12345.JPG"')
108-
expect(subject).to include('<img src="http://localhost/rails/active_storage/blobs/redirect/56789.JPG"')
109-
end
110-
111-
context "when host is not present" do
112-
subject { helper.send(:transform_image_urls, text, nil) }
113-
114-
it "returns the full content" do
115-
expect(subject).to eq text
116-
end
117-
end
118-
end
119103
end
120104
end

decidim-core/spec/helpers/decidim/sanitize_helper_spec.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,70 @@ module Decidim
104104
expect(helper.decidim_sanitize('<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..." onerror="alert(\'XSS\')">', strip_tags: false)).not_to include("onerror")
105105
end
106106
end
107+
108+
context "when decidim_transform_image_urls is invoked" do
109+
let(:host) { "example.org" }
110+
111+
let(:user_input) do
112+
%{(<p>Hello, %{name}</p>
113+
<a href="https://meta.decidim.org">Link</a>
114+
<img src="/rails/active_storage/blobs/redirect/12345.JPG" alt="image" />
115+
<a href="https://meta.decidim.org/">Link</a>
116+
<img src="/rails/active_storage/blobs/redirect/56789.JPG" alt="second image" />)}
117+
end
118+
119+
subject { helper.send(:decidim_transform_image_urls, user_input, host) }
120+
121+
it "transforms image URLs with the host" do
122+
root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host:)[0..-2]
123+
expect(subject).to include(%(<img src="#{root_url}/rails/active_storage/blobs/redirect/12345.JPG"))
124+
expect(subject).to include(%(<img src="#{root_url}/rails/active_storage/blobs/redirect/56789.JPG"))
125+
end
126+
127+
context "when a relative src matches the suffix of an absolute URL" do
128+
let(:user_input) do
129+
%(<img src="/image.jpg" alt="relative" /><img src="https://example.com/image.jpg" alt="absolute" />)
130+
end
131+
132+
it "transforms only the relative URL" do
133+
root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host:)[0..-2]
134+
135+
expect(subject).to include(%(<img src="#{root_url}/image.jpg" alt="relative" />))
136+
expect(subject).to include(%(<img src="https://example.com/image.jpg" alt="absolute" />))
137+
end
138+
end
139+
140+
context "when src uses data/protocol-relative/cid URLs" do
141+
let(:user_input) do
142+
%(<img src="data:image/png;base64,AAAA" alt="data" />
143+
<img src="//cdn.example.org/image.jpg" alt="protocol-relative" />
144+
<img src="cid:logo@example.org" alt="cid" />)
145+
end
146+
147+
it "keeps them unchanged" do
148+
expect(subject).to include(%(src="data:image/png;base64,AAAA"))
149+
expect(subject).to include(%(src="//cdn.example.org/image.jpg"))
150+
expect(subject).to include(%(src="cid:logo@example.org"))
151+
end
152+
end
153+
154+
context "when src attribute is single-quoted" do
155+
let(:user_input) { "<img src='/image.jpg' alt='relative' />" }
156+
157+
it "transforms the URL preserving single quotes" do
158+
root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host:).chomp("/")
159+
expect(subject).to include(%(<img src='#{root_url}/image.jpg' alt='relative' />))
160+
end
161+
end
162+
163+
context "when host is not present" do
164+
subject { helper.send(:decidim_transform_image_urls, user_input, nil) }
165+
166+
it "returns the full content" do
167+
expect(subject).to eq user_input
168+
end
169+
end
170+
end
107171
end
108172
end
109173
end

0 commit comments

Comments
 (0)