Macros processing overhaul (#3061, #11633).

* macro arguments are no longer parsed by text formatters
* macro output is escaped unless it's html safe

git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@10209 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Jean-Philippe Lang 2012-08-17 14:46:55 +00:00
parent af5a814f4c
commit 73aece0baf
5 changed files with 171 additions and 28 deletions

View File

@ -527,6 +527,8 @@ module ApplicationHelper
project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil) project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
only_path = options.delete(:only_path) == false ? false : true only_path = options.delete(:only_path) == false ? false : true
text = text.dup
macros = catch_macros(text)
text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr) text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
@parsed_headings = [] @parsed_headings = []
@ -534,8 +536,8 @@ module ApplicationHelper
@current_section = 0 if options[:edit_section_links] @current_section = 0 if options[:edit_section_links]
parse_sections(text, project, obj, attr, only_path, options) parse_sections(text, project, obj, attr, only_path, options)
text = parse_non_pre_blocks(text) do |text| text = parse_non_pre_blocks(text, obj, macros) do |text|
[:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links, :parse_macros].each do |method_name| [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
send method_name, text, project, obj, attr, only_path, options send method_name, text, project, obj, attr, only_path, options
end end
end end
@ -548,7 +550,7 @@ module ApplicationHelper
text.html_safe text.html_safe
end end
def parse_non_pre_blocks(text) def parse_non_pre_blocks(text, obj, macros)
s = StringScanner.new(text) s = StringScanner.new(text)
tags = [] tags = []
parsed = '' parsed = ''
@ -557,6 +559,9 @@ module ApplicationHelper
text, full_tag, closing, tag = s[1], s[2], s[3], s[4] text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
if tags.empty? if tags.empty?
yield text yield text
inject_macros(text, obj, macros) if macros.any?
else
inject_macros(text, obj, macros, false) if macros.any?
end end
parsed << text parsed << text
if tag if tag
@ -856,7 +861,7 @@ module ApplicationHelper
end end
end end
MACROS_RE = / MACROS_RE = /(
(!)? # escaping (!)? # escaping
( (
\{\{ # opening tag \{\{ # opening tag
@ -864,22 +869,48 @@ module ApplicationHelper
(\((.*?)\))? # optional arguments (\((.*?)\))? # optional arguments
\}\} # closing tag \}\} # closing tag
) )
/x unless const_defined?(:MACROS_RE) )/x unless const_defined?(:MACROS_RE)
# Macros substitution MACRO_SUB_RE = /(
def parse_macros(text, project, obj, attr, only_path, options) \{\{
macro\((\d+)\)
\}\}
)/x unless const_defined?(:MACROS_SUB_RE)
# Extracts macros from text
def catch_macros(text)
macros = {}
text.gsub!(MACROS_RE) do text.gsub!(MACROS_RE) do
esc, all, macro, args = $1, $2, $3.downcase, $5.to_s all, macro = $1, $4.downcase
if esc.nil? if macro_exists?(macro) || all =~ MACRO_SUB_RE
begin index = macros.size
exec_macro(macro, obj, args) macros[index] = all
rescue => e "{{macro(#{index})}}"
"<div class=\"flash error\">Error executing the <strong>#{macro}</strong> macro (#{e})</div>"
end || all
else else
all all
end end
end end
macros
end
# Executes and replaces macros in text
def inject_macros(text, obj, macros, execute=true)
text.gsub!(MACRO_SUB_RE) do
all, index = $1, $2.to_i
orig = macros.delete(index)
if execute && orig && orig =~ MACROS_RE
esc, all, macro, args = $2, $3, $4.downcase, $6.to_s
if esc.nil?
h(exec_macro(macro, obj, args) || all)
else
h(all)
end
elsif orig
h(orig)
else
h(all)
end
end
end end
TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE) TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)

View File

@ -15,6 +15,8 @@
# along with this program; if not, write to the Free Software # along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
require 'digest/md5'
module Redmine module Redmine
module WikiFormatting module WikiFormatting
class StaleSectionError < Exception; end class StaleSectionError < Exception; end
@ -50,7 +52,7 @@ module Redmine
end end
def to_html(format, text, options = {}) def to_html(format, text, options = {})
text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, options[:object], options[:attribute]) text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, text, options[:object], options[:attribute])
# Text retrieved from the cache store may be frozen # Text retrieved from the cache store may be frozen
# We need to dup it so we can do in-place substitutions with gsub! # We need to dup it so we can do in-place substitutions with gsub!
cache_store.fetch cache_key do cache_store.fetch cache_key do
@ -67,10 +69,10 @@ module Redmine
(formatter.instance_methods & ['update_section', :update_section]).any? (formatter.instance_methods & ['update_section', :update_section]).any?
end end
# Returns a cache key for the given text +format+, +object+ and +attribute+ or nil if no caching should be done # Returns a cache key for the given text +format+, +text+, +object+ and +attribute+ or nil if no caching should be done
def cache_key_for(format, object, attribute) def cache_key_for(format, text, object, attribute)
if object && attribute && !object.new_record? && object.respond_to?(:updated_on) && !format.blank? if object && attribute && !object.new_record? && format.present?
"formatted_text/#{format}/#{object.class.model_name.cache_key}/#{object.id}-#{attribute}-#{object.updated_on.to_s(:number)}" "formatted_text/#{format}/#{object.class.model_name.cache_key}/#{object.id}-#{attribute}-#{Digest::MD5.hexdigest text}"
end end
end end

