article.rb 10 KB

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