rubygems/rubygems.org

View on GitHub
app/models/rubygem.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
98%
class Rubygem < ApplicationRecord
  include Patterns
  include RubygemSearchable
  include Events::Recordable

  has_many :ownerships, -> { confirmed }, dependent: :destroy, inverse_of: :rubygem
  has_many :ownerships_including_unconfirmed, dependent: :destroy, class_name: "Ownership"
  has_many :owners, through: :ownerships, source: :user
  has_many :owners_including_unconfirmed, through: :ownerships_including_unconfirmed, source: :user
  has_many :push_notifiable_owners, ->(gem) { gem.owners.push_notifiable_owners }, through: :ownerships, source: :user
  has_many :ownership_notifiable_owners, ->(gem) { gem.owners.ownership_notifiable_owners }, through: :ownerships, source: :user
  has_many :ownership_request_notifiable_owners, ->(gem) { gem.owners.ownership_request_notifiable_owners }, through: :ownerships, source: :user
  has_many :subscriptions, dependent: :destroy
  has_many :subscribers, through: :subscriptions, source: :user
  has_many :versions, dependent: :destroy, validate: false
  has_one :latest_version, -> { latest.order(:position) }, class_name: "Version", inverse_of: :rubygem
  has_many :web_hooks, dependent: :destroy
  has_one :linkset, dependent: :destroy
  has_one :gem_download, -> { where(version_id: 0) }, inverse_of: :rubygem
  has_many :ownership_calls, -> { opened }, dependent: :destroy, inverse_of: :rubygem
  has_many :ownership_requests, -> { opened }, dependent: :destroy, inverse_of: :rubygem
  has_many :audits, as: :auditable, inverse_of: :auditable
  has_many :link_verifications, as: :linkable, inverse_of: :linkable, dependent: :destroy
  has_many :oidc_rubygem_trusted_publishers, class_name: "OIDC::RubygemTrustedPublisher", inverse_of: :rubygem, dependent: :destroy
  has_many :incoming_dependencies, -> { where(versions: { indexed: true, position: 0 }) }, class_name: "Dependency", inverse_of: :rubygem
  has_many :reverse_dependencies, through: :incoming_dependencies, source: :version_rubygem
  has_many :reverse_development_dependencies, -> { merge(Dependency.development) }, through: :incoming_dependencies, source: :version_rubygem
  has_many :reverse_runtime_dependencies, -> { merge(Dependency.runtime) }, through: :incoming_dependencies, source: :version_rubygem

  has_one :most_recent_version,
    lambda {
      order(Arel.sql("case when #{quoted_table_name}.latest AND #{quoted_table_name}.platform = 'ruby' then 2 else 1 end desc"))
        .order(Arel.sql("case when #{quoted_table_name}.latest then #{quoted_table_name}.number else NULL end desc"))
        .order(id: :desc)
    },
    class_name: "Version", inverse_of: :rubygem

  validates :name,
    length: { maximum: Gemcutter::MAX_FIELD_LENGTH },
    presence: true,
    uniqueness: { case_sensitive: false },
    name_format: true,
    if: :needs_name_validation?
  validate :reserved_names_exclusion, if: :needs_name_validation?
  validate :protected_gem_typo, on: :create, unless: -> { Array(validation_context).include?(:typo_exception) }

  after_create :update_unresolved
  # TODO: Remove this once we move to GemDownload only
  after_create :create_gem_download
  before_destroy :mark_unresolved

  MFA_RECOMMENDED_THRESHOLD = 165_000_000
  MFA_REQUIRED_THRESHOLD = 180_000_000

  scope :mfa_recommended, -> { joins(:gem_download).where("gem_downloads.count > ?", MFA_RECOMMENDED_THRESHOLD) }
  scope :mfa_required, -> { joins(:gem_download).where("gem_downloads.count > ?", MFA_REQUIRED_THRESHOLD) }

  def create_gem_download
    GemDownload.create!(count: 0, rubygem_id: id, version_id: 0)
  end

  scope :with_versions, lambda {
    where(indexed: true)
  }

  scope :without_versions, lambda {
    where(indexed: false)
  }

  scope :with_one_version, lambda {
    select("rubygems.*")
      .joins(:versions)
      .group(column_names.map { |name| "rubygems.#{name}" }.join(", "))
      .having("COUNT(versions.id) = 1")
  }

  scope :name_is, lambda { |name|
    sensitive = where(name: name.strip).limit(1)
    return sensitive unless sensitive.empty?

    where("UPPER(name) = UPPER(?)", name.strip).limit(1)
  }

  scope :name_starts_with, lambda { |letter|
    where("UPPER(name) LIKE UPPER(?)", "#{letter}%")
  }

  scope :total_count, lambda {
    with_versions.count
  }

  scope :latest, lambda { |limit = 5|
    with_one_version.order(created_at: :desc).limit(limit)
  }

  scope :downloaded, lambda { |limit = 5|
    with_versions.by_downloads.limit(limit)
  }

  scope :letter, lambda { |letter|
    name_starts_with(letter).by_name.with_versions
  }

  scope :by_name, lambda {
    order(name: :asc)
  }

  scope :by_downloads, lambda {
    joins(:gem_download).order("gem_downloads.count DESC")
  }

  scope :news, lambda { |days|
    joins(:latest_version)
      .where("versions.created_at BETWEEN ? AND ?", days.ago.in_time_zone, Time.zone.now)
      .group(:id)
      .order("MAX(versions.created_at) DESC")
  }

  scope :popular, lambda { |days|
    joins(:gem_download).order("MAX(gem_downloads.count) DESC").news(days)
  }

  def self.letterize(letter)
    /\A[A-Za-z]\z/.match?(letter) ? letter.upcase : "A"
  end

  def self.current_rubygems_release
    rubygem = find_by(name: "rubygems-update")
    rubygem && rubygem.versions.release.indexed.latest.first
  end

  def all_errors(version = nil)
    [self, linkset, version].compact.map do |ar|
      ar.errors.full_messages
    end.flatten.join(", ")
  end

  has_many :public_versions, -> { by_position.published }, class_name: "Version", inverse_of: :rubygem

  def public_versions_with_extra_version(extra_version)
    versions = public_versions.limit(5).to_a
    versions << extra_version
    versions.uniq.sort_by(&:position)
  end

  # NB: this intentionally does not default the platform to ruby.
  # Without platform, finds the most recent version by (position, created_at) ignoring platform.
  def find_public_version(number, platform = nil)
    if platform
      public_versions.find_by(number:, platform:)
    else
      public_versions.find_by(number:)
    end
  end

  def public_version_payload(number, platform = nil)
    version = find_public_version(number, platform)
    payload(version).merge!(version.as_json) if version
  end

  def find_version!(number:, platform:)
    platform = platform.presence || "ruby"
    versions.find_by!(number: number, platform: platform)
  end

  def find_version_by_slug!(slug)
    full_name = "#{name}-#{slug}"
    versions.find_by!(full_name: full_name)
  end

  def hosted?
    versions.count.nonzero?
  end

  def unowned?
    ownerships.blank?
  end

  def indexed_versions?
    versions.indexed.count > 0
  end

  def owned_by?(user)
    return false unless user
    ownerships.exists?(user_id: user.id)
  end

  def unconfirmed_ownerships
    ownerships_including_unconfirmed.unconfirmed
  end

  def unconfirmed_ownership?(user)
    unconfirmed_ownerships.exists?(user: user)
  end

  def to_s
    most_recent_version&.to_title || name
  end

  def downloads
    gem_download&.count || 0
  end

  def links(version = most_recent_version)
    Links.new(self, version)
  end

  def payload(version = most_recent_version, protocol = Gemcutter::PROTOCOL, host_with_port = Gemcutter::HOST)
    versioned_links = links(version)
    deps = version.dependencies.to_a.select(&:rubygem)
    {
      "name"               => name,
      "downloads"          => downloads,
      "version"            => version.number,
      "version_created_at" => version.created_at,
      "version_downloads"  => version.downloads_count,
      "platform"           => version.platform,
      "authors"            => version.authors,
      "info"               => version.info,
      "licenses"           => version.licenses,
      "metadata"           => version.metadata,
      "yanked"             => version.yanked?,
      "sha"                => version.sha256_hex,
      "project_uri"        => "#{protocol}://#{host_with_port}/gems/#{name}",
      "gem_uri"            => "#{protocol}://#{host_with_port}/gems/#{version.gem_file_name}",
      "homepage_uri"       => versioned_links.homepage_uri,
      "wiki_uri"           => versioned_links.wiki_uri,
      "documentation_uri"  => versioned_links.documentation_uri,
      "mailing_list_uri"   => versioned_links.mailing_list_uri,
      "source_code_uri"    => versioned_links.source_code_uri,
      "bug_tracker_uri"    => versioned_links.bug_tracker_uri,
      "changelog_uri"      => versioned_links.changelog_uri,
      "funding_uri"        => versioned_links.funding_uri,
      "dependencies"       => {
        "development" => deps.select { |r| r.scope == "development" },
        "runtime"     => deps.select { |r| r.scope == "runtime" }
      }
    }
  end

  delegate :as_json, :to_yaml, to: :payload

  def to_xml(options = {})
    payload.to_xml(options.merge(root: "rubygem"))
  end

  def slug
    name.remove(/[^#{Patterns::ALLOWED_CHARACTERS}]/o)
  end

  def pushable?
    new_record? || (versions.indexed.none? && not_protected?)
  end

  def create_ownership(user)
    Ownership.create_confirmed(self, user, user) if unowned?
  end

  def ownership_call
    ownership_calls.find_by(status: "opened")
  end

  def ownership_requestable?
    abandoned_release_threshold   = 1.year.ago
    abandoned_downloads_threshold = 10_000
    ownership_calls.any? || (latest_version && latest_version.created_at < abandoned_release_threshold && downloads < abandoned_downloads_threshold)
  end

  def update_versions!(version, spec)
    version.update_attributes_from_gem_specification!(spec)
  end

  def update_dependencies!(version, spec)
    spec.dependencies.each do |dependency|
      version.dependencies.create!(gem_dependency: dependency)
    rescue ActiveRecord::RecordInvalid => e
      # ActiveRecord can't chain a nested error here, so we have to add and reraise
      e.record.errors.errors.each do |error|
        errors.import(error, attribute: "dependency.#{error.attribute}")
      end
      raise
    end
  end

  def update_linkset!(spec)
    self.linkset ||= Linkset.new
    self.linkset.update_attributes_from_gem_specification!(spec)
    self.linkset.save!
  end

  def update_attributes_from_gem_specification!(version, spec)
    Rubygem.transaction do
      save!
      update_versions! version, spec
      update_dependencies! version, spec
      update_linkset! spec if version.reload.latest?
    end
  end

  delegate :count, to: :versions, prefix: true

  def yanked_versions?
    versions.yanked.exists?
  end

  def reorder_versions
    bulk_reorder_versions

    versions_of_platforms = versions
      .release
      .indexed
      .group_by(&:platform)

    Version.default_scoped.where(id: versions_of_platforms.values.map! { |v| v.max.id }).update_all(latest: true)
  end

  def refresh_indexed!
    update!(indexed: versions.indexed.any?)
  end

  def disown
    ownerships_including_unconfirmed.find_each(&:delete)
    ownerships_including_unconfirmed.clear

    oidc_rubygem_trusted_publishers.find_each(&:delete)
    oidc_rubygem_trusted_publishers.clear
  end

  def find_or_initialize_version_from_spec(spec)
    version = versions.find_or_initialize_by(number: spec.version.to_s,
                                             platform: spec.original_platform.to_s,
                                             gem_platform: spec.platform.to_s)
    version.rubygem = self
    version
  end

  # returns days left before the reserved namespace will be released
  # 100 + 1 days are added so that last_protected_day / 1.day = 1
  def protected_days
    days = (updated_at - 101.days.ago).to_i / 1.day
    days.positive? ? days : 0
  end

  def release_reserved_namespace!
    update_attribute(:updated_at, 101.days.ago)
  end

  def metadata_mfa_required?
    latest_version&.rubygems_metadata_mfa_required?
  end

  def mfa_requirement_satisfied_for?(user)
    user.mfa_enabled? || !metadata_mfa_required?
  end

  def version_manifest(number, platform = nil)
    VersionManifest.new(gem: name, number: number, platform: platform)
  end

  def file_content(fingerprint)
    RubygemContents.new(gem: name).get(fingerprint)
  end

  def yank_versions!(version_id: nil)
    security_user = User.security_user
    versions_to_yank = version_id ? versions.where(id: version_id) : versions

    versions_to_yank.find_each do |version|
      security_user.deletions.create!(version: version) unless version.yanked?
    end
  end

  def linkable_verification_uri
    URI.join("https://rubygems.org/gems/", name)
  end

  private

  # a gem namespace is not protected if it is
  # updated(yanked) in more than 100 days or it is created in last 30 days
  def not_protected?
    updated_at < 100.days.ago || created_at > 30.days.ago
  end

  def needs_name_validation?
    new_record? || name_changed?
  end

  def reserved_names_exclusion
    return unless GemNameReservation.reserved?(name)
    errors.add :name, "'#{name}' is a reserved gem name."
  end

  def protected_gem_typo
    gem_typo = GemTypo.new(name)

    return unless gem_typo.protected_typo?
    errors.add :name, "'#{name}' is too similar to an existing gem named '#{gem_typo.protected_gem}'"
  end

  def update_unresolved
    Dependency.where(unresolved_name: name).find_each do |dependency|
      dependency.update_resolved(self)
    end
  end

  def mark_unresolved
    Dependency.mark_unresolved_for(self)
  end

  def bulk_reorder_versions
    numbers = reload.versions.pluck(:number).uniq.sort_by { |n| Gem::Version.new(n) }.reverse

    ids = []
    positions = []
    versions.each do |version|
      ids << version.id
      positions << numbers.index(version.number)
    end

    update_query = ["update versions set position = positions_data.position, latest = false
      from (select unnest(array[?]) as id, unnest(array[?]) as position) as positions_data
      where versions.id = positions_data.id", ids, positions]

    sanitized_query = ActiveRecord::Base.send(:sanitize_sql_array, update_query)
    ActiveRecord::Base.connection.execute(sanitized_query)
  end
end