From 12de5641763e3f9871069e140cfd44daf3deaded Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 1 Dec 2025 12:46:37 +0100 Subject: [PATCH 01/85] Remove MySQL from default local development setup https://3.basecamp.com/2914079/buckets/37331921/chats/9301300227@9334968661 --- README.md | 9 ++++--- bin/setup | 69 +++++++++++++++++----------------------------------- config/ci.rb | 8 ++---- 3 files changed, 29 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index e88bca3c3..e0c2218b4 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,15 @@ The full continuous integration tests can be run with: ### Database configuration -Fizzy supports SQLite (default, recommended for most scenarios) and MySQL. You can switch adapters with the `DATABASE_ADAPTER` environment variable. +Fizzy works on SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL: ```sh -DATABASE_ADAPTER=mysql bin/rails -DATABASE_ADAPTER=mysql bin/test -bin/ci # Runs tests against both SQLite and MySQL +DATABASE_ADAPTER=mysql bin/setup --reset +DATABASE_ADAPTER=mysql bin/ci ``` +The remote CI pipeline will run tests against both SQLite and MySQL. + ### Outbound Emails You can view email previews at http://fizzy.localhost:3006/rails/mailers. diff --git a/bin/setup b/bin/setup index b89de589d..af296c2bf 100755 --- a/bin/setup +++ b/bin/setup @@ -59,30 +59,6 @@ needs_seeding() { } -setup_database() { - local adapter="$1" - local reset="$2" - local label="${adapter:+ ($adapter)}" - local env_cmd="${adapter:+env DATABASE_ADAPTER=$adapter}" - - # When setting up sqlite in SAAS mode, we need to disable SAAS so that - # database.yml will use database.sqlite.yml instead of the fizzy-saas config. - # We also unset BUNDLE_GEMFILE so the standard Gemfile is used (without fizzy-saas). - if [ -n "$SAAS" ] && [ "$adapter" = "sqlite" ]; then - env_cmd="env DATABASE_ADAPTER=$adapter SAAS=false BUNDLE_GEMFILE=" - fi - - if [ "$reset" = "true" ]; then - step "Resetting the database$label" $env_cmd rails db:reset - else - step "Preparing the database$label" $env_cmd rails db:prepare - - if needs_seeding; then - step "Seeding the database$label" $env_cmd rails db:seed - fi - fi -} - echo gum style --foreground 153 " ˚ ∘ ∘ ˚ " gum style --foreground 111 --bold " ∘˚˳°∘° 𝒻𝒾𝓏𝓏𝓎 °∘°˳˚∘ " @@ -108,34 +84,33 @@ if [ -n "$SAAS" ]; then saas_setup=$(bundle show fizzy-saas)/bin/setup source "$saas_setup" else - if ! nc -z localhost 3306 2>/dev/null; then - if docker ps -aq -f name=fizzy-mysql | grep -q .; then - step "Starting MySQL" docker start fizzy-mysql - else - step "Setting up MySQL" bash -c ' - docker pull mysql:8.4 - docker run -d \ - --name fizzy-mysql \ - -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ - -p 3306:3306 \ - mysql:8.4 - echo "MySQL is starting… (it may take a few seconds)" - ' + if [ "$DATABASE_ADAPTER" = "mysql" ]; then + if ! nc -z localhost 3306 2>/dev/null; then + if docker ps -aq -f name=fizzy-mysql | grep -q .; then + step "Starting MySQL" docker start fizzy-mysql + else + step "Setting up MySQL" bash -c ' + docker pull mysql:8.4 + docker run -d \ + --name fizzy-mysql \ + -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ + -p 3306:3306 \ + mysql:8.4 + echo "MySQL is starting… (it may take a few seconds)" + ' + fi fi fi fi -reset_flag="" -[[ $* == *--reset* ]] && reset_flag="true" - -if [ -n "$SAAS" ]; then - for adapter in saas sqlite; do - setup_database "$adapter" "$reset_flag" - done +if [[ $* == *--reset* ]]; then + step "Resetting the database" rails db:reset else - for adapter in sqlite mysql; do - setup_database "$adapter" "$reset_flag" - done + step "Preparing the database" rails db:prepare + + if needs_seeding; then + step "Seeding the database" rails db:seed + fi fi step "Cleaning up logs and tempfiles" rails log:clear tmp:clear diff --git a/config/ci.rb b/config/ci.rb index aeab8a647..053fe2682 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -19,13 +19,9 @@ CI.run do if Fizzy.saas? step "Tests: SaaS", "#{SAAS_ENV} bin/rails test" step "Tests: SaaS System", "#{SAAS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system" - step "Tests: SQLite", "#{OSS_ENV} DATABASE_ADAPTER=sqlite bin/rails test" - step "Tests: SQLite System", "#{OSS_ENV} DATABASE_ADAPTER=sqlite #{SYSTEM_TEST_ENV} bin/rails test:system" else - step "Tests: MySQL", "#{OSS_ENV} DATABASE_ADAPTER=mysql bin/rails test" - step "Tests: MySQL System", "#{OSS_ENV} DATABASE_ADAPTER=mysql #{SYSTEM_TEST_ENV} bin/rails test:system" - step "Tests: SQLite", "#{OSS_ENV} DATABASE_ADAPTER=sqlite bin/rails test" - step "Tests: SQLite System", "#{OSS_ENV} DATABASE_ADAPTER=sqlite #{SYSTEM_TEST_ENV} bin/rails test:system" + step "Tests: SQLite", "#{OSS_ENV} bin/rails test" + step "Tests: SQLite System", "#{OSS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system" end if success? From cc6dcb6c5b6e15abd72a6ec3b5745e21454d7a1d Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 1 Dec 2025 12:48:35 +0100 Subject: [PATCH 02/85] Extract mysql setup to function --- README.md | 2 +- bin/setup | 33 ++++++++++++++++++--------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e0c2218b4..b668747e9 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The full continuous integration tests can be run with: ### Database configuration -Fizzy works on SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL: +Fizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL: ```sh DATABASE_ADAPTER=mysql bin/setup --reset diff --git a/bin/setup b/bin/setup index af296c2bf..c29d5e1d6 100755 --- a/bin/setup +++ b/bin/setup @@ -58,6 +58,23 @@ needs_seeding() { fi } +oss_mysql_setup() { + if ! nc -z localhost 3306 2>/dev/null; then + if docker ps -aq -f name=fizzy-mysql | grep -q .; then + step "Starting MySQL" docker start fizzy-mysql + else + step "Setting up MySQL" bash -c ' + docker pull mysql:8.4 + docker run -d \ + --name fizzy-mysql \ + -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ + -p 3306:3306 \ + mysql:8.4 + echo "MySQL is starting… (it may take a few seconds)" + ' + fi + fi +} echo gum style --foreground 153 " ˚ ∘ ∘ ˚ " @@ -85,21 +102,7 @@ if [ -n "$SAAS" ]; then source "$saas_setup" else if [ "$DATABASE_ADAPTER" = "mysql" ]; then - if ! nc -z localhost 3306 2>/dev/null; then - if docker ps -aq -f name=fizzy-mysql | grep -q .; then - step "Starting MySQL" docker start fizzy-mysql - else - step "Setting up MySQL" bash -c ' - docker pull mysql:8.4 - docker run -d \ - --name fizzy-mysql \ - -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ - -p 3306:3306 \ - mysql:8.4 - echo "MySQL is starting… (it may take a few seconds)" - ' - fi - fi + oss_mysql_setup fi fi From 5a1b59dc47ab7c02963622cb11d1da45f7e45766 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 1 Dec 2025 13:25:44 +0100 Subject: [PATCH 03/85] Define solid cable databases for the OSS mode --- config/database.mysql.yml | 27 +++++++++++++++++++++------ config/database.sqlite.yml | 12 ++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/config/database.mysql.yml b/config/database.mysql.yml index e3188b1ac..14e8a946a 100644 --- a/config/database.mysql.yml +++ b/config/database.mysql.yml @@ -8,13 +8,28 @@ default: &default timeout: 5000 development: - <<: *default - database: fizzy_development + primary: + <<: *default + database: fizzy_development + cable: + <<: *default + database: fizzy_development_cable + migrations_paths: db/cable_migrate test: - <<: *default - database: fizzy_test + primary: + <<: *default + database: fizzy_test + cable: + <<: *default + database: fizzy_test_cable + migrations_paths: db/cable_migrate production: - <<: *default - database: fizzy_production + primary: + <<: *default + database: fizzy_production + cable: + <<: *default + database: fizzy_production_cable + migrations_paths: db/cable_migrate diff --git a/config/database.sqlite.yml b/config/database.sqlite.yml index 7cc7404c7..16ae64765 100644 --- a/config/database.sqlite.yml +++ b/config/database.sqlite.yml @@ -8,15 +8,27 @@ development: <<: *default database: storage/development.sqlite3 schema_dump: schema_sqlite.rb + cable: + <<: *default + database: storage/development_cable.sqlite3 + migrations_paths: db/cable_migrate test: primary: <<: *default database: storage/test.sqlite3 schema_dump: schema_sqlite.rb + cable: + <<: *default + database: storage/test_cable.sqlite3 + migrations_paths: db/cable_migrate production: primary: <<: *default database: storage/production.sqlite3 schema_dump: schema_sqlite.rb + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate From 3480e2d6dbcbd4499edc4d823dbcf7b44e1e46b2 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 1 Dec 2025 13:32:54 +0100 Subject: [PATCH 04/85] Handle size in binary columns In the cable schema we use: ``` t.binary "payload", size: :long, null: false ``` This will fail when resetting the db otherwise with an error due to the unexpected size property. --- config/initializers/table_definition_column_limits.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/initializers/table_definition_column_limits.rb b/config/initializers/table_definition_column_limits.rb index ff01c5e3a..f604bd325 100644 --- a/config/initializers/table_definition_column_limits.rb +++ b/config/initializers/table_definition_column_limits.rb @@ -24,13 +24,13 @@ module TableDefinitionColumnLimits options[:limit] ||= STRING_DEFAULT_LIMIT end - if type == :text + if type == :text || type == :binary if options.key?(:size) size = options.delete(:size) options[:limit] ||= TEXT_SIZE_TO_LIMIT.fetch(size) do raise ArgumentError, "Unknown text size: #{size.inspect}. Use :tiny, :medium, or :long" end - else + elsif type == :text options[:limit] ||= TEXT_DEFAULT_LIMIT end end From 29d07e732673f4f5ba208614ff35f8294295d9a4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 1 Dec 2025 15:39:45 +0100 Subject: [PATCH 05/85] Fix: not detecting mentions when publishing saved drafts The problem was that publishing a card with `#publish` was tracking the event after updating the status, which was clearing the saved changes and preventing the code from detecting the mention. See: https://app.fizzy.do/5986089/cards/2835 --- app/models/card/mentions.rb | 2 +- app/models/card/statuses.rb | 14 ++++++++++++++ test/models/card/statuses_test.rb | 22 ++++++++++++++++++++++ test/models/concerns/mentions_test.rb | 2 +- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/models/card/mentions.rb b/app/models/card/mentions.rb index 3ce74726b..11e35b97c 100644 --- a/app/models/card/mentions.rb +++ b/app/models/card/mentions.rb @@ -9,7 +9,7 @@ module Card::Mentions end def should_check_mentions? - saved_change_to_status? && published? + was_just_published? end end end diff --git a/app/models/card/statuses.rb b/app/models/card/statuses.rb index d339acd17..53b243e4e 100644 --- a/app/models/card/statuses.rb +++ b/app/models/card/statuses.rb @@ -4,7 +4,10 @@ module Card::Statuses included do enum :status, %w[ drafted published ].index_by(&:itself) + attr_reader :initial_status + before_save :update_created_at_on_publication + before_save :remember_initial_status after_create -> { track_event :published }, if: :published? scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) } @@ -17,10 +20,21 @@ module Card::Statuses end end + def was_just_published? + initial_status&.drafted? && status_in_database.inquiry.published? + end + private def update_created_at_on_publication if will_save_change_to_status? && status_in_database.inquiry.drafted? self.created_at = Time.current end end + + # So that we can check it in callbacks when other operations in the transaction clean the changes. + def remember_initial_status + if will_save_change_to_status? + @initial_status ||= status_in_database.to_s.inquiry + end + end end diff --git a/test/models/card/statuses_test.rb b/test/models/card/statuses_test.rb index ba801bfd9..396e1f615 100644 --- a/test/models/card/statuses_test.rb +++ b/test/models/card/statuses_test.rb @@ -67,4 +67,26 @@ class Card::StatusesTest < ActiveSupport::TestCase assert_equal Time.current, card.created_at end + + test "detect drafts that were just published" do + Current.session = sessions(:david) + + card = boards(:writebook).cards.create! creator: users(:kevin), title: "Draft Card" + assert card.drafted? + assert_not card.was_just_published? + + card.publish + + assert card.was_just_published? + assert_not Card.find(card.id).was_just_published? + end + + test "detect cards that were created and published" do + Current.session = sessions(:david) + + card = boards(:writebook).cards.create! creator: users(:kevin), title: "Published Card", status: :published + assert card.was_just_published? + + assert_not Card.find(card.id).was_just_published? + end end diff --git a/test/models/concerns/mentions_test.rb b/test/models/concerns/mentions_test.rb index 833d6df0d..48b94e6f1 100644 --- a/test/models/concerns/mentions_test.rb +++ b/test/models/concerns/mentions_test.rb @@ -23,7 +23,7 @@ class MentionsTest < ActiveSupport::TestCase card = Card.find(card.id) assert_difference -> { Mention.count }, +1 do - card.published! + card.publish end end end From 7dd3497d3aa3a427223c5a84717fa479dc3f4ccd Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 1 Dec 2025 15:42:03 +0100 Subject: [PATCH 06/85] Extract common setup step --- test/models/card/statuses_test.rb | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/test/models/card/statuses_test.rb b/test/models/card/statuses_test.rb index 396e1f615..2f9ba95ca 100644 --- a/test/models/card/statuses_test.rb +++ b/test/models/card/statuses_test.rb @@ -1,6 +1,10 @@ require "test_helper" class Card::StatusesTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + end + test "cards start out in a `drafted` state" do card = boards(:writebook).cards.create! creator: users(:kevin), title: "Newly created card" @@ -24,8 +28,6 @@ class Card::StatusesTest < ActiveSupport::TestCase end test "an event is created when a card is created in the published state" do - Current.session = sessions(:david) - assert_no_difference(-> { Event.count }) do boards(:writebook).cards.create! creator: users(:kevin), title: "Draft Card" end @@ -40,8 +42,6 @@ class Card::StatusesTest < ActiveSupport::TestCase end test "an event is created when a card is published" do - Current.session = sessions(:david) - card = boards(:writebook).cards.create! creator: users(:kevin), title: "Published Card" assert_difference(-> { Event.count } => +1) do card.publish @@ -53,7 +53,6 @@ class Card::StatusesTest < ActiveSupport::TestCase end test "created_at is updated when the card is published" do - Current.session = sessions(:david) freeze_time card = travel_to 1.week.ago do @@ -69,8 +68,6 @@ class Card::StatusesTest < ActiveSupport::TestCase end test "detect drafts that were just published" do - Current.session = sessions(:david) - card = boards(:writebook).cards.create! creator: users(:kevin), title: "Draft Card" assert card.drafted? assert_not card.was_just_published? @@ -82,8 +79,6 @@ class Card::StatusesTest < ActiveSupport::TestCase end test "detect cards that were created and published" do - Current.session = sessions(:david) - card = boards(:writebook).cards.create! creator: users(:kevin), title: "Published Card", status: :published assert card.was_just_published? From e16cc21b0ad614a223d43edca1717e76e2b8f8d4 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Mon, 1 Dec 2025 10:10:51 +0000 Subject: [PATCH 07/85] Add "data export" feature - Adds a button in Account Settings where you can request a ZIP export of your Fizzy data - Export files are created in the background. When ready, a link to download them is sent to the requester. - Exports expire after 24 hours. And are limited to 10 per day. --- Gemfile | 1 + Gemfile.lock | 1 + Gemfile.saas.lock | 1 + app/controllers/account/exports_controller.rb | 24 +++++ .../controllers/auto_click_controller.js | 7 ++ app/jobs/export_account_data_job.rb | 7 ++ app/mailers/export_mailer.rb | 8 ++ app/models/account.rb | 1 + app/models/account/export.rb | 75 +++++++++++++++ app/models/card.rb | 2 +- app/models/card/exportable.rb | 76 +++++++++++++++ app/models/user.rb | 1 + app/views/account/exports/show.html.erb | 37 ++++++++ app/views/account/settings/_export.html.erb | 22 +++++ app/views/account/settings/show.html.erb | 1 + .../mailers/export_mailer/completed.html.erb | 6 ++ .../mailers/export_mailer/completed.text.erb | 3 + config/recurring.yml | 3 + config/routes.rb | 1 + .../20251201100607_create_account_exports.rb | 14 +++ db/schema.rb | 13 ++- db/schema_sqlite.rb | 13 ++- .../accounts/exports_controller_test.rb | 78 +++++++++++++++ test/fixtures/account/exports.yml | 12 +++ test/mailers/export_mailer_test.rb | 16 ++++ .../mailers/previews/export_mailer_preview.rb | 11 +++ test/models/account/export_test.rb | 95 +++++++++++++++++++ test/models/card/exportable_test.rb | 39 ++++++++ 28 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 app/controllers/account/exports_controller.rb create mode 100644 app/javascript/controllers/auto_click_controller.js create mode 100644 app/jobs/export_account_data_job.rb create mode 100644 app/mailers/export_mailer.rb create mode 100644 app/models/account/export.rb create mode 100644 app/models/card/exportable.rb create mode 100644 app/views/account/exports/show.html.erb create mode 100644 app/views/account/settings/_export.html.erb create mode 100644 app/views/mailers/export_mailer/completed.html.erb create mode 100644 app/views/mailers/export_mailer/completed.text.erb create mode 100644 db/migrate/20251201100607_create_account_exports.rb create mode 100644 test/controllers/accounts/exports_controller_test.rb create mode 100644 test/fixtures/account/exports.yml create mode 100644 test/mailers/export_mailer_test.rb create mode 100644 test/mailers/previews/export_mailer_preview.rb create mode 100644 test/models/account/export_test.rb create mode 100644 test/models/card/exportable_test.rb diff --git a/Gemfile b/Gemfile index 63c98f89c..7f7a463bb 100644 --- a/Gemfile +++ b/Gemfile @@ -33,6 +33,7 @@ gem "platform_agent" gem "aws-sdk-s3", require: false gem "web-push" gem "net-http-persistent" +gem "rubyzip", require: "zip" gem "mittens" gem "useragent", bc: "useragent" diff --git a/Gemfile.lock b/Gemfile.lock index 502c14965..5eefe452a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -503,6 +503,7 @@ DEPENDENCIES rouge rqrcode rubocop-rails-omakase + rubyzip selenium-webdriver solid_cable (>= 3.0) solid_cache (~> 1.0) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f1c1b3b98..698763f7c 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -632,6 +632,7 @@ DEPENDENCIES rouge rqrcode rubocop-rails-omakase + rubyzip selenium-webdriver sentry-rails sentry-ruby diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb new file mode 100644 index 000000000..13d000002 --- /dev/null +++ b/app/controllers/account/exports_controller.rb @@ -0,0 +1,24 @@ +class Account::ExportsController < ApplicationController + before_action :ensure_export_limit_not_exceeded, only: :create + before_action :set_export, only: :show + + CURRENT_EXPORT_LIMIT = 10 + + def show + end + + def create + Current.account.exports.create!(user: Current.user).build_later + + redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." + end + + private + def ensure_export_limit_not_exceeded + head :too_many_requests if Current.user.exports.current.count >= CURRENT_EXPORT_LIMIT + end + + def set_export + @export = Current.account.exports.completed.find_by(id: params[:id], user: Current.user) + end +end diff --git a/app/javascript/controllers/auto_click_controller.js b/app/javascript/controllers/auto_click_controller.js new file mode 100644 index 000000000..ebc6722b2 --- /dev/null +++ b/app/javascript/controllers/auto_click_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.click() + } +} diff --git a/app/jobs/export_account_data_job.rb b/app/jobs/export_account_data_job.rb new file mode 100644 index 000000000..82b5071cb --- /dev/null +++ b/app/jobs/export_account_data_job.rb @@ -0,0 +1,7 @@ +class ExportAccountDataJob < ApplicationJob + queue_as :backend + + def perform(export) + export.build + end +end diff --git a/app/mailers/export_mailer.rb b/app/mailers/export_mailer.rb new file mode 100644 index 000000000..62def6931 --- /dev/null +++ b/app/mailers/export_mailer.rb @@ -0,0 +1,8 @@ +class ExportMailer < ApplicationMailer + def completed(export) + @export = export + @user = export.user + + mail to: @user.identity.email_address, subject: "Your Fizzy export is ready" + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 9c445f10e..01f4d1d8f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -8,6 +8,7 @@ class Account < ApplicationRecord has_many :webhooks, dependent: :destroy has_many :tags, dependent: :destroy has_many :columns, dependent: :destroy + has_many :exports, class_name: "Account::Export", dependent: :destroy has_many_attached :uploads diff --git a/app/models/account/export.rb b/app/models/account/export.rb new file mode 100644 index 000000000..289c0352f --- /dev/null +++ b/app/models/account/export.rb @@ -0,0 +1,75 @@ +class Account::Export < ApplicationRecord + belongs_to :account + belongs_to :user + + has_one_attached :file + + enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending + + scope :current, -> { where(created_at: 24.hours.ago..) } + scope :expired, -> { where(completed_at: ...24.hours.ago) } + + def self.cleanup + expired.destroy_all + end + + def build_later + ExportAccountDataJob.perform_later(self) + end + + def build + processing! + + zipfile = generate_zip + file.attach( + io: File.open(zipfile.path), + filename: "export-#{id}.zip", + content_type: "application/zip" + ) + + mark_completed + ExportMailer.completed(self).deliver_later + rescue => e + update!(status: :failed) + raise + ensure + zipfile&.close + zipfile&.unlink + end + + def mark_completed + update!(status: :completed, completed_at: Time.current) + end + + private + def generate_zip + Tempfile.new([ "export", ".zip" ]).tap do |tempfile| + Zip::File.open(tempfile.path, create: true) do |zip| + exportable_cards.find_each do |card| + add_card_to_zip(zip, card) + end + end + end + end + + def exportable_cards + user.accessible_cards.includes( + :board, + creator: :identity, + comments: { creator: :identity }, + rich_text_description: { embeds_attachments: :blob } + ) + end + + def add_card_to_zip(zip, card) + zip.get_output_stream("#{card.number}.json") do |f| + f.write(card.export_json) + end + + card.export_attachments.each do |attachment| + zip.get_output_stream(attachment[:path]) do |f| + attachment[:blob].download { |chunk| f.write(chunk) } + end + end + end +end diff --git a/app/models/card.rb b/app/models/card.rb index 0a589d0b7..08787b3f3 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,6 +1,6 @@ class Card < ApplicationRecord include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, - Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, + Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } diff --git a/app/models/card/exportable.rb b/app/models/card/exportable.rb new file mode 100644 index 000000000..dc437c296 --- /dev/null +++ b/app/models/card/exportable.rb @@ -0,0 +1,76 @@ +module Card::Exportable + extend ActiveSupport::Concern + include ActionView::Helpers::TagHelper + + def export_json + { + number: number, + title: title, + board: board.name, + status: export_status, + creator: export_user(creator), + description: export_html(description), + created_at: created_at.iso8601, + updated_at: updated_at.iso8601, + comments: comments.chronologically.map do |comment| + { + id: comment.id, + body: export_html(comment.body), + creator: export_user(comment.creator), + created_at: comment.created_at.iso8601 + } + end + }.to_json + end + + def export_attachments + collect_attachments.map do |attachment| + { path: export_attachment_path(attachment.blob), blob: attachment.blob } + end + end + + private + def export_html(rich_text) + return "" if rich_text.blank? + + rich_text.body.render_attachments do |attachment| + blob = attachment.attachable + path = export_attachment_path(blob) + + if blob.image? + tag.img(src: path, alt: blob.filename) + else + tag.a(blob.filename, href: path) + end + end.to_html + end + + def export_user(user) + { + id: user.id, + name: user.name, + email: user.identity&.email_address + } + end + + def export_attachment_path(blob) + "#{number}/#{blob.key}_#{blob.filename}" + end + + def collect_attachments + attachments.to_a + comments.flat_map { |c| c.attachments.to_a } + end + + def export_status + case + when closed? + "Done" + when postponed? + "Not now" + when column.present? + column.name + else + "Maybe?" + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 90aab0338..68df97a65 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,6 +16,7 @@ class User < ApplicationRecord has_many :closures, dependent: :nullify has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card + has_many :exports, class_name: "Account::Export", dependent: :destroy scope :with_avatars, -> { preload(:account, :avatar_attachment) } diff --git a/app/views/account/exports/show.html.erb b/app/views/account/exports/show.html.erb new file mode 100644 index 000000000..5a3defa5a --- /dev/null +++ b/app/views/account/exports/show.html.erb @@ -0,0 +1,37 @@ +<% if @export.present? %> + <% @page_title = "Download Export" %> +<% else %> + <% @page_title = "Download Expired" %> +<% end %> + +<% content_for :header do %> +
+ <%= link_to account_settings_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + + <%= icon_tag "arrow-left" %> + Back to Account Settings + + + <% end %> +
+<% end %> + +
+
+

