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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/controllers/app/settings/blogs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -32,7 +34,9 @@ def blog_params
:google_site_verification,
:seo_title,
:locale,
:show_metrics
:show_metrics,
:password,
:password_confirmation
]

if @blog.user.subscribed?
Expand Down
10 changes: 9 additions & 1 deletion app/controllers/blogs/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?

Expand Down
25 changes: 25 additions & 0 deletions app/controllers/blogs/unlock_controller.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/models/blog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,6 +52,10 @@ def display_name
title.blank? ? "@#{subdomain}" : title
end

def password_protected?
password_digest.present?
end

private

def within_blog_limit
Expand Down
26 changes: 26 additions & 0 deletions app/views/app/settings/blogs/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,32 @@
</div>
</div>

<div class="mt-8">
<h3 class="font-bold text-lg mb-2">Password Protection</h3>
<p>
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.
</p>

<fieldset>
<%= 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) %>
</fieldset>

<% if @blog.password_protected? %>
<div class="mt-4">
<div class="flex gap-3">
<div class="flex h-6 shrink-0 items-center">
<%= 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" %>
</div>
<div>
<%= label_tag "blog_remove_password", "Remove password protection", class: "text-slate-800 dark:text-slate-100" %>
</div>
</div>
</div>
<% end %>
</div>

<div class="mt-8">
<h3 class="font-bold text-lg mb-2">Google Site Verification</h3>
<p>
Expand Down
17 changes: 17 additions & 0 deletions app/views/blogs/unlock/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<%= render "blogs/header", hide_email_form: true, hide_bio: true %>

<div class="lexxy-content">
<article class="overflow-auto break-words">
<h2>This blog is password protected 🔒</h2>

<%= 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 %>
</article>
</div>
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20260702145149_add_password_digest_to_blogs.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddPasswordDigestToBlogs < ActiveRecord::Migration[8.2]
def change
add_column :blogs, :password_digest, :string
end
end
48 changes: 47 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions test/controllers/app/settings/blogs_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
79 changes: 79 additions & 0 deletions test/controllers/blogs/unlock_controller_test.rb
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions test/models/blog_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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