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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +175 -1
- data/README.md +3 -2
- data/app/assets/tailwind/studio_engine/engine.css +11 -1
- 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/mailers/user_mailer.rb +8 -7
- data/app/models/studio/link.rb +55 -2
- data/app/views/studio/_confirm_interstitial.html.erb +4 -3
- data/db/migrate/20260620000002_allow_null_image_cache_owner.rb +10 -0
- data/lib/studio/color_scale.rb +13 -0
- data/lib/studio/link_resolution.rb +139 -0
- data/lib/studio/link_token.rb +13 -4
- data/lib/studio/theme_resolver.rb +53 -6
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +47 -27
- metadata +3 -4
- data/app/services/magic_link.rb +0 -122
- data/app/views/magic_links/confirm.html.erb +0 -2
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.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-
|
|
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
|
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
|