studio-engine 0.117.0 → 0.119.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 +57 -0
- data/README.md +43 -1
- data/app/assets/tailwind/studio_engine/engine-motion.css +323 -0
- data/app/assets/tailwind/studio_engine/engine.css +44 -0
- data/app/controllers/omniauth_callbacks_controller.rb +15 -2
- data/app/controllers/studio/google_setup_requests_controller.rb +66 -0
- data/app/helpers/studio/loader_helper.rb +79 -0
- data/app/helpers/studio_theme_helper.rb +1 -1
- data/app/javascript/studio/application.js +2 -0
- data/app/javascript/studio/loader.js +214 -0
- data/app/mailers/studio/google_setup_mailer.rb +29 -0
- data/app/models/concerns/studio/omniauth_user.rb +153 -0
- data/app/views/registrations/new.html.erb +3 -5
- data/app/views/sessions/new.html.erb +3 -5
- data/app/views/studio/google_setup_mailer/setup_requested.html.erb +18 -0
- data/app/views/studio/google_setup_mailer/setup_requested.text.erb +23 -0
- data/app/views/studio/google_sign_in/_button.html.erb +52 -0
- data/app/views/studio/google_sign_in/_setup_modal.html.erb +55 -0
- data/app/views/studio/loader/_loader.html.erb +75 -0
- data/app/views/studio/loader/_overlay.html.erb +16 -0
- data/app/views/studio/profiles/_google_section.html.erb +5 -5
- data/app/views/style/_tricks.html.erb +59 -0
- data/lib/studio/engine.rb +25 -0
- data/lib/studio/google_sign_in.rb +361 -0
- data/lib/studio/loader.rb +158 -0
- data/lib/studio/type_scale.rb +64 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +63 -0
- data/studio-engine.gemspec +8 -0
- metadata +75 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<%# The Google setup modal, opened by studio/google_sign_in/_button while the
|
|
2
|
+
app has no OAuth client. One root element: the host's template x-if clones
|
|
3
|
+
only the first. No double quote inside any x-data or @click below.
|
|
4
|
+
|
|
5
|
+
Locals:
|
|
6
|
+
email_fallback — true: "Use email instead" closes the modal and focuses
|
|
7
|
+
the page's email field. false: "Not now" only closes it.
|
|
8
|
+
The focus waits out the close: the host hands focus
|
|
9
|
+
back to the opener when its last card leaves (220ms,
|
|
10
|
+
CLOSE_ANIM_MS), which would otherwise undo it.
|
|
11
|
+
|
|
12
|
+
"Request Google setup" is a plain form POST with Turbo off, so it works with
|
|
13
|
+
the same CSRF token as every other engine form, and the flash on the page it
|
|
14
|
+
lands back on says what happened. Hidden when the host draws its own routes
|
|
15
|
+
without Studio.routes, which leaves nowhere to send it. %>
|
|
16
|
+
<%
|
|
17
|
+
setup_email_fallback = local_assigns.fetch(:email_fallback, true)
|
|
18
|
+
setup_request_path = respond_to?(:studio_google_setup_request_path) ? studio_google_setup_request_path : nil
|
|
19
|
+
%>
|
|
20
|
+
<div class="text-center" data-google-setup-modal>
|
|
21
|
+
<div class="flex justify-end -mt-2 -mr-2">
|
|
22
|
+
<button type="button" @click="$store.googleSetupModals.close()"
|
|
23
|
+
class="text-secondary hover:text-heading text-xl leading-none"
|
|
24
|
+
aria-label="Close">×</button>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="mx-auto mb-4 w-12 h-12 rounded-full bg-white border border-subtle shadow-sm flex items-center justify-center">
|
|
28
|
+
<%= render "components/google_logo" %>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<h3 class="text-heading font-bold text-lg leading-tight">Google sign-in is almost ready</h3>
|
|
32
|
+
<p class="text-secondary text-sm mt-2">
|
|
33
|
+
Google sign-in is still being set up for <%= Studio.app_name %>.
|
|
34
|
+
<% if setup_request_path %>Ask the team to finish it, and<% else %>Until it is,<% end %>
|
|
35
|
+
<% if setup_email_fallback %>sign in with your email in the meantime.<% else %>use another sign-in method in the meantime.<% end %>
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<div class="mt-6 flex flex-col gap-3">
|
|
39
|
+
<% if setup_request_path %>
|
|
40
|
+
<%= button_to "Request Google setup", setup_request_path, method: :post,
|
|
41
|
+
data: { turbo: false }, class: "btn btn-primary w-full", form: { class: "w-full" } %>
|
|
42
|
+
<% end %>
|
|
43
|
+
|
|
44
|
+
<% if setup_email_fallback %>
|
|
45
|
+
<button type="button" class="btn btn-neutral w-full"
|
|
46
|
+
@click="$store.googleSetupModals.close(); setTimeout(() => document.querySelector('input[type=email]')?.focus(), 260)">
|
|
47
|
+
Use email instead
|
|
48
|
+
</button>
|
|
49
|
+
<% else %>
|
|
50
|
+
<button type="button" class="btn btn-neutral w-full" @click="$store.googleSetupModals.close()">
|
|
51
|
+
Not now
|
|
52
|
+
</button>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<%# locals: (style:, size:, icon:, art:, label:, percent:, mono:, pending:, uid:, html: {}) %>
|
|
2
|
+
<%#
|
|
3
|
+
The branded loader (Studio::LoaderHelper#studio_loader, docs/LOADER.md): ONE
|
|
4
|
+
class, .studio-loader, sized by --studio-loader-size, styled per
|
|
5
|
+
data-loader-style by engine-motion.css (section "Branded loader"). The icon is
|
|
6
|
+
the app's own file; a signature style inlines the art's marked layers over it.
|
|
7
|
+
|
|
8
|
+
A pending loader sits inside a button: hidden from assistive tech (the button
|
|
9
|
+
carries aria-busy) and from sight until the button or its form is busy.
|
|
10
|
+
%>
|
|
11
|
+
<%
|
|
12
|
+
extra = html.dup
|
|
13
|
+
data = (extra.delete(:data) || {}).merge(loader_style: style.to_s.tr("_", "-"))
|
|
14
|
+
data[:loader_pending] = "" if pending
|
|
15
|
+
data[:loader_mono] = "" if mono
|
|
16
|
+
data[:loader_percent] = "" unless percent.nil?
|
|
17
|
+
vars = [ "--studio-loader-size: #{size}" ]
|
|
18
|
+
vars << "--studio-loader-percent: #{percent}" unless percent.nil?
|
|
19
|
+
vars << extra.delete(:style) if extra[:style]
|
|
20
|
+
classes = [ "studio-loader", extra.delete(:class) ].compact.join(" ")
|
|
21
|
+
draw_icon = lambda do |modifier = nil|
|
|
22
|
+
css = [ "studio-loader-icon", modifier ].compact.join(" ")
|
|
23
|
+
if icon.blank?
|
|
24
|
+
tag.span(class: css, data: { loader_blank: "" })
|
|
25
|
+
elsif mono
|
|
26
|
+
tag.span(class: css, style: "--studio-loader-mask: url(\"#{icon.to_s.gsub('"', '%22')}\")")
|
|
27
|
+
else
|
|
28
|
+
tag.img(src: icon, alt: "", class: css, draggable: "false", decoding: "async")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
aria = pending ? { hidden: "true" } : { live: "polite" }
|
|
32
|
+
%>
|
|
33
|
+
<%= tag.span(class: classes, role: (pending ? nil : "status"), aria: aria, data: data, style: vars.join("; "), **extra) do %>
|
|
34
|
+
<% case style %>
|
|
35
|
+
<% when :breathe %>
|
|
36
|
+
<span class="studio-loader-halo" aria-hidden="true"></span>
|
|
37
|
+
<%= draw_icon.call %>
|
|
38
|
+
<% when :liquid %>
|
|
39
|
+
<%= draw_icon.call("studio-loader-ghost") %>
|
|
40
|
+
<span class="studio-loader-fill" aria-hidden="true"><%= draw_icon.call %></span>
|
|
41
|
+
<% when :orbit %>
|
|
42
|
+
<span class="studio-loader-track" aria-hidden="true"></span>
|
|
43
|
+
<%= draw_icon.call %>
|
|
44
|
+
<span class="studio-loader-orb" aria-hidden="true"><span class="studio-loader-trail"></span><span class="studio-loader-spark"></span></span>
|
|
45
|
+
<% when :steel %>
|
|
46
|
+
<svg class="studio-loader-frame" viewBox="0 0 100 100" aria-hidden="true"><path d="M0,14V0H14M86,0H100V14M100,86V100H86M14,100H0V86"/></svg>
|
|
47
|
+
<%= draw_icon.call %>
|
|
48
|
+
<% when :weld_bead %>
|
|
49
|
+
<%= draw_icon.call %>
|
|
50
|
+
<svg class="studio-loader-art" viewBox="<%= art[:view_box] %>" aria-hidden="true">
|
|
51
|
+
<defs><filter id="<%= uid %>-glow" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="<%= (art[:width] / 52.86).round(2) %>" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
|
|
52
|
+
<% outline = art[:layers].first %>
|
|
53
|
+
<% layers = studio_loader_bead_layers(art) %>
|
|
54
|
+
<% [ layers.reject { |l| l[:glow] }, layers.select { |l| l[:glow] } ].each_with_index do |group, glowing| %>
|
|
55
|
+
<%= tag.g(filter: (glowing == 1 ? "url(##{uid}-glow)" : nil), transform: outline[:transform]) do %>
|
|
56
|
+
<% group.each do |layer| %>
|
|
57
|
+
<%= tag.path(d: outline[:d], pathLength: "100", class: "studio-loader-bead", data: { layer: layer[:n] },
|
|
58
|
+
"stroke-width": layer[:width], "stroke-opacity": layer[:opacity],
|
|
59
|
+
"stroke-dasharray": "#{layer[:dash]} #{(100 - layer[:dash]).round(2)}",
|
|
60
|
+
style: "--studio-loader-bead-length: #{layer[:dash]}") %>
|
|
61
|
+
<% end %>
|
|
62
|
+
<% end %>
|
|
63
|
+
<% end %>
|
|
64
|
+
</svg>
|
|
65
|
+
<% when :sparkle %>
|
|
66
|
+
<%= draw_icon.call %>
|
|
67
|
+
<svg class="studio-loader-art" viewBox="<%= art[:view_box] %>" aria-hidden="true">
|
|
68
|
+
<% art[:layers].each_with_index do |spark, index| %>
|
|
69
|
+
<%= tag.path(d: spark[:d], transform: spark[:transform], class: "studio-loader-sparkle",
|
|
70
|
+
style: "--studio-loader-i: #{index}; --studio-loader-sparks: #{art[:layers].size}") %>
|
|
71
|
+
<% end %>
|
|
72
|
+
</svg>
|
|
73
|
+
<% end %>
|
|
74
|
+
<% unless pending || label.blank? %><span class="sr-only"><%= label %></span><% end %>
|
|
75
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# locals: (label:, delay:, max_wait:, size:, options: {}) %>
|
|
2
|
+
<%#
|
|
3
|
+
The page-load overlay (Studio::LoaderHelper#studio_loader_overlay). Rendered
|
|
4
|
+
hidden, once per page; studio/loader shows it when a visit the user started
|
|
5
|
+
or a form marked data-studio-loader-overlay outlasts data-delay ms, clears it
|
|
6
|
+
after data-max-wait ms at most, and window.StudioLoader.show(caption) /
|
|
7
|
+
hide() drive it for any other long wait. The caption line is empty until a
|
|
8
|
+
caller names one. It never marks itself aria-busy: that would mute the
|
|
9
|
+
"Loading" status inside it.
|
|
10
|
+
%>
|
|
11
|
+
<%= tag.div(class: "studio-loader-overlay", hidden: true, data: { studio_loader_overlay: "", delay: delay, max_wait: max_wait }) do %>
|
|
12
|
+
<div class="studio-loader-overlay-panel">
|
|
13
|
+
<%= studio_loader(size: size, label: label, **options) %>
|
|
14
|
+
<p class="studio-loader-caption" data-studio-loader-caption></p>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
<% end %>
|
|
49
49
|
</div>
|
|
50
50
|
<% else %>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
<%# A POST to OmniAuth once the app has its OAuth client, the setup modal
|
|
52
|
+
until then (studio/google_sign_in/_button). Already signed in, so the
|
|
53
|
+
modal offers "Not now" rather than a way back to email. %>
|
|
54
|
+
<%= render "studio/google_sign_in/button", label: "Link Google Account",
|
|
55
|
+
button_class: "btn btn-google gap-3 w-full", email_fallback: false %>
|
|
56
56
|
<% end %>
|
|
@@ -185,6 +185,65 @@
|
|
|
185
185
|
</div>
|
|
186
186
|
</section>
|
|
187
187
|
|
|
188
|
+
<!-- Branded loader -->
|
|
189
|
+
<%
|
|
190
|
+
loader_config = Studio.loader
|
|
191
|
+
# An ERB tag as text, built so this file never holds a closing tag inside one.
|
|
192
|
+
erb_tag = ->(code) { "<" + "%= #{code} %" + ">" }
|
|
193
|
+
loader_art_styles = Studio::Loader::ART_STYLES.keys.select do |style|
|
|
194
|
+
loader_config[:art] && Studio::Loader.art(loader_config[:art], style)
|
|
195
|
+
end
|
|
196
|
+
%>
|
|
197
|
+
<section id="loader" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
198
|
+
<div class="space-y-1">
|
|
199
|
+
<h3 class="text-xl font-bold text-heading">Branded loader</h3>
|
|
200
|
+
<p class="text-muted text-sm max-w-2xl">
|
|
201
|
+
This app's own icon in its theme colours, in the style <code class="font-mono text-2xs">config.loader</code>
|
|
202
|
+
names (<strong><%= loader_config[:style].to_s.tr("_", " ") %></strong>). Page loads, long waits and pending
|
|
203
|
+
buttons all draw it. One class, <code class="font-mono text-2xs">.studio-loader</code>, sized by
|
|
204
|
+
<code class="font-mono text-2xs">--studio-loader-size</code>. studio-engine docs/LOADER.md.
|
|
205
|
+
</p>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
208
|
+
<%= render layout: "style/specimen",
|
|
209
|
+
locals: { klass: ".studio-loader (this app's style)",
|
|
210
|
+
usage: [erb_tag.call("studio_loader"), erb_tag.call('studio_loader size: 20, label: "Saving"')].join("\n") } do %>
|
|
211
|
+
<%= studio_loader %>
|
|
212
|
+
<%= studio_loader size: 32 %>
|
|
213
|
+
<%= studio_loader size: 20 %>
|
|
214
|
+
<% end %>
|
|
215
|
+
|
|
216
|
+
<%= render layout: "style/specimen",
|
|
217
|
+
locals: { klass: "studio_loader_pending (inside a busy button)",
|
|
218
|
+
usage: ["<button class=\"btn btn-primary\">", " <span data-loader-idle>Save</span>", " #{erb_tag.call('studio_loader_pending')}", "</button>"].join("\n") } do %>
|
|
219
|
+
<button type="button" class="btn btn-primary" aria-busy="true">
|
|
220
|
+
<%= studio_loader_pending %><span class="ml-2">Saving…</span>
|
|
221
|
+
</button>
|
|
222
|
+
<% end %>
|
|
223
|
+
|
|
224
|
+
<%= render layout: "style/specimen",
|
|
225
|
+
locals: { klass: "studio_loader_overlay (the page-load overlay)",
|
|
226
|
+
usage: ["#{erb_tag.call('studio_loader_overlay')} (once, in the layout body)", 'window.StudioLoader.show("Caption") / .hide()'].join("\n") } do %>
|
|
227
|
+
<button type="button" class="btn btn-secondary" data-test="style-show-loader-overlay"
|
|
228
|
+
x-data x-on:click="window.StudioLoader && window.StudioLoader.show('Loading your page…'); setTimeout(() => window.StudioLoader && window.StudioLoader.hide(), 3000)">
|
|
229
|
+
Show the overlay for 3 seconds
|
|
230
|
+
</button>
|
|
231
|
+
<% end %>
|
|
232
|
+
|
|
233
|
+
<% Studio::Loader::STYLES.each do |style| %>
|
|
234
|
+
<% signature = Studio::Loader.art_style?(style) %>
|
|
235
|
+
<% unavailable = signature && !loader_art_styles.include?(style) %>
|
|
236
|
+
<%= render layout: "style/specimen",
|
|
237
|
+
locals: { klass: %(style: :#{style}#{signature ? " (signature, needs art)" : ""}),
|
|
238
|
+
usage: %(config.loader = #{signature ? "{ style: :#{style}, art: \"/brand/loader-#{style}.svg\" }" : ":#{style}"}),
|
|
239
|
+
disabled: unavailable, disabled_label: "needs this app's art file" } do %>
|
|
240
|
+
<%= studio_loader style: style, size: 72 %>
|
|
241
|
+
<%= studio_loader style: style, size: 28 %>
|
|
242
|
+
<% end %>
|
|
243
|
+
<% end %>
|
|
244
|
+
</div>
|
|
245
|
+
</section>
|
|
246
|
+
|
|
188
247
|
<!-- Motion primitives -->
|
|
189
248
|
<section class="space-y-5">
|
|
190
249
|
<div class="space-y-1">
|
data/lib/studio/engine.rb
CHANGED
|
@@ -11,6 +11,15 @@ require_relative "component_gallery"
|
|
|
11
11
|
# the order this file saw; out of order, the gallery is not mounted.
|
|
12
12
|
Studio::ComponentGallery.lookbook_loaded_before_engine = defined?(::Lookbook::Engine) ? true : false
|
|
13
13
|
require "view_component"
|
|
14
|
+
# Google sign-in ships ready (Studio::GoogleSignIn): the strategy, and the CSRF
|
|
15
|
+
# check on its POST-only request phase. Required here because Bundler.require
|
|
16
|
+
# loads only the gems a host's Gemfile names, and a new app names none of these.
|
|
17
|
+
# The CSRF gem registers a Railtie, so it has to load before the app initializes,
|
|
18
|
+
# which a require at engine load does. Idempotent beside a host's own require.
|
|
19
|
+
require "omniauth"
|
|
20
|
+
require "omniauth-google-oauth2"
|
|
21
|
+
require "omniauth/rails_csrf_protection"
|
|
22
|
+
require_relative "google_sign_in"
|
|
14
23
|
|
|
15
24
|
module Studio
|
|
16
25
|
class Engine < ::Rails::Engine
|
|
@@ -274,6 +283,22 @@ module Studio
|
|
|
274
283
|
.sort
|
|
275
284
|
end
|
|
276
285
|
|
|
286
|
+
# GOOGLE SIGN-IN, the engine's registration (Studio::GoogleSignIn). The
|
|
287
|
+
# middleware is added to every host and decides per request whether it is
|
|
288
|
+
# the google_oauth2 strategy or a pass-through: a host whose own
|
|
289
|
+
# config/initializers/omniauth.rb registers google_oauth2 keeps its own, and
|
|
290
|
+
# the engine never registers a second. It cannot decide here, because the
|
|
291
|
+
# host's initializers have not run yet. `use` appends, and this runs before
|
|
292
|
+
# the host's initializers, so the engine's middleware sits outside the
|
|
293
|
+
# host's OmniAuth::Builder and hands the request on to it.
|
|
294
|
+
#
|
|
295
|
+
# The request phase is POST-only by OmniAuth 2's own default
|
|
296
|
+
# (allowed_request_methods [:post]), and omniauth-rails_csrf_protection
|
|
297
|
+
# checks the token on it; nothing here changes either.
|
|
298
|
+
initializer "studio.google_sign_in" do |app|
|
|
299
|
+
app.config.middleware.use Studio::GoogleSignIn::Middleware
|
|
300
|
+
end
|
|
301
|
+
|
|
277
302
|
initializer "studio.footer_only_eager_load" do
|
|
278
303
|
next if Studio.active_record?
|
|
279
304
|
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# Google sign-in that ships ready with every app (docs/GOOGLE_AUTH_SETUP.md).
|
|
5
|
+
#
|
|
6
|
+
# Alex McRitchie, 2026-10-29: "I want this to be automatic with new apps. Even
|
|
7
|
+
# if there is still manual work to be done, please have the link for google
|
|
8
|
+
# login. If there is manual work to be done to properly setup Google, the link
|
|
9
|
+
# should show a modal requesting access to Google auth."
|
|
10
|
+
#
|
|
11
|
+
# So an app that bundles studio-engine gets, with nothing of its own:
|
|
12
|
+
#
|
|
13
|
+
# * the google_oauth2 OmniAuth strategy, registered by the engine's
|
|
14
|
+
# Middleware below, UNLESS the host's own initializer registered one;
|
|
15
|
+
# * a default User.from_omniauth (Studio::OmniauthUser), which a host's own
|
|
16
|
+
# definition overrides;
|
|
17
|
+
# * the "Sign in with Google" button on /login, /signup and /profile. While
|
|
18
|
+
# the OAuth client is not configured (ready? false) the button opens a
|
|
19
|
+
# modal that asks the team to finish setup, instead of posting to a
|
|
20
|
+
# strategy that would send the person to a Google error page.
|
|
21
|
+
#
|
|
22
|
+
# THE ONE MANUAL STEP that cannot ship in a gem: register the app's callback
|
|
23
|
+
# (redirect_uri below) on the shared production OAuth client and set
|
|
24
|
+
# GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. The modal's "Request Google setup"
|
|
25
|
+
# mails the team exactly that.
|
|
26
|
+
#
|
|
27
|
+
# Pure Ruby apart from the methods that read Rails.application or OmniAuth,
|
|
28
|
+
# each of which guards on the constant, so the unit suite loads this file as is.
|
|
29
|
+
module GoogleSignIn
|
|
30
|
+
PROVIDER = "google_oauth2"
|
|
31
|
+
REQUEST_PATH = "/auth/google_oauth2"
|
|
32
|
+
CALLBACK_PATH = "/auth/google_oauth2/callback"
|
|
33
|
+
SCOPE = "email,profile"
|
|
34
|
+
PROMPT = "select_account"
|
|
35
|
+
CREDENTIAL_KEYS = %w[GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET].freeze
|
|
36
|
+
|
|
37
|
+
# How long a "Request Google setup" claim holds. The key is per day, so this
|
|
38
|
+
# only has to outlive the day it names.
|
|
39
|
+
REQUEST_TTL_SECONDS = 26 * 60 * 60
|
|
40
|
+
|
|
41
|
+
# Records what an OmniAuth::Builder block registers without registering
|
|
42
|
+
# anything: `provider` notes its first argument, and every other call a
|
|
43
|
+
# host's block makes (configure, on_failure, options) is swallowed.
|
|
44
|
+
class ProviderRecorder
|
|
45
|
+
attr_reader :providers
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@providers = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def provider(klass, *_args, **_options, &_block)
|
|
52
|
+
@providers << klass
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def method_missing(*_args, **_options, &_block)
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def respond_to_missing?(*_args)
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The engine's half of the registration: a Rack middleware the engine adds
|
|
66
|
+
# to every host's stack (Studio::Engine, "studio.google_sign_in"). It is the
|
|
67
|
+
# google_oauth2 strategy when the engine owns Google, and a pass-through
|
|
68
|
+
# otherwise:
|
|
69
|
+
#
|
|
70
|
+
# * the host registered its own provider -> pass through; the host's wins
|
|
71
|
+
# * no credentials and not OmniAuth test mode -> pass through, so POST
|
|
72
|
+
# /auth/google_oauth2 answers 404 rather than a Google error page
|
|
73
|
+
# * otherwise -> the strategy, built from the credentials of the moment
|
|
74
|
+
#
|
|
75
|
+
# Deciding per request rather than at boot is what makes host detection
|
|
76
|
+
# possible: the engine's initializers run BEFORE the host's
|
|
77
|
+
# config/initializers, so at boot the host's OmniAuth::Builder is not yet on
|
|
78
|
+
# the stack to be seen.
|
|
79
|
+
class Middleware
|
|
80
|
+
def initialize(app)
|
|
81
|
+
@app = app
|
|
82
|
+
@strategies = {}
|
|
83
|
+
@lock = Mutex.new
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def call(env)
|
|
87
|
+
return @app.call(env) unless GoogleSignIn.engine_registers?
|
|
88
|
+
|
|
89
|
+
strategy(*GoogleSignIn.credentials).call(env)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# The strategy's callback host is pinned when the app names one (see
|
|
95
|
+
# GoogleSignIn.pinned_full_host), and otherwise OmniAuth's allowed_hosts
|
|
96
|
+
# is seeded from config.hosts (GoogleSignIn.seed_allowed_hosts!), so a
|
|
97
|
+
# forged Host or X-Forwarded-Host never becomes the redirect_uri Google
|
|
98
|
+
# sees. Either one also settles OmniAuth 2.3's request-host warning.
|
|
99
|
+
def strategy(client_id, client_secret)
|
|
100
|
+
@lock.synchronize do
|
|
101
|
+
@strategies[[client_id, client_secret]] ||= begin
|
|
102
|
+
options = { scope: SCOPE, prompt: PROMPT }
|
|
103
|
+
if (full_host = GoogleSignIn.pinned_full_host)
|
|
104
|
+
options[:full_host] = full_host
|
|
105
|
+
else
|
|
106
|
+
GoogleSignIn.seed_allowed_hosts!
|
|
107
|
+
end
|
|
108
|
+
::OmniAuth::Strategies::GoogleOauth2.new(@app, client_id, client_secret, **options)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class << self
|
|
115
|
+
# True when the app holds an OAuth client: both env vars present.
|
|
116
|
+
def ready?(env: ENV)
|
|
117
|
+
CREDENTIAL_KEYS.all? { |key| !env[key].to_s.strip.empty? }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def credentials(env: ENV)
|
|
121
|
+
CREDENTIAL_KEYS.map { |key| env[key].to_s.strip.then { |value| value.empty? ? nil : value } }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# OmniAuth's test mode answers every request with the mock, so the button
|
|
125
|
+
# posts there whatever the credentials say.
|
|
126
|
+
def mock_mode?
|
|
127
|
+
defined?(::OmniAuth) && ::OmniAuth.config.test_mode ? true : false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Whether the Google button POSTs to the strategy (true) or opens the
|
|
131
|
+
# setup modal (false). A host that registered its own provider owns its
|
|
132
|
+
# readiness, so its button keeps posting exactly as it did before the
|
|
133
|
+
# engine shipped one: hub, turf-monster, moms-app, cyvasse and
|
|
134
|
+
# acquisition-studio test their buttons as POST forms with no credentials.
|
|
135
|
+
def button_posts?(env: ENV)
|
|
136
|
+
ready?(env: env) || mock_mode? || host_registered?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Whether the engine's own strategy answers /auth/google_oauth2.
|
|
140
|
+
def engine_registers?(env: ENV)
|
|
141
|
+
return false if host_registered?
|
|
142
|
+
|
|
143
|
+
ready?(env: env) || mock_mode?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# :host, :engine or :none: who answers /auth/google_oauth2 right now.
|
|
147
|
+
def registration(env: ENV)
|
|
148
|
+
return :host if host_registered?
|
|
149
|
+
|
|
150
|
+
engine_registers?(env: env) ? :engine : :none
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The callback a Google OAuth client must list for this host.
|
|
154
|
+
def redirect_uri(host:, scheme: "https")
|
|
155
|
+
"#{scheme}://#{host}#{CALLBACK_PATH}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ---- The app's canonical host: from configuration, NEVER the request ----
|
|
159
|
+
#
|
|
160
|
+
# "Request Google setup" mails ops a redirect URI to add to the shared
|
|
161
|
+
# production OAuth client, so that URI must not come from a header a
|
|
162
|
+
# stranger controls (Host, X-Forwarded-Host). In order:
|
|
163
|
+
#
|
|
164
|
+
# 1. Studio.google_canonical_host, set in config/initializers/studio.rb
|
|
165
|
+
# 2. ENV["APP_HOST"], the deployed apps' canonical host (the hub's
|
|
166
|
+
# CanonicalHost reads the same variable)
|
|
167
|
+
# 3. the first config.hosts entry that is a plain hostname: a String of
|
|
168
|
+
# letters, digits, dots and hyphens, no port, no leading dot
|
|
169
|
+
#
|
|
170
|
+
# nil when none is configured; the email then says so instead of naming
|
|
171
|
+
# a host.
|
|
172
|
+
PLAIN_HOSTNAME = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\z/i
|
|
173
|
+
|
|
174
|
+
def canonical_host(env: ENV, configured: configured_canonical_host, hosts: rails_config_hosts)
|
|
175
|
+
explicit = normalize_host(configured) || normalize_host(env["APP_HOST"])
|
|
176
|
+
return explicit if explicit
|
|
177
|
+
|
|
178
|
+
Array(hosts).find { |entry| entry.is_a?(String) && PLAIN_HOSTNAME.match?(entry) }&.downcase
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Scheme and host only, lowercased: "https://App.Example/x" -> "app.example".
|
|
182
|
+
# A port an operator wrote into APP_HOST is kept; it is configuration.
|
|
183
|
+
def normalize_host(value)
|
|
184
|
+
text = value.to_s.strip.sub(%r{\A[a-z][a-z0-9+.-]*://}i, "").split("/").first.to_s.downcase
|
|
185
|
+
text.empty? ? nil : text
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def canonical_scheme(production: rails_production?)
|
|
189
|
+
production ? "https" : "http"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The redirect URI to register, from the canonical host; nil when the app
|
|
193
|
+
# names none.
|
|
194
|
+
def canonical_redirect_uri(env: ENV, production: rails_production?)
|
|
195
|
+
host = canonical_host(env: env)
|
|
196
|
+
host && redirect_uri(host: host, scheme: canonical_scheme(production: production))
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The strategy's full_host: pinned only in production and only from an
|
|
200
|
+
# EXPLICIT host (Studio.google_canonical_host or APP_HOST). A desk on
|
|
201
|
+
# localhost:<port> must keep its own port in the callback, and a QA app's
|
|
202
|
+
# config.hosts may list the production host first.
|
|
203
|
+
def pinned_full_host(env: ENV, production: rails_production?)
|
|
204
|
+
return nil unless production
|
|
205
|
+
|
|
206
|
+
host = normalize_host(configured_canonical_host) || normalize_host(env["APP_HOST"])
|
|
207
|
+
host && "https://#{host}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# OmniAuth.config.allowed_hosts mirrored from config.hosts, so OmniAuth
|
|
211
|
+
# builds callbacks only on hosts the app already accepts. Left alone when
|
|
212
|
+
# the app set OmniAuth's own host config, when config.hosts is empty, or
|
|
213
|
+
# when it admits every host anyway (an IPAddr range, as development has).
|
|
214
|
+
# A leading-dot entry (".example.com") becomes the same subdomain pattern
|
|
215
|
+
# Rails gives it; ports are dropped, since OmniAuth compares bare hosts.
|
|
216
|
+
def allowed_hosts_from(hosts)
|
|
217
|
+
entries = Array(hosts)
|
|
218
|
+
return nil if entries.empty?
|
|
219
|
+
return nil if entries.any? { |entry| !entry.is_a?(String) && !entry.is_a?(Regexp) }
|
|
220
|
+
|
|
221
|
+
entries.map do |entry|
|
|
222
|
+
next entry if entry.is_a?(Regexp)
|
|
223
|
+
|
|
224
|
+
bare = entry.downcase.sub(/:\d+\z/, "")
|
|
225
|
+
bare.start_with?(".") ? /(?:[a-z0-9-]+\.)*#{Regexp.escape(bare.delete_prefix("."))}/i : bare
|
|
226
|
+
end.uniq
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def seed_allowed_hosts!(hosts: rails_config_hosts)
|
|
230
|
+
return unless defined?(::OmniAuth)
|
|
231
|
+
return if ::OmniAuth.config.full_host || ::OmniAuth.config.allowed_hosts
|
|
232
|
+
|
|
233
|
+
allowed = allowed_hosts_from(hosts)
|
|
234
|
+
::OmniAuth.config.allowed_hosts = allowed if allowed
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The once-a-day key: the app, not the request's host, so a forged Host or
|
|
238
|
+
# port cannot mint a fresh day.
|
|
239
|
+
def app_key
|
|
240
|
+
name = defined?(Studio) && Studio.respond_to?(:app_name) ? Studio.app_name.to_s : ""
|
|
241
|
+
key = name.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
|
|
242
|
+
key.empty? ? "app" : key
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Does a built middleware stack already register google_oauth2? Reads each
|
|
246
|
+
# entry's class and block (ActionDispatch::MiddlewareStack::Middleware):
|
|
247
|
+
#
|
|
248
|
+
# * the strategy used directly (`use OmniAuth::Strategies::GoogleOauth2`)
|
|
249
|
+
# * an OmniAuth::Builder whose block calls `provider :google_oauth2`,
|
|
250
|
+
# found by evaluating the block against a ProviderRecorder
|
|
251
|
+
#
|
|
252
|
+
# A Builder registering only other providers does not count, so the engine
|
|
253
|
+
# still supplies Google beside them. A block the recorder cannot evaluate
|
|
254
|
+
# counts as registering Google: two strategies on one path is the failure
|
|
255
|
+
# this exists to prevent, so doubt resolves toward the host.
|
|
256
|
+
def host_registers_google?(middlewares)
|
|
257
|
+
Array(middlewares).any? do |entry|
|
|
258
|
+
klass = entry.respond_to?(:klass) ? entry.klass : entry
|
|
259
|
+
name = klass.to_s
|
|
260
|
+
next false if name == Middleware.name
|
|
261
|
+
next true if google_provider?(klass)
|
|
262
|
+
next false unless omniauth_builder?(klass)
|
|
263
|
+
|
|
264
|
+
builder_registers_google?(entry.respond_to?(:block) ? entry.block : nil)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def builder_registers_google?(block)
|
|
269
|
+
return false unless block
|
|
270
|
+
|
|
271
|
+
recorder = ProviderRecorder.new
|
|
272
|
+
recorder.instance_eval(&block)
|
|
273
|
+
recorder.providers.any? { |provider| google_provider?(provider) }
|
|
274
|
+
rescue StandardError, ScriptError
|
|
275
|
+
true
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def google_provider?(provider)
|
|
279
|
+
name = provider.to_s
|
|
280
|
+
name == PROVIDER || name.end_with?("::GoogleOauth2")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def omniauth_builder?(klass)
|
|
284
|
+
return true if klass.to_s == "OmniAuth::Builder"
|
|
285
|
+
|
|
286
|
+
defined?(::OmniAuth::Builder) && klass.is_a?(Class) && klass < ::OmniAuth::Builder ? true : false
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Memoized once the app's stack is built; nil-safe before that (and in a
|
|
290
|
+
# process with no Rails app), answering false without memoizing.
|
|
291
|
+
def host_registered?
|
|
292
|
+
return @host_registered unless @host_registered.nil?
|
|
293
|
+
|
|
294
|
+
stack = rails_middleware_stack
|
|
295
|
+
return false unless stack
|
|
296
|
+
|
|
297
|
+
@host_registered = host_registers_google?(stack.middlewares)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def reset!
|
|
301
|
+
@host_registered = nil
|
|
302
|
+
@fallback_store = nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# "Request Google setup" is answered once per APP per day (key: app_key,
|
|
306
|
+
# never a request header). Returns true for the request that claims the
|
|
307
|
+
# day, false for every later one.
|
|
308
|
+
#
|
|
309
|
+
# Checked with exist? before the unless_exist write, because not every
|
|
310
|
+
# cache store honours unless_exist; the two together leave only a race
|
|
311
|
+
# between simultaneous first clicks, which costs one extra email.
|
|
312
|
+
def claim_daily_request(app = app_key, store: request_store, today: current_date)
|
|
313
|
+
key = "studio/google_setup_request/#{app.to_s.downcase}/#{today.iso8601}"
|
|
314
|
+
return false if store.exist?(key)
|
|
315
|
+
|
|
316
|
+
store.write(key, today.iso8601, unless_exist: true, expires_in: REQUEST_TTL_SECONDS) ? true : false
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# The app's Rails.cache, unless that is a NullStore, which remembers
|
|
320
|
+
# nothing and would let every click mail the team. Then a store of this
|
|
321
|
+
# process's own: a per-dyno limit, still a limit.
|
|
322
|
+
def request_store
|
|
323
|
+
cache = defined?(::Rails) && ::Rails.respond_to?(:cache) ? ::Rails.cache : nil
|
|
324
|
+
return cache if cache && !(defined?(::ActiveSupport::Cache::NullStore) && cache.is_a?(::ActiveSupport::Cache::NullStore))
|
|
325
|
+
|
|
326
|
+
@fallback_store ||= begin
|
|
327
|
+
require "active_support/cache"
|
|
328
|
+
::ActiveSupport::Cache::MemoryStore.new
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The app's day (Time.zone) where ActiveSupport has one, else the system's.
|
|
333
|
+
def current_date
|
|
334
|
+
Date.respond_to?(:current) ? Date.current : Date.today
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
private
|
|
338
|
+
|
|
339
|
+
def configured_canonical_host
|
|
340
|
+
defined?(Studio) && Studio.respond_to?(:google_canonical_host) ? Studio.google_canonical_host : nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def rails_config_hosts
|
|
344
|
+
return [] unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
345
|
+
|
|
346
|
+
Array(::Rails.application.config.hosts)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def rails_production?
|
|
350
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.production? ? true : false
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def rails_middleware_stack
|
|
354
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
355
|
+
|
|
356
|
+
stack = ::Rails.application.config.middleware
|
|
357
|
+
stack.respond_to?(:middlewares) ? stack : nil
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|