studio-engine 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 251aa22a7a41f33e6cab5a4f1f1c38a714aa4a14f2f4b08567c27340e1563f24
4
- data.tar.gz: 2f8b5ec4901d403c1ffdaf42dee430c69f1c31625c926e8deb43c021a41eb6a9
3
+ metadata.gz: f4e53e209d300c3d55aa211ae40024421ee78734929e1e1db1fd683fa84ffeb0
4
+ data.tar.gz: 372749c4b18ba3072cd4977ee5e2481d18b870ba14b93e44cca9863f5781c1f6
5
5
  SHA512:
6
- metadata.gz: 1c8fa01470ce96a17550dac1d2d70db086b1acf0c6f6ed97a8b31679f2f3559dbb8549687bb79e806b2148a058ab700154bc330f4b94eb6b4216480fb0831d67
7
- data.tar.gz: 4ae572b5cadc0734b1ef69a8d0ec0d4331c57f3545be08e850dcd2b0d53c0f6d30d5e1b2d287a65542d46dec4b1d803cad6611471f24c533143118daa0fc75b4
6
+ metadata.gz: b984b2a14f1065b8c021a87a28ea50f447870361a43b027bb26538d7c360eb5a89c75f3aa8241b0889578af54325421727b6967f8c2a3ec6e3daaf64c36c5295
7
+ data.tar.gz: bee489407d99c8c5b6d7beda43161b01de9e06014453eb9b80a890b9c913563c2520059c36c18b67d1aaa2dbaddbf933a047878fd4d7abffb90514d8a4ef744d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.8.0 — 2026-06-21
8
+
9
+ ### Added
10
+ - **`Studio::Link` + unified `/l/<token>`** — one short-token model + entry point
11
+ for both single-use, expiring **magic_link**s and reusable, non-expiring
12
+ **referral** links. Polymorphic `linkable` (optional), `metadata` jsonb (email/
13
+ return_to/target/age_attested off the wire), nullable `expires_at`, atomic
14
+ single-use consume. `Studio::LinkToken` holds the pure token/kind/sanitizer
15
+ logic (unit-tested without a DB); `Studio::LinksController` dispatches by kind
16
+ (magic → scanner-safe confirm + POST consume; referral → attribution cookie +
17
+ redirect). `Studio::LinkConsumption` concern shares sign-in/sign-up. Reference
18
+ migration `create_studio_links` (installed per app like `studio_email_deliveries`).
19
+ - **`Studio.magic_link_store`** (`:signed` default | `:database`) and
20
+ **`Studio.draw_link_routes`** flags. `:database` mints `Studio::Link`s and
21
+ emails the short `/l/<token>` (vs the legacy `/magic_link/<MessageVerifier>`);
22
+ fully back-compatible — the default leaves existing consumers untouched.
23
+ - **Branded mailer** lifted into the engine: `layouts/branded_mailer` + a branded
24
+ `UserMailer#magic_link` body (theme-colored button, banner-aware). Apps with
25
+ their own `UserMailer`/`branded_mailer` still win.
26
+ - **`Studio::EmailImage`** + **`/admin/email_images`** (admin-gated) — manage the
27
+ banner image on transactional emails (magic-link now; per-variant registry is
28
+ the extension point). Backed by `Studio::S3` + an owner-less `ImageCache` row
29
+ (permanent public URL, nil-safe pre-upload).
30
+
31
+ ### Changed
32
+ - `ImageCache#owner` is now optional (+ reference migration to drop the owner
33
+ `NOT NULL`), so app-global images (e.g. email banners) can be cached.
34
+
7
35
  ## v0.7.0 (2026-06-20)
8
36
 
9
37
  ### Added
@@ -0,0 +1,52 @@
1
+ module Studio
2
+ # Shared create-or-login building blocks for the magic-link / link consume
3
+ # controllers (MagicLinksController + Studio::LinksController). `result` is
4
+ # anything responding to #email and #return_to — the legacy MagicLink::Result
5
+ # OR a Studio::Link — so both token schemes reuse this. Apps that override the
6
+ # link controllers (e.g. turf-monster's contest landing) include it too.
7
+ #
8
+ # Relies on the host ApplicationController contract from Studio::ErrorHandling:
9
+ # set_app_session, rescue_and_log, current_user, plus root_path / login_path.
10
+ module LinkConsumption
11
+ extend ActiveSupport::Concern
12
+
13
+ private
14
+
15
+ def sign_in_existing(user, result)
16
+ set_app_session(user)
17
+ # Clicking the link proves email ownership, so verify any account that
18
+ # reached here without it (e.g. a Google/wallet-only signup).
19
+ user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at) && user.email_verified_at.blank?
20
+ redirect_to(safe_path(result.return_to) || root_path, notice: "Signed in. Welcome back!")
21
+ end
22
+
23
+ # Build → configure_new_user → save!. No password — email auth is link-only.
24
+ def sign_up_new(result)
25
+ user = User.new(email: result.email)
26
+ Studio.configure_new_user.call(user)
27
+ rescue_and_log(target: user) do
28
+ user.save!
29
+ user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at)
30
+ set_app_session(user)
31
+ redirect_to(safe_path(result.return_to) || root_path, notice: Studio.welcome_message.call(user))
32
+ end
33
+ rescue ActiveRecord::RecordNotUnique
34
+ # Two valid tokens for the same brand-new email consumed near-simultaneously
35
+ # both miss find_by and race to save!; the loser hits the unique index.
36
+ # Benign — the account now exists, so just log the winner in.
37
+ existing = User.find_by(email: result.email)
38
+ return sign_in_existing(existing, result) if existing
39
+
40
+ redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
41
+ rescue StandardError => e
42
+ Rails.logger.error("[Studio::LinkConsumption#sign_up_new] signup failed #{e.class}: #{e.message}")
43
+ redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
44
+ end
45
+
46
+ # Only same-origin absolute paths survive; everything else collapses to nil.
47
+ def safe_path(path)
48
+ p = path.to_s
49
+ p.start_with?("/") && !p.start_with?("//") ? p : nil
50
+ end
51
+ end
52
+ end
@@ -14,6 +14,8 @@
14
14
  # OVERRIDE this controller in the app and reuse the MagicLink service + the
15
15
  # sign_in_existing / sign_up_new building blocks.
16
16
  class MagicLinksController < ApplicationController
17
+ include Studio::LinkConsumption
18
+
17
19
  skip_before_action :require_authentication
18
20
  layout false, only: :confirm
