renderer.rb 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. class NotificationFactory::Renderer
  3. =begin
  4. examples how to use
  5. message_subject = NotificationFactory::Renderer.new(
  6. objects: {
  7. ticket: Ticket.first,
  8. },
  9. locale: 'de-de',
  10. timezone: 'America/Port-au-Prince',
  11. template: 'some template <b>#{ticket.title}</b> {config.fqdn}',
  12. escape: false, # Perform HTML encoding on replaced values
  13. url_encode: false, # Perform URI encoding on replaced values
  14. trusted: false, # Allow ERB tags in the template?
  15. ).render
  16. message_body = NotificationFactory::Renderer.new(
  17. objects: {
  18. ticket: Ticket.first,
  19. },
  20. locale: 'de-de',
  21. timezone: 'America/Port-au-Prince',
  22. template: 'some template <b>#{ticket.title}</b> #{config.fqdn}',
  23. ).render
  24. =end
  25. def initialize(objects:, template:, locale: nil, timezone: nil, escape: true, url_encode: false, trusted: false)
  26. @objects = objects
  27. @locale = locale || Locale.default
  28. @timezone = timezone || Setting.get('timezone_default')
  29. @template = NotificationFactory::Template.new(template, escape || url_encode, trusted)
  30. @escape = escape
  31. @url_encode = url_encode
  32. end
  33. def render(debug_errors: true)
  34. @debug_errors = debug_errors
  35. ERB.new(@template.to_s).result(binding)
  36. rescue Exception => e # rubocop:disable Lint/RescueException
  37. raise StandardError, e.message if e.is_a? SyntaxError
  38. raise
  39. end
  40. # d - data of object
  41. # d('user.firstname', htmlEscape)
  42. def d(key, escape = nil, escaping: true)
  43. # do validation, ignore some methods
  44. return "\#{#{key} / not allowed}" if !data_key_valid?(key)
  45. article_tags = %w[article last_article last_internal_article last_external_article
  46. created_article created_internal_article created_external_article]
  47. # aliases
  48. map = { 'ticket.tags' => 'ticket.tag_list', 'ticket.group.name' => 'ticket.group.fullname', 'group.name' => 'group.fullname' }
  49. article_tags.each do |tag|
  50. map["#{tag}.body"] = "#{tag}.body_as_text_with_quote.text2html"
  51. end
  52. if map[key]
  53. key = map[key]
  54. end
  55. # escape in html mode
  56. if escape
  57. no_escape = {}
  58. article_tags.each do |tag|
  59. no_escape["#{tag}.body_as_html"] = true
  60. no_escape["#{tag}.body_as_text_with_quote.text2html"] = true
  61. end
  62. if no_escape[key]
  63. escape = false
  64. end
  65. end
  66. value = nil
  67. object_methods = key.split('.')
  68. object_name = object_methods.shift
  69. # if no object is given, just return
  70. return debug("\#{no such object}") if object_name.blank?
  71. object_refs = @objects[object_name] || @objects[object_name.to_sym]
  72. # if object is not in available objects, just return
  73. return debug("\#{#{object_name} / no such object}") if !object_refs
  74. # if content of method is a complex datatype, just return
  75. if object_methods.blank? && object_refs.class != String && object_refs.class != Float && object_refs.class != Integer
  76. return debug("\#{#{key} / no such method}")
  77. end
  78. method_whitelist = %w[avatar]
  79. previous_object_refs = ''
  80. object_methods_s = ''
  81. object_methods.each do |method_raw|
  82. method = method_raw.strip
  83. if method == 'value'
  84. temp = object_refs
  85. object_refs = display_value(previous_object_refs, method, object_methods_s, object_refs)
  86. previous_object_refs = temp
  87. end
  88. if object_methods_s != ''
  89. object_methods_s += '.'
  90. end
  91. object_methods_s += method
  92. next if method == 'value'
  93. if object_methods_s == ''
  94. value = debug("\#{#{object_name}.#{object_methods_s} / no such method}")
  95. break
  96. end
  97. arguments = nil
  98. if %r{\A(?<method_id>[^(]+)\((?<parameter>[^)]+)\)\z} =~ method
  99. parameters = []
  100. parameter.split(',').each do |p|
  101. p = p.strip!
  102. if p != p.to_i.to_s
  103. value = debug("\#{#{object_name}.#{object_methods_s} / invalid parameter: #{p}}")
  104. break
  105. end
  106. parameters << parameter.to_i
  107. end
  108. # Ensure that e.g. 'ticket.title.slice(3,4)' is not allowed, but 'ticket.owner.avatar(150,150)' is
  109. if !parameters.size.eql?(1) && method_whitelist.exclude?(method_id)
  110. value = debug("\#{#{object_name}.#{object_methods_s} / invalid parameter: #{parameter}}")
  111. break
  112. end
  113. begin
  114. arguments = parameters
  115. method = method_id
  116. rescue
  117. value = debug("\#{#{object_name}.#{object_methods_s} / #{e.message}}")
  118. break
  119. end
  120. end
  121. # if method exists
  122. if !object_refs.respond_to?(method.to_sym) && method_whitelist.exclude?(method)
  123. value = debug("\#{#{object_name}.#{object_methods_s} / no such method}")
  124. break
  125. end
  126. begin
  127. previous_object_refs = object_refs
  128. if method.to_sym.eql?(:avatar)
  129. object_refs = handle_user_avatar(previous_object_refs, *arguments)
  130. escape = false
  131. break
  132. end
  133. object_refs = object_refs.send(method.to_sym, *arguments)
  134. # body_as_html should trigger the cloning of all inline attachments from the parent article (issue #2399)
  135. if method.to_sym == :body_as_html && previous_object_refs.respond_to?(:should_clone_inline_attachments)
  136. previous_object_refs.should_clone_inline_attachments = true
  137. end
  138. rescue => e
  139. value = debug("\#{#{object_name}.#{object_methods_s} / #{e.message}}")
  140. break
  141. end
  142. end
  143. placeholder = value || object_refs
  144. return placeholder if !escaping
  145. escaping(convert_to_timezone(placeholder), escape)
  146. end
  147. # c - config
  148. # c('fqdn', htmlEscape)
  149. def c(key, escape = nil)
  150. config = Setting.get(key)
  151. escaping(config, escape)
  152. end
  153. # t - translation
  154. # t('yes', htmlEscape)
  155. def t(key, escape = nil)
  156. translation = Translation.translate(@locale, key)
  157. escaping(translation, escape)
  158. end
  159. # h - htmlEscape
  160. # h(htmlEscape)
  161. def h(value)
  162. return value if !value
  163. CGI.escapeHTML(convert_to_timezone(value).to_s)
  164. end
  165. def dt(params_string)
  166. datetime_object, format_string, timezone = params_string.scan(%r{(?:['"].*?["']|[^,])+}).map do |param|
  167. param.strip.sub(%r{^["']}, '').sub(%r{["']$}, '')
  168. end
  169. return debug("\#{datetime object missing / invalid parameter}") if datetime_object.blank?
  170. value = d(datetime_object, escaping: false)
  171. allowed_classes = %w[ActiveSupport::TimeWithZone Date Time DateTime].freeze
  172. return debug("\#{#{datetime_object} / invalid parameter}") if allowed_classes.exclude?(value.class.to_s)
  173. format_string = format_string.presence || '%Y-%m-%d %H:%M:%S'
  174. timezone = timezone.presence || @timezone
  175. begin
  176. result = value.in_time_zone(timezone).strftime(format_string)
  177. rescue
  178. return debug("\#{#{timezone} / invalid parameter}")
  179. end
  180. result
  181. end
  182. private
  183. def debug(message)
  184. @debug_errors ? message : '-'
  185. end
  186. def convert_to_timezone(value)
  187. return Translation.timestamp(@locale, @timezone, value) if value.instance_of?(ActiveSupport::TimeWithZone)
  188. return Translation.date(@locale, value) if value.instance_of?(Date)
  189. value
  190. end
  191. def escaping(key, escape)
  192. return escaping(key['value'], escape) if key.is_a?(Hash) && key.key?('value')
  193. return escaping(key.join(', '), escape) if key.respond_to?(:join)
  194. return key if escape == false
  195. return key if escape.nil? && !@escape && !@url_encode
  196. return ERB::Util.url_encode(key) if @url_encode
  197. h key
  198. end
  199. def data_key_valid?(key)
  200. return false if key =~ %r{`|\.(|\s*)(save|destroy|delete|remove|drop|update|create|new|all|where|find|raise|dump|rollback|freeze)}i && key !~ %r{(update|create)d_(at|by)}i
  201. true
  202. end
  203. def select_value(attribute, key)
  204. key = Array(key)
  205. options = attribute.data_option['options']
  206. if options.is_a?(Array)
  207. key.map { |k| options.detect { |o| o['value'] == k }&.dig('name') || k }
  208. else
  209. key.map { |k| options[k] || k }
  210. end
  211. end
  212. def display_value(object, method_name, previous_method_names, key)
  213. return key if method_name != 'value' ||
  214. (!key.instance_of?(String) && !key.instance_of?(Array) && !key.is_a?(Hash))
  215. attributes = ObjectManager::Attribute
  216. .where(object_lookup_id: ObjectLookup.by_name(object.class.to_s))
  217. .where(name: previous_method_names.split('.').last)
  218. case attributes.first.data_type
  219. when %r{^(multi)?select$}
  220. select_value(attributes.first, key)
  221. when 'autocompletion_ajax_external_data_source'
  222. key['label']
  223. else
  224. key
  225. end
  226. end
  227. def handle_user_avatar(user, width = 60, height = 60)
  228. return if user.image.blank?
  229. file = avatar_file(user.image)
  230. return if file.nil?
  231. file_content_type = file.preferences['Content-Type'] || file.preferences['Mime-Type']
  232. "<img src='data:#{file_content_type};base64,#{Base64.strict_encode64(file.content)}' width='#{width}' height='#{height}' />"
  233. end
  234. def avatar_file(image_hash)
  235. Avatar.get_by_hash(image_hash)
  236. rescue
  237. nil
  238. end
  239. end