Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/controllers/books/leaves_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class Books::LeavesController < ApplicationController
include BookScoped

allow_bearer_key_access only: :index

def index
@leaves = @book.leaves.active.with_leafables.positioned
end
end
1 change: 1 addition & 0 deletions app/controllers/books_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class BooksController < ApplicationController
allow_unauthenticated_access only: %i[ index show ]
allow_bearer_key_access only: :show

before_action :ensure_index_is_not_empty, only: :index
before_action :set_book, only: %i[ show edit update destroy ]
Expand Down
33 changes: 30 additions & 3 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Authentication
before_action :require_authentication
helper_method :signed_in?

protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
protect_from_forgery with: :exception, unless: -> { authenticated_by.bearer_key? }
end

class_methods do
Expand All @@ -19,6 +19,10 @@ def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
before_action :restore_authentication, **options
end

def allow_bearer_key_access(**options)
prepend_before_action :permit_bearer_key_authentication, **options
end
end

private
Expand All @@ -33,12 +37,35 @@ def require_authentication
def restore_authentication
if session = find_session_by_cookie
resume_session session
elsif user = find_user_by_bearer_key
authenticated_as_api_client user
end
end

# Bearer keys only authenticate on controllers that opted in via
# allow_bearer_key_access. Everywhere else the request stays anonymous.
def permit_bearer_key_authentication
@bearer_key_authentication_permitted = true
end

def find_user_by_bearer_key
if @bearer_key_authentication_permitted
authenticate_with_http_token { |token, _options| User.active.find_by(bearer_key: token) }
end
end

def authenticated_as_api_client(user)
Current.user = user
set_authenticated_by :bearer_key
end

def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_url
if request.authorization.present? || !request.format.html?
head :unauthorized
else
session[:return_to_after_authenticating] = request.url
redirect_to new_session_url
end
end

def redirect_signed_in_user_to_root
Expand Down
71 changes: 60 additions & 11 deletions app/controllers/leafables_controller.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
class LeafablesController < ApplicationController
allow_unauthenticated_access only: :show
allow_bearer_key_access only: %i[ show create update destroy ]

include SetBookLeaf

before_action :ensure_editable, except: :show
before_action :broadcast_being_edited_indicator, only: :update
before_action :broadcast_being_edited_indicator, only: :update, unless: -> { api_request? }

rescue_from Leaf::Document::Malformed do |error|
render plain: error.message, status: :unprocessable_entity
end

def new
@leafable = new_leafable
end

def create
@leaf = @book.press new_leafable, leaf_params
position_new_leaf @leaf
if api_request? && @leaf = leaf_with_external_id
revise_leaf
render_leaf
else
@leaf = @book.press new_leafable, leaf_params.with_defaults(default_leaf_params)
position_leaf
render_leaf status: :created if api_request?
end
end

def show
Expand All @@ -26,11 +37,12 @@ def edit
end

def update
@leaf.edit leafable_params: leafable_params, leaf_params: leaf_params
revise_leaf

respond_to do |format|
format.turbo_stream { render }
format.html { head :no_content }
format.any(:md, :json) { render_leaf }
end
end

Expand All @@ -40,12 +52,55 @@ def destroy
respond_to do |format|
format.turbo_stream { render }
format.html { redirect_to book_slug_url(@book) }
format.any(:md, :json) { head :no_content }
end
end

private
def api_request?
request.format.md? || request.format.json?
end

def leaf_document
@leaf_document ||= Leaf::Document.parse(request.raw_post) if request.format.md?
end

def external_id
leaf_document ? leaf_document.external_id : params[:external_id].presence
end

def leaf_with_external_id
@book.leaves.find_by(external_id: external_id) if external_id
end

def revise_leaf
@leaf.active! if @leaf.trashed?
@leaf.edit leafable_params: leafable_params, leaf_params: leaf_params
position_leaf
end

def position_leaf
if position = requested_position
@leaf.move_to_position position
end
end

def requested_position
leaf_document ? leaf_document.position : params[:position]&.to_i
end

def render_leaf(status: :ok)
respond_to do |format|
format.any(:md, :json) { render :show, status: status }
end
end

def leaf_params
default_leaf_params.merge params.fetch(:leaf, {}).permit(:title)
if leaf_document
{ title: leaf_document.title, external_id: leaf_document.external_id }.compact
else
params.fetch(:leaf, {}).permit(:title).to_h.symbolize_keys.merge({ external_id: external_id }.compact)
end
end

