usernames 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +362 -0
- data/SECURITY.md +42 -0
- data/config/locales/usernames.en.yml +72 -0
- data/config/locales/usernames.es.yml +51 -0
- data/config/locales/usernames.pt.yml +51 -0
- data/data/LICENSES/allprofanity.txt +24 -0
- data/data/LICENSES/big-username-blocklist.txt +25 -0
- data/data/LICENSES/corpora.txt +128 -0
- data/data/LICENSES/cuss.txt +25 -0
- data/data/LICENSES/django-registration.txt +33 -0
- data/data/LICENSES/dsojevic-profanity-list.txt +24 -0
- data/data/LICENSES/forwardemail.txt +24 -0
- data/data/LICENSES/friendly-words.txt +24 -0
- data/data/LICENSES/gitea.txt +23 -0
- data/data/LICENSES/gitlab.txt +31 -0
- data/data/LICENSES/iana-well-known-uris.txt +130 -0
- data/data/LICENSES/imsky-wordlists.txt +24 -0
- data/data/LICENSES/mottie-github-reserved-names.txt +24 -0
- data/data/LICENSES/obscenity.txt +27 -0
- data/data/LICENSES/petname.txt +218 -0
- data/data/LICENSES/rfc2142.txt +9 -0
- data/data/LICENSES/shouldbee.txt +25 -0
- data/data/LICENSES/usernames.txt +30 -0
- data/data/NOTICE +36 -0
- data/data/offensive/README.md +90 -0
- data/data/offensive/allowlist/en.txt +727 -0
- data/data/offensive/allowlist/es.txt +39 -0
- data/data/offensive/en.json +5680 -0
- data/data/offensive/es.json +1014 -0
- data/data/offensive/hate.yml +51 -0
- data/data/offensive/leet.yml +27 -0
- data/data/reserved/ALLOWLIST_EXCEPTIONS.txt +6 -0
- data/data/reserved/impersonation.txt +69 -0
- data/data/reserved/mailboxes.txt +77 -0
- data/data/reserved/system.txt +124 -0
- data/data/reserved/web.txt +962 -0
- data/data/words/adjectives.txt +367 -0
- data/data/words/animals.txt +253 -0
- data/lib/generators/usernames/availability_generator.rb +67 -0
- data/lib/generators/usernames/existing_schema.rb +85 -0
- data/lib/generators/usernames/install_generator.rb +241 -0
- data/lib/generators/usernames/install_messages.rb +63 -0
- data/lib/generators/usernames/locale_generator.rb +56 -0
- data/lib/generators/usernames/templates/add_username.rb.erb +53 -0
- data/lib/generators/usernames/templates/availability/_field.html.erb.tt +25 -0
- data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +107 -0
- data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +115 -0
- data/lib/generators/usernames/templates/usernames.rb +34 -0
- data/lib/tasks/usernames.rake +38 -0
- data/lib/usernames/adapters/moderate.rb +37 -0
- data/lib/usernames/audit/output.rb +51 -0
- data/lib/usernames/audit/values.rb +34 -0
- data/lib/usernames/audit.rb +230 -0
- data/lib/usernames/candidates.rb +64 -0
- data/lib/usernames/comparison/index_contract.rb +241 -0
- data/lib/usernames/comparison/index_expression.rb +183 -0
- data/lib/usernames/comparison.rb +127 -0
- data/lib/usernames/configuration.rb +197 -0
- data/lib/usernames/data.rb +205 -0
- data/lib/usernames/engine.rb +32 -0
- data/lib/usernames/errors.rb +16 -0
- data/lib/usernames/generator.rb +206 -0
- data/lib/usernames/model.rb +397 -0
- data/lib/usernames/normalizer.rb +51 -0
- data/lib/usernames/offensive/hate.rb +124 -0
- data/lib/usernames/offensive/lexicon.rb +156 -0
- data/lib/usernames/offensive/name.rb +254 -0
- data/lib/usernames/offensive.rb +136 -0
- data/lib/usernames/phrase.rb +68 -0
- data/lib/usernames/reserved/listing.rb +79 -0
- data/lib/usernames/reserved.rb +219 -0
- data/lib/usernames/result.rb +52 -0
- data/lib/usernames/rules/options.rb +175 -0
- data/lib/usernames/rules.rb +257 -0
- data/lib/usernames/unique_violation.rb +176 -0
- data/lib/usernames/validator.rb +61 -0
- data/lib/usernames/version.rb +5 -0
- data/lib/usernames/words.rb +13 -0
- data/lib/usernames.rb +138 -0
- data/sig/usernames.rbs +75 -0
- metadata +217 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
module Generators
|
|
5
|
+
# What the database holds now for one table and column, as the install
|
|
6
|
+
# generator sees it: the column (nil when missing), the table's indexes,
|
|
7
|
+
# and whether any row already has a username.
|
|
8
|
+
#
|
|
9
|
+
# Every lookup is guarded: a generator must run without a database (a
|
|
10
|
+
# fresh checkout, CI, a laptop with the server off). Then nothing is known,
|
|
11
|
+
# and the column is assumed missing.
|
|
12
|
+
class ExistingSchema
|
|
13
|
+
attr_reader :column, :indexes
|
|
14
|
+
|
|
15
|
+
# `owner` is the model (or ActiveRecord::Base) whose database holds the
|
|
16
|
+
# table; `mode` is the case mode the index is for: the first index that
|
|
17
|
+
# enforces its comparison contract is the matching one. Every other
|
|
18
|
+
# index involving the column is kept with why it does not count.
|
|
19
|
+
def self.read(owner, table, column, mode)
|
|
20
|
+
owner.with_connection do |connection|
|
|
21
|
+
next new(reachable: true) unless connection.table_exists?(table)
|
|
22
|
+
|
|
23
|
+
existing = connection.columns(table).find { |each| each.name == column.to_s }
|
|
24
|
+
indexes = connection.indexes(table)
|
|
25
|
+
contract = Usernames::Comparison::IndexContract.new(connection, table: table, column: column, mode: mode)
|
|
26
|
+
new(reachable: true, column: existing, indexes: indexes,
|
|
27
|
+
verdicts: verdicts(indexes, contract),
|
|
28
|
+
populated: !existing.nil? && rows?(connection, table, column))
|
|
29
|
+
end
|
|
30
|
+
rescue StandardError
|
|
31
|
+
new(reachable: false)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The migration line that drops `index` (one of `indexes`) and can be
|
|
35
|
+
# rolled back: it restates the columns or expression as the database
|
|
36
|
+
# reads them back, the uniqueness and the condition.
|
|
37
|
+
def self.remove_index_line(table, index)
|
|
38
|
+
columns = index.columns
|
|
39
|
+
options = [columns.is_a?(Array) ? "column: #{columns.map(&:to_sym).inspect}" : columns.inspect]
|
|
40
|
+
options << "unique: true" if index.unique
|
|
41
|
+
options << "where: #{index.where.inspect}" if index.where.present?
|
|
42
|
+
options << "name: #{index.name.inspect}"
|
|
43
|
+
"remove_index #{Usernames::Comparison.ruby_name(table)}, #{options.join(', ')}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# [[index, reason], …] for every index involving the column; reason nil
|
|
47
|
+
# when it enforces the contract.
|
|
48
|
+
def self.verdicts(indexes, contract)
|
|
49
|
+
indexes.select { |index| contract.mentions?(index) }.map { |index| [index, contract.violation(index)] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.rows?(connection, table, column)
|
|
53
|
+
sql = "SELECT 1 FROM #{connection.quote_table_name(table)} " \
|
|
54
|
+
"WHERE #{connection.quote_column_name(column)} IS NOT NULL LIMIT 1"
|
|
55
|
+
!connection.select_value(sql).nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(reachable:, column: nil, indexes: [], verdicts: [], populated: false)
|
|
59
|
+
@reachable = reachable
|
|
60
|
+
@column = column
|
|
61
|
+
@indexes = indexes
|
|
62
|
+
@verdicts = verdicts
|
|
63
|
+
@populated = populated
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The first index that enforces the contract, or nil.
|
|
67
|
+
def matching_index
|
|
68
|
+
@verdicts.find { |_index, reason| reason.nil? }&.first
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# [[index, reason], …]: the indexes on the column that do not.
|
|
72
|
+
def rejected_indexes
|
|
73
|
+
@verdicts.select { |_index, reason| reason }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reachable?
|
|
77
|
+
@reachable
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def populated?
|
|
81
|
+
@populated
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "existing_schema"
|
|
6
|
+
require_relative "install_messages"
|
|
7
|
+
|
|
8
|
+
module Usernames
|
|
9
|
+
module Generators
|
|
10
|
+
# rails generate usernames:install [--model=User] [--column=username] [--case=preserve]
|
|
11
|
+
#
|
|
12
|
+
# Writes config/initializers/usernames.rb and one migration: the column
|
|
13
|
+
# (if the table has none), byte-compared ("C" on PostgreSQL, utf8mb4_bin
|
|
14
|
+
# on MySQL — except PostgreSQL in preserve mode, whose index expression
|
|
15
|
+
# carries the "C" itself), and the ONE unique index the gem needs, built
|
|
16
|
+
# from the same expression every query uses (Usernames::Comparison).
|
|
17
|
+
#
|
|
18
|
+
# It looks at the database first, when it can reach it, so a re-run
|
|
19
|
+
# writes nothing twice and an existing column is adopted rather than
|
|
20
|
+
# added. A column that already holds usernames gets the adoption workflow
|
|
21
|
+
# instead of a promise: the index cannot be trusted — or even added —
|
|
22
|
+
# until `rails usernames:audit` comes back clean.
|
|
23
|
+
class InstallGenerator < Rails::Generators::Base
|
|
24
|
+
include ActiveRecord::Generators::Migration
|
|
25
|
+
include InstallMessages
|
|
26
|
+
|
|
27
|
+
source_root File.expand_path("templates", __dir__)
|
|
28
|
+
|
|
29
|
+
desc "Add usernames to a model: the initializer, the column if missing, and its unique index."
|
|
30
|
+
|
|
31
|
+
class_option :model, type: :string, default: "User", desc: "The model that gets has_username"
|
|
32
|
+
class_option :column, type: :string, default: "username", desc: "The column that holds the username"
|
|
33
|
+
# The index depends on the case mode, so the generator reads it from
|
|
34
|
+
# the model's has_username (or config.case), and --case overrides that
|
|
35
|
+
# for a model that does not declare it yet.
|
|
36
|
+
class_option :case, type: :string,
|
|
37
|
+
desc: "lowercase or preserve (default: the model's has_username, else config.case)"
|
|
38
|
+
|
|
39
|
+
def self.next_migration_number(dir)
|
|
40
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The index and collation depend on the adapter: an unsupported one
|
|
44
|
+
# stops the generator here, before it writes a single file.
|
|
45
|
+
def check_the_database_adapter
|
|
46
|
+
adapter
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_initializer
|
|
50
|
+
# Never over an initializer the app has already edited.
|
|
51
|
+
template "usernames.rb", "config/initializers/usernames.rb", skip: true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def create_migration_file
|
|
55
|
+
if nothing_to_migrate?
|
|
56
|
+
say_status :skip, "#{table}.#{column} and #{matching_index.name} are already in place", :yellow
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An index on the column that looks like the one but does not count
|
|
61
|
+
# gets said why, rather than a second unique index appearing unexplained.
|
|
62
|
+
database.rejected_indexes.each do |existing, reason|
|
|
63
|
+
say_status :ignore, "#{existing.name} doesn't make #{column} unique: it #{reason}", :yellow
|
|
64
|
+
end
|
|
65
|
+
migration_template "add_username.rb.erb", File.join(db_migrate_path, "#{migration_name}.rb")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def display_post_install_message
|
|
69
|
+
say "\n\tThe `usernames` gem has been successfully installed!", :green
|
|
70
|
+
warn_about_the_database unless database.reachable?
|
|
71
|
+
populated? ? adoption_message : setup_message
|
|
72
|
+
say "\nEnjoy your new usernames!", :green
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# --- what the migration does -----------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# A file name and a constant, whatever the table is called:
|
|
80
|
+
# `public.users` makes add_username_to_public_users, AddUsernameToPublicUsers.
|
|
81
|
+
def migration_name
|
|
82
|
+
table_part = table.gsub(/\W+/, "_")
|
|
83
|
+
column_part = column.gsub(/\W+/, "_")
|
|
84
|
+
add_column? ? "add_#{column_part}_to_#{table_part}" : "add_unique_#{column_part}_index_to_#{table_part}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def migration_class_name = migration_name.camelize
|
|
88
|
+
|
|
89
|
+
# A table or column as the migration's argument (`:users`, `"public.users"`).
|
|
90
|
+
def ruby_name(name) = Usernames::Comparison.ruby_name(name)
|
|
91
|
+
|
|
92
|
+
def nothing_to_migrate?
|
|
93
|
+
!add_column? && !change_collation? && !add_index?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def add_column? = database.column.nil?
|
|
97
|
+
|
|
98
|
+
# Adopting a PostgreSQL column: set "C" when it is anything else, and
|
|
99
|
+
# make citext a plain string. In preserve mode the comparison
|
|
100
|
+
# carries its own COLLATE "C", so only citext changes: the column keeps
|
|
101
|
+
# the collation its ORDER BY sorts by. MySQL's change_column restates
|
|
102
|
+
# the whole column definition, so there the migration only shows the line.
|
|
103
|
+
def change_collation?
|
|
104
|
+
existing = database.column
|
|
105
|
+
return false if existing.nil? || adapter != :postgresql
|
|
106
|
+
|
|
107
|
+
existing.sql_type == "citext" || (!collation.nil? && existing.collation != collation)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def mysql_collation_note?
|
|
111
|
+
existing = database.column
|
|
112
|
+
!existing.nil? && adapter == :mysql && existing.collation != collation
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def add_index? = matching_index.nil?
|
|
116
|
+
|
|
117
|
+
# An index of the same name that is not the one the mode needs (a plain
|
|
118
|
+
# non-unique index_users_on_username, say) has to go first.
|
|
119
|
+
def conflicting_index
|
|
120
|
+
database.indexes.find { |existing| existing.name == index[:name] && !existing.equal?(matching_index) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def matching_index = database.matching_index
|
|
124
|
+
|
|
125
|
+
# Whether the migration touches the column before its indexes.
|
|
126
|
+
def column_step? = add_column? || change_collation? || mysql_collation_note?
|
|
127
|
+
|
|
128
|
+
def index
|
|
129
|
+
@index ||= Usernames::Comparison.index_definition(adapter: adapter, table: table, column: column, mode: mode)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def collation = Usernames::Comparison.column_collation(adapter, mode)
|
|
133
|
+
|
|
134
|
+
# Readable in the migration: 'lower(username COLLATE "C")', not an escaped string.
|
|
135
|
+
def index_column
|
|
136
|
+
wanted = index[:column]
|
|
137
|
+
return wanted.inspect unless wanted.is_a?(String) && wanted.include?('"') && !wanted.include?("'")
|
|
138
|
+
|
|
139
|
+
"'#{wanted}'"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def column_options = collation ? ", collation: #{collation.inspect}" : ""
|
|
143
|
+
|
|
144
|
+
# The audit runs before has_username is declared, so it is told a mode
|
|
145
|
+
# the configuration does not already say.
|
|
146
|
+
def audit_arguments = [model_name, column, (mode if mode != configured_mode)].compact.join(",")
|
|
147
|
+
|
|
148
|
+
def macro_line
|
|
149
|
+
options = []
|
|
150
|
+
options << ":#{column}" unless column == "username"
|
|
151
|
+
options << "case: :preserve" if mode == :preserve && configured_mode != :preserve
|
|
152
|
+
["has_username", options.join(", ")].reject(&:empty?).join(" ")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- the app ---------------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def model_name
|
|
158
|
+
options[:model].camelize
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def model_class
|
|
162
|
+
model_name.safe_constantize
|
|
163
|
+
rescue StandardError, LoadError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def table
|
|
168
|
+
@table ||= model_class.try(:table_name) || model_name.tableize.tr("/", "_")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def column
|
|
172
|
+
options[:column].to_s
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def mode
|
|
176
|
+
@mode ||= begin
|
|
177
|
+
given = options[:case]&.to_sym || declared_mode || configured_mode
|
|
178
|
+
unless %i[lowercase preserve].include?(given)
|
|
179
|
+
raise Thor::Error, "--case must be lowercase or preserve (got #{options[:case].inspect})"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
given
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def declared_mode
|
|
187
|
+
model_class.try(:_usernames)&.[](column.to_sym)&.mode
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def configured_mode
|
|
191
|
+
Usernames.configuration.case
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The model's own database when the model exists — an app with several
|
|
195
|
+
# databases keeps its users on any of them — else the primary one.
|
|
196
|
+
def connection_owner
|
|
197
|
+
model_class.is_a?(Class) && model_class < ActiveRecord::Base ? model_class : ActiveRecord::Base
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# From the model's database configuration, which needs no connection.
|
|
201
|
+
# The index and the collation depend on it, so an adapter the gem has
|
|
202
|
+
# no comparison contract for — or none at all — stops the generator.
|
|
203
|
+
def adapter
|
|
204
|
+
@adapter ||= Usernames::Comparison.adapter(connection_owner.connection_db_config&.adapter.to_s)
|
|
205
|
+
rescue Usernames::ConfigurationError => e
|
|
206
|
+
raise Thor::Error, "#{e.message} (#{model_name}'s database in config/database.yml)"
|
|
207
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
208
|
+
raise Thor::Error, "couldn't read #{model_name}'s database adapter (#{e.message}): the migration depends on it"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def database
|
|
212
|
+
@database ||= ExistingSchema.read(connection_owner, table, column, mode)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A model on the primary database migrates where Rails always does. A
|
|
216
|
+
# model on another one migrates where that database's migrations live
|
|
217
|
+
# (its migrations_paths); without them there is nowhere the migration
|
|
218
|
+
# would run against the right database, so the generator says so.
|
|
219
|
+
def db_migrate_path
|
|
220
|
+
own, primary = [connection_owner, ActiveRecord::Base].map(&:connection_db_config)
|
|
221
|
+
return super if own.equal?(primary) || own.configuration_hash == primary.configuration_hash
|
|
222
|
+
|
|
223
|
+
Array(own.migrations_paths).first || raise(Thor::Error, <<~MESSAGE.squish)
|
|
224
|
+
#{model_name} lives on the #{own.name.inspect} database, which sets no migrations_paths, so a migration
|
|
225
|
+
written here would run against the primary database. Add migrations_paths for it in config/database.yml
|
|
226
|
+
and run the generator again.
|
|
227
|
+
MESSAGE
|
|
228
|
+
rescue ActiveRecord::ActiveRecordError # no database configuration to read: Rails' default
|
|
229
|
+
super
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def populated?
|
|
233
|
+
database.populated?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def migration_version
|
|
237
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
module Generators
|
|
5
|
+
# What `rails generate usernames:install` tells the developer once it has
|
|
6
|
+
# written its files: the next steps on a fresh column, or the adoption
|
|
7
|
+
# workflow on one that already holds usernames. :nodoc:
|
|
8
|
+
module InstallMessages
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def setup_message
|
|
12
|
+
say "\nTo complete the setup:"
|
|
13
|
+
say " 1. Run 'rails db:migrate'#{migration_summary}."
|
|
14
|
+
say " 2. Add `#{macro_line}` to your #{model_name} model."
|
|
15
|
+
say " 3. Optionally, run 'rails generate usernames:availability' for a live \"is this name free?\" " \
|
|
16
|
+
"check on your sign-up form."
|
|
17
|
+
say "\nUsernames are unique from the moment the index exists: the database decides, " \
|
|
18
|
+
"with the same comparison the gem uses everywhere."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# On a populated column no index is a promise — not even one already in
|
|
22
|
+
# place, which compares the values as they were stored (`JAVI`), while
|
|
23
|
+
# the gem stores and compares them normalised (`javi`). Only the audit
|
|
24
|
+
# can say the two agree.
|
|
25
|
+
def adoption_message
|
|
26
|
+
say "\n#{table}.#{column} already holds usernames, and `normalizes` never rewrites rows already stored, " \
|
|
27
|
+
"so #{index_doubt}:", :yellow
|
|
28
|
+
say " 1. Run 'rails usernames:audit[#{audit_arguments}]'. It's read-only: it lists values that differ " \
|
|
29
|
+
"from their normalised form, names that would collide, and the column's collation and indexes."
|
|
30
|
+
say " 2. If it finds anything, fix it in a migration you own, under a short freeze of the username form: " \
|
|
31
|
+
"rewrite noncanonical values with your own callbacks and policy, and resolve collisions by contacting " \
|
|
32
|
+
"the people or appending a suffix. The gem renames nobody."
|
|
33
|
+
say " 3. #{nothing_to_migrate? ? 'Re-run the audit until it comes back clean' : "Run 'rails db:migrate'#{migration_summary}, re-run the audit"}, " \
|
|
34
|
+
"and reopen the form."
|
|
35
|
+
say " 4. Add `#{macro_line}` to your #{model_name} model. Existing names that break today's rules " \
|
|
36
|
+
"keep working; only a change is validated."
|
|
37
|
+
say " See \"Adopting an existing column\" in the README."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def index_doubt
|
|
41
|
+
return "the unique index isn't trustworthy yet (it may not even be addable)" if add_index?
|
|
42
|
+
|
|
43
|
+
"#{matching_index.name} can't vouch for them yet: to it a stored JAVI and a new javi are two names"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def warn_about_the_database
|
|
47
|
+
say "\nCouldn't reach the database to look at #{table}.#{column}, so the migration assumes the column " \
|
|
48
|
+
"doesn't exist yet. If it does, delete the add_column line before migrating.", :yellow
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def migration_summary
|
|
52
|
+
return "" if nothing_to_migrate?
|
|
53
|
+
|
|
54
|
+
steps = []
|
|
55
|
+
steps << "to add #{table}.#{column}" if add_column?
|
|
56
|
+
steps << (collation ? "to set its collation to #{collation.inspect}" : "to make it a string column") if
|
|
57
|
+
change_collation?
|
|
58
|
+
steps << "#{steps.empty? ? 'to add' : 'and add'} its unique index, #{index[:name]}" if add_index?
|
|
59
|
+
" (#{steps.join(' ')})"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Usernames
|
|
6
|
+
module Generators
|
|
7
|
+
# rails generate usernames:locale --locale=fr
|
|
8
|
+
#
|
|
9
|
+
# Copies the gem's English messages to config/locales/usernames.fr.yml,
|
|
10
|
+
# under the `fr:` key, for you to translate. Rails loads an engine's
|
|
11
|
+
# locale files before the app's, so every key you translate wins over
|
|
12
|
+
# the gem's, and every key you leave out still has a message.
|
|
13
|
+
class LocaleGenerator < Rails::Generators::Base
|
|
14
|
+
source_root File.expand_path("../../../config/locales", __dir__)
|
|
15
|
+
|
|
16
|
+
desc "Copy the English messages to config/locales/usernames.<locale>.yml for you to translate."
|
|
17
|
+
|
|
18
|
+
class_option :locale, type: :string, required: true, desc: "The locale to translate into, like fr or pt-BR"
|
|
19
|
+
|
|
20
|
+
# A locale key as I18n spells it: fr, pt-BR, zh-Hant-TW.
|
|
21
|
+
LOCALE = /\A[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*\z/
|
|
22
|
+
|
|
23
|
+
def copy_locale
|
|
24
|
+
raise Thor::Error, "--locale must be a locale like fr or pt-BR (got #{locale.inspect})" unless LOCALE.match?(locale)
|
|
25
|
+
|
|
26
|
+
create_file "config/locales/usernames.#{locale}.yml", translated_copy
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def display_post_install_message
|
|
30
|
+
say "\n\tconfig/locales/usernames.#{locale}.yml is ready to translate.", :green
|
|
31
|
+
say "\nTranslate the values; keep the keys and every placeholder (count, letters, separators_phrase…) " \
|
|
32
|
+
"as it is, and add the plural keys your language needs (one/other are English's). Never add a value " \
|
|
33
|
+
"placeholder: a message must not echo what was typed."
|
|
34
|
+
say "The gem already ships #{locale}; your file overrides it key by key.", :yellow if shipped?
|
|
35
|
+
say "\nThen please consider sending it upstream: https://github.com/rameerez/usernames", :green
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def locale
|
|
41
|
+
options[:locale].to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The English file with its top-level key swapped, and a note saying so.
|
|
45
|
+
def translated_copy
|
|
46
|
+
english = File.read(File.join(self.class.source_root, "usernames.en.yml"), encoding: "UTF-8")
|
|
47
|
+
note = "# Copied from the usernames gem's English messages by `rails generate usernames:locale --locale=#{locale}`.\n"
|
|
48
|
+
note + english.sub(/^en:$/, "#{locale}:")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def shipped?
|
|
52
|
+
File.exist?(File.join(self.class.source_root, "usernames.#{locale}.yml"))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
<% if add_column? -%>
|
|
4
|
+
<% if collation.nil? && adapter == :postgresql -%>
|
|
5
|
+
# No collation of its own: the unique index below compares through
|
|
6
|
+
# lower(<%= column %> COLLATE "C"), byte by byte, whatever the column's
|
|
7
|
+
# collation is — so ORDER BY <%= column %> keeps the database's own order.
|
|
8
|
+
<% else -%>
|
|
9
|
+
# Compared byte by byte (<%= collation ? "collation #{collation.inspect}" : "SQLite's default" %>), so the database agrees
|
|
10
|
+
# with Ruby about what "the same name" means: no locale lowercasing I to
|
|
11
|
+
# ı, no collation that thinks jose and josé are one name.
|
|
12
|
+
<% end -%>
|
|
13
|
+
add_column <%= ruby_name(table) %>, <%= ruby_name(column) %>, :string<%= column_options %>
|
|
14
|
+
<% elsif change_collation? -%>
|
|
15
|
+
<% if collation -%>
|
|
16
|
+
# Compared byte by byte ("C"), so the database agrees with Ruby about what
|
|
17
|
+
# "the same name" means: no locale lowercasing I to ı, no collation that
|
|
18
|
+
# ignores case or punctuation. Run `rails usernames:audit` first.
|
|
19
|
+
<% else -%>
|
|
20
|
+
# citext compares under the database locale; the gem compares through
|
|
21
|
+
# lower(<%= column %> COLLATE "C") on a plain string column. Run
|
|
22
|
+
# `rails usernames:audit` first.
|
|
23
|
+
<% end -%>
|
|
24
|
+
reversible do |direction|
|
|
25
|
+
direction.up { change_column <%= ruby_name(table) %>, <%= ruby_name(column) %>, :string<%= column_options %> }
|
|
26
|
+
end
|
|
27
|
+
<% elsif mysql_collation_note? -%>
|
|
28
|
+
# The comparison contract wants a byte-wise collation. MySQL's change_column
|
|
29
|
+
# restates the whole column, so restate its null/default here too:
|
|
30
|
+
# change_column <%= ruby_name(table) %>, <%= ruby_name(column) %>, :string, collation: <%= collation.inspect %>
|
|
31
|
+
<% end -%>
|
|
32
|
+
<% if (existing = conflicting_index) -%>
|
|
33
|
+
<% if column_step? -%>
|
|
34
|
+
|
|
35
|
+
<% end -%>
|
|
36
|
+
# <%= existing.name %> isn't the unique index the gem needs; replace it.
|
|
37
|
+
<%= Usernames::Generators::ExistingSchema.remove_index_line(table, existing) %>
|
|
38
|
+
<% end -%>
|
|
39
|
+
<% if add_index? -%>
|
|
40
|
+
<% if column_step? || conflicting_index -%>
|
|
41
|
+
|
|
42
|
+
<% end -%>
|
|
43
|
+
<% if mode == :preserve -%>
|
|
44
|
+
# Case is kept as typed, and compared without it: CoolCat and coolcat are
|
|
45
|
+
# one name. Every query the gem makes uses this same expression.
|
|
46
|
+
<% else -%>
|
|
47
|
+
# Usernames are stored lowercase, so a plain unique index is the whole
|
|
48
|
+
# contract: uniqueness is `<%= column %> = ?`, and this index serves it.
|
|
49
|
+
<% end -%>
|
|
50
|
+
add_index <%= ruby_name(table) %>, <%= index_column %>, unique: true, name: <%= index[:name].inspect %>
|
|
51
|
+
<% end -%>
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%%# locals: (form:, attribute: :<%= column %>, label: nil) -%>
|
|
2
|
+
<%%#
|
|
3
|
+
A username field with live hints and an availability check.
|
|
4
|
+
Generated by `rails generate usernames:availability`. It's yours: edit it.
|
|
5
|
+
|
|
6
|
+
Render it inside a form, handing it the form builder:
|
|
7
|
+
|
|
8
|
+
render "usernames/field", form: form
|
|
9
|
+
|
|
10
|
+
Hints are advisory: nothing here stops the form from submitting, and the
|
|
11
|
+
model's validations have the last word.
|
|
12
|
+
-%>
|
|
13
|
+
<div data-controller="username-check"
|
|
14
|
+
data-username-check-url-value="<%%= usernames_availability_path %>"
|
|
15
|
+
data-username-check-param-value="<%= column %>"
|
|
16
|
+
data-username-check-rules-value="<%%= <%= model_name %>.<%= column %>_rules_for_browser.to_json %>"
|
|
17
|
+
data-username-check-lowercase-value="<%%= <%= model_name %>.<%= column %>_options[:case] != :preserve %>">
|
|
18
|
+
<%%= form.label attribute, label %>
|
|
19
|
+
<%%= form.text_field attribute,
|
|
20
|
+
autocomplete: "username", autocapitalize: "none", autocorrect: "off", spellcheck: false,
|
|
21
|
+
aria: { describedby: "#{form.field_id(attribute)}_hint #{form.field_id(attribute)}_status" },
|
|
22
|
+
data: { username_check_target: "input", action: "input->username-check#check" } %>
|
|
23
|
+
<p id="<%%= form.field_id(attribute) %>_hint" data-username-check-target="hint" aria-live="polite"></p>
|
|
24
|
+
<p id="<%%= form.field_id(attribute) %>_status" data-username-check-target="status" aria-live="polite"></p>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# GET /usernames/availability?<%= column %>=javi
|
|
5
|
+
# => { "state": "free" | "taken" | "invalid", "message": "…" }
|
|
6
|
+
#
|
|
7
|
+
# Generated by `rails generate usernames:availability`. It's yours: edit it.
|
|
8
|
+
#
|
|
9
|
+
# An availability endpoint tells anyone which names exist — that is its
|
|
10
|
+
# job — so it comes with guard rails:
|
|
11
|
+
#
|
|
12
|
+
# * Rails' own rate_limit, on a store this controller checks. With the
|
|
13
|
+
# NullStore (Rails' default in development and test) the limiter silently
|
|
14
|
+
# counts nothing, so in production this answers 503, with a log line,
|
|
15
|
+
# until config.cache_store is a shared store (Solid Cache, Redis,
|
|
16
|
+
# Memcached). A MemoryStore works, but per process: each worker counts on
|
|
17
|
+
# its own, so it logs a warning once.
|
|
18
|
+
# * Taken, reserved and not-allowed names all answer "taken", with one
|
|
19
|
+
# message: it never says which list a name is on, or which word matched.
|
|
20
|
+
# * Never cached (no-store), and advisory only: the model's validations and
|
|
21
|
+
# the unique index decide at save time.
|
|
22
|
+
class AvailabilityController < ApplicationController
|
|
23
|
+
# Captured once: the very object the limiter writes to, so the guard
|
|
24
|
+
# below checks the store that is actually in use.
|
|
25
|
+
RATE_LIMIT_STORE = cache_store
|
|
26
|
+
|
|
27
|
+
MEMORY_STORE_WARNED = Concurrent::AtomicBoolean.new(false)
|
|
28
|
+
|
|
29
|
+
# Format failures are shown as they are (the browser hints them anyway);
|
|
30
|
+
# reserved and not-allowed names are reported as taken (see above).
|
|
31
|
+
FORMAT_FAILURES = %i[
|
|
32
|
+
username_spaces username_characters username_unicode too_short too_long username_edges
|
|
33
|
+
username_consecutive username_first_character username_digits_only username_extension
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Before everything else, the limiter included: an answer about who
|
|
37
|
+
# exists is never cached, and neither is a 429 or a 503.
|
|
38
|
+
prepend_before_action :no_store
|
|
39
|
+
|
|
40
|
+
rate_limit to: 30, within: 1.minute, store: RATE_LIMIT_STORE, with: -> { head :too_many_requests }
|
|
41
|
+
|
|
42
|
+
before_action :require_a_working_rate_limit_store
|
|
43
|
+
|
|
44
|
+
def show
|
|
45
|
+
render json: answer_for(params[:<%= column %>].to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Every message in <%= model_name %>'s own words: a translation under
|
|
51
|
+
# activerecord.errors.models.<%= singular %>.attributes.<%= column %> wins here as on the form.
|
|
52
|
+
def answer_for(value)
|
|
53
|
+
result = <%= model_name %>.check_<%= column %>(value)
|
|
54
|
+
|
|
55
|
+
if result.canonical.empty?
|
|
56
|
+
{ state: "invalid", message: message(:blank) }
|
|
57
|
+
elsif own?(result.canonical)
|
|
58
|
+
{ state: "free", message: available }
|
|
59
|
+
elsif FORMAT_FAILURES.include?(result.reason)
|
|
60
|
+
{ state: "invalid", message: result.message }
|
|
61
|
+
elsif result.ok? && <%= model_name %>.<%= column %>_available?(result.canonical)
|
|
62
|
+
{ state: "free", message: available }
|
|
63
|
+
else
|
|
64
|
+
{ state: "taken", message: message(:taken) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def message(type)
|
|
69
|
+
<%= model_name %>.new.errors.generate_message(:<%= column %>, type)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Said out loud too: the status is a live region, so a screen reader
|
|
73
|
+
# hears that the name is free, not just silence.
|
|
74
|
+
def available
|
|
75
|
+
I18n.t(:available, scope: :usernames)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def rules
|
|
79
|
+
<%= model_name %>.<%= column %>_options
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The signed-in person's own name is free: it's theirs. Devise and most
|
|
83
|
+
# authentication gems define current_user; with Rails' own authentication
|
|
84
|
+
# generator, use Current.user instead.
|
|
85
|
+
def own?(canonical)
|
|
86
|
+
user = current_user if respond_to?(:current_user, true)
|
|
87
|
+
name = user.try(:<%= column %>)
|
|
88
|
+
name.present? && Usernames.comparison_key(name, **rules) == Usernames.comparison_key(canonical, **rules)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def require_a_working_rate_limit_store
|
|
92
|
+
case RATE_LIMIT_STORE
|
|
93
|
+
when ActiveSupport::Cache::NullStore
|
|
94
|
+
return unless Rails.env.production?
|
|
95
|
+
|
|
96
|
+
Rails.logger.error "[usernames] The availability check is off (503): config.cache_store is the NullStore, " \
|
|
97
|
+
"where Rails' rate_limit counts nothing. Use a shared store: Solid Cache, Redis or Memcached."
|
|
98
|
+
head :service_unavailable
|
|
99
|
+
when ActiveSupport::Cache::MemoryStore
|
|
100
|
+
if MEMORY_STORE_WARNED.make_true
|
|
101
|
+
Rails.logger.warn "[usernames] The availability check's rate limit uses a MemoryStore, which each process " \
|
|
102
|
+
"counts on its own. Use a shared store (Solid Cache, Redis, Memcached) for one limit per client."
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|