From 8bf7f09112ab40dc8a8633fde9349d779e6d8924 Mon Sep 17 00:00:00 2001 From: Marius Balteanu Date: Thu, 12 Feb 2026 02:52:28 +0000 Subject: [PATCH] Refactors parse_hires_images and parse_inline_attachments methods from ApplicationHelper to scrubbers (#43745). git-svn-id: https://svn.redmine.org/redmine/trunk@24406 e93f8b46-1217-0410-a6f0-8f06a7374b81 --- app/helpers/application_helper.rb | 54 +------------ lib/redmine/wiki_formatting.rb | 7 +- .../wiki_formatting/common_mark/formatter.rb | 14 +++- .../wiki_formatting/hires_images_scrubber.rb | 41 ++++++++++ .../inline_attachments_scrubber.rb | 79 +++++++++++++++++++ .../wiki_formatting/textile/formatter.rb | 16 +++- test/helpers/application_helper_test.rb | 14 ++-- .../common_mark/application_helper_test.rb | 2 +- 8 files changed, 160 insertions(+), 67 deletions(-) create mode 100644 lib/redmine/wiki_formatting/hires_images_scrubber.rb create mode 100644 lib/redmine/wiki_formatting/inline_attachments_scrubber.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c04a97e4d..a96d40b7b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -914,7 +914,7 @@ module ApplicationHelper return '' if text.blank? project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil) - @only_path = only_path = options.delete(:only_path) == false ? false : true + @only_path = only_path = options[:only_path] = (options[:only_path] != false) text = text.dup macros = catch_macros(text) @@ -923,7 +923,7 @@ module ApplicationHelper text = h(text) else formatting = Setting.text_formatting - text = Redmine::WikiFormatting.to_html(formatting, text, :object => obj, :attribute => attr) + text = Redmine::WikiFormatting.to_html(formatting, text, options.merge(:object => obj, :attribute => attr, :view => self)) end @parsed_headings = [] @@ -932,7 +932,7 @@ module ApplicationHelper parse_sections(text, project, obj, attr, only_path, options) text = parse_non_pre_blocks(text, obj, macros, options) do |txt| - [:parse_inline_attachments, :parse_hires_images, :parse_wiki_links, :parse_redmine_links].each do |method_name| + [:parse_wiki_links, :parse_redmine_links].each do |method_name| send method_name, txt, project, obj, attr, only_path, options end end @@ -977,54 +977,6 @@ module ApplicationHelper parsed end - # add srcset attribute to img tags if filename includes @2x, @3x, etc. - # to support hires displays - def parse_hires_images(text, project, obj, attr, only_path, options) - text.gsub!(/src="([^"]+@(\dx)\.(bmp|gif|jpg|jpe|jpeg|png))"/i) do |m| - filename, dpr = $1, $2 - m + " srcset=\"#{filename} #{dpr}\"" - end - end - - def parse_inline_attachments(text, project, obj, attr, only_path, options) - return if options[:inline_attachments] == false - - # when using an image link, try to use an attachment, if possible - attachments = options[:attachments] || [] - if obj.is_a?(Journal) - attachments += obj.journalized.attachments if obj.journalized.respond_to?(:attachments) - else - attachments += obj.attachments if obj.respond_to?(:attachments) - end - if attachments.present? - title_and_alt_re = /\s+(title|alt)="([^"]*)"/i - - text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png|webp))"([^>]*)/i) do |m| - filename, ext, other_attrs = $1, $2, $3 - - # search for the picture in attachments - if found = Attachment.latest_attach(attachments, CGI.unescape(filename)) - image_url = download_named_attachment_url(found, found.filename, :only_path => only_path) - desc = found.description.to_s.delete('"') - - # remove title and alt attributes after extracting them - title_and_alt = other_attrs.scan(title_and_alt_re).to_h - other_attrs.gsub!(title_and_alt_re, '') - - title_and_alt_attrs = if !desc.blank? && title_and_alt['alt'].blank? - " title=\"#{desc}\" alt=\"#{desc}\"" - else - # restore original title and alt attributes - " #{title_and_alt.map { |k, v| %[#{k}="#{v}"] }.join(' ')}" - end - "src=\"#{image_url}\"#{title_and_alt_attrs} loading=\"lazy\"#{other_attrs}" - else - m - end - end - end - end - # Wiki links # # Examples: diff --git a/lib/redmine/wiki_formatting.rb b/lib/redmine/wiki_formatting.rb index ce6097646..3d61df87d 100644 --- a/lib/redmine/wiki_formatting.rb +++ b/lib/redmine/wiki_formatting.rb @@ -93,10 +93,10 @@ module Redmine # Text retrieved from the cache store may be frozen # We need to dup it so we can do in-place substitutions with gsub! cache_store.fetch cache_key do - formatter_for(format).new(text).to_html + formatter_for(format).new(text, options).to_html end.dup else - formatter_for(format).new(text).to_html + formatter_for(format).new(text, options).to_html end text end @@ -127,8 +127,9 @@ module Redmine include ActionView::Helpers::UrlHelper include Redmine::WikiFormatting::LinksHelper - def initialize(text) + def initialize(text, options = {}) @text = text + @options = options end def to_html(*args) diff --git a/lib/redmine/wiki_formatting/common_mark/formatter.rb b/lib/redmine/wiki_formatting/common_mark/formatter.rb index 668537190..a4941d4fd 100644 --- a/lib/redmine/wiki_formatting/common_mark/formatter.rb +++ b/lib/redmine/wiki_formatting/common_mark/formatter.rb @@ -65,8 +65,9 @@ module Redmine class Formatter include Redmine::WikiFormatting::SectionHelper - def initialize(text) + def initialize(text, options = {}) @text = text + @options = options end def to_html(*args) @@ -75,7 +76,7 @@ module Redmine SANITIZER.call(fragment) scrubber = Loofah::Scrubber.new do |node| - SCRUBBERS.each do |s| + (SCRUBBERS + post_processor_scrubbers).each do |s| result = s.scrub(node) break result if result == Loofah::Scrubber::STOP break if node.parent.nil? @@ -85,6 +86,15 @@ module Redmine fragment.scrub!(scrubber) fragment.to_s end + + private + + def post_processor_scrubbers + [ + Redmine::WikiFormatting::InlineAttachmentsScrubber.new(@options), + Redmine::WikiFormatting::HiresImagesScrubber.new + ] + end end end end diff --git a/lib/redmine/wiki_formatting/hires_images_scrubber.rb b/lib/redmine/wiki_formatting/hires_images_scrubber.rb new file mode 100644 index 000000000..b483497d4 --- /dev/null +++ b/lib/redmine/wiki_formatting/hires_images_scrubber.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +module Redmine + module WikiFormatting + class HiresImagesScrubber < Loofah::Scrubber + HIRES_FILENAME_REGEX = /@(?\dx)\.(?:bmp|gif|jpg|jpe|jpeg|png)\z/i + + def scrub(node) + return unless node.name == 'img' && node['src'].present? + + src = node['src'] + + return unless src.include?('@') + + match = src.match(HIRES_FILENAME_REGEX) + + return unless match + + # Set the srcset attribute. + node['srcset'] = "#{src} #{match[:dpr]}" + end + end + end +end diff --git a/lib/redmine/wiki_formatting/inline_attachments_scrubber.rb b/lib/redmine/wiki_formatting/inline_attachments_scrubber.rb new file mode 100644 index 000000000..2da380df7 --- /dev/null +++ b/lib/redmine/wiki_formatting/inline_attachments_scrubber.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +module Redmine + module WikiFormatting + class InlineAttachmentsScrubber < Loofah::Scrubber + def initialize(options = {}) + super() + @options = options + @obj = options[:object] + @view = options[:view] + @only_path = options[:only_path] + @attachments = options[:attachments] || [] + if @obj.is_a?(Journal) + @attachments += @obj.journalized.attachments if @obj.journalized.respond_to?(:attachments) + elsif @obj.respond_to?(:attachments) + @attachments += @obj.attachments + end + + if @attachments.present? + @attachments = @attachments.sort_by{|attachment| [attachment.created_on, attachment.id]}.reverse + end + end + + def scrub(node) + return unless node.name == 'img' && node['src'].present? + + parse_inline_attachments(node) + end + + private + + def parse_inline_attachments(node) + return if @attachments.blank? + + src = node['src'] + + if src =~ %r{\A(?[^/"]+?\.(?:bmp|gif|jpg|jpeg|jpe|png|webp))\z}i + filename = $~[:filename] + if found = find_attachment(CGI.unescape(filename)) + image_url = @view.download_named_attachment_url(found, found.filename, :only_path => @only_path) + node['src'] = image_url + + desc = found.description.to_s.delete('"') + if !desc.blank? && node['alt'].blank? + node['title'] = desc + node['alt'] = desc + end + node['loading'] = 'lazy' + end + end + end + + def find_attachment(filename) + return unless filename.valid_encoding? + + @attachments.detect do |att| + filename.casecmp?(att.filename) + end + end + end + end +end diff --git a/lib/redmine/wiki_formatting/textile/formatter.rb b/lib/redmine/wiki_formatting/textile/formatter.rb index af1def1be..c1bd7d03f 100644 --- a/lib/redmine/wiki_formatting/textile/formatter.rb +++ b/lib/redmine/wiki_formatting/textile/formatter.rb @@ -32,8 +32,9 @@ module Redmine extend Forwardable def_delegators :@filter, :extract_sections, :rip_offtags - def initialize(args) - @filter = Filter.new(args) + def initialize(text, options = {}) + @filter = Filter.new(text) + @options = options end def to_html(*rules) @@ -41,7 +42,7 @@ module Redmine fragment = Loofah.html5_fragment(html) scrubber = Loofah::Scrubber.new do |node| - SCRUBBERS.each do |s| + (SCRUBBERS + post_processor_scrubbers).each do |s| result = s.scrub(node) break result if result == Loofah::Scrubber::STOP break if node.parent.nil? @@ -51,6 +52,15 @@ module Redmine fragment.scrub!(scrubber) fragment.to_s end + + private + + def post_processor_scrubbers + [ + Redmine::WikiFormatting::InlineAttachmentsScrubber.new(@options), + Redmine::WikiFormatting::HiresImagesScrubber.new + ] + end end class Filter < RedCloth3 diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index 3484c8a72..b6f8500fb 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -169,16 +169,16 @@ class ApplicationHelperTest < Redmine::HelperTest def test_attached_images to_test = { 'Inline image: !logo.gif!' => - 'Inline image: This is a logo', + 'Inline image: This is a logo', 'Inline image: !logo.GIF!' => - 'Inline image: This is a logo', + 'Inline image: This is a logo', 'Inline WebP image: !logo.webp!' => - 'Inline WebP image: WebP image', + 'Inline WebP image: WebP image', 'No match: !ogo.gif!' => 'No match: ', 'No match: !ogo.GIF!' => 'No match: ', # link image '!logo.gif!:http://foo.bar/' => - 'This is a logo', + 'This is a logo', } attachments = Attachment.all with_settings :text_formatting => 'textile' do @@ -194,11 +194,11 @@ class ApplicationHelperTest < Redmine::HelperTest textilizable('!logo.gif(alt text)!', attachments: attachments) # When alt text and style are set - assert_match %r[alt text], + assert_match %r[alt text], textilizable('!{width:100px}logo.gif(alt text)!', attachments: attachments) # When alt text is not set - assert_match %r[This is a logo], + assert_match %r[This is a logo], textilizable('!logo.gif!', attachments: attachments) # When alt text is not set and the attachment has no description @@ -272,7 +272,7 @@ class ApplicationHelperTest < Redmine::HelperTest with_settings :text_formatting => 'textile' do assert_equal( %(

), + %(alt="" loading="lazy" srcset="/attachments/download/#{attachment.id}/image@2x.png 2x">

), textilizable("!image@2x.png!", :attachments => [attachment]) ) end diff --git a/test/unit/lib/redmine/wiki_formatting/common_mark/application_helper_test.rb b/test/unit/lib/redmine/wiki_formatting/common_mark/application_helper_test.rb index 4ecc3e62a..21aaf79c4 100644 --- a/test/unit/lib/redmine/wiki_formatting/common_mark/application_helper_test.rb +++ b/test/unit/lib/redmine/wiki_formatting/common_mark/application_helper_test.rb @@ -58,7 +58,7 @@ class Redmine::WikiFormatting::CommonMark::ApplicationHelperTest < Redmine::Help textilizable('![alt text](logo.gif)', attachments: attachments) # When alt text is not set - assert_match %r[This is a logo], + assert_match %r[This is a logo], textilizable('![](logo.gif)', attachments: attachments) # When alt text is not set and the attachment has no description