usernames 0.1.3 → 0.2.0
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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +154 -7
- data/app/assets/javascripts/usernames/index.js +16 -0
- data/app/assets/javascripts/usernames/username_check_controller.js +191 -0
- data/app/controllers/usernames/availability_controller.rb +177 -0
- data/config/importmap.rb +8 -0
- data/config/locales/usernames.en.yml +6 -2
- data/config/locales/usernames.es.yml +2 -0
- data/config/locales/usernames.pt.yml +2 -0
- data/lib/generators/usernames/availability_generator.rb +85 -24
- data/lib/generators/usernames/install_generator.rb +24 -0
- data/lib/generators/usernames/install_messages.rb +41 -2
- data/lib/generators/usernames/javascript_generator.rb +38 -0
- data/lib/generators/usernames/javascript_setup.rb +124 -0
- data/lib/generators/usernames/templates/availability/_field.html.erb.tt +5 -15
- data/lib/generators/usernames/templates/usernames.rb +8 -2
- data/lib/usernames/configuration.rb +93 -3
- data/lib/usernames/engine.rb +74 -13
- data/lib/usernames/form_helpers.rb +215 -0
- data/lib/usernames/locale.rb +39 -0
- data/lib/usernames/model.rb +137 -26
- data/lib/usernames/reserved/listing.rb +1 -0
- data/lib/usernames/reserved.rb +18 -8
- data/lib/usernames/routes.rb +26 -0
- data/lib/usernames/rules/options.rb +9 -1
- data/lib/usernames/rules.rb +9 -1
- data/lib/usernames/targets.rb +65 -0
- data/lib/usernames/unique_violation.rb +0 -1
- data/lib/usernames/version.rb +1 -1
- data/lib/usernames.rb +3 -0
- data/sig/usernames.rbs +33 -6
- metadata +11 -3
- data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +0 -107
- data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +0 -115
data/lib/usernames/model.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Usernames
|
|
|
25
25
|
base.class_attribute :_usernames, instance_accessor: false, default: {}.freeze
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# has_username(column = :username, required: false, generate: nil, **rules)
|
|
28
|
+
# has_username(column = :username, required: false, generate: nil, seed: nil, clearable: true, **rules)
|
|
29
29
|
#
|
|
30
30
|
# Installs, for that column:
|
|
31
31
|
#
|
|
@@ -35,17 +35,30 @@ module Usernames
|
|
|
35
35
|
# attributes;
|
|
36
36
|
# 3. with `required:`, presence — on create, and on an update that
|
|
37
37
|
# changes it (a legacy blank still saves unrelated changes);
|
|
38
|
-
# 4.
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# 6. a
|
|
38
|
+
# 4. with `clearable: false`, no clearing: a saved name cannot be saved
|
|
39
|
+
# blank again, required or not — other people link to it;
|
|
40
|
+
# 5. uniqueness, compared the way the unique index compares (Comparison);
|
|
41
|
+
# 6. a generated name for a new record that has none — by default,
|
|
42
|
+
# unless a name is required for it in the context it is validated in —
|
|
43
|
+
# drawn from `seed:` when that has a value;
|
|
44
|
+
# 7. a race wrapper, so two sign-ups racing for one name end in a redraw
|
|
42
45
|
# (a generated name) or in "has already been taken" (a typed one) —
|
|
43
46
|
# never a RecordNotUnique 500.
|
|
44
47
|
#
|
|
45
48
|
# `required:` and `generate:` take true, false, a method name or a proc,
|
|
46
49
|
# evaluated on the record the way Rails' `if:` is: a sign-up flow that
|
|
47
50
|
# asks for a name (`required: :handle_required`) gets presence, and every
|
|
48
|
-
# other record a generated name.
|
|
51
|
+
# other record a generated name. `required: { on: :profile }` asks for
|
|
52
|
+
# one only in that validation context (`save(context: :profile)`), on
|
|
53
|
+
# every save there.
|
|
54
|
+
#
|
|
55
|
+
# `seed:` is a method name or a proc (evaluated the same way) whose value
|
|
56
|
+
# a generated name starts from: `seed: :first_name` makes `ana` (or
|
|
57
|
+
# `ana_42` when that is taken) rather than `quiet_otter_42`. It is read
|
|
58
|
+
# once per save, and a redraw after a lost race reuses it. A blank seed
|
|
59
|
+
# draws the default shape. Whatever it returns becomes part of a public
|
|
60
|
+
# name: an email is used only if the seed returns one, and then as
|
|
61
|
+
# Usernames.generate reads it (the local part, or the domain for info@).
|
|
49
62
|
#
|
|
50
63
|
# And the class helpers, named after the column: `username_available?`,
|
|
51
64
|
# `suggest_usernames`, `generate_username`, `find_by_username` (and `!`),
|
|
@@ -56,8 +69,9 @@ module Usernames
|
|
|
56
69
|
# mode, so the model keeps comparing in it even if the configuration
|
|
57
70
|
# changes later. Every other rule is read from the configuration on each
|
|
58
71
|
# check.
|
|
59
|
-
def has_username(column = :username, required: false, generate: nil, **rules)
|
|
60
|
-
column = Column.new(self, column, required: required, generate: generate,
|
|
72
|
+
def has_username(column = :username, required: false, generate: nil, seed: nil, clearable: true, **rules)
|
|
73
|
+
column = Column.new(self, column, required: required, generate: generate, seed: seed, clearable: clearable,
|
|
74
|
+
rules: rules)
|
|
61
75
|
if _usernames.key?(column.name)
|
|
62
76
|
raise ConfigurationError, "has_username :#{column.name} is already declared on #{name || self}"
|
|
63
77
|
end
|
|
@@ -73,36 +87,62 @@ module Usernames
|
|
|
73
87
|
result =
|
|
74
88
|
case condition
|
|
75
89
|
when true, false then condition
|
|
76
|
-
|
|
77
|
-
else condition.arity.zero? ? record.instance_exec(&condition) : condition.call(record)
|
|
90
|
+
else value_of(condition, record)
|
|
78
91
|
end
|
|
79
92
|
result ? true : false
|
|
80
93
|
end
|
|
81
94
|
|
|
95
|
+
# What a method name or a proc gives for `record`, evaluated as
|
|
96
|
+
# applies? evaluates a condition. :nodoc:
|
|
97
|
+
def self.value_of(source, record)
|
|
98
|
+
return record.send(source) if source.is_a?(Symbol)
|
|
99
|
+
|
|
100
|
+
source.arity.zero? ? record.instance_exec(&source) : source.call(record)
|
|
101
|
+
end
|
|
102
|
+
|
|
82
103
|
# One has_username declaration, and everything that column needs: the
|
|
83
104
|
# rules in force, the comparison expression, and the queries behind the
|
|
84
105
|
# class helpers. :nodoc:
|
|
85
106
|
class Column
|
|
86
|
-
attr_reader :model, :name, :required, :generate, :options
|
|
107
|
+
attr_reader :model, :name, :required, :generate, :seed, :options
|
|
87
108
|
|
|
88
|
-
def initialize(model, name, required:, generate:, rules:)
|
|
109
|
+
def initialize(model, name, required:, generate:, rules:, seed: nil, clearable: true)
|
|
89
110
|
@model = model
|
|
90
111
|
@name = name.to_sym
|
|
91
|
-
@required = condition(:required, required)
|
|
112
|
+
@required = required.is_a?(Hash) ? contexts(required) : condition(:required, required)
|
|
92
113
|
# By default a name is generated exactly where none is required: where
|
|
93
114
|
# one is, the person chooses.
|
|
94
115
|
@generate = generate.nil? ? nil : condition(:generate, generate)
|
|
116
|
+
@seed = seed.nil? ? nil : source(:seed, seed)
|
|
117
|
+
@clearable = switch(:clearable, clearable)
|
|
95
118
|
# Resolving them now raises ConfigurationError for a bad option when
|
|
96
119
|
# the model is defined, and pins the case mode (see has_username).
|
|
97
120
|
@options = rules.merge(case: Usernames.configuration.rules_for(**rules).case).freeze
|
|
98
121
|
end
|
|
99
122
|
|
|
100
|
-
# Whether `record` must have a name.
|
|
123
|
+
# Whether `record` must have a name. With `required: { on: … }`, while
|
|
124
|
+
# it is being validated in one of those contexts.
|
|
101
125
|
def required?(record)
|
|
102
|
-
Model.applies?(required, record)
|
|
126
|
+
return Model.applies?(required, record) unless required.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
Array(record.validation_context).intersect?(required.fetch(:on))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Whether a saved name may be saved blank again (`clearable: false` says no).
|
|
132
|
+
def clearable?
|
|
133
|
+
@clearable
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# What a generated name for `record` starts from: the seed's value, or
|
|
137
|
+
# nil (the default shape) when there is no seed or it is blank.
|
|
138
|
+
def seed_for(record)
|
|
139
|
+
return if seed.nil?
|
|
140
|
+
|
|
141
|
+
value = Model.value_of(seed, record)
|
|
142
|
+
value.to_s unless value.blank?
|
|
103
143
|
end
|
|
104
144
|
|
|
105
|
-
# Whether `record
|
|
145
|
+
# Whether `record`, while new and without a name, gets a generated one.
|
|
106
146
|
def generate?(record)
|
|
107
147
|
generate.nil? ? !required?(record) : Model.applies?(generate, record)
|
|
108
148
|
end
|
|
@@ -174,28 +214,60 @@ module Usernames
|
|
|
174
214
|
|
|
175
215
|
def install_normalization_and_rules
|
|
176
216
|
column = name
|
|
177
|
-
definition = self
|
|
178
217
|
changed = :"will_save_change_to_#{column}?"
|
|
179
218
|
model.normalizes column, with: Usernames.normalizer(**options)
|
|
180
219
|
model.validates column, username: options, if: changed
|
|
220
|
+
install_presence
|
|
221
|
+
install_no_clearing
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# On create, and when an update touches it: a pre-existing blank
|
|
225
|
+
# saves unrelated changes; clearing a name is :blank. Not when
|
|
226
|
+
# generation already said why the name is blank
|
|
227
|
+
# (:username_unavailable): one error per name. In a named context
|
|
228
|
+
# (`required: { on: :profile }`), on every save in it: that form is
|
|
229
|
+
# where the person types one.
|
|
230
|
+
def install_presence
|
|
181
231
|
return if required == false
|
|
232
|
+
return install_presence_in_context if required.is_a?(Hash)
|
|
182
233
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
# generation already said why the name is blank
|
|
186
|
-
# (:username_unavailable): one error per name.
|
|
234
|
+
column = name
|
|
235
|
+
definition = self
|
|
187
236
|
model.validates column, presence: true, if: lambda {
|
|
188
237
|
(new_record? || will_save_change_to_attribute?(column)) && definition.required?(self) &&
|
|
189
238
|
!errors.of_kind?(column, :username_unavailable)
|
|
190
239
|
}
|
|
191
240
|
end
|
|
192
241
|
|
|
242
|
+
def install_presence_in_context
|
|
243
|
+
column = name
|
|
244
|
+
model.validates column, presence: true, on: required.fetch(:on),
|
|
245
|
+
if: -> { !errors.of_kind?(column, :username_unavailable) }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# `clearable: false`: a saved name other people link to stays.
|
|
249
|
+
# Saving it blank is :blank, required or not (once, if presence already
|
|
250
|
+
# said so). A stored blank (nil, "", " ") was never a name, so saving
|
|
251
|
+
# it blank again is not clearing; a new record has nothing saved to
|
|
252
|
+
# clear. It is a validation: update_column and update_all skip it.
|
|
253
|
+
def install_no_clearing
|
|
254
|
+
return if clearable?
|
|
255
|
+
|
|
256
|
+
column = name
|
|
257
|
+
model.validate(if: lambda {
|
|
258
|
+
persisted? && will_save_change_to_attribute?(column) && public_send(column).blank? &&
|
|
259
|
+
attribute_in_database(column).present? && !errors.of_kind?(column, :blank)
|
|
260
|
+
}) { errors.add(column, :blank) }
|
|
261
|
+
end
|
|
262
|
+
|
|
193
263
|
# Left out altogether when no record can ever get a generated name.
|
|
264
|
+
# Gated on new_record?, not `on: :create`: a new record validated in a
|
|
265
|
+
# context of its own (`save(context: :import)`) is still new.
|
|
194
266
|
def install_generation
|
|
195
267
|
return if generate == false || (generate.nil? && required == true)
|
|
196
268
|
|
|
197
269
|
definition = self
|
|
198
|
-
model.before_validation(:"assign_#{name}",
|
|
270
|
+
model.before_validation(:"assign_#{name}", if: -> { new_record? && definition.generate?(self) })
|
|
199
271
|
end
|
|
200
272
|
|
|
201
273
|
# Lowercase mode stores the canonical value, so Rails' own exact
|
|
@@ -219,8 +291,33 @@ module Usernames
|
|
|
219
291
|
def condition(option, value)
|
|
220
292
|
return value if [true, false].include?(value) || value.is_a?(Symbol) || value.is_a?(Proc)
|
|
221
293
|
|
|
222
|
-
|
|
223
|
-
|
|
294
|
+
expected = if option == :required
|
|
295
|
+
"true, false, a method name, a proc or { on: :context }"
|
|
296
|
+
else
|
|
297
|
+
"true, false, a method name or a proc"
|
|
298
|
+
end
|
|
299
|
+
raise ConfigurationError, "has_username #{option}: must be #{expected} (got #{value.inspect})"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# `{ on: :profile }` or `{ on: %i[profile settings] }`, as Rails' own `on:`.
|
|
303
|
+
def contexts(value)
|
|
304
|
+
on = Array(value[:on])
|
|
305
|
+
return { on: on.freeze }.freeze if value.keys == [:on] && on.any? && on.all?(Symbol)
|
|
306
|
+
|
|
307
|
+
raise ConfigurationError, "has_username required: takes { on: :context } (one or more validation " \
|
|
308
|
+
"contexts) as a Hash (got #{value.inspect})"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def source(option, value)
|
|
312
|
+
return value if value.is_a?(Symbol) || value.is_a?(Proc)
|
|
313
|
+
|
|
314
|
+
raise ConfigurationError, "has_username #{option}: must be a method name or a proc (got #{value.inspect})"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def switch(option, value)
|
|
318
|
+
return value if [true, false].include?(value)
|
|
319
|
+
|
|
320
|
+
raise ConfigurationError, "has_username #{option}: must be true or false (got #{value.inspect})"
|
|
224
321
|
end
|
|
225
322
|
end
|
|
226
323
|
|
|
@@ -327,12 +424,26 @@ module Usernames
|
|
|
327
424
|
drawn = instance_variable_get(generated)
|
|
328
425
|
!drawn.nil? && drawn == public_send(column)
|
|
329
426
|
end
|
|
427
|
+
define_drawing(methods, column, generated)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Assigning a name to a blank new record reads the seed once (it is the
|
|
431
|
+
# app's code: whatever it raises is the app's error); a redraw after a
|
|
432
|
+
# lost race reuses it.
|
|
433
|
+
def self.define_drawing(methods, column, generated)
|
|
434
|
+
seed = :"@#{column}_seed"
|
|
330
435
|
methods.define_method(:"draw_#{column}") do
|
|
331
|
-
|
|
436
|
+
drawn = self.class.public_send(:"generate_#{column}", from: instance_variable_get(seed))
|
|
437
|
+
public_send(:"#{column}=", instance_variable_set(generated, drawn))
|
|
332
438
|
rescue NoFreeNameError
|
|
333
439
|
errors.add(column, :username_unavailable)
|
|
334
440
|
end
|
|
335
|
-
methods.define_method(:"assign_#{column}")
|
|
441
|
+
methods.define_method(:"assign_#{column}") do
|
|
442
|
+
return unless public_send(column).blank?
|
|
443
|
+
|
|
444
|
+
instance_variable_set(seed, self.class._usernames.fetch(column).seed_for(self))
|
|
445
|
+
send(:"draw_#{column}")
|
|
446
|
+
end
|
|
336
447
|
methods.send(:private, :"draw_#{column}", :"assign_#{column}")
|
|
337
448
|
end
|
|
338
449
|
|
|
@@ -37,6 +37,7 @@ module Usernames
|
|
|
37
37
|
words.each { |word, source| io.puts " #{word.ljust(width)} #{source}" }
|
|
38
38
|
lifted = allowed(rules)
|
|
39
39
|
io.puts "Lifted by config.allowed: #{lifted.join(' ')}" if lifted.any?
|
|
40
|
+
io.puts "And whatever config.reserved_if reserves, asked after these lists." if rules.reserved_if
|
|
40
41
|
words.size
|
|
41
42
|
end
|
|
42
43
|
|
data/lib/usernames/reserved.rb
CHANGED
|
@@ -32,8 +32,8 @@ module Usernames
|
|
|
32
32
|
Sets = ::Data.define(:exact, :custom, :impersonation, :allowed)
|
|
33
33
|
|
|
34
34
|
class << self
|
|
35
|
-
# The kind of reservation that matched — :impersonation, :exact, :route
|
|
36
|
-
# or :
|
|
35
|
+
# The kind of reservation that matched — :impersonation, :exact, :route,
|
|
36
|
+
# :custom or :reserved_if — or nil when the name is free to use. `key` is
|
|
37
37
|
# Usernames.comparison_key of a name that already passed every format
|
|
38
38
|
# rule; `rules` is the Usernames::Rules in force.
|
|
39
39
|
#
|
|
@@ -44,15 +44,16 @@ module Usernames
|
|
|
44
44
|
# word and reported as :impersonation; :custom names a config.reserved
|
|
45
45
|
# word that is not bundled. A reserved prefix (`.well-known`, `xn--`)
|
|
46
46
|
# is reported as :exact.
|
|
47
|
-
|
|
47
|
+
#
|
|
48
|
+
# Last, after every list, config.reserved_if is asked about `name` —
|
|
49
|
+
# the name as normalised, case kept in preserve mode — and a truthy
|
|
50
|
+
# answer is :reserved_if. It runs on the checks the lists let
|
|
51
|
+
# through, and whatever it raises is the app's.
|
|
52
|
+
def verdict(key, rules, name = key)
|
|
48
53
|
sets = sets_for(rules)
|
|
49
54
|
return if sets.allowed.include?(key)
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
elsif sets.custom.include?(key) then :custom
|
|
53
|
-
elsif sets.exact.include?(key) || prefix?(key) then :exact
|
|
54
|
-
elsif rules.reserve_routes && routes.include?(key) then :route
|
|
55
|
-
end
|
|
56
|
+
listed(key, sets, rules) || (:reserved_if if rules.reserved_if&.call(name))
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
# Whether any spelling of the name is an impersonation entry: the name
|
|
@@ -115,6 +116,15 @@ module Usernames
|
|
|
115
116
|
|
|
116
117
|
private
|
|
117
118
|
|
|
119
|
+
# The list that reserves `key`, in the order they are asked.
|
|
120
|
+
def listed(key, sets, rules)
|
|
121
|
+
if impersonation?(key, sets.impersonation) then :impersonation
|
|
122
|
+
elsif sets.custom.include?(key) then :custom
|
|
123
|
+
elsif sets.exact.include?(key) || prefix?(key) then :exact
|
|
124
|
+
elsif rules.reserve_routes && routes.include?(key) then :route
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
118
128
|
def sets_for(rules)
|
|
119
129
|
rules.compiled(:reserved) do
|
|
120
130
|
bundled = Data.reserved.to_set
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# The engine draws the availability route itself, after your routes, so
|
|
5
|
+
# anything you declare wins. A catch-all such as `get "*path"` (or a
|
|
6
|
+
# dynamic `get ":section/:page"`) would win too, and swallow it: declare
|
|
7
|
+
# it yourself, above the catch-all, and the engine draws nothing.
|
|
8
|
+
#
|
|
9
|
+
# Rails.application.routes.draw do
|
|
10
|
+
# usernames_availability
|
|
11
|
+
# get "*path", to: "pages#show"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Call it at the top level of the routes, outside any namespace or scope.
|
|
15
|
+
# It answers at config.availability.path (or the path you pass), is named
|
|
16
|
+
# `usernames_availability`, and takes no format: `/usernames/availability.json`
|
|
17
|
+
# is not a route.
|
|
18
|
+
module Routes
|
|
19
|
+
DEFAULT_PATH = "/usernames/availability"
|
|
20
|
+
|
|
21
|
+
def usernames_availability(path = nil)
|
|
22
|
+
path ||= Usernames.configuration.availability.path || DEFAULT_PATH
|
|
23
|
+
get path, to: "usernames/availability#show", as: :usernames_availability, format: false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -9,7 +9,7 @@ module Usernames
|
|
|
9
9
|
module Options
|
|
10
10
|
NAMES = %i[
|
|
11
11
|
length separators edge_separators case first_character reserve_routes app_name
|
|
12
|
-
reserved forbidden allowed offensive on_adapter_failure generator unique_index
|
|
12
|
+
reserved forbidden allowed reserved_if offensive on_adapter_failure generator unique_index
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
15
|
OFFENSIVE_NAMES = %i[severity languages allowed adapters].freeze
|
|
@@ -53,6 +53,7 @@ module Usernames
|
|
|
53
53
|
@reserved = valid_words(:reserved, options.fetch(:reserved))
|
|
54
54
|
@forbidden = valid_words(:forbidden, options.fetch(:forbidden))
|
|
55
55
|
@allowed = valid_words(:allowed, options.fetch(:allowed))
|
|
56
|
+
@reserved_if = valid_callable(:reserved_if, options.fetch(:reserved_if), "->(name) { … }")
|
|
56
57
|
@on_adapter_failure = one_of(:on_adapter_failure, options.fetch(:on_adapter_failure), ADAPTER_FAILURE_MODES)
|
|
57
58
|
end
|
|
58
59
|
|
|
@@ -145,6 +146,13 @@ module Usernames
|
|
|
145
146
|
|
|
146
147
|
# nil detects the name from the Rails application; false opts out; a
|
|
147
148
|
# String names it explicitly (an app whose brand is not its module name).
|
|
149
|
+
# nil, or anything that answers `call`.
|
|
150
|
+
def valid_callable(name, value, example)
|
|
151
|
+
invalid!(name, value, "nil or a callable such as #{example}") unless value.nil? || value.respond_to?(:call)
|
|
152
|
+
|
|
153
|
+
value
|
|
154
|
+
end
|
|
155
|
+
|
|
148
156
|
def valid_app_name(value)
|
|
149
157
|
valid = value.nil? || value == false || (value.is_a?(String) && !value.strip.empty?)
|
|
150
158
|
invalid!(:app_name, value, "a String, false to opt out, or nil to detect it") unless valid
|
data/lib/usernames/rules.rb
CHANGED
|
@@ -51,6 +51,14 @@ module Usernames
|
|
|
51
51
|
defined?(Mime::EXTENSION_LOOKUP) ? Mime::EXTENSION_LOOKUP.keys.map(&:to_s) : []
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Every reason a format rule can give, in the order the rules check:
|
|
55
|
+
# those of rules with every optional format rule switched on (all three
|
|
56
|
+
# separators, a letter first), so a new rule is in it the day it exists.
|
|
57
|
+
def self.format_reasons
|
|
58
|
+
@format_reasons ||= Configuration.new.rules_for(separators: Options::SEPARATORS, first_character: :letter)
|
|
59
|
+
.format_checks.map(&:first).freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
attr_reader :format_checks
|
|
55
63
|
|
|
56
64
|
def initialize(**options)
|
|
@@ -143,7 +151,7 @@ module Usernames
|
|
|
143
151
|
# Reserved words are matched on the comparison key alone; the offensive
|
|
144
152
|
# matcher also reads the case the name was typed in (Offensive.verdict).
|
|
145
153
|
def content_failure(string)
|
|
146
|
-
if (rule = Reserved.verdict(comparison_key(string), self))
|
|
154
|
+
if (rule = Reserved.verdict(comparison_key(string), self, string))
|
|
147
155
|
Failure.new(reason: :username_reserved, rule: rule)
|
|
148
156
|
elsif (rule = Offensive.verdict(string, self))
|
|
149
157
|
Failure.new(reason: :username_not_allowed, rule: rule)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# The username columns the availability endpoint answers for, by alias:
|
|
5
|
+
# config.availability.targets, `{ "user" => "User#handle" }`.
|
|
6
|
+
#
|
|
7
|
+
# A request names an alias, and the alias is only ever a key looked up in
|
|
8
|
+
# that Hash: the class name resolved is the one the app configured, never
|
|
9
|
+
# anything a client sent. It is resolved on every lookup, so in
|
|
10
|
+
# development a reloaded model is the new class, and a model that no
|
|
11
|
+
# longer declares has_username fails loudly instead of answering for a
|
|
12
|
+
# column that is gone. :nodoc:
|
|
13
|
+
module Targets
|
|
14
|
+
class << self
|
|
15
|
+
# [model, column] for a configured alias; nil for anything else (the
|
|
16
|
+
# endpoint's 404). Raises ConfigurationError when the alias is
|
|
17
|
+
# configured but does not name a model with that has_username.
|
|
18
|
+
def find(name)
|
|
19
|
+
target = configured[name] if name.is_a?(String)
|
|
20
|
+
resolve(name, target) if target
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The alias the form of a `model` record sends for `column`: the target
|
|
24
|
+
# naming its class or, for a single-table subclass, the nearest parent
|
|
25
|
+
# that one names. Compared by name; nothing is loaded.
|
|
26
|
+
def alias_for(model, column)
|
|
27
|
+
wanted = column.to_s
|
|
28
|
+
model.ancestors.each do |ancestor|
|
|
29
|
+
next unless ancestor.is_a?(Class) && ancestor.name
|
|
30
|
+
|
|
31
|
+
found = configured.find { |_name, target| target == "#{ancestor.name}##{wanted}" }
|
|
32
|
+
return found.first if found
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def configured
|
|
40
|
+
Usernames.configuration.availability.targets
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve(name, target)
|
|
44
|
+
model_name, column = target.split("#", 2)
|
|
45
|
+
model = constant(name, target, model_name)
|
|
46
|
+
declared = model.is_a?(Class) && model.respond_to?(:_usernames) && model._usernames.key?(column.to_sym)
|
|
47
|
+
unless declared && !model.abstract_class?
|
|
48
|
+
broken!(name, target, "#{model_name} isn't a model that declares has_username :#{column}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
[model, column.to_sym]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def constant(name, target, model_name)
|
|
55
|
+
Object.const_get(model_name)
|
|
56
|
+
rescue NameError => e
|
|
57
|
+
broken!(name, target, "#{model_name} can't be loaded (#{e.message})")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def broken!(name, target, why)
|
|
61
|
+
raise ConfigurationError, "config.availability.targets[#{name.inspect}] is #{target.inspect}, but #{why}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -49,7 +49,6 @@ module Usernames
|
|
|
49
49
|
# connection is used. `conflict` answers whether another row has the
|
|
50
50
|
# record's name under the gem's comparison; without one, an ambiguous
|
|
51
51
|
# SQLite message is not ours.
|
|
52
|
-
# rubocop:disable-next Metrics/ParameterLists -- each is one fact about the save, all by keyword
|
|
53
52
|
def of?(error, table:, column:, rules: Usernames.configuration.rules_for, connection: nil, conflict: nil)
|
|
54
53
|
target = Target.new(table: table.to_s, bare_table: table.to_s.split(".").last, column: column.to_s,
|
|
55
54
|
index: rules.unique_index&.to_s, mode: rules.case, connection: connection,
|
data/lib/usernames/version.rb
CHANGED
data/lib/usernames.rb
CHANGED
|
@@ -20,11 +20,14 @@ require_relative "usernames/words"
|
|
|
20
20
|
require_relative "usernames/generator"
|
|
21
21
|
require_relative "usernames/candidates"
|
|
22
22
|
require_relative "usernames/rules"
|
|
23
|
+
require_relative "usernames/routes"
|
|
23
24
|
require_relative "usernames/configuration"
|
|
24
25
|
require_relative "usernames/validator"
|
|
25
26
|
require_relative "usernames/comparison"
|
|
26
27
|
require_relative "usernames/unique_violation"
|
|
27
28
|
require_relative "usernames/model"
|
|
29
|
+
require_relative "usernames/targets"
|
|
30
|
+
require_relative "usernames/locale"
|
|
28
31
|
require_relative "usernames/engine" if defined?(Rails::Engine)
|
|
29
32
|
|
|
30
33
|
module Usernames
|
data/sig/usernames.rbs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# The public surface of the usernames gem: the module functions, the result
|
|
2
|
-
# of a check, the errors,
|
|
2
|
+
# of a check, the errors, the has_username macro, the form helpers, the
|
|
3
|
+
# availability routing method and its configuration.
|
|
3
4
|
module Usernames
|
|
4
5
|
VERSION: String
|
|
5
6
|
ROUTE: Regexp
|
|
@@ -16,7 +17,7 @@ module Usernames
|
|
|
16
17
|
class DataError < Error
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
type rule = :impersonation | :exact | :route | :custom | :severe | :strong | :hate_number | :adapter
|
|
20
|
+
type rule = :impersonation | :exact | :route | :custom | :reserved_if | :severe | :strong | :hate_number | :adapter
|
|
20
21
|
|
|
21
22
|
# Usernames.check("Ad_min") # => #<data Usernames::Result ok?=false, reason=:username_reserved, …>
|
|
22
23
|
class Result
|
|
@@ -52,24 +53,50 @@ module Usernames
|
|
|
52
53
|
attr_accessor reserved: Array[String]
|
|
53
54
|
attr_accessor forbidden: Array[String]
|
|
54
55
|
attr_accessor allowed: Array[String]
|
|
56
|
+
attr_accessor reserved_if: (^(String) -> boolish)?
|
|
55
57
|
attr_accessor on_adapter_failure: :allow | :reject
|
|
56
58
|
attr_accessor unique_index: (String | Symbol)?
|
|
57
59
|
def offensive: () -> untyped
|
|
58
60
|
def generator: () -> untyped
|
|
61
|
+
def availability: () -> Availability
|
|
59
62
|
def generator=: (untyped callable) -> untyped
|
|
60
63
|
def validate!: () -> self
|
|
64
|
+
|
|
65
|
+
# config.availability: the built-in "is this name free?" endpoint.
|
|
66
|
+
class Availability
|
|
67
|
+
attr_accessor path: (String | false)?
|
|
68
|
+
# { "user" => "User#handle" }: alias => "Model#column"
|
|
69
|
+
attr_accessor targets: Hash[String, String]
|
|
70
|
+
attr_accessor parent_controller: String?
|
|
71
|
+
attr_accessor rate_limit: { to: Integer, within: Numeric }
|
|
72
|
+
attr_accessor rate_limit_store: untyped
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# f.username_field / f.username_status, on every ActionView form builder.
|
|
77
|
+
module FormBuilder
|
|
78
|
+
def username_field: (?(Symbol | String) column, ?availability: bool, ?static: bool, **untyped html) -> String
|
|
79
|
+
def username_status: (?(Symbol | String) column, **untyped html) -> String
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `usernames_availability` in config/routes.rb, above a catch-all.
|
|
83
|
+
module Routes
|
|
84
|
+
def usernames_availability: (?String? path) -> void
|
|
61
85
|
end
|
|
62
86
|
|
|
63
|
-
# has_username(column = :username, required: false, generate: nil, **rules)
|
|
87
|
+
# has_username(column = :username, required: false, generate: nil, seed: nil, clearable: true, **rules)
|
|
64
88
|
#
|
|
65
89
|
# required: and generate: take true, false, a method name or a proc,
|
|
66
|
-
# evaluated on the record like Rails' if
|
|
67
|
-
#
|
|
90
|
+
# evaluated on the record like Rails' if:; required: also takes
|
|
91
|
+
# { on: context } (or an Array of contexts). generate: nil (the default)
|
|
92
|
+
# means: wherever a name is not required. seed: is a method name or a
|
|
93
|
+
# proc whose value a generated name starts from; clearable: false
|
|
94
|
+
# refuses saving a saved name blank.
|
|
68
95
|
#
|
|
69
96
|
# Also defines, named after the column: <column>_available?, suggest_<column>s,
|
|
70
97
|
# generate_<column>, find_by_<column>, find_by_<column>!, <column>_options, check_<column>,
|
|
71
98
|
# <column>_rules_for_browser, and <column>_generated? on instances.
|
|
72
99
|
module Model
|
|
73
|
-
def has_username: (?(Symbol | String) column, ?required: (bool | Symbol | Proc), ?generate: (bool | Symbol | Proc)?, **untyped rules) -> void
|
|
100
|
+
def has_username: (?(Symbol | String) column, ?required: (bool | Symbol | Proc | { on: Symbol | Array[Symbol] }), ?generate: (bool | Symbol | Proc)?, ?seed: (Symbol | Proc)?, ?clearable: bool, **untyped rules) -> void
|
|
74
101
|
end
|
|
75
102
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: usernames
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
@@ -106,6 +106,10 @@ files:
|
|
|
106
106
|
- LICENSE.txt
|
|
107
107
|
- README.md
|
|
108
108
|
- SECURITY.md
|
|
109
|
+
- app/assets/javascripts/usernames/index.js
|
|
110
|
+
- app/assets/javascripts/usernames/username_check_controller.js
|
|
111
|
+
- app/controllers/usernames/availability_controller.rb
|
|
112
|
+
- config/importmap.rb
|
|
109
113
|
- config/locales/usernames.en.yml
|
|
110
114
|
- config/locales/usernames.es.yml
|
|
111
115
|
- config/locales/usernames.pt.yml
|
|
@@ -146,11 +150,11 @@ files:
|
|
|
146
150
|
- lib/generators/usernames/existing_schema.rb
|
|
147
151
|
- lib/generators/usernames/install_generator.rb
|
|
148
152
|
- lib/generators/usernames/install_messages.rb
|
|
153
|
+
- lib/generators/usernames/javascript_generator.rb
|
|
154
|
+
- lib/generators/usernames/javascript_setup.rb
|
|
149
155
|
- lib/generators/usernames/locale_generator.rb
|
|
150
156
|
- lib/generators/usernames/templates/add_username.rb.erb
|
|
151
157
|
- lib/generators/usernames/templates/availability/_field.html.erb.tt
|
|
152
|
-
- lib/generators/usernames/templates/availability/availability_controller.rb.tt
|
|
153
|
-
- lib/generators/usernames/templates/availability/username_check_controller.js.tt
|
|
154
158
|
- lib/generators/usernames/templates/usernames.rb
|
|
155
159
|
- lib/tasks/usernames.rake
|
|
156
160
|
- lib/usernames.rb
|
|
@@ -166,7 +170,9 @@ files:
|
|
|
166
170
|
- lib/usernames/data.rb
|
|
167
171
|
- lib/usernames/engine.rb
|
|
168
172
|
- lib/usernames/errors.rb
|
|
173
|
+
- lib/usernames/form_helpers.rb
|
|
169
174
|
- lib/usernames/generator.rb
|
|
175
|
+
- lib/usernames/locale.rb
|
|
170
176
|
- lib/usernames/model.rb
|
|
171
177
|
- lib/usernames/normalizer.rb
|
|
172
178
|
- lib/usernames/offensive.rb
|
|
@@ -177,8 +183,10 @@ files:
|
|
|
177
183
|
- lib/usernames/reserved.rb
|
|
178
184
|
- lib/usernames/reserved/listing.rb
|
|
179
185
|
- lib/usernames/result.rb
|
|
186
|
+
- lib/usernames/routes.rb
|
|
180
187
|
- lib/usernames/rules.rb
|
|
181
188
|
- lib/usernames/rules/options.rb
|
|
189
|
+
- lib/usernames/targets.rb
|
|
182
190
|
- lib/usernames/unique_violation.rb
|
|
183
191
|
- lib/usernames/validator.rb
|
|
184
192
|
- lib/usernames/version.rb
|