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.lock b/Gemfile.lock
index ea4794b5a..d99375463 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
- revision: bf81d40a91880c059ce9d0447219385a2c8e4a15
+ revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
branch: ast-immediate-variants-process-locally
specs:
actioncable (8.2.0.alpha)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index aabe04258..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
@@ -82,7 +82,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
- revision: bf81d40a91880c059ce9d0447219385a2c8e4a15
+ revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
branch: ast-immediate-variants-process-locally
specs:
actioncable (8.2.0.alpha)
diff --git a/README.md b/README.md
index b1739b5e8..5ea839008 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,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_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 bedfdc630..761efd2fa 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1,5 +1,5 @@
class Account < ApplicationRecord
- include Entropic, Seedeable, MultiTenant
+ include Account::Storage, Entropic, MultiTenant, Seedeable
has_one :join_code
has_many :users, dependent: :destroy
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/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/environments/production.rb b/config/environments/production.rb
index 3c676693f..6398d14d0 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -42,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"
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 74461c795..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
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 \
+ %(