19
21
 
@@ -23,7 +25,7 @@ class MagicLinksController < ApplicationController
23
25
  def create
24
26
  email = params[:email].to_s.strip.downcase
25
27
  if email.match?(URI::MailTo::EMAIL_REGEXP)
26
- token = MagicLink.generate(email: email, return_to: safe_path(params[:return_to]))
28
+ token = issue_magic_link(email, safe_path(params[:return_to]))
27
29
  Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
28
30
  end
29
31
  respond_to do |format|
@@ -57,38 +59,16 @@ class MagicLinksController < ApplicationController
57
59
 
58
60
  private
59
61
 
60
- def sign_in_existing(user, result)
61
- set_app_session(user)
62
- user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at) && user.email_verified_at.blank?
63
- redirect_to(safe_path(result.return_to) || root_path, notice: "Signed in. Welcome back!")
64
- end
65
-
66
- # Build configure_new_user → save!. There is no password — email auth is
67
- # magic-link only (the password_digest column, if present, stays dormant).
68
- def sign_up_new(result)
69
- user = User.new(email: result.email)
70
- Studio.configure_new_user.call(user)
71
- rescue_and_log(target: user) do
72
- user.save!
73
- user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at)
74
- set_app_session(user)
75
- redirect_to(safe_path(result.return_to) || root_path, notice: Studio.welcome_message.call(user))
62
+ # Mint a token in the configured store. Default :signed keeps the legacy
63
+ # stateless MessageVerifier link (URL: /magic_link/<token>); :database mints a
64
+ # Studio::Link row (URL: /l/<token>) the short, unified scheme. The mailer
65
+ # builds the matching URL (see UserMailer#magic_link). sign_in_existing /
66
+ # sign_up_new / safe_path come from Studio::LinkConsumption.
67
+ def issue_magic_link(email, return_to)
68
+ if Studio.magic_link_store == :database
69
+ Studio::Link.create_magic_link(email: email, return_to: return_to).token
70
+ else
71
+ MagicLink.generate(email: email, return_to: return_to)
76
72
  end
77
- rescue ActiveRecord::RecordNotUnique
78
- # Two valid tokens for the same brand-new email consumed near-simultaneously
79
- # both miss find_by and race to save!; the loser hits the unique index.
80
- # Benign — the account now exists, so just log the winner in.
81
- existing = User.find_by(email: result.email)
82
- return sign_in_existing(existing, result) if existing
83
-
84
- redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
85
- rescue StandardError => e
86
- Rails.logger.error("[MagicLinksController#consume] signup failed #{e.class}: #{e.message}")
87
- redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
88
- end
89
-
90
- def safe_path(path)
91
- p = path.to_s
92
- p.start_with?("/") && !p.start_with?("//") ? p : nil
93
73
  end
94
74
  end
@@ -0,0 +1,41 @@
1
+ module Studio
2
+ # Admin page to manage the banner image on transactional emails (Studio::
3
+ # EmailImage). One managed variant today (magic_link); the registry is the
4
+ # extension point. Shared by every app — surfaced from each app's admin hub.
5
+ class EmailImagesController < ApplicationController
6
+ before_action :require_admin
7
+
8
+ MAX_BYTES = 8.megabytes
9
+
10
+ def index
11
+ @variants = Studio::EmailImage.variants
12
+ end
13
+
14
+ # PATCH /admin/email_images/:variant — upload/replace a banner.
15
+ def update
16
+ variant = params[:variant].to_s
17
+ return head :not_found unless Studio::EmailImage.known?(variant)
18
+
19
+ file = params[:image]
20
+ unless valid_image?(file)
21
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
22
+ return redirect_to admin_email_images_path, alert: message, status: :see_other
23
+ end
24
+
25
+ rescue_and_log do
26
+ Studio::EmailImage.store(variant, io: file, content_type: file.content_type)
27
+ redirect_to admin_email_images_path, notice: "#{Studio::EmailImage.label(variant)} banner updated."
28
+ end
29
+ rescue StandardError
30
+ redirect_to admin_email_images_path, alert: "Couldn't save the image. Please try again.", status: :see_other
31
+ end
32
+
33
+ private
34
+
35
+ def valid_image?(file)
36
+ file.respond_to?(:content_type) &&
37
+ file.content_type.to_s.start_with?("image/") &&
38
+ file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,65 @@
1
+ module Studio
2
+ # The unified short-token link entry point — GET/POST /l/<token>. Dispatches by
3
+ # Studio::Link#kind:
4
+ #
5
+ # magic_link → scanner-safe confirm interstitial (GET, inert) that auto-POSTs
6
+ # to #consume, the ONLY place the single-use token is burned +
7
+ # the recipient is signed in / signed up.
8
+ # referral → idempotent: capture attribution into a cookie + redirect to
9
+ # the link's target (or root). Reusable + safe to prefetch, so
10
+ # GET does the work (no POST step).
11
+ #
12
+ # Namespaced (not top-level Links) because mcritchie-studio already owns a
13
+ # public /links linktree (top-level LinksController). Apps needing richer
14
+ # post-consume routing (contest landing, picks rehydration, age-gate) define
15
+ # their own Studio::LinksController and reuse Studio::Link + the
16
+ # Studio::LinkConsumption building blocks.
17
+ class LinksController < ApplicationController
18
+ include Studio::LinkConsumption
19
+
20
+ skip_before_action :require_authentication
21
+ layout false, only: :show
22
+
23
+ # GET /l/:token
24
+ def show
25
+ response.set_header("Referrer-Policy", "strict-origin")
26
+ @link = Studio::Link.find_by(token: params[:token])
27
+
28
+ case @link&.kind
29
+ when "magic_link"
30
+ @token = params[:token]
31
+ render :confirm
32
+ when "referral"
33
+ capture_referral(@link)
34
+ redirect_to(@link.target || root_path)
35
+ else
36
+ redirect_to login_path, alert: "That link is invalid or has expired. Request a fresh one below."
37
+ end
38
+ end
39
+
40
+ # POST /l/:token — authoritative magic-link consume. Only magic_link kinds are
41
+ # consumable here; referral links are reusable and handled entirely on GET.
42
+ def consume
43
+ response.set_header("Referrer-Policy", "strict-origin")
44
+ link = Studio::Link.find_by(token: params[:token])
45
+ raise Studio::Link::InvalidToken, "not a magic link" unless link&.kind == "magic_link"
46
+
47
+ link.consume! # burns the single-use token; raises if already used / expired
48
+ user = User.find_by(email: link.email)
49
+ user ? sign_in_existing(user, link) : sign_up_new(link)
50
+ rescue Studio::Link::InvalidToken
51
+ redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
52
+ end
53
+
54
+ private
55
+
56
+ # Attribution rides in a cookie the app reads at signup (same :reference
57
+ # cookie the legacy ?ref= capture used). Value = the inviter's stable handle
58
+ # (slug) when available, else the link token. Capped to 64 chars.
59
+ def capture_referral(link)
60
+ inviter = link.linkable
61
+ ref = (inviter.respond_to?(:slug) && inviter.slug.presence) || link.token
62
+ cookies[:reference] = { value: ref.to_s.first(64), expires: 30.days, same_site: :lax }
63
+ end
64
+ end
65
+ end
@@ -84,7 +84,11 @@ module Studio
84
84
  path =
