mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 10:20:58 -06:00
df3eb93973
* DEV: Sanitize HTML admin inputs
This PR adds on-save HTML sanitization for:
Client site settings
translation overrides
badges descriptions
user fields descriptions
I used Rails's SafeListSanitizer, which [accepts the following HTML tags and attributes](018cf54073/lib/rails/html/sanitizer.rb (L108)
)
* Make sure that the sanitization logic doesn't corrupt settings with special characters
358 lines
8.6 KiB
Ruby
358 lines
8.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Badge < ActiveRecord::Base
|
|
# TODO: Drop in July 2021
|
|
self.ignored_columns = %w{image}
|
|
|
|
include GlobalPath
|
|
include HasSanitizableFields
|
|
|
|
# NOTE: These badge ids are not in order! They are grouped logically.
|
|
# When picking an id, *search* for it.
|
|
|
|
BasicUser = 1
|
|
Member = 2
|
|
Regular = 3
|
|
Leader = 4
|
|
|
|
Welcome = 5
|
|
NicePost = 6
|
|
GoodPost = 7
|
|
GreatPost = 8
|
|
Autobiographer = 9
|
|
Editor = 10
|
|
WikiEditor = 48
|
|
|
|
FirstLike = 11
|
|
FirstShare = 12
|
|
FirstFlag = 13
|
|
FirstLink = 14
|
|
FirstQuote = 15
|
|
FirstMention = 40
|
|
FirstEmoji = 41
|
|
FirstOnebox = 42
|
|
FirstReplyByEmail = 43
|
|
|
|
ReadGuidelines = 16
|
|
Reader = 17
|
|
NiceTopic = 18
|
|
GoodTopic = 19
|
|
GreatTopic = 20
|
|
NiceShare = 21
|
|
GoodShare = 22
|
|
GreatShare = 23
|
|
Anniversary = 24
|
|
|
|
Promoter = 25
|
|
Campaigner = 26
|
|
Champion = 27
|
|
|
|
PopularLink = 28
|
|
HotLink = 29
|
|
FamousLink = 30
|
|
|
|
Appreciated = 36
|
|
Respected = 37
|
|
Admired = 31
|
|
|
|
OutOfLove = 33
|
|
HigherLove = 34
|
|
CrazyInLove = 35
|
|
|
|
ThankYou = 38
|
|
GivesBack = 32
|
|
Empathetic = 39
|
|
|
|
Enthusiast = 45
|
|
Aficionado = 46
|
|
Devotee = 47
|
|
|
|
NewUserOfTheMonth = 44
|
|
|
|
# other consts
|
|
AutobiographerMinBioLength = 10
|
|
|
|
# used by serializer
|
|
attr_accessor :has_badge
|
|
|
|
def self.trigger_hash
|
|
@trigger_hash ||= Badge::Trigger.constants.map do |k|
|
|
name = k.to_s.underscore
|
|
[name, Badge::Trigger.const_get(k)] unless name =~ /deprecated/
|
|
end.compact.to_h
|
|
end
|
|
|
|
module Trigger
|
|
None = 0
|
|
PostAction = 1
|
|
PostRevision = 2
|
|
TrustLevelChange = 4
|
|
UserChange = 8
|
|
DeprecatedPostProcessed = 16 # No longer in use
|
|
|
|
def self.is_none?(trigger)
|
|
[None].include? trigger
|
|
end
|
|
|
|
def self.uses_user_ids?(trigger)
|
|
[TrustLevelChange, UserChange].include? trigger
|
|
end
|
|
|
|
def self.uses_post_ids?(trigger)
|
|
[PostAction, PostRevision].include? trigger
|
|
end
|
|
end
|
|
|
|
belongs_to :badge_type
|
|
belongs_to :badge_grouping
|
|
belongs_to :image_upload, class_name: 'Upload'
|
|
|
|
has_many :user_badges, dependent: :destroy
|
|
|
|
validates :name, presence: true, uniqueness: true
|
|
validates :badge_type, presence: true
|
|
validates :allow_title, inclusion: [true, false]
|
|
validates :multiple_grant, inclusion: [true, false]
|
|
|
|
scope :enabled, -> { where(enabled: true) }
|
|
|
|
before_create :ensure_not_system
|
|
before_save :sanitize_description
|
|
|
|
after_commit do
|
|
SvgSprite.expire_cache
|
|
UserStat.update_distinct_badge_count if saved_change_to_enabled?
|
|
UserBadge.ensure_consistency! if saved_change_to_enabled?
|
|
end
|
|
|
|
# fields that can not be edited on system badges
|
|
def self.protected_system_fields
|
|
[
|
|
:name, :badge_type_id, :multiple_grant,
|
|
:target_posts, :show_posts, :query,
|
|
:trigger, :auto_revoke, :listable
|
|
]
|
|
end
|
|
|
|
def self.trust_level_badge_ids
|
|
(1..4).to_a
|
|
end
|
|
|
|
def self.like_badge_counts
|
|
@like_badge_counts ||= {
|
|
NicePost => 10,
|
|
GoodPost => 25,
|
|
GreatPost => 50,
|
|
NiceTopic => 10,
|
|
GoodTopic => 25,
|
|
GreatTopic => 50
|
|
}
|
|
end
|
|
|
|
def self.ensure_consistency!
|
|
DB.exec <<~SQL
|
|
DELETE FROM user_badges
|
|
USING user_badges ub
|
|
LEFT JOIN users u ON u.id = ub.user_id
|
|
WHERE u.id IS NULL
|
|
AND user_badges.id = ub.id
|
|
SQL
|
|
|
|
DB.exec <<~SQL
|
|
WITH X AS (
|
|
SELECT badge_id
|
|
, COUNT(user_id) users
|
|
FROM user_badges
|
|
GROUP BY badge_id
|
|
)
|
|
UPDATE badges
|
|
SET grant_count = X.users
|
|
FROM X
|
|
WHERE id = X.badge_id
|
|
AND grant_count <> X.users
|
|
SQL
|
|
end
|
|
|
|
def clear_user_titles!
|
|
DB.exec(<<~SQL, badge_id: self.id, updated_at: Time.zone.now)
|
|
UPDATE users AS u
|
|
SET title = '', updated_at = :updated_at
|
|
FROM user_profiles AS up
|
|
WHERE up.user_id = u.id AND up.granted_title_badge_id = :badge_id
|
|
SQL
|
|
DB.exec(<<~SQL, badge_id: self.id)
|
|
UPDATE user_profiles AS up
|
|
SET badge_granted_title = false, granted_title_badge_id = NULL
|
|
WHERE up.granted_title_badge_id = :badge_id
|
|
SQL
|
|
end
|
|
|
|
##
|
|
# Update all user titles based on a badge to the new name
|
|
def update_user_titles!(new_title)
|
|
DB.exec(<<~SQL, granted_title_badge_id: self.id, title: new_title, updated_at: Time.zone.now)
|
|
UPDATE users AS u
|
|
SET title = :title, updated_at = :updated_at
|
|
FROM user_profiles AS up
|
|
WHERE up.user_id = u.id AND up.granted_title_badge_id = :granted_title_badge_id
|
|
SQL
|
|
end
|
|
|
|
##
|
|
# When a badge has its TranslationOverride cleared, reset
|
|
# all user titles granted to the standard name.
|
|
def reset_user_titles!
|
|
DB.exec(<<~SQL, granted_title_badge_id: self.id, updated_at: Time.zone.now)
|
|
UPDATE users AS u
|
|
SET title = badges.name, updated_at = :updated_at
|
|
FROM user_profiles AS up
|
|
INNER JOIN badges ON badges.id = up.granted_title_badge_id
|
|
WHERE up.user_id = u.id AND up.granted_title_badge_id = :granted_title_badge_id
|
|
SQL
|
|
end
|
|
|
|
def self.i18n_name(name)
|
|
name.downcase.tr(' ', '_')
|
|
end
|
|
|
|
def self.display_name(name)
|
|
I18n.t(i18n_key(name), default: name)
|
|
end
|
|
|
|
def self.i18n_key(name)
|
|
"badges.#{i18n_name(name)}.name"
|
|
end
|
|
|
|
def self.find_system_badge_id_from_translation_key(translation_key)
|
|
return unless translation_key.starts_with?('badges.')
|
|
badge_name_klass = translation_key.split('.').second.camelize
|
|
Badge.const_defined?(badge_name_klass) ? "Badge::#{badge_name_klass}".constantize : nil
|
|
end
|
|
|
|
def awarded_for_trust_level?
|
|
id <= 4
|
|
end
|
|
|
|
def reset_grant_count!
|
|
self.grant_count = UserBadge.where(badge_id: id).count
|
|
save!
|
|
end
|
|
|
|
def single_grant?
|
|
!self.multiple_grant?
|
|
end
|
|
|
|
def default_icon=(val)
|
|
if self.image_upload_id.blank?
|
|
self.icon ||= val
|
|
self.icon = val if self.icon == "fa-certificate"
|
|
end
|
|
end
|
|
|
|
def default_allow_title=(val)
|
|
return unless self.new_record?
|
|
self.allow_title ||= val
|
|
end
|
|
|
|
def default_badge_grouping_id=(val)
|
|
# allow to correct orphans
|
|
if !self.badge_grouping_id || self.badge_grouping_id <= BadgeGrouping::Other
|
|
self.badge_grouping_id = val
|
|
end
|
|
end
|
|
|
|
def display_name
|
|
self.class.display_name(name)
|
|
end
|
|
|
|
def translation_key
|
|
self.class.i18n_key(name)
|
|
end
|
|
|
|
def long_description
|
|
key = "badges.#{i18n_name}.long_description"
|
|
I18n.t(key, default: self[:long_description] || '', base_uri: Discourse.base_path, max_likes_per_day: SiteSetting.max_likes_per_day)
|
|
end
|
|
|
|
def long_description=(val)
|
|
self[:long_description] = val if val != long_description
|
|
val
|
|
end
|
|
|
|
def description
|
|
key = "badges.#{i18n_name}.description"
|
|
I18n.t(key, default: self[:description] || '', base_uri: Discourse.base_path, max_likes_per_day: SiteSetting.max_likes_per_day)
|
|
end
|
|
|
|
def description=(val)
|
|
self[:description] = val if val != description
|
|
val
|
|
end
|
|
|
|
def slug
|
|
Slug.for(self.display_name, '-')
|
|
end
|
|
|
|
def manually_grantable?
|
|
query.blank? && !system?
|
|
end
|
|
|
|
def i18n_name
|
|
@i18n_name ||= self.class.i18n_name(name)
|
|
end
|
|
|
|
def image_url
|
|
if image_upload_id.present?
|
|
upload_cdn_path(image_upload.url)
|
|
end
|
|
end
|
|
|
|
def for_beginners?
|
|
id == Welcome || (badge_grouping_id == BadgeGrouping::GettingStarted && id != NewUserOfTheMonth)
|
|
end
|
|
|
|
protected
|
|
|
|
def ensure_not_system
|
|
self.id = [Badge.maximum(:id) + 1, 100].max unless id
|
|
end
|
|
|
|
def sanitize_description
|
|
if description_changed?
|
|
self.description = sanitize_field(self.description)
|
|
end
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: badges
|
|
#
|
|
# id :integer not null, primary key
|
|
# name :string not null
|
|
# description :text
|
|
# badge_type_id :integer not null
|
|
# grant_count :integer default(0), not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# allow_title :boolean default(FALSE), not null
|
|
# multiple_grant :boolean default(FALSE), not null
|
|
# icon :string default("fa-certificate")
|
|
# listable :boolean default(TRUE)
|
|
# target_posts :boolean default(FALSE)
|
|
# query :text
|
|
# enabled :boolean default(TRUE), not null
|
|
# auto_revoke :boolean default(TRUE), not null
|
|
# badge_grouping_id :integer default(5), not null
|
|
# trigger :integer
|
|
# show_posts :boolean default(FALSE), not null
|
|
# system :boolean default(FALSE), not null
|
|
# long_description :text
|
|
# image_upload_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_badges_on_badge_type_id (badge_type_id)
|
|
# index_badges_on_name (name) UNIQUE
|
|
#
|