From 7328090e59fb6583776e3e92d39c9104214b8ee0 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 28 Aug 2026 14:14:14 -0700 Subject: [PATCH 01/15] Return the linked page's title and description from OpenGraph The dashboard's link form had its own OpenGraph scraper alongside Pressa's, one for prefilling title/description and one for the Image front matter. Fold the text fields into Pressa::OpenGraph so there's a single scraper for the web app to call. --- lib/pressa/open_graph.rb | 30 ++++++++++++++++++++------- test/open_graph_test.rb | 45 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/lib/pressa/open_graph.rb b/lib/pressa/open_graph.rb index 64133eed..1f66fdaf 100644 --- a/lib/pressa/open_graph.rb +++ b/lib/pressa/open_graph.rb @@ -1,16 +1,19 @@ +require "cgi" require "net/http" require "uri" module Pressa - # Best-effort scraper for OpenGraph metadata on a linked page, used to fill - # in an Image for link posts. Never raises: network failures, timeouts, and - # missing tags all just resolve to a nil image so post creation never blocks - # on a flaky or slow third-party site. + # Best-effort scraper for OpenGraph metadata on a linked page: an Image for + # link posts, plus the title and description used to prefill the link form. + # Never raises: network failures, timeouts, and missing tags all just resolve + # to nil fields so post creation never blocks on a flaky or slow third-party + # site. class OpenGraph - Result = Data.define(:image) + Result = Data.define(:title, :description, :image) USER_AGENT = "samhuri.net-link-preview/1.0".freeze MAX_REDIRECTS = 5 + TITLE_ELEMENT = /]*>([^<]*)<\/title>/i def self.fetch(url, http_get: method(:http_get)) html = http_get.call(url) @@ -22,10 +25,16 @@ def self.fetch(url, http_get: method(:http_get)) end def self.extract(html, base_url:) + title = text_field(html, "og:title") || clean(html[TITLE_ELEMENT, 1]) + description = text_field(html, "og:description") || text_field(html, "description") image = meta_content(html, "og:image") || meta_content(html, "twitter:image") - return nil if image.nil? + return nil if title.nil? && description.nil? && image.nil? - Result.new(image: resolve(image, base_url:)) + Result.new(title:, description:, image: image && resolve(image, base_url:)) + end + + def self.text_field(html, property) + clean(meta_content(html, property)) end def self.meta_content(html, property) @@ -37,6 +46,13 @@ def self.meta_content(html, property) content&.strip&.then { |value| value.empty? ? nil : value } end + def self.clean(value) + return nil if value.nil? + + unescaped = CGI.unescapeHTML(value).strip + unescaped.empty? ? nil : unescaped + end + def self.resolve(image, base_url:) URI.join(base_url, image).to_s rescue URI::InvalidURIError, URI::InvalidComponentError diff --git a/test/open_graph_test.rb b/test/open_graph_test.rb index b15656ba..a2e6de98 100644 --- a/test/open_graph_test.rb +++ b/test/open_graph_test.rb @@ -27,10 +27,10 @@ def test_extract_falls_back_to_twitter_image assert_equal("https://cdn.example.net/tw.png", result.image) end - def test_extract_returns_nil_when_no_image_meta_present + def test_extract_leaves_image_nil_when_no_image_meta_present html = "No image here" - refute(Pressa::OpenGraph.extract(html, base_url: "https://example.net")) + assert_nil(Pressa::OpenGraph.extract(html, base_url: "https://example.net").image) end def test_extract_handles_single_quoted_attributes @@ -40,6 +40,47 @@ def test_extract_handles_single_quoted_attributes assert_equal("https://cdn.example.net/single.png", result.image) end + def test_extract_returns_og_title + html = %(ignored) + + result = Pressa::OpenGraph.extract(html, base_url: "https://trails.example.net") + assert_equal("Ride the Lightning Rail", result.title) + end + + def test_extract_falls_back_to_the_title_element + html = " Powder Day Protocol " + + result = Pressa::OpenGraph.extract(html, base_url: "https://powder.example.net") + assert_equal("Powder Day Protocol", result.title) + end + + def test_extract_returns_og_description + html = %() + + result = Pressa::OpenGraph.extract(html, base_url: "https://powder.example.net") + assert_equal("A field guide to tree wells.", result.description) + end + + def test_extract_falls_back_to_the_meta_description + html = %() + + result = Pressa::OpenGraph.extract(html, base_url: "https://powder.example.net") + assert_equal("Notes from the lift line.", result.description) + end + + def test_extract_unescapes_html_entities_in_text_fields + html = %() + + %() + + result = Pressa::OpenGraph.extract(html, base_url: "https://beats.example.net") + assert_equal("Bikes & Boards", result.title) + assert_equal("Trent's workshop", result.description) + end + + def test_extract_returns_nil_when_the_page_offers_nothing + refute(Pressa::OpenGraph.extract("hi", base_url: "https://example.net")) + end + def test_fetch_uses_injected_http_get_and_extracts_image html = %() result = Pressa::OpenGraph.fetch("https://example.net/post", http_get: ->(_url) { html }) From 4a8f3552f1c9f71a6cbc71d6a699aff953caeab0 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 28 Aug 2026 14:14:14 -0700 Subject: [PATCH 02/15] Expose single-post rendering outside the build The HTML and Gemini writers could only render a post by writing it to disk as part of a full site build, and PostRepo could only build a Post from a file. Pull out the seams a preview needs: build a Post from a string, render one post's HTML page, render one post's gemtext. All three are the code paths the build itself uses, so a preview shows the real output rather than an approximation of it. Also collapses the two identical copies of the kramdown call onto MarkdownRenderer.render_html. --- lib/pressa/posts/gemini_writer.rb | 15 ++++-- lib/pressa/posts/repo.rb | 50 ++++++++----------- lib/pressa/posts/writer.rb | 33 +++++++------ lib/pressa/utils/markdown_renderer.rb | 28 ++++++----- test/posts/gemini_writer_test.rb | 69 +++++++++++++++++++++++++++ test/posts/repo_test.rb | 49 +++++++++++++++++++ test/posts/writer_test.rb | 19 ++++++++ 7 files changed, 203 insertions(+), 60 deletions(-) create mode 100644 test/posts/gemini_writer_test.rb diff --git a/lib/pressa/posts/gemini_writer.rb b/lib/pressa/posts/gemini_writer.rb index 2b4742e5..b14406ca 100644 --- a/lib/pressa/posts/gemini_writer.rb +++ b/lib/pressa/posts/gemini_writer.rb @@ -60,9 +60,10 @@ def write_posts_index(target_path:) Utils::FileWriter.write(path: File.join(target_path, "posts", "feed.gmi"), content:) end - private - - def write_post(post:, target_path:) + # The gemtext for one post, exactly as write_posts would write it. The + # preview endpoint renders drafts through this so what you see is what + # the capsule gets. + def post_content(post:) rows = ["# #{post.title}", "", "#{post.formatted_date} by #{post.author}", ""] if post.link_post? @@ -78,8 +79,14 @@ def write_post(post:, target_path:) rows << "=> #{web_url_for("#{post.path}/")} Read on the web" if include_web_link?(post) rows << "" + rows.join("\n") + end + + private + + def write_post(post:, target_path:) file_path = File.join(target_path, post.path.sub(%r{^/}, ""), "index.gmi") - Utils::FileWriter.write(path: file_path, content: rows.join("\n")) + Utils::FileWriter.write(path: file_path, content: post_content(post:)) end def post_link_line(post) diff --git a/lib/pressa/posts/repo.rb b/lib/pressa/posts/repo.rb index f9d03883..fa5b7ceb 100644 --- a/lib/pressa/posts/repo.rb +++ b/lib/pressa/posts/repo.rb @@ -1,7 +1,6 @@ -require "kramdown" require "pressa/posts/models" require "pressa/posts/metadata" -require "pressa/utils/rouge_html_formatter" +require "pressa/utils/markdown_renderer" module Pressa module Posts @@ -22,24 +21,13 @@ def read_posts(posts_dir) PostsByYear.new(by_year: @posts_by_year) end - private - - def enumerate_markdown_files(dir, &block) - Dir.glob(File.join(dir, "**", "*.md")).each(&block) - end - - def read_post(file_path) - content = File.read(file_path) + # Builds a Post from raw markdown, no file needed, so drafts and + # unsaved link posts can be rendered exactly the way the build renders + # published ones. + def build_post(content:, slug:) metadata = PostMetadata.parse(content) - body_markdown = content.sub(/\A---\s*\n.*?\n---\s*\n/m, "") - html_body = render_markdown(body_markdown) - - slug = File.basename(file_path, ".md") - path = generate_path(slug, metadata.date) - excerpt = generate_excerpt(body_markdown) - Post.new( slug:, title: metadata.title, @@ -49,25 +37,25 @@ def read_post(file_path) link: metadata.link, tags: metadata.tags, image: metadata.image, - body: html_body, + body: render_markdown(body_markdown), markdown_body: body_markdown, - excerpt:, - path: + excerpt: generate_excerpt(body_markdown), + path: generate_path(slug, metadata.date) ) end + private + + def enumerate_markdown_files(dir, &block) + Dir.glob(File.join(dir, "**", "*.md")).each(&block) + end + + def read_post(file_path) + build_post(content: File.read(file_path), slug: File.basename(file_path, ".md")) + end + def render_markdown(markdown) - Kramdown::Document.new( - markdown, - input: "GFM", - hard_wrap: false, - syntax_highlighter: "rouge", - syntax_highlighter_opts: { - line_numbers: false, - wrap: true, - formatter: Pressa::Utils::RougeHTMLFormatter - } - ).to_html + Utils::MarkdownRenderer.render_html(markdown) end def generate_path(slug, date) diff --git a/lib/pressa/posts/writer.rb b/lib/pressa/posts/writer.rb index ac4e6143..a1409e94 100644 --- a/lib/pressa/posts/writer.rb +++ b/lib/pressa/posts/writer.rb @@ -23,6 +23,24 @@ def write_posts(target_path:) end end + # The full HTML page for one post, exactly as write_posts would write it, + # so the preview endpoint shows the real page rather than an + # approximation of it. + def post_html(post:) + content_view = Views::PostView.new(post:, site: @site, article_class: "container") + + render_layout( + page_subtitle: post.title, + canonical_url: @site.url_for(post.path), + content: content_view, + page_description: post.excerpt, + page_type: "article", + page_image: post.image, + page_tags: post.tags, + page_published_time: post.date.iso8601 + ) + end + def write_recent_posts(target_path:, limit: 10) recent = @posts_by_year.recent_posts(limit) content_view = Views::RecentPostsView.new(posts: recent, site: @site) @@ -110,21 +128,8 @@ def write_tag_page(tag:, target_path:) end def write_post(post:, target_path:) - content_view = Views::PostView.new(post:, site: @site, article_class: "container") - - html = render_layout( - page_subtitle: post.title, - canonical_url: @site.url_for(post.path), - content: content_view, - page_description: post.excerpt, - page_type: "article", - page_image: post.image, - page_tags: post.tags, - page_published_time: post.date.iso8601 - ) - file_path = File.join(target_path, post.path.sub(/^\//, ""), "index.html") - Utils::FileWriter.write(path: file_path, content: html) + Utils::FileWriter.write(path: file_path, content: post_html(post:)) end def write_year_index(year:, year_posts:, target_path:) diff --git a/lib/pressa/utils/markdown_renderer.rb b/lib/pressa/utils/markdown_renderer.rb index 3b05b044..0e57b6b3 100644 --- a/lib/pressa/utils/markdown_renderer.rb +++ b/lib/pressa/utils/markdown_renderer.rb @@ -12,6 +12,22 @@ module Utils class MarkdownRenderer EXCERPT_LENGTH = 300 + # The one place markdown becomes HTML, shared with PostRepo and the + # preview endpoint so all three agree on GFM and syntax highlighting. + def self.render_html(markdown) + Kramdown::Document.new( + markdown, + input: "GFM", + hard_wrap: false, + syntax_highlighter: "rouge", + syntax_highlighter_opts: { + line_numbers: false, + wrap: true, + formatter: Pressa::Utils::RougeHTMLFormatter + } + ).to_html + end + def can_render_file?(filename:, extension:) extension == "md" end @@ -71,17 +87,7 @@ def parse_content(content) end def render_markdown(markdown) - Kramdown::Document.new( - markdown, - input: "GFM", - hard_wrap: false, - syntax_highlighter: "rouge", - syntax_highlighter_opts: { - line_numbers: false, - wrap: true, - formatter: Pressa::Utils::RougeHTMLFormatter - } - ).to_html + self.class.render_html(markdown) end def render_layout(site:, page_subtitle:, canonical_url:, body:, page_description:, page_type:) diff --git a/test/posts/gemini_writer_test.rb b/test/posts/gemini_writer_test.rb new file mode 100644 index 00000000..8f2b8e34 --- /dev/null +++ b/test/posts/gemini_writer_test.rb @@ -0,0 +1,69 @@ +require "test_helper" +require "tmpdir" +require "pressa/posts/gemini_writer" +require "pressa/posts/repo" + +class Pressa::Posts::GeminiWriterTest < Minitest::Test + def site + @site ||= Pressa::Site.new( + author: "Sami Samhuri", + email: "sami@samhuri.net", + title: "samhuri.net", + description: "blog", + url: "https://samhuri.net", + output_format: "gemini", + output_options: Pressa::GeminiOutputOptions.new + ) + end + + def post(content) + Pressa::Posts::PostRepo.new.build_post(content:, slug: "tree-well-protocol") + end + + def link_post_source + <<~MARKDOWN + --- + Title: Tree Well Protocol + Author: Jane Doe + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + Link: https://powder.example.net/tree-wells + --- + + Never ride alone in deep snow. + MARKDOWN + end + + def writer(posts) + by_month = Pressa::Posts::MonthPosts.new( + month: Pressa::Posts::Month.new(name: "June", number: 6, padded: "06"), + posts: + ) + posts_by_year = Pressa::Posts::PostsByYear.new( + by_year: {2026 => Pressa::Posts::YearPosts.new(year: 2026, by_month: {6 => by_month})} + ) + Pressa::Posts::GeminiWriter.new(site:, posts_by_year:) + end + + def test_post_content_renders_a_single_post_as_gemtext + entry = post(link_post_source) + gemtext = writer([entry]).post_content(post: entry) + + assert_includes(gemtext, "# Tree Well Protocol") + assert_includes(gemtext, "7th June, 2026 by Jane Doe") + assert_includes(gemtext, "=> https://powder.example.net/tree-wells") + assert_includes(gemtext, "Never ride alone in deep snow.") + assert_includes(gemtext, "=> /posts Back to posts") + end + + def test_post_content_matches_what_write_posts_puts_on_disk + Dir.mktmpdir do |tmpdir| + entry = post(link_post_source) + gemini_writer = writer([entry]) + gemini_writer.write_posts(target_path: tmpdir) + on_disk = File.read(File.join(tmpdir, "posts/2026/06/tree-well-protocol/index.gmi")) + + assert_equal(on_disk, gemini_writer.post_content(post: entry)) + end + end +end diff --git a/test/posts/repo_test.rb b/test/posts/repo_test.rb index 6d60e25f..87b3e556 100644 --- a/test/posts/repo_test.rb +++ b/test/posts/repo_test.rb @@ -105,4 +105,53 @@ def test_read_posts_merges_multiple_posts_in_same_month assert_equal(["Second Post", "First Post"], month_posts.sorted_posts.map(&:title)) end end + + def test_build_post_builds_a_post_from_content_without_touching_the_filesystem + content = <<~MARKDOWN + --- + Title: Tree Well Protocol + Author: Jane Doe + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: snowboarding, safety + Link: https://powder.example.net/tree-wells + --- + + Never ride alone in deep snow. + MARKDOWN + + post = repo.build_post(content:, slug: "tree-well-protocol") + + assert_equal("Tree Well Protocol", post.title) + assert_equal("Jane Doe", post.author) + assert_equal("tree-well-protocol", post.slug) + assert_equal("/posts/2026/06/tree-well-protocol", post.path) + assert_equal(["snowboarding", "safety"], post.tags) + assert_equal("https://powder.example.net/tree-wells", post.link) + assert_includes(post.body, "

Never ride alone in deep snow.

") + assert_equal("Never ride alone in deep snow.\n", post.markdown_body) + end + + def test_read_posts_and_build_post_agree + Dir.mktmpdir do |tmpdir| + posts_dir = File.join(tmpdir, "posts", "2026", "06") + FileUtils.mkdir_p(posts_dir) + + content = <<~MARKDOWN + --- + Title: Lift Line Notes + Author: Fat Mike + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + --- + + Chairlift conversations, collected. + MARKDOWN + + File.write(File.join(posts_dir, "lift-line-notes.md"), content) + from_disk = repo.read_posts(File.join(tmpdir, "posts")).all_posts.first + + assert_equal(from_disk.to_h, repo.build_post(content:, slug: "lift-line-notes").to_h) + end + end end diff --git a/test/posts/writer_test.rb b/test/posts/writer_test.rb index 4625bd5c..a3aa901c 100644 --- a/test/posts/writer_test.rb +++ b/test/posts/writer_test.rb @@ -164,4 +164,23 @@ def test_write_tag_pages_writes_each_tag_page refute_includes(html, "Linked") end end + + def test_post_html_renders_a_single_post_without_writing_a_file + post = posts_by_year.all_posts.find { it.slug == "regular-post" } + html = writer.post_html(post:) + + assert_includes(html, "

regular body

") + assert_includes(html, "Regular") + refute_includes(html, "linked body") + end + + def test_post_html_matches_what_write_posts_puts_on_disk + Dir.mktmpdir do |tmpdir| + writer.write_posts(target_path: tmpdir) + post = posts_by_year.all_posts.find { it.slug == "regular-post" } + on_disk = File.read(File.join(tmpdir, "posts/2024/10/regular-post/index.html")) + + assert_equal(on_disk, writer.post_html(post:)) + end + end end From b9d126fcec733a50114760cc45fd8288eb1c5a7a Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 28 Aug 2026 14:14:14 -0700 Subject: [PATCH 03/15] Render a post as HTML and gemtext side by side Pressa::Web::Preview turns one post's source into both outputs at once, through the same writers the build uses. Catching capsule formatting problems currently means publishing and then reading the built capsule; this makes it something you can see before you publish. --- lib/pressa/web/preview.rb | 53 ++++++++++++++++++ test/web/preview_test.rb | 110 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 lib/pressa/web/preview.rb create mode 100644 test/web/preview_test.rb diff --git a/lib/pressa/web/preview.rb b/lib/pressa/web/preview.rb new file mode 100644 index 00000000..2c6e647e --- /dev/null +++ b/lib/pressa/web/preview.rb @@ -0,0 +1,53 @@ +require "pressa/posts/gemini_writer" +require "pressa/posts/models" +require "pressa/posts/repo" +require "pressa/posts/writer" + +module Pressa + module Web + # Renders one post's source as both a web page and a gemtext capsule page, + # through the same writers the build uses, so formatting problems show up + # before publishing rather than after. + class Preview + class Error < StandardError; end + + Result = Data.define(:title, :html, :gemtext) + + def initialize(html_site:, gemini_site:) + @html_site = html_site + @gemini_site = gemini_site + end + + def render(source, slug:) + post = + begin + Posts::PostRepo.new.build_post(content: source, slug:) + rescue => e + raise Error, e.message + end + + Result.new( + title: post.title, + html: html_writer.post_html(post:), + gemtext: gemini_writer.post_content(post:) + ) + end + + private + + # Neither post_html nor post_content consults the index, and a preview + # has no site to index anyway. + def empty_index + Posts::PostsByYear.new(by_year: {}) + end + + def html_writer + @html_writer ||= Posts::PostWriter.new(site: @html_site, posts_by_year: empty_index) + end + + def gemini_writer + @gemini_writer ||= Posts::GeminiWriter.new(site: @gemini_site, posts_by_year: empty_index) + end + end + end +end diff --git a/test/web/preview_test.rb b/test/web/preview_test.rb new file mode 100644 index 00000000..5acfd58a --- /dev/null +++ b/test/web/preview_test.rb @@ -0,0 +1,110 @@ +require "test_helper" +require "pressa/web/preview" + +class Pressa::Web::PreviewTest < Minitest::Test + def build_site(output_format) + options = + if output_format == "gemini" + Pressa::GeminiOutputOptions.new + else + Pressa::HTMLOutputOptions.new + end + + Pressa::Site.new( + author: "Sami Samhuri", + email: "sami@samhuri.net", + title: "samhuri.net", + description: "blog", + url: "https://samhuri.net", + output_format:, + output_options: options + ) + end + + def preview + @preview ||= Pressa::Web::Preview.new( + html_site: build_site("html"), + gemini_site: build_site("gemini") + ) + end + + def link_post_source + <<~MARKDOWN + --- + Title: Tree Well Protocol + Author: Jane Doe + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: snowboarding, safety + Link: https://powder.example.net/tree-wells + --- + + Never ride alone in deep snow. + MARKDOWN + end + + def test_renders_html_and_gemtext_for_the_same_source + result = preview.render(link_post_source, slug: "tree-well-protocol") + + assert_equal("Tree Well Protocol", result.title) + assert_includes(result.html, "

Never ride alone in deep snow.

") + assert_includes(result.html, "Tree Well Protocol") + assert_includes(result.gemtext, "# Tree Well Protocol") + assert_includes(result.gemtext, "=> https://powder.example.net/tree-wells") + assert_includes(result.gemtext, "Never ride alone in deep snow.") + end + + def test_html_is_a_whole_page_not_a_fragment + result = preview.render(link_post_source, slug: "tree-well-protocol") + + assert_match(/\A/i, result.html) + end + + def test_gemtext_flags_raw_html_the_capsule_cannot_render + source = link_post_source.sub( + "Never ride alone in deep snow.", + "

Never ride alone in deep snow.

