From c985e17ae3387393e21afc7fc8ef851e68f4fb59 Mon Sep 17 00:00:00 2001 From: Olly Headey Date: Thu, 2 Jul 2026 17:41:20 +0100 Subject: [PATCH] Add password protection for blogs Blog owners can set a password that gates the entire blog. Visitors are redirected to a themed unlock page and, on the correct password, an encrypted per-host cookie storing the blog's password_digest is set so the unlock survives changes to the session cookie and auto-invalidates when the password changes or is removed. Protected blogs skip Cloudflare edge caching so locked content is never served publicly. --- .../app/settings/blogs_controller.rb | 6 +- app/controllers/blogs/base_controller.rb | 10 ++- app/controllers/blogs/unlock_controller.rb | 25 ++++++ app/models/blog.rb | 6 ++ app/views/app/settings/blogs/_form.html.erb | 26 ++++++ app/views/blogs/unlock/new.html.erb | 17 ++++ config/routes.rb | 3 + ...0702145149_add_password_digest_to_blogs.rb | 5 ++ db/schema.rb | 48 ++++++++++- .../app/settings/blogs_controller_test.rb | 17 ++++ .../blogs/unlock_controller_test.rb | 79 +++++++++++++++++++ test/models/blog_test.rb | 27 +++++++ 12 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 app/controllers/blogs/unlock_controller.rb create mode 100644 app/views/blogs/unlock/new.html.erb create mode 100644 db/migrate/20260702145149_add_password_digest_to_blogs.rb create mode 100644 test/controllers/blogs/unlock_controller_test.rb diff --git a/app/controllers/app/settings/blogs_controller.rb b/app/controllers/app/settings/blogs_controller.rb index c92c441a7..a3349b17f 100644 --- a/app/controllers/app/settings/blogs_controller.rb +++ b/app/controllers/app/settings/blogs_controller.rb @@ -5,6 +5,8 @@ def index def update on_demand_tls = ENV["ON_DEMAND_TLS"].present? + @blog.password = nil if params.dig(:blog, :remove_password) == "1" + if @blog.update(blog_params) if @blog.domain_changed? if @blog.custom_domain_previously_was.present? @@ -32,7 +34,9 @@ def blog_params :google_site_verification, :seo_title, :locale, - :show_metrics + :show_metrics, + :password, + :password_confirmation ] if @blog.user.subscribed? diff --git a/app/controllers/blogs/base_controller.rb b/app/controllers/blogs/base_controller.rb index 695bf63be..c90b85a10 100644 --- a/app/controllers/blogs/base_controller.rb +++ b/app/controllers/blogs/base_controller.rb @@ -2,7 +2,7 @@ class Blogs::BaseController < ApplicationController layout "blog" skip_before_action :domain_check - before_action :load_blog, :validate_user, :enforce_custom_domain, :set_locale, :reject_malicious_params + before_action :load_blog, :validate_user, :enforce_custom_domain, :require_blog_password, :set_locale, :reject_malicious_params rescue_from ActiveRecord::RecordNotFound, with: :render_blog_not_found rescue_from ActionController::TooManyRequests, with: :render_too_many_requests @@ -38,6 +38,13 @@ def validate_user redirect_to_app_home unless @blog.user&.verified? && @blog.user&.kept? end + def require_blog_password + return unless @blog&.password_protected? + return if cookies.encrypted[:blog_unlock] == @blog.password_digest + + redirect_to blog_unlock_path(return_to: request.fullpath) + end + def blog_from_custom_domain Blog.find_by_domain_with_www_fallback(request.host) end @@ -101,6 +108,7 @@ def reject_malicious_params # Custom domains are not edge-cached (they route through Caddy, not Cloudflare). # No-op unless Cloudflare credentials are configured. def set_blog_cache_headers + return if @blog.password_protected? return unless default_domain_request? return unless Rails.env.production? && ENV["CLOUDFLARE_ZONE_ID"].present? && ENV["CLOUDFLARE_API_TOKEN"].present? diff --git a/app/controllers/blogs/unlock_controller.rb b/app/controllers/blogs/unlock_controller.rb new file mode 100644 index 000000000..fa4a78fd9 --- /dev/null +++ b/app/controllers/blogs/unlock_controller.rb @@ -0,0 +1,25 @@ +class Blogs::UnlockController < Blogs::BaseController + skip_before_action :require_blog_password + rate_limit to: 10, within: 1.minute, only: :create + + def new + redirect_to "/" unless @blog.password_protected? + end + + def create + if @blog.authenticate(params[:password]) + cookies.encrypted[:blog_unlock] = { value: @blog.password_digest, expires: 30.days, httponly: true } + redirect_to safe_return_to + else + flash.now[:alert] = "Incorrect password" + render :new, status: :unprocessable_entity + end + end + + private + + def safe_return_to + path = params[:return_to].to_s + path.start_with?("/") && !path.start_with?("//") ? path : "/" + end +end diff --git a/app/models/blog.rb b/app/models/blog.rb index 2b930474f..e2330e658 100644 --- a/app/models/blog.rb +++ b/app/models/blog.rb @@ -4,6 +4,8 @@ class Blog < ApplicationRecord enum :layout, [ :stream_layout, :title_layout, :cards_layout ] + has_secure_password validations: false + belongs_to :user, inverse_of: :blogs MAX_BLOGS_FREE = 1 @@ -50,6 +52,10 @@ def display_name title.blank? ? "@#{subdomain}" : title end + def password_protected? + password_digest.present? + end + private def within_blog_limit diff --git a/app/views/app/settings/blogs/_form.html.erb b/app/views/app/settings/blogs/_form.html.erb index 095f09bd5..7334c9989 100644 --- a/app/views/app/settings/blogs/_form.html.erb +++ b/app/views/app/settings/blogs/_form.html.erb @@ -70,6 +70,32 @@ +
+

