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>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user