85
85
  case record.email_key
86
86
  when /#magic_link\z/
87
- "/magic_link/#{ERB::Util.url_encode(token)}"
87
+ # /l/<token> only when the app actually emails /l (Studio::Link store +
88
+ # /l routes drawn); otherwise the legacy /magic_link/<token> — covers the
89
+ # signed store AND apps like turf-monster that keep their own /magic_link.
90
+ base = Studio.magic_link_via_l_route? ? "/l" : "/magic_link"
91
+ "#{base}/#{ERB::Util.url_encode(token)}"
88
92
  when /#email_verification\z/
89
93
  "/email_verification/#{ERB::Util.url_encode(token)}"
90
94
  when /#wallet_export\z/
@@ -1,4 +1,8 @@
1
1
  class UserMailer < ApplicationMailer
2
+ # Branded shell (banner + card) for engine-sent UserMailer emails. An app with
3
+ # its own UserMailer + branded_mailer layout (e.g. turf-monster) overrides both.
4
+ layout "branded_mailer"
5
+
2
6
  # Passwordless sign-in link. `email` is a raw string (the recipient may not
3
7
  # have an account yet). Token is a signed MagicLink payload (email + return_to
4
8
  # + jti, single-use). Clicking the link logs the recipient in or creates their
@@ -7,8 +11,20 @@ class UserMailer < ApplicationMailer
7
11
  # Engine GENERIC base. An app needing richer copy (e.g. turf-monster's
8
12
  # contest-aware variant) defines its own UserMailer, which wins.
9
13
  def magic_link(email, token)
10
- @app_name = Studio.app_name
11
- @magic_url = magic_link_url(token: token)
14
+ @app_name = Studio.app_name
15
+ @email = email
16
+ @magic_url = magic_link_url_for(token)
17
+ @banner_url = Studio::EmailImage.url(:magic_link) # admin-managed; nil renders bannerless
18
+ @banner_alt = "Your #{@app_name} sign-in link"
12
19
  mail(to: email, subject: "Your #{@app_name} sign-in link")
13
20
  end
21
+
22
+ private
23
+
24
+ # Match the emailed URL to Studio.magic_link_store: the short /l/<token> for
25
+ # the :database scheme, the legacy /magic_link/<token> for :signed. The
26
+ # request side (MagicLinksController#issue_magic_link) mints the matching token.
27
+ def magic_link_url_for(token)
28
+ Studio.magic_link_via_l_route? ? link_url(token: token) : magic_link_url(token: token)
29
+ end
14
30
  end
@@ -1,5 +1,9 @@
1
1
  class ImageCache < ApplicationRecord
2
- belongs_to :owner, polymorphic: true
2
+ # Optional so app-GLOBAL images (no owning record) can be cached too — e.g.
3
+ # Studio::EmailImage stores the admin-managed email banners owner-less. Per-
4
+ # record images (athlete/coach headshots) still set an owner; the
5
+ # variant-uniqueness scope below keeps both shapes distinct.
6
+ belongs_to :owner, polymorphic: true, optional: true
3
7
 
4
8
  validates :purpose, :variant, :s3_key, presence: true
5
9
  validates :s3_key, uniqueness: true