Password Protection

+

+ Require a password to read your blog. Everyone visiting your blog will need to enter it before they can see any of your posts or pages. Useful for a private family journal or travelogue. +

+ +
+ <%= form.label :password, "Password", class: "sr-only" %> + <%= form.password_field :password, placeholder: @blog.password_protected? ? "Enter a new password to change it" : "Set a password", autocomplete: "new-password", class: "form-field mt-4 md:w-96" %> + <%= field_error(@blog, :password) %> +
+ + <% if @blog.password_protected? %> +
+
+
+ <%= check_box_tag "blog[remove_password]", "1", false, class: "form-checkbox h-5 w-5 text-slate-600 border-slate-300 dark:border-slate-600 dark:bg-slate-700 rounded focus:ring focus:ring-slate-300 dark:focus:ring-slate-600 focus:ring-offset-0" %> +
+
+ <%= label_tag "blog_remove_password", "Remove password protection", class: "text-slate-800 dark:text-slate-100" %> +
+
+
+ <% end %> +
+

Google Site Verification

diff --git a/app/views/blogs/unlock/new.html.erb b/app/views/blogs/unlock/new.html.erb new file mode 100644 index 000000000..88c916ee8 --- /dev/null +++ b/app/views/blogs/unlock/new.html.erb @@ -0,0 +1,17 @@ +<%= render "blogs/header", hide_email_form: true, hide_bio: true %> + +

+
+

This blog is password protected 🔒

