mirror of
https://github.com/zammad/zammad
synced 2026-05-24 09:48:36 +00:00
Co-authored-by: Dominik Klein <dk@zammad.com> Co-authored-by: Mantas Masalskis <mm@zammad.com> Co-authored-by: Rene Reimann <rr@zammad.com> Co-authored-by: Florian Liebe <fl@zammad.com>
355 lines
11 KiB
Ruby
355 lines
11 KiB
Ruby
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
|
|
|
class NotificationFactory::Renderer
|
|
ARTICLE_TAGS = %i[
|
|
article last_article last_internal_article last_external_article
|
|
first_article first_internal_article first_external_article
|
|
created_article created_internal_article created_external_article
|
|
].freeze
|
|
|
|
=begin
|
|
|
|
examples how to use
|
|
|
|
message_subject = NotificationFactory::Renderer.new(
|
|
objects: {
|
|
ticket: Ticket.first,
|
|
},
|
|
locale: 'de-de',
|
|
timezone: 'America/Port-au-Prince',
|
|
template: 'some template <b>#{ticket.title}</b> {config.fqdn}',
|
|
escape: false, # Perform HTML encoding on replaced values
|
|
url_encode: false, # Perform URI encoding on replaced values
|
|
trusted: false, # Allow ERB tags in the template?
|
|
).render
|
|
|
|
message_body = NotificationFactory::Renderer.new(
|
|
objects: {
|
|
ticket: Ticket.first,
|
|
},
|
|
locale: 'de-de',
|
|
timezone: 'America/Port-au-Prince',
|
|
template: 'some template <b>#{ticket.title}</b> #{config.fqdn}',
|
|
).render
|
|
|
|
=end
|
|
|
|
def initialize(objects:, template:, locale: nil, timezone: nil, escape: true, url_encode: false, trusted: false, ignore_missing_objects: false, trim_mode: nil)
|
|
@objects = objects
|
|
@locale = locale || Locale.default
|
|
@timezone = timezone || Setting.get('timezone_default')
|
|
@template = NotificationFactory::Template.new(template, escape || url_encode, trusted)
|
|
@escape = escape
|
|
@url_encode = url_encode
|
|
@trusted = trusted
|
|
@ignore_missing_objects = ignore_missing_objects
|
|
@trim_mode = trim_mode
|
|
end
|
|
|
|
def render(debug_errors: true)
|
|
@debug_errors = debug_errors
|
|
template_str = @template.to_s
|
|
ERB.new(template_str, trim_mode: @trim_mode).result(template_binding)
|
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
raise StandardError, e.message if e.is_a? SyntaxError
|
|
|
|
raise
|
|
end
|
|
|
|
# d - data of object
|
|
# d('user.firstname', htmlEscape)
|
|
def d(key, escape = nil, escaping: true)
|
|
# do validation, ignore some methods
|
|
return "\#{#{key} / not allowed}" if !data_key_valid?(key)
|
|
|
|
# aliases
|
|
map = { 'ticket.tags' => 'ticket.tag_list', 'ticket.group.name' => 'ticket.group.fullname', 'group.name' => 'group.fullname' }
|
|
ARTICLE_TAGS.each do |tag|
|
|
map["#{tag}.body"] = "#{tag}.body_as_text_with_quote.text2html"
|
|
end
|
|
|
|
if map[key]
|
|
key = map[key]
|
|
end
|
|
|
|
# escape in html mode
|
|
if escape
|
|
no_escape = {}
|
|
ARTICLE_TAGS.each do |tag|
|
|
no_escape["#{tag}.body_as_html"] = true
|
|
no_escape["#{tag}.body_as_text_with_quote.text2html"] = true
|
|
no_escape["#{tag}.body_as_text.text2html"] = true
|
|
end
|
|
if no_escape[key]
|
|
escape = false
|
|
end
|
|
end
|
|
|
|
value = nil
|
|
object_methods = key.split('.')
|
|
object_name = object_methods.shift
|
|
|
|
# if no object is given, just return
|
|
return debug("\#{no such object}") if object_name.blank?
|
|
|
|
object_refs = @objects[object_name] || @objects[object_name.to_sym]
|
|
|
|
# if object is not in available objects, just return
|
|
if !object_refs
|
|
return "\#{#{key}}" if @ignore_missing_objects
|
|
|
|
return debug("\#{#{object_name} / no such object}")
|
|
end
|
|
|
|
# if content of method is a complex datatype, just return
|
|
if object_methods.blank? && object_refs.class != String && object_refs.class != Float && object_refs.class != Integer
|
|
return debug("\#{#{key} / no such method}")
|
|
end
|
|
|
|
method_whitelist = %w[avatar]
|
|
|
|
previous_object_refs = ''
|
|
object_methods_s = ''
|
|
object_methods.each_with_index do |method_raw, index|
|
|
|
|
method = method_raw.strip
|
|
|
|
if method == 'value'
|
|
escape = textarea_attributes(previous_object_refs).exclude?(object_methods_s.split('.').last)
|
|
temp = object_refs
|
|
object_refs = display_value(previous_object_refs, method, object_methods_s, object_refs)
|
|
previous_object_refs = temp
|
|
elsif index == object_methods.length - 1 && (is_textarea_attribute = textarea_attributes(object_refs).include?(method))
|
|
temp = object_refs
|
|
object_refs = object_refs.send(method.to_sym)&.text2html
|
|
previous_object_refs = temp
|
|
escape = false
|
|
end
|
|
|
|
if object_methods_s != ''
|
|
object_methods_s += '.'
|
|
end
|
|
object_methods_s += method
|
|
|
|
next if method == 'value' || is_textarea_attribute
|
|
|
|
if object_methods_s == ''
|
|
value = debug("\#{#{object_name}.#{object_methods_s} / no such method}")
|
|
break
|
|
end
|
|
|
|
arguments = nil
|
|
if %r{\A(?<method_id>[^(]+)\((?<parameter>[^)]+)\)\z} =~ method
|
|
|
|
parameters = []
|
|
parameter.split(',').each do |p|
|
|
p = p.strip
|
|
|
|
if p != p.to_i.to_s
|
|
value = debug("\#{#{object_name}.#{object_methods_s} / invalid parameter: #{p}}")
|
|
break
|
|
end
|
|
|
|
parameters << parameter.to_i
|
|
end
|
|
|
|
# Ensure that e.g. 'ticket.title.slice(3,4)' is not allowed, but 'ticket.owner.avatar(150,150)' is
|
|
if !parameters.size.eql?(1) && method_whitelist.exclude?(method_id)
|
|
value = debug("\#{#{object_name}.#{object_methods_s} / invalid parameter: #{parameter}}")
|
|
break
|
|
end
|
|
|
|
begin
|
|
arguments = parameters
|
|
method = method_id
|
|
rescue
|
|
value = debug("\#{#{object_name}.#{object_methods_s} / #{e.message}}")
|
|
break
|
|
end
|
|
end
|
|
|
|
# if method exists
|
|
if !object_refs.respond_to?(method.to_sym) && method_whitelist.exclude?(method)
|
|
value = debug("\#{#{object_name}.#{object_methods_s} / no such method}")
|
|
break
|
|
end
|
|
|
|
begin
|
|
previous_object_refs = object_refs
|
|
|
|
if method.to_sym.eql?(:avatar)
|
|
object_refs = handle_user_avatar(previous_object_refs, *arguments)
|
|
escape = false
|
|
break
|
|
end
|
|
|
|
object_refs = object_refs.send(method.to_sym, *arguments)
|
|
|
|
# body_as_html should trigger the cloning of all inline attachments from the parent article (issue #2399)
|
|
if method.to_sym == :body_as_html && previous_object_refs.respond_to?(:should_clone_inline_attachments)
|
|
previous_object_refs.should_clone_inline_attachments = true
|
|
end
|
|
rescue => e
|
|
value = debug("\#{#{object_name}.#{object_methods_s} / #{e.message}}")
|
|
break
|
|
end
|
|
end
|
|
placeholder = value || object_refs
|
|
|
|
return placeholder if !escaping
|
|
|
|
escaping(convert_to_timezone(placeholder), escape)
|
|
end
|
|
|
|
# c - config
|
|
# c('fqdn', htmlEscape)
|
|
def c(key, escape = nil)
|
|
config = Setting.get(key)
|
|
escaping(config, escape)
|
|
end
|
|
|
|
# t - translation
|
|
# t('yes', htmlEscape)
|
|
def t(key, escape = nil)
|
|
translation = Translation.translate(@locale, key)
|
|
escaping(translation, escape)
|
|
end
|
|
|
|
# h - htmlEscape
|
|
# h(htmlEscape)
|
|
def h(value)
|
|
return value if !value
|
|
|
|
CGI.escapeHTML(convert_to_timezone(value).to_s)
|
|
end
|
|
|
|
def dt(params_string)
|
|
datetime_object, format_string, timezone = params_string.scan(%r{(?:['"].*?["']|[^,])+}).map do |param|
|
|
param.strip.sub(%r{^["']}, '').sub(%r{["']$}, '')
|
|
end
|
|
return debug("\#{datetime object missing / invalid parameter}") if datetime_object.blank?
|
|
|
|
value = d(datetime_object, escaping: false)
|
|
|
|
allowed_classes = %w[ActiveSupport::TimeWithZone Date Time DateTime].freeze
|
|
return debug("\#{#{datetime_object} / invalid parameter}") if allowed_classes.exclude?(value.class.to_s)
|
|
|
|
format_string = format_string.presence || '%Y-%m-%d %H:%M:%S'
|
|
timezone = timezone.presence || @timezone
|
|
|
|
begin
|
|
result = value.in_time_zone(timezone).strftime(format_string)
|
|
rescue
|
|
return debug("\#{#{timezone} / invalid parameter}")
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
private
|
|
|
|
def template_binding
|
|
TemplateContext.new(self, @objects).safe_binding
|
|
end
|
|
|
|
# Restricted ERB execution context that only exposes safe template helpers.
|
|
class TemplateContext
|
|
def initialize(renderer, objects)
|
|
@renderer = renderer
|
|
@objects = objects
|
|
end
|
|
|
|
def d(...) = @renderer.d(...)
|
|
def t(...) = @renderer.t(...)
|
|
def c(...) = @renderer.c(...)
|
|
def h(...) = @renderer.h(...)
|
|
def dt(...) = @renderer.dt(...)
|
|
|
|
def safe_binding
|
|
binding
|
|
end
|
|
end
|
|
|
|
def debug(message)
|
|
@debug_errors ? message : '-'
|
|
end
|
|
|
|
def convert_to_timezone(value)
|
|
return Translation.timestamp(@locale, @timezone, value) if value.instance_of?(ActiveSupport::TimeWithZone)
|
|
return Translation.date(@locale, value) if value.instance_of?(Date)
|
|
|
|
value
|
|
end
|
|
|
|
def escaping(key, escape)
|
|
return escaping(key['value'], escape) if key.is_a?(Hash) && key.key?('value')
|
|
return escaping(key.join(', '), escape) if key.respond_to?(:join)
|
|
return key if escape == false
|
|
return key if escape.nil? && !@escape && !@url_encode
|
|
|
|
return ERB::Util.url_encode(key) if @url_encode
|
|
|
|
h key
|
|
end
|
|
|
|
def data_key_valid?(key)
|
|
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
|
|
|
|
true
|
|
end
|
|
|
|
def select_value(attribute, key)
|
|
key = Array(key)
|
|
options = attribute.data_option['options']
|
|
if options.is_a?(Array)
|
|
key.map { |k| options.detect { |o| o['value'] == k }&.dig('name') || k }
|
|
else
|
|
key.map { |k| options[k] || k }
|
|
end
|
|
end
|
|
|
|
def display_value(object, method_name, previous_method_names, key)
|
|
return key if method_name != 'value' ||
|
|
(!key.instance_of?(String) && !key.instance_of?(Array) && !key.is_a?(Hash))
|
|
|
|
attribute = object_manager_attributes(object)
|
|
.where(name: previous_method_names.split('.').last)
|
|
.first
|
|
|
|
case attribute.data_type
|
|
when %r{^(multi)?select$}
|
|
select_value(attribute, key)
|
|
when 'textarea'
|
|
key.text2html
|
|
when 'autocompletion_ajax_external_data_source'
|
|
key['label']
|
|
else
|
|
key
|
|
end
|
|
end
|
|
|
|
def handle_user_avatar(user, width = 60, height = 60)
|
|
return if user.image.blank?
|
|
|
|
file = avatar_file(user.image)
|
|
return if file.nil?
|
|
|
|
file_content_type = file.preferences['Content-Type'] || file.preferences['Mime-Type']
|
|
|
|
"<img src='data:#{file_content_type};base64,#{Base64.strict_encode64(file.content)}' width='#{width}' height='#{height}' />"
|
|
end
|
|
|
|
def avatar_file(image_hash)
|
|
Avatar.get_by_hash(image_hash)
|
|
rescue
|
|
nil
|
|
end
|
|
|
|
def object_manager_attributes(object)
|
|
ObjectManager::Attribute.where(object_lookup_id: ObjectLookup.by_name(object.class.to_s))
|
|
end
|
|
|
|
def textarea_attributes(object)
|
|
object_manager_attributes(object).where(data_type: :textarea).map(&:name)
|
|
end
|
|
end
|