usernames 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +154 -7
- data/app/assets/javascripts/usernames/index.js +16 -0
- data/app/assets/javascripts/usernames/username_check_controller.js +191 -0
- data/app/controllers/usernames/availability_controller.rb +177 -0
- data/config/importmap.rb +8 -0
- data/config/locales/usernames.en.yml +6 -2
- data/config/locales/usernames.es.yml +2 -0
- data/config/locales/usernames.pt.yml +2 -0
- data/lib/generators/usernames/availability_generator.rb +85 -24
- data/lib/generators/usernames/install_generator.rb +24 -0
- data/lib/generators/usernames/install_messages.rb +41 -2
- data/lib/generators/usernames/javascript_generator.rb +38 -0
- data/lib/generators/usernames/javascript_setup.rb +124 -0
- data/lib/generators/usernames/templates/availability/_field.html.erb.tt +5 -15
- data/lib/generators/usernames/templates/usernames.rb +8 -2
- data/lib/usernames/configuration.rb +93 -3
- data/lib/usernames/engine.rb +74 -13
- data/lib/usernames/form_helpers.rb +215 -0
- data/lib/usernames/locale.rb +39 -0
- data/lib/usernames/model.rb +137 -26
- data/lib/usernames/reserved/listing.rb +1 -0
- data/lib/usernames/reserved.rb +18 -8
- data/lib/usernames/routes.rb +26 -0
- data/lib/usernames/rules/options.rb +9 -1
- data/lib/usernames/rules.rb +9 -1
- data/lib/usernames/targets.rb +65 -0
- data/lib/usernames/unique_violation.rb +0 -1
- data/lib/usernames/version.rb +1 -1
- data/lib/usernames.rb +3 -0
- data/sig/usernames.rbs +33 -6
- metadata +11 -3
- data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +0 -107
- data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +0 -115
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Usernames
|
|
4
|
-
# GET /usernames/availability?<%= column %>=javi
|
|
5
|
-
# => { "state": "free" | "taken" | "invalid", "message": "…" }
|
|
6
|
-
#
|
|
7
|
-
# Generated by `rails generate usernames:availability`. It's yours: edit it.
|
|
8
|
-
#
|
|
9
|
-
# An availability endpoint tells anyone which names exist — that is its
|
|
10
|
-
# job — so it comes with guard rails:
|
|
11
|
-
#
|
|
12
|
-
# * Rails' own rate_limit, on a store this controller checks. With the
|
|
13
|
-
# NullStore (Rails' default in development and test) the limiter silently
|
|
14
|
-
# counts nothing, so in production this answers 503, with a log line,
|
|
15
|
-
# until config.cache_store is a shared store (Solid Cache, Redis,
|
|
16
|
-
# Memcached). A MemoryStore works, but per process: each worker counts on
|
|
17
|
-
# its own, so it logs a warning once.
|
|
18
|
-
# * Taken, reserved and not-allowed names all answer "taken", with one
|
|
19
|
-
# message: it never says which list a name is on, or which word matched.
|
|
20
|
-
# * Never cached (no-store), and advisory only: the model's validations and
|
|
21
|
-
# the unique index decide at save time.
|
|
22
|
-
class AvailabilityController < ApplicationController
|
|
23
|
-
# Captured once: the very object the limiter writes to, so the guard
|
|
24
|
-
# below checks the store that is actually in use.
|
|
25
|
-
RATE_LIMIT_STORE = cache_store
|
|
26
|
-
|
|
27
|
-
MEMORY_STORE_WARNED = Concurrent::AtomicBoolean.new(false)
|
|
28
|
-
|
|
29
|
-
# Format failures are shown as they are (the browser hints them anyway);
|
|
30
|
-
# reserved and not-allowed names are reported as taken (see above).
|
|
31
|
-
FORMAT_FAILURES = %i[
|
|
32
|
-
username_spaces username_characters username_unicode too_short too_long username_edges
|
|
33
|
-
username_consecutive username_first_character username_digits_only username_extension
|
|
34
|
-
].freeze
|
|
35
|
-
|
|
36
|
-
# Before everything else, the limiter included: an answer about who
|
|
37
|
-
# exists is never cached, and neither is a 429 or a 503.
|
|
38
|
-
prepend_before_action :no_store
|
|
39
|
-
|
|
40
|
-
rate_limit to: 30, within: 1.minute, store: RATE_LIMIT_STORE, with: -> { head :too_many_requests }
|
|
41
|
-
|
|
42
|
-
before_action :require_a_working_rate_limit_store
|
|
43
|
-
|
|
44
|
-
def show
|
|
45
|
-
render json: answer_for(params[:<%= column %>].to_s)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
# Every message in <%= model_name %>'s own words: a translation under
|
|
51
|
-
# activerecord.errors.models.<%= singular %>.attributes.<%= column %> wins here as on the form.
|
|
52
|
-
def answer_for(value)
|
|
53
|
-
result = <%= model_name %>.check_<%= column %>(value)
|
|
54
|
-
|
|
55
|
-
if result.canonical.empty?
|
|
56
|
-
{ state: "invalid", message: message(:blank) }
|
|
57
|
-
elsif own?(result.canonical)
|
|
58
|
-
{ state: "free", message: available }
|
|
59
|
-
elsif FORMAT_FAILURES.include?(result.reason)
|
|
60
|
-
{ state: "invalid", message: result.message }
|
|
61
|
-
elsif result.ok? && <%= model_name %>.<%= column %>_available?(result.canonical)
|
|
62
|
-
{ state: "free", message: available }
|
|
63
|
-
else
|
|
64
|
-
{ state: "taken", message: message(:taken) }
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def message(type)
|
|
69
|
-
<%= model_name %>.new.errors.generate_message(:<%= column %>, type)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Said out loud too: the status is a live region, so a screen reader
|
|
73
|
-
# hears that the name is free, not just silence.
|
|
74
|
-
def available
|
|
75
|
-
I18n.t(:available, scope: :usernames)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def rules
|
|
79
|
-
<%= model_name %>.<%= column %>_options
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# The signed-in person's own name is free: it's theirs. Devise and most
|
|
83
|
-
# authentication gems define current_user; with Rails' own authentication
|
|
84
|
-
# generator, use Current.user instead.
|
|
85
|
-
def own?(canonical)
|
|
86
|
-
user = current_user if respond_to?(:current_user, true)
|
|
87
|
-
name = user.try(:<%= column %>)
|
|
88
|
-
name.present? && Usernames.comparison_key(name, **rules) == Usernames.comparison_key(canonical, **rules)
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def require_a_working_rate_limit_store
|
|
92
|
-
case RATE_LIMIT_STORE
|
|
93
|
-
when ActiveSupport::Cache::NullStore
|
|
94
|
-
return unless Rails.env.production?
|
|
95
|
-
|
|
96
|
-
Rails.logger.error "[usernames] The availability check is off (503): config.cache_store is the NullStore, " \
|
|
97
|
-
"where Rails' rate_limit counts nothing. Use a shared store: Solid Cache, Redis or Memcached."
|
|
98
|
-
head :service_unavailable
|
|
99
|
-
when ActiveSupport::Cache::MemoryStore
|
|
100
|
-
if MEMORY_STORE_WARNED.make_true
|
|
101
|
-
Rails.logger.warn "[usernames] The availability check's rate limit uses a MemoryStore, which each process " \
|
|
102
|
-
"counts on its own. Use a shared store (Solid Cache, Redis, Memcached) for one limit per client."
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
end
|
|
@@ -1,115 +0,0 @@
|
|
|
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
|
-
}
|