usernames 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +362 -0
- data/SECURITY.md +42 -0
- data/config/locales/usernames.en.yml +72 -0
- data/config/locales/usernames.es.yml +51 -0
- data/config/locales/usernames.pt.yml +51 -0
- data/data/LICENSES/allprofanity.txt +24 -0
- data/data/LICENSES/big-username-blocklist.txt +25 -0
- data/data/LICENSES/corpora.txt +128 -0
- data/data/LICENSES/cuss.txt +25 -0
- data/data/LICENSES/django-registration.txt +33 -0
- data/data/LICENSES/dsojevic-profanity-list.txt +24 -0
- data/data/LICENSES/forwardemail.txt +24 -0
- data/data/LICENSES/friendly-words.txt +24 -0
- data/data/LICENSES/gitea.txt +23 -0
- data/data/LICENSES/gitlab.txt +31 -0
- data/data/LICENSES/iana-well-known-uris.txt +130 -0
- data/data/LICENSES/imsky-wordlists.txt +24 -0
- data/data/LICENSES/mottie-github-reserved-names.txt +24 -0
- data/data/LICENSES/obscenity.txt +27 -0
- data/data/LICENSES/petname.txt +218 -0
- data/data/LICENSES/rfc2142.txt +9 -0
- data/data/LICENSES/shouldbee.txt +25 -0
- data/data/LICENSES/usernames.txt +30 -0
- data/data/NOTICE +36 -0
- data/data/offensive/README.md +90 -0
- data/data/offensive/allowlist/en.txt +727 -0
- data/data/offensive/allowlist/es.txt +39 -0
- data/data/offensive/en.json +5680 -0
- data/data/offensive/es.json +1014 -0
- data/data/offensive/hate.yml +51 -0
- data/data/offensive/leet.yml +27 -0
- data/data/reserved/ALLOWLIST_EXCEPTIONS.txt +6 -0
- data/data/reserved/impersonation.txt +69 -0
- data/data/reserved/mailboxes.txt +77 -0
- data/data/reserved/system.txt +124 -0
- data/data/reserved/web.txt +962 -0
- data/data/words/adjectives.txt +367 -0
- data/data/words/animals.txt +253 -0
- data/lib/generators/usernames/availability_generator.rb +67 -0
- data/lib/generators/usernames/existing_schema.rb +85 -0
- data/lib/generators/usernames/install_generator.rb +241 -0
- data/lib/generators/usernames/install_messages.rb +63 -0
- data/lib/generators/usernames/locale_generator.rb +56 -0
- data/lib/generators/usernames/templates/add_username.rb.erb +53 -0
- data/lib/generators/usernames/templates/availability/_field.html.erb.tt +25 -0
- data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +107 -0
- data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +115 -0
- data/lib/generators/usernames/templates/usernames.rb +34 -0
- data/lib/tasks/usernames.rake +38 -0
- data/lib/usernames/adapters/moderate.rb +37 -0
- data/lib/usernames/audit/output.rb +51 -0
- data/lib/usernames/audit/values.rb +34 -0
- data/lib/usernames/audit.rb +230 -0
- data/lib/usernames/candidates.rb +64 -0
- data/lib/usernames/comparison/index_contract.rb +241 -0
- data/lib/usernames/comparison/index_expression.rb +183 -0
- data/lib/usernames/comparison.rb +127 -0
- data/lib/usernames/configuration.rb +197 -0
- data/lib/usernames/data.rb +205 -0
- data/lib/usernames/engine.rb +32 -0
- data/lib/usernames/errors.rb +16 -0
- data/lib/usernames/generator.rb +206 -0
- data/lib/usernames/model.rb +397 -0
- data/lib/usernames/normalizer.rb +51 -0
- data/lib/usernames/offensive/hate.rb +124 -0
- data/lib/usernames/offensive/lexicon.rb +156 -0
- data/lib/usernames/offensive/name.rb +254 -0
- data/lib/usernames/offensive.rb +136 -0
- data/lib/usernames/phrase.rb +68 -0
- data/lib/usernames/reserved/listing.rb +79 -0
- data/lib/usernames/reserved.rb +219 -0
- data/lib/usernames/result.rb +52 -0
- data/lib/usernames/rules/options.rb +175 -0
- data/lib/usernames/rules.rb +257 -0
- data/lib/usernames/unique_violation.rb +176 -0
- data/lib/usernames/validator.rb +61 -0
- data/lib/usernames/version.rb +5 -0
- data/lib/usernames/words.rb +13 -0
- data/lib/usernames.rb +138 -0
- data/sig/usernames.rbs +75 -0
- metadata +217 -0
|
@@ -0,0 +1,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
|