Files
numolus/app/models/magic_link.rb
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

89 lines
3.0 KiB
Ruby

# A one-time sign-in link (and code) sent by mail. Also used for invitations:
# then it additionally grants access to a document when redeemed. Only
# digests of the token and the code are stored.
class MagicLink < ApplicationRecord
LOGIN_TTL = 15.minutes
INVITATION_TTL = 7.days
MAX_CODE_ATTEMPTS = 5
MAX_PER_EMAIL_PER_HOUR = 5
belongs_to :document, optional: true
belongs_to :invited_by, class_name: "User", optional: true
enum :purpose, { login: 0, invitation: 1 }
enum :role, { viewer: 0, editor: 1 }, prefix: :grants
normalizes :email, with: ->(e) { e.strip.downcase }
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :document, :role, :invited_by, presence: true, if: :invitation?
# plain values, only available on the instance that created them (for the mail)
attr_reader :token, :code
scope :usable, -> { where(used_at: nil).where("expires_at > ?", Time.current) }
def self.digest(value) = OpenSSL::Digest::SHA256.hexdigest(value.to_s)
def self.issue!(email:, purpose: :login, return_to: nil, **attrs)
link = new(email:, purpose:, return_to: safe_path(return_to), **attrs)
link.send(:generate_secrets)
link.expires_at = (link.invitation? ? INVITATION_TTL : LOGIN_TTL).from_now
link.save!
link
end
def self.throttled?(email)
where(email: email.to_s.strip.downcase, purpose: :login).where("created_at > ?", 1.hour.ago).count >= MAX_PER_EMAIL_PER_HOUR
end
def self.find_by_token(token)
usable.find_by(token_digest: digest(token)) if token.present?
end
# The newest usable login link for `email` whose code matches. Wrong codes
# count against that link; after MAX_CODE_ATTEMPTS it is burned.
def self.find_by_code(email, code)
link = usable.login.where(email: email.to_s.strip.downcase).order(created_at: :desc).first
return unless link && link.failed_attempts < MAX_CODE_ATTEMPTS
if ActiveSupport::SecurityUtils.secure_compare(link.code_digest, digest(code.to_s.gsub(/\D/, "")))
link
else
link.increment!(:failed_attempts)
nil
end
end
# Only same-site paths, so a link can never redirect elsewhere.
def self.safe_path(path)
path = path.to_s
path.start_with?("/") && !path.start_with?("//") ? path[0, 500] : nil
end
# Mark used and return the user (created on first sign-in). Invitations
# also grant their document access.
def redeem!
transaction do
lock!
raise ActiveRecord::RecordNotFound if used_at || expires_at.past?
update!(used_at: Time.current)
user = User.find_or_create_by!(email:)
DocumentMembership.grant!(document:, user:, role:, source: :invite) if invitation?
user
end
end
def destination
return "/d/#{document_id}" if invitation?
return_to.presence || "/"
end
private
def generate_secrets
@token = SecureRandom.urlsafe_base64(32)
@code = format("%06d", SecureRandom.random_number(1_000_000))
self.token_digest = self.class.digest(@token)
self.code_digest = self.class.digest(@code)
end
end