discourse/lib/site_setting_extension.rb
Régis Hanol 5f0b41013f
UX: Make depends_behavior: hidden site settings react without a reload (#39403)
When a site setting has `depends_behavior: hidden`, it is shown or
hidden based on whether its parent setting is enabled. Until now that
decision was made on the server, so toggling the parent in the admin UI
required saving and reloading the page for dependent settings to appear
or disappear.

The server no longer excludes hidden-by-dependency settings from the
`/admin/site_settings` payload; it just serializes `depends_behavior`
alongside `depends_on` and lets the client decide. A new
`site-setting-store` service tracks every loaded setting by name and
exposes `isSettingVisible(setting, activeFilter)`, the single source of
truth for the following rule:

- On first load, children of a disabled parent are not rendered.
- Enabling the parent (checkbox or reset-to-default) reveals them
reactively; a `revealed` latch on the model keeps them visible for the
rest of the session.
- Disabling the parent again after reveal leaves the children mounted
and switches them to a disabled state (no re-hide jank).
- Searching by exact setting name still surfaces an unrevealed child (in
a disabled state), so deep links like
`?filter=topic_voting_tl0_vote_limit` work even when the parent is off.

Visibility is enforced at the iteration layer (category controller and
`admin-filtered-site-settings`) using the shared helper, so the filter
recomputes whenever `revealed` flips on a tracked `SiteSetting`.

Ref - t/181881

---------

Co-authored-by: Martin Brennan <martin@discourse.org>
2026-04-23 10:45:36 +10:00

1393 lines
44 KiB
Ruby

# frozen_string_literal: true
module SiteSettingExtension
include SiteSettings::DeprecatedSettings
include HasSanitizableFields
SiteSettingChangeResult = Struct.new(:previous_value, :new_value)
InvalidSettingAccess = Class.new(StandardError)
delegate :description, :keywords, :placeholder, :humanized_name, to: SiteSettings::LabelFormatter
# support default_locale being set via global settings
# this also adds support for testing the extension and global settings
# for site locale
def self.extended(klass)
if GlobalSetting.respond_to?(:default_locale) && GlobalSetting.default_locale.present?
# protected
klass.send :setup_shadowed_methods, :default_locale, GlobalSetting.default_locale
end
end
# we need a default here to support defaults per locale
def default_locale=(val)
val = val.to_s
raise Discourse::InvalidParameters.new(:value) unless LocaleSiteSetting.valid_value?(val)
if val != self.default_locale
add_override!(:default_locale, val)
refresh!
Discourse.request_refresh!
end
end
def default_locale?
true
end
# set up some sort of default so we can look stuff up
def default_locale
# note optimised cause this is called a lot so avoiding .presence which
# adds 2 method calls
locale = current[:default_locale]
if locale && locale.present?
locale
else
SiteSettings::DefaultsProvider::DEFAULT_LOCALE
end
end
def has_setting?(v)
defaults.has_setting?(v)
end
def supported_types
SiteSettings::TypeSupervisor.supported_types
end
def types
SiteSettings::TypeSupervisor.types
end
def listen_for_changes=(val)
@listen_for_changes = val
end
def provider=(val)
@provider = val
refresh!
end
def provider
@provider ||= SiteSettings::DbProvider.new(SiteSetting)
end
def mutex
@mutex ||= Mutex.new
end
# Represents the current values of all site settings, incorporating
# the database values and merging them with shadowed settings and
# default values.
def current
@containers ||= {}
@containers[provider.current_site] ||= {}
end
# Represents settings that actually have a value saved in the
# database. We currently even store the default value in the
# DB if an admin saves a different value then changes back
# to the default.
def modified
@modified ||= {}
@modified[provider.current_site] ||= {}
end
# Represents a map of theme IDs to all theme site settings
# and values.
def theme_site_settings
@theme_site_settings ||= {}
@theme_site_settings[provider.current_site] ||= {}
end
#
def humanized_names(name)
@humanized_names ||= {}
@humanized_names[name] ||= humanized_name(name)
end
# Used for upcoming changes settings to determine which specific
# groups have the change turned on for them. This is done separately
# from group-based site settings because upcoming change settings
# are always booleans.
def site_setting_group_ids
@site_setting_group_ids ||= {}
@site_setting_group_ids[provider.current_site] ||= {}
end
def defaults
@defaults ||= SiteSettings::DefaultsProvider.new(self)
end
def type_supervisor
@type_supervisor ||= SiteSettings::TypeSupervisor.new(defaults)
end
def categories
@categories ||= {}
end
def themeable
@themeable ||= {}
end
def areas
@areas ||= {}
end
def mandatory_values
@mandatory_values ||= {}
end
def disallowed_groups
@disallowed_groups ||= {}
end
def shadowed_settings
@shadowed_settings ||= Set.new
end
def requires_confirmation_settings
@requires_confirmation_settings ||= {}
end
# Valid upcoming change metadata looks like this
# in site_settings.yml:
#
# setting_name:
# default: false
# client: true
# hidden: true
# upcoming_change:
# status: "alpha" (see UpcomingChanges.statuses.keys)
# impact: "feature,staff" (feature|other for the first part, staff|admins|moderators|all_members|developers for the second part)
# learn_more_url: ""
def upcoming_change_metadata
@upcoming_change_metadata ||= {}
end
# Has a pointer from a site setting name to the upcoming change name
# and overriden default value. Looks like this in site_settings.yml:
#
# setting_name:
# upcoming_change_default_override:
# upcoming_change: upcoming_change_name
# new_default: new_default_value
#
# The upcoming change name is used to determine if the override is active.
# The new default value is used to override the default value for the site setting.
def upcoming_change_default_overrides
@upcoming_change_default_overrides ||= {}
end
def hidden_settings_provider
@hidden_settings_provider ||= SiteSettings::HiddenProvider.new
end
def hidden_settings
hidden_settings_provider.all
end
def refresh_settings
@refresh_settings ||= [:default_locale]
end
def client_settings
@client_settings ||= [:default_locale]
end
def previews
@previews ||= {}
end
def secret_settings
@secret_settings ||= Set.new
end
def plugins
@plugins ||= {}
end
def load_settings(file, plugin: nil)
SiteSettings::YamlLoader
.new(file)
.load do |category, name, default, opts|
setting(name, default, opts.merge(category: category, plugin: plugin))
end
end
def settings_hash
result = {}
defaults.all.keys.each do |s|
next if themeable[s]
result[s] = if deprecated_settings.include?(s.to_s)
public_send(s, warn: false).to_s
else
public_send(s).to_s
end
end
result
end
def deprecated_settings
@deprecated_settings ||= SiteSettings::DeprecatedSettings::SETTINGS.map(&:first).to_set
end
def deprecated_setting_alias(setting_name)
SiteSettings::DeprecatedSettings::SETTINGS
.find { |setting| setting.second.to_s == setting_name.to_s }
&.first
end
def theme_site_settings_json(theme_id)
key = SiteSettingExtension.theme_site_settings_cache_key(theme_id)
json =
Discourse
.cache
.fetch(key, expires_in: 30.minutes) { theme_site_settings_json_uncached(theme_id) }
Rails.logger.error("Nil theme_site_settings_json from the cache for '#{key}'") if json.nil?
json || ""
rescue => e
Rails.logger.error("Error while retrieving theme_site_settings_json: #{e.message}")
""
end
def setting_metadata_hash(setting)
{
setting:,
default: SiteSetting.defaults[setting],
description: SiteSetting.description(setting),
humanized_name: humanized_names(setting),
}.merge(type_supervisor.type_hash(setting))
end
def themeable_site_settings
themeable.select { |_, value| value }.keys.sort
end
def upcoming_change_site_settings
upcoming_change_metadata.keys.sort
end
def client_settings_json
key = SiteSettingExtension.client_settings_cache_key
json = Discourse.cache.fetch(key, expires_in: 30.minutes) { client_settings_json_uncached }
Rails.logger.error("Nil client_settings_json from the cache for '#{key}'") if json.nil?
json || ""
rescue => e
Rails.logger.error("Error while retrieving client_settings_json: #{e.message}")
""
end
def client_settings_json_uncached(return_defaults: false)
uncached_json =
@client_settings.filter_map do |name|
# Themeable site settings require a theme ID, which we do not always
# have when loading client site settings. They are excluded here,
# to get them use theme_site_settings_json(:theme_id)
next if themeable[name]
value =
if return_defaults
SiteSetting.defaults[name]
elsif deprecated_settings.include?(name.to_s)
public_send(name, warn: false)
else
public_send(name)
end
type = type_supervisor.get_type(name)
if type == :upload
value = value.to_s
elsif type == :uploaded_image_list && value.present?
value = value.map(&:to_s).join("|")
end
[name, value]
end
MultiJson.dump(Hash[uncached_json])
rescue => err
# If something goes wrong here we really need to be aware of it in tests.
raise err if Rails.env.test?
Rails.logger.error("Error while generating client_settings_json_uncached: #{err.message}")
raise
end
def theme_site_settings_json_uncached(theme_id)
begin
# There are a few legit scenarios where the current
# theme ID may be blank, such as safe mode. In this
# case it will be better to return default site setting
# values rather than to cause random/undefined behaviour
# in the UI.
if theme_id.blank?
MultiJson.dump(ThemeSiteSetting.generate_defaults_map)
else
MultiJson.dump(theme_site_settings[theme_id])
end
rescue => err
# If something goes wrong here we really need to be aware of it in tests.
raise err if Rails.env.test?
Rails.logger.error(
"Error while generating theme_site_settings_json_uncached for theme ID #{theme_id}: #{err.message}",
)
nil
end
end
def all_settings(
include_hidden: false,
include_locale_setting: true,
only_overridden: false,
basic_attributes: false,
only_upcoming_changes: false,
filter_categories: nil,
filter_plugin: nil,
filter_names: nil,
filter_allowed_hidden: nil,
filter_area: nil
)
locale_setting_hash = {
setting: :default_locale,
humanized_name: humanized_names(:default_locale),
default: SiteSettings::DefaultsProvider::DEFAULT_LOCALE,
category: "required",
primary_area: "localization",
description: description(:default_locale),
type: SiteSetting.types[SiteSetting.types[:locale_enum]],
preview: nil,
value: self.default_locale,
valid_values: LocaleSiteSetting.values,
translate_names: LocaleSiteSetting.translate_names?,
}
include_locale_setting = false if filter_categories.present? || filter_plugin.present?
# There is a hidden_site_settings modifier in HiddenSettingsProvider
# that can cause perf overhead, so instead of calling hidden_settings
# in a loop, we call it once here.
current_hidden_settings = hidden_settings
defaults
.all(default_locale)
.reject do |setting_name, _|
plugins[setting_name] && !Discourse.plugins_by_name[plugins[setting_name]].configurable?
end
.select do |setting_name, _|
is_hidden = current_hidden_settings.include?(setting_name)
next true if !is_hidden
next false if !include_hidden
next true if filter_allowed_hidden.nil?
filter_allowed_hidden.include?(setting_name)
end
.select do |setting_name, _|
if filter_categories && filter_categories.any?
filter_categories.include?(categories[setting_name])
else
true
end
end
.select do |setting_name, _|
if filter_area
Array.wrap(areas[setting_name]).include?(filter_area)
else
true
end
end
.select do |setting_name, _|
if filter_plugin
plugins[setting_name] == filter_plugin
else
true
end
end
.select do |setting_name, _|
if only_upcoming_changes
UpcomingChanges.exists?(setting_name) &&
UpcomingChanges.change_status(setting_name) != :conceptual
else
true
end
end
.map do |s, v|
type_hash = type_supervisor.type_hash(s)
# NOTE: This can be overridden by an upcoming change, see .refresh!() for the
# logic here, and .setting() for where we load the upcoming change default overrides
# from yaml.
default = defaults.get(s, default_locale).to_s
# Has the old default, new default, and upcoming change name. Will only
# have these values if there is an override and the upcoming change is enabled.
upcoming_change_default_override_metadata = defaults.upcoming_change_override_metadata(s)
if themeable[s]
value = public_send(s, { theme_id: SiteSetting.default_theme_id })
else
value = public_send(s)
end
value = value.map(&:to_s).join("|") if type_hash[:type].to_s == "uploaded_image_list"
if type_hash[:type].to_s == "upload" && default.to_i < Upload::SEEDED_ID_THRESHOLD
default = default_uploads[default.to_i]
end
# For upload type settings, include the upload metadata
# public_send returns the Upload object directly (not a URL string)
upload_metadata = nil
if type_hash[:type].to_s == "upload" && value.is_a?(Upload)
upload_metadata = {
original_filename: value.original_filename,
human_filesize: value.human_filesize,
width: value.width,
height: value.height,
}
end
# For uploads nested in objects type, hydrate upload IDs to URLs
if type_hash[:type].to_s == "objects" && type_hash[:schema]
parsed_value = JSON.parse(value)
value = hydrate_uploads_in_objects(parsed_value, type_hash[:schema])
end
opts = {
setting: s,
humanized_name: humanized_names(s),
description: description(s),
keywords: keywords(s),
category: categories[s],
primary_area: areas[s]&.first,
}
if !basic_attributes
# For objects type, serialize as JSON
serialized_value =
if type_hash[:type].to_s == "objects"
value.to_json
else
value.to_s
end
opts_data = {
default:,
value: serialized_value,
preview: previews[s],
secret: secret_settings.include?(s),
placeholder: placeholder(s),
mandatory_values: mandatory_values[s],
disallowed_groups: disallowed_groups[s],
requires_confirmation: requires_confirmation_settings[s],
upcoming_change: only_upcoming_changes ? upcoming_change_metadata[s] : nil,
themeable: themeable[s],
}
if depends_on = type_supervisor.dependencies[s]
opts_data[:depends_on] = depends_on
opts_data[:depends_on_humanized_names] = depends_on.map { |dep| humanized_names(dep) }
opts_data[:depends_behavior] = type_supervisor.dependencies.behaviors[s]
end
if upcoming_change_default_override_metadata
opts_data[
:upcoming_change_default_override_metadata
] = upcoming_change_default_override_metadata
end
opts.merge!(opts_data.merge(type_hash))
end
opts[:plugin] = plugins[s] if plugins[s]
opts[:upload] = upload_metadata if upload_metadata
DiscoursePluginRegistry.apply_modifier(:site_setting_result, opts)
end
.select do |setting|
if only_overridden
setting[:value] != setting[:default]
else
true
end
end
.select do |setting|
if filter_names
filter_names.include?(setting[:setting].to_s)
else
true
end
end
.unshift(include_locale_setting && !only_overridden ? locale_setting_hash : nil)
.compact
end
def self.client_settings_cache_key
# NOTE: we use the git version in the key to ensure
# that we don't end up caching the incorrect version
# in cases where we are cycling unicorns
"client_settings_json_#{Discourse.git_version}"
end
def self.theme_site_settings_cache_key(theme_id)
theme_id = "notheme" if theme_id.blank?
# NOTE: we use the git version in the key to ensure
# that we don't end up caching the incorrect version
# in cases where we are cycling unicorns
"theme_site_settings_json_#{theme_id}__#{Discourse.git_version}"
end
# Merges the provider values of site settings (whether it be from the DB or wherever)
# and theme site settings with the default values of those settings, also taking into
# account shadowed site settings and upcoming change behaviour.
def refresh!(refresh_site_settings: true, refresh_theme_site_settings: true)
mutex.synchronize do
ensure_listen_for_changes
if refresh_site_settings
new_current_hash = fetch_setting_hash_from_provider
# We have to do this because we need to reject any settings that are the
# same as the default value before we merge the defaults view onto the current
# hash.
new_modified = new_current_hash.dup
refresh_site_setting_group_ids!
defaults_view =
defaults.all(new_current_hash[:default_locale], include_upcoming_changes_overrides: false)
# If the "modified" value is the same as the setting default,
# this doesn't really count and is more of a quirk on how we
# store site settings. In this case, we should not consider this
# setting to be modified.
#
# There are two exceptions, both tied to upcoming changes:
#
# 1. Upcoming change settings themselves — admins need to be able
# to manually opt out of an auto-promoted change even when the
# DB value matches the YAML default.
#
# 2. Target settings of an upcoming_change_default_override — the
# "clean" defaults_view here is computed without overrides
# applied, so it still reports the original YAML default.
#
# When an override is active, the *effective* default is different,
# and the admin's explicit DB value (even if it equals the
# original default) is a deliberate opt-out that must survive
# refresh!. Without this exception, the override loop below
# would re-clobber the admin's choice.
new_modified.reject! do |name, val|
if UpcomingChanges.exists?(name) || upcoming_change_default_overrides.key?(name)
false
else
val.to_s == defaults_view[name].to_s
end
end
# Merge in the default values for the current locale, since these are
# precomputed and cached. This ensures that locale-specific and default
# settings are always available in current_hash.
new_current_hash = defaults_view.merge!(new_current_hash)
shadowed_settings.each { |ss| new_current_hash[ss] = GlobalSetting.public_send(ss) }
changes, deletions = diff_hash(new_current_hash, current)
changes.each { |name, val| current[name] = val }
deletions.each { |name, _| current[name] = defaults_view[name] }
modified.clear
modified.merge!(new_modified)
uploads.clear
upcoming_change_default_overrides.each do |setting_name, override|
if UpcomingChanges.enabled?(override[:upcoming_change])
defaults.activate_upcoming_change_override(override[:upcoming_change])
if !setting_modified_from_default?(setting_name)
current[setting_name] = override[:new_default]
end
else
defaults.deactivate_upcoming_change_override(override[:upcoming_change])
end
end
end
refresh_theme_site_settings! if refresh_theme_site_settings
clear_cache!(
expire_theme_site_setting_cache:
ThemeSiteSetting.can_access_db? && refresh_theme_site_settings,
)
end
end
# Whether an admin or something else has changed the site setting from its
# default value, writing a new value to the database.
#
# Note that if the DB value is the same as the default value, this will return false.
def setting_modified_from_default?(setting_name)
modified.key?(setting_name)
end
def refresh_site_setting_group_ids!
new_site_setting_group_ids_hash = SiteSettingGroup.generate_setting_group_map
site_setting_group_id_changes, site_setting_group_id_deletions =
diff_hash(new_site_setting_group_ids_hash, site_setting_group_ids)
site_setting_group_id_changes.each { |name, val| site_setting_group_ids[name] = val }
site_setting_group_id_deletions.each { |name, _| site_setting_group_ids.delete(name) }
end
def refresh_theme_site_settings!
new_theme_site_settings = ThemeSiteSetting.generate_theme_map
theme_site_setting_changes, theme_site_setting_deletions =
diff_hash(new_theme_site_settings, theme_site_settings)
theme_site_setting_changes.each do |theme_id, settings|
theme_site_settings[theme_id] ||= {}
theme_site_settings[theme_id].merge!(settings)
end
theme_site_setting_deletions.each { |theme_id, _| theme_site_settings.delete(theme_id) }
end
SITE_SETTINGS_CHANNEL = "/site_settings"
CLIENT_SETTINGS_CHANNEL = "/client_settings"
def ensure_listen_for_changes
return if @listen_for_changes == false
unless @subscribed
MessageBus.subscribe(SITE_SETTINGS_CHANNEL) do |message|
process_message(message) if message.data["process"] != process_id
end
@subscribed = true
end
end
def process_message(message)
begin
MessageBus.on_connect.call(message.site_id)
refresh!
ensure
MessageBus.on_disconnect.call(message.site_id)
end
end
def process_id
@process_id ||= SecureRandom.uuid
end
def after_fork
@process_id = nil
ensure_listen_for_changes
end
def raise_invalid_setting_access(setting_name)
raise SiteSettingExtension::InvalidSettingAccess.new(
"#{setting_name} cannot be changed like this because it is a themeable setting. Instead, use the ThemeSiteSettingManager service to manage themeable site settings.",
)
end
##
# Removes an override for a setting, reverting it to the default value.
# This method is only called manually usually, more often than not
# setting overrides are removed in database migrations.
#
# Here we also handle notifying the UI of the change in the case
# of theme site settings and clearing relevant caches, and triggering
# server-side events for changed settings.
#
# Themeable site settings cannot be removed this way, they must be
# changed via the ThemeSiteSetting model.
#
# @param name [Symbol] the name of the setting
# @param val [Any] the value to set
def remove_override!(name)
raise_invalid_setting_access(name) if themeable[name]
old_val = current[name]
provider.destroy(name)
current[name] = defaults.get(name, default_locale)
modified.delete(name)
if UpcomingChanges.exists?(name)
apply_upcoming_change_default_overrides_for!(
name,
upcoming_change_enabled: UpcomingChanges.enabled?(name),
)
end
return if current[name] == old_val
clear_uploads_cache(name)
clear_cache!
if old_val != current[name]
DiscourseEvent.trigger(:site_setting_changed, name, old_val, current[name])
end
end
##
# Adds an override, which is to say a database entry for the setting
# instead of using the default.
#
# The `set`, `set_and_log`, and `setting_name=` methods all call
# this method. Its opposite is remove_override!.
#
# Here we also handle notifying the UI of the change in the case
# of theme site settings and clearing relevant caches, and triggering
# server-side events for changed settings.
#
# Themeable site settings cannot be changed this way, they must be
# changed via the ThemeSiteSetting model.
#
# @param name [Symbol] the name of the setting
# @param val [Any] the value to set
#
# @example
# SiteSetting.add_override!(:site_description, "My awesome forum")
#
# @raise [SiteSettingExtension::InvalidSettingAccess] if the setting is themeable
# (themeable settings must be changed via ThemeSiteSetting model)
#
# @note When called from the Rails console, this method automatically logs the change
# with the system user.
#
# @see remove_override! for removing an override and reverting to default value
def add_override!(name, val)
raise_invalid_setting_access(name) if themeable[name]
old_val = current[name]
val, type = type_supervisor.to_db_value(name, val)
sanitize_override = val.is_a?(String) && client_settings.include?(name)
sanitized_val = sanitize_override ? sanitize_field(val) : val
if mandatory_values[name.to_sym]
sanitized_val =
(mandatory_values[name.to_sym].split("|") | sanitized_val.to_s.split("|")).join("|")
end
if disallowed_groups[name.to_sym]
disallowed = disallowed_groups[name.to_sym].split("|")
sanitized_val = sanitized_val.to_s.split("|").reject { |v| disallowed.include?(v) }.join("|")
end
provider.save(name, sanitized_val, type)
current[name] = type_supervisor.to_rb_value(name, sanitized_val)
modified[name] = current[name]
if UpcomingChanges.exists?(name)
apply_upcoming_change_default_overrides_for!(name, upcoming_change_enabled: current[name])
end
return if current[name] == old_val
clear_uploads_cache(name)
notify_clients!(name) if client_settings.include?(name)
clear_cache!
if defined?(Rails::Console)
details = "Updated via Rails console"
details = DiscoursePluginRegistry.apply_modifier(:site_setting_log_details, details)
log(name, val, old_val, Discourse.system_user, details)
end
DiscourseEvent.trigger(:site_setting_changed, name, old_val, current[name])
end
# Updates a theme-specific site setting value in memory and notifies observers.
#
# This method is used to change site settings that are marked as "themeable",
# which means they can have different values per theme. Unlike `add_override!`,
# the database isn't touched here.
#
# @param theme_id [Integer] The ID of the theme to update the setting for
# @param name [String, Symbol] The name of the site setting to change
# @param val [Object] The new "ruby" value for the site setting
#
# @example
# SiteSetting.change_themeable_site_setting(5, "enable_welcome_banner", false)
#
# @note Unlike regular site settings which use add_override!, themeable settings
# should be changed via the ThemeSiteSettingManager service.
#
# @see ThemeSiteSettingManager service for the higher-level implementation that handles
# database persistence and logging.
def change_themeable_site_setting(theme_id, name, val)
name = name.to_sym
theme_site_settings[theme_id] ||= {}
old_val = theme_site_settings[theme_id][name]
theme_site_settings[theme_id][name] = val
notify_clients!(name, theme_id: theme_id) if client_settings.include?(name)
notify_changed!
clear_cache!(expire_theme_site_setting_cache: true)
DiscourseEvent.trigger(:theme_site_setting_changed, name, old_val, val)
end
# NOTE: This will not refresh the current process' site settings, only other processes
# that are listening for changes. We check if the current process_id is != to the message
# process ID before refreshing in process_message.
#
# If you need to refresh the current process as well, call refresh! (or another
# method to update caches) directly.
def notify_changed!
MessageBus.publish(SITE_SETTINGS_CHANNEL, process: process_id)
end
def notify_clients!(name, scoped_to = nil)
# Group-based upcoming changes cannot update clients, because we need
# to know a user to determine if the change is active for them.
#
# This is the same limitation that group-based site settings have --
# we cannot determine the full groups of a user on the client side,
# so we only use these in the CurrentUserSerializer to send down an
# attribute. Users will get the new value on page reload.
#
# If the upcoming change is not group-based then it's safe to just
# use the underlying site setting value.
if upcoming_change_site_settings.include?(name.to_sym) && UpcomingChanges.has_groups?(name)
return
end
MessageBus.publish(
CLIENT_SETTINGS_CHANNEL,
name: name,
# default_locale is a special case, it is not themeable and we define
# a custom getter for it, so we can just use the normal getter
value:
name.to_s == "default_locale" ? self.public_send(name) : self.public_send(name, scoped_to),
scoped_to: scoped_to,
)
end
def requires_refresh?(name)
refresh_settings.include?(name.to_sym)
end
HOSTNAME_SETTINGS = %w[
disabled_image_download_domains
blocked_onebox_domains
exclude_rel_nofollow_domains
blocked_email_domains
allowed_email_domains
allowed_spam_host_domains
]
def filter_value(name, value)
if HOSTNAME_SETTINGS.include?(name)
value
.split("|")
.map do |url|
url.strip!
get_hostname(url)
end
.compact
.uniq
.join("|")
else
value
end
end
def set(name, value, options = nil)
if has_setting?(name)
raise_invalid_setting_access(name) if themeable[name]
value = filter_value(name, value)
if options
self.public_send("#{name}=", value, options)
else
self.public_send("#{name}=", value)
end
Discourse.request_refresh! if requires_refresh?(name)
else
raise Discourse::InvalidParameters.new(
"Either no setting named '#{name}' exists or value provided is invalid",
)
end
end
def set_and_log(name, value, user = Discourse.system_user, detailed_message = nil)
if has_setting?(name)
raise_invalid_setting_access(name) if themeable[name]
prev_value = public_send(name)
return if prev_value == value
set(name, value)
# Logging via the rails console is already handled in add_override!
log(name, value, prev_value, user, detailed_message) unless defined?(Rails::Console)
SiteSettingChangeResult.new(prev_value, public_send(name))
else
raise Discourse::InvalidParameters.new(
I18n.t("errors.site_settings.invalid_site_setting", name: name),
)
end
end
def log(name, value, prev_value, user = Discourse.system_user, detailed_message = nil)
return if hidden_settings.include?(name.to_sym)
value = prev_value = "[FILTERED]" if secret_settings.include?(name.to_sym)
StaffActionLogger.new(user).log_site_setting_change(
name,
prev_value,
value,
{ details: detailed_message }.compact_blank,
)
end
def get(name, scoped_to = nil)
if has_setting?(name)
if themeable[name]
if scoped_to.nil? || !scoped_to.key?(:theme_id) || scoped_to[:theme_id].nil?
raise SiteSettingExtension::InvalidSettingAccess.new(
"#{name} requires a theme_id because it is themeable",
)
else
self.public_send(name, scoped_to)
end
else
self.public_send(name)
end
else
raise Discourse::InvalidParameters.new(
I18n.t("errors.site_settings.invalid_site_setting", name: name),
)
end
end
if defined?(Rails::Console)
# Convenience method for debugging site setting issues
# Returns a hash with information about a specific setting
def info(name)
{
resolved_value: get(name),
default_value: defaults[name],
global_override: GlobalSetting.respond_to?(name) ? GlobalSetting.public_send(name) : nil,
database_value: provider.find(name)&.value,
refresh?: refresh_settings.include?(name),
client?: client_settings.include?(name),
secret?: secret_settings.include?(name),
}
end
end
def valid_areas
Set.new(SiteSetting::VALID_AREAS | DiscoursePluginRegistry.site_setting_areas.to_a)
end
protected
def clear_cache!(expire_theme_site_setting_cache: false)
Discourse.cache.delete(SiteSettingExtension.client_settings_cache_key)
Theme.expire_site_setting_cache! if expire_theme_site_setting_cache
Site.clear_anon_cache!
end
def diff_hash(new_hash, old)
changes = []
deletions = []
new_hash.each do |name, value|
changes << [name, value] if !old.has_key?(name) || old[name] != value
end
old.each { |name, value| deletions << [name, value] unless new_hash.has_key?(name) }
[changes, deletions]
end
def setup_shadowed_methods(name, value)
clean_name = name.to_s.sub("?", "").to_sym
define_singleton_method clean_name do |scoped_to = nil|
value
end
define_singleton_method "#{clean_name}?" do |scoped_to = nil|
value
end
define_singleton_method "#{clean_name}=" do |val|
if value != val
Rails.logger.warn(
"An attempt was to change #{clean_name} SiteSetting to #{val} however it is shadowed so this will be ignored!",
)
end
nil
end
end
def setup_methods(name)
clean_name = name.to_s.sub("?", "").to_sym
if type_supervisor.get_type(name) == :uploaded_image_list
define_singleton_method clean_name do |scoped_to = nil|
if themeable[clean_name]
if scoped_to.nil? || !scoped_to.key?(:theme_id) || scoped_to[:theme_id].nil?
raise SiteSettingExtension::InvalidSettingAccess.new(
"#{clean_name} requires a theme_id because it is themeable",
)
end
end
uploads_list = uploads[name]
return uploads_list if uploads_list
if (value = current[name]).nil?
refresh!
value = current[name]
end
return [] if value.empty?
value = value.split("|").map(&:to_i)
uploads_list = Upload.where(id: value).to_a
uploads[name] = uploads_list if uploads_list
end
elsif type_supervisor.get_type(name) == :upload
define_singleton_method clean_name do |scoped_to = nil|
if themeable[clean_name]
if scoped_to.nil? || !scoped_to.key?(:theme_id) || scoped_to[:theme_id].nil?
raise SiteSettingExtension::InvalidSettingAccess.new(
"#{clean_name} requires a theme_id because it is themeable",
)
end
end
upload = uploads[name]
return upload if upload
if (value = current[name]).nil?
refresh!
value = current[name]
end
value = value.to_i
if value != Upload::SEEDED_ID_THRESHOLD
upload = Upload.find_by(id: value)
uploads[name] = upload if upload
end
end
else
define_singleton_method clean_name do |scoped_to = nil|
if themeable[clean_name]
if scoped_to.nil? || !scoped_to.key?(:theme_id) || scoped_to[:theme_id].nil?
raise SiteSettingExtension::InvalidSettingAccess.new(
"#{clean_name} requires a theme_id because it is themeable",
)
end
# If the theme hasn't overridden any theme site settings (or changed defaults)
# then we will just fall back further down bellow to the current site setting value.
settings_overridden_for_theme = theme_site_settings[scoped_to[:theme_id]]
if settings_overridden_for_theme && settings_overridden_for_theme.key?(clean_name)
return settings_overridden_for_theme[clean_name]
end
end
if plugins[name]
plugin = Discourse.plugins_by_name[plugins[name]]
return false if !plugin.configurable? && plugin.enabled_site_setting == name
end
refresh! if current[name].nil?
value =
if UpcomingChanges.exists?(name)
UpcomingChanges.enabled?(name)
else
# Will either be the value the admin changed the site setting to
# in the DB/provider, or the default value, which may also be affected
# by an upcoming change.
current[name]
end
if mandatory_values[name]
return (mandatory_values[name].split("|") | value.to_s.split("|")).join("|")
end
value
end
end
# Any group_list or category_list setting will have a getter defined with _map
# on the end, e.g. personal_message_enabled_groups_map, to avoid having to
# manually split and convert to integer for these settings.
if %i[group_list category_list].include?(type_supervisor.get_type(name))
define_singleton_method("#{clean_name}_map") do
self.public_send(clean_name).to_s.split("|").map(&:to_i)
end
end
# Upcoming change settings have a supplemental array of group IDs that are used to opt-in
# certain groups to the change early. We use the data from SiteSettingGroup to define
# a getter with _groups_map on the end, e.g. allow_unlimited_uploads_groups_map,
# to avoid having to manually split and convert to integer for these settings.
if UpcomingChanges.exists?(name) && type_supervisor.get_type(name) == :bool
define_singleton_method("#{clean_name}_groups_map") do
site_setting_group_ids[name].presence || []
end
end
# Same logic as above for other list type settings, with the caveat that normal
# list settings are not necessarily integers, so we just want to handle the splitting.
if %i[list emoji_list tag_list].include?(type_supervisor.get_type(name))
list_type = type_supervisor.get_list_type(name)
if %w[simple compact].include?(list_type) || list_type.nil?
define_singleton_method("#{clean_name}_map") do |scoped_to = nil|
self.public_send(clean_name, scoped_to).to_s.split("|")
end
end
end
define_singleton_method "#{clean_name}?" do |scoped_to = nil|
self.public_send(clean_name, scoped_to)
end
define_singleton_method "#{clean_name}=" do |val|
raise_invalid_setting_access(clean_name) if themeable[clean_name]
add_override!(name, val)
end
end
def get_hostname(url)
host =
begin
URI.parse(url)&.host
rescue URI::Error
nil
end
host ||=
begin
URI.parse("http://#{url}")&.host
rescue URI::Error
nil
end
host.presence || url
end
private
# Gets all of the site setting values from the provider (DbProvider (most
# common), TestProvider, LocalProcessProvider etc.) and returns a hash of
# setting names to values based on the type of the setting.
#
# @return [Hash] a hash of setting names to values based on the type of the setting
#
# @example
# {
# enable_mobile_theme: true,
# topics_per_period_in_top_page: 50,
# title: "My awesome forum"
# }
def fetch_setting_hash_from_provider
Hash[
*(
provider
.all
.map do |setting|
[
setting.name.to_sym,
type_supervisor.to_rb_value(setting.name, setting.value, setting.data_type),
]
end
.flatten
)
]
end
def apply_upcoming_change_default_overrides_for!(name, upcoming_change_enabled:)
if upcoming_change_enabled
defaults.activate_upcoming_change_override(name)
else
defaults.deactivate_upcoming_change_override(name)
end
# Similar to what we do in refresh!, but we are being more reactive here.
# If the admin enables/disables an upcoming change we need to make sure
# the overridden default is used, otherwise we fall back to the old default,
# but only if the admin hasn't modified the target setting themselves.
upcoming_change_default_overrides.each do |setting_name, override|
next if override[:upcoming_change] != name.to_sym
next if setting_modified_from_default?(setting_name)
if upcoming_change_enabled
current[setting_name] = override[:new_default]
else
current[setting_name] = defaults.get(setting_name, default_locale)
end
end
end
def setting(name_arg, default = nil, opts = {})
name = name_arg.to_sym
if name == :default_locale
raise Discourse::InvalidParameters.new(
"Other settings depend on default locale, you can not configure it like this",
)
end
shadowed_val = nil
mutex.synchronize do
defaults.load_setting(name, default, opts.delete(:locale_default))
mandatory_values[name] = opts[:mandatory_values] if opts[:mandatory_values]
disallowed_groups[name] = opts[:disallowed_groups] if opts[:disallowed_groups]
requires_confirmation_settings[name] = (
if SiteSettings::TypeSupervisor::REQUIRES_CONFIRMATION_TYPES.values.include?(
opts[:requires_confirmation],
)
opts[:requires_confirmation]
end
)
if opts[:upcoming_change]
upcoming_change_metadata[name] ||= {}
impact_type, impact_role = opts[:upcoming_change][:impact].split(",")
upcoming_change_metadata[name].merge!(
**opts[:upcoming_change].except(:impact),
impact_type: impact_type,
impact_role: impact_role,
status: opts[:upcoming_change][:status].to_sym,
)
end
upcoming_change_default_override = opts[:upcoming_change_default_override]
if upcoming_change_default_override.present?
upcoming_change_default_overrides[name] = {
upcoming_change: upcoming_change_default_override[:upcoming_change].to_sym,
new_default: upcoming_change_default_override[:new_default],
}
end
categories[name] = opts[:category] || :uncategorized
themeable[name] = opts[:themeable] ? true : false
if opts[:area]
split_areas = opts[:area].split("|")
if split_areas.any? { |area| !SiteSetting.valid_areas.include?(area) }
raise Discourse::InvalidParameters.new(
"One of the areas in #{opts[:area]} for setting #{name} is invalid, valid areas are: #{SiteSetting.valid_areas.join(", ")}",
)
end
areas[name] = split_areas
end
hidden_settings_provider.add_hidden(name) if opts[:hidden]
if GlobalSetting.respond_to?(name)
val = GlobalSetting.public_send(name)
unless val.nil? || (val == "")
shadowed_val = val
hidden_settings_provider.add_hidden(name)
shadowed_settings << name
end
end
refresh_settings << name if opts[:refresh]
client_settings << name.to_sym if opts[:client]
previews[name] = opts[:preview] if opts[:preview]
secret_settings << name if opts[:secret]
plugins[name] = opts[:plugin] if opts[:plugin]
choices_opts = opts.extract!(*SiteSettings::TypeSupervisor::CONSUMED_OPTS)
type_supervisor.load_setting(name, choices_opts)
if !shadowed_val.nil?
setup_shadowed_methods(name, shadowed_val)
else
setup_methods(name)
end
end
end
def default_uploads
@default_uploads ||= {}
@default_uploads[provider.current_site] ||= begin
Upload.where("id < ?", Upload::SEEDED_ID_THRESHOLD).pluck(:id, :url).to_h
end
end
def uploads
@uploads ||= {}
@uploads[provider.current_site] ||= {}
end
def clear_uploads_cache(name)
if (
type_supervisor.get_type(name) == :upload ||
type_supervisor.get_type(name) == :uploaded_image_list
) && uploads.has_key?(name)
uploads.delete(name)
end
end
def logger
Rails.logger
end
private
def hydrate_uploads_in_objects(objects, schema)
return objects if objects.blank?
upload_ids =
SchemaSettingsObjectValidator.property_values_of_type(
schema: schema,
objects: objects,
type: "upload",
)
uploads_by_id = Upload.where(id: upload_ids).index_by(&:id)
objects.map { |obj| hydrate_uploads_in_object(obj, schema[:properties], uploads_by_id) }
end
def hydrate_uploads_in_object(object, properties, uploads_by_id)
properties.each do |prop_key, prop_value|
case prop_value[:type]
when "upload"
key = prop_key.to_s
upload_id = object[key]
upload = uploads_by_id[upload_id]
object[key] = upload.url if upload
when "objects"
nested_objects = object[prop_key.to_s]
if nested_objects.is_a?(Array)
nested_objects.each do |nested_obj|
hydrate_uploads_in_object(nested_obj, prop_value[:schema][:properties], uploads_by_id)
end
end
end
end
object
end
end