studio-engine 0.29.1 → 0.31.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 +254 -0
- data/README.md +5 -4
- data/app/assets/tailwind/studio_engine/engine.css +18 -0
- data/app/controllers/concerns/studio/error_handling.rb +7 -2
- data/app/controllers/concerns/studio/link_consumption.rb +131 -11
- data/app/controllers/concerns/studio/magic_link_issuing.rb +15 -20
- data/app/controllers/magic_links_controller.rb +13 -40
- data/app/controllers/registrations_controller.rb +3 -1
- data/app/controllers/studio/links_controller.rb +17 -19
- data/app/controllers/studio/local_reviews_controller.rb +4 -4
- data/app/helpers/studio_sidebar_helper.rb +22 -0
- data/app/mailers/user_mailer.rb +8 -7
- data/app/models/studio/link.rb +55 -2
- data/app/views/components/_link_sidebar.html.erb +179 -0
- data/app/views/components/_link_sidebar_trigger.html.erb +21 -0
- data/app/views/components/_sidebar_panel.html.erb +63 -0
- data/app/views/components/_user_nav.html.erb +13 -7
- data/app/views/layouts/_navbar.html.erb +29 -5
- data/app/views/navbar/show.html.erb +4 -1
- data/app/views/studio/_confirm_interstitial.html.erb +4 -3
- data/app/views/studio/banners/_button.html.erb +10 -2
- data/app/views/studio/banners/_email_status_button.html.erb +25 -6
- data/app/views/studio/banners/_environment.html.erb +38 -13
- data/db/migrate/20260620000002_allow_null_image_cache_owner.rb +10 -0
- data/lib/studio/environment_banner.rb +56 -0
- data/lib/studio/link_resolution.rb +139 -0
- data/lib/studio/link_token.rb +13 -4
- data/lib/studio/sidebar_sections.rb +34 -0
- data/lib/studio/theme_resolver.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +108 -28
- data/studio-engine.gemspec +5 -5
- metadata +14 -9
- data/app/services/magic_link.rb +0 -122
- data/app/views/magic_links/confirm.html.erb +0 -2
data/lib/studio.rb
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
require "studio/version"
|
|
2
2
|
require "studio/engine"
|
|
3
3
|
require "studio/color_scale"
|
|
4
|
+
require "studio/environment_banner"
|
|
4
5
|
require "studio/theme_resolver"
|
|
5
6
|
require "studio/ui_primitives"
|
|
7
|
+
require "studio/sidebar_sections"
|
|
6
8
|
require "studio/username_generator"
|
|
7
9
|
require "studio/s3"
|
|
8
10
|
require "studio/image_cache"
|
|
9
11
|
require "studio/link_token"
|
|
12
|
+
require "studio/link_resolution"
|
|
10
13
|
require "studio/email"
|
|
11
14
|
require "studio/email_smoke"
|
|
12
15
|
require "studio/mail_transport"
|
|
@@ -61,18 +64,57 @@ module Studio
|
|
|
61
64
|
# feature off (e.g. McRitchie Studio, which ships neither).
|
|
62
65
|
mattr_accessor :features, default: []
|
|
63
66
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
+
# ---- Sidebar navigation ----
|
|
68
|
+
# Out-of-the-box navigation: the engine navbar mounts a link-sidebar trigger
|
|
69
|
+
# and slide-out panel when the host declares sections here. The default []
|
|
70
|
+
# renders NOTHING, so existing consumers see no change on upgrade until they
|
|
71
|
+
# opt in. Accepts a static Array of section hashes or a callable (receives
|
|
72
|
+
# the view context) for dynamic sections — route helpers, logged_in? walls.
|
|
73
|
+
# Sections flagged admin: true render only for admin? viewers. Shape and
|
|
74
|
+
# resolution rules: lib/studio/sidebar_sections.rb.
|
|
75
|
+
#
|
|
76
|
+
# Studio.configure do |config|
|
|
77
|
+
# config.sidebar_sections = ->(view) {
|
|
78
|
+
# [ { title: "Site", links: [
|
|
79
|
+
# { label: "Home", href: view.root_path, emoji: "🏠" } ] } ]
|
|
80
|
+
# }
|
|
81
|
+
# end
|
|
82
|
+
mattr_accessor :sidebar_sections, default: []
|
|
83
|
+
|
|
84
|
+
# How long a freshly minted magic link stays live.
|
|
85
|
+
mattr_accessor :magic_link_ttl, default: 15.minutes
|
|
86
|
+
|
|
87
|
+
# RETIRED (0.31.0) — kept only so an initializer that still sets it boots.
|
|
88
|
+
# It named the MessageVerifier purpose for the old :signed store, which no
|
|
89
|
+
# longer exists. Delete the line from your initializer.
|
|
67
90
|
mattr_accessor :magic_link_token_name, default: "magic_link_v1"
|
|
68
91
|
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
|
|
92
|
+
# RETIRED (0.31.0) — magic links are ALWAYS Studio::Link rows now, so this
|
|
93
|
+
# reads :database and nothing else. Assigning :signed raises rather than
|
|
94
|
+
# silently downgrading: that store minted a ~350-character MessageVerifier
|
|
95
|
+
# blob whose EXPIRED form cannot be decoded, so an app on it could not tell
|
|
96
|
+
# whose dead link it was holding — which is exactly the fact
|
|
97
|
+
# Studio::LinkResolution needs to leave a live session alone. Requires the
|
|
98
|
+
# studio_links table, installed by `bin/rails studio_engine:install:migrations`
|
|
99
|
+
# (never hand-copied — a hand copy collides with the task's own copy on
|
|
100
|
+
# `class CreateStudioLinks`).
|
|
101
|
+
mattr_reader :magic_link_store, default: :database
|
|
102
|
+
|
|
103
|
+
# `to_s.to_sym`, not `to_sym`: this runs from an initializer, and nil or an
|
|
104
|
+
# Integer would raise NoMethodError — swallowing the explanation below with a
|
|
105
|
+
# message that says nothing about what to do. A blank falls through to the
|
|
106
|
+
# raise instead, so the operator reads the actual instruction.
|
|
107
|
+
def self.magic_link_store=(value)
|
|
108
|
+
return if value.to_s.to_sym == :database
|
|
109
|
+
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"Studio.magic_link_store = #{value.inspect} is retired (studio-engine 0.31.0). " \
|
|
112
|
+
"Magic links are Studio::Link rows served at /l/<token>. Delete this line from " \
|
|
113
|
+
"config/initializers/studio.rb, then install the table with " \
|
|
114
|
+
"`bin/rails studio_engine:install:migrations && bin/rails db:migrate` — in that " \
|
|
115
|
+
"order, because this raise fires while the initializer loads and no rake task can " \
|
|
116
|
+
"boot until the line is gone."
|
|
117
|
+
end
|
|
76
118
|
|
|
77
119
|
# Whether Studio.routes draws the magic_link + solana wallet routes. An app that
|
|
78
120
|
# already defines its own auth routes (e.g. turf-monster, which has battle-tested
|
|
@@ -210,13 +252,13 @@ module Studio
|
|
|
210
252
|
false
|
|
211
253
|
end
|
|
212
254
|
|
|
213
|
-
# True when the emailed/inbox magic-link URL is the short /l/<token> —
|
|
214
|
-
#
|
|
215
|
-
# the
|
|
216
|
-
#
|
|
217
|
-
#
|
|
255
|
+
# True when the emailed/inbox magic-link URL is the short /l/<token> — the
|
|
256
|
+
# standard. False means this app draws its own token route instead and owns
|
|
257
|
+
# the matching consume: turf-monster keeps /magic_link/<token> because /l is
|
|
258
|
+
# already its landing-page namespace. Either way the TOKEN is the same short
|
|
259
|
+
# Studio::Link token; only the path in front of it differs.
|
|
218
260
|
def self.magic_link_via_l_route?
|
|
219
|
-
|
|
261
|
+
draw_link_routes
|
|
220
262
|
end
|
|
221
263
|
|
|
222
264
|
# The floor every developer-desk tool sits on: the local email inbox
|
|
@@ -238,6 +280,40 @@ module Studio
|
|
|
238
280
|
env_truthy?(ENV["LOCAL_EMAIL_CAPTURE"]) || env_truthy?(ENV["AGENT_WORKTREE"])
|
|
239
281
|
end
|
|
240
282
|
|
|
283
|
+
# ---- Shared environment banner ------------------------------------------
|
|
284
|
+
# Rules live in Studio::EnvironmentBanner (pure Ruby, unit-tested); these are
|
|
285
|
+
# the Rails-aware entry points `studio/banners/_environment` calls. A host
|
|
286
|
+
# renders that ONE partial instead of hand-rolling its own strip.
|
|
287
|
+
|
|
288
|
+
# True for a stable QA app: Rails-production, but a non-production review
|
|
289
|
+
# target that must identify itself as one. Keyed off QA_ENV, the signal the
|
|
290
|
+
# release conductor already sets on every QA app.
|
|
291
|
+
def self.qa_environment?
|
|
292
|
+
EnvironmentBanner.qa_environment?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def self.show_environment_banner?(rails_env: rails_env_name)
|
|
296
|
+
EnvironmentBanner.show?(rails_env: rails_env, qa_environment: qa_environment?)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def self.environment_banner_message(rails_env: rails_env_name, extra: [])
|
|
300
|
+
EnvironmentBanner.message(rails_env: rails_env, qa_environment: qa_environment?, extra: extra)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Whether the local email inbox is actually REACHABLE for this request, which
|
|
304
|
+
# is the only honest reason to render a link to it. Deliberately the same
|
|
305
|
+
# gate the controller enforces (local_tool_enabled?), so the banner can never
|
|
306
|
+
# advertise a page that answers 404 — QA gets a status chip instead.
|
|
307
|
+
def self.local_inbox_reachable?(request_local:)
|
|
308
|
+
local_tool_enabled?(request_local: request_local)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def self.rails_env_name
|
|
312
|
+
return "development" unless defined?(Rails) && Rails.respond_to?(:env)
|
|
313
|
+
|
|
314
|
+
Rails.env.to_s
|
|
315
|
+
end
|
|
316
|
+
|
|
241
317
|
def self.user_wallet_address(user)
|
|
242
318
|
return nil unless user
|
|
243
319
|
|
|
@@ -278,7 +354,7 @@ module Studio
|
|
|
278
354
|
|
|
279
355
|
See the USER_CONTRACT.md doc in the studio-engine repo for the full
|
|
280
356
|
contract + a minimal compliant example:
|
|
281
|
-
https://github.com/
|
|
357
|
+
https://github.com/McRitchie-Studio/studio-engine/blob/main/docs/USER_CONTRACT.md
|
|
282
358
|
|
|
283
359
|
To bypass this check temporarily, set Studio.validate_user_contract = false
|
|
284
360
|
in config/initializers/studio.rb.
|
|
@@ -309,6 +385,13 @@ module Studio
|
|
|
309
385
|
entry ? "/#{entry[:file]}" : nil
|
|
310
386
|
end
|
|
311
387
|
|
|
388
|
+
# Sidebar sections resolved for a view context: a callable config is called
|
|
389
|
+
# with the view, keys symbolize, and admin-only sections drop for non-admin
|
|
390
|
+
# viewers. Rendering gates on `.any?`, so [] keeps the navbar untouched.
|
|
391
|
+
def self.sidebar_sections_for(view)
|
|
392
|
+
SidebarSections.resolve(sidebar_sections, view)
|
|
393
|
+
end
|
|
394
|
+
|
|
312
395
|
def self.env_truthy?(value)
|
|
313
396
|
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
314
397
|
end
|
|
@@ -333,18 +416,15 @@ module Studio
|
|
|
333
416
|
get "_studio/local_review", to: "studio/local_reviews#show", as: :studio_local_review
|
|
334
417
|
end
|
|
335
418
|
|
|
336
|
-
# Passwordless email (magic link).
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
#
|
|
341
|
-
#
|
|
419
|
+
# Passwordless email (magic link) — the REQUEST half only. Helper:
|
|
420
|
+
# magic_link_request_path (POST an email address, get a link mailed).
|
|
421
|
+
#
|
|
422
|
+
# The token-bearing half moved to /l/<token> below (0.31.0). There is one
|
|
423
|
+
# token format now — a short Studio::Link row — and one place that burns
|
|
424
|
+
# it, so the old /magic_link/:token confirm+consume pair would have been a
|
|
425
|
+
# second door onto the same lock.
|
|
342
426
|
if Studio.draw_auth_routes && Studio.auth_method?(:magic_link)
|
|
343
|
-
post "magic_link",
|
|
344
|
-
get "magic_link/:token", to: "magic_links#confirm", as: :magic_link,
|
|
345
|
-
constraints: { token: %r{[^/]+} }
|
|
346
|
-
post "magic_link/:token", to: "magic_links#consume", as: :magic_link_consume,
|
|
347
|
-
constraints: { token: %r{[^/]+} }
|
|
427
|
+
post "magic_link", to: "magic_links#create", as: :magic_link_request
|
|
348
428
|
end
|
|
349
429
|
|
|
350
430
|
# Unified short-token links — /l/<token> for magic sign-in links + referral
|
data/studio-engine.gemspec
CHANGED
|
@@ -7,15 +7,15 @@ Gem::Specification.new do |spec|
|
|
|
7
7
|
spec.email = ["studio-engine@mcritchie.studio"]
|
|
8
8
|
spec.summary = "Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed image caching"
|
|
9
9
|
spec.description = "Studio Engine is a non-isolated Rails engine that ships an opinionated authentication + SSO contract, a polymorphic ErrorLog model, a Sluggable concern, a 7-role dynamic theme system with CSS-custom-property generation, and an S3-backed ImageCache. Used in production across the McRitchie Studio + Turf Monster apps."
|
|
10
|
-
spec.homepage = "https://github.com/
|
|
10
|
+
spec.homepage = "https://github.com/McRitchie-Studio/studio-engine"
|
|
11
11
|
spec.license = "MIT"
|
|
12
12
|
spec.required_ruby_version = ">= 3.0"
|
|
13
13
|
|
|
14
14
|
spec.metadata = {
|
|
15
|
-
"homepage_uri" => "https://github.com/
|
|
16
|
-
"source_code_uri" => "https://github.com/
|
|
17
|
-
"bug_tracker_uri" => "https://github.com/
|
|
18
|
-
"changelog_uri" => "https://github.com/
|
|
15
|
+
"homepage_uri" => "https://github.com/McRitchie-Studio/studio-engine",
|
|
16
|
+
"source_code_uri" => "https://github.com/McRitchie-Studio/studio-engine/tree/main",
|
|
17
|
+
"bug_tracker_uri" => "https://github.com/McRitchie-Studio/studio-engine/issues",
|
|
18
|
+
"changelog_uri" => "https://github.com/McRitchie-Studio/studio-engine/blob/main/CHANGELOG.md"
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "db/**/*", "tailwind/**/*", "Gemfile", "studio-engine.gemspec", "README.md", "CHANGELOG.md", "LICENSE"]
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.31.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -201,6 +201,7 @@ files:
|
|
|
201
201
|
- app/controllers/theme_settings_controller.rb
|
|
202
202
|
- app/helpers/studio/admin_models_table_helper.rb
|
|
203
203
|
- app/helpers/studio_email_delivery_helper.rb
|
|
204
|
+
- app/helpers/studio_sidebar_helper.rb
|
|
204
205
|
- app/helpers/studio_theme_helper.rb
|
|
205
206
|
- app/jobs/error_log_cleanup_job.rb
|
|
206
207
|
- app/jobs/studio/email_delivery_job.rb
|
|
@@ -219,7 +220,6 @@ files:
|
|
|
219
220
|
- app/models/studio/model_page.rb
|
|
220
221
|
- app/models/theme_setting.rb
|
|
221
222
|
- app/services/google_oauth_validator.rb
|
|
222
|
-
- app/services/magic_link.rb
|
|
223
223
|
- app/services/studio/email_image.rb
|
|
224
224
|
- app/views/components/_admin_dropdown.html.erb
|
|
225
225
|
- app/views/components/_avatar.html.erb
|
|
@@ -232,7 +232,10 @@ files:
|
|
|
232
232
|
- app/views/components/_google_logo.html.erb
|
|
233
233
|
- app/views/components/_input.html.erb
|
|
234
234
|
- app/views/components/_json_debug.html.erb
|
|
235
|
+
- app/views/components/_link_sidebar.html.erb
|
|
236
|
+
- app/views/components/_link_sidebar_trigger.html.erb
|
|
235
237
|
- app/views/components/_progress_bar.html.erb
|
|
238
|
+
- app/views/components/_sidebar_panel.html.erb
|
|
236
239
|
- app/views/components/_theme_toggle.html.erb
|
|
237
240
|
- app/views/components/_theme_toggle_morph.html.erb
|
|
238
241
|
- app/views/components/_user_nav.html.erb
|
|
@@ -243,7 +246,6 @@ files:
|
|
|
243
246
|
- app/views/layouts/studio/_flash.html.erb
|
|
244
247
|
- app/views/layouts/studio/_head.html.erb
|
|
245
248
|
- app/views/layouts/studio/_smooth_load.html.erb
|
|
246
|
-
- app/views/magic_links/confirm.html.erb
|
|
247
249
|
- app/views/navbar/show.html.erb
|
|
248
250
|
- app/views/registrations/new.html.erb
|
|
249
251
|
- app/views/schema/index.html.erb
|
|
@@ -330,11 +332,14 @@ files:
|
|
|
330
332
|
- lib/studio/email.rb
|
|
331
333
|
- lib/studio/email_smoke.rb
|
|
332
334
|
- lib/studio/engine.rb
|
|
335
|
+
- lib/studio/environment_banner.rb
|
|
333
336
|
- lib/studio/image_cache.rb
|
|
337
|
+
- lib/studio/link_resolution.rb
|
|
334
338
|
- lib/studio/link_token.rb
|
|
335
339
|
- lib/studio/mail_transport.rb
|
|
336
340
|
- lib/studio/redis.rb
|
|
337
341
|
- lib/studio/s3.rb
|
|
342
|
+
- lib/studio/sidebar_sections.rb
|
|
338
343
|
- lib/studio/theme_resolver.rb
|
|
339
344
|
- lib/studio/ui_primitives.rb
|
|
340
345
|
- lib/studio/username_generator.rb
|
|
@@ -343,14 +348,14 @@ files:
|
|
|
343
348
|
- lib/tasks/studio_ses.rake
|
|
344
349
|
- studio-engine.gemspec
|
|
345
350
|
- tailwind/studio.tailwind.config.js
|
|
346
|
-
homepage: https://github.com/
|
|
351
|
+
homepage: https://github.com/McRitchie-Studio/studio-engine
|
|
347
352
|
licenses:
|
|
348
353
|
- MIT
|
|
349
354
|
metadata:
|
|
350
|
-
homepage_uri: https://github.com/
|
|
351
|
-
source_code_uri: https://github.com/
|
|
352
|
-
bug_tracker_uri: https://github.com/
|
|
353
|
-
changelog_uri: https://github.com/
|
|
355
|
+
homepage_uri: https://github.com/McRitchie-Studio/studio-engine
|
|
356
|
+
source_code_uri: https://github.com/McRitchie-Studio/studio-engine/tree/main
|
|
357
|
+
bug_tracker_uri: https://github.com/McRitchie-Studio/studio-engine/issues
|
|
358
|
+
changelog_uri: https://github.com/McRitchie-Studio/studio-engine/blob/main/CHANGELOG.md
|
|
354
359
|
post_install_message:
|
|
355
360
|
rdoc_options: []
|
|
356
361
|
require_paths:
|
data/app/services/magic_link.rb
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
# Unified create-or-login magic link.
|
|
2
|
-
#
|
|
3
|
-
# A magic link is a signed, short-lived, single-use token keyed on an EMAIL
|
|
4
|
-
# (the user may not exist yet — clicking the link either logs them in or
|
|
5
|
-
# creates the account). The token is a `message_verifier(token_name)` payload
|
|
6
|
-
# carrying the email + a sanitized return_to + a random jti.
|
|
7
|
-
#
|
|
8
|
-
# Single-use is enforced with the jti: on `generate` we record the jti in
|
|
9
|
-
# Rails.cache (Redis, cross-process); on `consume` we delete it and reject if
|
|
10
|
-
# it was already gone (replay / second click). The signature already covers
|
|
11
|
-
# tamper + expiry; the jti closes the replay gap.
|
|
12
|
-
#
|
|
13
|
-
# Token name + TTL come from Studio config (Studio.magic_link_token_name /
|
|
14
|
-
# Studio.magic_link_ttl) so each app can tune them; the jti cache entry is
|
|
15
|
-
# always given a few extra minutes so a still-valid token's jti is present.
|
|
16
|
-
#
|
|
17
|
-
# NOTE on test env: the test cache is :null_store, where writes/deletes are
|
|
18
|
-
# no-ops and `delete` always returns false — enforcing single-use there would
|
|
19
|
-
# reject every legitimate consume. So enforcement is skipped for non-tracking
|
|
20
|
-
# stores; the service unit test injects a real MemoryStore to exercise it.
|
|
21
|
-
#
|
|
22
|
-
# Lifted into studio-engine (was turf-monster app/services/magic_link.rb).
|
|
23
|
-
class MagicLink
|
|
24
|
-
# Back-compat defaults. Behavior is driven by the `token_name` / `ttl` methods
|
|
25
|
-
# (which read Studio config); these constants remain so existing consumer code
|
|
26
|
-
# /tests referencing MagicLink::TTL keep working, and they equal the config
|
|
27
|
-
# defaults.
|
|
28
|
-
TOKEN_KEY = "magic_link_v1"
|
|
29
|
-
TTL = 15.minutes
|
|
30
|
-
|
|
31
|
-
class InvalidToken < StandardError; end
|
|
32
|
-
|
|
33
|
-
Result = Struct.new(:email, :return_to, keyword_init: true)
|
|
34
|
-
|
|
35
|
-
class << self
|
|
36
|
-
# Test seam — defaults to Rails.cache. The service unit test sets this to
|
|
37
|
-
# an ActiveSupport::Cache::MemoryStore to assert single-use, then resets it.
|
|
38
|
-
attr_writer :cache
|
|
39
|
-
|
|
40
|
-
def cache
|
|
41
|
-
@cache || Rails.cache
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def token_name
|
|
45
|
-
Studio.magic_link_token_name
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def ttl
|
|
49
|
-
Studio.magic_link_ttl
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# jti outlives the token so a valid token's jti is always still present.
|
|
53
|
-
def jti_ttl
|
|
54
|
-
ttl + 5.minutes
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Returns a signed token string. `return_to` is sanitized to a local path.
|
|
58
|
-
# The MessageVerifier blob is standard base64 (can contain "/" and "+"),
|
|
59
|
-
# which breaks the `%r{[^/]+}` route constraint once the payload is large
|
|
60
|
-
# enough to emit a "/". Wrap it URL-safe so the token is always
|
|
61
|
-
# [A-Za-z0-9_-]=, matching the route and surviving URL generation.
|
|
62
|
-
def generate(email:, return_to: nil)
|
|
63
|
-
normalized = normalize_email(email)
|
|
64
|
-
jti = SecureRandom.hex(16)
|
|
65
|
-
cache.write(jti_key(jti), normalized, expires_in: jti_ttl) if enforce_single_use?
|
|
66
|
-
raw = verifier.generate(
|
|
67
|
-
{ email: normalized, return_to: sanitize_path(return_to), jti: jti, v: 1 },
|
|
68
|
-
expires_in: ttl
|
|
69
|
-
)
|
|
70
|
-
Base64.urlsafe_encode64(raw)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Verifies signature + expiry + single-use. Returns a Result or raises
|
|
74
|
-
# InvalidToken. Idempotency is NOT offered — a consumed token is dead.
|
|
75
|
-
def consume(token)
|
|
76
|
-
raw = Base64.urlsafe_decode64(token.to_s)
|
|
77
|
-
payload = verifier.verify(raw).with_indifferent_access
|
|
78
|
-
raise InvalidToken, "unexpected token shape" unless payload[:v] == 1 && payload[:email].present?
|
|
79
|
-
|
|
80
|
-
if enforce_single_use?
|
|
81
|
-
# delete returns true only when the jti was still present
|
|
82
|
-
raise InvalidToken, "link already used or expired" unless cache.delete(jti_key(payload[:jti]))
|
|
83
|
-
elsif !Rails.env.test?
|
|
84
|
-
# Single-use is disabled (non-tracking cache). Expected in :null_store
|
|
85
|
-
# dev; in any other env it means replay protection is silently OFF —
|
|
86
|
-
# tokens are replayable for their TTL. Surface it loudly.
|
|
87
|
-
Rails.logger.warn("[MagicLink] single-use NOT enforced (cache=#{cache.class}); links are replayable until expiry")
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
Result.new(email: payload[:email], return_to: sanitize_path(payload[:return_to]))
|
|
91
|
-
rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError
|
|
92
|
-
# ArgumentError → malformed base64 (tampered/truncated token).
|
|
93
|
-
raise InvalidToken, "invalid or expired link"
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
private
|
|
97
|
-
|
|
98
|
-
def verifier
|
|
99
|
-
Rails.application.message_verifier(token_name)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def jti_key(jti)
|
|
103
|
-
"magic_link/jti/#{jti}"
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def normalize_email(email)
|
|
107
|
-
email.to_s.strip.downcase
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Only same-origin absolute paths survive; everything else (protocol-relative
|
|
111
|
-
# "//evil", absolute URLs, blank) collapses to nil so callers fall back to a
|
|
112
|
-
# default redirect.
|
|
113
|
-
def sanitize_path(path)
|
|
114
|
-
p = path.to_s
|
|
115
|
-
p.start_with?("/") && !p.start_with?("//") ? p : nil
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def enforce_single_use?
|
|
119
|
-
!cache.is_a?(ActiveSupport::Cache::NullStore)
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|