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,115 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Live hints and a "is this name free?" check for <%= model_name %>#<%= column %>.
|
|
4
|
+
// Generated by `rails generate usernames:availability`. It's yours: edit it.
|
|
5
|
+
// app/views/usernames/_field.html.erb wires it up:
|
|
6
|
+
//
|
|
7
|
+
// <div data-controller="username-check"
|
|
8
|
+
// data-username-check-url-value="/usernames/availability"
|
|
9
|
+
// data-username-check-param-value="<%= column %>"
|
|
10
|
+
// data-username-check-rules-value="[…<%= model_name %>.<%= column %>_rules_for_browser…]">
|
|
11
|
+
// <input data-username-check-target="input" data-action="input->username-check#check">
|
|
12
|
+
// <p data-username-check-target="hint"></p>
|
|
13
|
+
// <p data-username-check-target="status"></p>
|
|
14
|
+
// </div>
|
|
15
|
+
//
|
|
16
|
+
// Everything here is a HINT. The browser only approximates the server's
|
|
17
|
+
// normalisation (Ruby's strip and JavaScript's trim() disagree on NUL, U+FEFF
|
|
18
|
+
// and U+1680), so nothing here blocks submission: no setCustomValidity, no
|
|
19
|
+
// pattern attribute. The model's validations and the unique index decide.
|
|
20
|
+
export default class extends Controller {
|
|
21
|
+
static targets = ["input", "hint", "status"]
|
|
22
|
+
static values = {
|
|
23
|
+
url: String,
|
|
24
|
+
// The query parameter the endpoint reads: GET /usernames/availability?<%= column %>=…
|
|
25
|
+
param: { type: String, default: "<%= column %>" },
|
|
26
|
+
rules: Array,
|
|
27
|
+
lowercase: { type: Boolean, default: true },
|
|
28
|
+
delay: { type: Number, default: 300 }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
connect() {
|
|
32
|
+
this.patterns = this.rulesValue.map((rule) => ({ ...rule, regexp: this.compile(rule.pattern) }))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
disconnect() {
|
|
36
|
+
this.cancel()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// On every keystroke: the first rule the name breaks, at once; whether it
|
|
40
|
+
// is free, once typing pauses.
|
|
41
|
+
check() {
|
|
42
|
+
const name = this.normalize(this.inputTarget.value)
|
|
43
|
+
this.current = name
|
|
44
|
+
this.cancel()
|
|
45
|
+
|
|
46
|
+
const broken = this.firstBrokenRule(name)
|
|
47
|
+
this.write(this.hasHintTarget && this.hintTarget, broken ? broken.message : "")
|
|
48
|
+
this.render(null)
|
|
49
|
+
// Only names that could be registered are worth a request.
|
|
50
|
+
if (name === "" || broken) return
|
|
51
|
+
|
|
52
|
+
this.timer = setTimeout(() => this.ask(name), this.delayValue)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async ask(name) {
|
|
56
|
+
this.request = new AbortController()
|
|
57
|
+
const url = new URL(this.urlValue, window.location.href)
|
|
58
|
+
url.searchParams.set(this.paramValue, name)
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(url, {
|
|
62
|
+
headers: { Accept: "application/json" },
|
|
63
|
+
signal: this.request.signal,
|
|
64
|
+
credentials: "same-origin"
|
|
65
|
+
})
|
|
66
|
+
if (!response.ok) return // rate-limited or switched off: say nothing rather than guess
|
|
67
|
+
|
|
68
|
+
const answer = await response.json()
|
|
69
|
+
// A slow answer for something typed earlier is not an answer for this.
|
|
70
|
+
if (name === this.current) this.render(answer)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.name !== "AbortError") throw error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The server's normalisation, approximately: NFKC, trim,
|
|
77
|
+
// lowercase (unless case is kept), one leading @ before a letter or digit.
|
|
78
|
+
normalize(value) {
|
|
79
|
+
let name = value.normalize("NFKC").trim()
|
|
80
|
+
if (this.lowercaseValue) name = name.toLowerCase().normalize("NFKC")
|
|
81
|
+
return name.replace(this.lowercaseValue ? /^@(?=[a-z0-9])/ : /^@(?=[A-Za-z0-9])/, "")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Each pattern matches a name that BREAKS its rule; the first one wins.
|
|
85
|
+
firstBrokenRule(name) {
|
|
86
|
+
if (name === "") return null
|
|
87
|
+
return this.patterns.find(({ regexp }) => regexp && regexp.test(name)) || null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// With the u flag a length counts code points, as Ruby does.
|
|
91
|
+
compile(pattern) {
|
|
92
|
+
try {
|
|
93
|
+
return new RegExp(pattern, "u")
|
|
94
|
+
} catch {
|
|
95
|
+
try { return new RegExp(pattern) } catch { return null }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
render(answer) {
|
|
100
|
+
if (!this.hasStatusTarget) return
|
|
101
|
+
|
|
102
|
+
this.statusTarget.dataset.state = answer ? answer.state : ""
|
|
103
|
+
this.write(this.statusTarget, answer && answer.message ? answer.message : "")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
write(element, text) {
|
|
107
|
+
if (element) element.textContent = text
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
cancel() {
|
|
111
|
+
clearTimeout(this.timer)
|
|
112
|
+
if (this.request) this.request.abort()
|
|
113
|
+
this.request = null
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Every line below shows the default: uncomment one to change it.
|
|
4
|
+
# The README documents the less common options (first_character,
|
|
5
|
+
# reserve_routes, app_name, offensive.adapters, on_adapter_failure, generator,
|
|
6
|
+
# unique_index). Every option is also accepted per model:
|
|
7
|
+
# has_username :handle, length: 2..24, case: :preserve
|
|
8
|
+
Usernames.configure do |config|
|
|
9
|
+
# How long a username may be, in characters.
|
|
10
|
+
# config.length = 3..30
|
|
11
|
+
|
|
12
|
+
# Which separators a name may contain.
|
|
13
|
+
# %w[_ .] is Instagram-style (and adds the rule that a name can't end like a
|
|
14
|
+
# file: .json, .png…) · %w[-] is GitHub- and subdomain-style.
|
|
15
|
+
# config.separators = %w[_]
|
|
16
|
+
|
|
17
|
+
# true allows _javi_ (a dot is never allowed at an edge).
|
|
18
|
+
# config.edge_separators = false
|
|
19
|
+
|
|
20
|
+
# :preserve keeps the case people type (CoolCat) and still compares without
|
|
21
|
+
# it (nobody else can be coolcat). Decide before you migrate: the unique
|
|
22
|
+
# index is built for one mode.
|
|
23
|
+
# config.case = :lowercase
|
|
24
|
+
|
|
25
|
+
# Reserved names. `+=` adds to the bundled lists; `=` replaces them.
|
|
26
|
+
# config.reserved += %w[] # exact: nobody can be exactly this
|
|
27
|
+
# config.forbidden += %w[] # impersonation matching, like admin (ad.min, adm1n, admins)
|
|
28
|
+
# config.allowed += %w[] # exceptions to the reserved checks
|
|
29
|
+
|
|
30
|
+
# Offensive words, in severity tiers per language.
|
|
31
|
+
# config.offensive.severity = :strong # :severe · :strong · :mild · :off
|
|
32
|
+
# config.offensive.languages = %i[en es]
|
|
33
|
+
# config.offensive.allowed += %w[] # exceptions to the offensive-word checks (a whole name or segment)
|
|
34
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :usernames do
|
|
4
|
+
desc "Read-only report on an existing username column: values to normalise, collisions, index and " \
|
|
5
|
+
"collation (rails usernames:audit, usernames:audit[Member,handle] or usernames:audit[Member,handle,preserve])"
|
|
6
|
+
task :audit, %i[model column case] => :environment do |_task, args|
|
|
7
|
+
require "usernames/audit"
|
|
8
|
+
|
|
9
|
+
args.with_defaults(model: "User", column: "username")
|
|
10
|
+
model = args[:model].safe_constantize
|
|
11
|
+
abort "[usernames] No model named #{args[:model]}. Usage: rails usernames:audit[User,username]" unless model
|
|
12
|
+
if args[:case] && !%w[lowercase preserve].include?(args[:case])
|
|
13
|
+
abort "[usernames] The case mode is lowercase or preserve (got #{args[:case]})."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Always exits 0: this is a report, not a gate. Read it, then fix what
|
|
17
|
+
# it lists in a migration you own. The case mode matters before
|
|
18
|
+
# has_username is declared (the README runs the audit first): without
|
|
19
|
+
# it, the configured mode is assumed.
|
|
20
|
+
Usernames::Audit.new(model, args[:column], mode: args[:case]).report($stdout)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
desc "Every reserved name in force and where it comes from (or usernames:reserved[Player,handle] " \
|
|
24
|
+
"for one model's rules)"
|
|
25
|
+
task :reserved, %i[model column] => :environment do |_task, args|
|
|
26
|
+
require "usernames/reserved/listing"
|
|
27
|
+
|
|
28
|
+
rules = Usernames.configuration.rules_for
|
|
29
|
+
if args[:model]
|
|
30
|
+
model = args[:model].safe_constantize
|
|
31
|
+
abort "[usernames] No model named #{args[:model]}." unless model
|
|
32
|
+
|
|
33
|
+
column = model.try(:_usernames)&.[]((args[:column] || "username").to_sym)
|
|
34
|
+
rules = column.rules if column
|
|
35
|
+
end
|
|
36
|
+
Usernames::Reserved::Listing.report($stdout, rules)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
module Adapters
|
|
5
|
+
# `config.offensive.adapters = [:moderate]` (the default): ask the
|
|
6
|
+
# moderate gem's offline wordlist too, when the app has that gem. Without
|
|
7
|
+
# it this is a no-op, not an error — the default must never make an
|
|
8
|
+
# app add a dependency.
|
|
9
|
+
#
|
|
10
|
+
# Moderate reads text, not handles, so the name is handed over the way a
|
|
11
|
+
# person would read it: camelCase split and separators as spaces
|
|
12
|
+
# (`BigBadWolf_x` → "Big Bad Wolf x"), which lets its word-boundary
|
|
13
|
+
# patterns see words.
|
|
14
|
+
module Moderate
|
|
15
|
+
class << self
|
|
16
|
+
def call(name)
|
|
17
|
+
return false unless available?
|
|
18
|
+
|
|
19
|
+
::Moderate::Filters::Wordlist.classify(text_for(name)).flagged?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def available?
|
|
23
|
+
defined?(::Moderate::Filters::Wordlist) ? true : false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def text_for(name)
|
|
27
|
+
name.to_s
|
|
28
|
+
.gsub(/([a-z\d])([A-Z])/, '\1 \2')
|
|
29
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1 \2')
|
|
30
|
+
.tr("_.-", " ")
|
|
31
|
+
.squeeze(" ")
|
|
32
|
+
.strip
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
class Audit
|
|
5
|
+
# How the audit writes: a section per subject, ✓ for what is fine, ✗ for
|
|
6
|
+
# a problem (counted), · for a note, and at most EXAMPLES rows per finding.
|
|
7
|
+
module Output
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def list(rows, clean, found, &)
|
|
11
|
+
return ok(clean) if rows.empty?
|
|
12
|
+
|
|
13
|
+
problem("#{format(found, rows.size)}:")
|
|
14
|
+
rows.first(EXAMPLES).each { |row| line " #{yield(*row)}" }
|
|
15
|
+
more(rows)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def more(rows)
|
|
19
|
+
line " … and #{rows.size - EXAMPLES} more" if rows.size > EXAMPLES
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def finish
|
|
23
|
+
line ""
|
|
24
|
+
if @problems.zero?
|
|
25
|
+
line "Nothing to fix: the database enforces the same uniqueness the gem checks."
|
|
26
|
+
else
|
|
27
|
+
line "#{@problems} #{@problems == 1 ? 'thing' : 'things'} to fix. See the README, \"Adopting an existing column\"."
|
|
28
|
+
end
|
|
29
|
+
@problems
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def section(title)
|
|
33
|
+
line ""
|
|
34
|
+
line title
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ok(message)
|
|
38
|
+
line " ✓ #{message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def problem(message)
|
|
42
|
+
@problems += 1
|
|
43
|
+
line " ✗ #{message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def line(text)
|
|
47
|
+
@io.puts(text)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
class Audit
|
|
5
|
+
# What one pass over the stored values finds. Collision groups are keyed
|
|
6
|
+
# by comparison_key(normalize(stored)), over non-nil values only: an
|
|
7
|
+
# absent username is not an identity, and never collides.
|
|
8
|
+
class Values
|
|
9
|
+
attr_reader :noncanonical, :failing, :total
|
|
10
|
+
|
|
11
|
+
def initialize(rules)
|
|
12
|
+
@rules = rules
|
|
13
|
+
@noncanonical = []
|
|
14
|
+
@groups = Hash.new { |hash, key| hash[key] = [] }
|
|
15
|
+
@failing = []
|
|
16
|
+
@total = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def <<((id, value))
|
|
20
|
+
@total += 1
|
|
21
|
+
canonical = @rules.normalize(value)
|
|
22
|
+
@noncanonical << [id, value, canonical] unless canonical == value
|
|
23
|
+
@groups[@rules.comparison_key(canonical)] << [id, value]
|
|
24
|
+
failure = canonical.empty? ? :blank : @rules.failure_for(canonical)&.reason
|
|
25
|
+
@failing << [id, value, failure] if failure
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def collisions
|
|
30
|
+
@groups.select { |_key, rows| rows.size > 1 }.to_a
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "audit/values"
|
|
4
|
+
require_relative "audit/output"
|
|
5
|
+
|
|
6
|
+
module Usernames
|
|
7
|
+
# `rails usernames:audit[User,username]`: what stands between an
|
|
8
|
+
# existing column and the gem's guarantees. Read-only — it never writes a
|
|
9
|
+
# row, an index or a setting.
|
|
10
|
+
#
|
|
11
|
+
# Rails' `normalizes` does not touch rows already in the database, so a
|
|
12
|
+
# stored `JAVI` next to a new `javi` would be two identities, and a unique
|
|
13
|
+
# index cannot be added over values that collide once normalised. The
|
|
14
|
+
# audit lists exactly that, plus whether the column's type, collation and
|
|
15
|
+
# indexes give the comparison contract (Usernames::Comparison), and which
|
|
16
|
+
# stored names today's rules would refuse — those keep working (only a
|
|
17
|
+
# change is validated); they are listed so nobody is surprised.
|
|
18
|
+
#
|
|
19
|
+
# Fixing what it finds is the app's own migration, under a username-write
|
|
20
|
+
# freeze (README, "Adopting an existing column"): the gem deliberately
|
|
21
|
+
# renames nobody.
|
|
22
|
+
class Audit
|
|
23
|
+
include Output
|
|
24
|
+
|
|
25
|
+
# Rows shown per finding; the counts are always complete.
|
|
26
|
+
EXAMPLES = 20
|
|
27
|
+
BATCH = 10_000
|
|
28
|
+
|
|
29
|
+
# The types the comparison contract covers; citext is excluded.
|
|
30
|
+
POSTGRESQL_TYPES = /\A(text|character varying(\(\d+\))?)\z/
|
|
31
|
+
MODES = %i[lowercase preserve].freeze
|
|
32
|
+
|
|
33
|
+
attr_reader :model, :column, :rules, :mode
|
|
34
|
+
|
|
35
|
+
# `mode` audits as that case mode: the README's adoption workflow runs the
|
|
36
|
+
# audit BEFORE has_username is declared, when the model cannot say which
|
|
37
|
+
# mode its index will be built for.
|
|
38
|
+
def initialize(model, column = :username, mode: nil)
|
|
39
|
+
@model = model.is_a?(String) ? model.constantize : model
|
|
40
|
+
@column = column.to_sym
|
|
41
|
+
@definition = @model._usernames[@column] if @model.respond_to?(:_usernames)
|
|
42
|
+
@mode_given = !mode.to_s.empty?
|
|
43
|
+
@mode = @mode_given ? mode.to_sym : Comparison.mode_for(@model, @column)
|
|
44
|
+
raise ArgumentError, "mode must be lowercase or preserve (got #{mode.inspect})" unless MODES.include?(@mode)
|
|
45
|
+
|
|
46
|
+
@rules = @definition ? @definition.rules : Usernames.configuration.rules_for(case: @mode)
|
|
47
|
+
@problems = 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Writes the report to `io` and returns the number of problems found
|
|
51
|
+
# (values failing today's rules are a note, not a problem).
|
|
52
|
+
def report(io = $stdout)
|
|
53
|
+
@io = io
|
|
54
|
+
line "usernames:audit #{model.name}##{column} (#{model.table_name}.#{column}, #{mode} mode, " \
|
|
55
|
+
"#{connection.adapter_name}) — read-only"
|
|
56
|
+
undeclared_note
|
|
57
|
+
return finish unless column_section
|
|
58
|
+
|
|
59
|
+
index_section
|
|
60
|
+
values_section
|
|
61
|
+
finish
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def connection
|
|
67
|
+
model.lease_connection
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def adapter
|
|
71
|
+
Comparison.adapter(connection)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Without the declaration the audit cannot know the column's own rules
|
|
75
|
+
# (length, separators…) or, unless told, its case mode.
|
|
76
|
+
def undeclared_note
|
|
77
|
+
return if @definition
|
|
78
|
+
|
|
79
|
+
other = (MODES - [mode]).first
|
|
80
|
+
mode_hint = @mode_given ? "" : " (the configured one; for #{other}: usernames:audit[#{model.name},#{column},#{other}])"
|
|
81
|
+
line " · #{model.name} doesn't declare has_username :#{column} yet: checked against the configured rules, " \
|
|
82
|
+
"in #{mode} mode#{mode_hint}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- the column (the criteria per adapter) ------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def column_section
|
|
88
|
+
section "Column"
|
|
89
|
+
definition = model.columns_hash[column.to_s]
|
|
90
|
+
if definition
|
|
91
|
+
type_and_collation(definition)
|
|
92
|
+
else
|
|
93
|
+
problem("there is no column #{model.table_name}.#{column} (rails generate usernames:install adds it)")
|
|
94
|
+
end
|
|
95
|
+
definition
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def type_and_collation(definition)
|
|
99
|
+
collation = contract.column_collation
|
|
100
|
+
case adapter
|
|
101
|
+
when :postgresql then postgresql_column(definition.sql_type, collation)
|
|
102
|
+
when :mysql then expect_collation(definition.sql_type, collation, "utf8mb4_bin")
|
|
103
|
+
else sqlite_column(definition.sql_type, collation)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def postgresql_column(type, collation)
|
|
108
|
+
if type == "citext"
|
|
109
|
+
return problem("citext is incompatible: its case-insensitivity follows the database locale; " \
|
|
110
|
+
"use a string column with collation \"C\"")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
problem("#{type} is not text or character varying") unless POSTGRESQL_TYPES.match?(type)
|
|
114
|
+
if !column_collation_ok?(:postgresql, collation)
|
|
115
|
+
problem(%(#{type}, collation #{collation.inspect}: equality and lower() follow it; set collation: "C"))
|
|
116
|
+
problem(%(collation #{collation.inspect} is nondeterministic: it can ignore case or punctuation)) unless
|
|
117
|
+
deterministic?(collation)
|
|
118
|
+
elsif mode == :preserve
|
|
119
|
+
# lower(column COLLATE "C"): the explicit collation wins, so the
|
|
120
|
+
# column's own one only decides ORDER BY.
|
|
121
|
+
ok %(#{type}, collation #{collation.inspect} (compared through COLLATE "C"))
|
|
122
|
+
else
|
|
123
|
+
ok %(#{type}, collation #{collation.inspect})
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def expect_collation(type, collation, expected)
|
|
128
|
+
return ok("#{type}, collation #{expected}") if column_collation_ok?(:mysql, collation)
|
|
129
|
+
|
|
130
|
+
problem("#{type}, collation #{collation.inspect}: set collation: #{expected.inspect}, a byte-wise comparison")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# SQLite's default BINARY is the contract; NOCASE (or any custom one) is not.
|
|
134
|
+
def sqlite_column(type, collation)
|
|
135
|
+
return ok("#{type}, default BINARY collation") if column_collation_ok?(:sqlite, collation)
|
|
136
|
+
|
|
137
|
+
problem("#{type}, COLLATE #{collation}: the contract is SQLite's default BINARY")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The index verifier's own rule and its reading of the column's
|
|
141
|
+
# collation (Comparison::IndexContract), so the column check here and
|
|
142
|
+
# the index verdict below can never disagree.
|
|
143
|
+
def column_collation_ok?(adapter, collation)
|
|
144
|
+
Comparison::IndexContract.column_collation_ok?(adapter, mode, collation)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def deterministic?(collation)
|
|
148
|
+
value = connection.select_value(
|
|
149
|
+
"SELECT collisdeterministic FROM pg_collation WHERE collname = #{connection.quote(collation)}"
|
|
150
|
+
)
|
|
151
|
+
value.nil? || ActiveModel::Type::Boolean.new.cast(value)
|
|
152
|
+
rescue ActiveRecord::StatementInvalid
|
|
153
|
+
true # before PostgreSQL 12 every collation is deterministic, and the column does not exist
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# --- indexes --------------------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
# Every index that involves the column, each with why it does not
|
|
159
|
+
# enforce the comparison contract (Comparison::IndexContract) — a
|
|
160
|
+
# partial, composite or NOCASE unique index is not uniqueness.
|
|
161
|
+
def index_section
|
|
162
|
+
section "Index"
|
|
163
|
+
matching = listed_indexes.find { |_index, reason| reason.nil? }&.first
|
|
164
|
+
return ok("#{matching.name} matches the #{mode} mode") if matching
|
|
165
|
+
|
|
166
|
+
problem("no unique index matches the #{mode} mode, so the database does not enforce uniqueness yet: " \
|
|
167
|
+
"#{add_index_line}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def add_index_line
|
|
171
|
+
expected = contract.definition
|
|
172
|
+
"add_index #{Comparison.ruby_name(model.table_name)}, #{expected[:column].inspect}, unique: true, " \
|
|
173
|
+
"name: #{expected[:name].inspect}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def contract
|
|
177
|
+
@contract ||= Comparison::IndexContract.new(connection, table: model.table_name, column: column, mode: mode)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# [[index, reason], …] for every index that involves the column, each
|
|
181
|
+
# printed with why it falls short (reason nil: it enforces the contract).
|
|
182
|
+
def listed_indexes
|
|
183
|
+
verdicts = connection.indexes(model.table_name).select { |index| contract.mentions?(index) }
|
|
184
|
+
.map { |index| [index, contract.violation(index)] }
|
|
185
|
+
verdicts.each { |index, reason| line " · #{describe(index)}#{" — #{reason}" if reason}" }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def describe(index)
|
|
189
|
+
"#{index.name} (#{index.unique ? 'unique' : 'not unique'} on #{Array(index.columns).join(', ')})"
|
|
190
|
+
end
|
|
191
|
+
# --- stored values ----------------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def values_section
|
|
194
|
+
values = Values.new(rules)
|
|
195
|
+
each_value { |id, value| values << [id, value] }
|
|
196
|
+
section "Stored values (#{values.total} with a #{column})"
|
|
197
|
+
noncanonical(values.noncanonical)
|
|
198
|
+
collisions(values.collisions)
|
|
199
|
+
notes(values.failing)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def noncanonical(rows)
|
|
203
|
+
list(rows, "every stored #{column} is already in its normalised form",
|
|
204
|
+
"%d differ from their normalised form (rewrite them in your own migration)") do |id, value, canonical|
|
|
205
|
+
"##{id} #{value.inspect} → #{canonical.empty? ? 'nil' : canonical.inspect}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def collisions(groups)
|
|
210
|
+
list(groups, "no two stored names collide once normalised",
|
|
211
|
+
"groups that collide once normalised: %d (the unique index cannot be added over them)") do |key, rows|
|
|
212
|
+
"#{key.inspect}: #{rows.map { |id, value| "##{id} #{value.inspect}" }.join(', ')}"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def each_value(&)
|
|
217
|
+
model.unscoped.where.not(column => nil).in_batches(of: BATCH) do |batch|
|
|
218
|
+
batch.pluck(model.primary_key, column).each(&)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def notes(failing)
|
|
223
|
+
return ok("every stored #{column} passes today's rules") if failing.empty?
|
|
224
|
+
|
|
225
|
+
line " · #{failing.size} fail today's rules — they keep working; only a change is validated:"
|
|
226
|
+
failing.first(EXAMPLES).each { |id, value, reason| line " ##{id} #{value.inspect}: #{reason}" }
|
|
227
|
+
more(failing)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# Suggestions for a sign-up form: `Usernames.candidates_for("Javi R", count: 5)`.
|
|
5
|
+
#
|
|
6
|
+
# Up to 3 × count allowed names, best first: the seed itself, first name +
|
|
7
|
+
# last initial, the seed with a few random digits, then default shapes.
|
|
8
|
+
# Three times as many as asked for because the caller still subtracts
|
|
9
|
+
# the taken ones (one query) and should usually have `count` left.
|
|
10
|
+
module Candidates
|
|
11
|
+
class << self
|
|
12
|
+
def for(seed, count:, rules:)
|
|
13
|
+
unless count.is_a?(Integer) && count.positive?
|
|
14
|
+
raise ArgumentError, "count must be a positive whole number (got #{count.inspect})"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
limit = 3 * count
|
|
18
|
+
names = []
|
|
19
|
+
seeded(seed, count, rules).each { |draw| add(names, draw, limit, rules) }
|
|
20
|
+
# Default shapes for the rest, within a bounded number of draws: a
|
|
21
|
+
# tight rule set may allow few of them, and this must still end.
|
|
22
|
+
[limit * 2, Generator::DRAWS].max.times do |index|
|
|
23
|
+
break if names.size >= limit
|
|
24
|
+
|
|
25
|
+
add(names, Generator.shape(index + 1, rules), limit, rules)
|
|
26
|
+
end
|
|
27
|
+
names
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# The cleaned seed; first name + last initial, with each configured
|
|
33
|
+
# separator and with none (`javi_r`, `javir`); then the seed with two
|
|
34
|
+
# and three random digits, `count` of them in turn.
|
|
35
|
+
def seeded(seed, count, rules)
|
|
36
|
+
return [] if seed.nil?
|
|
37
|
+
|
|
38
|
+
base = Generator.clean(seed, rules)
|
|
39
|
+
return [] if base.empty?
|
|
40
|
+
|
|
41
|
+
max = rules.length.max
|
|
42
|
+
[Generator.fit(base, max, rules), *initials(Generator.words(seed, rules), rules)] +
|
|
43
|
+
Array.new(count) { |index| Generator.with_digits(base, index.even? ? 10..99 : 100..999, rules) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initials(words, rules)
|
|
47
|
+
return [] if words.size < 2
|
|
48
|
+
|
|
49
|
+
initial = words.last[0]
|
|
50
|
+
(rules.separators + [""]).filter_map do |separator|
|
|
51
|
+
first = Generator.fit(words.first, rules.length.max - separator.length - initial.length, rules)
|
|
52
|
+
"#{first}#{separator}#{initial}" if first
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add(names, draw, limit, rules)
|
|
57
|
+
return if draw.nil? || names.size >= limit
|
|
58
|
+
|
|
59
|
+
name = rules.normalize(draw)
|
|
60
|
+
names << name unless name.empty? || names.include?(name) || rules.failure_for(name)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|