From a761d809aef1763fa9e595f5cdd23fa7d6f1aae9 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 17 Mar 2026 12:10:24 +0100 Subject: [PATCH] Add 1GB storage limit with self-host notice for SaaS Limit free storage to 1GB on the SaaS version. When exceeded, card publishing, comment creation, and JSON card creation are blocked, and the card footer shows a "self-host Fizzy for unlimited storage" notice instead of the create buttons. A nearing-limit warning appears when usage exceeds 500MB. Uses the same SaaS engine patterns as the removed billing system: model concern on Account, controller concerns included via config.to_prepare, view partials in saas/ with Fizzy.saas? guards in the main app. --- app/views/cards/_messages.html.erb | 6 ++- .../cards/container/footer/_create.html.erb | 2 + app/views/cards/drafts/_container.html.erb | 6 ++- .../concerns/card/storage_limited.rb | 8 ++++ .../card/storage_limited/commenting.rb | 9 ++++ .../concerns/card/storage_limited/creation.rb | 9 ++++ .../card/storage_limited/publishing.rb | 9 ++++ saas/app/models/account/storage_limited.rb | 14 ++++++ .../views/cards/comments/saas/_new.html.erb | 5 ++ .../saas/_storage_limit_exceeded.html.erb | 4 ++ .../container/footer/saas/_create.html.erb | 5 ++ .../saas/_storage_limit_exceeded.html.erb | 8 ++++ .../saas/_storage_limit_notice.html.erb | 5 ++ saas/lib/fizzy/saas/engine.rb | 4 ++ .../card/storage_limited/commenting_test.rb | 29 ++++++++++++ .../card/storage_limited/creation_test.rb | 47 +++++++++++++++++++ .../card/storage_limited/publishing_test.rb | 23 +++++++++ .../controllers/card/storage_limited_test.rb | 27 +++++++++++ .../comment/storage_limited_test.rb | 25 ++++++++++ .../models/account/storage_limited_test.rb | 39 +++++++++++++++ 20 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 saas/app/controllers/concerns/card/storage_limited.rb create mode 100644 saas/app/controllers/concerns/card/storage_limited/commenting.rb create mode 100644 saas/app/controllers/concerns/card/storage_limited/creation.rb create mode 100644 saas/app/controllers/concerns/card/storage_limited/publishing.rb create mode 100644 saas/app/models/account/storage_limited.rb create mode 100644 saas/app/views/cards/comments/saas/_new.html.erb create mode 100644 saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/_create.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/_storage_limit_exceeded.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb create mode 100644 saas/test/controllers/card/storage_limited/commenting_test.rb create mode 100644 saas/test/controllers/card/storage_limited/creation_test.rb create mode 100644 saas/test/controllers/card/storage_limited/publishing_test.rb create mode 100644 saas/test/controllers/card/storage_limited_test.rb create mode 100644 saas/test/controllers/comment/storage_limited_test.rb create mode 100644 saas/test/models/account/storage_limited_test.rb diff --git a/app/views/cards/_messages.html.erb b/app/views/cards/_messages.html.erb index a2784e72a..c51ab6d8b 100644 --- a/app/views/cards/_messages.html.erb +++ b/app/views/cards/_messages.html.erb @@ -1,7 +1,11 @@ <%= messages_tag(card) do %> <% if card.published? %> <%= render partial: "cards/comments/comment", collection: card.comments.preloaded.chronologically, cached: true %> - <%= render "cards/comments/new", card: card %> + <% if Fizzy.saas? %> + <%= render "cards/comments/saas/new", card: card %> + <% else %> + <%= render "cards/comments/new", card: card %> + <% end %> <%= render "cards/comments/watchers", card: card %> <% end %> diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb index 648beffd1..4e5a83e20 100644 --- a/app/views/cards/container/footer/_create.html.erb +++ b/app/views/cards/container/footer/_create.html.erb @@ -13,5 +13,7 @@ Create and add another <% end %> + + <%= render "cards/container/footer/saas/storage_limit_notice" if Fizzy.saas? %> diff --git a/app/views/cards/drafts/_container.html.erb b/app/views/cards/drafts/_container.html.erb index 9ffc04711..fa7134b55 100644 --- a/app/views/cards/drafts/_container.html.erb +++ b/app/views/cards/drafts/_container.html.erb @@ -26,5 +26,9 @@ <% end %> - <%= render "cards/container/footer/create", card: card %> + <% if Fizzy.saas? %> + <%= render "cards/container/footer/saas/create", card: card %> + <% else %> + <%= render "cards/container/footer/create", card: card %> + <% end %> diff --git a/saas/app/controllers/concerns/card/storage_limited.rb b/saas/app/controllers/concerns/card/storage_limited.rb new file mode 100644 index 000000000..05357f43f --- /dev/null +++ b/saas/app/controllers/concerns/card/storage_limited.rb @@ -0,0 +1,8 @@ +module Card::StorageLimited + extend ActiveSupport::Concern + + private + def ensure_within_storage_limit + head :forbidden if Current.account.exceeding_storage_limit? + end +end diff --git a/saas/app/controllers/concerns/card/storage_limited/commenting.rb b/saas/app/controllers/concerns/card/storage_limited/commenting.rb new file mode 100644 index 000000000..a4b7e3307 --- /dev/null +++ b/saas/app/controllers/concerns/card/storage_limited/commenting.rb @@ -0,0 +1,9 @@ +module Card::StorageLimited::Commenting + extend ActiveSupport::Concern + + included do + include Card::StorageLimited + + before_action :ensure_within_storage_limit, only: :create + end +end diff --git a/saas/app/controllers/concerns/card/storage_limited/creation.rb b/saas/app/controllers/concerns/card/storage_limited/creation.rb new file mode 100644 index 000000000..9d894779d --- /dev/null +++ b/saas/app/controllers/concerns/card/storage_limited/creation.rb @@ -0,0 +1,9 @@ +module Card::StorageLimited::Creation + extend ActiveSupport::Concern + + included do + include Card::StorageLimited + + before_action :ensure_within_storage_limit, only: :create, if: -> { request.format.json? } + end +end diff --git a/saas/app/controllers/concerns/card/storage_limited/publishing.rb b/saas/app/controllers/concerns/card/storage_limited/publishing.rb new file mode 100644 index 000000000..abf7731a4 --- /dev/null +++ b/saas/app/controllers/concerns/card/storage_limited/publishing.rb @@ -0,0 +1,9 @@ +module Card::StorageLimited::Publishing + extend ActiveSupport::Concern + + included do + include Card::StorageLimited + + before_action :ensure_within_storage_limit, only: :create + end +end diff --git a/saas/app/models/account/storage_limited.rb b/saas/app/models/account/storage_limited.rb new file mode 100644 index 000000000..dbab32b5d --- /dev/null +++ b/saas/app/models/account/storage_limited.rb @@ -0,0 +1,14 @@ +module Account::StorageLimited + extend ActiveSupport::Concern + + STORAGE_LIMIT = 1.gigabyte + NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes + + def exceeding_storage_limit? + bytes_used > STORAGE_LIMIT + end + + def nearing_storage_limit? + !exceeding_storage_limit? && bytes_used > STORAGE_LIMIT - NEAR_STORAGE_LIMIT_THRESHOLD + end +end diff --git a/saas/app/views/cards/comments/saas/_new.html.erb b/saas/app/views/cards/comments/saas/_new.html.erb new file mode 100644 index 000000000..74cb6dd43 --- /dev/null +++ b/saas/app/views/cards/comments/saas/_new.html.erb @@ -0,0 +1,5 @@ +<% if Current.account.exceeding_storage_limit? %> + <%= render "cards/comments/saas/storage_limit_exceeded" %> +<% else %> + <%= render "cards/comments/new", card: card %> +<% end %> diff --git a/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb b/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb new file mode 100644 index 000000000..6be5fb587 --- /dev/null +++ b/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb @@ -0,0 +1,4 @@ +
+ This account has used all <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> of free storage. + <%= link_to "Self-host Fizzy", "https://github.com/basecamp/fizzy", target: "_blank" %> for unlimited storage. +
diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb new file mode 100644 index 000000000..e5544e576 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/_create.html.erb @@ -0,0 +1,5 @@ +<% if Current.account.exceeding_storage_limit? %> + <%= render "cards/container/footer/saas/storage_limit_exceeded" %> +<% else %> + <%= render "cards/container/footer/create", card: card %> +<% end %> diff --git a/saas/app/views/cards/container/footer/saas/_storage_limit_exceeded.html.erb b/saas/app/views/cards/container/footer/saas/_storage_limit_exceeded.html.erb new file mode 100644 index 000000000..6fb95be11 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/_storage_limit_exceeded.html.erb @@ -0,0 +1,8 @@ +
+
+ This account has used all <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> of free storage. +
+ <%= link_to "Self-host Fizzy", "https://github.com/basecamp/fizzy", target: "_blank" %> for unlimited storage. +
+
+
diff --git a/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb b/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb new file mode 100644 index 000000000..942041fb4 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb @@ -0,0 +1,5 @@ +<% if Current.account.nearing_storage_limit? %> +
+ This account has used <%= number_to_human_size(Current.account.bytes_used) %> of <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> storage. +
+<% end %> diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index dea321cfc..673be1147 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -133,10 +133,14 @@ module Fizzy end config.to_prepare do + ::Account.include Account::StorageLimited ::Identity.include Authorization::Identity, Identity::Devices ::Session.include Session::Devices ::Signup.prepend Signup ApplicationController.include Authorization::Controller + CardsController.include(Card::StorageLimited::Creation) + Cards::CommentsController.include(Card::StorageLimited::Commenting) + Cards::PublishesController.include(Card::StorageLimited::Publishing) Notification.register_push_target(:native) diff --git a/saas/test/controllers/card/storage_limited/commenting_test.rb b/saas/test/controllers/card/storage_limited/commenting_test.rb new file mode 100644 index 000000000..e32015130 --- /dev/null +++ b/saas/test/controllers/card/storage_limited/commenting_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class Card::StorageLimited::CommentingTest < ActionDispatch::IntegrationTest + test "cannot create comments when storage limit exceeded" do + sign_in_as :david + + Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) + + assert_no_difference -> { Comment.count } do + post card_comments_path(cards(:logo), script_name: accounts(:"37s").slug), + params: { comment: { body: "Blocked comment" } }, + as: :turbo_stream + end + + assert_response :forbidden + end + + test "can create comments when under storage limit" do + sign_in_as :david + + assert_difference -> { Comment.count } do + post card_comments_path(cards(:logo), script_name: accounts(:"37s").slug), + params: { comment: { body: "Allowed comment" } }, + as: :turbo_stream + end + + assert_response :success + end +end diff --git a/saas/test/controllers/card/storage_limited/creation_test.rb b/saas/test/controllers/card/storage_limited/creation_test.rb new file mode 100644 index 000000000..ab04774fe --- /dev/null +++ b/saas/test/controllers/card/storage_limited/creation_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +class Card::StorageLimited::CreationTest < ActionDispatch::IntegrationTest + test "cannot create cards via JSON when storage limit exceeded" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) + + assert_no_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), + params: { card: { title: "Blocked card" } }, + as: :json + end + + assert_response :forbidden + end + + test "can create cards via HTML when storage limit exceeded since they become drafts" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) + accounts(:initech).update_column(:cards_count, 100) + boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all + + assert_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug) + end + + assert_response :redirect + assert Card.last.drafted? + end + + test "can create cards via JSON when under storage limit" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(500.megabytes) + accounts(:initech).update_column(:cards_count, 100) + + assert_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), + params: { card: { title: "Allowed card" } }, + as: :json + end + + assert_response :created + end +end diff --git a/saas/test/controllers/card/storage_limited/publishing_test.rb b/saas/test/controllers/card/storage_limited/publishing_test.rb new file mode 100644 index 000000000..94e9b8e4f --- /dev/null +++ b/saas/test/controllers/card/storage_limited/publishing_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Card::StorageLimited::PublishingTest < ActionDispatch::IntegrationTest + test "cannot publish cards when storage limit exceeded" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) + + post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :forbidden + assert cards(:unfinished_thoughts).reload.drafted? + end + + test "can publish cards when under storage limit" do + sign_in_as :mike + + post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :redirect + assert cards(:unfinished_thoughts).reload.published? + end +end diff --git a/saas/test/controllers/card/storage_limited_test.rb b/saas/test/controllers/card/storage_limited_test.rb new file mode 100644 index 000000000..69790c363 --- /dev/null +++ b/saas/test/controllers/card/storage_limited_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Card::StorageLimitedTest < ActionDispatch::IntegrationTest + test "draft card shows storage limit notice instead of create buttons when limit exceeded" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) + + get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_select ".card-perma__notch" do + assert_select "strong", text: /used all/ + assert_select "a[href='https://github.com/basecamp/fizzy']", text: "Self-host Fizzy" + end + assert_select ".card-perma__notch-new-card-buttons", count: 0 + end + + test "draft card shows create buttons when under storage limit" do + sign_in_as :mike + + get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_select ".card-perma__notch-new-card-buttons" + end +end diff --git a/saas/test/controllers/comment/storage_limited_test.rb b/saas/test/controllers/comment/storage_limited_test.rb new file mode 100644 index 000000000..4137971b1 --- /dev/null +++ b/saas/test/controllers/comment/storage_limited_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Comment::StorageLimitedTest < ActionDispatch::IntegrationTest + test "published card shows storage limit notice instead of comment form when limit exceeded" do + sign_in_as :david + + Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) + + get card_path(cards(:logo), script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "strong", text: /used all/ + assert_select "a[href='https://github.com/basecamp/fizzy']", text: "Self-host Fizzy" + assert_select "##{dom_id(cards(:logo), :new_comment)}", count: 0 + end + + test "published card shows comment form when under storage limit" do + sign_in_as :david + + get card_path(cards(:logo), script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "##{dom_id(cards(:logo), :new_comment)}" + end +end diff --git a/saas/test/models/account/storage_limited_test.rb b/saas/test/models/account/storage_limited_test.rb new file mode 100644 index 000000000..f587d9ef1 --- /dev/null +++ b/saas/test/models/account/storage_limited_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Account::StorageLimitedTest < ActiveSupport::TestCase + test "exceeding storage limit when bytes used exceeds 1 GB" do + Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) + + assert accounts(:initech).exceeding_storage_limit? + end + + test "not exceeding storage limit when bytes used equals 1 GB" do + Account.any_instance.stubs(:bytes_used).returns(1.gigabyte) + + assert_not accounts(:initech).exceeding_storage_limit? + end + + test "not exceeding storage limit when under 1 GB" do + Account.any_instance.stubs(:bytes_used).returns(500.megabytes) + + assert_not accounts(:initech).exceeding_storage_limit? + end + + test "nearing storage limit when within 500 MB of the limit" do + Account.any_instance.stubs(:bytes_used).returns(600.megabytes) + + assert accounts(:initech).nearing_storage_limit? + end + + test "not nearing storage limit when well under the threshold" do + Account.any_instance.stubs(:bytes_used).returns(400.megabytes) + + assert_not accounts(:initech).nearing_storage_limit? + end + + test "not nearing storage limit when already exceeding it" do + Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) + + assert_not accounts(:initech).nearing_storage_limit? + end +end