diff --git a/app/controllers/books/leaves_controller.rb b/app/controllers/books/leaves_controller.rb
new file mode 100644
index 00000000..0b3ae6e1
--- /dev/null
+++ b/app/controllers/books/leaves_controller.rb
@@ -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
diff --git a/app/controllers/books_controller.rb b/app/controllers/books_controller.rb
index eda26210..6fe2920c 100644
--- a/app/controllers/books_controller.rb
+++ b/app/controllers/books_controller.rb
@@ -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 ]
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index 96c7ee74..9d2cb118 100644
--- a/app/controllers/concerns/authentication.rb
+++ b/app/controllers/concerns/authentication.rb
@@ -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
@@ -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
@@ -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
diff --git a/app/controllers/leafables_controller.rb b/app/controllers/leafables_controller.rb
index c1aa3915..175c6bad 100644
--- a/app/controllers/leafables_controller.rb
+++ b/app/controllers/leafables_controller.rb
@@ -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
@@ -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
@@ -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
@@ -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 }
diff --git a/app/controllers/pages/uploads_controller.rb b/app/controllers/pages/uploads_controller.rb
new file mode 100644
index 00000000..adba3952
--- /dev/null
+++ b/app/controllers/pages/uploads_controller.rb
@@ -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
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index ca615270..281c36c8 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -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
diff --git a/app/controllers/users/bearer_keys_controller.rb b/app/controllers/users/bearer_keys_controller.rb
new file mode 100644
index 00000000..d77a3511
--- /dev/null
+++ b/app/controllers/users/bearer_keys_controller.rb
@@ -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
diff --git a/app/helpers/translations_helper.rb b/app/helpers/translations_helper.rb
index 89ce4d4e..6e281798 100644
--- a/app/helpers/translations_helper.rb
+++ b/app/helpers/translations_helper.rb
@@ -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" },
diff --git a/app/models/leaf/document.rb b/app/models/leaf/document.rb
new file mode 100644
index 00000000..614b309d
--- /dev/null
+++ b/app/models/leaf/document.rb
@@ -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
diff --git a/app/models/leaf/editable.rb b/app/models/leaf/editable.rb
index d00f2a69..2850b060 100644
--- a/app/models/leaf/editable.rb
+++ b/app/models/leaf/editable.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index e6e6ad6b..830f0d42 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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
diff --git a/app/views/action_text/markdown/uploads/create.json.jbuilder b/app/views/action_text/markdown/uploads/create.json.jbuilder
index 147122a6..4211976b 100644
--- a/app/views/action_text/markdown/uploads/create.json.jbuilder
+++ b/app/views/action_text/markdown/uploads/create.json.jbuilder
@@ -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)
diff --git a/app/views/books/leaves/index.json.jbuilder b/app/views/books/leaves/index.json.jbuilder
new file mode 100644
index 00000000..7ab9492e
--- /dev/null
+++ b/app/views/books/leaves/index.json.jbuilder
@@ -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
diff --git a/app/views/books/show.md.erb b/app/views/books/show.md.erb
index a8e7cf4f..d267d0dc 100644
--- a/app/views/books/show.md.erb
+++ b/app/views/books/show.md.erb
@@ -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) %>"
---
diff --git a/app/views/leafables/show.json.jbuilder b/app/views/leafables/show.json.jbuilder
new file mode 100644
index 00000000..5f55be70
--- /dev/null
+++ b/app/views/leafables/show.json.jbuilder
@@ -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)
diff --git a/app/views/leafables/show.md.erb b/app/views/leafables/show.md.erb
index b0fc977f..32e21edb 100644
--- a/app/views/leafables/show.md.erb
+++ b/app/views/leafables/show.md.erb
@@ -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)) %>
\ No newline at end of file
diff --git a/app/views/users/_bearer_key.html.erb b/app/views/users/_bearer_key.html.erb
new file mode 100644
index 00000000..e06991b8
--- /dev/null
+++ b/app/views/users/_bearer_key.html.erb
@@ -0,0 +1,25 @@
+
+
+
+
+ <%= button_to_copy_to_clipboard(user.bearer_key) do %>
+ <%= image_tag "copy-paste.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
+ Copy API key
+ <% 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" %>
+ Reset API key
+ <% end %>
+
<%= button_to session_path, method: :delete, class: "btn center" do %>
<%= image_tag "logout.svg", aria: { hidden: true }, size: 24 %>
diff --git a/config/routes.rb b/config/routes.rb
index 6a759da6..6b1eb36b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -24,6 +24,8 @@
resource :bookmark, controller: "books/bookmarks", only: :show
scope module: "books" do
+ resources :leaves, only: :index
+
namespace :leaves do
resources :moves, only: :create
end
@@ -33,7 +35,11 @@
resources :sections
resources :pictures
- resources :pages
+ resources :pages do
+ scope module: "pages" do
+ resources :uploads, only: :create
+ end
+ end
end
get "/:id/:slug", to: "books#show", constraints: { id: /\d+/ }, as: :slugged_book
@@ -57,6 +63,7 @@
resources :users do
scope module: "users" do
resource :profile
+ resource :bearer_key, only: :create
end
end
diff --git a/db/migrate/20260813124551_add_bearer_key_to_users.rb b/db/migrate/20260813124551_add_bearer_key_to_users.rb
new file mode 100644
index 00000000..5ad422f9
--- /dev/null
+++ b/db/migrate/20260813124551_add_bearer_key_to_users.rb
@@ -0,0 +1,12 @@
+class AddBearerKeyToUsers < ActiveRecord::Migration[8.2]
+ def up
+ add_column :users, :bearer_key, :string
+ add_index :users, :bearer_key, unique: true
+
+ User.find_each(&:regenerate_bearer_key)
+ end
+
+ def down
+ remove_column :users, :bearer_key
+ end
+end
diff --git a/db/migrate/20260813124952_add_external_id_to_leaves.rb b/db/migrate/20260813124952_add_external_id_to_leaves.rb
new file mode 100644
index 00000000..505bff52
--- /dev/null
+++ b/db/migrate/20260813124952_add_external_id_to_leaves.rb
@@ -0,0 +1,6 @@
+class AddExternalIdToLeaves < ActiveRecord::Migration[8.2]
+ def change
+ add_column :leaves, :external_id, :string
+ add_index :leaves, [ :book_id, :external_id ], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index e0331993..aebd65a4 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,42 +10,42 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2024_09_28_005927) do
+ActiveRecord::Schema[8.2].define(version: 2026_08_13_124952) do
create_table "accesses", force: :cascade do |t|
- t.integer "user_id", null: false
t.integer "book_id", null: false
- t.string "level", null: false
t.datetime "created_at", null: false
+ t.string "level", null: false
t.datetime "updated_at", null: false
+ t.integer "user_id", null: false
t.index ["book_id"], name: "index_accesses_on_book_id"
t.index ["user_id", "book_id"], name: "index_accesses_on_user_id_and_book_id", unique: true
t.index ["user_id"], name: "index_accesses_on_user_id"
end
create_table "accounts", force: :cascade do |t|
- t.string "name", null: false
- t.string "join_code", null: false
t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
t.text "custom_styles"
+ t.string "join_code", null: false
+ t.string "name", null: false
+ t.datetime "updated_at", null: false
end
create_table "action_text_markdowns", force: :cascade do |t|
- t.string "record_type", null: false
- t.integer "record_id", null: false
- t.string "name", null: false
t.text "content", default: "", null: false
t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.integer "record_id", null: false
+ t.string "record_type", null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id"], name: "index_action_text_markdowns_on_record"
end
create_table "active_storage_attachments", force: :cascade do |t|
- t.string "name", null: false
- t.string "record_type", null: false
- t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.bigint "record_id", null: false
+ t.string "record_type", null: false
t.string "slug"
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
@@ -53,14 +53,14 @@
end
create_table "active_storage_blobs", force: :cascade do |t|
- t.string "key", null: false
- t.string "filename", null: false
- t.string "content_type"
- t.text "metadata"
- t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum"
+ t.string "content_type"
t.datetime "created_at", null: false
+ t.string "filename", null: false
+ t.string "key", null: false
+ t.text "metadata"
+ t.string "service_name", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
@@ -71,24 +71,24 @@
end
create_table "books", force: :cascade do |t|
- t.string "title", null: false
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.string "subtitle"
t.string "author"
+ t.datetime "created_at", null: false
+ t.boolean "everyone_access", default: true, null: false
t.boolean "published", default: false, null: false
t.string "slug", null: false
- t.boolean "everyone_access", default: true, null: false
+ t.string "subtitle"
t.string "theme", default: "blue", null: false
+ t.string "title", null: false
+ t.datetime "updated_at", null: false
t.index ["published"], name: "index_books_on_published"
end
create_table "edits", force: :cascade do |t|
- t.integer "leaf_id", null: false
- t.string "leafable_type", null: false
- t.integer "leafable_id", null: false
t.string "action", null: false
t.datetime "created_at", null: false
+ t.integer "leaf_id", null: false
+ t.integer "leafable_id", null: false
+ t.string "leafable_type", null: false
t.datetime "updated_at", null: false
t.index ["leaf_id"], name: "index_edits_on_leaf_id"
t.index ["leafable_type", "leafable_id"], name: "index_edits_on_leafable"
@@ -96,13 +96,15 @@
create_table "leaves", force: :cascade do |t|
t.integer "book_id", null: false
- t.string "leafable_type", null: false
+ t.datetime "created_at", null: false
+ t.string "external_id"
t.integer "leafable_id", null: false
+ t.string "leafable_type", null: false
t.float "position_score", null: false
t.string "status", null: false
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
t.string "title", null: false
+ t.datetime "updated_at", null: false
+ t.index ["book_id", "external_id"], name: "index_leaves_on_book_id_and_external_id", unique: true
t.index ["book_id"], name: "index_leaves_on_book_id"
t.index ["leafable_type", "leafable_id"], name: "index_leafs_on_leafable"
end
@@ -113,38 +115,40 @@
end
create_table "pictures", force: :cascade do |t|
+ t.string "caption"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.string "caption"
end
create_table "sections", force: :cascade do |t|
+ t.text "body"
t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
t.string "theme"
- t.text "body"
+ t.datetime "updated_at", null: false
end
create_table "sessions", force: :cascade do |t|
- t.integer "user_id", null: false
- t.string "token", null: false
+ t.datetime "created_at", null: false
t.string "ip_address"
- t.string "user_agent"
t.datetime "last_active_at", null: false
- t.datetime "created_at", null: false
+ t.string "token", null: false
t.datetime "updated_at", null: false
+ t.string "user_agent"
+ t.integer "user_id", null: false
t.index ["token"], name: "index_sessions_on_token", unique: true
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "users", force: :cascade do |t|
- t.string "name", null: false
+ t.boolean "active", default: true
+ t.string "bearer_key"
+ t.datetime "created_at", null: false
t.string "email_address", null: false
+ t.string "name", null: false
t.string "password_digest", null: false
t.integer "role", null: false
- t.boolean "active", default: true
- t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.index ["bearer_key"], name: "index_users_on_bearer_key", unique: true
t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["name"], name: "index_users_on_name", unique: true
end
diff --git a/lib/rails_ext/action_text_markdown.rb b/lib/rails_ext/action_text_markdown.rb
index afc0b2b5..b6b0d42a 100644
--- a/lib/rails_ext/action_text_markdown.rb
+++ b/lib/rails_ext/action_text_markdown.rb
@@ -40,10 +40,12 @@ module ActionText::Markdown::Uploads
end
end
-ActiveSupport.on_load :active_storage_attachment do
- class ActionText::Markdown
- include ActionText::Markdown::Uploads
- end
+# to_prepare, not on_load(:active_storage_attachment): the load hook only fired
+# once something else happened to load ActiveStorage::Attachment, leaving uploads
+# undefined in a process serving an upload as its first storage-touching request.
+# has_many_attached isn't defined until after the initializers, so it can't run here.
+Rails.application.config.to_prepare do
+ ActionText::Markdown.include ActionText::Markdown::Uploads
end
ActiveSupport.run_load_hooks :action_text_markdown, ActionText::Markdown
diff --git a/plans/api.md b/plans/api.md
new file mode 100644
index 00000000..82c35fba
--- /dev/null
+++ b/plans/api.md
@@ -0,0 +1,271 @@
+# A write API for Writebook
+
+## Goal
+
+Let a book be maintained in a git repo and mirrored into Writebook by a script,
+without a shell account on the box. Read access already exists; this plan adds
+the write half and the authentication to use it.
+
+The driving use case is the Omarchy manual, whose authoritative source is moving
+to `basecamp/omarchy` under `manual/` (45 pages, one file per leaf, ordered by
+filename prefix). A push to that repo should update learn.omacom.io.
+
+## What already exists
+
+Most of the pieces are here and unexposed. Read the following before starting.
+
+**The read half is done.** `BooksController#show` and `LeafablesController#show`
+both `respond_to { format.md }` under `allow_unauthenticated_access`, rendering
+`app/views/books/show.md.erb` and `app/views/leafables/show.md.erb`. That is where
+`https://learn.omacom.io/2/the-omarchy-manual.md` comes from.
+
+**CSRF exemption for keyed clients is already wired, and dead.**
+`app/controllers/concerns/authentication.rb:9`:
+
+```ruby
+protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
+```
+
+Nothing in this repo ever calls `set_authenticated_by(:bot_key)`, so the branch is
+unreachable. Writebook inherited the guard from the shared ONCE `Authentication`
+concern without the implementation. Campfire has the other half.
+
+**Correct upsert semantics already exist.** `Leaf::Editable#edit` (`app/models/leaf/editable.rb`)
+is what the API must call:
+
+```ruby
+MINIMUM_TIME_BETWEEN_VERSIONS = 10.minutes
+
+def record_new_edit?(leafable_params)
+ will_change_leafable?(leafable_params) && last_edit_old?
+end
+```
+
+Repeated edits inside 10 minutes coalesce. **But the no-change detection is
+broken for Pages**: `will_change_leafable?` compares `leafable.attributes[key]`,
+and a Page's body is a `has_markdown` association, not a column — so
+`attributes["body"]` is always nil and any submitted body counts as a change.
+Re-sending 45 unchanged pages on a push more than 10 minutes after the last one
+would record 45 junk revisions, each duplicating the Page and its Markdown row
+(`update_and_record_edit` dups the leafable). Fix it in the model — compare
+`leafable.body.content.to_s` for markdown attributes — so the web UI benefits
+too. Sections are unaffected (`body` is a real column). With that fixed, the
+cheap-resync property holds — **provided the API goes through `Leaf#edit` and
+not `page.update!`**.
+
+**Other relevant API:** `Book#press(leafable, leaf_params)` creates a leaf;
+`Positionable#move_to_position(offset)` reorders; `Leaf#slug` is
+`title.parameterize`; URLs are `/:book_id/:book_slug/:id/:slug`.
+
+## What's missing
+
+1. No way to authenticate a non-browser client.
+2. No write endpoints.
+3. No stable key to upsert against.
+4. No upload path usable without a browser session.
+
+## Design
+
+### 1. Authentication: a personal bearer key
+
+No bot users, no role changes. Every user gets a resettable API key:
+
+```ruby
+has_secure_token :bearer_key # Session already uses has_secure_token
+```
+
+Authenticate from the `Authorization: Bearer ` header (no path/param key —
+those land in logs, `Referer`, and proxy traces):
+
+```ruby
+def restore_authentication
+ if session = find_session_by_cookie
+ resume_session session
+ elsif user = authenticate_with_http_token { |token, _| User.active.find_by(bearer_key: token) }
+ Current.user = user
+ set_authenticated_by :bearer_key
+ end
+end
+```
+
+`Current` has a `user` attribute settable independently of `session`
+(`app/models/current.rb`), so no `Session` row is involved. Scoping the lookup to
+`User.active` means deactivating a user kills their key, and
+`user.regenerate_bearer_key` (free with `has_secure_token`) handles resets.
+
+Because the key resolves to a real `User`, **the existing `Access` rows and
+`book.editable?` checks keep working untouched**, including in
+`ActionText::Markdown::UploadsController`. Do not invent a parallel authorization
+path. Per-book scoping falls out too: the Omarchy mirror runs as a dedicated
+ordinary user (say, "Omarchy Sync") holding an editor `Access` row on just the
+manual — no new mechanism.
+
+**Default-deny stays, reshaped.** A bearer key lives in CI secrets and scripts, so
+it is far leakier than a session cookie; it must not be a full session equivalent.
+Keep Campfire's shape but keyed to the header auth:
+
+```ruby
+before_action :deny_bearer_keys # default deny
+def deny_bearer_keys = head :forbidden if authenticated_by.bearer_key?
+```
+
+with `allow_bearer_key_access` to opt in the API controllers (and the new uploads
+route) only. This makes the dead CSRF guard at
+`app/controllers/concerns/authentication.rb:9` live — rename its `bot_key?`
+inquiry to `bearer_key?` to match.
+
+Show and reset the key on the profile edit page, alongside the session transfer
+link it resembles — never on the profile show page, which everyone in the account
+can see. Note that
+`request_authentication` redirects to login — the bearer path must render
+`head :unauthorized` for API requests instead of a 302.
+
+### 2. Representation: the leaf `.md` document round-trips; JSON only for the manifest
+
+Round-trip is scoped to leaves. The book-level `.md` stays a lossy, human-facing
+export (`Book#markable` joins with `"\n\n"`, sections are indistinguishable from
+prose) — the sync never reads it.
+
+The per-leaf `.md` (`app/views/leafables/show.md.erb`) is already the right
+document: front matter (`title`, `url`) plus the raw body, and `has_markdown`
+stores body as verbatim markdown source, so it round-trips byte-for-byte. Writes
+accept the same format (`Content-Type: text/markdown`) instead of JSON-wrapping
+markdown in strings. `PUT` back what you `GET`, edited.
+
+To make the round trip exact:
+
+- **Escape the title.** `title: "<%= @leaf.title %>"` emits invalid YAML when the
+ title contains `"`. Emit a JSON-encoded string (valid YAML) and parse it back.
+- **Fix the parsing rule.** Front matter is required on write, starts at byte 0,
+ and ends at the first `\n---\n`; everything after is body, verbatim. Bodies
+ containing `---` lines survive.
+- **`url:` is output-only.** Ignore it (and any unknown keys) on write. Accept
+ optional `position` and `external_id` keys.
+- **No whitespace drift.** The serializer must not append newlines the parser
+ drops, or every sync run looks like a change.
+
+Sections are writable in v1 — the Omarchy manual's four part dividers are
+Sections and currently exist nowhere in git. They don't need round-trip; they
+stay writable with a plain JSON body (`title`, `body`, `theme`) —
+`Section#markable` is a bare string anyway. In the repo a section is a file like
+any other leaf, marked `type: section` in its front matter; the sync script reads
+that to pick the endpoint. The one JSON read endpoint is the manifest: `id`,
+`leafable_type`, `title`, `slug`, `position`, `external_id` per leaf.
+
+### 3. Endpoints
+
+Nest under the existing `resources :books` in `config/routes.rb`, opted in with
+`allow_bearer_key_access`:
+
+```
+GET /books/:book_id/leaves.json # ordered manifest: id, type, title, slug, position, external_id
+POST /books/:book_id/pages.md # create from a leaf .md document (Book#press)
+PUT /books/:book_id/pages/:id.md # update via Leaf#edit, same document format
+DELETE /books/:book_id/pages/:id # Leaf#trashed! (soft, already how destroy works)
+POST /books/:book_id/pages/:id/uploads.json
+```
+
+Reading a leaf already works: `GET /:book_id/:book_slug/:id/:slug.md`. The write
+side parses `request.raw_post` per the rules in §2 — the `:md` mime type is
+registered, but Rails won't parse a markdown request body into `params`.
+
+`sections` get the same routes with JSON bodies. `pictures` can wait — the
+Omarchy manual uses inline markdown images, not Picture leaves.
+
+Reuse `SetBookLeaf`, which already gives `set_book` (`Book.accessable_or_published`),
+`set_leaf` (`@book.leaves.active`), and `ensure_editable`.
+
+### 4. Upsert key
+
+Add `external_id` (string, nullable) to `leaves`, unique per book. Upsert rides
+the front matter: a `POST` whose document carries `external_id:` finds-or-creates
+by it. No dedicated `/by_external_id/` route — the key contains `.`, which would
+fight the router's format parsing.
+
+Without this the client must keep its own filename→leaf-ID map, which drifts the
+first time someone edits in the web UI, and makes renames indistinguishable from
+delete+create. The Omarchy sync sets it to the filename with the ordering prefix
+stripped: `27-monitors.md` → `monitors.md`. The prefix carries position, the name
+carries identity — so renumbering files to reorder or insert pages (the common
+case) doesn't churn identity, leaf ids, or published URLs. A true rename still
+reads as delete+create: rare, and acceptable. Two files sharing a stripped name
+collide on the unique index, which fails the sync loudly — an authoring error,
+correctly rejected.
+
+Deletes should trash, not destroy — `Leaf` already has `status: %w[active trashed]`
+and `Leaf::Editable#record_moved_to_trash` logs it. A bad sync must be recoverable.
+A trashed leaf keeps its `external_id`, so upsert must match trashed leaves too
+and restore them (back to `active`, then `Leaf#edit`) rather than collide with the
+unique index — a bad sync that trashed pages heals itself on the next good push.
+
+**Git always wins.** The sync trashes leaves absent from the repo and overwrites
+web-UI edits on the next push. Soft-trash and the revision history make both
+recoverable. This makes web editing of a mirrored book advisory — that's the
+simplicity trade, made deliberately.
+
+### 5. Uploads
+
+`ActionText::Markdown::UploadsController#create` currently requires a signed
+GlobalID minted into the editor:
+
+```ruby
+@record = GlobalID::Locator.locate_signed params[:record_gid],
+ only: Page, for: ActionText::Markdown::UPLOADS_SIGNED_ID_PURPOSE
+```
+
+There is no way for a script to obtain one. Add a bearer-key-authenticated route
+that resolves the page from `:book_id`/`:id` instead, authorizes with the existing
+`ensure_editable`, and otherwise reuses the same attach-and-render path. Return the
+`/u/...` URL.
+
+Serving already works for this use case: `#show` is `allow_unauthenticated_access`
+and sets `expires_in 1.year, public: true` for published books, so the URLs render
+on GitHub and cache well.
+
+**Uploads return absolute URLs, and the editor inserts them.** Today the editor
+inserts the relative `/u/` path — `create.json.jbuilder` renders `fileUrl`
+from `Attachment#slug_path` (`lib/rails_ext/active_storage_sluggable.rb:8`).
+Switch that to the full URL (`action_text_markdown_upload_url`) so bodies carry
+the same explicit URL the git source does. Images then render on GitHub and in
+local editors, and the byte-for-byte round trip holds with no rewriting on either
+side. The cost is baking the canonical host into stored bodies — a domain move
+needs a one-time rewrite. Existing bodies hold relative paths; normalize the
+manual's once during the initial export to git.
+
+### 6. Positioning
+
+Accept `position` on create/update and apply via `move_to_position`. Don't write
+`position_score` directly — `Positionable` owns the gap arithmetic and the
+`REBALANCE_THRESHOLD` rebalance.
+
+## Migrations
+
+1. `leaves.external_id` (string, index unique on `[book_id, external_id]`).
+2. `users.bearer_key` (string, unique index). `has_secure_token` only generates on
+ create, so backfill existing users in the migration
+ (`User.find_each(&:regenerate_bearer_key)`).
+
+## Testing
+
+Per `AGENTS.md`: prefer existing fixtures (`leaves(:welcome_page)`), use `_path`
+helpers, and `assert_in_body` / `assert_not_in_body`.
+
+Cover at minimum:
+
+- A bearer key authenticates and CSRF is skipped; a bad, reset, or deactivated
+ user's key gets `:unauthorized` (not a redirect).
+- `deny_bearer_keys` blocks a valid key on a non-API controller (regression guard
+ for the default-deny above).
+- A key whose user has no `Access` to a private book gets `:forbidden`.
+- Re-sending identical content records **no** new `Edit`.
+- Two edits inside `MINIMUM_TIME_BETWEEN_VERSIONS` produce one revision.
+- Upsert by `external_id` updates rather than duplicating, and restores a
+ trashed match instead of colliding with the unique index.
+- `GET` then `PUT` of an untouched leaf `.md` is a no-op, and a title containing
+ `"` survives the round trip.
+- Upload returns a `/u/...` URL that `#show` then serves.
+
+## Out of scope
+
+Picture leaves, book create/destroy, user management, and reading via JSON beyond
+the manifest needed to sync.
diff --git a/test/controllers/action_text/markdown/uploads_controller_test.rb b/test/controllers/action_text/markdown/uploads_controller_test.rb
index 93472f32..7be2e75e 100644
--- a/test/controllers/action_text/markdown/uploads_controller_test.rb
+++ b/test/controllers/action_text/markdown/uploads_controller_test.rb
@@ -16,8 +16,9 @@ class ActionText::Markdown::UploadsControllerTest < ActionDispatch::IntegrationT
assert_response :success
- # Uploads should use relative URLs, to allow for future hostname changes
- assert JSON.parse(response.body)["fileUrl"].start_with?("/")
+ # Absolute URLs, so bodies mirrored into git render everywhere. A hostname
+ # change means a one-time rewrite of stored bodies.
+ assert JSON.parse(response.body)["fileUrl"].start_with?("http://www.example.com/u/")
end
test "a signed id minted for some other purpose can't be used to upload" do
diff --git a/test/controllers/api_authentication_test.rb b/test/controllers/api_authentication_test.rb
new file mode 100644
index 00000000..a7635c1f
--- /dev/null
+++ b/test/controllers/api_authentication_test.rb
@@ -0,0 +1,60 @@
+require "test_helper"
+
+class ApiAuthenticationTest < ActionDispatch::IntegrationTest
+ # The handbook fixture is unpublished, so anonymous requests can't see it
+ # and bearer-key requests only can when the key authenticates.
+
+ test "a bearer key authenticates where the controller allows it" do
+ get book_slug_path(books(:handbook)), headers: bearer_key_header(:david)
+
+ assert_response :success
+ end
+
+ test "a bearer key works without a session cookie or CSRF token" do
+ get book_slug_path(books(:handbook)), headers: bearer_key_header(:jz)
+
+ assert_response :success
+ assert_not cookies[:session_token].present?
+ end
+
+ test "a bad key stays anonymous" do
+ get book_slug_path(books(:handbook)), headers: { "Authorization" => "Bearer wrong" }
+
+ assert_response :not_found
+ end
+
+ test "a reset key stops working" do
+ old_key = users(:david).bearer_key
+ users(:david).regenerate_bearer_key
+
+ get book_slug_path(books(:handbook)), headers: { "Authorization" => "Bearer #{old_key}" }
+
+ assert_response :not_found
+ end
+
+ test "a deactivated user's key stops working" do
+ users(:david).deactivate
+
+ get book_slug_path(books(:handbook)), headers: bearer_key_header(:david)
+
+ assert_response :not_found
+ end
+
+ test "a valid key does not authenticate on controllers that haven't opted in" do
+ get edit_book_path(books(:handbook)), headers: bearer_key_header(:david)
+
+ assert_response :unauthorized
+ end
+
+ test "requests with a key get unauthorized instead of a login redirect" do
+ get users_path, headers: bearer_key_header(:david)
+
+ assert_response :unauthorized
+ end
+
+ test "browser requests without a key still get the login redirect" do
+ get edit_book_path(books(:handbook))
+
+ assert_redirected_to new_session_url
+ end
+end
diff --git a/test/controllers/books/leaves_controller_test.rb b/test/controllers/books/leaves_controller_test.rb
new file mode 100644
index 00000000..f4d54c11
--- /dev/null
+++ b/test/controllers/books/leaves_controller_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+
+class Books::LeavesControllerTest < ActionDispatch::IntegrationTest
+ test "index returns the ordered manifest" do
+ get book_leaves_path(books(:handbook), format: :json), headers: bearer_key_header(:david)
+
+ assert_response :success
+
+ manifest = response.parsed_body
+ assert_equal books(:handbook).leaves.active.count, manifest.size
+ assert_equal (0...manifest.size).to_a, manifest.map { it["position"] }
+
+ welcome = manifest.find { it["id"] == leaves(:welcome_page).id }
+ assert_equal "Page", welcome["leafable_type"]
+ assert_equal "Welcome to The Handbook!", welcome["title"]
+ assert_equal "welcome-to-the-handbook", welcome["slug"]
+ assert_equal leafable_slug_url(leaves(:welcome_page)), welcome["url"]
+ end
+
+ test "index excludes trashed leaves" do
+ leaves(:welcome_page).trashed!
+
+ get book_leaves_path(books(:handbook), format: :json), headers: bearer_key_header(:david)
+
+ assert_not_includes response.parsed_body.map { it["id"] }, leaves(:welcome_page).id
+ end
+
+ test "index requires authentication" do
+ get book_leaves_path(books(:handbook), format: :json)
+
+ assert_response :unauthorized
+ end
+
+ test "index also works with a signed-in session" do
+ sign_in :david
+
+ get book_leaves_path(books(:handbook), format: :json)
+
+ assert_response :success
+ end
+end
diff --git a/test/controllers/leafables_controller_test.rb b/test/controllers/leafables_controller_test.rb
index d9e383c7..948e354b 100644
--- a/test/controllers/leafables_controller_test.rb
+++ b/test/controllers/leafables_controller_test.rb
@@ -86,6 +86,16 @@ class LeafablesControllerTest < ActionDispatch::IntegrationTest
assert_select "figure img[src*=\"pixel.bmp\"]"
end
+ test "show with markdown format escapes the title for the front matter" do
+ leaves(:welcome_page).update!(title: %(A "quoted" title & more))
+
+ get leafable_slug_path(leaves(:welcome_page), format: :md)
+
+ assert_response :success
+ assert_in_body 'title: "A \"quoted\" title & more"'
+ assert_not_in_body "&"
+ end
+
test "show with markdown format does not escape HTML entities" do
leaves(:welcome_page).leafable.update!(body: "This has a link")
diff --git a/test/controllers/pages/uploads_controller_test.rb b/test/controllers/pages/uploads_controller_test.rb
new file mode 100644
index 00000000..23b3c689
--- /dev/null
+++ b/test/controllers/pages/uploads_controller_test.rb
@@ -0,0 +1,43 @@
+require "test_helper"
+
+class Pages::UploadsControllerTest < ActionDispatch::IntegrationTest
+ test "a bearer key can upload to a page it can edit" do
+ assert_changes -> { ActiveStorage::Attachment.count }, +1 do
+ post book_page_uploads_path(books(:handbook), leaves(:welcome_page), format: :json),
+ params: { file: fixture_file_upload("reading.webp", "image/webp") },
+ headers: bearer_key_header(:david)
+ end
+
+ assert_response :created
+ assert response.parsed_body["fileUrl"].start_with?("http://www.example.com/u/")
+ end
+
+ test "the returned URL serves publicly once the book is published" do
+ post book_page_uploads_path(books(:handbook), leaves(:welcome_page), format: :json),
+ params: { file: fixture_file_upload("reading.webp", "image/webp") },
+ headers: bearer_key_header(:david)
+
+ books(:handbook).update! published: true
+
+ get response.parsed_body["fileUrl"]
+
+ assert_response :redirect
+ assert_equal "max-age=31556952, public", response.headers["Cache-Control"]
+ end
+
+ test "a reader's key cannot upload" do
+ post book_page_uploads_path(books(:handbook), leaves(:welcome_page), format: :json),
+ params: { file: fixture_file_upload("reading.webp", "image/webp") },
+ headers: bearer_key_header(:jz)
+
+ assert_response :forbidden
+ end
+
+ test "uploads only attach to pages" do
+ post book_page_uploads_path(books(:handbook), leaves(:welcome_section), format: :json),
+ params: { file: fixture_file_upload("reading.webp", "image/webp") },
+ headers: bearer_key_header(:david)
+
+ assert_response :unprocessable_entity
+ end
+end
diff --git a/test/controllers/pages_api_test.rb b/test/controllers/pages_api_test.rb
new file mode 100644
index 00000000..8ddc5289
--- /dev/null
+++ b/test/controllers/pages_api_test.rb
@@ -0,0 +1,146 @@
+require "test_helper"
+
+class PagesApiTest < ActionDispatch::IntegrationTest
+ test "creating a page from a document" do
+ assert_difference -> { books(:handbook).leaves.count }, +1 do
+ post_document document(title: "Monitors", body: "How to configure monitors.", external_id: "monitors.md")
+ end
+
+ assert_response :created
+
+ leaf = books(:handbook).leaves.find_by!(external_id: "monitors.md")
+ assert_equal "Monitors", leaf.title
+ assert_equal "How to configure monitors.", leaf.page.body.content.to_s
+ assert_in_body 'title: "Monitors"'
+ end
+
+ test "creating with a position lands the page there" do
+ post_document document(title: "First!", body: "Body", external_id: "first.md", position: 0)
+
+ assert_equal "First!", books(:handbook).leaves.active.positioned.first.title
+ end
+
+ test "re-posting the same external_id updates instead of duplicating" do
+ post_document document(title: "Monitors", body: "Original", external_id: "monitors.md")
+ assert_response :created
+
+ assert_no_difference -> { books(:handbook).leaves.count } do
+ post_document document(title: "Monitors", body: "Updated", external_id: "monitors.md")
+ end
+
+ assert_response :success
+ assert_equal "Updated", books(:handbook).leaves.find_by!(external_id: "monitors.md").page.body.content.to_s
+ end
+
+ test "re-posting identical content records no edit" do
+ post_document document(title: "Monitors", body: "Same", external_id: "monitors.md")
+
+ travel 1.hour do
+ assert_no_difference -> { Edit.count } do
+ post_document document(title: "Monitors", body: "Same", external_id: "monitors.md")
+ end
+ end
+ end
+
+ test "re-posting the external_id of a trashed leaf restores it" do
+ post_document document(title: "Monitors", body: "Body", external_id: "monitors.md")
+ leaf = books(:handbook).leaves.find_by!(external_id: "monitors.md")
+ leaf.trashed!
+
+ assert_no_difference -> { books(:handbook).leaves.count } do
+ post_document document(title: "Monitors", body: "Body", external_id: "monitors.md")
+ end
+
+ assert leaf.reload.active?
+ end
+
+ test "updating a page by id" do
+ put book_page_path(books(:handbook), leaves(:welcome_page), format: :md),
+ params: document(title: "Welcome!", body: "New body"), headers: markdown_headers(:david)
+
+ assert_response :success
+ assert_equal "Welcome!", leaves(:welcome_page).reload.title
+ assert_equal "New body", leaves(:welcome_page).page.body.content.to_s
+ end
+
+ test "putting back what you get is a no-op" do
+ get leafable_slug_path(leaves(:welcome_page), format: :md), headers: bearer_key_header(:david)
+ exported = response.body
+
+ travel 1.hour do
+ assert_no_difference -> { Edit.count } do
+ put book_page_path(books(:handbook), leaves(:welcome_page), format: :md),
+ params: exported, headers: markdown_headers(:david)
+ end
+ end
+
+ get leafable_slug_path(leaves(:welcome_page), format: :md), headers: bearer_key_header(:david)
+ assert_equal exported, response.body
+ end
+
+ test "titles with quotes survive the round trip" do
+ post_document document(title: %(A "quoted" title), body: "Body", external_id: "quoted.md")
+ leaf = books(:handbook).leaves.find_by!(external_id: "quoted.md")
+
+ get leafable_slug_path(leaf, format: :md), headers: bearer_key_header(:david)
+ put book_page_path(books(:handbook), leaf, format: :md), params: response.body, headers: markdown_headers(:david)
+
+ assert_equal %(A "quoted" title), leaf.reload.title
+ end
+
+ test "destroy trashes, not destroys" do
+ assert_no_difference -> { Leaf.count } do
+ delete book_page_path(books(:handbook), leaves(:welcome_page), format: :json),
+ headers: bearer_key_header(:david)
+ end
+
+ assert_response :no_content
+ assert leaves(:welcome_page).reload.trashed?
+ end
+
+ test "a malformed document is rejected" do
+ post_document "No front matter here"
+
+ assert_response :unprocessable_entity
+ end
+
+ test "a reader's key cannot write" do
+ post_document document(title: "Nope", body: "Nope"), user: :jz
+
+ assert_response :forbidden
+ end
+
+ test "an unrelated user's key cannot even see a private book" do
+ books(:handbook).accesses.where(user: users(:kevin)).delete_all
+
+ post_document document(title: "Nope", body: "Nope"), user: :kevin
+
+ assert_response :not_found
+ end
+
+ test "bearer key writes skip CSRF protection" do
+ ActionController::Base.allow_forgery_protection = true
+
+ post_document document(title: "No token", body: "Body")
+
+ assert_response :created
+ ensure
+ ActionController::Base.allow_forgery_protection = false
+ end
+
+ private
+ def document(title:, body:, external_id: nil, position: nil)
+ front = [ "---", "title: #{JSON.generate(title)}" ]
+ front << "external_id: #{JSON.generate(external_id)}" if external_id
+ front << "position: #{position}" if position
+ (front + [ "---", "", body ]).join("\n")
+ end
+
+ def post_document(doc, user: :david)
+ post book_pages_path(books(:handbook), format: :md), params: doc, headers: markdown_headers(user)
+ end
+
+ def markdown_headers(user)
+ bearer_key_header(user).merge("Content-Type" => "text/markdown")
+ end
+end
diff --git a/test/controllers/sections_api_test.rb b/test/controllers/sections_api_test.rb
new file mode 100644
index 00000000..96f9aaf1
--- /dev/null
+++ b/test/controllers/sections_api_test.rb
@@ -0,0 +1,63 @@
+require "test_helper"
+
+class SectionsApiTest < ActionDispatch::IntegrationTest
+ test "creating a section with JSON" do
+ assert_difference -> { books(:handbook).leaves.count }, +1 do
+ post book_sections_path(books(:handbook), format: :json),
+ params: { leaf: { title: "The Basics" }, external_id: "the-basics", position: 0 },
+ headers: bearer_key_header(:david), as: :json
+ end
+
+ assert_response :created
+
+ leaf = books(:handbook).leaves.find_by!(external_id: "the-basics")
+ assert_equal "Section", leaf.leafable_type
+ assert_equal "The Basics", leaf.title
+ assert_equal "The Basics", leaf.section.body
+ assert_equal leaf, books(:handbook).leaves.active.positioned.first
+ assert_equal leaf.id, response.parsed_body["id"]
+ end
+
+ test "re-posting the same external_id updates instead of duplicating" do
+ 2.times do |round|
+ assert_difference -> { books(:handbook).leaves.count }, round.zero? ? +1 : 0 do
+ post book_sections_path(books(:handbook), format: :json),
+ params: { leaf: { title: "The Basics" }, section: { theme: "vol#{round}" }, external_id: "the-basics" },
+ headers: bearer_key_header(:david), as: :json
+ end
+ end
+
+ assert_equal "vol1", books(:handbook).leaves.find_by!(external_id: "the-basics").section.theme
+ end
+
+ test "re-posting identical content records no edit" do
+ params = { leaf: { title: "The Basics" }, section: { body: "The Basics" }, external_id: "the-basics" }
+
+ post book_sections_path(books(:handbook), format: :json),
+ params: params, headers: bearer_key_header(:david), as: :json
+
+ travel 1.hour do
+ assert_no_difference -> { Edit.count } do
+ post book_sections_path(books(:handbook), format: :json),
+ params: params, headers: bearer_key_header(:david), as: :json
+ end
+ end
+ end
+
+ test "updating a section by id" do
+ put book_section_path(books(:handbook), leaves(:welcome_section), format: :json),
+ params: { leaf: { title: "Renamed" }, section: { body: "Renamed" } },
+ headers: bearer_key_header(:david), as: :json
+
+ assert_response :success
+ assert_equal "Renamed", leaves(:welcome_section).reload.title
+ assert_equal "Renamed", leaves(:welcome_section).section.body
+ end
+
+ test "a reader's key cannot write sections" do
+ post book_sections_path(books(:handbook), format: :json),
+ params: { leaf: { title: "Nope" } }, headers: bearer_key_header(:jz), as: :json
+
+ assert_response :forbidden
+ end
+end
diff --git a/test/controllers/users/bearer_keys_controller_test.rb b/test/controllers/users/bearer_keys_controller_test.rb
new file mode 100644
index 00000000..1bb568a2
--- /dev/null
+++ b/test/controllers/users/bearer_keys_controller_test.rb
@@ -0,0 +1,51 @@
+require "test_helper"
+
+class Users::BearerKeysControllerTest < ActionDispatch::IntegrationTest
+ test "resetting your own key" do
+ sign_in :kevin
+ old_key = users(:kevin).bearer_key
+
+ post user_bearer_key_path(users(:kevin))
+
+ assert_redirected_to edit_user_profile_url(users(:kevin))
+ assert_not_equal old_key, users(:kevin).reload.bearer_key
+ end
+
+ test "a reset key stops authenticating" do
+ old_key = users(:david).bearer_key
+ sign_in :david
+
+ post user_bearer_key_path(users(:david))
+ sign_out
+
+ get book_slug_path(books(:handbook)), headers: { "Authorization" => "Bearer #{old_key}" }
+ assert_response :not_found
+
+ get book_slug_path(books(:handbook)), headers: bearer_key_header(users(:david).reload)
+ assert_response :success
+ end
+
+ test "resetting someone else's key is forbidden" do
+ sign_in :david
+ old_key = users(:kevin).bearer_key
+
+ post user_bearer_key_path(users(:kevin))
+
+ assert_response :forbidden
+ assert_equal old_key, users(:kevin).reload.bearer_key
+ end
+
+ test "your key is shown on your own settings, and nobody else's" do
+ sign_in :kevin
+
+ get edit_user_profile_path(users(:kevin))
+
+ assert_response :success
+ assert_in_body users(:kevin).bearer_key
+
+ get user_profile_path(users(:david))
+
+ assert_response :success
+ assert_not_in_body users(:david).bearer_key
+ end
+end
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index 201fec46..13d087ec 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -2,24 +2,28 @@
david:
name: David
+ bearer_key: david_bearer_key
email_address: david@example.com
password_digest: <%= password_digest %>
role: administrator
jason:
name: Jason
+ bearer_key: jason_bearer_key
email_address: jason@example.com
password_digest: <%= password_digest %>
role: administrator
jz:
name: JZ
+ bearer_key: jz_bearer_key
email_address: jz@example.com
password_digest: <%= password_digest %>
role: member
kevin:
name: Kevin
+ bearer_key: kevin_bearer_key
email_address: kevin@example.com
password_digest: <%= password_digest %>
role: member
diff --git a/test/models/leaf/document_test.rb b/test/models/leaf/document_test.rb
new file mode 100644
index 00000000..3501f8a7
--- /dev/null
+++ b/test/models/leaf/document_test.rb
@@ -0,0 +1,102 @@
+require "test_helper"
+
+class Leaf::DocumentTest < ActiveSupport::TestCase
+ test "generates front matter and body from a leaf" do
+ document = Leaf::Document.from(leaves(:welcome_page), url: "http://example.com/1/handbook/2/welcome")
+
+ assert_equal <<~MD.chomp, document.to_s
+ ---
+ title: "Welcome to The Handbook!"
+ url: "http://example.com/1/handbook/2/welcome"
+ ---
+
+ This is _such_ a great handbook.
+ MD
+ end
+
+ test "parses back what it generates, byte for byte" do
+ document = Leaf::Document.from(leaves(:welcome_page))
+ parsed = Leaf::Document.parse(document.to_s)
+
+ assert_equal document.title, parsed.title
+ assert_equal document.body, parsed.body
+ assert_equal document.to_s, parsed.to_s
+ end
+
+ test "titles with quotes survive the round trip" do
+ document = Leaf::Document.new(title: %(A "quoted" title), body: "Body")
+ parsed = Leaf::Document.parse(document.to_s)
+
+ assert_equal %(A "quoted" title), parsed.title
+ end
+
+ test "bodies containing front matter delimiters survive" do
+ body = "Before\n\n---\n\nAfter the rule\n---\nmore"
+ document = Leaf::Document.new(title: "Rules", body: body)
+
+ assert_equal body, Leaf::Document.parse(document.to_s).body
+ end
+
+ test "bodies keep their exact leading and trailing whitespace" do
+ body = "\nStarts blank, ends with two newlines\n\n"
+ document = Leaf::Document.new(title: "Space", body: body)
+ parsed = Leaf::Document.parse(document.to_s)
+
+ assert_equal body, parsed.body
+ assert_equal document.to_s, parsed.to_s
+ end
+
+ test "parses hand-authored YAML front matter" do
+ parsed = Leaf::Document.parse(<<~MD)
+ ---
+ title: Monitors
+ position: 27
+ external_id: monitors.md
+ ---
+
+ How to configure monitors.
+ MD
+
+ assert_equal "Monitors", parsed.title
+ assert_equal 27, parsed.position
+ assert_equal "monitors.md", parsed.external_id
+ assert_equal "How to configure monitors.\n", parsed.body
+ end
+
+ test "ignores url and unknown front matter keys" do
+ parsed = Leaf::Document.parse("---\ntitle: T\nurl: http://example.com/x\nwhatever: else\n---\n\nBody")
+
+ assert_equal "T", parsed.title
+ assert_nil parsed.url
+ assert_equal "Body", parsed.body
+ end
+
+ test "binary-encoded requests with UTF-8 content parse" do
+ raw = "---\ntitle: Führung\n---\n\nEm — dash".b
+ parsed = Leaf::Document.parse(raw)
+
+ assert_equal "Führung", parsed.title
+ assert_equal "Em — dash", parsed.body
+ assert_equal Encoding::UTF_8, parsed.body.encoding
+ end
+
+ test "rejects invalid UTF-8" do
+ assert_raises Leaf::Document::Malformed do
+ Leaf::Document.parse("---\ntitle: T\n---\n\n\xE2 broken".b)
+ end
+ end
+
+ test "rejects documents without front matter" do
+ assert_raises Leaf::Document::Malformed do
+ Leaf::Document.parse("Just a body")
+ end
+
+ assert_raises Leaf::Document::Malformed do
+ Leaf::Document.parse("---\ntitle: unclosed\n\nBody")
+ end
+
+ assert_raises Leaf::Document::Malformed do
+ Leaf::Document.parse("---\n- just\n- a\n- list\n---\n\nBody")
+ end
+ end
+end
diff --git a/test/models/leaf/editable_test.rb b/test/models/leaf/editable_test.rb
index b6546420..1fcdcd5b 100644
--- a/test/models/leaf/editable_test.rb
+++ b/test/models/leaf/editable_test.rb
@@ -46,6 +46,26 @@ class Leaf::EditableTest < ActiveSupport::TestCase
end
end
+ test "re-sending an identical body doesn't create a revision" do
+ leaves(:welcome_page).edit leafable_params: { body: "New body" }
+
+ travel 1.hour do
+ assert_no_difference -> { Edit.count } do
+ leaves(:welcome_page).edit leafable_params: { body: "New body" }
+ end
+ end
+ end
+
+ test "re-sending an identical section body doesn't create a revision" do
+ leaves(:welcome_section).edit leafable_params: { body: "New body" }
+
+ travel 1.hour do
+ assert_no_difference -> { Edit.count } do
+ leaves(:welcome_section).edit leafable_params: { body: "New body" }
+ end
+ end
+ end
+
test "editing a leafable with an attachment includes the attachments in the new version" do
assert leaves(:reading_picture).picture.image.attached?
diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb
index 15e98587..b6e836cb 100644
--- a/test/test_helpers/session_test_helper.rb
+++ b/test/test_helpers/session_test_helper.rb
@@ -13,4 +13,9 @@ def sign_out
delete session_url
assert_not cookies[:session_token].present?
end
+
+ def bearer_key_header(user)
+ user = users(user) unless user.is_a? User
+ { "Authorization" => "Bearer #{user.bearer_key}" }
+ end
end