This commit is contained in:
6
app/models/access.rb
Normal file
6
app/models/access.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class Access < ApplicationRecord
|
||||
enum :level, %w[ reader editor ].index_by(&:itself)
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :book
|
||||
end
|
||||
3
app/models/account.rb
Normal file
3
app/models/account.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Account < ApplicationRecord
|
||||
include Joinable
|
||||
end
|
||||
16
app/models/account/joinable.rb
Normal file
16
app/models/account/joinable.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module Account::Joinable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_create { self.join_code = generate_join_code }
|
||||
end
|
||||
|
||||
def reset_join_code
|
||||
update! join_code: generate_join_code
|
||||
end
|
||||
|
||||
private
|
||||
def generate_join_code
|
||||
SecureRandom.alphanumeric(12).scan(/.{4}/).join("-")
|
||||
end
|
||||
end
|
||||
3
app/models/application_record.rb
Normal file
3
app/models/application_record.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
15
app/models/book.rb
Normal file
15
app/models/book.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class Book < ApplicationRecord
|
||||
include Accessable, Sluggable
|
||||
|
||||
has_many :leaves, dependent: :destroy
|
||||
has_one_attached :cover, dependent: :purge_later
|
||||
|
||||
scope :ordered, -> { order(:title) }
|
||||
scope :published, -> { where(published: true) }
|
||||
|
||||
enum :theme, %w[ black blue green magenta orange violet white ].index_by(&:itself), suffix: true, default: :blue
|
||||
|
||||
def press(leafable, leaf_params)
|
||||
leaves.create! leaf_params.merge(leafable: leafable)
|
||||
end
|
||||
end
|
||||
47
app/models/book/accessable.rb
Normal file
47
app/models/book/accessable.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
module Book::Accessable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :accesses, dependent: :destroy
|
||||
scope :with_everyone_access, -> { where(everyone_access: true) }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def accessable_or_published(user: Current.user)
|
||||
if user.present?
|
||||
accessable_or_published_books
|
||||
else
|
||||
published
|
||||
end
|
||||
end
|
||||
|
||||
def accessable_or_published_books(user: Current.user)
|
||||
user.books.or(published).distinct
|
||||
end
|
||||
end
|
||||
|
||||
def accessable?(user: Current.user)
|
||||
accesses.exists?(user: user)
|
||||
end
|
||||
|
||||
def editable?(user: Current.user)
|
||||
access_for(user: user)&.editor? || user&.administrator?
|
||||
end
|
||||
|
||||
def access_for(user: Current.user)
|
||||
accesses.find_by(user: user)
|
||||
end
|
||||
|
||||
def update_access(editors:, readers:)
|
||||
editors = Set.new(editors)
|
||||
readers = Set.new(everyone_access? ? User.active.ids : readers)
|
||||
|
||||
all = editors + readers
|
||||
all_accesses = all.collect { |user_id|
|
||||
{ user_id: user_id, level: editors.include?(user_id) ? :editor : :reader }
|
||||
}
|
||||
|
||||
accesses.upsert_all(all_accesses, unique_by: [ :book_id, :user_id ])
|
||||
accesses.where.not(user_id: all).delete_all
|
||||
end
|
||||
end
|
||||
11
app/models/book/sluggable.rb
Normal file
11
app/models/book/sluggable.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
module Book::Sluggable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_save :generate_slug, if: -> { slug.blank? }
|
||||
end
|
||||
|
||||
def generate_slug
|
||||
self.slug = title.parameterize
|
||||
end
|
||||
end
|
||||
10
app/models/concerns/authorization.rb
Normal file
10
app/models/concerns/authorization.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
module Authorization
|
||||
private
|
||||
def ensure_can_administer
|
||||
head :forbidden unless Current.user.can_administer?
|
||||
end
|
||||
|
||||
def ensure_current_user
|
||||
head :forbidden unless @user.current?
|
||||
end
|
||||
end
|
||||
117
app/models/concerns/positionable.rb
Normal file
117
app/models/concerns/positionable.rb
Normal file
@@ -0,0 +1,117 @@
|
||||
module Positionable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
REBALANCE_THRESHOLD = 1e-10
|
||||
ELEMENT_GAP = 1
|
||||
|
||||
included do
|
||||
scope :positioned, -> { order(:position_score, :id) }
|
||||
|
||||
scope :before, ->(other) { positioned.where("position_score < ?", other.position_score) }
|
||||
scope :after, ->(other) { positioned.where("position_score > ?", other.position_score) }
|
||||
|
||||
around_create :insert_at_default_position
|
||||
after_save_commit :rebalance_positions, if: :rebalance_required?
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def positioned_within(parent, association:, filter:)
|
||||
define_method :positioning_parent do
|
||||
send(parent)
|
||||
end
|
||||
|
||||
define_method :all_positioned_siblings do
|
||||
positioning_parent.send(association).send(filter).positioned
|
||||
end
|
||||
|
||||
define_method :other_positioned_siblings do
|
||||
all_positioned_siblings.excluding(self)
|
||||
end
|
||||
|
||||
private :positioning_parent, :all_positioned_siblings, :other_positioned_siblings
|
||||
end
|
||||
end
|
||||
|
||||
def previous
|
||||
other_positioned_siblings.before(self).last
|
||||
end
|
||||
|
||||
def next
|
||||
other_positioned_siblings.after(self).first
|
||||
end
|
||||
|
||||
def move_to_position(offset, followed_by: [])
|
||||
with_positioning_lock do
|
||||
all_to_move = [ self, *followed_by ]
|
||||
before, after = before_and_after_for(offset: offset, moving: all_to_move)
|
||||
gap = (after - before) / (all_to_move.count + 1)
|
||||
|
||||
all_to_move.each.with_index(1) do |item, index|
|
||||
item.update!(position_score: before + (index * gap))
|
||||
end
|
||||
|
||||
remember_to_rebalance_positions if gap < REBALANCE_THRESHOLD
|
||||
end
|
||||
end
|
||||
|
||||
def position_as_percentage
|
||||
100 * ordinal_position.to_f / all_positioned_siblings.count
|
||||
end
|
||||
|
||||
private
|
||||
def ordinal_position
|
||||
other_positioned_siblings.before(self).count + 1
|
||||
end
|
||||
|
||||
def insert_at_default_position
|
||||
with_positioning_lock do
|
||||
position_at_end
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def position_at_start
|
||||
self.position_score = (all_positioned_siblings.minimum(:position_score) || (2 * ELEMENT_GAP)) - ELEMENT_GAP
|
||||
end
|
||||
|
||||
def position_at_end
|
||||
self.position_score = (all_positioned_siblings.maximum(:position_score) || 0) + ELEMENT_GAP
|
||||
end
|
||||
|
||||
def before_and_after_for(offset:, moving:)
|
||||
other_items = all_positioned_siblings.excluding(moving)
|
||||
|
||||
if offset < 1
|
||||
after = all_positioned_siblings.minimum(:position_score) || (2 * ELEMENT_GAP)
|
||||
before = after - ELEMENT_GAP
|
||||
else
|
||||
before, after = other_items.offset(offset - 1).limit(2).pluck(:position_score)
|
||||
before ||= all_positioned_siblings.maximum(:position_score)
|
||||
after ||= before + (moving.count.succ * ELEMENT_GAP)
|
||||
end
|
||||
|
||||
[ before, after ]
|
||||
end
|
||||
|
||||
def remember_to_rebalance_positions
|
||||
@rebalance_required = true
|
||||
end
|
||||
|
||||
def rebalance_required?
|
||||
@rebalance_required
|
||||
end
|
||||
|
||||
def rebalance_positions
|
||||
with_positioning_lock do
|
||||
odered = all_positioned_siblings.select("row_number() over (order by position_score, id) as new_score, id")
|
||||
sql = "update #{self.class.table_name} set position_score = new_score from (#{odered.to_sql}) as ordered where #{self.class.table_name}.id = ordered.id"
|
||||
|
||||
self.class.connection.execute sql
|
||||
end
|
||||
@rebalance_required = false
|
||||
end
|
||||
|
||||
def with_positioning_lock(&block)
|
||||
positioning_parent.with_lock &block
|
||||
end
|
||||
end
|
||||
7
app/models/current.rb
Normal file
7
app/models/current.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
|
||||
def account
|
||||
Account.first
|
||||
end
|
||||
end
|
||||
87
app/models/demo_content.rb
Normal file
87
app/models/demo_content.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
class DemoContent
|
||||
class << self
|
||||
def create_manual(user)
|
||||
book = create_book(user)
|
||||
load_markdown_pages(book)
|
||||
end
|
||||
|
||||
private
|
||||
def create_book(user)
|
||||
Book.create(title: "The Writebook Manual", author: "37signals", everyone_access: true).tap do |book|
|
||||
with_attachment("writebook-manual.jpg") { |attachment| book.cover.attach(attachment) }
|
||||
book.update_access(readers: [], editors: [ user.id ])
|
||||
end
|
||||
end
|
||||
|
||||
def load_markdown_pages(book)
|
||||
pages = {}
|
||||
|
||||
Dir.glob(Rails.root.join("app/assets/markdown/demo/*.md")).each do |fname|
|
||||
front_matter = FrontMatterParser::Parser.parse_file(fname)
|
||||
|
||||
if front_matter["class"] == "Section"
|
||||
load_section(book, front_matter)
|
||||
else
|
||||
page = load_markdown_page(book, front_matter)
|
||||
attach_images(page)
|
||||
pages[page.leaf.slug] = page
|
||||
end
|
||||
end
|
||||
|
||||
book.leaves.pages.each { |leaf| localize_ref_links(leaf.page, pages) }
|
||||
end
|
||||
|
||||
def load_markdown_page(book, front_matter)
|
||||
book.press(Page.new(body: front_matter.content), title: front_matter["title"]).page
|
||||
end
|
||||
|
||||
def load_section(book, front_matter)
|
||||
book.press Section.new(body: front_matter.content, theme: front_matter["theme"]), title: front_matter["title"]
|
||||
end
|
||||
|
||||
def attach_images(page)
|
||||
re = %r{
|
||||
\/u\/ # leading portion of path
|
||||
(\S+-\w+\.\w+) # filename including slug and extension
|
||||
}x
|
||||
|
||||
body = page.body.content.gsub(re) do |match|
|
||||
with_attachment($1) { |attachment| page.body.uploads.attach(attachment) }
|
||||
|
||||
attachment = page.body.uploads.attachments.last
|
||||
attachment.analyze
|
||||
|
||||
"/u/" + attachment.slug
|
||||
end
|
||||
|
||||
page.update!(body: body)
|
||||
end
|
||||
|
||||
def localize_ref_links(page, pages)
|
||||
re = %r{
|
||||
(\[.+\]) # link title
|
||||
\( # opening paren
|
||||
\/\d+\/[\w-]+\/\d+\/ # leading portion of path
|
||||
([\w-]+) # leaf slug
|
||||
}x
|
||||
|
||||
body = page.body.content.gsub(re) do |match|
|
||||
link_title, leaf_slug, anchor = $1, $2, $3
|
||||
linked_page = pages[leaf_slug]
|
||||
raise "Invalid reference link: #{page_title}" unless linked_page.present?
|
||||
|
||||
url = Rails.application.routes.url_helpers.leafable_slug_path(linked_page.leaf, anchor: anchor, only_path: true)
|
||||
|
||||
"#{link_title}(#{url}"
|
||||
end
|
||||
|
||||
page.update!(body: body)
|
||||
end
|
||||
|
||||
def with_attachment(filename)
|
||||
File.open(Rails.root.join("app/assets/images/demo/#{filename}")) do |file|
|
||||
yield io: file, filename: filename
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
18
app/models/edit.rb
Normal file
18
app/models/edit.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class Edit < ApplicationRecord
|
||||
belongs_to :leaf
|
||||
delegated_type :leafable, types: Leafable::TYPES, dependent: :destroy
|
||||
|
||||
enum :action, %w[ revision trash ].index_by(&:itself)
|
||||
|
||||
scope :sorted, -> { order(created_at: :desc) }
|
||||
scope :before, ->(edit) { where("created_at < ?", edit.created_at) }
|
||||
scope :after, ->(edit) { where("created_at > ?", edit.created_at) }
|
||||
|
||||
def previous
|
||||
leaf.edits.before(self).last
|
||||
end
|
||||
|
||||
def next
|
||||
leaf.edits.after(self).first
|
||||
end
|
||||
end
|
||||
11
app/models/first_run.rb
Normal file
11
app/models/first_run.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class FirstRun
|
||||
ACCOUNT_NAME = "Writebook"
|
||||
|
||||
def self.create!(user_params)
|
||||
account = Account.create!(name: ACCOUNT_NAME)
|
||||
|
||||
User.create!(user_params.merge(role: :administrator)).tap do |user|
|
||||
DemoContent.create_manual(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
8
app/models/html_scrubber.rb
Normal file
8
app/models/html_scrubber.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class HtmlScrubber < Rails::Html::PermitScrubber
|
||||
def initialize
|
||||
super
|
||||
self.tags = Rails::Html::WhiteListSanitizer.allowed_tags + %w[
|
||||
audio details summary iframe options table tbody td th thead tr video source
|
||||
]
|
||||
end
|
||||
end
|
||||
15
app/models/leaf.rb
Normal file
15
app/models/leaf.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class Leaf < ApplicationRecord
|
||||
include Editable, Positionable
|
||||
|
||||
belongs_to :book, touch: true
|
||||
delegated_type :leafable, types: Leafable::TYPES, dependent: :destroy
|
||||
positioned_within :book, association: :leaves, filter: :active
|
||||
|
||||
enum :status, %w[ active trashed ].index_by(&:itself), default: :active
|
||||
|
||||
scope :with_leafables, -> { includes(:leafable) }
|
||||
|
||||
def slug
|
||||
title.parameterize.presence || "-"
|
||||
end
|
||||
end
|
||||
69
app/models/leaf/editable.rb
Normal file
69
app/models/leaf/editable.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
module Leaf::Editable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
MINIMUM_TIME_BETWEEN_VERSIONS = 10.minutes
|
||||
|
||||
included do
|
||||
has_many :edits, dependent: :delete_all
|
||||
|
||||
after_update :record_moved_to_trash, if: :was_trashed?
|
||||
end
|
||||
|
||||
def edit(leafable_params: {}, leaf_params: {})
|
||||
if record_new_edit?(leafable_params)
|
||||
update_and_record_edit leaf_params, leafable_params
|
||||
else
|
||||
update_without_recording_edit leaf_params, leafable_params
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def record_new_edit?(leafable_params)
|
||||
will_change_leafable?(leafable_params) && last_edit_old?
|
||||
end
|
||||
|
||||
def last_edit_old?
|
||||
edits.empty? || edits.last.created_at.before?(MINIMUM_TIME_BETWEEN_VERSIONS.ago)
|
||||
end
|
||||
|
||||
def will_change_leafable?(leafable_params)
|
||||
leafable_params.select do |key, value|
|
||||
leafable.attributes[key.to_s] != value
|
||||
end.present?
|
||||
end
|
||||
|
||||
def update_without_recording_edit(leaf_params, leafable_params)
|
||||
transaction do
|
||||
leafable.update!(leafable_params)
|
||||
|
||||
edits.last&.touch
|
||||
update! leaf_params
|
||||
end
|
||||
end
|
||||
|
||||
def update_and_record_edit(leaf_params, leafable_params)
|
||||
transaction do
|
||||
new_leafable = dup_leafable_with_attachments leafable
|
||||
new_leafable.update!(leafable_params)
|
||||
|
||||
edits.revision.create!(leafable: leafable)
|
||||
update! leaf_params.merge(leafable: new_leafable)
|
||||
end
|
||||
end
|
||||
|
||||
def dup_leafable_with_attachments(leafable)
|
||||
leafable.dup.tap do |new|
|
||||
leafable.attachment_reflections.each do |name, _|
|
||||
new.send(name).attach(leafable.send(name).blob)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def record_moved_to_trash
|
||||
edits.trash.create!(leafable: leafable)
|
||||
end
|
||||
|
||||
def was_trashed?
|
||||
trashed? && previous_changes.include?(:status)
|
||||
end
|
||||
end
|
||||
22
app/models/leafable.rb
Normal file
22
app/models/leafable.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
module Leafable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TYPES = %w[ Page Section Picture ]
|
||||
|
||||
included do
|
||||
has_one :leaf, as: :leafable, inverse_of: :leafable, touch: true
|
||||
has_one :book, through: :leaf
|
||||
|
||||
delegate :title, to: :leaf
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def leafable_name
|
||||
@leafable_name ||= ActiveModel::Name.new(self).singular.inquiry
|
||||
end
|
||||
end
|
||||
|
||||
def leafable_name
|
||||
self.class.leafable_name
|
||||
end
|
||||
end
|
||||
19
app/models/page.rb
Normal file
19
app/models/page.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class Page < ApplicationRecord
|
||||
include Leafable
|
||||
|
||||
cattr_accessor :preview_renderer do
|
||||
renderer = Redcarpet::Render::HTML.new(ActionText::Markdown::DEFAULT_RENDERER_OPTIONS)
|
||||
Redcarpet::Markdown.new(renderer, ActionText::Markdown::DEFAULT_MARKDOWN_EXTENSIONS)
|
||||
end
|
||||
|
||||
has_markdown :body
|
||||
|
||||
def html_preview
|
||||
preview_renderer.render(body_preview)
|
||||
end
|
||||
|
||||
private
|
||||
def body_preview
|
||||
body.content.to_s.first(1024)
|
||||
end
|
||||
end
|
||||
7
app/models/picture.rb
Normal file
7
app/models/picture.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Picture < ApplicationRecord
|
||||
include Leafable
|
||||
|
||||
has_one_attached :image do |attachable|
|
||||
attachable.variant :large, resize_to_limit: [ 1500, 1500 ]
|
||||
end
|
||||
end
|
||||
26
app/models/qr_code_link.rb
Normal file
26
app/models/qr_code_link.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class QrCodeLink
|
||||
attr_reader :url
|
||||
|
||||
def initialize(url)
|
||||
@url = url
|
||||
end
|
||||
|
||||
def signed
|
||||
self.class.verifier.generate(@url, purpose: :qr_code)
|
||||
end
|
||||
|
||||
def self.from_signed(signed)
|
||||
new verifier.verify(signed, purpose: :qr_code)
|
||||
end
|
||||
|
||||
private
|
||||
class << self
|
||||
def verifier
|
||||
ActiveSupport::MessageVerifier.new(secret, url_safe: true)
|
||||
end
|
||||
|
||||
def secret
|
||||
Rails.application.key_generator.generate_key("qr_codes")
|
||||
end
|
||||
end
|
||||
end
|
||||
3
app/models/section.rb
Normal file
3
app/models/section.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Section < ApplicationRecord
|
||||
include Leafable
|
||||
end
|
||||
19
app/models/session.rb
Normal file
19
app/models/session.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class Session < ApplicationRecord
|
||||
ACTIVITY_REFRESH_RATE = 1.hour
|
||||
|
||||
has_secure_token
|
||||
|
||||
belongs_to :user
|
||||
|
||||
before_create { self.last_active_at ||= Time.now }
|
||||
|
||||
def self.start!(user_agent:, ip_address:)
|
||||
create! user_agent: user_agent, ip_address: ip_address
|
||||
end
|
||||
|
||||
def resume(user_agent:, ip_address:)
|
||||
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
|
||||
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
|
||||
end
|
||||
end
|
||||
end
|
||||
36
app/models/user.rb
Normal file
36
app/models/user.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
class User < ApplicationRecord
|
||||
include Role, Transferable
|
||||
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_secure_password validations: false
|
||||
|
||||
has_many :accesses, dependent: :destroy
|
||||
has_many :books, through: :accesses
|
||||
has_many :leaves, through: :books
|
||||
|
||||
after_create :grant_access_to_everyone_books
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:name) }
|
||||
|
||||
def current?
|
||||
self == Current.user
|
||||
end
|
||||
|
||||
def deactivate
|
||||
transaction do
|
||||
sessions.delete_all
|
||||
update! active: false, email_address: deactived_email_address
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def deactived_email_address
|
||||
email_address&.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
|
||||
end
|
||||
|
||||
def grant_access_to_everyone_books
|
||||
all_accesses = Book.with_everyone_access.ids.collect { |id| { book_id: id, level: :reader } }
|
||||
accesses.insert_all(all_accesses)
|
||||
end
|
||||
end
|
||||
11
app/models/user/role.rb
Normal file
11
app/models/user/role.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
module User::Role
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
enum :role, %i[ member administrator ], default: :member
|
||||
end
|
||||
|
||||
def can_administer?
|
||||
administrator?
|
||||
end
|
||||
end
|
||||
15
app/models/user/transferable.rb
Normal file
15
app/models/user/transferable.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module User::Transferable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
|
||||
|
||||
class_methods do
|
||||
def find_by_transfer_id(id)
|
||||
find_signed(id, purpose: :transfer)
|
||||
end
|
||||
end
|
||||
|
||||
def transfer_id
|
||||
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user