diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 4286f90ae..bbd1eb22a 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -42,7 +42,7 @@ jobs: uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.11.1 + uses: docker/setup-buildx-action@v3.12.0 - name: Log in to GHCR uses: docker/login-action@v3.6.0 @@ -98,7 +98,7 @@ jobs: - name: Attest image provenance (per-arch) if: github.event_name != 'pull_request' - uses: actions/attest-build-provenance@v3.0.0 + uses: actions/attest-build-provenance@v3.1.0 with: subject-name: ${{ steps.vars.outputs.canonical }} subject-digest: ${{ steps.build.outputs.digest }} @@ -115,7 +115,7 @@ jobs: IMAGE_NAME: ${{ github.repository }} steps: - name: Set up Docker Buildx (for imagetools) - uses: docker/setup-buildx-action@v3.11.1 + uses: docker/setup-buildx-action@v3.12.0 - name: Log in to GHCR uses: docker/login-action@v3.6.0 diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 9c20fcda5..000000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,39 +0,0 @@ -# syntax = docker/dockerfile:1 - -# Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=3.4.7 -FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base - -# Rails app lives here -WORKDIR /rails - -# Set development environment -ENV RAILS_ENV="development" \ - BUNDLE_PATH="/usr/local/bundle" - -# Install packages needed for development -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential pkg-config git ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools libyaml-dev libssl-dev sqlite3 && \ - rm -rf /var/lib/apt/lists /var/cache/apt/archives - -# Install application gems -COPY Gemfile Gemfile.lock .ruby-version ./ -COPY lib/fizzy.rb ./lib/fizzy.rb -COPY gems ./gems/ -RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-devbundle-${RUBY_VERSION},sharing=locked,target=/devbundle \ - gem install bundler foreman && \ - BUNDLE_PATH=/devbundle BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \ - cp -a /devbundle/. "$BUNDLE_PATH"/ && \ - bundle clean --force - -# Final stage -FROM base - -# Entrypoint prepares the database -ENTRYPOINT ["/rails/bin/docker-entrypoint"] - -# Start the server by default -EXPOSE 3000 -EXPOSE 3006 -EXPOSE 5001 -CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/app/assets/images/jf-avatar.jpg b/app/assets/images/jf-avatar.jpg new file mode 100644 index 000000000..07f9f6592 Binary files /dev/null and b/app/assets/images/jf-avatar.jpg differ diff --git a/app/assets/images/jf-signature.svg b/app/assets/images/jf-signature.svg new file mode 100644 index 000000000..0e0653d06 --- /dev/null +++ b/app/assets/images/jf-signature.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 30e5da88a..25bc18277 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -10,7 +10,7 @@ --block-space-double: calc(var(--block-space) * 2); /* Text */ - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + --font-sans: "Adwaita Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; --font-serif: ui-serif, serif; --font-mono: ui-monospace, monospace; @@ -74,11 +74,13 @@ --z-events-column-header: 1; --z-events-day-header: 3; --z-popup: 10; - --z-nav: 30; - --z-flash: 40; - --z-tooltip: 50; - --z-bar: 60; - --z-tray: 61; + --z-nav: 20; + --z-flash: 30; + --z-tooltip: 40; + --z-bar: 50; + --z-tray: 51; + --z-welcome: 52; + --z-nav-open: 100; /* OKLCH colors: Fixed */ --lch-black: 0% 0 0; diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index aed15d90f..07021a330 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -15,7 +15,7 @@ --column-width-collapsed: 40px; --column-width-expanded: 450px; --progress-increment: var(--progress-max-height) / var(--progress-max-cards); - --progress-max-cards: 20; + --progress-max-cards: 15; /* should match first geared pagination page size */ --progress-max-height: 50dvh; container-type: inline-size; @@ -35,6 +35,7 @@ } &:has(.cards) { + block-size: 100%; min-block-size: 20lh; } @@ -344,6 +345,7 @@ &:after { block-size: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment)); + max-block-size: none; opacity: 1; inline-size: var(--column-width-collapsed); } @@ -756,7 +758,7 @@ --column-padding-expanded: calc(var(--column-gap) * 2); --column-width-collapsed: 40px; --progress-increment: var(--progress-max-width) / var(--progress-max-cards); - --progress-max-cards: 20; + --progress-max-cards: 15; /* should match first geared pagination page size */ --progress-max-width: 100%; padding-inline: 3vw; diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index c3dbb7d74..c8453bc14 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -23,20 +23,15 @@ } .comment { - display: flex; - margin-inline: auto; - max-inline-size: var(--comment-max); - position: relative; - - house-md-toolbar { - margin-block-start: calc(var(--comment-padding-block) * -0.5); + /* Distinguish from the .comment class used for code formatting without extra specificity */ + &:where(.comments &) { + display: flex; + margin-inline: auto; + max-inline-size: var(--comment-max); + position: relative; } - .house-md-content { - padding: var(--comment-padding-block) 0 0; - } - - .comment--system & { + .comment-by-system & { --comment-padding-block: var(--block-space-half); text-align: center; @@ -72,7 +67,7 @@ var(--stripe-color) 1px 10px); padding-inline: var(--comment-padding-inline); - .comments--system-expanded .comment--system & { + .comments--system-expanded .comment-by-system & { --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); } @@ -144,7 +139,7 @@ position: absolute; } - .comment--system { + .comment-by-system { display: none; transition: var(--dialog-duration) allow-discrete; transition-property: display; @@ -155,7 +150,7 @@ } /* Show the last system comment */ - :nth-last-child(1 of .comment--system) { + :nth-last-child(1 of .comment-by-system) { display: contents; .comment__history { @@ -164,7 +159,7 @@ } /* Hide the "Show history" button if there's only one system comment */ - :nth-child(1 of .comment--system) { + :nth-child(1 of .comment-by-system) { .comment__history { display: none; } diff --git a/app/assets/stylesheets/header.css b/app/assets/stylesheets/header.css index a3b90ac4f..5a3c7ee83 100644 --- a/app/assets/stylesheets/header.css +++ b/app/assets/stylesheets/header.css @@ -15,6 +15,8 @@ max-inline-size: 100dvw; padding-block: calc(var(--block-space-half) + env(safe-area-inset-top)) var(--block-space-half); padding-inline: var(--main-padding); + position: relative; + z-index: var(--z-nav); /* Change the grid size depending on how many buttons are present */ &:has(.header__actions > *:nth-child(1)) { --header-button-count: 1; } @@ -25,6 +27,10 @@ row-gap: 0; } + &:has(dialog[open]) { + z-index: var(--z-nav-open); + } + &:has(~ #main .card-columns) { inline-size: 100dvw; margin-inline: auto; diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index e82992dfc..b9f8d6367 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -22,11 +22,6 @@ max-inline-size: 100dvw; } - :where(#header) { - position: relative; - z-index: var(--z-nav); - } - :is(#header, #footer) { @media print { display: none; diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index 57d1803bd..c48a78034 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -3,7 +3,7 @@ /* ------------------------------------------------------------------------ */ .nav__trigger { - --input-background: var(--color-canvas); + --input-background: transparent; --input-border-color: transparent; --input-padding: 0.3em 2em 0.3em 0.75em; @@ -17,21 +17,11 @@ @media (any-hover: hover) { &:hover { - filter: brightness(0.9); + --input-background: var(--color-ink-lighter); span { background: var(--color-ink-lighter); } - - html[data-theme="dark"] & { - --input-background: var(--color-ink-lighter); - } - - @media (prefers-color-scheme: dark) { - html:not([data-theme]) & { - --input-background: var(--color-ink-lighter); - } - } } } diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index e252d8981..91c04ff53 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -16,6 +16,7 @@ .txt-ink { color: var(--color-ink); } .txt-reversed { color: var(--color-ink-inverted); } .txt-negative { color: var(--color-negative); } + .txt-positive { color: var(--color-positive); } .txt-subtle { color: var(--color-ink-dark); } .txt-alert { color: var(--color-marker); } .txt-undecorated { text-decoration: none; } diff --git a/app/assets/stylesheets/welcome-letter.css b/app/assets/stylesheets/welcome-letter.css new file mode 100644 index 000000000..80424ef66 --- /dev/null +++ b/app/assets/stylesheets/welcome-letter.css @@ -0,0 +1,27 @@ +@layer components { + .welcome-letter { + position: relative; + view-transition-name: welcome-letter; + z-index: var(--z-welcome); + + h2, p { + text-wrap: pretty; + } + } + + .welcome-letter__close { + inset: var(--block-space) var(--block-space) auto auto; + position: absolute; + } + + .welcome-letter__signature { + background-color: currentColor; + block-size: 3em; + display: inline-block; + inline-size: 8em; + mask-image: url("jf-signature.svg"); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 8em 3em; + } +} diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb index c4aac26d1..a78b30441 100644 --- a/app/controllers/cards/closures_controller.rb +++ b/app/controllers/cards/closures_controller.rb @@ -2,20 +2,30 @@ class Cards::ClosuresController < ApplicationController include CardScoped def create + capture_card_location @card.close + refresh_stream_if_needed respond_to do |format| - format.turbo_stream { render_card_replacement } + format.turbo_stream format.json { head :no_content } end end def destroy @card.reopen + refresh_stream_after_reopen respond_to do |format| - format.turbo_stream { render_card_replacement } + format.turbo_stream format.json { head :no_content } end end + + private + def refresh_stream_after_reopen + if @card.awaiting_triage? + set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded + end + end end diff --git a/app/controllers/cards/not_nows_controller.rb b/app/controllers/cards/not_nows_controller.rb index 8eeb0e663..ea91436b0 100644 --- a/app/controllers/cards/not_nows_controller.rb +++ b/app/controllers/cards/not_nows_controller.rb @@ -2,10 +2,12 @@ class Cards::NotNowsController < ApplicationController include CardScoped def create + capture_card_location @card.postpone + refresh_stream_if_needed respond_to do |format| - format.turbo_stream { render_card_replacement } + format.turbo_stream format.json { head :no_content } end end diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index bfdc1ee2e..daf22a5bc 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -61,6 +61,6 @@ class CardsController < ApplicationController end def card_params - params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at ]) + params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ]) end end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 1afb878ef..f30d518cc 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -26,7 +26,12 @@ module Authorization end def ensure_can_access_account - redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active? + if Current.user.blank? || !Current.user.active? + respond_to do |format| + format.html { redirect_to session_menu_path(script_name: nil) } + format.json { head :forbidden } + end + end end def redirect_existing_user diff --git a/app/controllers/concerns/card_scoped.rb b/app/controllers/concerns/card_scoped.rb index 52311809e..72e42d080 100644 --- a/app/controllers/concerns/card_scoped.rb +++ b/app/controllers/concerns/card_scoped.rb @@ -17,4 +17,15 @@ module CardScoped def render_card_replacement render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) end + + def capture_card_location + @source_column = @card.column + @was_in_stream = @card.awaiting_triage? + end + + def refresh_stream_if_needed + if @was_in_stream + set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded + end + end end diff --git a/app/controllers/landings_controller.rb b/app/controllers/landings_controller.rb index b3b4c9162..e1c206c7b 100644 --- a/app/controllers/landings_controller.rb +++ b/app/controllers/landings_controller.rb @@ -1,5 +1,7 @@ class LandingsController < ApplicationController def show + flash.keep(:welcome_letter) + if Current.user.boards.one? redirect_to board_path(Current.user.boards.first) else diff --git a/app/controllers/signups/completions_controller.rb b/app/controllers/signups/completions_controller.rb index f0ea56ff9..4fcaaabd3 100644 --- a/app/controllers/signups/completions_controller.rb +++ b/app/controllers/signups/completions_controller.rb @@ -11,6 +11,7 @@ class Signups::CompletionsController < ApplicationController @signup = Signup.new(signup_params) if @signup.complete + flash[:welcome_letter] = true redirect_to landing_url(script_name: @signup.account.slug) else render :new, status: :unprocessable_entity diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index b2dc74727..d99c125cb 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -12,6 +12,7 @@ module ColumnsHelper def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block) classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed) + hotkeys_disabled = data[:card_hotkeys_disabled] data = { drag_and_drop_target: "container", @@ -37,6 +38,7 @@ module ColumnsHelper navigable_list_auto_select_value: "false", navigable_list_actionable_items_value: "true", navigable_list_only_act_on_focused_items_value: "true", + card_hotkeys_disabled: hotkeys_disabled, action: "keydown->navigable-list#navigate" }, &block) end diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb index 218d58e78..a3d0bbab3 100644 --- a/app/helpers/html_helper.rb +++ b/app/helpers/html_helper.rb @@ -1,64 +1,5 @@ module HtmlHelper - include ERB::Util - - EXCLUDE_PUNCTUATION = %(.?,:!;"'<>) - EXCLUDE_PUNCTUATION_REGEX = /[#{Regexp.escape(EXCLUDE_PUNCTUATION)}]+\z/ - def format_html(html) - fragment = Nokogiri::HTML5.fragment(html) - - auto_link(fragment) - - fragment.to_html.html_safe + Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe end - - private - EXCLUDED_ELEMENTS = %w[ a figcaption pre code ] - EMAIL_AUTOLINK_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/ - URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https]) - - def auto_link(fragment) - fragment.traverse do |node| - next unless auto_linkable_node?(node) - - # Take care to escape the html text node, so that the subsequent Nokogiri re-parse doesn't - # create tags where there aren't any. - content = h(node.text) - linked_content = content.dup - - auto_link_urls(linked_content) - auto_link_emails(linked_content) - - if linked_content != content - node.replace(Nokogiri::HTML5.fragment(linked_content)) - end - end - end - - def auto_linkable_node?(node) - node.text? && node.ancestors.none? { |ancestor| EXCLUDED_ELEMENTS.include?(ancestor.name) } - end - - def auto_link_urls(linked_content) - linked_content.gsub!(URL_REGEXP) do |match| - url, trailing_punct = extract_url_and_punctuation(match) - %(#{url}#{trailing_punct}) - end - end - - def extract_url_and_punctuation(url_match) - url_match = CGI.unescapeHTML(url_match) - if match = url_match.match(EXCLUDE_PUNCTUATION_REGEX) - len = match[0].length - [ url_match[..-(len+1)], url_match[-len..] ] - else - [ url_match, "" ] - end - end - - def auto_link_emails(text) - text.gsub!(EMAIL_AUTOLINK_REGEXP) do |match| - %(#{match}) - end - end end diff --git a/app/javascript/controllers/card_hotkeys_controller.js b/app/javascript/controllers/card_hotkeys_controller.js new file mode 100644 index 000000000..564c076ad --- /dev/null +++ b/app/javascript/controllers/card_hotkeys_controller.js @@ -0,0 +1,134 @@ +import { Controller } from "@hotwired/stimulus" +import { post } from "@rails/request.js" + +export default class extends Controller { + static outlets = [ "navigable-list" ] + + connect() { + this.morphCompletePromise = null + this.morphCompleteResolver = null + } + + handleKeydown(event) { + if (this.#shouldIgnore(event) || this.#hasModifier(event)) return + + const handler = this.#keyHandlers[event.key.toLowerCase()] + if (handler) { + handler.call(this, event) + } + } + + // Called when turbo:morph completes - resolves our waiting promise + handleMorphComplete() { + if (this.morphCompleteResolver) { + this.morphCompleteResolver() + this.morphCompleteResolver = null + this.morphCompletePromise = null + } + } + + // Private + + #shouldIgnore(event) { + const target = event.target + return target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable || + target.closest("input, textarea, [contenteditable], lexxy-editor") + } + + #hasModifier(event) { + return event.metaKey || event.ctrlKey || event.altKey || event.shiftKey + } + + get #selectedCard() { + // Find the navigable-list that currently has focus + const focusedList = this.navigableListOutlets.find(list => list.hasFocus) + if (!focusedList) return null + + const currentItem = focusedList.currentItem + if (currentItem?.classList.contains("card") && !this.#hotkeysDisabled(focusedList)) { + return { card: currentItem, controller: focusedList } + } + return null + } + + async #postponeCard(event) { + const selection = this.#selectedCard + if (!selection) return + + const url = selection.card.dataset.cardNotNowUrl + if (url) { + event.preventDefault() + await this.#performCardAction(url, selection) + } + } + + async #closeCard(event) { + const selection = this.#selectedCard + if (!selection) return + + const url = selection.card.dataset.cardClosureUrl + if (url) { + event.preventDefault() + await this.#performCardAction(url, selection) + } + } + + async #assignToMe(event) { + const selection = this.#selectedCard + if (!selection) return + + const url = selection.card.dataset.cardAssignToMeUrl + if (url) { + event.preventDefault() + await post(url, { responseKind: "turbo-stream" }) + } + } + + async #performCardAction(url, selection) { + const { controller } = selection + const visibleItems = controller.visibleItems + const currentIndex = visibleItems.indexOf(selection.card) + const wasLastItem = currentIndex === visibleItems.length - 1 + + // Set up promise to wait for morph completion + this.morphCompletePromise = new Promise(resolve => { + this.morphCompleteResolver = resolve + }) + + await post(url, { responseKind: "turbo-stream" }) + + // Wait for Turbo Stream morph to complete + await Promise.race([ + this.morphCompletePromise, + new Promise(resolve => setTimeout(resolve, 200)) // Fallback timeout + ]) + + // Select the next card (or previous if it was the last) + const newVisibleItems = controller.visibleItems + if (newVisibleItems.length === 0) { + controller.clearSelection() + return + } + + if (wasLastItem) { + controller.selectLast() + } else { + const nextIndex = Math.min(currentIndex, newVisibleItems.length - 1) + if (newVisibleItems[nextIndex]) { + await controller.selectItem(newVisibleItems[nextIndex]) + } + } + } + + #hotkeysDisabled(navigableList) { + return navigableList?.element.dataset.cardHotkeysDisabled === "true" + } + + #keyHandlers = { + "["(event) { this.#postponeCard(event) }, + "]"(event) { this.#closeCard(event) }, + m(event) { this.#assignToMe(event) } + } +} diff --git a/app/javascript/controllers/css_variable_counter_controller.js b/app/javascript/controllers/css_variable_counter_controller.js index 1fa2cd5b9..5b4e9c940 100644 --- a/app/javascript/controllers/css_variable_counter_controller.js +++ b/app/javascript/controllers/css_variable_counter_controller.js @@ -5,7 +5,7 @@ export default class extends Controller { static targets = [ "item", "counter" ] static values = { propertyName: String, - maxValue: { type: Number, default: 20 } + maxValue: { type: Number, default: 15 } // should match first geared pagination page size } initialize() { @@ -13,7 +13,9 @@ export default class extends Controller { } connect() { - this.#updateCounter() + if (this.itemTargets.length > 0) { + this.#updateCounter() + } } itemTargetConnected() { diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index cdc8ea7cd..f6449cea7 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -5,11 +5,13 @@ export default class extends Controller { static targets = [ "dialog" ] static values = { modal: { type: Boolean, default: false }, - sizing: { type: Boolean, default: true } + sizing: { type: Boolean, default: true }, + autoOpen: { type: Boolean, default: false } } connect() { this.dialogTarget.setAttribute("aria-hidden", "true") + if (this.autoOpenValue) this.open() } open() { diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js index dfe8d6087..0be9bd170 100644 --- a/app/javascript/controllers/navigable_list_controller.js +++ b/app/javascript/controllers/navigable_list_controller.js @@ -51,6 +51,10 @@ export default class extends Controller { this.selectItem(target, true) } + hoverSelect({ currentTarget }) { + this.selectItem(currentTarget) + } + selectCurrentOrReset(event) { if (this.currentItem) { this.#setCurrentFrom(this.currentItem) @@ -227,6 +231,20 @@ export default class extends Controller { }) } + // Public accessors for card_hotkeys_controller outlet + get visibleItems() { + return this.#visibleItems + } + + clearSelection() { + this.#clearSelection() + this.currentItem = null + } + + get hasFocus() { + return this.element.contains(document.activeElement) + } + #keyHandlers = { ArrowDown(event) { if (this.supportsVerticalNavigationValue) { @@ -259,6 +277,6 @@ export default class extends Controller { } else { this.#clickCurrentItem(event) } - }, + } } } diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index 154bf4fc0..cf64df579 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -26,13 +26,27 @@ export default class extends Controller { set #theme(theme) { localStorage.setItem("theme", theme) - if (theme === "auto") { - document.documentElement.removeAttribute("data-theme") - } else { - document.documentElement.setAttribute("data-theme", theme) + const currentTheme = document.documentElement.getAttribute("data-theme") || "auto" + const hasChanged = currentTheme !== theme + + const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches + const animate = hasChanged && !prefersReducedMotion + + const applyTheme = () => { + if (theme === "auto") { + document.documentElement.removeAttribute("data-theme") + } else { + document.documentElement.setAttribute("data-theme", theme) + } + + this.#updateButtons() } - this.#updateButtons() + if (animate && document.startViewTransition) { + document.startViewTransition(applyTheme) + } else { + applyTheme() + } } #applyStoredTheme() { diff --git a/app/jobs/storage/reconcile_job.rb b/app/jobs/storage/reconcile_job.rb index be0c0748c..cd3bf3803 100644 --- a/app/jobs/storage/reconcile_job.rb +++ b/app/jobs/storage/reconcile_job.rb @@ -1,9 +1,13 @@ class Storage::ReconcileJob < ApplicationJob + class ReconcileAborted < StandardError; end + queue_as :backend + limits_concurrency to: 1, key: ->(owner) { owner } discard_on ActiveJob::DeserializationError + retry_on ReconcileAborted, wait: 1.minute, attempts: 3 def perform(owner) - owner.reconcile_storage + raise ReconcileAborted, "Could not get stable snapshot for #{owner.class}##{owner.id}" unless owner.reconcile_storage end end diff --git a/app/models/account/export.rb b/app/models/account/export.rb index c5e3d64c3..1fd2689d2 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -37,6 +37,10 @@ class Account::Export < ApplicationRecord update!(status: :completed, completed_at: Time.current) end + def accessible_to?(accessor) + accessor == user + end + private def generate_zip Tempfile.new([ "export", ".zip" ]).tap do |tempfile| diff --git a/app/models/board/publishable.rb b/app/models/board/publishable.rb index 6c1ce47d4..8b113cc9e 100644 --- a/app/models/board/publishable.rb +++ b/app/models/board/publishable.rb @@ -15,6 +15,7 @@ module Board::Publishable def published? publication.present? end + alias_method :publicly_accessible?, :published? def publish create_publication! unless published? diff --git a/app/models/board/storage.rb b/app/models/board/storage.rb index 64dde9985..24028e714 100644 --- a/app/models/board/storage.rb +++ b/app/models/board/storage.rb @@ -12,25 +12,32 @@ module Board::Storage # Calculate actual storage by summing blob sizes. # - # Uses batched pluck queries to avoid loading huge ID arrays, and avoids - # ActiveRecord model queries on ActiveStorage tables to sidestep cross-pool - # issues when ActiveStorage uses separate connection pools (e.g., with replicas). + # Storage tracking is a business abstraction - we count what users upload. + # Original upload bytes only; variants/previews/derivatives excluded. + # Physical storage optimizations (deduplication, compression) don't affect quotas. def calculate_real_storage_bytes + @card_ids = nil # Clear memoization for fresh calculation card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes end + def card_ids + @card_ids ||= cards.pluck(:id) + end + def card_image_bytes sum_blob_bytes_in_batches \ ActiveStorage::Attachment.where(record_type: "Card", name: "image"), - cards.pluck(:id) + card_ids end def card_embed_bytes - sum_embed_bytes_for "Card", cards.pluck(:id) + sum_embed_bytes_for "Card", card_ids end def comment_embed_bytes - sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id) + card_ids.each_slice(BATCH_SIZE).sum do |batch| + sum_embed_bytes_for "Comment", Comment.where(card_id: batch).pluck(:id) + end end def board_embed_bytes @@ -48,9 +55,18 @@ module Board::Storage end def sum_blob_bytes_in_batches(base_scope, record_ids) + # Count per-attachment to match ledger model. + # Same blob attached 3 times = 3x bytes (business abstraction, not physical storage). + # + # Do NOT remove the join thinking it's a performance optimization - it's required + # for correct per-attachment counting. We keep ActiveStorage/ActionText in the same + # database (realm/geo partitioning, not functionality partitioning), so cross-table + # joins are fine. record_ids.each_slice(BATCH_SIZE).sum do |batch_ids| - blob_ids = base_scope.where(record_id: batch_ids).pluck(:blob_id) - ActiveStorage::Blob.where(id: blob_ids).sum(:byte_size) + base_scope + .where(record_id: batch_ids) + .joins(:blob) + .sum("active_storage_blobs.byte_size") end end end diff --git a/app/models/card.rb b/app/models/card.rb index 33c96e502..18957cb9b 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -48,6 +48,10 @@ class Card < ApplicationRecord delegate :accessible_to?, to: :board + def publicly_accessible? + published? && board.publicly_accessible? + end + def card self end @@ -68,6 +72,55 @@ class Card < ApplicationRecord end private + STORAGE_BATCH_SIZE = 1000 + + # Override to include comments, but only load comments that have attachments. + # Cards can have thousands of comments; most won't have attachments. + # Streams in batches to avoid loading all IDs into memory at once. + def storage_transfer_records + comment_ids_with_attachments = storage_comment_ids_with_attachments + + if comment_ids_with_attachments.any? + [ self, *comments.where(id: comment_ids_with_attachments).to_a ] + else + [ self ] + end + end + + def storage_comment_ids_with_attachments + direct = [] + rich_text_map = {} + + # Stream comment IDs in batches to avoid loading all into memory + comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch| + batch_ids = batch.pluck(:id) + + direct.concat \ + ActiveStorage::Attachment + .where(record_type: "Comment", record_id: batch_ids) + .distinct + .pluck(:record_id) + + ActionText::RichText + .where(record_type: "Comment", record_id: batch_ids) + .pluck(:id, :record_id) + .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id } + end + + embed_comment_ids = if rich_text_map.any? + rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids| + ActiveStorage::Attachment + .where(record_type: "ActionText::RichText", record_id: batch_ids) + .distinct + .pluck(:record_id) + end.filter_map { |rt_id| rich_text_map[rt_id] } + else + [] + end + + (direct + embed_comment_ids).uniq + end + def set_default_title self.title = "Untitled" if title.blank? end diff --git a/app/models/comment.rb b/app/models/comment.rb index d0315eea8..30706e8b5 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -15,7 +15,7 @@ class Comment < ApplicationRecord after_create_commit :watch_card_by_creator - delegate :board, :watch_by, to: :card + delegate :publicly_accessible?, :accessible_to?, :board, :watch_by, to: :card def to_partial_path "cards/#{super}" diff --git a/app/models/concerns/storage/totaled.rb b/app/models/concerns/storage/totaled.rb index 3f220f55f..b54a6ef22 100644 --- a/app/models/concerns/storage/totaled.rb +++ b/app/models/concerns/storage/totaled.rb @@ -19,7 +19,7 @@ module Storage::Totaled # Exact: snapshot + pending entries def bytes_used_exact - (storage_total || create_storage_total!).current_usage + create_or_find_storage_total.current_usage end def materialize_storage_later @@ -28,7 +28,7 @@ module Storage::Totaled # Materialize all pending entries into snapshot def materialize_storage - total = storage_total || create_storage_total! + total = create_or_find_storage_total total.with_lock do latest_entry_id = storage_entries.maximum(:id) @@ -44,26 +44,44 @@ module Storage::Totaled end # Reconcile ledger against actual attachment storage. - # Uses cursor to ensure consistency: captures max entry ID first, then calculates - # real bytes, then sums only entries up to that cursor. Concurrent uploads during - # calculation will have entries with IDs beyond the cursor, avoiding double-count. + # + # Uses two-cursor approach for consistency: capture cursor before AND after the + # scan. If they differ, entries were added during the scan and we can't get an + # accurate diff without risking double-counting or undercounting. + # + # Returns true if reconciled successfully, false if aborted due to concurrent + # writes. Caller (ReconcileJob) handles retries to avoid amplification. def reconcile_storage - max_entry_id = storage_entries.maximum(:id) + cursor_before = storage_entries.maximum(:id) real_bytes = calculate_real_storage_bytes - ledger_bytes = max_entry_id ? storage_entries.where(id: ..max_entry_id).sum(:delta) : 0 - diff = real_bytes - ledger_bytes + cursor_after = storage_entries.maximum(:id) - if diff.nonzero? - Storage::Entry.record \ - account: is_a?(Account) ? self : account, - board: is_a?(Board) ? self : nil, - recordable: nil, - delta: diff, - operation: "reconcile" + if cursor_before != cursor_after + Rails.logger.warn "[Storage] Reconcile aborted for #{self.class}##{id}: cursor moved during scan" + false + else + ledger_bytes = cursor_after ? storage_entries.where(id: ..cursor_after).sum(:delta) : 0 + diff = real_bytes - ledger_bytes + + if diff.nonzero? + Rails.logger.info "[Storage] Reconcile #{self.class}##{id}: adjusting by #{diff} bytes" + Storage::Entry.record \ + account: is_a?(Account) ? self : account, + board: is_a?(Board) ? self : nil, + recordable: nil, + delta: diff, + operation: "reconcile" + end + + true end end private + def create_or_find_storage_total + self.storage_total ||= Storage::Total.create_or_find_by!(owner: self) + end + def calculate_real_storage_bytes raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes" end diff --git a/app/models/concerns/storage/tracked.rb b/app/models/concerns/storage/tracked.rb index 7b96e95c0..745ef6692 100644 --- a/app/models/concerns/storage/tracked.rb +++ b/app/models/concerns/storage/tracked.rb @@ -1,3 +1,6 @@ +# Storage tracking is a business abstraction - we count what users upload. +# Original upload bytes only; variants/previews/derivatives excluded. +# Physical storage optimizations (deduplication, compression) don't affect quotas. module Storage::Tracked extend ActiveSupport::Concern @@ -22,21 +25,27 @@ module Storage::Tracked private def board_transfer? - respond_to?(:board_id_changed?) && board_id_changed? + respond_to?(:will_save_change_to_board_id?) && will_save_change_to_board_id? end def track_board_transfer - old_board_id = board_id_was - current_bytes = storage_bytes + old_board = Board.find_by(id: attribute_in_database(:board_id)) + records = storage_transfer_records.compact + return if records.empty? + + attachments_by_record = storage_attachments_for_records(records) + + attachments_by_record.each do |recordable, attachments| + bytes = attachments.sum { |attachment| attachment.blob.byte_size } + next if bytes.zero? - if current_bytes.positive? # Debit old board - if old_board_id + if old_board Storage::Entry.record \ account: account, - board_id: old_board_id, - recordable: self, - delta: -current_bytes, + board: old_board, + recordable: recordable, + delta: -bytes, operation: "transfer_out" end @@ -44,14 +53,45 @@ module Storage::Tracked Storage::Entry.record \ account: account, board: board, - recordable: self, - delta: current_bytes, + recordable: recordable, + delta: bytes, operation: "transfer_in" end end + def storage_transfer_records + [ self ] + end + # Override if needed. Default = all direct attachments - def attachments_for_storage - ActiveStorage::Attachment.where(record: self) + def attachments_for_storage(recordable = self) + storage_attachments_for_records([ recordable ]).fetch(recordable, []) + end + + def storage_attachments_for_records(recordables) + records = Array(recordables).compact + return {} if records.empty? + + # Build lookup for records by (type, id) to avoid N+1 when resolving RichText parents + records_by_key = records.index_by { |r| [ r.class.name, r.id ] } + + rich_texts = ActionText::RichText.where(record: records) + rich_text_to_parent = rich_texts.to_h { |rt| [ rt.id, records_by_key[[ rt.record_type, rt.record_id ]] ] } + + attachments = ActiveStorage::Attachment + .where(record: records + rich_texts) + .includes(:blob) + .to_a + + attachments.each_with_object(Hash.new { |h, k| h[k] = [] }) do |attachment, grouped| + # Resolve parent without N+1: use lookup for RichText, direct for others + recordable = if attachment.record_type == "ActionText::RichText" + rich_text_to_parent[attachment.record_id] + else + records_by_key[[ attachment.record_type, attachment.record_id ]] + end + + grouped[recordable] << attachment if recordable + end end end diff --git a/app/models/event/description.rb b/app/models/event/description.rb index 672784ddb..f44233011 100644 --- a/app/models/event/description.rb +++ b/app/models/event/description.rb @@ -14,7 +14,7 @@ class Event::Description end def to_plain_text - to_sentence(creator_name, card.title) + to_sentence(creator_name, h(card.title)) end private diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb index a73b6b3be..40be7d157 100644 --- a/app/models/ssrf_protection.rb +++ b/app/models/ssrf_protection.rb @@ -16,13 +16,19 @@ module SsrfProtection def resolve_public_ip(hostname) ip_addresses = resolve_dns(hostname) - public_ips = ip_addresses.reject { |ip| private_address?(ip) } + public_ips = ip_addresses.reject { |ip| blocked_address?(ip) } public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s end - def private_address?(ip) + def blocked_address?(ip) ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr) - ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || in_disallowed_range?(ip) + + ip.private? || + ip.loopback? || + ip.link_local? || + ip.ipv4_mapped? || + ip.ipv4_compat? || + in_disallowed_range?(ip) end private diff --git a/app/models/storage.rb b/app/models/storage.rb index 9c17b7cd9..445362b34 100644 --- a/app/models/storage.rb +++ b/app/models/storage.rb @@ -2,4 +2,17 @@ module Storage def self.table_name_prefix "storage_" end + + # Record types that participate in storage tracking (ledger entries created on attach). + # The no-reuse validation uses this to scope its check. + # + # IMPORTANT: Update this constant when adding tracked attachments to new models. + # If you add a direct attachment (not via RichText embeds) to Comment, Board, or + # another model with Storage::Tracked, you must add its record_type here or the + # no-reuse validation won't protect it. + TRACKED_RECORD_TYPES = %w[Card ActionText::RichText].freeze + + # Account ID for template/demo blobs that can be reused cross-tenant. + # Set to nil to disable the whitelist (no exemptions). + TEMPLATE_ACCOUNT_ID = nil end diff --git a/app/models/storage/attachment_tracking.rb b/app/models/storage/attachment_tracking.rb index d68f5845d..1c3c7151d 100644 --- a/app/models/storage/attachment_tracking.rb +++ b/app/models/storage/attachment_tracking.rb @@ -26,24 +26,23 @@ module Storage::AttachmentTracking return unless @storage_snapshot Storage::Entry.record \ - account_id: @storage_snapshot[:account_id], - board_id: @storage_snapshot[:board_id], - recordable_type: @storage_snapshot[:recordable_type], - recordable_id: @storage_snapshot[:recordable_id], - blob_id: @storage_snapshot[:blob_id], + account: @storage_snapshot[:account], + board: @storage_snapshot[:board], + recordable: @storage_snapshot[:recordable], + blob: blob, delta: -blob.byte_size, operation: "detach" end + # Snapshot records in before_destroy since parent may be deleted by the time + # after_destroy_commit runs. The records may be destroyed but .id still works. def snapshot_storage_context return unless storage_tracked_record @storage_snapshot = { - account_id: storage_tracked_record.account.id, - board_id: storage_tracked_record.board_for_storage_tracking&.id, - recordable_type: storage_tracked_record.class.name, - recordable_id: storage_tracked_record.id, - blob_id: blob.id + account: storage_tracked_record.account, + board: storage_tracked_record.board_for_storage_tracking, + recordable: storage_tracked_record } end diff --git a/app/models/storage/entry.rb b/app/models/storage/entry.rb index a2f6e726b..768a4c6f8 100644 --- a/app/models/storage/entry.rb +++ b/app/models/storage/entry.rb @@ -5,30 +5,26 @@ class Storage::Entry < ApplicationRecord scope :pending, ->(last_entry_id) { where.not(id: ..last_entry_id) if last_entry_id } - # Accepts either objects or _id params (for after_destroy_commit snapshots) - def self.record(delta:, operation:, account: nil, account_id: nil, board: nil, board_id: nil, - recordable: nil, recordable_type: nil, recordable_id: nil, blob: nil, blob_id: nil) + # Records may be destroyed (during cascading deletes) but .id still works. + # Skip entirely if account is destroyed - no need to track storage for deleted accounts. + # Skip materialize jobs for destroyed boards since there's nothing to update. + def self.record(delta:, operation:, account:, board: nil, recordable: nil, blob: nil) return if delta.zero? - - account_id ||= account&.id - board_id ||= board&.id - blob_id ||= blob&.id + return if account.destroyed? entry = create! \ - account_id: account_id, - board_id: board_id, - recordable_type: recordable_type || recordable&.class&.name, - recordable_id: recordable_id || recordable&.id, - blob_id: blob_id, + account_id: account.id, + board_id: board&.id, + recordable_type: recordable&.class&.name, + recordable_id: recordable&.id, + blob_id: blob&.id, delta: delta, operation: operation, user_id: Current.user&.id, request_id: Current.request_id - # Enqueue materialization - use find_by to handle cascading deletes - # (Account/Board may be destroyed while attachments are still being cleaned up) - Account.find_by(id: account_id)&.materialize_storage_later - Board.find_by(id: board_id)&.materialize_storage_later if board_id + account.materialize_storage_later + board&.materialize_storage_later unless board&.destroyed? entry end diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index 400738cb1..c62d87e09 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -22,6 +22,11 @@ module User::Avatar avatar.variable? ? avatar.variant(:thumb) : avatar end + # Avatars are always publicly accessible + def publicly_accessible? + true + end + private def avatar_content_type_allowed if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) diff --git a/app/views/boards/show/_closed.html.erb b/app/views/boards/show/_closed.html.erb index 5d2820399..d4712b832 100644 --- a/app/views/boards/show/_closed.html.erb +++ b/app/views/boards/show/_closed.html.erb @@ -1,5 +1,6 @@ <%= column_tag id: "closed-cards", name: "Done", drop_url: columns_card_drops_closure_path("__id__"), class: "cards--closed", style: "--card-color: var(--color-card-complete);", data: { + card_hotkeys_disabled: true, drag_and_strum_target: "container", collapsible_columns_target: "column", action: "focus->collapsible-columns#focusOnColumn" diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index a6001da79..bf7a76fcc 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -1,5 +1,5 @@ <%= tag.div class: "card-columns hide-scrollbar", data: { - controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list", + controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys", drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item", drag_and_drop_hover_container_class: "drag-and-drop__hover-container", collapsible_columns_board_value: board.id, @@ -11,8 +11,11 @@ navigable_list_prevent_handled_keys_value: true, navigable_list_auto_select_value: false, navigable_list_auto_scroll_value: false, + card_hotkeys_navigable_list_outlet: ".cards__transition-container", action: " keydown->navigable-list#navigate + keydown->card-hotkeys#handleKeydown + turbo:morph@document->card-hotkeys#handleMorphComplete dragstart->drag-and-drop#dragStart dragover->drag-and-drop#dragOver dragenter->drag-and-strum#dragEnter diff --git a/app/views/boards/show/_expander.html.erb b/app/views/boards/show/_expander.html.erb index d58b68715..c66d12ea3 100644 --- a/app/views/boards/show/_expander.html.erb +++ b/app/views/boards/show/_expander.html.erb @@ -1,5 +1,5 @@ - <% if Current.user %> + <% if Current.user && !local_assigns[:preview] %> <%= button_to "Assign to me", card_assignments_path(card, params: { assignee_id: Current.user.id }), method: :post, data: {controller: "hotkey", action: "keydown.m@document->hotkey#click" }, hidden: true %> <% end %> diff --git a/app/views/cards/not_nows/create.turbo_stream.erb b/app/views/cards/not_nows/create.turbo_stream.erb new file mode 100644 index 000000000..9128580e2 --- /dev/null +++ b/app/views/cards/not_nows/create.turbo_stream.erb @@ -0,0 +1,9 @@ +<%= turbo_stream.replace("not-now", partial: "boards/show/not_now", method: :morph, locals: { board: @card.board }) %> + +<% if @source_column %> + <%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %> +<% elsif @was_in_stream %> + <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> +<% end %> + +<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d47dce6ef..7211e718d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -26,6 +26,8 @@ <%= render "notifications/tray" %> <% end %> + + <%= render "layouts/shared/welcome_letter" if flash[:welcome_letter] %> diff --git a/app/views/layouts/shared/_welcome_letter.html.erb b/app/views/layouts/shared/_welcome_letter.html.erb new file mode 100644 index 000000000..04073d518 --- /dev/null +++ b/app/views/layouts/shared/_welcome_letter.html.erb @@ -0,0 +1,28 @@ +
+ + + +
+ <%= image_tag "jf-avatar.jpg", size: 36%> +
+ +