def default_leaf_params
Expand All @@ -60,12 +115,6 @@ def leafable_params
raise NotImplementedError.new "Implement in subclass"
end

def position_new_leaf(leaf)
if position = params[:position]&.to_i
leaf.move_to_position position
end
end

def broadcast_being_edited_indicator
Turbo::StreamsChannel.broadcast_render_later_to @leaf, :being_edited,
partial: "leaves/being_edited_by", locals: { leaf: @leaf, user: Current.user }
Expand Down
33 changes: 33 additions & 0 deletions app/controllers/pages/uploads_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
class Pages::UploadsController < ApplicationController
allow_bearer_key_access

before_action do
ActiveStorage::Current.url_options = { protocol: request.protocol, host: request.host, port: request.port }
end

before_action :set_page, :ensure_editable

# Same attach-and-render as ActionText::Markdown::UploadsController, but the
# page comes from the path instead of a signed GlobalID, which no script can mint.
def create
@markdown = @page.body
@markdown.uploads.attach [ params[:file] ]
@markdown.save!

@upload = @markdown.uploads.attachments.last

render "action_text/markdown/uploads/create", status: :created, formats: :json
end

private
def set_page
@book = Book.accessable_or_published.find(params[:book_id])
leafable = @book.leaves.active.find(params[:page_id]).leafable

head :unprocessable_entity unless @page = (leafable if leafable.is_a?(Page))
end

def ensure_editable
head :forbidden unless @book.editable?
end
end
6 changes: 5 additions & 1 deletion app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ def new_leafable
end

def leafable_params
params.fetch(:page, {}).permit(:body)
if leaf_document
{ body: leaf_document.body }
else
params.fetch(:page, {}).permit(:body)
end
end
end
10 changes: 10 additions & 0 deletions app/controllers/users/bearer_keys_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class Users::BearerKeysController < ApplicationController
include UserScoped

before_action :ensure_current_user

