diff options
Diffstat (limited to 'app/models/concerns')
24 files changed, 477 insertions, 107 deletions
diff --git a/app/models/concerns/blocks_json_serialization.rb b/app/models/concerns/blocks_json_serialization.rb deleted file mode 100644 index 18c00532d78..00000000000 --- a/app/models/concerns/blocks_json_serialization.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -# Overrides `as_json` and `to_json` to raise an exception when called in order -# to prevent accidentally exposing attributes -# -# Not that would ever happen... but just in case. -module BlocksJsonSerialization - extend ActiveSupport::Concern - - JsonSerializationError = Class.new(StandardError) - - def to_json(*) - raise JsonSerializationError, - "JSON serialization has been disabled on #{self.class.name}" - end - - alias_method :as_json, :to_json -end diff --git a/app/models/concerns/blocks_unsafe_serialization.rb b/app/models/concerns/blocks_unsafe_serialization.rb new file mode 100644 index 00000000000..72adbe70f15 --- /dev/null +++ b/app/models/concerns/blocks_unsafe_serialization.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Overrides `#serializable_hash` to raise an exception when called without the `only` option +# in order to prevent accidentally exposing attributes. +# +# An `unsafe: true` option can also be passed in to bypass this check. +# +# `#serializable_hash` is used by ActiveModel serializers like `ActiveModel::Serializers::JSON` +# which overrides `#as_json` and `#to_json`. +# +module BlocksUnsafeSerialization + extend ActiveSupport::Concern + extend ::Gitlab::Utils::Override + + UnsafeSerializationError = Class.new(StandardError) + + override :serializable_hash + def serializable_hash(options = nil) + return super if allow_serialization?(options) + + raise UnsafeSerializationError, + "Serialization has been disabled on #{self.class.name}" + end + + private + + def allow_serialization?(options = nil) + return false unless options + + !!(options[:only] || options[:unsafe]) + end +end diff --git a/app/models/concerns/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb index 927d6ccb28f..efc65e55e40 100644 --- a/app/models/concerns/bulk_member_access_load.rb +++ b/app/models/concerns/bulk_member_access_load.rb @@ -1,61 +1,19 @@ # frozen_string_literal: true -# Returns and caches in thread max member access for a resource -# module BulkMemberAccessLoad extend ActiveSupport::Concern included do - # Determine the maximum access level for a group of resources in bulk. - # - # Returns a Hash mapping resource ID -> maximum access level. - def max_member_access_for_resource_ids(resource_klass, resource_ids, &block) - raise 'Block is mandatory' unless block_given? - - memoization_index = self.id - memoization_class = self.class - - resource_ids = resource_ids.uniq - memo_id = "#{memoization_class}:#{memoization_index}" - access = load_access_hash(resource_klass, memo_id) - - # Look up only the IDs we need - resource_ids -= access.keys - - return access if resource_ids.empty? - - resource_access = yield(resource_ids) - - access.merge!(resource_access) - - missing_resource_ids = resource_ids - resource_access.keys - - missing_resource_ids.each do |resource_id| - access[resource_id] = Gitlab::Access::NO_ACCESS - end - - access - end - def merge_value_to_request_store(resource_klass, resource_id, value) - max_member_access_for_resource_ids(resource_klass, [resource_id]) do + Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(resource_klass), + resource_ids: [resource_id], + default_value: Gitlab::Access::NO_ACCESS) 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, memo_id) - return {} unless Gitlab::SafeRequestStore.active? - - key = max_member_access_for_resource_key(resource_klass, memo_id) - Gitlab::SafeRequestStore[key] ||= {} - - Gitlab::SafeRequestStore[key] + def max_member_access_for_resource_key(klass) + "max_member_access_for_#{klass.name.underscore.pluralize}:#{self.class}:#{self.id}" end end end diff --git a/app/models/concerns/ci/has_deployment_name.rb b/app/models/concerns/ci/has_deployment_name.rb new file mode 100644 index 00000000000..fe288134872 --- /dev/null +++ b/app/models/concerns/ci/has_deployment_name.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Ci + module HasDeploymentName + extend ActiveSupport::Concern + + def count_user_deployment? + Feature.enabled?(:job_deployment_count) && deployment_name? + end + + def deployment_name? + self.class::DEPLOYMENT_NAMES.any? { |n| name.downcase.include?(n) } + end + end +end diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index ccaccec3b6b..313c767e59f 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -7,12 +7,16 @@ module Ci DEFAULT_STATUS = 'created' BLOCKED_STATUS = %w[manual scheduled].freeze AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze + # TODO: replace STARTED_STATUSES with data from BUILD_STARTED_RUNNING_STATUSES in https://gitlab.com/gitlab-org/gitlab/-/issues/273378 + # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82149#note_865508501 + BUILD_STARTED_RUNNING_STATUSES = %w[running success failed].freeze STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze + CANCELABLE_STATUSES = %w[running waiting_for_resource preparing pending created scheduled].freeze 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 @@ -85,7 +89,7 @@ module Ci scope :waiting_for_resource_or_upcoming, -> { with_status(:created, :scheduled, :waiting_for_resource) } scope :cancelable, -> do - where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled]) + where(status: klass::CANCELABLE_STATUSES) end scope :without_statuses, -> (names) do diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index 4bfeba338d2..b41b1ba6008 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -102,9 +102,7 @@ module CounterAttribute run_after_commit_or_now do if counter_attribute_enabled?(attribute) - redis_state do |redis| - redis.incrby(counter_key(attribute), increment) - end + increment_counter(attribute, increment) FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute) else @@ -115,6 +113,28 @@ module CounterAttribute true end + def increment_counter(attribute, increment) + if counter_attribute_enabled?(attribute) + redis_state do |redis| + redis.incrby(counter_key(attribute), increment) + end + end + end + + def clear_counter!(attribute) + if counter_attribute_enabled?(attribute) + redis_state { |redis| redis.del(counter_key(attribute)) } + end + end + + def get_counter_value(attribute) + if counter_attribute_enabled?(attribute) + redis_state do |redis| + redis.get(counter_key(attribute)).to_i + end + end + end + def counter_key(attribute) "project:{#{project_id}}:counters:#{self.class}:#{id}:#{attribute}" end diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index b6245e29746..d9c622f247a 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -3,6 +3,8 @@ module DeploymentPlatform # rubocop:disable Gitlab/ModuleWithInstanceVariables def deployment_platform(environment: nil) + return if Feature.disabled?(:certificate_based_clusters, default_enabled: :yaml, type: :ops) + @deployment_platform ||= {} @deployment_platform[environment] ||= find_deployment_platform(environment) diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index 28ee54afaa9..ad070090dd5 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -46,4 +46,17 @@ module HasUserType def internal? ghost? || (bot? && !project_bot?) end + + def redacted_name(viewing_user) + return self.name unless self.project_bot? + + return self.name if self.groups.any? && viewing_user&.can?(:read_group, self.groups.first) + + return self.name if viewing_user&.can?(:read_project, self.projects.first) + + # If the requester does not have permission to read the project bot name, + # the API returns an arbitrary string. UI changes will be addressed in a follow up issue: + # https://gitlab.com/gitlab-org/gitlab/-/issues/346058 + '****' + end end diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb new file mode 100644 index 00000000000..b1def38d019 --- /dev/null +++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Integrations + module HasIssueTrackerFields + extend ActiveSupport::Concern + + included do + field :project_url, + required: true, + storage: :data_fields, + title: -> { _('Project URL') }, + help: -> { s_('IssueTracker|The URL to the project in the external issue tracker.') } + + field :issues_url, + required: true, + storage: :data_fields, + title: -> { s_('IssueTracker|Issue URL') }, + help: -> do + format s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.'), + colon_id: '<code>:id</code>'.html_safe + end + + field :new_issue_url, + required: true, + storage: :data_fields, + title: -> { s_('IssueTracker|New issue URL') }, + help: -> { s_('IssueTracker|The URL to create an issue in the external issue tracker.') } + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 0138c0ad20f..1eb30e88f16 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -74,6 +74,7 @@ module Issuable end has_many :note_authors, -> { distinct }, through: :notes, source: :author + has_many :user_note_authors, -> { distinct.where("notes.system = false") }, through: :notes, source: :author has_many :label_links, as: :target, inverse_of: :target has_many :labels, through: :label_links @@ -464,37 +465,54 @@ module Issuable false end - def to_hook_data(user, old_associations: {}) - changes = previous_changes + def hook_association_changes(old_associations) + changes = {} - if old_associations - old_labels = old_associations.fetch(:labels, labels) - old_assignees = old_associations.fetch(:assignees, assignees) - old_severity = old_associations.fetch(:severity, severity) + old_labels = old_associations.fetch(:labels, labels) + old_assignees = old_associations.fetch(:assignees, assignees) + old_severity = old_associations.fetch(:severity, severity) - if old_labels != labels - changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)] - end + if old_labels != labels + changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)] + end - if old_assignees != assignees - changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] - end + if old_assignees != assignees + changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] + end + + if supports_severity? && old_severity != severity + changes[:severity] = [old_severity, severity] + end + + if supports_escalation? && escalation_status + current_escalation_status = escalation_status.status_name + old_escalation_status = old_associations.fetch(:escalation_status, current_escalation_status) - if supports_severity? && old_severity != severity - changes[:severity] = [old_severity, severity] + if old_escalation_status != current_escalation_status + changes[:escalation_status] = [old_escalation_status, current_escalation_status] end + end - if self.respond_to?(:total_time_spent) - old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent) - old_time_change = old_associations.fetch(:time_change, time_change) + if self.respond_to?(:total_time_spent) + old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent) + old_time_change = old_associations.fetch(:time_change, time_change) - if old_total_time_spent != total_time_spent - changes[:total_time_spent] = [old_total_time_spent, total_time_spent] - changes[:time_change] = [old_time_change, time_change] - end + if old_total_time_spent != total_time_spent + changes[:total_time_spent] = [old_total_time_spent, total_time_spent] + changes[:time_change] = [old_time_change, time_change] end end + changes + end + + def to_hook_data(user, old_associations: {}) + changes = previous_changes + + if old_associations.present? + changes.merge!(hook_association_changes(old_associations)) + end + Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes) end diff --git a/app/models/concerns/issuable_link.rb b/app/models/concerns/issuable_link.rb new file mode 100644 index 00000000000..3e14507bc70 --- /dev/null +++ b/app/models/concerns/issuable_link.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# == IssuableLink concern +# +# Contains common functionality shared between related Issues and related Epics +# +# Used by IssueLink, Epic::RelatedEpicLink +# +module IssuableLink + extend ActiveSupport::Concern + + TYPE_RELATES_TO = 'relates_to' + TYPE_BLOCKS = 'blocks' ## EE-only. Kept here to be used on link_type enum. + + class_methods do + def inverse_link_type(type) + type + end + + def issuable_type + raise NotImplementedError + end + end + + included do + validates :source, presence: true + validates :target, presence: true + validates :source, uniqueness: { scope: :target_id, message: 'is already related' } + validate :check_self_relation + validate :check_opposite_relation + + enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 } + + private + + def check_self_relation + return unless source && target + + if source == target + errors.add(:source, 'cannot be related to itself') + end + end + + def check_opposite_relation + return unless source && target + + if self.class.base_class.find_by(source: target, target: source) + errors.add(:source, "is already related to this #{self.class.issuable_type}") + end + end + end +end + +IssuableLink.prepend_mod_with('IssuableLink') +IssuableLink::ClassMethods.prepend_mod_with('IssuableLink::ClassMethods') diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb index 1c24032dbbb..5cbc937e465 100644 --- a/app/models/concerns/issue_resource_event.rb +++ b/app/models/concerns/issue_resource_event.rb @@ -8,6 +8,10 @@ module IssueResourceEvent scope :by_issue, ->(issue) { where(issue_id: issue.id) } - scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) } + scope :by_created_at_earlier_or_equal_to, ->(time) { where('created_at <= ?', time) } + scope :by_issue_ids, ->(issue_ids) do + table = self.klass.arel_table + where(table[:issue_id].in(issue_ids)) + end end end diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb index 5859f43a70c..893d06b4da8 100644 --- a/app/models/concerns/merge_request_reviewer_state.rb +++ b/app/models/concerns/merge_request_reviewer_state.rb @@ -14,6 +14,14 @@ module MergeRequestReviewerState presence: true, inclusion: { in: self.states.keys } + belongs_to :updated_state_by, class_name: 'User', foreign_key: :updated_state_by_user_id + after_initialize :set_state, unless: :persisted? + + def attention_requested_by + return unless attention_requested? + + updated_state_by + end end end diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb new file mode 100644 index 00000000000..68357c44300 --- /dev/null +++ b/app/models/concerns/pg_full_text_searchable.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +# This module adds PG full-text search capabilities to a model. +# A `search_data` association with a `search_vector` column is required. +# +# Declare the fields that will be part of the search vector with their +# corresponding weights. Possible values for weight are A, B, C, or D. +# For example: +# +# include PgFullTextSearchable +# pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }] +# +# This module sets up an after_commit hook that updates the search data +# when the searchable columns are changed. You will need to implement the +# `#persist_pg_full_text_search_vector` method that does the actual insert or update. +# +# This also adds a `pg_full_text_search` scope so you can do: +# +# Model.pg_full_text_search("some search term") + +module PgFullTextSearchable + extend ActiveSupport::Concern + + LONG_WORDS_REGEX = %r([A-Za-z0-9+/@]{50,}).freeze + TSVECTOR_MAX_LENGTH = 1.megabyte.freeze + TEXT_SEARCH_DICTIONARY = 'english' + + def update_search_data! + tsvector_sql_nodes = self.class.pg_full_text_searchable_columns.map do |column, weight| + tsvector_arel_node(column, weight)&.to_sql + end + + persist_pg_full_text_search_vector(Arel.sql(tsvector_sql_nodes.compact.join(' || '))) + rescue ActiveRecord::StatementInvalid => e + raise unless e.cause.is_a?(PG::ProgramLimitExceeded) && e.message.include?('string is too long for tsvector') + + Gitlab::AppJsonLogger.error( + message: 'Error updating search data: string is too long for tsvector', + class: self.class.name, + model_id: self.id + ) + end + + private + + def persist_pg_full_text_search_vector(search_vector) + raise NotImplementedError + end + + def tsvector_arel_node(column, weight) + return if self[column].blank? + + column_text = self[column].gsub(LONG_WORDS_REGEX, ' ') + column_text = column_text[0..(TSVECTOR_MAX_LENGTH - 1)] + column_text = ActiveSupport::Inflector.transliterate(column_text) + + Arel::Nodes::NamedFunction.new( + 'setweight', + [ + Arel::Nodes::NamedFunction.new( + 'to_tsvector', + [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(column_text)] + ), + Arel::Nodes.build_quoted(weight) + ] + ) + end + + included do + cattr_reader :pg_full_text_searchable_columns do + {} + end + end + + class_methods do + def pg_full_text_searchable(columns:) + raise 'Full text search columns already defined!' if pg_full_text_searchable_columns.present? + + columns.each do |column| + pg_full_text_searchable_columns[column[:name]] = column[:weight] + end + + # We update this outside the transaction because this could raise an error if the resulting tsvector + # is too long. When that happens, we still persist the create / update but the model will not have a + # search data record. This is fine in most cases because this is a very rare occurrence and only happens + # with strings that are most likely unsearchable anyway. + # + # We also do not want to use a subtransaction here due to: https://gitlab.com/groups/gitlab-org/-/epics/6540 + after_save_commit do + next unless pg_full_text_searchable_columns.keys.any? { |f| saved_changes.has_key?(f) } + + update_search_data! + end + end + + def pg_full_text_search(search_term) + search_data_table = reflect_on_association(:search_data).klass.arel_table + + joins(:search_data).where( + Arel::Nodes::InfixOperation.new( + '@@', + search_data_table[:search_vector], + Arel::Nodes::NamedFunction.new( + 'websearch_to_tsquery', + [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(search_term)] + ) + ) + ) + end + end +end diff --git a/app/models/concerns/runners_token_prefixable.rb b/app/models/concerns/runners_token_prefixable.rb index 1aea874337e..99bbbece7c7 100644 --- a/app/models/concerns/runners_token_prefixable.rb +++ b/app/models/concerns/runners_token_prefixable.rb @@ -1,14 +1,8 @@ # frozen_string_literal: true module RunnersTokenPrefixable - extend ActiveSupport::Concern - # Prefix for runners_token which can be used to invalidate existing tokens. # The value chosen here is GR (for Gitlab Runner) combined with the rotation # date (20220225) decimal to hex encoded. RUNNERS_TOKEN_PREFIX = 'GR1348941' - - def runners_token_prefix - RUNNERS_TOKEN_PREFIX - end end diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb index 49342e30db6..5a7e16eb2c4 100644 --- a/app/models/concerns/select_for_project_authorization.rb +++ b/app/models/concerns/select_for_project_authorization.rb @@ -8,8 +8,10 @@ module SelectForProjectAuthorization select("projects.id AS project_id", "members.access_level") end - def select_as_maintainer_for_project_authorization - select(["projects.id AS project_id", "#{Gitlab::Access::MAINTAINER} AS access_level"]) + # workaround until we migrate Project#owners to have membership with + # OWNER access level + def select_project_owner_for_project_authorization + select(["projects.id AS project_id", "#{Gitlab::Access::OWNER} AS access_level"]) end end end diff --git a/app/models/concerns/sensitive_serializable_hash.rb b/app/models/concerns/sensitive_serializable_hash.rb new file mode 100644 index 00000000000..725ec60e9b6 --- /dev/null +++ b/app/models/concerns/sensitive_serializable_hash.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module SensitiveSerializableHash + extend ActiveSupport::Concern + + included do + class_attribute :attributes_exempt_from_serializable_hash, default: [] + end + + class_methods do + def prevent_from_serialization(*keys) + self.attributes_exempt_from_serializable_hash ||= [] + self.attributes_exempt_from_serializable_hash.concat keys + end + end + + # Override serializable_hash to exclude sensitive attributes by default + # + # In general, prefer NOT to use serializable_hash / to_json / as_json in favor + # of serializers / entities instead which has an allowlist of attributes + def serializable_hash(options = nil) + return super unless prevent_sensitive_fields_from_serializable_hash? + return super if options && options[:unsafe_serialization_hash] + + options = options.try(:dup) || {} + options[:except] = Array(options[:except]).dup + + options[:except].concat self.class.attributes_exempt_from_serializable_hash + + if self.class.respond_to?(:encrypted_attributes) + options[:except].concat self.class.encrypted_attributes.keys + + # Per https://github.com/attr-encrypted/attr_encrypted/blob/a96693e9a2a25f4f910bf915e29b0f364f277032/lib/attr_encrypted.rb#L413 + options[:except].concat self.class.encrypted_attributes.values.map { |v| v[:attribute] } + options[:except].concat self.class.encrypted_attributes.values.map { |v| "#{v[:attribute]}_iv" } + end + + super(options) + end + + private + + def prevent_sensitive_fields_from_serializable_hash? + Feature.enabled?(:prevent_sensitive_fields_from_serializable_hash, default_enabled: :yaml) + end +end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 4901cd832ff..b475eb79aa3 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -12,7 +12,7 @@ module Spammable included do has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - attr_accessor :spam + attr_writer :spam attr_accessor :needs_recaptcha attr_accessor :spam_log @@ -29,6 +29,10 @@ module Spammable delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true end + def spam + !!@spam # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + def submittable_as_spam_by?(current_user) current_user && current_user.admin? && submittable_as_spam? end @@ -74,8 +78,9 @@ module Spammable end def recaptcha_error! - self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\ - "Please, change the content or solve the reCAPTCHA to proceed.") + self.errors.add(:base, _("Your %{spammable_entity_type} has been recognized as spam. "\ + "Please, change the content or solve the reCAPTCHA to proceed.") \ + % { spammable_entity_type: spammable_entity_type }) end def unrecoverable_spam_error! diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 943ef3fa59f..d53594eb5af 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -44,7 +44,6 @@ module Timebox validates :group, presence: true, unless: :project validates :project, presence: true, unless: :group - validates :title, presence: true validate :timebox_type_check validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index f44ad8ebe90..d91ec161b84 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -8,6 +8,10 @@ module TokenAuthenticatable @encrypted_token_authenticatable_fields ||= [] end + def token_authenticatable_fields + @token_authenticatable_fields ||= [] + end + private def add_authentication_token_field(token_field, options = {}) @@ -23,6 +27,8 @@ module TokenAuthenticatable strategy = TokenAuthenticatableStrategies::Base .fabricate(self, token_field, options) + prevent_from_serialization(*strategy.token_fields) if respond_to?(:prevent_from_serialization) + if options.fetch(:unique, true) define_singleton_method("find_by_#{token_field}") do |token| strategy.find_token_authenticatable(token) @@ -82,9 +88,5 @@ module TokenAuthenticatable @token_authenticatable_module ||= const_set(:TokenAuthenticatable, Module.new).tap(&method(:include)) end - - def token_authenticatable_fields - @token_authenticatable_fields ||= [] - end end end diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index 2cec4ab460e..2b677f37c89 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -23,6 +23,14 @@ module TokenAuthenticatableStrategies raise NotImplementedError end + def token_fields + result = [token_field] + + result << @expires_at_field if expirable? + + result + end + # Default implementation returns the token as-is def format_token(instance, token) instance.send("format_#{@token_field}", token) # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/models/concerns/token_authenticatable_strategies/digest.rb b/app/models/concerns/token_authenticatable_strategies/digest.rb index 9926662ed66..5c94f25949f 100644 --- a/app/models/concerns/token_authenticatable_strategies/digest.rb +++ b/app/models/concerns/token_authenticatable_strategies/digest.rb @@ -2,6 +2,10 @@ module TokenAuthenticatableStrategies class Digest < Base + def token_fields + super + [token_field_name] + end + def find_token_authenticatable(token, unscoped = false) return unless token diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb index e957d09fbc6..1db88c27181 100644 --- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb +++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb @@ -2,6 +2,10 @@ module TokenAuthenticatableStrategies class Encrypted < Base + def token_fields + super + [encrypted_field] + end + def find_token_authenticatable(token, unscoped = false) return if token.blank? diff --git a/app/models/concerns/update_namespace_statistics.rb b/app/models/concerns/update_namespace_statistics.rb new file mode 100644 index 00000000000..26d6fc10228 --- /dev/null +++ b/app/models/concerns/update_namespace_statistics.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# This module provides helpers for updating `NamespaceStatistics` with `after_save` and +# `after_destroy` hooks. +# +# Models including this module must respond to and return a `namespace` +# +# Example: +# +# class DependencyProxy::Manifest +# include UpdateNamespaceStatistics +# +# belongs_to :group +# alias_attribute :namespace, :group +# +# update_namespace_statistics namespace_statistics_name: :dependency_proxy_size +# end +module UpdateNamespaceStatistics + extend ActiveSupport::Concern + include AfterCommitQueue + + class_methods do + attr_reader :namespace_statistics_name, :statistic_attribute + + # Configure the model to update `namespace_statistics_name` on NamespaceStatistics, + # when `statistic_attribute` changes + # + # - namespace_statistics_name: A column of `NamespaceStatistics` to update + # - statistic_attribute: An attribute of the current model, default to `size` + def update_namespace_statistics(namespace_statistics_name:, statistic_attribute: :size) + @namespace_statistics_name = namespace_statistics_name + @statistic_attribute = statistic_attribute + + after_save(:schedule_namespace_statistics_refresh, if: :update_namespace_statistics?) + after_destroy(:schedule_namespace_statistics_refresh) + end + + private :update_namespace_statistics + end + + included do + private + + def update_namespace_statistics? + saved_change_to_attribute?(self.class.statistic_attribute) + end + + def schedule_namespace_statistics_refresh + run_after_commit do + Groups::UpdateStatisticsWorker.perform_async(namespace.id, [self.class.namespace_statistics_name]) + end + end + end +end |