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,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `require "usernames/model"` on its own (Active Record without Rails) brings
4
+ # the rest of the gem with it.
5
+ require_relative "../usernames" unless defined?(Usernames::Configuration)
6
+
7
+ module Usernames
8
+ # `has_username`, on every Active Record model: the engine extends
9
+ # ActiveRecord::Base with this module when Active Record loads, and so does
10
+ # requiring this file without Rails.
11
+ #
12
+ # class User < ApplicationRecord
13
+ # has_username
14
+ # end
15
+ #
16
+ # class Player < ApplicationRecord
17
+ # has_username :handle, length: 2..24, case: :preserve, separators: %w[_ . -]
18
+ # end
19
+ module Model
20
+ def self.extended(base)
21
+ return if base.respond_to?(:_usernames)
22
+
23
+ # { column => Usernames::Model::Column } for every has_username of a
24
+ # model (and, inherited, of its subclasses). :nodoc:
25
+ base.class_attribute :_usernames, instance_accessor: false, default: {}.freeze
26
+ end
27
+
28
+ # has_username(column = :username, required: false, generate: nil, **rules)
29
+ #
30
+ # Installs, for that column:
31
+ #
32
+ # 1. `normalizes`, so " @Javi " is stored as "javi" and a blank as nil;
33
+ # 2. the username rules, checked only when the value changes — a rule
34
+ # added later never stops an old account from saving its other
35
+ # attributes;
36
+ # 3. with `required:`, presence — on create, and on an update that
37
+ # changes it (a legacy blank still saves unrelated changes);
38
+ # 4. uniqueness, compared the way the unique index compares (Comparison);
39
+ # 5. a generated name on create when none was given — by default, unless
40
+ # a name is required for that record;
41
+ # 6. a race wrapper, so two sign-ups racing for one name end in a redraw
42
+ # (a generated name) or in "has already been taken" (a typed one) —
43
+ # never a RecordNotUnique 500.
44
+ #
45
+ # `required:` and `generate:` take true, false, a method name or a proc,
46
+ # evaluated on the record the way Rails' `if:` is: a sign-up flow that
47
+ # asks for a name (`required: :handle_required`) gets presence, and every
48
+ # other record a generated name.
49
+ #
50
+ # And the class helpers, named after the column: `username_available?`,
51
+ # `suggest_usernames`, `generate_username`, `find_by_username` (and `!`),
52
+ # `username_options`, `check_username`, `username_rules_for_browser`
53
+ # (`handle_available?`, `suggest_handles`, … for `has_username :handle`).
54
+ #
55
+ # The case mode is read here, once: the unique index is built for one
56
+ # mode, so the model keeps comparing in it even if the configuration
57
+ # changes later. Every other rule is read from the configuration on each
58
+ # check.
59
+ def has_username(column = :username, required: false, generate: nil, **rules)
60
+ column = Column.new(self, column, required: required, generate: generate, rules: rules)
61
+ if _usernames.key?(column.name)
62
+ raise ConfigurationError, "has_username :#{column.name} is already declared on #{name || self}"
63
+ end
64
+
65
+ self._usernames = _usernames.merge(column.name => column).freeze
66
+ column.install
67
+ end
68
+
69
+ # true, false, or a condition evaluated on `record` as Rails evaluates
70
+ # `if:` — a Symbol is a method sent to it, a proc without arguments is
71
+ # run on it, a proc with one is given it. :nodoc:
72
+ def self.applies?(condition, record)
73
+ result =
74
+ case condition
75
+ when true, false then condition
76
+ when Symbol then record.send(condition)
77
+ else condition.arity.zero? ? record.instance_exec(&condition) : condition.call(record)
78
+ end
79
+ result ? true : false
80
+ end
81
+
82
+ # One has_username declaration, and everything that column needs: the
83
+ # rules in force, the comparison expression, and the queries behind the
84
+ # class helpers. :nodoc:
85
+ class Column
86
+ attr_reader :model, :name, :required, :generate, :options
87
+
88
+ def initialize(model, name, required:, generate:, rules:)
89
+ @model = model
90
+ @name = name.to_sym
91
+ @required = condition(:required, required)
92
+ # By default a name is generated exactly where none is required: where
93
+ # one is, the person chooses.
94
+ @generate = generate.nil? ? nil : condition(:generate, generate)
95
+ # Resolving them now raises ConfigurationError for a bad option when
96
+ # the model is defined, and pins the case mode (see has_username).
97
+ @options = rules.merge(case: Usernames.configuration.rules_for(**rules).case).freeze
98
+ end
99
+
100
+ # Whether `record` must have a name.
101
+ def required?(record)
102
+ Model.applies?(required, record)
103
+ end
104
+
105
+ # Whether `record` gets a generated name on create when it has none.
106
+ def generate?(record)
107
+ generate.nil? ? !required?(record) : Model.applies?(generate, record)
108
+ end
109
+
110
+ def mode
111
+ options.fetch(:case)
112
+ end
113
+
114
+ # The Usernames::Rules in force: the configuration as it is now, with
115
+ # this column's options over it (cached by the configuration).
116
+ def rules
117
+ Usernames.configuration.rules_for(**options)
118
+ end
119
+
120
+ def install
121
+ install_normalization_and_rules
122
+ install_uniqueness
123
+ install_generation
124
+ model.include(InstanceMethods.for(name))
125
+ model.extend(ClassMethods.for(self))
126
+ end
127
+
128
+ # The comparison expression, for a bound predicate.
129
+ def sql(on = model)
130
+ Comparison.sql(on, name)
131
+ end
132
+
133
+ # Those of `names` nobody has, in order — ONE query for all of them.
134
+ def free(names, rules, on)
135
+ return [] if names.empty?
136
+
137
+ keys = names.map { |candidate| rules.comparison_key(candidate) }
138
+ expression = sql(on)
139
+ taken = on.base_class.unscoped.where("#{expression} IN (?)", keys.uniq)
140
+ .pluck(Arel.sql(expression)).to_set { |stored| rules.comparison_key(stored.to_s) }
141
+ names.reject { |candidate| taken.include?(rules.comparison_key(candidate)) }
142
+ end
143
+
144
+ # Whether someone else already has the canonical name `canonical`.
145
+ # `except` is the record itself, so a case-only change (`Javi` → `JAVI`
146
+ # in preserve mode) does not collide with its own row.
147
+ def taken?(canonical, on, except: nil)
148
+ scope = on.base_class.unscoped.where("#{sql(on)} = ?", rules.comparison_key(canonical))
149
+ scope = scope.where.not(on.primary_key => except.id_in_database) if except&.persisted?
150
+ scope.exists?
151
+ end
152
+
153
+ # Whether a RecordNotUnique raised while saving `record` came from this
154
+ # column's unique index, read on the connection the save is using.
155
+ # When the database's message cannot say which index it was, it is
156
+ # ours only if another row really has the name under the gem's
157
+ # comparison.
158
+ #
159
+ # with_connection, not lease_connection: inside a scoped checkout
160
+ # (a thread or job wrapped in `with_connection`), lease_connection
161
+ # would turn it into a permanent lease, and the connection would stay
162
+ # checked out after the block, held by a thread that may already be
163
+ # gone.
164
+ def violated_by?(error, record)
165
+ on = record.class
166
+ conflict = -> { taken?(record.public_send(name).to_s, on, except: record) }
167
+ on.with_connection do |connection|
168
+ UniqueViolation.of?(error, table: on.table_name, column: name, rules: rules, connection: connection,
169
+ conflict: conflict)
170
+ end
171
+ end
172
+
173
+ private
174
+
175
+ def install_normalization_and_rules
176
+ column = name
177
+ definition = self
178
+ changed = :"will_save_change_to_#{column}?"
179
+ model.normalizes column, with: Usernames.normalizer(**options)
180
+ model.validates column, username: options, if: changed
181
+ return if required == false
182
+
183
+ # On create, and when an update touches it: a pre-existing blank
184
+ # saves unrelated changes; clearing a name is :blank. Not when
185
+ # generation already said why the name is blank
186
+ # (:username_unavailable): one error per name.
187
+ model.validates column, presence: true, if: lambda {
188
+ (new_record? || will_save_change_to_attribute?(column)) && definition.required?(self) &&
189
+ !errors.of_kind?(column, :username_unavailable)
190
+ }
191
+ end
192
+
193
+ # Left out altogether when no record can ever get a generated name.
194
+ def install_generation
195
+ return if generate == false || (generate.nil? && required == true)
196
+
197
+ definition = self
198
+ model.before_validation(:"assign_#{name}", on: :create, if: -> { definition.generate?(self) })
199
+ end
200
+
201
+ # Lowercase mode stores the canonical value, so Rails' own exact
202
+ # comparison on a byte-compared column is the contract. Preserve mode
203
+ # needs the comparison expression, which Rails' `case_sensitive: false`
204
+ # (a collation-dependent LOWER()) is not.
205
+ #
206
+ # Skipped when the name already failed a rule: one error per name, the
207
+ # first that applies — a new `admin` is reserved, whoever else is
208
+ # called that.
209
+ def install_uniqueness
210
+ column = name
211
+ checks = { if: :"will_save_change_to_#{name}?", unless: -> { errors.include?(column) } }
212
+ if mode == :preserve
213
+ model.validate :"validate_#{name}_uniqueness", **checks
214
+ else
215
+ model.validates name, uniqueness: { case_sensitive: true }, allow_nil: true, **checks
216
+ end
217
+ end
218
+
219
+ def condition(option, value)
220
+ return value if [true, false].include?(value) || value.is_a?(Symbol) || value.is_a?(Proc)
221
+
222
+ raise ConfigurationError, "has_username #{option}: must be true, false, a method name or a proc " \
223
+ "(got #{value.inspect})"
224
+ end
225
+ end
226
+
227
+ # The class helpers for one column, as a module the model extends.
228
+ module ClassMethods
229
+ def self.for(column)
230
+ Module.new.tap do |helpers|
231
+ %i[available suggest generate find_by options check browser].each { |helper| send(helper, helpers, column) }
232
+ end
233
+ end
234
+
235
+ # User.username_available?("javi") — false when taken, reserved, not allowed or invalid.
236
+ def self.available(helpers, column)
237
+ helpers.define_method(:"#{column.name}_available?") do |value|
238
+ rules = column.rules
239
+ canonical = rules.normalize(value)
240
+ !canonical.empty? && rules.failure_for(canonical).nil? && !column.taken?(canonical, self)
241
+ end
242
+ end
243
+
244
+ # User.suggest_usernames("Javi R", count: 5) — up to `count` free
245
+ # names, in one query; possibly fewer, even [].
246
+ def self.suggest(helpers, column)
247
+ helpers.define_method(:"suggest_#{column.name.to_s.pluralize}") do |seed = nil, count: 5|
248
+ rules = column.rules
249
+ column.free(Candidates.for(seed, count: count, rules: rules), rules, self).first(count)
250
+ end
251
+ end
252
+
253
+ # User.generate_username / User.generate_username(from: "José García") —
254
+ # one batch of candidates, one query, the first free one.
255
+ def self.generate(helpers, column)
256
+ helpers.define_method(:"generate_#{column.name}") do |from: nil|
257
+ rules = column.rules
258
+ free = column.free(Generator.candidates(from: from, rules: rules), rules, self).first
259
+ free || raise(NoFreeNameError, "every generated #{column.name} in this batch is taken or not allowed; " \
260
+ "check length, separators and the generator's word lists")
261
+ end
262
+ end
263
+
264
+ # User.find_by_username("@Javi") — normalised, then compared as the index
265
+ # compares; nil when nobody has it. User.find_by_username!("@Javi") raises
266
+ # ActiveRecord::RecordNotFound instead, as find_by! does, so a profile
267
+ # route answers 404.
268
+ def self.find_by(helpers, column)
269
+ finder = :"find_by_#{column.name}"
270
+ helpers.define_method(finder) do |value|
271
+ rules = column.rules
272
+ canonical = rules.normalize(value)
273
+ find_by("#{column.sql(self)} = ?", rules.comparison_key(canonical)) unless canonical.empty?
274
+ end
275
+ helpers.define_method(:"#{finder}!") do |value|
276
+ public_send(finder, value) ||
277
+ raise(ActiveRecord::RecordNotFound.new("Couldn't find #{name} with #{column.name} #{value.inspect}", name))
278
+ end
279
+ end
280
+
281
+ # The rule options of this column, for Usernames.check,
282
+ # Usernames.comparison_key and Usernames.rules_for_browser.
283
+ # The availability check needs the model's own rules to tell
284
+ # "invalid" from "taken".
285
+ def self.options(helpers, column)
286
+ helpers.define_method(:"#{column.name}_options") { column.options }
287
+ end
288
+
289
+ # User.check_username("Ad_min") — Usernames.check with this column's
290
+ # rules, its message worded as this model's own error would be (a
291
+ # per-model translation wins). Eligibility only, like Usernames.check:
292
+ # no query, so `ok?` says nothing about whether the name is free.
293
+ def self.check(helpers, column)
294
+ helpers.define_method(:"check_#{column.name}") do |value|
295
+ rules = column.rules
296
+ canonical = rules.normalize(value)
297
+ Result.for(canonical, rules.failure_for(canonical), record: new, attribute: column.name)
298
+ end
299
+ end
300
+
301
+ # User.username_rules_for_browser — Usernames.rules_for_browser with
302
+ # this column's rules and this model's words, for the hints a form
303
+ # shows while someone types.
304
+ def self.browser(helpers, column)
305
+ helpers.define_method(:"#{column.name}_rules_for_browser") { column.rules.for_browser(new, column.name) }
306
+ end
307
+ end
308
+
309
+ # The instance side for one column: `username_generated?`, generation on
310
+ # create, preserve-mode uniqueness and the race wrapper.
311
+ module InstanceMethods
312
+ def self.for(column)
313
+ Module.new.tap do |methods|
314
+ define_generation(methods, column)
315
+ define_uniqueness(methods, column)
316
+ methods.module_eval(source(column), __FILE__, SOURCE_LINE)
317
+ end
318
+ end
319
+
320
+ # `username_generated?`, and drawing a name on create or in a redraw.
321
+ # A batch with nothing free in it is a validation error, not an
322
+ # exception: `save` returns false, `save!` raises RecordInvalid.
323
+ def self.define_generation(methods, column)
324
+ generated = :"@#{column}_generated"
325
+ # Whether the gem assigned this record's current name (on create, or in a redraw).
326
+ methods.define_method(:"#{column}_generated?") do
327
+ drawn = instance_variable_get(generated)
328
+ !drawn.nil? && drawn == public_send(column)
329
+ end
330
+ methods.define_method(:"draw_#{column}") do
331
+ public_send(:"#{column}=", instance_variable_set(generated, self.class.public_send(:"generate_#{column}")))
332
+ rescue NoFreeNameError
333
+ errors.add(column, :username_unavailable)
334
+ end
335
+ methods.define_method(:"assign_#{column}") { send(:"draw_#{column}") if public_send(column).blank? }
336
+ methods.send(:private, :"draw_#{column}", :"assign_#{column}")
337
+ end
338
+
339
+ # Preserve mode's uniqueness check, against the record's own class: a
340
+ # model that inherits has_username from an abstract parent has the table.
341
+ def self.define_uniqueness(methods, column)
342
+ methods.define_method(:"validate_#{column}_uniqueness") do
343
+ value = public_send(column)
344
+ taken = !value.nil? && self.class._usernames.fetch(column).taken?(value, self.class, except: self)
345
+ errors.add(column, :taken, value: value) if taken
346
+ end
347
+ methods.send(:private, :"validate_#{column}_uniqueness")
348
+ end
349
+
350
+ # Where the heredoc below starts, so a backtrace points at the real line.
351
+ SOURCE_LINE = __LINE__ + 6
352
+
353
+ # What has to be a `def`: these call `super`, and the race wrapper's
354
+ # block does, which define_method cannot.
355
+ def self.source(column)
356
+ <<~RUBY
357
+ private
358
+
359
+ def _create_record(...)
360
+ with_#{column}_race_handling(redraw: #{column}_generated?) { super }
361
+ end
362
+
363
+ def _update_record(...)
364
+ return super unless will_save_change_to_#{column}?
365
+
366
+ with_#{column}_race_handling(redraw: false) { super }
367
+ end
368
+
369
+ # The insert or update runs inside a savepoint, and the unique
370
+ # violation is rescued OUTSIDE it: by then the savepoint has rolled
371
+ # back, so the surrounding transaction is usable again and a
372
+ # redraw (or the query that reads the indexes) can run in it.
373
+ def with_#{column}_race_handling(redraw:)
374
+ attempts = 0
375
+ loop do
376
+ begin
377
+ return self.class.transaction(requires_new: true) { yield }
378
+ rescue ActiveRecord::RecordNotUnique => error
379
+ raise unless self.class._usernames.fetch(:#{column}).violated_by?(error, self)
380
+
381
+ if redraw && (attempts += 1) <= 3
382
+ draw_#{column} # generated names only
383
+ else
384
+ errors.add(:#{column}, :taken) # typed names, or redraws exhausted
385
+ end
386
+ raise ActiveRecord::RecordInvalid, self if errors.include?(:#{column}) # save → false, save! → RecordInvalid
387
+ end
388
+ end
389
+ end
390
+ RUBY
391
+ end
392
+ end
393
+ end
394
+ end
395
+
396
+ # Without Rails there is no engine to do it (see Usernames::Engine).
397
+ ActiveSupport.on_load(:active_record) { extend Usernames::Model }
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # Turns whatever someone typed (or pasted) into the one spelling the gem
5
+ # validates, stores and looks up.
6
+ #
7
+ # The property that matters is IDEMPOTENCE: normalize(normalize(x)) ==
8
+ # normalize(x) for every x. Rails applies `normalizes` on assignment AND in
9
+ # queries, so a value is normalised more than once in its life; a normaliser
10
+ # that changes its own output turns one account into two (Spotify, 2013:
11
+ # `ᴮᴵᴳᴮᴵᴿᴰ` became `BIGBIRD` on the first pass and `bigbird` on the second).
12
+ # The test suite asserts it over the Big List of Naughty Strings and 10,000
13
+ # random Unicode strings, in both case modes.
14
+ #
15
+ # It never raises: a normaliser that raises on a strange byte is a 500 on a
16
+ # sign-up form. And it never strips anything invisible — a zero-width space
17
+ # or a right-to-left override survives, so the character rule refuses it
18
+ # loudly instead of the name silently becoming somebody else's.
19
+ module Normalizer
20
+ # Removed LAST, so nothing after it can expose a second one; and only when
21
+ # a letter or digit follows, so `@` alone (or `@_x`) is left for the
22
+ # character rule to refuse rather than turned into something valid.
23
+ LEADING_AT = {
24
+ lowercase: /\A@(?=[a-z0-9])/,
25
+ preserve: /\A@(?=[A-Za-z0-9])/
26
+ }.freeze
27
+
28
+ class << self
29
+ def call(value, case_mode = :lowercase)
30
+ string = utf8(value).unicode_normalize(:nfkc).strip
31
+ # Lowercasing can leave a string that is no longer NFKC (`Dž` → `dž`
32
+ # composes differently), so it is normalised again.
33
+ string = string.downcase.unicode_normalize(:nfkc) if case_mode == :lowercase
34
+ string.sub(LEADING_AT.fetch(case_mode), "")
35
+ end
36
+
37
+ # Any value as valid UTF-8. Bytes that cannot be converted are dropped,
38
+ # not guessed at: a name is never built from a byte we could not read.
39
+ def utf8(value)
40
+ string = value.to_s
41
+ string = begin
42
+ string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "")
43
+ rescue EncodingError # e.g. a dummy encoding, which has no converter
44
+ string.dup.force_encoding(Encoding::UTF_8)
45
+ end
46
+ # Re-encoding UTF-8 into UTF-8 is a no-op that keeps invalid bytes.
47
+ string.scrub("")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ module Offensive
5
+ # Rule c: hate tokens and numbers, from data/offensive/hate.yml.
6
+ #
7
+ # A bare number is not evidence. `88` is a birth year (`mike88`), ham
8
+ # radio's "love and kisses" and a lucky number; `31488210` is a phone
9
+ # fragment. So numbers count only in the forms hate.yml lists — a whole
10
+ # digit run, two parts joined by one separator, or next to a token —
11
+ # while the unambiguous tokens (`heilhitler`, `rahowa`) are refused
12
+ # anywhere. No allowlist or config.offensive.allowed entry lifts these.
13
+ module Hate
14
+ # What may not touch an adjacency token of each kind, before and after it.
15
+ NUMBER_EDGES = ['(?<!\d)', '(?!\d)'].freeze
16
+ WORD_EDGES = ["(?<![a-z])", "(?![a-z])"].freeze
17
+
18
+ class << self
19
+ def verdict(name, rules)
20
+ :hate_number if always?(name) || numbers?(name) || whole_token?(name, rules.offensive.languages)
21
+ end
22
+
23
+ private
24
+
25
+ # Every number rule needs a digit in the name as typed, and the
26
+ # adjacency rule one of its numbers (folding only ever removes digits,
27
+ # so a number in R1 is in R0 too): most names skip them all.
28
+ def numbers?(name)
29
+ key = name.key
30
+ return false unless key.match?(/\d/)
31
+
32
+ digit_run?(key) || (adjacent.any? { |number, _tokens| key.include?(number) } && adjacent?(name))
33
+ end
34
+
35
+ # A token (all four letters or more) anywhere in a whole: `heil_hitler`
36
+ # is `heilhitler` with its separator removed.
37
+ def always?(name)
38
+ name.wholes.any? { |whole| always.any? { |token| whole.text.include?(token) } }
39
+ end
40
+
41
+ # `x1488`, `14_88`, `1.488` — but never inside a longer run.
42
+ # Two parts joined by one separator means the number split at any
43
+ # point (14_88, 1_488, 148_8), not only in the middle.
44
+ def digit_run?(key)
45
+ key.scan(/\d+/).any? { |run| digit_runs.include?(run) } || joined_runs&.match?(key)
46
+ end
47
+
48
+ # `88` beside heil/hh/hitler… with or without one separator between
49
+ # (`hh88`, `heil_88`, `88hitler`), in either form (`h3il88` is
50
+ # `heil88` folded). The neighbour must be a whole run: `88` after
51
+ # `ahh` is not after `hh`.
52
+ # Number tokens (`14`, `18`, `88`) are whole digit runs, so
53
+ # `14_88` counts but `1888` (a year) does not.
54
+ def adjacent?(name)
55
+ name.forms.any? { |form| adjacency.match?(form) }
56
+ end
57
+
58
+ # `kkk` and `zog` only as a whole segment, and only in the languages
59
+ # that list them (kkk is laughter in Brazilian Portuguese).
60
+ def whole_token?(name, languages)
61
+ languages.any? do |locale|
62
+ tokens = whole_tokens.fetch(locale.to_s, [])
63
+ tokens.any? && name.segments.any? { |segment| tokens.include?(segment) }
64
+ end
65
+ end
66
+
67
+ def always
68
+ @always ||= rules.fetch("always").map { |token| Offensive.fold(token.to_s) }.uniq.freeze
69
+ end
70
+
71
+ def digit_runs
72
+ @digit_runs ||= rules.fetch("digit_runs").map(&:to_s).freeze
73
+ end
74
+
75
+ def joined_runs
76
+ return @joined_runs if defined?(@joined_runs)
77
+
78
+ splits = digit_runs.flat_map do |run|
79
+ (1...run.length).map { |at| "#{run[0, at]}[_.\\-]#{run[at..]}" }
80
+ end
81
+ @joined_runs = splits.empty? ? nil : /(?<!\d)(?:#{splits.join('|')})(?!\d)/
82
+ end
83
+
84
+ def adjacent
85
+ @adjacent ||= rules.fetch("adjacent").to_h do |number, tokens|
86
+ [number.to_s, tokens.map(&:to_s).freeze]
87
+ end.freeze
88
+ end
89
+
90
+ # One Regexp for every number and its tokens, each side: the token a
91
+ # whole run (nothing of its own kind touching it), then at most one
92
+ # separator, then the number as a whole digit run — or the mirror
93
+ # image. A number token always needs its separator: `1488` is one run.
94
+ def adjacency
95
+ @adjacency ||= begin
96
+ sides = adjacent.flat_map do |number, tokens|
97
+ numbers, words = tokens.partition { |token| token.match?(/\A\d+\z/) }
98
+ beside(number, numbers, NUMBER_EDGES, "[_.\\-]") + beside(number, words, WORD_EDGES, "[_.\\-]?")
99
+ end
100
+ sides.empty? ? /(?!)/ : Regexp.new(sides.join("|"))
101
+ end
102
+ end
103
+
104
+ # `tokens` before `number` and after it, as Regexp sources.
105
+ def beside(number, tokens, edges, joint)
106
+ return [] if tokens.empty?
107
+
108
+ group = "(?:#{tokens.map { |token| Regexp.escape(token) }.join('|')})"
109
+ ["#{edges[0]}#{group}#{joint}#{number}(?!\\d)", "(?<!\\d)#{number}#{joint}#{group}#{edges[1]}"]
110
+ end
111
+
112
+ def whole_tokens
113
+ @whole_tokens ||= rules.fetch("whole_token").to_h do |locale, tokens|
114
+ [locale.to_s, Array(tokens).map(&:to_s).freeze]
115
+ end.freeze
116
+ end
117
+
118
+ def rules
119
+ Data.hate
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end