def create
@user.regenerate_bearer_key
redirect_to edit_user_profile_url(@user)
end
end
1 change: 1 addition & 0 deletions app/helpers/translations_helper.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module TranslationsHelper
TRANSLATIONS = {
bearer_key: { "🇺🇸": "Your API key for scripts that write to your books", "🇪🇸": "Tu clave de API para scripts que escriben en tus libros", "🇫🇷": "Votre clé d'API pour les scripts qui écrivent dans vos livres", "🇮🇳": "आपकी API कुंजी उन स्क्रिप्ट्स के लिए जो आपकी पुस्तकों में लिखती हैं", "🇩🇪": "Ihr API-Schlüssel für Skripte, die in Ihre Bücher schreiben", "🇧🇷": "Sua chave de API para scripts que escrevem em seus livros" },
book_author: { "🇺🇸": "Author", "🇪🇸": "Autor", "🇫🇷": "Auteur", "🇮🇳": "लेखक", "🇩🇪": "Autor", "🇧🇷": "Autor" },
book_subtitle: { "🇺🇸": "Subtitle", "🇪🇸": "Subtítulo", "🇫🇷": "Sous-titre", "🇮🇳": "उपशीर्षक", "🇩🇪": "Untertitel", "🇧🇷": "Subtítulo" },
book_title: { "🇺🇸": "Book title", "🇪🇸": "Título del libro", "🇫🇷": "Titre du livre", "🇮🇳": "पुस्तक का शीर्षक", "🇩🇪": "Buchtitel", "🇧🇷": "Título do livro" },
Expand Down
45 changes: 45 additions & 0 deletions app/models/leaf/document.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
class Leaf::Document
class Malformed < StandardError; end

FRONT_MATTER_DELIMITER = "\n---\n"

attr_reader :title, :position, :external_id, :body, :url

# The .md wire format: YAML front matter, then the body, verbatim. The parser
# takes the first closing delimiter and exactly one blank line after it, so
# bodies containing --- lines round-trip untouched.
def self.parse(text)
# Request bodies arrive binary-encoded; the wire format is UTF-8
text = text.dup.force_encoding(Encoding::UTF_8)
raise Malformed, "not valid UTF-8" unless text.valid_encoding?
raise Malformed, "missing front matter" unless text.start_with?("---\n")

front, delimiter, body = text[4..].partition(FRONT_MATTER_DELIMITER)
raise Malformed, "missing closing front matter delimiter" if delimiter.empty?

attributes = YAML.safe_load(front)
raise Malformed, "front matter is not a mapping" unless attributes.is_a?(Hash)

new title: attributes["title"]&.to_s, position: attributes["position"]&.to_i,
external_id: attributes["external_id"]&.to_s, body: body.delete_prefix("\n")
rescue Psych::Exception => error
raise Malformed, error.message
end

def self.from(leaf, url: nil)
new title: leaf.title, external_id: leaf.external_id, body: leaf.leafable.markable.to_s, url: url
end

def initialize(title:, body:, position: nil, external_id: nil, url: nil)
@title, @body, @position, @external_id, @url = title, body, position, external_id, url
end

def to_s
lines = [ "---" ]
lines << "title: #{JSON.generate(title)}"
lines << "url: #{JSON.generate(url)}" if url
lines << "---"

"#{lines.join("\n")}\n\n#{body}"
end
end
7 changes: 6 additions & 1 deletion app/models/leaf/editable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ def last_edit_old?

def will_change_leafable?(leafable_params)
leafable_params.select do |key, value|
leafable.attributes[key.to_s] != value
# Markdown attributes live in an association, not a column, so attributes[] can't see them
if markdown = leafable.safe_markdown_attribute(key)
markdown.content.to_s != value.to_s
else
leafable.attributes[key.to_s] != value
end
end.present?
end

Expand Down
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class User < ApplicationRecord

has_many :sessions, dependent: :destroy
has_secure_password validations: false
has_secure_token :bearer_key

has_many :accesses, dependent: :destroy
has_many :books, through: :accesses
Expand Down
3 changes: 2 additions & 1 deletion app/views/action_text/markdown/uploads/create.json.jbuilder
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
json.message "File uploaded successfully"
json.fileName @upload.filename.to_s
json.mimetype @upload.content_type
json.fileUrl @upload.slug_path
# main_app: rendered from inside the isolated ActionText namespace, where url helpers resolve against the engine
json.fileUrl main_app.action_text_markdown_upload_url(@upload.slug)
9 changes: 9 additions & 0 deletions app/views/books/leaves/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
json.array! @leaves.each_with_index.to_a do |(leaf, index)|
json.id leaf.id
json.leafable_type leaf.leafable_type
json.title leaf.title
json.slug leaf.slug
json.position index
json.external_id leaf.external_id
json.url leafable_slug_url(leaf)
end
4 changes: 2 additions & 2 deletions app/views/books/show.md.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "<%= @book.title %>"
author: "<%= @book.author %>"
title: <%= raw JSON.generate(@book.title) %>
author: <%= raw JSON.generate(@book.author.to_s) %>
url: "<%= book_slug_url(@book) %>"
---

Expand Down
6 changes: 6 additions & 0 deletions app/views/leafables/show.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
json.id @leaf.id
json.leafable_type @leaf.leafable_type
json.title @leaf.title
json.slug @leaf.slug
json.external_id @leaf.external_id
json.url leafable_slug_url(@leaf)
7 changes: 1 addition & 6 deletions app/views/leafables/show.md.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1 @@
---
title: "<%= @leaf.title %>"
url: "<%= leafable_slug_url(@leaf) %>"
---

<%= raw @leaf.leafable.markable %>
<%= raw Leaf::Document.from(@leaf, url: leafable_slug_url(@leaf)) %>
25 changes: 25 additions & 0 deletions app/views/users/_bearer_key.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<div class="flex flex-column align-center gap txt-medium--responsive">
<label class="flex flex-column gap full-width">
<div class="flex align-center gap">
<%= translation_button(:bearer_key) %>
<strong id="bearer_key_label" class="txt-align-start">Your API key for scripts that write to your books</strong>
</div>
<span class="flex align-center gap margin-inline">
<input type="text" class="input fill-white" id="bearer_key" value="<%= user.bearer_key %>" aria-labelledby="bearer_key_label" readonly>
</span>
</label>

<div class="flex align-center gap">
<%= button_to_copy_to_clipboard(user.bearer_key) do %>
<%= image_tag "copy-paste.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
<span class="for-screen-reader">Copy API key</span>
<% end %>

<%= button_to user_bearer_key_path(user), class: "btn btn--negative", data: {
turbo_confirm: "Are you sure? Any script using the current key will stop working until you give it the new one."
} do %>
<%= image_tag "arrow-reverse.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
<span class="for-screen-reader">Reset API key</span>
<% end %>
</div>
</div>
Loading
Loading