Class: Studio::Link
- Inherits:
-
ApplicationRecord
- Object
- ApplicationRecord
- Studio::Link
- Defined in:
- app/models/studio/link.rb
Overview
One table, one /l/<token> entry point, for every short-token link the apps hand out: single-use, expiring **magic_link**s and reusable, non-expiring referral links. ‘kind` selects the behavior; `metadata` (jsonb) carries the kind-specific payload (email, return_to, target, age_attested) OFF the wire so the URL is just the short random token.
Generalizes turf-monster’s app-local MagicLink model: adds a polymorphic ‘linkable` owner (the inviting User for referrals; left nil for a magic link to a not-yet-existent email — that email rides in metadata) and the `kind` discriminator. Replaces the engine’s stateless MessageVerifier MagicLink service for mcritchie-studio so both apps share one short-token scheme.
Like Studio::EmailDelivery, the table lives in each consumer app (copy the reference migration in db/migrate); this model is shipped by the gem.
Defined Under Namespace
Classes: InvalidToken
Class Method Summary collapse
-
.consume!(token) ⇒ Object
Find by token + burn-if-single-use in one call.
-
.create_magic_link(email:, return_to: nil, age_attested: false, linkable: nil, ttl: nil) ⇒ Object
A single-use sign-in/sign-up link.
-
.referral_for(linkable, target: nil) ⇒ Object
A user’s referral link is stable + reusable, keyed by its landing target so sharing contest A vs B yields distinct (but each stable) links — both crediting the same inviter.
Instance Method Summary collapse
- #age_attested ⇒ Object (also: #age_attested?)
-
#consume! ⇒ Object
Single-use kinds (magic_link) are atomically burned: only the first caller flips consumed_at, so a replay / double-submit loses the race and is rejected.
- #consumed? ⇒ Boolean
-
#email ⇒ Object
— metadata readers (sanitized on the way out) ————————-.
- #expired? ⇒ Boolean
- #live? ⇒ Boolean
- #return_to ⇒ Object
- #single_use? ⇒ Boolean
- #target ⇒ Object
Class Method Details
.consume!(token) ⇒ Object
Find by token + burn-if-single-use in one call. Raises InvalidToken for unknown / expired / already-used links. Returns the live Link.
68 69 70 71 72 73 |
# File 'app/models/studio/link.rb', line 68 def consume!(token) link = find_by(token: token.to_s) raise InvalidToken, "unknown link" unless link link.consume! end |
.create_magic_link(email:, return_to: nil, age_attested: false, linkable: nil, ttl: nil) ⇒ Object
A single-use sign-in/sign-up link. The email rides in metadata (not the URL, not a column) per the create-or-login flow — the account may not exist yet. ‘ttl` defaults to the app’s Studio.magic_link_ttl.
37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'app/models/studio/link.rb', line 37 def create_magic_link(email:, return_to: nil, age_attested: false, linkable: nil, ttl: nil) ttl ||= Studio.magic_link_ttl mint!( kind: "magic_link", linkable: linkable, expires_at: ttl.from_now, metadata: { "email" => Studio::LinkToken.normalize_email(email), "return_to" => Studio::LinkToken.sanitize_path(return_to), "age_attested" => !!age_attested }.compact ) end |
.referral_for(linkable, target: nil) ⇒ Object
A user’s referral link is stable + reusable, keyed by its landing target so sharing contest A vs B yields distinct (but each stable) links — both crediting the same inviter. ‘target` is an optional same-origin path the referral redirects to (e.g. a specific contest).
55 56 57 58 59 60 61 62 63 64 |
# File 'app/models/studio/link.rb', line 55 def referral_for(linkable, target: nil) wanted = Studio::LinkToken.sanitize_path(target) referrals.where(linkable: linkable).live.detect { |link| link.target == wanted } || mint!( kind: "referral", linkable: linkable, expires_at: nil, metadata: { "target" => wanted }.compact ) end |
Instance Method Details
#age_attested ⇒ Object Also known as: age_attested?
139 140 141 |
# File 'app/models/studio/link.rb', line 139 def age_attested !!( && ["age_attested"]) end |
#consume! ⇒ Object
Single-use kinds (magic_link) are atomically burned: only the first caller flips consumed_at, so a replay / double-submit loses the race and is rejected. Reusable kinds (referral) only check expiry. Returns self.
94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'app/models/studio/link.rb', line 94 def consume! if single_use? burned = self.class.unconsumed .where(id: id) .where("expires_at IS NULL OR expires_at > ?", Time.current) .update_all(consumed_at: Time.current) raise InvalidToken, "link already used or expired" if burned.zero? self.consumed_at = Time.current elsif expired? raise InvalidToken, "link expired" end self end |
#consumed? ⇒ Boolean
117 118 119 |
# File 'app/models/studio/link.rb', line 117 def consumed? consumed_at.present? end |
#email ⇒ Object
— metadata readers (sanitized on the way out) ————————-
127 128 129 |
# File 'app/models/studio/link.rb', line 127 def email && ["email"] end |
#expired? ⇒ Boolean
113 114 115 |
# File 'app/models/studio/link.rb', line 113 def expired? expires_at.present? && expires_at <= Time.current end |
#live? ⇒ Boolean
121 122 123 |
# File 'app/models/studio/link.rb', line 121 def live? !expired? && !(single_use? && consumed?) end |
#return_to ⇒ Object
131 132 133 |
# File 'app/models/studio/link.rb', line 131 def return_to Studio::LinkToken.sanitize_path( && ["return_to"]) end |
#single_use? ⇒ Boolean
109 110 111 |
# File 'app/models/studio/link.rb', line 109 def single_use? Studio::LinkToken.single_use?(kind) end |
#target ⇒ Object
135 136 137 |
# File 'app/models/studio/link.rb', line 135 def target Studio::LinkToken.sanitize_path( && ["target"]) end |