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,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rules/options"
|
|
4
|
+
|
|
5
|
+
module Usernames
|
|
6
|
+
# The rules in force for one check: the configuration merged with one
|
|
7
|
+
# model's or one validator call's options, validated once and frozen.
|
|
8
|
+
# Build them with `Usernames.configuration.rules_for(**options)`, which
|
|
9
|
+
# caches them — every check after the first reuses the compiled regexes.
|
|
10
|
+
#
|
|
11
|
+
# The format rules are an ordered list of
|
|
12
|
+
#
|
|
13
|
+
# [reason, test, browser_source]
|
|
14
|
+
#
|
|
15
|
+
# where `test` is a Regexp (or a lambda) that MATCHES A NAME THAT FAILS,
|
|
16
|
+
# and the first failure wins. Server regexes anchor with \A and \z: `^` and
|
|
17
|
+
# `$` are LINE anchors in Ruby, which is how "validuser\nadmin" gets past
|
|
18
|
+
# the regex everyone copies from Stack Overflow. `browser_source` is the
|
|
19
|
+
# same rule as a JavaScript RegExp source (see #for_browser), or nil for a
|
|
20
|
+
# rule the browser does not hint.
|
|
21
|
+
class Rules
|
|
22
|
+
include Options
|
|
23
|
+
|
|
24
|
+
# Every code point Ruby's \p{Space} matches (Unicode White_Space), spelled
|
|
25
|
+
# out for JavaScript, whose \s is a slightly different set (it has U+FEFF
|
|
26
|
+
# and lacks U+0085).
|
|
27
|
+
WHITESPACE_SOURCE = '[\t-\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]'
|
|
28
|
+
|
|
29
|
+
# A character outside the allowed set that is a non-ASCII letter (or a
|
|
30
|
+
# combining mark, as in an accent left over from `İ`): the case for the
|
|
31
|
+
# friendlier :username_unicode message.
|
|
32
|
+
NON_ASCII_LETTER = /\A(?=[^\x00-\x7F])[\p{L}\p{M}]\z/
|
|
33
|
+
|
|
34
|
+
# Endings that read as a file when dots are enabled, on top of
|
|
35
|
+
# every extension Rails' Mime registry knows: Discourse's
|
|
36
|
+
# CONFUSING_EXTENSIONS, Gitea's reserved patterns, git, and what a web
|
|
37
|
+
# server would execute or serve as a secret.
|
|
38
|
+
FILE_EXTENSIONS = %w[
|
|
39
|
+
js json css htm html xml jpg jpeg png gif bmp ico tif tiff woff
|
|
40
|
+
keys gpg rss atom
|
|
41
|
+
git
|
|
42
|
+
php asp aspx jsp cgi pl py rb sh exe dll bak env
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# Every extension Rails' Mime registry knows when rules are built,
|
|
46
|
+
# including any the app registered in an initializer. None without
|
|
47
|
+
# Action Dispatch: then there is no Rails router to confuse. Read when
|
|
48
|
+
# the rules are compiled: the first check, or the first after a
|
|
49
|
+
# configuration change.
|
|
50
|
+
def self.rails_extensions
|
|
51
|
+
defined?(Mime::EXTENSION_LOOKUP) ? Mime::EXTENSION_LOOKUP.keys.map(&:to_s) : []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :format_checks
|
|
55
|
+
|
|
56
|
+
def initialize(**options)
|
|
57
|
+
assign(options)
|
|
58
|
+
@format_checks = build_format_checks
|
|
59
|
+
# Left unfrozen on purpose: the reserved and offensive matchers keep
|
|
60
|
+
# what they build from these rules here (see #compiled).
|
|
61
|
+
@compiled = {}
|
|
62
|
+
freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# What a content matcher built from these rules (a reserved set, the
|
|
66
|
+
# offensive exemptions), built on first use and kept for as long as the
|
|
67
|
+
# rules are: Configuration#rules_for caches the rules, so every check
|
|
68
|
+
# after the first reuses it. Two threads may build the same thing once
|
|
69
|
+
# each; both results are equal, so either may win. :nodoc:
|
|
70
|
+
def compiled(name)
|
|
71
|
+
@compiled.fetch(name) { @compiled[name] = yield }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Short enough to read in a console or a failing test: the lists can
|
|
75
|
+
# hold a thousand words.
|
|
76
|
+
def inspect
|
|
77
|
+
"#<#{self.class.name} length=#{length} separators=#{separators.inspect} case=#{@case.inspect} " \
|
|
78
|
+
"edge_separators=#{edge_separators} first_character=#{first_character.inspect} " \
|
|
79
|
+
"offensive.severity=#{offensive.severity.inspect}>"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def lowercase?
|
|
83
|
+
@case == :lowercase
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize(value)
|
|
87
|
+
Normalizer.call(value, @case)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# What two names are compared by. ASCII-only on purpose: it is exactly
|
|
91
|
+
# what PostgreSQL's lower() does under COLLATE "C", so Ruby and the
|
|
92
|
+
# database agree.
|
|
93
|
+
def comparison_key(canonical)
|
|
94
|
+
canonical.downcase(:ascii)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The first rule `value` breaks, as a Usernames::Failure, or nil. Applied
|
|
98
|
+
# to the value AS GIVEN — normalise first if that is what you mean.
|
|
99
|
+
# Structure before content: format, then reserved, then offensive.
|
|
100
|
+
def failure_for(value)
|
|
101
|
+
string = readable(value)
|
|
102
|
+
return failure(:username_characters) unless string
|
|
103
|
+
|
|
104
|
+
format_failure(string) || content_failure(string)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def format_failure(string)
|
|
108
|
+
# `===` is Ruby's "does this match": Regexp#=== and Proc#=== both answer it.
|
|
109
|
+
reason, = format_checks.find { |_reason, test, _source| test === string } # rubocop:disable Style/CaseEquality
|
|
110
|
+
failure(reason) if reason
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Hints for a form, as [{ rule:, pattern:, message: }] in the current
|
|
114
|
+
# locale. Advisory only: Ruby's strip and JavaScript's trim()
|
|
115
|
+
# disagree on NUL, U+FEFF and U+1680, so the browser approximates
|
|
116
|
+
# normalisation and the server stays the only authority. A pattern
|
|
117
|
+
# MATCHES a name that breaks the rule, and is written in the subset both
|
|
118
|
+
# engines read the same way — no \A, \z, lookbehind or \p{…}. With a
|
|
119
|
+
# `record` and `attribute`, the messages are that model's (Failure#message).
|
|
120
|
+
def for_browser(record = nil, attribute = nil)
|
|
121
|
+
format_checks.filter_map do |reason, _test, source|
|
|
122
|
+
{ rule: reason, pattern: source, message: failure(reason).message(record, attribute) } if source
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The Failure for a format rule, with the interpolations its message needs.
|
|
127
|
+
def failure(reason)
|
|
128
|
+
Failure.new(reason: reason, details: details_for(reason))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# Valid UTF-8, or nil when the value's own bytes are broken. The
|
|
134
|
+
# validator sees values exactly as assigned, and a regex over invalid
|
|
135
|
+
# bytes raises; such a value has characters that cannot be allowed.
|
|
136
|
+
def readable(value)
|
|
137
|
+
string = value.to_s
|
|
138
|
+
string.encode(Encoding::UTF_8) if string.valid_encoding?
|
|
139
|
+
rescue EncodingError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Reserved words are matched on the comparison key alone; the offensive
|
|
144
|
+
# matcher also reads the case the name was typed in (Offensive.verdict).
|
|
145
|
+
def content_failure(string)
|
|
146
|
+
if (rule = Reserved.verdict(comparison_key(string), self))
|
|
147
|
+
Failure.new(reason: :username_reserved, rule: rule)
|
|
148
|
+
elsif (rule = Offensive.verdict(string, self))
|
|
149
|
+
Failure.new(reason: :username_not_allowed, rule: rule)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def details_for(reason)
|
|
154
|
+
case reason
|
|
155
|
+
when :too_short then { count: length.min }
|
|
156
|
+
when :too_long then { count: length.max }
|
|
157
|
+
when :username_characters then characters_details
|
|
158
|
+
when :username_edges then { edge_separators_phrase: Phrase.any_separator(edge_set) }
|
|
159
|
+
else {}
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# `count` (how many separators) picks the sentence: one finishes the list
|
|
164
|
+
# ("…, numbers and underscores"), more continue it ("…, numbers,
|
|
165
|
+
# underscores and periods"). With none there is no list to finish, so the
|
|
166
|
+
# message is a different translation of the same error type — not a
|
|
167
|
+
# :zero plural, which pluralizers such as rails-i18n's Spanish one ignore.
|
|
168
|
+
# username_characters_without_separators is that translation: the same
|
|
169
|
+
# error type, only worded for no separators.
|
|
170
|
+
def characters_details
|
|
171
|
+
letters = Phrase.letters(@case)
|
|
172
|
+
return { message: :username_characters_without_separators, letters: letters } if separators.empty?
|
|
173
|
+
|
|
174
|
+
{ count: separators.size, letters: letters, separators_phrase: Phrase.separators(separators) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def build_format_checks
|
|
178
|
+
[
|
|
179
|
+
[:username_spaces, /\p{Space}/, WHITESPACE_SOURCE],
|
|
180
|
+
*character_checks,
|
|
181
|
+
[:too_short, ->(string) { string.length < length.min }, "^[\\s\\S]{0,#{length.min - 1}}$"],
|
|
182
|
+
[:too_long, ->(string) { string.length > length.max }, "[\\s\\S]{#{length.max + 1}}"],
|
|
183
|
+
*separator_checks,
|
|
184
|
+
*first_character_checks,
|
|
185
|
+
# Rule 7: a name with no letter in it reads as a number, whatever
|
|
186
|
+
# separators sit between the digits (`231640921`, `123_456`), and a
|
|
187
|
+
# number is an id, not a person. Separators alone never reach this
|
|
188
|
+
# rule: edges and runs refuse them first.
|
|
189
|
+
[:username_digits_only, /\A[^A-Za-z]+\z/, "^[^A-Za-z]+$"],
|
|
190
|
+
*extension_checks
|
|
191
|
+
].freeze
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Rule 2. Everything that is not an allowed character fails here —
|
|
195
|
+
# accents, emoji, invisible and bidi characters alike — with the
|
|
196
|
+
# friendlier :username_unicode message when every offender is a
|
|
197
|
+
# non-ASCII letter (`josé`, `иван`). The browser only hints the general
|
|
198
|
+
# rule; the refinement is the same failure in other words.
|
|
199
|
+
def character_checks
|
|
200
|
+
allowed = "#{lowercase? ? 'a-z' : 'A-Za-z'}0-9#{class_of(separators)}"
|
|
201
|
+
disallowed = /[^#{allowed}]/
|
|
202
|
+
only_letters = lambda do |string|
|
|
203
|
+
offenders = string.scan(disallowed)
|
|
204
|
+
offenders.any? && offenders.all?(NON_ASCII_LETTER)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
[[:username_unicode, only_letters, nil], [:username_characters, disallowed, "[^#{allowed}]"]]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Rules 4 and 5: no separator at an edge (never a dot, even with
|
|
211
|
+
# edge_separators — "thanks @javi." ends a sentence), no two in a row of
|
|
212
|
+
# any kind (`oliver.__-hjj`).
|
|
213
|
+
def separator_checks
|
|
214
|
+
checks = []
|
|
215
|
+
if edge_set.any?
|
|
216
|
+
edge = class_of(edge_set)
|
|
217
|
+
checks << [:username_edges, /\A[#{edge}]|[#{edge}]\z/, "^[#{edge}]|[#{edge}]$"]
|
|
218
|
+
end
|
|
219
|
+
if separators.any?
|
|
220
|
+
all = class_of(separators)
|
|
221
|
+
checks << [:username_consecutive, /[#{all}]{2}/, "[#{all}]{2}"]
|
|
222
|
+
end
|
|
223
|
+
checks
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Rule 6. With :alphanumeric any letter or digit may come first, and a
|
|
227
|
+
# separator first is rule 4's business, so there is nothing to check.
|
|
228
|
+
# With :letter the first character must be a letter even when
|
|
229
|
+
# edge_separators allows `_javi`: the stricter reading is the one that
|
|
230
|
+
# matches "must start with a letter".
|
|
231
|
+
def first_character_checks
|
|
232
|
+
return [] unless first_character == :letter
|
|
233
|
+
|
|
234
|
+
letters = lowercase? ? "a-z" : "A-Za-z"
|
|
235
|
+
[[:username_first_character, /\A[^#{letters}]/, "^[^#{letters}]"]]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Rule 8, only when dots are allowed: `/@john.json` confuses
|
|
239
|
+
# routers, CDNs and crawlers. Not a browser hint: the hints stop at rule 7.
|
|
240
|
+
def extension_checks
|
|
241
|
+
return [] unless separators.include?(".")
|
|
242
|
+
|
|
243
|
+
extensions = (Rules.rails_extensions + FILE_EXTENSIONS).uniq.map { |extension| Regexp.escape(extension) }
|
|
244
|
+
[[:username_extension, /\.(?:#{extensions.join('|')})\z/i, nil]]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def edge_set
|
|
248
|
+
edge_separators ? separators & ["."] : separators
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The body of a character class holding exactly these separators, written
|
|
252
|
+
# the same way for Ruby and JavaScript (`\.` and `\-` mean themselves in both).
|
|
253
|
+
def class_of(characters)
|
|
254
|
+
characters.map { |character| Regexp.escape(character) }.join
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# Whether an ActiveRecord::RecordNotUnique came from the username's own
|
|
5
|
+
# unique index — the one that decides who has a name — or from another one.
|
|
6
|
+
#
|
|
7
|
+
# A duplicate email raises exactly the same exception class as a duplicate
|
|
8
|
+
# username, and so does an app's own unique index that merely mentions the
|
|
9
|
+
# column (`substr(username, 1, 3)`, `(tenant_id, username)`). Redrawing a
|
|
10
|
+
# generated name over one of those would loop for nothing, and reporting it
|
|
11
|
+
# as "username has already been taken" would be a lie that hides a real
|
|
12
|
+
# error — so the race wrapper asks this first, and anything that is not the
|
|
13
|
+
# username's identity index propagates untouched.
|
|
14
|
+
#
|
|
15
|
+
# Each adapter names the culprit differently:
|
|
16
|
+
#
|
|
17
|
+
# PostgreSQL the constraint (and its table and schema), from the server's error diagnostics
|
|
18
|
+
# MySQL Duplicate entry 'javi' for key 'users.index_users_on_username'
|
|
19
|
+
# SQLite UNIQUE constraint failed: users.username
|
|
20
|
+
# UNIQUE constraint failed: index 'index_users_on_lower_username'
|
|
21
|
+
#
|
|
22
|
+
# The identity index is the one the gem names itself, the app's configured
|
|
23
|
+
# `unique_index`, or any index that passes the comparison contract for the
|
|
24
|
+
# column (Comparison::IndexContract). SQLite's column form names no index,
|
|
25
|
+
# so there the column must be the only one named, and the table must have
|
|
26
|
+
# an identity index on exactly it. When the table has more than one unique
|
|
27
|
+
# index on that column alone (an app's own NOCASE one, say), the message
|
|
28
|
+
# could be either's: then it is ours only when `conflict` — one query
|
|
29
|
+
# under the gem's comparison — finds another row with the name.
|
|
30
|
+
module UniqueViolation
|
|
31
|
+
POSTGRESQL_CONSTRAINT = /unique constraint "(?<name>[^"]+)"/
|
|
32
|
+
MYSQL_KEY = /for key '(?:(?<table>[^'.]+)\.)?(?<name>[^']+)'/
|
|
33
|
+
SQLITE_COLUMNS = /UNIQUE constraint failed: (?<columns>[^:']+?)\s*\z/
|
|
34
|
+
SQLITE_INDEX = /UNIQUE constraint failed: index '(?<name>[^']+)'/
|
|
35
|
+
|
|
36
|
+
# What a violation is matched against: the table as the model names it
|
|
37
|
+
# (possibly schema-qualified), its bare name, the column, the configured
|
|
38
|
+
# index, the case mode, the connection to read indexes back with and the
|
|
39
|
+
# conflict query. :nodoc:
|
|
40
|
+
Target = ::Data.define(:table, :bare_table, :column, :index, :mode, :connection, :conflict)
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# UniqueViolation.of?(error, table: "users", column: :username, rules: rules)
|
|
44
|
+
#
|
|
45
|
+
# `rules` are the column's Usernames::Rules: its case mode, and
|
|
46
|
+
# config.unique_index (an index the generator did not create).
|
|
47
|
+
# `connection` is the model's own, which reads its indexes back (an app
|
|
48
|
+
# may have several databases); without one, Active Record's default
|
|
49
|
+
# connection is used. `conflict` answers whether another row has the
|
|
50
|
+
# record's name under the gem's comparison; without one, an ambiguous
|
|
51
|
+
# SQLite message is not ours.
|
|
52
|
+
# rubocop:disable-next Metrics/ParameterLists -- each is one fact about the save, all by keyword
|
|
53
|
+
def of?(error, table:, column:, rules: Usernames.configuration.rules_for, connection: nil, conflict: nil)
|
|
54
|
+
target = Target.new(table: table.to_s, bare_table: table.to_s.split(".").last, column: column.to_s,
|
|
55
|
+
index: rules.unique_index&.to_s, mode: rules.case, connection: connection,
|
|
56
|
+
conflict: conflict)
|
|
57
|
+
name = postgresql_constraint(error)
|
|
58
|
+
return postgresql_target?(error, target) && identity?(name, target) if name
|
|
59
|
+
|
|
60
|
+
named_in_message?(error.message.to_s, target)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# SQLite names the columns, or the index of an expression; MySQL the key.
|
|
66
|
+
def named_in_message?(message, target)
|
|
67
|
+
if (match = SQLITE_COLUMNS.match(message))
|
|
68
|
+
match[:columns].split(",").map(&:strip) == ["#{target.bare_table}.#{target.column}"] &&
|
|
69
|
+
on_the_column_alone?(target)
|
|
70
|
+
elsif (match = SQLITE_INDEX.match(message) || MYSQL_KEY.match(message))
|
|
71
|
+
same_table?(match, target) && identity?(match[:name], target)
|
|
72
|
+
else
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The constraint name from PostgreSQL's diagnostics (PG_DIAG_CONSTRAINT_NAME),
|
|
78
|
+
# or from the message when the error carries no result.
|
|
79
|
+
def postgresql_constraint(error)
|
|
80
|
+
field = diagnostic(error, :PG_DIAG_CONSTRAINT_NAME)
|
|
81
|
+
return field if field
|
|
82
|
+
|
|
83
|
+
POSTGRESQL_CONSTRAINT.match(error.message.to_s)&.[](:name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A violation reported on another table (a trigger writing elsewhere),
|
|
87
|
+
# or on a same-named table in another schema, is not ours, whatever its
|
|
88
|
+
# index is called.
|
|
89
|
+
def postgresql_target?(error, target)
|
|
90
|
+
table = diagnostic(error, :PG_DIAG_TABLE_NAME)
|
|
91
|
+
schema = diagnostic(error, :PG_DIAG_SCHEMA_NAME)
|
|
92
|
+
wanted_schema = target.table.split(".")[-2]
|
|
93
|
+
(table.nil? || table == target.bare_table) && (schema.nil? || wanted_schema.nil? || schema == wanted_schema)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def diagnostic(error, field)
|
|
97
|
+
result = error.cause.try(:result)
|
|
98
|
+
return unless result && defined?(::PG) && ::PG.const_defined?(field)
|
|
99
|
+
|
|
100
|
+
result.error_field(::PG.const_get(field))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A MySQL key names its table only from 8.0.19 on; SQLite's index form never does.
|
|
104
|
+
def same_table?(match, target)
|
|
105
|
+
reported = match[:table] if match.names.include?("table")
|
|
106
|
+
reported.nil? || reported == target.bare_table
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def identity?(name, target)
|
|
110
|
+
return true if known_name?(name, target)
|
|
111
|
+
|
|
112
|
+
reading(target) do |connection, contract|
|
|
113
|
+
found = connection.indexes(target.table).find { |index| index.name == name }
|
|
114
|
+
found ? contract.satisfied_by?(found) : false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# SQLite's "UNIQUE constraint failed: users.username": some unique
|
|
119
|
+
# index on exactly that column was violated. It is ours when one such
|
|
120
|
+
# index is the identity index and either it is the only one or another
|
|
121
|
+
# row really has the name.
|
|
122
|
+
def on_the_column_alone?(target)
|
|
123
|
+
reading(target) do |connection, contract|
|
|
124
|
+
alone = connection.indexes(target.table).select { |index| alone_on_the_column?(index, target) }
|
|
125
|
+
next false unless alone.any? { |index| known_name?(index.name, target) || contract.satisfied_by?(index) }
|
|
126
|
+
|
|
127
|
+
[alone.size, sqlite_unique_indexes_on(connection, contract, target)].max <= 1 || target.conflict&.call == true
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def alone_on_the_column?(index, target)
|
|
132
|
+
index.unique && Array(index.columns) == [target.column]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Every unique index on the column alone, as SQLite itself lists them:
|
|
136
|
+
# Active Record leaves out those a UNIQUE column constraint creates.
|
|
137
|
+
def sqlite_unique_indexes_on(connection, contract, target)
|
|
138
|
+
return 0 unless contract.adapter == :sqlite
|
|
139
|
+
|
|
140
|
+
names = connection.select_values("SELECT name FROM pragma_index_list(#{connection.quote(target.bare_table)}) " \
|
|
141
|
+
"WHERE \"unique\" = 1")
|
|
142
|
+
names.count do |name|
|
|
143
|
+
connection.select_values("SELECT name FROM pragma_index_info(#{connection.quote(name)})") == [target.column]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def known_name?(name, target)
|
|
148
|
+
name == target.index || gem_index_names(target).include?(name)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def gem_index_names(target)
|
|
152
|
+
%i[lowercase preserve].map do |mode|
|
|
153
|
+
Comparison.index_definition(adapter: :sqlite, table: target.table, column: target.column, mode: mode)[:name]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Reading indexes back can fail (a permissions quirk, a dropped
|
|
158
|
+
# connection); then only the names above are recognised, and the error
|
|
159
|
+
# propagates.
|
|
160
|
+
def reading(target)
|
|
161
|
+
with_connection(target) do |connection|
|
|
162
|
+
yield connection, Comparison::IndexContract.new(connection, table: target.table, column: target.column,
|
|
163
|
+
mode: target.mode)
|
|
164
|
+
end
|
|
165
|
+
rescue StandardError
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def with_connection(target, &)
|
|
170
|
+
return yield(target.connection) if target.connection
|
|
171
|
+
|
|
172
|
+
ActiveRecord::Base.with_connection(&)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
|
|
5
|
+
module ActiveModel
|
|
6
|
+
module Validations
|
|
7
|
+
# validates :username, username: true
|
|
8
|
+
# validates :username, username: { length: 2..15, separators: %w[_ .] }
|
|
9
|
+
# validates :username, username: { message: "isn't available" }
|
|
10
|
+
#
|
|
11
|
+
# The rules only — format, reserved, offensive — with no database: you
|
|
12
|
+
# keep your own uniqueness validation and index (has_username brings both).
|
|
13
|
+
#
|
|
14
|
+
# It validates the value AS SUPPLIED. In lowercase mode `Javi`
|
|
15
|
+
# fails "can only contain lowercase letters…", because `Javi` is not what
|
|
16
|
+
# would be stored: validate what you store. Pair it with
|
|
17
|
+
#
|
|
18
|
+
# normalizes :username, with: Usernames.normalizer
|
|
19
|
+
#
|
|
20
|
+
# and typed ` @Javi ` becomes `javi` before this ever sees it.
|
|
21
|
+
#
|
|
22
|
+
# One error at most, for the first rule that fails, so a form never shows
|
|
23
|
+
# a stack of complaints about one short string. nil is skipped (presence
|
|
24
|
+
# is a separate question: `validates :username, presence: true`).
|
|
25
|
+
class UsernameValidator < EachValidator
|
|
26
|
+
# Options Active Model itself understands; everything else is a rule.
|
|
27
|
+
VALIDATION_OPTIONS = %i[message strict if unless on allow_nil allow_blank class].freeze
|
|
28
|
+
|
|
29
|
+
# Bad rule options fail when the model is defined, not at the first sign-up.
|
|
30
|
+
def check_validity!
|
|
31
|
+
rules
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_each(record, attribute, value)
|
|
35
|
+
return if value.nil?
|
|
36
|
+
|
|
37
|
+
failure = rules.failure_for(value)
|
|
38
|
+
return unless failure
|
|
39
|
+
|
|
40
|
+
# The details first and `message:` last, so a message passed to the
|
|
41
|
+
# validator replaces every rule's message.
|
|
42
|
+
record.errors.add(attribute, failure.reason, **failure.details, **options.slice(:message, :strict))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Resolved on every validation (and cached by the configuration), so a
|
|
48
|
+
# configuration changed after the model was loaded still applies.
|
|
49
|
+
def rules
|
|
50
|
+
Usernames.configuration.rules_for(**options.except(*VALIDATION_OPTIONS))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module HelperMethods
|
|
55
|
+
# validates_username_of :username, length: 2..15
|
|
56
|
+
def validates_username_of(*attr_names)
|
|
57
|
+
validates_with UsernameValidator, _merge_attributes(attr_names)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# The generator's default vocabulary, from data/words/: short,
|
|
5
|
+
# everyday, reviewed words that no adjective + animal pair, with any
|
|
6
|
+
# digits, turns into something the gem itself would refuse — the test
|
|
7
|
+
# suite checks every pair. `config.generator.adjectives` / `.nouns` start
|
|
8
|
+
# from these.
|
|
9
|
+
module Words
|
|
10
|
+
ADJECTIVES = Data.adjectives
|
|
11
|
+
ANIMALS = Data.nouns
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/usernames.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_model"
|
|
5
|
+
require "i18n"
|
|
6
|
+
|
|
7
|
+
require_relative "usernames/errors"
|
|
8
|
+
require_relative "usernames/version"
|
|
9
|
+
require_relative "usernames/data"
|
|
10
|
+
|
|
11
|
+
# Every bundled list, read now: a broken install fails here, naming the file.
|
|
12
|
+
Usernames::Data.load!
|
|
13
|
+
|
|
14
|
+
require_relative "usernames/normalizer"
|
|
15
|
+
require_relative "usernames/phrase"
|
|
16
|
+
require_relative "usernames/result"
|
|
17
|
+
require_relative "usernames/reserved"
|
|
18
|
+
require_relative "usernames/offensive"
|
|
19
|
+
require_relative "usernames/words"
|
|
20
|
+
require_relative "usernames/generator"
|
|
21
|
+
require_relative "usernames/candidates"
|
|
22
|
+
require_relative "usernames/rules"
|
|
23
|
+
require_relative "usernames/configuration"
|
|
24
|
+
require_relative "usernames/validator"
|
|
25
|
+
require_relative "usernames/comparison"
|
|
26
|
+
require_relative "usernames/unique_violation"
|
|
27
|
+
require_relative "usernames/model"
|
|
28
|
+
require_relative "usernames/engine" if defined?(Rails::Engine)
|
|
29
|
+
|
|
30
|
+
module Usernames
|
|
31
|
+
# Deliberately permissive: what may ROUTE is not what may be
|
|
32
|
+
# REGISTERED. A name grandfathered in under older rules still has a page,
|
|
33
|
+
# the validator decides what can be registered, and the database decides
|
|
34
|
+
# what exists. No anchors: Rails refuses them in route constraints.
|
|
35
|
+
ROUTE = /[A-Za-z0-9._-]+/
|
|
36
|
+
|
|
37
|
+
LOCALES = File.expand_path("../config/locales/*.yml", __dir__)
|
|
38
|
+
|
|
39
|
+
# Under Rails the engine loads config/locales (before the app's, so the
|
|
40
|
+
# app's keys win). Without Rails nothing would, and every message would
|
|
41
|
+
# read "Translation missing".
|
|
42
|
+
I18n.load_path |= Dir[LOCALES] unless defined?(Rails::Engine)
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
attr_writer :configuration
|
|
46
|
+
|
|
47
|
+
# Lazily initialized, so every function works without an initializer.
|
|
48
|
+
def configuration
|
|
49
|
+
@configuration ||= Configuration.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Validates at the end of the block, so a bad value fails on the line
|
|
53
|
+
# that boots the app, naming itself.
|
|
54
|
+
def configure
|
|
55
|
+
yield configuration
|
|
56
|
+
configuration.validate!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Usernames.check("Ad_min")
|
|
60
|
+
# # => #<data Usernames::Result ok?=false, reason=:username_reserved, rule=:impersonation, canonical="ad_min", …>
|
|
61
|
+
#
|
|
62
|
+
# Normalises, then applies every rule in order; the first failure wins.
|
|
63
|
+
# Options override the configuration for this call (`length: 2..15`).
|
|
64
|
+
def check(value, **)
|
|
65
|
+
rules = configuration.rules_for(**)
|
|
66
|
+
canonical = rules.normalize(value)
|
|
67
|
+
Result.for(canonical, rules.failure_for(canonical))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Eligibility only: whether the name may be registered at all. Says
|
|
71
|
+
# nothing about whether somebody already has it.
|
|
72
|
+
def allowed?(value, **)
|
|
73
|
+
check(value, **).ok?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Usernames.normalize(" @Javi_R ") # => "javi_r"
|
|
77
|
+
def normalize(value, **)
|
|
78
|
+
configuration.rules_for(**).normalize(value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# What two names are compared by: Usernames.comparison_key("Javi") # => "javi".
|
|
82
|
+
# Normalises first, which changes nothing in a name that already is
|
|
83
|
+
# canonical (normalisation is idempotent), so it accepts any input.
|
|
84
|
+
def comparison_key(value, **)
|
|
85
|
+
rules = configuration.rules_for(**)
|
|
86
|
+
rules.comparison_key(rules.normalize(value))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# normalizes :username, with: Usernames.normalizer
|
|
90
|
+
#
|
|
91
|
+
# A blank result is stored as nil, so " " never becomes an empty
|
|
92
|
+
# username that a unique index would let only one person have. The
|
|
93
|
+
# options are checked now, when the model is defined; the configuration
|
|
94
|
+
# is read on every call, so it may still change after that.
|
|
95
|
+
def normalizer(**options)
|
|
96
|
+
configuration.rules_for(**options)
|
|
97
|
+
|
|
98
|
+
lambda do |value|
|
|
99
|
+
normalized = normalize(value, **options)
|
|
100
|
+
normalized unless normalized.empty?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# get "@:username", to: "profiles#show", format: false, constraints: { username: Usernames.route }
|
|
105
|
+
def route
|
|
106
|
+
ROUTE
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# [{ rule:, pattern:, message: }] for live hints in a form.
|
|
110
|
+
def rules_for_browser(**)
|
|
111
|
+
configuration.rules_for(**).for_browser
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Usernames.generate # => "quiet_otter_42"
|
|
115
|
+
# Usernames.generate(from: "José García") # => "jose_garcia"
|
|
116
|
+
#
|
|
117
|
+
# A name the rules allow. Eligibility only, like check: whether
|
|
118
|
+
# somebody already has it is for the database to say — has_username's
|
|
119
|
+
# generate_username takes the whole batch there in one query.
|
|
120
|
+
# Raises NoFreeNameError when the rules leave nothing to draw
|
|
121
|
+
# (a word list emptied, a minimum length no shape reaches) rather than
|
|
122
|
+
# returning nil, which a model would store as a missing username.
|
|
123
|
+
def generate(from: nil, **)
|
|
124
|
+
name = Generator.candidates(from: from, rules: configuration.rules_for(**)).first
|
|
125
|
+
return name if name
|
|
126
|
+
|
|
127
|
+
raise NoFreeNameError, "no name these rules allow could be generated; check length, separators and the generator's word lists"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Usernames.candidates_for("Javi R", count: 5) # => ["javi_r", "javir", "javi_r_42", …]
|
|
131
|
+
#
|
|
132
|
+
# Up to 3 × count allowed names, ordered, deduplicated. May be
|
|
133
|
+
# fewer, never raises for running out.
|
|
134
|
+
def candidates_for(seed, count:, **)
|
|
135
|
+
Candidates.for(seed, count: count, rules: configuration.rules_for(**))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|