" + ) + result = preview.render(source, slug: "tree-well-protocol") + + assert_includes(result.gemtext, "Read on the web") + end + + def test_renders_an_unpublished_draft + source = <<~MARKDOWN + --- + Author: Fat Mike + Title: Lift Line Notes + Date: unpublished + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: + --- + + Chairlift conversations, collected. + MARKDOWN + + result = preview.render(source, slug: "lift-line-notes") + + assert_equal("Lift Line Notes", result.title) + assert_includes(result.gemtext, "unpublished by Fat Mike") + assert_includes(result.html, "Chairlift conversations, collected.") + end + + def test_raises_a_preview_error_for_source_without_front_matter + error = assert_raises(Pressa::Web::Preview::Error) do + preview.render("Just some notes.\n", slug: "notes") + end + + assert_match(/front-matter/i, error.message) + end + + def test_raises_a_preview_error_when_required_fields_are_missing + source = "---\nTitle: Half a Post\n---\n\nBody.\n" + + error = assert_raises(Pressa::Web::Preview::Error) do + preview.render(source, slug: "half-a-post") + end + + assert_match(/Author/, error.message) + end +end From 217d53ccc233b873aa95ae4fe93d93cb8ca4abbe Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 28 Aug 2026 14:14:14 -0700 Subject: [PATCH 04/15] Add the job model behind async publishing Publishing is pull, write, commit, push, build twice, rsync twice. Far too slow to block a request on, and it mutates a git checkout that bin/post-link also writes to over SSH. Model it as a job instead: JobRegistry holds the single work slot and a short history, Job carries the log and lets browsers subscribe to it, and JobRunner runs the existing scripts and streams their progress. A second publish while one is running is refused with the running job rather than queued. Silently queueing a publish is worse than being told to wait, and two at once against the same checkout would corrupt something. --- lib/pressa/web/job.rb | 115 ++++++++++++++++++++++++++++++++ lib/pressa/web/job_registry.rb | 89 +++++++++++++++++++++++++ lib/pressa/web/job_runner.rb | 62 +++++++++++++++++ test/web/job_registry_test.rb | 93 ++++++++++++++++++++++++++ test/web/job_runner_test.rb | 78 ++++++++++++++++++++++ test/web/job_test.rb | 117 +++++++++++++++++++++++++++++++++ 6 files changed, 554 insertions(+) create mode 100644 lib/pressa/web/job.rb create mode 100644 lib/pressa/web/job_registry.rb create mode 100644 lib/pressa/web/job_runner.rb create mode 100644 test/web/job_registry_test.rb create mode 100644 test/web/job_runner_test.rb create mode 100644 test/web/job_test.rb diff --git a/lib/pressa/web/job.rb b/lib/pressa/web/job.rb new file mode 100644 index 00000000..14b094f8 --- /dev/null +++ b/lib/pressa/web/job.rb @@ -0,0 +1,115 @@ +require "monitor" +require "time" + +module Pressa + module Web + # One unit of long-running work — publishing a link, publishing a draft — + # with its log. Publishing mutates a git checkout and takes far too long + # for a blocking request, so requests start a job and then watch it. + # + # Written by the worker thread and read by every connected browser, so all + # state changes go through the monitor. Subscribers get the backlog and a + # live queue in one atomic step, which is what lets a phone reconnect + # mid-publish without missing or repeating lines. + class Job + STATES = %i[running succeeded failed].freeze + + attr_reader :id, :kind, :label, :started_at, :finished_at, :state, :result, :error + + def initialize(id:, kind:, label: nil, clock: -> { Time.now }) + @id = id + @kind = kind + @label = label + @clock = clock + @state = :running + @started_at = clock.call + @finished_at = nil + @result = nil + @error = nil + @lines = [] + @subscribers = [] + @monitor = Monitor.new + end + + def running? = state == :running + + def finished? = !running? + + def lines + @monitor.synchronize { @lines.dup } + end + + def append(line) + @monitor.synchronize do + return if finished? + + @lines << line + @subscribers.each { it << line } + end + end + + def succeed(result) + finish(:succeeded) { @result = result } + end + + def fail(error) + finish(:failed) { @error = error } + end + + # Returns the lines so far plus a queue carrying every line after them, + # then nil once the job finishes. Taken together under the monitor so no + # line can slip between the snapshot and the subscription. + def subscribe + @monitor.synchronize do + queue = Queue.new + if finished? + queue << nil + else + @subscribers << queue + end + [@lines.dup, queue] + end + end + + def unsubscribe(queue) + @monitor.synchronize { @subscribers.delete(queue) } + end + + def duration + return nil unless finished_at + + finished_at - started_at + end + + def to_h + @monitor.synchronize do + { + id: @id, + kind: @kind, + label: @label, + state: @state.to_s, + started_at: @started_at.iso8601, + finished_at: @finished_at&.iso8601, + duration: duration, + result: @result, + error: @error + } + end + end + + private + + def finish(state) + @monitor.synchronize do + return if finished? + + yield + @state = state + @finished_at = @clock.call + @subscribers.each { it << nil } + @subscribers.clear + end + end + end + end +end diff --git a/lib/pressa/web/job_registry.rb b/lib/pressa/web/job_registry.rb new file mode 100644 index 00000000..d8d08148 --- /dev/null +++ b/lib/pressa/web/job_registry.rb @@ -0,0 +1,89 @@ +require "monitor" +require "securerandom" +require "pressa/web/job" + +module Pressa + module Web + # Holds the one job that may run at a time, plus a short history. + # + # Publishing pulls, commits, pushes, builds, and rsyncs a git checkout that + # bin/post-link also writes to over SSH. Two of those at once would corrupt + # something, so a second request while one is running is refused outright + # rather than queued — silently queueing a publish is worse than being told + # to wait. + class JobRegistry + class Busy < StandardError + attr_reader :job + + def initialize(job) + @job = job + super("#{job.kind} job #{job.id} is already running") + end + end + + MAX_HISTORY = 20 + + def initialize(executor: ->(&block) { Thread.new(&block) }, clock: -> { Time.now }, max_history: MAX_HISTORY) + @executor = executor + @clock = clock + @max_history = max_history + @current = nil + @history = [] + @monitor = Monitor.new + end + + # Claims the single work slot and starts the block on the executor, + # returning the job right away so the request can redirect to its status + # stream. Raises Busy, carrying the running job, when the slot is taken. + def start(kind:, label: nil, &work) + job = @monitor.synchronize do + raise Busy.new(@current) if @current + + @current = Job.new(id: next_id, kind:, label:, clock: @clock) + end + + @executor.call { run(job, &work) } + job + end + + def current + @monitor.synchronize { @current } + end + + def find(id) + @monitor.synchronize do + return @current if @current&.id == id + + @history.find { it.id == id } + end + end + + # Newest first, running job included. + def recent + @monitor.synchronize { [@current, *@history].compact } + end + + private + + def run(job, &work) + job.succeed(work.call(job)) + rescue => e + job.fail(e.message) + ensure + retire(job) + end + + def retire(job) + @monitor.synchronize do + @current = nil + @history.unshift(job) + @history.pop while @history.length > @max_history + end + end + + def next_id + "#{@clock.call.strftime("%H%M%S")}-#{SecureRandom.hex(3)}" + end + end + end +end diff --git a/lib/pressa/web/job_runner.rb b/lib/pressa/web/job_runner.rb new file mode 100644 index 00000000..ec48f0ef --- /dev/null +++ b/lib/pressa/web/job_runner.rb @@ -0,0 +1,62 @@ +require "open3" + +module Pressa + module Web + # Runs one of the existing publish scripts and streams its progress. + # + # bin/post-link already knows how to pull, write, commit, push, build, and + # rsync; the web app runs that same script rather than a second copy of the + # flow. The scripts follow the convention that stderr is progress and + # stdout is the answer (the post path, the preview URL), so stderr lines + # are streamed to the caller as they arrive and stdout becomes the result. + module JobRunner + class Failed < StandardError + attr_reader :exit_status + + def initialize(message, exit_status:) + @exit_status = exit_status + super(message) + end + end + + # Yields each output line as it arrives and returns the command's stdout. + # Reads whole lines, which suits the line-oriented scripts it runs. + def self.run(command:, stdin_data: nil, chdir: nil, env: {}, &on_line) + stdout_lines = [] + log_tail = nil + + options = chdir ? {chdir: chdir} : {} + status = Open3.popen3(env, *command, **options) do |stdin, stdout, stderr, wait_thread| + stdin.write(stdin_data) if stdin_data + stdin.close + + open_streams = [stdout, stderr] + until open_streams.empty? + ready, = IO.select(open_streams) + ready.each do |stream| + line = stream.gets + if line.nil? + open_streams.delete(stream) + next + end + + line = line.chomp + stdout_lines << line if stream == stdout + log_tail = line unless line.strip.empty? + on_line&.call(line) + end + end + + wait_thread.value + end + + unless status.success? + raise Failed.new(log_tail || "#{command.first} exited with status #{status.exitstatus}", + exit_status: status.exitstatus) + end + + stdout_lines.join("\n").strip + end + end + end +end diff --git a/test/web/job_registry_test.rb b/test/web/job_registry_test.rb new file mode 100644 index 00000000..c44d7a26 --- /dev/null +++ b/test/web/job_registry_test.rb @@ -0,0 +1,93 @@ +require "test_helper" +require "pressa/web/job_registry" + +class Pressa::Web::JobRegistryTest < Minitest::Test + # Runs work on the calling thread so tests never wait on a scheduler. + def inline_executor = ->(&block) { block.call } + + # Holds the work instead of running it, so a job stays "running". + def deferred_executor + @deferred ||= [] + ->(&block) { @deferred << block } + end + + def registry(executor: inline_executor, **options) + Pressa::Web::JobRegistry.new(executor:, **options) + end + + def test_start_runs_the_work_and_records_the_result + subject = registry + job = subject.start(kind: "publish_link", label: "Tree Well Protocol") do |running| + running.append("==> Building") + "posts/2026/06/tree-well-protocol.md" + end + + assert_equal(:succeeded, job.state) + assert_equal("posts/2026/06/tree-well-protocol.md", job.result) + assert_equal(["==> Building"], job.lines) + end + + def test_start_records_a_raised_error_as_a_failed_job + subject = registry + job = subject.start(kind: "publish_link") { raise "rsync exited with 23" } + + assert_equal(:failed, job.state) + assert_equal("rsync exited with 23", job.error) + end + + def test_a_second_start_while_one_is_running_reports_the_running_job + subject = registry(executor: deferred_executor) + running = subject.start(kind: "publish_link", label: "First") { "ok" } + + error = assert_raises(Pressa::Web::JobRegistry::Busy) do + subject.start(kind: "publish_link", label: "Second") { "ok" } + end + + assert_same(running, error.job) + assert_equal("First", error.job.label) + end + + def test_the_slot_frees_up_once_a_job_finishes + subject = registry + subject.start(kind: "publish_link") { "ok" } + + assert_nil(subject.current) + assert(subject.start(kind: "publish_link") { "ok" }) + end + + def test_the_slot_frees_up_even_when_the_work_raises + subject = registry + subject.start(kind: "publish_link") { raise "boom" } + + assert_nil(subject.current) + end + + def test_current_is_the_running_job + subject = registry(executor: deferred_executor) + job = subject.start(kind: "publish_link") { "ok" } + + assert_same(job, subject.current) + end + + def test_find_looks_up_running_and_finished_jobs_by_id + subject = registry + job = subject.start(kind: "publish_link") { "ok" } + + assert_same(job, subject.find(job.id)) + assert_nil(subject.find("nope")) + end + + def test_jobs_get_distinct_ids + subject = registry + ids = 3.times.map { subject.start(kind: "publish_link") { "ok" }.id } + + assert_equal(3, ids.uniq.length) + end + + def test_recent_lists_newest_first_and_forgets_old_jobs + subject = registry(max_history: 2) + 3.times { |i| subject.start(kind: "publish_link", label: "job #{i}") { "ok" } } + + assert_equal(["job 2", "job 1"], subject.recent.map(&:label)) + end +end diff --git a/test/web/job_runner_test.rb b/test/web/job_runner_test.rb new file mode 100644 index 00000000..39bb4a07 --- /dev/null +++ b/test/web/job_runner_test.rb @@ -0,0 +1,78 @@ +require "test_helper" +require "tmpdir" +require "pressa/web/job_runner" + +class Pressa::Web::JobRunnerTest < Minitest::Test + def sh(script) = ["sh", "-c", script] + + def test_returns_stdout_as_the_result + result = Pressa::Web::JobRunner.run(command: sh("echo posts/2026/06/tree-well-protocol.md")) + + assert_equal("posts/2026/06/tree-well-protocol.md", result) + end + + def test_streams_progress_lines_to_the_block_as_they_arrive + lines = [] + Pressa::Web::JobRunner.run(command: sh("echo '==> Pulling' >&2; echo '==> Building' >&2")) do |line| + lines << line + end + + assert_equal(["==> Pulling", "==> Building"], lines) + end + + def test_the_log_carries_stdout_too_so_nothing_is_hidden + lines = [] + Pressa::Web::JobRunner.run(command: sh("echo progress >&2; echo the-result")) { lines << it } + + assert_equal(["progress", "the-result"], lines.sort) + end + + def test_sends_stdin_data_to_the_process + result = Pressa::Web::JobRunner.run(command: sh("cat"), stdin_data: %({"title":"Ride On"})) + + assert_equal(%({"title":"Ride On"}), result) + end + + def test_runs_in_the_given_directory + Dir.mktmpdir do |tmpdir| + result = Pressa::Web::JobRunner.run(command: sh("pwd"), chdir: tmpdir) + + assert_equal(File.realpath(tmpdir), File.realpath(result)) + end + end + + def test_passes_environment_variables_through + result = Pressa::Web::JobRunner.run( + command: sh("echo $SAMHURI_PUBLISH_HOST"), env: {"SAMHURI_PUBLISH_HOST" => "local"} + ) + + assert_equal("local", result) + end + + def test_raises_with_the_last_log_line_when_the_command_fails + error = assert_raises(Pressa::Web::JobRunner::Failed) do + Pressa::Web::JobRunner.run(command: sh("echo 'fatal: not a git repository' >&2; exit 128")) + end + + assert_equal("fatal: not a git repository", error.message) + assert_equal(128, error.exit_status) + end + + def test_raises_with_the_exit_status_when_the_command_says_nothing + error = assert_raises(Pressa::Web::JobRunner::Failed) do + Pressa::Web::JobRunner.run(command: sh("exit 23")) + end + + assert_match(/exited with status 23/, error.message) + end + + def test_streams_lines_before_the_command_finishes + seen_early = false + Pressa::Web::JobRunner.run(command: sh("echo first >&2; sleep 0.2; echo second >&2")) do |line| + seen_early = true if line == "first" + raise "second arrived before first was streamed" if line == "second" && !seen_early + end + + assert(seen_early) + end +end diff --git a/test/web/job_test.rb b/test/web/job_test.rb new file mode 100644 index 00000000..9b1c3bb1 --- /dev/null +++ b/test/web/job_test.rb @@ -0,0 +1,117 @@ +require "test_helper" +require "pressa/web/job" + +class Pressa::Web::JobTest < Minitest::Test + def job(**overrides) + defaults = {id: "abc123", kind: "publish_link", label: "Tree Well Protocol"} + Pressa::Web::Job.new(**defaults.merge(overrides)) + end + + def test_starts_out_running_with_no_lines + entry = job + + assert_predicate(entry, :running?) + refute_predicate(entry, :finished?) + assert_empty(entry.lines) + assert_nil(entry.result) + assert_nil(entry.finished_at) + end + + def test_append_collects_lines_in_order + entry = job + entry.append("==> Pulling latest") + entry.append("==> Creating link post") + + assert_equal(["==> Pulling latest", "==> Creating link post"], entry.lines) + end + + def test_lines_returns_a_snapshot_that_cannot_mutate_the_job + entry = job + entry.append("one") + entry.lines << "two" + + assert_equal(["one"], entry.lines) + end + + def test_succeed_records_the_result_and_finishes + entry = job + entry.succeed("posts/2026/06/tree-well-protocol.md") + + assert_predicate(entry, :finished?) + refute_predicate(entry, :running?) + assert_equal(:succeeded, entry.state) + assert_equal("posts/2026/06/tree-well-protocol.md", entry.result) + assert(entry.finished_at) + end + + def test_fail_records_the_error_and_finishes + entry = job + entry.fail("rsync exited with 23") + + assert_predicate(entry, :finished?) + assert_equal(:failed, entry.state) + assert_equal("rsync exited with 23", entry.error) + end + + def test_subscribe_hands_back_the_backlog_and_then_live_lines + entry = job + entry.append("==> Pulling latest") + + backlog, queue = entry.subscribe + entry.append("==> Building") + + assert_equal(["==> Pulling latest"], backlog) + assert_equal("==> Building", queue.pop) + end + + def test_subscribers_are_woken_when_the_job_finishes + entry = job + _backlog, queue = entry.subscribe + entry.succeed("done") + + assert_nil(queue.pop) + end + + def test_subscribing_to_a_finished_job_yields_the_backlog_and_an_immediate_end + entry = job + entry.append("==> Building") + entry.succeed("done") + + backlog, queue = entry.subscribe + + assert_equal(["==> Building"], backlog) + assert_nil(queue.pop) + end + + def test_unsubscribe_stops_delivery + entry = job + _backlog, queue = entry.subscribe + entry.unsubscribe(queue) + entry.append("==> Building") + + assert_predicate(queue, :empty?) + end + + def test_to_h_carries_what_the_status_stream_needs + entry = job + entry.append("==> Building") + entry.succeed("posts/2026/06/tree-well-protocol.md") + + payload = entry.to_h + + assert_equal("abc123", payload[:id]) + assert_equal("publish_link", payload[:kind]) + assert_equal("Tree Well Protocol", payload[:label]) + assert_equal("succeeded", payload[:state]) + assert_equal("posts/2026/06/tree-well-protocol.md", payload[:result]) + assert_nil(payload[:error]) + end + + def test_append_ignores_lines_once_the_job_has_finished + entry = job + entry.succeed("done") + entry.append("too late") + + assert_empty(entry.lines) + end +end From f31e1408c676ffeda6c7cc184459209f0c23964f Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 28 Aug 2026 14:18:43 -0700 Subject: [PATCH 05/15] Serialise the publish scripts and add bin/publish-draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three scripts that write to this checkout shared a copy-pasted rv preamble and repo/remote resolution; that moves to bin/lib/common.sh, which also carries a flock. Publishing pulls, commits, pushes, builds, and rsyncs, and now has two callers — the phone Shortcut over SSH and the web app — so two of them at once against one git repo is a real possibility rather than a theoretical one. Exit 75 says "try again shortly" rather than "that failed". bin/publish-draft is the draft counterpart to bin/post-link. It commits pending draft edits before pulling, since the web app writes drafts straight into the checkout and `git pull --ff-only` is entitled to refuse otherwise. --- .gitignore | 1 + bin/lib/common.sh | 48 +++++++++++++++++++++++++++++++++++++ bin/post-link | 34 +++++++-------------------- bin/preview-link | 26 ++++++-------------- bin/publish-draft | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 45 deletions(-) create mode 100644 bin/lib/common.sh create mode 100755 bin/publish-draft diff --git a/.gitignore b/.gitignore index f6a50d58..fcb95dd5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ www gemini +.publish.lock diff --git a/bin/lib/common.sh b/bin/lib/common.sh new file mode 100644 index 00000000..d9fa5b51 --- /dev/null +++ b/bin/lib/common.sh @@ -0,0 +1,48 @@ +# Shared setup for the scripts that write to this checkout. Source it, don't +# run it. + +# Non-interactive SSH sessions get a bare PATH and skip .profile, so rv is +# never reachable. Source .profile (which already puts rv on PATH via +# ~/.cargo/env) rather than guessing rv's install location, then load its +# Ruby env explicitly since that's only wired into interactive shells via +# .bashrc. +[ -f "$HOME/.profile" ] && . "$HOME/.profile" +eval "$(rv shell env bash)" + +# Run from the repo root regardless of where the calling script lives. +REPO="${SAMHURI_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +cd "$REPO" + +# Local clones here name the GitHub remote "github"; a fresh clone on the server +# names it "origin". Prefer github, fall back to origin, unless overridden. +REMOTE="${SAMHURI_REMOTE:-$(git remote | grep -qx github && echo github || echo origin)}" +BRANCH="${SAMHURI_BRANCH:-main}" + +# Serialise everything that writes to the checkout. The phone Shortcut over SSH +# and the Pressa web app both end up in these scripts, and two publishes at once +# against one git repo would corrupt something. Exit 75 (EX_TEMPFAIL) says "try +# again shortly" rather than "that failed". +acquire_publish_lock() { + local lock_file="${SAMHURI_LOCK_FILE:-$REPO/.publish.lock}" + + if ! command -v flock >/dev/null 2>&1; then + echo "==> flock unavailable, running without the publish lock" >&2 + return 0 + fi + + exec 9>"$lock_file" + if ! flock -n 9; then + echo "Error: another publish is already running, try again shortly" >&2 + exit 75 + fi +} + +read_payload() { + local payload + payload="$(cat)" + if [ -z "${payload//[[:space:]]/}" ]; then + echo "Error: empty payload on stdin" >&2 + exit 1 + fi + printf '%s' "$payload" +} diff --git a/bin/post-link b/bin/post-link index 12e5ce7d..dda1f255 100755 --- a/bin/post-link +++ b/bin/post-link @@ -1,9 +1,10 @@ #!/usr/bin/env bash # # Create and publish a link post from a JSON payload on stdin, then deploy. -# Designed to be invoked over SSH from a phone Shortcut on the Tailscale network. -# The Shortcut base64-encodes the JSON (so quotes in the title/body can't break -# shell quoting) and the SSH command decodes it back onto our stdin: +# Driven by the Pressa web app on mudge, and directly over SSH from a phone +# Shortcut on the Tailscale network. The Shortcut base64-encodes the JSON (so +# quotes in the title/body can't break shell quoting) and the SSH command +# decodes it back onto our stdin: # # echo | base64 --decode | $HOME/samhuri.net/bin/post-link # @@ -20,29 +21,10 @@ set -euo pipefail -# Non-interactive SSH sessions get a bare PATH and skip .profile, so rv is -# never reachable. Source .profile (which already puts rv on PATH via -# ~/.cargo/env) rather than guessing rv's install location, then load its -# Ruby env explicitly since that's only wired into interactive shells via -# .bashrc. -[ -f "$HOME/.profile" ] && . "$HOME/.profile" -eval "$(rv shell env bash)" +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/common.sh" -# Run from the repo root regardless of where the script lives. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO="${SAMHURI_REPO:-$(cd "$SCRIPT_DIR/.." && pwd)}" -cd "$REPO" - -# Local clones here name the GitHub remote "github"; a fresh clone on the server -# names it "origin". Prefer github, fall back to origin, unless overridden. -REMOTE="${SAMHURI_REMOTE:-$(git remote | grep -qx github && echo github || echo origin)}" -BRANCH="${SAMHURI_BRANCH:-main}" - -payload="$(cat)" -if [ -z "${payload//[[:space:]]/}" ]; then - echo "Error: empty payload on stdin" >&2 - exit 1 -fi +payload="$(read_payload)" +acquire_publish_lock echo "==> Pulling latest from $REMOTE/$BRANCH" >&2 git pull --ff-only "$REMOTE" "$BRANCH" >&2 @@ -65,5 +47,5 @@ echo "==> Building and publishing" >&2 SAMHURI_PUBLISH_HOST=local bundle exec bake publish >&2 echo "==> Published $post_path" >&2 -# Stdout carries just the path so the caller (Shortcut) can show/use it. +# Stdout carries just the path so the caller (Shortcut, web app) can show/use it. echo "$post_path" diff --git a/bin/preview-link b/bin/preview-link index de8b0ca3..c29c62ad 100755 --- a/bin/preview-link +++ b/bin/preview-link @@ -11,29 +11,17 @@ # "body": "Optional commentary.", "tags": "gear, tech"} # JSON # -# Prints the preview URL on stdout, progress on stderr. Used by the -# dashboard's "Publish Beta" button. +# Prints the preview URL on stdout, progress on stderr. The Pressa web app +# renders its own preview in-process instead; this is the CLI/SSH equivalent, +# and the only one that shows the post inside a full site build. set -euo pipefail -# Non-interactive SSH sessions get a bare PATH and skip .profile, so rv is -# never reachable. Source .profile (which already puts rv on PATH via -# ~/.cargo/env) rather than guessing rv's install location, then load its -# Ruby env explicitly since that's only wired into interactive shells via -# .bashrc. -[ -f "$HOME/.profile" ] && . "$HOME/.profile" -eval "$(rv shell env bash)" +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/common.sh" -# Run from the repo root regardless of where the script lives. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO="${SAMHURI_REPO:-$(cd "$SCRIPT_DIR/.." && pwd)}" -cd "$REPO" - -payload="$(cat)" -if [ -z "${payload//[[:space:]]/}" ]; then - echo "Error: empty payload on stdin" >&2 - exit 1 -fi +payload="$(read_payload)" +# Writes into posts/ and rebuilds www/, so it takes the same lock as publishing. +acquire_publish_lock echo "==> Building preview" >&2 # `bake mudge` logs its own build progress to stdout (not stderr), so only the diff --git a/bin/publish-draft b/bin/publish-draft new file mode 100755 index 00000000..2a0b4235 --- /dev/null +++ b/bin/publish-draft @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Publish a draft from public/drafts and deploy, the draft counterpart to +# bin/post-link. Takes the draft's filename (or slug) as its only argument: +# +# ssh mudge '$HOME/samhuri.net/bin/publish-draft tree-well-protocol' +# +# Pending edits to the draft are committed before the pull so the working tree +# is clean going in -- the web app writes drafts straight to the checkout, and +# `git pull --ff-only` is entitled to refuse otherwise. Then it moves the draft +# into posts/YYYY/MM via `bake publish_draft`, commits, pushes, and runs +# `bake publish` to build HTML + Gemini and rsync to the live directories. +# +# Prints the published post's path on stdout, progress on stderr. + +set -euo pipefail + +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/common.sh" + +draft="${1:-}" +if [ -z "$draft" ]; then + echo "Usage: bin/publish-draft " >&2 + exit 1 +fi +draft="$(basename "$draft" .md)" +draft_path="public/drafts/$draft.md" + +if [ ! -f "$draft_path" ]; then + echo "Error: no draft at $draft_path" >&2 + exit 1 +fi + +acquire_publish_lock + +if ! git diff --quiet -- public/drafts; then + echo "==> Committing pending draft edits" >&2 + git add public/drafts + git -c commit.gpgsign=false commit -m "Update drafts" >&2 +fi + +echo "==> Pulling latest from $REMOTE/$BRANCH" >&2 +git pull --ff-only "$REMOTE" "$BRANCH" >&2 + +echo "==> Publishing draft $draft" >&2 +# publish_draft reports "Published draft: -> "; we want the target. +post_path="$(bundle exec bake publish_draft "$draft_path" | tail -n 1 | awk '{print $NF}')" +echo " $post_path" >&2 + +echo "==> Committing and pushing" >&2 +git add "$post_path" "$draft_path" +# Skip commit signing: non-interactive SSH can't unlock a passphrased key. +git -c commit.gpgsign=false commit -m "Publish draft: $draft" >&2 +git push "$REMOTE" "HEAD:$BRANCH" >&2 + +echo "==> Building and publishing" >&2 +# Already on the publish host, so rsync locally rather than SSH back to self. +SAMHURI_PUBLISH_HOST=local bundle exec bake publish >&2 + +echo "==> Published $post_path" >&2 +echo "$post_path" From d59bc5d25a0b11a037164cb0759d0f357004e5a0 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 28 Aug 2026 14:18:43 -0700 Subject: [PATCH 06/15] Add the Pressa web app Posting a link, drafts, and preview, served on the tailnet from mudge. It drives bin/post-link and bin/publish-draft rather than reimplementing the flow, and renders previews through the build's own writers, so there's one copy of each behaviour rather than one per front end. Publishing runs as a job and the browser watches its log over SSE, copying the dashboard's streaming pattern. A second publish while one is running is refused with a link to the running job. Sinatra and puma share the existing Gemfile on purpose. bin/post-link runs `bundle exec bake`, and a second Gemfile for the web app would leave BUNDLE_GEMFILE pointing at the wrong one in the child -- the bug the dashboard worked around with Bundler.with_unbundled_env. Writes are guarded against cross-site requests. There are no cookies here, so what a malicious page would ride isn't a login, it's this machine's position on the tailnet: without this, any page open in a browser on the tailnet could publish a post or delete a draft. super_good-csrf_protection allows Sec-Fetch-Site of same-origin or none, falls back to Origin-vs-Host for older browsers, and allows requests carrying neither header, since those didn't come from a browser and can't be the confused deputy a CSRF needs -- which is what keeps curl and the phone Shortcut working. /link/metadata gets the same treatment despite being a GET, because it makes this server fetch a URL the caller chose and could otherwise be used to probe the tailnet blind. --- AGENTS.md | 13 + Gemfile | 10 + Gemfile.lock | 28 ++ bake.rb | 8 +- lib/pressa/web/app.rb | 352 ++++++++++++++++++++++++ lib/pressa/web/draft_store.rb | 83 ++++++ test/web/app_test.rb | 495 ++++++++++++++++++++++++++++++++++ test/web/draft_store_test.rb | 110 ++++++++ web/bin/start | 10 + web/config.ru | 6 + web/public/favicon.svg | 4 + web/public/style.css | 187 +++++++++++++ web/views/_preview.erb | 49 ++++ web/views/draft.erb | 29 ++ web/views/drafts.erb | 21 ++ web/views/job.erb | 41 +++ web/views/jobs.erb | 16 ++ web/views/layout.erb | 32 +++ web/views/link.erb | 98 +++++++ web/views/not_found.erb | 3 + 20 files changed, 1594 insertions(+), 1 deletion(-) create mode 100644 lib/pressa/web/app.rb create mode 100644 lib/pressa/web/draft_store.rb create mode 100644 test/web/app_test.rb create mode 100644 test/web/draft_store_test.rb create mode 100755 web/bin/start create mode 100644 web/config.ru create mode 100644 web/public/favicon.svg create mode 100644 web/public/style.css create mode 100644 web/views/_preview.erb create mode 100644 web/views/draft.erb create mode 100644 web/views/drafts.erb create mode 100644 web/views/job.erb create mode 100644 web/views/jobs.erb create mode 100644 web/views/layout.erb create mode 100644 web/views/link.erb create mode 100644 web/views/not_found.erb diff --git a/AGENTS.md b/AGENTS.md index 0f7a3bcf..08f7e489 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ This repository is a Ruby static-site generator (Pressa) that outputs both HTML and Gemini formats. - Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`) +- Web app: `lib/pressa/web/` (Sinatra app, jobs, drafts, preview) with templates and assets in `web/` +- Publish scripts: `bin/post-link`, `bin/publish-draft`, `bin/preview-link`, sharing `bin/lib/common.sh` - Build/publish/draft tasks: `bake.rb` (delegating to helpers under `lib/pressa/`) - Tests: `test/` - Site config: `site.toml`, `projects.toml` @@ -23,6 +25,7 @@ Keep new code under the existing `Pressa` module structure (for example `lib/pre - `bin/bootstrap`: install prerequisites and gems via `rv`. - `bundle exec bake debug`: build HTML for `http://localhost:8000` into `www/`. - `bundle exec bake serve`: serve `www/` via WEBrick on port 8000. +- `bundle exec bake web`: run the Pressa web app on `http://localhost:1112`. - `bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required). - `bundle exec bake mudge|beta|release`: build HTML with environment-specific base URLs. - `bundle exec bake gemini`: build Gemini capsule into `gemini/`. @@ -41,6 +44,15 @@ Keep new code under the existing `Pressa` module structure (for example `lib/pre - `bundle exec bake new_draft "Post Title"` creates `public/drafts/.md`. - `bundle exec bake drafts` lists available drafts. - `bundle exec bake publish_draft public/drafts/.md` moves draft to `posts/YYYY/MM/` and updates `Date` and `Timestamp`. +- `bin/publish-draft ` does the whole thing on the publish host: commit pending edits, pull, `bake publish_draft`, commit, push, `bake publish`. + +## Web App +`lib/pressa/web/` is a Sinatra app that owns posting a link, drafts, and preview. It runs on mudge as `pressa-web.service` on `127.0.0.1:1112`, published by Caddy at `http://mudge:7777` and restricted to Tailscale source IPs, and is served by `web/bin/start` (puma, threads only). + +- It drives `bin/post-link` and `bin/publish-draft` rather than reimplementing the publish flow, and renders previews through `Posts::PostWriter#post_html` and `Posts::GeminiWriter#post_content` — the same code the build uses. +- Anything that writes to the checkout runs as a job (`Web::JobRegistry`, `Web::Job`, `Web::JobRunner`); the browser watches the log over SSE at `/jobs/:id/stream`. One job at a time: a second publish is refused with the running job, never queued. +- Puma runs threads only, not workers — the single-writer guard is an in-process mutex. `bin/lib/common.sh` adds an `flock` so the SSH path can't race it. +- Sinatra and puma live in this repo's Gemfile on purpose. A separate `web/Gemfile` would leave `BUNDLE_GEMFILE` pointing at the wrong one inside `bin/post-link`'s `bundle exec bake`. ## Content and Metadata Requirements Posts must include YAML front matter. Required keys (enforced by `Pressa::Posts::PostMetadata`) are: @@ -76,6 +88,7 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`. ## Deployment & Security Notes - Publish tasks are defined in `bake.rb` via rsync over SSH. +- The web app is deployed from the `mudge.samhuri.net` repo (`config/systemd/pressa-web.service` and the Caddy vhost); the code lives here. - Current publish host is `mudge` with: - production HTML: `/var/www/samhuri.net/public` - beta HTML: `/var/www/beta.samhuri.net/public` diff --git a/Gemfile b/Gemfile index 0e828e9c..b77b1d15 100644 --- a/Gemfile +++ b/Gemfile @@ -9,8 +9,18 @@ gem "dry-struct", "~> 1.8" gem "builder", "~> 3.3" gem "bake", "~> 0.20" +# The Pressa web app under web/. It shares this bundle with bake on purpose: +# it shells out to bin/post-link, which runs `bundle exec bake`, and a separate +# Gemfile would leave BUNDLE_GEMFILE pointing at the wrong one in the child. +group :web do + gem "sinatra", "~> 4.1" + gem "puma", "~> 6.6" + gem "super_good-csrf_protection", "~> 0.2" +end + group :development, :test do gem "guard", "~> 2.18" gem "minitest", "~> 6.0" + gem "rack-test", "~> 2.2" gem "standard", "~> 1.52" end diff --git a/Gemfile.lock b/Gemfile.lock index effdd11f..dd55d249 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,7 @@ GEM bake (0.25.0) bigdecimal samovar (~> 2.1) + base64 (0.3.0) bigdecimal (4.1.2) builder (3.3.0) coderay (1.1.3) @@ -81,7 +82,9 @@ GEM minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) + mustermann (3.1.1) nenv (0.3.0) + nio4r (2.7.5) notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) @@ -97,7 +100,19 @@ GEM coderay (~> 1.1) method_source (~> 1.0) reline (>= 0.6.0) + puma (6.6.1) + nio4r (~> 2.0) racc (1.8.1) + rack (3.2.7) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) rainbow (3.1.1) rb-fsevent (0.11.2) rb-inotify (0.11.1) @@ -133,6 +148,13 @@ GEM samovar (2.5.1) console (~> 1.0) shellany (0.0.1) + sinatra (4.2.1) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.2.1) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) standard (1.55.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -146,7 +168,9 @@ GEM lint_roller (~> 1.1) rubocop-performance (~> 1.26.0) strscan (3.1.8) + super_good-csrf_protection (0.2.0) thor (1.5.0) + tilt (2.9.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -173,8 +197,12 @@ DEPENDENCIES kramdown-parser-gfm (~> 1.1) minitest (~> 6.0) phlex (~> 2.3) + puma (~> 6.6) + rack-test (~> 2.2) rouge (~> 5.0) + sinatra (~> 4.1) standard (~> 1.52) + super_good-csrf_protection (~> 0.2) RUBY VERSION ruby 4.0.5 diff --git a/bake.rb b/bake.rb index 1646f7b4..2202e623 100644 --- a/bake.rb +++ b/bake.rb @@ -33,7 +33,7 @@ GEMINI_PUBLISH_DIR = "/var/gemini/samhuri.net".freeze STATIC_PUBLISH_DIR = "/var/www/static.samhuri.net/public".freeze WATCHABLE_DIRECTORIES = %w[public posts lib].freeze -LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze +LINT_TARGETS = %w[bake.rb Gemfile lib test web/config.ru].freeze BUILD_TARGETS = %w[debug mudge beta release gemini].freeze # Generate the site in debug mode (localhost:8000) @@ -61,6 +61,12 @@ def gemini build("https://samhuri.net", output_format: "gemini", target_path: "gemini") end +# Run the Pressa web app locally. +# @parameter port [String] Port to bind on 127.0.0.1 (default: 1112). +def web(port: "1112") + exec("bundle", "exec", "puma", "-t", "0:16", "-b", "tcp://127.0.0.1:#{port}", "web/config.ru") +end + # Start local development server def serve require "webrick" diff --git a/lib/pressa/web/app.rb b/lib/pressa/web/app.rb new file mode 100644 index 00000000..1779fe2e --- /dev/null +++ b/lib/pressa/web/app.rb @@ -0,0 +1,352 @@ +require "json" +require "sinatra/base" +require "super_good/csrf_protection" +require "pressa" +require "pressa/link_post" +require "pressa/open_graph" +require "pressa/posts/repo" +require "pressa/posts/tag_index" +require "pressa/web/draft_store" +require "pressa/web/job_registry" +require "pressa/web/job_runner" +require "pressa/web/preview" + +module Pressa + module Web + # Pressa's web front end, hosted on mudge behind Caddy on the tailnet. + # + # It owns posting a link, drafts, and preview, and it does so by driving + # the same bin/ scripts the phone Shortcut drives over SSH rather than + # reimplementing the flow. Anything that writes to the checkout goes + # through a job so the request can return immediately and the browser can + # watch the log. + class App < Sinatra::Base + WEB_ROOT = File.expand_path("../../../web", __dir__) + REPO_ROOT = File.expand_path("../../..", __dir__) + TAG_CHIP_LIMIT = 24 + + # There are no cookies or sessions here, so what a malicious page would be + # riding isn't a login -- it's this machine's position on the tailnet. + # Sec-Fetch-* are forbidden header names, so page JavaScript can't forge + # them; a request without them didn't come from a browser and can't be the + # confused deputy a CSRF needs, which is what keeps curl and the phone + # Shortcut working. + use SuperGood::CSRFProtection + + # The middleware guards unsafe methods, which is the right default. It + # leaves GETs alone, but /link/metadata is a GET that makes this server + # fetch a URL the caller chose, so a cross-site page could use it to probe + # the tailnet blind. Rather than keep a second copy of the rule, ask the + # middleware how it would treat the request if it were a POST. + CROSS_ORIGIN_PROBE = SuperGood::CSRFProtection.new(->(_env) { [200, {}, []] }) + + set :root, WEB_ROOT + set :views, File.join(WEB_ROOT, "views") + set :public_folder, File.join(WEB_ROOT, "public") + set :bind, ENV.fetch("BIND_ADDRESS", "127.0.0.1") + set :port, ENV.fetch("PORT", "1112") + set :host_authorization, {permitted_hosts: ["pressa", "mudge", "localhost", "127.0.0.1"]} + + set :repo_root, REPO_ROOT + set :site_url, ENV.fetch("PRESSA_SITE_URL", "https://samhuri.net") + set :registry, JobRegistry.new + set :link_scraper, OpenGraph + set :html_site, nil + set :gemini_site, nil + set :author, nil + set :tag_cache, nil + set :keep_alive_seconds, 15 + + helpers do + def h(text) = Rack::Utils.escape_html(text.to_s) + + def registry = settings.registry + + def repo_path(*parts) = File.join(settings.repo_root, *parts) + + def drafts = DraftStore.new(dir: repo_path("public", "drafts")) + + def preview_renderer = Preview.new(html_site:, gemini_site:) + + def html_site + settings.html_site || settings.set(:html_site, build_site("html")) && settings.html_site + end + + def gemini_site + settings.gemini_site || settings.set(:gemini_site, build_site("gemini")) && settings.gemini_site + end + + def build_site(output_format) + Pressa.create_site( + source_path: settings.repo_root, url_override: settings.site_url, output_format: + ) + end + + def author + settings.author || settings.set(:author, html_site.author) && settings.author + end + + # Rendering every post to count tags takes a beat, and the link form + # is the page you want instant on a phone, so it's cached until a post + # is added or edited. + def tag_chips + files = Dir.glob(repo_path("posts", "**", "*.md")) + key = [files.length, files.map { File.mtime(it) }.max] + cached = settings.tag_cache + return cached[:tags] if cached && cached[:key] == key + + posts = Posts::PostRepo.new.read_posts(repo_path("posts")) + tags = Posts::TagIndex.from_posts_by_year(posts).counts.keys.first(TAG_CHIP_LIMIT) + settings.set(:tag_cache, {key:, tags:}) + tags + end + + def link_form + { + title: params[:title].to_s.strip, + link: params[:link].to_s.strip, + # Browsers normalize textarea line breaks to CRLF on submit, per + # the HTML spec, even though nothing here ever inserts one. + body: params[:body].to_s.gsub("\r\n", "\n").strip, + tags: normalize_tags(params[:tags]), + image: params[:image].to_s.strip + } + end + + def normalize_tags(value) + value.to_s.split(",").map { it.strip.downcase }.reject(&:empty?).join(", ") + end + + def link_post_source(form) + LinkPost.build( + title: form[:title], link: form[:link], body: form[:body], tags: form[:tags], + image: form[:image].empty? ? nil : form[:image], author: + ) + end + + def start_job(kind:, label:, command:, stdin_data: nil) + registry.start(kind:, label:) do |job| + JobRunner.run(command:, stdin_data:, chdir: settings.repo_root) { job.append(it) } + end + end + + def sse(payload) = "data: #{payload.to_json}\n\n" + + def cross_origin_request? + status, = CROSS_ORIGIN_PROBE.call(request.env.merge("REQUEST_METHOD" => "POST")) + status == 403 + end + + def json_error(status, message) = halt(status, {error: message}.to_json) + end + + # --- posting a link ---------------------------------------------------- + + get "/" do + @form = {} + @tags = tag_chips + erb :link + end + + post "/link" do + @form = link_form + @tags = tag_chips + + if @form[:title].empty? || @form[:link].empty? + @error = "A URL and a title are both required." + halt 422, erb(:link) + end + + payload = @form.reject { |_key, value| value.to_s.empty? }.to_json + begin + job = start_job( + kind: "publish_link", label: @form[:title], + command: [repo_path("bin", "post-link")], stdin_data: payload + ) + rescue JobRegistry::Busy => e + @busy = e.job + @error = "Something is already publishing. Watch it finish, then try again." + halt 409, erb(:link) + end + + redirect to("/jobs/#{job.id}"), 303 + end + + get "/link/metadata" do + halt 403, "Forbidden" if cross_origin_request? + + content_type :json + url = params[:url].to_s.strip + json_error(400, "missing url") if url.empty? + + found = settings.link_scraper.fetch(url) + return "{}" unless found + + {title: found.title, description: found.description, image: found.image}.compact.to_json + end + + # --- preview ----------------------------------------------------------- + + post "/preview" do + content_type :json + + begin + source, slug = preview_source + result = preview_renderer.render(source, slug:) + rescue Preview::Error, LinkPost::Error => e + json_error(422, e.message) + end + + {title: result.title, html: result.html, gemtext: result.gemtext}.to_json + end + + # --- jobs -------------------------------------------------------------- + + get "/jobs" do + @jobs = registry.recent + erb :jobs + end + + get "/jobs/:id" do + @job = registry.find(params[:id]) || halt(404, not_found_page("No job by that name.")) + erb :job + end + + get "/jobs/:id/stream" do + job = registry.find(params[:id]) || halt(404, "unknown job") + + content_type "text/event-stream" + headers "Cache-Control" => "no-cache", "X-Accel-Buffering" => "no" + + stream(:keep_open) do |out| + backlog, queue = job.subscribe + out.callback { job.unsubscribe(queue) } + + backlog.each { out << sse(type: "line", text: it) } + loop do + # A timed pop lets an idle job still get a periodic write, which is + # the only way to notice the browser hung up. + line = queue.pop(timeout: settings.keep_alive_seconds) + if line.nil? + break if job.finished? && queue.empty? + + out << ": keep-alive\n\n" + next + end + out << sse(type: "line", text: line) + end + + out << sse(job.to_h.merge(type: "state")) + out.close + rescue IOError, Errno::EPIPE + # browser went away + end + end + + # --- drafts ------------------------------------------------------------ + + get "/drafts" do + @drafts = drafts.list + erb :drafts + end + + post "/drafts" do + slug = + begin + drafts.create(params[:title].to_s) + rescue DraftStore::Conflict => e + @drafts = drafts.list + @error = e.message + halt 409, erb(:drafts) + rescue DraftStore::InvalidTitle + @drafts = drafts.list + @error = "That title doesn't make a usable filename." + halt 422, erb(:drafts) + end + + redirect to("/drafts/#{slug}"), 303 + end + + get "/drafts/:slug" do + @slug = params[:slug] + @source = find_draft(@slug) + erb :draft + end + + post "/drafts/:slug" do + find_draft(params[:slug]) + drafts.write(params[:slug], params[:source].to_s) + redirect to("/drafts/#{params[:slug]}"), 303 + end + + post "/drafts/:slug/publish" do + @slug = params[:slug] + @source = find_draft(@slug) + + begin + job = start_job( + kind: "publish_draft", label: @slug, + command: [repo_path("bin", "publish-draft"), @slug] + ) + rescue JobRegistry::Busy => e + @busy = e.job + @error = "Something is already publishing. Watch it finish, then try again." + halt 409, erb(:draft) + end + + redirect to("/jobs/#{job.id}"), 303 + end + + post "/drafts/:slug/delete" do + find_draft(params[:slug]) + drafts.delete(params[:slug]) + redirect to("/drafts"), 303 + end + + # --- odds and ends ----------------------------------------------------- + + get "/tags" do + content_type :json + tag_chips.to_json + end + + get "/up" do + "ok" + end + + not_found do + next if response.body.any? + + erb :not_found + end + + helpers do + def find_draft(slug) + drafts.read(slug) + rescue DraftStore::Error + halt 404, not_found_page("No draft named #{h(slug)}.") + end + + def not_found_page(message) + @message = message + erb :not_found + end + + def preview_source + raw = params[:source].to_s + unless raw.strip.empty? + return [raw.gsub("\r\n", "\n"), preview_slug] + end + + post = link_post_source(link_form) + [post.content, File.basename(post.filename, ".md")] + end + + def preview_slug + slug = params[:slug].to_s + slug.match?(DraftStore::SLUG_PATTERN) ? slug : "preview" + end + end + end + end +end diff --git a/lib/pressa/web/draft_store.rb b/lib/pressa/web/draft_store.rb new file mode 100644 index 00000000..a42e86b8 --- /dev/null +++ b/lib/pressa/web/draft_store.rb @@ -0,0 +1,83 @@ +require "fileutils" +require "pressa/drafts" +require "pressa/drafts/repo" + +module Pressa + module Web + # The drafts directory as the web app sees it: list, read, write, create, + # delete. Slugs arrive from URLs, so every path goes through the same + # validation before it reaches the filesystem. + class DraftStore + class Error < StandardError; end + + class NotFound < Error; end + + class Conflict < Error; end + + class InvalidSlug < Error; end + + class InvalidTitle < Error; end + + # Exactly what Drafts.slugify produces, so nothing that could climb out + # of the drafts directory can name a file. + SLUG_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ + + def initialize(dir: Drafts::DEFAULT_DIR) + @dir = dir + end + + def list + return [] unless Dir.exist?(@dir) + + Drafts::Repo.new(dir: @dir).read_entries + end + + def read(slug) + File.read(path(slug)) + rescue Errno::ENOENT + raise NotFound, "no draft named #{slug}" + end + + def write(slug, content) + target = path(slug) + raise NotFound, "no draft named #{slug}" unless File.exist?(target) + + # Browsers normalize textarea line breaks to CRLF on submit, per the + # HTML spec, even though nothing here ever inserts one. + File.write(target, content.gsub("\r\n", "\n")) + end + + def create(title, now: Time.now) + title = title.to_s.strip + slug = Drafts.slugify(title) + raise InvalidTitle, "title cannot be converted to a filename: #{title.inspect}" unless slug.match?(SLUG_PATTERN) + + target = File.join(@dir, "#{slug}.md") + raise Conflict, "a draft already exists at #{target}" if File.exist?(target) + + FileUtils.mkdir_p(@dir) + File.write(target, drafts.render_template(title, now:)) + slug + end + + def delete(slug) + target = path(slug) + raise NotFound, "no draft named #{slug}" unless File.exist?(target) + + FileUtils.rm_f(target) + end + + def path(slug) + raise InvalidSlug, "invalid draft name: #{slug.inspect}" unless slug.to_s.match?(SLUG_PATTERN) + + File.join(@dir, "#{slug}.md") + end + + private + + def drafts + @drafts ||= Drafts.new(dir: @dir) + end + end + end +end diff --git a/test/web/app_test.rb b/test/web/app_test.rb new file mode 100644 index 00000000..114eb708 --- /dev/null +++ b/test/web/app_test.rb @@ -0,0 +1,495 @@ +require "test_helper" +require "fileutils" +require "json" +require "rack/test" +require "tmpdir" +require "pressa/web/app" + +class Pressa::Web::AppTest < Minitest::Test + include Rack::Test::Methods + + POST_SOURCE = <<~MARKDOWN + --- + Title: Tree Well Protocol + Author: Jane Doe + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: snowboarding, safety + --- + + Never ride alone in deep snow. + MARKDOWN + + DRAFT_SOURCE = <<~MARKDOWN + --- + Author: Fat Mike + Title: Lift Line Notes + Date: unpublished + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: + --- + + Chairlift conversations, collected. + MARKDOWN + + def setup + @root = Dir.mktmpdir + FileUtils.mkdir_p(File.join(@root, "posts/2026/06")) + FileUtils.mkdir_p(File.join(@root, "public/drafts")) + File.write(File.join(@root, "posts/2026/06/tree-well-protocol.md"), POST_SOURCE) + File.write(File.join(@root, "public/drafts/lift-line-notes.md"), DRAFT_SOURCE) + + @metadata = nil + @executor = ->(&block) { block.call } + write_bin("post-link", "cat > /dev/null; echo '==> Building' >&2; echo posts/2026/06/new-post.md") + write_bin("publish-draft", "echo '==> Publishing' >&2; echo \"posts/2026/06/$1.md\"") + end + + # The app runs the repo's real scripts, so tests stand in fake ones rather + # than injecting a command runner. + def write_bin(name, script) + path = File.join(@root, "bin", name) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "#!/bin/sh\n#{script}\n") + FileUtils.chmod(0o755, path) + end + + # Stands in for Pressa::OpenGraph. + def scraper = self + + def fetch(_url) = @metadata + + def teardown + FileUtils.remove_entry(@root) + end + + def build_site(output_format) + options = (output_format == "gemini") ? Pressa::GeminiOutputOptions.new : Pressa::HTMLOutputOptions.new + Pressa::Site.new( + author: "Sami Samhuri", email: "sami@samhuri.net", title: "samhuri.net", + description: "blog", url: "https://samhuri.net", output_format:, output_options: options + ) + end + + def registry + @registry ||= Pressa::Web::JobRegistry.new(executor: ->(&block) { @executor.call(&block) }) + end + + def app + @app ||= Class.new(Pressa::Web::App) do + set :environment, :test + set :show_exceptions, false + set :raise_errors, true + set :host_authorization, {permitted_hosts: []} + end.tap do |klass| + klass.set(:repo_root, @root) + klass.set(:registry, registry) + klass.set(:html_site, build_site("html")) + klass.set(:gemini_site, build_site("gemini")) + klass.set(:author, "Sami Samhuri") + klass.set(:link_scraper, scraper) + end + end + + def json_body = JSON.parse(last_response.body) + + # --- posting a link ------------------------------------------------------ + + def test_the_home_page_is_the_link_form + get "/" + + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, %(name="link")) + assert_includes(last_response.body, %(name="title")) + end + + def test_the_home_page_offers_tags_already_in_use_as_chips + get "/" + + assert_includes(last_response.body, "snowboarding") + assert_includes(last_response.body, "safety") + end + + def test_posting_a_link_starts_a_job_and_redirects_to_it + post "/link", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol", + body: "Never ride alone.", tags: "Snowboarding, Safety" + + assert_equal(303, last_response.status) + job = registry.recent.first + assert_match(%r{/jobs/#{job.id}\z}, last_response.headers["Location"]) + assert_equal("posts/2026/06/new-post.md", job.result) + assert_equal("publish_link", job.kind) + end + + def test_posting_a_link_sends_the_form_to_the_script_as_json_on_stdin + write_bin("post-link", "cat; echo; echo posts/x.md") + post "/link", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol", + body: "Never ride alone.\r\nSecond line.", tags: "Snowboarding, safety , " + + payload = JSON.parse(registry.recent.first.lines.first) + + assert_equal("Tree Well Protocol", payload["title"]) + assert_equal("https://powder.example.net/tree-wells", payload["link"]) + assert_equal("Never ride alone.\nSecond line.", payload["body"]) + assert_equal("snowboarding, safety", payload["tags"]) + end + + def test_a_failing_publish_leaves_a_failed_job_rather_than_a_500 + write_bin("post-link", "echo 'fatal: not a git repository' >&2; exit 128") + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" + + assert_equal(303, last_response.status) + assert_equal(:failed, registry.recent.first.state) + assert_equal("fatal: not a git repository", registry.recent.first.error) + end + + def test_a_second_publish_while_one_is_running_is_refused_not_queued + held = nil + @executor = ->(&block) { held = block } + post "/link", link: "https://powder.example.net/one", title: "First Post" + post "/link", link: "https://powder.example.net/two", title: "Second Post" + + assert_equal(409, last_response.status) + assert_includes(last_response.body, "already") + assert_equal(1, registry.recent.length) + refute_nil(held) + end + + def test_a_link_without_a_url_or_title_is_rejected_and_the_form_comes_back_filled_in + post "/link", link: "", title: "", body: "Never ride alone." + + assert_equal(422, last_response.status) + assert_includes(last_response.body, "Never ride alone.") + assert_empty(registry.recent) + end + + # --- link metadata ------------------------------------------------------- + + def test_link_metadata_returns_what_the_scraper_found + @metadata = Pressa::OpenGraph::Result.new( + title: "Tree Wells", description: "A field guide.", image: "https://powder.example.net/cover.png" + ) + get "/link/metadata", url: "https://powder.example.net/tree-wells" + + assert_predicate(last_response, :ok?) + assert_equal("Tree Wells", json_body["title"]) + assert_equal("A field guide.", json_body["description"]) + assert_equal("https://powder.example.net/cover.png", json_body["image"]) + end + + def test_link_metadata_is_an_empty_object_when_the_page_offers_nothing + get "/link/metadata", url: "https://powder.example.net/bare" + + assert_predicate(last_response, :ok?) + assert_empty(json_body) + end + + def test_link_metadata_needs_a_url + get "/link/metadata" + + assert_equal(400, last_response.status) + end + + # --- preview ------------------------------------------------------------- + + def test_preview_renders_link_form_fields_as_html_and_gemtext + post "/preview", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol", + body: "Never ride alone.", tags: "snowboarding" + + assert_predicate(last_response, :ok?) + assert_includes(json_body["html"], "Never ride alone.") + assert_includes(json_body["gemtext"], "# Tree Well Protocol") + assert_includes(json_body["gemtext"], "=> https://powder.example.net/tree-wells") + end + + def test_preview_renders_raw_draft_source + post "/preview", source: DRAFT_SOURCE + + assert_predicate(last_response, :ok?) + assert_includes(json_body["gemtext"], "# Lift Line Notes") + assert_includes(json_body["html"], "Chairlift conversations, collected.") + end + + def test_preview_reports_bad_source_rather_than_blowing_up + post "/preview", source: "no front matter here\n" + + assert_equal(422, last_response.status) + assert(json_body["error"]) + end + + # --- jobs ---------------------------------------------------------------- + + def test_a_job_page_shows_its_state_and_log + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" + job = registry.recent.first + get "/jobs/#{job.id}" + + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, "==> Building") + assert_includes(last_response.body, "posts/2026/06/new-post.md") + end + + def test_an_unknown_job_is_a_404 + get "/jobs/nope" + + assert_equal(404, last_response.status) + end + + def test_the_job_stream_replays_the_log_and_ends_with_the_final_state + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" + job = registry.recent.first + get "/jobs/#{job.id}/stream" + + assert_match(%r{\Atext/event-stream}, last_response.headers["Content-Type"]) + events = last_response.body.scan(/^data: (.+)$/).flatten.map { JSON.parse(it) } + + assert_includes(events.map { it["text"] }, "==> Building") + assert_equal("succeeded", events.last["state"]) + end + + def test_the_job_stream_delivers_lines_while_the_job_is_still_running + @executor = ->(&block) { Thread.new(&block) } + app.set(:keep_alive_seconds, 0.05) + write_bin("post-link", "echo '==> Pulling' >&2; sleep 0.5; echo '==> Building' >&2; echo posts/x.md") + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" + job = registry.recent.first + + get "/jobs/#{job.id}/stream" + events = last_response.body.scan(/^data: (.+)$/).flatten.map { JSON.parse(it) } + + assert_includes(events.map { it["text"] }, "==> Building") + assert_includes(last_response.body, ": keep-alive") + assert_equal("succeeded", events.last["state"]) + end + + def test_streaming_an_unknown_job_is_a_404 + get "/jobs/nope/stream" + + assert_equal(404, last_response.status) + end + + def test_the_jobs_page_lists_recent_jobs + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" + get "/jobs" + + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, "Tree Well Protocol") + end + + # --- drafts -------------------------------------------------------------- + + def test_drafts_are_listed_newest_first + get "/drafts" + + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, "Lift Line Notes") + end + + def test_creating_a_draft_redirects_to_its_editor + post "/drafts", title: "Tree Well Protocol" + + assert_equal(303, last_response.status) + assert_match(%r{/drafts/tree-well-protocol\z}, last_response.headers["Location"]) + assert(File.exist?(File.join(@root, "public/drafts/tree-well-protocol.md"))) + end + + def test_creating_a_draft_that_already_exists_is_refused + post "/drafts", title: "Lift Line Notes" + + assert_equal(409, last_response.status) + end + + def test_creating_a_draft_with_an_unusable_title_is_refused + post "/drafts", title: "!!!" + + assert_equal(422, last_response.status) + end + + def test_the_editor_shows_the_draft_source + get "/drafts/lift-line-notes" + + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, "Chairlift conversations, collected.") + end + + def test_saving_a_draft_writes_it_back + post "/drafts/lift-line-notes", source: "---\nTitle: Lift Line Notes\n---\n\nRewritten.\n" + + assert_equal(303, last_response.status) + assert_includes(File.read(File.join(@root, "public/drafts/lift-line-notes.md")), "Rewritten.") + end + + def test_publishing_a_draft_starts_a_job + post "/drafts/lift-line-notes/publish" + + assert_equal(303, last_response.status) + job = registry.recent.first + + assert_equal("publish_draft", job.kind) + assert_equal("posts/2026/06/lift-line-notes.md", job.result) + end + + def test_publishing_a_draft_while_something_is_running_is_refused + held = nil + @executor = ->(&block) { held = block } + post "/link", link: "https://powder.example.net/one", title: "First Post" + post "/drafts/lift-line-notes/publish" + + assert_equal(409, last_response.status) + assert_includes(last_response.body, "already") + assert_equal(1, registry.recent.length) + refute_nil(held) + end + + def test_deleting_a_draft_removes_it + post "/drafts/lift-line-notes/delete" + + assert_equal(303, last_response.status) + refute(File.exist?(File.join(@root, "public/drafts/lift-line-notes.md"))) + end + + def test_an_unknown_draft_is_a_404 + get "/drafts/nope" + + assert_equal(404, last_response.status) + end + + def test_a_draft_slug_that_could_escape_the_drafts_directory_is_a_404 + get "/drafts/..%2F..%2Fsite" + + assert_equal(404, last_response.status) + end + + # --- odds and ends ------------------------------------------------------- + + def test_tags_are_available_as_json + get "/tags" + + assert_predicate(last_response, :ok?) + assert_includes(json_body, "snowboarding") + end + + def test_the_site_config_comes_from_the_repo_when_it_is_not_supplied + %w[site.toml projects.toml].each do |config| + FileUtils.cp(File.expand_path("../../#{config}", __dir__), File.join(@root, config)) + end + app.set(:html_site, nil) + app.set(:gemini_site, nil) + app.set(:author, nil) + + post "/preview", source: DRAFT_SOURCE + + assert_predicate(last_response, :ok?) + assert_includes(json_body["gemtext"], "# Lift Line Notes") + end + + def test_an_unknown_page_is_a_404 + get "/nowhere" + + assert_equal(404, last_response.status) + assert_includes(last_response.body, "Not here") + end + + def test_there_is_a_health_check + get "/up" + + assert_predicate(last_response, :ok?) + end + + # --- cross-origin protection --------------------------------------------- + # + # There are no cookies or sessions here, so the ambient credential a malicious + # page would be riding is this machine's position on the tailnet. Sec-Fetch-* + # are forbidden header names, so page JavaScript can't forge them. + + def sec_fetch(site) = {"HTTP_SEC_FETCH_SITE" => site} + + def test_a_cross_site_publish_is_refused + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("cross-site") + + assert_equal(403, last_response.status) + assert_empty(registry.recent) + end + + def test_a_same_site_publish_is_refused_too + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("same-site") + + assert_equal(403, last_response.status) + assert_empty(registry.recent) + end + + def test_a_same_origin_publish_goes_through + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("same-origin") + + assert_equal(303, last_response.status) + end + + def test_a_user_initiated_publish_goes_through + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("none") + + assert_equal(303, last_response.status) + end + + def test_a_request_with_no_browser_headers_goes_through + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" + + assert_equal(303, last_response.status) + end + + def test_an_older_browser_falls_back_to_the_origin_header + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, + {"HTTP_ORIGIN" => "https://evil.example.net"} + + assert_equal(403, last_response.status) + assert_empty(registry.recent) + end + + def test_a_matching_origin_header_goes_through + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, + {"HTTP_ORIGIN" => "http://example.org"} + + assert_equal(303, last_response.status) + end + + def test_cross_site_draft_deletion_is_refused + post "/drafts/lift-line-notes/delete", {}, sec_fetch("cross-site") + + assert_equal(403, last_response.status) + assert(File.exist?(File.join(@root, "public/drafts/lift-line-notes.md"))) + end + + def test_cross_site_draft_publishing_is_refused + post "/drafts/lift-line-notes/publish", {}, sec_fetch("cross-site") + + assert_equal(403, last_response.status) + assert_empty(registry.recent) + end + + def test_reading_pages_cross_site_is_still_allowed + get "/", {}, sec_fetch("cross-site") + + assert_predicate(last_response, :ok?) + end + + # The one GET that gets the same treatment: it makes this server fetch a URL + # the caller chose, so cross-site callers could use it to probe the tailnet. + def test_cross_site_link_metadata_is_refused + get "/link/metadata", {url: "http://100.64.0.1:9091/"}, sec_fetch("cross-site") + + assert_equal(403, last_response.status) + end + + def test_same_origin_link_metadata_is_allowed + @metadata = Pressa::OpenGraph::Result.new(title: "Tree Wells", description: nil, image: nil) + get "/link/metadata", {url: "https://powder.example.net/tree-wells"}, sec_fetch("same-origin") + + assert_predicate(last_response, :ok?) + assert_equal("Tree Wells", json_body["title"]) + end + + def test_link_metadata_still_works_without_browser_headers + get "/link/metadata", url: "https://powder.example.net/bare" + + assert_predicate(last_response, :ok?) + end +end diff --git a/test/web/draft_store_test.rb b/test/web/draft_store_test.rb new file mode 100644 index 00000000..94bc25d3 --- /dev/null +++ b/test/web/draft_store_test.rb @@ -0,0 +1,110 @@ +require "test_helper" +require "fileutils" +require "tmpdir" +require "pressa/web/draft_store" + +class Pressa::Web::DraftStoreTest < Minitest::Test + def setup + @tmpdir = Dir.mktmpdir + @dir = File.join(@tmpdir, "drafts") + Dir.mkdir(@dir) + end + + def teardown + FileUtils.remove_entry(@tmpdir) + end + + def store = @store ||= Pressa::Web::DraftStore.new(dir: @dir) + + def write_draft(slug, title:, timestamp: "2026-06-07T14:30:00-07:00", body: "TKTK") + File.write(File.join(@dir, "#{slug}.md"), <<~MARKDOWN) + --- + Author: Sami Samhuri + Title: #{title} + Date: unpublished + Timestamp: #{timestamp} + Tags: + --- + + #{body} + MARKDOWN + end + + def test_list_is_empty_when_there_are_no_drafts + assert_empty(store.list) + end + + def test_list_returns_drafts_newest_first + write_draft("lift-line-notes", title: "Lift Line Notes", timestamp: "2026-06-01T09:00:00-07:00") + write_draft("tree-wells", title: "Tree Wells", timestamp: "2026-06-07T09:00:00-07:00") + + assert_equal(["Tree Wells", "Lift Line Notes"], store.list.map(&:title)) + assert_equal(["tree-wells", "lift-line-notes"], store.list.map(&:slug)) + end + + def test_read_returns_the_raw_markdown + write_draft("tree-wells", title: "Tree Wells", body: "Never ride alone.") + + assert_includes(store.read("tree-wells"), "Never ride alone.") + assert_includes(store.read("tree-wells"), "Title: Tree Wells") + end + + def test_read_raises_for_an_unknown_draft + assert_raises(Pressa::Web::DraftStore::NotFound) { store.read("nope") } + end + + def test_write_replaces_the_contents_of_an_existing_draft + write_draft("tree-wells", title: "Tree Wells") + store.write("tree-wells", "---\nTitle: Tree Wells\n---\n\nRewritten.\n") + + assert_includes(store.read("tree-wells"), "Rewritten.") + end + + def test_write_raises_for_an_unknown_draft + assert_raises(Pressa::Web::DraftStore::NotFound) { store.write("nope", "content") } + end + + def test_write_normalizes_the_crlf_browsers_send_from_a_textarea + write_draft("tree-wells", title: "Tree Wells") + store.write("tree-wells", "line one\r\nline two\r\n") + + assert_equal("line one\nline two\n", store.read("tree-wells")) + end + + def test_create_writes_a_draft_from_the_title_and_returns_its_slug + slug = store.create("Tree Well Protocol") + + assert_equal("tree-well-protocol", slug) + assert_includes(store.read(slug), "Title: Tree Well Protocol") + assert_includes(store.read(slug), "Date: unpublished") + end + + def test_create_refuses_to_clobber_an_existing_draft + store.create("Tree Well Protocol") + + assert_raises(Pressa::Web::DraftStore::Conflict) { store.create("Tree Well Protocol") } + end + + def test_create_rejects_a_title_with_no_usable_slug + assert_raises(Pressa::Web::DraftStore::InvalidTitle) { store.create("!!!") } + assert_raises(Pressa::Web::DraftStore::InvalidTitle) { store.create(" ") } + end + + def test_delete_removes_the_draft + store.create("Tree Well Protocol") + store.delete("tree-well-protocol") + + assert_empty(store.list) + end + + def test_slugs_that_could_escape_the_drafts_directory_are_refused + outside = File.expand_path("../secrets.md", @dir) + File.write(outside, "not yours") + + ["../secrets", "..%2Fsecrets", "sub/dir", "/etc/passwd", "tree wells", ""].each do |slug| + assert_raises(Pressa::Web::DraftStore::InvalidSlug, "expected #{slug.inspect} to be refused") do + store.read(slug) + end + end + end +end diff --git a/web/bin/start b/web/bin/start new file mode 100755 index 00000000..c4653cc4 --- /dev/null +++ b/web/bin/start @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +# Threads, not workers: the single-publish-at-a-time guard is an in-process +# mutex, so a second puma worker would happily start a second publish against +# the same git checkout. bin/post-link takes an flock as well, which is what +# keeps the phone Shortcut's SSH path from racing this one. +cd "$(dirname "${BASH_SOURCE[0]}")/.." +exec "${RV:-/home/sjs/.cargo/bin/rv}" run bundle exec puma \ + -t 0:16 -b "tcp://${BIND_ADDRESS:-127.0.0.1}:${PORT:-1112}" config.ru diff --git a/web/config.ru b/web/config.ru new file mode 100644 index 00000000..dfcd2431 --- /dev/null +++ b/web/config.ru @@ -0,0 +1,6 @@ +lib_path = File.expand_path("../lib", __dir__) +$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path) + +require "pressa/web/app" + +run Pressa::Web::App diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 00000000..14ab6c98 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/public/style.css b/web/public/style.css new file mode 100644 index 00000000..a1775ea4 --- /dev/null +++ b/web/public/style.css @@ -0,0 +1,187 @@ +:root { + --bg: #fbfaf8; + --panel: #ffffff; + --ink: #1d1b18; + --quiet: #6b6559; + --line: #ded8cc; + --accent: #8a3f2a; + --ok: #2f6b3f; + --bad: #a32c1e; + --running: #9a6b12; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #171614; + --panel: #201e1b; + --ink: #ece7dd; + --quiet: #9c948a; + --line: #35322d; + --accent: #e2825f; + --ok: #7cc48c; + --bad: #e8806f; + --running: #d8ab52; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0 0 3rem; + background: var(--bg); + color: var(--ink); + font: 16px/1.5 ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif; + -webkit-text-size-adjust: 100%; +} + +.bar { + display: flex; + align-items: baseline; + gap: 1rem; + padding: 0.75rem 1rem; + padding-top: max(0.75rem, env(safe-area-inset-top)); + border-bottom: 1px solid var(--line); + background: var(--panel); + position: sticky; + top: 0; + z-index: 2; +} + +.brand { + font-weight: 700; + letter-spacing: 0.02em; + color: var(--accent); + text-decoration: none; +} + +.bar nav { display: flex; gap: 1rem; margin-left: auto; } +.bar nav a { color: var(--quiet); text-decoration: none; } +.bar nav a.on { color: var(--ink); font-weight: 600; } + +main { max-width: 46rem; margin: 0 auto; padding: 1rem; } + +h1 { font-size: 1.35rem; margin: 1rem 0; } +h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--quiet); margin: 0 0 0.4rem; } + +.quiet { color: var(--quiet); } + +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 1rem; + margin-bottom: 1rem; +} + +.card.bad { border-color: var(--bad); } +.card.bad p { margin: 0 0 0.5rem; } +.card.bad p:last-child { margin-bottom: 0; } + +.stack { display: flex; flex-direction: column; gap: 0.85rem; } +.row { display: flex; gap: 0.5rem; } +.row input { flex: 1; } + +label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; color: var(--quiet); } + +input[type="text"], input[type="url"], textarea { + width: 100%; + padding: 0.65rem 0.7rem; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + color: var(--ink); + /* 16px keeps iOS Safari from zooming the whole page on focus. */ + font-size: 16px; + font-family: inherit; +} + +textarea { resize: vertical; } +#draft-source { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; } + +input:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; } + +button { + font: inherit; + font-weight: 600; + padding: 0.7rem 1.1rem; + min-height: 2.75rem; + border: 1px solid var(--accent); + border-radius: 8px; + background: var(--accent); + color: var(--panel); + cursor: pointer; +} + +button.secondary { background: transparent; color: var(--accent); } +button.danger { background: transparent; color: var(--bad); border-color: var(--bad); } +button:disabled { opacity: 0.6; cursor: default; } + +.actions { display: flex; gap: 0.6rem; } +.actions.spread { justify-content: space-between; } + +.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; } + +.chip { + font-size: 0.8rem; + font-weight: 500; + padding: 0.35rem 0.6rem; + min-height: 0; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--quiet); +} + +.rows { list-style: none; margin: 0; padding: 0; } +.rows li { border-bottom: 1px solid var(--line); } +.rows li:last-child { border-bottom: 0; } +.rows a { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.6rem; + padding: 0.8rem 0.2rem; + color: inherit; + text-decoration: none; +} + +.pill { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.2rem 0.5rem; + border-radius: 999px; + border: 1px solid currentColor; +} + +.pill.running { color: var(--running); } +.pill.succeeded { color: var(--ok); } +.pill.failed { color: var(--bad); } + +pre { + margin: 0; + padding: 0.75rem; + max-height: 60vh; + overflow: auto; + border-radius: 8px; + background: var(--bg); + border: 1px solid var(--line); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.preview-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; } +.panes { display: grid; gap: 1rem; grid-template-columns: 1fr; } +.panes iframe { width: 100%; height: 24rem; border: 1px solid var(--line); border-radius: 8px; background: #fff; } + +/* Side by side is the point, but only once there's room for two columns. */ +@media (min-width: 52rem) { + .panes { grid-template-columns: 1fr 1fr; } +} + +p.bad { color: var(--bad); } diff --git a/web/views/_preview.erb b/web/views/_preview.erb new file mode 100644 index 00000000..946b066a --- /dev/null +++ b/web/views/_preview.erb @@ -0,0 +1,49 @@ + + + diff --git a/web/views/draft.erb b/web/views/draft.erb new file mode 100644 index 00000000..9288a7e6 --- /dev/null +++ b/web/views/draft.erb @@ -0,0 +1,29 @@ +

