Class: Studio::Link

Inherits:
ApplicationRecord
  • Object
show all
Defined in:
app/models/studio/link.rb

Overview

One table, one /l/ entry point, for every short-token link the apps hand out: single-use, expiring magic_links 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 — installed by bin/rails studio_engine:install:migrations, never hand-copied (a hand copy collides with the task's own copy on class CreateStudioLinks). This model is shipped by the gem.

Defined Under Namespace

Classes: InvalidToken, MissingTable

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:



75
76
77
78
79
80
# File 'app/models/studio/link.rb', line 75

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.



44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'app/models/studio/link.rb', line 44

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).



62
63
64
65
66
67
68
69
70
71
# File 'app/models/studio/link.rb', line 62

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?



192
193
194
# File 'app/models/studio/link.rb', line 192

def age_attested
  !!( && ["age_attested"])
end

#burnObject

The non-raising sibling of #consume!, and the one the click flow uses. Returns whether THIS caller won the burn — false for a link that was already used, has expired, or lost the atomic race to a concurrent click.

Why a boolean and not the exception: "the link was dead" is not an error here, it is one of the two normal outcomes, and the branch it feeds (Studio::LinkResolution) needs the answer as data. Reloads on a loss so the caller reads the row's settled state (consumed_at / expires_at) rather than the copy it held before the race.



143
144
145
146
147
148
149
# File 'app/models/studio/link.rb', line 143

def burn
  consume!
  true
rescue InvalidToken
  reload
  false
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.



119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'app/models/studio/link.rb', line 119

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)


170
171
172
# File 'app/models/studio/link.rb', line 170

def consumed?
  consumed_at.present?
end

#dead_statusObject

How a failed burn should be described. Only meaningful once #burn has returned false (or on a link that was never burned at all).



153
154
155
156
157
158
159
160
# File 'app/models/studio/link.rb', line 153

def dead_status
  return :used if single_use? && consumed?
  return :expired if expired?

  # Neither flag is set but the burn did not land: a concurrent click won
  # it between our read and our write. Same story for the reader.
  :used
end

#emailObject

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



180
181
182
# File 'app/models/studio/link.rb', line 180

def email
   && ["email"]
end

#expired?Boolean

Returns:

  • (Boolean)


166
167
168
# File 'app/models/studio/link.rb', line 166

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

#live?Boolean

Returns:

  • (Boolean)


174
175
176
# File 'app/models/studio/link.rb', line 174

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

#return_toObject



184
185
186
# File 'app/models/studio/link.rb', line 184

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

#single_use?Boolean

Returns:

  • (Boolean)


162
163
164
# File 'app/models/studio/link.rb', line 162

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

#targetObject



188
189
190
# File 'app/models/studio/link.rb', line 188

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