diff options
Diffstat (limited to 'app/models/concerns')
36 files changed, 632 insertions, 47 deletions
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index c106c08c04a..fdc418029be 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -131,7 +131,6 @@ module Avatarable def clear_avatar_caches return unless respond_to?(:verified_emails) && verified_emails.any? && avatar_changed? - return unless Feature.enabled?(:avatar_cache_for_email, self, type: :development) Gitlab::AvatarCache.delete_by_email(*verified_emails) end diff --git a/app/models/concerns/boards/listable.rb b/app/models/concerns/boards/listable.rb index d6863e87261..b9827a79422 100644 --- a/app/models/concerns/boards/listable.rb +++ b/app/models/concerns/boards/listable.rb @@ -13,6 +13,7 @@ module Boards scope :ordered, -> { order(:list_type, :position) } scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) } scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) } + scope :without_types, ->(list_types) { where.not(list_type: list_types) } class << self def preload_preferences_for_user(lists, user) diff --git a/app/models/concerns/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb index f44ad474cd5..e252ca36629 100644 --- a/app/models/concerns/bulk_member_access_load.rb +++ b/app/models/concerns/bulk_member_access_load.rb @@ -13,13 +13,7 @@ module BulkMemberAccessLoad raise 'Block is mandatory' unless block_given? resource_ids = resource_ids.uniq - key = max_member_access_for_resource_key(resource_klass, memoization_index) - access = {} - - if Gitlab::SafeRequestStore.active? - Gitlab::SafeRequestStore[key] ||= {} - access = Gitlab::SafeRequestStore[key] - end + access = load_access_hash(resource_klass, memoization_index) # Look up only the IDs we need resource_ids -= access.keys @@ -39,10 +33,28 @@ module BulkMemberAccessLoad access end + def merge_value_to_request_store(resource_klass, resource_id, memoization_index, value) + max_member_access_for_resource_ids(resource_klass, [resource_id], memoization_index) do + { resource_id => value } + end + end + private def max_member_access_for_resource_key(klass, memoization_index) "max_member_access_for_#{klass.name.underscore.pluralize}:#{memoization_index}" end + + def load_access_hash(resource_klass, memoization_index) + key = max_member_access_for_resource_key(resource_klass, memoization_index) + + access = {} + if Gitlab::SafeRequestStore.active? + Gitlab::SafeRequestStore[key] ||= {} + access = Gitlab::SafeRequestStore[key] + end + + access + end end end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 45944401c2d..34c1b6d25a4 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -56,12 +56,12 @@ module CacheMarkdownField # Update every applicable column in a row if any one is invalidated, as we only store # one version per row def refresh_markdown_cache - updates = cached_markdown_fields.markdown_fields.map do |markdown_field| + updates = cached_markdown_fields.markdown_fields.to_h do |markdown_field| [ cached_markdown_fields.html_field(markdown_field), rendered_field_content(markdown_field) ] - end.to_h + end updates['cached_markdown_version'] = latest_cached_markdown_version diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb new file mode 100644 index 00000000000..2b4a108a9a0 --- /dev/null +++ b/app/models/concerns/cascading_namespace_setting_attribute.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +# +# Cascading attributes enables managing settings in a flexible way. +# +# - Instance administrator can define an instance-wide default setting, or +# lock the setting to prevent change by group owners. +# - Group maintainers/owners can define a default setting for their group, or +# lock the setting to prevent change by sub-group maintainers/owners. +# +# Behavior: +# +# - When a group does not have a value (value is `nil`), cascade up the +# hierarchy to find the first non-nil value. +# - Settings can be locked at any level to prevent groups/sub-groups from +# overriding. +# - If the setting isn't locked, the default can be overridden. +# - An instance administrator or group maintainer/owner can push settings values +# to groups/sub-groups to override existing values, even when the setting +# is not otherwise locked. +# +module CascadingNamespaceSettingAttribute + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + class_methods do + def cascading_settings_feature_enabled? + ::Feature.enabled?(:cascading_namespace_settings, default_enabled: true) + end + + private + + # Facilitates the cascading lookup of values and, + # similar to Rails' `attr_accessor`, defines convenience methods such as + # a reader, writer, and validators. + # + # Example: `cascading_attr :delayed_project_removal` + # + # Public methods defined: + # - `delayed_project_removal` + # - `delayed_project_removal=` + # - `delayed_project_removal_locked?` + # - `delayed_project_removal_locked_by_ancestor?` + # - `delayed_project_removal_locked_by_application_setting?` + # - `delayed_project_removal?` (only defined for boolean attributes) + # - `delayed_project_removal_locked_ancestor` - Returns locked namespace settings object (only namespace_id) + # + # Defined validators ensure attribute value cannot be updated if locked by + # an ancestor or application settings. + # + # Requires database columns be present in both `namespace_settings` and + # `application_settings`. + def cascading_attr(*attributes) + attributes.map(&:to_sym).each do |attribute| + # public methods + define_attr_reader(attribute) + define_attr_writer(attribute) + define_lock_methods(attribute) + alias_boolean(attribute) + + # private methods + define_validator_methods(attribute) + define_after_update(attribute) + + validate :"#{attribute}_changeable?" + validate :"lock_#{attribute}_changeable?" + + after_update :"clear_descendant_#{attribute}_locks", if: -> { saved_change_to_attribute?("lock_#{attribute}", to: true) } + end + end + + # The cascading attribute reader method handles lookups + # with the following criteria: + # + # 1. Returns the dirty value, if the attribute has changed. + # 2. Return locked ancestor value. + # 3. Return locked instance-level application settings value. + # 4. Return this namespace's attribute, if not nil. + # 5. Return value from nearest ancestor where value is not nil. + # 6. Return instance-level application setting. + def define_attr_reader(attribute) + define_method(attribute) do + strong_memoize(attribute) do + next self[attribute] unless self.class.cascading_settings_feature_enabled? + + next self[attribute] if will_save_change_to_attribute?(attribute) + next locked_value(attribute) if cascading_attribute_locked?(attribute) + next self[attribute] unless self[attribute].nil? + + cascaded_value = cascaded_ancestor_value(attribute) + next cascaded_value unless cascaded_value.nil? + + application_setting_value(attribute) + end + end + end + + def define_attr_writer(attribute) + define_method("#{attribute}=") do |value| + clear_memoization(attribute) + + super(value) + end + end + + def define_lock_methods(attribute) + define_method("#{attribute}_locked?") do + cascading_attribute_locked?(attribute) + end + + define_method("#{attribute}_locked_by_ancestor?") do + locked_by_ancestor?(attribute) + end + + define_method("#{attribute}_locked_by_application_setting?") do + locked_by_application_setting?(attribute) + end + + define_method("#{attribute}_locked_ancestor") do + locked_ancestor(attribute) + end + end + + def alias_boolean(attribute) + return unless Gitlab::Database.exists? && type_for_attribute(attribute).type == :boolean + + alias_method :"#{attribute}?", attribute + end + + # Defines two validations - one for the cascadable attribute itself and one + # for the lock attribute. Only allows the respective value to change if + # an ancestor has not already locked the value. + def define_validator_methods(attribute) + define_method("#{attribute}_changeable?") do + return unless cascading_attribute_changed?(attribute) + return unless cascading_attribute_locked?(attribute) + + errors.add(attribute, s_('CascadingSettings|cannot be changed because it is locked by an ancestor')) + end + + define_method("lock_#{attribute}_changeable?") do + return unless cascading_attribute_changed?("lock_#{attribute}") + + if cascading_attribute_locked?(attribute) + return errors.add(:"lock_#{attribute}", s_('CascadingSettings|cannot be changed because it is locked by an ancestor')) + end + + # Don't allow locking a `nil` attribute. + # Even if the value being locked is currently cascaded from an ancestor, + # it should be copied to this record to avoid the ancestor changing the + # value unexpectedly later. + return unless self[attribute].nil? && public_send("lock_#{attribute}?") # rubocop:disable GitlabSecurity/PublicSend + + errors.add(attribute, s_('CascadingSettings|cannot be nil when locking the attribute')) + end + + private :"#{attribute}_changeable?", :"lock_#{attribute}_changeable?" + end + + # When a particular group locks the attribute, clear all sub-group locks + # since the higher lock takes priority. + def define_after_update(attribute) + define_method("clear_descendant_#{attribute}_locks") do + self.class.where(namespace_id: descendants).update_all("lock_#{attribute}" => false) + end + + private :"clear_descendant_#{attribute}_locks" + end + end + + private + + def locked_value(attribute) + ancestor = locked_ancestor(attribute) + return ancestor.read_attribute(attribute) if ancestor + + Gitlab::CurrentSettings.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend + end + + def locked_ancestor(attribute) + return unless self.class.cascading_settings_feature_enabled? + return unless namespace.has_parent? + + strong_memoize(:"#{attribute}_locked_ancestor") do + self.class + .select(:namespace_id, "lock_#{attribute}", attribute) + .where(namespace_id: namespace_ancestor_ids) + .where(self.class.arel_table["lock_#{attribute}"].eq(true)) + .limit(1).load.first + end + end + + def locked_by_ancestor?(attribute) + return false unless self.class.cascading_settings_feature_enabled? + + locked_ancestor(attribute).present? + end + + def locked_by_application_setting?(attribute) + return false unless self.class.cascading_settings_feature_enabled? + + Gitlab::CurrentSettings.public_send("lock_#{attribute}") # rubocop:disable GitlabSecurity/PublicSend + end + + def cascading_attribute_locked?(attribute) + locked_by_ancestor?(attribute) || locked_by_application_setting?(attribute) + end + + def cascading_attribute_changed?(attribute) + public_send("#{attribute}_changed?") # rubocop:disable GitlabSecurity/PublicSend + end + + def cascaded_ancestor_value(attribute) + return unless namespace.has_parent? + + # rubocop:disable GitlabSecurity/SqlInjection + self.class + .select(attribute) + .joins("join unnest(ARRAY[#{namespace_ancestor_ids.join(',')}]) with ordinality t(namespace_id, ord) USING (namespace_id)") + .where("#{attribute} IS NOT NULL") + .order('t.ord') + .limit(1).first&.read_attribute(attribute) + # rubocop:enable GitlabSecurity/SqlInjection + end + + def application_setting_value(attribute) + Gitlab::CurrentSettings.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend + end + + def namespace_ancestor_ids + strong_memoize(:namespace_ancestor_ids) do + namespace.self_and_ancestors(hierarchy_order: :asc).pluck(:id).reject { |id| id == namespace_id } + end + end + + def descendants + strong_memoize(:descendants) do + namespace.descendants.pluck(:id) + end + end +end diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb index cbe7d3b6abb..0d29955268f 100644 --- a/app/models/concerns/ci/artifactable.rb +++ b/app/models/concerns/ci/artifactable.rb @@ -4,8 +4,10 @@ module Ci module Artifactable extend ActiveSupport::Concern - NotSupportedAdapterError = Class.new(StandardError) + include ObjectStorable + STORE_COLUMN = :file_store + NotSupportedAdapterError = Class.new(StandardError) FILE_FORMAT_ADAPTERS = { gzip: Gitlab::Ci::Build::Artifacts::Adapters::GzipStream, raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream @@ -20,6 +22,7 @@ module Ci scope :expired_before, -> (timestamp) { where(arel_table[:expire_at].lt(timestamp)) } scope :expired, -> (limit) { expired_before(Time.current).limit(limit) } + scope :project_id_in, ->(ids) { where(project_id: ids) } end def each_blob(&blk) @@ -39,3 +42,5 @@ module Ci end end end + +Ci::Artifactable.prepend_ee_mod diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index 0412f7a072b..c990da5873a 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -16,6 +16,19 @@ module Ci STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, failed: 4, canceled: 5, skipped: 6, manual: 7, scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze + STATUSES_DESCRIPTION = { + created: 'Pipeline has been created', + waiting_for_resource: 'A resource (for example, a runner) that the pipeline requires to run is unavailable', + preparing: 'Pipeline is preparing to run', + pending: 'Pipeline has not started running yet', + running: 'Pipeline is running', + failed: 'At least one stage of the pipeline failed', + success: 'Pipeline completed successfully', + canceled: 'Pipeline was canceled before completion', + skipped: 'Pipeline was skipped', + manual: 'Pipeline needs to be manually started', + scheduled: 'Pipeline is scheduled to run' + }.freeze UnknownStatusError = Class.new(StandardError) diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index b468415c4c7..829b2a6ef21 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -33,7 +33,7 @@ module CounterAttribute extend AfterCommitQueue include Gitlab::ExclusiveLeaseHelpers - LUA_STEAL_INCREMENT_SCRIPT = <<~EOS.freeze + LUA_STEAL_INCREMENT_SCRIPT = <<~EOS local increment_key, flushed_key = KEYS[1], KEYS[2] local increment_value = redis.call("get", increment_key) or 0 local flushed_value = redis.call("incrby", flushed_key, increment_value) diff --git a/app/models/concerns/deprecated_assignee.rb b/app/models/concerns/deprecated_assignee.rb index 7f12ce39c96..3f557ee9b48 100644 --- a/app/models/concerns/deprecated_assignee.rb +++ b/app/models/concerns/deprecated_assignee.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# This module handles backward compatibility for import/export of Merge Requests after +# This module handles backward compatibility for import/export of merge requests after # multiple assignees feature was introduced. Also, it handles the scenarios where # the #26496 background migration hasn't finished yet. # Ideally, most of this code should be removed at #59457. diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index 48b4a402974..de17f50cd29 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -20,6 +20,8 @@ module Enums scheduler_failure: 11, data_integrity_failure: 12, forward_deployment_failure: 13, + user_blocked: 14, + project_deleted: 15, insufficient_bridge_permissions: 1_001, downstream_bridge_project_not_found: 1_002, invalid_bridge_trigger: 1_003, diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index f8314d8b429..fdc48d09db2 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -13,7 +13,9 @@ module Enums activity_limit_exceeded: 20, size_limit_exceeded: 21, job_activity_limit_exceeded: 22, - deployments_limit_exceeded: 23 + deployments_limit_exceeded: 23, + user_blocked: 24, + project_deleted: 25 } end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index b9ad78c14fd..774cda2c3e8 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -77,9 +77,14 @@ module HasRepository def default_branch_from_preferences return unless empty_repo? - group_branch_default_name = group&.default_branch_name if respond_to?(:group) + (default_branch_from_group_preferences || Gitlab::CurrentSettings.default_branch_name).presence + end + + def default_branch_from_group_preferences + return unless respond_to?(:group) + return unless group - (group_branch_default_name || Gitlab::CurrentSettings.default_branch_name).presence + group.default_branch_name || group.root_ancestor.default_branch_name end def reload_default_branch diff --git a/app/models/concerns/has_timelogs_report.rb b/app/models/concerns/has_timelogs_report.rb new file mode 100644 index 00000000000..90f9876de95 --- /dev/null +++ b/app/models/concerns/has_timelogs_report.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module HasTimelogsReport + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + def timelogs(start_time, end_time) + strong_memoize(:timelogs) { timelogs_for(start_time, end_time) } + end + + def user_can_access_group_timelogs?(current_user) + Ability.allowed?(current_user, :read_group_timelogs, self) + end + + private + + def timelogs_for(start_time, end_time) + Timelog.between_times(start_time, end_time).for_issues_in_group(self) + end +end diff --git a/app/models/concerns/integration.rb b/app/models/concerns/integration.rb index 9d446841a9f..5e53f13be95 100644 --- a/app/models/concerns/integration.rb +++ b/app/models/concerns/integration.rb @@ -6,12 +6,12 @@ module Integration class_methods do def with_custom_integration_for(integration, page = nil, per = nil) custom_integration_project_ids = Service + .select(:project_id) .where(type: integration.type) .where(inherit_from_id: nil) - .distinct # Required until https://gitlab.com/gitlab-org/gitlab/-/issues/207385 + .where.not(project_id: nil) .page(page) .per(per) - .pluck(:project_id) Project.where(id: custom_integration_project_ids) end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index e1be0665452..1e44321e148 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -65,7 +65,7 @@ module Issuable has_many :label_links, as: :target, dependent: :destroy, inverse_of: :target # rubocop:disable Cop/ActiveRecordDependent has_many :labels, through: :label_links - has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :todos, as: :target has_one :metrics, inverse_of: model_name.singular.to_sym, autosave: true @@ -137,6 +137,14 @@ module Issuable scope :references_project, -> { references(:project) } scope :non_archived, -> { join_project.where(projects: { archived: false }) } + scope :includes_for_bulk_update, -> do + associations = %i[author assignees epic group labels metrics project source_project target_project].select do |association| + reflect_on_association(association) + end + + includes(*associations) + end + attr_mentionable :title, pipeline: :single_line attr_mentionable :description @@ -324,7 +332,7 @@ module Issuable # This prevents errors when ignored columns are present in the database. issuable_columns = with_cte ? issue_grouping_columns(use_cte: with_cte) : "#{table_name}.*" - extra_select_columns = extra_select_columns.unshift("(#{highest_priority}) AS highest_priority") + extra_select_columns.unshift("(#{highest_priority}) AS highest_priority") select(issuable_columns) .select(extra_select_columns) @@ -437,7 +445,7 @@ module Issuable end def subscribed_without_subscriptions?(user, project) - participants(user).include?(user) + participant?(user) end def can_assign_epic?(user) diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb index e624b9aa356..59e0ed75d2d 100644 --- a/app/models/concerns/loaded_in_group_list.rb +++ b/app/models/concerns/loaded_in_group_list.rb @@ -73,6 +73,10 @@ module LoadedInGroupList def member_count @member_count ||= try(:preloaded_member_count) || members.count end + + def guest_count + @guest_count ||= members.guests.count + end end LoadedInGroupList::ClassMethods.prepend_if_ee('EE::LoadedInGroupList::ClassMethods') diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index ccb334343ff..d42417bb6c1 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -39,11 +39,13 @@ module Milestoneable private def milestone_is_valid - errors.add(:milestone_id, 'is invalid') if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available? + errors.add(:milestone_id, 'is invalid') if respond_to?(:milestone_id) && !milestone_available? end end def milestone_available? + return true if milestone_id.blank? + project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 5f24564dc56..eaf64f2541d 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Milestoneish - DISPLAY_ISSUES_LIMIT = 3000 + DISPLAY_ISSUES_LIMIT = 500 def total_issues_count @total_issues_count ||= Milestones::IssuesCountService.new(self).count @@ -15,6 +15,10 @@ module Milestoneish total_issues_count - closed_issues_count end + def total_merge_requests_count + @total_merge_request_count ||= Milestones::MergeRequestsCountService.new(self).count + end + def complete? total_issues_count > 0 && total_issues_count == closed_issues_count end diff --git a/app/models/concerns/object_storable.rb b/app/models/concerns/object_storable.rb new file mode 100644 index 00000000000..c13dddc0b88 --- /dev/null +++ b/app/models/concerns/object_storable.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ObjectStorable + extend ActiveSupport::Concern + + included do + scope :with_files_stored_locally, -> { where(klass::STORE_COLUMN => ObjectStorage::Store::LOCAL) } + scope :with_files_stored_remotely, -> { where(klass::STORE_COLUMN => ObjectStorage::Store::REMOTE) } + end +end diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index af105629398..acd654bd229 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -56,18 +56,34 @@ module Participable # This method processes attributes of objects in breadth-first order. # # Returns an Array of User instances. - def participants(current_user = nil) - all_participants[current_user] + def participants(user = nil) + filtered_participants_hash[user] + end + + # Checks if the user is a participant in a discussion. + # + # This method processes attributes of objects in breadth-first order. + # + # Returns a Boolean. + def participant?(user) + can_read_participable?(user) && + all_participants_hash[user].include?(user) end private - def all_participants - @all_participants ||= Hash.new do |hash, user| + def all_participants_hash + @all_participants_hash ||= Hash.new do |hash, user| hash[user] = raw_participants(user) end end + def filtered_participants_hash + @filtered_participants_hash ||= Hash.new do |hash, user| + hash[user] = filter_by_ability(all_participants_hash[user]) + end + end + def raw_participants(current_user = nil) current_user ||= author ext = Gitlab::ReferenceExtractor.new(project, current_user) @@ -98,8 +114,6 @@ module Participable end participants.merge(ext.users) - - filter_by_ability(participants) end def filter_by_ability(participants) @@ -110,6 +124,15 @@ module Participable Ability.users_that_can_read_project(participants.to_a, project) end end + + def can_read_participable?(participant) + case self + when PersonalSnippet + participant.can?(:read_snippet, self) + else + participant.can?(:read_project, project) + end + end end Participable.prepend_if_ee('EE::Participable') diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index 65195a8d5aa..2828ae4a3a9 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -4,7 +4,7 @@ module ProtectedRef extend ActiveSupport::Concern included do - belongs_to :project + belongs_to :project, touch: true validates :name, presence: true validates :project, presence: true diff --git a/app/models/concerns/safe_url.rb b/app/models/concerns/safe_url.rb index febca7d241f..7dce05bddba 100644 --- a/app/models/concerns/safe_url.rb +++ b/app/models/concerns/safe_url.rb @@ -3,12 +3,12 @@ module SafeUrl extend ActiveSupport::Concern - def safe_url(usernames_whitelist: []) + def safe_url(allowed_usernames: []) return if url.nil? uri = URI.parse(url) uri.password = '*****' if uri.password - uri.user = '*****' if uri.user && !usernames_whitelist.include?(uri.user) + uri.user = '*****' if uri.user && allowed_usernames.exclude?(uri.user) uri.to_s rescue URI::Error end diff --git a/app/models/concerns/sidebars/container_with_html_options.rb b/app/models/concerns/sidebars/container_with_html_options.rb new file mode 100644 index 00000000000..12ea366c66a --- /dev/null +++ b/app/models/concerns/sidebars/container_with_html_options.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Sidebars + module ContainerWithHtmlOptions + # The attributes returned from this method + # will be applied to helper methods like + # `link_to` or the div containing the container. + def container_html_options + { + aria: { label: title } + }.merge(extra_container_html_options) + end + + # Classes will override mostly this method + # and not `container_html_options`. + def extra_container_html_options + {} + end + + # Attributes to pass to the html_options attribute + # in the helper method that sets the active class + # on each element. + def nav_link_html_options + {} + end + + def title + raise NotImplementedError + end + + # The attributes returned from this method + # will be applied right next to the title, + # for example in the span that renders the title. + def title_html_options + {} + end + + def link + raise NotImplementedError + end + end +end diff --git a/app/models/concerns/sidebars/has_active_routes.rb b/app/models/concerns/sidebars/has_active_routes.rb new file mode 100644 index 00000000000..e7a153f067a --- /dev/null +++ b/app/models/concerns/sidebars/has_active_routes.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Sidebars + module HasActiveRoutes + # This method will indicate for which paths or + # controllers, the menu or menu item should + # be set as active. + # + # The returned values are passed to the `nav_link` helper method, + # so the params can be either `path`, `page`, `controller`. + # Param 'action' is not supported. + def active_routes + {} + end + end +end diff --git a/app/models/concerns/sidebars/has_hint.rb b/app/models/concerns/sidebars/has_hint.rb new file mode 100644 index 00000000000..21dca39dca0 --- /dev/null +++ b/app/models/concerns/sidebars/has_hint.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# This module has the necessary methods to store +# hints for menus. Hints are elements displayed +# when the user hover the menu item. +module Sidebars + module HasHint + def show_hint? + false + end + + def hint_html_options + {} + end + end +end diff --git a/app/models/concerns/sidebars/has_icon.rb b/app/models/concerns/sidebars/has_icon.rb new file mode 100644 index 00000000000..d1a87918285 --- /dev/null +++ b/app/models/concerns/sidebars/has_icon.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# This module has the necessary methods to show +# sprites or images next to the menu item. +module Sidebars + module HasIcon + def sprite_icon + nil + end + + def sprite_icon_html_options + {} + end + + def image_path + nil + end + + def image_html_options + {} + end + + def icon_or_image? + sprite_icon || image_path + end + end +end diff --git a/app/models/concerns/sidebars/has_pill.rb b/app/models/concerns/sidebars/has_pill.rb new file mode 100644 index 00000000000..ad7064fe63d --- /dev/null +++ b/app/models/concerns/sidebars/has_pill.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# This module introduces the logic to show the "pill" element +# next to the menu item, indicating the a count. +module Sidebars + module HasPill + def has_pill? + false + end + + # In this method we will need to provide the query + # to retrieve the elements count + def pill_count + raise NotImplementedError + end + + def pill_html_options + {} + end + end +end diff --git a/app/models/concerns/sidebars/positionable_list.rb b/app/models/concerns/sidebars/positionable_list.rb new file mode 100644 index 00000000000..30830d547f3 --- /dev/null +++ b/app/models/concerns/sidebars/positionable_list.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# This module handles elements in a list. All elements +# must have a different class +module Sidebars + module PositionableList + def add_element(list, element) + list << element + end + + def insert_element_before(list, before_element, new_element) + index = index_of(list, before_element) + + if index + list.insert(index, new_element) + else + list.unshift(new_element) + end + end + + def insert_element_after(list, after_element, new_element) + index = index_of(list, after_element) + + if index + list.insert(index + 1, new_element) + else + add_element(list, new_element) + end + end + + private + + def index_of(list, element) + list.index { |e| e.is_a?(element) } + end + end +end diff --git a/app/models/concerns/sidebars/renderable.rb b/app/models/concerns/sidebars/renderable.rb new file mode 100644 index 00000000000..a3976af8515 --- /dev/null +++ b/app/models/concerns/sidebars/renderable.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Sidebars + module Renderable + # This method will control whether the menu or menu_item + # should be rendered. It will be overriden by specific + # classes. + def render? + true + end + end +end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index 4fe2a0e1827..9f5e9b2bb57 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -9,6 +9,7 @@ module Sortable included do scope :with_order_id_desc, -> { order(self.arel_table['id'].desc) } + scope :with_order_id_asc, -> { order(self.arel_table['id'].asc) } scope :order_id_desc, -> { reorder(self.arel_table['id'].desc) } scope :order_id_asc, -> { reorder(self.arel_table['id'].asc) } scope :order_created_desc, -> { reorder(self.arel_table['created_at'].desc) } diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index 33e9e0e38fb..5a10ea7a248 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -17,13 +17,37 @@ module Subscribable def subscribed?(user, project = nil) return false unless user - if subscription = subscriptions.find_by(user: user, project: project) + if (subscription = lazy_subscription(user, project)&.itself) subscription.subscribed else subscribed_without_subscriptions?(user, project) end end + def lazy_subscription(user, project = nil) + return unless user + + # handle project and group labels as well as issuable subscriptions + subscribable_type = self.class.ancestors.include?(Label) ? 'Label' : self.class.name + BatchLoader.for(id: id, subscribable_type: subscribable_type, project_id: project&.id).batch do |items, loader| + values = items.each_with_object({ ids: Set.new, subscribable_types: Set.new, project_ids: Set.new }) do |item, result| + result[:ids] << item[:id] + result[:subscribable_types] << item[:subscribable_type] + result[:project_ids] << item[:project_id] + end + + subscriptions = Subscription.where(subscribable_id: values[:ids], subscribable_type: values[:subscribable_types], project_id: values[:project_ids], user: user) + + subscriptions.each do |subscription| + loader.call({ + id: subscription.subscribable_id, + subscribable_type: subscription.subscribable_type, + project_id: subscription.project_id + }, subscription) + end + end + end + # Override this method to define custom logic to consider a subscribable as # subscribed without an explicit subscription record. def subscribed_without_subscriptions?(user, project) @@ -41,8 +65,10 @@ module Subscribable def toggle_subscription(user, project = nil) unsubscribe_from_other_levels(user, project) + new_value = !subscribed?(user, project) + find_or_initialize_subscription(user, project) - .update(subscribed: !subscribed?(user, project)) + .update(subscribed: new_value) end def subscribe(user, project = nil) @@ -83,6 +109,8 @@ module Subscribable end def find_or_initialize_subscription(user, project) + BatchLoader::Executor.clear_current + subscriptions .find_or_initialize_by(user_id: user.id, project_id: project.try(:id)) end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index 5debfa6f834..d8867177059 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -30,7 +30,8 @@ module Taskable end def self.get_updated_tasks(old_content:, new_content:) - old_tasks, new_tasks = get_tasks(old_content), get_tasks(new_content) + old_tasks = get_tasks(old_content) + new_tasks = get_tasks(new_content) new_tasks.select.with_index do |new_task, i| old_task = old_tasks[i] diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb index 672402ee4d6..50a2613bb10 100644 --- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb +++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb @@ -42,14 +42,14 @@ module TokenAuthenticatableStrategies return insecure_strategy.get_token(instance) if migrating? encrypted_token = instance.read_attribute(encrypted_field) - token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) + token = EncryptionHelper.decrypt_token(encrypted_token) token || (insecure_strategy.get_token(instance) if optional?) end def set_token(instance, token) raise ArgumentError unless token.present? - instance[encrypted_field] = Gitlab::CryptoHelper.aes256_gcm_encrypt(token) + instance[encrypted_field] = EncryptionHelper.encrypt_token(token) instance[token_field] = token if migrating? instance[token_field] = nil if optional? token @@ -85,16 +85,9 @@ module TokenAuthenticatableStrategies end def find_by_encrypted_token(token, unscoped) - nonce = Feature.enabled?(:dynamic_nonce_creation) ? find_hashed_iv(token) : Gitlab::CryptoHelper::AES256_GCM_IV_STATIC - encrypted_value = Gitlab::CryptoHelper.aes256_gcm_encrypt(token, nonce: nonce) - - relation(unscoped).find_by(encrypted_field => encrypted_value) - end - - def find_hashed_iv(token) - token_record = TokenWithIv.find_by_plaintext_token(token) - - token_record&.iv || Gitlab::CryptoHelper::AES256_GCM_IV_STATIC + encrypted_value = EncryptionHelper.encrypt_token(token) + token_encrypted_with_static_iv = Gitlab::CryptoHelper.aes256_gcm_encrypt(token) + relation(unscoped).find_by(encrypted_field => [encrypted_value, token_encrypted_with_static_iv]) end def insecure_strategy diff --git a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb new file mode 100644 index 00000000000..25c050820d6 --- /dev/null +++ b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module TokenAuthenticatableStrategies + class EncryptionHelper + DYNAMIC_NONCE_IDENTIFIER = "|" + NONCE_SIZE = 12 + + def self.encrypt_token(plaintext_token) + Gitlab::CryptoHelper.aes256_gcm_encrypt(plaintext_token) + end + + def self.decrypt_token(token) + return unless token + + # The pattern of the token is "#{DYNAMIC_NONCE_IDENTIFIER}#{token}#{iv_of_12_characters}" + if token.start_with?(DYNAMIC_NONCE_IDENTIFIER) && token.size > NONCE_SIZE + DYNAMIC_NONCE_IDENTIFIER.size + token_to_decrypt = token[1...-NONCE_SIZE] + iv = token[-NONCE_SIZE..-1] + + Gitlab::CryptoHelper.aes256_gcm_decrypt(token_to_decrypt, nonce: iv) + else + Gitlab::CryptoHelper.aes256_gcm_decrypt(token) + end + end + end +end diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb new file mode 100644 index 00000000000..cf50305faab --- /dev/null +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module VulnerabilityFindingHelpers + extend ActiveSupport::Concern +end + +VulnerabilityFindingHelpers.prepend_if_ee('EE::VulnerabilityFindingHelpers') diff --git a/app/models/concerns/vulnerability_finding_signature_helpers.rb b/app/models/concerns/vulnerability_finding_signature_helpers.rb new file mode 100644 index 00000000000..f57e3cb0bfb --- /dev/null +++ b/app/models/concerns/vulnerability_finding_signature_helpers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module VulnerabilityFindingSignatureHelpers + extend ActiveSupport::Concern +end + +VulnerabilityFindingSignatureHelpers.prepend_if_ee('EE::VulnerabilityFindingSignatureHelpers') |