<%= h(@slug) %>

+ +
+ + +
+ + +
+
+ +<%= erb :_preview, layout: false %> + +
+
+ +
+
+ +
+
+ + diff --git a/web/views/drafts.erb b/web/views/drafts.erb new file mode 100644 index 00000000..31a115d7 --- /dev/null +++ b/web/views/drafts.erb @@ -0,0 +1,21 @@ +

Drafts

+ +
+ + +
+ +<% if @drafts.empty? %> +

No drafts yet.

+<% else %> + +<% end %> diff --git a/web/views/job.erb b/web/views/job.erb new file mode 100644 index 00000000..1bccec34 --- /dev/null +++ b/web/views/job.erb @@ -0,0 +1,41 @@ +

<%= h(@job.label || @job.kind) %>

+ +
+

+ <%= h(@job.state) %> + + <% if @job.result %><%= h(@job.result) %><% end %> + <% if @job.error %><%= h(@job.error) %><% end %> + +

+
<% @job.lines.each do |line| %><%= h(line) %>
+<% end %>
+
+ +

Post another link · Drafts

+ + diff --git a/web/views/jobs.erb b/web/views/jobs.erb new file mode 100644 index 00000000..bfc22ece --- /dev/null +++ b/web/views/jobs.erb @@ -0,0 +1,16 @@ +