<%= @page_title %>

+
+ + <% if @export.present? %> +

Your export is ready. The download should start automatically.

+ + <%= link_to rails_blob_path(@export.file, disposition: "attachment"), + id: "download-link", + class: "btn btn--primary", + data: { turbo: false, controller: "auto-click" } do %> + Download Export + <% end %> + <% else %> +

Your download link has expired. You'll need to request a new export.

+ <% end %> + +
diff --git a/app/views/account/settings/_export.html.erb b/app/views/account/settings/_export.html.erb new file mode 100644 index 000000000..e5c2a9c02 --- /dev/null +++ b/app/views/account/settings/_export.html.erb @@ -0,0 +1,22 @@ +
+
+

Data Export

+

You can request a ZIP file containing all your Fizzy data.

+
+ +
+ + + +

Data Export

+

We will create a downloadable ZIP file of all the Fizzy data that you have access to in this account.

+

This can take a few minutes. When the file is ready, we'll email you a link to download it.

+

The download link will expire after 24 hours. But you can always request another export later.

+ +
+ + <%= button_to "Export", account_exports_path, method: :post, class: "btn btn--primary", form: { data: { action: "submit->dialog#close" } } %> +
+
+
+
diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 57f3913bf..4291dc1b4 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -16,4 +16,5 @@ <%= render "account/settings/entropy", account: @account %> + <%= render "account/settings/export" %> diff --git a/app/views/mailers/export_mailer/completed.html.erb b/app/views/mailers/export_mailer/completed.html.erb new file mode 100644 index 000000000..d95aa9d69 --- /dev/null +++ b/app/views/mailers/export_mailer/completed.html.erb @@ -0,0 +1,6 @@ +

