app/models/setting.rb
# 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