discourse/lib/user_name_suggester.rb
Andrei Prigorshnev 19d95c64af
DEV: simplify username suggester (#14531)
This PR doesn't change any behavior, but just removes code that wasn't in use. This is a pretty dangerous place to change, since it gets called during user's registration. At the same time the refactoring is very straightforward, it's clear that this code wasn't doing any work (it still needs to be double-checked during review though). Also, the test coverage of UserNameSuggester is good.
2021-10-27 14:41:24 +04:00

149 lines
4.2 KiB
Ruby

# frozen_string_literal: true
module UserNameSuggester
GENERIC_NAMES = ['i', 'me', 'info', 'support', 'admin', 'webmaster', 'hello', 'mail', 'office', 'contact', 'team']
LAST_RESORT_USERNAME = "user"
def self.suggest(name_or_email)
name = parse_name_from_email(name_or_email)
find_available_username_based_on(name)
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)
name = fix_username(name)
offset = nil
i = 1
attempt = name
until 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
}
# 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)
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}"
i += 1
end
until User.username_available?(attempt) || i > 200
attempt = SecureRandom.hex[1..SiteSetting.max_username_length]
i += 1
end
attempt
end
def self.fix_username(name)
fixed_username = sanitize_username(name)
if fixed_username.empty?
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!
# TODO: Jan 2022, review if still needed
# see: https://meta.discourse.org/t/unicode-username-with-as-the-final-char-leads-to-an-error-loading-profile-page/173182
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