Your export is ready

+

Your Fizzy data export has finished processing and is ready to download.

+ +

<%= link_to "Download your export", account_export_url(@export) %>

+ + diff --git a/app/views/mailers/export_mailer/completed.text.erb b/app/views/mailers/export_mailer/completed.text.erb new file mode 100644 index 000000000..bfa633a5a --- /dev/null +++ b/app/views/mailers/export_mailer/completed.text.erb @@ -0,0 +1,3 @@ +Your Fizzy data export has finished processing and is ready to download. + +Download your export: <%= account_export_url(@export) %> diff --git a/config/recurring.yml b/config/recurring.yml index 2afc11c85..f59f97ead 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -27,6 +27,9 @@ production: &production cleanup_magic_links: command: "MagicLink.cleanup" schedule: every 4 hours + cleanup_exports: + command: "Account::Export.cleanup" + schedule: every hour at minute 20 <% if Fizzy.saas? %> # Metrics diff --git a/config/routes.rb b/config/routes.rb index 6d13e219b..bbce4fe01 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,7 @@ Rails.application.routes.draw do resource :join_code resource :settings resource :entropy + resources :exports, only: [ :create, :show ] end resources :users do diff --git a/db/migrate/20251201100607_create_account_exports.rb b/db/migrate/20251201100607_create_account_exports.rb new file mode 100644 index 000000000..9638840c1 --- /dev/null +++ b/db/migrate/20251201100607_create_account_exports.rb @@ -0,0 +1,14 @@ +class CreateAccountExports < ActiveRecord::Migration[8.2] + def change + create_table :account_exports, id: :uuid do |t| + t.uuid :account_id, null: false + t.uuid :user_id, null: false + t.string :status, default: "pending", null: false + t.datetime :completed_at + t.timestamps + + t.index :account_id + t.index :user_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 52a460e71..b84d3c160 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do +ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,6 +25,17 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "completed_at" + t.datetime "created_at", null: false + t.string "status", default: "pending", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_account_exports_on_account_id" + t.index ["user_id"], name: "index_account_exports_on_user_id" + end + create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "value", default: 0, null: false t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 1a4a85e1d..6f6d6cf53 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do +ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,6 +25,17 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_exports", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "completed_at" + t.datetime "created_at", null: false + t.string "status", limit: 255, default: "pending", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_account_exports_on_account_id" + t.index ["user_id"], name: "index_account_exports_on_user_id" + end + create_table "account_external_id_sequences", id: :uuid, force: :cascade do |t| t.bigint "value", default: 0, null: false t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true diff --git a/test/controllers/accounts/exports_controller_test.rb b/test/controllers/accounts/exports_controller_test.rb new file mode 100644 index 000000000..4806b65f0 --- /dev/null +++ b/test/controllers/accounts/exports_controller_test.rb @@ -0,0 +1,78 @@ +require "test_helper" + +class Account::ExportsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :david + end + + test "create creates an export record and enqueues job" do + assert_difference -> { Account::Export.count }, 1 do + assert_enqueued_with(job: ExportAccountDataJob) do + post account_exports_path + end + end + + assert_redirected_to account_settings_path + assert_equal "Export started. You'll receive an email when it's ready.", flash[:notice] + end + + test "create associates export with current user" do + post account_exports_path + + export = Account::Export.last + assert_equal users(:david), export.user + assert_equal Current.account, export.account + assert export.pending? + end + + test "create rejects request when current export limit is reached" do + Account::ExportsController::CURRENT_EXPORT_LIMIT.times do + Account::Export.create!(account: Current.account, user: users(:david)) + end + + assert_no_difference -> { Account::Export.count } do + post account_exports_path + end + + assert_response :too_many_requests + end + + test "create allows request when exports are older than one day" do + Account::ExportsController::CURRENT_EXPORT_LIMIT.times do + Account::Export.create!(account: Current.account, user: users(:david), created_at: 2.days.ago) + end + + assert_difference -> { Account::Export.count }, 1 do + post account_exports_path + end + + assert_redirected_to account_settings_path + end + + test "show displays completed export with download link" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + export.build + + get account_export_path(export) + + assert_response :success + assert_select "a#download-link" + end + + test "show displays a warning if the export is missing" do + get account_export_path("not-really-an-export") + + assert_response :success + assert_select "h2", "Download Expired" + end + + test "show does not allow access to another user's export" do + export = Account::Export.create!(account: Current.account, user: users(:kevin)) + export.build + + get account_export_path(export) + + assert_response :success + assert_select "h2", "Download Expired" + end +end diff --git a/test/fixtures/account/exports.yml b/test/fixtures/account/exports.yml new file mode 100644 index 000000000..764f8aada --- /dev/null +++ b/test/fixtures/account/exports.yml @@ -0,0 +1,12 @@ +pending_export: + id: <%= ActiveRecord::FixtureSet.identify("pending_export", :uuid) %> + account: 37s_uuid + user: david_uuid + status: pending + +completed_export: + id: <%= ActiveRecord::FixtureSet.identify("completed_export", :uuid) %> + account: 37s_uuid + user: david_uuid + status: completed + completed_at: <%= 1.hour.ago.to_fs(:db) %> diff --git a/test/mailers/export_mailer_test.rb b/test/mailers/export_mailer_test.rb new file mode 100644 index 000000000..a627d615c --- /dev/null +++ b/test/mailers/export_mailer_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class ExportMailerTest < ActionMailer::TestCase + test "completed" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + email = ExportMailer.completed(export) + + assert_emails 1 do + email.deliver_now + end + + assert_equal [ "david@37signals.com" ], email.to + assert_equal "Your Fizzy export is ready", email.subject + assert_match %r{/exports/#{export.id}}, email.body.encoded + end +end diff --git a/test/mailers/previews/export_mailer_preview.rb b/test/mailers/previews/export_mailer_preview.rb new file mode 100644 index 000000000..3a9ce0113 --- /dev/null +++ b/test/mailers/previews/export_mailer_preview.rb @@ -0,0 +1,11 @@ +class ExportMailerPreview < ActionMailer::Preview + def completed + export = Account::Export.new( + id: "preview-export-id", + account: Account.first, + user: User.first + ) + + ExportMailer.completed(export) + end +end diff --git a/test/models/account/export_test.rb b/test/models/account/export_test.rb new file mode 100644 index 000000000..37b0ea47e --- /dev/null +++ b/test/models/account/export_test.rb @@ -0,0 +1,95 @@ +require "test_helper" + +class Account::ExportTest < ActiveSupport::TestCase + test "build_later enqueues ExportAccountDataJob" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + assert_enqueued_with(job: ExportAccountDataJob, args: [ export ]) do + export.build_later + end + end + + test "build generates zip with card JSON files" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + export.build + + assert export.completed? + assert export.file.attached? + assert_equal "application/zip", export.file.content_type + end + + test "build sets status to processing then completed" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + export.build + + assert export.completed? + assert_not_nil export.completed_at + end + + test "build sends email when completed" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do + export.build + end + end + + test "build sets status to failed on error" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + export.stubs(:generate_zip).raises(StandardError.new("Test error")) + + assert_raises(StandardError) do + export.build + end + + assert export.failed? + end + + test "cleanup deletes exports completed more than 24 hours ago" do + old_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago) + recent_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago) + pending_export = Account::Export.create!(account: Current.account, user: users(:david), status: :pending) + + Account::Export.cleanup + + assert_not Account::Export.exists?(old_export.id) + assert Account::Export.exists?(recent_export.id) + assert Account::Export.exists?(pending_export.id) + end + + test "build includes only accessible cards for user" do + user = users(:david) + export = Account::Export.create!(account: Current.account, user: user) + + export.build + + assert export.completed? + assert export.file.attached? + + # Verify zip contents + Tempfile.create([ "test", ".zip" ]) do |temp| + temp.binmode + export.file.download { |chunk| temp.write(chunk) } + temp.rewind + + Zip::File.open(temp.path) do |zip| + json_files = zip.glob("*.json") + assert json_files.any?, "Zip should contain at least one JSON file" + + # Verify structure of a JSON file + json_content = JSON.parse(zip.read(json_files.first.name)) + assert json_content.key?("number") + assert json_content.key?("title") + assert json_content.key?("board") + assert json_content.key?("creator") + assert json_content["creator"].key?("id") + assert json_content["creator"].key?("name") + assert json_content["creator"].key?("email") + assert json_content.key?("description") + assert json_content.key?("comments") + end + end + end +end diff --git a/test/models/card/exportable_test.rb b/test/models/card/exportable_test.rb new file mode 100644 index 000000000..080ed9948 --- /dev/null +++ b/test/models/card/exportable_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Card::ExportableTest < ActiveSupport::TestCase + test "export_json returns card data as JSON" do + card = cards(:logo) + + json = JSON.parse(card.export_json) + + assert_equal 1, json["number"] + assert_equal "The logo isn't big enough", json["title"] + assert_equal "Writebook", json["board"] + assert_equal "Triage", json["status"] + assert_equal users(:david).id, json["creator"]["id"] + assert_equal "David", json["creator"]["name"] + assert_equal "david@37signals.com", json["creator"]["email"] + assert_equal "", json["description"] + assert_equal 5, json["comments"].count + assert_equal card.created_at.iso8601, json["created_at"] + assert_equal card.updated_at.iso8601, json["updated_at"] + end + + test "export_attachments returns attachment paths and blobs" do + card = cards(:logo) + + blob = ActiveStorage::Blob.create_and_upload!( + io: file_fixture("moon.jpg").open, + filename: "moon.jpg", + content_type: "image/jpeg" + ) + attachment_html = ActionText::Attachment.from_attachable(blob).to_html + card.update!(description: "

