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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +362 -0
  5. data/SECURITY.md +42 -0
  6. data/config/locales/usernames.en.yml +72 -0
  7. data/config/locales/usernames.es.yml +51 -0
  8. data/config/locales/usernames.pt.yml +51 -0
  9. data/data/LICENSES/allprofanity.txt +24 -0
  10. data/data/LICENSES/big-username-blocklist.txt +25 -0
  11. data/data/LICENSES/corpora.txt +128 -0
  12. data/data/LICENSES/cuss.txt +25 -0
  13. data/data/LICENSES/django-registration.txt +33 -0
  14. data/data/LICENSES/dsojevic-profanity-list.txt +24 -0
  15. data/data/LICENSES/forwardemail.txt +24 -0
  16. data/data/LICENSES/friendly-words.txt +24 -0
  17. data/data/LICENSES/gitea.txt +23 -0
  18. data/data/LICENSES/gitlab.txt +31 -0
  19. data/data/LICENSES/iana-well-known-uris.txt +130 -0
  20. data/data/LICENSES/imsky-wordlists.txt +24 -0
  21. data/data/LICENSES/mottie-github-reserved-names.txt +24 -0
  22. data/data/LICENSES/obscenity.txt +27 -0
  23. data/data/LICENSES/petname.txt +218 -0
  24. data/data/LICENSES/rfc2142.txt +9 -0
  25. data/data/LICENSES/shouldbee.txt +25 -0
  26. data/data/LICENSES/usernames.txt +30 -0
  27. data/data/NOTICE +36 -0
  28. data/data/offensive/README.md +90 -0
  29. data/data/offensive/allowlist/en.txt +727 -0
  30. data/data/offensive/allowlist/es.txt +39 -0
  31. data/data/offensive/en.json +5680 -0
  32. data/data/offensive/es.json +1014 -0
  33. data/data/offensive/hate.yml +51 -0
  34. data/data/offensive/leet.yml +27 -0
  35. data/data/reserved/ALLOWLIST_EXCEPTIONS.txt +6 -0
  36. data/data/reserved/impersonation.txt +69 -0
  37. data/data/reserved/mailboxes.txt +77 -0
  38. data/data/reserved/system.txt +124 -0
  39. data/data/reserved/web.txt +962 -0
  40. data/data/words/adjectives.txt +367 -0
  41. data/data/words/animals.txt +253 -0
  42. data/lib/generators/usernames/availability_generator.rb +67 -0
  43. data/lib/generators/usernames/existing_schema.rb +85 -0
  44. data/lib/generators/usernames/install_generator.rb +241 -0
  45. data/lib/generators/usernames/install_messages.rb +63 -0
  46. data/lib/generators/usernames/locale_generator.rb +56 -0
  47. data/lib/generators/usernames/templates/add_username.rb.erb +53 -0
  48. data/lib/generators/usernames/templates/availability/_field.html.erb.tt +25 -0
  49. data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +107 -0
  50. data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +115 -0
  51. data/lib/generators/usernames/templates/usernames.rb +34 -0
  52. data/lib/tasks/usernames.rake +38 -0
  53. data/lib/usernames/adapters/moderate.rb +37 -0
  54. data/lib/usernames/audit/output.rb +51 -0
  55. data/lib/usernames/audit/values.rb +34 -0
  56. data/lib/usernames/audit.rb +230 -0
  57. data/lib/usernames/candidates.rb +64 -0
  58. data/lib/usernames/comparison/index_contract.rb +241 -0
  59. data/lib/usernames/comparison/index_expression.rb +183 -0
  60. data/lib/usernames/comparison.rb +127 -0
  61. data/lib/usernames/configuration.rb +197 -0
  62. data/lib/usernames/data.rb +205 -0
  63. data/lib/usernames/engine.rb +32 -0
  64. data/lib/usernames/errors.rb +16 -0
  65. data/lib/usernames/generator.rb +206 -0
  66. data/lib/usernames/model.rb +397 -0
  67. data/lib/usernames/normalizer.rb +51 -0
  68. data/lib/usernames/offensive/hate.rb +124 -0
  69. data/lib/usernames/offensive/lexicon.rb +156 -0
  70. data/lib/usernames/offensive/name.rb +254 -0
  71. data/lib/usernames/offensive.rb +136 -0
  72. data/lib/usernames/phrase.rb +68 -0
  73. data/lib/usernames/reserved/listing.rb +79 -0
  74. data/lib/usernames/reserved.rb +219 -0
  75. data/lib/usernames/result.rb +52 -0
  76. data/lib/usernames/rules/options.rb +175 -0
  77. data/lib/usernames/rules.rb +257 -0
  78. data/lib/usernames/unique_violation.rb +176 -0
  79. data/lib/usernames/validator.rb +61 -0
  80. data/lib/usernames/version.rb +5 -0
  81. data/lib/usernames/words.rb +13 -0
  82. data/lib/usernames.rb +138 -0
  83. data/sig/usernames.rbs +75 -0
  84. 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