diff --git a/app/controllers/admin/username_blocks_controller.rb b/app/controllers/admin/username_blocks_controller.rb
new file mode 100644
index 00000000000..22ac9408178
--- /dev/null
+++ b/app/controllers/admin/username_blocks_controller.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+class Admin::UsernameBlocksController < Admin::BaseController
+ before_action :set_username_block, only: [:edit, :update]
+
+ def index
+ authorize :username_block, :index?
+ @username_blocks = UsernameBlock.order(username: :asc).page(params[:page])
+ @form = Form::UsernameBlockBatch.new
+ end
+
+ def batch
+ authorize :username_block, :index?
+
+ @form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button))
+ @form.save
+ rescue ActionController::ParameterMissing
+ flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected')
+ rescue Mastodon::NotPermittedError
+ flash[:alert] = I18n.t('admin.username_blocks.not_permitted')
+ ensure
+ redirect_to admin_username_blocks_path
+ end
+
+ def new
+ authorize :username_block, :create?
+ @username_block = UsernameBlock.new(exact: true)
+ end
+
+ def edit
+ authorize @username_block, :update?
+ end
+
+ def create
+ authorize :username_block, :create?
+
+ @username_block = UsernameBlock.new(resource_params)
+
+ if @username_block.save
+ log_action :create, @username_block
+ redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg')
+ else
+ render :new
+ end
+ end
+
+ def update
+ authorize @username_block, :update?
+
+ if @username_block.update(resource_params)
+ log_action :update, @username_block
+ redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg')
+ else
+ render :new
+ end
+ end
+
+ private
+
+ def set_username_block
+ @username_block = UsernameBlock.find(params[:id])
+ end
+
+ def form_username_block_batch_params
+ params
+ .expect(form_username_block_batch: [username_block_ids: []])
+ end
+
+ def resource_params
+ params
+ .expect(username_block: [:username, :comparison, :allow_with_approval])
+ end
+
+ def action_from_button
+ 'delete' if params[:delete]
+ end
+end
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 859f9246876..4a55a36ecd1 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
end
when 'UserRole'
link_to log.human_identifier, admin_roles_path(log.target_id)
+ when 'UsernameBlock'
+ link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
when 'Report'
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
diff --git a/app/javascript/material-icons/400-24px/supervised_user_circle_off-fill.svg b/app/javascript/material-icons/400-24px/supervised_user_circle_off-fill.svg
new file mode 100644
index 00000000000..1daf50f858a
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/supervised_user_circle_off-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/supervised_user_circle_off.svg b/app/javascript/material-icons/400-24px/supervised_user_circle_off.svg
new file mode 100644
index 00000000000..060c515ae18
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/supervised_user_circle_off.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb
index fd6b4289cee..a5bdd97420f 100644
--- a/app/models/admin/action_log_filter.rb
+++ b/app/models/admin/action_log_filter.rb
@@ -77,6 +77,9 @@ class Admin::ActionLogFilter
update_user_role: { target_type: 'UserRole', action: 'update' }.freeze,
update_ip_block: { target_type: 'IpBlock', action: 'update' }.freeze,
unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze,
+ create_username_block: { target_type: 'UsernameBlock', action: 'create' }.freeze,
+ update_username_block: { target_type: 'UsernameBlock', action: 'update' }.freeze,
+ destroy_username_block: { target_type: 'UsernameBlock', action: 'destroy' }.freeze,
}.freeze
attr_reader :params
diff --git a/app/models/form/username_block_batch.rb b/app/models/form/username_block_batch.rb
new file mode 100644
index 00000000000..f490391159a
--- /dev/null
+++ b/app/models/form/username_block_batch.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class Form::UsernameBlockBatch < Form::BaseBatch
+ attr_accessor :username_block_ids
+
+ def save
+ case action
+ when 'delete'
+ delete!
+ end
+ end
+
+ private
+
+ def username_blocks
+ @username_blocks ||= UsernameBlock.where(id: username_block_ids)
+ end
+
+ def delete!
+ verify_authorization(:destroy?)
+
+ username_blocks.each do |username_block|
+ username_block.destroy
+ log_action :destroy, username_block
+ end
+ end
+
+ def verify_authorization(permission)
+ username_blocks.each { |username_block| authorize(username_block, permission) }
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 2ba8c2926d9..0c876c64bf8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -443,7 +443,7 @@ class User < ApplicationRecord
def set_approved
self.approved = begin
- if sign_up_from_ip_requires_approval? || sign_up_email_requires_approval?
+ if sign_up_from_ip_requires_approval? || sign_up_email_requires_approval? || sign_up_username_requires_approval?
false
else
open_registrations? || valid_invitation? || external?
@@ -499,6 +499,10 @@ class User < ApplicationRecord
EmailDomainBlock.requires_approval?(records + [domain], attempt_ip: sign_up_ip)
end
+ def sign_up_username_requires_approval?
+ account.username? && UsernameBlock.matches?(account.username, allow_with_approval: true)
+ end
+
def open_registrations?
Setting.registrations_mode == 'open'
end
diff --git a/app/models/username_block.rb b/app/models/username_block.rb
new file mode 100644
index 00000000000..227def66e14
--- /dev/null
+++ b/app/models/username_block.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: username_blocks
+#
+# id :bigint(8) not null, primary key
+# allow_with_approval :boolean default(FALSE), not null
+# exact :boolean default(FALSE), not null
+# normalized_username :string not null
+# username :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class UsernameBlock < ApplicationRecord
+ HOMOGLYPHS = {
+ '1' => 'i',
+ '2' => 'z',
+ '3' => 'e',
+ '4' => 'a',
+ '5' => 's',
+ '7' => 't',
+ '8' => 'b',
+ '9' => 'g',
+ '0' => 'o',
+ }.freeze
+
+ validates :username, presence: true, uniqueness: true
+
+ scope :matches_exactly, ->(str) { where(exact: true).where(normalized_username: str) }
+ scope :matches_partially, ->(str) { where(exact: false).where(Arel::Nodes.build_quoted(str).matches(Arel::Nodes.build_quoted('%').concat(arel_table[:normalized_username]).concat(Arel::Nodes.build_quoted('%')))) }
+
+ before_save :set_normalized_username
+
+ def comparison
+ exact? ? 'equals' : 'contains'
+ end
+
+ def comparison=(val)
+ self.exact = val == 'equals'
+ end
+
+ def self.matches?(str, allow_with_approval: false)
+ normalized_str = str.downcase.gsub(Regexp.union(HOMOGLYPHS.keys), HOMOGLYPHS)
+ where(allow_with_approval: allow_with_approval).matches_exactly(normalized_str).or(matches_partially(normalized_str)).any?
+ end
+
+ def to_log_human_identifier
+ username
+ end
+
+ private
+
+ def set_normalized_username
+ self.normalized_username = normalize(username)
+ end
+
+ def normalize(str)
+ str.downcase.gsub(Regexp.union(HOMOGLYPHS.keys), HOMOGLYPHS)
+ end
+end
diff --git a/app/policies/username_block_policy.rb b/app/policies/username_block_policy.rb
new file mode 100644
index 00000000000..9f6b8cfc018
--- /dev/null
+++ b/app/policies/username_block_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class UsernameBlockPolicy < ApplicationPolicy
+ def index?
+ role.can?(:manage_blocks)
+ end
+
+ def create?
+ role.can?(:manage_blocks)
+ end
+
+ def update?
+ role.can?(:manage_blocks)
+ end
+
+ def destroy?
+ role.can?(:manage_blocks)
+ end
+end
diff --git a/app/validators/unreserved_username_validator.rb b/app/validators/unreserved_username_validator.rb
index 55a8c835fae..f20f4a7494b 100644
--- a/app/validators/unreserved_username_validator.rb
+++ b/app/validators/unreserved_username_validator.rb
@@ -28,14 +28,6 @@ class UnreservedUsernameValidator < ActiveModel::Validator
end
def settings_username_reserved?
- settings_has_reserved_usernames? && settings_reserves_username?
- end
-
- def settings_has_reserved_usernames?
- Setting.reserved_usernames.present?
- end
-
- def settings_reserves_username?
- Setting.reserved_usernames.include?(@username.downcase)
+ UsernameBlock.matches?(@username, allow_with_approval: false)
end
end
diff --git a/app/views/admin/username_blocks/_form.html.haml b/app/views/admin/username_blocks/_form.html.haml
new file mode 100644
index 00000000000..bfbbb18e2f9
--- /dev/null
+++ b/app/views/admin/username_blocks/_form.html.haml
@@ -0,0 +1,16 @@
+.fields-group
+ = form.input :username,
+ wrapper: :with_block_label,
+ input_html: { autocomplete: 'new-password', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT }
+
+.fields-group
+ = form.input :comparison,
+ as: :select,
+ wrapper: :with_block_label,
+ collection: %w(equals contains),
+ include_blank: false,
+ label_method: ->(type) { I18n.t(type, scope: 'admin.username_blocks.comparison') }
+
+.fields-group
+ = form.input :allow_with_approval,
+ wrapper: :with_label
diff --git a/app/views/admin/username_blocks/_username_block.html.haml b/app/views/admin/username_blocks/_username_block.html.haml
new file mode 100644
index 00000000000..617ec65bc6d
--- /dev/null
+++ b/app/views/admin/username_blocks/_username_block.html.haml
@@ -0,0 +1,12 @@
+.batch-table__row
+ %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+ = f.check_box :username_block_ids, { multiple: true, include_hidden: false }, username_block.id
+ .sr-only= username_block.username
+ .batch-table__row__content.pending-account
+ .pending-account__header
+ = t(username_block.exact? ? 'admin.username_blocks.matches_exactly_html' : 'admin.username_blocks.contains_html', string: content_tag(:samp, link_to(username_block.username, edit_admin_username_block_path(username_block))))
+ %br/
+ - if username_block.allow_with_approval?
+ = t('admin.email_domain_blocks.allow_registrations_with_approval')
+ - else
+ = t('admin.username_blocks.block_registrations')
diff --git a/app/views/admin/username_blocks/edit.html.haml b/app/views/admin/username_blocks/edit.html.haml
new file mode 100644
index 00000000000..eee0fedef07
--- /dev/null
+++ b/app/views/admin/username_blocks/edit.html.haml
@@ -0,0 +1,10 @@
+- content_for :page_title do
+ = t('admin.username_blocks.edit.title')
+
+= simple_form_for @username_block, url: admin_username_block_path(@username_block) do |form|
+ = render 'shared/error_messages', object: @username_block
+
+ = render form
+
+ .actions
+ = form.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/username_blocks/index.html.haml b/app/views/admin/username_blocks/index.html.haml
new file mode 100644
index 00000000000..697edfda51a
--- /dev/null
+++ b/app/views/admin/username_blocks/index.html.haml
@@ -0,0 +1,26 @@
+- content_for :page_title do
+ = t('admin.username_blocks.title')
+
+- content_for :heading_actions do
+ = link_to t('admin.username_blocks.add_new'), new_admin_username_block_path, class: 'button'
+
+= form_with model: @form, url: batch_admin_username_blocks_path do |f|
+ = hidden_field_tag :page, params[:page] || 1
+
+ .batch-table
+ .batch-table__toolbar
+ %label.batch-table__toolbar__select.batch-checkbox-all
+ = check_box_tag :batch_checkbox_all, nil, false
+ .batch-table__toolbar__actions
+ = f.button safe_join([material_symbol('close'), t('admin.username_blocks.delete')]),
+ class: 'table-action-link',
+ data: { confirm: t('admin.reports.are_you_sure') },
+ name: :delete,
+ type: :submit
+ .batch-table__body
+ - if @username_blocks.empty?
+ = nothing_here 'nothing-here--under-tabs'
+ - else
+ = render partial: 'username_block', collection: @username_blocks, locals: { f: f }
+
+= paginate @username_blocks
diff --git a/app/views/admin/username_blocks/new.html.haml b/app/views/admin/username_blocks/new.html.haml
new file mode 100644
index 00000000000..0f5bd27952b
--- /dev/null
+++ b/app/views/admin/username_blocks/new.html.haml
@@ -0,0 +1,10 @@
+- content_for :page_title do
+ = t('admin.username_blocks.new.title')
+
+= simple_form_for @username_block, url: admin_username_blocks_path do |form|
+ = render 'shared/error_messages', object: @username_block
+
+ = render form
+
+ .actions
+ = form.button :button, t('admin.username_blocks.new.create'), type: :submit
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index 0dbc0873855..b934696bda6 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -72,6 +72,8 @@ ignore_unused:
- 'preferences.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use
- 'edit_profile.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use
- 'admin.terms_of_service.generate' # temporarily disabled
+ - 'admin.username_blocks.matches_exactly_html'
+ - 'admin.username_blocks.contains_html'
ignore_inconsistent_interpolations:
- '*.one'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 204340f504c..a149c18c775 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -190,6 +190,7 @@ en:
create_relay: Create Relay
create_unavailable_domain: Create Unavailable Domain
create_user_role: Create Role
+ create_username_block: Create Username Rule
demote_user: Demote User
destroy_announcement: Delete Announcement
destroy_canonical_email_block: Delete Email Block
@@ -203,6 +204,7 @@ en:
destroy_status: Delete Post
destroy_unavailable_domain: Delete Unavailable Domain
destroy_user_role: Destroy Role
+ destroy_username_block: Delete Username Rule
disable_2fa_user: Disable 2FA
disable_custom_emoji: Disable Custom Emoji
disable_relay: Disable Relay
@@ -237,6 +239,7 @@ en:
update_report: Update Report
update_status: Update Post
update_user_role: Update Role
+ update_username_block: Update Username Rule
actions:
approve_appeal_html: "%{name} approved moderation decision appeal from %{target}"
approve_user_html: "%{name} approved sign-up from %{target}"
@@ -255,6 +258,7 @@ en:
create_relay_html: "%{name} created a relay %{target}"
create_unavailable_domain_html: "%{name} stopped delivery to domain %{target}"
create_user_role_html: "%{name} created %{target} role"
+ create_username_block_html: "%{name} added rule for usernames containing %{target}"
demote_user_html: "%{name} demoted user %{target}"
destroy_announcement_html: "%{name} deleted announcement %{target}"
destroy_canonical_email_block_html: "%{name} unblocked email with the hash %{target}"
@@ -268,6 +272,7 @@ en:
destroy_status_html: "%{name} removed post by %{target}"
destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}"
destroy_user_role_html: "%{name} deleted %{target} role"
+ destroy_username_block_html: "%{name} removed rule for usernames containing %{target}"
disable_2fa_user_html: "%{name} disabled two factor requirement for user %{target}"
disable_custom_emoji_html: "%{name} disabled emoji %{target}"
disable_relay_html: "%{name} disabled the relay %{target}"
@@ -302,6 +307,7 @@ en:
update_report_html: "%{name} updated report %{target}"
update_status_html: "%{name} updated post by %{target}"
update_user_role_html: "%{name} changed %{target} role"
+ update_username_block_html: "%{name} updated rule for usernames containing %{target}"
deleted_account: deleted account
empty: No logs found.
filter_by_action: Filter by action
@@ -1085,6 +1091,25 @@ en:
other: Used by %{count} people over the last week
title: Recommendations & Trends
trending: Trending
+ username_blocks:
+ add_new: Add new
+ block_registrations: Block registrations
+ comparison:
+ contains: Contains
+ equals: Equals
+ contains_html: Contains %{string}
+ created_msg: Successfully created username rule
+ delete: Delete
+ edit:
+ title: Edit username rule
+ matches_exactly_html: Equals %{string}
+ new:
+ create: Create rule
+ title: Create new username rule
+ no_username_block_selected: No username rules were changed as none were selected
+ not_permitted: Not permitted
+ title: Username rules
+ updated_msg: Successfully updated username rule
warning_presets:
add_new: Add new
delete: Delete
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 1b410a802d5..86fb4528de4 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -160,6 +160,10 @@ en:
name: Public name of the role, if role is set to be displayed as a badge
permissions_as_keys: Users with this role will have access to...
position: Higher role decides conflict resolution in certain situations. Certain actions can only be performed on roles with a lower priority
+ username_block:
+ allow_with_approval: Instead of preventing sign-up outright, matching sign-ups will require your approval
+ comparison: Please be mindful of the Scunthorpe Problem when blocking partial matches
+ username: Will be matched regardless of casing and common homoglyphs like "4" for "a" or "3" for "e"
webhook:
events: Select events to send
template: Compose your own JSON payload using variable interpolation. Leave blank for default JSON.
@@ -371,6 +375,10 @@ en:
name: Name
permissions_as_keys: Permissions
position: Priority
+ username_block:
+ allow_with_approval: Allow registrations with approval
+ comparison: Method of comparison
+ username: Word to match
webhook:
events: Enabled events
template: Payload template
diff --git a/config/navigation.rb b/config/navigation.rb
index d60f8cbc5b3..a8f686fd8b1 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -59,6 +59,7 @@ SimpleNavigation::Configuration.run do |navigation|
current_user.can?(:manage_federation)
}
s.item :email_domain_blocks, safe_join([material_symbol('mail'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_path, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.can?(:manage_blocks) }
+ s.item :username_blocks, safe_join([material_symbol('supervised_user_circle_off'), t('admin.username_blocks.title')]), admin_username_blocks_path, highlights_on: %r{/admin/username_blocks}, if: -> { current_user.can?(:manage_blocks) }
s.item :ip_blocks, safe_join([material_symbol('hide_source'), t('admin.ip_blocks.title')]), admin_ip_blocks_path, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.can?(:manage_blocks) }
s.item :action_logs, safe_join([material_symbol('list'), t('admin.action_logs.title')]), admin_action_logs_path, if: -> { current_user.can?(:view_audit_log) }
end
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 3d9f24ae838..97f84da44e7 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -230,4 +230,10 @@ namespace :admin do
end
resources :software_updates, only: [:index]
+
+ resources :username_blocks, except: [:show, :destroy] do
+ collection do
+ post :batch
+ end
+ end
end
diff --git a/config/settings.yml b/config/settings.yml
index ba81fcb8c6a..7d2f0a00c07 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -20,28 +20,6 @@ defaults: &defaults
trends: true
trends_as_landing_page: true
trendable_by_default: false
- reserved_usernames:
- - abuse
- - account
- - accounts
- - admin
- - administration
- - administrator
- - admins
- - help
- - helpdesk
- - instance
- - mod
- - moderator
- - moderators
- - mods
- - owner
- - root
- - security
- - server
- - staff
- - support
- - webmaster
disallowed_hashtags: # space separated string or list of hashtags without the hash
bootstrap_timeline_accounts: ''
activity_api_enabled: true
diff --git a/db/migrate/20250717003848_create_username_blocks.rb b/db/migrate/20250717003848_create_username_blocks.rb
new file mode 100644
index 00000000000..01649d06574
--- /dev/null
+++ b/db/migrate/20250717003848_create_username_blocks.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class CreateUsernameBlocks < ActiveRecord::Migration[8.0]
+ def change
+ create_table :username_blocks do |t|
+ t.string :username, null: false
+ t.string :normalized_username, null: false
+ t.boolean :exact, null: false, default: false
+ t.boolean :allow_with_approval, null: false, default: false
+
+ t.timestamps
+ end
+
+ add_index :username_blocks, 'lower(username)', unique: true, name: 'index_username_blocks_on_username_lower_btree'
+ add_index :username_blocks, :normalized_username
+
+ reversible do |dir|
+ dir.up do
+ load Rails.root.join('db', 'seeds', '05_blocked_usernames.rb')
+ end
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0237b476445..272d6fac182 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
+ActiveRecord::Schema[8.0].define(version: 2025_07_17_003848) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -1238,6 +1238,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
t.datetime "updated_at", null: false
end
+ create_table "username_blocks", force: :cascade do |t|
+ t.string "username", null: false
+ t.string "normalized_username", null: false
+ t.boolean "exact", default: false, null: false
+ t.boolean "allow_with_approval", default: false, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index "lower((username)::text)", name: "index_username_blocks_on_username_lower_btree", unique: true
+ t.index ["normalized_username"], name: "index_username_blocks_on_normalized_username"
+ end
+
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.datetime "created_at", precision: nil, null: false
diff --git a/db/seeds/05_blocked_usernames.rb b/db/seeds/05_blocked_usernames.rb
new file mode 100644
index 00000000000..8bfe536c898
--- /dev/null
+++ b/db/seeds/05_blocked_usernames.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+%w(
+ abuse
+ account
+ accounts
+ admin
+ administration
+ administrator
+ admins
+ help
+ helpdesk
+ instance
+ mod
+ moderator
+ moderators
+ mods
+ owner
+ root
+ security
+ server
+ staff
+ support
+ webmaster
+).each do |str|
+ UsernameBlock.create_with(username: str, exact: true).find_or_create_by(username: str)
+end
+
+%w(
+ mastodon
+ mastadon
+).each do |str|
+ UsernameBlock.create_with(username: str, exact: false).find_or_create_by(username: str)
+end
diff --git a/spec/fabricators/username_block_fabricator.rb b/spec/fabricators/username_block_fabricator.rb
new file mode 100644
index 00000000000..edca5419aba
--- /dev/null
+++ b/spec/fabricators/username_block_fabricator.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+Fabricator(:username_block) do
+ username { sequence(:email) { |i| "#{i}#{Faker::Internet.username}" } }
+ exact false
+ allow_with_approval false
+end
diff --git a/spec/models/username_block_spec.rb b/spec/models/username_block_spec.rb
new file mode 100644
index 00000000000..72dbe028bdf
--- /dev/null
+++ b/spec/models/username_block_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe UsernameBlock do
+ describe '.matches?' do
+ context 'when there is an exact block' do
+ before do
+ Fabricate(:username_block, username: 'carriage', exact: true)
+ end
+
+ it 'returns true on exact match' do
+ expect(described_class.matches?('carriage')).to be true
+ end
+
+ it 'returns true on case insensitive match' do
+ expect(described_class.matches?('CaRRiagE')).to be true
+ end
+
+ it 'returns true on homoglyph match' do
+ expect(described_class.matches?('c4rr14g3')).to be true
+ end
+
+ it 'returns false on partial match' do
+ expect(described_class.matches?('foo_carriage')).to be false
+ end
+
+ it 'returns false on no match' do
+ expect(described_class.matches?('foo')).to be false
+ end
+ end
+
+ context 'when there is a partial block' do
+ before do
+ Fabricate(:username_block, username: 'carriage', exact: false)
+ end
+
+ it 'returns true on exact match' do
+ expect(described_class.matches?('carriage')).to be true
+ end
+
+ it 'returns true on case insensitive match' do
+ expect(described_class.matches?('CaRRiagE')).to be true
+ end
+
+ it 'returns true on homoglyph match' do
+ expect(described_class.matches?('c4rr14g3')).to be true
+ end
+
+ it 'returns true on suffix match' do
+ expect(described_class.matches?('foo_carriage')).to be true
+ end
+
+ it 'returns true on prefix match' do
+ expect(described_class.matches?('carriage_foo')).to be true
+ end
+
+ it 'returns false on no match' do
+ expect(described_class.matches?('foo')).to be false
+ end
+ end
+ end
+end
diff --git a/spec/requests/admin/username_blocks_spec.rb b/spec/requests/admin/username_blocks_spec.rb
new file mode 100644
index 00000000000..6e17ca2d470
--- /dev/null
+++ b/spec/requests/admin/username_blocks_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin Username Blocks' do
+ describe 'GET /admin/username_blocks' do
+ before { sign_in Fabricate(:admin_user) }
+
+ it 'returns http success' do
+ get admin_username_blocks_path
+
+ expect(response)
+ .to have_http_status(200)
+ end
+ end
+
+ describe 'POST /admin/username_blocks' do
+ before { sign_in Fabricate(:admin_user) }
+
+ it 'gracefully handles invalid nested params' do
+ post admin_username_blocks_path(username_block: 'invalid')
+
+ expect(response)
+ .to have_http_status(400)
+ end
+
+ it 'creates a username block' do
+ post admin_username_blocks_path(username_block: { username: 'banana', comparison: 'contains', allow_with_approval: '0' })
+
+ expect(response)
+ .to redirect_to(admin_username_blocks_path)
+ expect(UsernameBlock.find_by(username: 'banana'))
+ .to_not be_nil
+ end
+ end
+
+ describe 'POST /admin/username_blocks/batch' do
+ before { sign_in Fabricate(:admin_user) }
+
+ let(:username_blocks) { Fabricate.times(2, :username_block) }
+
+ it 'gracefully handles invalid nested params' do
+ post batch_admin_username_blocks_path(form_username_block_batch: 'invalid')
+
+ expect(response)
+ .to redirect_to(admin_username_blocks_path)
+ end
+
+ it 'deletes selected username blocks' do
+ post batch_admin_username_blocks_path(form_username_block_batch: { username_block_ids: username_blocks.map(&:id) }, delete: '1')
+
+ expect(response)
+ .to redirect_to(admin_username_blocks_path)
+ expect(UsernameBlock.where(id: username_blocks.map(&:id)))
+ .to be_empty
+ end
+ end
+
+ describe 'GET /admin/username_blocks/new' do
+ before { sign_in Fabricate(:admin_user) }
+
+ it 'returns http success' do
+ get new_admin_username_block_path
+
+ expect(response)
+ .to have_http_status(200)
+ end
+ end
+
+ describe 'GET /admin/username_blocks/:id/edit' do
+ before { sign_in Fabricate(:admin_user) }
+
+ let(:username_block) { Fabricate(:username_block) }
+
+ it 'returns http success' do
+ get edit_admin_username_block_path(username_block)
+
+ expect(response)
+ .to have_http_status(200)
+ end
+ end
+
+ describe 'PUT /admin/username_blocks/:id' do
+ before { sign_in Fabricate(:admin_user) }
+
+ let(:username_block) { Fabricate(:username_block, username: 'banana') }
+
+ it 'updates username block' do
+ put admin_username_block_path(username_block, username_block: { username: 'bebebe' })
+
+ expect(response)
+ .to redirect_to(admin_username_blocks_path)
+ expect(username_block.reload.username)
+ .to eq 'bebebe'
+ end
+ end
+end
diff --git a/spec/validators/unreserved_username_validator_spec.rb b/spec/validators/unreserved_username_validator_spec.rb
index 67a2921885e..55dca7db844 100644
--- a/spec/validators/unreserved_username_validator_spec.rb
+++ b/spec/validators/unreserved_username_validator_spec.rb
@@ -10,8 +10,13 @@ RSpec.describe UnreservedUsernameValidator do
attr_accessor :username
validates_with UnreservedUsernameValidator
+
+ def self.name
+ 'Foo'
+ end
end
end
+
let(:record) { record_class.new }
describe '#validate' do
@@ -114,7 +119,7 @@ RSpec.describe UnreservedUsernameValidator do
end
def stub_reserved_usernames(value)
- allow(Setting).to receive(:[]).with('reserved_usernames').and_return(value)
+ value&.each { |str| Fabricate(:username_block, username: str, exact: true) }
end
end
end