Here is an image:

#{attachment_html}") + + attachments = card.export_attachments + + assert_equal 1, attachments.count + assert_equal file_fixture("moon.jpg").binread, attachments.first[:blob].download + assert attachments.first[:path].start_with?("#{card.number}/") + end +end From 28371e10c840fe3e999f05bcc8c58ad232626dac Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 1 Dec 2025 16:25:23 +0100 Subject: [PATCH 08/85] Turn off the help notice --- config/initializers/mission_control.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/config/initializers/mission_control.rb b/config/initializers/mission_control.rb index 2fe3c3ed0..1e6a434ae 100644 --- a/config/initializers/mission_control.rb +++ b/config/initializers/mission_control.rb @@ -1,3 +1,4 @@ Rails.application.config.before_initialize do MissionControl::Jobs.base_controller_class = "AdminController" + MissionControl::Jobs.show_console_help = false end From 12f6dbf79f0ad067a83af3670f8d4c3bd8937e8e Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Mon, 1 Dec 2025 15:36:35 +0000 Subject: [PATCH 09/85] Handle remote image attachments too --- app/models/card/exportable.rb | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/models/card/exportable.rb b/app/models/card/exportable.rb index dc437c296..1ad23002c 100644 --- a/app/models/card/exportable.rb +++ b/app/models/card/exportable.rb @@ -34,13 +34,20 @@ module Card::Exportable return "" if rich_text.blank? rich_text.body.render_attachments do |attachment| - blob = attachment.attachable - path = export_attachment_path(blob) + attachable = attachment.attachable - if blob.image? - tag.img(src: path, alt: blob.filename) + case attachable + when ActiveStorage::Blob + path = export_attachment_path(attachable) + if attachable.image? + tag.img(src: path, alt: attachable.filename) + else + tag.a(attachable.filename, href: path) + end + when ActionText::Attachables::RemoteImage + tag.img(src: attachable.url, alt: "Remote image") else - tag.a(blob.filename, href: path) + attachment.to_html end end.to_html end From 45afe494ecd1f1c675dfdb9693cfff417aaac4c0 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Mon, 1 Dec 2025 15:40:07 +0000 Subject: [PATCH 10/85] Skip missing files --- app/models/account/export.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/account/export.rb b/app/models/account/export.rb index 289c0352f..0f3d50f0c 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -70,6 +70,8 @@ class Account::Export < ApplicationRecord zip.get_output_stream(attachment[:path]) do |f| attachment[:blob].download { |chunk| f.write(chunk) } end + rescue ActiveStorage::FileNotFoundError + # Skip attachments where the file is missing from storage end end end From c492e6c4c45eab115408007e3ac328a42f41a6ba Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Mon, 1 Dec 2025 15:54:24 +0000 Subject: [PATCH 11/85] Make the JSON prettier --- app/models/card/exportable.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/card/exportable.rb b/app/models/card/exportable.rb index 1ad23002c..b1edfc1fa 100644 --- a/app/models/card/exportable.rb +++ b/app/models/card/exportable.rb @@ -3,7 +3,7 @@ module Card::Exportable include ActionView::Helpers::TagHelper def export_json - { + JSON.pretty_generate({ number: number, title: title, board: board.name, @@ -20,7 +20,7 @@ module Card::Exportable created_at: comment.created_at.iso8601 } end - }.to_json + }) end def export_attachments From 235c20f3aa09b9059c58a42ed20e72bbab73adc7 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 1 Dec 2025 09:54:57 -0600 Subject: [PATCH 12/85] Capitalize sentence --- app/views/sessions/new.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index b90809105..fc89bf681 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -10,7 +10,7 @@ -