View File

@ -19,6 +19,11 @@ module Redmine
module WikiFormatting module WikiFormatting
module Macros module Macros
module Definitions module Definitions
# Returns true if +name+ is the name of an existing macro
def macro_exists?(name)
Redmine::WikiFormatting::Macros.available_macros.key?(name.to_sym)
end
def exec_macro(name, obj, args) def exec_macro(name, obj, args)
macro_options = Redmine::WikiFormatting::Macros.available_macros[name.to_sym] macro_options = Redmine::WikiFormatting::Macros.available_macros[name.to_sym]
return unless macro_options return unless macro_options
@ -27,7 +32,12 @@ module Redmine
unless macro_options[:parse_args] == false unless macro_options[:parse_args] == false
args = args.split(',').map(&:strip) args = args.split(',').map(&:strip)
end end
send(method_name, obj, args) if respond_to?(method_name)
begin
send(method_name, obj, args) if respond_to?(method_name)
rescue => e
"<div class=\"flash error\">Error executing the <strong>#{h name}</strong> macro (#{h e.to_s})</div>".html_safe
end
end end
def extract_macro_options(args, *keys) def extract_macro_options(args, *keys)
@ -97,7 +107,7 @@ module Redmine
# Builtin macros # Builtin macros
desc "Sample macro." desc "Sample macro."
macro :hello_world do |obj, args| macro :hello_world do |obj, args|
"Hello world! Object: #{obj.class.name}, " + (args.empty? ? "Called with no argument." : "Arguments: #{args.join(', ')}") h("Hello world! Object: #{obj.class.name}, " + (args.empty? ? "Called with no argument." : "Arguments: #{args.join(', ')}"))
end end
desc "Displays a list of all available macros, including description if available." desc "Displays a list of all available macros, including description if available."

View File