@@ -0,0 +1,144 @@
1
+ module Studio
2
+ # One table, one /l/<token> entry point, for every short-token link the apps
3
+ # hand out: single-use, expiring **magic_link**s and reusable, non-expiring
4
+ # **referral** links. `kind` selects the behavior; `metadata` (jsonb) carries
5
+ # the kind-specific payload (email, return_to, target, age_attested) OFF the
6
+ # wire so the URL is just the short random token.
7
+ #
8
+ # Generalizes turf-monster's app-local MagicLink model: adds a polymorphic
9
+ # `linkable` owner (the inviting User for referrals; left nil for a magic link
10
+ # to a not-yet-existent email — that email rides in metadata) and the `kind`
11
+ # discriminator. Replaces the engine's stateless MessageVerifier MagicLink
12
+ # service for mcritchie-studio so both apps share one short-token scheme.
13
+ #
14
+ # Like Studio::EmailDelivery, the table lives in each consumer app (copy the
15
+ # reference migration in db/migrate); this model is shipped by the gem.
16
+ class Link < ApplicationRecord
17
+ self.table_name = "studio_links"
18
+
19
+ class InvalidToken < StandardError; end
20
+
21
+ belongs_to :linkable, polymorphic: true, optional: true
22
+
23
+ validates :kind, inclusion: { in: Studio::LinkToken::KINDS }
24
+ validates :token, presence: true, uniqueness: true
25
+
26
+ scope :magic_links, -> { where(kind: "magic_link") }
27
+ scope :referrals, -> { where(kind: "referral") }
28
+ scope :unconsumed, -> { where(consumed_at: nil) }
29
+ scope :live, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
30
+
31
+ class << self
32
+ # --- minting -----------------------------------------------------------
33
+
34
+ # A single-use sign-in/sign-up link. The email rides in metadata (not the
35
+ # URL, not a column) per the create-or-login flow — the account may not
36
+ # exist yet. `ttl` defaults to the app's Studio.magic_link_ttl.
37
+ def create_magic_link(email:, return_to: nil, age_attested: false, linkable: nil, ttl: nil)
38
+ ttl ||= Studio.magic_link_ttl
39
+ mint!(
40
+ kind: "magic_link",
41
+ linkable: linkable,
42
+ expires_at: ttl.from_now,
43
+ metadata: {
44
+ "email" => Studio::LinkToken.normalize_email(email),
45
+ "return_to" => Studio::LinkToken.sanitize_path(return_to),
46
+ "age_attested" => !!age_attested
47
+ }.compact
48
+ )
49
+ end
50
+
51
+ # A user's referral link is stable + reusable, keyed by its landing target
52
+ # so sharing contest A vs B yields distinct (but each stable) links — both
53
+ # crediting the same inviter. `target` is an optional same-origin path the
54
+ # referral redirects to (e.g. a specific contest).
55
+ def referral_for(linkable, target: nil)
56
+ wanted = Studio::LinkToken.sanitize_path(target)
57
+ referrals.where(linkable: linkable).live.detect { |link| link.target == wanted } ||
58
+ mint!(
59
+ kind: "referral",
60
+ linkable: linkable,
61
+ expires_at: nil,
62
+ metadata: { "target" => wanted }.compact
63
+ )
64
+ end
65
+
66
+ # Find by token + burn-if-single-use in one call. Raises InvalidToken for
67
+ # unknown / expired / already-used links. Returns the live Link.
68
+ def consume!(token)
69
+ link = find_by(token: token.to_s)
70
+ raise InvalidToken, "unknown link" unless link
71
+
72
+ link.consume!
73
+ end
74
+
75
+ private
76
+
77
+ # create! with a fresh random token, retrying the (astronomically rare)
78
+ # unique-index collision a couple of times before surfacing the error.
79
+ def mint!(attrs)
80
+ 3.times do
81
+ return create!(attrs.merge(token: Studio::LinkToken.generate))
82
+ rescue ActiveRecord::RecordNotUnique
83
+ next
84
+ end
85
+ create!(attrs.merge(token: Studio::LinkToken.generate))
86
+ end
87
+ end
88
+
89
+ # --- consumption ---------------------------------------------------------
90
+
91
+ # Single-use kinds (magic_link) are atomically burned: only the first caller
92
+ # flips consumed_at, so a replay / double-submit loses the race and is
93
+ # rejected. Reusable kinds (referral) only check expiry. Returns self.
94
+ def consume!
95
+ if single_use?
96
+ burned = self.class.unconsumed
97
+ .where(id: id)
98
+ .where("expires_at IS NULL OR expires_at > ?", Time.current)
99
+ .update_all(consumed_at: Time.current)
100
+ raise InvalidToken, "link already used or expired" if burned.zero?
101
+
102
+ self.consumed_at = Time.current
103
+ elsif expired?
104
+ raise InvalidToken, "link expired"
105
+ end
106
+ self
107
+ end
108
+
109
+ def single_use?
110
+ Studio::LinkToken.single_use?(kind)
111
+ end
112
+
113
+ def expired?
114
+ expires_at.present? && expires_at <= Time.current
115
+ end
116
+
117
+ def consumed?
118
+ consumed_at.present?
119
+ end
120
+
121
+ def live?
122
+ !expired? && !(single_use? && consumed?)
123
+ end
124
+
125
+ # --- metadata readers (sanitized on the way out) -------------------------
126
+
127
+ def email
128
+ metadata && metadata["email"]
129
+ end
130
+
131
+ def return_to
132
+ Studio::LinkToken.sanitize_path(metadata && metadata["return_to"])
133
+ end
134
+
135
+ def target
136
+ Studio::LinkToken.sanitize_path(metadata && metadata["target"])
137
+ end
138
+
139
+ def age_attested
140
+ !!(metadata && metadata["age_attested"])
141
+ end
142
+ alias_method :age_attested?, :age_attested
143
+ end
144
+ end
@@ -0,0 +1,85 @@
1
+ module Studio
2
+ # Admin-managed banner images for transactional emails. One image per "variant"
3
+ # (the email type), uploaded to S3 with a stable PUBLIC url via Studio::S3 and
4
+ # tracked by an owner-less ImageCache row (purpose "email_banner"). The branded
5
+ # mailer resolves the current banner with .url; the admin email-image page
6
+ # writes it with .store.
7
+ #
8
+ # VARIANTS is the registry — magic_link now; adding an entry is all it takes to
9
+ # admin-manage another email's header image (the "extensible" part of the
10
+ # magic-link-now-extensible scope).
11
+ module EmailImage
12
+ PURPOSE = "email_banner".freeze
13
+
14
+ # variant => human label, in admin display order.
15
+ VARIANTS = {
16
+ "magic_link" => "Magic-link sign-in"
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ def variants
22
+ VARIANTS
23
+ end
24
+
25
+ def label(variant)
26
+ VARIANTS[variant.to_s] || variant.to_s.humanize
27
+ end
28
+
29
+ def known?(variant)
30
+ VARIANTS.key?(variant.to_s)
31
+ end
32
+
33
+ # The ImageCache row for this variant, or nil (no banner uploaded / table not
34
+ # installed yet). Nil-safe so the mailer renders bannerless before any upload.
35
+ def record(variant)
36
+ return nil unless table_ready?
37
+
38
+ ::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: variant.to_s)
39
+ end
40
+
41
+ # Permanent public S3 url for the current banner, or nil.
42
+ def url(variant)
43
+ record(variant)&.url
44
+ end
45
+
46
+ # Upload bytes to S3 + upsert the ImageCache row (replacing any prior object).
47
+ # Returns the ::ImageCache. Raises on failure after cleaning up the new object.
48
+ def store(variant, io:, content_type: nil)
49
+ key = "email_banners/#{variant}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
50
+ Studio::S3.upload(key: key, body: io.read, content_type: content_type,
51
+ cache_control: "public, max-age=300")
52
+ record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: variant.to_s)
53
+ previous = record.s3_key
54
+ record.update!(s3_key: key)
55
+ delete_object(previous) if previous.present? && previous != key
56
+ record
57
+ rescue StandardError
58
+ delete_object(key)
59
+ raise
60
+ end
61
+
62
+ # Reference ImageCache directly so Zeitwerk autoloads it — defined?() does NOT
63
+ # trigger autoload, so it would read "undefined" for a not-yet-loaded const.
64
+ def table_ready?
65
+ ::ImageCache.table_exists?
66
+ rescue NameError, ActiveRecord::ActiveRecordError
67
+ false
68
+ end
69
+
70
+ def ext_for(content_type)
71
+ case content_type.to_s
72
+ when %r{png} then ".png"
73
+ when %r{jpe?g} then ".jpg"
74
+ when %r{webp} then ".webp"
75
+ else ".png"
76
+ end
77
+ end
78
+
79
+ def delete_object(key)
80
+ Studio::S3.delete(key: key)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,31 @@
1
+ <%#
2
+ Shared branded transactional email shell. A full-bleed banner (set @banner_url,
3
+ e.g. from Studio::EmailImage.url(:magic_link)) sits flush at the top and sets
4
+ the 600px width; each email view supplies the body via yield. Bannerless is
5
+ fine — the card still renders. Lifted from turf-monster so every Studio app
6
+ shares one branded look. An app can override by defining its own
7
+ layouts/branded_mailer.html.erb.
8
+ %>
9
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0;padding:24px 0;background:#f4f5f7;">
10
+ <tr>
11
+ <td align="center" style="padding:0 12px;">
12
+ <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 1px 4px rgba(15,23,42,0.08);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
13
+
14
+ <% if @banner_url.present? %>
15
+ <tr>
16
+ <td style="padding:0;line-height:0;">
17
+ <img src="<%= @banner_url %>" width="600" alt="<%= @banner_alt || Studio.app_name %>"
18
+ style="display:block;width:100%;max-width:600px;height:auto;border:0;" />
19
+ </td>
20
+ </tr>
21
+ <% end %>
22
+
23
+ <tr>
24
+ <td style="padding:32px 36px 36px;color:#2f3a2c;">
25
+ <%= yield %>
26
+ </td>
27
+ </tr>
28
+ </table>
29
+ </td>
30
+ </tr>
31
+ </table>
@@ -1,96 +1,2 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <%= csrf_meta_tags %>
7
- <title>Signing you in - <%= Studio.app_name %></title>
8
- <style>
9
- @keyframes magic-spin { to { transform: rotate(360deg); } }
10
-
11
- * { box-sizing: border-box; }
12
-
13
- body {
14
- margin: 0;
15
- min-height: 100vh;
16
- display: grid;
17
- place-items: center;
18
- color: #f8fafc;
19
- background: <%= Studio.theme_dark %>;
20
- font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
21
- }
22
-
23
- main { width: min(100% - 32px, 420px); text-align: center; }
24
-
25
- .magic-spinner {
26
- width: 88px;
27
- height: 88px;
28
- margin: 0 auto;
29
- border-radius: 9999px;
30
- border: 8px solid rgba(255, 255, 255, 0.14);
31
- border-top-color: <%= Studio.theme_success %>;
32
- animation: magic-spin 0.8s linear infinite;
33
- }
34
-
35
- .magic-fallback {
36
- display: none;
37
- margin-top: 2rem;
38
- color: rgba(248, 250, 252, 0.8);
39
- }
40
-
41
- .magic-fallback p { margin: 0 0 1rem; font-size: 15px; }
42
-
43
- .magic-submit {
44
- display: inline-block;
45
- max-width: 100%;
46
- padding: 14px 34px;
47
- border: 0;
48
- border-radius: 8px;
49
- color: #ffffff;
50
- background: <%= Studio.theme_success %>;
51
- font: inherit;
52
- font-size: 16px;
53
- font-weight: 700;
54
- cursor: pointer;
55
- }
56
- </style>
57
- </head>
58
- <body>
59
- <main>
60
- <div class="magic-spinner" role="status" aria-label="Signing you in"></div>
61
-
62
- <div id="magic-fallback" class="magic-fallback">
63
- <p>Taking longer than expected?</p>
64
- <%= button_to magic_link_consume_path(token: @token),
65
- method: :post,
66
- form: { id: "magic-consume-form", data: { turbo: "false" } },
67
- class: "magic-submit" do %>
68
- Sign in to <%= Studio.app_name %>
69
- <% end %>
70
- </div>
71
- </main>
72
-
73
- <noscript>
74
- <style>
75
- #magic-fallback { display: block !important; }
76
- .magic-spinner { display: none; }
77
- </style>
78
- </noscript>
79
-
80
- <script>
81
- (function () {
82
- var form = document.getElementById("magic-consume-form");
83
- if (form && !form.dataset.autoSubmitted) {
84
- form.dataset.autoSubmitted = "1";
85
- if (typeof form.requestSubmit === "function") form.requestSubmit();
86
- else form.submit();
87
- }
88
-
89
- setTimeout(function () {
90
- var fallback = document.getElementById("magic-fallback");
91
- if (fallback) fallback.style.display = "block";
92
- }, 4000);
93
- })();
94
- </script>
95
- </body>
96
- </html>
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) %>
@@ -0,0 +1,105 @@
1
+ <%#
2
+ Shared scanner-safe sign-in interstitial. The GET that renders this is inert;
3
+ the page auto-POSTs the CSRF-protected form to `consume_path` (the only place
4
+ a single-use token is burned). Used by both MagicLinksController#confirm
5
+ (consume_path: magic_link_consume_path) and Studio::LinksController#show
6
+ (consume_path: link_consume_path). Rendered with layout false (full document).
7
+
8
+ Local: consume_path — the POST target that burns the token + signs in.
9
+ %>
10
+ <!DOCTYPE html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="utf-8">
14
+ <meta name="viewport" content="width=device-width,initial-scale=1">
15
+ <%= csrf_meta_tags %>
16
+ <title>Signing you in - <%= Studio.app_name %></title>
17
+ <style>
18
+ @keyframes magic-spin { to { transform: rotate(360deg); } }
19
+
20
+ * { box-sizing: border-box; }
21
+
22
+ body {
23
+ margin: 0;
24
+ min-height: 100vh;
25
+ display: grid;
26
+ place-items: center;
27
+ color: #f8fafc;
28
+ background: <%= Studio.theme_dark %>;
29
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
30
+ }
31
+
32
+ main { width: min(100% - 32px, 420px); text-align: center; }
33
+
34
+ .magic-spinner {
35
+ width: 88px;
36
+ height: 88px;
37
+ margin: 0 auto;
38
+ border-radius: 9999px;
39
+ border: 8px solid rgba(255, 255, 255, 0.14);
40
+ border-top-color: <%= Studio.theme_success %>;
41
+ animation: magic-spin 0.8s linear infinite;
42
+ }
43
+
44
+ .magic-fallback {
45
+ display: none;
46
+ margin-top: 2rem;
47
+ color: rgba(248, 250, 252, 0.8);
48
+ }
49
+
50
+ .magic-fallback p { margin: 0 0 1rem; font-size: 15px; }
51
+
52
+ .magic-submit {
53
+ display: inline-block;
54
+ max-width: 100%;
55
+ padding: 14px 34px;
56
+ border: 0;
57
+ border-radius: 8px;
58
+ color: #ffffff;
59
+ background: <%= Studio.theme_success %>;
60
+ font: inherit;
61
+ font-size: 16px;
62
+ font-weight: 700;
63
+ cursor: pointer;
64
+ }
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <main>
69
+ <div class="magic-spinner" role="status" aria-label="Signing you in"></div>
70
+
71
+ <div id="magic-fallback" class="magic-fallback">
72
+ <p>Taking longer than expected?</p>
73
+ <%= button_to consume_path,
74
+ method: :post,
75
+ form: { id: "magic-consume-form", data: { turbo: "false" } },
76
+ class: "magic-submit" do %>
77
+ Sign in to <%= Studio.app_name %>
78
+ <% end %>
79
+ </div>
80
+ </main>
81
+
82
+ <noscript>
83
+ <style>
84
+ #magic-fallback { display: block !important; }
85
+ .magic-spinner { display: none; }
86
+ </style>
87
+ </noscript>
88
+
89
+ <script>
90
+ (function () {
91
+ var form = document.getElementById("magic-consume-form");
92
+ if (form && !form.dataset.autoSubmitted) {
93
+ form.dataset.autoSubmitted = "1";
94
+ if (typeof form.requestSubmit === "function") form.requestSubmit();
95
+ else form.submit();
96
+ }
97
+
98
+ setTimeout(function () {
99
+ var fallback = document.getElementById("magic-fallback");
100
+ if (fallback) fallback.style.display = "block";
101
+ }, 4000);
102
+ })();
103
+ </script>
104
+ </body>
105
+ </html>
@@ -0,0 +1,41 @@
1
+ <% content_for(:title) { "Email images" } %>
2
+ <div class="max-w-2xl mx-auto px-4 py-8">
3
+ <header class="mb-6">
4
+ <h1 class="text-2xl font-bold">Email images</h1>
5
+ <p class="text-sm text-muted mt-1">
6
+ The banner shown at the top of branded transactional emails. Recommended
7
+ width 600px (it's displayed full-bleed at the top of the email card).
8
+ </p>
9
+ </header>
10
+
11
+ <% flash.each do |type, message| %>
12
+ <div class="mb-4 rounded-lg px-4 py-3 text-sm <%= type.to_s == "alert" ? "bg-danger/10 text-danger" : "bg-success/10 text-success" %>">
13
+ <%= message %>
14
+ </div>
15
+ <% end %>
16
+
17
+ <div class="space-y-6">
18
+ <% @variants.each do |variant, label| %>
19
+ <% current_url = Studio::EmailImage.url(variant) %>
20
+ <section class="rounded-xl border border-subtle p-5">
21
+ <h2 class="font-semibold mb-3"><%= label %></h2>
22
+
23
+ <div class="rounded-lg overflow-hidden border border-subtle mb-3" style="aspect-ratio: 600 / 240; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
24
+ <% if current_url %>
25
+ <%= image_tag current_url, class: "w-full h-full object-cover", alt: "#{label} email banner" %>
26
+ <% else %>
27
+ <div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
28
+ No image yet — emails send without a banner until you upload one.
29
+ </div>
30
+ <% end %>
31
+ </div>
32
+
33
+ <%= form_with url: admin_email_image_path(variant), method: :patch, html: { multipart: true }, class: "flex flex-wrap items-center gap-3" do %>
34
+ <%= file_field_tag :image, accept: "image/png,image/jpeg,image/webp",
35
+ class: "text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-white file:cursor-pointer" %>
36
+ <%= submit_tag(current_url ? "Replace image" : "Upload image", class: "btn btn-primary btn-sm") %>
37
+ <% end %>
38
+ </section>
39
+ <% end %>
40
+ </div>
41
+ </div>
@@ -0,0 +1,2 @@
1
+ <%# /l/:token magic-link interstitial. Shared body in studio/_confirm_interstitial. %>
2
+ <%= render "studio/confirm_interstitial", consume_path: link_consume_path(token: @token) %>
@@ -1,14 +1,29 @@
1
- <h1>Your sign-in link</h1>
1
+ <%# Body only — branded_mailer supplies the banner + card. Uses the theme success
2
+ color for the button so it matches each app. %>
3
+ <h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">Your sign-in link</h1>
2
4
 
3
- <p>Tap the button below to sign in to <%= @app_name %>. If you don't have an
4
- account yet, we'll create one for you — no password needed:</p>
5
+ <p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
6
+ Tap the button below to sign in to <strong style="color:#1f2a1c;"><%= @app_name %></strong> — no password needed.
7
+ If you don't have an account yet, we'll create one for you.
8
+ </p>
5
9
 
6
- <p><%= link_to "Sign in to #{@app_name}", @magic_url %></p>
10
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
11
+ <tr><td align="center" bgcolor="<%= Studio.theme_success %>" style="border-radius:10px;">
12
+ <a href="<%= @magic_url %>" style="display:inline-block;padding:16px 40px;font-size:17px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:10px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">Sign in to <%= @app_name %> 🪄</a>
13
+ </td></tr>
14
+ </table>
7
15
 
8
- <p>If the button doesn't work, copy and paste this URL into your browser:</p>
9
- <p><%= @magic_url %></p>
16
+ <p style="margin:28px 0 0;font-size:13px;line-height:1.5;color:#6b756a;text-align:center;">
17
+ This link is for <strong style="color:#2f3a2c;"><%= @email %></strong> and expires in <%= Studio.magic_link_ttl.in_minutes.round %> minutes — it can only be used once.
18
+ </p>
10
19
 
11
- <p>Open this link on this device to finish. It expires shortly and can only be
12
- used once. If you didn't request it, you can safely ignore this message.</p>
20
+ <p style="margin:24px 0 0;font-size:12px;line-height:1.4;color:#8a948a;text-align:center;">
21
+ Button not working? Copy and paste this link:<br>
22
+ <a href="<%= @magic_url %>" style="font-size:9px;line-height:1.3;color:<%= Studio.theme_success %>;word-break:break-all;"><%= @magic_url %></a>
23
+ </p>
13
24
 
14
- <p>— <%= @app_name %></p>
25
+ <p style="margin:28px 0 0;font-size:12px;line-height:1.5;color:#8a948a;text-align:center;">
26
+ Didn't request this? You can safely ignore this email.
27
+ </p>
28
+
29
+ <p style="margin:20px 0 0;font-size:13px;color:#6b756a;text-align:center;">— <%= @app_name %></p>
@@ -0,0 +1,27 @@
1
+ # Reference migration for the Studio::Link model. Like the engine's
2
+ # studio_email_deliveries migration, each consumer app installs its own copy of
3
+ # this into db/migrate (the adoption tasks do that) so the table is created in
4
+ # the app's database.
5
+ class CreateStudioLinks < ActiveRecord::Migration[7.2]
6
+ def change
7
+ create_table :studio_links do |t|
8
+ t.string :token, null: false
9
+ t.string :kind, null: false
10
+ # Polymorphic owner — the inviting User for referrals; nil for a magic
11
+ # link to a not-yet-existent email (that email rides in metadata).
12
+ t.references :linkable, polymorphic: true, index: false
13
+ t.jsonb :metadata, null: false, default: {}
14
+ t.datetime :expires_at
15
+ t.datetime :consumed_at
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :studio_links, :token, unique: true
21
+ add_index :studio_links, :kind
22
+ # Covers both "this owner's links" and "this owner's referral" lookups
23
+ # (referral_for) via the leading columns.
24
+ add_index :studio_links, [:linkable_type, :linkable_id, :kind],
25
+ name: "idx_studio_links_owner_kind"
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ # Lets ImageCache cache app-GLOBAL images (no owning record) — e.g. Studio::
2
+ # EmailImage stores the admin-managed email banners owner-less. Reference
3
+ # migration; each consumer app installs its own copy (the table is app-owned).
4
+ class AllowNullImageCacheOwner < ActiveRecord::Migration[7.2]
5
+ def change
6
+ change_column_null :image_caches, :owner_type, true
7
+ change_column_null :image_caches, :owner_id, true
8
+ end
9
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Studio
6
+ # Pure-Ruby helpers behind the Studio::Link model — token minting, the kind
7
+ # rules, and the input sanitizers. Kept free of ActiveRecord so it loads (and
8
+ # unit-tests) without a database, mirroring the other lib/studio/*.rb pure
9
+ # classes. The AR model (app/models/studio/link.rb) delegates to this.
10
+ module LinkToken
11
+ # The kinds of link that share the studio_links table + the /l/<token>
12
+ # entry point.
13
+ # magic_link — single-use, short-lived passwordless sign-in / sign-up
14
+ # referral — reusable, non-expiring share link owned by a User
15
+ KINDS = %w[magic_link referral].freeze
16
+
17
+ # Kinds burned on first successful consume. Referral links are reusable, so
18
+ # they are deliberately NOT single-use.
19
+ SINGLE_USE_KINDS = %w[magic_link].freeze
20
+
21
+ # 96 bits of entropy → ~16 URL-safe chars (e.g. "PP-PDbEj5V3-aNh4"). Short
22
+ # enough to keep the URL clean, far too large to brute-force — especially
23
+ # for single-use, expiring magic links. Matches turf-monster's proven format.
24
+ TOKEN_BYTES = 12
25
+
26
+ module_function
27
+
28
+ # A fresh URL-safe token. urlsafe_base64 emits only [A-Za-z0-9_-], so the
29
+ # token satisfies the %r{[^/]+} route constraint and survives URL generation
30
+ # without extra encoding.
31
+ def generate
32
+ SecureRandom.urlsafe_base64(TOKEN_BYTES)
33
+ end
34
+
35
+ def kind?(kind)
36
+ KINDS.include?(kind.to_s)
37
+ end
38
+
39
+ def single_use?(kind)
40
+ SINGLE_USE_KINDS.include?(kind.to_s)
41
+ end
42
+
43
+ def normalize_email(email)
44
+ email.to_s.strip.downcase
45
+ end
46
+
47
+ # Only same-origin absolute paths survive; protocol-relative ("//evil"),
48
+ # absolute URLs, and blanks collapse to nil so callers fall back to a safe
49
+ # default redirect. Mirrors the MagicLink service's sanitizer.
50
+ def sanitize_path(path)
51
+ p = path.to_s
52
+ p.start_with?("/") && !p.start_with?("//") ? p : nil
53
+ end
54
+ end
55
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -6,6 +6,7 @@ require "studio/ui_primitives"
6
6
  require "studio/username_generator"
7
7
  require "studio/s3"
8
8
  require "studio/image_cache"
9
+ require "studio/link_token"
9
10
  require "studio/email"
10
11
  require "studio/email_smoke"
11
12
  require "studio/mail_transport"
@@ -35,12 +36,26 @@ module Studio
35
36
  mattr_accessor :magic_link_ttl, default: 15.minutes
36
37
  mattr_accessor :magic_link_token_name, default: "magic_link_v1"
37
38
 
39
+ # Where magic-link tokens are stored / which URL scheme they use.
40
+ # :signed (default) — stateless MessageVerifier MagicLink service; URL is
41
+ # /magic_link/<long token>. No table needed. Back-compat default.
42
+ # :database — a Studio::Link row; URL is the short /l/<token>. Requires the
43
+ # studio_links table (install the reference migration). The
44
+ # unified scheme both apps move to.
45
+ mattr_accessor :magic_link_store, default: :signed
46
+
38
47
  # Whether Studio.routes draws the magic_link + solana wallet routes. An app that
39
48
  # already defines its own auth routes (e.g. turf-monster, which has battle-tested
40
49
  # magic_link/solana routes + extras) sets this false to avoid duplicate route
41
50
  # NAMES at boot, keeping its own routes intact. New consumers leave it true.
42
51
  mattr_accessor :draw_auth_routes, default: true
43
52
 
53
+ # Whether Studio.routes draws the unified /l/<token> link routes
54
+ # (Studio::LinksController — magic-link confirm/consume + referral redirect).
55
+ # Default true; an app wanting its own /l handling sets this false and draws
56
+ # its own routes (it can still reuse Studio::Link + Studio::LinkConsumption).
57
+ mattr_accessor :draw_link_routes, default: true
58
+
44
59
  # Optional admin Act As / impersonation session conventions. Consumers that
45
60
  # include Studio::Impersonation get current_user layered over true_user with
46
61
  # these session keys, but still own authorization, audit logging, and routes.
@@ -135,6 +150,15 @@ module Studio
135
150
  auth_methods.include?(method.to_sym)
136
151
  end
137
152
 
153
+ # True when the emailed/inbox magic-link URL is the short /l/<token> — i.e.
154
+ # magic links are Studio::Link rows AND this app draws the /l routes. False =
155
+ # the legacy /magic_link/<token> path: the :signed store, OR an app on the
156
+ # :database store that keeps its own /magic_link route (e.g. turf-monster,
157
+ # whose /l is already its landing-page namespace).
158
+ def self.magic_link_via_l_route?
159
+ magic_link_store == :database && draw_link_routes
160
+ end
161
+
138
162
  def self.local_email_capture?
139
163
  return false if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
140
164
  return !!local_email_capture unless local_email_capture.nil?
@@ -247,6 +271,19 @@ module Studio
247
271
  constraints: { token: %r{[^/]+} }
248
272
  end
249
273
 
274
+ # Unified short-token links — /l/<token> for magic sign-in links + referral
275
+ # links (Studio::Link). Studio::LinksController dispatches by kind: a
276
+ # magic_link renders the scanner-safe confirm interstitial then POSTs to
277
+ # consume; a referral captures attribution + redirects. Helpers: link_path
278
+ # / link_url(token:) and link_consume_path. Drawn for every consumer
279
+ # (including draw_auth_routes=false apps) unless draw_link_routes is off.
280
+ if Studio.draw_link_routes
281
+ get "l/:token", to: "studio/links#show", as: :link,
282
+ constraints: { token: %r{[^/]+} }
283
+ post "l/:token", to: "studio/links#consume", as: :link_consume,
284
+ constraints: { token: %r{[^/]+} }
285
+ end
286
+
250
287
  # Solana / Phantom wallet sign-in (nonce challenge + signature verify).
251
288
  # The browser posts to these literal paths from the shared Connect-Wallet
252
289
  # flow; app-specific surfaces (mobile deep-link callback, account-linking,
@@ -263,6 +300,13 @@ module Studio
263
300
  patch "admin/theme", to: "theme_settings#update", as: :admin_theme_update
264
301
  post "admin/theme/regenerate", to: "theme_settings#regenerate", as: :admin_theme_regenerate
265
302
  get "admin/schema", to: "schema#index", as: :admin_schema
303
+
304
+ # Admin-managed transactional-email banner images (Studio::EmailImage).
305
+ # index lists each managed email variant + its current banner; update
306
+ # uploads a replacement. Surfaced from each app's admin hub.
307
+ get "admin/email_images", to: "studio/email_images#index", as: :admin_email_images
308
+ patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
309
+ constraints: { variant: /[a-z_]+/ }
266
310
  end
267
311
  end
268
312
  end
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.7.0
4
+ version: 0.8.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-06-20 00:00:00.000000000 Z
11
+ date: 2026-06-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -148,6 +148,7 @@ files:
148
148
  - app/controllers/concerns/studio/admin_models.rb
149
149
  - app/controllers/concerns/studio/error_handling.rb
150
150
  - app/controllers/concerns/studio/impersonation.rb
151
+ - app/controllers/concerns/studio/link_consumption.rb
151
152
  - app/controllers/error_logs_controller.rb
152
153
  - app/controllers/magic_links_controller.rb
153
154
  - app/controllers/navbar_controller.rb
@@ -156,6 +157,8 @@ files:
156
157
  - app/controllers/schema_controller.rb
157
158
  - app/controllers/sessions_controller.rb
158
159
  - app/controllers/solana_sessions_controller.rb
160
+ - app/controllers/studio/email_images_controller.rb
161
+ - app/controllers/studio/links_controller.rb
159
162
  - app/controllers/studio/local_emails_controller.rb
160
163
  - app/controllers/theme_settings_controller.rb
161
164
  - app/helpers/studio/admin_models_table_helper.rb
@@ -171,9 +174,11 @@ files:
171
174
  - app/models/image_cache.rb
172
175
  - app/models/session_context.rb
173
176
  - app/models/studio/email_delivery.rb
177
+ - app/models/studio/link.rb
174
178
  - app/models/theme_setting.rb
175
179
  - app/services/google_oauth_validator.rb
176
180
  - app/services/magic_link.rb
181
+ - app/services/studio/email_image.rb
177
182
  - app/views/components/_admin_dropdown.html.erb
178
183
  - app/views/components/_avatar.html.erb
179
184
  - app/views/components/_avatar_cropper.html.erb
@@ -192,6 +197,7 @@ files:
192
197
  - app/views/error_logs/index.html.erb
193
198
  - app/views/error_logs/show.html.erb
194
199
  - app/views/layouts/_navbar.html.erb
200
+ - app/views/layouts/branded_mailer.html.erb
195
201
  - app/views/layouts/studio/_flash.html.erb
196
202
  - app/views/layouts/studio/_head.html.erb
197
203
  - app/views/magic_links/confirm.html.erb
@@ -200,6 +206,7 @@ files:
200
206
  - app/views/schema/index.html.erb
201
207
  - app/views/sessions/_sso_continue.html.erb
202
208
  - app/views/sessions/new.html.erb
209
+ - app/views/studio/_confirm_interstitial.html.erb
203
210
  - app/views/studio/_cropper_assets.html.erb
204
211
  - app/views/studio/admin_models/_arenas_table.html.erb
205
212
  - app/views/studio/admin_models/_teams_table.html.erb
@@ -211,6 +218,8 @@ files:
211
218
  - app/views/studio/banners/_email_status_button.html.erb
212
219
  - app/views/studio/banners/_environment.html.erb
213
220
  - app/views/studio/banners/_impersonation.html.erb
221
+ - app/views/studio/email_images/index.html.erb
222
+ - app/views/studio/links/confirm.html.erb
214
223
  - app/views/studio/local_emails/index.html.erb
215
224
  - app/views/studio/modals/_crop_photo.html.erb
216
225
  - app/views/studio/modals/_host.html.erb
@@ -224,6 +233,8 @@ files:
224
233
  - app/views/user_mailer/magic_link.html.erb
225
234
  - app/views/user_mailer/magic_link.text.erb
226
235
  - db/migrate/20260614000000_create_studio_email_deliveries.rb
236
+ - db/migrate/20260620000001_create_studio_links.rb
237
+ - db/migrate/20260620000002_allow_null_image_cache_owner.rb
227
238
  - lib/studio-engine.rb
228
239
  - lib/studio.rb
229
240
  - lib/studio/color_scale.rb
@@ -231,6 +242,7 @@ files:
231
242
  - lib/studio/email_smoke.rb
232
243
  - lib/studio/engine.rb
233
244
  - lib/studio/image_cache.rb
245
+ - lib/studio/link_token.rb
234
246
  - lib/studio/mail_transport.rb
235
247
  - lib/studio/s3.rb
236
248
  - lib/studio/theme_resolver.rb