New here? <%= link_to "sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we’ll get you signed in.

+

New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we’ll get you signed in.

+
+ - -

Data Export

-

We will create a downloadable ZIP file of all the Fizzy data that you have access to in this account.

-

This can take a few minutes. When the file is ready, we'll email you a link to download it.

-

The download link will expire after 24 hours. But you can always request another export later.

+ +

Export your account data

+

This will kick off a request to generate a ZIP archive of all the data in boards you have access to.

+

When the file is ready, we’ll email you a link to download it. The link will expire after 24 hours.

-
- - <%= button_to "Export", account_exports_path, method: :post, class: "btn btn--primary", form: { data: { action: "submit->dialog#close" } } %> -
-
-
- +
+ <%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> + +
+ + \ No newline at end of file diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 4291dc1b4..273a07b9a 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -15,6 +15,8 @@ <%= render "account/settings/users", users: @users %> - <%= render "account/settings/entropy", account: @account %> - <%= render "account/settings/export" %> +
+ <%= render "account/settings/entropy", account: @account %> + <%= render "account/settings/export" %> +
From 41c91e6dd6eade4fbc23686e6b3df603791575ae Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 1 Dec 2025 22:27:53 -0600 Subject: [PATCH 43/85] Mailer copy --- app/mailers/export_mailer.rb | 2 +- app/views/mailers/export_mailer/completed.html.erb | 4 ++-- app/views/mailers/export_mailer/completed.text.erb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/mailers/export_mailer.rb b/app/mailers/export_mailer.rb index 62def6931..5385aeed6 100644 --- a/app/mailers/export_mailer.rb +++ b/app/mailers/export_mailer.rb @@ -3,6 +3,6 @@ class ExportMailer < ApplicationMailer @export = export @user = export.user - mail to: @user.identity.email_address, subject: "Your Fizzy export is ready" + mail to: @user.identity.email_address, subject: "Your Fizzy data export is ready for download" end end diff --git a/app/views/mailers/export_mailer/completed.html.erb b/app/views/mailers/export_mailer/completed.html.erb index d95aa9d69..c0d458fc2 100644 --- a/app/views/mailers/export_mailer/completed.html.erb +++ b/app/views/mailers/export_mailer/completed.html.erb @@ -1,6 +1,6 @@ -

Your export is ready

+

Download your Fizzy data

Your Fizzy data export has finished processing and is ready to download.

-

<%= link_to "Download your export", account_export_url(@export) %>

+

