studio-engine 0.30.0 → 0.32.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.
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.30.0
4
+ version: 0.32.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-08-08 00:00:00.000000000 Z
11
+ date: 2026-08-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -220,7 +220,6 @@ files:
220
220
  - app/models/studio/model_page.rb
221
221
  - app/models/theme_setting.rb
222
222
  - app/services/google_oauth_validator.rb
223
- - app/services/magic_link.rb
224
223
  - app/services/studio/email_image.rb
225
224
  - app/views/components/_admin_dropdown.html.erb
226
225
  - app/views/components/_avatar.html.erb
@@ -247,7 +246,6 @@ files:
247
246
  - app/views/layouts/studio/_flash.html.erb
248
247
  - app/views/layouts/studio/_head.html.erb
249
248
  - app/views/layouts/studio/_smooth_load.html.erb
250
- - app/views/magic_links/confirm.html.erb
251
249
  - app/views/navbar/show.html.erb
252
250
  - app/views/registrations/new.html.erb
253
251
  - app/views/schema/index.html.erb
@@ -336,6 +334,7 @@ files:
336
334
  - lib/studio/engine.rb
337
335
  - lib/studio/environment_banner.rb
338
336
  - lib/studio/image_cache.rb
337
+ - lib/studio/link_resolution.rb
339
338
  - lib/studio/link_token.rb
340
339
  - lib/studio/mail_transport.rb
341
340
  - lib/studio/redis.rb
@@ -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
@@ -1,2 +0,0 @@
1
- <%# Legacy /magic_link/:token interstitial. Shared body in studio/_confirm_interstitial. %>
2
- <%= render "studio/confirm_interstitial", consume_path: magic_link_consume_path(token: @token) %>