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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +154 -7
  4. data/app/assets/javascripts/usernames/index.js +16 -0
  5. data/app/assets/javascripts/usernames/username_check_controller.js +191 -0
  6. data/app/controllers/usernames/availability_controller.rb +177 -0
  7. data/config/importmap.rb +8 -0
  8. data/config/locales/usernames.en.yml +6 -2
  9. data/config/locales/usernames.es.yml +2 -0
  10. data/config/locales/usernames.pt.yml +2 -0
  11. data/lib/generators/usernames/availability_generator.rb +85 -24
  12. data/lib/generators/usernames/install_generator.rb +24 -0
  13. data/lib/generators/usernames/install_messages.rb +41 -2
  14. data/lib/generators/usernames/javascript_generator.rb +38 -0
  15. data/lib/generators/usernames/javascript_setup.rb +124 -0
  16. data/lib/generators/usernames/templates/availability/_field.html.erb.tt +5 -15
  17. data/lib/generators/usernames/templates/usernames.rb +8 -2
  18. data/lib/usernames/configuration.rb +93 -3
  19. data/lib/usernames/engine.rb +74 -13
  20. data/lib/usernames/form_helpers.rb +215 -0
  21. data/lib/usernames/locale.rb +39 -0
  22. data/lib/usernames/model.rb +137 -26
  23. data/lib/usernames/reserved/listing.rb +1 -0
  24. data/lib/usernames/reserved.rb +18 -8
  25. data/lib/usernames/routes.rb +26 -0
  26. data/lib/usernames/rules/options.rb +9 -1
  27. data/lib/usernames/rules.rb +9 -1
  28. data/lib/usernames/targets.rb +65 -0
  29. data/lib/usernames/unique_violation.rb +0 -1
  30. data/lib/usernames/version.rb +1 -1
  31. data/lib/usernames.rb +3 -0
  32. data/sig/usernames.rbs +33 -6
  33. metadata +11 -3
  34. data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +0 -107
  35. 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
- }