<%= link_to "Download your data", account_export_url(@export) %>

diff --git a/app/views/mailers/export_mailer/completed.text.erb b/app/views/mailers/export_mailer/completed.text.erb index bfa633a5a..e5c274a9a 100644 --- a/app/views/mailers/export_mailer/completed.text.erb +++ b/app/views/mailers/export_mailer/completed.text.erb @@ -1,3 +1,3 @@ Your Fizzy data export has finished processing and is ready to download. -Download your export: <%= account_export_url(@export) %> +Download your data: <%= account_export_url(@export) %> From abee7a2ec37b19484b53c642278ef7370977514e Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 1 Dec 2025 22:33:55 -0600 Subject: [PATCH 44/85] Style download screen --- app/views/account/exports/show.html.erb | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/views/account/exports/show.html.erb b/app/views/account/exports/show.html.erb index 5a3defa5a..5dee8d81f 100644 --- a/app/views/account/exports/show.html.erb +++ b/app/views/account/exports/show.html.erb @@ -6,13 +6,7 @@ <% content_for :header do %>
- <%= link_to account_settings_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> - - <%= icon_tag "arrow-left" %> - Back to Account Settings - - - <% end %> + <%= back_link_to "Account Settings", account_settings_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
<% end %> @@ -26,12 +20,12 @@ <%= link_to rails_blob_path(@export.file, disposition: "attachment"), id: "download-link", - class: "btn btn--primary", + class: "btn btn--link", data: { turbo: false, controller: "auto-click" } do %> - Download Export + Download your data <% end %> <% else %> -

Your download link has expired. You'll need to request a new export.

+

That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-lnk" %>.

<% end %> From 20781a64930e2ab2187be35722fc077f241f59f3 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 1 Dec 2025 22:36:20 -0600 Subject: [PATCH 45/85] Layout --- app/views/account/exports/show.html.erb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/views/account/exports/show.html.erb b/app/views/account/exports/show.html.erb index 5dee8d81f..f01c13dc5 100644 --- a/app/views/account/exports/show.html.erb +++ b/app/views/account/exports/show.html.erb @@ -11,12 +11,10 @@ <% end %>
-
-

<%= @page_title %>

-
+

<%= @page_title %>

<% if @export.present? %> -

Your export is ready. The download should start automatically.

+

Your export is ready. The download should start automatically.

<%= link_to rails_blob_path(@export.file, disposition: "attachment"), id: "download-link", @@ -25,7 +23,7 @@ Download your data <% end %> <% else %> -

That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-lnk" %>.

+

That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-lnk" %>.

<% end %>
From af0113c9ee9572f026e7e8edd70eeaa18baa01fe Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 2 Dec 2025 05:52:07 +0100 Subject: [PATCH 46/85] After touch should trigger board touch too The change in https://github.com/basecamp/fizzy/commit/c606de7b41f57734c4c859c5fc3d98c952b26d58#diff-1ecde9fb1ee2494e5d94e807f51f861928f77cfcfb0884889911b62a32c2ff4cL4-R23 was preventing marking cards as golden from broadcasting --- app/models/card.rb | 1 + test/models/card/golden_test.rb | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/app/models/card.rb b/app/models/card.rb index 0a589d0b7..f7c9743d9 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -16,6 +16,7 @@ class Card < ApplicationRecord before_create :assign_number after_save -> { board.touch }, if: :published? + after_touch -> { board.touch }, if: :published? after_update :handle_board_change, if: :saved_change_to_board_id? scope :reverse_chronologically, -> { order created_at: :desc, id: :desc } diff --git a/test/models/card/golden_test.rb b/test/models/card/golden_test.rb index be501c754..3ddd8ea39 100644 --- a/test/models/card/golden_test.rb +++ b/test/models/card/golden_test.rb @@ -24,4 +24,19 @@ class Card::GoldenTest < ActiveSupport::TestCase assert_includes Card.golden, cards(:logo) assert_not_includes Card.golden, cards(:text) end + + test "gilding a card touches both the card and the board" do + card = cards(:text) + board = card.board + + card_updated_at = card.updated_at + board_updated_at = board.updated_at + + travel 1.minute do + card.gild + end + + assert card.reload.updated_at > card_updated_at + assert board.reload.updated_at > board_updated_at + end end From 15f4454861b5fad1a19f54f0a1f4610c1b63a788 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 1 Dec 2025 23:52:21 -0600 Subject: [PATCH 47/85] Remove line breaks, update back button copy Lexxy no longer requires two returns between items --- app/models/account/seeder.rb | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/app/models/account/seeder.rb b/app/models/account/seeder.rb index 9858cff6b..354674ddc 100644 --- a/app/models/account/seeder.rb +++ b/app/models/account/seeder.rb @@ -30,43 +30,36 @@ class Account::Seeder # Cards playground.cards.create! creator: creator, title: "Finally, watch this Fizzy orientation video", status: "published", description: <<~HTML

There’s a whole lot more you can do in Fizzy. In the video below 37signals founder and CEO, Jason Fried, will walk you through the basics in just 8 minutes.

-


HTML playground.cards.create! creator: creator, title: "Now, grab the invite link to invite someone else", status: "published", description: <<~HTML

Open Fizzy menu, select “+ Add people”, then copy the invite link. You can give this link to someone else so they can make an login for themselves in your account.

-


HTML playground.cards.create! creator: creator, title: "Then, head back home to check out activity", status: "published", description: <<~HTML

Hit “1” or pull down the Fizzy menu and select “Home”.

-


HTML playground.cards.create! creator: creator, title: "Now,check out all cards assigned to you", status: "published", description: <<~HTML

Pull down the Fizzy menu at the top of the screen, and select “Assigned to me” or just hit “2” on your keyboard any time.

-


HTML playground.cards.create! creator: creator, title: "Then, open the Fizzy menu", status: "published", description: <<~HTML

The Fizzy menu is how you get around the app. Click “Fizzy” at the top of the screen or hit the “J” key on your keyboard to pop it open.

-


HTML playground.cards.create! creator: creator, title: "Next, assign this card to yourself", status: "published", description: <<~HTML

Click the little head with the + next to it, pick yourself.

-


HTML playground.cards.create! creator: creator, title: "Now, tag this card “Design” then move it to YES", status: "published", description: <<~HTML

Click the little Tag icon, type “design”, then “Create tag”. Then, move the card to the new “YES” column you created in the previous step.

-


