Merge pull request #2713 from basecamp/add-storage-limit-notice

Add 1GB storage cap with self-host notice
This commit is contained in:
Jorge Manrubia
2026-03-17 17:45:20 +01:00
committed by GitHub
22 changed files with 369 additions and 52 deletions
+8
View File
@@ -543,4 +543,12 @@
}
}
}
.card-perma__account-limit-message {
background-color: var(--color-canvas);
border: 2px solid var(--color-container);
border-radius: 4px;
margin-block-start: calc(var(--padding-block) / -2);
padding: 1ch 2ch;
}
}
+70 -50
View File
@@ -1,4 +1,7 @@
@layer components {
/* List
/* ------------------------------------------------------------------------ */
.comments {
--avatar-size: 2.33em;
--comment-padding-block: var(--block-space-half);
@@ -21,10 +24,8 @@
}
}
.comments__subscribers {
max-inline-size: var(--comment-max);
padding-inline: calc(var(--comment-padding-block) + var(--inline-space-double));
}
/* Comment
/* ------------------------------------------------------------------------ */
.comment {
/* Distinguish from the .comment class used for code formatting without extra specificity */
@@ -35,52 +36,6 @@
position: relative;
}
.comment-by-system & {
--comment-padding-block: var(--block-space-half);
text-align: center;
&::before {
/* Make up space for lack of avatar */
content: "";
display: flex;
inline-size: calc(var(--comment-padding-inline) * 0.9);
}
.comment__avatar {
display: none;
}
.comment__author {
a { margin: 0 auto; }
h3 { margin-inline: auto; }
strong { display: none; }
}
.comment__body {
padding: 0;
text-align: center;
}
.comment__content {
--stripe-color: var(--color-ink-lightest);
background-image: repeating-linear-gradient(
45deg in srgb,
var(--color-canvas) 0 1px,
var(--stripe-color) 1px 10px);
padding-inline: var(--comment-padding-inline);
.comments--system-expanded .comment-by-system & {
--stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
}
}
.reactions {
display: none !important;
}
}
.reactions {
margin-block-start: var(--block-space-half);
margin-inline: calc(var(--column-gap) / -1);
@@ -195,7 +150,12 @@
}
}
/* System comment
/* ------------------------------------------------------------------------ */
.comment-by-system {
--stripe-color: var(--color-ink-lightest);
display: none;
transition: var(--dialog-duration) allow-discrete;
transition-property: display;
@@ -203,6 +163,50 @@
.comments--system-expanded & {
display: contents;
}
.comment {
--comment-padding-block: var(--block-space-half);
text-align: center;
&::before {
/* Make up space for lack of avatar */
content: "";
display: flex;
inline-size: calc(var(--comment-padding-inline) * 0.9);
}
}
.comment__avatar {
display: none;
}
.comment__author {
a { margin: 0 auto; }
h3 { margin-inline: auto; }
strong { display: none; }
}
.comment__body {
padding: 0;
text-align: center;
}
.comment__content {
background-image: repeating-linear-gradient(
45deg in srgb,
var(--color-canvas) 0 1px,
var(--stripe-color) 1px 10px);
padding-inline: var(--comment-padding-inline);
.comments--system-expanded .comment-by-system & {
--stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
}
}
.reactions {
display: none !important;
}
}
/* Show the last system comment */
@@ -220,4 +224,20 @@
display: none;
}
}
.comment-by-system--account-limit {
--stripe-color: oklch(var(--lch-blue-lightest));
.comment__content {
padding: 3ch;
}
}
/* Subscribers
/* ------------------------------------------------------------------------ */
.comments__subscribers {
max-inline-size: var(--comment-max);
padding-inline: calc(var(--comment-padding-block) + var(--inline-space-double));
}
}
+4
View File
@@ -1,7 +1,11 @@
<%= messages_tag(card) do %>
<% if card.published? %>
<%= render partial: "cards/comments/comment", collection: card.comments.preloaded.chronologically, cached: true %>
<% 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 %>
@@ -13,5 +13,7 @@
<span>Create and add another</span>
<% end %>
</div>
<%= render "cards/container/footer/saas/storage_limit_notice" if Fizzy.saas? %>
</div>
@@ -26,5 +26,9 @@
</div>
<% end %>
<% if Fizzy.saas? %>
<%= render "cards/container/footer/saas/create", card: card %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
<% end %>
</section>
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 %>
@@ -0,0 +1,12 @@
<div class="comment-by-system comment-by-system--account-limit">
<div class="comment align-start full-width">
<div class="comment__content flex flex-column flex-item-grow full-width">
<div class="comment__body lexxy-content">
<div class="action-text-content">
<strong>This account has used all the included free storage (<%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %>).</strong>
<div><%= link_to "Self-host Fizzy", "https://github.com/basecamp/fizzy", target: "_blank" %> for unlimited storage.</div>
</div>
</div>
</div>
</div>
</div>
@@ -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 %>
@@ -0,0 +1,8 @@
<div class="card-perma__notch card-perma__notch--bottom">
<div class="card-perma__account-limit-message">
<strong>This account has used all the included free storage (<%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %>).</strong>
<div>
<%= link_to "Self-host Fizzy", "https://github.com/basecamp/fizzy", target: "_blank" %> for unlimited storage.
</div>
</div>
</div>
@@ -0,0 +1,6 @@
<% if Current.account.nearing_storage_limit? %>
<div class="txt-small">
This account has used <strong><%= number_to_human_size(Current.account.bytes_used) %></strong> of <strong><%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %></strong> storage.
<div><%= link_to "Self-host Fizzy", "https://github.com/basecamp/fizzy", class: "txt-current txt-underline", target: "_blank" %> for unlimited storage.</div>
</div>
<% end %>
+4
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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