Class: Studio::Link

Inherits:
ApplicationRecord
  • Object
show all
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

Instance Method Summary collapse

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.

Raises:



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

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_attestedObject 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

Returns:

  • (Boolean)


117
118
119
# File 'app/models/studio/link.rb', line 117

def consumed?
  consumed_at.present?
end

#emailObject

— metadata readers (sanitized on the way out) ————————-



127
128
129
# File 'app/models/studio/link.rb', line 127

def email
   && ["email"]
end

#expired?Boolean

Returns:

  • (Boolean)


113
114
115
# File 'app/models/studio/link.rb', line 113

def expired?
  expires_at.present? && expires_at <= Time.current
end

#live?Boolean

Returns:

  • (Boolean)


121
122
123
# File 'app/models/studio/link.rb', line 121

def live?
  !expired? && !(single_use? && consumed?)
end

#return_toObject



131
132
133
# File 'app/models/studio/link.rb', line 131

def return_to
  Studio::LinkToken.sanitize_path( && ["return_to"])
end

#single_use?Boolean

Returns:

  • (Boolean)


109
110
111
# File 'app/models/studio/link.rb', line 109

def single_use?
  Studio::LinkToken.single_use?(kind)
end

#targetObject



135
136
137
# File 'app/models/studio/link.rb', line 135

def target
  Studio::LinkToken.sanitize_path( && ["target"])
end