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,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :session
|
||||
delegate :user, to: :session, allow_nil: true
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
class Document < ApplicationRecord
|
||||
ROLES = %w[viewer editor owner].freeze
|
||||
LOCK_TTL = 90.seconds
|
||||
MAX_CONTENT_BYTES = 20.megabytes
|
||||
|
||||
belongs_to :owner, class_name: "User"
|
||||
belongs_to :updated_by, class_name: "User", optional: true
|
||||
has_many :document_memberships, dependent: :delete_all
|
||||
has_many :members, through: :document_memberships, source: :user
|
||||
has_many :share_links, dependent: :delete_all
|
||||
|
||||
validates :title, presence: true, length: { maximum: 200 }
|
||||
validates :content, presence: true
|
||||
validate :content_is_document_json, if: :content_changed?
|
||||
|
||||
before_validation { self.content_updated_at ||= Time.current }
|
||||
|
||||
# :owner / :editor / :viewer / nil for a signed-in user and/or a share
|
||||
# link token (a visitor without an account holds only the token). The
|
||||
# strongest of all applicable grants wins.
|
||||
def role_for(user: nil, token: nil)
|
||||
roles = []
|
||||
if user
|
||||
return :owner if owner_id == user.id
|
||||
m = document_memberships.find_by(user_id: user.id)
|
||||
roles << m.role.to_sym if m
|
||||
end
|
||||
if token.present?
|
||||
link = share_links.active.find_by(token: token)
|
||||
roles << link.role.to_sym if link
|
||||
end
|
||||
roles.max_by { |r| ROLES.index(r.to_s) }
|
||||
end
|
||||
|
||||
def self.role_at_least?(role, minimum)
|
||||
role.present? && ROLES.index(role.to_s) >= ROLES.index(minimum.to_s)
|
||||
end
|
||||
|
||||
# ---- edit lock --------------------------------------------------------
|
||||
def locked_by_other?(holder)
|
||||
lock_holder.present? && lock_holder != holder && lock_expires_at&.future?
|
||||
end
|
||||
|
||||
# Take or refresh the lock for `holder`. Returns true when `holder` has it.
|
||||
def acquire_lock!(holder, name)
|
||||
with_lock do
|
||||
return false if locked_by_other?(holder)
|
||||
update_columns(lock_holder: holder, lock_holder_name: name, lock_expires_at: LOCK_TTL.from_now)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def release_lock!(holder)
|
||||
with_lock do
|
||||
update_columns(lock_holder: nil, lock_holder_name: nil, lock_expires_at: nil) if lock_holder == holder
|
||||
end
|
||||
end
|
||||
|
||||
def lock_info(holder)
|
||||
{ locked_by_other: locked_by_other?(holder), holder_name: (lock_holder_name if locked_by_other?(holder)) }
|
||||
end
|
||||
|
||||
def meta_json(role:)
|
||||
{
|
||||
id:, title:, version:, role:,
|
||||
owner_name: owner.name,
|
||||
updated_at: content_updated_at.iso8601(3),
|
||||
updated_by_name: updated_by&.name
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def content_is_document_json
|
||||
if content.bytesize > MAX_CONTENT_BYTES
|
||||
errors.add(:content, "ist zu groß (max. #{MAX_CONTENT_BYTES / 1.megabyte} MB)")
|
||||
return
|
||||
end
|
||||
parsed = JSON.parse(content)
|
||||
errors.add(:content, "ist kein numolus-Dokument") unless parsed.is_a?(Hash) && parsed["sheets"].is_a?(Array)
|
||||
rescue JSON::ParserError
|
||||
errors.add(:content, "ist kein gültiges JSON")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
class DocumentMembership < ApplicationRecord
|
||||
belongs_to :document
|
||||
belongs_to :user
|
||||
belongs_to :share_link, optional: true
|
||||
|
||||
enum :role, { viewer: 0, editor: 1 }, validate: true
|
||||
enum :source, { invite: 0, link: 1 }, validate: true
|
||||
|
||||
# Grant access. An invitation is explicit: it sets exactly the invited role
|
||||
# and survives revoking any link. A link never weakens existing access and
|
||||
# never turns an invitation back into link-based access.
|
||||
def self.grant!(document:, user:, role:, source:, share_link: nil)
|
||||
return if document.owner_id == user.id
|
||||
m = find_or_initialize_by(document:, user:)
|
||||
if source.to_s == "invite"
|
||||
m.assign_attributes(role:, source: :invite, share_link: nil)
|
||||
elsif m.new_record?
|
||||
m.assign_attributes(role:, source: :link, share_link:)
|
||||
elsif m.link? && roles[role.to_s] > roles[m.role]
|
||||
m.assign_attributes(role:, share_link:)
|
||||
end
|
||||
m.save!
|
||||
m
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# One signed-in browser. The browser holds the (signed) session id in a cookie.
|
||||
class Session < ApplicationRecord
|
||||
belongs_to :user
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
# "Anyone with the link" access. The token is the secret part of the URL
|
||||
# (/d/<document id>#k=<token>). It is stored in plain text so the owner can
|
||||
# copy the link again at any time; the document content next to it in the
|
||||
# same database is no less sensitive.
|
||||
class ShareLink < ApplicationRecord
|
||||
belongs_to :document
|
||||
belongs_to :created_by, class_name: "User"
|
||||
has_many :document_memberships, dependent: :delete_all
|
||||
|
||||
enum :role, { viewer: 0, editor: 1 }, validate: true
|
||||
|
||||
scope :active, -> { where(revoked_at: nil) }
|
||||
|
||||
before_validation(on: :create) { self.token ||= SecureRandom.urlsafe_base64(24) }
|
||||
|
||||
# Revoking also removes the access people got through this link.
|
||||
def revoke!
|
||||
transaction do
|
||||
update!(revoked_at: Time.current)
|
||||
document_memberships.delete_all
|
||||
end
|
||||
end
|
||||
|
||||
def as_json(*)
|
||||
{ id:, role:, token:, created_at: created_at.iso8601 }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
class User < ApplicationRecord
|
||||
COLORS = %w[#e67e22 #2980b9 #27ae60 #8e44ad #c0392b #16a085 #d35400 #2c3e50].freeze
|
||||
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :owned_documents, class_name: "Document", foreign_key: :owner_id, dependent: :destroy, inverse_of: :owner
|
||||
has_many :document_memberships, dependent: :destroy
|
||||
has_many :shared_documents, through: :document_memberships, source: :document
|
||||
|
||||
normalizes :email, with: ->(e) { e.strip.downcase }
|
||||
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :display_name, length: { maximum: 80 }
|
||||
|
||||
before_create { self.color ||= COLORS.sample }
|
||||
|
||||
# Name shown to others (share dialog, lock holder, later: cursors).
|
||||
def name
|
||||
display_name.presence || email.split("@").first
|
||||
end
|
||||
|
||||
# Documents this user owns or has been given access to.
|
||||
def accessible_documents
|
||||
Document.where(owner_id: id).or(Document.where(id: document_memberships.select(:document_id)))
|
||||
end
|
||||
|
||||
def as_json(*)
|
||||
{ id:, email:, name:, display_name:, color: }
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user