zammad/zammad

View on GitHub
app/models/setting.rb

Summary

Maintainability
A
25 mins
Test Coverage
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

# Class variables are used here as performance optimization.
# Technically it is not thread-safe, but it never caused issues.
# rubocop:disable Style/ClassVars
class Setting < ApplicationModel
  store         :options
  store         :state_current
  store         :state_initial
  store         :preferences
  before_validation :state_check
  before_create :set_initial
  after_save    :reset_class_cache_key
  after_commit  :reset_other_caches, :broadcast_frontend, :check_refresh

  validates_with Setting::Validator

  attr_accessor :state

  @@current         = {}
  @@raw             = {}
  @@query_cache_key = nil
  @@last_changed_at = nil
  @@lookup_at       = nil
  @@lookup_timeout  = if ENV['ZAMMAD_SETTING_TTL']
                        ENV['ZAMMAD_SETTING_TTL'].to_i.seconds
                      else
                        15.seconds
                      end

=begin

set config setting

  Setting.set('some_config_name', some_value)

=end

  def self.set(name, value)
    setting = Setting.find_by(name: name)
    if !setting
      raise "Can't find config setting '#{name}'"
    end

    setting.state_current = { value: value }
    setting.save!
    logger.info "Setting.set('#{name}', #{value.inspect})"
    true
  end

=begin

get config setting

  value = Setting.get('some_config_name')

=end

  def self.get(name)
    load
    @@current[name].deep_dup # prevents accidental modification of settings in console
  end

=begin

reset config setting to default

  Setting.reset('some_config_name')

  Setting.reset('some_config_name', force) # true|false - force it false per default

=end

  def self.reset(name, force = false)
    setting = Setting.find_by(name: name)
    if !setting
      raise "Can't find config setting '#{name}'"
    end
    return true if !force && setting.state_current == setting.state_initial

    setting.state_current = setting.state_initial
    setting.save!
    logger.info "Setting.reset('#{name}', #{setting.state_current.inspect})"
    true
  end

=begin

reload config settings

  Setting.reload

=end

  def self.reload
    @@last_changed_at = nil
    load(true)
  end

  # check if cache is still valid
  def self.cache_valid?
    # Check if last last lookup was recent enough
    if @@lookup_at && @@lookup_at > @@lookup_timeout.ago
      # logger.debug "Setting.cache_valid?: cache_id has been set within last #{@@lookup_timeout} seconds"
      return true
    end

    if @@query_cache_key && Setting.reorder(:id).cache_key_with_version == @@query_cache_key
      @@lookup_at = Time.current

      return true
    end

    false
  end

  private

  # load values and cache them
  def self.load(force = false)

    # check if config is already generated
    return false if !force && @@current.present? && cache_valid?

    # read all or only changed since last read
    latest = Setting.maximum(:updated_at)

    base_query = Setting.reorder(:id)

    settings_query = if @@last_changed_at && @@current.present?
                       base_query.where(updated_at: @@last_changed_at..)
                     else
                       base_query
                     end

    settings = settings_query.pluck(:name, :state_current)

    @@last_changed_at = [Time.current, latest].min if latest

    if settings.present?
      settings.each do |setting|
        @@raw[setting[0]] = setting[1]['value']
      end

      @@raw.each do |key, value|
        @@current[key] = interpolate_value value
      end
    end

    @@query_cache_key = base_query.cache_key_with_version
    @@lookup_at = Time.current

    true
  end
  private_class_method :load

  def self.interpolate_value(input)
    return input if !input.is_a? String

    input.gsub(%r{\#\{config\.(.+?)\}}) do
      @@raw[$1].to_s
    end
  end
  private_class_method :interpolate_value

  # set initial value in state_initial
  def set_initial
    self.state_initial = state_current
  end

  def reset_class_cache_key
    @@lookup_at = nil
    @@query_cache_key = nil
  end

  # Resets caches related to the setting in question.
  def reset_other_caches
    return if preferences[:cache].blank?

    Array(preferences[:cache]).each do |key|
      Rails.cache.delete(key)
    end
  end

  # Convert state into hash to be able to store it as store.
  def state_check
    return if state.nil? # allow false value
    return if state.try(:key?, :value)

    self.state_current = { value: state }
  end

  # Notify clients about config changes.
  def broadcast_frontend
    return if !frontend

    # Some setting values use interpolation to reference other settings.
    # This is applied in `Setting.get`, thus direct reading of the value should be avoided.
    value = self.class.get(name)

    Sessions.broadcast(
      {
        event: 'config_update',
        data:  { name: name, value: value }
      },
      preferences[:authentication] ? 'authenticated' : 'public'
    )

    Gql::Subscriptions::ConfigUpdates.trigger(self)
  end

  # NB: Force users to reload on SAML credentials config changes
  #   This is needed because the setting is not frontend related,
  #   so we can't rely on 'config_update_local' mechanism to kick in
  # https://github.com/zammad/zammad/issues/4263
  def check_refresh
    return if ['auth_saml_credentials'].exclude?(name)

    AppVersion.set(true, AppVersion::MSG_CONFIG_CHANGED)
  end
end
# rubocop:enable Style/ClassVars