Jobs

+<% if @jobs.empty? %> +

Nothing has run yet.

+<% else %> + +<% end %> diff --git a/web/views/layout.erb b/web/views/layout.erb new file mode 100644 index 00000000..3b127408 --- /dev/null +++ b/web/views/layout.erb @@ -0,0 +1,32 @@ + + + + + + <%= @page_title ? "#{@page_title} — Pressa" : "Pressa" %> + + "> + + +
+ Pressa + +
+ +
+ <% if @error %> +
+

<%= h(@error) %>

+ <% if @busy %> +

Watch <%= h(@busy.label || @busy.kind) %> →

+ <% end %> +
+ <% end %> + <%= yield %> +
+ + diff --git a/web/views/link.erb b/web/views/link.erb new file mode 100644 index 00000000..75d9a6f0 --- /dev/null +++ b/web/views/link.erb @@ -0,0 +1,98 @@ +

Post a link

+ + + +<%= erb :_preview, layout: false %> + + diff --git a/web/views/not_found.erb b/web/views/not_found.erb new file mode 100644 index 00000000..7c0a0502 --- /dev/null +++ b/web/views/not_found.erb @@ -0,0 +1,3 @@ +

Not here

+

<%= h(@message || "That page doesn't exist.") %>

+

Back to the link form

