mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-02 17:57:36 +08:00
When `use_email_for_username_and_name_suggestions` is disabled, the username suggester was falling back to generic usernames like "user1", "user2", etc. These suggestions are rarely helpful as they create indistinct usernames that users often accept without modification, resulting in forums populated with "user763", "user764", etc. This change adds an `allow_generic_fallback` option to `UserNameSuggester.suggest()`. When set to false, it returns nil instead of falling back to generic "userN" usernames. The OAuth authentication flow now uses this option, leaving the username field blank so users must choose their own meaningful username. Ref - https://meta.discourse.org/t/391542
168 lines
5.1 KiB
Ruby
168 lines
5.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module UserNameSuggester
|
|
GENERIC_NAMES = %w[i me info support admin webmaster hello mail office contact team]
|
|
LAST_RESORT_USERNAME = "user"
|
|
|
|
def self.suggest(*input, current_username: nil, allow_generic_fallback: true)
|
|
name =
|
|
input.find do |item|
|
|
if !SiteSetting.use_email_for_username_and_name_suggestions
|
|
next if item.to_s =~ User::EMAIL
|
|
end
|
|
parsed_name = parse_name_from_email(item)
|
|
break parsed_name if sanitize_username(parsed_name).present?
|
|
end
|
|
|
|
name = fix_username(name, allow_generic_fallback:)
|
|
return nil if name.blank?
|
|
find_available_username_based_on(name, current_username)
|
|
end
|
|
|
|
def self.parse_name_from_email(name_or_email)
|
|
return name_or_email if name_or_email.to_s !~ User::EMAIL
|
|
|
|
# When 'walter@white.com' take 'walter'
|
|
name = Regexp.last_match[1]
|
|
|
|
# When 'me@eviltrout.com' take 'eviltrout'
|
|
name = Regexp.last_match[2] if GENERIC_NAMES.include?(name)
|
|
name
|
|
end
|
|
|
|
def self.find_available_username_based_on(name, current_username = nil)
|
|
offset = nil
|
|
i = 1
|
|
|
|
attempt = name
|
|
normalized_attempt = User.normalize_username(attempt)
|
|
|
|
original_allowed_username = current_username
|
|
current_username = User.normalize_username(current_username) if current_username
|
|
|
|
until (normalized_attempt == current_username || User.username_available?(attempt) || i > 100)
|
|
if offset.nil?
|
|
normalized = User.normalize_username(name)
|
|
similar = "#{normalized}(0|1|2|3|4|5|6|7|8|9)+"
|
|
|
|
count = DB.query_single(<<~SQL, like: "#{normalized}%", similar: similar).first
|
|
SELECT count(*) FROM users
|
|
WHERE username_lower LIKE :like AND
|
|
username_lower SIMILAR TO :similar
|
|
SQL
|
|
|
|
if count > 0
|
|
params = {
|
|
count: count + 10,
|
|
name: normalized,
|
|
allowed_normalized: current_username || "",
|
|
}
|
|
|
|
# increasing the search space a bit to allow for some extra noise
|
|
available = DB.query_single(<<~SQL, params).first
|
|
WITH numbers AS (SELECT generate_series(1, :count) AS n)
|
|
|
|
SELECT n FROM numbers
|
|
LEFT JOIN users ON (
|
|
username_lower = :name || n::varchar
|
|
) AND (
|
|
username_lower <> :allowed_normalized
|
|
)
|
|
WHERE users.id IS NULL
|
|
ORDER by n ASC
|
|
LIMIT 1
|
|
SQL
|
|
|
|
# we start at 1
|
|
offset = available.to_i - 1
|
|
offset = 0 if offset < 0
|
|
else
|
|
offset = 0
|
|
end
|
|
end
|
|
|
|
suffix = (i + offset).to_s
|
|
|
|
max_length = User.username_length.end - suffix.length
|
|
attempt = "#{truncate(name, max_length)}#{suffix}"
|
|
normalized_attempt = User.normalize_username(attempt)
|
|
i += 1
|
|
end
|
|
|
|
until normalized_attempt == current_username || User.username_available?(attempt) || i > 200
|
|
attempt = SecureRandom.hex[1..SiteSetting.max_username_length]
|
|
normalized_attempt = User.normalize_username(attempt)
|
|
i += 1
|
|
end
|
|
|
|
if current_username == normalized_attempt
|
|
original_allowed_username
|
|
else
|
|
attempt
|
|
end
|
|
end
|
|
|
|
def self.fix_username(name, allow_generic_fallback: true)
|
|
fixed_username = sanitize_username(name)
|
|
if fixed_username.blank?
|
|
return nil unless allow_generic_fallback
|
|
fixed_username << sanitize_username(I18n.t("fallback_username"))
|
|
fixed_username << LAST_RESORT_USERNAME if fixed_username.empty?
|
|
end
|
|
|
|
rightsize_username(fixed_username)
|
|
end
|
|
|
|
def self.sanitize_username(name)
|
|
name = name.to_s.dup
|
|
|
|
if SiteSetting.unicode_usernames
|
|
name.unicode_normalize!
|
|
|
|
if name.include?("Σ")
|
|
ctx = MiniRacer::Context.new
|
|
name = ctx.eval("#{name.to_s.to_json}.toLowerCase()")
|
|
ctx.dispose
|
|
end
|
|
else
|
|
name = ActiveSupport::Inflector.transliterate(name)
|
|
end
|
|
|
|
name.gsub!(UsernameValidator.invalid_char_pattern, "_")
|
|
name = apply_allowlist(name) if UsernameValidator.char_allowlist_exists?
|
|
name.gsub!(UsernameValidator::INVALID_LEADING_CHAR_PATTERN, "")
|
|
name.gsub!(UsernameValidator::CONFUSING_EXTENSIONS, "_")
|
|
name.gsub!(UsernameValidator::INVALID_TRAILING_CHAR_PATTERN, "")
|
|
name.gsub!(UsernameValidator::REPEATED_SPECIAL_CHAR_PATTERN, "_")
|
|
name
|
|
end
|
|
|
|
def self.apply_allowlist(name)
|
|
name.grapheme_clusters.map { |c| UsernameValidator.allowed_char?(c) ? c : "_" }.join
|
|
end
|
|
|
|
def self.rightsize_username(name)
|
|
name = truncate(name, User.username_length.end)
|
|
name.gsub!(UsernameValidator::INVALID_TRAILING_CHAR_PATTERN, "")
|
|
|
|
missing_char_count = User.username_length.begin - name.grapheme_clusters.size
|
|
name << "1" * missing_char_count if missing_char_count > 0
|
|
name
|
|
end
|
|
|
|
def self.truncate(name, max_grapheme_clusters)
|
|
clusters = name.grapheme_clusters
|
|
|
|
if clusters.size > max_grapheme_clusters
|
|
clusters = clusters[0..max_grapheme_clusters - 1]
|
|
name = clusters.join
|
|
end
|
|
|
|
while name.length > UsernameValidator::MAX_CHARS
|
|
clusters.pop
|
|
name = clusters.join
|
|
end
|
|
|
|
name
|
|
end
|
|
end
|