2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-04 01:15:08 +08:00
discourse/app/models/username_validator.rb
Gabriel Grubba ad5f57b29c
FEATURE: Add plugin hook for extra username validations (#35522)
This PR adds the `:username_validation` modifier to allow plugins to add
custom username validations.

The modifier is called only if all built-in validations pass.

It also adds `object` to the UsernameValidator context.

Example:

```rb
register_modifier(:username_validation) do |errors, context|
    next if context.object.nil? || context.object.name.blank?
    errors << "Please do not use this name, it is not good!" if is_really_bad_name?(context.object)
  end
```

---------

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
2025-10-27 11:03:42 -03:00

175 lines
5.2 KiB
Ruby

# frozen_string_literal: true
class UsernameValidator
# Public: Perform the validation of a field in a given object
# it adds the errors (if any) to the object that we're giving as parameter
#
# object - Object in which we're performing the validation
# field_name - name of the field that we're validating
#
# Example: UsernameValidator.perform_validation(user, 'name')
def self.perform_validation(object, field_name, opts = {})
validator = UsernameValidator.new(object.public_send(field_name), object:, **opts)
unless validator.valid_format?
validator.errors.each { |e| object.errors.add(field_name.to_sym, e) }
end
end
def initialize(username, skip_length_validation: false, object: nil)
@username = username&.unicode_normalize
@skip_length_validation = skip_length_validation
@object = object
@errors = []
end
attr_accessor :errors
attr_reader :username
attr_reader :object
attr_reader :skip_length_validation
def valid_format?
username_present?
username_length_min? if !skip_length_validation
username_length_max? if !skip_length_validation
username_char_valid?
username_char_allowed?
username_first_char_valid?
username_last_char_valid?
username_no_double_special?
username_does_not_end_with_confusing_suffix?
username_plugin_validation
errors.empty?
end
CONFUSING_EXTENSIONS = /\.(js|json|css|htm|html|xml|jpg|jpeg|png|gif|bmp|ico|tif|tiff|woff)\z/i
MAX_CHARS = 60
ASCII_INVALID_CHAR_PATTERN = /[^\w.-]/
# All Unicode characters except for alphabetic and numeric character, marks and underscores are invalid.
# In addition to that, the following letters and nonspacing marks are invalid:
# (U+034F) Combining Grapheme Joiner
# (U+115F) Hangul Choseong Filler
# (U+1160) Hangul Jungseong Filler
# (U+17B4) Khmer Vowel Inherent Aq
# (U+17B5) Khmer Vowel Inherent Aa
# (U+180B - U+180D) Mongolian Free Variation Selectors
# (U+3164) Hangul Filler
# (U+FFA0) Halfwidth Hangul Filler
# (U+FE00 - U+FE0F) "Variation Selectors" block
# (U+E0100 - U+E01EF) "Variation Selectors Supplement" block
UNICODE_INVALID_CHAR_PATTERN =
/
[^\p{Alnum}\p{M}._-]|
[
\u{034F}
\u{115F}
\u{1160}
\u{17B4}
\u{17B5}
\u{180B}-\u{180D}
\u{3164}
\u{FFA0}
\p{In Variation Selectors}
\p{In Variation Selectors Supplement}
]
/x
INVALID_LEADING_CHAR_PATTERN = /\A[^\p{Alnum}\p{M}_]+/
INVALID_TRAILING_CHAR_PATTERN = /[^\p{Alnum}\p{M}]+\z/
REPEATED_SPECIAL_CHAR_PATTERN = /[-_.]{2,}/
private
def username_present?
return unless errors.empty?
self.errors << I18n.t(:"user.username.blank") if username.blank?
end
def username_length_min?
return unless errors.empty?
if username_grapheme_clusters.size < User.username_length.begin
self.errors << I18n.t(:"user.username.short", count: User.username_length.begin)
end
end
def username_length_max?
return unless errors.empty?
if username_grapheme_clusters.size > User.username_length.end
self.errors << I18n.t(:"user.username.long", count: User.username_length.end)
elsif username.length > MAX_CHARS
self.errors << I18n.t(:"user.username.too_long")
end
end
def username_char_valid?
return unless errors.empty?
if self.class.invalid_char_pattern.match?(username)
self.errors << I18n.t(:"user.username.characters")
end
end
def username_char_allowed?
return unless errors.empty? && self.class.char_allowlist_exists?
if username.chars.any? { |c| !self.class.allowed_char?(c) }
self.errors << I18n.t(:"user.username.characters")
end
end
def username_first_char_valid?
return unless errors.empty?
if INVALID_LEADING_CHAR_PATTERN.match?(username_grapheme_clusters.first)
self.errors << I18n.t(:"user.username.must_begin_with_alphanumeric_or_underscore")
end
end
def username_last_char_valid?
return unless errors.empty?
if INVALID_TRAILING_CHAR_PATTERN.match?(username_grapheme_clusters.last)
self.errors << I18n.t(:"user.username.must_end_with_alphanumeric")
end
end
def username_no_double_special?
return unless errors.empty?
if REPEATED_SPECIAL_CHAR_PATTERN.match?(username)
self.errors << I18n.t(:"user.username.must_not_contain_two_special_chars_in_seq")
end
end
def username_does_not_end_with_confusing_suffix?
return unless errors.empty?
if CONFUSING_EXTENSIONS.match?(username)
self.errors << I18n.t(:"user.username.must_not_end_with_confusing_suffix")
end
end
def username_plugin_validation
return unless errors.empty?
DiscoursePluginRegistry.apply_modifier(:username_validation, self.errors, self)
end
def username_grapheme_clusters
@username_grapheme_clusters ||= username.grapheme_clusters
end
def self.invalid_char_pattern
SiteSetting.unicode_usernames ? UNICODE_INVALID_CHAR_PATTERN : ASCII_INVALID_CHAR_PATTERN
end
def self.char_allowlist_exists?
SiteSetting.unicode_usernames && SiteSetting.allowed_unicode_username_characters.present?
end
def self.allowed_char?(c)
c.match?(/[\w.-]/) || c.match?(SiteSetting.allowed_unicode_username_characters_regex)
end
end