FEATURE: Add support for Unicode usernames and group names

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
Gerhard Schlager 2019-04-23 12:22:47 +02:00
parent d07605d885
commit a7bc1ecbae
29 changed files with 908 additions and 193 deletions

View File

@ -44,7 +44,7 @@ function addHashtag(buffer, matches, state) {
export function setup(helper) {
helper.registerPlugin(md => {
const rule = {
matcher: /#([\u00C0-\u1FFF\u2C00-\uD7FF\w-:]{1,101})/,
matcher: /#([\u00C0-\u1FFF\u2C00-\uD7FF\w:-]{1,101})/,
onMatch: addHashtag
};

File diff suppressed because one or more lines are too long

View File

@ -15,9 +15,14 @@ export class TextPostProcessRuler {
this.matcherIndex = [];
let rules = this.rules.map(
r => "(" + r.rule.matcher.toString().slice(1, -1) + ")"
);
const rules = [];
const flags = new Set("g");
this.rules.forEach(r => {
const matcher = r.rule.matcher;
rules.push(`(${matcher.source})`);
matcher.flags.split("").forEach(f => flags.add(f));
});
let i;
let regexString = "";
@ -41,7 +46,7 @@ export class TextPostProcessRuler {
last = "x".match(regex).length - 1;
}
this.matcher = new RegExp(rules.join("|"), "g");
this.matcher = new RegExp(rules.join("|"), [...flags].join(""));
return this.matcher;
}

View File

@ -313,7 +313,7 @@ class UsersController < ApplicationController
params.require(:username) if !params[:email].present?
return render(json: success_json)
end
username = params[:username]
username = params[:username]&.unicode_normalize
target_user = user_from_params_or_current_user

View File

@ -5,16 +5,27 @@ module Jobs
def execute(args)
@user_id = args[:user_id]
@old_username = args[:old_username]
@new_username = args[:new_username]
@old_username = args[:old_username].unicode_normalize
@new_username = args[:new_username].unicode_normalize
@avatar_img = PrettyText.avatar_img(args[:avatar_template], "tiny")
@raw_mention_regex = /(?:(?<![\w`_])|(?<=_))@#{@old_username}(?:(?![\w\-\.])|(?=[\-\.](?:\s|$)))/i
@raw_mention_regex = /
(?:
(?<![\p{Alnum}\p{M}`]) # make sure there is no preceding letter, number or backtick
)
@#{@old_username}
(?:
(?![\p{Alnum}\p{M}_\-.`]) # make sure there is no trailing letter, number, underscore, dash, dot or backtick
| # or
(?=[-_.](?:\s|$)) # there is an underscore, dash or dot followed by a whitespace or end of line
)
/ix
@raw_quote_regex = /(\[quote\s*=\s*["'']?)#{@old_username}(\,?[^\]]*\])/i
cooked_username = PrettyText::Helpers.format_username(@old_username)
@cooked_mention_username_regex = /^@#{cooked_username}$/i
@cooked_mention_user_path_regex = /^\/u(?:sers)?\/#{cooked_username}$/i
@cooked_mention_user_path_regex = /^\/u(?:sers)?\/#{CGI.escape(cooked_username)}$/i
@cooked_quote_username_regex = /(?<=\s)#{cooked_username}(?=:)/i
update_posts

View File

@ -281,7 +281,7 @@ class Group < ActiveRecord::Base
end
# don't allow shoddy localization to break this
localized_name = I18n.t("groups.default_names.#{name}", locale: SiteSetting.default_locale).downcase
localized_name = User.normalize_username(I18n.t("groups.default_names.#{name}", locale: SiteSetting.default_locale))
validator = UsernameValidator.new(localized_name)
if validator.valid_format? && !User.username_exists?(localized_name)
@ -621,14 +621,14 @@ class Group < ActiveRecord::Base
# avoid strip! here, it works now
# but may not continue to work long term, especially
# once we start returning frozen strings
if self.name != (stripped = self.name.strip)
if self.name != (stripped = self.name.unicode_normalize.strip)
self.name = stripped
end
UsernameValidator.perform_validation(self, 'name') || begin
name_lower = self.name.downcase
normalized_name = User.normalize_username(self.name)
if self.will_save_change_to_name? && self.name_was&.downcase != name_lower && User.username_exists?(name_lower)
if self.will_save_change_to_name? && User.normalize_username(self.name_was) != normalized_name && User.username_exists?(self.name)
errors.add(:name, I18n.t("activerecord.errors.messages.taken"))
end
end

View File

@ -68,7 +68,7 @@ class PostAnalyzer
raw_mentions = cooked_stripped.css('.mention, .mention-group').map do |e|
if name = e.inner_text
name = name[1..-1]
name.downcase! if name
name = User.normalize_username(name)
name
end
end

View File

@ -121,6 +121,11 @@ class SiteSetting < ActiveRecord::Base
@attachment_filename_blacklist_regex ||= Regexp.union(SiteSetting.attachment_filename_blacklist.split("|"))
end
def self.unicode_username_character_whitelist_regex
@unicode_username_whitelist_regex = SiteSetting.unicode_username_character_whitelist.present? \
? Regexp.new(SiteSetting.unicode_username_character_whitelist) : nil
end
# helpers for getting s3 settings that fallback to global
class Upload
def self.s3_cdn_url

View File

@ -117,7 +117,7 @@ class User < ActiveRecord::Base
after_create :ensure_in_trust_level_group
after_create :set_default_categories_preferences
before_save :update_username_lower
before_save :update_usernames
before_save :ensure_password_is_hashed
before_save :match_title_to_primary_group_changes
before_save :check_if_title_is_badged_granted
@ -224,8 +224,12 @@ class User < ActiveRecord::Base
SiteSetting.min_username_length.to_i..SiteSetting.max_username_length.to_i
end
def self.normalize_username(username)
username.unicode_normalize.downcase if username.present?
end
def self.username_available?(username, email = nil, allow_reserved_username: false)
lower = username.downcase
lower = normalize_username(username)
return false if !allow_reserved_username && reserved_username?(lower)
return true if !username_exists?(lower)
@ -234,10 +238,10 @@ class User < ActiveRecord::Base
end
def self.reserved_username?(username)
lower = username.downcase
username = normalize_username(username)
SiteSetting.reserved_usernames.split("|").any? do |reserved|
!!lower.match("^#{Regexp.escape(reserved).gsub('\*', '.*')}$")
SiteSetting.reserved_usernames.unicode_normalize.split("|").any? do |reserved|
username.match?(/^#{Regexp.escape(reserved).gsub('\*', '.*')}$/)
end
end
@ -362,7 +366,7 @@ class User < ActiveRecord::Base
end
def self.find_by_username(username)
find_by(username_lower: username.downcase)
find_by(username_lower: normalize_username(username))
end
def group_granted_trust_level
@ -1293,30 +1297,31 @@ class User < ActiveRecord::Base
self.trust_level ||= SiteSetting.default_trust_level
end
def update_username_lower
def update_usernames
self.username.unicode_normalize!
self.username_lower = username.downcase
end
USERNAME_EXISTS_SQL = <<~SQL
(SELECT users.id AS id, true as is_user FROM users
WHERE users.username_lower = :username)
(SELECT users.id AS id, true as is_user FROM users
WHERE users.username_lower = :username)
UNION ALL
UNION ALL
(SELECT groups.id, false as is_user FROM groups
WHERE lower(groups.name) = :username)
(SELECT groups.id, false as is_user FROM groups
WHERE lower(groups.name) = :username)
SQL
def self.username_exists?(username_lower)
DB.exec(User::USERNAME_EXISTS_SQL, username: username_lower) > 0
def self.username_exists?(username)
username = normalize_username(username)
DB.exec(User::USERNAME_EXISTS_SQL, username: username) > 0
end
def username_validator
username_format_validator || begin
lower = username.downcase
existing = DB.query(
USERNAME_EXISTS_SQL, username: lower
USERNAME_EXISTS_SQL,
username: self.class.normalize_username(username)
)
user_id = existing.select { |u| u.is_user }.first&.id

View File

@ -16,9 +16,10 @@ class UsernameValidator
end
def initialize(username)
@username = username
@username = username&.unicode_normalize
@errors = []
end
attr_accessor :errors
attr_reader :username
@ -27,10 +28,11 @@ class UsernameValidator
end
def valid_format?
username_exist?
username_present?
username_length_min?
username_length_max?
username_char_valid?
username_char_whitelisted?
username_first_char_valid?
username_last_char_valid?
username_no_double_special?
@ -39,62 +41,103 @@ class UsernameValidator
end
CONFUSING_EXTENSIONS ||= /\.(js|json|css|htm|html|xml|jpg|jpeg|png|gif|bmp|ico|tif|tiff|woff)$/i
MAX_CHARS ||= 60
ASCII_INVALID_CHAR_PATTERN ||= /[^\w.-]/
UNICODE_INVALID_CHAR_PATTERN ||= /[^\p{Alnum}\p{M}._-]/
INVALID_LEADING_CHAR_PATTERN ||= /^[^\p{Alnum}\p{M}_]+/
INVALID_TRAILING_CHAR_PATTERN ||= /[^\p{Alnum}\p{M}]+$/
REPEATED_SPECIAL_CHAR_PATTERN ||= /[-_.]{2,}/
private
def username_exist?
def username_present?
return unless errors.empty?
unless username
if username.blank?
self.errors << I18n.t(:'user.username.blank')
end
end
def username_length_min?
return unless errors.empty?
if username.length < User.username_length.begin
if username_grapheme_clusters.size < User.username_length.begin
self.errors << I18n.t(:'user.username.short', min: User.username_length.begin)
end
end
def username_length_max?
return unless errors.empty?
if username.length > User.username_length.end
if username_grapheme_clusters.size > User.username_length.end
self.errors << I18n.t(:'user.username.long', max: 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 username =~ /[^\w.-]/
if self.class.invalid_char_pattern.match?(username)
self.errors << I18n.t(:'user.username.characters')
end
end
def username_char_whitelisted?
return unless errors.empty? && self.class.char_whitelist_exists?
if username.chars.any? { |c| !self.class.whitelisted_char?(c) }
self.errors << I18n.t(:'user.username.characters')
end
end
def username_first_char_valid?
return unless errors.empty?
if username[0] =~ /\W/
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 username[-1] =~ /[^A-Za-z0-9]/
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 username =~ /[-_.]{2,}/
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 username =~ CONFUSING_EXTENSIONS
if CONFUSING_EXTENSIONS.match?(username)
self.errors << I18n.t(:'user.username.must_not_end_with_confusing_suffix')
end
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_whitelist_exists?
SiteSetting.unicode_usernames && SiteSetting.unicode_username_character_whitelist_regex.present?
end
def self.whitelisted_char?(c)
c.match?(/[\w.-]/) || c.match?(SiteSetting.unicode_username_character_whitelist_regex)
end
end

View File

@ -912,9 +912,9 @@ en:
submit: "Save preferences"
digest_frequency:
title: "You are receiving summary emails %{frequency}"
select_title: 'Set summary emails frequency to:'
never: 'never'
select_title: "Set summary emails frequency to:"
never: "never"
every_30_minutes: "every 30 minutes"
every_hour: "hourly"
daily: "daily"
@ -922,7 +922,6 @@ en:
every_month: "every month"
every_six_months: "every six months"
user_api_key:
title: "Authorize application access"
authorize: "Authorize"
@ -1474,6 +1473,8 @@ en:
min_username_length: "Minimum username length in characters. WARNING: if any existing users or groups have names shorter than this, your site will break!"
max_username_length: "Maximum username length in characters. WARNING: if any existing users or groups have names longer than this, your site will break!"
unicode_usernames: "Allow usernames and group names to contain Unicode letters and numbers."
unicode_username_character_whitelist: "Regular expression to allow only some Unicode characters within usernames. ASCII letters and numbers will always be allowed and don't need to be included in the whitelist."
reserved_usernames: "Usernames for which signup is not allowed. Wildcard symbol * can be used to match any character zero or more times."
@ -2066,6 +2067,10 @@ en:
low_weight_invalid: "You cannot set the weight to be greater or equal to 1 or smaller than 'category_search_priority_very_low_weight'."
high_weight_invalid: "You cannot set the weight to be greater or equal to 1 or greater than 'category_search_priority_very_high_weight'."
very_high_weight_invalid: "You cannot set the weight to be smaller than 'category_search_priority_high_weight'."
unicode_username_whitelist:
regex_invalid: "The regular expression is invalid: %{error}"
leading_trailing_slash: "The regular expression must not start and end with a slash."
unicode_usernames_avatars: "The internal system avatars do not support Unicode usernames."
placeholder:
sso_provider_secrets:
@ -2220,6 +2225,7 @@ en:
username:
short: "must be at least %{min} characters"
long: "must be no more than %{max} characters"
too_long: "is too long"
characters: "must only include numbers, letters, dashes, and underscores"
unique: "must be unique"
blank: "must be present"
@ -4272,8 +4278,7 @@ en:
privacy:
title: "Access"
description:
"<p>Is your community open to everyone, or is it restricted by membership, invitation, or approval? If you prefer, you can set things up privately, then switch over to public later.</p>"
description: "<p>Is your community open to everyone, or is it restricted by membership, invitation, or approval? If you prefer, you can set things up privately, then switch over to public later.</p>"
fields:
privacy:

View File

@ -6,7 +6,7 @@ require_dependency "homepage_constraint"
require_dependency "permalink_constraint"
# The following constants have been replaced with `RouteFormat` and are deprecated.
USERNAME_ROUTE_FORMAT = /[\w.\-]+?/ unless defined? USERNAME_ROUTE_FORMAT
USERNAME_ROUTE_FORMAT = /[%\w.\-]+?/ unless defined? USERNAME_ROUTE_FORMAT
BACKUP_ROUTE_FORMAT = /.+\.(sql\.gz|tar\.gz|tgz)/i unless defined? BACKUP_ROUTE_FORMAT
Discourse::Application.routes.draw do

View File

@ -450,6 +450,15 @@ users:
min: 8
max: 60
validator: "MaxUsernameLengthValidator"
unicode_usernames:
default: false
client: true
validator: "UnicodeUsernameValidator"
unicode_username_character_whitelist:
validator: "UnicodeUsernameWhitelistValidator"
default: ""
locale_default:
de: "[äöüßÄÖÜẞ]"
reserved_usernames:
type: list
list_type: compact
@ -1107,8 +1116,9 @@ files:
default: true
client: true
shadowed_by_global: true
validator: "ExternalSystemAvatarsValidator"
external_system_avatars_url:
default: "/letter_avatar_proxy/v3/letter/{first_letter}/{color}/{size}.png"
default: "/letter_avatar_proxy/v4/letter/{first_letter}/{color}/{size}.png"
client: true
regex: '^((https?:)?\/)?\/.+[^\/]'
shadowed_by_global: true

View File

@ -1,7 +1,7 @@
module RouteFormat
def self.username
/[\w.\-]+?/
/[%\w.\-]+?/
end
def self.backup

View File

@ -1,33 +1,35 @@
module UserNameSuggester
GENERIC_NAMES = ['i', 'me', 'info', 'support', 'admin', 'webmaster', 'hello', 'mail', 'office', 'contact', 'team']
def self.suggest(name, allow_username = nil)
return unless name.present?
name = parse_name_from_email(name)
find_available_username_based_on(name, allow_username)
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)
end
def self.parse_name_from_email(name)
if name =~ 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)
end
def self.parse_name_from_email(name_or_email)
return name_or_email if name_or_email !~ 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, allow_username = nil)
def self.find_available_username_based_on(name, allowed_username = nil)
name = fix_username(name)
i = 1
attempt = name
until attempt == allow_username || User.username_available?(attempt) || i > 100
until attempt == allowed_username || User.username_available?(attempt) || i > 100
suffix = i.to_s
max_length = User.username_length.end - suffix.length - 1
attempt = "#{name[0..max_length]}#{suffix}"
max_length = User.username_length.end - suffix.length
attempt = "#{truncate(name, max_length)}#{suffix}"
i += 1
end
until attempt == allow_username || User.username_available?(attempt) || i > 200
until attempt == allowed_username || User.username_available?(attempt) || i > 200
attempt = SecureRandom.hex[1..SiteSetting.max_username_length]
i += 1
end
@ -39,28 +41,45 @@ module UserNameSuggester
end
def self.sanitize_username(name)
name = ActiveSupport::Inflector.transliterate(name.to_s)
# 1. replace characters that aren't allowed with '_'
name.gsub!(UsernameValidator::CONFUSING_EXTENSIONS, "_")
name.gsub!(/[^\w.-]/, "_")
# 2. removes unallowed leading characters
name.gsub!(/^\W+/, "")
# 3. removes unallowed trailing characters
name = remove_unallowed_trailing_characters(name)
# 4. unify special characters
name.gsub!(/[-_.]{2,}/, "_")
name
end
name = name.to_s
def self.remove_unallowed_trailing_characters(name)
name.gsub!(/[^A-Za-z0-9]+$/, "")
if SiteSetting.unicode_usernames
name.unicode_normalize!
else
name = ActiveSupport::Inflector.transliterate(name)
end
name.gsub!(UsernameValidator.invalid_char_pattern, '_')
name.chars.map! { |c| UsernameValidator.whitelisted_char?(c) ? c : '_' } if UsernameValidator.char_whitelist_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.rightsize_username(name)
name = name[0, User.username_length.end]
name = remove_unallowed_trailing_characters(name)
name.ljust(User.username_length.begin, '1')
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

View File

@ -0,0 +1,13 @@
class ExternalSystemAvatarsValidator
def initialize(opts = {})
@opts = opts
end
def valid_value?(value)
@valid = value == "t" || !SiteSetting.unicode_usernames
end
def error_message
I18n.t("site_settings.errors.unicode_usernames_avatars") if !@valid
end
end

View File

@ -0,0 +1,13 @@
class UnicodeUsernameValidator
def initialize(opts = {})
@opts = opts
end
def valid_value?(value)
@valid = SiteSetting.external_system_avatars_enabled || value == "f"
end
def error_message
I18n.t("site_settings.errors.unicode_usernames_avatars") if !@valid
end
end

View File

@ -0,0 +1,26 @@
class UnicodeUsernameWhitelistValidator
def initialize(opts = {})
@opts = opts
end
def valid_value?(value)
@error_message = nil
return true if value.blank?
if value.match?(/^\/.*\/[imxo]*$/)
@error_message = I18n.t("site_settings.errors.unicode_username_whitelist.leading_trailing_slash")
else
begin
Regexp.new(value)
rescue RegexpError => e
@error_message = I18n.t("site_settings.errors.unicode_username_whitelist.regex_invalid", error: e.message)
end
end
@error_message.blank?
end
def error_message
@error_message
end
end

View File

@ -336,6 +336,21 @@ describe PrettyText do
expect(PrettyText.cook(". http://test/@sam")).not_to include('mention')
end
context "with Unicode usernames disabled" do
before { SiteSetting.unicode_usernames = false }
it 'does not detect mention' do
expect(PrettyText.cook("Hello @狮子")).to_not include("mention")
end
end
context "with Unicode usernames enabled" do
before { SiteSetting.unicode_usernames = true }
it 'does detect mention' do
expect(PrettyText.cook("Hello @狮子")).to match_html '<p>Hello <span class="mention">@狮子</span></p>'
end
end
end
describe "code fences" do

View File

@ -2,16 +2,10 @@ require 'rails_helper'
require 'user_name_suggester'
describe UserNameSuggester do
describe 'name heuristics' do
it 'is able to guess a decent username from an email' do
expect(UserNameSuggester.suggest('bob@bob.com')).to eq('bob')
end
end
describe '.suggest' do
before do
User.stubs(:username_length).returns(3..15)
SiteSetting.min_username_length = 3
SiteSetting.max_username_length = 15
end
it "doesn't raise an error on nil username" do
@ -26,10 +20,6 @@ describe UserNameSuggester do
expect(UserNameSuggester.suggest("Darth%^Vader")).to eq('Darth_Vader')
end
it "transliterates some characters" do
expect(UserNameSuggester.suggest("Jørn")).to eq('Jorn')
end
it 'adds 1 to an existing username' do
user = Fabricate(:user)
expect(UserNameSuggester.suggest(user.username)).to eq("#{user.username}1")
@ -39,6 +29,10 @@ describe UserNameSuggester do
expect(UserNameSuggester.suggest('a')).to eq('a11')
end
it 'is able to guess a decent username from an email' do
expect(UserNameSuggester.suggest('bob@example.com')).to eq('bob')
end
it "has a special case for me and i emails" do
expect(UserNameSuggester.suggest('me@eviltrout.com')).to eq('eviltrout')
expect(UserNameSuggester.suggest('i@eviltrout.com')).to eq('eviltrout')
@ -106,6 +100,57 @@ describe UserNameSuggester do
User.stubs(:username_length).returns(8..8)
expect(UserNameSuggester.suggest('uuuuuuu_u')).to eq('uuuuuuu1')
end
end
context "with Unicode usernames disabled" do
before { SiteSetting.unicode_usernames = false }
it "transliterates some characters" do
expect(UserNameSuggester.suggest('Jørn')).to eq('Jorn')
end
it "replaces Unicode characters" do
expect(UserNameSuggester.suggest('طائر')).to eq('111')
expect(UserNameSuggester.suggest('πουλί')).to eq('111')
end
end
context "with Unicode usernames enabled" do
before { SiteSetting.unicode_usernames = true }
it "does not transliterate" do
expect(UserNameSuggester.suggest("Jørn")).to eq('Jørn')
end
it "does not replace Unicode characters" do
expect(UserNameSuggester.suggest('طائر')).to eq('طائر')
expect(UserNameSuggester.suggest('πουλί')).to eq('πουλί')
end
it "shortens usernames by counting grapheme clusters" do
SiteSetting.max_username_length = 10
expect(UserNameSuggester.suggest('बहुत-लंबा-उपयोगकर्ता-नाम')).to eq('बहुत-लंबा-उपयो')
end
it "adds numbers if it's too short" do
expect(UserNameSuggester.suggest('鳥')).to eq('鳥11')
# grapheme cluster consists of 3 code points
expect(UserNameSuggester.suggest('য়া')).to eq('য়া11')
end
it "normalizes usernames" do
actual = 'Löwe' # NFD, "Lo\u0308we"
expected = 'Löwe' # NFC, "L\u00F6we"
expect(UserNameSuggester.suggest(actual)).to eq(expected)
end
it "does not suggest a username longer than max column size" do
SiteSetting.max_username_length = 40
expect(UserNameSuggester.suggest('য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া'))
.to eq('য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া-য়া')
end
end
end
end

View File

@ -0,0 +1,25 @@
require 'rails_helper'
describe ExternalSystemAvatarsValidator do
subject { described_class.new }
it "disallows disabling external system avatars when Unicode usernames are enabled" do
SiteSetting.unicode_usernames = true
expect(subject.valid_value?("f")).to eq(false)
expect(subject.error_message).to eq(I18n.t("site_settings.errors.unicode_usernames_avatars"))
expect(subject.valid_value?("t")).to eq(true)
expect(subject.error_message).to be_blank
end
it "allows disabling external system avatars when Unicode usernames are disabled" do
SiteSetting.unicode_usernames = false
expect(subject.valid_value?("t")).to eq(true)
expect(subject.error_message).to be_blank
expect(subject.valid_value?("f")).to eq(true)
expect(subject.error_message).to be_blank
end
end

View File

@ -0,0 +1,25 @@
require 'rails_helper'
describe UnicodeUsernameValidator do
subject { described_class.new }
it "disallows Unicode usernames when external system avatars are disabled" do
SiteSetting.external_system_avatars_enabled = false
expect(subject.valid_value?("t")).to eq(false)
expect(subject.error_message).to eq(I18n.t("site_settings.errors.unicode_usernames_avatars"))
expect(subject.valid_value?("f")).to eq(true)
expect(subject.error_message).to be_blank
end
it "allows Unicode usernames when external system avatars are enabled" do
SiteSetting.external_system_avatars_enabled = true
expect(subject.valid_value?("t")).to eq(true)
expect(subject.error_message).to be_blank
expect(subject.valid_value?("f")).to eq(true)
expect(subject.error_message).to be_blank
end
end

View File

@ -0,0 +1,36 @@
require 'rails_helper'
describe UnicodeUsernameWhitelistValidator do
subject { described_class.new }
it "allows an empty whitelist" do
expect(subject.valid_value?("")).to eq(true)
expect(subject.error_message).to be_blank
end
it "disallows leading and trailing slashes" do
expected_error = I18n.t("site_settings.errors.unicode_username_whitelist.leading_trailing_slash")
expect(subject.valid_value?("/foo/")).to eq(false)
expect(subject.error_message).to eq(expected_error)
expect(subject.valid_value?("foo/")).to eq(true)
expect(subject.error_message).to be_blank
expect(subject.valid_value?("/foo")).to eq(true)
expect(subject.error_message).to be_blank
expect(subject.valid_value?("f/o/o")).to eq(true)
expect(subject.error_message).to be_blank
expect(subject.valid_value?("/foo/i")).to eq(false)
expect(subject.error_message).to eq(expected_error)
end
it "detects invalid regular expressions" do
expected_error = I18n.t("site_settings.errors.unicode_username_whitelist.regex_invalid", error: "")
expect(subject.valid_value?("\\p{Foo}")).to eq(false)
expect(subject.error_message).to start_with(expected_error)
end
end

View File

@ -844,4 +844,13 @@ describe Group do
group = Group.find(group.id)
expect(group.flair_url).to eq("fab fa-bandcamp")
end
context "Unicode usernames and group names" do
before { SiteSetting.unicode_usernames = true }
it "should normalize the name" do
group = Fabricate(:group, name: "Bücherwurm") # NFD
expect(group.name).to eq("Bücherwurm") # NFC
end
end
end

View File

@ -477,13 +477,13 @@ describe User do
describe 'username format' do
def assert_bad(username)
user = Fabricate.build(:user)
user = Fabricate(:user)
user.username = username
expect(user.valid?).to eq(false)
end
def assert_good(username)
user = Fabricate.build(:user)
user = Fabricate(:user)
user.username = username
expect(user.valid?).to eq(true)
end
@ -494,39 +494,78 @@ describe User do
assert_good("abcde")
end
%w{ first.last
first first-last
_name first_last
context 'when Unicode usernames are disabled' do
before { SiteSetting.unicode_usernames = false }
%w{
first.last
first
first-last
_name
first_last
mc.hammer_nose
UPPERCASE
sgif
}.each do |username|
it "allows #{username}" do
assert_good(username)
}.each do |username|
it "allows #{username}" do
assert_good(username)
end
end
%w{
traildot.
has\ space
double__underscore
with%symbol
Exclamation!
@twitter
my@email.com
.tester
sa$sy
sam.json
sam.xml
sam.html
sam.htm
sam.js
sam.woff
sam.Png
sam.gif
}.each do |username|
it "disallows #{username}" do
assert_bad(username)
end
end
end
%w{
traildot.
has\ space
double__underscore
with%symbol
Exclamation!
@twitter
my@email.com
.tester
sa$sy
sam.json
sam.xml
sam.html
sam.htm
sam.js
sam.woff
sam.Png
sam.gif
}.each do |username|
it "disallows #{username}" do
assert_bad(username)
context 'when Unicode usernames are enabled' do
before { SiteSetting.unicode_usernames = true }
%w{
Джофрэй
Джо.фрэй
Джофр-эй
Д.жофрэй
_雷
_乔夫雷
}.each do |username|
it "allows #{username}" do
assert_good(username)
end
end
%w{
.Джофрэй
Джофрэй.
Джо\ фрэй
Джоф__рэй
.js
.
%
}.each do |username|
it "disallows #{username}" do
assert_bad(username)
end
end
end
end
@ -540,12 +579,12 @@ describe User do
it "should not allow saving if username is reused" do
@codinghorror.username = @user.username
expect(@codinghorror.save).to eq(false)
expect(@codinghorror.save).to eq(false)
end
it "should not allow saving if username is reused in different casing" do
@codinghorror.username = @user.username.upcase
expect(@codinghorror.save).to eq(false)
expect(@codinghorror.save).to eq(false)
end
end
@ -585,6 +624,21 @@ describe User do
Fabricate(:group, name: 'foo')
expect(User.username_available?('Foo')).to eq(false)
end
context "with Unicode usernames enabled" do
before { SiteSetting.unicode_usernames = true }
it 'returns false when the username is taken, but the Unicode normalization form is different' do
Fabricate(:user, username: "L\u00F6we") # NFC
requested_username = "Lo\u0308we" # NFD
expect(User.username_available?(requested_username)).to eq(false)
end
it 'returns false when the username is taken and the case differs' do
Fabricate(:user, username: 'LÖWE')
expect(User.username_available?('löwe')).to eq(false)
end
end
end
describe '.reserved_username?' do
@ -597,7 +651,7 @@ describe User do
end
it 'should not allow usernames matched against an expession' do
SiteSetting.reserved_usernames = 'test)|*admin*|foo*|*bar|abc.def'
SiteSetting.reserved_usernames = "test)|*admin*|foo*|*bar|abc.def|löwe|ka\u0308fer"
expect(User.reserved_username?('test')).to eq(false)
expect(User.reserved_username?('abc9def')).to eq(false)
@ -610,6 +664,11 @@ describe User do
expect(User.reserved_username?('bar.foo')).to eq(false)
expect(User.reserved_username?('foo.bar')).to eq(true)
expect(User.reserved_username?('baz.bar')).to eq(true)
expect(User.reserved_username?('LÖwe')).to eq(true)
expect(User.reserved_username?("Lo\u0308we")).to eq(true) # NFD
expect(User.reserved_username?('löwe')).to eq(true) # NFC
expect(User.reserved_username?('käfer')).to eq(true) # NFC
end
end
@ -989,6 +1048,14 @@ describe User do
expect(found_user).to eq bob
end
it 'finds users with Unicode username' do
SiteSetting.unicode_usernames = true
user = Fabricate(:user, username: 'löwe')
expect(User.find_by_username('LÖWE')).to eq(user) # NFC
expect(User.find_by_username("LO\u0308WE")).to eq(user) # NFD
expect(User.find_by_username("lo\u0308we")).to eq(user) # NFD
end
end
describe "#new_user_posting_on_first_day?" do
@ -1099,9 +1166,9 @@ describe User do
before do
Jobs.run_immediately!
PostCreator.new(Fabricate(:user),
raw: 'whatever this is a raw post',
topic_id: topic.id,
reply_to_post_number: post.post_number).create
raw: 'whatever this is a raw post',
topic_id: topic.id,
reply_to_post_number: post.post_number).create
end
it "resets the `posted_too_much` threshold" do
@ -1170,7 +1237,7 @@ describe User do
expect(user.small_avatar_url).to eq("//test.localhost/letter_avatar/sam/45/#{LetterAvatar.version}.png")
SiteSetting.external_system_avatars_enabled = true
expect(user.small_avatar_url).to eq("//test.localhost/letter_avatar_proxy/v3/letter/s/5f9b8f/45.png")
expect(user.small_avatar_url).to eq("//test.localhost/letter_avatar_proxy/v4/letter/s/5f9b8f/45.png")
end
end
@ -1193,7 +1260,7 @@ describe User do
describe "update_posts_read!" do
context "with a UserVisit record" do
let!(:user) { Fabricate(:user) }
let!(:now) { Time.zone.now }
let!(:now) { Time.zone.now }
before { user.update_last_seen!(now) }
it "with existing UserVisit record, increments the posts_read value" do
@ -1301,17 +1368,17 @@ describe User do
before do
PostCreator.new(Discourse.system_user,
title: "Welcome to our Discourse",
raw: "This is a welcome message",
archetype: Archetype.private_message,
target_usernames: [unactivated_old_with_system_pm.username],
title: "Welcome to our Discourse",
raw: "This is a welcome message",
archetype: Archetype.private_message,
target_usernames: [unactivated_old_with_system_pm.username],
).create
PostCreator.new(user,
title: "Welcome to our Discourse",
raw: "This is a welcome message",
archetype: Archetype.private_message,
target_usernames: [unactivated_old_with_human_pm.username],
title: "Welcome to our Discourse",
raw: "This is a welcome message",
archetype: Archetype.private_message,
target_usernames: [unactivated_old_with_human_pm.username],
).create
end
@ -1357,10 +1424,10 @@ describe User do
let!(:group) {
Fabricate(:group,
automatic_membership_email_domains: "bar.com|wat.com",
grant_trust_level: 1,
title: "bars and wats",
primary_group: true
automatic_membership_email_domains: "bar.com|wat.com",
grant_trust_level: 1,
title: "bars and wats",
primary_group: true
)
}
@ -1394,10 +1461,10 @@ describe User do
it "get attributes from the group" do
user = Fabricate.build(:user,
active: true,
trust_level: 0,
email: "foo@bar.com",
password: "strongpassword4Uguys"
active: true,
trust_level: 0,
email: "foo@bar.com",
password: "strongpassword4Uguys"
)
user.password_required!
@ -1659,8 +1726,8 @@ describe User do
end.first
expect(message.data[:recent]).to eq([
[notification2.id, true], [notification.id, false]
])
[notification2.id, true], [notification.id, false]
])
end
end
@ -2015,4 +2082,26 @@ describe User do
expect(Discourse.system_user).not_to be_human
end
end
context "Unicode username" do
before { SiteSetting.unicode_usernames = true }
let(:user) { Fabricate(:user, username: "Lo\u0308we") } # NFD
it "normalizes usernames" do
expect(user.username).to eq("L\u00F6we") # NFC
expect(user.username_lower).to eq("l\u00F6we") # NFC
end
describe ".username_exists?" do
it "normalizes username before executing query" do
expect(User.username_exists?(user.username)).to eq(true)
expect(User.username_exists?("Lo\u0308we")).to eq(true) # NFD
expect(User.username_exists?("L\u00F6we")).to eq(true) # NFC
expect(User.username_exists?("LO\u0308WE")).to eq(true) # NFD
expect(User.username_exists?("l\u00D6wE")).to eq(true) # NFC
expect(User.username_exists?("foo")).to eq(false)
end
end
end
end

View File

@ -1,17 +1,205 @@
require 'rails_helper'
describe UsernameValidator do
context "#valid_format?" do
it 'returns true when username is both valid and available' do
expect(UsernameValidator.new('Available').valid_format?).to eq true
def expect_valid(*usernames)
usernames.each do |username|
validator = UsernameValidator.new(username)
aggregate_failures do
expect(validator.valid_format?).to eq(true), "expected '#{username}' to be valid"
expect(validator.errors).to be_empty
end
end
end
def expect_invalid(*usernames, error_message:)
usernames.each do |username|
validator = UsernameValidator.new(username)
aggregate_failures do
expect(validator.valid_format?).to eq(false), "expected '#{username}' to be invalid"
expect(validator.errors).to include(error_message)
end
end
end
shared_examples 'ASCII username' do
it 'is invalid when the username is blank' do
expect_invalid('', error_message: I18n.t(:'user.username.blank'))
end
it 'returns true when the username is valid but not available' do
expect(UsernameValidator.new(Fabricate(:user).username).valid_format?).to eq true
it 'is invalid when the username is too short' do
SiteSetting.min_username_length = 4
expect_invalid('a', 'ab', 'abc',
error_message: I18n.t(:'user.username.short', min: 4))
end
it 'returns false when the username is not valid' do
expect(UsernameValidator.new('not valid.name').valid_format?).to eq false
it 'is valid when the username has the minimum lenght' do
SiteSetting.min_username_length = 4
expect_valid('abcd')
end
it 'is invalid when the username is too long' do
SiteSetting.max_username_length = 8
expect_invalid('abcdefghi',
error_message: I18n.t(:'user.username.long', max: 8))
end
it 'is valid when the username has the maximum lenght' do
SiteSetting.max_username_length = 8
expect_valid('abcdefgh')
end
it 'is valid when the username contains alphanumeric characters, dots, underscores and dashes' do
expect_valid('ab-cd.123_ABC-xYz')
end
it 'is invalid when the username contains non-alphanumeric characters other than dots, underscores and dashes' do
expect_invalid('abc|', 'a#bc', 'abc xyz',
error_message: I18n.t(:'user.username.characters'))
end
it 'is valid when the username starts with a alphanumeric character or underscore' do
expect_valid('abcd', '1abc', '_abc')
end
it 'is invalid when the username starts with a dot or dash' do
expect_invalid('.abc', '-abc',
error_message: I18n.t(:'user.username.must_begin_with_alphanumeric_or_underscore'))
end
it 'is valid when the username ends with a alphanumeric character' do
expect_valid('abcd', 'abc9')
end
it 'is invalid when the username ends with an underscore, a dot or dash' do
expect_invalid('abc_', 'abc.', 'abc-',
error_message: I18n.t(:'user.username.must_end_with_alphanumeric'))
end
it 'is invalid when the username contains consecutive underscores, dots or dashes' do
expect_invalid('a__bc', 'a..bc', 'a--bc',
error_message: I18n.t(:'user.username.must_not_contain_two_special_chars_in_seq'))
end
it 'is invalid when the username ends with certain file extensions' do
expect_invalid('abc.json', 'abc.png',
error_message: I18n.t(:'user.username.must_not_end_with_confusing_suffix'))
end
end
context 'when Unicode usernames are disabled' do
before { SiteSetting.unicode_usernames = false }
include_examples 'ASCII username'
it 'is invalid when the username contains non-ASCII characters except dots, underscores and dashes' do
expect_invalid('abcö', 'abc象',
error_message: I18n.t(:'user.username.characters'))
end
end
context 'when Unicode usernames are enabled' do
before { SiteSetting.unicode_usernames = true }
context "ASCII usernames" do
include_examples 'ASCII username'
end
context "Unicode usernames" do
before { SiteSetting.min_username_length = 1 }
it 'is invalid when the username is too short' do
SiteSetting.min_username_length = 3
expect_invalid('鳥', 'পাখি',
error_message: I18n.t(:'user.username.short', min: 3))
end
it 'is valid when the username has the minimum lenght' do
SiteSetting.min_username_length = 2
expect_valid('পাখি', 'طائر')
end
it 'is invalid when the username is too long' do
SiteSetting.max_username_length = 8
expect_invalid(וטב_עצים', 'Holzfäller',
error_message: I18n.t(:'user.username.long', max: 8))
end
it 'is valid when the username has the maximum lenght' do
SiteSetting.max_username_length = 9
expect_valid('Дровосек', 'چوب-لباسی', 'தமிழ்-தமிழ்')
end
it 'is invalid when the username has too many Unicode codepoints' do
SiteSetting.max_username_length = 30
expect_invalid('য়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়ায়া',
error_message: I18n.t(:'user.username.too_long'))
end
it 'is valid when the username contains Unicode letters' do
expect_valid('鳥', 'طائر', 'թռչուն', 'πουλί', 'পাখি', 'madár', '새',
'پرنده', 'птица', 'fågel', 'นก', 'پرندے', 'ציפור')
end
it 'is valid when the username contains numbers from the Nd or Nl Unicode category' do
expect_valid('arabic٠١٢٣٤٥٦٧٨٩', 'bengali১২৩৫৬৮৯', 'romanⅥ', 'hangzhou〺')
end
it 'is invalid when the username contains numbers from the No Unicode category' do
expect_invalid('circled㊸', 'fraction¾',
error_message: I18n.t(:'user.username.characters'))
end
it 'is invalid when the username contains symbols or emojis' do
SiteSetting.min_username_length = 1
expect_invalid('©', '⇨', '“', '±', '‿', '😃', '🚗',
error_message: I18n.t(:'user.username.characters'))
end
it 'is invalid when the username contains zero width join characters' do
expect_invalid('ണ്‍', 'র‌্যাম',
error_message: I18n.t(:'user.username.characters'))
end
it 'is valid when the username ends with a Unicode Mark' do
expect_valid('தமிழ்')
end
it 'allows all Unicode letters when the whitelist is empty' do
expect_valid('鳥')
end
context "with Unicode whitelist" do
before { SiteSetting.unicode_username_character_whitelist = "[äöüÄÖÜß]" }
it 'is invalid when username contains non-whitelisted letters' do
expect_invalid('鳥', 'francès', error_message: I18n.t(:'user.username.characters'))
end
it 'is valid when username contains only whitelisted letters' do
expect_valid('Löwe', 'Ötzi')
end
it 'is valid when username contains only ASCII letters and numbers regardless of whitelist' do
expect_valid('a-z_A-Z.0-9')
end
it 'is valid after resetting the site setting' do
SiteSetting.unicode_username_character_whitelist = ""
expect_valid('鳥')
end
end
end
end
end

View File

@ -97,7 +97,7 @@ describe UsernameChanger do
block.call(post) if block
UsernameChanger.change(user, 'bar')
UsernameChanger.change(user, args[:target_username] || 'bar')
post.reload
end
@ -130,13 +130,13 @@ describe UsernameChanger do
expect(post.raw).to eq(".@bar -@bar %@bar _@bar ,@bar ;@bar @@bar")
expect(post.cooked).to match_html(<<~HTML)
<p>.<a class="mention" href="/u/bar">@bar</a>
-<a class="mention" href="/u/bar">@bar</a>
%<a class="mention" href="/u/bar">@bar</a>
_<a class="mention" href="/u/bar">@bar</a>
,<a class="mention" href="/u/bar">@bar</a>
;<a class="mention" href="/u/bar">@bar</a>
@<a class="mention" href="/u/bar">@bar</a></p>
<p>.<a class="mention" href="/u/bar">@bar</a>
-<a class="mention" href="/u/bar">@bar</a>
%<a class="mention" href="/u/bar">@bar</a>
_<a class="mention" href="/u/bar">@bar</a>
,<a class="mention" href="/u/bar">@bar</a>
;<a class="mention" href="/u/bar">@bar</a>
@<a class="mention" href="/u/bar">@bar</a></p>
HTML
end
@ -147,38 +147,55 @@ describe UsernameChanger do
expect(post.cooked).to eq(%Q(<p>“<a class="mention" href="/u/bar">@bar</a>” <a class="mention" href="/u/bar">@bar</a></p>))
end
it 'replaces mentions when there are trailing symbols' do
post = create_post_and_change_username(raw: "@foo. @foo, @foo: @foo; @foo-")
it 'replaces Markdown formatted mentions' do
post = create_post_and_change_username(raw: "**@foo** *@foo* _@foo_ ~~@foo~~")
expect(post.raw).to eq("@bar. @bar, @bar: @bar; @bar-")
expect(post.raw).to eq("**@bar** *@bar* _@bar_ ~~@bar~~")
expect(post.cooked).to match_html(<<~HTML)
<p><a class="mention" href="/u/bar">@bar</a>.
<a class="mention" href="/u/bar">@bar</a>,
<a class="mention" href="/u/bar">@bar</a>:
<a class="mention" href="/u/bar">@bar</a>;
<a class="mention" href="/u/bar">@bar</a>-</p>
<p><strong><a class="mention" href="/u/bar">@bar</a></strong>
<em><a class="mention" href="/u/bar">@bar</a></em>
<em><a class="mention" href="/u/bar">@bar</a></em>
<s><a class="mention" href="/u/bar">@bar</a></s></p>
HTML
end
it 'does not replace mention when followed by an underscore' do
post = create_post_and_change_username(raw: "@foo_")
it 'replaces mentions when there are trailing symbols' do
post = create_post_and_change_username(raw: "@foo. @foo, @foo: @foo; @foo_ @foo-")
expect(post.raw).to eq("@foo_")
expect(post.cooked).to eq(%Q(<p><span class="mention">@foo_</span></p>))
expect(post.raw).to eq("@bar. @bar, @bar: @bar; @bar_ @bar-")
expect(post.cooked).to match_html(<<~HTML)
<p><a class="mention" href="/u/bar">@bar</a>.
<a class="mention" href="/u/bar">@bar</a>,
<a class="mention" href="/u/bar">@bar</a>:
<a class="mention" href="/u/bar">@bar</a>;
<a class="mention" href="/u/bar">@bar</a>_
<a class="mention" href="/u/bar">@bar</a>-</p>
HTML
end
it 'does not replace mention in cooked when mention contains a trailing underscore' do
# Older versions of Discourse detected a trailing underscore as part of a username.
# That doesn't happen anymore, so we need to do create the `cooked` for this test manually.
post = create_post_and_change_username(raw: "@foobar @foo") do |p|
p.update_columns(raw: p.raw.gsub("@foobar", "@foo_"), cooked: p.cooked.gsub("@foobar", "@foo_"))
end
expect(post.raw).to eq("@bar_ @bar")
expect(post.cooked).to eq(%Q(<p><span class="mention">@foo_</span> <a class="mention" href="/u/bar">@bar</a></p>))
end
it 'does not replace mentions when there are leading alphanumeric chars' do
post = create_post_and_change_username(raw: "a@foo 2@foo")
post = create_post_and_change_username(raw: "@foo a@foo 2@foo")
expect(post.raw).to eq("a@foo 2@foo")
expect(post.cooked).to eq(%Q(<p>a@foo 2@foo</p>))
expect(post.raw).to eq("@bar a@foo 2@foo")
expect(post.cooked).to eq(%Q(<p><a class="mention" href="/u/bar">@bar</a> a@foo 2@foo</p>))
end
it 'does not replace username within email address' do
post = create_post_and_change_username(raw: "mail@foo.com")
post = create_post_and_change_username(raw: "@foo mail@foo.com")
expect(post.raw).to eq("mail@foo.com")
expect(post.cooked).to eq(%Q(<p><a href="mailto:mail@foo.com">mail@foo.com</a></p>))
expect(post.raw).to eq("@bar mail@foo.com")
expect(post.cooked).to eq(%Q(<p><a class="mention" href="/u/bar">@bar</a> <a href="mailto:mail@foo.com">mail@foo.com</a></p>))
end
it 'does not replace username in a mention of a similar username' do
@ -191,11 +208,11 @@ describe UsernameChanger do
expect(post.raw).to eq("@bar @foobar @foo-bar @foo_bar @foo1")
expect(post.cooked).to match_html(<<~HTML)
<p><a class="mention" href="/u/bar">@bar</a>
<a class="mention" href="/u/foobar">@foobar</a>
<a class="mention" href="/u/foo-bar">@foo-bar</a>
<a class="mention" href="/u/foo_bar">@foo_bar</a>
<a class="mention" href="/u/foo1">@foo1</a></p>
<p><a class="mention" href="/u/bar">@bar</a>
<a class="mention" href="/u/foobar">@foobar</a>
<a class="mention" href="/u/foo-bar">@foo-bar</a>
<a class="mention" href="/u/foo_bar">@foo_bar</a>
<a class="mention" href="/u/foo1">@foo1</a></p>
HTML
end
@ -253,6 +270,44 @@ describe UsernameChanger do
expect(post.raw).to eq('<a class="mention">@bar</a> and <a class="mention">@someuser</a>')
expect(post.cooked).to match_html('<p><a class="mention">@bar</a> and <a class="mention">@someuser</a></p>')
end
context "Unicode usernames" do
before { SiteSetting.unicode_usernames = true }
let(:user) { Fabricate(:user, username: 'թռչուն') }
it 'it correctly updates mentions' do
post = create_post_and_change_username(raw: "Hello @թռչուն", target_username: 'птица')
expect(post.raw).to eq("Hello @птица")
expect(post.cooked).to eq(%Q(<p>Hello <a class="mention" href="/u/%D0%BF%D1%82%D0%B8%D1%86%D0%B0">@птица</a></p>))
end
it 'does not replace mentions when there are leading alphanumeric chars' do
post = create_post_and_change_username(raw: "Hello @թռչուն 鳥@թռչուն 2@թռչուն ٩@թռչուն", target_username: 'птица')
expect(post.raw).to eq("Hello @птица 鳥@թռչուն 2@թռչուն ٩@թռչուն")
expect(post.cooked).to eq(%Q(<p>Hello <a class="mention" href="/u/%D0%BF%D1%82%D0%B8%D1%86%D0%B0">@птица</a> 鳥@թռչուն 2@թռչուն ٩@թռչուն</p>))
end
it 'does not replace username in a mention of a similar username' do
Fabricate(:user, username: 'թռչուն鳥')
Fabricate(:user, username: 'թռչուն-鳥')
Fabricate(:user, username: ռչուն_鳥')
Fabricate(:user, username: 'թռչուն٩')
post = create_post_and_change_username(raw: "@թռչուն @թռչուն鳥 @թռչուն-鳥 @թռչուն_鳥 @թռչուն٩", target_username: 'птица')
expect(post.raw).to eq("@птица @թռչուն鳥 @թռչուն-鳥 @թռչուն_鳥 @թռչուն٩")
expect(post.cooked).to match_html(<<~HTML)
<p><a class="mention" href="/u/%D0%BF%D1%82%D0%B8%D1%86%D0%B0">@птица</a>
<a class="mention" href="/u/%D5%A9%D5%BC%D5%B9%D5%B8%D6%82%D5%B6%E9%B3%A5">@թռչուն</a>
<a class="mention" href="/u/%D5%A9%D5%BC%D5%B9%D5%B8%D6%82%D5%B6-%E9%B3%A5">@թռչուն-</a>
<a class="mention" href="/u/%D5%A9%D5%BC%D5%B9%D5%B8%D6%82%D5%B6_%E9%B3%A5">@թռչուն_鳥</a>
<a class="mention" href="/u/%D5%A9%D5%BC%D5%B9%D5%B8%D6%82%D5%B6%D9%A9">@թռչուն٩</a></p>
HTML
end
end
end
context 'quotes' do

View File

@ -94,7 +94,8 @@ Discourse.SiteSettingsOriginal = {
emoji_set: "emoji_one",
desktop_category_page_style: "categories_and_latest_topics",
enable_mentions: true,
enable_personal_messages: true
enable_personal_messages: true,
unicode_usernames: false
};
Discourse.SiteSettings = jQuery.extend(
true,

View File

@ -555,6 +555,48 @@ QUnit.test("Mentions", assert => {
'<p><small>a <span class="mention">@sam</span> c</small></p>',
"it allows mentions within HTML tags"
);
assert.cooked(
"@_sam @1sam @ab-cd.123_ABC-xYz @sam1",
'<p><span class="mention">@_sam</span> <span class="mention">@1sam</span> <span class="mention">@ab-cd.123_ABC-xYz</span> <span class="mention">@sam1</span></p>',
"it detects mentions of valid usernames"
);
assert.cooked(
"@.sam @-sam @sam. @sam_ @sam-",
'<p>@.sam @-sam <span class="mention">@sam</span>. <span class="mention">@sam</span>_ <span class="mention">@sam</span>-</p>',
"it does not detect mentions of invalid usernames"
);
assert.cookedOptions(
"Hello @狮子",
{ siteSettings: { unicode_usernames: false } },
"<p>Hello @狮子</p>",
"it does not detect mentions of Unicode usernames"
);
});
QUnit.test("Mentions - Unicode usernames enabled", assert => {
assert.cookedOptions(
"Hello @狮子",
{ siteSettings: { unicode_usernames: true } },
'<p>Hello <span class="mention">@狮子</span></p>',
"it detects mentions of Unicode usernames"
);
assert.cookedOptions(
"@狮子 @_狮子 @1狮子 @狮-ø.١٢٣_Ö-ழ் @狮子1",
{ siteSettings: { unicode_usernames: true } },
'<p><span class="mention">@狮子</span> <span class="mention">@_狮子</span> <span class="mention">@1狮子</span> <span class="mention">@狮-ø.١٢٣_Ö-ழ்</span> <span class="mention">@狮子1</span></p>',
"it detects mentions of valid Unicode usernames"
);
assert.cookedOptions(
"@.狮子 @-狮子 @狮子. @狮子_ @狮子-",
{ siteSettings: { unicode_usernames: true } },
'<p>@.狮子 @-狮子 <span class="mention">@狮子</span>. <span class="mention">@狮子</span>_ <span class="mention">@狮子</span>-</p>',
"it does not detect mentions of invalid Unicode usernames"
);
});
QUnit.test("Mentions - disabled", assert => {