1# frozen_string_literal: true
2
3# == Participable concern
4#
5# Contains functionality related to objects that can have participants, such as
6# an author, an assignee and people mentioned in its description or comments.
7#
8# Usage:
9#
10#     class Issue < ApplicationRecord
11#       include Participable
12#
13#       # ...
14#
15#       participant :author
16#       participant :assignee
17#       participant :notes
18#
19#       participant -> (current_user, ext) do
20#         ext.analyze('...')
21#       end
22#     end
23#
24#     issue = Issue.last
25#     users = issue.participants
26module Participable
27  extend ActiveSupport::Concern
28  class_methods do
29    # Adds a list of participant attributes. Attributes can either be symbols or
30    # Procs.
31    #
32    # When using a Proc instead of a Symbol the Proc will be given two
33    # arguments:
34    #
35    # 1. The current user (as an instance of User)
36    # 2. An instance of `Gitlab::ReferenceExtractor`
37    #
38    # It is expected that a Proc populates the given reference extractor
39    # instance with data. The return value of the Proc is ignored.
40    #
41    # attr - The name of the attribute or a Proc
42    def participant(attr)
43      participant_attrs << attr
44    end
45  end
46
47  included do
48    # Accessor for participant attributes.
49    cattr_accessor :participant_attrs, instance_accessor: false do
50      []
51    end
52  end
53
54  # Returns the users participating in a discussion.
55  #
56  # This method processes attributes of objects in breadth-first order.
57  #
58  # Returns an Array of User instances.
59  def participants(user = nil)
60    filtered_participants_hash[user]
61  end
62
63  # Returns only participants visible for the user
64  #
65  # Returns an Array of User instances.
66  def visible_participants(user)
67    return participants(user) unless Feature.enabled?(:verify_participants_access, project, default_enabled: :yaml)
68
69    filter_by_ability(raw_participants(user, verify_access: true))
70  end
71
72  # Checks if the user is a participant in a discussion.
73  #
74  # This method processes attributes of objects in breadth-first order.
75  #
76  # Returns a Boolean.
77  def participant?(user)
78    can_read_participable?(user) &&
79      all_participants_hash[user].include?(user)
80  end
81
82  private
83
84  def all_participants_hash
85    @all_participants_hash ||= Hash.new do |hash, user|
86      hash[user] = raw_participants(user)
87    end
88  end
89
90  def filtered_participants_hash
91    @filtered_participants_hash ||= Hash.new do |hash, user|
92      hash[user] = filter_by_ability(all_participants_hash[user])
93    end
94  end
95
96  def raw_participants(current_user = nil, verify_access: false)
97    ext = Gitlab::ReferenceExtractor.new(project, current_user)
98    participants = Set.new
99    process = [self]
100
101    until process.empty?
102      source = process.pop
103
104      case source
105      when User
106        participants << source
107      when Participable
108        next unless !verify_access || source_visible_to_user?(source, current_user)
109
110        source.class.participant_attrs.each do |attr|
111          if attr.respond_to?(:call)
112            source.instance_exec(current_user, ext, &attr)
113          else
114            process << source.__send__(attr) # rubocop:disable GitlabSecurity/PublicSend
115          end
116        end
117      when Enumerable, ActiveRecord::Relation
118        # This uses reverse_each so we can use "pop" to get the next value to
119        # process (in order). Using unshift instead of pop would require
120        # moving all Array values one index to the left (which can be
121        # expensive).
122        source.reverse_each { |obj| process << obj }
123      end
124    end
125
126    participants.merge(ext.users)
127  end
128
129  def source_visible_to_user?(source, user)
130    Ability.allowed?(user, "read_#{source.model_name.element}".to_sym, source)
131  end
132
133  def filter_by_ability(participants)
134    case self
135    when PersonalSnippet
136      Ability.users_that_can_read_personal_snippet(participants.to_a, self)
137    else
138      Ability.users_that_can_read_project(participants.to_a, project)
139    end
140  end
141
142  def can_read_participable?(participant)
143    case self
144    when PersonalSnippet
145      participant.can?(:read_snippet, self)
146    else
147      participant.can?(:read_project, project)
148    end
149  end
150end
151
152Participable.prepend_mod_with('Participable')
153