diff --git a/.dockerignore b/.dockerignore
index 96123753a..df5bbdacc 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -6,6 +6,14 @@
# Ignore bundler config.
/.bundle
+# Ignore documentation
+/docs/
+/README.md
+/CLAUDE.md
+/AGENTS.md
+/STYLE.md
+/CONTRIBUTING.md
+
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index f4f755614..dbb544dc1 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -30,4 +30,3 @@ updates:
open-pull-requests-limit: 10
cooldown:
default-days: 7
- semver-major-days: 14
diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml
index f3b992387..d097b9590 100644
--- a/.github/workflows/ci-checks.yml
+++ b/.github/workflows/ci-checks.yml
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
@@ -33,7 +33,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml
index 4970ef182..4286f90ae 100644
--- a/.github/workflows/publish-image.yml
+++ b/.github/workflows/publish-image.yml
@@ -39,13 +39,13 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.11.1
- name: Log in to GHCR
- uses: docker/login-action@v3.5.0
+ uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -62,7 +62,7 @@ jobs:
- name: Extract Docker metadata (tags, labels) with arch suffix
id: meta
- uses: docker/metadata-action@v5.8.0
+ uses: docker/metadata-action@v5.10.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -118,7 +118,7 @@ jobs:
uses: docker/setup-buildx-action@v3.11.1
- name: Log in to GHCR
- uses: docker/login-action@v3.5.0
+ uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -135,7 +135,7 @@ jobs:
- name: Compute base tags (no suffix)
id: meta
- uses: docker/metadata-action@v5.8.0
+ uses: docker/metadata-action@v5.10.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -179,7 +179,7 @@ jobs:
done <<< "$tags"
- name: Install Cosign
- uses: sigstore/cosign-installer@v3.9.2
+ uses: sigstore/cosign-installer@v4.0.0
- name: Cosign sign all tags (keyless OIDC)
shell: bash
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e62118357..beee16f15 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -56,7 +56,7 @@ jobs:
- name: Install system packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
diff --git a/.gitleaks.toml b/.gitleaks.toml
index 6c6bb399a..4a1991496 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -6,4 +6,6 @@ paths = [
'''log''',
'''tmp''',
'''.*\.yml\.enc''',
+ '''docs/''',
+ '''test/''',
]
diff --git a/.gitleaksignore b/.gitleaksignore
index 0d7b9b49f..7a3cd9392 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -1,9 +1,3 @@
d8463077:gems/fizzy-saas/bin/setup:generic-api-key:54
c4073c1c:app/models/integration/basecamp.rb:generic-api-key:3
c4073c1c:app/models/integration/basecamp.rb:generic-api-key:4
-02a42167:test/models/webhook_test.rb:slack-webhook-url:57
-2fc9215b:test/models/webhook/delivery_test.rb:slack-webhook-url:156
-2fc9215b:test/models/webhook_test.rb:slack-webhook-url:57
-a515ea3b:test/fixtures/webhooks.yml:generic-api-key:5
-a515ea3b:test/fixtures/webhooks.yml:generic-api-key:11
-1f21c12c:test/vcr_cassettes/command/ai/translator_test-test_combine_commands_and_filters.yml:github-oauth:73012
diff --git a/Gemfile b/Gemfile
index 7f7a463bb..e8a5f8514 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,8 +1,9 @@
source "https://rubygems.org"
-gem "rails", github: "rails/rails", branch: "main"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
+gem "rails", github: "rails/rails", branch: "ast-immediate-variants-process-locally"
+
# Assets & front end
gem "importmap-rails"
gem "propshaft"
@@ -27,7 +28,7 @@ gem "rqrcode"
gem "redcarpet"
gem "rouge"
gem "jbuilder"
-gem "lexxy"
+gem "lexxy", bc: "lexxy"
gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index e4f364c1c..d99375463 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,10 @@
+GIT
+ remote: https://github.com/basecamp/lexxy
+ revision: d292280b6d2c5d8a924327adf8bb9f0b25953539
+ specs:
+ lexxy (0.1.23.beta)
+ rails (>= 8.0.2)
+
GIT
remote: https://github.com/basecamp/useragent
revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e
@@ -6,8 +13,8 @@ GIT
GIT
remote: https://github.com/rails/rails.git
- revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
- branch: main
+ revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
+ branch: ast-immediate-variants-process-locally
specs:
actioncable (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
@@ -237,8 +244,6 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
- lexxy (0.1.23.beta)
- rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
@@ -518,7 +523,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy
+ lexxy!
mission_control-jobs
mittens
mocha
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index f093ac677..91c11943f 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -21,7 +21,7 @@ GIT
GIT
remote: https://github.com/basecamp/fizzy-saas
- revision: 43dbc896ce7b6a08194a92ddd1695d3f1ebf554b
+ revision: a14df11b57818697df4b2cc7b6a43e762ebaa196
specs:
fizzy-saas (0.1.0)
audits1984
@@ -41,6 +41,13 @@ GIT
yabeda-puma-plugin
yabeda-rails (>= 0.10)
+GIT
+ remote: https://github.com/basecamp/lexxy
+ revision: d292280b6d2c5d8a924327adf8bb9f0b25953539
+ specs:
+ lexxy (0.1.23.beta)
+ rails (>= 8.0.2)
+
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2
@@ -75,8 +82,8 @@ GIT
GIT
remote: https://github.com/rails/rails.git
- revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
- branch: main
+ revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
+ branch: ast-immediate-variants-process-locally
specs:
actioncable (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
@@ -313,8 +320,6 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
- lexxy (0.1.23.beta)
- rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
@@ -653,7 +658,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy
+ lexxy!
mission_control-jobs
mittens
mocha
diff --git a/README.md b/README.md
index 07f55614e..27ba149fc 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ This repo contains a starter deployment file that you can modify for your own sp
The steps to configure your very own Fizzy are:
1. Fork the repo
-2. Edit few things in config/deploy.yml, .kamal/secrets, and config/environments/production.rb
+2. Edit few things in config/deploy.yml and .kamal/secrets
3. Run `kamal setup` to do your first deploy.
We'll go through each of these in turn.
@@ -37,6 +37,7 @@ We've added comments to that file to highlight what each setting needs to be, bu
- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here.
- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off.
- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy.
+- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this.
Fizzy also requires a few environment variables to be set up, some of which contain secrets.
The simplest way to do this is to put them in a file called `.kamal/secrets`.
@@ -58,6 +59,7 @@ SMTP_PASSWORD=email-provider-password
The values you enter here will be specific to you, and you can get or create them as follows:
- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this.
+- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use.
- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with:
```sh
@@ -73,10 +75,6 @@ The values you enter here will be specific to you, and you can get or create the
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
-- `SMTP_USERNAME`/`SMTP_PASSWORD` are credentials you should get from your email provider.
-
-Lastly, you'll need to set up the rest of your email configuration in `config/environments/production.rb`. There is an example configuration in comments at the top of that file. The actual settings you use here will depend on your email provider, but in most cases will look similar to that section, so you can uncomment it and edit to suit. Note that it will use the `SMTP_USERNAME` and `SMTP_PASSWORD` values you entered in your secrets.
-
Once you've made all those changes, commit them to your fork so they're saved.
### Deploy Fizzy!
@@ -95,6 +93,25 @@ After the first deploy is done, any subsequent steps won't need to do that initi
bin/kamal deploy
```
+## File storage (Active Storage)
+
+Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`.
+
+To use the included `s3` service, set:
+
+- `ACTIVE_STORAGE_SERVICE=s3`
+- `S3_ACCESS_KEY_ID`
+- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`)
+- `S3_REGION` (defaults to `us-east-1`)
+- `S3_SECRET_ACCESS_KEY`
+
+Optional for S3-compatible endpoints:
+
+- `S3_ENDPOINT`
+- `S3_FORCE_PATH_STYLE=true`
+- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`)
+- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`)
+
## Development
### Setting up
diff --git a/app/assets/stylesheets/access-tokens.css b/app/assets/stylesheets/access-tokens.css
new file mode 100644
index 000000000..a0f54f91c
--- /dev/null
+++ b/app/assets/stylesheets/access-tokens.css
@@ -0,0 +1,19 @@
+.access_tokens_table {
+ border-collapse: collapse;
+ inline-size: 100%;
+
+ td, th {
+ border-block-end: 1px solid var(--color-ink-light);
+ padding-inline: var(--inline-space);
+ text-align: start;
+ }
+
+ th {
+ font-size: var(--text-x-small);
+ text-transform: uppercase;
+ }
+
+ tr:nth-of-type(even) {
+ background-color: var(--color-ink-lightest);
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css
index cc79ca76a..f89028f02 100644
--- a/app/assets/stylesheets/lexxy.css
+++ b/app/assets/stylesheets/lexxy.css
@@ -295,6 +295,10 @@
max-height: 200px;
overflow: scroll;
+
+ &:is(.lexxy-prompt-menu--visible) {
+ padding: var(--lexxy-prompt-padding);
+ }
}
.lexxy-prompt-menu--visible {
diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css
index 8d9b2e5c1..b50189366 100644
--- a/app/assets/stylesheets/nav.css
+++ b/app/assets/stylesheets/nav.css
@@ -219,6 +219,15 @@
}
}
+ .nav__blank-slate {
+ font-size: var(--text-small);
+ margin: 1rem auto;
+
+ .nav:has(.popup__item:not([hidden])) & {
+ display: none;
+ }
+ }
+
.nav__footer {
background-color: var(--color-canvas);
border-block-start: 1px solid var(--color-ink-lighter);
diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index 5b73e1266..0f487e1f9 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -116,6 +116,8 @@
pointer-events: none;
}
+ .cursor-pointer { cursor: pointer; }
+
/* Padding */
.pad { padding: var(--block-space) var(--inline-space); }
.pad-double { padding: var(--block-space-double) var(--inline-space-double); }
diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb
index fba2ccfe9..217ee603c 100644
--- a/app/controllers/account/join_codes_controller.rb
+++ b/app/controllers/account/join_codes_controller.rb
@@ -9,8 +9,11 @@ class Account::JoinCodesController < ApplicationController
end
def update
- @join_code.update!(join_code_params)
- redirect_to account_join_code_path
+ if @join_code.update(join_code_params)
+ redirect_to account_join_code_path
+ else
+ render :edit, status: :unprocessable_entity
+ end
end
def destroy
diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb
index fa4f3e072..9e14eb937 100644
--- a/app/controllers/boards/columns_controller.rb
+++ b/app/controllers/boards/columns_controller.rb
@@ -3,6 +3,11 @@ class Boards::ColumnsController < ApplicationController
before_action :set_column, only: %i[ show update destroy ]
+ def index
+ @columns = @board.columns.sorted
+ fresh_when etag: @columns
+ end
+
def show
set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded
fresh_when etag: @page.records
@@ -10,14 +15,29 @@ class Boards::ColumnsController < ApplicationController
def create
@column = @board.columns.create!(column_params)
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :created, location: board_column_path(@board, @column, format: :json) }
+ end
end
def update
@column.update!(column_params)
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
def destroy
@column.destroy
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb
index ff7c35ec0..6c8a17575 100644
--- a/app/controllers/boards_controller.rb
+++ b/app/controllers/boards_controller.rb
@@ -1,9 +1,13 @@
class BoardsController < ApplicationController
include FilterScoped
- before_action :set_board, except: %i[ new create ]
+ before_action :set_board, except: %i[ index new create ]
before_action :ensure_permission_to_admin_board, only: %i[ update destroy ]
+ def index
+ set_page_and_extract_portion_from Current.user.boards
+ end
+
def show
if @filter.used?(ignore_boards: true)
show_filtered_cards
@@ -18,7 +22,11 @@ class BoardsController < ApplicationController
def create
@board = Board.create! board_params.with_defaults(all_access: true)
- redirect_to board_path(@board)
+
+ respond_to do |format|
+ format.html { redirect_to board_path(@board) }
+ format.json { head :created, location: board_path(@board, format: :json) }
+ end
end
def edit
@@ -31,16 +39,25 @@ class BoardsController < ApplicationController
@board.update! board_params
@board.accesses.revise granted: grantees, revoked: revokees if grantees_changed?
- if @board.accessible_to?(Current.user)
- redirect_to edit_board_path(@board), notice: "Saved"
- else
- redirect_to root_path, notice: "Saved (you were removed from the board)"
+ respond_to do |format|
+ format.html do
+ if @board.accessible_to?(Current.user)
+ redirect_to edit_board_path(@board), notice: "Saved"
+ else
+ redirect_to root_path, notice: "Saved (you were removed from the board)"
+ end
+ end
+ format.json { head :no_content }
end
end
def destroy
@board.destroy
- redirect_to root_path
+
+ respond_to do |format|
+ format.html { redirect_to root_path }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/cards/assignments_controller.rb b/app/controllers/cards/assignments_controller.rb
index 886537fa1..396fd3a5f 100644
--- a/app/controllers/cards/assignments_controller.rb
+++ b/app/controllers/cards/assignments_controller.rb
@@ -9,5 +9,10 @@ class Cards::AssignmentsController < ApplicationController
def create
@card.toggle_assignment @board.users.active.find(params[:assignee_id])
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
end
diff --git a/app/controllers/cards/boards_controller.rb b/app/controllers/cards/boards_controller.rb
index 4a8e503ab..3444cd345 100644
--- a/app/controllers/cards/boards_controller.rb
+++ b/app/controllers/cards/boards_controller.rb
@@ -11,7 +11,11 @@ class Cards::BoardsController < ApplicationController
def update
@card.move_to(@board)
- redirect_to @card
+
+ respond_to do |format|
+ format.html { redirect_to @card }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb
index b23600d91..c4aac26d1 100644
--- a/app/controllers/cards/closures_controller.rb
+++ b/app/controllers/cards/closures_controller.rb
@@ -3,11 +3,19 @@ class Cards::ClosuresController < ApplicationController
def create
@card.close
- render_card_replacement
+
+ respond_to do |format|
+ format.turbo_stream { render_card_replacement }
+ format.json { head :no_content }
+ end
end
def destroy
@card.reopen
- render_card_replacement
+
+ respond_to do |format|
+ format.turbo_stream { render_card_replacement }
+ format.json { head :no_content }
+ end
end
end
diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb
index c65d34291..0f1277a3c 100644
--- a/app/controllers/cards/comments/reactions_controller.rb
+++ b/app/controllers/cards/comments/reactions_controller.rb
@@ -13,10 +13,20 @@ class Cards::Comments::ReactionsController < ApplicationController
def create
@reaction = @comment.reactions.create!(params.expect(reaction: :content))
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :created }
+ end
end
def destroy
@reaction.destroy
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb
index b7c6c867a..27c507abe 100644
--- a/app/controllers/cards/comments_controller.rb
+++ b/app/controllers/cards/comments_controller.rb
@@ -4,8 +4,17 @@ class Cards::CommentsController < ApplicationController
before_action :set_comment, only: %i[ show edit update destroy ]
before_action :ensure_creatorship, only: %i[ edit update destroy ]
+ def index
+ set_page_and_extract_portion_from @card.comments.chronologically
+ end
+
def create
@comment = @card.comments.create!(comment_params)
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :created, location: card_comment_path(@card, @comment, format: :json) }
+ end
end
def show
@@ -16,10 +25,20 @@ class Cards::CommentsController < ApplicationController
def update
@comment.update! comment_params
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
def destroy
@comment.destroy
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
private
@@ -32,6 +51,6 @@ class Cards::CommentsController < ApplicationController
end
def comment_params
- params.expect(comment: :body)
+ params.expect(comment: [ :body, :created_at ])
end
end
diff --git a/app/controllers/cards/goldnesses_controller.rb b/app/controllers/cards/goldnesses_controller.rb
index 1a0912a24..b2b3e619d 100644
--- a/app/controllers/cards/goldnesses_controller.rb
+++ b/app/controllers/cards/goldnesses_controller.rb
@@ -3,11 +3,19 @@ class Cards::GoldnessesController < ApplicationController
def create
@card.gild
- render_card_replacement
+
+ respond_to do |format|
+ format.turbo_stream { render_card_replacement }
+ format.json { head :no_content }
+ end
end
def destroy
@card.ungild
- render_card_replacement
+
+ respond_to do |format|
+ format.turbo_stream { render_card_replacement }
+ format.json { head :no_content }
+ end
end
end
diff --git a/app/controllers/cards/images_controller.rb b/app/controllers/cards/images_controller.rb
index fad419c52..f4fec16d5 100644
--- a/app/controllers/cards/images_controller.rb
+++ b/app/controllers/cards/images_controller.rb
@@ -3,6 +3,10 @@ class Cards::ImagesController < ApplicationController
def destroy
@card.image.purge_later
- redirect_to @card
+
+ respond_to do |format|
+ format.html { redirect_to @card }
+ format.json { head :no_content }
+ end
end
end
diff --git a/app/controllers/cards/not_nows_controller.rb b/app/controllers/cards/not_nows_controller.rb
index 8f43db1be..8eeb0e663 100644
--- a/app/controllers/cards/not_nows_controller.rb
+++ b/app/controllers/cards/not_nows_controller.rb
@@ -3,6 +3,10 @@ class Cards::NotNowsController < ApplicationController
def create
@card.postpone
- render_card_replacement
+
+ respond_to do |format|
+ format.turbo_stream { render_card_replacement }
+ format.json { head :no_content }
+ end
end
end
diff --git a/app/controllers/cards/steps_controller.rb b/app/controllers/cards/steps_controller.rb
index 305890508..0b788fe36 100644
--- a/app/controllers/cards/steps_controller.rb
+++ b/app/controllers/cards/steps_controller.rb
@@ -5,6 +5,11 @@ class Cards::StepsController < ApplicationController
def create
@step = @card.steps.create!(step_params)
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :created, location: card_step_path(@card, @step, format: :json) }
+ end
end
def show
@@ -15,10 +20,20 @@ class Cards::StepsController < ApplicationController
def update
@step.update!(step_params)
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { render :show }
+ end
end
def destroy
@step.destroy!
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/cards/taggings_controller.rb b/app/controllers/cards/taggings_controller.rb
index 9190b20f1..2385b4326 100644
--- a/app/controllers/cards/taggings_controller.rb
+++ b/app/controllers/cards/taggings_controller.rb
@@ -9,6 +9,11 @@ class Cards::TaggingsController < ApplicationController
def create
@card.toggle_tag_with sanitized_tag_title_param
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/cards/triages_controller.rb b/app/controllers/cards/triages_controller.rb
index 0c1a7bd1e..d8fc548f1 100644
--- a/app/controllers/cards/triages_controller.rb
+++ b/app/controllers/cards/triages_controller.rb
@@ -5,11 +5,18 @@ class Cards::TriagesController < ApplicationController
column = @card.board.columns.find(params[:column_id])
@card.triage_into(column)
- redirect_to @card
+ respond_to do |format|
+ format.html { redirect_to @card }
+ format.json { head :no_content }
+ end
end
def destroy
@card.send_back_to_triage
- redirect_to @card
+
+ respond_to do |format|
+ format.html { redirect_to @card }
+ format.json { head :no_content }
+ end
end
end
diff --git a/app/controllers/cards/watches_controller.rb b/app/controllers/cards/watches_controller.rb
index 4d83567d0..9d314dedf 100644
--- a/app/controllers/cards/watches_controller.rb
+++ b/app/controllers/cards/watches_controller.rb
@@ -7,9 +7,19 @@ class Cards::WatchesController < ApplicationController
def create
@card.watch_by Current.user
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
def destroy
@card.unwatch_by Current.user
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
end
diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb
index cb40079e8..8223d3801 100644
--- a/app/controllers/cards_controller.rb
+++ b/app/controllers/cards_controller.rb
@@ -10,8 +10,17 @@ class CardsController < ApplicationController
end
def create
- card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted")
- redirect_to card
+ respond_to do |format|
+ format.html do
+ card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted")
+ redirect_to card
+ end
+
+ format.json do
+ card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
+ head :created, location: card_path(card, format: :json)
+ end
+ end
end
def show
@@ -22,11 +31,20 @@ class CardsController < ApplicationController
def update
@card.update! card_params
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { render :show }
+ end
end
def destroy
@card.destroy!
- redirect_to @card.board, notice: "Card deleted"
+
+ respond_to do |format|
+ format.html { redirect_to @card.board, notice: "Card deleted" }
+ format.json { head :no_content }
+ end
end
private
@@ -43,6 +61,6 @@ class CardsController < ApplicationController
end
def card_params
- params.expect(card: [ :status, :title, :description, :image, tag_ids: [] ])
+ params.expect(card: [ :status, :title, :description, :image, :created_at, tag_ids: [] ])
end
end
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index 4cdbbdb69..30f54820b 100644
--- a/app/controllers/concerns/authentication.rb
+++ b/app/controllers/concerns/authentication.rb
@@ -7,7 +7,7 @@ module Authentication
after_action :ensure_development_magic_link_not_leaked
helper_method :authenticated?
- etag { Current.session.id if authenticated? }
+ etag { Current.identity.id if authenticated? }
include LoginHelper
end
@@ -32,7 +32,7 @@ module Authentication
private
def authenticated?
- Current.session.present?
+ Current.identity.present?
end
def require_account
@@ -42,7 +42,7 @@ module Authentication
end
def require_authentication
- resume_session || request_authentication
+ resume_session || authenticate_by_bearer_token || request_authentication
end
def resume_session
@@ -55,6 +55,16 @@ module Authentication
Session.find_signed(cookies.signed[:session_token])
end
+ def authenticate_by_bearer_token
+ if request.authorization.to_s.include?("Bearer")
+ authenticate_or_request_with_http_token do |token|
+ if identity = Identity.find_by_permissable_access_token(token, method: request.method)
+ Current.identity = identity
+ end
+ end
+ end
+ end
+
def request_authentication
if Current.account.present?
session[:return_to_after_authenticating] = request.url
diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb
index 8ede51fc7..95fa12892 100644
--- a/app/controllers/concerns/request_forgery_protection.rb
+++ b/app/controllers/concerns/request_forgery_protection.rb
@@ -12,7 +12,7 @@ module RequestForgeryProtection
end
def verified_request?
- super || safe_fetch_site?
+ super || safe_fetch_site? || request.format.json?
end
SAFE_FETCH_SITES = %w[ same-origin same-site ]
diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb
new file mode 100644
index 000000000..99c87893f
--- /dev/null
+++ b/app/controllers/my/access_tokens_controller.rb
@@ -0,0 +1,40 @@
+class My::AccessTokensController < ApplicationController
+ def index
+ @access_tokens = my_access_tokens.order(created_at: :desc)
+ end
+
+ def show
+ @access_token = my_access_tokens.find(verifier.verify(params[:id]))
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
+ redirect_to my_access_tokens_path, alert: "Token is no longer visible"
+ end
+
+ def new
+ @access_token = my_access_tokens.new
+ end
+
+ def create
+ access_token = my_access_tokens.create!(access_token_params)
+ expiring_id = verifier.generate access_token.id, expires_in: 10.seconds
+
+ redirect_to my_access_token_path(expiring_id)
+ end
+
+ def destroy
+ my_access_tokens.find(params[:id]).destroy!
+ redirect_to my_access_tokens_path
+ end
+
+ private
+ def my_access_tokens
+ Current.identity.access_tokens
+ end
+
+ def access_token_params
+ params.expect(access_token: %i[ description permission ])
+ end
+
+ def verifier
+ Rails.application.message_verifier(:access_tokens)
+ end
+end
diff --git a/app/controllers/my/identities_controller.rb b/app/controllers/my/identities_controller.rb
new file mode 100644
index 000000000..7a388a3c3
--- /dev/null
+++ b/app/controllers/my/identities_controller.rb
@@ -0,0 +1,7 @@
+class My::IdentitiesController < ApplicationController
+ disallow_account_scope
+
+ def show
+ @identity = Current.identity
+ end
+end
diff --git a/app/controllers/notifications/bulk_readings_controller.rb b/app/controllers/notifications/bulk_readings_controller.rb
index 5f80938fe..2c15a871b 100644
--- a/app/controllers/notifications/bulk_readings_controller.rb
+++ b/app/controllers/notifications/bulk_readings_controller.rb
@@ -2,10 +2,15 @@ class Notifications::BulkReadingsController < ApplicationController
def create
Current.user.notifications.unread.read_all
- if from_tray?
- head :ok
- else
- redirect_to notifications_path
+ respond_to do |format|
+ format.html do
+ if from_tray?
+ head :ok
+ else
+ redirect_to notifications_path
+ end
+ end
+ format.json { head :no_content }
end
end
diff --git a/app/controllers/notifications/readings_controller.rb b/app/controllers/notifications/readings_controller.rb
index 2accc3373..622668efc 100644
--- a/app/controllers/notifications/readings_controller.rb
+++ b/app/controllers/notifications/readings_controller.rb
@@ -2,10 +2,20 @@ class Notifications::ReadingsController < ApplicationController
def create
@notification = Current.user.notifications.find(params[:notification_id])
@notification.read
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
def destroy
@notification = Current.user.notifications.find(params[:notification_id])
@notification.unread
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
end
diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb
index 76bd62974..b116ed1a2 100644
--- a/app/controllers/notifications_controller.rb
+++ b/app/controllers/notifications_controller.rb
@@ -1,13 +1,20 @@
class NotificationsController < ApplicationController
MAX_UNREAD_NOTIFICATIONS = 500
+ MAX_UNREAD_NOTIFICATIONS_VIA_API = 100
def index
- @unread = Current.user.notifications.unread.ordered.preloaded.limit(MAX_UNREAD_NOTIFICATIONS) unless current_page_param
+ @unread = Current.user.notifications.unread.ordered.preloaded.limit(max_unread_notifications) unless current_page_param
set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded
respond_to do |format|
format.turbo_stream if current_page_param # Allows read-all action to side step pagination
format.html
+ format.json
end
end
+
+ private
+ def max_unread_notifications
+ request.format.json? ? MAX_UNREAD_NOTIFICATIONS_VIA_API : MAX_UNREAD_NOTIFICATIONS
+ end
end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
new file mode 100644
index 000000000..6b72fdf50
--- /dev/null
+++ b/app/controllers/tags_controller.rb
@@ -0,0 +1,5 @@
+class TagsController < ApplicationController
+ def index
+ set_page_and_extract_portion_from Current.account.tags.alphabetically
+ end
+end
diff --git a/app/controllers/users/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb
index bf5e5f87e..b511a6b19 100644
--- a/app/controllers/users/push_subscriptions_controller.rb
+++ b/app/controllers/users/push_subscriptions_controller.rb
@@ -5,13 +5,7 @@ class Users::PushSubscriptionsController < ApplicationController
end
def create
- if subscription = @push_subscriptions.find_by(push_subscription_params)
- subscription.touch
- else
- @push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
- end
-
- head :ok
+ @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)
end
def destroy
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 15f8ecd6f..74deb686b 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,7 +1,11 @@
class UsersController < ApplicationController
- before_action :set_user
+ before_action :set_user, except: %i[ index ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
+ def index
+ set_page_and_extract_portion_from Current.account.users.active.alphabetically
+ end
+
def show
end
@@ -10,15 +14,25 @@ class UsersController < ApplicationController
def update
if @user.update(user_params)
- redirect_to @user
+ respond_to do |format|
+ format.html { redirect_to @user }
+ format.json { head :no_content }
+ end
else
- render :edit, status: :unprocessable_entity
+ respond_to do |format|
+ format.html { render :edit, status: :unprocessable_entity }
+ format.json { render json: @user.errors, status: :unprocessable_entity }
+ end
end
end
def destroy
@user.deactivate
- redirect_to users_path
+
+ respond_to do |format|
+ format.html { redirect_to users_path }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb
index 8998ffcb9..218d58e78 100644
--- a/app/helpers/html_helper.rb
+++ b/app/helpers/html_helper.rb
@@ -1,6 +1,11 @@
module HtmlHelper
+ include ERB::Util
+
+ EXCLUDE_PUNCTUATION = %(.?,:!;"'<>)
+ EXCLUDE_PUNCTUATION_REGEX = /[#{Regexp.escape(EXCLUDE_PUNCTUATION)}]+\z/
+
def format_html(html)
- fragment = Nokogiri::HTML.fragment(html)
+ fragment = Nokogiri::HTML5.fragment(html)
auto_link(fragment)
@@ -16,14 +21,16 @@ module HtmlHelper
fragment.traverse do |node|
next unless auto_linkable_node?(node)
- content = node.text
+ # 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::HTML.fragment(linked_content))
+ node.replace(Nokogiri::HTML5.fragment(linked_content))
end
end
end
@@ -40,8 +47,10 @@ module HtmlHelper
end
def extract_url_and_punctuation(url_match)
- if url_match.end_with?(".", "?", ",", ":")
- [ url_match[..-2], url_match[-1] ]
+ 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
diff --git a/app/jobs/storage/materialize_job.rb b/app/jobs/storage/materialize_job.rb
new file mode 100644
index 000000000..78ecd3746
--- /dev/null
+++ b/app/jobs/storage/materialize_job.rb
@@ -0,0 +1,10 @@
+class Storage::MaterializeJob < ApplicationJob
+ queue_as :backend
+ limits_concurrency to: 1, key: ->(owner) { owner }
+
+ discard_on ActiveJob::DeserializationError
+
+ def perform(owner)
+ owner.materialize_storage
+ end
+end
diff --git a/app/jobs/storage/reconcile_job.rb b/app/jobs/storage/reconcile_job.rb
new file mode 100644
index 000000000..be0c0748c
--- /dev/null
+++ b/app/jobs/storage/reconcile_job.rb
@@ -0,0 +1,9 @@
+class Storage::ReconcileJob < ApplicationJob
+ queue_as :backend
+
+ discard_on ActiveJob::DeserializationError
+
+ def perform(owner)
+ owner.reconcile_storage
+ end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 61a636adb..34aaf64ae 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1,5 +1,5 @@
class Account < ApplicationRecord
- include Entropic, Seedeable
+ include Account::Storage, Entropic, Seedeable
has_one :join_code
has_many :users, dependent: :destroy
@@ -10,8 +10,6 @@ class Account < ApplicationRecord
has_many :columns, dependent: :destroy
has_many :exports, class_name: "Account::Export", dependent: :destroy
- has_many_attached :uploads
-
before_create :assign_external_account_id
after_create :create_join_code
diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb
index 82283e4f6..79160cbaa 100644
--- a/app/models/account/join_code.rb
+++ b/app/models/account/join_code.rb
@@ -1,8 +1,11 @@
class Account::JoinCode < ApplicationRecord
CODE_LENGTH = 12
+ USAGE_LIMIT_MAX = 10_000_000_000
belongs_to :account
+ validates :usage_limit, numericality: { less_than_or_equal_to: USAGE_LIMIT_MAX, message: "cannot be larger than the population of the planet" }
+
scope :active, -> { where("usage_count < usage_limit") }
before_create :generate_code, if: -> { code.blank? }
diff --git a/app/models/account/storage.rb b/app/models/account/storage.rb
new file mode 100644
index 000000000..8b432e8ce
--- /dev/null
+++ b/app/models/account/storage.rb
@@ -0,0 +1,9 @@
+module Account::Storage
+ extend ActiveSupport::Concern
+ include Storage::Totaled
+
+ private
+ def calculate_real_storage_bytes
+ boards.sum { |board| board.send(:calculate_real_storage_bytes) }
+ end
+end
diff --git a/app/models/board.rb b/app/models/board.rb
index 9f1bcd5d7..ada49f1f2 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,5 +1,5 @@
class Board < ApplicationRecord
- include Accessible, AutoPostponing, Broadcastable, Cards, Entropic, Filterable, Publishable, Triageable
+ include Accessible, AutoPostponing, Board::Storage, Broadcastable, Cards, Entropic, Filterable, Publishable, ::Storage::Tracked, Triageable
belongs_to :creator, class_name: "User", default: -> { Current.user }
belongs_to :account, default: -> { creator.account }
diff --git a/app/models/board/storage.rb b/app/models/board/storage.rb
new file mode 100644
index 000000000..64dde9985
--- /dev/null
+++ b/app/models/board/storage.rb
@@ -0,0 +1,56 @@
+module Board::Storage
+ extend ActiveSupport::Concern
+ include Storage::Totaled
+
+ # Board's own embeds (public_description) count toward itself
+ def board_for_storage_tracking
+ self
+ end
+
+ private
+ BATCH_SIZE = 1000
+
+ # 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).
+ def calculate_real_storage_bytes
+ card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes
+ end
+
+ def card_image_bytes
+ sum_blob_bytes_in_batches \
+ ActiveStorage::Attachment.where(record_type: "Card", name: "image"),
+ cards.pluck(:id)
+ end
+
+ def card_embed_bytes
+ sum_embed_bytes_for "Card", cards.pluck(:id)
+ end
+
+ def comment_embed_bytes
+ sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id)
+ end
+
+ def board_embed_bytes
+ sum_embed_bytes_for "Board", [ id ]
+ end
+
+ def sum_embed_bytes_for(record_type, record_ids)
+ rich_text_ids = ActionText::RichText \
+ .where(record_type: record_type, record_id: record_ids)
+ .pluck(:id)
+
+ sum_blob_bytes_in_batches \
+ ActiveStorage::Attachment.where(record_type: "ActionText::RichText", name: "embeds"),
+ rich_text_ids
+ end
+
+ def sum_blob_bytes_in_batches(base_scope, record_ids)
+ 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)
+ end
+ end
+end
diff --git a/app/models/card.rb b/app/models/card.rb
index dfad00cb8..33c96e502 100644
--- a/app/models/card.rb
+++ b/app/models/card.rb
@@ -1,7 +1,7 @@
class Card < ApplicationRecord
include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
- Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable
+ Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
belongs_to :account, default: -> { board.account }
belongs_to :board
diff --git a/app/models/card/eventable/system_commenter.rb b/app/models/card/eventable/system_commenter.rb
index 0e76700b3..5c7defc29 100644
--- a/app/models/card/eventable/system_commenter.rb
+++ b/app/models/card/eventable/system_commenter.rb
@@ -1,4 +1,6 @@
class Card::Eventable::SystemCommenter
+ include ERB::Util
+
attr_reader :card, :event
def initialize(card, event)
@@ -15,25 +17,53 @@ class Card::Eventable::SystemCommenter
def comment_body
case event.action
when "card_assigned"
- "#{event.creator.name} assigned this to #{event.assignees.pluck(:name).to_sentence}."
+ "#{creator_name} assigned this to #{assignee_names}."
when "card_unassigned"
- "#{event.creator.name} unassigned from #{event.assignees.pluck(:name).to_sentence}."
+ "#{creator_name} unassigned from #{assignee_names}."
when "card_closed"
- "Moved to “Done” by #{event.creator.name}"
+ "Moved to “Done” by #{creator_name}"
when "card_reopened"
- "Reopened by #{event.creator.name}"
+ "Reopened by #{creator_name}"
when "card_postponed"
- "#{event.creator.name} moved this to “Not Now”"
+ "#{creator_name} moved this to “Not Now”"
when "card_auto_postponed"
"Moved to “Not Now” due to inactivity"
when "card_title_changed"
- "#{event.creator.name} changed the title from “#{event.particulars.dig('particulars', 'old_title')}” to “#{event.particulars.dig('particulars', 'new_title')}”."
+ "#{creator_name} changed the title from “#{old_title}” to “#{new_title}”."
when "card_board_changed"
- "#{event.creator.name} moved this from “#{event.particulars.dig('particulars', 'old_board')}” to “#{event.particulars.dig('particulars', 'new_board')}”."
+ "#{creator_name} moved this from “#{old_board}” to “#{new_board}”."
when "card_triaged"
- "#{event.creator.name} moved this to “#{event.particulars.dig('particulars', 'column')}”"
+ "#{creator_name} moved this to “#{column}”"
when "card_sent_back_to_triage"
- "#{event.creator.name} moved this back to “Maybe?”"
+ "#{creator_name} moved this back to “Maybe?”"
end
end
+
+ def creator_name
+ h event.creator.name
+ end
+
+ def assignee_names
+ h event.assignees.pluck(:name).to_sentence
+ end
+
+ def old_title
+ h event.particulars.dig("particulars", "old_title")
+ end
+
+ def new_title
+ h event.particulars.dig("particulars", "new_title")
+ end
+
+ def old_board
+ h event.particulars.dig("particulars", "old_board")
+ end
+
+ def new_board
+ h event.particulars.dig("particulars", "new_board")
+ end
+
+ def column
+ h event.particulars.dig("particulars", "column")
+ end
end
diff --git a/app/models/card/statuses.rb b/app/models/card/statuses.rb
index 53b243e4e..5311c211e 100644
--- a/app/models/card/statuses.rb
+++ b/app/models/card/statuses.rb
@@ -4,37 +4,25 @@ 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
+ before_save :mark_if_just_published
after_create -> { track_event :published }, if: :published?
scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) }
end
+ attr_accessor :was_just_published
+ alias_method :was_just_published?, :was_just_published
+
def publish
transaction do
+ self.created_at = Time.current
published!
track_event :published
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
+ def mark_if_just_published
+ self.was_just_published = true if published? && status_changed?
end
end
diff --git a/app/models/comment.rb b/app/models/comment.rb
index f81c465ad..a396a904f 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -1,5 +1,5 @@
class Comment < ApplicationRecord
- include Attachments, Eventable, Mentions, Promptable, Searchable
+ include Attachments, Eventable, Mentions, Promptable, Searchable, Storage::Tracked
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
diff --git a/app/models/concerns/storage/totaled.rb b/app/models/concerns/storage/totaled.rb
new file mode 100644
index 000000000..3f220f55f
--- /dev/null
+++ b/app/models/concerns/storage/totaled.rb
@@ -0,0 +1,70 @@
+module Storage::Totaled
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :storage_total, as: :owner, class_name: "Storage::Total", dependent: :destroy
+ has_many :storage_entries, class_name: "Storage::Entry", foreign_key: foreign_key_for_storage
+ end
+
+ class_methods do
+ def foreign_key_for_storage
+ "#{model_name.singular}_id"
+ end
+ end
+
+ # Fast: materialized snapshot (may be slightly stale)
+ def bytes_used
+ storage_total&.bytes_stored || 0
+ end
+
+ # Exact: snapshot + pending entries
+ def bytes_used_exact
+ (storage_total || create_storage_total!).current_usage
+ end
+
+ def materialize_storage_later
+ Storage::MaterializeJob.perform_later(self)
+ end
+
+ # Materialize all pending entries into snapshot
+ def materialize_storage
+ total = storage_total || create_storage_total!
+
+ total.with_lock do
+ latest_entry_id = storage_entries.maximum(:id)
+
+ if latest_entry_id && total.last_entry_id != latest_entry_id
+ scope = storage_entries.where(id: ..latest_entry_id)
+ scope = scope.where.not(id: ..total.last_entry_id) if total.last_entry_id
+ delta_sum = scope.sum(:delta)
+
+ total.update! bytes_stored: total.bytes_stored + delta_sum, last_entry_id: latest_entry_id
+ end
+ end
+ 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.
+ def reconcile_storage
+ max_entry_id = 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
+
+ if diff.nonzero?
+ Storage::Entry.record \
+ account: is_a?(Account) ? self : account,
+ board: is_a?(Board) ? self : nil,
+ recordable: nil,
+ delta: diff,
+ operation: "reconcile"
+ end
+ end
+
+ private
+ def calculate_real_storage_bytes
+ raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes"
+ end
+end
diff --git a/app/models/concerns/storage/tracked.rb b/app/models/concerns/storage/tracked.rb
new file mode 100644
index 000000000..7b96e95c0
--- /dev/null
+++ b/app/models/concerns/storage/tracked.rb
@@ -0,0 +1,57 @@
+module Storage::Tracked
+ extend ActiveSupport::Concern
+
+ included do
+ before_update :track_board_transfer, if: :board_transfer?
+ end
+
+ # Return self as the trackable record for storage entries
+ def storage_tracked_record
+ self
+ end
+
+ # Override in models where board is determined differently (e.g., Board itself)
+ def board_for_storage_tracking
+ board
+ end
+
+ # Total bytes for all attachments on this record
+ def storage_bytes
+ attachments_for_storage.sum { |a| a.blob.byte_size }
+ end
+
+ private
+ def board_transfer?
+ respond_to?(:board_id_changed?) && board_id_changed?
+ end
+
+ def track_board_transfer
+ old_board_id = board_id_was
+ current_bytes = storage_bytes
+
+ if current_bytes.positive?
+ # Debit old board
+ if old_board_id
+ Storage::Entry.record \
+ account: account,
+ board_id: old_board_id,
+ recordable: self,
+ delta: -current_bytes,
+ operation: "transfer_out"
+ end
+
+ # Credit new board
+ Storage::Entry.record \
+ account: account,
+ board: board,
+ recordable: self,
+ delta: current_bytes,
+ operation: "transfer_in"
+ end
+ end
+
+ # Override if needed. Default = all direct attachments
+ def attachments_for_storage
+ ActiveStorage::Attachment.where(record: self)
+ end
+end
diff --git a/app/models/current.rb b/app/models/current.rb
index 47f2b6c21..94ef9688c 100644
--- a/app/models/current.rb
+++ b/app/models/current.rb
@@ -1,13 +1,19 @@
class Current < ActiveSupport::CurrentAttributes
- attribute :session, :user, :account
+ attribute :session, :user, :identity, :account
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
- delegate :identity, to: :session, allow_nil: true
-
def session=(value)
super(value)
- if value.present? && account.present?
+ if value.present?
+ self.identity = session.identity
+ end
+ end
+
+ def identity=(identity)
+ super(identity)
+
+ if identity.present?
self.user = identity.users.find_by(account: account)
end
end
diff --git a/app/models/identity.rb b/app/models/identity.rb
index bb69734b4..7495e37c3 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -1,6 +1,7 @@
class Identity < ApplicationRecord
include Joinable, Transferable
+ has_many :access_tokens, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
has_many :users, dependent: :nullify
@@ -13,6 +14,12 @@ class Identity < ApplicationRecord
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
+ def self.find_by_permissable_access_token(token, method:)
+ if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method)
+ access_token.identity
+ end
+ end
+
def send_magic_link(**attributes)
attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for)
diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb
new file mode 100644
index 000000000..abdf37eba
--- /dev/null
+++ b/app/models/identity/access_token.rb
@@ -0,0 +1,10 @@
+class Identity::AccessToken < ApplicationRecord
+ belongs_to :identity
+
+ has_secure_token
+ enum :permission, %w[ read write ].index_by(&:itself), default: :read
+
+ def allows?(method)
+ method.in?(%w[ GET HEAD ]) || write?
+ end
+end
diff --git a/app/models/push/subscription.rb b/app/models/push/subscription.rb
index afd417367..1248e9091 100644
--- a/app/models/push/subscription.rb
+++ b/app/models/push/subscription.rb
@@ -1,5 +1,6 @@
class Push::Subscription < ApplicationRecord
PERMITTED_ENDPOINT_HOSTS = %w[
+ jmt17.google.com
fcm.googleapis.com
updates.push.services.mozilla.com
web.push.apple.com
diff --git a/app/models/storage.rb b/app/models/storage.rb
new file mode 100644
index 000000000..9c17b7cd9
--- /dev/null
+++ b/app/models/storage.rb
@@ -0,0 +1,5 @@
+module Storage
+ def self.table_name_prefix
+ "storage_"
+ end
+end
diff --git a/app/models/storage/attachment_tracking.rb b/app/models/storage/attachment_tracking.rb
new file mode 100644
index 000000000..d68f5845d
--- /dev/null
+++ b/app/models/storage/attachment_tracking.rb
@@ -0,0 +1,53 @@
+module Storage::AttachmentTracking
+ extend ActiveSupport::Concern
+
+ included do
+ # Snapshot IDs in before_destroy since parent record may be deleted
+ # by the time after_destroy_commit runs
+ before_destroy :snapshot_storage_context
+ after_create_commit :record_storage_attach
+ after_destroy_commit :record_storage_detach
+ end
+
+ private
+ def record_storage_attach
+ return unless storage_tracked_record
+
+ Storage::Entry.record \
+ account: storage_tracked_record.account,
+ board: storage_tracked_record.board_for_storage_tracking,
+ recordable: storage_tracked_record,
+ blob: blob,
+ delta: blob.byte_size,
+ operation: "attach"
+ end
+
+ def record_storage_detach
+ 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],
+ delta: -blob.byte_size,
+ operation: "detach"
+ end
+
+ 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
+ }
+ end
+
+ def storage_tracked_record
+ record.try(:storage_tracked_record)
+ end
+end
diff --git a/app/models/storage/entry.rb b/app/models/storage/entry.rb
new file mode 100644
index 000000000..a2f6e726b
--- /dev/null
+++ b/app/models/storage/entry.rb
@@ -0,0 +1,35 @@
+class Storage::Entry < ApplicationRecord
+ belongs_to :account
+ belongs_to :board, optional: true
+ belongs_to :recordable, polymorphic: true, optional: true
+
+ 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)
+ return if delta.zero?
+
+ account_id ||= account&.id
+ board_id ||= board&.id
+ blob_id ||= blob&.id
+
+ 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,
+ 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
+
+ entry
+ end
+end
diff --git a/app/models/storage/total.rb b/app/models/storage/total.rb
new file mode 100644
index 000000000..7e1651d79
--- /dev/null
+++ b/app/models/storage/total.rb
@@ -0,0 +1,12 @@
+class Storage::Total < ApplicationRecord
+ belongs_to :owner, polymorphic: true
+
+ def pending_entries
+ owner.storage_entries.pending(last_entry_id)
+ end
+
+ # Exact current usage (snapshot + pending)
+ def current_usage
+ bytes_stored + pending_entries.sum(:delta)
+ end
+end
diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb
index c5dab1497..3ebe49b06 100644
--- a/app/models/webhook/delivery.rb
+++ b/app/models/webhook/delivery.rb
@@ -1,7 +1,10 @@
class Webhook::Delivery < ApplicationRecord
+ class ResponseTooLarge < StandardError; end
+
STALE_TRESHOLD = 7.days
USER_AGENT = "fizzy/1.0.0 Webhook"
ENDPOINT_TIMEOUT = 7.seconds
+ MAX_RESPONSE_SIZE = 100.kilobytes
belongs_to :account, default: -> { webhook.account }
belongs_to :webhook
@@ -52,12 +55,16 @@ class Webhook::Delivery < ApplicationRecord
if resolved_ip.nil?
{ error: :private_uri }
else
- response = http.request(
- Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
- )
+ request = Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
+
+ response = http.request(request) do |net_http_response|
+ stream_body_with_limit(net_http_response)
+ end
{ code: response.code.to_i }
end
+ rescue ResponseTooLarge
+ { error: :response_too_large }
rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
{ error: :dns_lookup_failed }
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
@@ -68,6 +75,14 @@ class Webhook::Delivery < ApplicationRecord
{ error: :failed_tls }
end
+ def stream_body_with_limit(response)
+ bytes_read = 0
+ response.read_body do |chunk|
+ bytes_read += chunk.bytesize
+ raise ResponseTooLarge if bytes_read > MAX_RESPONSE_SIZE
+ end
+ end
+
def resolved_ip
return @resolved_ip if defined?(@resolved_ip)
@resolved_ip = SsrfProtection.resolve_public_ip(uri.host)
diff --git a/app/views/account/join_codes/edit.html.erb b/app/views/account/join_codes/edit.html.erb
index bca92e58f..8a65adca7 100644
--- a/app/views/account/join_codes/edit.html.erb
+++ b/app/views/account/join_codes/edit.html.erb
@@ -14,12 +14,21 @@
<%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
<%= form.number_field :usage_limit,
- required: true, autofocus: true, min: @join_code.usage_count,
+ required: true, autofocus: true,
+ in: 0..Account::JoinCode::USAGE_LIMIT_MAX,
class: "input center txt-large fit-content font-weight-black txt-align-center", style: "max-inline-size: 8ch",
data: { action: "keydown.esc@document->form#cancel focus->form#select" } %>
+ <% if @join_code.errors.any? %>
+
+ <% @join_code.errors.full_messages.each do |message| %>
+
<%= message %>
+ <% end %>
+
+ <% end %>
+
- This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times.
+ This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit_in_database %> times.
<%= form.button type: :submit, class: "btn btn--link center txt-medium", data: { form_target: "submit" } do %>
diff --git a/app/views/boards/_board.json.jbuilder b/app/views/boards/_board.json.jbuilder
index ce2ef0451..fd4e891d7 100644
--- a/app/views/boards/_board.json.jbuilder
+++ b/app/views/boards/_board.json.jbuilder
@@ -1,8 +1,7 @@
json.cache! board do
json.(board, :id, :name, :all_access)
json.created_at board.created_at.utc
+ json.url board_url(board)
- json.creator do
- json.partial! "users/user", user: board.creator
- end
+ json.creator board.creator, partial: "users/user", as: :user
end
diff --git a/app/views/boards/columns/index.json.jbuilder b/app/views/boards/columns/index.json.jbuilder
new file mode 100644
index 000000000..4142996de
--- /dev/null
+++ b/app/views/boards/columns/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @columns, partial: "columns/column", as: :column
diff --git a/app/views/boards/columns/show.json.jbuilder b/app/views/boards/columns/show.json.jbuilder
new file mode 100644
index 000000000..f94e6584b
--- /dev/null
+++ b/app/views/boards/columns/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! "columns/column", column: @column
diff --git a/app/views/boards/edit/_publication.html.erb b/app/views/boards/edit/_publication.html.erb
index 7cfc10ca6..c0f60cd77 100644
--- a/app/views/boards/edit/_publication.html.erb
+++ b/app/views/boards/edit/_publication.html.erb
@@ -6,17 +6,17 @@
<% if board.published? %>
Personal access tokens can be used like a password to access the Fizzy developer API. You can have as many tokens as you need and revoke access to each one at any time.
+ <% end %>
+
+ <%= link_to new_my_access_token_path, class: "btn btn--link" do %>
+ <%= icon_tag "add" %>
+ Generate a new access token
+ <% end %>
+
diff --git a/app/views/my/access_tokens/new.html.erb b/app/views/my/access_tokens/new.html.erb
new file mode 100644
index 000000000..be08e5031
--- /dev/null
+++ b/app/views/my/access_tokens/new.html.erb
@@ -0,0 +1,29 @@
+<% @page_title = "Generate a personal access token" %>
+
+<% content_for :header do %>
+
<% end %>
diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb
index 02522aedb..e1a6af513 100644
--- a/app/views/webhooks/new.html.erb
+++ b/app/views/webhooks/new.html.erb
@@ -31,7 +31,7 @@
<%= form.label :actions do %>
Events
-
Trigger a call to the Payload URL when these things occur:
+
Trigger a call to the Payload URL when:
<% end %>
<%= render "webhooks/form/actions", form: form %>
diff --git a/bin/dev b/bin/dev
index 51d232d91..d01747dd3 100755
--- a/bin/dev
+++ b/bin/dev
@@ -4,8 +4,8 @@ bin/rails runner - <
password: <%= ENV["MYSQL_PASSWORD"] %>
pool: 50
+ ssl_mode: <%= ENV["MYSQL_SSL_MODE"] %>
timeout: 5000
development:
@@ -40,4 +41,4 @@ production:
cache:
<<: *default
database: fizzy_production_cache
- migrations_paths: db/cache_migrate
\ No newline at end of file
+ migrations_paths: db/cache_migrate
diff --git a/config/deploy.yml b/config/deploy.yml
index 0e855727d..8245a7136 100644
--- a/config/deploy.yml
+++ b/config/deploy.yml
@@ -28,7 +28,8 @@ env:
- SMTP_USERNAME
- SMTP_PASSWORD
clear:
- MAILER_FROM_ADDRESS: support@example.com # The email address that Fizzy sends email from
+ MAILER_FROM_ADDRESS: support@example.com # The email "from" address that Fizzy sends email from
+ SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email
SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 7b29a00d1..6398d14d0 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -5,18 +5,20 @@ Rails.application.configure do
# Email provider Settings
#
- # Configure these according to whichever email provider you use. An example setup
- # using SMTP looks like the following:
- #
- # config.action_mailer.smtp_settings = {
- # address: 'smtp.example.com', # The address of your email provider's SMTP server
- # port: 2525,
- # domain: 'example.com', # Your domain, which Fizzy will send email from
- # user_name: ENV["SMTP_USERNAME"],
- # password: ENV["SMTP_PASSWORD"],
- # authentication: :plain,
- # enable_starttls_auto: true
- # }
+ # SMTP setting can be configured via environment variables.
+ # For other configuration options, consult the Action Mailer documentation.
+ if smtp_address = ENV["SMTP_ADDRESS"].presence
+ config.action_mailer.delivery_method = :smtp
+ config.action_mailer.smtp_settings = {
+ address: smtp_address,
+ port: ENV.fetch("SMTP_PORT", "587").to_i,
+ domain: ENV.fetch("SMTP_DOMAIN", nil),
+ user_name: ENV.fetch("SMTP_USERNAME", nil),
+ password: ENV.fetch("SMTP_PASSWORD", nil),
+ authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"),
+ enable_starttls_auto: ENV.fetch("SMTP_ENABLE_STARTTLS_AUTO", "true") == "true"
+ }
+ end
# Code is not reloaded between requests.
config.enable_reloading = false
@@ -40,6 +42,12 @@ Rails.application.configure do
"Cache-Control" => "public, max-age=#{1.year.to_i}"
}
+ # Select Active Storage service via env var; default to local disk.
+ # Don't overwrite if it's already been set (e.g. by fizzy-saas)
+ if config.active_storage.service.blank?
+ config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym
+ end
+
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
@@ -54,10 +62,10 @@ Rails.application.configure do
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
# Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.
- config.assume_ssl = true
+ config.assume_ssl = ENV.fetch("ASSUME_SSL", "true") == "true"
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
- config.force_ssl = true
+ config.force_ssl = ENV.fetch("FORCE_SSL", "true") == "true"
# Log to STDOUT by default
config.logger = ActiveSupport::Logger.new(STDOUT)
diff --git a/config/initializers/action_text.rb b/config/initializers/action_text.rb
index eee87de0d..0f804e57b 100644
--- a/config/initializers/action_text.rb
+++ b/config/initializers/action_text.rb
@@ -11,6 +11,11 @@ module ActionText
end
end
end
+
+ # Delegate storage tracking to the parent record (Card, Comment, Board, etc.)
+ def storage_tracked_record
+ record.try(:storage_tracked_record)
+ end
end
end
end
diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb
index 4a4a0343d..e647a72cf 100644
--- a/config/initializers/active_storage.rb
+++ b/config/initializers/active_storage.rb
@@ -1,3 +1,7 @@
+ActiveSupport.on_load(:active_storage_attachment) do
+ include Storage::AttachmentTracking
+end
+
ActiveSupport.on_load(:active_storage_blob) do
ActiveStorage::DiskController.after_action only: :show do
expires_in 5.minutes, public: true
@@ -11,10 +15,9 @@ end
# before the User model's upload callback, causing FileNotFoundError when
# using `process: :immediately` for variants.
# See: https://github.com/rails/rails/issues/53694
-#
-# ActiveSupport.on_load(:active_storage_record) do
-# configure_replica_connections
-# end
+ActiveSupport.on_load(:active_storage_record) do
+ configure_replica_connections
+end
module ActiveStorageControllerExtensions
extend ActiveSupport::Concern
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
index fe37a5cde..eb84315e8 100644
--- a/config/initializers/content_security_policy.rb
+++ b/config/initializers/content_security_policy.rb
@@ -2,9 +2,40 @@
# Define an application-wide Content Security Policy.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
+#
+# Directives are configurable via environment variables with fallback to config.x
+# settings. This allows fizzy-saas (or other deployments) to extend the base policy
+# without duplicating it.
+#
+# ENV vars (space-separated sources):
+# CSP_DEFAULT_SRC, CSP_SCRIPT_SRC, CSP_STYLE_SRC, CSP_CONNECT_SRC, CSP_FRAME_SRC,
+# CSP_IMG_SRC, CSP_FONT_SRC, CSP_MEDIA_SRC, CSP_WORKER_SRC, CSP_FRAME_ANCESTORS,
+# CSP_FORM_ACTION, CSP_REPORT_URI, CSP_REPORT_ONLY, DISABLE_CSP
+#
+# config.x.content_security_policy.* (string, space-separated string, or array):
+# script_src, style_src, connect_src, frame_src, img_src, font_src, media_src,
+# worker_src, frame_ancestors, form_action, report_uri, report_only
Rails.application.configure do
- # Configure with environment variables with fallback to config.x values (via fizzy-sass)
+ # Helper to get additional CSP sources from ENV or config.x.
+ # Supports: nil, string, space-separated string, or array.
+ sources = ->(directive) do
+ env_key = "CSP_#{directive.to_s.upcase}"
+ value = if ENV.key?(env_key)
+ ENV[env_key]
+ else
+ config.x.content_security_policy.send(directive)
+ end
+
+ case value
+ when nil then []
+ when Array then value
+ when String then value.split
+ else []
+ end
+ end
+
+ # Report URI and report-only mode
report_uri = ENV.fetch("CSP_REPORT_URI") { config.x.content_security_policy.report_uri }
report_only =
if ENV.key?("CSP_REPORT_ONLY")
@@ -18,23 +49,25 @@ Rails.application.configure do
config.content_security_policy_nonce_directives = %w[ script-src ]
config.content_security_policy do |policy|
- policy.default_src :self
- policy.script_src :self, "https://challenges.cloudflare.com"
- policy.connect_src :self, "https://storage.basecamp.com"
- policy.frame_src :self, "https://challenges.cloudflare.com"
+ policy.default_src :self, *sources.(:default_src)
+ policy.script_src :self, *sources.(:script_src)
+ policy.connect_src :self, *sources.(:connect_src)
+ policy.frame_src :self, *sources.(:frame_src)
# Don't fight user tools: permit inline styles, data:/https: sources, and
# blob: workers for accessibility extensions, privacy tools, and custom fonts.
- policy.style_src :self, :unsafe_inline
- policy.img_src :self, "blob:", "data:", "https:"
- policy.font_src :self, "data:", "https:"
- policy.media_src :self, "blob:", "data:", "https:"
- policy.worker_src :self, "blob:"
+ policy.style_src :self, :unsafe_inline, *sources.(:style_src)
+ policy.img_src :self, "blob:", "data:", "https:", *sources.(:img_src)
+ policy.font_src :self, "data:", "https:", *sources.(:font_src)
+ policy.media_src :self, "blob:", "data:", "https:", *sources.(:media_src)
+ policy.worker_src :self, "blob:", *sources.(:worker_src)
+ # Security-critical defaults (not configurable)
policy.object_src :none
policy.base_uri :none
- policy.form_action :self
- policy.frame_ancestors :self
+
+ policy.form_action :self, *sources.(:form_action)
+ policy.frame_ancestors :self, *sources.(:frame_ancestors)
# Specify URI for violation reports (e.g., Sentry CSP endpoint)
policy.report_uri report_uri if report_uri
diff --git a/config/puma.rb b/config/puma.rb
index 489ae0779..70ca6ebb4 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -7,8 +7,11 @@ pidfile ENV.fetch("PIDFILE", "tmp/pids/server.pid")
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
-# Run Solid Queue with Puma
-plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
+# Run Solid Queue with Puma by default.
+# Disabled when running fizzy-saas or via SOLID_QUEUE_IN_PUMA=false.
+unless Fizzy.saas? || ENV["SOLID_QUEUE_IN_PUMA"] == "false"
+ plugin :solid_queue
+end
# Expose Prometheus metrics at http://0.0.0.0:9394/metrics (SaaS only).
# In dev, overridden to http://127.0.0.1:9306/metrics in .mise.toml.
diff --git a/config/routes.rb b/config/routes.rb
index e67e4ef7c..aa2815433 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -94,6 +94,8 @@ Rails.application.routes.draw do
end
end
+ resources :tags, only: :index
+
namespace :notifications do
resource :settings
resource :unsubscribe
@@ -162,6 +164,8 @@ Rails.application.routes.draw do
resource :landing
namespace :my do
+ resource :identity, only: :show
+ resources :access_tokens
resources :pins
resource :timezone
resource :menu
diff --git a/config/storage.oss.yml b/config/storage.oss.yml
index e50debd34..37052e6c5 100644
--- a/config/storage.oss.yml
+++ b/config/storage.oss.yml
@@ -16,3 +16,14 @@ devminio:
region: us-east-1 # default region required for signer
access_key_id: minioadmin
secret_access_key: minioadmin
+
+s3:
+ service: S3
+ access_key_id: <%= ENV["S3_ACCESS_KEY_ID"] %>
+ bucket: <%= ENV["S3_BUCKET"] || "fizzy-#{Rails.env}-activestorage" %>
+ endpoint: <%= ENV["S3_ENDPOINT"] %>
+ force_path_style: <%= ENV["S3_FORCE_PATH_STYLE"] == "true" %>
+ region: <%= ENV.fetch("S3_REGION", "us-east-1") %>
+ request_checksum_calculation: <%= ENV.fetch("S3_REQUEST_CHECKSUM_CALCULATION", "when_supported") %>
+ response_checksum_validation: <%= ENV.fetch("S3_RESPONSE_CHECKSUM_VALIDATION", "when_supported") %>
+ secret_access_key: <%= ENV["S3_SECRET_ACCESS_KEY"] %>
diff --git a/db/migrate/20251201132341_create_identity_access_tokens.rb b/db/migrate/20251201132341_create_identity_access_tokens.rb
new file mode 100644
index 000000000..0e455104d
--- /dev/null
+++ b/db/migrate/20251201132341_create_identity_access_tokens.rb
@@ -0,0 +1,14 @@
+class CreateIdentityAccessTokens < ActiveRecord::Migration[8.2]
+ def change
+ create_table :identity_access_tokens, id: :uuid do |t|
+ t.uuid :identity_id, null: false
+ t.string :token
+ t.string :permission
+ t.text :description
+
+ t.timestamps
+
+ t.index ["identity_id"], name: "index_access_token_on_identity_id"
+ end
+ end
+end
diff --git a/db/migrate/20251205205826_create_storage_tables.rb b/db/migrate/20251205205826_create_storage_tables.rb
new file mode 100644
index 000000000..878b6dd72
--- /dev/null
+++ b/db/migrate/20251205205826_create_storage_tables.rb
@@ -0,0 +1,27 @@
+class CreateStorageTables < ActiveRecord::Migration[8.0]
+ def change
+ # Storage ledger: debit/credit event stream
+ create_table :storage_entries, id: :uuid do |t|
+ t.references :account, type: :uuid, null: false
+ t.references :board, type: :uuid, null: true
+
+ t.references :recordable, type: :uuid, polymorphic: true, null: true
+
+ t.bigint :delta, null: false
+ t.string :operation, null: false
+
+ t.datetime :created_at, null: false
+ end
+
+ # Storage totals: cached snapshots
+ create_table :storage_totals, id: :uuid do |t|
+ t.references :owner, type: :uuid, polymorphic: true, null: false, index: false
+
+ t.bigint :bytes_stored, null: false, default: 0
+ t.uuid :last_entry_id # Cursor: includes all entries <= this ID
+
+ t.timestamps
+ t.index %i[ owner_type owner_id ], unique: true
+ end
+ end
+end
diff --git a/db/migrate/20251210054934_add_blob_id_and_audit_context_to_storage_entries.rb b/db/migrate/20251210054934_add_blob_id_and_audit_context_to_storage_entries.rb
new file mode 100644
index 000000000..42fa66a77
--- /dev/null
+++ b/db/migrate/20251210054934_add_blob_id_and_audit_context_to_storage_entries.rb
@@ -0,0 +1,9 @@
+class AddBlobIdAndAuditContextToStorageEntries < ActiveRecord::Migration[8.2]
+ def change
+ change_table :storage_entries do |t|
+ t.references :blob, type: :uuid, foreign_key: false, index: true
+ t.references :user, type: :uuid, foreign_key: false, index: true
+ t.string :request_id, index: true
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 42458db17..645cb7a96 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_12_05_010536) do
+ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) 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
@@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
t.index ["email_address"], name: "index_identities_on_email_address", unique: true
end
+ create_table "identity_access_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.text "description"
+ t.uuid "identity_id", null: false
+ t.string "permission"
+ t.string "token"
+ t.datetime "updated_at", null: false
+ t.index ["identity_id"], name: "index_access_token_on_identity_id"
+ end
+
create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "code", null: false
t.datetime "created_at", null: false
@@ -687,6 +697,35 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
t.index ["card_id"], name: "index_steps_on_card_id"
end
+ create_table "storage_entries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.uuid "account_id", null: false
+ t.uuid "blob_id"
+ t.uuid "board_id"
+ t.datetime "created_at", null: false
+ t.bigint "delta", null: false
+ t.string "operation", null: false
+ t.uuid "recordable_id"
+ t.string "recordable_type"
+ t.string "request_id"
+ t.uuid "user_id"
+ t.index ["account_id"], name: "index_storage_entries_on_account_id"
+ t.index ["blob_id"], name: "index_storage_entries_on_blob_id"
+ t.index ["board_id"], name: "index_storage_entries_on_board_id"
+ t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable"
+ t.index ["request_id"], name: "index_storage_entries_on_request_id"
+ t.index ["user_id"], name: "index_storage_entries_on_user_id"
+ end
+
+ create_table "storage_totals", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "bytes_stored", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.uuid "last_entry_id"
+ t.uuid "owner_id", null: false
+ t.string "owner_type", null: false
+ t.datetime "updated_at", null: false
+ t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true
+ end
+
create_table "taggings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "card_id", null: false
diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb
index 1dd2b3e00..84fd827bb 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_12_05_010536) do
+ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
t.index ["email_address"], name: "index_identities_on_email_address", unique: true
end
+ create_table "identity_access_tokens", id: :uuid, force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.text "description", limit: 65535
+ t.uuid "identity_id", null: false
+ t.string "permission", limit: 255
+ t.string "token", limit: 255
+ t.datetime "updated_at", null: false
+ t.index ["identity_id"], name: "index_access_token_on_identity_id"
+ end
+
create_table "magic_links", id: :uuid, force: :cascade do |t|
t.string "code", limit: 255, null: false
t.datetime "created_at", null: false
@@ -460,6 +470,35 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
t.index ["card_id"], name: "index_steps_on_card_id"
end
+ create_table "storage_entries", id: :uuid, force: :cascade do |t|
+ t.uuid "account_id", null: false
+ t.uuid "blob_id"
+ t.uuid "board_id"
+ t.datetime "created_at", null: false
+ t.bigint "delta", null: false
+ t.string "operation", limit: 255, null: false
+ t.uuid "recordable_id"
+ t.string "recordable_type", limit: 255
+ t.string "request_id"
+ t.uuid "user_id"
+ t.index ["account_id"], name: "index_storage_entries_on_account_id"
+ t.index ["blob_id"], name: "index_storage_entries_on_blob_id"
+ t.index ["board_id"], name: "index_storage_entries_on_board_id"
+ t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable"
+ t.index ["request_id"], name: "index_storage_entries_on_request_id"
+ t.index ["user_id"], name: "index_storage_entries_on_user_id"
+ end
+
+ create_table "storage_totals", id: :uuid, force: :cascade do |t|
+ t.bigint "bytes_stored", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.uuid "last_entry_id"
+ t.uuid "owner_id", null: false
+ t.string "owner_type", limit: 255, null: false
+ t.datetime "updated_at", null: false
+ t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true
+ end
+
create_table "taggings", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "card_id", null: false
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 000000000..3710e582f
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,1165 @@
+# Fizzy API
+
+Fizzy has an API that allows you to integrate your application with it or to create
+a bot to perform various actions for you.
+
+## Authentication
+
+To use the API you'll need an access token. To get one, go to your profile, then,
+in the API section, click on "Personal access tokens" and then click on
+"Generate new access token".
+
+Give it a description and pick what kind of permission you want the access token to have:
+- `Read`: allows reading data from your account
+- `Read + Write`: allows reading and writing data to your account on your behalf
+
+Then click on "Generate access token".
+
+
+ Access token generation guide with screenshots
+
+ | Step | Description | Screenshot |
+ |:----:|-------------|:----------:|
+ | 1 | Go to your profile | |
+ | 2 | In the API section click on "Personal access token" | |
+ | 3 | Click on "Generate a new access token" | |
+ | 4 | Give it a description and assign it a permission | |
+
+
+> [!IMPORTANT]
+> __An access token is like a password, keep it secret and do not share it with anyone.__
+> Any person or application that has your access token can perform actions on your behalf.
+
+To authenticate a request using your access token, include it in the `Authorization` header:
+
+```bash
+curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/my/identity
+```
+
+## Caching
+
+Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data.
+
+### Using ETags
+
+When you make a request, the response includes an `ETag` header:
+
+```
+HTTP/1.1 200 OK
+ETag: "abc123"
+Cache-Control: max-age=0, private, must-revalidate
+```
+
+On subsequent requests, include the ETag value in the `If-None-Match` header:
+
+```
+GET /1234567/cards/42.json
+If-None-Match: "abc123"
+```
+
+If the resource hasn't changed, you'll receive a `304 Not Modified` response with no body, saving bandwidth and processing time:
+
+```
+HTTP/1.1 304 Not Modified
+ETag: "abc123"
+```
+
+If the resource has changed, you'll receive the full response with a new ETag.
+
+__Example in Ruby:__
+
+```ruby
+# Store the ETag from the response
+etag = response.headers["ETag"]
+
+# On next request, send it back
+headers = { "If-None-Match" => etag }
+response = client.get("/1234567/cards/42.json", headers: headers)
+
+if response.status == 304
+ # Nothing to do, the card hasn't changed
+else
+ # The card has changed, process the new data
+end
+```
+
+## Error Responses
+
+When a request fails, the API response will communicate the source of the problem through the HTTP status code.
+
+| Status Code | Description |
+|-------------|-------------|
+| `400 Bad Request` | The request was malformed or missing required parameters |
+| `401 Unauthorized` | Authentication failed or access token is invalid |
+| `403 Forbidden` | You don't have permission to perform this action |
+| `404 Not Found` | The requested resource doesn't exist or you don't have access to it |
+| `422 Unprocessable Entity` | Validation failed (see error response format above) |
+| `500 Internal Server Error` | An unexpected error occurred on the server |
+
+If a request contains invalid data for fields, such as entering a string into a number field, in most cases the API will respond with a `500 Internal Server Error`. Clients are expected to perform some validation on their end before making a request.
+
+A validation error will produce a `422 Unprocessable Entity` response, which will sometimes be accompanied by details about the error:
+
+```json
+{
+ "avatar": ["must be a JPEG, PNG, GIF, or WebP image"]
+}
+```
+
+## Pagination
+
+All endpoints that return a list of items are paginated. The page size can vary from endpoint to endpoint,
+and we use a dynamic page size where initial pages return fewer results than later pages.
+
+If there are more results to fetch, the response will include a `Link` header with a `rel="next"` link to the next page of results:
+
+```bash
+curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" -v http://fizzy.localhost:3006/686465299/cards
+# ...
+< link: ; rel="next"
+# ...
+```
+
+## List parameters
+
+When an endpoint accepts a list of values as a parameter, you can provide multiple values by repeating the parameter name:
+
+```
+?tag_ids[]=tag1&tag_ids[]=tag2&tag_ids[]=tag3
+```
+
+List parameters always end with `[]`.
+
+## File Uploads
+
+Some endpoints accept file uploads. To upload a file, send a `multipart/form-data` request instead of JSON.
+You can combine file uploads with other parameters in the same request.
+
+__Example using curl:__
+
+```bash
+curl -X PUT \
+ -H "Authorization: Bearer put-your-access-token-here" \
+ -F "user[name]=David H. Hansson" \
+ -F "user[avatar]=@/path/to/avatar.jpg" \
+ http://fizzy.localhost:3006/686465299/users/03f5v9zjw7pz8717a4no1h8a7
+```
+
+## Rich Text Fields
+
+Some fields accept rich text content. These fields accept HTML input, which will be sanitized to remove unsafe tags and attributes.
+
+```json
+{
+ "card": {
+ "title": "My card",
+ "description": "
This is bold and this is italic.
Item 1
Item 2
"
+ }
+}
+```
+
+### Attaching files to rich text
+
+To attach files (images, documents) to rich text fields, use ActionText's direct upload flow:
+
+#### 1. Create a direct upload
+
+First, request a direct upload URL by sending file metadata:
+
+```bash
+curl -X POST \
+ -H "Authorization: Bearer put-your-access-token-here" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "blob": {
+ "filename": "screenshot.png",
+ "byte_size": 12345,
+ "checksum": "GQ5SqLsM7ylnji0Wgd9wNA==",
+ "content_type": "image/png"
+ }
+ }' \
+ https://app.fizzy.do/rails/active_storage/direct_uploads
+```
+
+The `checksum` is a Base64-encoded MD5 hash of the file content.
+
+__Response:__
+
+```json
+{
+ "id": "abc123",
+ "key": "abc123def456",
+ "filename": "screenshot.png",
+ "content_type": "image/png",
+ "byte_size": 12345,
+ "checksum": "GQ5SqLsM7ylnji0Wgd9wNA==",
+ "direct_upload": {
+ "url": "https://storage.example.com/...",
+ "headers": {
+ "Content-Type": "image/png",
+ "Content-MD5": "GQ5SqLsM7ylnji0Wgd9wNA=="
+ }
+ },
+ "signed_id": "eyJfcmFpbHMi..."
+}
+```
+
+#### 2. Upload the file
+
+Upload the file directly to the provided URL with the specified headers:
+
+```bash
+curl -X PUT \
+ -H "Content-Type: image/png" \
+ -H "Content-MD5: GQ5SqLsM7ylnji0Wgd9wNA==" \
+ --data-binary @screenshot.png \
+ "https://storage.example.com/..."
+```
+
+#### 3. Reference the file in rich text
+
+Use the `signed_id` from step 1 to embed the file in your rich text using an `` tag:
+
+```json
+{
+ "card": {
+ "title": "Card with image",
+ "description": "
Here's a screenshot:
"
+ }
+}
+```
+
+The `sgid` attribute should contain the `signed_id` returned from the direct upload response.
+
+## Identity
+
+An Identity represents a person using Fizzy.
+
+### `GET /my/identity`
+
+Returns a list of accounts the identity has access to, including the user for each account.
+
+```json
+{
+ "accounts": [
+ {
+ "id": "03f5v9zjskhcii2r45ih3u1rq",
+ "name": "37signals",
+ "slug": "/897362094",
+ "created_at": "2025-12-05T19:36:35.377Z",
+ "user": {
+ "id": "03f5v9zjw7pz8717a4no1h8a7",
+ "name": "David Heinemeier Hansson",
+ "role": "owner",
+ "active": true,
+ "email_address": "david@example.com",
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "url": "http://fizzy.localhost:3006/users/03f5v9zjw7pz8717a4no1h8a7"
+ }
+ },
+ {
+ "id": "03f5v9zpko7mmhjzwum3youpp",
+ "name": "Honcho",
+ "slug": "/686465299",
+ "created_at": "2025-12-05T19:36:36.746Z",
+ "user": {
+ "id": "03f5v9zppzlksuj4mxba2nbzn",
+ "name": "David Heinemeier Hansson",
+ "role": "owner",
+ "active": true,
+ "email_address": "david@example.com",
+ "created_at": "2025-12-05T19:36:36.783Z",
+ "url": "http://fizzy.localhost:3006/users/03f5v9zppzlksuj4mxba2nbzn"
+ }
+ }
+ ]
+}
+```
+
+## Boards
+
+Boards are where you organize your work - they contain your cards.
+
+### `GET /:account_slug/boards`
+
+Returns a list of boards that you can access in the specified account.
+
+__Response:__
+
+```json
+[
+ {
+ "id": "03f5v9zkft4hj9qq0lsn9ohcm",
+ "name": "Fizzy",
+ "all_access": true,
+ "created_at": "2025-12-05T19:36:35.534Z",
+ "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
+ "creator": {
+ "id": "03f5v9zjw7pz8717a4no1h8a7",
+ "name": "David Heinemeier Hansson",
+ "role": "owner",
+ "active": true,
+ "email_address": "david@example.com",
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
+ }
+ }
+]
+```
+
+### `GET /:account_slug/boards/:board_id`
+
+Returns the specified board.
+
+__Response:__
+
+```json
+{
+ "id": "03f5v9zkft4hj9qq0lsn9ohcm",
+ "name": "Fizzy",
+ "all_access": true,
+ "created_at": "2025-12-05T19:36:35.534Z",
+ "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
+ "creator": {
+ "id": "03f5v9zjw7pz8717a4no1h8a7",
+ "name": "David Heinemeier Hansson",
+ "role": "owner",
+ "active": true,
+ "email_address": "david@example.com",
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
+ }
+}
+```
+
+### `POST /:account_slug/boards`
+
+Creates a new Board in the account.
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `name` | string | Yes | The name of the board |
+| `all_access` | boolean | No | Whether any user in the account can access this board. Defaults to `true` |
+| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed |
+| `public_description` | string | No | Rich text description shown on the public board page |
+
+__Request:__
+
+```json
+{
+ "board": {
+ "name": "My new board",
+ }
+}
+```
+
+__Response:__
+
+Returns `201 Created` with a `Location` header pointing to the new board:
+
+```
+HTTP/1.1 201 Created
+Location: /897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm.json
+```
+
+### `PUT /:account_slug/boards/:board_id`
+
+Updates a Board. Only board administrators can update a board.
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `name` | string | No | The name of the board |
+| `all_access` | boolean | No | Whether any user in the account can access this board |
+| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed |
+| `public_description` | string | No | Rich text description shown on the public board page |
+| `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) |
+
+__Request:__
+
+```json
+{
+ "board": {
+ "name": "Updated board name",
+ "auto_postpone_period": 14,
+ "public_description": "This is a **public** description of the board.",
+ "all_access": false,
+ "user_ids": [
+ "03f5v9zppzlksuj4mxba2nbzn",
+ "03f5v9zjw7pz8717a4no1h8a7"
+ ]
+ }
+}
+```
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `DELETE /:account_slug/boards/:board_id`
+
+Deletes a Board. Only board administrators can delete a board.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+## Cards
+
+Cards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments.
+
+### `GET /:account_slug/cards`
+
+Returns a paginated list of cards you have access to. Results can be filtered using query parameters.
+
+__Query Parameters:__
+
+| Parameter | Description |
+|-----------|-------------|
+| `board_ids[]` | Filter by board ID(s) |
+| `tag_ids[]` | Filter by tag ID(s) |
+| `assignee_ids[]` | Filter by assignee user ID(s) |
+| `creator_ids[]` | Filter by card creator ID(s) |
+| `closer_ids[]` | Filter by user ID(s) who closed the cards |
+| `card_ids[]` | Filter to specific card ID(s) |
+| `indexed_by` | Filter by: `all` (default), `closed`, `not_now`, `stalled`, `postponing_soon`, `golden` |
+| `sorted_by` | Sort order: `latest` (default), `newest`, `oldest` |
+| `assignment_status` | Filter by assignment status: `unassigned` |
+| `creation` | Filter by creation date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` |
+| `closure` | Filter by closure date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` |
+| `terms[]` | Search terms to filter cards |
+
+__Response:__
+
+```json
+[
+ {
+ "id": "03f5vaeq985jlvwv3arl4srq2",
+ "number": 1,
+ "title": "First!",
+ "status": "published",
+ "description": "Hello, World!",
+ "description_html": "
Hello, World!
",
+ "image_url": null,
+ "tags": ["programming"],
+ "golden": false,
+ "last_active_at": "2025-12-05T19:38:48.553Z",
+ "created_at": "2025-12-05T19:38:48.540Z",
+ "url": "http://fizzy.localhost:3006/897362094/cards/4",
+ "board": {
+ "id": "03f5v9zkft4hj9qq0lsn9ohcm",
+ "name": "Fizzy",
+ "all_access": true,
+ "created_at": "2025-12-05T19:36:35.534Z",
+ "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
+ "creator": {
+ "id": "03f5v9zjw7pz8717a4no1h8a7",
+ "name": "David Heinemeier Hansson",
+ "role": "owner",
+ "active": true,
+ "email_address": "david@example.com",
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
+ }
+ },
+ "creator": {
+ "id": "03f5v9zjw7pz8717a4no1h8a7",
+ "name": "David Heinemeier Hansson",
+ "role": "owner",
+ "active": true,
+ "email_address": "david@example.com",
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
+ },
+ "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments"
+ },
+]
+```
+
+### `GET /:account_slug/cards/:card_number`
+
+Returns a specific card by its number.
+
+__Response:__
+
+Same as the card object in the list response.
+
+### `POST /:account_slug/boards/:board_id/cards`
+
+Creates a new card in a board.
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `title` | string | Yes | The title of the card |
+| `description` | string | No | Rich text description of the card |
+| `status` | string | No | Initial status: `published` (default), `drafted` |
+| `image` | file | No | Header image for the card |
+| `tag_ids` | array | No | Array of tag IDs to apply to the card |
+| `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) |
+
+__Request:__
+
+```json
+{
+ "card": {
+ "title": "Add dark mode support",
+ "description": "We need to add dark mode to the app"
+ }
+}
+```
+
+__Response:__
+
+Returns `201 Created` with a `Location` header pointing to the new card.
+
+### `PUT /:account_slug/cards/:card_number`
+
+Updates a card.
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `title` | string | No | The title of the card |
+| `description` | string | No | Rich text description of the card |
+| `status` | string | No | Card status: `drafted`, `published` |
+| `image` | file | No | Header image for the card |
+| `tag_ids` | array | No | Array of tag IDs to apply to the card |
+
+__Request:__
+
+```json
+{
+ "card": {
+ "title": "Add dark mode support (Updated)"
+ }
+}
+```
+
+__Response:__
+
+Returns the updated card.
+
+### `DELETE /:account_slug/cards/:card_number`
+
+Deletes a card. Only the card creator or board administrators can delete cards.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `POST /:account_slug/cards/:card_number/closure`
+
+Closes a card.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `DELETE /:account_slug/cards/:card_number/closure`
+
+Reopens a closed card.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `POST /:account_slug/cards/:card_number/not_now`
+
+Moves a card to "Not Now" status.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `POST /:account_slug/cards/:card_number/triage`
+
+Moves a card from triage into a column.
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `column_id` | string | Yes | The ID of the column to move the card into |
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `DELETE /:account_slug/cards/:card_number/triage`
+
+Sends a card back to triage.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `POST /:account_slug/cards/:card_number/taggings`
+
+Toggles a tag on or off for a card. If the tag doesn't exist, it will be created.
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `tag_title` | string | Yes | The title of the tag (leading `#` is stripped) |
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `POST /:account_slug/cards/:card_number/assignments`
+
+Toggles assignment of a user to/from a card.
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `assignee_id` | string | Yes | The ID of the user to assign/unassign |
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `POST /:account_slug/cards/:card_number/watch`
+
+Subscribes the current user to notifications for this card.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `DELETE /:account_slug/cards/:card_number/watch`
+
+Unsubscribes the current user from notifications for this card.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+## Comments
+
+Comments are attached to cards and support rich text.
+
+### `GET /:account_slug/cards/:card_number/comments`
+
+Returns a paginated list of comments on a card, sorted chronologically (oldest first).
+
+__Response:__
+
+```json
+[
+ {
+ "id": "03f5v9zo9qlcwwpyc0ascnikz",
+ "created_at": "2025-12-05T19:36:35.534Z",
+ "updated_at": "2025-12-05T19:36:35.534Z",
+ "body": {
+ "plain_text": "This looks great!",
+ "html": "
")
+
+ # trailing entities that decode to punctuation
+ # use assert_equal and not assert_equal_html to make sure we're getting entities correct
+ assert_equal \
+ %(