HTML @@ -75,11 +68,8 @@ class Account::Seeder
  • Make one called "Yes"
  • Make another called "Working on"
  • -


    Go back to the Board view, click the little “+” to the right of the DONE column, name the column, pick a color, then do it again.

    -


    After that, drag this card to “DONE” or select “DONE” in the sidebar.

    -


    HTML @@ -93,9 +83,8 @@ class Account::Seeder
    1. Click the title and you can rename the card, change the description, or add more information to the card.
    2. Then, hit "Mark as Done" at the bottom of the card.
    3. -
    4. Finally, hit “←Playground” in the top left of the screen to go back to the board.
    5. +
    6. Finally, hit “Back to Playground” in the top left of the screen to go back to the board.
    -


    HTML end From 11249949eb83a4b14581a4877e01c3172e697b7e Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 2 Dec 2025 07:06:50 +0100 Subject: [PATCH 48/85] Fix: edit comment button disappears when morphing the page This also replaces the ad-hoc stimulus controller with the pure-css based solution we recently added. https://app.fizzy.do/5986089/cards/3243 --- app/assets/stylesheets/comments.css | 4 ---- app/helpers/messages_helper.rb | 6 +----- .../created_by_current_user_controller.js | 12 ------------ app/views/cards/comments/_comment.html.erb | 4 ++-- 4 files changed, 3 insertions(+), 23 deletions(-) delete mode 100644 app/javascript/controllers/created_by_current_user_controller.js diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 6720fa258..78ec57467 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -82,10 +82,6 @@ .comment__edit { background-color: var(--color-ink-lightest); - - .comment:not(.comment--mine) & { - display: none; - } } .comment--mine { diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index 9dae7757c..f0e774882 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -3,10 +3,6 @@ module MessagesHelper turbo_frame_tag dom_id(card, :messages), class: "comments gap center", style: "--card-color: #{card.color}", - role: "group", aria: { label: "Messages" }, - data: { - controller: "created-by-current-user", - created_by_current_user_mine_class: "comment--mine" - }, & + role: "group", aria: { label: "Messages" }, & end end diff --git a/app/javascript/controllers/created_by_current_user_controller.js b/app/javascript/controllers/created_by_current_user_controller.js deleted file mode 100644 index 8941fc120..000000000 --- a/app/javascript/controllers/created_by_current_user_controller.js +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = [ "creation" ] - static classes = [ "mine" ] - - creationTargetConnected(element) { - if (element.dataset.creatorId == Current.user.id) { - element.classList.add(this.mineClass) - } - } -} diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index 94bb5eec4..49ae74182 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -1,7 +1,7 @@ <% cache comment do %> <%= turbo_frame_tag comment, :container do %> <%# Bump for CSS changes: 2025-06-30 -%> -
    " data-creator-id="<%= comment.creator_id %>" data-created-by-current-user-target="creation"> +
    "> @@ -20,7 +20,7 @@ <%= link_to edit_card_comment_path(comment.card, comment), - class: "comment__edit btn btn--circle borderless translucent" do %> + class: "comment__edit btn btn--circle borderless translucent", data: { only_visible_to_you: true } do %> <%= icon_tag "menu-dots-horizontal" %> Edit this comment <% end %> From f385a971d7c1da688b777b2f012239fc62e16f18 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 2 Dec 2025 07:07:52 +0100 Subject: [PATCH 49/85] Remove comment--mine classes (not used) --- app/assets/stylesheets/comments.css | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 78ec57467..b58156f36 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -45,10 +45,6 @@ .comment__avatar { margin: calc(var(--comment-padding-block) * 0.75) calc(var(--comment-padding-inline) * -0.75); z-index: 0; - - .comment--mine_ & { - margin-inline: calc(var(--comment-padding-inline) * -0.75); - } } .comment__body { @@ -84,12 +80,6 @@ background-color: var(--color-ink-lightest); } - .comment--mine { - .comment__reaction { - display: none; - } - } - .comment--system { --comment-padding-block: var(--block-space-half); From 9ae9e67d24c146184655461b4bd6cb52bead927d Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Wed, 26 Nov 2025 16:35:45 +0000 Subject: [PATCH 50/85] Remove all credentials files --- config/credentials/beta.yml.enc | 1 - config/credentials/development.yml.enc | 1 - config/credentials/production.yml.enc | 1 - config/credentials/staging.yml.enc | 1 - config/credentials/test.yml.enc | 1 - 5 files changed, 5 deletions(-) delete mode 100644 config/credentials/beta.yml.enc delete mode 100644 config/credentials/development.yml.enc delete mode 100644 config/credentials/production.yml.enc delete mode 100644 config/credentials/staging.yml.enc delete mode 100644 config/credentials/test.yml.enc diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc deleted file mode 100644 index a4316dc2e..000000000 --- a/config/credentials/beta.yml.enc +++ /dev/null @@ -1 +0,0 @@ -JwcXSv6qlqJvbzfHX3v0TB1WvqgI8CEqVRuR4m760EUTJDe6k6NeX1b41P33Zr341GT/LSyxnlxU5psfx4TxvSZhnJRv4e/YuAr04hFIhWnKnjvJtkwDQRgaZH689O4vgGVnQIEPln/DGag=--Q4Xa+Y5602cPApVn--s3PqHeC1cQ/7Fg2dTJ1w4w== \ No newline at end of file diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc deleted file mode 100644 index fadb19009..000000000 --- a/config/credentials/development.yml.enc +++ /dev/null @@ -1 +0,0 @@ -vWcWTHpq+ngYMhjXPNuAdQoX0IWy18O+vXn8Ny//U81BzG0tZIw30hOJ2MYx9yqgG9TMo8skPDE8fYGjHjuNCmp0CAgay6tcJzDtue+8l7nosbVBhQDkdW4GGAs8zRzVevQFNVXiYggQBeY=--vOZV11N8QeP6HBLz--enCNPwzwui/5QILC4bceBg== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc deleted file mode 100644 index 254ca92de..000000000 --- a/config/credentials/production.yml.enc +++ /dev/null @@ -1 +0,0 @@ -ZZgOiLwd51WsQRpTwuCdPmDqmErel30sv5ZO+hOk7PrdqSgKuA7SCM3BaaM01gusiWCAq2760SmjTrrhaEBPkydR2dDERxPNcWe+zvqYklczYY8Qs4DqqLq3L8O89REO5GFvlbRulDQlO0w=--LC7VCBgZNnfp05RJ--svimv/2SjVLEJfVrBxC87Q== \ No newline at end of file diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc deleted file mode 100644 index 3cbcf5de6..000000000 --- a/config/credentials/staging.yml.enc +++ /dev/null @@ -1 +0,0 @@ -eqnqN1H7D3SMoWZAS61s1U+Km/tbbbb+1ofw+B+7AlD+Y6aNLBUuIbm6qSZPZ2L4bEl49RG9H3tLKtaawGWbqCCePygCZMw2wMlCt0Y08hKR1IAesRsJdMLRJUUn6vvTywXIm6leO/OdQlU=--H1k6MN/bYQAb3Kre--9jfEjn3753Brzy/63b56SA== \ No newline at end of file diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc deleted file mode 100644 index 6726323b6..000000000 --- a/config/credentials/test.yml.enc +++ /dev/null @@ -1 +0,0 @@ -kd4RhBwzDS97pb8kht6DOzdg/NFgRQj0sAezzV7q7wiqk4pAMT2v5pZTJoq3pz0f8URB7nXK1KeHI6ceqN/9GKWMqpwyUyeM8A7LbCK5ffBiaJZH2YFMtKEnX0topl87sEDNlMawY0OT6ySi4KhLkrQMyEGqJx3XXmS1U4aGfF2P7i3GTGOjPpLtPntzSVT62cLU7/GSfDvXdqiW/WDRNvCpQJDqw+J1DdnlZtq+A6lo+B42o2clGHOVw09MY9INHuhfcscQfR035exSkRZ3wsvqDay3fez+D3xvDXOreyRrlCUpDfAxuXHPgavnYuPF73Xhg5Ov48B3EW9RLNHH5Nl57M/laxPvhkSa73IK6VPL9BSv9osW--MmZ9291ZbCvv0tqX--yLAI3tXJEW5rJoA/2KMk+Q== \ No newline at end of file From b97877dc3056545c7edcb1a9552ca59b7718bd3a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 2 Dec 2025 09:12:47 +0100 Subject: [PATCH 51/85] Remove basic auth restriction on signup --- app/controllers/signups_controller.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index ea88383e0..33da02f28 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -1,12 +1,4 @@ class SignupsController < ApplicationController - # FIXME: Remove this before launch! - unless Rails.env.local? - http_basic_authenticate_with \ - name: Rails.application.credentials.account_signup_http_basic_auth.name, - password: Rails.application.credentials.account_signup_http_basic_auth.password, - realm: "Fizzy Signup" - end - disallow_account_scope allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." } From b9899e71542e9ee616d936f97037b77838805f62 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 2 Dec 2025 08:26:09 +0000 Subject: [PATCH 52/85] Fix test --- test/mailers/export_mailer_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mailers/export_mailer_test.rb b/test/mailers/export_mailer_test.rb index a627d615c..c981aa191 100644 --- a/test/mailers/export_mailer_test.rb +++ b/test/mailers/export_mailer_test.rb @@ -10,7 +10,7 @@ class ExportMailerTest < ActionMailer::TestCase end assert_equal [ "david@37signals.com" ], email.to - assert_equal "Your Fizzy export is ready", email.subject + assert_equal "Your Fizzy data export is ready for download", email.subject assert_match %r{/exports/#{export.id}}, email.body.encoded end end From 78c6751fa048e8a1c47efaa5cca26a78812e06dc Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 2 Dec 2025 09:32:15 +0100 Subject: [PATCH 53/85] Prohibit link local addresses --- app/models/webhook/delivery.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 05768e04a..ce69c01ad 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -4,8 +4,6 @@ class Webhook::Delivery < ApplicationRecord ENDPOINT_TIMEOUT = 7.seconds DNS_RESOLUTION_TIMEOUT = 2 DISALLOWED_IP_RANGES = [ - # IPv4 mapped to IPv6 - IPAddr.new("::ffff:0:0/96"), # Broadcasts IPAddr.new("0.0.0.0/8") ].freeze @@ -85,7 +83,7 @@ class Webhook::Delivery < ApplicationRecord end ip_addresses.any? do |ip| - ip.private? || ip.loopback? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) } + ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) } end end From 23576d13ffbfdb4e2a5d0a0861da150d679cfafe Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 2 Dec 2025 10:22:40 +0100 Subject: [PATCH 54/85] Extract method --- app/controllers/join_codes_controller.rb | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index 7e7ded229..440618c83 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -20,12 +20,7 @@ class JoinCodesController < ApplicationController elsif identity == Current.identity redirect_to new_users_join_url(script_name: @join_code.account.slug) else - terminate_session if Current.identity - - magic_link = identity.send_magic_link - flash[:magic_link_code] = magic_link&.code if Rails.env.development? - - session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug) + logout_and_send_new_magic_link(identity) redirect_to session_magic_link_url(script_name: nil) end end @@ -42,4 +37,13 @@ class JoinCodesController < ApplicationController render :inactive, status: :gone end end + + def logout_and_send_new_magic_link(identity) + terminate_session if Current.identity + + magic_link = identity.send_magic_link + flash[:magic_link_code] = magic_link&.code if Rails.env.development? + + session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug) + end end From c9ebb79dbde78dc7d5e053d53f87b0e1907ee6df Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 2 Dec 2025 10:51:14 +0100 Subject: [PATCH 55/85] Add some protections around sharing the magic link in development --- app/controllers/concerns/authentication.rb | 13 +++++++++++++ app/controllers/join_codes_controller.rb | 2 +- app/controllers/sessions_controller.rb | 2 +- test/controllers/sessions_controller_test.rb | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 23f82dbd2..89eda5887 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -4,6 +4,7 @@ module Authentication included do before_action :require_account # Checking and setting account must happen first before_action :require_authentication + after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? etag { Current.session.id if authenticated? } @@ -89,4 +90,16 @@ module Authentication Current.session.destroy cookies.delete(:session_token) end + + def ensure_development_magic_link_not_leaked + unless Rails.env.development? + raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? + end + end + + def serve_development_magic_link(magic_link) + if Rails.env.development? + flash[:magic_link_code] = magic_link&.code + end + end end diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index 440618c83..5b0977cd5 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -42,7 +42,7 @@ class JoinCodesController < ApplicationController terminate_session if Current.identity magic_link = identity.send_magic_link - flash[:magic_link_code] = magic_link&.code if Rails.env.development? + serve_development_magic_link(magic_link) session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 72131e61c..31ca0006f 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -11,7 +11,7 @@ class SessionsController < ApplicationController def create if identity = Identity.find_by_email_address(email_address) magic_link = identity.send_magic_link - flash[:magic_link_code] = magic_link&.code if Rails.env.development? + serve_development_magic_link(magic_link) end redirect_to session_magic_link_path diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 8edf74573..9dbcc770d 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -18,6 +18,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to session_magic_link_path + assert_nil flash[:magic_link_code] end end From a3127e8fc13952ecd023db1dee734e355acd3479 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:58:52 +0000 Subject: [PATCH 56/85] Bump rqrcode from 3.1.0 to 3.1.1 Bumps [rqrcode](https://github.com/whomwah/rqrcode) from 3.1.0 to 3.1.1. - [Release notes](https://github.com/whomwah/rqrcode/releases) - [Changelog](https://github.com/whomwah/rqrcode/blob/main/CHANGELOG.md) - [Commits](https://github.com/whomwah/rqrcode/compare/v3.1.0...v3.1.1) --- updated-dependencies: - dependency-name: rqrcode dependency-version: 3.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5eefe452a..4fd2c7ff1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -347,7 +347,7 @@ GEM io-console (~> 0.5) rexml (3.4.4) rouge (4.6.1) - rqrcode (3.1.0) + rqrcode (3.1.1) chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.1) From f7e2b4e0ba93f17523efe8ba569100578718874f Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 2 Dec 2025 10:39:56 +0000 Subject: [PATCH 57/85] Don't compress attachments in export Since they are typically images and videos, which are already compressed. --- app/models/account/export.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/account/export.rb b/app/models/account/export.rb index 3f1832b3a..c5e3d64c3 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -63,7 +63,7 @@ class Account::Export < ApplicationRecord end card.export_attachments.each do |attachment| - zip.get_output_stream(attachment[:path]) do |f| + zip.get_output_stream(attachment[:path], compression_method: Zip::Entry::STORED) do |f| attachment[:blob].download { |chunk| f.write(chunk) } end rescue ActiveStorage::FileNotFoundError From 80a6ffc55eb9adb04cf576eb00586864942ac694 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 2 Dec 2025 08:41:00 -0500 Subject: [PATCH 58/85] Add an expiration note to emails and the magic link page. ref: https://app.fizzy.do/5986089/cards/3211 --- .../mailers/magic_link_mailer/sign_in_instructions.html.erb | 2 ++ .../mailers/magic_link_mailer/sign_in_instructions.text.erb | 2 ++ app/views/sessions/magic_links/show.html.erb | 2 ++ 3 files changed, 6 insertions(+) diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb index 40303c39e..6d941492e 100644 --- a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb @@ -9,6 +9,8 @@ <%= @magic_link.code %> +

    This code is good for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

    + diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb index b21f00509..bc879a323 100644 --- a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb @@ -5,3 +5,5 @@ Please enter this 6-character verification code on the Fizzy sign-up page to cre <% end %> <%= @magic_link.code %> + +This code is good for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>. diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index 4edffc406..3a543639c 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -12,6 +12,8 @@ autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code], data: { magic_link_target: "input", action: "keydown.enter->magic-link#submit paste->magic-link#paste" } %> <% end %> + +

    The code is good for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

    <% if Rails.env.development? %> From 8d1a09808ff2128d5790128dbe11825df73bf6fa Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 2 Dec 2025 15:51:23 +0100 Subject: [PATCH 59/85] Update gem --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f0cfa6dc0..35fb53be9 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/basecamp/fizzy-saas - revision: 130c8b23f9861a89feb59ca68f192bd05d51310c + revision: acc2f01d21f3bf2efdad343a88608b73dca9a1c2 specs: fizzy-saas (0.1.0) prometheus-client-mmap From 8c1d3c6c42f6269602089b38b4dda17a057895f9 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 2 Dec 2025 09:08:57 -0600 Subject: [PATCH 60/85] Copy edits --- .../mailers/magic_link_mailer/sign_in_instructions.html.erb | 2 +- .../mailers/magic_link_mailer/sign_in_instructions.text.erb | 2 +- app/views/sessions/magic_links/show.html.erb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb index 6d941492e..e81fd1d63 100644 --- a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb @@ -9,7 +9,7 @@ <%= @magic_link.code %> -

    This code is good for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

    +

    This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

    diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb index bc879a323..3c157d2a0 100644 --- a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb @@ -6,4 +6,4 @@ Please enter this 6-character verification code on the Fizzy sign-up page to cre <%= @magic_link.code %> -This code is good for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>. +This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>. diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index 3a543639c..affa97b8a 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -13,7 +13,7 @@ data: { magic_link_target: "input", action: "keydown.enter->magic-link#submit paste->magic-link#paste" } %> <% end %> -

    The code is good for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

    +

    The code you receive will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

    <% if Rails.env.development? %> From 69b701c035e95ec2a832215a6fe5b0288c19803f Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 2 Dec 2025 11:12:38 -0500 Subject: [PATCH 61/85] Add recent signups to the dashboard --- app/controllers/admin/stats_controller.rb | 2 ++ app/views/admin/stats/show.html.erb | 31 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 92b49d3ce..6d508fc2c 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -14,5 +14,7 @@ class Admin::StatsController < AdminController .where("cards_count > 0") .order(cards_count: :desc) .limit(20) + + @recent_accounts = Account.order(created_at: :desc).limit(10) end end diff --git a/app/views/admin/stats/show.html.erb b/app/views/admin/stats/show.html.erb index a3ae18c55..7c7491d2a 100644 --- a/app/views/admin/stats/show.html.erb +++ b/app/views/admin/stats/show.html.erb @@ -61,6 +61,37 @@ +
    +
    +

    + 10 Most Recent Signups +

    +
    + +
      + <% @recent_accounts.each do |account| %> + <% admin_user = account.users.owner.first %> +
    • +
      + <%= account.name %> +
      + + #<%= account.external_account_id %> • + <%= admin_user&.identity&.email_address || "No admin" %> + +
      + +
      + <%= time_ago_in_words(account.created_at) %> ago +
      +
    • + <% end %> +
    +
    +

    From 2f88908cdfd0e6f3a4ecf2c01fbc3c610f97e3b8 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 2 Dec 2025 17:21:35 +0100 Subject: [PATCH 62/85] Address HTML markup issues reported by Herb --- app/views/boards/new.html.erb | 2 +- app/views/cards/display/_public_preview.html.erb | 2 +- app/views/layouts/public.html.erb | 1 + app/views/webhooks/show.html.erb | 6 +++--- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb index 57c809b17..4536af7fc 100644 --- a/app/views/boards/new.html.erb +++ b/app/views/boards/new.html.erb @@ -2,7 +2,7 @@
    <%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form" } do |form| %> -

    <%= @page_title %>

    +

    <%= @page_title %>

    <%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off",placeholder: "Name it…", data: { action: "keydown.esc@document->form#cancel" } %>