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 +4 -4
- data/CHANGELOG.md +28 -0
- data/app/controllers/concerns/studio/link_consumption.rb +52 -0
- data/app/controllers/magic_links_controller.rb +13 -33
- data/app/controllers/studio/email_images_controller.rb +41 -0
- data/app/controllers/studio/links_controller.rb +65 -0
- data/app/controllers/studio/local_emails_controller.rb +5 -1
- data/app/mailers/user_mailer.rb +18 -2
- data/app/models/image_cache.rb +5 -1
- data/app/models/studio/link.rb +144 -0
- data/app/services/studio/email_image.rb +85 -0
- data/app/views/layouts/branded_mailer.html.erb +31 -0
- data/app/views/magic_links/confirm.html.erb +2 -96
- data/app/views/studio/_confirm_interstitial.html.erb +105 -0
- data/app/views/studio/email_images/index.html.erb +41 -0
- data/app/views/studio/links/confirm.html.erb +2 -0
- data/app/views/user_mailer/magic_link.html.erb +24 -9
- data/db/migrate/20260620000001_create_studio_links.rb +27 -0
- data/db/migrate/20260620000002_allow_null_image_cache_owner.rb +9 -0
- data/lib/studio/link_token.rb +55 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +44 -0
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f4e53e209d300c3d55aa211ae40024421ee78734929e1e1db1fd683fa84ffeb0
|
|
4
|
+
data.tar.gz: 372749c4b18ba3072cd4977ee5e2481d18b870ba14b93e44cca9863f5781c1f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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/
|
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -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
|
|
11
|
-
@
|
|
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
|
data/app/models/image_cache.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
class ImageCache < ApplicationRecord
|
|
2
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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>
|
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
|
|
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
|
|
4
|
-
|
|
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
|
-
<
|
|
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
|
|
9
|
-
<
|
|
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
|
|
12
|
-
|
|
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
|
|
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
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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-
|
|
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
|