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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
# GET /usernames/availability?username=javi&target=user
|
|
5
|
+
# => { "state": "free" | "taken" | "invalid", "message": "…" }
|
|
6
|
+
#
|
|
7
|
+
# The endpoint behind `f.username_field`. The engine draws its route
|
|
8
|
+
# (config.availability.path); `target` is an alias from
|
|
9
|
+
# config.availability.targets, and any other value is a 404.
|
|
10
|
+
#
|
|
11
|
+
# An availability endpoint tells anyone which names exist — that is its
|
|
12
|
+
# job — so it comes with guard rails:
|
|
13
|
+
#
|
|
14
|
+
# * Rails' own rate_limit (config.availability.rate_limit), counted in
|
|
15
|
+
# config.availability.rate_limit_store (Rails.cache by default), read
|
|
16
|
+
# once when this class loads. A store that cannot count — the NullStore,
|
|
17
|
+
# Rails' default in development and test, or a backend that is down —
|
|
18
|
+
# makes the limiter count nothing, so outside development and test the
|
|
19
|
+
# endpoint answers 503, with a log line, until it can. A MemoryStore
|
|
20
|
+
# works, but per process: each worker counts on its own, so it logs a
|
|
21
|
+
# warning once.
|
|
22
|
+
# * Taken, reserved and not-allowed names all answer "taken", with one
|
|
23
|
+
# message: no list, matched word or reason is ever sent to the browser.
|
|
24
|
+
# * Never cached (no-store), whatever the answer, and advisory only: the
|
|
25
|
+
# model's validations and the unique index decide at save time.
|
|
26
|
+
#
|
|
27
|
+
# It inherits config.availability.parent_controller (your
|
|
28
|
+
# ApplicationController by default), so that controller's locale and
|
|
29
|
+
# authentication callbacks apply. Copy it into your app to change it:
|
|
30
|
+
# `rails generate usernames:availability --copy`.
|
|
31
|
+
class AvailabilityController < Usernames.configuration.availability.parent_class
|
|
32
|
+
# Raised by the counter when the rate limit store cannot count.
|
|
33
|
+
class CounterUnavailable < StandardError; end
|
|
34
|
+
|
|
35
|
+
# The store Rails' rate_limit writes to, wrapped so that a count that
|
|
36
|
+
# did not happen is noticed: Rails' limiter lets a request through when
|
|
37
|
+
# increment returns nil, which is exactly what a NullStore, or a cache
|
|
38
|
+
# whose backend is down, returns.
|
|
39
|
+
class Counter
|
|
40
|
+
attr_reader :store
|
|
41
|
+
|
|
42
|
+
def initialize(store)
|
|
43
|
+
@store = store
|
|
44
|
+
@warned = Concurrent::AtomicBoolean.new(false)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def increment(name, amount = 1, **)
|
|
48
|
+
count = begin
|
|
49
|
+
store.increment(name, amount, **)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
failure = e
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
return count if count.is_a?(Integer)
|
|
55
|
+
|
|
56
|
+
unavailable!(failure)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def null?
|
|
60
|
+
store.is_a?(ActiveSupport::Cache::NullStore)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def memory?
|
|
64
|
+
store.is_a?(ActiveSupport::Cache::MemoryStore)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Why nothing was counted, for the log.
|
|
68
|
+
def problem(failure = nil)
|
|
69
|
+
if null? then "the rate limit store is the NullStore, where Rails' rate_limit counts nothing"
|
|
70
|
+
elsif failure then "the rate limit store failed to count (#{failure.class}: #{failure.message})"
|
|
71
|
+
else "the rate limit store's increment returned nothing, so Rails' rate_limit counts nothing"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# In development and test an unlimited check is the useful one: log
|
|
78
|
+
# once and let it through. Anywhere else, refuse.
|
|
79
|
+
def unavailable!(failure)
|
|
80
|
+
raise CounterUnavailable, problem(failure) unless Rails.env.local?
|
|
81
|
+
|
|
82
|
+
if @warned.make_true
|
|
83
|
+
Rails.logger.warn "[usernames] The availability check isn't rate limited here: #{problem(failure)}. " \
|
|
84
|
+
"Outside development and test it answers 503 until the store can count."
|
|
85
|
+
end
|
|
86
|
+
0
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Captured once: the very object the limiter writes to.
|
|
91
|
+
RATE_LIMIT = Counter.new(Usernames.configuration.availability.rate_limit_store || Rails.cache)
|
|
92
|
+
|
|
93
|
+
MEMORY_STORE_WARNED = Concurrent::AtomicBoolean.new(false)
|
|
94
|
+
|
|
95
|
+
# Format failures are shown as they are (the browser hints them anyway);
|
|
96
|
+
# reserved and not-allowed names are reported as taken (see above).
|
|
97
|
+
FORMAT_FAILURES = Rules.format_reasons
|
|
98
|
+
|
|
99
|
+
# Far beyond any username: what is longer is cut here, before the
|
|
100
|
+
# normaliser sees it, and still breaks the length rule.
|
|
101
|
+
LONGEST = 1024
|
|
102
|
+
|
|
103
|
+
# Before everything else, the limiter included: an answer about who
|
|
104
|
+
# exists is never cached, and neither is a 404, a 429 or a 503.
|
|
105
|
+
prepend_before_action :no_store
|
|
106
|
+
|
|
107
|
+
# A counter of its own: an unnamed limiter the parent declares would key
|
|
108
|
+
# on this controller's path too, and the two would share one count and
|
|
109
|
+
# one expiry — a parent's shorter window resetting this one, a longer
|
|
110
|
+
# one spending it twice. Rails 8 takes a name for that; Rails 7.2 keys
|
|
111
|
+
# only on `by`, so there the name goes into it.
|
|
112
|
+
OWN_COUNTER =
|
|
113
|
+
if instance_method(:rate_limiting).parameters.any? { |_kind, name| name == :name }
|
|
114
|
+
{ name: "usernames/availability" }
|
|
115
|
+
else
|
|
116
|
+
{ by: -> { "usernames/availability:#{request.remote_ip}" } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
rate_limit(**Usernames.configuration.availability.rate_limit, **OWN_COUNTER, store: RATE_LIMIT,
|
|
120
|
+
with: -> { head :too_many_requests })
|
|
121
|
+
|
|
122
|
+
before_action :warn_about_a_memory_store
|
|
123
|
+
# After the limiter, so guessing aliases is rate limited too.
|
|
124
|
+
before_action :find_the_target
|
|
125
|
+
|
|
126
|
+
rescue_from CounterUnavailable do |error|
|
|
127
|
+
Rails.logger.error "[usernames] The availability check is off (503): #{error.message}. " \
|
|
128
|
+
"Use a shared store that counts: Solid Cache, Redis or Memcached."
|
|
129
|
+
head :service_unavailable
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def show
|
|
133
|
+
render json: answer_for(typed)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def find_the_target
|
|
139
|
+
@model, @column = Targets.find(params[:target])
|
|
140
|
+
head :not_found unless @model
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def typed
|
|
144
|
+
value = params[:username]
|
|
145
|
+
value.is_a?(String) ? value[0, LONGEST] : ""
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Every message in the model's own words: a translation under
|
|
149
|
+
# activerecord.errors.models.<model>.attributes.<column> wins here as on
|
|
150
|
+
# the form.
|
|
151
|
+
def answer_for(value)
|
|
152
|
+
result = @model.public_send(:"check_#{@column}", value)
|
|
153
|
+
|
|
154
|
+
if result.canonical.empty?
|
|
155
|
+
{ state: "invalid", message: message(:blank) }
|
|
156
|
+
elsif FORMAT_FAILURES.include?(result.reason)
|
|
157
|
+
{ state: "invalid", message: result.message }
|
|
158
|
+
elsif result.ok? && @model.public_send(:"#{@column}_available?", result.canonical)
|
|
159
|
+
{ state: "free", message: Locale.t(:usernames, :available) }
|
|
160
|
+
else
|
|
161
|
+
{ state: "taken", message: message(:taken) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def message(type)
|
|
166
|
+
@model.new.errors.generate_message(@column, type)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def warn_about_a_memory_store
|
|
170
|
+
return unless RATE_LIMIT.memory? && MEMORY_STORE_WARNED.make_true
|
|
171
|
+
|
|
172
|
+
Rails.logger.warn "[usernames] The availability check's rate limit uses a MemoryStore, which each " \
|
|
173
|
+
"process counts on its own. Use a shared store (Solid Cache, Redis, Memcached) for one " \
|
|
174
|
+
"limit per client."
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Drawn into the app's importmap by the engine (importmap-rails only).
|
|
4
|
+
# `import "usernames"` registers the username-check Stimulus controller on the
|
|
5
|
+
# app's own Stimulus application; "usernames/username-check" is the controller
|
|
6
|
+
# alone, for an app that registers it itself.
|
|
7
|
+
pin "usernames", to: "usernames/index.js"
|
|
8
|
+
pin "usernames/username-check", to: "usernames/username_check_controller.js"
|
|
@@ -48,9 +48,13 @@ en:
|
|
|
48
48
|
one: "is too long (maximum is 1 character)"
|
|
49
49
|
other: "is too long (maximum is %{count} characters)"
|
|
50
50
|
usernames:
|
|
51
|
-
# What the
|
|
52
|
-
#
|
|
51
|
+
# What the availability check says, in its live region, when a name is
|
|
52
|
+
# free to take…
|
|
53
53
|
available: "is available"
|
|
54
|
+
availability:
|
|
55
|
+
# …and when it could not ask (offline, rate limited, switched off).
|
|
56
|
+
# The form still submits; the model decides.
|
|
57
|
+
error: "couldn't be checked just now"
|
|
54
58
|
# The pieces the phrases above are built from.
|
|
55
59
|
letters:
|
|
56
60
|
lowercase: "lowercase letters"
|
|
@@ -4,58 +4,119 @@ require "rails/generators/base"
|
|
|
4
4
|
|
|
5
5
|
module Usernames
|
|
6
6
|
module Generators
|
|
7
|
-
# rails generate usernames:availability [--model=User] [--column=username]
|
|
7
|
+
# rails generate usernames:availability [--copy] [--model=User] [--column=username]
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
9
|
+
# The live "is this name free?" check is built in: the gem's endpoint,
|
|
10
|
+
# its route and its Stimulus controller, used by `f.username_field`. On
|
|
11
|
+
# its own this generator says so, and — when it finds the files a 0.1
|
|
12
|
+
# install copied into the app — what to do with them.
|
|
11
13
|
#
|
|
12
|
-
#
|
|
14
|
+
# --copy writes a set of your own to change, which replaces the gem's:
|
|
15
|
+
#
|
|
16
|
+
# app/controllers/usernames/availability_controller.rb the endpoint (the gem's class, overridden)
|
|
13
17
|
# app/javascript/controllers/username_check_controller.js
|
|
14
18
|
# app/views/usernames/_field.html.erb <%= render "usernames/field", form: form %>
|
|
19
|
+
# config/routes.rb usernames_availability, at the top
|
|
15
20
|
class AvailabilityGenerator < Rails::Generators::Base
|
|
16
21
|
source_root File.expand_path("templates/availability", __dir__)
|
|
17
22
|
|
|
18
|
-
desc "
|
|
23
|
+
desc "Explain the built-in availability check, or copy it into your app (--copy) to customise it."
|
|
19
24
|
|
|
25
|
+
class_option :copy, type: :boolean, default: false, desc: "Copy the endpoint, the JavaScript and a partial"
|
|
20
26
|
class_option :model, type: :string, default: "User", desc: "The model with has_username"
|
|
21
27
|
class_option :column, type: :string, default: "username", desc: "Its username column"
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
GEM = File.expand_path("../../..", __dir__)
|
|
30
|
+
CONTROLLER = "app/controllers/usernames/availability_controller.rb"
|
|
31
|
+
JAVASCRIPT = "app/javascript/controllers/username_check_controller.js"
|
|
32
|
+
PARTIAL = "app/views/usernames/_field.html.erb"
|
|
33
|
+
ROUTES = "config/routes.rb"
|
|
34
|
+
|
|
35
|
+
# Read before anything is written, so --copy's own files are not mistaken for 0.1's.
|
|
36
|
+
def look_for_copied_files
|
|
37
|
+
@found = [CONTROLLER, JAVASCRIPT, PARTIAL].select { |path| File.exist?(File.join(destination_root, path)) }
|
|
38
|
+
@found << ROUTES if routes.include?("usernames/availability#show")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def copy_the_endpoint
|
|
42
|
+
return unless options[:copy]
|
|
43
|
+
|
|
44
|
+
create_file CONTROLLER, copied("#", File.read(File.join(GEM, CONTROLLER), encoding: "UTF-8"))
|
|
25
45
|
end
|
|
26
46
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
47
|
+
def copy_the_javascript
|
|
48
|
+
return unless options[:copy]
|
|
49
|
+
|
|
50
|
+
source = File.join(GEM, "app/assets/javascripts/usernames/username_check_controller.js")
|
|
51
|
+
create_file JAVASCRIPT, copied("//", File.read(source, encoding: "UTF-8"))
|
|
30
52
|
end
|
|
31
53
|
|
|
32
|
-
def
|
|
33
|
-
template "
|
|
54
|
+
def copy_the_partial
|
|
55
|
+
template "_field.html.erb.tt", PARTIAL if options[:copy]
|
|
34
56
|
end
|
|
35
57
|
|
|
36
|
-
|
|
37
|
-
|
|
58
|
+
# Above every other route, so a catch-all can't shadow it; the engine
|
|
59
|
+
# then draws none. Thor injects a line only once.
|
|
60
|
+
def draw_the_route
|
|
61
|
+
route "usernames_availability" if options[:copy]
|
|
38
62
|
end
|
|
39
63
|
|
|
40
64
|
def display_post_install_message
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
say " 1. Render the field in your sign-up form: <%= render \"usernames/field\", form: form %>"
|
|
44
|
-
say " 2. If you don't use importmap's eagerLoadControllersFrom, register the Stimulus controller " \
|
|
45
|
-
"(rails stimulus:manifest:update)."
|
|
46
|
-
say " 3. Give production a shared cache store (Solid Cache, Redis or Memcached). With the NullStore, " \
|
|
47
|
-
"Rails' rate_limit counts nothing, so the endpoint answers 503 there until you do."
|
|
48
|
-
say "\nThe route is declared at the top of config/routes.rb, so a catch-all /:#{column} below it can't shadow it."
|
|
49
|
-
say "Hints in the browser never block submission: the model and its unique index decide.", :green
|
|
65
|
+
options[:copy] ? copied_message : built_in_message
|
|
66
|
+
upgrade_message unless @found.empty? || options[:copy]
|
|
50
67
|
end
|
|
51
68
|
|
|
52
69
|
private
|
|
53
70
|
|
|
71
|
+
def built_in_message
|
|
72
|
+
say "\n\tThe availability check is built into the usernames gem: nothing to generate.", :green
|
|
73
|
+
say "\n <%= f.username_field :#{column} %> <%= f.username_status :#{column} %> in the form"
|
|
74
|
+
say %( import "usernames" in app/javascript/application.js)
|
|
75
|
+
say %( config.availability.targets = { #{target_alias.inspect} => "#{model_name}##{column}" } in the initializer)
|
|
76
|
+
say "\nTo change how it works, copy it into your app: rails generate usernames:availability --copy"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A 0.1 install copied a controller, a route, a Stimulus controller and
|
|
80
|
+
# a partial that speak an older protocol. Either mode keeps the pair
|
|
81
|
+
# consistent; mixing them does not.
|
|
82
|
+
def upgrade_message
|
|
83
|
+
say "\nFound files a 0.1 install copied into this app: #{@found.join(', ')}.", :yellow
|
|
84
|
+
say "They speak the 0.1 protocol, and the app's copies win over the gem's. Pick one:"
|
|
85
|
+
say " * Managed (recommended): delete them all — the controller, the usernames/availability route, " \
|
|
86
|
+
"the Stimulus controller and the partial — and use f.username_field / f.username_status with " \
|
|
87
|
+
"`import \"usernames\"`. Upgrades then come with the gem."
|
|
88
|
+
say " * Customized: keep your pair, and set config.availability.path = nil so the gem draws no route. " \
|
|
89
|
+
"Don't `import \"usernames\"`: your Stimulus controller is the one registered as username-check."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def copied_message
|
|
93
|
+
say "\n\tThe availability check is now yours to change.", :green
|
|
94
|
+
say "\n #{CONTROLLER} replaces the gem's endpoint (same class); `usernames_availability` in config/routes.rb " \
|
|
95
|
+
"draws its route, above any catch-all, so the gem draws none."
|
|
96
|
+
say " #{JAVASCRIPT} is registered by your Stimulus setup as username-check: remove " \
|
|
97
|
+
"`import \"usernames\"` from app/javascript/application.js, or the gem's would register over it."
|
|
98
|
+
say %( Render the field with <%= render "usernames/field", form: form %>.)
|
|
99
|
+
say " The endpoint answers for config.availability.targets, as the gem's did: keep " \
|
|
100
|
+
"{ #{target_alias.inspect} => \"#{model_name}##{column}\" } there."
|
|
101
|
+
say "Gem upgrades no longer change these files: compare them with the gem's when you upgrade.", :yellow
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def copied(comment, source)
|
|
105
|
+
note = "#{comment} Copied from the usernames gem by `rails generate usernames:availability --copy`. " \
|
|
106
|
+
"It's yours: edit it.\n"
|
|
107
|
+
source.start_with?("# frozen_string_literal") ? source.sub("\n", "\n\n#{note}") : note + source
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def routes
|
|
111
|
+
path = File.join(destination_root, ROUTES)
|
|
112
|
+
File.exist?(path) ? File.read(path) : ""
|
|
113
|
+
end
|
|
114
|
+
|
|
54
115
|
def model_name
|
|
55
116
|
options[:model].camelize
|
|
56
117
|
end
|
|
57
118
|
|
|
58
|
-
def
|
|
119
|
+
def target_alias
|
|
59
120
|
model_name.underscore.tr("/", "_")
|
|
60
121
|
end
|
|
61
122
|
|
|
@@ -4,6 +4,7 @@ require "rails/generators/base"
|
|
|
4
4
|
require "rails/generators/active_record"
|
|
5
5
|
require_relative "existing_schema"
|
|
6
6
|
require_relative "install_messages"
|
|
7
|
+
require_relative "javascript_setup"
|
|
7
8
|
|
|
8
9
|
module Usernames
|
|
9
10
|
module Generators
|
|
@@ -20,9 +21,14 @@ module Usernames
|
|
|
20
21
|
# added. A column that already holds usernames gets the adoption workflow
|
|
21
22
|
# instead of a promise: the index cannot be trusted — or even added —
|
|
22
23
|
# until `rails usernames:audit` comes back clean.
|
|
24
|
+
#
|
|
25
|
+
# The initializer publishes that column to the live availability check
|
|
26
|
+
# (config.availability.targets), and in an importmap + Stimulus app the
|
|
27
|
+
# generator adds `import "usernames"` to app/javascript/application.js, once.
|
|
23
28
|
class InstallGenerator < Rails::Generators::Base
|
|
24
29
|
include ActiveRecord::Generators::Migration
|
|
25
30
|
include InstallMessages
|
|
31
|
+
include JavascriptSetup
|
|
26
32
|
|
|
27
33
|
source_root File.expand_path("templates", __dir__)
|
|
28
34
|
|
|
@@ -65,6 +71,19 @@ module Usernames
|
|
|
65
71
|
migration_template "add_username.rb.erb", File.join(db_migrate_path, "#{migration_name}.rb")
|
|
66
72
|
end
|
|
67
73
|
|
|
74
|
+
# Only where the line can work: importmap (nothing else resolves it),
|
|
75
|
+
# the entrypoint, and stimulus-rails' controllers/application.js
|
|
76
|
+
# exporting the `application` it registers on. Never twice, and never
|
|
77
|
+
# removing anything.
|
|
78
|
+
def add_the_javascript_import
|
|
79
|
+
return unless javascript_setup == :ready
|
|
80
|
+
|
|
81
|
+
separator = File.read(destination(APPLICATION_JS)).end_with?("\n") ? "" : "\n"
|
|
82
|
+
# force: Thor would skip text it finds anywhere in the file, even in a
|
|
83
|
+
# comment; javascript_setup has already said the import is missing.
|
|
84
|
+
append_to_file APPLICATION_JS, %(#{separator}import "usernames"\n), force: true
|
|
85
|
+
end
|
|
86
|
+
|
|
68
87
|
def display_post_install_message
|
|
69
88
|
say "\n\tThe `usernames` gem has been successfully installed!", :green
|
|
70
89
|
warn_about_the_database unless database.reachable?
|
|
@@ -74,6 +93,11 @@ module Usernames
|
|
|
74
93
|
|
|
75
94
|
private
|
|
76
95
|
|
|
96
|
+
# The initializer's line: config.availability.targets = { "user" => "User#handle" }.
|
|
97
|
+
def target_alias = model_name.underscore.tr("/", "_")
|
|
98
|
+
|
|
99
|
+
def target = "#{model_name}##{column}"
|
|
100
|
+
|
|
77
101
|
# --- what the migration does -----------------------------------------------------------------
|
|
78
102
|
|
|
79
103
|
# A file name and a constant, whatever the table is called:
|
|
@@ -12,10 +12,9 @@ module Usernames
|
|
|
12
12
|
say "\nTo complete the setup:"
|
|
13
13
|
say " 1. Run 'rails db:migrate'#{migration_summary}."
|
|
14
14
|
say " 2. Add `#{macro_line}` to your #{model_name} model."
|
|
15
|
-
say " 3. Optionally, run 'rails generate usernames:availability' for a live \"is this name free?\" " \
|
|
16
|
-
"check on your sign-up form."
|
|
17
15
|
say "\nUsernames are unique from the moment the index exists: the database decides, " \
|
|
18
16
|
"with the same comparison the gem uses everywhere."
|
|
17
|
+
integration_message
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
# On a populated column no index is a promise — not even one already in
|
|
@@ -35,8 +34,48 @@ module Usernames
|
|
|
35
34
|
say " 4. Add `#{macro_line}` to your #{model_name} model. Existing names that break today's rules " \
|
|
36
35
|
"keep working; only a change is validated."
|
|
37
36
|
say " See \"Adopting an existing column\" in the README."
|
|
37
|
+
integration_message
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Then, in the forms: the field and its status, and the one line of
|
|
41
|
+
# JavaScript behind them.
|
|
42
|
+
def integration_message
|
|
43
|
+
say "\nThen, in any form for a #{model_name}, a username field that checks names as people type:"
|
|
44
|
+
say " <%= f.label :#{column} %> <%= f.username_field :#{column} %> <%= f.username_status :#{column} %>"
|
|
45
|
+
say " #{JAVASCRIPT_STEPS.fetch(javascript_setup)}"
|
|
46
|
+
devise_message if defined?(::Devise)
|
|
47
|
+
say " In production, the check counts requests in Rails.cache: give it a shared store (Solid Cache, " \
|
|
48
|
+
"Redis, Memcached). It answers 503 until one counts."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Devise permits only its own attributes: without these lines a sign-up
|
|
52
|
+
# form says the name is free, and the save drops it.
|
|
53
|
+
def devise_message
|
|
54
|
+
say " Devise drops attributes it doesn't know, so permit :#{column} where people choose or change " \
|
|
55
|
+
"it (in ApplicationController, before_action ... if: :devise_controller?):"
|
|
56
|
+
say " devise_parameter_sanitizer.permit(:sign_up, keys: [:#{column}])"
|
|
57
|
+
say " devise_parameter_sanitizer.permit(:account_update, keys: [:#{column}])"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
JAVASCRIPT_STEPS = {
|
|
61
|
+
ready: %(Added `import "usernames"` to app/javascript/application.js.),
|
|
62
|
+
imported: %(app/javascript/application.js already has `import "usernames"`.),
|
|
63
|
+
no_importmap: "No importmap here: run 'rails generate usernames:javascript' to copy the Stimulus controller " \
|
|
64
|
+
"into app/javascript/controllers (then it is yours to keep up to date).",
|
|
65
|
+
no_entrypoint: %(No app/javascript/application.js: add `import "usernames"` to your importmap entrypoint.),
|
|
66
|
+
no_stimulus: "No app/javascript/controllers/application.js (stimulus-rails' Stimulus setup): the field " \
|
|
67
|
+
"works as a plain input; with stimulus-rails, add `import \"usernames\"` for the live check.",
|
|
68
|
+
uncertain_source: "Didn't add `import \"usernames\"`: app/javascript/controllers/application.js has " \
|
|
69
|
+
"constructs the installer won't guess at (template interpolation or a regular " \
|
|
70
|
+
"expression). Add the line yourself if that file exports `application`.",
|
|
71
|
+
no_application_export: "Didn't add `import \"usernames\"`: app/javascript/controllers/application.js " \
|
|
72
|
+
"doesn't export the Stimulus application by the name `application` " \
|
|
73
|
+
"(`export { application }`), which it imports, and a missing export would stop " \
|
|
74
|
+
"app/javascript/application.js loading at all. Export it under that name and add the " \
|
|
75
|
+
"line, or register \"usernames/username-check\" on your application yourself."
|
|
76
|
+
}.freeze
|
|
77
|
+
private_constant :JAVASCRIPT_STEPS
|
|
78
|
+
|
|
40
79
|
def index_doubt
|
|
41
80
|
return "the unique index isn't trustworthy yet (it may not even be addable)" if add_index?
|
|
42
81
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Usernames
|
|
6
|
+
module Generators
|
|
7
|
+
# rails generate usernames:javascript
|
|
8
|
+
#
|
|
9
|
+
# For an app that bundles its JavaScript (jsbundling, esbuild, Vite…)
|
|
10
|
+
# rather than using importmap, where `import "usernames"` has nothing to
|
|
11
|
+
# resolve it: copies the gem's Stimulus controller to
|
|
12
|
+
# app/javascript/controllers/username_check_controller.js, where the
|
|
13
|
+
# bundler and Stimulus' manifest pick it up. From then on the file is
|
|
14
|
+
# the app's: run this again after a gem upgrade to refresh it.
|
|
15
|
+
class JavascriptGenerator < Rails::Generators::Base
|
|
16
|
+
desc "Copy the username-check Stimulus controller into app/javascript/controllers (for jsbundling apps)."
|
|
17
|
+
|
|
18
|
+
SOURCE = File.expand_path("../../../app/assets/javascripts/usernames/username_check_controller.js", __dir__)
|
|
19
|
+
DESTINATION = "app/javascript/controllers/username_check_controller.js"
|
|
20
|
+
|
|
21
|
+
def copy_the_controller
|
|
22
|
+
note = "// Copied from the usernames gem #{Usernames::VERSION} by `rails generate usernames:javascript`. " \
|
|
23
|
+
"Run it again after upgrading the gem.\n"
|
|
24
|
+
create_file DESTINATION, note + File.read(SOURCE, encoding: "UTF-8")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def display_post_install_message
|
|
28
|
+
say "\n\t#{DESTINATION} is in place.", :green
|
|
29
|
+
say "\nRegister it as username-check: `rails stimulus:manifest:update`, or in " \
|
|
30
|
+
"app/javascript/controllers/index.js:"
|
|
31
|
+
say %( import UsernameCheckController from "./username_check_controller"\n) +
|
|
32
|
+
%( application.register("username-check", UsernameCheckController))
|
|
33
|
+
say "Then f.username_field / f.username_status work as with importmap. The file is the app's now: " \
|
|
34
|
+
"the gem's fields and endpoint change with the gem, so refresh it when you upgrade.", :yellow
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usernames
|
|
4
|
+
module Generators
|
|
5
|
+
# Whether `import "usernames"` can work in the app being installed into,
|
|
6
|
+
# for `rails generate usernames:install`: importmap (nothing else
|
|
7
|
+
# resolves the specifier), an app/javascript/application.js entrypoint,
|
|
8
|
+
# and stimulus-rails' controllers/application.js exporting the
|
|
9
|
+
# `application` the gem's index.js imports by name. A static import of a
|
|
10
|
+
# name a module doesn't export fails the whole entrypoint before any of
|
|
11
|
+
# it runs, so anything short of a plain named export is a no. :nodoc:
|
|
12
|
+
module JavascriptSetup
|
|
13
|
+
APPLICATION_JS = "app/javascript/application.js"
|
|
14
|
+
STIMULUS_APPLICATION = "app/javascript/controllers/application.js"
|
|
15
|
+
IMPORTED = /^\s*import\s+["']usernames["']/
|
|
16
|
+
|
|
17
|
+
# `export const application = …` (or let, var).
|
|
18
|
+
EXPORTS_A_BINDING = /\bexport\s+(?:const|let|var)\s+application\b/
|
|
19
|
+
|
|
20
|
+
# `export { … }`, with an optional `from "…"`: the list, to read.
|
|
21
|
+
EXPORT_LISTS = /\bexport\s*\{([^}]*)\}/
|
|
22
|
+
|
|
23
|
+
# One entry of such a list: `application`, `application as application`,
|
|
24
|
+
# or `other as application` — whatever is exported under that name.
|
|
25
|
+
EXPORTED_AS_APPLICATION = /\A(?:application|[\w$]+\s+as\s+application)\z/
|
|
26
|
+
|
|
27
|
+
QUOTES = %w[' " `].freeze
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# :ready, :imported, or what is missing for the line to work. Read
|
|
32
|
+
# once, before the line is added, so the message can say it was.
|
|
33
|
+
def javascript_setup
|
|
34
|
+
@javascript_setup ||=
|
|
35
|
+
if !File.exist?(destination("config/importmap.rb")) then :no_importmap
|
|
36
|
+
elsif !File.exist?(destination(APPLICATION_JS)) then :no_entrypoint
|
|
37
|
+
elsif code(APPLICATION_JS, strings: true).match?(IMPORTED) then :imported
|
|
38
|
+
elsif !File.exist?(destination(STIMULUS_APPLICATION)) then :no_stimulus
|
|
39
|
+
elsif uncertain?(STIMULUS_APPLICATION) then :uncertain_source
|
|
40
|
+
elsif !exports_application?(code(STIMULUS_APPLICATION)) then :no_application_export
|
|
41
|
+
else :ready
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def exports_application?(source)
|
|
46
|
+
source.match?(EXPORTS_A_BINDING) || source.scan(EXPORT_LISTS).flatten.any? do |list|
|
|
47
|
+
list.split(",").any? { |entry| entry.strip.match?(EXPORTED_AS_APPLICATION) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A file read as code, in one pass: each comment becomes one space
|
|
52
|
+
# (so `export/* c */const` still reads as two words), each string or
|
|
53
|
+
# template literal `""` (so `"export { application }"` exports
|
|
54
|
+
# nothing, and the `//` in `"https://…"` starts no comment).
|
|
55
|
+
# `strings: true` keeps '…' and "…" strings as written, to read an
|
|
56
|
+
# import's specifier.
|
|
57
|
+
def code(path, strings: false)
|
|
58
|
+
scan(path, strings).first
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether the file has what this reading doesn't follow, and so won't
|
|
62
|
+
# guess about: a template literal interpolating `${…}` (which may nest
|
|
63
|
+
# code, comments and more templates) or a `/` that starts no comment —
|
|
64
|
+
# division, or a regular-expression literal that may hold anything.
|
|
65
|
+
# stimulus-rails' generated controllers/application.js has neither.
|
|
66
|
+
def uncertain?(path)
|
|
67
|
+
scan(path, false).last
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# [code, uncertain]
|
|
71
|
+
def scan(path, strings)
|
|
72
|
+
source = File.read(destination(path), encoding: "UTF-8")
|
|
73
|
+
code = +""
|
|
74
|
+
uncertain = false
|
|
75
|
+
index = 0
|
|
76
|
+
while index < source.length
|
|
77
|
+
finish = token_end(source, index)
|
|
78
|
+
uncertain ||= finish ? interpolates?(source, index, finish) : source[index] == "/"
|
|
79
|
+
code << (finish ? token(source, index, finish, strings) : source[index])
|
|
80
|
+
index = finish || (index + 1)
|
|
81
|
+
end
|
|
82
|
+
[code, uncertain]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def interpolates?(source, start, finish)
|
|
86
|
+
source[start] == "`" && source[start...finish].include?("${")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Where the comment or literal starting at `index` ends, or nil for
|
|
90
|
+
# any other character.
|
|
91
|
+
def token_end(source, index)
|
|
92
|
+
case source[index, 2]
|
|
93
|
+
when "//" then source.index("\n", index) || source.length
|
|
94
|
+
when "/*" then (finish = source.index("*/", index + 2)) ? finish + 2 : source.length
|
|
95
|
+
else literal_end(source, index) if QUOTES.include?(source[index])
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def token(source, start, finish, strings)
|
|
100
|
+
return " " if source[start] == "/"
|
|
101
|
+
return source[start...finish] if strings && source[start] != "`"
|
|
102
|
+
|
|
103
|
+
'""'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Just past the literal opening at `start`: its closing quote, with
|
|
107
|
+
# backslash escapes skipped. A '…' or "…" string ends at a line break
|
|
108
|
+
# at the latest; a template's `${…}` is read as part of it.
|
|
109
|
+
def literal_end(source, start)
|
|
110
|
+
quote = source[start]
|
|
111
|
+
index = start + 1
|
|
112
|
+
while index < source.length
|
|
113
|
+
return index + 1 if source[index] == quote
|
|
114
|
+
return index if source[index] == "\n" && quote != "`"
|
|
115
|
+
|
|
116
|
+
index += source[index] == "\\" ? 2 : 1
|
|
117
|
+
end
|
|
118
|
+
source.length
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def destination(path) = File.join(destination_root, path)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|