Add validations to SiteConfig model (#12341)
* Move straightforward validations * Add color contrast validation to SiteConfig * Move domain and emoji validation to SiteConfig * Add specs * Update service object * Experimentally remove validation * Update spec * Fix comment
This commit is contained in:
parent
b02c36f929
commit
b4a480edae
6 changed files with 175 additions and 86 deletions
|
|
@ -4,14 +4,15 @@
|
|||
class SiteConfig < RailsSettings::Base
|
||||
self.table_name = "site_configs"
|
||||
|
||||
validates :var, presence: true
|
||||
|
||||
# the site configuration is cached, change this if you want to force update
|
||||
# the cache, or call SiteConfig.clear_cache
|
||||
cache_prefix { "v1" }
|
||||
|
||||
HEX_COLOR_REGEX = /\A#(\h{6}|\h{3})\z/.freeze
|
||||
LIGHTNING_ICON = File.read(Rails.root.join("app/assets/images/lightning.svg")).freeze
|
||||
STACK_ICON = File.read(Rails.root.join("app/assets/images/stack.svg")).freeze
|
||||
VALID_URL = %r{\A(http|https)://([/|.\w\s-])*.[a-z]{2,5}(:[0-9]{1,5})?(/.*)?\z}.freeze
|
||||
URL_MESSAGE = "must be a valid URL".freeze
|
||||
|
||||
# Meta
|
||||
field :admin_action_taken_at, type: :datetime, default: Time.current
|
||||
|
|
@ -27,7 +28,9 @@ class SiteConfig < RailsSettings::Base
|
|||
# Authentication
|
||||
field :allow_email_password_registration, type: :boolean, default: false
|
||||
field :allow_email_password_login, type: :boolean, default: true
|
||||
field :allowed_registration_email_domains, type: :array, default: %w[]
|
||||
field :allowed_registration_email_domains, type: :array, default: %w[], validates: {
|
||||
valid_domain_csv: true
|
||||
}
|
||||
field :display_email_domain_allow_list_publicly, type: :boolean, default: false
|
||||
field :require_captcha_for_email_password_registration, type: :boolean, default: false
|
||||
field :authentication_providers, type: :array, default: %w[]
|
||||
|
|
@ -48,14 +51,16 @@ class SiteConfig < RailsSettings::Base
|
|||
field :campaign_hero_html_variant_name, type: :string, default: ""
|
||||
field :campaign_featured_tags, type: :array, default: %w[]
|
||||
field :campaign_sidebar_enabled, type: :boolean, default: 0
|
||||
field :campaign_sidebar_image, type: :string, default: nil
|
||||
field :campaign_sidebar_image, type: :string, default: nil, validates: {
|
||||
format: { with: VALID_URL, message: URL_MESSAGE }
|
||||
}
|
||||
field :campaign_url, type: :string, default: nil
|
||||
field :campaign_articles_require_approval, type: :boolean, default: 0
|
||||
field :campaign_articles_expiry_time, type: :integer, default: 4
|
||||
|
||||
# Community Content
|
||||
field :community_name, type: :string, default: ApplicationConfig["COMMUNITY_NAME"] || "New Forem"
|
||||
field :community_emoji, type: :string, default: "🌱"
|
||||
field :community_emoji, type: :string, default: "🌱", validates: { emoji_only: true }
|
||||
# collective_noun and collective_noun_disabled have been added back temporarily for
|
||||
# a data_update script, but will be removed in a future PR!
|
||||
field :collective_noun, type: :string, default: "Community"
|
||||
|
|
@ -63,9 +68,9 @@ class SiteConfig < RailsSettings::Base
|
|||
field :community_description, type: :string
|
||||
field :community_member_label, type: :string, default: "user"
|
||||
field :tagline, type: :string
|
||||
field :community_copyright_start_year, type: :integer,
|
||||
default: ApplicationConfig["COMMUNITY_COPYRIGHT_START_YEAR"] ||
|
||||
Time.zone.today.year
|
||||
field :community_copyright_start_year,
|
||||
type: :integer,
|
||||
default: ApplicationConfig["COMMUNITY_COPYRIGHT_START_YEAR"] || Time.zone.today.year
|
||||
field :staff_user_id, type: :integer, default: 1
|
||||
field :experience_low, type: :string, default: "Total Newbies"
|
||||
field :experience_high, type: :string, default: "Experienced Users"
|
||||
|
|
@ -95,13 +100,21 @@ class SiteConfig < RailsSettings::Base
|
|||
field :recaptcha_secret_key, type: :string, default: ApplicationConfig["RECAPTCHA_SECRET"]
|
||||
|
||||
# Images
|
||||
field :main_social_image, type: :string, default: proc { URL.local_image("social-media-cover.png") }
|
||||
field :main_social_image,
|
||||
type: :string,
|
||||
default: proc { URL.local_image("social-media-cover.png") },
|
||||
validates: { format: { with: VALID_URL, message: URL_MESSAGE } }
|
||||
|
||||
field :favicon_url, type: :string, default: proc { URL.local_image("favicon.ico") }
|
||||
field :logo_png, type: :string, default: proc { URL.local_image("icon.png") }
|
||||
field :logo_png,
|
||||
type: :string,
|
||||
default: proc { URL.local_image("icon.png") },
|
||||
validates: { format: { with: VALID_URL, message: URL_MESSAGE } }
|
||||
|
||||
field :logo_svg, type: :string
|
||||
field :secondary_logo_url, type: :string
|
||||
field :secondary_logo_url, type: :string, validates: {
|
||||
format: { with: VALID_URL, message: URL_MESSAGE }
|
||||
}
|
||||
|
||||
field :left_navbar_svg_icon, type: :string, default: STACK_ICON
|
||||
field :right_navbar_svg_icon, type: :string, default: LIGHTNING_ICON
|
||||
|
|
@ -109,9 +122,14 @@ class SiteConfig < RailsSettings::Base
|
|||
|
||||
# Mascot
|
||||
field :mascot_user_id, type: :integer, default: nil
|
||||
field :mascot_image_url, type: :string, default: proc { URL.local_image("mascot.png") }
|
||||
field :mascot_image_url,
|
||||
type: :string,
|
||||
default: proc { URL.local_image("mascot.png") },
|
||||
validates: { format: { with: VALID_URL, message: URL_MESSAGE } }
|
||||
field :mascot_image_description, type: :string, default: "The community mascot"
|
||||
field :mascot_footer_image_url, type: :string
|
||||
field :mascot_footer_image_url, type: :string, validates: {
|
||||
format: { with: VALID_URL, message: URL_MESSAGE }
|
||||
}
|
||||
field :mascot_footer_image_width, type: :integer, default: 52
|
||||
field :mascot_footer_image_height, type: :integer, default: 120
|
||||
|
||||
|
|
@ -140,9 +158,15 @@ class SiteConfig < RailsSettings::Base
|
|||
field :mailchimp_incoming_webhook_secret, type: :string, default: ""
|
||||
|
||||
# Onboarding
|
||||
field :onboarding_logo_image, type: :string
|
||||
field :onboarding_background_image, type: :string
|
||||
field :onboarding_taskcard_image, type: :string
|
||||
field :onboarding_logo_image, type: :string, validates: {
|
||||
format: { with: VALID_URL, message: URL_MESSAGE }
|
||||
}
|
||||
field :onboarding_background_image, type: :string, validates: {
|
||||
format: { with: VALID_URL, message: URL_MESSAGE }
|
||||
}
|
||||
field :onboarding_taskcard_image, type: :string, validates: {
|
||||
format: { with: VALID_URL, message: URL_MESSAGE }
|
||||
}
|
||||
field :suggested_tags, type: :array, default: %w[]
|
||||
field :suggested_users, type: :array, default: %w[]
|
||||
field :prefer_manual_suggested_users, type: :boolean, default: false
|
||||
|
|
@ -190,7 +214,13 @@ class SiteConfig < RailsSettings::Base
|
|||
field :public, type: :boolean, default: 0
|
||||
# The default font for all users that have not chosen a custom font yet
|
||||
field :default_font, type: :string, default: "sans_serif"
|
||||
field :primary_brand_color_hex, type: :string, default: "#3b49df"
|
||||
field :primary_brand_color_hex, type: :string, default: "#3b49df", validates: {
|
||||
format: {
|
||||
with: HEX_COLOR_REGEX,
|
||||
message: "must be be a 3 or 6 character hex (starting with #)"
|
||||
},
|
||||
color_contrast: true
|
||||
}
|
||||
field :feed_strategy, type: :string, default: "basic"
|
||||
field :tag_feed_minimum_score, type: :integer, default: 0
|
||||
field :home_feed_minimum_score, type: :integer, default: 0
|
||||
|
|
|
|||
|
|
@ -1,21 +1,6 @@
|
|||
module SiteConfigs
|
||||
class Upsert
|
||||
EMOJI_ONLY_FIELDS = %w[community_emoji].freeze
|
||||
IMAGE_FIELDS =
|
||||
%w[
|
||||
main_social_image
|
||||
logo_png
|
||||
secondary_logo_url
|
||||
campaign_sidebar_image
|
||||
mascot_image_url
|
||||
mascot_footer_image_url
|
||||
onboarding_logo_image
|
||||
onboarding_background_image
|
||||
onboarding_taskcard_image
|
||||
].freeze
|
||||
|
||||
VALID_URL = %r{\A(http|https)://([/|.\w\s-])*.[a-z]{2,5}(:[0-9]{1,5})?(/.*)?\z}.freeze
|
||||
|
||||
VALID_DOMAIN = /^[a-zA-Z0-9]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/.freeze
|
||||
|
||||
PARAMS_TO_BE_CLEANED = %i[sidebar_tags suggested_tags suggested_users].freeze
|
||||
|
|
@ -32,16 +17,13 @@ module SiteConfigs
|
|||
end
|
||||
|
||||
def call
|
||||
@errors = []
|
||||
clean_up_params
|
||||
validate_inputs
|
||||
validate_emoji
|
||||
validate_image_urls
|
||||
|
||||
return self if @errors.flatten.any?
|
||||
@errors = []
|
||||
upsert_configs
|
||||
return self if @errors.any?
|
||||
|
||||
@success = true
|
||||
upsert_configs
|
||||
after_upsert_tasks
|
||||
self
|
||||
end
|
||||
|
|
@ -61,6 +43,9 @@ module SiteConfigs
|
|||
else
|
||||
SiteConfig.public_send("#{key}=", value.strip) unless value.nil?
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@errors << e.message
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -75,27 +60,6 @@ module SiteConfigs
|
|||
@configs[:credit_prices_in_cents]&.transform_values!(&:to_i)
|
||||
end
|
||||
|
||||
def validate_inputs
|
||||
@errors << "Brand color must be darker for accessibility." if brand_contrast_too_low
|
||||
@errors << "Brand color must be be a 6 character hex (starting with #)." if brand_color_not_hex
|
||||
@errors << "Allowed emails must be list of domains." if allowed_domains_include_improper_format
|
||||
end
|
||||
|
||||
def validate_emoji
|
||||
emoji_params = @configs.slice(*EMOJI_ONLY_FIELDS).to_h
|
||||
@errors << emoji_params.filter_map do |field, value|
|
||||
non_emoji_characters = value.downcase.gsub(EmojiRegex::RGIEmoji, "")
|
||||
"#{field} contains invalid emoji" if non_emoji_characters.present?
|
||||
end
|
||||
end
|
||||
|
||||
def validate_image_urls
|
||||
image_params = @configs.slice(*IMAGE_FIELDS).to_h
|
||||
@errors << image_params.filter_map do |field, url|
|
||||
"#{field} must be a valid URL" unless url.blank? || valid_image_url(url)
|
||||
end
|
||||
end
|
||||
|
||||
def update_enabled_auth_providers(value)
|
||||
enabled_providers = value.split(",").filter_map do |entry|
|
||||
entry unless invalid_provider_entry(entry)
|
||||
|
|
@ -124,30 +88,5 @@ module SiteConfigs
|
|||
|
||||
Tag.find_or_create_all_with_like_by_name(SiteConfig.suggested_tags + SiteConfig.sidebar_tags)
|
||||
end
|
||||
|
||||
# Validations
|
||||
def brand_contrast_too_low
|
||||
hex = @configs[:primary_brand_color_hex]
|
||||
hex.present? && Color::Accessibility.new(hex).low_contrast?
|
||||
end
|
||||
|
||||
def brand_color_not_hex
|
||||
hex = @configs[:primary_brand_color_hex]
|
||||
hex.present? && !hex.match?(/\A#(\h{6}|\h{3})\z/)
|
||||
end
|
||||
|
||||
def allowed_domains_include_improper_format
|
||||
domains = @configs[:allowed_registration_email_domains]
|
||||
return unless domains
|
||||
|
||||
domains_array = domains.delete(" ").split(",")
|
||||
valid_domains = domains_array
|
||||
.select { |d| d.match?(VALID_DOMAIN) }
|
||||
valid_domains.size != domains_array.size
|
||||
end
|
||||
|
||||
def valid_image_url(url)
|
||||
url.match?(VALID_URL)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
11
app/validators/color_contrast_validator.rb
Normal file
11
app/validators/color_contrast_validator.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class ColorContrastValidator < ActiveModel::EachValidator
|
||||
DEFAULT_MESSAGE = "must be darker for accessibility".freeze
|
||||
|
||||
def validate_each(record, attribute, value)
|
||||
return unless Color::Accessibility.new(value).low_contrast?
|
||||
|
||||
record.errors.add(attribute, (options[:message] || DEFAULT_MESSAGE))
|
||||
rescue WCAGColorContrast::InvalidColorError
|
||||
# nothing to do here, this should be picked up by the format validation
|
||||
end
|
||||
end
|
||||
9
app/validators/emoji_only_validator.rb
Normal file
9
app/validators/emoji_only_validator.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
class EmojiOnlyValidator < ActiveModel::EachValidator
|
||||
DEFAULT_MESSAGE = "contains non-emoji characters or invalid emoji".freeze
|
||||
|
||||
def validate_each(record, attribute, value)
|
||||
return if value.gsub(EmojiRegex::RGIEmoji, "").blank?
|
||||
|
||||
record.errors.add(attribute, options[:message] || DEFAULT_MESSAGE)
|
||||
end
|
||||
end
|
||||
10
app/validators/valid_domain_csv_validator.rb
Normal file
10
app/validators/valid_domain_csv_validator.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
class ValidDomainCsvValidator < ActiveModel::EachValidator
|
||||
DEFAULT_MESSAGE = "must be a comma-separated list of valid domains".freeze
|
||||
VALID_DOMAIN = /^[a-zA-Z0-9]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/.freeze
|
||||
|
||||
def validate_each(record, attribute, value)
|
||||
return if value.all? { |domain| domain.match?(VALID_DOMAIN) }
|
||||
|
||||
record.errors.add(attribute, options[:message] || DEFAULT_MESSAGE)
|
||||
end
|
||||
end
|
||||
|
|
@ -2,8 +2,98 @@ require "rails_helper"
|
|||
|
||||
RSpec.describe SiteConfig, type: :model do
|
||||
describe "validations" do
|
||||
describe "builtin validations" do
|
||||
it { is_expected.to validate_presence_of(:var) }
|
||||
describe "validating URLs" do
|
||||
let(:url_fields) do
|
||||
%w[
|
||||
campaign_sidebar_image main_social_image logo_png secondary_logo_url
|
||||
mascot_image_url mascot_footer_image_url onboarding_background_image
|
||||
onboarding_logo_image onboarding_taskcard_image
|
||||
]
|
||||
end
|
||||
|
||||
it "accepts valid URLs" do
|
||||
url_fields.each do |attribute|
|
||||
expect do
|
||||
described_class.public_send("#{attribute}=", "https://example.com")
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects invalid URLs and accepts valid ones", :aggregate_failures do
|
||||
url_fields.each do |attribute|
|
||||
expect do
|
||||
described_class.public_send("#{attribute}=", "example.com")
|
||||
end.to raise_error(/must be a valid URL/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "validating domain lists" do
|
||||
it "allows valid domain lists" do
|
||||
expect do
|
||||
described_class.allowed_registration_email_domains = "example.com, example2.com"
|
||||
end.not_to raise_error
|
||||
end
|
||||
|
||||
it "rejects invalid domain lists" do
|
||||
expect do
|
||||
described_class.allowed_registration_email_domains = "example.com, e.c"
|
||||
end.to raise_error(/must be a comma-separated list of valid domains/)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validating emojis strings" do
|
||||
it "allows emoji-only strings" do
|
||||
expect do
|
||||
described_class.community_emoji = "💯"
|
||||
end.not_to raise_error
|
||||
end
|
||||
|
||||
it "rejects non emoji-only strings" do
|
||||
expect do
|
||||
described_class.community_emoji = "abc"
|
||||
end.to raise_error(/contains non-emoji characters or invalid emoji/)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validating hex string format" do
|
||||
it "allows 3 chacter hex strings" do
|
||||
expect do
|
||||
described_class.primary_brand_color_hex = "#000"
|
||||
end.not_to raise_error
|
||||
end
|
||||
|
||||
it "allows 6 character hex strings" do
|
||||
expect do
|
||||
described_class.primary_brand_color_hex = "#000000"
|
||||
end.not_to raise_error
|
||||
end
|
||||
|
||||
it "rejects strings without leading #" do
|
||||
expect do
|
||||
described_class.primary_brand_color_hex = "000000"
|
||||
end.to raise_error(/must be be a 3 or 6 character hex \(starting with #\)/)
|
||||
end
|
||||
|
||||
it "rejects invalid character" do
|
||||
expect do
|
||||
described_class.primary_brand_color_hex = "#00000g"
|
||||
end.to raise_error(/must be be a 3 or 6 character hex \(starting with #\)/)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validating color contrast" do
|
||||
it "allows high enough color contrast" do
|
||||
expect do
|
||||
described_class.primary_brand_color_hex = "#000"
|
||||
end.not_to raise_error
|
||||
end
|
||||
|
||||
it "rejects too low color contrast" do
|
||||
expect do
|
||||
described_class.primary_brand_color_hex = "#fff"
|
||||
end.to raise_error(/must be darker for accessibility/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue