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? %>
-
- <%= icon_tag "lock" %> -
+ + <% end %>
<%= text_field_tag :publication_url, published_board_url(board), readonly: true, class: "full-width input fill-white" %> @@ -40,17 +40,17 @@
<% else %>
-
- <%= icon_tag "lock" %> -
+ <% end %>
<% end %> <% end %> diff --git a/app/views/boards/index.json.jbuilder b/app/views/boards/index.json.jbuilder new file mode 100644 index 0000000000..047401cff0 --- /dev/null +++ b/app/views/boards/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "boards/board", as: :board diff --git a/app/views/boards/show.json.jbuilder b/app/views/boards/show.json.jbuilder new file mode 100644 index 0000000000..a6916c4673 --- /dev/null +++ b/app/views/boards/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "boards/board", board: @board diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index 1fa339328d..a1c509a66a 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -1,26 +1,20 @@ -json.cache! [ card, card.column&.color ] do - json.(card, :id, :title, :status) +json.cache! card do + json.(card, :id, :number, :title, :status) + json.description card.description.to_plain_text + json.description_html card.description.to_s json.image_url card.image.presence && url_for(card.image) + json.tags card.tags.pluck(:title).sort + json.golden card.golden? json.last_active_at card.last_active_at.utc json.created_at card.created_at.utc json.url card_url(card) - json.board do - json.partial! "boards/board", locals: { board: card.board } - end - - json.column do - if card.column - json.partial! "columns/column", column: card.column - else - nil - end - end + json.board card.board, partial: "boards/board", as: :board + json.column card.column, partial: "columns/column", as: :column if card.column + json.creator card.creator, partial: "users/user", as: :user - json.creator do - json.partial! "users/user", user: card.creator - end + json.comments_url card_comments_url(card) end diff --git a/app/views/cards/comments/_comment.json.jbuilder b/app/views/cards/comments/_comment.json.jbuilder index a66ae1ef9d..3eb9855ed5 100644 --- a/app/views/cards/comments/_comment.json.jbuilder +++ b/app/views/cards/comments/_comment.json.jbuilder @@ -9,9 +9,7 @@ json.cache! comment do json.html comment.body.to_s end - json.creator do - json.partial! "users/user", user: comment.creator - end + json.creator comment.creator, partial: "users/user", as: :user json.reactions_url card_comment_reactions_url(comment.card_id, comment.id) json.url card_comment_url(comment.card_id, comment.id) diff --git a/app/views/cards/comments/index.json.jbuilder b/app/views/cards/comments/index.json.jbuilder new file mode 100644 index 0000000000..7e261ccc7f --- /dev/null +++ b/app/views/cards/comments/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "cards/comments/comment", as: :comment diff --git a/app/views/cards/comments/reactions/_reaction.json.jbuilder b/app/views/cards/comments/reactions/_reaction.json.jbuilder new file mode 100644 index 0000000000..17ad70f707 --- /dev/null +++ b/app/views/cards/comments/reactions/_reaction.json.jbuilder @@ -0,0 +1,5 @@ +json.cache! reaction do + json.(reaction, :id, :content) + json.reacter reaction.reacter, partial: "users/user", as: :user + json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction) +end diff --git a/app/views/cards/comments/reactions/index.json.jbuilder b/app/views/cards/comments/reactions/index.json.jbuilder new file mode 100644 index 0000000000..5dd744f12b --- /dev/null +++ b/app/views/cards/comments/reactions/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @comment.reactions.ordered, partial: "cards/comments/reactions/reaction", as: :reaction diff --git a/app/views/cards/comments/show.json.jbuilder b/app/views/cards/comments/show.json.jbuilder new file mode 100644 index 0000000000..52ef29c2be --- /dev/null +++ b/app/views/cards/comments/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "cards/comments/comment", comment: @comment diff --git a/app/views/cards/index.json.jbuilder b/app/views/cards/index.json.jbuilder new file mode 100644 index 0000000000..c1dc1dff15 --- /dev/null +++ b/app/views/cards/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "cards/card", as: :card diff --git a/app/views/cards/show.json.jbuilder b/app/views/cards/show.json.jbuilder new file mode 100644 index 0000000000..39feca7754 --- /dev/null +++ b/app/views/cards/show.json.jbuilder @@ -0,0 +1,2 @@ +json.partial! "cards/card", card: @card +json.steps @card.steps, partial: "cards/steps/step", as: :step diff --git a/app/views/cards/steps/_step.json.jbuilder b/app/views/cards/steps/_step.json.jbuilder new file mode 100644 index 0000000000..ac0d5a1f8d --- /dev/null +++ b/app/views/cards/steps/_step.json.jbuilder @@ -0,0 +1,3 @@ +json.cache! step do + json.(step, :id, :content, :completed) +end diff --git a/app/views/cards/steps/show.json.jbuilder b/app/views/cards/steps/show.json.jbuilder new file mode 100644 index 0000000000..1190f84e17 --- /dev/null +++ b/app/views/cards/steps/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "cards/steps/step", step: @step diff --git a/app/views/columns/_column.json.jbuilder b/app/views/columns/_column.json.jbuilder index 355a626064..5bb56bdd6f 100644 --- a/app/views/columns/_column.json.jbuilder +++ b/app/views/columns/_column.json.jbuilder @@ -1,2 +1,4 @@ -json.(column, :id, :name, :color) -json.created_at column.created_at.utc +json.cache! column do + json.(column, :id, :name, :color) + json.created_at column.created_at.utc +end diff --git a/app/views/my/access_tokens/_access_token.html.erb b/app/views/my/access_tokens/_access_token.html.erb new file mode 100644 index 0000000000..cd33831bb3 --- /dev/null +++ b/app/views/my/access_tokens/_access_token.html.erb @@ -0,0 +1,13 @@ + + <%= access_token.description %> + <%= access_token.permission.humanize %> + <%= local_datetime_tag access_token.created_at, style: :datetime %> + + <%= button_to my_access_token_path(access_token), method: :delete, + class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent", + data: { turbo_confirm: "Are you sure you want to permanently revoke this access token?" } do %> + <%= icon_tag "trash" %> + Edit this token + <% end %> + + diff --git a/app/views/my/access_tokens/index.html.erb b/app/views/my/access_tokens/index.html.erb new file mode 100644 index 0000000000..cc9b83ede5 --- /dev/null +++ b/app/views/my/access_tokens/index.html.erb @@ -0,0 +1,35 @@ +<% @page_title = "Personal access tokens" %> + +<% content_for :header do %> +
+ <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <% if @access_tokens.any? %> +

