unclesp1d3r/CipherSwarm

View on GitHub
app/models/campaign.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
94%
# frozen_string_literal: true

# SPDX-FileCopyrightText:  2024 UncleSp1d3r
# SPDX-License-Identifier: MPL-2.0

# The Campaign class represents a campaign within the system, including its priority,
# state, associated attacks, and various operations that can be performed on it.
#
# The class includes:
# * Enum for priority levels of campaigns
# * Associations with hash lists and projects
# * Various scopes and validations
# * Callbacks to manage state transitions
# * Methods to pause, resume, and complete campaigns
#
# Example:
#   campaign = Campaign.find(1)
#   campaign.pause
#   campaign.resume
#
# == Schema Information
#
# Table name: campaigns
#
#  id                                                                                                     :bigint           not null, primary key
#  attacks_count                                                                                          :integer          default(0), not null
#  deleted_at                                                                                             :datetime         indexed
#  description                                                                                            :text
#  name                                                                                                   :string           not null
#  priority( -1: Deferred, 0: Routine, 1: Priority, 2: Urgent, 3: Immediate, 4: Flash, 5: Flash Override) :integer          default("routine"), not null
#  created_at                                                                                             :datetime         not null
#  updated_at                                                                                             :datetime         not null
#  hash_list_id                                                                                           :bigint           not null, indexed
#  project_id                                                                                             :bigint           not null, indexed
#
# Indexes
#
#  index_campaigns_on_deleted_at    (deleted_at)
#  index_campaigns_on_hash_list_id  (hash_list_id)
#  index_campaigns_on_project_id    (project_id)
#
# Foreign Keys
#
#  fk_rails_...  (hash_list_id => hash_lists.id) ON DELETE => cascade
#  fk_rails_...  (project_id => projects.id) ON DELETE => cascade
#
class Campaign < ApplicationRecord
  acts_as_paranoid # Soft deletes the campaign.

  # Priority enum for the campaign.
  #
  # The priority enum is used to determine the priority of the campaign.
  # When a campaign exists in the system with a priority, all campaigns of lower priority are paused until the campaign is completed.
  #
  # The priority can be one of the following values:
  # - `deferred`: -1 (best effort, runs when no other campaigns are running)
  # - `routine`: 0 (default)
  # - `priority`: 1 (Important, but not urgent)
  # - `urgent`: 2 (Important and urgent)
  # - `immediate`: 3 (Immediate, must be run as soon as possible)
  # - `flash`: 4 (Critical and should only include a small number of hashes with simple attacks)
  # - `flash_override`: 5 (Restricted to admin users only)
  #
  # @return [Hash] the priority enum hash.
  enum :priority, { deferred: -1, routine: 0, priority: 1, urgent: 2, immediate: 3, flash: 4, flash_override: 5 }

  # Associations
  belongs_to :hash_list, touch: true
  belongs_to :project, touch: true
  has_many :attacks, dependent: :destroy
  has_many :tasks, through: :attacks, dependent: :destroy

  # Validations
  validates :name, presence: true
  validates :priority, presence: true
  validates_associated :hash_list
  validates_associated :project

  # Scopes
  default_scope { in_order_of(:priority, %i[flash_override flash immediate urgent priority routine deferred]).order(:created_at) }
  scope :completed, -> { joins(:attacks).where(attacks: { state: :completed }) }
  scope :active, -> { joins(:attacks).where(attacks: { state: %i[running paused pending] }) }
  scope :in_projects, ->(ids) { where(project_id: ids) }

  # Delegations
  delegate :uncracked_count, :cracked_count, :hash_item_count, to: :hash_list

  # Broadcasts a refresh to the client when the campaign is updated unless running in test environment
  broadcasts_refreshes unless Rails.env.test?

  # Callbacks
  after_commit :check_and_pause_lower_priority_campaigns, on: %i[create update]
  after_commit :mark_attacks_complete, on: [:update]

  # Pauses all campaigns with a priority lower than the maximum priority and resumes all campaigns with the maximum priority.
  #
  # This method performs the following steps:
  # 1. Finds the maximum priority of all active campaigns in the system.
  # 2. Pauses all campaigns that have a priority lower than the maximum priority.
  # 3. Resumes all campaigns that have the maximum priority.
  #
  # @return [void]
  def self.pause_lower_priority_campaigns
    max_priority = Campaign.active.maximum(:priority)
    Campaign.where(priority: ...max_priority).find_each(&:pause)
    Campaign.where(priority: max_priority).find_each(&:resume)
  end

  # Provides a label indicating the number of incomplete attacks out of the total number of attacks.
  #
  # @return [String] the label showing incomplete and total attacks.
  def attack_count_label
    "#{attacks.incomplete.size} / #{attacks.size}"
  end

  # Checks if the campaign is completed.
  #
  # A campaign is considered completed if all the hash items in the hash list have been cracked
  # or all the attacks associated with the campaign are in the completed state.
  #
  # @return [Boolean] true if the campaign is completed, false otherwise.
  def completed?
    uncracked_items_empty = hash_list.uncracked_items.empty?
    all_attacks_completed = attacks.without_state(:completed).empty?

    uncracked_items_empty || all_attacks_completed
  end

  # Provides a label indicating the number of cracked hashes out of the total number of hash items.
  #
  # @return [String] the label showing cracked and total hashes.
  def hash_count_label
    "#{cracked_count} of #{hash_item_count}"
  end

  # Pauses all associated attacks for the campaign.
  # Iterates through each attack associated with the campaign and calls the `pause` method on it.
  #
  # @return [void]
  def pause
    attacks.active.find_each(&:pause)
  end

  # Checks if the campaign is paused.
  #
  # A campaign is considered paused if all its attacks are either paused or completed,
  # and there is at least one attack in the paused state.
  #
  # @return [Boolean] true if the campaign is paused, false otherwise.
  def paused?
    attacks.without_states(%i[paused completed]).empty? && attacks.with_state(:paused).any?
  end

  # Converts the campaign's priority to a corresponding emoji.
  #
  # @return [String] the emoji representing the campaign's priority.
  #   - "deferred" => "🕰"
  #   - "routine" => "🔄"
  #   - "priority" => "🔵"
  #   - "urgent" => "🟠"
  #   - "immediate" => "🔴"
  #   - "flash" => "🟡"
  #   - "flash_override" => "🔒"
  #   - any other value => "❓"
  def priority_to_emoji
    case priority
    when "deferred"
      "🕰"
    when "routine"
      "🔄"
    when "priority"
      "🔵"
    when "urgent"
      "🟠"
    when "immediate"
      "🔴"
    when "flash"
      "🟡"
    when "flash_override"
      "🔒"
    else
      "❓"
    end
  end

  # Resumes all attacks associated with the campaign.
  # Iterates through each attack and calls the `resume` method on it.
  #
  # @return [void]
  def resume
    attacks.with_state(:paused).find_each(&:resume)
  end

  private

  # This method checks and pauses campaigns with lower priority.
  # It calls the class method `pause_lower_priority_campaigns` on the `Campaign` model.
  #
  # @return [void]
  def check_and_pause_lower_priority_campaigns
    self.class.pause_lower_priority_campaigns
  end

  # Marks all attacks as complete if the campaign is completed.
  # This is skipped in test environments to avoid interfering with unit tests.
  #
  # @return [void]
  def mark_attacks_complete
    return if Rails.env.test?
    attacks.without_state(:completed).each(&:complete) if completed?
  end
end