From ff13abac24fdb1281ce0fc6df030edf68c7a6c90 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 29 Aug 2026 10:38:25 -0700 Subject: [PATCH 07/15] Extract draft publishing out of bake and make its output machine-readable bin/publish-draft was recovering the published path by piping bake's prose through `tail -n 1 | awk '{print $NF}'`. The task now follows the same convention as new_link and preview_link -- stdout is the answer, stderr is the progress -- so the script just captures stdout. Moving the mechanics into Drafts::Publisher put them under test for the first time, which turned up two bugs. The date rewrite ignored whether the sub! matched, so a draft missing its Date or Timestamp line published a post that only failed later, at build time, after bin/publish-draft had already committed and pushed; it now validates the result with the same PostMetadata the build uses and refuses before anything is moved. And unlike new_link there was no check for an existing post, so a slug clash silently overwrote one. --- bake.rb | 38 ++++------ bin/publish-draft | 5 +- lib/pressa/drafts/publisher.rb | 67 ++++++++++++++++++ test/drafts/publisher_test.rb | 124 +++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 27 deletions(-) create mode 100644 lib/pressa/drafts/publisher.rb create mode 100644 test/drafts/publisher_test.rb diff --git a/bake.rb b/bake.rb index 2202e623..db347a4a 100644 --- a/bake.rb +++ b/bake.rb @@ -10,6 +10,7 @@ $LOAD_PATH.unshift(LIB_PATH) unless $LOAD_PATH.include?(LIB_PATH) require "pressa/drafts" +require "pressa/drafts/publisher" require "pressa/link_post" require "pressa/open_graph" require "pressa/config/simple_toml" @@ -186,44 +187,33 @@ def new_draft(title) puts content end -# Publish a draft by moving it to posts/YYYY/MM and updating dates. +# Publish a draft by moving it to posts/YYYY/MM and updating dates. Prints the +# published post's path on stdout and progress on stderr, so bin/publish-draft +# can capture the path without parsing prose. # @parameter input_path [String] Draft path or filename in public/drafts. def publish_draft(input_path) - drafts = Pressa::Drafts.new(dir: DRAFTS_DIR) if input_path.strip.empty? - puts "Usage: bake publish_draft " - puts - puts "Available drafts:" + warn "Usage: bake publish_draft " + warn "" + warn "Available drafts:" available = Dir.glob("#{DRAFTS_DIR}/*.md").map { |path| File.basename(path) } if available.empty? - puts " (no drafts found)" + warn " (no drafts found)" else - available.each { |draft| puts " #{draft}" } + available.each { |draft| warn " #{draft}" } end abort end - draft_path_value, draft_file = + result = begin - drafts.resolve_input(input_path) - rescue Pressa::Drafts::Error => e + Pressa::Drafts::Publisher.new(drafts_dir: DRAFTS_DIR).publish(input_path) + rescue Pressa::Drafts::Publisher::Error => e abort "Error: #{e.message}" end - abort "Error: File not found: #{draft_path_value}" unless File.exist?(draft_path_value) - - now = Time.now - content = File.read(draft_path_value) - content.sub!(/^Date:.*$/, "Date: #{Pressa::Drafts.ordinal_date(now)}") - content.sub!(/^Timestamp:.*$/, "Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}") - - target_dir = "posts/#{now.strftime("%Y/%m")}" - FileUtils.mkdir_p(target_dir) - target_path = "#{target_dir}/#{draft_file}" - - File.write(target_path, content) - FileUtils.rm_f(draft_path_value) - puts "Published draft: #{draft_path_value} -> #{target_path}" + warn "Published draft: #{result.draft_path} -> #{result.target_path}" + puts result.target_path end # Watch content directories and rebuild on every change. diff --git a/bin/publish-draft b/bin/publish-draft index 2a0b4235..235a5c2d 100755 --- a/bin/publish-draft +++ b/bin/publish-draft @@ -42,9 +42,8 @@ echo "==> Pulling latest from $REMOTE/$BRANCH" >&2 git pull --ff-only "$REMOTE" "$BRANCH" >&2 echo "==> Publishing draft $draft" >&2 -# publish_draft reports "Published draft: -> "; we want the target. -post_path="$(bundle exec bake publish_draft "$draft_path" | tail -n 1 | awk '{print $NF}')" -echo " $post_path" >&2 +# publish_draft puts the published path on stdout and its progress on stderr. +post_path="$(bundle exec bake publish_draft "$draft_path")" echo "==> Committing and pushing" >&2 git add "$post_path" "$draft_path" diff --git a/lib/pressa/drafts/publisher.rb b/lib/pressa/drafts/publisher.rb new file mode 100644 index 00000000..d0b0c335 --- /dev/null +++ b/lib/pressa/drafts/publisher.rb @@ -0,0 +1,67 @@ +require "fileutils" +require "pressa/drafts" +require "pressa/posts/metadata" + +module Pressa + class Drafts + # Moves a draft out of the drafts directory and into posts/YYYY/MM, stamped + # with its publication date. + class Publisher + class Error < StandardError; end + + class NotFound < Error; end + + class Conflict < Error; end + + class Invalid < Error; end + + Result = Data.define(:draft_path, :target_path) + + def initialize(drafts_dir: Drafts::DEFAULT_DIR, posts_dir: "posts") + @drafts = Drafts.new(dir: drafts_dir) + @posts_dir = posts_dir + end + + def publish(input_path, now: Time.now) + draft_path, filename = resolve(input_path) + raise NotFound, "file not found: #{draft_path}" unless File.exist?(draft_path) + + content = self.class.rewrite(File.read(draft_path), now:) + target_path = File.join(@posts_dir, now.strftime("%Y/%m"), filename) + raise Conflict, "post already exists at #{target_path}" if File.exist?(target_path) + + FileUtils.mkdir_p(File.dirname(target_path)) + File.write(target_path, content) + FileUtils.rm_f(draft_path) + + Result.new(draft_path:, target_path:) + end + + # Stamps the draft with the date it's being published on, then checks the + # result against the same parser the build uses. bin/publish-draft commits + # and pushes before it builds, so a draft that can't become a valid post + # has to fail here rather than halfway through a deploy. + def self.rewrite(content, now:) + updated = content + .sub(/^Date:.*$/, "Date: #{Drafts.ordinal_date(now)}") + .sub(/^Timestamp:.*$/, "Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}") + + begin + Posts::PostMetadata.parse(updated) + rescue => e + raise Invalid, "draft cannot be published: #{e.message}" + end + + updated + end + + private + + def resolve(input_path) + @drafts.resolve_input(input_path) + rescue Drafts::Error => e + raise Error, e.message + end + end + end +end diff --git a/test/drafts/publisher_test.rb b/test/drafts/publisher_test.rb new file mode 100644 index 00000000..484f2bc4 --- /dev/null +++ b/test/drafts/publisher_test.rb @@ -0,0 +1,124 @@ +require "test_helper" +require "fileutils" +require "tmpdir" +require "pressa/drafts/publisher" +require "pressa/posts/metadata" + +class Pressa::Drafts::PublisherTest < Minitest::Test + def setup + @root = Dir.mktmpdir + @drafts_dir = File.join(@root, "public/drafts") + @posts_dir = File.join(@root, "posts") + FileUtils.mkdir_p(@drafts_dir) + @now = Time.new(2026, 6, 7, 14, 30, 0, "-07:00") + end + + def teardown + FileUtils.remove_entry(@root) + end + + def publisher = Pressa::Drafts::Publisher.new(drafts_dir: @drafts_dir, posts_dir: @posts_dir) + + def write_draft(slug = "tree-well-protocol", **overrides) + fields = { + "Author" => "Jane Doe", "Title" => "Tree Well Protocol", + "Date" => "unpublished", "Timestamp" => "2026-06-01T09:00:00-07:00", "Tags" => "snowboarding" + }.merge(overrides) + front = fields.compact.map { |key, value| "#{key}: #{value}" }.join("\n") + path = File.join(@drafts_dir, "#{slug}.md") + File.write(path, "---\n#{front}\n---\n\nNever ride alone in deep snow.\n") + path + end + + def test_publishes_the_draft_into_posts_by_year_and_month + write_draft + result = publisher.publish("tree-well-protocol.md", now: @now) + + assert_equal(File.join(@posts_dir, "2026/06/tree-well-protocol.md"), result.target_path) + assert(File.exist?(result.target_path)) + end + + def test_stamps_the_publication_date_and_timestamp + write_draft + result = publisher.publish("tree-well-protocol.md", now: @now) + metadata = Pressa::Posts::PostMetadata.parse(File.read(result.target_path)) + + assert_equal("7th June, 2026", metadata.formatted_date) + assert_equal("2026-06-07T14:30:00-07:00", metadata.date.strftime("%Y-%m-%dT%H:%M:%S%:z")) + end + + def test_keeps_the_rest_of_the_draft_intact + write_draft + result = publisher.publish("tree-well-protocol.md", now: @now) + published = File.read(result.target_path) + + assert_includes(published, "Never ride alone in deep snow.") + assert_includes(published, "Title: Tree Well Protocol") + assert_includes(published, "Tags: snowboarding") + end + + def test_removes_the_draft_once_published + draft_path = write_draft + result = publisher.publish("tree-well-protocol.md", now: @now) + + refute(File.exist?(draft_path)) + assert_equal(draft_path, result.draft_path) + end + + def test_accepts_a_full_path_as_well_as_a_bare_filename + write_draft + result = publisher.publish(File.join(@drafts_dir, "tree-well-protocol.md"), now: @now) + + assert(File.exist?(result.target_path)) + end + + def test_refuses_a_path_that_is_already_published + error = assert_raises(Pressa::Drafts::Publisher::Error) do + publisher.publish("posts/2026/06/tree-well-protocol.md", now: @now) + end + + assert_match(/already published/, error.message) + end + + def test_refuses_a_draft_that_is_not_there + assert_raises(Pressa::Drafts::Publisher::NotFound) { publisher.publish("nope.md", now: @now) } + end + + # bin/publish-draft commits and pushes before the build runs, so a draft that + # can't become a valid post has to fail here rather than at build time. + def test_refuses_a_draft_with_no_date_line + draft_path = write_draft("no-date", "Date" => nil) + + error = assert_raises(Pressa::Drafts::Publisher::Invalid) { publisher.publish("no-date.md", now: @now) } + + assert_match(/Date/, error.message) + assert(File.exist?(draft_path), "the draft should be left alone") + end + + def test_refuses_a_draft_with_no_timestamp_line + write_draft("no-timestamp", "Timestamp" => nil) + + error = assert_raises(Pressa::Drafts::Publisher::Invalid) { publisher.publish("no-timestamp.md", now: @now) } + + assert_match(/Timestamp/, error.message) + end + + def test_refuses_a_draft_with_no_front_matter + File.write(File.join(@drafts_dir, "bare.md"), "Just some notes.\n") + + assert_raises(Pressa::Drafts::Publisher::Invalid) { publisher.publish("bare.md", now: @now) } + end + + def test_refuses_to_overwrite_an_existing_post + write_draft + existing = File.join(@posts_dir, "2026/06/tree-well-protocol.md") + FileUtils.mkdir_p(File.dirname(existing)) + File.write(existing, "the original post\n") + + error = assert_raises(Pressa::Drafts::Publisher::Conflict) { publisher.publish("tree-well-protocol.md", now: @now) } + + assert_match(%r{posts/2026/06/tree-well-protocol\.md}, error.message) + assert_equal("the original post\n", File.read(existing)) + assert(File.exist?(File.join(@drafts_dir, "tree-well-protocol.md")), "the draft should be left alone") + end +end From 0b9269c3fb6afa62315661d996b8d5ccdcc8ed5b Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 29 Aug 2026 10:43:11 -0700 Subject: [PATCH 08/15] Commit only the draft being published, and see untracked ones Publishing one draft committed every other edited draft too, under a vague "Update drafts", because the pull needs a clean tree and that was the blunt way to get one. It now stages just the draft being published. That also fixes a worse bug behind it. The check was `git diff --quiet`, which reports untracked files as clean -- and a draft created in the web app is untracked. So it was never committed here, and then the `git add` after publish_draft moved it away died with "pathspec did not match any files", aborting the script under set -e with the post already written. Publishing a newly created draft failed every time. `git status --porcelain` sees untracked files, so one change covers both. --- bin/publish-draft | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bin/publish-draft b/bin/publish-draft index 235a5c2d..5983a4a7 100755 --- a/bin/publish-draft +++ b/bin/publish-draft @@ -32,10 +32,17 @@ fi acquire_publish_lock -if ! git diff --quiet -- public/drafts; then - echo "==> Committing pending draft edits" >&2 - git add public/drafts - git -c commit.gpgsign=false commit -m "Update drafts" >&2 +# Commit just this draft, if it has uncommitted work: the web app writes drafts +# straight into the checkout, and `git pull --ff-only` won't run over a dirty +# tree. Other drafts are deliberately left alone -- publishing one is no reason +# to commit half-finished work on the others. --porcelain rather than +# `git diff` because a draft created in the web app is untracked, and git diff +# calls untracked files clean; that left it uncommitted here and then broke the +# `git add` below, once publish_draft had moved it out from under us. +if [ -n "$(git status --porcelain -- "$draft_path")" ]; then + echo "==> Committing pending edits to $draft" >&2 + git add -- "$draft_path" + git -c commit.gpgsign=false commit -m "Update draft: $draft" >&2 fi echo "==> Pulling latest from $REMOTE/$BRANCH" >&2 From 02291ce8552fa3bbd03f1b9769fc6dc2469c20f0 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 29 Aug 2026 10:43:11 -0700 Subject: [PATCH 09/15] Build the Sites once at boot instead of memoising per request The app needs two Site objects -- one configured for HTML, one for Gemini -- so the preview can render a post through the same writers the build uses. They were being built lazily on first request and stuffed back into a Sinatra setting, which worked only because `set` happens to return the class, defined singleton methods at request time, and raced between threads. Building both costs about six milliseconds, once, so web/config.ru does it at boot via configure_sites! and the helpers are plain readers that raise a clear error naming config.ru if nobody set them. Every entry point already goes through config.ru: the systemd unit via web/bin/start, `bake web`, and the documented smoke test. The one test that relied on the lazy path now calls configure_sites!, so it exercises the real boot path instead. The tag-chip cache moves off Sinatra settings to a plain hash, keyed by repo root as well as post count and mtime. It's a mutable cache, not configuration, and `set` was the wrong mechanism for it. --- lib/pressa/web/app.rb | 48 +++++++++++++++++++++++++++---------------- test/web/app_test.rb | 15 +++++++++++++- web/config.ru | 2 ++ 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/lib/pressa/web/app.rb b/lib/pressa/web/app.rb index 1779fe2e..df094d4b 100644 --- a/lib/pressa/web/app.rb +++ b/lib/pressa/web/app.rb @@ -21,6 +21,8 @@ module Web # through a job so the request can return immediately and the browser can # watch the log. class App < Sinatra::Base + class ConfigurationError < StandardError; end + WEB_ROOT = File.expand_path("../../../web", __dir__) REPO_ROOT = File.expand_path("../../..", __dir__) TAG_CHIP_LIMIT = 24 @@ -40,6 +42,25 @@ class App < Sinatra::Base # middleware how it would treat the request if it were a POST. CROSS_ORIGIN_PROBE = SuperGood::CSRFProtection.new(->(_env) { [200, {}, []] }) + # Rendering every post to count tags takes a beat, and the link form is + # the page that has to feel instant on a phone, so the chips are cached + # until a post is added or edited. A plain hash rather than a Sinatra + # setting: this is a mutable cache, not configuration, and `set` defines + # methods. + TAG_CACHE = {} + + # Built once at boot, by web/config.ru. Nothing builds them per request: + # it costs about six milliseconds, and the memoisation that saved those + # six milliseconds was the ugliest code in this file. + def self.configure_sites! + set :html_site, build_site("html") + set :gemini_site, build_site("gemini") + end + + def self.build_site(output_format) + Pressa.create_site(source_path: repo_root, url_override: site_url, output_format:) + end + set :root, WEB_ROOT set :views, File.join(WEB_ROOT, "views") set :public_folder, File.join(WEB_ROOT, "public") @@ -54,7 +75,6 @@ class App < Sinatra::Base set :html_site, nil set :gemini_site, nil set :author, nil - set :tag_cache, nil set :keep_alive_seconds, 15 helpers do @@ -68,36 +88,28 @@ def drafts = DraftStore.new(dir: repo_path("public", "drafts")) def preview_renderer = Preview.new(html_site:, gemini_site:) - def html_site - settings.html_site || settings.set(:html_site, build_site("html")) && settings.html_site - end + def html_site = settings.html_site || missing_site!(:html_site) - def gemini_site - settings.gemini_site || settings.set(:gemini_site, build_site("gemini")) && settings.gemini_site - end + def gemini_site = settings.gemini_site || missing_site!(:gemini_site) - def build_site(output_format) - Pressa.create_site( - source_path: settings.repo_root, url_override: settings.site_url, output_format: - ) + def missing_site!(key) + raise ConfigurationError, "#{key} was never set; web/config.ru builds it at boot" end - def author - settings.author || settings.set(:author, html_site.author) && settings.author - end + def author = settings.author || html_site.author # Rendering every post to count tags takes a beat, and the link form # is the page you want instant on a phone, so it's cached until a post # is added or edited. def tag_chips files = Dir.glob(repo_path("posts", "**", "*.md")) - key = [files.length, files.map { File.mtime(it) }.max] - cached = settings.tag_cache - return cached[:tags] if cached && cached[:key] == key + key = [settings.repo_root, files.length, files.map { File.mtime(it) }.max] + return TAG_CACHE[:tags] if TAG_CACHE[:key] == key posts = Posts::PostRepo.new.read_posts(repo_path("posts")) tags = Posts::TagIndex.from_posts_by_year(posts).counts.keys.first(TAG_CHIP_LIMIT) - settings.set(:tag_cache, {key:, tags:}) + TAG_CACHE[:key] = key + TAG_CACHE[:tags] = tags tags end diff --git a/test/web/app_test.rb b/test/web/app_test.rb index 114eb708..563c8dac 100644 --- a/test/web/app_test.rb +++ b/test/web/app_test.rb @@ -369,18 +369,31 @@ def test_tags_are_available_as_json assert_includes(json_body, "snowboarding") end - def test_the_site_config_comes_from_the_repo_when_it_is_not_supplied + # What web/config.ru does at boot, and the only thing that builds a Site. + def test_configure_sites_builds_both_from_the_repos_own_config %w[site.toml projects.toml].each do |config| FileUtils.cp(File.expand_path("../../#{config}", __dir__), File.join(@root, config)) end app.set(:html_site, nil) app.set(:gemini_site, nil) app.set(:author, nil) + app.configure_sites! post "/preview", source: DRAFT_SOURCE assert_predicate(last_response, :ok?) assert_includes(json_body["gemtext"], "# Lift Line Notes") + assert_equal("Sami Samhuri", app.html_site.author) + end + + def test_a_missing_site_configuration_says_where_to_set_it + app.set(:html_site, nil) + + error = assert_raises(Pressa::Web::App::ConfigurationError) do + post "/preview", source: DRAFT_SOURCE + end + + assert_match(/config\.ru/, error.message) end def test_an_unknown_page_is_a_404 diff --git a/web/config.ru b/web/config.ru index dfcd2431..7efc326d 100644 --- a/web/config.ru +++ b/web/config.ru @@ -3,4 +3,6 @@ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path) require "pressa/web/app" +Pressa::Web::App.configure_sites! + run Pressa::Web::App From 34316a2adc640808d63983ed0c840eefb63c49ec Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 29 Aug 2026 10:56:43 -0700 Subject: [PATCH 10/15] Refuse a draft save from an editor that has gone stale Saving a draft overwrote whatever was there. Two tabs, or a phone and a laptop, and the second save silently destroyed the first -- and now that publishing pulls from GitHub, a pull can rewrite a draft underneath an open editor too. The editor carries a SHA-256 of what it loaded and sends it back with the save. A mismatch is refused with 409, the submitted text still in the textarea and what's on disk shown below it, so neither version is lost and the reconciling is a copy-paste rather than a merge UI. A digest rather than mtime because re-saving identical content shouldn't be a conflict and same-second writes shouldn't compare equal. A save carrying no token at all -- a tab left open across a deploy -- is refused too, rather than falling back to last-write-wins. --- lib/pressa/web/app.rb | 25 +++++++++++++-- lib/pressa/web/draft_store.rb | 27 ++++++++++++++++- test/web/app_test.rb | 37 +++++++++++++++++++++-- test/web/draft_store_test.rb | 57 +++++++++++++++++++++++++++++++++-- web/views/draft.erb | 8 +++++ 5 files changed, 145 insertions(+), 9 deletions(-) diff --git a/lib/pressa/web/app.rb b/lib/pressa/web/app.rb index df094d4b..37864da6 100644 --- a/lib/pressa/web/app.rb +++ b/lib/pressa/web/app.rb @@ -282,13 +282,32 @@ def json_error(status, message) = halt(status, {error: message}.to_json) get "/drafts/:slug" do @slug = params[:slug] @source = find_draft(@slug) + @version = drafts.version(@slug) erb :draft end post "/drafts/:slug" do - find_draft(params[:slug]) - drafts.write(params[:slug], params[:source].to_s) - redirect to("/drafts/#{params[:slug]}"), 303 + @slug = params[:slug] + find_draft(@slug) + @source = params[:source].to_s + @version = params[:version].to_s + + if @version.empty? + @error = "This form is out of date. Reload the page, then paste your text back in." + halt 422, erb(:draft) + end + + begin + drafts.write(@slug, @source, expected_version: @version) + rescue DraftStore::Stale => e + @version = e.current_version + @conflict = e.current_content + @error = "This draft changed on disk since you opened it. Your text is still below; " \ + "what's on disk now is underneath it." + halt 409, erb(:draft) + end + + redirect to("/drafts/#{@slug}"), 303 end post "/drafts/:slug/publish" do diff --git a/lib/pressa/web/draft_store.rb b/lib/pressa/web/draft_store.rb index a42e86b8..9cc1bdf7 100644 --- a/lib/pressa/web/draft_store.rb +++ b/lib/pressa/web/draft_store.rb @@ -1,3 +1,4 @@ +require "digest" require "fileutils" require "pressa/drafts" require "pressa/drafts/repo" @@ -18,6 +19,18 @@ class InvalidSlug < Error; end class InvalidTitle < Error; end + # Raised when the draft changed between the editor loading it and the + # save arriving. Carries what's on disk so nothing has to be lost. + class Stale < Error + attr_reader :current_content, :current_version + + def initialize(current_content:, current_version:) + @current_content = current_content + @current_version = current_version + super("the draft changed on disk since it was opened") + end + end + # Exactly what Drafts.slugify produces, so nothing that could climb out # of the drafts directory can name a file. SLUG_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ @@ -38,10 +51,22 @@ def read(slug) raise NotFound, "no draft named #{slug}" end - def write(slug, content) + # A digest of the draft as it stands, handed to the editor and returned + # with the save so a second tab can't quietly overwrite the first. + def version(slug) + Digest::SHA256.hexdigest(read(slug)) + end + + def write(slug, content, expected_version:) target = path(slug) raise NotFound, "no draft named #{slug}" unless File.exist?(target) + current = File.read(target) + current_version = Digest::SHA256.hexdigest(current) + unless expected_version == current_version + raise Stale.new(current_content: current, current_version:) + end + # Browsers normalize textarea line breaks to CRLF on submit, per the # HTML spec, even though nothing here ever inserts one. File.write(target, content.gsub("\r\n", "\n")) diff --git a/test/web/app_test.rb b/test/web/app_test.rb index 563c8dac..9f09f86b 100644 --- a/test/web/app_test.rb +++ b/test/web/app_test.rb @@ -312,11 +312,44 @@ def test_the_editor_shows_the_draft_source assert_includes(last_response.body, "Chairlift conversations, collected.") end + def draft_path = File.join(@root, "public/drafts/lift-line-notes.md") + + def current_version + get "/drafts/lift-line-notes" + last_response.body[/name="version" value="([a-f0-9]+)"/, 1] + end + + def test_the_editor_carries_a_version_of_what_it_loaded + refute_nil(current_version) + end + def test_saving_a_draft_writes_it_back - post "/drafts/lift-line-notes", source: "---\nTitle: Lift Line Notes\n---\n\nRewritten.\n" + post "/drafts/lift-line-notes", + source: "---\nTitle: Lift Line Notes\n---\n\nRewritten.\n", version: current_version assert_equal(303, last_response.status) - assert_includes(File.read(File.join(@root, "public/drafts/lift-line-notes.md")), "Rewritten.") + assert_includes(File.read(draft_path), "Rewritten.") + end + + def test_a_save_from_a_stale_editor_is_refused_and_keeps_both_versions + stale = current_version + File.write(draft_path, "---\nTitle: Lift Line Notes\n---\n\nThe other tab got here first.\n") + + post "/drafts/lift-line-notes", source: "My slower edit.\n", version: stale + + assert_equal(409, last_response.status) + assert_includes(last_response.body, "My slower edit.") + assert_includes(last_response.body, "The other tab got here first.") + assert_includes(File.read(draft_path), "The other tab got here first.") + refute_includes(File.read(draft_path), "My slower edit.") + end + + def test_a_save_with_no_version_at_all_is_refused + post "/drafts/lift-line-notes", source: "From a tab opened before the deploy.\n" + + assert_equal(422, last_response.status) + assert_includes(last_response.body, "From a tab opened before the deploy.") + refute_includes(File.read(draft_path), "From a tab opened before the deploy.") end def test_publishing_a_draft_starts_a_job diff --git a/test/web/draft_store_test.rb b/test/web/draft_store_test.rb index 94bc25d3..8df5bf19 100644 --- a/test/web/draft_store_test.rb +++ b/test/web/draft_store_test.rb @@ -55,22 +55,73 @@ def test_read_raises_for_an_unknown_draft def test_write_replaces_the_contents_of_an_existing_draft write_draft("tree-wells", title: "Tree Wells") - store.write("tree-wells", "---\nTitle: Tree Wells\n---\n\nRewritten.\n") + store.write("tree-wells", "---\nTitle: Tree Wells\n---\n\nRewritten.\n", + expected_version: store.version("tree-wells")) assert_includes(store.read("tree-wells"), "Rewritten.") end def test_write_raises_for_an_unknown_draft - assert_raises(Pressa::Web::DraftStore::NotFound) { store.write("nope", "content") } + assert_raises(Pressa::Web::DraftStore::NotFound) do + store.write("nope", "content", expected_version: "whatever") + end end def test_write_normalizes_the_crlf_browsers_send_from_a_textarea write_draft("tree-wells", title: "Tree Wells") - store.write("tree-wells", "line one\r\nline two\r\n") + store.write("tree-wells", "line one\r\nline two\r\n", expected_version: store.version("tree-wells")) assert_equal("line one\nline two\n", store.read("tree-wells")) end + # --- concurrent edits ----------------------------------------------------- + + def test_version_is_stable_for_unchanged_contents + write_draft("tree-wells", title: "Tree Wells") + + assert_equal(store.version("tree-wells"), store.version("tree-wells")) + end + + def test_version_changes_once_the_draft_is_written + write_draft("tree-wells", title: "Tree Wells") + before = store.version("tree-wells") + store.write("tree-wells", "something else\n", expected_version: before) + + refute_equal(before, store.version("tree-wells")) + end + + def test_write_refuses_a_version_that_is_out_of_date + write_draft("tree-wells", title: "Tree Wells") + stale = store.version("tree-wells") + store.write("tree-wells", "the other tab got here first\n", expected_version: stale) + + error = assert_raises(Pressa::Web::DraftStore::Stale) do + store.write("tree-wells", "my slower edit\n", expected_version: stale) + end + + assert_equal("the other tab got here first\n", store.read("tree-wells")) + assert_equal("the other tab got here first\n", error.current_content) + assert_equal(store.version("tree-wells"), error.current_version) + end + + def test_write_refuses_a_missing_version + write_draft("tree-wells", title: "Tree Wells") + + assert_raises(Pressa::Web::DraftStore::Stale) do + store.write("tree-wells", "no token at all\n", expected_version: nil) + end + + refute_includes(store.read("tree-wells"), "no token at all") + end + + def test_rewriting_identical_content_is_not_a_conflict + write_draft("tree-wells", title: "Tree Wells") + same = store.read("tree-wells") + store.write("tree-wells", same, expected_version: store.version("tree-wells")) + + assert_equal(same, store.read("tree-wells")) + end + def test_create_writes_a_draft_from_the_title_and_returns_its_slug slug = store.create("Tree Well Protocol") diff --git a/web/views/draft.erb b/web/views/draft.erb index 9288a7e6..fd75ac8d 100644 --- a/web/views/draft.erb +++ b/web/views/draft.erb @@ -3,12 +3,20 @@
+
+<% if @conflict %> +
+ What's on disk now +
<%= h(@conflict) %>
+
+<% end %> + <%= erb :_preview, layout: false %>
From 362bd561a691a3c89883fd614bc759d1b373919e Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 29 Aug 2026 11:19:51 -0700 Subject: [PATCH 11/15] Publish inline and delete the job model The job model was built on a number I never measured. Publishing was supposed to take 30-60 seconds, a figure inherited from the dashboard's own "usually ~30s" and never checked. Measured against beta: HTML build 0.72s, Gemini build 0.48s, build and rsync to mudge 1.0s, a GitHub round trip 1.25s, an og:image fetch 0.12-0.85s against real sites. A whole publish is about five seconds. At five seconds a request can just do the work. POST /link and the draft publish run the script inline and render the result and its log on the page. Job, JobRegistry, the three /jobs routes, the SSE stream and its two views are gone -- about 580 lines including their tests. Serialisation is unaffected: the flock in bin/lib/common.sh was always the real guard, since it's the only one the phone Shortcut's SSH path respects too. A publish that can't take it exits 75 and the app turns that into a 409 saying to try again, rather than the in-process mutex saying the same thing less reliably. --- AGENTS.md | 4 +- lib/pressa/web/app.rb | 107 ++++++--------------- lib/pressa/web/job.rb | 115 ----------------------- lib/pressa/web/job_registry.rb | 89 ------------------ test/web/app_test.rb | 165 +++++++++++---------------------- test/web/job_registry_test.rb | 93 ------------------- test/web/job_test.rb | 117 ----------------------- web/public/style.css | 16 +--- web/views/job.erb | 41 -------- web/views/jobs.erb | 16 ---- web/views/layout.erb | 18 +++- 11 files changed, 102 insertions(+), 679 deletions(-) delete mode 100644 lib/pressa/web/job.rb delete mode 100644 lib/pressa/web/job_registry.rb delete mode 100644 test/web/job_registry_test.rb delete mode 100644 test/web/job_test.rb delete mode 100644 web/views/job.erb delete mode 100644 web/views/jobs.erb diff --git a/AGENTS.md b/AGENTS.md index 08f7e489..f657ad47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,8 +50,8 @@ Keep new code under the existing `Pressa` module structure (for example `lib/pre `lib/pressa/web/` is a Sinatra app that owns posting a link, drafts, and preview. It runs on mudge as `pressa-web.service` on `127.0.0.1:1112`, published by Caddy at `http://mudge:7777` and restricted to Tailscale source IPs, and is served by `web/bin/start` (puma, threads only). - It drives `bin/post-link` and `bin/publish-draft` rather than reimplementing the publish flow, and renders previews through `Posts::PostWriter#post_html` and `Posts::GeminiWriter#post_content` — the same code the build uses. -- Anything that writes to the checkout runs as a job (`Web::JobRegistry`, `Web::Job`, `Web::JobRunner`); the browser watches the log over SSE at `/jobs/:id/stream`. One job at a time: a second publish is refused with the running job, never queued. -- Puma runs threads only, not workers — the single-writer guard is an in-process mutex. `bin/lib/common.sh` adds an `flock` so the SSH path can't race it. +- Publishing runs inline via `Web::JobRunner`, which drives the script and collects its output for the page. A publish measures about five seconds, most of it the two GitHub round trips, so there is nothing for a queue to do. +- Only one publish may touch the checkout at a time. The `flock` in `bin/lib/common.sh` enforces that across processes, so the phone Shortcut over SSH and the web app can't collide; a blocked publish exits 75 (EX_TEMPFAIL) and the app turns that into a 409 telling you to try again. - Sinatra and puma live in this repo's Gemfile on purpose. A separate `web/Gemfile` would leave `BUNDLE_GEMFILE` pointing at the wrong one inside `bin/post-link`'s `bundle exec bake`. ## Content and Metadata Requirements diff --git a/lib/pressa/web/app.rb b/lib/pressa/web/app.rb index 37864da6..dbb0c2f0 100644 --- a/lib/pressa/web/app.rb +++ b/lib/pressa/web/app.rb @@ -7,7 +7,6 @@ require "pressa/posts/repo" require "pressa/posts/tag_index" require "pressa/web/draft_store" -require "pressa/web/job_registry" require "pressa/web/job_runner" require "pressa/web/preview" @@ -17,15 +16,19 @@ module Web # # It owns posting a link, drafts, and preview, and it does so by driving # the same bin/ scripts the phone Shortcut drives over SSH rather than - # reimplementing the flow. Anything that writes to the checkout goes - # through a job so the request can return immediately and the browser can - # watch the log. + # reimplementing the flow. Publishing runs inline: it measures about five + # seconds, and the flock those scripts take is what stops two of them + # running at once, so there is nothing here for a job queue to do. class App < Sinatra::Base class ConfigurationError < StandardError; end WEB_ROOT = File.expand_path("../../../web", __dir__) REPO_ROOT = File.expand_path("../../..", __dir__) TAG_CHIP_LIMIT = 24 + # bin/post-link and bin/publish-draft take an flock, so a second publish + # exits 75 (EX_TEMPFAIL) instead of running. That's "try again shortly", + # not a failure. + BUSY_EXIT_STATUS = 75 # There are no cookies or sessions here, so what a malicious page would be # riding isn't a login -- it's this machine's position on the tailnet. @@ -70,18 +73,14 @@ def self.build_site(output_format) set :repo_root, REPO_ROOT set :site_url, ENV.fetch("PRESSA_SITE_URL", "https://samhuri.net") - set :registry, JobRegistry.new set :link_scraper, OpenGraph set :html_site, nil set :gemini_site, nil set :author, nil - set :keep_alive_seconds, 15 helpers do def h(text) = Rack::Utils.escape_html(text.to_s) - def registry = settings.registry - def repo_path(*parts) = File.join(settings.repo_root, *parts) def drafts = DraftStore.new(dir: repo_path("public", "drafts")) @@ -136,14 +135,19 @@ def link_post_source(form) ) end - def start_job(kind:, label:, command:, stdin_data: nil) - registry.start(kind:, label:) do |job| - JobRunner.run(command:, stdin_data:, chdir: settings.repo_root) { job.append(it) } - end + # Runs one of the publish scripts inline. Returns nil when it worked, + # or the status to halt with when it didn't, leaving @error and @log + # for the page to render. + def run_publish(command:, stdin_data: nil) + @log = [] + @published = JobRunner.run(command:, stdin_data:, chdir: settings.repo_root) { @log << it } + nil + rescue JobRunner::Failed => e + busy = e.exit_status == BUSY_EXIT_STATUS + @error = busy ? "Something else is publishing right now. Try again in a moment." : e.message + busy ? 409 : 500 end - def sse(payload) = "data: #{payload.to_json}\n\n" - def cross_origin_request? status, = CROSS_ORIGIN_PROBE.call(request.env.merge("REQUEST_METHOD" => "POST")) status == 403 @@ -170,18 +174,11 @@ def json_error(status, message) = halt(status, {error: message}.to_json) end payload = @form.reject { |_key, value| value.to_s.empty? }.to_json - begin - job = start_job( - kind: "publish_link", label: @form[:title], - command: [repo_path("bin", "post-link")], stdin_data: payload - ) - rescue JobRegistry::Busy => e - @busy = e.job - @error = "Something is already publishing. Watch it finish, then try again." - halt 409, erb(:link) - end + status = run_publish(command: [repo_path("bin", "post-link")], stdin_data: payload) + halt status, erb(:link) if status - redirect to("/jobs/#{job.id}"), 303 + @form = {} + erb :link end get "/link/metadata" do @@ -212,49 +209,6 @@ def json_error(status, message) = halt(status, {error: message}.to_json) {title: result.title, html: result.html, gemtext: result.gemtext}.to_json end - # --- jobs -------------------------------------------------------------- - - get "/jobs" do - @jobs = registry.recent - erb :jobs - end - - get "/jobs/:id" do - @job = registry.find(params[:id]) || halt(404, not_found_page("No job by that name.")) - erb :job - end - - get "/jobs/:id/stream" do - job = registry.find(params[:id]) || halt(404, "unknown job") - - content_type "text/event-stream" - headers "Cache-Control" => "no-cache", "X-Accel-Buffering" => "no" - - stream(:keep_open) do |out| - backlog, queue = job.subscribe - out.callback { job.unsubscribe(queue) } - - backlog.each { out << sse(type: "line", text: it) } - loop do - # A timed pop lets an idle job still get a periodic write, which is - # the only way to notice the browser hung up. - line = queue.pop(timeout: settings.keep_alive_seconds) - if line.nil? - break if job.finished? && queue.empty? - - out << ": keep-alive\n\n" - next - end - out << sse(type: "line", text: line) - end - - out << sse(job.to_h.merge(type: "state")) - out.close - rescue IOError, Errno::EPIPE - # browser went away - end - end - # --- drafts ------------------------------------------------------------ get "/drafts" do @@ -313,19 +267,14 @@ def json_error(status, message) = halt(status, {error: message}.to_json) post "/drafts/:slug/publish" do @slug = params[:slug] @source = find_draft(@slug) + @version = drafts.version(@slug) - begin - job = start_job( - kind: "publish_draft", label: @slug, - command: [repo_path("bin", "publish-draft"), @slug] - ) - rescue JobRegistry::Busy => e - @busy = e.job - @error = "Something is already publishing. Watch it finish, then try again." - halt 409, erb(:draft) - end + status = run_publish(command: [repo_path("bin", "publish-draft"), @slug]) + halt status, erb(:draft) if status - redirect to("/jobs/#{job.id}"), 303 + # The draft is gone now, so there's nothing left to edit. + @drafts = drafts.list + erb :drafts end post "/drafts/:slug/delete" do diff --git a/lib/pressa/web/job.rb b/lib/pressa/web/job.rb deleted file mode 100644 index 14b094f8..00000000 --- a/lib/pressa/web/job.rb +++ /dev/null @@ -1,115 +0,0 @@ -require "monitor" -require "time" - -module Pressa - module Web - # One unit of long-running work — publishing a link, publishing a draft — - # with its log. Publishing mutates a git checkout and takes far too long - # for a blocking request, so requests start a job and then watch it. - # - # Written by the worker thread and read by every connected browser, so all - # state changes go through the monitor. Subscribers get the backlog and a - # live queue in one atomic step, which is what lets a phone reconnect - # mid-publish without missing or repeating lines. - class Job - STATES = %i[running succeeded failed].freeze - - attr_reader :id, :kind, :label, :started_at, :finished_at, :state, :result, :error - - def initialize(id:, kind:, label: nil, clock: -> { Time.now }) - @id = id - @kind = kind - @label = label - @clock = clock - @state = :running - @started_at = clock.call - @finished_at = nil - @result = nil - @error = nil - @lines = [] - @subscribers = [] - @monitor = Monitor.new - end - - def running? = state == :running - - def finished? = !running? - - def lines - @monitor.synchronize { @lines.dup } - end - - def append(line) - @monitor.synchronize do - return if finished? - - @lines << line - @subscribers.each { it << line } - end - end - - def succeed(result) - finish(:succeeded) { @result = result } - end - - def fail(error) - finish(:failed) { @error = error } - end - - # Returns the lines so far plus a queue carrying every line after them, - # then nil once the job finishes. Taken together under the monitor so no - # line can slip between the snapshot and the subscription. - def subscribe - @monitor.synchronize do - queue = Queue.new - if finished? - queue << nil - else - @subscribers << queue - end - [@lines.dup, queue] - end - end - - def unsubscribe(queue) - @monitor.synchronize { @subscribers.delete(queue) } - end - - def duration - return nil unless finished_at - - finished_at - started_at - end - - def to_h - @monitor.synchronize do - { - id: @id, - kind: @kind, - label: @label, - state: @state.to_s, - started_at: @started_at.iso8601, - finished_at: @finished_at&.iso8601, - duration: duration, - result: @result, - error: @error - } - end - end - - private - - def finish(state) - @monitor.synchronize do - return if finished? - - yield - @state = state - @finished_at = @clock.call - @subscribers.each { it << nil } - @subscribers.clear - end - end - end - end -end diff --git a/lib/pressa/web/job_registry.rb b/lib/pressa/web/job_registry.rb deleted file mode 100644 index d8d08148..00000000 --- a/lib/pressa/web/job_registry.rb +++ /dev/null @@ -1,89 +0,0 @@ -require "monitor" -require "securerandom" -require "pressa/web/job" - -module Pressa - module Web - # Holds the one job that may run at a time, plus a short history. - # - # Publishing pulls, commits, pushes, builds, and rsyncs a git checkout that - # bin/post-link also writes to over SSH. Two of those at once would corrupt - # something, so a second request while one is running is refused outright - # rather than queued — silently queueing a publish is worse than being told - # to wait. - class JobRegistry - class Busy < StandardError - attr_reader :job - - def initialize(job) - @job = job - super("#{job.kind} job #{job.id} is already running") - end - end - - MAX_HISTORY = 20 - - def initialize(executor: ->(&block) { Thread.new(&block) }, clock: -> { Time.now }, max_history: MAX_HISTORY) - @executor = executor - @clock = clock - @max_history = max_history - @current = nil - @history = [] - @monitor = Monitor.new - end - - # Claims the single work slot and starts the block on the executor, - # returning the job right away so the request can redirect to its status - # stream. Raises Busy, carrying the running job, when the slot is taken. - def start(kind:, label: nil, &work) - job = @monitor.synchronize do - raise Busy.new(@current) if @current - - @current = Job.new(id: next_id, kind:, label:, clock: @clock) - end - - @executor.call { run(job, &work) } - job - end - - def current - @monitor.synchronize { @current } - end - - def find(id) - @monitor.synchronize do - return @current if @current&.id == id - - @history.find { it.id == id } - end - end - - # Newest first, running job included. - def recent - @monitor.synchronize { [@current, *@history].compact } - end - - private - - def run(job, &work) - job.succeed(work.call(job)) - rescue => e - job.fail(e.message) - ensure - retire(job) - end - - def retire(job) - @monitor.synchronize do - @current = nil - @history.unshift(job) - @history.pop while @history.length > @max_history - end - end - - def next_id - "#{@clock.call.strftime("%H%M%S")}-#{SecureRandom.hex(3)}" - end - end - end -end diff --git a/test/web/app_test.rb b/test/web/app_test.rb index 9f09f86b..7a6e4b1c 100644 --- a/test/web/app_test.rb +++ b/test/web/app_test.rb @@ -40,9 +40,8 @@ def setup File.write(File.join(@root, "public/drafts/lift-line-notes.md"), DRAFT_SOURCE) @metadata = nil - @executor = ->(&block) { block.call } - write_bin("post-link", "cat > /dev/null; echo '==> Building' >&2; echo posts/2026/06/new-post.md") - write_bin("publish-draft", "echo '==> Publishing' >&2; echo \"posts/2026/06/$1.md\"") + write_bin("post-link", "cat > /dev/null; echo ran >> ran.log; echo '==> Building' >&2; echo posts/2026/06/new-post.md") + write_bin("publish-draft", "echo ran >> ran.log; echo '==> Publishing' >&2; echo \"posts/2026/06/$1.md\"") end # The app runs the repo's real scripts, so tests stand in fake ones rather @@ -54,6 +53,12 @@ def write_bin(name, script) FileUtils.chmod(0o755, path) end + # The scripts append to ran.log, so a test can assert one never ran. + def publish_attempts + path = File.join(@root, "ran.log") + File.exist?(path) ? File.read(path).lines.size : 0 + end + # Stands in for Pressa::OpenGraph. def scraper = self @@ -71,10 +76,6 @@ def build_site(output_format) ) end - def registry - @registry ||= Pressa::Web::JobRegistry.new(executor: ->(&block) { @executor.call(&block) }) - end - def app @app ||= Class.new(Pressa::Web::App) do set :environment, :test @@ -83,7 +84,6 @@ def app set :host_authorization, {permitted_hosts: []} end.tap do |klass| klass.set(:repo_root, @root) - klass.set(:registry, registry) klass.set(:html_site, build_site("html")) klass.set(:gemini_site, build_site("gemini")) klass.set(:author, "Sami Samhuri") @@ -110,23 +110,28 @@ def test_the_home_page_offers_tags_already_in_use_as_chips assert_includes(last_response.body, "safety") end - def test_posting_a_link_starts_a_job_and_redirects_to_it + def test_posting_a_link_publishes_and_shows_the_result post "/link", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol", body: "Never ride alone.", tags: "Snowboarding, Safety" - assert_equal(303, last_response.status) - job = registry.recent.first - assert_match(%r{/jobs/#{job.id}\z}, last_response.headers["Location"]) - assert_equal("posts/2026/06/new-post.md", job.result) - assert_equal("publish_link", job.kind) + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, "posts/2026/06/new-post.md") + assert_includes(last_response.body, "==> Building") + assert_equal(1, publish_attempts) + end + + def test_a_successful_publish_clears_the_form + post "/link", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol" + + refute_includes(last_response.body, %(value="https://powder.example.net/tree-wells")) end def test_posting_a_link_sends_the_form_to_the_script_as_json_on_stdin - write_bin("post-link", "cat; echo; echo posts/x.md") + write_bin("post-link", "cat > payload.json; echo posts/x.md") post "/link", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol", body: "Never ride alone.\r\nSecond line.", tags: "Snowboarding, safety , " - payload = JSON.parse(registry.recent.first.lines.first) + payload = JSON.parse(File.read(File.join(@root, "payload.json"))) assert_equal("Tree Well Protocol", payload["title"]) assert_equal("https://powder.example.net/tree-wells", payload["link"]) @@ -134,25 +139,30 @@ def test_posting_a_link_sends_the_form_to_the_script_as_json_on_stdin assert_equal("snowboarding, safety", payload["tags"]) end - def test_a_failing_publish_leaves_a_failed_job_rather_than_a_500 - write_bin("post-link", "echo 'fatal: not a git repository' >&2; exit 128") + def test_a_failing_publish_shows_the_error_and_the_log + write_bin("post-link", "echo '==> Pulling' >&2; echo 'fatal: not a git repository' >&2; exit 128") post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" - assert_equal(303, last_response.status) - assert_equal(:failed, registry.recent.first.state) - assert_equal("fatal: not a git repository", registry.recent.first.error) + assert_equal(500, last_response.status) + assert_includes(last_response.body, "fatal: not a git repository") + assert_includes(last_response.body, "==> Pulling") + end + + def test_a_failing_publish_keeps_what_was_typed + write_bin("post-link", "exit 128") + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol", body: "Never ride alone." + + assert_includes(last_response.body, "Never ride alone.") end - def test_a_second_publish_while_one_is_running_is_refused_not_queued - held = nil - @executor = ->(&block) { held = block } - post "/link", link: "https://powder.example.net/one", title: "First Post" - post "/link", link: "https://powder.example.net/two", title: "Second Post" + # The scripts flock the checkout; exit 75 is EX_TEMPFAIL, meaning something + # else holds it -- the phone Shortcut over SSH, most likely. + def test_a_publish_that_cannot_get_the_lock_says_to_try_again + write_bin("post-link", "echo 'Error: another publish is already running' >&2; exit 75") + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" assert_equal(409, last_response.status) - assert_includes(last_response.body, "already") - assert_equal(1, registry.recent.length) - refute_nil(held) + assert_includes(last_response.body, "Try again in a moment") end def test_a_link_without_a_url_or_title_is_rejected_and_the_form_comes_back_filled_in @@ -160,7 +170,7 @@ def test_a_link_without_a_url_or_title_is_rejected_and_the_form_comes_back_fille assert_equal(422, last_response.status) assert_includes(last_response.body, "Never ride alone.") - assert_empty(registry.recent) + assert_equal(0, publish_attempts) end # --- link metadata ------------------------------------------------------- @@ -219,63 +229,6 @@ def test_preview_reports_bad_source_rather_than_blowing_up # --- jobs ---------------------------------------------------------------- - def test_a_job_page_shows_its_state_and_log - post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" - job = registry.recent.first - get "/jobs/#{job.id}" - - assert_predicate(last_response, :ok?) - assert_includes(last_response.body, "==> Building") - assert_includes(last_response.body, "posts/2026/06/new-post.md") - end - - def test_an_unknown_job_is_a_404 - get "/jobs/nope" - - assert_equal(404, last_response.status) - end - - def test_the_job_stream_replays_the_log_and_ends_with_the_final_state - post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" - job = registry.recent.first - get "/jobs/#{job.id}/stream" - - assert_match(%r{\Atext/event-stream}, last_response.headers["Content-Type"]) - events = last_response.body.scan(/^data: (.+)$/).flatten.map { JSON.parse(it) } - - assert_includes(events.map { it["text"] }, "==> Building") - assert_equal("succeeded", events.last["state"]) - end - - def test_the_job_stream_delivers_lines_while_the_job_is_still_running - @executor = ->(&block) { Thread.new(&block) } - app.set(:keep_alive_seconds, 0.05) - write_bin("post-link", "echo '==> Pulling' >&2; sleep 0.5; echo '==> Building' >&2; echo posts/x.md") - post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" - job = registry.recent.first - - get "/jobs/#{job.id}/stream" - events = last_response.body.scan(/^data: (.+)$/).flatten.map { JSON.parse(it) } - - assert_includes(events.map { it["text"] }, "==> Building") - assert_includes(last_response.body, ": keep-alive") - assert_equal("succeeded", events.last["state"]) - end - - def test_streaming_an_unknown_job_is_a_404 - get "/jobs/nope/stream" - - assert_equal(404, last_response.status) - end - - def test_the_jobs_page_lists_recent_jobs - post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" - get "/jobs" - - assert_predicate(last_response, :ok?) - assert_includes(last_response.body, "Tree Well Protocol") - end - # --- drafts -------------------------------------------------------------- def test_drafts_are_listed_newest_first @@ -352,26 +305,20 @@ def test_a_save_with_no_version_at_all_is_refused refute_includes(File.read(draft_path), "From a tab opened before the deploy.") end - def test_publishing_a_draft_starts_a_job + def test_publishing_a_draft_runs_the_script_and_shows_the_result post "/drafts/lift-line-notes/publish" - assert_equal(303, last_response.status) - job = registry.recent.first - - assert_equal("publish_draft", job.kind) - assert_equal("posts/2026/06/lift-line-notes.md", job.result) + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, "posts/2026/06/lift-line-notes.md") + assert_equal(1, publish_attempts) end - def test_publishing_a_draft_while_something_is_running_is_refused - held = nil - @executor = ->(&block) { held = block } - post "/link", link: "https://powder.example.net/one", title: "First Post" + def test_a_failing_draft_publish_shows_the_error + write_bin("publish-draft", "echo 'Error: no draft' >&2; exit 1") post "/drafts/lift-line-notes/publish" - assert_equal(409, last_response.status) - assert_includes(last_response.body, "already") - assert_equal(1, registry.recent.length) - refute_nil(held) + assert_equal(500, last_response.status) + assert_includes(last_response.body, "Error: no draft") end def test_deleting_a_draft_removes_it @@ -454,32 +401,32 @@ def test_a_cross_site_publish_is_refused post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("cross-site") assert_equal(403, last_response.status) - assert_empty(registry.recent) + assert_equal(0, publish_attempts) end def test_a_same_site_publish_is_refused_too post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("same-site") assert_equal(403, last_response.status) - assert_empty(registry.recent) + assert_equal(0, publish_attempts) end def test_a_same_origin_publish_goes_through post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("same-origin") - assert_equal(303, last_response.status) + assert_predicate(last_response, :ok?) end def test_a_user_initiated_publish_goes_through post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("none") - assert_equal(303, last_response.status) + assert_predicate(last_response, :ok?) end def test_a_request_with_no_browser_headers_goes_through post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" - assert_equal(303, last_response.status) + assert_predicate(last_response, :ok?) end def test_an_older_browser_falls_back_to_the_origin_header @@ -487,14 +434,14 @@ def test_an_older_browser_falls_back_to_the_origin_header {"HTTP_ORIGIN" => "https://evil.example.net"} assert_equal(403, last_response.status) - assert_empty(registry.recent) + assert_equal(0, publish_attempts) end def test_a_matching_origin_header_goes_through post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, {"HTTP_ORIGIN" => "http://example.org"} - assert_equal(303, last_response.status) + assert_predicate(last_response, :ok?) end def test_cross_site_draft_deletion_is_refused @@ -508,7 +455,7 @@ def test_cross_site_draft_publishing_is_refused post "/drafts/lift-line-notes/publish", {}, sec_fetch("cross-site") assert_equal(403, last_response.status) - assert_empty(registry.recent) + assert_equal(0, publish_attempts) end def test_reading_pages_cross_site_is_still_allowed diff --git a/test/web/job_registry_test.rb b/test/web/job_registry_test.rb deleted file mode 100644 index c44d7a26..00000000 --- a/test/web/job_registry_test.rb +++ /dev/null @@ -1,93 +0,0 @@ -require "test_helper" -require "pressa/web/job_registry" - -class Pressa::Web::JobRegistryTest < Minitest::Test - # Runs work on the calling thread so tests never wait on a scheduler. - def inline_executor = ->(&block) { block.call } - - # Holds the work instead of running it, so a job stays "running". - def deferred_executor - @deferred ||= [] - ->(&block) { @deferred << block } - end - - def registry(executor: inline_executor, **options) - Pressa::Web::JobRegistry.new(executor:, **options) - end - - def test_start_runs_the_work_and_records_the_result - subject = registry - job = subject.start(kind: "publish_link", label: "Tree Well Protocol") do |running| - running.append("==> Building") - "posts/2026/06/tree-well-protocol.md" - end - - assert_equal(:succeeded, job.state) - assert_equal("posts/2026/06/tree-well-protocol.md", job.result) - assert_equal(["==> Building"], job.lines) - end - - def test_start_records_a_raised_error_as_a_failed_job - subject = registry - job = subject.start(kind: "publish_link") { raise "rsync exited with 23" } - - assert_equal(:failed, job.state) - assert_equal("rsync exited with 23", job.error) - end - - def test_a_second_start_while_one_is_running_reports_the_running_job - subject = registry(executor: deferred_executor) - running = subject.start(kind: "publish_link", label: "First") { "ok" } - - error = assert_raises(Pressa::Web::JobRegistry::Busy) do - subject.start(kind: "publish_link", label: "Second") { "ok" } - end - - assert_same(running, error.job) - assert_equal("First", error.job.label) - end - - def test_the_slot_frees_up_once_a_job_finishes - subject = registry - subject.start(kind: "publish_link") { "ok" } - - assert_nil(subject.current) - assert(subject.start(kind: "publish_link") { "ok" }) - end - - def test_the_slot_frees_up_even_when_the_work_raises - subject = registry - subject.start(kind: "publish_link") { raise "boom" } - - assert_nil(subject.current) - end - - def test_current_is_the_running_job - subject = registry(executor: deferred_executor) - job = subject.start(kind: "publish_link") { "ok" } - - assert_same(job, subject.current) - end - - def test_find_looks_up_running_and_finished_jobs_by_id - subject = registry - job = subject.start(kind: "publish_link") { "ok" } - - assert_same(job, subject.find(job.id)) - assert_nil(subject.find("nope")) - end - - def test_jobs_get_distinct_ids - subject = registry - ids = 3.times.map { subject.start(kind: "publish_link") { "ok" }.id } - - assert_equal(3, ids.uniq.length) - end - - def test_recent_lists_newest_first_and_forgets_old_jobs - subject = registry(max_history: 2) - 3.times { |i| subject.start(kind: "publish_link", label: "job #{i}") { "ok" } } - - assert_equal(["job 2", "job 1"], subject.recent.map(&:label)) - end -end diff --git a/test/web/job_test.rb b/test/web/job_test.rb deleted file mode 100644 index 9b1c3bb1..00000000 --- a/test/web/job_test.rb +++ /dev/null @@ -1,117 +0,0 @@ -require "test_helper" -require "pressa/web/job" - -class Pressa::Web::JobTest < Minitest::Test - def job(**overrides) - defaults = {id: "abc123", kind: "publish_link", label: "Tree Well Protocol"} - Pressa::Web::Job.new(**defaults.merge(overrides)) - end - - def test_starts_out_running_with_no_lines - entry = job - - assert_predicate(entry, :running?) - refute_predicate(entry, :finished?) - assert_empty(entry.lines) - assert_nil(entry.result) - assert_nil(entry.finished_at) - end - - def test_append_collects_lines_in_order - entry = job - entry.append("==> Pulling latest") - entry.append("==> Creating link post") - - assert_equal(["==> Pulling latest", "==> Creating link post"], entry.lines) - end - - def test_lines_returns_a_snapshot_that_cannot_mutate_the_job - entry = job - entry.append("one") - entry.lines << "two" - - assert_equal(["one"], entry.lines) - end - - def test_succeed_records_the_result_and_finishes - entry = job - entry.succeed("posts/2026/06/tree-well-protocol.md") - - assert_predicate(entry, :finished?) - refute_predicate(entry, :running?) - assert_equal(:succeeded, entry.state) - assert_equal("posts/2026/06/tree-well-protocol.md", entry.result) - assert(entry.finished_at) - end - - def test_fail_records_the_error_and_finishes - entry = job - entry.fail("rsync exited with 23") - - assert_predicate(entry, :finished?) - assert_equal(:failed, entry.state) - assert_equal("rsync exited with 23", entry.error) - end - - def test_subscribe_hands_back_the_backlog_and_then_live_lines - entry = job - entry.append("==> Pulling latest") - - backlog, queue = entry.subscribe - entry.append("==> Building") - - assert_equal(["==> Pulling latest"], backlog) - assert_equal("==> Building", queue.pop) - end - - def test_subscribers_are_woken_when_the_job_finishes - entry = job - _backlog, queue = entry.subscribe - entry.succeed("done") - - assert_nil(queue.pop) - end - - def test_subscribing_to_a_finished_job_yields_the_backlog_and_an_immediate_end - entry = job - entry.append("==> Building") - entry.succeed("done") - - backlog, queue = entry.subscribe - - assert_equal(["==> Building"], backlog) - assert_nil(queue.pop) - end - - def test_unsubscribe_stops_delivery - entry = job - _backlog, queue = entry.subscribe - entry.unsubscribe(queue) - entry.append("==> Building") - - assert_predicate(queue, :empty?) - end - - def test_to_h_carries_what_the_status_stream_needs - entry = job - entry.append("==> Building") - entry.succeed("posts/2026/06/tree-well-protocol.md") - - payload = entry.to_h - - assert_equal("abc123", payload[:id]) - assert_equal("publish_link", payload[:kind]) - assert_equal("Tree Well Protocol", payload[:label]) - assert_equal("succeeded", payload[:state]) - assert_equal("posts/2026/06/tree-well-protocol.md", payload[:result]) - assert_nil(payload[:error]) - end - - def test_append_ignores_lines_once_the_job_has_finished - entry = job - entry.succeed("done") - entry.append("too late") - - assert_empty(entry.lines) - end -end diff --git a/web/public/style.css b/web/public/style.css index a1775ea4..9888ba5d 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -75,6 +75,8 @@ h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color } .card.bad { border-color: var(--bad); } +.card.good { border-color: var(--ok); } +.card.good strong { color: var(--ok); } .card.bad p { margin: 0 0 0.5rem; } .card.bad p:last-child { margin-bottom: 0; } @@ -146,20 +148,6 @@ button:disabled { opacity: 0.6; cursor: default; } text-decoration: none; } -.pill { - font-size: 0.72rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - padding: 0.2rem 0.5rem; - border-radius: 999px; - border: 1px solid currentColor; -} - -.pill.running { color: var(--running); } -.pill.succeeded { color: var(--ok); } -.pill.failed { color: var(--bad); } - pre { margin: 0; padding: 0.75rem; diff --git a/web/views/job.erb b/web/views/job.erb deleted file mode 100644 index 1bccec34..00000000 --- a/web/views/job.erb +++ /dev/null @@ -1,41 +0,0 @@ -

<%= h(@job.label || @job.kind) %>

- -
-

- <%= h(@job.state) %> - - <% if @job.result %><%= h(@job.result) %><% end %> - <% if @job.error %><%= h(@job.error) %><% end %> - -

-
<% @job.lines.each do |line| %><%= h(line) %>
-<% end %>
-
- -

Post another link · Drafts

- - diff --git a/web/views/jobs.erb b/web/views/jobs.erb deleted file mode 100644 index bfc22ece..00000000 --- a/web/views/jobs.erb +++ /dev/null @@ -1,16 +0,0 @@ -

Jobs

-<% if @jobs.empty? %> -

Nothing has run yet.

-<% else %> - -<% end %> diff --git a/web/views/layout.erb b/web/views/layout.erb index 3b127408..2c761daa 100644 --- a/web/views/layout.erb +++ b/web/views/layout.erb @@ -13,19 +13,29 @@
+ <% if @published %> +
+ Published +

<%= h(@published) %>

+
+ <% end %> + <% if @error %>

<%= h(@error) %>

- <% if @busy %> -

Watch <%= h(@busy.label || @busy.kind) %> →

- <% end %>
<% end %> + + <% if @log && !@log.empty? %> +
> + Publish log +
<%= h(@log.join("\n")) %>
+
+ <% end %> <%= yield %>
From 710e9fe0bcd7dc3bee3a6dcff9e02f2fd6ead713 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 29 Aug 2026 11:57:02 -0700 Subject: [PATCH 12/15] Give the OpenGraph fetch one deadline for the whole thing Timeouts were per-hop: five seconds to connect plus five to read, across up to six redirects, so a hanging server could hold a fetch open for the better part of a minute. That was survivable when it ran in a background job. Now that publishing blocks, it's a minute the browser spends waiting. The whole fetch shares a five second budget, redirects included, and each hop gets only what's left of it. Real sites measure 0.11s to 0.85s, so there's plenty of headroom. Covered by a test against a socket that accepts and never answers. --- lib/pressa/open_graph.rb | 21 +++++++++++++++++--- test/open_graph_test.rb | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/lib/pressa/open_graph.rb b/lib/pressa/open_graph.rb index 1f66fdaf..3277e4f6 100644 --- a/lib/pressa/open_graph.rb +++ b/lib/pressa/open_graph.rb @@ -13,6 +13,11 @@ class OpenGraph USER_AGENT = "samhuri.net-link-preview/1.0".freeze MAX_REDIRECTS = 5 + # One budget for the whole fetch, redirects included. Publishing blocks on + # this, and a per-hop timeout let a hanging server hold the request open for + # five seconds of connect plus five of read, six hops deep. The slowest real + # site measured 0.85s, so five seconds is generous. + TOTAL_TIMEOUT_SECONDS = 5 TITLE_ELEMENT = /]*>([^<]*)<\/title>/i def self.fetch(url, http_get: method(:http_get)) @@ -59,13 +64,20 @@ def self.resolve(image, base_url:) image end - def self.http_get(url, redirects_left: MAX_REDIRECTS) + def self.http_get(url, redirects_left: MAX_REDIRECTS, timeout: TOTAL_TIMEOUT_SECONDS, deadline: nil) return nil if redirects_left < 0 + deadline ||= monotonic_now + timeout + remaining = deadline - monotonic_now + return nil unless remaining > 0 + uri = URI.parse(url) return nil unless uri.is_a?(URI::HTTP) - Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http| + Net::HTTP.start( + uri.host, uri.port, use_ssl: uri.scheme == "https", + open_timeout: remaining, read_timeout: remaining + ) do |http| response = http.get(uri.request_uri, "User-Agent" => USER_AGENT) case response @@ -75,9 +87,12 @@ def self.http_get(url, redirects_left: MAX_REDIRECTS) location = response["location"] return nil unless location - http_get(URI.join(url, location).to_s, redirects_left: redirects_left - 1) + # Redirects inherit the budget rather than getting a fresh one. + http_get(URI.join(url, location).to_s, redirects_left: redirects_left - 1, deadline:) end end end + + def self.monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC) end end diff --git a/test/open_graph_test.rb b/test/open_graph_test.rb index a2e6de98..32afa64f 100644 --- a/test/open_graph_test.rb +++ b/test/open_graph_test.rb @@ -1,5 +1,6 @@ require "test_helper" require "pressa/open_graph" +require "socket" class Pressa::OpenGraphTest < Minitest::Test def test_extract_returns_og_image_resolved_against_base_url @@ -99,4 +100,44 @@ def test_fetch_returns_nil_instead_of_raising_on_network_errors assert_nil(result) end + + # --- giving up ------------------------------------------------------------ + # + # Publishing blocks on this fetch, so a hanging third-party server used to be + # able to hold the request open for the better part of a minute: five seconds + # of connect plus five of read, per hop, across six possible hops. + + def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + def test_http_get_gives_up_on_a_server_that_accepts_and_never_answers + server = TCPServer.new("127.0.0.1", 0) + accepting = Thread.new { loop { server.accept } } + started = monotonic + + url = "http://127.0.0.1:#{server.addr[1]}/" + result = Pressa::OpenGraph.fetch(url, http_get: ->(u) { Pressa::OpenGraph.http_get(u, timeout: 0.5) }) + elapsed = monotonic - started + + assert_nil(result) + assert_operator(elapsed, :<, 3, "should have given up on its own budget, took #{elapsed}s") + ensure + accepting&.kill + server&.close + end + + def test_http_get_does_not_start_a_request_once_the_budget_is_spent + server = TCPServer.new("127.0.0.1", 0) + accepting = Thread.new { loop { server.accept } } + + result = Pressa::OpenGraph.http_get("http://127.0.0.1:#{server.addr[1]}/", deadline: monotonic - 1) + + assert_nil(result) + ensure + accepting&.kill + server&.close + end + + def test_the_whole_fetch_shares_one_budget_across_redirects + assert_equal(5, Pressa::OpenGraph::TOTAL_TIMEOUT_SECONDS) + end end From 7d2628b2372c371361e1c1eff260f97c99946768 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 29 Aug 2026 11:57:02 -0700 Subject: [PATCH 13/15] Fix what driving the UI in Safari turned up Publishing rendered straight from the POST, so the browser sat on /link with a form result and a reload would publish the post a second time. Success now redirects with the published path in the query string; a failure still renders inline, since that's the case where the form contents and the log are worth keeping. The draft editor was headed by the slug rather than the draft's own title. The link form's URL field autofocuses, which is what you want on a form whose whole job is pasting a link. "Quote the description" was stretching to full width in the flex column and reading as a primary action. And the preview breaks out of the reading column on wide screens, so the web pane gets enough room to show something other than the mobile layout. None of these were visible from the test suite, because none of the tests run a browser. --- lib/pressa/web/app.rb | 19 +++++++++++++----- test/web/app_test.rb | 46 ++++++++++++++++++++++++++++++++----------- web/public/style.css | 10 ++++++++++ web/views/draft.erb | 3 ++- web/views/link.erb | 2 +- 5 files changed, 61 insertions(+), 19 deletions(-) diff --git a/lib/pressa/web/app.rb b/lib/pressa/web/app.rb index dbb0c2f0..43009423 100644 --- a/lib/pressa/web/app.rb +++ b/lib/pressa/web/app.rb @@ -161,6 +161,7 @@ def json_error(status, message) = halt(status, {error: message}.to_json) get "/" do @form = {} @tags = tag_chips + @published = params[:published] erb :link end @@ -175,10 +176,11 @@ def json_error(status, message) = halt(status, {error: message}.to_json) payload = @form.reject { |_key, value| value.to_s.empty? }.to_json status = run_publish(command: [repo_path("bin", "post-link")], stdin_data: payload) + # A failure keeps the form and the log on screen so it can be retried. halt status, erb(:link) if status - @form = {} - erb :link + # Success redirects so a reload can't publish the same post twice. + redirect to("/?published=#{Rack::Utils.escape(@published)}"), 303 end get "/link/metadata" do @@ -213,6 +215,7 @@ def json_error(status, message) = halt(status, {error: message}.to_json) get "/drafts" do @drafts = drafts.list + @published = params[:published] erb :drafts end @@ -240,6 +243,12 @@ def json_error(status, message) = halt(status, {error: message}.to_json) erb :draft end + # The editor holds raw markdown, so its own front matter is the only + # place a readable title can come from. + def self.draft_title(source, slug) + source[/^Title:\s*(.+)$/, 1]&.strip&.then { it.empty? ? nil : it } || slug + end + post "/drafts/:slug" do @slug = params[:slug] find_draft(@slug) @@ -272,9 +281,9 @@ def json_error(status, message) = halt(status, {error: message}.to_json) status = run_publish(command: [repo_path("bin", "publish-draft"), @slug]) halt status, erb(:draft) if status - # The draft is gone now, so there's nothing left to edit. - @drafts = drafts.list - erb :drafts + # The draft is gone now, so there's nothing left to edit -- and the + # redirect keeps a reload from trying to publish it again. + redirect to("/drafts?published=#{Rack::Utils.escape(@published)}"), 303 end post "/drafts/:slug/delete" do diff --git a/test/web/app_test.rb b/test/web/app_test.rb index 7a6e4b1c..f254457c 100644 --- a/test/web/app_test.rb +++ b/test/web/app_test.rb @@ -110,18 +110,26 @@ def test_the_home_page_offers_tags_already_in_use_as_chips assert_includes(last_response.body, "safety") end - def test_posting_a_link_publishes_and_shows_the_result + # Rendering straight from the POST meant a reload republished the post. + def test_posting_a_link_publishes_and_redirects_to_the_result post "/link", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol", body: "Never ride alone.", tags: "Snowboarding, Safety" + assert_equal(303, last_response.status) + assert_includes(last_response.headers["Location"], "published=posts%2F2026%2F06%2Fnew-post.md") + assert_equal(1, publish_attempts) + end + + def test_the_result_shows_on_the_page_the_redirect_lands_on + get "/", published: "posts/2026/06/new-post.md" + assert_predicate(last_response, :ok?) assert_includes(last_response.body, "posts/2026/06/new-post.md") - assert_includes(last_response.body, "==> Building") - assert_equal(1, publish_attempts) + assert_includes(last_response.body, "Published") end - def test_a_successful_publish_clears_the_form - post "/link", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol" + def test_a_successful_publish_leaves_an_empty_form_to_land_on + get "/", published: "posts/2026/06/new-post.md" refute_includes(last_response.body, %(value="https://powder.example.net/tree-wells")) end @@ -258,6 +266,20 @@ def test_creating_a_draft_with_an_unusable_title_is_refused assert_equal(422, last_response.status) end + def test_the_editor_is_headed_by_the_drafts_title_not_its_slug + get "/drafts/lift-line-notes" + + assert_includes(last_response.body, "

Lift Line Notes

") + assert_includes(last_response.body, "lift-line-notes.md") + end + + def test_the_editor_falls_back_to_the_slug_when_there_is_no_title + File.write(File.join(@root, "public/drafts/lift-line-notes.md"), "no front matter\n") + get "/drafts/lift-line-notes" + + assert_includes(last_response.body, "

lift-line-notes

") + end + def test_the_editor_shows_the_draft_source get "/drafts/lift-line-notes" @@ -305,11 +327,11 @@ def test_a_save_with_no_version_at_all_is_refused refute_includes(File.read(draft_path), "From a tab opened before the deploy.") end - def test_publishing_a_draft_runs_the_script_and_shows_the_result + def test_publishing_a_draft_runs_the_script_and_redirects_to_the_result post "/drafts/lift-line-notes/publish" - assert_predicate(last_response, :ok?) - assert_includes(last_response.body, "posts/2026/06/lift-line-notes.md") + assert_equal(303, last_response.status) + assert_includes(last_response.headers["Location"], "published=posts%2F2026%2F06%2Flift-line-notes.md") assert_equal(1, publish_attempts) end @@ -414,19 +436,19 @@ def test_a_same_site_publish_is_refused_too def test_a_same_origin_publish_goes_through post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("same-origin") - assert_predicate(last_response, :ok?) + assert_equal(303, last_response.status) end def test_a_user_initiated_publish_goes_through post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("none") - assert_predicate(last_response, :ok?) + assert_equal(303, last_response.status) end def test_a_request_with_no_browser_headers_goes_through post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" - assert_predicate(last_response, :ok?) + assert_equal(303, last_response.status) end def test_an_older_browser_falls_back_to_the_origin_header @@ -441,7 +463,7 @@ def test_a_matching_origin_header_goes_through post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, {"HTTP_ORIGIN" => "http://example.org"} - assert_predicate(last_response, :ok?) + assert_equal(303, last_response.status) end def test_cross_site_draft_deletion_is_refused diff --git a/web/public/style.css b/web/public/style.css index 9888ba5d..e811cc24 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -124,6 +124,10 @@ button:disabled { opacity: 0.6; cursor: default; } .chips { display: flex; flex-wrap: wrap; gap: 0.4rem; } +/* A lone chip in a flex column would stretch to full width and read as a + primary action; it's a small affordance. */ +.stack > .chip { align-self: flex-start; } + .chip { font-size: 0.8rem; font-weight: 500; @@ -172,4 +176,10 @@ pre { .panes { grid-template-columns: 1fr 1fr; } } +/* Break the preview out of the reading column so the web pane gets enough + width to render something other than the mobile layout. */ +@media (min-width: 60rem) { + .preview { margin-inline: -6rem; } +} + p.bad { color: var(--bad); } diff --git a/web/views/draft.erb b/web/views/draft.erb index fd75ac8d..81c886b7 100644 --- a/web/views/draft.erb +++ b/web/views/draft.erb @@ -1,4 +1,5 @@ -

<%= h(@slug) %>

+

<%= h(Pressa::Web::App.draft_title(@source, @slug)) %>

+

<%= h(@slug) %>.md

diff --git a/web/views/link.erb b/web/views/link.erb index 75d9a6f0..8c3461ac 100644 --- a/web/views/link.erb +++ b/web/views/link.erb @@ -4,7 +4,7 @@ From 980abd2606d8758c803b593cb73436255cb4f513 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 29 Aug 2026 12:12:33 -0700 Subject: [PATCH 14/15] Run the publish scripts for real in the test suite bin/post-link and bin/publish-draft are 49 lines of shell that the phone Shortcut runs over SSH and the web app spawns, and they're where this repo's git bugs hide: a `git diff --quiet` that reported untracked files as clean meant publishing a newly created draft failed every time, and nothing in 300 Ruby tests could see it. These run the actual scripts against a temporary git repo with a real bare remote, stubbing only bake, whose tasks are unit tested separately. They cover the untracked draft, leaving other drafts alone, filename and slug arguments, the missing draft and usage errors, and that post-link puts its payload on bake's stdin and pushes what it commits. Reintroducing the old `git diff --quiet` makes the untracked-draft test fail, which is the point. The repo and its remote cost more to build than the scripts cost to run, so they're built once and copied per test: 3.4s rather than 4.7s, out of a suite that now takes 6.3s. --- AGENTS.md | 1 + test/bin/publish_scripts_test.rb | 191 +++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 test/bin/publish_scripts_test.rb diff --git a/AGENTS.md b/AGENTS.md index f657ad47..634557d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`. ## Testing Guidelines - Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`). +- `test/bin/publish_scripts_test.rb` runs `bin/post-link` and `bin/publish-draft` for real, against a temporary git repo with a real remote and a stubbed `bake.rb`. It's the slowest file in the suite (~3s of the ~6s total) and it's there because git sequencing bugs in those scripts are invisible to everything else. - Add regression tests for parser, rendering, feed, and generator behavior changes. - Before submitting, run: - `bundle exec bake test` diff --git a/test/bin/publish_scripts_test.rb b/test/bin/publish_scripts_test.rb new file mode 100644 index 00000000..e31d581c --- /dev/null +++ b/test/bin/publish_scripts_test.rb @@ -0,0 +1,191 @@ +require "test_helper" +require "fileutils" +require "open3" +require "tmpdir" + +# The publish scripts are what the phone Shortcut runs over SSH and what the +# web app spawns, and they're the part of this repo that git bugs hide in: a +# `git diff` that called untracked drafts clean once made publishing a newly +# created draft fail every time, and nothing in the Ruby suite could see it. +# +# So these run the real scripts against a real repo with a real remote. Only +# bake is stubbed, since its tasks are unit tested on their own. +class PublishScriptsTest < Minitest::Test + SOURCE_REPO = File.expand_path("../..", __dir__) + + STUB_BAKE = <<~RUBY_SOURCE + require "fileutils" + + def new_link + File.write("last-payload.json", $stdin.read) + path = "posts/2026/08/stub-link.md" + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "a link post\\n") + puts path + end + + def publish_draft(input_path) + name = File.basename(input_path) + path = File.join("posts/2026/08", name) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, File.read(input_path)) + FileUtils.rm_f(input_path) + warn "Published draft: \#{input_path} -> \#{path}" + puts path + end + + def publish + File.write("published.marker", "deployed") + warn "Site built successfully" + end + RUBY_SOURCE + + # Building the repo and its remote costs more than running the scripts, so + # it's built once and copied per test rather than rebuilt each time. + def self.template + @template ||= begin + root = Dir.mktmpdir("pressa-scripts-template") + at_exit { FileUtils.remove_entry(root) rescue nil } # rubocop:disable Style/RescueModifier + remote = File.join(root, "remote.git") + work = File.join(root, "work") + git = ->(*args, chdir: work) do + out, status = Open3.capture2e("git", *args, chdir:) + raise "git #{args.join(" ")} failed: #{out}" unless status.success? + end + + git.call("init", "-q", "--bare", remote, chdir: root) + git.call("clone", "-q", remote, work, chdir: root) + git.call("config", "user.email", "sami@example.net") + git.call("config", "user.name", "Sami Samhuri") + + FileUtils.cp_r(File.join(SOURCE_REPO, "bin"), work) + %w[Gemfile Gemfile.lock .ruby-version].each { FileUtils.cp(File.join(SOURCE_REPO, it), work) } + File.write(File.join(work, "bake.rb"), STUB_BAKE) + FileUtils.mkdir_p(File.join(work, "public/drafts")) + FileUtils.mkdir_p(File.join(work, "posts")) + + File.write(File.join(work, "README.md"), "seed\n") + git.call("add", "-A") + git.call("commit", "-qm", "seed") + git.call("branch", "-M", "main") + git.call("push", "-q", "origin", "main") + root + end + end + + def setup + skip "git is required" unless system("git", "--version", out: File::NULL, err: File::NULL) + + @root = Dir.mktmpdir + FileUtils.cp_r(File.join(self.class.template, "."), @root) + @remote = File.join(@root, "remote.git") + @work = File.join(@root, "work") + # The clone records an absolute path to the remote it came from. + git!("remote", "set-url", "origin", @remote) + end + + def teardown + FileUtils.remove_entry(@root) if @root + end + + def git!(*args, chdir: @work) + out, status = Open3.capture2e("git", *args, chdir:) + raise "git #{args.join(" ")} failed: #{out}" unless status.success? + out + end + + def run_script(name, *args, stdin: "") + Open3.capture3( + {"SAMHURI_REPO" => @work}, File.join(@work, "bin", name), *args, + stdin_data: stdin, chdir: @work + ) + end + + def write_draft(slug, title: "Tree Well Protocol", body: "Never ride alone.") + File.write(File.join(@work, "public/drafts/#{slug}.md"), <<~MARKDOWN) + --- + Author: Jane Doe + Title: #{title} + Date: unpublished + Timestamp: 2026-06-01T09:00:00-07:00 + --- + + #{body} + MARKDOWN + end + + def commit_subjects = git!("log", "--format=%s").lines.map(&:chomp) + + def pushed_subjects = git!("--git-dir", @remote, "log", "--format=%s", "main", chdir: @root).lines.map(&:chomp) + + # --- post-link ------------------------------------------------------------ + + def test_post_link_writes_commits_pushes_and_deploys + payload = %({"title":"Tree Well Protocol","link":"https://powder.example.net/x"}) + stdout, _stderr, status = run_script("post-link", stdin: payload) + + assert_predicate(status, :success?) + assert_equal("posts/2026/08/stub-link.md", stdout.strip) + assert_equal(payload, File.read(File.join(@work, "last-payload.json"))) + assert_includes(commit_subjects, "Add link post: stub-link") + assert_includes(pushed_subjects, "Add link post: stub-link") + assert(File.exist?(File.join(@work, "published.marker")), "should have run bake publish") + end + + def test_post_link_refuses_an_empty_payload + _stdout, stderr, status = run_script("post-link", stdin: " \n") + + refute_predicate(status, :success?) + assert_match(/empty payload/, stderr) + assert_equal(["seed"], commit_subjects) + end + + # --- publish-draft -------------------------------------------------------- + + def test_publish_draft_commits_an_untracked_draft_before_publishing_it + write_draft("tree-well-protocol") + stdout, _stderr, status = run_script("publish-draft", "tree-well-protocol") + + assert_predicate(status, :success?) + assert_equal("posts/2026/08/tree-well-protocol.md", stdout.strip) + assert_includes(commit_subjects, "Update draft: tree-well-protocol") + assert_includes(commit_subjects, "Publish draft: tree-well-protocol") + assert_includes(pushed_subjects, "Publish draft: tree-well-protocol") + refute(File.exist?(File.join(@work, "public/drafts/tree-well-protocol.md"))) + end + + def test_publish_draft_leaves_other_drafts_uncommitted + write_draft("other-draft", title: "Other") + git!("add", "public/drafts/other-draft.md") + git!("commit", "-qm", "add other draft") + File.write(File.join(@work, "public/drafts/other-draft.md"), "half-finished edit\n") + + write_draft("tree-well-protocol") + _stdout, _stderr, status = run_script("publish-draft", "tree-well-protocol") + + assert_predicate(status, :success?) + assert_equal("half-finished edit\n", File.read(File.join(@work, "public/drafts/other-draft.md"))) + refute_empty(git!("status", "--porcelain", "--", "public/drafts/other-draft.md")) + end + + def test_publish_draft_accepts_a_filename_as_well_as_a_slug + write_draft("tree-well-protocol") + _stdout, _stderr, status = run_script("publish-draft", "tree-well-protocol.md") + + assert_predicate(status, :success?) + end + + def test_publish_draft_refuses_a_draft_that_is_not_there + _stdout, stderr, status = run_script("publish-draft", "nope") + + refute_predicate(status, :success?) + assert_match(%r{no draft at public/drafts/nope\.md}, stderr) + end + + def test_publish_draft_needs_an_argument + _stdout, stderr, status = run_script("publish-draft") + + refute_predicate(status, :success?) + assert_match(/Usage:/, stderr) + end +end From ee4aef58cf30006345b84c37f63d14df231e9e5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:19:30 +0000 Subject: [PATCH 15/15] Bump puma from 6.6.1 to 7.2.1 Bumps [puma](https://github.com/puma/puma) from 6.6.1 to 7.2.1. - [Release notes](https://github.com/puma/puma/releases) - [Changelog](https://github.com/puma/puma/blob/main/History.md) - [Commits](https://github.com/puma/puma/compare/v6.6.1...v7.2.1) --- updated-dependencies: - dependency-name: puma dependency-version: 7.2.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index b77b1d15..aa59a39e 100644 --- a/Gemfile +++ b/Gemfile @@ -14,7 +14,7 @@ gem "bake", "~> 0.20" # Gemfile would leave BUNDLE_GEMFILE pointing at the wrong one in the child. group :web do gem "sinatra", "~> 4.1" - gem "puma", "~> 6.6" + gem "puma", "~> 7.2" gem "super_good-csrf_protection", "~> 0.2" end diff --git a/Gemfile.lock b/Gemfile.lock index dd55d249..2c803697 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,7 +100,7 @@ GEM coderay (~> 1.1) method_source (~> 1.0) reline (>= 0.6.0) - puma (6.6.1) + puma (7.2.1) nio4r (~> 2.0) racc (1.8.1) rack (3.2.7) @@ -197,7 +197,7 @@ DEPENDENCIES kramdown-parser-gfm (~> 1.1) minitest (~> 6.0) phlex (~> 2.3) - puma (~> 6.6) + puma (~> 7.2) rack-test (~> 2.2) rouge (~> 5.0) sinatra (~> 4.1)