@ -76,12 +76,71 @@ class Redmine::WikiFormatting::MacrosTest < ActionView::TestCase
assert_equal '<p>no args args: c,d</p>', textilizable("{{foo}} {{foo(c,d)}}") assert_equal '<p>no args args: c,d</p>', textilizable("{{foo}} {{foo(c,d)}}")
end end
def test_macro_should_receive_the_object_as_argument_when_with_object_and_attribute
issue = Issue.find(1)
issue.description = "{{hello_world}}"
assert_equal '<p>Hello world! Object: Issue, Called with no argument.</p>', textilizable(issue, :description)
end
def test_macro_should_receive_the_object_as_argument_when_called_with_object_option
text = "{{hello_world}}"
assert_equal '<p>Hello world! Object: Issue, Called with no argument.</p>', textilizable(text, :object => Issue.find(1))
end
def test_macro_exception_should_be_displayed
Redmine::WikiFormatting::Macros.macro :exception do |obj, args|
raise "My message"
end
text = "{{exception}}"
assert_include '<div class="flash error">Error executing the <strong>exception</strong> macro (My message)</div>', textilizable(text)
end
def test_macro_arguments_should_not_be_parsed_by_formatters
text = '{{hello_world(http://www.redmine.org, #1)}}'
assert_include 'Arguments: http://www.redmine.org, #1', textilizable(text)
end
def test_exclamation_mark_should_not_run_macros
text = "!{{hello_world}}"
assert_equal '<p>{{hello_world}}</p>', textilizable(text)
end
def test_exclamation_mark_should_escape_macros
text = "!{{hello_world(<tag>)}}"
assert_equal '<p>{{hello_world(&lt;tag&gt;)}}</p>', textilizable(text)
end
def test_unknown_macros_should_not_be_replaced
text = "{{unknown}}"
assert_equal '<p>{{unknown}}</p>', textilizable(text)
end
def test_unknown_macros_should_parsed_as_text
text = "{{unknown(*test*)}}"
assert_equal '<p>{{unknown(<strong>test</strong>)}}</p>', textilizable(text)
end
def test_unknown_macros_should_be_escaped
text = "{{unknown(<tag>)}}"
assert_equal '<p>{{unknown(&lt;tag&gt;)}}</p>', textilizable(text)
end
def test_html_safe_macro_output_should_not_be_escaped
Redmine::WikiFormatting::Macros.macro :safe_macro do |obj, args|
"<tag>".html_safe
end
assert_equal '<p><tag></p>', textilizable("{{safe_macro}}")
end
def test_macro_hello_world def test_macro_hello_world
text = "{{hello_world}}" text = "{{hello_world}}"
assert textilizable(text).match(/Hello world!/) assert textilizable(text).match(/Hello world!/)
# escaping end
text = "!{{hello_world}}"
assert_equal '<p>{{hello_world}}</p>', textilizable(text) def test_macro_hello_world_should_escape_arguments
text = "{{hello_world(<tag>)}}"
assert_include 'Arguments: &lt;tag&gt;', textilizable(text)
end end
def test_macro_macro_list def test_macro_macro_list
@ -93,18 +152,18 @@ class Redmine::WikiFormatting::MacrosTest < ActionView::TestCase
@project = Project.find(1) @project = Project.find(1)
# include a page of the current project wiki # include a page of the current project wiki
text = "{{include(Another page)}}" text = "{{include(Another page)}}"
assert textilizable(text).match(/This is a link to a ticket/) assert_include 'This is a link to a ticket', textilizable(text)
@project = nil @project = nil
# include a page of a specific project wiki # include a page of a specific project wiki
text = "{{include(ecookbook:Another page)}}" text = "{{include(ecookbook:Another page)}}"
assert textilizable(text).match(/This is a link to a ticket/) assert_include 'This is a link to a ticket', textilizable(text)
text = "{{include(ecookbook:)}}" text = "{{include(ecookbook:)}}"
assert textilizable(text).match(/CookBook documentation/) assert_include 'CookBook documentation', textilizable(text)
text = "{{include(unknowidentifier:somepage)}}" text = "{{include(unknowidentifier:somepage)}}"
assert textilizable(text).match(/Page not found/) assert_include 'Page not found', textilizable(text)
end end
def test_macro_child_pages def test_macro_child_pages
@ -164,4 +223,40 @@ class Redmine::WikiFormatting::MacrosTest < ActionView::TestCase
assert_include 'test.png not found', assert_include 'test.png not found',
textilizable("{{thumbnail(test.png)}}", :object => Issue.find(14)) textilizable("{{thumbnail(test.png)}}", :object => Issue.find(14))
end end
def test_macros_should_not_be_executed_in_pre_tags
text = <<-RAW
{{hello_world(foo)}}
<pre>
{{hello_world(pre)}}
!{{hello_world(pre)}}
</pre>
{{hello_world(bar)}}
RAW
expected = <<-EXPECTED
<p>Hello world! Object: NilClass, Arguments: foo</p>
<pre>
{{hello_world(pre)}}
!{{hello_world(pre)}}
</pre>
<p>Hello world! Object: NilClass, Arguments: bar</p>
EXPECTED
assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(text).gsub(%r{[\r\n\t]}, '')
end
def test_macros_should_be_escaped_in_pre_tags
text = "<pre>{{hello_world(<tag>)}}</pre>"
assert_equal "<pre>{{hello_world(&lt;tag&gt;)}}</pre>", textilizable(text)
end
def test_macros_should_not_mangle_next_macros_outputs
text = '{{macro(2)}} !{{macro(2)}} {{hello_world(foo)}}'
assert_equal '<p>{{macro(2)}} {{macro(2)}} Hello world! Object: NilClass, Arguments: foo</p>', textilizable(text)
end
end end

View File

@ -18,6 +18,7 @@
require File.expand_path('../../../../test_helper', __FILE__) require File.expand_path('../../../../test_helper', __FILE__)
class Redmine::WikiFormattingTest < ActiveSupport::TestCase class Redmine::WikiFormattingTest < ActiveSupport::TestCase
fixtures :issues
def test_textile_formatter def test_textile_formatter
assert_equal Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting.formatter_for('textile') assert_equal Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting.formatter_for('textile')
@ -52,4 +53,8 @@ EXPECTED
assert_equal false, Redmine::WikiFormatting.supports_section_edit? assert_equal false, Redmine::WikiFormatting.supports_section_edit?
end end
end end
def test_cache_key_for_saved_object_should_no_be_nil
assert_not_nil Redmine::WikiFormatting.cache_key_for('textile', 'Text', Issue.find(1), :description)
end
end end