2019-04-30 08:25:53 +08:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2013-06-06 22:40:10 +08:00
|
|
|
module UserNameSuggester
|
2016-01-27 16:04:11 +08:00
|
|
|
GENERIC_NAMES = ['i', 'me', 'info', 'support', 'admin', 'webmaster', 'hello', 'mail', 'office', 'contact', 'team']
|
2013-06-06 22:40:10 +08:00
|
|
|
|
2019-04-23 18:22:47 +08:00
|
|
|
def self.suggest(name_or_email, allowed_username = nil)
|
|
|
|
return unless name_or_email.present?
|
|
|
|
|
|
|
|
name = parse_name_from_email(name_or_email)
|
|
|
|
find_available_username_based_on(name, allowed_username)
|
2013-06-06 22:40:10 +08:00
|
|
|
end
|
|
|
|
|
2019-04-23 18:22:47 +08:00
|
|
|
def self.parse_name_from_email(name_or_email)
|
2019-11-28 10:13:13 +08:00
|
|
|
return name_or_email if name_or_email.to_s !~ User::EMAIL
|
2019-04-23 18:22:47 +08:00
|
|
|
|
|
|
|
# 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)
|
2013-06-06 22:40:10 +08:00
|
|
|
name
|
|
|
|
end
|
|
|
|
|
2019-04-23 18:22:47 +08:00
|
|
|
def self.find_available_username_based_on(name, allowed_username = nil)
|
2014-08-25 16:48:29 +08:00
|
|
|
name = fix_username(name)
|
2019-05-16 15:15:03 +08:00
|
|
|
offset = nil
|
2013-06-06 22:40:10 +08:00
|
|
|
i = 1
|
2019-05-28 14:48:30 +08:00
|
|
|
|
2013-06-06 22:40:10 +08:00
|
|
|
attempt = name
|
2019-05-28 14:48:30 +08:00
|
|
|
normalized_attempt = User.normalize_username(attempt)
|
|
|
|
|
|
|
|
original_allowed_username = allowed_username
|
|
|
|
allowed_username = User.normalize_username(allowed_username) if allowed_username
|
2019-05-16 15:15:03 +08:00
|
|
|
|
2019-05-28 14:48:30 +08:00
|
|
|
until (
|
|
|
|
normalized_attempt == allowed_username ||
|
|
|
|
User.username_available?(attempt) ||
|
|
|
|
i > 100
|
|
|
|
)
|
2019-05-16 15:15:03 +08:00
|
|
|
|
|
|
|
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
|
2019-05-28 14:48:30 +08:00
|
|
|
|
|
|
|
params = {
|
|
|
|
count: count + 10,
|
|
|
|
name: normalized,
|
|
|
|
allowed_normalized: allowed_username || ''
|
|
|
|
}
|
|
|
|
|
2019-05-16 16:15:56 +08:00
|
|
|
# increasing the search space a bit to allow for some extra noise
|
2019-05-28 14:48:30 +08:00
|
|
|
available = DB.query_single(<<~SQL, params).first
|
2019-05-16 15:15:03 +08:00
|
|
|
WITH numbers AS (SELECT generate_series(1, :count) AS n)
|
|
|
|
|
|
|
|
SELECT n FROM numbers
|
2019-05-28 14:48:30 +08:00
|
|
|
LEFT JOIN users ON (
|
|
|
|
username_lower = :name || n::varchar
|
|
|
|
) AND (
|
|
|
|
username_lower <> :allowed_normalized
|
|
|
|
)
|
2019-05-16 15:15:03 +08:00
|
|
|
WHERE users.id IS NULL
|
|
|
|
ORDER by n ASC
|
|
|
|
LIMIT 1
|
|
|
|
SQL
|
|
|
|
|
|
|
|
# we start at 1
|
2019-05-16 16:15:56 +08:00
|
|
|
offset = available.to_i - 1
|
|
|
|
offset = 0 if offset < 0
|
2019-05-16 15:15:03 +08:00
|
|
|
else
|
|
|
|
offset = 0
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
suffix = (i + offset).to_s
|
2019-05-28 14:48:30 +08:00
|
|
|
|
2019-04-23 18:22:47 +08:00
|
|
|
max_length = User.username_length.end - suffix.length
|
|
|
|
attempt = "#{truncate(name, max_length)}#{suffix}"
|
2019-05-28 14:48:30 +08:00
|
|
|
normalized_attempt = User.normalize_username(attempt)
|
2013-06-06 22:40:10 +08:00
|
|
|
i += 1
|
|
|
|
end
|
2019-05-16 15:15:03 +08:00
|
|
|
|
2019-05-28 14:48:30 +08:00
|
|
|
until normalized_attempt == allowed_username || User.username_available?(attempt) || i > 200
|
2017-03-29 20:49:28 +08:00
|
|
|
attempt = SecureRandom.hex[1..SiteSetting.max_username_length]
|
2019-05-28 14:48:30 +08:00
|
|
|
normalized_attempt = User.normalize_username(attempt)
|
2017-02-28 04:28:56 +08:00
|
|
|
i += 1
|
|
|
|
end
|
2019-05-28 14:48:30 +08:00
|
|
|
|
|
|
|
if allowed_username == normalized_attempt
|
|
|
|
original_allowed_username
|
|
|
|
else
|
|
|
|
attempt
|
|
|
|
end
|
|
|
|
|
2013-06-06 22:40:10 +08:00
|
|
|
end
|
|
|
|
|
2014-08-25 16:48:29 +08:00
|
|
|
def self.fix_username(name)
|
|
|
|
rightsize_username(sanitize_username(name))
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.sanitize_username(name)
|
2019-04-30 08:25:53 +08:00
|
|
|
name = name.to_s.dup
|
2019-04-23 18:22:47 +08:00
|
|
|
|
|
|
|
if SiteSetting.unicode_usernames
|
|
|
|
name.unicode_normalize!
|
2020-12-23 05:51:36 +08:00
|
|
|
|
|
|
|
# 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
|
2019-04-23 18:22:47 +08:00
|
|
|
else
|
|
|
|
name = ActiveSupport::Inflector.transliterate(name)
|
|
|
|
end
|
|
|
|
|
|
|
|
name.gsub!(UsernameValidator.invalid_char_pattern, '_')
|
2020-07-27 08:23:54 +08:00
|
|
|
name = apply_allowlist(name) if UsernameValidator.char_allowlist_exists?
|
2019-04-23 18:22:47 +08:00
|
|
|
name.gsub!(UsernameValidator::INVALID_LEADING_CHAR_PATTERN, '')
|
2016-01-20 22:37:34 +08:00
|
|
|
name.gsub!(UsernameValidator::CONFUSING_EXTENSIONS, "_")
|
2019-04-23 18:22:47 +08:00
|
|
|
name.gsub!(UsernameValidator::INVALID_TRAILING_CHAR_PATTERN, '')
|
|
|
|
name.gsub!(UsernameValidator::REPEATED_SPECIAL_CHAR_PATTERN, '_')
|
2016-01-20 22:37:34 +08:00
|
|
|
name
|
2013-06-06 22:40:10 +08:00
|
|
|
end
|
|
|
|
|
2020-07-27 08:23:54 +08:00
|
|
|
def self.apply_allowlist(name)
|
2019-10-02 02:31:22 +08:00
|
|
|
name.grapheme_clusters
|
2020-07-27 08:23:54 +08:00
|
|
|
.map { |c| UsernameValidator.allowed_char?(c) ? c : '_' }
|
2019-10-02 02:31:22 +08:00
|
|
|
.join
|
|
|
|
end
|
|
|
|
|
2019-04-23 18:22:47 +08:00
|
|
|
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
|
2016-02-22 06:11:52 +08:00
|
|
|
name
|
|
|
|
end
|
|
|
|
|
2019-04-23 18:22:47 +08:00
|
|
|
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
|
2013-06-06 22:40:10 +08:00
|
|
|
|
2019-04-23 18:22:47 +08:00
|
|
|
while name.length > UsernameValidator::MAX_CHARS
|
|
|
|
clusters.pop
|
|
|
|
name = clusters.join
|
|
|
|
end
|
|
|
|
|
|
|
|
name
|
|
|
|
end
|
2014-03-19 06:02:33 +08:00
|
|
|
end
|