article.rb 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. # Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
  2. class Ticket::Article < ApplicationModel
  3. include CanBeImported
  4. include HasActivityStreamLog
  5. include ChecksClientNotification
  6. include HasHistory
  7. include ChecksHtmlSanitized
  8. include CanCsvImport
  9. include CanCloneAttachments
  10. include HasObjectManagerAttributes
  11. include Ticket::Article::Assets
  12. include Ticket::Article::EnqueueCommunicateEmailJob
  13. include Ticket::Article::EnqueueCommunicateFacebookJob
  14. include Ticket::Article::EnqueueCommunicateSmsJob
  15. include Ticket::Article::EnqueueCommunicateTelegramJob
  16. include Ticket::Article::EnqueueCommunicateTwitterJob
  17. include Ticket::Article::HasTicketContactAttributesImpact
  18. include Ticket::Article::ResetsTicketState
  19. include Ticket::Article::TriggersSubscriptions
  20. # AddsMetadataGeneral depends on AddsMetadataOriginById, so load that first
  21. include Ticket::Article::AddsMetadataOriginById
  22. include Ticket::Article::AddsMetadataGeneral
  23. include Ticket::Article::AddsMetadataEmail
  24. include HasTransactionDispatcher
  25. belongs_to :ticket, optional: true
  26. has_one :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', foreign_key: :ticket_article_id, dependent: :destroy, inverse_of: :ticket_article
  27. belongs_to :type, class_name: 'Ticket::Article::Type', optional: true
  28. belongs_to :sender, class_name: 'Ticket::Article::Sender', optional: true
  29. belongs_to :created_by, class_name: 'User', optional: true
  30. belongs_to :updated_by, class_name: 'User', optional: true
  31. belongs_to :origin_by, class_name: 'User', optional: true
  32. before_validation :check_mentions, on: :create
  33. before_create :check_subject, :check_body, :check_message_id_md5
  34. before_update :check_subject, :check_body, :check_message_id_md5
  35. after_destroy :store_delete, :update_time_units
  36. after_commit :ticket_touch, if: :persisted?
  37. store :preferences
  38. validates :ticket_id, presence: true
  39. validates :type_id, presence: true
  40. validates :sender_id, presence: true
  41. sanitized_html :body
  42. activity_stream_permission 'ticket.agent'
  43. activity_stream_attributes_ignored :type_id,
  44. :sender_id,
  45. :preferences
  46. history_attributes_ignored :type_id,
  47. :sender_id,
  48. :preferences,
  49. :message_id,
  50. :from,
  51. :to,
  52. :cc
  53. attr_accessor :should_clone_inline_attachments
  54. alias should_clone_inline_attachments? should_clone_inline_attachments
  55. # fillup md5 of message id to search easier on very long message ids
  56. def check_message_id_md5
  57. return true if message_id.blank?
  58. self.message_id_md5 = Digest::MD5.hexdigest(message_id.to_s)
  59. end
  60. =begin
  61. insert inline image urls to body
  62. article_attributes = Ticket::Article.insert_urls(article_attributes)
  63. returns
  64. article_attributes_with_body_and_urls
  65. =end
  66. def self.insert_urls(article)
  67. return article if article['attachments'].blank?
  68. return article if !article['content_type'].match?(%r{text/html}i)
  69. return article if article['body'] !~ %r{<img}i
  70. inline_attachments = {}
  71. article['body'].gsub!(%r{(<img[[:space:]](|.+?)src=")cid:(.+?)"(|.+?)>}im) do |item|
  72. tag_start = $1
  73. cid = $3
  74. tag_end = $4
  75. replace = item
  76. # look for attachment
  77. article['attachments'].each do |file|
  78. next if !file[:preferences] || !file[:preferences]['Content-ID'] || (file[:preferences]['Content-ID'] != cid && file[:preferences]['Content-ID'] != "<#{cid}>")
  79. replace = "#{tag_start}/api/v1/ticket_attachment/#{article['ticket_id']}/#{article['id']}/#{file[:id]}?view=inline\"#{tag_end}>"
  80. inline_attachments[file[:id]] = true
  81. break
  82. end
  83. replace
  84. end
  85. new_attachments = []
  86. article['attachments'].each do |file|
  87. next if inline_attachments[file[:id]]
  88. new_attachments.push file
  89. end
  90. article['attachments'] = new_attachments
  91. article
  92. end
  93. =begin
  94. get inline attachments of article
  95. article = Ticket::Article.find(123)
  96. attachments = article.attachments_inline
  97. returns
  98. [attachment1, attachment2, ...]
  99. =end
  100. def attachments_inline
  101. inline_attachments = {}
  102. body.gsub(%r{<img[[:space:]](|.+?)src="cid:(.+?)"(|.+?)>}im) do |_item|
  103. cid = $2
  104. # look for attachment
  105. attachments.each do |file|
  106. content_id = file.preferences['Content-ID'] || file.preferences['content_id']
  107. next if content_id.blank? || (content_id != cid && content_id != "<#{cid}>")
  108. inline_attachments[file.id] = true
  109. break
  110. end
  111. end
  112. new_attachments = []
  113. attachments.each do |file|
  114. next if !inline_attachments[file.id]
  115. new_attachments.push file
  116. end
  117. new_attachments
  118. end
  119. def self.last_customer_agent_article(ticket_id)
  120. sender = Ticket::Article::Sender.lookup(name: 'System')
  121. Ticket::Article.where('ticket_id = ? AND sender_id NOT IN (?)', ticket_id, sender.id).order(created_at: :desc).first
  122. end
  123. =begin
  124. get body as html
  125. article = Ticket::Article.find(123)
  126. article.body_as_html
  127. =end
  128. def body_as_html
  129. return '' if !body
  130. return body if content_type && content_type =~ %r{text/html}i
  131. body.text2html
  132. end
  133. =begin
  134. get body as text
  135. article = Ticket::Article.find(123)
  136. article.body_as_text
  137. =end
  138. def body_as_text
  139. return '' if !body
  140. return body if content_type.blank? || content_type =~ %r{text/plain}i
  141. body.html2text
  142. end
  143. =begin
  144. get body as text with quote sign "> " at the beginning of each line
  145. article = Ticket::Article.find(123)
  146. article.body_as_text
  147. =end
  148. def body_as_text_with_quote
  149. body_as_text.word_wrap.message_quote
  150. end
  151. =begin
  152. get article as raw (e. g. if it's a email, the raw email)
  153. article = Ticket::Article.find(123)
  154. article.as_raw
  155. returns:
  156. file # Store
  157. =end
  158. def as_raw
  159. list = Store.list(
  160. object: 'Ticket::Article::Mail',
  161. o_id: id,
  162. )
  163. return if list.blank?
  164. list[0]
  165. end
  166. =begin
  167. save article as raw (e. g. if it's a email, the raw email)
  168. article = Ticket::Article.find(123)
  169. article.save_as_raw(msg)
  170. returns:
  171. file # Store
  172. =end
  173. def save_as_raw(msg)
  174. Store.create!(
  175. object: 'Ticket::Article::Mail',
  176. o_id: id,
  177. data: msg,
  178. filename: "ticket-#{ticket.number}-#{id}.eml",
  179. preferences: {},
  180. created_by_id: created_by_id,
  181. )
  182. end
  183. def sanitizeable?(attribute, _value)
  184. return true if attribute != :body
  185. return false if content_type.blank?
  186. content_type =~ %r{html}i
  187. end
  188. =begin
  189. get relation name of model based on params
  190. model = Model.find(1)
  191. attributes = model.attributes_with_association_names
  192. returns
  193. hash with attributes, association ids, association names and relation name
  194. =end
  195. def attributes_with_association_names(empty_keys: false)
  196. attributes = super
  197. add_attachments_to_attributes(attributes)
  198. Ticket::Article.insert_urls(attributes)
  199. end
  200. =begin
  201. get relations of model based on params
  202. model = Model.find(1)
  203. attributes = model.attributes_with_association_ids
  204. returns
  205. hash with attributes and association ids
  206. =end
  207. def attributes_with_association_ids
  208. attributes = super
  209. add_attachments_to_attributes(attributes)
  210. if attributes['body'] && attributes['content_type'] =~ %r{text/html}i
  211. attributes['body'] = Rails.cache.fetch("#{self.class}/#{cache_key_with_version}/body/dynamic_image_size") do
  212. HtmlSanitizer.dynamic_image_size(attributes['body'])
  213. end
  214. end
  215. Ticket::Article.insert_urls(attributes)
  216. end
  217. private
  218. def add_attachments_to_attributes(attributes)
  219. attributes['attachments'] = attachments.map(&:attributes_for_display)
  220. attributes
  221. end
  222. # strip not wanted chars
  223. def check_subject
  224. return true if subject.blank?
  225. subject.gsub!(%r{\s|\t|\r}, ' ')
  226. true
  227. end
  228. # strip body length or raise exception
  229. def check_body
  230. return true if body.blank?
  231. limit = 1_500_000
  232. current_length = body.length
  233. return true if body.length <= limit
  234. raise Exceptions::UnprocessableEntity, "body of article is too large, #{current_length} chars - only #{limit} allowed" if !ApplicationHandleInfo.postmaster? && !Setting.get('import_mode')
  235. logger.warn "WARNING: cut string because of database length #{self.class}.body(#{limit} but is #{current_length})"
  236. self.body = body[0, limit]
  237. end
  238. def check_mentions
  239. begin
  240. mention_user_ids = Nokogiri::HTML(body).css('a[data-mention-user-id]').pluck('data-mention-user-id')
  241. rescue => e
  242. Rails.logger.error "Can't parse body '#{body}' as HTML for extracting Mentions."
  243. Rails.logger.error e
  244. return
  245. end
  246. return if mention_user_ids.blank?
  247. if !TicketPolicy.new(updated_by, ticket).create_mentions?
  248. return if ApplicationHandleInfo.postmaster?
  249. return if updated_by.id == 1
  250. raise "User #{updated_by_id} has no permission to mention other users!"
  251. end
  252. user_ids = User.where(id: mention_user_ids).pluck(:id)
  253. user_ids.each do |user_id|
  254. Mention.where(mentionable: ticket, user_id: user_id).first_or_create(mentionable: ticket, user_id: user_id)
  255. end
  256. end
  257. def history_log_attributes
  258. {
  259. related_o_id: self['ticket_id'],
  260. related_history_object: 'Ticket',
  261. }
  262. end
  263. # callback function to overwrite
  264. # default history stream log attributes
  265. # gets called from activity_stream_log
  266. def activity_stream_log_attributes
  267. {
  268. group_id: Ticket.find(ticket_id).group_id,
  269. }
  270. end
  271. # delete attachments and mails of article
  272. def store_delete
  273. Store.remove(
  274. object: 'Ticket::Article',
  275. o_id: id,
  276. )
  277. Store.remove(
  278. object: 'Ticket::Article::Mail',
  279. o_id: id,
  280. )
  281. end
  282. # recalculate time accounting
  283. def update_time_units
  284. Ticket::TimeAccounting.update_ticket(ticket)
  285. end
  286. def ticket_touch
  287. ticket.touch # rubocop:disable Rails/SkipsModelValidations
  288. end
  289. end