1# frozen_string_literal: true
2
3# A note on the root of an issue, merge request, commit, or snippet.
4#
5# A note of this type is never resolvable.
6class Note < ApplicationRecord
7  extend ActiveModel::Naming
8  extend Gitlab::Utils::Override
9
10  include Gitlab::Utils::StrongMemoize
11  include Participable
12  include Mentionable
13  include Awardable
14  include Importable
15  include FasterCacheKeys
16  include Redactable
17  include CacheMarkdownField
18  include AfterCommitQueue
19  include ResolvableNote
20  include Editable
21  include Gitlab::SQL::Pattern
22  include ThrottledTouch
23  include FromUnion
24  include Sortable
25
26  cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true
27
28  redact_field :note
29
30  TYPES_RESTRICTED_BY_ABILITY = {
31    branch: :download_code
32  }.freeze
33
34  # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes.
35  # See https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/10392/diffs#note_28719102
36  alias_attribute :last_edited_by, :updated_by
37
38  # Attribute containing rendered and redacted Markdown as generated by
39  # Banzai::ObjectRenderer.
40  attr_accessor :redacted_note_html
41
42  # Total of all references as generated by Banzai::ObjectRenderer
43  attr_accessor :total_reference_count
44
45  # Number of user visible references as generated by Banzai::ObjectRenderer
46  attr_accessor :user_visible_reference_count
47
48  # Attribute used to store the attributes that have been changed by quick actions.
49  attr_accessor :commands_changes
50
51  # Attribute used to determine whether keep_around_commits will be skipped for diff notes.
52  attr_accessor :skip_keep_around_commits
53
54  default_value_for :system, false
55
56  attr_mentionable :note, pipeline: :note
57  participant :author
58
59  belongs_to :project
60  belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
61  belongs_to :author, class_name: "User"
62  belongs_to :updated_by, class_name: "User"
63  belongs_to :last_edited_by, class_name: 'User'
64  belongs_to :review, inverse_of: :notes
65
66  has_many :todos
67
68  # The delete_all definition is required here in order
69  # to generate the correct DELETE sql for
70  # suggestions.delete_all calls
71  has_many :suggestions, -> { order(:relative_order) },
72    inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
73  has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
74  has_one :system_note_metadata
75  has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
76  has_many :diff_note_positions
77
78  delegate :gfm_reference, :local_reference, to: :noteable
79  delegate :name, to: :project, prefix: true
80  delegate :name, :email, to: :author, prefix: true
81  delegate :title, to: :noteable, allow_nil: true
82
83  validates :note, presence: true
84  validates :note, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }
85  validates :project, presence: true, if: :for_project_noteable?
86
87  # Attachments are deprecated and are handled by Markdown uploader
88  validates :attachment, file_size: { maximum: :max_attachment_size }
89
90  validates :noteable_type, presence: true
91  validates :noteable_id, presence: true, unless: [:for_commit?, :importing?]
92  validates :commit_id, presence: true, if: :for_commit?
93  validates :author, presence: true
94  validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
95
96  validate unless: [:for_commit?, :importing?, :skip_project_check?] do |note|
97    unless note.noteable.try(:project) == note.project
98      errors.add(:project, 'does not match noteable project')
99    end
100  end
101
102  validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?]
103
104  # @deprecated attachments are handled by the Upload model.
105  #
106  # https://gitlab.com/gitlab-org/gitlab/-/issues/20830
107  mount_uploader :attachment, AttachmentUploader
108
109  # Scopes
110  scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) }
111  scope :system, -> { where(system: true) }
112  scope :user, -> { where(system: false) }
113  scope :common, -> { where(noteable_type: ["", nil]) }
114  scope :fresh, -> { order_created_asc.with_order_id_asc }
115  scope :updated_after, ->(time) { where('updated_at > ?', time) }
116  scope :with_updated_at, ->(time) { where(updated_at: time) }
117  scope :with_discussion_ids, ->(discussion_ids) { where(discussion_id: discussion_ids) }
118  scope :with_suggestions, -> { joins(:suggestions) }
119  scope :inc_author, -> { includes(:author) }
120  scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) }
121  scope :inc_relations_for_view, -> do
122    includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
123             { system_note_metadata: :description_version }, :note_diff_file, :diff_note_positions, :suggestions)
124  end
125
126  scope :with_notes_filter, -> (notes_filter) do
127    case notes_filter
128    when UserPreference::NOTES_FILTERS[:only_comments]
129      user
130    when UserPreference::NOTES_FILTERS[:only_activity]
131      system
132    else
133      all
134    end
135  end
136
137  scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) }
138  scope :new_diff_notes, -> { where(type: 'DiffNote') }
139  scope :non_diff_notes, -> { where(type: ['Note', 'DiscussionNote', nil]) }
140
141  scope :with_associations, -> do
142    # FYI noteable cannot be loaded for LegacyDiffNote for commits
143    includes(:author, :noteable, :updated_by,
144             project: [:project_members, :namespace, { group: [:group_members] }])
145  end
146  scope :with_metadata, -> { includes(:system_note_metadata) }
147  scope :with_web_entity_associations, -> { preload(:project, :author, :noteable) }
148
149  scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) }
150  scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) }
151
152  before_validation :nullify_blank_type, :nullify_blank_line_code
153  after_save :keep_around_commit, if: :for_project_noteable?, unless: -> { importing? || skip_keep_around_commits }
154  after_save :expire_etag_cache, unless: :importing?
155  after_save :touch_noteable, unless: :importing?
156  after_destroy :expire_etag_cache
157  after_commit :notify_after_create, on: :create
158  after_commit :notify_after_destroy, on: :destroy
159
160  class << self
161    extend Gitlab::Utils::Override
162
163    def model_name
164      ActiveModel::Name.new(self, nil, 'note')
165    end
166
167    def discussions(context_noteable = nil)
168      Discussion.build_collection(all.includes(:noteable).fresh, context_noteable)
169    end
170
171    # Note: Where possible consider using Discussion#lazy_find to return
172    # Discussions in order to benefit from having records batch loaded.
173    def find_discussion(discussion_id)
174      notes = where(discussion_id: discussion_id).fresh.to_a
175
176      return if notes.empty?
177
178      Discussion.build(notes)
179    end
180
181    # Group diff discussions by line code or file path.
182    # It is not needed to group by line code when comment is
183    # on an image.
184    def grouped_diff_discussions(diff_refs = nil)
185      groups = {}
186
187      diff_notes.fresh.discussions.each do |discussion|
188        group_key =
189          if discussion.on_image?
190            discussion.file_new_path
191          else
192            discussion.line_code_in_diffs(diff_refs)
193          end
194
195        if group_key
196          discussions = groups[group_key] ||= []
197          discussions << discussion
198        end
199      end
200
201      groups
202    end
203
204    def positions
205      where.not(position: nil)
206        .select(:id, :type, :position) # ActiveRecord needs id and type for typecasting.
207        .map(&:position)
208    end
209
210    def count_for_collection(ids, type, count_column = 'COUNT(*) as count')
211      user.select(:noteable_id, count_column)
212        .group(:noteable_id)
213        .where(noteable_type: type, noteable_id: ids)
214    end
215
216    def search(query)
217      fuzzy_search(query, [:note])
218    end
219
220    # Override the `Sortable` module's `.simple_sorts` to remove name sorting,
221    # as a `Note` does not have any property that correlates to a "name".
222    override :simple_sorts
223    def simple_sorts
224      super.except('name_asc', 'name_desc')
225    end
226
227    def cherry_picked_merge_requests(shas)
228      where(noteable_type: 'MergeRequest', commit_id: shas).select(:noteable_id)
229    end
230  end
231
232  # rubocop: disable CodeReuse/ServiceClass
233  def system_note_with_references?
234    return unless system?
235
236    if force_cross_reference_regex_check?
237      matches_cross_reference_regex?
238    else
239      ::SystemNotes::IssuablesService.cross_reference?(note)
240    end
241  end
242  # rubocop: enable CodeReuse/ServiceClass
243
244  def diff_note?
245    false
246  end
247
248  def active?
249    true
250  end
251
252  def max_attachment_size
253    Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
254  end
255
256  def hook_attrs
257    Gitlab::HookData::NoteBuilder.new(self).build
258  end
259
260  def supports_suggestion?
261    false
262  end
263
264  def for_commit?
265    noteable_type == "Commit"
266  end
267
268  def for_issue?
269    noteable_type == "Issue"
270  end
271
272  def for_merge_request?
273    noteable_type == "MergeRequest"
274  end
275
276  def for_snippet?
277    noteable_type == "Snippet"
278  end
279
280  def for_alert_mangement_alert?
281    noteable_type == 'AlertManagement::Alert'
282  end
283
284  def for_vulnerability?
285    noteable_type == "Vulnerability"
286  end
287
288  def for_project_snippet?
289    noteable.is_a?(ProjectSnippet)
290  end
291
292  def for_personal_snippet?
293    noteable.is_a?(PersonalSnippet)
294  end
295
296  def for_project_noteable?
297    !for_personal_snippet?
298  end
299
300  def for_design?
301    noteable_type == DesignManagement::Design.name
302  end
303
304  def for_issuable?
305    for_issue? || for_merge_request?
306  end
307
308  def skip_project_check?
309    !for_project_noteable?
310  end
311
312  def commit
313    @commit ||= project.commit(commit_id) if commit_id.present?
314  end
315
316  # Notes on merge requests and commits can be traced back to one or several
317  # MRs. This method returns a relation if the note is for one of these types,
318  # or nil if it is a note on some other object.
319  def merge_requests
320    if for_commit?
321      project.merge_requests.by_commit_sha(commit_id)
322    elsif for_merge_request?
323      MergeRequest.id_in(noteable_id)
324    else
325      nil
326    end
327  end
328
329  # override to return commits, which are not active record
330  def noteable
331    return commit if for_commit?
332
333    super
334  rescue StandardError
335    # Temp fix to prevent app crash
336    # if note commit id doesn't exist
337    nil
338  end
339
340  # FIXME: Hack for polymorphic associations with STI
341  #        For more information visit http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#label-Polymorphic+Associations
342  def noteable_type=(noteable_type)
343    super(noteable_type.to_s.classify.constantize.base_class.to_s)
344  end
345
346  def noteable_assignee_or_author?(user)
347    return false unless user
348    return false unless noteable.respond_to?(:author_id)
349    return noteable.assignee_or_author?(user) if [MergeRequest, Issue].include?(noteable.class)
350
351    noteable.author_id == user.id
352  end
353
354  def contributor?
355    project&.team&.contributor?(self.author_id)
356  end
357
358  def noteable_author?(noteable)
359    noteable.author == self.author
360  end
361
362  def project_name
363    project&.name
364  end
365
366  def confidential?(include_noteable: false)
367    return true if confidential
368
369    include_noteable && noteable.try(:confidential?)
370  end
371
372  def editable?
373    !system?
374  end
375
376  # We used `last_edited_at` as an alias of `updated_at` before.
377  # This makes it compatible with the previous way without data migration.
378  def last_edited_at
379    super || updated_at
380  end
381
382  # Since we used `updated_at` as `last_edited_at`, it could be touched by transforming / resolving a note.
383  # This makes sure it is only marked as edited when the note body is updated.
384  def edited?
385    return false if updated_by.blank?
386
387    super
388  end
389
390  def award_emoji?
391    can_be_award_emoji? && contains_emoji_only?
392  end
393
394  def emoji_awardable?
395    !system?
396  end
397
398  def can_be_award_emoji?
399    noteable.is_a?(Awardable) && !part_of_discussion?
400  end
401
402  def contains_emoji_only?
403    note =~ /\A#{Banzai::Filter::EmojiFilter.emoji_pattern}\s?\Z/
404  end
405
406  def noteable_ability_name
407    if for_snippet?
408      'snippet'
409    elsif for_alert_mangement_alert?
410      'alert_management_alert'
411    elsif for_vulnerability?
412      'security_resource'
413    else
414      noteable_type.demodulize.underscore
415    end
416  end
417
418  def can_be_discussion_note?
419    self.noteable.supports_discussions? && !part_of_discussion?
420  end
421
422  def can_create_todo?
423    # Skip system notes, and notes on snippets
424    !system? && !for_snippet?
425  end
426
427  def discussion_class(noteable = nil)
428    # When commit notes are rendered on an MR's Discussion page, they are
429    # displayed in one discussion instead of individually.
430    # See also `#discussion_id` and `Discussion.override_discussion_id`.
431    if noteable && noteable != self.noteable
432      OutOfContextDiscussion
433    else
434      IndividualNoteDiscussion
435    end
436  end
437
438  # See `Discussion.override_discussion_id` for details.
439  def discussion_id(noteable = nil)
440    discussion_class(noteable).override_discussion_id(self) || super() || ensure_discussion_id
441  end
442
443  # Returns a discussion containing just this note.
444  # This method exists as an alternative to `#discussion` to use when the methods
445  # we intend to call on the Discussion object don't require it to have all of its notes,
446  # and just depend on the first note or the type of discussion. This saves us a DB query.
447  def to_discussion(noteable = nil)
448    Discussion.build([self], noteable)
449  end
450
451  # Returns the entire discussion this note is part of.
452  # Consider using `#to_discussion` if we do not need to render the discussion
453  # and all its notes and if we don't care about the discussion's resolvability status.
454  def discussion
455    strong_memoize(:discussion) do
456      full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
457      full_discussion || to_discussion
458    end
459  end
460
461  def start_of_discussion?
462    discussion.first_note == self
463  end
464
465  def part_of_discussion?
466    !to_discussion.individual_note?
467  end
468
469  def in_reply_to?(other)
470    case other
471    when Note
472      if part_of_discussion?
473        in_reply_to?(other.noteable) && in_reply_to?(other.to_discussion)
474      else
475        in_reply_to?(other.noteable)
476      end
477    when Discussion
478      self.discussion_id == other.id
479    when Noteable
480      self.noteable == other
481    else
482      false
483    end
484  end
485
486  def references
487    refs = [noteable]
488
489    if part_of_discussion?
490      refs += discussion.notes.take_while { |n| n.id < id }
491    end
492
493    refs
494  end
495
496  def bump_updated_at
497    # Instead of calling touch which is throttled via ThrottledTouch concern,
498    # we bump the updated_at column directly. This also prevents executing
499    # after_commit callbacks that we don't need.
500    update_column(:updated_at, Time.current)
501  end
502
503  def expire_etag_cache
504    noteable&.expire_note_etag_cache
505  end
506
507  def touch(*args, **kwargs)
508    # We're not using an explicit transaction here because this would in all
509    # cases result in all future queries going to the primary, even if no writes
510    # are performed.
511    #
512    # We touch the noteable first so its SELECT query can run before our writes,
513    # ensuring it runs on a secondary (if no prior write took place).
514    touch_noteable
515    super
516  end
517
518  # By default Rails will issue an "SELECT *" for the relation, which is
519  # overkill for just updating the timestamps. To work around this we manually
520  # touch the data so we can SELECT only the columns we need.
521  def touch_noteable
522    # Commits are not stored in the DB so we can't touch them.
523    return if for_commit?
524
525    assoc = association(:noteable)
526
527    noteable_object =
528      if assoc.loaded?
529        noteable
530      else
531        # If the object is not loaded (e.g. when notes are loaded async) we
532        # _only_ want the data we actually need.
533        assoc.scope.select(:id, :updated_at).take
534      end
535
536    noteable_object&.touch
537
538    # We return the noteable object so we can re-use it in EE for Elasticsearch.
539    noteable_object
540  end
541
542  def notify_after_create
543    noteable&.after_note_created(self)
544  end
545
546  def notify_after_destroy
547    noteable&.after_note_destroyed(self)
548  end
549
550  def banzai_render_context(field)
551    super.merge(noteable: noteable, system_note: system?, label_url_method: noteable_label_url_method)
552  end
553
554  def retrieve_upload(_identifier, paths)
555    Upload.find_by(model: self, path: paths)
556  end
557
558  def resource_parent
559    project
560  end
561
562  def user_mentions
563    return Note.none unless noteable.present?
564
565    noteable.user_mentions.where(note: self)
566  end
567
568  def system_note_with_references_visible_for?(user)
569    return true unless system?
570
571    (!system_note_with_references? || all_referenced_mentionables_allowed?(user)) && system_note_viewable_by?(user)
572  end
573
574  def parent_user
575    noteable.author if for_personal_snippet?
576  end
577
578  def skip_notification?
579    review.present? || !author.can_trigger_notifications?
580  end
581
582  def post_processed_cache_key
583    cache_key_items = [cache_key, author&.cache_key]
584    cache_key_items << project.team.human_max_access(author&.id) if author.present?
585    cache_key_items << Digest::SHA1.hexdigest(redacted_note_html) if redacted_note_html.present?
586
587    cache_key_items.join(':')
588  end
589
590  override :user_mention_class
591  def user_mention_class
592    return if noteable.blank?
593
594    noteable.user_mention_class
595  end
596
597  override :user_mention_identifier
598  def user_mention_identifier
599    return if noteable.blank?
600
601    noteable.user_mention_identifier.merge({
602      note_id: id
603    })
604  end
605
606  def show_outdated_changes?
607    return false unless for_merge_request?
608    return false unless Feature.enabled?(:display_outdated_line_diff, noteable.source_project, default_enabled: :yaml)
609    return false unless system?
610    return false unless change_position&.line_range
611
612    change_position.line_range["end"] || change_position.line_range["start"]
613  end
614
615  private
616
617  def system_note_viewable_by?(user)
618    return true unless system_note_metadata
619
620    restriction = TYPES_RESTRICTED_BY_ABILITY[system_note_metadata.action.to_sym]
621    return Ability.allowed?(user, restriction, project) if restriction
622
623    true
624  end
625
626  def keep_around_commit
627    project.repository.keep_around(self.commit_id)
628  end
629
630  def nullify_blank_type
631    self.type = nil if self.type.blank?
632  end
633
634  def nullify_blank_line_code
635    self.line_code = nil if self.line_code.blank?
636  end
637
638  def ensure_discussion_id
639    return if self.attribute_present?(:discussion_id)
640
641    self.discussion_id = derive_discussion_id
642  end
643
644  def derive_discussion_id
645    discussion_class.discussion_id(self)
646  end
647
648  def all_referenced_mentionables_allowed?(user)
649    if user_visible_reference_count.present? && total_reference_count.present?
650      # if they are not equal, then there are private/confidential references as well
651      user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count
652    else
653      refs = all_references(user)
654      refs.all.any? && refs.all_visible?
655    end
656  end
657
658  def force_cross_reference_regex_check?
659    return unless system?
660
661    system_note_metadata&.cross_reference_types&.include?(system_note_metadata&.action)
662  end
663
664  def does_not_exceed_notes_limit?
665    return unless noteable
666
667    errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT
668  end
669
670  def noteable_label_url_method
671    for_merge_request? ? :project_merge_requests_url : :project_issues_url
672  end
673end
674
675Note.prepend_mod_with('Note')
676