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 %> +
+
diff --git a/app/views/users/profiles/edit.html.erb b/app/views/users/profiles/edit.html.erb index de743130..0325b4a5 100644 --- a/app/views/users/profiles/edit.html.erb +++ b/app/views/users/profiles/edit.html.erb @@ -55,6 +55,10 @@ <%= render "users/transfer", user: @user %> +
+ <%= render "users/bearer_key", user: @user %> +
+
<%= 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