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:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+3
View File
@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
View File
+4
View File
@@ -0,0 +1,4 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end
+84
View File
@@ -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
+25
View File
@@ -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
+88
View File
@@ -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
+4
View File
@@ -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
+27
View File
@@ -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
+28
View File
@@ -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