Tokens you have generated that can be used to access the Fizzy API.

+ + + + + + + + + + + <%= render partial: "my/access_tokens/access_token", collection: @access_tokens %> + +
DescriptionPermissionCreated
+ <% else %> +

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 %> +
+ <%= back_link_to "tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <%= form_with model: @access_token, url: my_access_tokens_path, scope: :access_token, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %> +
+ <%= form.label :description, "Access token description" %> + <%= form.text_field :description, required: true, autofocus: true, class: "input", placeholder: "e.g. Github", data: { action: "keydown.esc@document->form#cancel" } %> +
+ +
+ <%= form.label :permission %> + <%= form.select :permission, options_for_select({ "Read" => "read", "Read + Write" => "write"}), {}, class: "input input--select" %> +
+ + <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> + Generate access token + <% end %> + + <%= link_to "Cancel and go back", my_access_tokens_path, data: { form_target: "cancel" }, hidden: true %> + <% end %> +
diff --git a/app/views/my/access_tokens/show.html.erb b/app/views/my/access_tokens/show.html.erb new file mode 100644 index 0000000000..16becf7c21 --- /dev/null +++ b/app/views/my/access_tokens/show.html.erb @@ -0,0 +1,26 @@ +<% @page_title = "New personal access token" %> + +<% content_for :header do %> +
+ <%= back_link_to "Tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+
+ +

Be sure to save this access token now because you won’t be able to see it again.

+ + <%= tag.button class: "btn btn--link center", data: { + controller: "copy-to-clipboard", action: "copy-to-clipboard#copy", + copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: @access_token.token } do %> + <%= icon_tag "copy-paste" %> + Copy access token + <% end %> +
+
diff --git a/app/views/my/identities/_account.json.jbuilder b/app/views/my/identities/_account.json.jbuilder new file mode 100644 index 0000000000..4c2076f0c4 --- /dev/null +++ b/app/views/my/identities/_account.json.jbuilder @@ -0,0 +1,4 @@ +json.cache! account do + json.(account, :id, :name, :slug) + json.created_at account.created_at.utc +end diff --git a/app/views/my/identities/show.json.jbuilder b/app/views/my/identities/show.json.jbuilder new file mode 100644 index 0000000000..a36569e416 --- /dev/null +++ b/app/views/my/identities/show.json.jbuilder @@ -0,0 +1,4 @@ +json.accounts @identity.users do |user| + json.partial! "my/identities/account", account: user.account + json.user user, partial: "users/user", as: :user +end diff --git a/app/views/my/menus/_jump.html.erb b/app/views/my/menus/_jump.html.erb index e35d031053..1f1ea70c9b 100644 --- a/app/views/my/menus/_jump.html.erb +++ b/app/views/my/menus/_jump.html.erb @@ -25,4 +25,8 @@
<%= yield %> + + diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder new file mode 100644 index 0000000000..ba27c5425e --- /dev/null +++ b/app/views/notifications/_notification.json.jbuilder @@ -0,0 +1,17 @@ +json.cache! notification do + json.(notification, :id) + json.read notification.read? + json.read_at notification.read_at&.utc + json.created_at notification.created_at.utc + + json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification + + json.creator notification.creator, partial: "users/user", as: :user + + json.card do + json.(notification.card, :id, :title, :status) + json.url card_url(notification.card) + end + + json.url notification_url(notification) +end diff --git a/app/views/notifications/index.json.jbuilder b/app/views/notifications/index.json.jbuilder new file mode 100644 index 0000000000..660bbb6736 --- /dev/null +++ b/app/views/notifications/index.json.jbuilder @@ -0,0 +1 @@ +json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification diff --git a/app/views/notifications/notification/event/_body.json.jbuilder b/app/views/notifications/notification/event/_body.json.jbuilder new file mode 100644 index 0000000000..7ea7510e50 --- /dev/null +++ b/app/views/notifications/notification/event/_body.json.jbuilder @@ -0,0 +1,2 @@ +json.title event_notification_title(notification.source) +json.body event_notification_body(notification.source) diff --git a/app/views/notifications/notification/mention/_body.json.jbuilder b/app/views/notifications/notification/mention/_body.json.jbuilder new file mode 100644 index 0000000000..970088149d --- /dev/null +++ b/app/views/notifications/notification/mention/_body.json.jbuilder @@ -0,0 +1,4 @@ +mention = notification.source + +json.title "#{mention.mentioner.first_name} @mentioned you" +json.body mention.source.mentionable_content.truncate(200) diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index 4e94f411ef..e4c4398033 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -3,7 +3,12 @@
">

<%= @page_title %>

-

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: +

<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index fc89bf6818..4e82c0b703 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -10,7 +10,11 @@
-

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

+ <% if Account.accepting_signups? %> +

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

+ <% else %> +

Enter your email and we’ll get you signed in.

+ <% end %>