Welcome, and thanks for signing up for Fizzy.

+ +

+ +

To get you started, we set you up with a Fizzy board called Playground. It’s got a few cards designed to help you learn Fizzy itself. Open each card, go through the simple steps, and you’ll be an expert in Fizzy in no time. You’ll see the Playground when you close this message.

+ +

If you ever need a hand, please contact me directly at jason@37signals.com. I'm here for you, we’re all here for you.

+ +

Thanks again and all the best,

+ + + +

Jason Fried, jason@37signals.com
+ CEO & co-founder of 37signals, makers of Fizzy, Basecamp, and HEY +

+
+
diff --git a/bin/setup b/bin/setup index a49eaa9d4..306282cb5 100755 --- a/bin/setup +++ b/bin/setup @@ -51,7 +51,7 @@ if ! command -v gh &>/dev/null; then echo echo "▸ Installing GitHub CLI" if command -v pacman &>/dev/null; then - sudo pacman -S --noconfirm gh + sudo pacman -S --noconfirm github-cli elif command -v brew &>/dev/null; then brew install gh else diff --git a/config/initializers/action_text.rb b/config/initializers/action_text.rb index 0f804e57b..6aed0616d 100644 --- a/config/initializers/action_text.rb +++ b/config/initializers/action_text.rb @@ -16,6 +16,14 @@ module ActionText def storage_tracked_record record.try(:storage_tracked_record) end + + def accessible_to?(user) + record.try(:accessible_to?, user) || record.try(:publicly_accessible?) + end + + def publicly_accessible? + record.try(:publicly_accessible?) + end end end end diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index 5c83b18f5..3edd75cc8 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -40,6 +40,7 @@ module ActiveStorageDirectUploadsControllerExtensions included do include Authentication + include Authorization skip_forgery_protection if: :authenticate_by_bearer_token end end diff --git a/config/initializers/active_storage_no_reuse.rb b/config/initializers/active_storage_no_reuse.rb new file mode 100644 index 000000000..2def087fe --- /dev/null +++ b/config/initializers/active_storage_no_reuse.rb @@ -0,0 +1,64 @@ +# Enforce storage ledger integrity by preventing blob reuse in tracked contexts. +# +# Two invariants: +# 1. Account match: blob.account_id == record.account_id (multi-tenant safety) +# 2. No reuse within tracked contexts: a blob can only have one tracked attachment +# +# With per-attachment reconcile, blob reuse inside an account wouldn't break correctness - +# ledger would still count each attach, and reconcile would agree. However, we intentionally +# forbid reuse (except templates) as a product/control decision: +# - Simpler mental model (one blob = one attachment) +# - Prevents accidental quota manipulation via direct blob_id reuse +# - Cleaner audit trail in ledger entries +# +# Scope note: The no-reuse validation only blocks reuse when the *new* attachment is tracked +# AND only checks *existing* attachments in Storage::TRACKED_RECORD_TYPES. A blob first +# attached to an untracked type (avatar/export) could theoretically be reused in a tracked +# context. This is acceptable - user-accessible blob IDs from untracked contexts are +# basically nonexistent in practice. + +ActiveSupport.on_load(:active_storage_attachment) do + validate :blob_account_matches_record, on: :create + validate :no_tracked_blob_reuse, on: :create + + private + # Multi-tenant safety: blob must belong to same account as record + # NOTE: Skips validation if record.account is nil. This is a theoretical bypass + # if someone attaches before account assignment, but our flows assign account + # before attachment. Global/unaccounted attachments (Identity/User avatars, exports) + # bypass tenancy checks via try(:account) returning nil - this is intentional as + # these classes don't participate in storage tracking. + def blob_account_matches_record + return unless record&.try(:account).present? + return if whitelisted_for_cross_account? + + unless blob&.account_id == record.account.id + errors.add(:blob_id, "blob account must match record account") + end + end + + # Ledger integrity: blob can only have one tracked attachment + def no_tracked_blob_reuse + tracked_record = record&.try(:storage_tracked_record) + return unless tracked_record.present? + return if whitelisted_for_cross_account? + + # Check for existing attachment of this blob in tracked contexts + # Uses Storage::TRACKED_RECORD_TYPES constant to stay generic + existing = ActiveStorage::Attachment + .where(blob_id: blob_id) + .where(record_type: Storage::TRACKED_RECORD_TYPES) + .where.not(id: id) + .exists? + + if existing + errors.add(:blob_id, "cannot reuse blob in tracked storage context") + end + end + + def whitelisted_for_cross_account? + # Only template account blobs can be reused cross-tenant. + # When TEMPLATE_ACCOUNT_ID is nil, no exemptions are granted. + Storage::TEMPLATE_ACCOUNT_ID.present? && blob&.account_id == Storage::TEMPLATE_ACCOUNT_ID + end +end diff --git a/config/recurring.yml b/config/recurring.yml index f59f97ead..6485278b3 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -38,6 +38,14 @@ production: &production schedule: every 60 seconds <% end %> -beta: *production +beta: + # Only Solid Queue maintenance + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 + clear_solid_queue_recurring_executions: + command: "SolidQueue::RecurringExecution.clear_in_batches" + schedule: every hour at minute 52 + staging: *production development: *production diff --git a/docs/API.md b/docs/API.md index 3c1d91977..ee1820f28 100644 --- a/docs/API.md +++ b/docs/API.md @@ -796,6 +796,10 @@ __Response:__ "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, + "card": { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "url": "http://fizzy.localhost:3006/897362094/cards/03f5v9zo9qlcwwpyc0ascnikz" + }, "reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions", "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz" } @@ -826,6 +830,10 @@ __Response:__ "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, + "card": { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "url": "http://fizzy.localhost:3006/897362094/cards/03f5v9zo9qlcwwpyc0ascnikz" + }, "reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions", "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz" } diff --git a/lib/auto_link_scrubber.rb b/lib/auto_link_scrubber.rb new file mode 100644 index 000000000..5ac9f2f3d --- /dev/null +++ b/lib/auto_link_scrubber.rb @@ -0,0 +1,79 @@ +# Loofah scrubber to auto-link URLs and email addresses in HTML text nodes. +# +# This scrubber does not perform HTML sanitization; it's assumed that the input is already sanitized +# (for example, ActionText rich text). +class AutoLinkScrubber < Loofah::Scrubber + EXCLUDED_ELEMENTS = %w[a figcaption pre code].freeze + + # This regexp is similar to URI::MailTo::EMAIL_REGEXP but uses \b word boundaries instead of \A/\z + # anchors, allowing it to match email addresses embedded within longer strings. + # + # It's named EMAIL_AUTOLINK_REGEXP (not EMAIL_REGEXP) to avoid confusing Brakeman's imprecise + # constant lookup, which otherwise assumes Identity's email validation uses this \b-anchored pattern. + # See https://github.com/presidentbeef/brakeman/pull/1981 + EMAIL_AUTOLINK_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/ + URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https]) + AUTOLINK_REGEXP = /(?#{URL_REGEXP})|(?#{EMAIL_AUTOLINK_REGEXP})/ + + TRAILING_PUNCTUATION = %(.?,:!;"'<>) + TRAILING_PUNCTUATION_REGEXP = /[#{Regexp.escape(TRAILING_PUNCTUATION)}]+\z/ + + def initialize + @direction = :top_down + end + + def scrub(node) + return Loofah::Scrubber::STOP if EXCLUDED_ELEMENTS.include?(node.name) + + if node.text? + replacement = autolink_text_node(node) + node.replace(replacement) if replacement + end + + Loofah::Scrubber::CONTINUE + end + + private + def autolink_text_node(node) + text = node.text + links = find_links(text) + + return nil if links.empty? + + doc = node.document + nodes = Nokogiri::XML::NodeSet.new(doc) + pos = 0 + + links.each do |link| + nodes << doc.create_text_node(text[pos...link[:start]]) if link[:start] > pos + nodes << doc.create_element("a", link[:text], href: link[:href], rel: "noopener noreferrer") + pos = link[:start] + link[:length] + end + nodes << doc.create_text_node(text[pos..]) if pos < text.length + + nodes + end + + def find_links(text) + links = [] + + text.scan(AUTOLINK_REGEXP) do + match_data = Regexp.last_match + start_pos = match_data.begin(0) + + if match_data[:url] + url = clean_url(match_data[:url]) + links << { start: start_pos, length: url.length, text: url, href: url } + else + email = match_data[:email] + links << { start: start_pos, length: email.length, text: email, href: "mailto:#{email}" } + end + end + + links + end + + def clean_url(url) + url.sub(TRAILING_PUNCTUATION_REGEXP, "") + end +end diff --git a/lib/rails_ext/active_storage_authorization.rb b/lib/rails_ext/active_storage_authorization.rb new file mode 100644 index 000000000..55d0de3bc --- /dev/null +++ b/lib/rails_ext/active_storage_authorization.rb @@ -0,0 +1,49 @@ +ActiveSupport.on_load :active_storage_blob do + def accessible_to?(user) + attachments.includes(:record).any? { |attachment| attachment.accessible_to?(user) } || attachments.none? + end + + def publicly_accessible? + attachments.includes(:record).any? { |attachment| attachment.publicly_accessible? } + end +end + +ActiveSupport.on_load :active_storage_attachment do + def accessible_to?(user) + record.try(:accessible_to?, user) + end + + def publicly_accessible? + record.try(:publicly_accessible?) + end +end + +Rails.application.config.to_prepare do + module ActiveStorage::Authorize + extend ActiveSupport::Concern + + include Authentication + + included do + # Ensure require_authentication runs after set_blob. + skip_before_action :require_authentication + before_action :require_authentication, :ensure_accessible, unless: :publicly_accessible_blob? + end + + private + def publicly_accessible_blob? + @blob.publicly_accessible? + end + + def ensure_accessible + unless @blob.accessible_to?(Current.user) + head :forbidden + end + end + end + + ActiveStorage::Blobs::RedirectController.include ActiveStorage::Authorize + ActiveStorage::Blobs::ProxyController.include ActiveStorage::Authorize + ActiveStorage::Representations::RedirectController.include ActiveStorage::Authorize + ActiveStorage::Representations::ProxyController.include ActiveStorage::Authorize +end diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 658b75c59..423ef11fb 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) diff --git a/saas/.kamal/secrets.beta1 b/saas/.kamal/secrets.beta1 new file mode 120000 index 000000000..80e6c155e --- /dev/null +++ b/saas/.kamal/secrets.beta1 @@ -0,0 +1 @@ +secrets.beta \ No newline at end of file diff --git a/saas/.kamal/secrets.beta2 b/saas/.kamal/secrets.beta2 new file mode 120000 index 000000000..80e6c155e --- /dev/null +++ b/saas/.kamal/secrets.beta2 @@ -0,0 +1 @@ +secrets.beta \ No newline at end of file diff --git a/saas/.kamal/secrets.beta3 b/saas/.kamal/secrets.beta3 new file mode 120000 index 000000000..80e6c155e --- /dev/null +++ b/saas/.kamal/secrets.beta3 @@ -0,0 +1 @@ +secrets.beta \ No newline at end of file diff --git a/saas/.kamal/secrets.beta4 b/saas/.kamal/secrets.beta4 new file mode 120000 index 000000000..80e6c155e --- /dev/null +++ b/saas/.kamal/secrets.beta4 @@ -0,0 +1 @@ +secrets.beta \ No newline at end of file diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 768abbcd0..46d7abfbc 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index e98540135..31979d170 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) diff --git a/saas/README.md b/saas/README.md index 80ff67a36..69697ded4 100644 --- a/saas/README.md +++ b/saas/README.md @@ -64,9 +64,14 @@ This environment uses a FlashBlade bucket for blob storage. Beta is primarily intended for testing product features. It uses the same production database and Active Storage configuration. -Beta tenant is: +There are 4 beta environments: -- https://fizzy-beta.37signals.com +- https://beta1.fizzy-beta.com +- https://beta2.fizzy-beta.com +- https://beta3.fizzy-beta.com +- https://beta4.fizzy-beta.com + +Deploy with: `bin/kamal deploy -d beta1` (or `-d beta2`, `-d beta3`, `-d beta4`) ### Staging diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css index 326b95b40..a3fedc53d 100644 --- a/saas/app/assets/stylesheets/fizzy/saas.css +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -42,7 +42,7 @@ box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color); color: var(--settings-subscription-text-color); margin-inline: auto; - padding: 0 0.3em 0 1.2em; + padding: 0.3em 0.3em 0.3em 1.2em; transform: rotate(-1deg); @media (max-width: 640px) { diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index 48ce0372f..fd6b5aa47 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -9,7 +9,11 @@ module SubscriptionsHelper elsif subscription.canceled? "Your Fizzy subscription ended on" else - "Your next payment is #{ number_to_currency(subscription.next_amount_due, strip_insignificant_zeros: true) } on".html_safe + "Your next payment is #{ format_currency(subscription.next_amount_due) } on".html_safe end end + + def format_currency(amount) + number_to_currency(amount, precision: (amount % 1).zero? ? 0 : 2) + end end diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb index 1f59ff49f..dacb7e150 100644 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -10,9 +10,9 @@ <% end %> -

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

+

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= format_currency(Plan.paid.price) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

-<%= button_to "Upgrade to #{Plan.paid.name} for #{ number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> +<%= button_to "Upgrade to #{Plan.paid.name} for #{ format_currency(Plan.paid.price) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

Cancel anytime, no contracts, take your data with you whenever.

diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb index 426568eae..6ea99a066 100644 --- a/saas/app/views/account/settings/_paid_plan.html.erb +++ b/saas/app/views/account/settings/_paid_plan.html.erb @@ -10,8 +10,10 @@ <% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %>

- The <%= Current.account.plan.name %> plan includes <%= storage_to_human_size(Plan.paid.storage_limit) %>. Upgrade to get <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> extra storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more. + The <%= Current.account.plan.name %> plan includes <%= storage_to_human_size(Plan.paid.storage_limit) %>. Upgrade to get <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> extra storage for <%= format_currency(Plan.paid_with_extra_storage.price - Plan.paid.price) %>/month more.

+ + <%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name} for #{ number_to_currency(Plan.paid_with_extra_storage.price, strip_insignificant_zeros: true) }/month", account_subscription_upgrade_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> <% end %> <% if Current.account.subscription %> diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index c3e629607..4eca0ad2d 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -9,9 +9,9 @@