+ + <%= form_with url: blog_unlock_path, method: :post, data: { turbo: false }, class: "space-y-4" do |form| %> + <%= hidden_field_tag :return_to, params[:return_to] %> + <%= form.password_field :password, + placeholder: "Password", + autofocus: true, + required: true, + class: "w-full sm:w-96 px-3 py-2.5 form-field rounded-lg" %> + <%= form.submit "Unlock", class: "btn-primary" %> + <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 96a87a7fa..ab4b43389 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -291,6 +291,9 @@ def subdomain_redirect(path = "/") post "embeds/bandcamp", to: "embeds#bandcamp" end + get "/unlock", to: "blogs/unlock#new", as: :blog_unlock + post "/unlock", to: "blogs/unlock#create" + get "/:slug", to: "blogs/posts#show", as: :blog_post resources :email_subscribers, controller: "blogs/email_subscribers", only: [ :create, :destroy ] diff --git a/db/migrate/20260702145149_add_password_digest_to_blogs.rb b/db/migrate/20260702145149_add_password_digest_to_blogs.rb new file mode 100644 index 000000000..ae8525962 --- /dev/null +++ b/db/migrate/20260702145149_add_password_digest_to_blogs.rb @@ -0,0 +1,5 @@ +class AddPasswordDigestToBlogs < ActiveRecord::Migration[8.2] + def change + add_column :blogs, :password_digest, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 5b70249e9..6817dc877 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_06_02_130000) do +ActiveRecord::Schema[8.2].define(version: 2026_07_02_145149) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -130,6 +130,8 @@ t.bigint "user_id", null: false t.string "width", default: "standard", null: false t.text "custom_footer_html" + t.boolean "external_links_in_new_tab", default: false, null: false + t.string "password_digest" t.index ["api_key_digest"], name: "index_blogs_on_api_key_digest", unique: true t.index ["custom_domain"], name: "index_blogs_on_custom_domain", unique: true, where: "(custom_domain IS NOT NULL)" t.index ["home_page_id"], name: "index_blogs_on_home_page_id" @@ -301,6 +303,7 @@ t.string "canonical_url" t.datetime "created_at", null: false t.datetime "discarded_at" + t.text "excerpt" t.boolean "hidden", default: false, null: false t.boolean "is_page", default: false, null: false t.string "locale" @@ -366,6 +369,46 @@ t.index ["status"], name: "index_spam_detections_on_status" end + create_table "standard_site_accounts", force: :cascade do |t| + t.bigint "blog_id", null: false + t.string "handle", null: false + t.string "did", null: false + t.string "pds_url", default: "https://bsky.social", null: false + t.text "access_jwt_ciphertext" + t.text "refresh_jwt_ciphertext" + t.datetime "connected_at" + t.datetime "disconnected_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["blog_id"], name: "index_standard_site_accounts_on_blog_id", unique: true + end + + create_table "standard_site_documents", force: :cascade do |t| + t.bigint "post_id", null: false + t.string "at_uri" + t.string "cid" + t.string "rkey", null: false + t.integer "sync_status", default: 0, null: false + t.datetime "last_synced_at" + t.text "sync_error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["post_id"], name: "index_standard_site_documents_on_post_id", unique: true + end + + create_table "standard_site_publications", force: :cascade do |t| + t.bigint "blog_id", null: false + t.string "at_uri" + t.string "cid" + t.string "rkey", default: "self", null: false + t.integer "sync_status", default: 0, null: false + t.datetime "last_synced_at" + t.text "sync_error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["blog_id"], name: "index_standard_site_publications_on_blog_id", unique: true + end + create_table "subscription_renewal_reminders", force: :cascade do |t| t.datetime "created_at", null: false t.string "period", null: false @@ -475,6 +518,9 @@ add_foreign_key "posts", "blogs" add_foreign_key "sender_email_addresses", "blogs" add_foreign_key "spam_detections", "blogs" + add_foreign_key "standard_site_accounts", "blogs" + add_foreign_key "standard_site_documents", "posts" + add_foreign_key "standard_site_publications", "blogs" add_foreign_key "subscription_renewal_reminders", "subscriptions" add_foreign_key "subscriptions", "users" add_foreign_key "unengaged_follow_ups", "users" diff --git a/test/controllers/app/settings/blogs_controller_test.rb b/test/controllers/app/settings/blogs_controller_test.rb index e46e7c0c6..2391dea0d 100644 --- a/test/controllers/app/settings/blogs_controller_test.rb +++ b/test/controllers/app/settings/blogs_controller_test.rb @@ -206,4 +206,21 @@ class App::Settings::BlogsControllerTest < ActionDispatch::IntegrationTest assert blog.show_subscription_in_header assert blog.show_subscription_in_footer end + + test "should set a blog password" do + patch app_settings_blog_url(@blog), params: { blog: { password: "letmein" } }, as: :turbo_stream + + assert_redirected_to app_settings_url + assert @blog.reload.password_protected? + assert @blog.authenticate("letmein") + end + + test "should remove a blog password" do + @blog.update!(password: "letmein") + + patch app_settings_blog_url(@blog), params: { blog: { remove_password: "1" } }, as: :turbo_stream + + assert_redirected_to app_settings_url + assert_not @blog.reload.password_protected? + end end diff --git a/test/controllers/blogs/unlock_controller_test.rb b/test/controllers/blogs/unlock_controller_test.rb new file mode 100644 index 000000000..f73746d76 --- /dev/null +++ b/test/controllers/blogs/unlock_controller_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Blogs::UnlockControllerTest < ActionDispatch::IntegrationTest + setup do + @blog = blogs(:joel) + host_subdomain! @blog.subdomain + Rails.cache.clear + end + + test "unprotected blog renders normally" do + get blog_posts_path + + assert_response :success + end + + test "protected blog redirects the index to the unlock page" do + @blog.update!(password: "letmein") + + get blog_posts_path + + assert_redirected_to blog_unlock_path(return_to: "/") + end + + test "protected blog redirects a post to the unlock page" do + @blog.update!(password: "letmein") + post = posts(:one) + + get blog_post_path(post.slug) + + assert_redirected_to blog_unlock_path(return_to: "/#{post.slug}") + end + + test "correct password unlocks the blog for subsequent requests" do + @blog.update!(password: "letmein") + + post blog_unlock_path, params: { password: "letmein", return_to: "/" } + assert_redirected_to "/" + + get blog_posts_path + assert_response :success + end + + test "wrong password re-renders the form and does not unlock" do + @blog.update!(password: "letmein") + + post blog_unlock_path, params: { password: "nope" } + assert_response :unprocessable_entity + + get blog_posts_path + assert_redirected_to blog_unlock_path(return_to: "/") + end + + test "changing the password invalidates an existing unlock" do + @blog.update!(password: "letmein") + post blog_unlock_path, params: { password: "letmein", return_to: "/" } + + get blog_posts_path + assert_response :success + + @blog.update!(password: "different") + + get blog_posts_path + assert_redirected_to blog_unlock_path(return_to: "/") + end + + test "unlock create only redirects to local paths" do + @blog.update!(password: "letmein") + + post blog_unlock_path, params: { password: "letmein", return_to: "https://evil.example.com" } + + assert_redirected_to "/" + end + + test "new redirects away when the blog is not protected" do + get blog_unlock_path + + assert_redirected_to "/" + end +end diff --git a/test/models/blog_test.rb b/test/models/blog_test.rb index 90bef25dc..3113231ee 100644 --- a/test/models/blog_test.rb +++ b/test/models/blog_test.rb @@ -276,4 +276,31 @@ def setup blog.update!(title: "New Title") end + + test "password_protected? reflects presence of a password" do + assert_not @blog.password_protected? + + @blog.update!(password: "letmein") + assert @blog.password_protected? + assert @blog.authenticate("letmein") + assert_not @blog.authenticate("wrong") + end + + test "blank password on update does not clear an existing password" do + @blog.update!(password: "letmein") + + @blog.update!(title: "New Title", password: "") + + assert @blog.reload.password_protected? + assert @blog.authenticate("letmein") + end + + test "password can be removed by setting it to nil" do + @blog.update!(password: "letmein") + + @blog.password = nil + @blog.save! + + assert_not @blog.reload.password_protected? + end end