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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rules/options"
4
+
5
+ module Usernames
6
+ # The rules in force for one check: the configuration merged with one
7
+ # model's or one validator call's options, validated once and frozen.
8
+ # Build them with `Usernames.configuration.rules_for(**options)`, which
9
+ # caches them — every check after the first reuses the compiled regexes.
10
+ #
11
+ # The format rules are an ordered list of
12
+ #
13
+ # [reason, test, browser_source]
14
+ #
15
+ # where `test` is a Regexp (or a lambda) that MATCHES A NAME THAT FAILS,
16
+ # and the first failure wins. Server regexes anchor with \A and \z: `^` and
17
+ # `$` are LINE anchors in Ruby, which is how "validuser\nadmin" gets past
18
+ # the regex everyone copies from Stack Overflow. `browser_source` is the
19
+ # same rule as a JavaScript RegExp source (see #for_browser), or nil for a
20
+ # rule the browser does not hint.
21
+ class Rules
22
+ include Options
23
+
24
+ # Every code point Ruby's \p{Space} matches (Unicode White_Space), spelled
25
+ # out for JavaScript, whose \s is a slightly different set (it has U+FEFF
26
+ # and lacks U+0085).
27
+ WHITESPACE_SOURCE = '[\t-\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]'
28
+
29
+ # A character outside the allowed set that is a non-ASCII letter (or a
30
+ # combining mark, as in an accent left over from `İ`): the case for the
31
+ # friendlier :username_unicode message.
32
+ NON_ASCII_LETTER = /\A(?=[^\x00-\x7F])[\p{L}\p{M}]\z/
33
+
34
+ # Endings that read as a file when dots are enabled, on top of
35
+ # every extension Rails' Mime registry knows: Discourse's
36
+ # CONFUSING_EXTENSIONS, Gitea's reserved patterns, git, and what a web
37
+ # server would execute or serve as a secret.
38
+ FILE_EXTENSIONS = %w[
39
+ js json css htm html xml jpg jpeg png gif bmp ico tif tiff woff
40
+ keys gpg rss atom
41
+ git
42
+ php asp aspx jsp cgi pl py rb sh exe dll bak env
43
+ ].freeze
44
+
45
+ # Every extension Rails' Mime registry knows when rules are built,
46
+ # including any the app registered in an initializer. None without
47
+ # Action Dispatch: then there is no Rails router to confuse. Read when
48
+ # the rules are compiled: the first check, or the first after a
49
+ # configuration change.
50
+ def self.rails_extensions
51
+ defined?(Mime::EXTENSION_LOOKUP) ? Mime::EXTENSION_LOOKUP.keys.map(&:to_s) : []
52
+ end
53
+
54
+ attr_reader :format_checks
55
+
56
+ def initialize(**options)
57
+ assign(options)
58
+ @format_checks = build_format_checks
59
+ # Left unfrozen on purpose: the reserved and offensive matchers keep
60
+ # what they build from these rules here (see #compiled).
61
+ @compiled = {}
62
+ freeze
63
+ end
64
+
65
+ # What a content matcher built from these rules (a reserved set, the
66
+ # offensive exemptions), built on first use and kept for as long as the
67
+ # rules are: Configuration#rules_for caches the rules, so every check
68
+ # after the first reuses it. Two threads may build the same thing once
69
+ # each; both results are equal, so either may win. :nodoc:
70
+ def compiled(name)
71
+ @compiled.fetch(name) { @compiled[name] = yield }
72
+ end
73
+
74
+ # Short enough to read in a console or a failing test: the lists can
75
+ # hold a thousand words.
76
+ def inspect
77
+ "#<#{self.class.name} length=#{length} separators=#{separators.inspect} case=#{@case.inspect} " \
78
+ "edge_separators=#{edge_separators} first_character=#{first_character.inspect} " \
79
+ "offensive.severity=#{offensive.severity.inspect}>"
80
+ end
81
+
82
+ def lowercase?
83
+ @case == :lowercase
84
+ end
85
+
86
+ def normalize(value)
87
+ Normalizer.call(value, @case)
88
+ end
89
+
90
+ # What two names are compared by. ASCII-only on purpose: it is exactly
91
+ # what PostgreSQL's lower() does under COLLATE "C", so Ruby and the
92
+ # database agree.
93
+ def comparison_key(canonical)
94
+ canonical.downcase(:ascii)
95
+ end
96
+
97
+ # The first rule `value` breaks, as a Usernames::Failure, or nil. Applied
98
+ # to the value AS GIVEN — normalise first if that is what you mean.
99
+ # Structure before content: format, then reserved, then offensive.
100
+ def failure_for(value)
101
+ string = readable(value)
102
+ return failure(:username_characters) unless string
103
+
104
+ format_failure(string) || content_failure(string)
105
+ end
106
+
107
+ def format_failure(string)
108
+ # `===` is Ruby's "does this match": Regexp#=== and Proc#=== both answer it.
109
+ reason, = format_checks.find { |_reason, test, _source| test === string } # rubocop:disable Style/CaseEquality
110
+ failure(reason) if reason
111
+ end
112
+
113
+ # Hints for a form, as [{ rule:, pattern:, message: }] in the current
114
+ # locale. Advisory only: Ruby's strip and JavaScript's trim()
115
+ # disagree on NUL, U+FEFF and U+1680, so the browser approximates
116
+ # normalisation and the server stays the only authority. A pattern
117
+ # MATCHES a name that breaks the rule, and is written in the subset both
118
+ # engines read the same way — no \A, \z, lookbehind or \p{…}. With a
119
+ # `record` and `attribute`, the messages are that model's (Failure#message).
120
+ def for_browser(record = nil, attribute = nil)
121
+ format_checks.filter_map do |reason, _test, source|
122
+ { rule: reason, pattern: source, message: failure(reason).message(record, attribute) } if source
123
+ end
124
+ end
125
+
126
+ # The Failure for a format rule, with the interpolations its message needs.
127
+ def failure(reason)
128
+ Failure.new(reason: reason, details: details_for(reason))
129
+ end
130
+
131
+ private
132
+
133
+ # Valid UTF-8, or nil when the value's own bytes are broken. The
134
+ # validator sees values exactly as assigned, and a regex over invalid
135
+ # bytes raises; such a value has characters that cannot be allowed.
136
+ def readable(value)
137
+ string = value.to_s
138
+ string.encode(Encoding::UTF_8) if string.valid_encoding?
139
+ rescue EncodingError
140
+ nil
141
+ end
142
+
143
+ # Reserved words are matched on the comparison key alone; the offensive
144
+ # matcher also reads the case the name was typed in (Offensive.verdict).
145
+ def content_failure(string)
146
+ if (rule = Reserved.verdict(comparison_key(string), self))
147
+ Failure.new(reason: :username_reserved, rule: rule)
148
+ elsif (rule = Offensive.verdict(string, self))
149
+ Failure.new(reason: :username_not_allowed, rule: rule)
150
+ end
151
+ end
152
+
153
+ def details_for(reason)
154
+ case reason
155
+ when :too_short then { count: length.min }
156
+ when :too_long then { count: length.max }
157
+ when :username_characters then characters_details
158
+ when :username_edges then { edge_separators_phrase: Phrase.any_separator(edge_set) }
159
+ else {}
160
+ end
161
+ end
162
+
163
+ # `count` (how many separators) picks the sentence: one finishes the list
164
+ # ("…, numbers and underscores"), more continue it ("…, numbers,
165
+ # underscores and periods"). With none there is no list to finish, so the
166
+ # message is a different translation of the same error type — not a
167
+ # :zero plural, which pluralizers such as rails-i18n's Spanish one ignore.
168
+ # username_characters_without_separators is that translation: the same
169
+ # error type, only worded for no separators.
170
+ def characters_details
171
+ letters = Phrase.letters(@case)
172
+ return { message: :username_characters_without_separators, letters: letters } if separators.empty?
173
+
174
+ { count: separators.size, letters: letters, separators_phrase: Phrase.separators(separators) }
175
+ end
176
+
177
+ def build_format_checks
178
+ [
179
+ [:username_spaces, /\p{Space}/, WHITESPACE_SOURCE],
180
+ *character_checks,
181
+ [:too_short, ->(string) { string.length < length.min }, "^[\\s\\S]{0,#{length.min - 1}}$"],
182
+ [:too_long, ->(string) { string.length > length.max }, "[\\s\\S]{#{length.max + 1}}"],
183
+ *separator_checks,
184
+ *first_character_checks,
185
+ # Rule 7: a name with no letter in it reads as a number, whatever
186
+ # separators sit between the digits (`231640921`, `123_456`), and a
187
+ # number is an id, not a person. Separators alone never reach this
188
+ # rule: edges and runs refuse them first.
189
+ [:username_digits_only, /\A[^A-Za-z]+\z/, "^[^A-Za-z]+$"],
190
+ *extension_checks
191
+ ].freeze
192
+ end
193
+
194
+ # Rule 2. Everything that is not an allowed character fails here —
195
+ # accents, emoji, invisible and bidi characters alike — with the
196
+ # friendlier :username_unicode message when every offender is a
197
+ # non-ASCII letter (`josé`, `иван`). The browser only hints the general
198
+ # rule; the refinement is the same failure in other words.
199
+ def character_checks
200
+ allowed = "#{lowercase? ? 'a-z' : 'A-Za-z'}0-9#{class_of(separators)}"
201
+ disallowed = /[^#{allowed}]/
202
+ only_letters = lambda do |string|
203
+ offenders = string.scan(disallowed)
204
+ offenders.any? && offenders.all?(NON_ASCII_LETTER)
205
+ end
206
+
207
+ [[:username_unicode, only_letters, nil], [:username_characters, disallowed, "[^#{allowed}]"]]
208
+ end
209
+
210
+ # Rules 4 and 5: no separator at an edge (never a dot, even with
211
+ # edge_separators — "thanks @javi." ends a sentence), no two in a row of
212
+ # any kind (`oliver.__-hjj`).
213
+ def separator_checks
214
+ checks = []
215
+ if edge_set.any?
216
+ edge = class_of(edge_set)
217
+ checks << [:username_edges, /\A[#{edge}]|[#{edge}]\z/, "^[#{edge}]|[#{edge}]$"]
218
+ end
219
+ if separators.any?
220
+ all = class_of(separators)
221
+ checks << [:username_consecutive, /[#{all}]{2}/, "[#{all}]{2}"]
222
+ end
223
+ checks
224
+ end
225
+
226
+ # Rule 6. With :alphanumeric any letter or digit may come first, and a
227
+ # separator first is rule 4's business, so there is nothing to check.
228
+ # With :letter the first character must be a letter even when
229
+ # edge_separators allows `_javi`: the stricter reading is the one that
230
+ # matches "must start with a letter".
231
+ def first_character_checks
232
+ return [] unless first_character == :letter
233
+
234
+ letters = lowercase? ? "a-z" : "A-Za-z"
235
+ [[:username_first_character, /\A[^#{letters}]/, "^[^#{letters}]"]]
236
+ end
237
+
238
+ # Rule 8, only when dots are allowed: `/@john.json` confuses
239
+ # routers, CDNs and crawlers. Not a browser hint: the hints stop at rule 7.
240
+ def extension_checks
241
+ return [] unless separators.include?(".")
242
+
243
+ extensions = (Rules.rails_extensions + FILE_EXTENSIONS).uniq.map { |extension| Regexp.escape(extension) }
244
+ [[:username_extension, /\.(?:#{extensions.join('|')})\z/i, nil]]
245
+ end
246
+
247
+ def edge_set
248
+ edge_separators ? separators & ["."] : separators
249
+ end
250
+
251
+ # The body of a character class holding exactly these separators, written
252
+ # the same way for Ruby and JavaScript (`\.` and `\-` mean themselves in both).
253
+ def class_of(characters)
254
+ characters.map { |character| Regexp.escape(character) }.join
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # Whether an ActiveRecord::RecordNotUnique came from the username's own
5
+ # unique index — the one that decides who has a name — or from another one.
6
+ #
7
+ # A duplicate email raises exactly the same exception class as a duplicate
8
+ # username, and so does an app's own unique index that merely mentions the
9
+ # column (`substr(username, 1, 3)`, `(tenant_id, username)`). Redrawing a
10
+ # generated name over one of those would loop for nothing, and reporting it
11
+ # as "username has already been taken" would be a lie that hides a real
12
+ # error — so the race wrapper asks this first, and anything that is not the
13
+ # username's identity index propagates untouched.
14
+ #
15
+ # Each adapter names the culprit differently:
16
+ #
17
+ # PostgreSQL the constraint (and its table and schema), from the server's error diagnostics
18
+ # MySQL Duplicate entry 'javi' for key 'users.index_users_on_username'
19
+ # SQLite UNIQUE constraint failed: users.username
20
+ # UNIQUE constraint failed: index 'index_users_on_lower_username'
21
+ #
22
+ # The identity index is the one the gem names itself, the app's configured
23
+ # `unique_index`, or any index that passes the comparison contract for the
24
+ # column (Comparison::IndexContract). SQLite's column form names no index,
25
+ # so there the column must be the only one named, and the table must have
26
+ # an identity index on exactly it. When the table has more than one unique
27
+ # index on that column alone (an app's own NOCASE one, say), the message
28
+ # could be either's: then it is ours only when `conflict` — one query
29
+ # under the gem's comparison — finds another row with the name.
30
+ module UniqueViolation
31
+ POSTGRESQL_CONSTRAINT = /unique constraint "(?<name>[^"]+)"/
32
+ MYSQL_KEY = /for key '(?:(?<table>[^'.]+)\.)?(?<name>[^']+)'/
33
+ SQLITE_COLUMNS = /UNIQUE constraint failed: (?<columns>[^:']+?)\s*\z/
34
+ SQLITE_INDEX = /UNIQUE constraint failed: index '(?<name>[^']+)'/
35
+
36
+ # What a violation is matched against: the table as the model names it
37
+ # (possibly schema-qualified), its bare name, the column, the configured
38
+ # index, the case mode, the connection to read indexes back with and the
39
+ # conflict query. :nodoc:
40
+ Target = ::Data.define(:table, :bare_table, :column, :index, :mode, :connection, :conflict)
41
+
42
+ class << self
43
+ # UniqueViolation.of?(error, table: "users", column: :username, rules: rules)
44
+ #
45
+ # `rules` are the column's Usernames::Rules: its case mode, and
46
+ # config.unique_index (an index the generator did not create).
47
+ # `connection` is the model's own, which reads its indexes back (an app
48
+ # may have several databases); without one, Active Record's default
49
+ # connection is used. `conflict` answers whether another row has the
50
+ # record's name under the gem's comparison; without one, an ambiguous
51
+ # SQLite message is not ours.
52
+ # rubocop:disable-next Metrics/ParameterLists -- each is one fact about the save, all by keyword
53
+ def of?(error, table:, column:, rules: Usernames.configuration.rules_for, connection: nil, conflict: nil)
54
+ target = Target.new(table: table.to_s, bare_table: table.to_s.split(".").last, column: column.to_s,
55
+ index: rules.unique_index&.to_s, mode: rules.case, connection: connection,
56
+ conflict: conflict)
57
+ name = postgresql_constraint(error)
58
+ return postgresql_target?(error, target) && identity?(name, target) if name
59
+
60
+ named_in_message?(error.message.to_s, target)
61
+ end
62
+
63
+ private
64
+
65
+ # SQLite names the columns, or the index of an expression; MySQL the key.
66
+ def named_in_message?(message, target)
67
+ if (match = SQLITE_COLUMNS.match(message))
68
+ match[:columns].split(",").map(&:strip) == ["#{target.bare_table}.#{target.column}"] &&
69
+ on_the_column_alone?(target)
70
+ elsif (match = SQLITE_INDEX.match(message) || MYSQL_KEY.match(message))
71
+ same_table?(match, target) && identity?(match[:name], target)
72
+ else
73
+ false
74
+ end
75
+ end
76
+
77
+ # The constraint name from PostgreSQL's diagnostics (PG_DIAG_CONSTRAINT_NAME),
78
+ # or from the message when the error carries no result.
79
+ def postgresql_constraint(error)
80
+ field = diagnostic(error, :PG_DIAG_CONSTRAINT_NAME)
81
+ return field if field
82
+
83
+ POSTGRESQL_CONSTRAINT.match(error.message.to_s)&.[](:name)
84
+ end
85
+
86
+ # A violation reported on another table (a trigger writing elsewhere),
87
+ # or on a same-named table in another schema, is not ours, whatever its
88
+ # index is called.
89
+ def postgresql_target?(error, target)
90
+ table = diagnostic(error, :PG_DIAG_TABLE_NAME)
91
+ schema = diagnostic(error, :PG_DIAG_SCHEMA_NAME)
92
+ wanted_schema = target.table.split(".")[-2]
93
+ (table.nil? || table == target.bare_table) && (schema.nil? || wanted_schema.nil? || schema == wanted_schema)
94
+ end
95
+
96
+ def diagnostic(error, field)
97
+ result = error.cause.try(:result)
98
+ return unless result && defined?(::PG) && ::PG.const_defined?(field)
99
+
100
+ result.error_field(::PG.const_get(field))
101
+ end
102
+
103
+ # A MySQL key names its table only from 8.0.19 on; SQLite's index form never does.
104
+ def same_table?(match, target)
105
+ reported = match[:table] if match.names.include?("table")
106
+ reported.nil? || reported == target.bare_table
107
+ end
108
+
109
+ def identity?(name, target)
110
+ return true if known_name?(name, target)
111
+
112
+ reading(target) do |connection, contract|
113
+ found = connection.indexes(target.table).find { |index| index.name == name }
114
+ found ? contract.satisfied_by?(found) : false
115
+ end
116
+ end
117
+
118
+ # SQLite's "UNIQUE constraint failed: users.username": some unique
119
+ # index on exactly that column was violated. It is ours when one such
120
+ # index is the identity index and either it is the only one or another
121
+ # row really has the name.
122
+ def on_the_column_alone?(target)
123
+ reading(target) do |connection, contract|
124
+ alone = connection.indexes(target.table).select { |index| alone_on_the_column?(index, target) }
125
+ next false unless alone.any? { |index| known_name?(index.name, target) || contract.satisfied_by?(index) }
126
+
127
+ [alone.size, sqlite_unique_indexes_on(connection, contract, target)].max <= 1 || target.conflict&.call == true
128
+ end
129
+ end
130
+
131
+ def alone_on_the_column?(index, target)
132
+ index.unique && Array(index.columns) == [target.column]
133
+ end
134
+
135
+ # Every unique index on the column alone, as SQLite itself lists them:
136
+ # Active Record leaves out those a UNIQUE column constraint creates.
137
+ def sqlite_unique_indexes_on(connection, contract, target)
138
+ return 0 unless contract.adapter == :sqlite
139
+
140
+ names = connection.select_values("SELECT name FROM pragma_index_list(#{connection.quote(target.bare_table)}) " \
141
+ "WHERE \"unique\" = 1")
142
+ names.count do |name|
143
+ connection.select_values("SELECT name FROM pragma_index_info(#{connection.quote(name)})") == [target.column]
144
+ end
145
+ end
146
+
147
+ def known_name?(name, target)
148
+ name == target.index || gem_index_names(target).include?(name)
149
+ end
150
+
151
+ def gem_index_names(target)
152
+ %i[lowercase preserve].map do |mode|
153
+ Comparison.index_definition(adapter: :sqlite, table: target.table, column: target.column, mode: mode)[:name]
154
+ end
155
+ end
156
+
157
+ # Reading indexes back can fail (a permissions quirk, a dropped
158
+ # connection); then only the names above are recognised, and the error
159
+ # propagates.
160
+ def reading(target)
161
+ with_connection(target) do |connection|
162
+ yield connection, Comparison::IndexContract.new(connection, table: target.table, column: target.column,
163
+ mode: target.mode)
164
+ end
165
+ rescue StandardError
166
+ false
167
+ end
168
+
169
+ def with_connection(target, &)
170
+ return yield(target.connection) if target.connection
171
+
172
+ ActiveRecord::Base.with_connection(&)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module ActiveModel
6
+ module Validations
7
+ # validates :username, username: true
8
+ # validates :username, username: { length: 2..15, separators: %w[_ .] }
9
+ # validates :username, username: { message: "isn't available" }
10
+ #
11
+ # The rules only — format, reserved, offensive — with no database: you
12
+ # keep your own uniqueness validation and index (has_username brings both).
13
+ #
14
+ # It validates the value AS SUPPLIED. In lowercase mode `Javi`
15
+ # fails "can only contain lowercase letters…", because `Javi` is not what
16
+ # would be stored: validate what you store. Pair it with
17
+ #
18
+ # normalizes :username, with: Usernames.normalizer
19
+ #
20
+ # and typed ` @Javi ` becomes `javi` before this ever sees it.
21
+ #
22
+ # One error at most, for the first rule that fails, so a form never shows
23
+ # a stack of complaints about one short string. nil is skipped (presence
24
+ # is a separate question: `validates :username, presence: true`).
25
+ class UsernameValidator < EachValidator
26
+ # Options Active Model itself understands; everything else is a rule.
27
+ VALIDATION_OPTIONS = %i[message strict if unless on allow_nil allow_blank class].freeze
28
+
29
+ # Bad rule options fail when the model is defined, not at the first sign-up.
30
+ def check_validity!
31
+ rules
32
+ end
33
+
34
+ def validate_each(record, attribute, value)
35
+ return if value.nil?
36
+
37
+ failure = rules.failure_for(value)
38
+ return unless failure
39
+
40
+ # The details first and `message:` last, so a message passed to the
41
+ # validator replaces every rule's message.
42
+ record.errors.add(attribute, failure.reason, **failure.details, **options.slice(:message, :strict))
43
+ end
44
+
45
+ private
46
+
47
+ # Resolved on every validation (and cached by the configuration), so a
48
+ # configuration changed after the model was loaded still applies.
49
+ def rules
50
+ Usernames.configuration.rules_for(**options.except(*VALIDATION_OPTIONS))
51
+ end
52
+ end
53
+
54
+ module HelperMethods
55
+ # validates_username_of :username, length: 2..15
56
+ def validates_username_of(*attr_names)
57
+ validates_with UsernameValidator, _merge_attributes(attr_names)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ VERSION = "0.1.3"
5
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # The generator's default vocabulary, from data/words/: short,
5
+ # everyday, reviewed words that no adjective + animal pair, with any
6
+ # digits, turns into something the gem itself would refuse — the test
7
+ # suite checks every pair. `config.generator.adjectives` / `.nouns` start
8
+ # from these.
9
+ module Words
10
+ ADJECTIVES = Data.adjectives
11
+ ANIMALS = Data.nouns
12
+ end
13
+ end
data/lib/usernames.rb ADDED
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_model"
5
+ require "i18n"
6
+
7
+ require_relative "usernames/errors"
8
+ require_relative "usernames/version"
9
+ require_relative "usernames/data"
10
+
11
+ # Every bundled list, read now: a broken install fails here, naming the file.
12
+ Usernames::Data.load!
13
+
14
+ require_relative "usernames/normalizer"
15
+ require_relative "usernames/phrase"
16
+ require_relative "usernames/result"
17
+ require_relative "usernames/reserved"
18
+ require_relative "usernames/offensive"
19
+ require_relative "usernames/words"
20
+ require_relative "usernames/generator"
21
+ require_relative "usernames/candidates"
22
+ require_relative "usernames/rules"
23
+ require_relative "usernames/configuration"
24
+ require_relative "usernames/validator"
25
+ require_relative "usernames/comparison"
26
+ require_relative "usernames/unique_violation"
27
+ require_relative "usernames/model"
28
+ require_relative "usernames/engine" if defined?(Rails::Engine)
29
+
30
+ module Usernames
31
+ # Deliberately permissive: what may ROUTE is not what may be
32
+ # REGISTERED. A name grandfathered in under older rules still has a page,
33
+ # the validator decides what can be registered, and the database decides
34
+ # what exists. No anchors: Rails refuses them in route constraints.
35
+ ROUTE = /[A-Za-z0-9._-]+/
36
+
37
+ LOCALES = File.expand_path("../config/locales/*.yml", __dir__)
38
+
39
+ # Under Rails the engine loads config/locales (before the app's, so the
40
+ # app's keys win). Without Rails nothing would, and every message would
41
+ # read "Translation missing".
42
+ I18n.load_path |= Dir[LOCALES] unless defined?(Rails::Engine)
43
+
44
+ class << self
45
+ attr_writer :configuration
46
+
47
+ # Lazily initialized, so every function works without an initializer.
48
+ def configuration
49
+ @configuration ||= Configuration.new
50
+ end
51
+
52
+ # Validates at the end of the block, so a bad value fails on the line
53
+ # that boots the app, naming itself.
54
+ def configure
55
+ yield configuration
56
+ configuration.validate!
57
+ end
58
+
59
+ # Usernames.check("Ad_min")
60
+ # # => #<data Usernames::Result ok?=false, reason=:username_reserved, rule=:impersonation, canonical="ad_min", …>
61
+ #
62
+ # Normalises, then applies every rule in order; the first failure wins.
63
+ # Options override the configuration for this call (`length: 2..15`).
64
+ def check(value, **)
65
+ rules = configuration.rules_for(**)
66
+ canonical = rules.normalize(value)
67
+ Result.for(canonical, rules.failure_for(canonical))
68
+ end
69
+
70
+ # Eligibility only: whether the name may be registered at all. Says
71
+ # nothing about whether somebody already has it.
72
+ def allowed?(value, **)
73
+ check(value, **).ok?
74
+ end
75
+
76
+ # Usernames.normalize(" @Javi_R ") # => "javi_r"
77
+ def normalize(value, **)
78
+ configuration.rules_for(**).normalize(value)
79
+ end
80
+
81
+ # What two names are compared by: Usernames.comparison_key("Javi") # => "javi".
82
+ # Normalises first, which changes nothing in a name that already is
83
+ # canonical (normalisation is idempotent), so it accepts any input.
84
+ def comparison_key(value, **)
85
+ rules = configuration.rules_for(**)
86
+ rules.comparison_key(rules.normalize(value))
87
+ end
88
+
89
+ # normalizes :username, with: Usernames.normalizer
90
+ #
91
+ # A blank result is stored as nil, so " " never becomes an empty
92
+ # username that a unique index would let only one person have. The
93
+ # options are checked now, when the model is defined; the configuration
94
+ # is read on every call, so it may still change after that.
95
+ def normalizer(**options)
96
+ configuration.rules_for(**options)
97
+
98
+ lambda do |value|
99
+ normalized = normalize(value, **options)
100
+ normalized unless normalized.empty?
101
+ end
102
+ end
103
+
104
+ # get "@:username", to: "profiles#show", format: false, constraints: { username: Usernames.route }
105
+ def route
106
+ ROUTE
107
+ end
108
+
109
+ # [{ rule:, pattern:, message: }] for live hints in a form.
110
+ def rules_for_browser(**)
111
+ configuration.rules_for(**).for_browser
112
+ end
113
+
114
+ # Usernames.generate # => "quiet_otter_42"
115
+ # Usernames.generate(from: "José García") # => "jose_garcia"
116
+ #
117
+ # A name the rules allow. Eligibility only, like check: whether
118
+ # somebody already has it is for the database to say — has_username's
119
+ # generate_username takes the whole batch there in one query.
120
+ # Raises NoFreeNameError when the rules leave nothing to draw
121
+ # (a word list emptied, a minimum length no shape reaches) rather than
122
+ # returning nil, which a model would store as a missing username.
123
+ def generate(from: nil, **)
124
+ name = Generator.candidates(from: from, rules: configuration.rules_for(**)).first
125
+ return name if name
126
+
127
+ raise NoFreeNameError, "no name these rules allow could be generated; check length, separators and the generator's word lists"
128
+ end
129
+
130
+ # Usernames.candidates_for("Javi R", count: 5) # => ["javi_r", "javir", "javi_r_42", …]
131
+ #
132
+ # Up to 3 × count allowed names, ordered, deduplicated. May be
133
+ # fewer, never raises for running out.
134
+ def candidates_for(seed, count:, **)
135
+ Candidates.for(seed, count: count, rules: configuration.rules_for(**))
136
+ end
137
+ end
138
+ end