diff --git a/.dockerignore b/.dockerignore
index 96123753a3..df5bbdacc5 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 f4f7556149..dbb544dc1d 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 f3b9923875..d097b95909 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 4970ef1824..4286f90ae4 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 e62118357a..beee16f15b 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 6c6bb399ae..4a19914966 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 0d7b9b49f5..7a3cd9392e 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 ea4794b5a9..d993754635 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 aabe042588..91c11943fb 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 27440196e8..5ea8390086 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,8 @@ We've added comments to that file to highlight what each setting needs to be, bu
- `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.
+- `env/clear/MULTI_TENANT`: Set to `false` to enable single-tenant mode (disable multi-account signups).
+
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`.
@@ -93,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 0000000000..a0f54f91c9
--- /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 cc79ca76a7..f89028f02c 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 8d9b2e5c1b..b501893661 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 5b73e12660..0f487e1f9a 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 fba2ccfe99..217ee603ca 100644
--- a/app/controllers/account/join_codes_controller.rb
+++ b/app/controllers/account/join_codes_controller.rb
@@ -9,8 +9,11 @@ def edit
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 fa4f3e0724..9e14eb937e 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 @@ def show
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 ff7c35ec02..6c8a17575b 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 @@ def new
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 @@ def update
@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 886537fa1d..396fd3a5fb 100644
--- a/app/controllers/cards/assignments_controller.rb
+++ b/app/controllers/cards/assignments_controller.rb
@@ -9,5 +9,10 @@ def new
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 4a8e503abf..3444cd3452 100644
--- a/app/controllers/cards/boards_controller.rb
+++ b/app/controllers/cards/boards_controller.rb
@@ -11,7 +11,11 @@ def edit
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 b23600d911..c4aac26d19 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 c65d34291f..0f1277a3c5 100644
--- a/app/controllers/cards/comments/reactions_controller.rb
+++ b/app/controllers/cards/comments/reactions_controller.rb
@@ -13,10 +13,20 @@ def new
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 b7c6c867a2..f512b6235a 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 @@ def edit
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
diff --git a/app/controllers/cards/goldnesses_controller.rb b/app/controllers/cards/goldnesses_controller.rb
index 1a0912a241..b2b3e619d6 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 fad419c522..f4fec16d5b 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 8f43db1be8..8eeb0e663b 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 305890508a..0b788fe36c 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 @@ def edit
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 9190b20f13..2385b43267 100644
--- a/app/controllers/cards/taggings_controller.rb
+++ b/app/controllers/cards/taggings_controller.rb
@@ -9,6 +9,11 @@ def new
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 0c1a7bd1e7..d8fc548f15 100644
--- a/app/controllers/cards/triages_controller.rb
+++ b/app/controllers/cards/triages_controller.rb
@@ -5,11 +5,18 @@ def create
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 4d83567d01..9d314dedf3 100644
--- a/app/controllers/cards/watches_controller.rb
+++ b/app/controllers/cards/watches_controller.rb
@@ -7,9 +7,19 @@ def show
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 cb40079e8c..a3098b4b0c 100644
--- a/app/controllers/cards_controller.rb
+++ b/app/controllers/cards_controller.rb
@@ -10,8 +10,18 @@ def index
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)
+ card.publish
+ head :created, location: card_path(card, format: :json)
+ end
+ end
end
def show
@@ -22,11 +32,20 @@ def edit
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
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index 4cdbbdb691..30f54820bf 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 @@ def disallow_account_scope(**options)
private
def authenticated?
- Current.session.present?
+ Current.identity.present?
end
def require_account
@@ -42,7 +42,7 @@ def require_account
end
def require_authentication
- resume_session || request_authentication
+ resume_session || authenticate_by_bearer_token || request_authentication
end
def resume_session
@@ -55,6 +55,16 @@ def find_session_by_cookie
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 8ede51fc78..95fa128923 100644
--- a/app/controllers/concerns/request_forgery_protection.rb
+++ b/app/controllers/concerns/request_forgery_protection.rb
@@ -12,7 +12,7 @@ def append_sec_fetch_site_to_vary_header
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 0000000000..99c87893f7
--- /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 0000000000..7a388a3c33
--- /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 5f80938feb..2c15a871b1 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 2accc33731..622668efc6 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 76bd629749..b116ed1a21 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/sessions_controller.rb b/app/controllers/sessions_controller.rb
index fb9fe0617e..e32cf38296 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -14,7 +14,8 @@ def create
else
signup = Signup.new(email_address: email_address)
if signup.valid?(:identity_creation)
- redirect_to_session_magic_link signup.create_identity
+ identity = signup.create_identity if Account.accepting_signups?
+ redirect_to_session_magic_link identity
else
head :unprocessable_entity
end
diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb
index 776421201d..b9e12d7592 100644
--- a/app/controllers/signups_controller.rb
+++ b/app/controllers/signups_controller.rb
@@ -3,6 +3,7 @@ class SignupsController < ApplicationController
allow_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." }
before_action :redirect_authenticated_user
+ before_action :prevent_signup_when_users_exist
layout "public"
@@ -27,4 +28,8 @@ def redirect_authenticated_user
def signup_params
params.expect signup: :email_address
end
+
+ def prevent_signup_when_users_exist
+ redirect_to new_session_url unless Account.accepting_signups?
+ end
end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
new file mode 100644
index 0000000000..6b72fdf50a
--- /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 15f8ecd6fc..74deb686b7 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 @@ def edit
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 8998ffcb9e..218d58e789 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 @@ def auto_link(fragment)
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 @@ def auto_link_urls(linked_content)
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 0000000000..b2e4949773
--- /dev/null
+++ b/app/jobs/storage/materialize_job.rb
@@ -0,0 +1,8 @@
+class Storage::MaterializeJob < ApplicationJob
+ queue_as :backend
+ limits_concurrency to: 1, key: ->(owner) { owner }
+
+ 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 0000000000..dde6dcb2e9
--- /dev/null
+++ b/app/jobs/storage/reconcile_job.rb
@@ -0,0 +1,7 @@
+class Storage::ReconcileJob < ApplicationJob
+ queue_as :backend
+
+ def perform(owner)
+ owner.reconcile_storage
+ end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 0bb4d41113..10fe527191 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, MultiTenant
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 82283e4f6a..79160cbaa6 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 0000000000..8b432e8ce6
--- /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 9f1bcd5d71..ada49f1f2e 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 0000000000..64dde9985a
--- /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 dfad00cb89..33c96e502d 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 0e76700b39..5c7defc29a 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 @@ def comment
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/comment.rb b/app/models/comment.rb
index f81c465adb..a396a904f1 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/multi_tenant.rb b/app/models/concerns/multi_tenant.rb
new file mode 100644
index 0000000000..07d98025d1
--- /dev/null
+++ b/app/models/concerns/multi_tenant.rb
@@ -0,0 +1,9 @@
+module MultiTenant
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def accepting_signups?
+ ENV.fetch("MULTI_TENANT", "true") == "true" || Account.none?
+ end
+ end
+end
diff --git a/app/models/concerns/storage/totaled.rb b/app/models/concerns/storage/totaled.rb
new file mode 100644
index 0000000000..3f220f55fb
--- /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 0000000000..7b96e95c08
--- /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 47f2b6c212..94ef9688c0 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 bb69734b4e..7495e37c3f 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 0000000000..abdf37eba8
--- /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 afd4173674..1248e90918 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 0000000000..9c17b7cd9a
--- /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 0000000000..d68f5845d4
--- /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 0000000000..a2f6e726b9
--- /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 0000000000..7e1651d79e
--- /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 bca92e58fa..8a65adca79 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 ce2ef04515..fd4e891d72 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 0000000000..4142996de1
--- /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 0000000000..f94e6584b4
--- /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 7cfc10ca64..c0f60cd77f 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 0000000000..be08e5031a
--- /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 %>
+
Then enter the verification code included in the email below:
+
+ <% unless Account.accepting_signups? %>
+ If your email belongs to an existing account.
+ <% end %>
+ Then enter the verification code included in the email below:
+