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,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# `Usernames.configure { |config| … }`. The generated initializer shows ten
|
|
5
|
+
# of these options; the README documents the rest.
|
|
6
|
+
#
|
|
7
|
+
# Word lists START FROM the bundled data, so `+=` adds to it and `=`
|
|
8
|
+
# replaces it:
|
|
9
|
+
#
|
|
10
|
+
# config.reserved += %w[recipe recipes] # the bundled words, and these
|
|
11
|
+
# config.reserved = %w[recipe recipes] # only these
|
|
12
|
+
#
|
|
13
|
+
# Lists are handed out frozen. The rules compiled from them are cached, and
|
|
14
|
+
# a list changed behind the cache's back (`config.reserved << "x"`) would
|
|
15
|
+
# be ignored without a word — so that raises FrozenError instead, and the
|
|
16
|
+
# two spellings above are the ones that work.
|
|
17
|
+
#
|
|
18
|
+
# Nothing is checked as you assign: validate! (run at the end of configure,
|
|
19
|
+
# and by the Rails engine after boot) checks every value at once and raises
|
|
20
|
+
# Usernames::ConfigurationError naming the bad one.
|
|
21
|
+
class Configuration
|
|
22
|
+
# Accessors whose writers invalidate the compiled rules.
|
|
23
|
+
module Settings
|
|
24
|
+
def option(*names)
|
|
25
|
+
names.each do |name|
|
|
26
|
+
attr_reader name
|
|
27
|
+
|
|
28
|
+
define_method(:"#{name}=") do |value|
|
|
29
|
+
changed!
|
|
30
|
+
instance_variable_set(:"@#{name}", value.is_a?(Array) ? value.dup.freeze : value)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
extend Settings
|
|
37
|
+
|
|
38
|
+
# config.offensive.severity / .languages / .allowed / .adapters
|
|
39
|
+
class Offensive
|
|
40
|
+
extend Settings
|
|
41
|
+
|
|
42
|
+
option :severity, :languages, :allowed, :adapters
|
|
43
|
+
|
|
44
|
+
def initialize(configuration)
|
|
45
|
+
@configuration = configuration
|
|
46
|
+
@severity = :strong
|
|
47
|
+
@languages = %i[en es].freeze
|
|
48
|
+
# Your own exemptions, so — unlike reserved and forbidden — this list
|
|
49
|
+
# starts empty: the bundled allowlists are per language and apply only
|
|
50
|
+
# inside their own language, so they are not merged in here.
|
|
51
|
+
@allowed = [].freeze
|
|
52
|
+
# A no-op unless the moderate gem is loaded.
|
|
53
|
+
@adapters = [:moderate].freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_h
|
|
57
|
+
{ severity: severity, languages: languages, allowed: allowed, adapters: adapters }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def changed!
|
|
63
|
+
@configuration.changed!
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# config.generator.adjectives / .nouns, or config.generator = ->(attempt) { … }
|
|
68
|
+
class Generator
|
|
69
|
+
extend Settings
|
|
70
|
+
|
|
71
|
+
option :adjectives, :nouns, :callable
|
|
72
|
+
|
|
73
|
+
def initialize(configuration)
|
|
74
|
+
@configuration = configuration
|
|
75
|
+
@adjectives = Data.adjectives
|
|
76
|
+
@nouns = Data.nouns
|
|
77
|
+
@callable = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_h
|
|
81
|
+
{ adjectives: adjectives, nouns: nouns, callable: callable }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def changed!
|
|
87
|
+
@configuration.changed!
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Per-call word lists ADD to the configured ones: a model that passes
|
|
92
|
+
# `reserved: %w[recipe]` keeps every bundled reservation. Every other
|
|
93
|
+
# per-call option replaces the configured value. Adding is the choice
|
|
94
|
+
# that can never silently un-reserve a name: a per-model list fails closed.
|
|
95
|
+
LISTS = %i[reserved forbidden allowed].freeze
|
|
96
|
+
|
|
97
|
+
# Distinct per-call option sets kept compiled. They come from model and
|
|
98
|
+
# validator declarations, so a handful in practice; the limit only stops
|
|
99
|
+
# a caller passing fresh options on every request from growing it forever.
|
|
100
|
+
CACHE_LIMIT = 64
|
|
101
|
+
|
|
102
|
+
option :length, :separators, :edge_separators, :case, :first_character, :reserve_routes, :app_name,
|
|
103
|
+
:reserved, :forbidden, :allowed, :on_adapter_failure, :unique_index
|
|
104
|
+
|
|
105
|
+
attr_reader :offensive, :generator
|
|
106
|
+
|
|
107
|
+
def initialize
|
|
108
|
+
@length = 3..30
|
|
109
|
+
@separators = %w[_].freeze
|
|
110
|
+
@edge_separators = false
|
|
111
|
+
@case = :lowercase
|
|
112
|
+
@first_character = :alphanumeric
|
|
113
|
+
@reserve_routes = true
|
|
114
|
+
@app_name = nil # detected from the Rails application; false opts out
|
|
115
|
+
@reserved = Data.reserved
|
|
116
|
+
@forbidden = Data.impersonation
|
|
117
|
+
@allowed = [].freeze
|
|
118
|
+
@on_adapter_failure = :allow
|
|
119
|
+
@unique_index = nil
|
|
120
|
+
@offensive = Offensive.new(self)
|
|
121
|
+
@generator = Generator.new(self)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `config.generator = ->(attempt) { … }` replaces the default shape;
|
|
125
|
+
# the word lists stay at config.generator.adjectives / .nouns.
|
|
126
|
+
def generator=(callable)
|
|
127
|
+
generator.callable = callable
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The compiled, frozen Usernames::Rules for these per-call options merged
|
|
131
|
+
# over this configuration, raising ConfigurationError for a bad value.
|
|
132
|
+
def rules_for(**overrides)
|
|
133
|
+
cache = (@rules_cache ||= {})
|
|
134
|
+
cache.clear if cache.size >= CACHE_LIMIT
|
|
135
|
+
cache[overrides] ||= begin
|
|
136
|
+
options = merge(overrides)
|
|
137
|
+
Rules.new(**options)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate!
|
|
142
|
+
rules_for
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def to_h
|
|
147
|
+
{
|
|
148
|
+
length: length, separators: separators, edge_separators: edge_separators, case: @case,
|
|
149
|
+
first_character: first_character, reserve_routes: reserve_routes, app_name: app_name,
|
|
150
|
+
reserved: reserved, forbidden: forbidden, allowed: allowed, offensive: offensive.to_h,
|
|
151
|
+
on_adapter_failure: on_adapter_failure, generator: generator.to_h, unique_index: unique_index
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Called by every writer, including those of config.offensive and
|
|
156
|
+
# config.generator. :nodoc:
|
|
157
|
+
def changed!
|
|
158
|
+
@rules_cache = nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def merge(overrides)
|
|
164
|
+
options = to_h
|
|
165
|
+
overrides.each do |name, value|
|
|
166
|
+
options[name] =
|
|
167
|
+
case name
|
|
168
|
+
when *LISTS then added(options[name], value)
|
|
169
|
+
when :offensive then merged_offensive(options[:offensive], value)
|
|
170
|
+
when :generator then merged_generator(options[:generator], value)
|
|
171
|
+
else value
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
options
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Anything that is not a list is passed on as given, for Rules to refuse by name.
|
|
178
|
+
def added(list, value)
|
|
179
|
+
value.is_a?(Array) ? list + value : value
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def merged_offensive(settings, value)
|
|
183
|
+
return value unless value.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
settings.merge(value) do |name, configured, given|
|
|
186
|
+
name == :allowed ? added(configured, given) : given
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def merged_generator(settings, value)
|
|
191
|
+
return settings.merge(callable: value) if value.nil? || value.respond_to?(:call)
|
|
192
|
+
return value unless value.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
settings.merge(value)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Usernames
|
|
7
|
+
# The word lists that ship in data/, read once per process and frozen.
|
|
8
|
+
#
|
|
9
|
+
# Every list is a plain text file, one entry per line; blank lines and
|
|
10
|
+
# `#` comments (which carry each block's source and reason, see
|
|
11
|
+
# data/NOTICE) are skipped. Every file is mandatory: one that is missing,
|
|
12
|
+
# empty or malformed raises Usernames::DataError when the gem loads, since
|
|
13
|
+
# the alternative — rules that silently check nothing — fails open.
|
|
14
|
+
#
|
|
15
|
+
# Note the name: inside `module Usernames`, `Data` is this module. Ruby's
|
|
16
|
+
# own value-object class is `::Data`, and the gem always spells it so.
|
|
17
|
+
module Data
|
|
18
|
+
ROOT = File.expand_path("../../data", __dir__)
|
|
19
|
+
|
|
20
|
+
# The lists every offensive entry carries, besides its term and severity.
|
|
21
|
+
TERM_LISTS = %w[variants tags exceptions].freeze
|
|
22
|
+
|
|
23
|
+
# Matched exactly: system words, mailboxes, web paths.
|
|
24
|
+
EXACT_FILES = %w[reserved/system.txt reserved/mailboxes.txt reserved/web.txt].freeze
|
|
25
|
+
|
|
26
|
+
# Matched the way impersonators type them.
|
|
27
|
+
IMPERSONATION_FILES = %w[reserved/impersonation.txt].freeze
|
|
28
|
+
|
|
29
|
+
# `# source: gitlab (MIT) — GitLab top-level and project wildcard routes`
|
|
30
|
+
SOURCE_LINE = /\A#\s*source:\s*(?<name>\S+)\s+\((?<licence>[^)]+)\)\s*(?:—\s*(?<reason>.*))?\z/
|
|
31
|
+
|
|
32
|
+
# Where one bundled reserved word comes from: its file (:impersonation,
|
|
33
|
+
# :system, :mailboxes, :web) and the `# source:` comment of its block.
|
|
34
|
+
Provenance = ::Data.define(:category, :source, :licence, :reason)
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# What `config.reserved` starts from.
|
|
38
|
+
def reserved
|
|
39
|
+
@reserved ||= EXACT_FILES.flat_map { |file| list(file) }.uniq.freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# What `config.forbidden` starts from.
|
|
43
|
+
def impersonation
|
|
44
|
+
@impersonation ||= IMPERSONATION_FILES.flat_map { |file| list(file) }.uniq.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# { word => Provenance } over every bundled reserved file. The
|
|
48
|
+
# impersonation file is read first: a word in it and in a mailbox list
|
|
49
|
+
# (`postmaster`) is matched the stricter way, so that is its source.
|
|
50
|
+
def reserved_provenance
|
|
51
|
+
@reserved_provenance ||= (IMPERSONATION_FILES + EXACT_FILES).each_with_object({}) do |file, words|
|
|
52
|
+
category = File.basename(file, ".txt").to_sym
|
|
53
|
+
sourced(file).each { |word, source| words[word] ||= Provenance.new(category: category, **source) }
|
|
54
|
+
end.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The generator's vocabulary.
|
|
58
|
+
def adjectives
|
|
59
|
+
@adjectives ||= list("words/adjectives.txt")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# data/words/animals.txt, published as Usernames::Words::ANIMALS.
|
|
63
|
+
def nouns
|
|
64
|
+
@nouns ||= list("words/animals.txt")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The languages whose offensive words ship (data/offensive/<locale>.json),
|
|
68
|
+
# as symbols: what config.offensive.languages may name.
|
|
69
|
+
def available_locales
|
|
70
|
+
@available_locales ||= begin
|
|
71
|
+
locales = Dir[File.join(ROOT, "offensive", "*.json")].map { |path| File.basename(path, ".json").to_sym }
|
|
72
|
+
broken!(File.join(ROOT, "offensive"), "holds no <locale>.json word list") if locales.empty?
|
|
73
|
+
locales.sort.freeze
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The parsed entries of data/offensive/<locale>.json: each a Hash with
|
|
78
|
+
# a "term", a "severity" from 1 to 4, and lists of "variants", "tags"
|
|
79
|
+
# and "exceptions". An empty list is as broken as a missing file: it
|
|
80
|
+
# would screen nothing in that language.
|
|
81
|
+
def offensive(locale)
|
|
82
|
+
(@offensive ||= {})[locale.to_sym] ||= read("offensive/#{locale}.json") do |path|
|
|
83
|
+
entries = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
84
|
+
broken!(path, "has no entries") if entries == []
|
|
85
|
+
unless entries.is_a?(Array) && entries.all? { |entry| term?(entry) }
|
|
86
|
+
broken!(path, "is not a list of {term, severity 1–4, variants, tags, exceptions} entries")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
deep_freeze(entries)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# data/offensive/allowlist/<locale>.txt (each line `word # source`).
|
|
94
|
+
def allowlist(locale)
|
|
95
|
+
(@allowlist ||= {})[locale.to_sym] ||= list("offensive/allowlist/#{locale}.txt")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# data/offensive/hate.yml: the hate tokens and number rules.
|
|
99
|
+
def hate
|
|
100
|
+
@hate ||= yaml("offensive/hate.yml", "always" => Array, "digit_runs" => Array, "adjacent" => Hash,
|
|
101
|
+
"whole_token" => Hash)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# data/offensive/leet.yml: the digit folds both matchers use, and the
|
|
105
|
+
# rewrites only the offensive matcher uses.
|
|
106
|
+
def leet
|
|
107
|
+
@leet ||= yaml("offensive/leet.yml", "digits" => Hash, "offensive_only" => Hash)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The entries of one data file, relative to data/ (or an absolute path).
|
|
111
|
+
# Every bundled file is read as UTF-8 explicitly: the host's locale
|
|
112
|
+
# (Encoding.default_external, US-ASCII where LANG is unset) must never
|
|
113
|
+
# decide how the lists are decoded.
|
|
114
|
+
def list(path)
|
|
115
|
+
read(path) do |file|
|
|
116
|
+
entries = File.foreach(file, chomp: true, encoding: "UTF-8").filter_map do |line|
|
|
117
|
+
entry = line.sub(/#.*/, "").strip
|
|
118
|
+
entry.freeze unless entry.empty?
|
|
119
|
+
end
|
|
120
|
+
broken!(file, "has no entries") if entries.empty?
|
|
121
|
+
entries.freeze
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# [[entry, { source:, licence:, reason: }], …] for one data file: each
|
|
126
|
+
# entry with the `# source:` comment of the block it sits in (nils for
|
|
127
|
+
# an entry before any).
|
|
128
|
+
def sourced(path)
|
|
129
|
+
read(path) do |file|
|
|
130
|
+
source = { source: nil, licence: nil, reason: nil }
|
|
131
|
+
File.foreach(file, chomp: true, encoding: "UTF-8").filter_map do |line|
|
|
132
|
+
if (match = SOURCE_LINE.match(line.strip))
|
|
133
|
+
source = { source: match[:name], licence: match[:licence], reason: match[:reason] }
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
entry = line.sub(/#.*/, "").strip
|
|
137
|
+
[entry, source] unless entry.empty?
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Reads every bundled file once, so a broken install fails when the gem
|
|
143
|
+
# loads rather than on the first sign-up that needs the missing list.
|
|
144
|
+
def load!
|
|
145
|
+
reserved
|
|
146
|
+
impersonation
|
|
147
|
+
reserved_provenance
|
|
148
|
+
adjectives
|
|
149
|
+
nouns
|
|
150
|
+
available_locales.each do |locale|
|
|
151
|
+
offensive(locale)
|
|
152
|
+
allowlist(locale)
|
|
153
|
+
end
|
|
154
|
+
hate
|
|
155
|
+
leet
|
|
156
|
+
self
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def read(relative)
|
|
162
|
+
path = File.expand_path(relative, ROOT)
|
|
163
|
+
broken!(path, "is missing") unless File.file?(path)
|
|
164
|
+
|
|
165
|
+
yield path
|
|
166
|
+
rescue JSON::ParserError, Psych::Exception => e
|
|
167
|
+
broken!(path, "can't be parsed (#{e.message.lines.first.strip})")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A YAML mapping with at least `shape`'s keys, each of its type.
|
|
171
|
+
def yaml(relative, shape)
|
|
172
|
+
read(relative) do |path|
|
|
173
|
+
data = YAML.safe_load_file(path)
|
|
174
|
+
wrong = shape.find { |key, type| !(data.is_a?(Hash) && data[key].is_a?(type)) }
|
|
175
|
+
broken!(path, "needs #{wrong[0].inspect} (a #{wrong[1].name})") if wrong
|
|
176
|
+
|
|
177
|
+
deep_freeze(data)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def term?(entry)
|
|
182
|
+
entry.is_a?(Hash) && entry["term"].is_a?(String) && !entry["term"].empty? &&
|
|
183
|
+
entry["severity"].is_a?(Integer) && (1..4).cover?(entry["severity"]) &&
|
|
184
|
+
term_lists?(entry) && [nil, true, false].include?(entry["segment_only"])
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def term_lists?(entry)
|
|
188
|
+
TERM_LISTS.all? { |key| entry[key].is_a?(Array) && entry[key].all?(String) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def broken!(path, problem)
|
|
192
|
+
raise DataError, "usernames' bundled data is broken: #{path} #{problem}. " \
|
|
193
|
+
"Reinstall the gem, or rebuild data/ with script/build_*.rb in a checkout."
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def deep_freeze(value)
|
|
197
|
+
case value
|
|
198
|
+
when Hash then value.each_value { |item| deep_freeze(item) }
|
|
199
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
200
|
+
end
|
|
201
|
+
value.freeze
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# Not isolated, and no routes: the gem mounts nothing behind your back
|
|
5
|
+
# (the availability check is a generator that copies files into the app).
|
|
6
|
+
#
|
|
7
|
+
# What it does do: its config/locales load BEFORE the app's — Rails puts
|
|
8
|
+
# engines first and the application last — so any key in the app's own
|
|
9
|
+
# locale files wins; `has_username` reaches every Active Record model; the
|
|
10
|
+
# app's own routes are reserved and re-read whenever they reload; and a bad
|
|
11
|
+
# configuration value fails at boot, not at the first sign-up.
|
|
12
|
+
#
|
|
13
|
+
# The generators (`usernames:install`, `usernames:availability`,
|
|
14
|
+
# `usernames:locale`) need no wiring: Rails finds them in
|
|
15
|
+
# lib/generators/usernames/ on the load path.
|
|
16
|
+
class Engine < ::Rails::Engine
|
|
17
|
+
initializer "usernames.active_record" do
|
|
18
|
+
ActiveSupport.on_load(:active_record) { extend Usernames::Model }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# After every initializer, including the app's config/initializers/usernames.rb.
|
|
22
|
+
initializer "usernames.validate_configuration" do |app|
|
|
23
|
+
app.config.after_initialize { Usernames.configuration.validate! }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The app's static top-level paths are reserved. They are
|
|
27
|
+
# collected lazily, on the first check after the routes load; in
|
|
28
|
+
# development the routes reload when config/routes.rb changes, and a
|
|
29
|
+
# page added there is reserved from the next check on.
|
|
30
|
+
config.after_routes_loaded { Usernames::Reserved.reset_routes! }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# A configuration value the gem cannot use, named in the message.
|
|
7
|
+
class ConfigurationError < Error; end
|
|
8
|
+
|
|
9
|
+
# Every generated candidate was taken (generate_username draws one batch).
|
|
10
|
+
class NoFreeNameError < Error; end
|
|
11
|
+
|
|
12
|
+
# A bundled data file (data/**) is missing or not what the gem expects.
|
|
13
|
+
# Raised when the gem loads: without its word lists the gem would check
|
|
14
|
+
# nothing at all — `admin` registrable, every slur allowed — and say nothing.
|
|
15
|
+
class DataError < Error; end
|
|
16
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "active_support/inflector"
|
|
5
|
+
|
|
6
|
+
module Usernames
|
|
7
|
+
# Default and seeded names: `quiet_otter_42`, or `jose_garcia`
|
|
8
|
+
# from "José García".
|
|
9
|
+
#
|
|
10
|
+
# Pure: it draws a batch of candidates and keeps those the rules allow; it
|
|
11
|
+
# never asks a database whether one is free. The model helpers take the
|
|
12
|
+
# batch to the database in ONE query, which is why it is a batch: twenty
|
|
13
|
+
# names at once instead of a retry loop of queries.
|
|
14
|
+
#
|
|
15
|
+
# The order inside each draw is fixed: assemble the name, shorten it
|
|
16
|
+
# to fit, and only then run the full check. Shortening can make a name
|
|
17
|
+
# the check refuses (an all-digit stem, a word cut into something else), and
|
|
18
|
+
# a check run before it would have passed a name that is not the one
|
|
19
|
+
# handed out. Digits always come from SecureRandom, never a counter, so
|
|
20
|
+
# the next name is not guessable from the last.
|
|
21
|
+
module Generator
|
|
22
|
+
# Draws per batch. Every shape, fallback and retry fits inside it.
|
|
23
|
+
DRAWS = 20
|
|
24
|
+
|
|
25
|
+
# Local parts that say nothing about the person: from
|
|
26
|
+
# info@acme.com the domain label is the better seed.
|
|
27
|
+
GENERIC_LOCAL_PARTS = %w[i me info support admin webmaster hello mail office contact team].freeze
|
|
28
|
+
|
|
29
|
+
# Below this maximum length `adjective_noun_42` cannot fit, so the
|
|
30
|
+
# compact shapes take over (as they do with no separators at all).
|
|
31
|
+
COMPACT_BELOW = 10
|
|
32
|
+
|
|
33
|
+
# How long a random-letters fallback is, within the length rule.
|
|
34
|
+
RANDOM_LETTERS = 8
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Up to DRAWS distinct names the rules allow, in the order drawn.
|
|
38
|
+
#
|
|
39
|
+
# Generator.candidates(rules: rules) # the default shapes
|
|
40
|
+
# Generator.candidates(from: "José García", rules: rules)
|
|
41
|
+
#
|
|
42
|
+
# With a seed: the cleaned seed, the seed with two random digits (three
|
|
43
|
+
# times) and with three (twice), then default shapes for the rest of
|
|
44
|
+
# the batch.
|
|
45
|
+
# The default shapes after a seed count their own attempts
|
|
46
|
+
# from 1, so they start at two digits as an unseeded batch does.
|
|
47
|
+
def candidates(rules:, from: nil)
|
|
48
|
+
base = from.nil? ? "" : clean(from, rules)
|
|
49
|
+
draws = base.empty? ? [] : seeded(base, rules)
|
|
50
|
+
draws += Array.new(DRAWS - draws.size) { |index| shape(index + 1, rules) }
|
|
51
|
+
allowed(draws, rules)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One draw of the default shape, or of config.generator when the app
|
|
55
|
+
# set one (called with the attempt number, 1 up). nil when the draw
|
|
56
|
+
# cannot fit the length rule; never checked here.
|
|
57
|
+
def shape(attempt, rules)
|
|
58
|
+
callable = rules.generator.callable
|
|
59
|
+
return callable.call(attempt).to_s if callable
|
|
60
|
+
|
|
61
|
+
compact?(rules) ? compact_shape(attempt, rules) : default_shape(attempt, rules)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A seed as a name: the useful half of an email,
|
|
65
|
+
# normalised, transliterated to ASCII, with every run of characters the
|
|
66
|
+
# rules do not allow turned into the first separator, runs of
|
|
67
|
+
# separators collapsed and the edges trimmed. May be "".
|
|
68
|
+
def clean(seed, rules)
|
|
69
|
+
text = prepare(seed, rules)
|
|
70
|
+
separators = rules.separators
|
|
71
|
+
allowed = "#{rules.lowercase? ? 'a-z' : 'A-Za-z'}0-9#{Regexp.escape(separators.join)}"
|
|
72
|
+
text = text.gsub(/[^#{allowed}]+/, separators.first.to_s)
|
|
73
|
+
return text if separators.empty?
|
|
74
|
+
|
|
75
|
+
run = "[#{Regexp.escape(separators.join)}]"
|
|
76
|
+
text.gsub(/#{run}{2,}/) { |separators_run| separators_run[0] }.gsub(/\A#{run}+|#{run}+\z/, "")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The words of a seed, whatever the separators: "Javi R" → javi, r.
|
|
80
|
+
def words(seed, rules)
|
|
81
|
+
prepare(seed, rules).split(/[^A-Za-z0-9]+/).reject(&:empty?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `base` cut to at most `budget` characters, at a separator that falls
|
|
85
|
+
# inside the budget (`pedro_gonzales_armillon` → `pedro_gonzales`).
|
|
86
|
+
# nil when not even the first word fits: a name is never cut mid-word
|
|
87
|
+
# (`maximilianus` is not `maximil`), so the batch moves on to its next
|
|
88
|
+
# candidate instead.
|
|
89
|
+
def fit(base, budget, rules)
|
|
90
|
+
return if budget < 1 || base.empty?
|
|
91
|
+
return base if base.length <= budget
|
|
92
|
+
|
|
93
|
+
boundary = rules.separators.empty? ? nil : base.rindex(Regexp.union(rules.separators), budget)
|
|
94
|
+
base[0, boundary] if boundary&.positive?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# `base` + separator + random digits from `range`, the base shortened
|
|
98
|
+
# first so the whole fits the maximum length.
|
|
99
|
+
def with_digits(base, range, rules)
|
|
100
|
+
separator = rules.separators.first.to_s
|
|
101
|
+
digits = SecureRandom.random_number(range).to_s
|
|
102
|
+
stem = fit(base, rules.length.max - separator.length - digits.length, rules)
|
|
103
|
+
"#{stem}#{separator}#{digits}" if stem
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def seeded(base, rules)
|
|
109
|
+
[fit(base, rules.length.max, rules)] +
|
|
110
|
+
Array.new(3) { with_digits(base, 10..99, rules) } +
|
|
111
|
+
Array.new(2) { with_digits(base, 100..999, rules) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# adjective + separator + noun + separator + digits, dropping the
|
|
115
|
+
# adjective when the pair does not fit the budget the digits leave.
|
|
116
|
+
def default_shape(attempt, rules)
|
|
117
|
+
separator = rules.separators.first
|
|
118
|
+
digits = suffix(attempt)
|
|
119
|
+
base = default_base(rules, rules.length.max - separator.length - digits.length)
|
|
120
|
+
"#{base}#{separator}#{digits}" if base
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def default_base(rules, budget)
|
|
124
|
+
noun = pick(rules.generator.nouns)
|
|
125
|
+
return unless noun
|
|
126
|
+
|
|
127
|
+
pair = [pick(rules.generator.adjectives), noun].compact.join(rules.separators.first)
|
|
128
|
+
[pair, noun].find { |words| words.length <= budget }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# For a short maximum or no separators: noun + digits, then the noun
|
|
132
|
+
# cut to the budget (`ott42`), then random letters when not even one
|
|
133
|
+
# letter fits beside the digits (`length: 2..2`).
|
|
134
|
+
def compact_shape(attempt, rules)
|
|
135
|
+
digits = suffix(attempt)
|
|
136
|
+
noun = pick(rules.generator.nouns)
|
|
137
|
+
budget = rules.length.max - digits.length
|
|
138
|
+
name = "#{noun[0, budget]}#{digits}" if noun && budget >= 1
|
|
139
|
+
name && name.length >= rules.length.min ? name : random_letters(rules)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def compact?(rules)
|
|
143
|
+
rules.separators.empty? || rules.length.max < COMPACT_BELOW
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Two digits for the first three draws, three for the next two, four
|
|
147
|
+
# for the next three, then six hex characters: short names
|
|
148
|
+
# first, and more room as the obvious ones turn out taken.
|
|
149
|
+
def suffix(attempt)
|
|
150
|
+
case attempt
|
|
151
|
+
when 1..3 then SecureRandom.random_number(10..99).to_s
|
|
152
|
+
when 4..5 then SecureRandom.random_number(100..999).to_s
|
|
153
|
+
when 6..8 then SecureRandom.random_number(1000..9999).to_s
|
|
154
|
+
else SecureRandom.hex(3)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def random_letters(rules)
|
|
159
|
+
length = RANDOM_LETTERS.clamp(rules.length.min, rules.length.max)
|
|
160
|
+
Array.new(length) { ("a".ord + SecureRandom.random_number(26)).chr }.join
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def pick(list)
|
|
164
|
+
list[SecureRandom.random_number(list.size)] if list.any?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Normalised, deduplicated and checked — the full check, last.
|
|
168
|
+
def allowed(draws, rules)
|
|
169
|
+
draws.each_with_object([]) do |draw, names|
|
|
170
|
+
next if draw.nil?
|
|
171
|
+
|
|
172
|
+
name = rules.normalize(draw)
|
|
173
|
+
names << name unless name.empty? || names.include?(name) || rules.failure_for(name)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Repaired into UTF-8 first, as the normaliser does: a UTF-16 or
|
|
178
|
+
# binary seed must not reach the String operations below.
|
|
179
|
+
def prepare(seed, rules)
|
|
180
|
+
text = Normalizer.utf8(seed)
|
|
181
|
+
text = email_seed(text) if text.include?("@")
|
|
182
|
+
text = transliterate(rules.normalize(text)).delete("?")
|
|
183
|
+
rules.lowercase? ? text.downcase : text
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The local part, without a +tag, unless it is generic or empty
|
|
187
|
+
# (`@javi` is a handle, not an address): then the domain's first label.
|
|
188
|
+
# The first label, so acme.co.uk gives `acme`; the +tag is dropped
|
|
189
|
+
# (`javi+news`).
|
|
190
|
+
def email_seed(text)
|
|
191
|
+
local, _, domain = text.rpartition("@")
|
|
192
|
+
local = local.split("+", 2).first.to_s.strip
|
|
193
|
+
return local unless local.empty? || GENERIC_LOCAL_PARTS.include?(local.downcase)
|
|
194
|
+
|
|
195
|
+
domain.strip.split(".").first.to_s
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# AnyAscii (gem `any_ascii`) when the app has it: it knows Cyrillic,
|
|
199
|
+
# Greek, CJK… Otherwise Active Support's transliteration, which knows
|
|
200
|
+
# Latin accents and writes `?` for the rest — dropped by the caller.
|
|
201
|
+
def transliterate(text)
|
|
202
|
+
defined?(::AnyAscii) ? ::AnyAscii.transliterate(text) : ActiveSupport::Inflector.transliterate(text)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|