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,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
module Usernames
|
|
6
|
+
# Reserved names: the exact list (config.reserved, your routes,
|
|
7
|
+
# the bundled system, mailbox and web words) and the impersonation list
|
|
8
|
+
# (config.forbidden, the bundled impersonation words, your app's name),
|
|
9
|
+
# with config.allowed lifting individual words.
|
|
10
|
+
#
|
|
11
|
+
# The two lists are matched differently because they fail differently.
|
|
12
|
+
# Somebody posing as staff types `adm1n`, `ad.min`, `admin_bob` or
|
|
13
|
+
# `admins`, so impersonation words are looked for in every form the name
|
|
14
|
+
# can be read as. The thousand-odd exact words are paths and mailboxes
|
|
15
|
+
# that only collide when spelled exactly: folding trailing digits over
|
|
16
|
+
# them is what refused `game969` in production, and matching them as
|
|
17
|
+
# prefixes is what refused `badminton_fan`.
|
|
18
|
+
module Reserved
|
|
19
|
+
SEPARATORS = "_.-"
|
|
20
|
+
|
|
21
|
+
# A segment is what sits between separators and digit runs:
|
|
22
|
+
# `adm1n_bob` reads as adm, n, bob.
|
|
23
|
+
SEGMENT_BOUNDARY = /[_.-]+|\d+/
|
|
24
|
+
|
|
25
|
+
# Names that read as a well-known URI or as an IDNA/punycode label
|
|
26
|
+
# whatever follows: `/.well-known/…` is served by the web server, and
|
|
27
|
+
# `xn--…` is how a Unicode lookalike travels as ASCII.
|
|
28
|
+
PREFIXES = %w[.well-known xn--].freeze
|
|
29
|
+
|
|
30
|
+
# The compiled sets for one Usernames::Rules. Every word is an ASCII
|
|
31
|
+
# comparison key, so a lookup is one Set#include?. :nodoc:
|
|
32
|
+
Sets = ::Data.define(:exact, :custom, :impersonation, :allowed)
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# The kind of reservation that matched — :impersonation, :exact, :route
|
|
36
|
+
# or :custom — or nil when the name is free to use. `key` is
|
|
37
|
+
# Usernames.comparison_key of a name that already passed every format
|
|
38
|
+
# rule; `rules` is the Usernames::Rules in force.
|
|
39
|
+
#
|
|
40
|
+
# config.allowed lifts any of them, but only for exactly that name:
|
|
41
|
+
# allowing `help` frees `help`, not `help_desk`.
|
|
42
|
+
#
|
|
43
|
+
# A word in config.forbidden is matched as an impersonation
|
|
44
|
+
# word and reported as :impersonation; :custom names a config.reserved
|
|
45
|
+
# word that is not bundled. A reserved prefix (`.well-known`, `xn--`)
|
|
46
|
+
# is reported as :exact.
|
|
47
|
+
def verdict(key, rules)
|
|
48
|
+
sets = sets_for(rules)
|
|
49
|
+
return if sets.allowed.include?(key)
|
|
50
|
+
|
|
51
|
+
if impersonation?(key, sets.impersonation) then :impersonation
|
|
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
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether any spelling of the name is an impersonation entry: the name
|
|
59
|
+
# exactly as typed (so a custom entry that is itself digits or
|
|
60
|
+
# separators, `web3`, still forbids itself), and — as typed and
|
|
61
|
+
# leet-folded — whole and without trailing digits (separators removed),
|
|
62
|
+
# and every segment. The first spelling that is an entry answers, so
|
|
63
|
+
# most names are refused or passed in a few lookups.
|
|
64
|
+
def impersonation?(key, entries)
|
|
65
|
+
return true if entries.include?(key)
|
|
66
|
+
|
|
67
|
+
[key, leet(key)].uniq.any? do |form|
|
|
68
|
+
entries.include?(form.delete(SEPARATORS)) ||
|
|
69
|
+
entries.include?(form.sub(/\d+\z/, "").delete(SEPARATORS)) ||
|
|
70
|
+
form.split(SEGMENT_BOUNDARY).any? { |segment| entries.include?(segment) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def prefix?(key)
|
|
75
|
+
key.start_with?(*PREFIXES)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The digit folds of data/offensive/leet.yml: 0→o 1→i 3→e 4→a 5→s 7→t.
|
|
79
|
+
def leet(string)
|
|
80
|
+
from, to = digit_folds
|
|
81
|
+
string.tr(from, to)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Where a bundled reserved word comes from, for `usernames:reserved`:
|
|
85
|
+
# a Usernames::Data::Provenance (category, source, licence, reason),
|
|
86
|
+
# or nil for a word the gem does not ship.
|
|
87
|
+
def source_of(word)
|
|
88
|
+
Data.reserved_provenance[word.to_s.downcase(:ascii)]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The first path segment of every static route the app draws, collected
|
|
92
|
+
# once and kept until reset_routes! (the engine calls it when routes
|
|
93
|
+
# reload). Empty outside a Rails application.
|
|
94
|
+
def routes
|
|
95
|
+
@routes ||= rails_routes
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def reset_routes!
|
|
99
|
+
@routes = nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The app's own name as it would be typed: `config.app_name`
|
|
103
|
+
# when it is a String, else the module of the Rails application —
|
|
104
|
+
# `RecipeBox::Application` reserves `recipe_box` and
|
|
105
|
+
# `recipebox`. None when opted out (`false`) or when there is no
|
|
106
|
+
# named application.
|
|
107
|
+
def app_names(rules)
|
|
108
|
+
name = rules.app_name
|
|
109
|
+
name = detected_app_name if name.nil?
|
|
110
|
+
return [] unless name.is_a?(String)
|
|
111
|
+
|
|
112
|
+
underscored = name.underscore.downcase(:ascii).gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
113
|
+
[underscored, underscored.delete("_")].reject(&:empty?).uniq
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def sets_for(rules)
|
|
119
|
+
rules.compiled(:reserved) do
|
|
120
|
+
bundled = Data.reserved.to_set
|
|
121
|
+
reserved = keys(rules.reserved)
|
|
122
|
+
Sets.new(
|
|
123
|
+
exact: (reserved & bundled).freeze,
|
|
124
|
+
custom: (reserved - bundled).freeze,
|
|
125
|
+
impersonation: impersonation_entries(keys(rules.forbidden) | app_names(rules)).freeze,
|
|
126
|
+
allowed: keys(rules.allowed)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Each word as the matcher stores it: itself, leet-folded, and the plural
|
|
132
|
+
# of both; and, for an entry written with separators (`my_app`), the
|
|
133
|
+
# same again without them — the name side is read without separators
|
|
134
|
+
# too, and an entry with one could otherwise only ever match literally.
|
|
135
|
+
def impersonation_entries(words)
|
|
136
|
+
words.each_with_object(Set.new) do |word, entries|
|
|
137
|
+
[word, word.delete(SEPARATORS)].uniq.each do |form|
|
|
138
|
+
[form, leet(form)].each { |spelling| entries << spelling << "#{spelling}s" }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def keys(words)
|
|
144
|
+
words.to_set { |word| word.strip.downcase(:ascii) }.freeze
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def digit_folds
|
|
148
|
+
@digit_folds ||= begin
|
|
149
|
+
digits = Data.leet.fetch("digits")
|
|
150
|
+
[digits.keys.join, digits.values.join].map(&:freeze).freeze
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def rails_routes
|
|
155
|
+
return Set.new.freeze unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
156
|
+
|
|
157
|
+
Routes.collect(::Rails.application.routes)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Rails::Application subclasses are named `<App>::Application`; one
|
|
161
|
+
# defined at the top level (a script, a test) has no app name.
|
|
162
|
+
def detected_app_name
|
|
163
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
164
|
+
|
|
165
|
+
parent = ::Rails.application.class.module_parent
|
|
166
|
+
parent.name unless parent == Object
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The first path segment of an app's static routes, so a user
|
|
171
|
+
# called `login` cannot take /login from under the app's feet.
|
|
172
|
+
module Routes
|
|
173
|
+
# What a route's first segment may be: anything a username could be.
|
|
174
|
+
# Others (`@`, `~`) can never collide, so they are left out.
|
|
175
|
+
SEGMENT = /\A[a-z0-9._-]+\z/
|
|
176
|
+
|
|
177
|
+
class << self
|
|
178
|
+
# The frozen Set of first segments from an ActionDispatch::Routing::RouteSet.
|
|
179
|
+
#
|
|
180
|
+
# Reads each route's Journey AST: a path of the form `/literal`, then
|
|
181
|
+
# nothing, another `/`, or an optional group that starts with `/` or
|
|
182
|
+
# `.` (`/login(.:format)`, `/bar(/:x)`). A literal followed directly by a
|
|
183
|
+
# dot or a parameter (`/foo.:kind`, `/@:username`) is only part of a
|
|
184
|
+
# dynamic segment, and `/:username`, `/*path` have no literal at all.
|
|
185
|
+
# A mounted engine contributes its mount path, like any other route.
|
|
186
|
+
def collect(route_set)
|
|
187
|
+
route_set.routes.each_with_object(Set.new) do |route, segments|
|
|
188
|
+
segment = first_segment(route.path.spec)
|
|
189
|
+
segments << segment if segment
|
|
190
|
+
end.freeze
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def first_segment(spec)
|
|
196
|
+
slash, literal, following = flatten(spec)
|
|
197
|
+
return unless slash&.type == :SLASH && literal&.type == :LITERAL && complete?(following)
|
|
198
|
+
|
|
199
|
+
segment = literal.left.to_s.downcase(:ascii)
|
|
200
|
+
segment if SEGMENT.match?(segment)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def complete?(node)
|
|
204
|
+
case node&.type
|
|
205
|
+
when nil, :SLASH then true
|
|
206
|
+
when :GROUP then %i[SLASH DOT].include?(flatten(node.left).first&.type)
|
|
207
|
+
else false
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A Journey path is a right-leaning tree of Cat nodes; this reads it
|
|
212
|
+
# as the list of nodes it spells, groups left whole.
|
|
213
|
+
def flatten(node)
|
|
214
|
+
node.type == :CAT ? flatten(node.left) + flatten(node.right) : [node]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# The answer to `Usernames.check`: whether a name may be registered, and if
|
|
5
|
+
# not, which rule said so.
|
|
6
|
+
#
|
|
7
|
+
# reason the error type a model would get (:username_reserved, :too_short…)
|
|
8
|
+
# rule for a content verdict, which kind of match it was: :impersonation,
|
|
9
|
+
# :exact, :route, :custom, :severe, :strong, :hate_number, :adapter.
|
|
10
|
+
# nil when the name is fine or failed a format rule (the reason
|
|
11
|
+
# already names a format rule exactly).
|
|
12
|
+
# canonical the normalised name the rules were applied to
|
|
13
|
+
# message the error message in the current locale, or nil when ok.
|
|
14
|
+
# Never contains what was typed — and never which word matched.
|
|
15
|
+
#
|
|
16
|
+
# `::Data`, not `Data`: inside this namespace `Data` is Usernames::Data.
|
|
17
|
+
Result = ::Data.define(:ok?, :reason, :rule, :canonical, :message) do
|
|
18
|
+
# `record` and `attribute` word the message the way that model's own
|
|
19
|
+
# error would be worded (see Failure#message).
|
|
20
|
+
def self.for(canonical, failure, record: nil, attribute: nil)
|
|
21
|
+
return new(ok?: true, reason: nil, rule: nil, canonical: canonical, message: nil) unless failure
|
|
22
|
+
|
|
23
|
+
new(ok?: false, reason: failure.reason, rule: failure.rule, canonical: canonical,
|
|
24
|
+
message: failure.message(record, attribute))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# One failed rule, as the pipeline hands it around before it becomes a
|
|
29
|
+
# Result (for the pure functions) or an Active Model error (for the
|
|
30
|
+
# validator). `details` are the interpolations the message needs, kept
|
|
31
|
+
# separate so the validator can hand them to errors.add and let a per-model
|
|
32
|
+
# translation win, exactly as for a Rails error. :nodoc:
|
|
33
|
+
Failure = ::Data.define(:reason, :rule, :details) do
|
|
34
|
+
def initialize(reason:, rule: nil, details: {})
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A Symbol :message in the details names the translation to use while the
|
|
39
|
+
# type stays `reason` — Active Model's own convention for errors.add.
|
|
40
|
+
#
|
|
41
|
+
# With a `record` (any instance of the model) and its `attribute`, the
|
|
42
|
+
# message is looked up exactly as that model's error would be
|
|
43
|
+
# (activerecord.errors.models.user.attributes.handle.too_short first, then
|
|
44
|
+
# up to errors.messages), so the browser hints and the availability
|
|
45
|
+
# check say what the form says.
|
|
46
|
+
def message(record = nil, attribute = nil)
|
|
47
|
+
return record.errors.generate_message(attribute, reason, details.dup) if record
|
|
48
|
+
|
|
49
|
+
I18n.t(details.fetch(:message, reason), scope: %i[errors messages], **details.except(:message)).to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
class Rules
|
|
5
|
+
# Every option the gem takes, checked once when a Rules is built — so an
|
|
6
|
+
# initializer typo (`config.case = :lower`, `length: 3`) fails at boot
|
|
7
|
+
# with the bad value named, instead of quietly meaning something else on
|
|
8
|
+
# the day somebody signs up.
|
|
9
|
+
module Options
|
|
10
|
+
NAMES = %i[
|
|
11
|
+
length separators edge_separators case first_character reserve_routes app_name
|
|
12
|
+
reserved forbidden allowed offensive on_adapter_failure generator unique_index
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
OFFENSIVE_NAMES = %i[severity languages allowed adapters].freeze
|
|
16
|
+
GENERATOR_NAMES = %i[adjectives nouns callable].freeze
|
|
17
|
+
|
|
18
|
+
SEPARATORS = %w[_ . -].freeze
|
|
19
|
+
CASES = %i[lowercase preserve].freeze
|
|
20
|
+
FIRST_CHARACTERS = %i[alphanumeric letter].freeze
|
|
21
|
+
SEVERITIES = %i[severe strong mild off].freeze
|
|
22
|
+
ADAPTER_FAILURE_MODES = %i[allow reject].freeze
|
|
23
|
+
|
|
24
|
+
OffensiveSettings = ::Data.define(*OFFENSIVE_NAMES)
|
|
25
|
+
GeneratorSettings = ::Data.define(*GENERATOR_NAMES)
|
|
26
|
+
|
|
27
|
+
attr_reader(*NAMES)
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def assign(options)
|
|
32
|
+
unknown = options.keys - NAMES
|
|
33
|
+
raise ConfigurationError, "unknown option #{unknown.first.inspect} (known: #{NAMES.join(', ')})" if unknown.any?
|
|
34
|
+
|
|
35
|
+
assign_format(options)
|
|
36
|
+
assign_lists(options)
|
|
37
|
+
assign_offensive(options.fetch(:offensive))
|
|
38
|
+
assign_generator(options.fetch(:generator))
|
|
39
|
+
@reserve_routes = boolean(:reserve_routes, options.fetch(:reserve_routes))
|
|
40
|
+
@app_name = valid_app_name(options.fetch(:app_name))
|
|
41
|
+
@unique_index = valid_unique_index(options.fetch(:unique_index))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def assign_format(options)
|
|
45
|
+
@length = valid_length(options.fetch(:length))
|
|
46
|
+
@separators = valid_separators(options.fetch(:separators))
|
|
47
|
+
@edge_separators = boolean(:edge_separators, options.fetch(:edge_separators))
|
|
48
|
+
@case = one_of(:case, options.fetch(:case), CASES)
|
|
49
|
+
@first_character = one_of(:first_character, options.fetch(:first_character), FIRST_CHARACTERS)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def assign_lists(options)
|
|
53
|
+
@reserved = valid_words(:reserved, options.fetch(:reserved))
|
|
54
|
+
@forbidden = valid_words(:forbidden, options.fetch(:forbidden))
|
|
55
|
+
@allowed = valid_words(:allowed, options.fetch(:allowed))
|
|
56
|
+
@on_adapter_failure = one_of(:on_adapter_failure, options.fetch(:on_adapter_failure), ADAPTER_FAILURE_MODES)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def assign_offensive(offensive)
|
|
60
|
+
offensive = settings(:offensive, offensive, OFFENSIVE_NAMES)
|
|
61
|
+
@offensive = OffensiveSettings.new(
|
|
62
|
+
severity: one_of(:"offensive.severity", offensive[:severity], SEVERITIES),
|
|
63
|
+
languages: valid_languages(offensive[:languages]),
|
|
64
|
+
allowed: valid_words(:"offensive.allowed", offensive[:allowed]),
|
|
65
|
+
adapters: valid_adapters(offensive[:adapters])
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def assign_generator(generator)
|
|
70
|
+
generator = settings(:generator, generator, GENERATOR_NAMES)
|
|
71
|
+
callable = generator[:callable]
|
|
72
|
+
unless callable.nil? || callable.respond_to?(:call)
|
|
73
|
+
invalid!(:generator, callable, "a callable such as ->(attempt) { … }")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@generator = GeneratorSettings.new(
|
|
77
|
+
adjectives: valid_words(:"generator.adjectives", generator[:adjectives]),
|
|
78
|
+
nouns: valid_words(:"generator.nouns", generator[:nouns]),
|
|
79
|
+
callable: callable
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def valid_length(value)
|
|
84
|
+
valid = value.is_a?(Range) && value.begin.is_a?(Integer) && value.end.is_a?(Integer) &&
|
|
85
|
+
value.min.to_i >= 1
|
|
86
|
+
invalid!(:length, value, "a Range of whole numbers from 1 up, like 3..30") unless valid
|
|
87
|
+
|
|
88
|
+
value.min..value.max
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def valid_separators(value)
|
|
92
|
+
unless value.is_a?(Array) && value.all? { |separator| SEPARATORS.include?(separator) }
|
|
93
|
+
invalid!(:separators, value, "a list drawn from #{SEPARATORS.map(&:inspect).join(', ')}, like %w[_]")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
value.uniq.freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def one_of(name, value, choices)
|
|
100
|
+
invalid!(name, value, "one of #{choices.map(&:inspect).join(', ')}") unless choices.include?(value)
|
|
101
|
+
|
|
102
|
+
value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def boolean(name, value)
|
|
106
|
+
one_of(name, value, [true, false])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A list of words, naming the one entry that is not a word rather than
|
|
110
|
+
# printing a list that may hold a thousand of them.
|
|
111
|
+
def valid_words(name, value)
|
|
112
|
+
invalid!(name, value, "a list of words, like %w[recipe]") unless value.is_a?(Array)
|
|
113
|
+
# An index, not the entry itself: the bad entry may be nil.
|
|
114
|
+
bad = value.index { |word| !word.is_a?(String) || word.strip.empty? }
|
|
115
|
+
raise ConfigurationError, "#{name} must be a list of words, like %w[recipe] (got #{value[bad].inspect} in it)" if bad
|
|
116
|
+
|
|
117
|
+
value.dup.freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Only languages whose words ship: a language without data would refuse
|
|
121
|
+
# nothing, so a typo (`:sp`) or a language not tiered yet would switch
|
|
122
|
+
# the word check off without a word of warning.
|
|
123
|
+
def valid_languages(value)
|
|
124
|
+
unless value.is_a?(Array) && value.all?(Symbol)
|
|
125
|
+
invalid!(:"offensive.languages", value, "a list of locale symbols, like %i[en es]")
|
|
126
|
+
end
|
|
127
|
+
unknown = value - Data.available_locales
|
|
128
|
+
if unknown.any?
|
|
129
|
+
raise ConfigurationError, "offensive.languages has no word list for #{unknown.first.inspect} " \
|
|
130
|
+
"(available: #{Data.available_locales.map(&:inspect).join(', ')})"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
value.uniq.freeze
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# :moderate (a no-op unless the moderate gem is loaded) or any callable
|
|
137
|
+
# that takes the name.
|
|
138
|
+
def valid_adapters(value)
|
|
139
|
+
unless value.is_a?(Array) && value.all? { |adapter| adapter == :moderate || adapter.respond_to?(:call) }
|
|
140
|
+
invalid!(:"offensive.adapters", value, "a list of :moderate or callables, like [:moderate]")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
value.dup.freeze
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# nil detects the name from the Rails application; false opts out; a
|
|
147
|
+
# String names it explicitly (an app whose brand is not its module name).
|
|
148
|
+
def valid_app_name(value)
|
|
149
|
+
valid = value.nil? || value == false || (value.is_a?(String) && !value.strip.empty?)
|
|
150
|
+
invalid!(:app_name, value, "a String, false to opt out, or nil to detect it") unless valid
|
|
151
|
+
|
|
152
|
+
value
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def valid_unique_index(value)
|
|
156
|
+
valid = value.nil? || ((value.is_a?(String) || value.is_a?(Symbol)) && !value.empty?)
|
|
157
|
+
invalid!(:unique_index, value, "the name of an index, or nil") unless valid
|
|
158
|
+
|
|
159
|
+
value
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def settings(name, value, names)
|
|
163
|
+
invalid!(name, value, "a Hash of #{names.join(', ')}") unless value.is_a?(Hash)
|
|
164
|
+
unknown = value.keys - names
|
|
165
|
+
raise ConfigurationError, "unknown option #{name}.#{unknown.first} (known: #{names.join(', ')})" if unknown.any?
|
|
166
|
+
|
|
167
|
+
value
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def invalid!(name, value, expectation)
|
|
171
|
+
raise ConfigurationError, "#{name} must be #{expectation} (got #{value.inspect})"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|