users: add stricter username rules.
Add stricter username rules: * Only allow usernames to contain basic letters, numbers, CJK characters, underscores, dashes and periods. * Don't allow names to start or end with punctuation. * Don't allow names to have multiple underscores in a row. * Don't allow active users to have names that look like deleted users (e.g. "user_1234"). * Don't allow emoji or any other Unicode characters except for Chinese, Japanese, and Korean characters. CJK characters are currently grandfathered in but will be disallowed in the future. Users with an invalid name will be shown a permanent sitewide banner until they change their name.
This commit is contained in:
@@ -7,14 +7,33 @@
|
|||||||
#
|
#
|
||||||
# @see https://guides.rubyonrails.org/active_record_validations.html#custom-validators
|
# @see https://guides.rubyonrails.org/active_record_validations.html#custom-validators
|
||||||
class UserNameValidator < ActiveModel::EachValidator
|
class UserNameValidator < ActiveModel::EachValidator
|
||||||
def validate_each(rec, attr, value)
|
ALLOWED_PUNCTUATION = "_.-" # All other punctuation characters are forbidden
|
||||||
name = value
|
|
||||||
|
|
||||||
rec.errors.add(attr, "already exists") if User.find_by_name(name).present?
|
def validate_each(rec, attr, name)
|
||||||
rec.errors.add(attr, "must be more than 1 character long") if name.length <= 1
|
forbidden_characters = name.delete(ALLOWED_PUNCTUATION).chars.grep(/[[:punct:]]/).uniq
|
||||||
rec.errors.add(attr, "must be less than 25 characters long") if name.length >= 25
|
|
||||||
rec.errors.add(attr, "cannot have whitespace or colons") if name =~ /[[:space:]]|:/
|
if rec.new_record? && User.find_by_name(name).present?
|
||||||
rec.errors.add(attr, "cannot begin or end with an underscore") if name =~ /\A_|_\z/
|
rec.errors.add(attr, "already exists")
|
||||||
rec.errors.add(attr, "is not allowed") if name =~ Regexp.union(Danbooru.config.user_name_blacklist)
|
elsif name.length <= 1
|
||||||
|
rec.errors.add(attr, "must be more than 1 character long")
|
||||||
|
elsif name.length >= 25
|
||||||
|
rec.errors.add(attr, "must be less than 25 characters long")
|
||||||
|
elsif name =~ /[[:space:]]/
|
||||||
|
rec.errors.add(attr, "can't contain whitespace")
|
||||||
|
elsif name =~ /\A[[:punct:]]/
|
||||||
|
rec.errors.add(attr, "can't start with '#{name.first}'")
|
||||||
|
elsif name =~ /[[:punct:]]\z/
|
||||||
|
rec.errors.add(attr, "can't end with '#{name.last}'")
|
||||||
|
elsif name =~ /__/
|
||||||
|
rec.errors.add(attr, "can't contain multiple underscores in a row")
|
||||||
|
elsif forbidden_characters.present?
|
||||||
|
rec.errors.add(attr, "can't contain #{forbidden_characters.map { |c| "'#{c}'" }.to_sentence}")
|
||||||
|
elsif name !~ /\A([a-zA-Z0-9]|\p{Han}|\p{Hangul}|\p{Hiragana}|\p{Katakana}|[#{ALLOWED_PUNCTUATION}])+\z/
|
||||||
|
rec.errors.add(attr, "must contain only basic letters or numbers")
|
||||||
|
elsif name =~ /\Auser_\d+\z/i
|
||||||
|
rec.errors.add(attr, "can't be the same as a deleted user")
|
||||||
|
elsif name =~ Regexp.union(Danbooru.config.user_name_blacklist)
|
||||||
|
rec.errors.add(attr, "is not allowed")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
|
extend Memoist
|
||||||
|
|
||||||
class PrivilegeError < StandardError; end
|
class PrivilegeError < StandardError; end
|
||||||
|
|
||||||
module Levels
|
module Levels
|
||||||
@@ -204,6 +206,18 @@ class User < ApplicationRecord
|
|||||||
errors.add(:base, "Can't enable privacy mode without a Gold account")
|
errors.add(:base, "Can't enable privacy mode without a Gold account")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def name_errors
|
||||||
|
User.validators_on(:name).each do |validator|
|
||||||
|
validator.validate_each(self, :name, name)
|
||||||
|
end
|
||||||
|
|
||||||
|
errors
|
||||||
|
end
|
||||||
|
|
||||||
|
def name_invalid?
|
||||||
|
name_errors.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
concerning :AuthenticationMethods do
|
concerning :AuthenticationMethods do
|
||||||
@@ -695,4 +709,6 @@ class User < ApplicationRecord
|
|||||||
def self.available_includes
|
def self.available_includes
|
||||||
[:inviter]
|
[:inviter]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
memoize :name_errors
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -104,6 +104,13 @@
|
|||||||
<%= render "users/dmail_notice" %>
|
<%= render "users/dmail_notice" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if !CurrentUser.user.is_anonymous? && CurrentUser.user.name_invalid? %>
|
||||||
|
<div class="notice notice-error notice-large" id="invalid-name-notice">
|
||||||
|
<h2>Action required </h2>
|
||||||
|
<div>You must <%= link_to "change your username", new_user_name_change_request_path %> to continue using <%= Danbooru.config.canonical_app_name %>.</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="notice notice-info" id="notice" style="<%= "display: none;" unless flash[:notice] %>">
|
<div class="notice notice-info" id="notice" style="<%= "display: none;" unless flash[:notice] %>">
|
||||||
<span class="prose"><%= format_text(flash[:notice], inline: true) %>.</span>
|
<span class="prose"><%= format_text(flash[:notice], inline: true) %>.</span>
|
||||||
<a href="#" id="close-notice-link">close</a>
|
<a href="#" id="close-notice-link">close</a>
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
<div id="c-user-name-change-requests">
|
<div id="c-user-name-change-requests">
|
||||||
<div id="a-new">
|
<div id="a-new" class="fixed-width-container">
|
||||||
<h1>Name Change Request</h1>
|
<h1>Change Name</h1>
|
||||||
|
|
||||||
<p>You can request a name change once per week. Your previous names will still
|
<% if CurrentUser.user.name_invalid? %>
|
||||||
be visible on your profile to other Danbooru members, but they won't be visible
|
<p> Your current username is invalid. You must change your username to continue
|
||||||
to search engines.</p>
|
using <%= Danbooru.config.canonical_app_name %>.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Current name: <b><%= CurrentUser.user.name %></b>.<br>
|
||||||
|
Error: <%= CurrentUser.user.name_errors.full_messages.join(". ").html_safe %>.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="prose mt-4 mb-4">
|
||||||
|
<h6>Rules</h6>
|
||||||
|
<ul>
|
||||||
|
<li>Names can contain only letters, numbers, underscore ('_'), period ('.'), and dash ('-') characters.</li>
|
||||||
|
<li>Names must start and end with a letter or number.</li>
|
||||||
|
<li>Names must be less than 25 characters long.</li>
|
||||||
|
<li>Names can't insult or impersonate other users.</li>
|
||||||
|
<li>Names are case-insensitive.</li>
|
||||||
|
<li>Your previous names will be visible on your profile to other Danbooru members, but they won't be visible to search engines.</li>
|
||||||
|
<li>You can't change your name more than once per week.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%= edit_form_for(@change_request) do |f| %>
|
<%= edit_form_for(@change_request) do |f| %>
|
||||||
<%= f.input :desired_name, label: "Name" %>
|
<%= f.input :desired_name, label: "New name" %>
|
||||||
<%= f.input :desired_name_confirmation, label: "Confirm name" %>
|
<%= f.input :desired_name_confirmation, label: "Confirm name" %>
|
||||||
<%= f.submit "Submit", "data-confirm": "Are you sure you want to change your name?" %>
|
<%= f.submit "Submit", "data-confirm": "Are you sure you want to change your name?" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ class ApplicationControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "when a user has an invalid username" do
|
||||||
|
should "show a warning banner" do
|
||||||
|
@user = create(:user)
|
||||||
|
@user.update_columns(name: "foo__bar")
|
||||||
|
|
||||||
|
get_auth posts_path, @user
|
||||||
|
assert_response :success
|
||||||
|
assert_select "#invalid-name-notice"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "on api authentication" do
|
context "on api authentication" do
|
||||||
setup do
|
setup do
|
||||||
@user = create(:user, password: "password")
|
@user = create(:user, password: "password")
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ class UserNameChangeRequestsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
get_auth new_user_name_change_request_path, @user
|
get_auth new_user_name_change_request_path, @user
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
|
should "render when the current user's name is invalid" do
|
||||||
|
@user.update_columns(name: "foo__bar")
|
||||||
|
get_auth new_user_name_change_request_path, @user
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "create action" do
|
context "create action" do
|
||||||
|
|||||||
@@ -148,27 +148,51 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
context "name" do
|
context "name" do
|
||||||
should "not contain whitespace" do
|
should "not contain whitespace" do
|
||||||
# U+2007: https://en.wikipedia.org/wiki/Figure_space
|
# U+2007: https://en.wikipedia.org/wiki/Figure_space
|
||||||
user = FactoryBot.build(:user, :name => "foo\u2007bar")
|
user = build(:user, name: "foo\u2007bar")
|
||||||
user.save
|
user.save
|
||||||
assert_equal(["Name cannot have whitespace or colons"], user.errors.full_messages)
|
assert_equal(["Name can't contain whitespace"], user.errors.full_messages)
|
||||||
|
end
|
||||||
|
|
||||||
|
should "be less than 25 characters long" do
|
||||||
|
user = build(:user, name: "a"*25)
|
||||||
|
user.save
|
||||||
|
assert_equal(["Name must be less than 25 characters long"], user.errors.full_messages)
|
||||||
end
|
end
|
||||||
|
|
||||||
should "not contain a colon" do
|
should "not contain a colon" do
|
||||||
user = FactoryBot.build(:user, :name => "a:b")
|
user = build(:user, name: "a:b")
|
||||||
user.save
|
user.save
|
||||||
assert_equal(["Name cannot have whitespace or colons"], user.errors.full_messages)
|
assert_equal(["Name can't contain ':'"], user.errors.full_messages)
|
||||||
end
|
end
|
||||||
|
|
||||||
should "not begin with an underscore" do
|
should "not begin with an underscore" do
|
||||||
user = FactoryBot.build(:user, :name => "_x")
|
user = build(:user, name: "_x")
|
||||||
user.save
|
user.save
|
||||||
assert_equal(["Name cannot begin or end with an underscore"], user.errors.full_messages)
|
assert_equal(["Name can't start with '_'"], user.errors.full_messages)
|
||||||
end
|
end
|
||||||
|
|
||||||
should "not end with an underscore" do
|
should "not end with an underscore" do
|
||||||
user = FactoryBot.build(:user, :name => "x_")
|
user = build(:user, name: "x_")
|
||||||
user.save
|
user.save
|
||||||
assert_equal(["Name cannot begin or end with an underscore"], user.errors.full_messages)
|
assert_equal(["Name can't end with '_'"], user.errors.full_messages)
|
||||||
|
end
|
||||||
|
|
||||||
|
should "not contain consecutive underscores" do
|
||||||
|
user = build(:user, name: "x__y")
|
||||||
|
user.save
|
||||||
|
assert_equal(["Name can't contain multiple underscores in a row"], user.errors.full_messages)
|
||||||
|
end
|
||||||
|
|
||||||
|
should "not allow non-ASCII characters" do
|
||||||
|
user = build(:user, name: "Pokémon")
|
||||||
|
user.save
|
||||||
|
assert_equal(["Name must contain only basic letters or numbers"], user.errors.full_messages)
|
||||||
|
end
|
||||||
|
|
||||||
|
should "not be in the same format as a deleted user" do
|
||||||
|
user = build(:user, name: "user_1234")
|
||||||
|
user.save
|
||||||
|
assert_equal(["Name can't be the same as a deleted user"], user.errors.full_messages)
|
||||||
end
|
end
|
||||||
|
|
||||||
should "not allow blacklisted names" do
|
should "not allow blacklisted names" do
|
||||||
@@ -177,11 +201,6 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
user.save
|
user.save
|
||||||
assert_equal(["Name is not allowed"], user.errors.full_messages)
|
assert_equal(["Name is not allowed"], user.errors.full_messages)
|
||||||
end
|
end
|
||||||
|
|
||||||
should "be updated" do
|
|
||||||
@user = FactoryBot.create(:user)
|
|
||||||
@user.update_attribute(:name, "danzig")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context "searching for users by name" do
|
context "searching for users by name" do
|
||||||
@@ -206,10 +225,14 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
assert_nil(User.name_to_id("does_not_exist"))
|
assert_nil(User.name_to_id("does_not_exist"))
|
||||||
end
|
end
|
||||||
|
|
||||||
should "work for names containing asterisks or backlashes" do
|
should "work for names containing asterisks or backslashes" do
|
||||||
@user1 = create(:user, name: "user*1")
|
@user1 = build(:user, name: "user*1")
|
||||||
@user2 = create(:user, name: "user*2")
|
@user2 = build(:user, name: "user*2")
|
||||||
@user3 = create(:user, name: "user\*3")
|
@user3 = build(:user, name: "user\*3")
|
||||||
|
|
||||||
|
@user1.save(validate: false)
|
||||||
|
@user2.save(validate: false)
|
||||||
|
@user3.save(validate: false)
|
||||||
|
|
||||||
assert_equal(@user1.id, User.find_by_name("user*1").id)
|
assert_equal(@user1.id, User.find_by_name("user*1").id)
|
||||||
assert_equal(@user2.id, User.find_by_name("user*2").id)
|
assert_equal(@user2.id, User.find_by_name("user*2").id)
|
||||||
@@ -259,9 +282,13 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
context "when searched by name" do
|
context "when searched by name" do
|
||||||
should "match wildcards" do
|
should "match wildcards" do
|
||||||
user1 = FactoryBot.create(:user, :name => "foo")
|
user1 = build(:user, name: "foo")
|
||||||
user2 = FactoryBot.create(:user, :name => "foo*bar")
|
user2 = build(:user, name: "foo*bar")
|
||||||
user3 = FactoryBot.create(:user, :name => "bar\*baz")
|
user3 = build(:user, name: "bar\*baz")
|
||||||
|
|
||||||
|
user1.save(validate: false)
|
||||||
|
user2.save(validate: false)
|
||||||
|
user3.save(validate: false)
|
||||||
|
|
||||||
assert_equal([user2.id, user1.id], User.search(name: "foo*").map(&:id))
|
assert_equal([user2.id, user1.id], User.search(name: "foo*").map(&:id))
|
||||||
assert_equal([user2.id], User.search(name: "foo\*bar").map(&:id))
|
assert_equal([user2.id], User.search(name: "foo\*bar").map(&:id))
|
||||||
|
|||||||
Reference in New Issue
Block a user