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>
89 lines
3.0 KiB
Ruby
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
|