diff --git a/.dockerignore b/.dockerignore index 96123753a..df5bbdacc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,14 @@ # Ignore bundler config. /.bundle +# Ignore documentation +/docs/ +/README.md +/CLAUDE.md +/AGENTS.md +/STYLE.md +/CONTRIBUTING.md + # Ignore all environment files (except templates). /.env* !/.env*.erb diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f4f755614..dbb544dc1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,4 +30,3 @@ updates: open-pull-requests-limit: 10 cooldown: default-days: 7 - semver-major-days: 14 diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index f3b992387..d097b9590 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: .ruby-version @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: .ruby-version diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 4970ef182..4286f90ae 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -39,13 +39,13 @@ jobs: IMAGE_NAME: ${{ github.repository }} steps: - name: Checkout - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.11.1 - name: Log in to GHCR - uses: docker/login-action@v3.5.0 + uses: docker/login-action@v3.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -62,7 +62,7 @@ jobs: - name: Extract Docker metadata (tags, labels) with arch suffix id: meta - uses: docker/metadata-action@v5.8.0 + uses: docker/metadata-action@v5.10.0 with: images: ${{ steps.vars.outputs.canonical }} tags: | @@ -118,7 +118,7 @@ jobs: uses: docker/setup-buildx-action@v3.11.1 - name: Log in to GHCR - uses: docker/login-action@v3.5.0 + uses: docker/login-action@v3.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -135,7 +135,7 @@ jobs: - name: Compute base tags (no suffix) id: meta - uses: docker/metadata-action@v5.8.0 + uses: docker/metadata-action@v5.10.0 with: images: ${{ steps.vars.outputs.canonical }} tags: | @@ -179,7 +179,7 @@ jobs: done <<< "$tags" - name: Install Cosign - uses: sigstore/cosign-installer@v3.9.2 + uses: sigstore/cosign-installer@v4.0.0 - name: Cosign sign all tags (keyless OIDC) shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e62118357..beee16f15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,7 +56,7 @@ jobs: - name: Install system packages run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: diff --git a/.gitleaks.toml b/.gitleaks.toml index 6c6bb399a..4a1991496 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -6,4 +6,6 @@ paths = [ '''log''', '''tmp''', '''.*\.yml\.enc''', + '''docs/''', + '''test/''', ] diff --git a/.gitleaksignore b/.gitleaksignore index 0d7b9b49f..7a3cd9392 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,9 +1,3 @@ d8463077:gems/fizzy-saas/bin/setup:generic-api-key:54 c4073c1c:app/models/integration/basecamp.rb:generic-api-key:3 c4073c1c:app/models/integration/basecamp.rb:generic-api-key:4 -02a42167:test/models/webhook_test.rb:slack-webhook-url:57 -2fc9215b:test/models/webhook/delivery_test.rb:slack-webhook-url:156 -2fc9215b:test/models/webhook_test.rb:slack-webhook-url:57 -a515ea3b:test/fixtures/webhooks.yml:generic-api-key:5 -a515ea3b:test/fixtures/webhooks.yml:generic-api-key:11 -1f21c12c:test/vcr_cassettes/command/ai/translator_test-test_combine_commands_and_filters.yml:github-oauth:73012 diff --git a/Gemfile b/Gemfile index 7f7a463bb..e8a5f8514 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,9 @@ source "https://rubygems.org" -gem "rails", github: "rails/rails", branch: "main" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } +gem "rails", github: "rails/rails", branch: "ast-immediate-variants-process-locally" + # Assets & front end gem "importmap-rails" gem "propshaft" @@ -27,7 +28,7 @@ gem "rqrcode" gem "redcarpet" gem "rouge" gem "jbuilder" -gem "lexxy" +gem "lexxy", bc: "lexxy" gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false diff --git a/Gemfile.lock b/Gemfile.lock index e4f364c1c..d99375463 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,10 @@ +GIT + remote: https://github.com/basecamp/lexxy + revision: d292280b6d2c5d8a924327adf8bb9f0b25953539 + specs: + lexxy (0.1.23.beta) + rails (>= 8.0.2) + GIT remote: https://github.com/basecamp/useragent revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e @@ -6,8 +13,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0 - branch: main + revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 + branch: ast-immediate-variants-process-locally specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -237,8 +244,6 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - lexxy (0.1.23.beta) - rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) loofah (2.24.1) @@ -518,7 +523,7 @@ DEPENDENCIES jbuilder kamal letter_opener - lexxy + lexxy! mission_control-jobs mittens mocha diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f093ac677..b5768acbe 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -41,6 +41,13 @@ GIT yabeda-puma-plugin yabeda-rails (>= 0.10) +GIT + remote: https://github.com/basecamp/lexxy + revision: d292280b6d2c5d8a924327adf8bb9f0b25953539 + specs: + lexxy (0.1.23.beta) + rails (>= 8.0.2) + GIT remote: https://github.com/basecamp/queenbee-plugin revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2 @@ -75,8 +82,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0 - branch: main + revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 + branch: ast-immediate-variants-process-locally specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -313,8 +320,6 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - lexxy (0.1.23.beta) - rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) loofah (2.24.1) @@ -653,7 +658,7 @@ DEPENDENCIES jbuilder kamal letter_opener - lexxy + lexxy! mission_control-jobs mittens mocha diff --git a/README.md b/README.md index 07f55614e..27ba149fc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This repo contains a starter deployment file that you can modify for your own sp The steps to configure your very own Fizzy are: 1. Fork the repo -2. Edit few things in config/deploy.yml, .kamal/secrets, and config/environments/production.rb +2. Edit few things in config/deploy.yml and .kamal/secrets 3. Run `kamal setup` to do your first deploy. We'll go through each of these in turn. @@ -37,6 +37,7 @@ We've added comments to that file to highlight what each setting needs to be, bu - `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here. - `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off. - `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy. +- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this. Fizzy also requires a few environment variables to be set up, some of which contain secrets. The simplest way to do this is to put them in a file called `.kamal/secrets`. @@ -58,6 +59,7 @@ SMTP_PASSWORD=email-provider-password The values you enter here will be specific to you, and you can get or create them as follows: - `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this. +- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use. - `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with: ```sh @@ -73,10 +75,6 @@ The values you enter here will be specific to you, and you can get or create the puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" ``` -- `SMTP_USERNAME`/`SMTP_PASSWORD` are credentials you should get from your email provider. - -Lastly, you'll need to set up the rest of your email configuration in `config/environments/production.rb`. There is an example configuration in comments at the top of that file. The actual settings you use here will depend on your email provider, but in most cases will look similar to that section, so you can uncomment it and edit to suit. Note that it will use the `SMTP_USERNAME` and `SMTP_PASSWORD` values you entered in your secrets. - Once you've made all those changes, commit them to your fork so they're saved. ### Deploy Fizzy! @@ -95,6 +93,25 @@ After the first deploy is done, any subsequent steps won't need to do that initi bin/kamal deploy ``` +## File storage (Active Storage) + +Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`. + +To use the included `s3` service, set: + +- `ACTIVE_STORAGE_SERVICE=s3` +- `S3_ACCESS_KEY_ID` +- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`) +- `S3_REGION` (defaults to `us-east-1`) +- `S3_SECRET_ACCESS_KEY` + +Optional for S3-compatible endpoints: + +- `S3_ENDPOINT` +- `S3_FORCE_PATH_STYLE=true` +- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`) +- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`) + ## Development ### Setting up diff --git a/app/assets/stylesheets/access-tokens.css b/app/assets/stylesheets/access-tokens.css new file mode 100644 index 000000000..a0f54f91c --- /dev/null +++ b/app/assets/stylesheets/access-tokens.css @@ -0,0 +1,19 @@ +.access_tokens_table { + border-collapse: collapse; + inline-size: 100%; + + td, th { + border-block-end: 1px solid var(--color-ink-light); + padding-inline: var(--inline-space); + text-align: start; + } + + th { + font-size: var(--text-x-small); + text-transform: uppercase; + } + + tr:nth-of-type(even) { + background-color: var(--color-ink-lightest); + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 82d5626f6..c90d5f6b9 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -304,6 +304,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 d7a12ed68..bd56c45e3 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -225,6 +225,15 @@ } } + .nav__blank-slate { + font-size: var(--text-small); + margin: 1rem auto; + + .nav:has(.popup__item:not([hidden])) & { + display: none; + } + } + .nav__footer { background-color: var(--color-canvas); border-block-start: 1px solid var(--color-ink-lighter); diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 5b73e1266..0f487e1f9 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -116,6 +116,8 @@ pointer-events: none; } + .cursor-pointer { cursor: pointer; } + /* Padding */ .pad { padding: var(--block-space) var(--inline-space); } .pad-double { padding: var(--block-space-double) var(--inline-space-double); } diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb index fba2ccfe9..217ee603c 100644 --- a/app/controllers/account/join_codes_controller.rb +++ b/app/controllers/account/join_codes_controller.rb @@ -9,8 +9,11 @@ class Account::JoinCodesController < ApplicationController end def update - @join_code.update!(join_code_params) - redirect_to account_join_code_path + if @join_code.update(join_code_params) + redirect_to account_join_code_path + else + render :edit, status: :unprocessable_entity + end end def destroy diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index fa4f3e072..9e14eb937 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -3,6 +3,11 @@ class Boards::ColumnsController < ApplicationController before_action :set_column, only: %i[ show update destroy ] + def index + @columns = @board.columns.sorted + fresh_when etag: @columns + end + def show set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded fresh_when etag: @page.records @@ -10,14 +15,29 @@ class Boards::ColumnsController < ApplicationController def create @column = @board.columns.create!(column_params) + + respond_to do |format| + format.turbo_stream + format.json { head :created, location: board_column_path(@board, @column, format: :json) } + end end def update @column.update!(column_params) + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end def destroy @column.destroy + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index ff7c35ec0..6c8a17575 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -1,9 +1,13 @@ class BoardsController < ApplicationController include FilterScoped - before_action :set_board, except: %i[ new create ] + before_action :set_board, except: %i[ index new create ] before_action :ensure_permission_to_admin_board, only: %i[ update destroy ] + def index + set_page_and_extract_portion_from Current.user.boards + end + def show if @filter.used?(ignore_boards: true) show_filtered_cards @@ -18,7 +22,11 @@ class BoardsController < ApplicationController def create @board = Board.create! board_params.with_defaults(all_access: true) - redirect_to board_path(@board) + + respond_to do |format| + format.html { redirect_to board_path(@board) } + format.json { head :created, location: board_path(@board, format: :json) } + end end def edit @@ -31,16 +39,25 @@ class BoardsController < ApplicationController @board.update! board_params @board.accesses.revise granted: grantees, revoked: revokees if grantees_changed? - if @board.accessible_to?(Current.user) - redirect_to edit_board_path(@board), notice: "Saved" - else - redirect_to root_path, notice: "Saved (you were removed from the board)" + respond_to do |format| + format.html do + if @board.accessible_to?(Current.user) + redirect_to edit_board_path(@board), notice: "Saved" + else + redirect_to root_path, notice: "Saved (you were removed from the board)" + end + end + format.json { head :no_content } end end def destroy @board.destroy - redirect_to root_path + + respond_to do |format| + format.html { redirect_to root_path } + format.json { head :no_content } + end end private diff --git a/app/controllers/cards/assignments_controller.rb b/app/controllers/cards/assignments_controller.rb index 886537fa1..396fd3a5f 100644 --- a/app/controllers/cards/assignments_controller.rb +++ b/app/controllers/cards/assignments_controller.rb @@ -9,5 +9,10 @@ class Cards::AssignmentsController < ApplicationController def create @card.toggle_assignment @board.users.active.find(params[:assignee_id]) + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/cards/boards_controller.rb b/app/controllers/cards/boards_controller.rb index 4a8e503ab..3444cd345 100644 --- a/app/controllers/cards/boards_controller.rb +++ b/app/controllers/cards/boards_controller.rb @@ -11,7 +11,11 @@ class Cards::BoardsController < ApplicationController def update @card.move_to(@board) - redirect_to @card + + respond_to do |format| + format.html { redirect_to @card } + format.json { head :no_content } + end end private diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb index b23600d91..c4aac26d1 100644 --- a/app/controllers/cards/closures_controller.rb +++ b/app/controllers/cards/closures_controller.rb @@ -3,11 +3,19 @@ class Cards::ClosuresController < ApplicationController def create @card.close - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end def destroy @card.reopen - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end end diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb index c65d34291..0f1277a3c 100644 --- a/app/controllers/cards/comments/reactions_controller.rb +++ b/app/controllers/cards/comments/reactions_controller.rb @@ -13,10 +13,20 @@ class Cards::Comments::ReactionsController < ApplicationController def create @reaction = @comment.reactions.create!(params.expect(reaction: :content)) + + respond_to do |format| + format.turbo_stream + format.json { head :created } + end end def destroy @reaction.destroy + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb index b7c6c867a..f512b6235 100644 --- a/app/controllers/cards/comments_controller.rb +++ b/app/controllers/cards/comments_controller.rb @@ -4,8 +4,17 @@ class Cards::CommentsController < ApplicationController before_action :set_comment, only: %i[ show edit update destroy ] before_action :ensure_creatorship, only: %i[ edit update destroy ] + def index + set_page_and_extract_portion_from @card.comments.chronologically + end + def create @comment = @card.comments.create!(comment_params) + + respond_to do |format| + format.turbo_stream + format.json { head :created, location: card_comment_path(@card, @comment, format: :json) } + end end def show @@ -16,10 +25,20 @@ class Cards::CommentsController < ApplicationController def update @comment.update! comment_params + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end def destroy @comment.destroy + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/controllers/cards/goldnesses_controller.rb b/app/controllers/cards/goldnesses_controller.rb index 1a0912a24..b2b3e619d 100644 --- a/app/controllers/cards/goldnesses_controller.rb +++ b/app/controllers/cards/goldnesses_controller.rb @@ -3,11 +3,19 @@ class Cards::GoldnessesController < ApplicationController def create @card.gild - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end def destroy @card.ungild - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end end diff --git a/app/controllers/cards/images_controller.rb b/app/controllers/cards/images_controller.rb index fad419c52..f4fec16d5 100644 --- a/app/controllers/cards/images_controller.rb +++ b/app/controllers/cards/images_controller.rb @@ -3,6 +3,10 @@ class Cards::ImagesController < ApplicationController def destroy @card.image.purge_later - redirect_to @card + + respond_to do |format| + format.html { redirect_to @card } + format.json { head :no_content } + end end end diff --git a/app/controllers/cards/not_nows_controller.rb b/app/controllers/cards/not_nows_controller.rb index 8f43db1be..8eeb0e663 100644 --- a/app/controllers/cards/not_nows_controller.rb +++ b/app/controllers/cards/not_nows_controller.rb @@ -3,6 +3,10 @@ class Cards::NotNowsController < ApplicationController def create @card.postpone - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end end diff --git a/app/controllers/cards/steps_controller.rb b/app/controllers/cards/steps_controller.rb index 305890508..0b788fe36 100644 --- a/app/controllers/cards/steps_controller.rb +++ b/app/controllers/cards/steps_controller.rb @@ -5,6 +5,11 @@ class Cards::StepsController < ApplicationController def create @step = @card.steps.create!(step_params) + + respond_to do |format| + format.turbo_stream + format.json { head :created, location: card_step_path(@card, @step, format: :json) } + end end def show @@ -15,10 +20,20 @@ class Cards::StepsController < ApplicationController def update @step.update!(step_params) + + respond_to do |format| + format.turbo_stream + format.json { render :show } + end end def destroy @step.destroy! + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/controllers/cards/taggings_controller.rb b/app/controllers/cards/taggings_controller.rb index 9190b20f1..2385b4326 100644 --- a/app/controllers/cards/taggings_controller.rb +++ b/app/controllers/cards/taggings_controller.rb @@ -9,6 +9,11 @@ class Cards::TaggingsController < ApplicationController def create @card.toggle_tag_with sanitized_tag_title_param + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/controllers/cards/triages_controller.rb b/app/controllers/cards/triages_controller.rb index 0c1a7bd1e..d8fc548f1 100644 --- a/app/controllers/cards/triages_controller.rb +++ b/app/controllers/cards/triages_controller.rb @@ -5,11 +5,18 @@ class Cards::TriagesController < ApplicationController column = @card.board.columns.find(params[:column_id]) @card.triage_into(column) - redirect_to @card + respond_to do |format| + format.html { redirect_to @card } + format.json { head :no_content } + end end def destroy @card.send_back_to_triage - redirect_to @card + + respond_to do |format| + format.html { redirect_to @card } + format.json { head :no_content } + end end end diff --git a/app/controllers/cards/watches_controller.rb b/app/controllers/cards/watches_controller.rb index 4d83567d0..9d314dedf 100644 --- a/app/controllers/cards/watches_controller.rb +++ b/app/controllers/cards/watches_controller.rb @@ -7,9 +7,19 @@ class Cards::WatchesController < ApplicationController def create @card.watch_by Current.user + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end def destroy @card.unwatch_by Current.user + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index cb40079e8..a3098b4b0 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -10,8 +10,18 @@ class CardsController < ApplicationController end def create - card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted") - redirect_to card + respond_to do |format| + format.html do + card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted") + redirect_to card + end + + format.json do + card = @board.cards.create! card_params.merge(creator: Current.user) + card.publish + head :created, location: card_path(card, format: :json) + end + end end def show @@ -22,11 +32,20 @@ class CardsController < ApplicationController def update @card.update! card_params + + respond_to do |format| + format.turbo_stream + format.json { render :show } + end end def destroy @card.destroy! - redirect_to @card.board, notice: "Card deleted" + + respond_to do |format| + format.html { redirect_to @card.board, notice: "Card deleted" } + format.json { head :no_content } + end end private diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 4cdbbdb69..30f54820b 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -7,7 +7,7 @@ module Authentication after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? - etag { Current.session.id if authenticated? } + etag { Current.identity.id if authenticated? } include LoginHelper end @@ -32,7 +32,7 @@ module Authentication private def authenticated? - Current.session.present? + Current.identity.present? end def require_account @@ -42,7 +42,7 @@ module Authentication end def require_authentication - resume_session || request_authentication + resume_session || authenticate_by_bearer_token || request_authentication end def resume_session @@ -55,6 +55,16 @@ module Authentication Session.find_signed(cookies.signed[:session_token]) end + def authenticate_by_bearer_token + if request.authorization.to_s.include?("Bearer") + authenticate_or_request_with_http_token do |token| + if identity = Identity.find_by_permissable_access_token(token, method: request.method) + Current.identity = identity + end + end + end + end + def request_authentication if Current.account.present? session[:return_to_after_authenticating] = request.url diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb index 8ede51fc7..95fa12892 100644 --- a/app/controllers/concerns/request_forgery_protection.rb +++ b/app/controllers/concerns/request_forgery_protection.rb @@ -12,7 +12,7 @@ module RequestForgeryProtection end def verified_request? - super || safe_fetch_site? + super || safe_fetch_site? || request.format.json? end SAFE_FETCH_SITES = %w[ same-origin same-site ] diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb new file mode 100644 index 000000000..99c87893f --- /dev/null +++ b/app/controllers/my/access_tokens_controller.rb @@ -0,0 +1,40 @@ +class My::AccessTokensController < ApplicationController + def index + @access_tokens = my_access_tokens.order(created_at: :desc) + end + + def show + @access_token = my_access_tokens.find(verifier.verify(params[:id])) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to my_access_tokens_path, alert: "Token is no longer visible" + end + + def new + @access_token = my_access_tokens.new + end + + def create + access_token = my_access_tokens.create!(access_token_params) + expiring_id = verifier.generate access_token.id, expires_in: 10.seconds + + redirect_to my_access_token_path(expiring_id) + end + + def destroy + my_access_tokens.find(params[:id]).destroy! + redirect_to my_access_tokens_path + end + + private + def my_access_tokens + Current.identity.access_tokens + end + + def access_token_params + params.expect(access_token: %i[ description permission ]) + end + + def verifier + Rails.application.message_verifier(:access_tokens) + end +end diff --git a/app/controllers/my/identities_controller.rb b/app/controllers/my/identities_controller.rb new file mode 100644 index 000000000..7a388a3c3 --- /dev/null +++ b/app/controllers/my/identities_controller.rb @@ -0,0 +1,7 @@ +class My::IdentitiesController < ApplicationController + disallow_account_scope + + def show + @identity = Current.identity + end +end diff --git a/app/controllers/notifications/bulk_readings_controller.rb b/app/controllers/notifications/bulk_readings_controller.rb index 5f80938fe..2c15a871b 100644 --- a/app/controllers/notifications/bulk_readings_controller.rb +++ b/app/controllers/notifications/bulk_readings_controller.rb @@ -2,10 +2,15 @@ class Notifications::BulkReadingsController < ApplicationController def create Current.user.notifications.unread.read_all - if from_tray? - head :ok - else - redirect_to notifications_path + respond_to do |format| + format.html do + if from_tray? + head :ok + else + redirect_to notifications_path + end + end + format.json { head :no_content } end end diff --git a/app/controllers/notifications/readings_controller.rb b/app/controllers/notifications/readings_controller.rb index 2accc3373..622668efc 100644 --- a/app/controllers/notifications/readings_controller.rb +++ b/app/controllers/notifications/readings_controller.rb @@ -2,10 +2,20 @@ class Notifications::ReadingsController < ApplicationController def create @notification = Current.user.notifications.find(params[:notification_id]) @notification.read + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end def destroy @notification = Current.user.notifications.find(params[:notification_id]) @notification.unread + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 76bd62974..b116ed1a2 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -1,13 +1,20 @@ class NotificationsController < ApplicationController MAX_UNREAD_NOTIFICATIONS = 500 + MAX_UNREAD_NOTIFICATIONS_VIA_API = 100 def index - @unread = Current.user.notifications.unread.ordered.preloaded.limit(MAX_UNREAD_NOTIFICATIONS) unless current_page_param + @unread = Current.user.notifications.unread.ordered.preloaded.limit(max_unread_notifications) unless current_page_param set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded respond_to do |format| format.turbo_stream if current_page_param # Allows read-all action to side step pagination format.html + format.json end end + + private + def max_unread_notifications + request.format.json? ? MAX_UNREAD_NOTIFICATIONS_VIA_API : MAX_UNREAD_NOTIFICATIONS + end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 000000000..6b72fdf50 --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -0,0 +1,5 @@ +class TagsController < ApplicationController + def index + set_page_and_extract_portion_from Current.account.tags.alphabetically + end +end diff --git a/app/controllers/users/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb index bf5e5f87e..b511a6b19 100644 --- a/app/controllers/users/push_subscriptions_controller.rb +++ b/app/controllers/users/push_subscriptions_controller.rb @@ -5,13 +5,7 @@ class Users::PushSubscriptionsController < ApplicationController end def create - if subscription = @push_subscriptions.find_by(push_subscription_params) - subscription.touch - else - @push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent) - end - - head :ok + @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params) end def destroy diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 15f8ecd6f..74deb686b 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,7 +1,11 @@ class UsersController < ApplicationController - before_action :set_user + before_action :set_user, except: %i[ index ] before_action :ensure_permission_to_change_user, only: %i[ update destroy ] + def index + set_page_and_extract_portion_from Current.account.users.active.alphabetically + end + def show end @@ -10,15 +14,25 @@ class UsersController < ApplicationController def update if @user.update(user_params) - redirect_to @user + respond_to do |format| + format.html { redirect_to @user } + format.json { head :no_content } + end else - render :edit, status: :unprocessable_entity + respond_to do |format| + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @user.errors, status: :unprocessable_entity } + end end end def destroy @user.deactivate - redirect_to users_path + + respond_to do |format| + format.html { redirect_to users_path } + format.json { head :no_content } + end end private diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb index 8998ffcb9..218d58e78 100644 --- a/app/helpers/html_helper.rb +++ b/app/helpers/html_helper.rb @@ -1,6 +1,11 @@ module HtmlHelper + include ERB::Util + + EXCLUDE_PUNCTUATION = %(.?,:!;"'<>) + EXCLUDE_PUNCTUATION_REGEX = /[#{Regexp.escape(EXCLUDE_PUNCTUATION)}]+\z/ + def format_html(html) - fragment = Nokogiri::HTML.fragment(html) + fragment = Nokogiri::HTML5.fragment(html) auto_link(fragment) @@ -16,14 +21,16 @@ module HtmlHelper fragment.traverse do |node| next unless auto_linkable_node?(node) - content = node.text + # Take care to escape the html text node, so that the subsequent Nokogiri re-parse doesn't + # create tags where there aren't any. + content = h(node.text) linked_content = content.dup auto_link_urls(linked_content) auto_link_emails(linked_content) if linked_content != content - node.replace(Nokogiri::HTML.fragment(linked_content)) + node.replace(Nokogiri::HTML5.fragment(linked_content)) end end end @@ -40,8 +47,10 @@ module HtmlHelper end def extract_url_and_punctuation(url_match) - if url_match.end_with?(".", "?", ",", ":") - [ url_match[..-2], url_match[-1] ] + url_match = CGI.unescapeHTML(url_match) + if match = url_match.match(EXCLUDE_PUNCTUATION_REGEX) + len = match[0].length + [ url_match[..-(len+1)], url_match[-len..] ] else [ url_match, "" ] end diff --git a/app/models/account.rb b/app/models/account.rb index 61a636adb..0bb4d4111 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -10,8 +10,6 @@ class Account < ApplicationRecord has_many :columns, dependent: :destroy has_many :exports, class_name: "Account::Export", dependent: :destroy - has_many_attached :uploads - before_create :assign_external_account_id after_create :create_join_code diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb index 82283e4f6..79160cbaa 100644 --- a/app/models/account/join_code.rb +++ b/app/models/account/join_code.rb @@ -1,8 +1,11 @@ class Account::JoinCode < ApplicationRecord CODE_LENGTH = 12 + USAGE_LIMIT_MAX = 10_000_000_000 belongs_to :account + validates :usage_limit, numericality: { less_than_or_equal_to: USAGE_LIMIT_MAX, message: "cannot be larger than the population of the planet" } + scope :active, -> { where("usage_count < usage_limit") } before_create :generate_code, if: -> { code.blank? } diff --git a/app/models/card/eventable/system_commenter.rb b/app/models/card/eventable/system_commenter.rb index 0e76700b3..5c7defc29 100644 --- a/app/models/card/eventable/system_commenter.rb +++ b/app/models/card/eventable/system_commenter.rb @@ -1,4 +1,6 @@ class Card::Eventable::SystemCommenter + include ERB::Util + attr_reader :card, :event def initialize(card, event) @@ -15,25 +17,53 @@ class Card::Eventable::SystemCommenter def comment_body case event.action when "card_assigned" - "#{event.creator.name} assigned this to #{event.assignees.pluck(:name).to_sentence}." + "#{creator_name} assigned this to #{assignee_names}." when "card_unassigned" - "#{event.creator.name} unassigned from #{event.assignees.pluck(:name).to_sentence}." + "#{creator_name} unassigned from #{assignee_names}." when "card_closed" - "Moved to “Done” by #{event.creator.name}" + "Moved to “Done” by #{creator_name}" when "card_reopened" - "Reopened by #{event.creator.name}" + "Reopened by #{creator_name}" when "card_postponed" - "#{event.creator.name} moved this to “Not Now”" + "#{creator_name} moved this to “Not Now”" when "card_auto_postponed" "Moved to “Not Now” due to inactivity" when "card_title_changed" - "#{event.creator.name} changed the title from “#{event.particulars.dig('particulars', 'old_title')}” to “#{event.particulars.dig('particulars', 'new_title')}”." + "#{creator_name} changed the title from “#{old_title}” to “#{new_title}”." when "card_board_changed" - "#{event.creator.name} moved this from “#{event.particulars.dig('particulars', 'old_board')}” to “#{event.particulars.dig('particulars', 'new_board')}”." + "#{creator_name} moved this from “#{old_board}” to “#{new_board}”." when "card_triaged" - "#{event.creator.name} moved this to “#{event.particulars.dig('particulars', 'column')}”" + "#{creator_name} moved this to “#{column}”" when "card_sent_back_to_triage" - "#{event.creator.name} moved this back to “Maybe?”" + "#{creator_name} moved this back to “Maybe?”" end end + + def creator_name + h event.creator.name + end + + def assignee_names + h event.assignees.pluck(:name).to_sentence + end + + def old_title + h event.particulars.dig("particulars", "old_title") + end + + def new_title + h event.particulars.dig("particulars", "new_title") + end + + def old_board + h event.particulars.dig("particulars", "old_board") + end + + def new_board + h event.particulars.dig("particulars", "new_board") + end + + def column + h event.particulars.dig("particulars", "column") + end end diff --git a/app/models/current.rb b/app/models/current.rb index 47f2b6c21..94ef9688c 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,13 +1,19 @@ class Current < ActiveSupport::CurrentAttributes - attribute :session, :user, :account + attribute :session, :user, :identity, :account attribute :http_method, :request_id, :user_agent, :ip_address, :referrer - delegate :identity, to: :session, allow_nil: true - def session=(value) super(value) - if value.present? && account.present? + if value.present? + self.identity = session.identity + end + end + + def identity=(identity) + super(identity) + + if identity.present? self.user = identity.users.find_by(account: account) end end diff --git a/app/models/identity.rb b/app/models/identity.rb index bb69734b4..7495e37c3 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,6 +1,7 @@ class Identity < ApplicationRecord include Joinable, Transferable + has_many :access_tokens, dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify @@ -13,6 +14,12 @@ class Identity < ApplicationRecord validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } normalizes :email_address, with: ->(value) { value.strip.downcase.presence } + def self.find_by_permissable_access_token(token, method:) + if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method) + access_token.identity + end + end + def send_magic_link(**attributes) attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for) diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb new file mode 100644 index 000000000..abdf37eba --- /dev/null +++ b/app/models/identity/access_token.rb @@ -0,0 +1,10 @@ +class Identity::AccessToken < ApplicationRecord + belongs_to :identity + + has_secure_token + enum :permission, %w[ read write ].index_by(&:itself), default: :read + + def allows?(method) + method.in?(%w[ GET HEAD ]) || write? + end +end diff --git a/app/models/push/subscription.rb b/app/models/push/subscription.rb index afd417367..1248e9091 100644 --- a/app/models/push/subscription.rb +++ b/app/models/push/subscription.rb @@ -1,5 +1,6 @@ class Push::Subscription < ApplicationRecord PERMITTED_ENDPOINT_HOSTS = %w[ + jmt17.google.com fcm.googleapis.com updates.push.services.mozilla.com web.push.apple.com diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index c5dab1497..3ebe49b06 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -1,7 +1,10 @@ class Webhook::Delivery < ApplicationRecord + class ResponseTooLarge < StandardError; end + STALE_TRESHOLD = 7.days USER_AGENT = "fizzy/1.0.0 Webhook" ENDPOINT_TIMEOUT = 7.seconds + MAX_RESPONSE_SIZE = 100.kilobytes belongs_to :account, default: -> { webhook.account } belongs_to :webhook @@ -52,12 +55,16 @@ class Webhook::Delivery < ApplicationRecord if resolved_ip.nil? { error: :private_uri } else - response = http.request( - Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload } - ) + request = Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload } + + response = http.request(request) do |net_http_response| + stream_body_with_limit(net_http_response) + end { code: response.code.to_i } end + rescue ResponseTooLarge + { error: :response_too_large } rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError { error: :dns_lookup_failed } rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT @@ -68,6 +75,14 @@ class Webhook::Delivery < ApplicationRecord { error: :failed_tls } end + def stream_body_with_limit(response) + bytes_read = 0 + response.read_body do |chunk| + bytes_read += chunk.bytesize + raise ResponseTooLarge if bytes_read > MAX_RESPONSE_SIZE + end + end + def resolved_ip return @resolved_ip if defined?(@resolved_ip) @resolved_ip = SsrfProtection.resolve_public_ip(uri.host) diff --git a/app/views/account/join_codes/edit.html.erb b/app/views/account/join_codes/edit.html.erb index bca92e58f..8a65adca7 100644 --- a/app/views/account/join_codes/edit.html.erb +++ b/app/views/account/join_codes/edit.html.erb @@ -14,12 +14,21 @@ <%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %> <%= form.number_field :usage_limit, - required: true, autofocus: true, min: @join_code.usage_count, + required: true, autofocus: true, + in: 0..Account::JoinCode::USAGE_LIMIT_MAX, class: "input center txt-large fit-content font-weight-black txt-align-center", style: "max-inline-size: 8ch", data: { action: "keydown.esc@document->form#cancel focus->form#select" } %> + <% if @join_code.errors.any? %> +
+ <% @join_code.errors.full_messages.each do |message| %> +

<%= message %>

+ <% end %> +
+ <% end %> +

- This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times. + This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit_in_database %> times.

<%= form.button type: :submit, class: "btn btn--link center txt-medium", data: { form_target: "submit" } do %> diff --git a/app/views/boards/_board.json.jbuilder b/app/views/boards/_board.json.jbuilder index ce2ef0451..fd4e891d7 100644 --- a/app/views/boards/_board.json.jbuilder +++ b/app/views/boards/_board.json.jbuilder @@ -1,8 +1,7 @@ json.cache! board do json.(board, :id, :name, :all_access) json.created_at board.created_at.utc + json.url board_url(board) - json.creator do - json.partial! "users/user", user: board.creator - end + json.creator board.creator, partial: "users/user", as: :user end diff --git a/app/views/boards/columns/index.json.jbuilder b/app/views/boards/columns/index.json.jbuilder new file mode 100644 index 000000000..4142996de --- /dev/null +++ b/app/views/boards/columns/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @columns, partial: "columns/column", as: :column diff --git a/app/views/boards/columns/show.json.jbuilder b/app/views/boards/columns/show.json.jbuilder new file mode 100644 index 000000000..f94e6584b --- /dev/null +++ b/app/views/boards/columns/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "columns/column", column: @column diff --git a/app/views/boards/edit/_publication.html.erb b/app/views/boards/edit/_publication.html.erb index 7cfc10ca6..c0f60cd77 100644 --- a/app/views/boards/edit/_publication.html.erb +++ b/app/views/boards/edit/_publication.html.erb @@ -6,17 +6,17 @@ <% if board.published? %>
-
- <%= 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 000000000..047401cff --- /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 000000000..a6916c467 --- /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 1fa339328..a1c509a66 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.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.column do - if card.column - json.partial! "columns/column", column: card.column - else - nil - end - end - - 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 a66ae1ef9..3eb9855ed 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 000000000..7e261ccc7 --- /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 000000000..17ad70f70 --- /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 000000000..5dd744f12 --- /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 000000000..52ef29c2b --- /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 000000000..c1dc1dff1 --- /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 000000000..39feca775 --- /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 000000000..ac0d5a1f8 --- /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 000000000..1190f84e1 --- /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 355a62606..5bb56bdd6 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 000000000..cd33831bb --- /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 000000000..cc9b83ede --- /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 000000000..be08e5031 --- /dev/null +++ b/app/views/my/access_tokens/new.html.erb @@ -0,0 +1,29 @@ +<% @page_title = "Generate a personal access token" %> + +<% content_for :header do %> +
+ <%= 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 000000000..16becf7c2 --- /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 000000000..4c2076f0c --- /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 000000000..a36569e41 --- /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 e35d03105..1f1ea70c9 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 000000000..ba27c5425 --- /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 000000000..660bbb673 --- /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 000000000..7ea7510e5 --- /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 000000000..970088149 --- /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/tags/_tag.json.jbuilder b/app/views/tags/_tag.json.jbuilder new file mode 100644 index 000000000..c8a7ffe40 --- /dev/null +++ b/app/views/tags/_tag.json.jbuilder @@ -0,0 +1,5 @@ +json.cache! tag do + json.(tag, :id, :title) + json.created_at tag.created_at.utc + json.url cards_url(tag_ids: [ tag ]) +end diff --git a/app/views/tags/index.json.jbuilder b/app/views/tags/index.json.jbuilder new file mode 100644 index 000000000..58fa7f00f --- /dev/null +++ b/app/views/tags/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "tags/tag", as: :tag diff --git a/app/views/uploads/create.json.jbuilder b/app/views/uploads/create.json.jbuilder deleted file mode 100644 index 984afba49..000000000 --- a/app/views/uploads/create.json.jbuilder +++ /dev/null @@ -1,4 +0,0 @@ -json.message "File uploaded successfully" -json.fileName @upload.filename.to_s -json.mimetype @upload.content_type -json.fileUrl @upload.slug_url diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb index 489d9765a..533693ea8 100644 --- a/app/views/users/_transfer.html.erb +++ b/app/views/users/_transfer.html.erb @@ -1,6 +1,11 @@
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %> +
+

Link a device

+

Use this link to sign-in on another device

+
+
- + \ No newline at end of file diff --git a/app/views/users/_user.json.jbuilder b/app/views/users/_user.json.jbuilder index 6a49bd1de..0884f225a 100644 --- a/app/views/users/_user.json.jbuilder +++ b/app/views/users/_user.json.jbuilder @@ -1,7 +1,7 @@ json.cache! user do json.(user, :id, :name, :role, :active) - json.email_address user.identity.email_address + json.email_address user.identity&.email_address json.created_at user.created_at.utc json.url user_url(user) diff --git a/app/views/users/index.json.jbuilder b/app/views/users/index.json.jbuilder new file mode 100644 index 000000000..2472a7b47 --- /dev/null +++ b/app/views/users/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "users/user", as: :user diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index d0aef2280..2bd0bb516 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -32,9 +32,9 @@ <% if @user.verified? %>
<%= link_to "Which cards are assigned to #{me_or_you}?", - cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %> + cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %> <%= link_to "Which cards were added by #{me_or_you}?", - cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %> + cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %>
<% end %> @@ -49,6 +49,19 @@ <%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> Sign out of Fizzy on this device +
+
+

API

+
+ +
+ <%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %> +
+
+ +
+ <%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %> + Sign out of Fizzy <% end %> <% end %> diff --git a/app/views/users/show.json.jbuilder b/app/views/users/show.json.jbuilder new file mode 100644 index 000000000..ff40bb960 --- /dev/null +++ b/app/views/users/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "users/user", user: @user diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb index 9075683a4..badb16bc3 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -15,7 +15,7 @@
<%= form.label :actions do %> Events
-

Trigger a call to the Payload URL when these things occur:

+

Trigger a call to the Payload URL when:

<% end %> <%= render "webhooks/form/actions", form: form %>
diff --git a/app/views/webhooks/event.json.jbuilder b/app/views/webhooks/event.json.jbuilder index b7ce90dcd..2f4f817d8 100644 --- a/app/views/webhooks/event.json.jbuilder +++ b/app/views/webhooks/event.json.jbuilder @@ -9,11 +9,6 @@ json.cache! @event do end end - json.board do - json.partial! "boards/board", locals: { board: @event.board } - end - - json.creator do - json.partial! "users/user", user: @event.creator - end + json.board @event.board, partial: "boards/board", as: :board + json.creator @event.creator, partial: "users/user", as: :user end diff --git a/app/views/webhooks/form/_actions.html.erb b/app/views/webhooks/form/_actions.html.erb index a887fa644..95a287abe 100644 --- a/app/views/webhooks/form/_actions.html.erb +++ b/app/views/webhooks/form/_actions.html.erb @@ -4,13 +4,14 @@ webhook_action_options, :first, :last do |item| %> -
  • -
  • + - <%= item.text %>
  • <% end %> diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb index 02522aedb..e1a6af513 100644 --- a/app/views/webhooks/new.html.erb +++ b/app/views/webhooks/new.html.erb @@ -31,7 +31,7 @@
    <%= form.label :actions do %> Events
    -

    Trigger a call to the Payload URL when these things occur:

    +

    Trigger a call to the Payload URL when:

    <% end %> <%= render "webhooks/form/actions", form: form %>
    diff --git a/bin/dev b/bin/dev index 51d232d91..d01747dd3 100755 --- a/bin/dev +++ b/bin/dev @@ -4,8 +4,8 @@ bin/rails runner - < password: <%= ENV["MYSQL_PASSWORD"] %> pool: 50 + ssl_mode: <%= ENV["MYSQL_SSL_MODE"] %> timeout: 5000 development: @@ -40,4 +41,4 @@ production: cache: <<: *default database: fizzy_production_cache - migrations_paths: db/cache_migrate \ No newline at end of file + migrations_paths: db/cache_migrate diff --git a/config/deploy.yml b/config/deploy.yml index 0e855727d..8245a7136 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -28,7 +28,8 @@ env: - SMTP_USERNAME - SMTP_PASSWORD clear: - MAILER_FROM_ADDRESS: support@example.com # The email address that Fizzy sends email from + MAILER_FROM_ADDRESS: support@example.com # The email "from" address that Fizzy sends email from + SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container diff --git a/config/environments/production.rb b/config/environments/production.rb index 7b29a00d1..6398d14d0 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -5,18 +5,20 @@ Rails.application.configure do # Email provider Settings # - # Configure these according to whichever email provider you use. An example setup - # using SMTP looks like the following: - # - # config.action_mailer.smtp_settings = { - # address: 'smtp.example.com', # The address of your email provider's SMTP server - # port: 2525, - # domain: 'example.com', # Your domain, which Fizzy will send email from - # user_name: ENV["SMTP_USERNAME"], - # password: ENV["SMTP_PASSWORD"], - # authentication: :plain, - # enable_starttls_auto: true - # } + # SMTP setting can be configured via environment variables. + # For other configuration options, consult the Action Mailer documentation. + if smtp_address = ENV["SMTP_ADDRESS"].presence + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: smtp_address, + port: ENV.fetch("SMTP_PORT", "587").to_i, + domain: ENV.fetch("SMTP_DOMAIN", nil), + user_name: ENV.fetch("SMTP_USERNAME", nil), + password: ENV.fetch("SMTP_PASSWORD", nil), + authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"), + enable_starttls_auto: ENV.fetch("SMTP_ENABLE_STARTTLS_AUTO", "true") == "true" + } + end # Code is not reloaded between requests. config.enable_reloading = false @@ -40,6 +42,12 @@ Rails.application.configure do "Cache-Control" => "public, max-age=#{1.year.to_i}" } + # Select Active Storage service via env var; default to local disk. + # Don't overwrite if it's already been set (e.g. by fizzy-saas) + if config.active_storage.service.blank? + config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym + end + # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" @@ -54,10 +62,10 @@ Rails.application.configure do # Assume all access to the app is happening through a SSL-terminating reverse proxy. # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - config.assume_ssl = true + config.assume_ssl = ENV.fetch("ASSUME_SSL", "true") == "true" # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true + config.force_ssl = ENV.fetch("FORCE_SSL", "true") == "true" # Log to STDOUT by default config.logger = ActiveSupport::Logger.new(STDOUT) diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index 4a4a0343d..74461c795 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -11,10 +11,9 @@ end # before the User model's upload callback, causing FileNotFoundError when # using `process: :immediately` for variants. # See: https://github.com/rails/rails/issues/53694 -# -# ActiveSupport.on_load(:active_storage_record) do -# configure_replica_connections -# end +ActiveSupport.on_load(:active_storage_record) do + configure_replica_connections +end module ActiveStorageControllerExtensions extend ActiveSupport::Concern diff --git a/config/puma.rb b/config/puma.rb index 489ae0779..70ca6ebb4 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -7,8 +7,11 @@ pidfile ENV.fetch("PIDFILE", "tmp/pids/server.pid") # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart -# Run Solid Queue with Puma -plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] +# Run Solid Queue with Puma by default. +# Disabled when running fizzy-saas or via SOLID_QUEUE_IN_PUMA=false. +unless Fizzy.saas? || ENV["SOLID_QUEUE_IN_PUMA"] == "false" + plugin :solid_queue +end # Expose Prometheus metrics at http://0.0.0.0:9394/metrics (SaaS only). # In dev, overridden to http://127.0.0.1:9306/metrics in .mise.toml. diff --git a/config/routes.rb b/config/routes.rb index e67e4ef7c..aa2815433 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -94,6 +94,8 @@ Rails.application.routes.draw do end end + resources :tags, only: :index + namespace :notifications do resource :settings resource :unsubscribe @@ -162,6 +164,8 @@ Rails.application.routes.draw do resource :landing namespace :my do + resource :identity, only: :show + resources :access_tokens resources :pins resource :timezone resource :menu diff --git a/config/storage.oss.yml b/config/storage.oss.yml index e50debd34..37052e6c5 100644 --- a/config/storage.oss.yml +++ b/config/storage.oss.yml @@ -16,3 +16,14 @@ devminio: region: us-east-1 # default region required for signer access_key_id: minioadmin secret_access_key: minioadmin + +s3: + service: S3 + access_key_id: <%= ENV["S3_ACCESS_KEY_ID"] %> + bucket: <%= ENV["S3_BUCKET"] || "fizzy-#{Rails.env}-activestorage" %> + endpoint: <%= ENV["S3_ENDPOINT"] %> + force_path_style: <%= ENV["S3_FORCE_PATH_STYLE"] == "true" %> + region: <%= ENV.fetch("S3_REGION", "us-east-1") %> + request_checksum_calculation: <%= ENV.fetch("S3_REQUEST_CHECKSUM_CALCULATION", "when_supported") %> + response_checksum_validation: <%= ENV.fetch("S3_RESPONSE_CHECKSUM_VALIDATION", "when_supported") %> + secret_access_key: <%= ENV["S3_SECRET_ACCESS_KEY"] %> diff --git a/db/migrate/20251201132341_create_identity_access_tokens.rb b/db/migrate/20251201132341_create_identity_access_tokens.rb new file mode 100644 index 000000000..0e455104d --- /dev/null +++ b/db/migrate/20251201132341_create_identity_access_tokens.rb @@ -0,0 +1,14 @@ +class CreateIdentityAccessTokens < ActiveRecord::Migration[8.2] + def change + create_table :identity_access_tokens, id: :uuid do |t| + t.uuid :identity_id, null: false + t.string :token + t.string :permission + t.text :description + + t.timestamps + + t.index ["identity_id"], name: "index_access_token_on_identity_id" + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 42458db17..85a8f8064 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.index ["email_address"], name: "index_identities_on_email_address", unique: true end + create_table "identity_access_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.uuid "identity_id", null: false + t.string "permission" + t.string "token" + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_access_token_on_identity_id" + end + create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "code", null: false t.datetime "created_at", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 1dd2b3e00..e2f65dccc 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.index ["email_address"], name: "index_identities_on_email_address", unique: true end + create_table "identity_access_tokens", id: :uuid, force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description", limit: 65535 + t.uuid "identity_id", null: false + t.string "permission", limit: 255 + t.string "token", limit: 255 + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_access_token_on_identity_id" + end + create_table "magic_links", id: :uuid, force: :cascade do |t| t.string "code", limit: 255, null: false t.datetime "created_at", null: false diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 000000000..af4ed1184 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,1163 @@ +# Fizzy API + +Fizzy has an API that allows you to integrate your application with it or to create +a bot to perform various actions for you. + +## Authentication + +To use the API you'll need an access token. To get one, go to your profile, then, +in the API section, click on "Personal access tokens" and then click on +"Generate new access token". + +Give it a description and pick what kind of permission you want the access token to have: +- `Read`: allows reading data from your account +- `Read + Write`: allows reading and writing data to your account on your behalf + +Then click on "Generate access token". + +
    + Access token generation guide with screenshots + + | Step | Description | Screenshot | + |:----:|-------------|:----------:| + | 1 | Go to your profile | Profile page with API section | + | 2 | In the API section click on "Personal access token" | Personal access tokens page | + | 3 | Click on "Generate a new access token" | Generate new access token dialog | + | 4 | Give it a description and assign it a permission | Access token created | +
    + +> [!IMPORTANT] +> __An access token is like a password, keep it secret and do not share it with anyone.__ +> Any person or application that has your access token can perform actions on your behalf. + +To authenticate a request using your access token, include it in the `Authorization` header: + +```bash +curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/my/identity +``` + +## Caching + +Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data. + +### Using ETags + +When you make a request, the response includes an `ETag` header: + +``` +HTTP/1.1 200 OK +ETag: "abc123" +Cache-Control: max-age=0, private, must-revalidate +``` + +On subsequent requests, include the ETag value in the `If-None-Match` header: + +``` +GET /1234567/cards/42.json +If-None-Match: "abc123" +``` + +If the resource hasn't changed, you'll receive a `304 Not Modified` response with no body, saving bandwidth and processing time: + +``` +HTTP/1.1 304 Not Modified +ETag: "abc123" +``` + +If the resource has changed, you'll receive the full response with a new ETag. + +__Example in Ruby:__ + +```ruby +# Store the ETag from the response +etag = response.headers["ETag"] + +# On next request, send it back +headers = { "If-None-Match" => etag } +response = client.get("/1234567/cards/42.json", headers: headers) + +if response.status == 304 + # Nothing to do, the card hasn't changed +else + # The card has changed, process the new data +end +``` + +## Error Responses + +When a request fails, the API response will communicate the source of the problem through the HTTP status code. + +| Status Code | Description | +|-------------|-------------| +| `400 Bad Request` | The request was malformed or missing required parameters | +| `401 Unauthorized` | Authentication failed or access token is invalid | +| `403 Forbidden` | You don't have permission to perform this action | +| `404 Not Found` | The requested resource doesn't exist or you don't have access to it | +| `422 Unprocessable Entity` | Validation failed (see error response format above) | +| `500 Internal Server Error` | An unexpected error occurred on the server | + +If a request contains invalid data for fields, such as entering a string into a number field, in most cases the API will respond with a `500 Internal Server Error`. Clients are expected to perform some validation on their end before making a request. + +A validation error will produce a `422 Unprocessable Entity` response, which will sometimes be accompanied by details about the error: + +```json +{ + "avatar": ["must be a JPEG, PNG, GIF, or WebP image"] +} +``` + +## Pagination + +All endpoints that return a list of items are paginated. The page size can vary from endpoint to endpoint, +and we use a dynamic page size where initial pages return fewer results than later pages. + +If there are more results to fetch, the response will include a `Link` header with a `rel="next"` link to the next page of results: + +```bash +curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" -v http://fizzy.localhost:3006/686465299/cards +# ... +< link: ; rel="next" +# ... +``` + +## List parameters + +When an endpoint accepts a list of values as a parameter, you can provide multiple values by repeating the parameter name: + +``` +?tag_ids[]=tag1&tag_ids[]=tag2&tag_ids[]=tag3 +``` + +List parameters always end with `[]`. + +## File Uploads + +Some endpoints accept file uploads. To upload a file, send a `multipart/form-data` request instead of JSON. +You can combine file uploads with other parameters in the same request. + +__Example using curl:__ + +```bash +curl -X PUT \ + -H "Authorization: Bearer put-your-access-token-here" \ + -F "user[name]=David H. Hansson" \ + -F "user[avatar]=@/path/to/avatar.jpg" \ + http://fizzy.localhost:3006/686465299/users/03f5v9zjw7pz8717a4no1h8a7 +``` + +## Rich Text Fields + +Some fields accept rich text content. These fields accept HTML input, which will be sanitized to remove unsafe tags and attributes. + +```json +{ + "card": { + "title": "My card", + "description": "

    This is bold and this is italic.

    • Item 1
    • Item 2
    " + } +} +``` + +### Attaching files to rich text + +To attach files (images, documents) to rich text fields, use ActionText's direct upload flow: + +#### 1. Create a direct upload + +First, request a direct upload URL by sending file metadata: + +```bash +curl -X POST \ + -H "Authorization: Bearer put-your-access-token-here" \ + -H "Content-Type: application/json" \ + -d '{ + "blob": { + "filename": "screenshot.png", + "byte_size": 12345, + "checksum": "GQ5SqLsM7ylnji0Wgd9wNA==", + "content_type": "image/png" + } + }' \ + https://app.fizzy.do/rails/active_storage/direct_uploads +``` + +The `checksum` is a Base64-encoded MD5 hash of the file content. + +__Response:__ + +```json +{ + "id": "abc123", + "key": "abc123def456", + "filename": "screenshot.png", + "content_type": "image/png", + "byte_size": 12345, + "checksum": "GQ5SqLsM7ylnji0Wgd9wNA==", + "direct_upload": { + "url": "https://storage.example.com/...", + "headers": { + "Content-Type": "image/png", + "Content-MD5": "GQ5SqLsM7ylnji0Wgd9wNA==" + } + }, + "signed_id": "eyJfcmFpbHMi..." +} +``` + +#### 2. Upload the file + +Upload the file directly to the provided URL with the specified headers: + +```bash +curl -X PUT \ + -H "Content-Type: image/png" \ + -H "Content-MD5: GQ5SqLsM7ylnji0Wgd9wNA==" \ + --data-binary @screenshot.png \ + "https://storage.example.com/..." +``` + +#### 3. Reference the file in rich text + +Use the `signed_id` from step 1 to embed the file in your rich text using an `` tag: + +```json +{ + "card": { + "title": "Card with image", + "description": "

    Here's a screenshot:

    " + } +} +``` + +The `sgid` attribute should contain the `signed_id` returned from the direct upload response. + +## Identity + +An Identity represents a person using Fizzy. + +### `GET /my/identity` + +Returns a list of accounts the identity has access to, including the user for each account. + +```json +{ + "accounts": [ + { + "id": "03f5v9zjskhcii2r45ih3u1rq", + "name": "37signals", + "slug": "/897362094", + "created_at": "2025-12-05T19:36:35.377Z", + "user": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/users/03f5v9zjw7pz8717a4no1h8a7" + } + }, + { + "id": "03f5v9zpko7mmhjzwum3youpp", + "name": "Honcho", + "slug": "/686465299", + "created_at": "2025-12-05T19:36:36.746Z", + "user": { + "id": "03f5v9zppzlksuj4mxba2nbzn", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:36.783Z", + "url": "http://fizzy.localhost:3006/users/03f5v9zppzlksuj4mxba2nbzn" + } + } + ] +} +``` + +## Boards + +Boards are where you organize your work - they contain your cards. + +### `GET /:account_slug/boards` + +Returns a list of boards that you can access in the specified account. + +__Response:__ + +```json +[ + { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Fizzy", + "all_access": true, + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + } + } +] +``` + +### `GET /:account_slug/boards/:board_id` + +Returns the specified board. + +__Response:__ + +```json +{ + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Fizzy", + "all_access": true, + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + } +} +``` + +### `POST /:account_slug/boards` + +Creates a new Board in the account. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | Yes | The name of the board | +| `all_access` | boolean | No | Whether any user in the account can access this board. Defaults to `true` | +| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed | +| `public_description` | string | No | Rich text description shown on the public board page | + +__Request:__ + +```json +{ + "board": { + "name": "My new board", + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new board: + +``` +HTTP/1.1 201 Created +Location: /897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm.json +``` + +### `PUT /:account_slug/boards/:board_id` + +Updates a Board. Only board administrators can update a board. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | The name of the board | +| `all_access` | boolean | No | Whether any user in the account can access this board | +| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed | +| `public_description` | string | No | Rich text description shown on the public board page | +| `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) | + +__Request:__ + +```json +{ + "board": { + "name": "Updated board name", + "auto_postpone_period": 14, + "public_description": "This is a **public** description of the board.", + "all_access": false, + "user_ids": [ + "03f5v9zppzlksuj4mxba2nbzn", + "03f5v9zjw7pz8717a4no1h8a7" + ] + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/boards/:board_id` + +Deletes a Board. Only board administrators can delete a board. + +__Response:__ + +Returns `204 No Content` on success. + +## Cards + +Cards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments. + +### `GET /:account_slug/cards` + +Returns a paginated list of cards you have access to. Results can be filtered using query parameters. + +__Query Parameters:__ + +| Parameter | Description | +|-----------|-------------| +| `board_ids[]` | Filter by board ID(s) | +| `tag_ids[]` | Filter by tag ID(s) | +| `assignee_ids[]` | Filter by assignee user ID(s) | +| `creator_ids[]` | Filter by card creator ID(s) | +| `closer_ids[]` | Filter by user ID(s) who closed the cards | +| `card_ids[]` | Filter to specific card ID(s) | +| `indexed_by` | Filter by: `all` (default), `closed`, `not_now`, `stalled`, `postponing_soon`, `golden` | +| `sorted_by` | Sort order: `latest` (default), `newest`, `oldest` | +| `assignment_status` | Filter by assignment status: `unassigned` | +| `creation` | Filter by creation date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` | +| `closure` | Filter by closure date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` | +| `terms[]` | Search terms to filter cards | + +__Response:__ + +```json +[ + { + "id": "03f5vaeq985jlvwv3arl4srq2", + "number": 1, + "title": "First!", + "status": "published", + "description": "Hello, World!", + "description_html": "

    Hello, World!

    ", + "image_url": null, + "tags": ["programming"], + "golden": false, + "last_active_at": "2025-12-05T19:38:48.553Z", + "created_at": "2025-12-05T19:38:48.540Z", + "url": "http://fizzy.localhost:3006/897362094/cards/4", + "board": { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Fizzy", + "all_access": true, + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + } + }, + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + }, + "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments" + }, +] +``` + +### `GET /:account_slug/cards/:card_number` + +Returns a specific card by its number. + +__Response:__ + +Same as the card object in the list response. + +### `POST /:account_slug/boards/:board_id/cards` + +Creates a new card in a board. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `title` | string | Yes | The title of the card | +| `description` | string | No | Rich text description of the card | +| `status` | string | No | Initial status: `published` (default), `drafted` | +| `image` | file | No | Header image for the card | +| `tag_ids` | array | No | Array of tag IDs to apply to the card | + +__Request:__ + +```json +{ + "card": { + "title": "Add dark mode support", + "description": "We need to add dark mode to the app" + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new card. + +### `PUT /:account_slug/cards/:card_number` + +Updates a card. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `title` | string | No | The title of the card | +| `description` | string | No | Rich text description of the card | +| `status` | string | No | Card status: `drafted`, `published` | +| `image` | file | No | Header image for the card | +| `tag_ids` | array | No | Array of tag IDs to apply to the card | + +__Request:__ + +```json +{ + "card": { + "title": "Add dark mode support (Updated)" + } +} +``` + +__Response:__ + +Returns the updated card. + +### `DELETE /:account_slug/cards/:card_number` + +Deletes a card. Only the card creator or board administrators can delete cards. + +__Response:__ + +Returns `204 No Content` on success. + +### `POST /:account_slug/cards/:card_number/closure` + +Closes a card. + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/cards/:card_number/closure` + +Reopens a closed card. + +__Response:__ + +Returns `204 No Content` on success. + +### `POST /:account_slug/cards/:card_number/not_now` + +Moves a card to "Not Now" status. + +__Response:__ + +Returns `204 No Content` on success. + +### `POST /:account_slug/cards/:card_number/triage` + +Moves a card from triage into a column. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `column_id` | string | Yes | The ID of the column to move the card into | + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/cards/:card_number/triage` + +Sends a card back to triage. + +__Response:__ + +Returns `204 No Content` on success. + +### `POST /:account_slug/cards/:card_number/taggings` + +Toggles a tag on or off for a card. If the tag doesn't exist, it will be created. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `tag_title` | string | Yes | The title of the tag (leading `#` is stripped) | + +__Response:__ + +Returns `204 No Content` on success. + +### `POST /:account_slug/cards/:card_number/assignments` + +Toggles assignment of a user to/from a card. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `assignee_id` | string | Yes | The ID of the user to assign/unassign | + +__Response:__ + +Returns `204 No Content` on success. + +### `POST /:account_slug/cards/:card_number/watch` + +Subscribes the current user to notifications for this card. + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/cards/:card_number/watch` + +Unsubscribes the current user from notifications for this card. + +__Response:__ + +Returns `204 No Content` on success. + +## Comments + +Comments are attached to cards and support rich text. + +### `GET /:account_slug/cards/:card_number/comments` + +Returns a paginated list of comments on a card, sorted chronologically (oldest first). + +__Response:__ + +```json +[ + { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "created_at": "2025-12-05T19:36:35.534Z", + "updated_at": "2025-12-05T19:36:35.534Z", + "body": { + "plain_text": "This looks great!", + "html": "
    This looks great!
    " + }, + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + }, + "reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions", + "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz" + } +] +``` + +### `GET /:account_slug/cards/:card_number/comments/:comment_id` + +Returns a specific comment. + +__Response:__ + +```json +{ + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "created_at": "2025-12-05T19:36:35.534Z", + "updated_at": "2025-12-05T19:36:35.534Z", + "body": { + "plain_text": "This looks great!", + "html": "
    This looks great!
    " + }, + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + }, + "reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions", + "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz" +} +``` + +### `POST /:account_slug/cards/:card_number/comments` + +Creates a new comment on a card. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `body` | string | Yes | The comment body (supports rich text) | + +__Request:__ + +```json +{ + "comment": { + "body": "This looks great!" + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new comment. + +### `PUT /:account_slug/cards/:card_number/comments/:comment_id` + +Updates a comment. Only the comment creator can update their comments. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `body` | string | Yes | The updated comment body | + +__Request:__ + +```json +{ + "comment": { + "body": "This looks even better now!" + } +} +``` + +__Response:__ + +Returns the updated comment. + +### `DELETE /:account_slug/cards/:card_number/comments/:comment_id` + +Deletes a comment. Only the comment creator can delete their comments. + +__Response:__ + +Returns `204 No Content` on success. + +## Reactions + +Reactions are short (16-character max) responses to comments. + +### `GET /:account_slug/cards/:card_number/comments/:comment_id/reactions` + +Returns a list of reactions on a comment. + +__Response:__ + +```json +[ + { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "content": "👍", + "reacter": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + }, + "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions/03f5v9zo9qlcwwpyc0ascnikz" + } +] +``` + +### `POST /:account_slug/cards/:card_number/comments/:comment_id/reactions` + +Adds a reaction to a comment. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `content` | string | Yes | The reaction text | + +__Request:__ + +```json +{ + "reaction": { + "content": "Great 👍" + } +} +``` + +__Response:__ + +Returns `201 Created` on success. + +### `DELETE /:account_slug/cards/:card_number/comments/:comment_id/reactions/:reaction_id` + +Removes your reaction from a comment. + +__Response:__ + +Returns `204 No Content` on success. + +## Steps + +Steps are to-do items on a card. + +### `GET /:account_slug/cards/:card_number/steps/:step_id` + +Returns a specific step. + +__Response:__ + +```json +{ + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "content": "Write tests", + "completed": false +} +``` + +### `POST /:account_slug/cards/:card_number/steps` + +Creates a new step on a card. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `content` | string | Yes | The step text | +| `completed` | boolean | No | Whether the step is completed (default: `false`) | + +__Request:__ + +```json +{ + "step": { + "content": "Write tests" + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new step. + +### `PUT /:account_slug/cards/:card_number/steps/:step_id` + +Updates a step. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `content` | string | No | The step text | +| `completed` | boolean | No | Whether the step is completed | + +__Request:__ + +```json +{ + "step": { + "completed": true + } +} +``` + +__Response:__ + +Returns the updated step. + +### `DELETE /:account_slug/cards/:card_number/steps/:step_id` + +Deletes a step. + +__Response:__ + +Returns `204 No Content` on success. + +## Tags + +Tags are labels that can be applied to cards for organization and filtering. + +### `GET /:account_slug/tags` + +Returns a list of all tags in the account, sorted alphabetically. + +__Response:__ + +```json +[ + { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "title": "bug", + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnikz" + }, + { + "id": "03f5v9zo9qlcwwpyc0ascnilz", + "title": "feature", + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnilz" + } +] +``` + +## Columns + +Columns represent stages in a workflow on a board. Cards move through columns as they progress. + +### `GET /:account_slug/boards/:board_id/columns` + +Returns a list of columns on a board, sorted by position. + +__Response:__ + +```json +[ + { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Recording", + "color": "var(--color-card-default)", + "created_at": "2025-12-05T19:36:35.534Z" + }, + { + "id": "03f5v9zkft4hj9qq0lsn9ohcn", + "name": "Published", + "color": "var(--color-card-4)", + "created_at": "2025-12-05T19:36:35.534Z" + } +] +``` + +### `GET /:account_slug/boards/:board_id/columns/:column_id` + +Returns the specified column. + +__Response:__ + +```json +{ + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "In Progress", + "color": "var(--color-card-default)", + "created_at": "2025-12-05T19:36:35.534Z" +} +``` + +### `POST /:account_slug/boards/:board_id/columns` + +Creates a new column on the board. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | Yes | The name of the column | +| `color` | string | No | The column color. One of: `var(--color-card-default)` (Blue), `var(--color-card-1)` (Gray), `var(--color-card-2)` (Tan), `var(--color-card-3)` (Yellow), `var(--color-card-4)` (Lime), `var(--color-card-5)` (Aqua), `var(--color-card-6)` (Violet), `var(--color-card-7)` (Purple), `var(--color-card-8)` (Pink) | + +__Request:__ + +```json +{ + "column": { + "name": "In Progress", + "color": "var(--color-card-4)" + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new column. + +### `PUT /:account_slug/boards/:board_id/columns/:column_id` + +Updates a column. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | The name of the column | +| `color` | string | No | The column color | + +__Request:__ + +```json +{ + "column": { + "name": "Done" + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/boards/:board_id/columns/:column_id` + +Deletes a column. + +__Response:__ + +Returns `204 No Content` on success. + +## Users + +Users represent people who have access to an account. + +### `GET /:account_slug/users` + +Returns a list of active users in the account. + +__Response:__ + +```json +[ + { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + }, + { + "id": "03f5v9zjysoy0fqs9yg0ei3hq", + "name": "Jason Fried", + "role": "member", + "active": true, + "email_address": "jason@example.com", + "created_at": "2025-12-05T19:36:35.419Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjysoy0fqs9yg0ei3hq" + }, + { + "id": "03f5v9zk1dtqduod5bkhv3k8m", + "name": "Jason Zimdars", + "role": "member", + "active": true, + "email_address": "jz@example.com", + "created_at": "2025-12-05T19:36:35.435Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zk1dtqduod5bkhv3k8m" + }, + { + "id": "03f5v9zk3nw9ja92e7s4h2wbe", + "name": "Kevin Mcconnell", + "role": "member", + "active": true, + "email_address": "kevin@example.com", + "created_at": "2025-12-05T19:36:35.451Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zk3nw9ja92e7s4h2wbe" + } +] +``` + +### `GET /:account_slug/users/:user_id` + +Returns the specified user. + +__Response:__ + +```json +{ + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" +} +``` + +### `PUT /:account_slug/users/:user_id` + +Updates a user. You can only update users you have permission to change. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | The user's display name | +| `avatar` | file | No | The user's avatar image | + +__Request:__ + +```json +{ + "user": { + "name": "David H. Hansson" + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/users/:user_id` + +Deactivates a user. You can only deactivate users you have permission to change. + +__Response:__ + +Returns `204 No Content` on success. + +## Notifications + +Notifications inform users about events that happened in the account, such as comments, assignments, and card updates. + +### `GET /:account_slug/notifications` + +Returns a list of notifications for the current user. Unread notifications are returned first, followed by read notifications. + +__Response:__ + +```json +[ + { + "id": "03f5va03bpuvkcjemcxl73ho2", + "read": false, + "read_at": null, + "created_at": "2025-11-19T04:03:58.000Z", + "title": "Plain text mentions", + "body": "Assigned to self", + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + }, + "card": { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "title": "Plain text mentions", + "status": "published", + "url": "http://fizzy.localhost:3006/897362094/cards/3" + }, + "url": "http://fizzy.localhost:3006/897362094/notifications/03f5va03bpuvkcjemcxl73ho2" + } +] +``` + +### `POST /:account_slug/notifications/:notification_id/reading` + +Marks a notification as read. + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/notifications/:notification_id/reading` + +Marks a notification as unread. + +__Response:__ + +Returns `204 No Content` on success. + +### `POST /:account_slug/notifications/bulk_reading` + +Marks all unread notifications as read. + +__Response:__ + +Returns `204 No Content` on success. diff --git a/script/migrations/convert_markdowns_to_rich_text.rb b/script/migrations/convert_markdowns_to_rich_text.rb deleted file mode 100755 index e093730f6..000000000 --- a/script/migrations/convert_markdowns_to_rich_text.rb +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env ruby - -require_relative "../config/environment" -require "redcarpet" -require "nokogiri" - -class ActionText::Markdown < ApplicationRecord - belongs_to :record, polymorphic: true -end - -class MarkdownToActionTextConverter - ATTACHMENT_URL_REGEX = %r{/u/(?[^\/\s\)]+)} - - def initialize(html) - @doc = Nokogiri::HTML::DocumentFragment.parse(html) - @attachments = [] - end - - def convert - process_images - process_links - [ @doc.to_html, @attachments ] - end - - private - def process_images - @doc.css("img").each do |img| - src = img["src"].presence - if src && match = src.match(ATTACHMENT_URL_REGEX) - if (attachment = find_attachment(match[:slug])) - img.replace(build_attachment_node(attachment)) - @attachments << attachment - end - end - end - end - - def process_links - @doc.css("a").each do |link| - href = link["href"].presence - - if href && match = href.match(ATTACHMENT_URL_REGEX) - if (attachment = find_attachment(match[:slug])) - link.replace(build_attachment_node(attachment)) - @attachments << attachment - end - end - end - end - - def build_attachment_node(attachment) - html = ActionText::Attachment.from_attachable(attachment).to_html - fragment = Nokogiri::HTML::DocumentFragment.parse(html) - - node = fragment.at_css("action-text-attachment") - node["url"] = Rails.application.routes.url_helpers.rails_blob_path(attachment.blob, only_path: true) - - fragment - end - - def find_attachment(slug) - ActiveStorage::Attachment.find_by(slug: slug) - end -end - -class RedcarpetRenderer - def self.render(markdown) - renderer = Redcarpet::Render::HTML.new - markdowner = Redcarpet::Markdown.new(renderer, - autolink: true, - tables: true, - fenced_code_blocks: true, - strikethrough: true, - superscript: true, - ) - markdowner.render(markdown.to_s) - end -end - -def process_all(klass, field) - klass.find_each do |record| - markdown = ActionText::Markdown.find_by(record: record, name: field) - next unless markdown - - puts "markdown.id=#{markdown.id}" - next unless markdown.record - - html = RedcarpetRenderer.render(markdown.content.to_s) - converter = MarkdownToActionTextConverter.new(html) - rich_text_html, attachments = converter.convert - - rich_text = ActionText::RichText.create!( - name: markdown.name, - record: markdown.record, - body: rich_text_html - ) - - attachments.each do |attachment| - attachment.update!(record: rich_text) - end - - puts "✓ Created rich text for #{markdown.record_type}##{markdown.record_id} (#{markdown.name})" - rescue => e - warn "✗ Failed to process markdown ##{markdown.id}: #{e.class} - #{e.message}" - end -end - -ApplicationRecord.with_each_tenant do |tenant| - puts "Processing tenant: #{tenant}" - - ActionText::RichText.delete_all - - process_all(Card, :description) - process_all(Comment, :body) -end diff --git a/test/controllers/accounts/join_codes_controller_test.rb b/test/controllers/accounts/join_codes_controller_test.rb index 33c118386..15d41b5ec 100644 --- a/test/controllers/accounts/join_codes_controller_test.rb +++ b/test/controllers/accounts/join_codes_controller_test.rb @@ -37,4 +37,14 @@ class Account::JoinCodesControllerTest < ActionDispatch::IntegrationTest delete account_join_code_path assert_response :forbidden end + + test "update with extremely large usage_limit" do + # A number larger than bigint max (2^63 - 1 = 9223372036854775807) + huge_number = "99999999999999999999999999999999999" + + put account_join_code_path, params: { account_join_code: { usage_limit: huge_number } } + + assert_response :unprocessable_entity + assert_select ".txt-negative", text: /cannot be larger than the population of the planet/ + end end diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb new file mode 100644 index 000000000..a48511cf8 --- /dev/null +++ b/test/controllers/api_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class ApiTest < ActionDispatch::IntegrationTest + setup do + @davids_bearer_token = bearer_token_env(identity_access_tokens(:davids_api_token).token) + @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) + end + + test "authenticate with valid access token" do + get boards_path(format: :json), env: @davids_bearer_token + assert_response :success + end + + test "fail to authenticate with invalid access token" do + get boards_path(format: :json), env: bearer_token_env("nonsense") + assert_response :unauthorized + end + + test "changing data requires a write-endowed access token" do + post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @jasons_bearer_token + assert_response :unauthorized + + post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token + assert_response :success + end + + private + def bearer_token_env(token) + { "HTTP_AUTHORIZATION" => "Bearer #{token}" } + end +end diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb index 7c6e7f525..d7542da27 100644 --- a/test/controllers/boards/columns_controller_test.rb +++ b/test/controllers/boards/columns_controller_test.rb @@ -36,4 +36,52 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest assert_response :success end end + + test "index as JSON" do + board = boards(:writebook) + + get board_columns_path(board), as: :json + + assert_response :success + assert_equal board.columns.count, @response.parsed_body.count + end + + test "show as JSON" do + column = columns(:writebook_in_progress) + + get board_column_path(column.board, column), as: :json + + assert_response :success + assert_equal column.id, @response.parsed_body["id"] + end + + test "create as JSON" do + board = boards(:writebook) + + assert_difference -> { board.columns.count }, +1 do + post board_columns_path(board), params: { column: { name: "New Column" } }, as: :json + end + + assert_response :created + assert_equal board_column_path(board, Column.last, format: :json), @response.headers["Location"] + end + + test "update as JSON" do + column = columns(:writebook_in_progress) + + put board_column_path(column.board, column), params: { column: { name: "Updated Name" } }, as: :json + + assert_response :no_content + assert_equal "Updated Name", column.reload.name + end + + test "destroy as JSON" do + column = columns(:writebook_on_hold) + + assert_difference -> { column.board.columns.count }, -1 do + delete board_column_path(column.board, column), as: :json + end + + assert_response :no_content + end end diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index a7e3580de..7669881cb 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -190,4 +190,44 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}'][disabled]" end end + + test "index as JSON" do + get boards_path, as: :json + assert_response :success + assert_equal users(:kevin).boards.count, @response.parsed_body.count + end + + test "show as JSON" do + get board_path(boards(:writebook)), as: :json + assert_response :success + assert_equal boards(:writebook).name, @response.parsed_body["name"] + end + + test "create as JSON" do + assert_difference -> { Board.count }, +1 do + post boards_path, params: { board: { name: "My new board" } }, as: :json + end + + assert_response :created + assert_equal board_path(Board.last, format: :json), @response.headers["Location"] + end + + test "update as JSON" do + board = boards(:writebook) + + put board_path(board), params: { board: { name: "Updated Name" } }, as: :json + + assert_response :no_content + assert_equal "Updated Name", board.reload.name + end + + test "destroy as JSON" do + board = boards(:writebook) + + assert_difference -> { Board.count }, -1 do + delete board_path(board), as: :json + end + + assert_response :no_content + end end diff --git a/test/controllers/cards/assignments_controller_test.rb b/test/controllers/cards/assignments_controller_test.rb index c823174a8..179f11c2d 100644 --- a/test/controllers/cards/assignments_controller_test.rb +++ b/test/controllers/cards/assignments_controller_test.rb @@ -22,6 +22,20 @@ class Cards::AssignmentsControllerTest < ActionDispatch::IntegrationTest end end + test "create as JSON" do + card = cards(:logo) + + assert_not card.assigned_to?(users(:david)) + + post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json + assert_response :no_content + assert card.reload.assigned_to?(users(:david)) + + post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json + assert_response :no_content + assert_not card.reload.assigned_to?(users(:david)) + end + private def assert_meta_replaced(card) assert_turbo_stream action: :replace, target: dom_id(card, :meta) diff --git a/test/controllers/cards/boards_controller_test.rb b/test/controllers/cards/boards_controller_test.rb index 7f2b15c97..ebadff9b9 100644 --- a/test/controllers/cards/boards_controller_test.rb +++ b/test/controllers/cards/boards_controller_test.rb @@ -17,4 +17,16 @@ class Cards::BoardsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to card end + + test "update as JSON" do + card = cards(:logo) + new_board = boards(:private) + + assert_not_equal new_board, card.board + + put card_board_path(card), params: { board_id: new_board.id }, as: :json + + assert_response :no_content + assert_equal new_board, card.reload.board + end end diff --git a/test/controllers/cards/closures_controller_test.rb b/test/controllers/cards/closures_controller_test.rb index 73c1181e4..f95506f11 100644 --- a/test/controllers/cards/closures_controller_test.rb +++ b/test/controllers/cards/closures_controller_test.rb @@ -9,7 +9,7 @@ class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest card = cards(:logo) assert_changes -> { card.reload.closed? }, from: false, to: true do - post card_closure_path(card) + post card_closure_path(card), as: :turbo_stream assert_card_container_rerendered(card) end end @@ -18,8 +18,30 @@ class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest card = cards(:shipping) assert_changes -> { card.reload.closed? }, from: true, to: false do - delete card_closure_path(card) + delete card_closure_path(card), as: :turbo_stream assert_card_container_rerendered(card) end end + + test "create as JSON" do + card = cards(:logo) + + assert_not card.closed? + + post card_closure_path(card), as: :json + + assert_response :no_content + assert card.reload.closed? + end + + test "destroy as JSON" do + card = cards(:shipping) + + assert card.closed? + + delete card_closure_path(card), as: :json + + assert_response :no_content + assert_not card.reload.closed? + end end diff --git a/test/controllers/cards/comments/reactions_controller_test.rb b/test/controllers/cards/comments/reactions_controller_test.rb index 57d635e96..21a94c5f3 100644 --- a/test/controllers/cards/comments/reactions_controller_test.rb +++ b/test/controllers/cards/comments/reactions_controller_test.rb @@ -7,6 +7,11 @@ class Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest @card = @comment.card end + test "index" do + get card_comment_reactions_path(@card, @comment) + assert_response :success + end + test "create" do assert_difference -> { @comment.reactions.count }, 1 do post card_comment_reactions_path(@comment.card, @comment, format: :turbo_stream), params: { reaction: { content: "Great work!" } } @@ -30,4 +35,29 @@ class Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end end + + test "index as JSON" do + get card_comment_reactions_path(@card, @comment), as: :json + + assert_response :success + assert_equal @comment.reactions.count, @response.parsed_body.count + end + + test "create as JSON" do + assert_difference -> { @comment.reactions.count }, 1 do + post card_comment_reactions_path(@card, @comment), params: { reaction: { content: "👍" } }, as: :json + end + + assert_response :created + end + + test "destroy as JSON" do + reaction = reactions(:david) + + assert_difference -> { @comment.reactions.count }, -1 do + delete card_comment_reaction_path(@card, @comment, reaction), as: :json + end + + assert_response :no_content + end end diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb index 856470302..138fada59 100644 --- a/test/controllers/cards/comments_controller_test.rb +++ b/test/controllers/cards/comments_controller_test.rb @@ -27,4 +27,51 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end + + test "index as JSON" do + card = cards(:logo) + + get card_comments_path(card), as: :json + + assert_response :success + assert_equal card.comments.count, @response.parsed_body.count + end + + test "create as JSON" do + card = cards(:logo) + + assert_difference -> { card.comments.count }, +1 do + post card_comments_path(card), params: { comment: { body: "New comment" } }, as: :json + end + + assert_response :created + assert_equal card_comment_path(card, Comment.last, format: :json), @response.headers["Location"] + end + + test "show as JSON" do + comment = comments(:logo_agreement_kevin) + + get card_comment_path(cards(:logo), comment), as: :json + + assert_response :success + assert_equal comment.id, @response.parsed_body["id"] + end + + test "update as JSON" do + comment = comments(:logo_agreement_kevin) + + put card_comment_path(cards(:logo), comment), params: { comment: { body: "Updated comment" } }, as: :json + + assert_response :success + assert_equal "Updated comment", comment.reload.body.to_plain_text + end + + test "destroy as JSON" do + comment = comments(:logo_agreement_kevin) + + delete card_comment_path(cards(:logo), comment), as: :json + + assert_response :no_content + assert_not Comment.exists?(comment.id) + end end diff --git a/test/controllers/cards/goldnesses_controller_test.rb b/test/controllers/cards/goldnesses_controller_test.rb index 447aa2b71..3e4146443 100644 --- a/test/controllers/cards/goldnesses_controller_test.rb +++ b/test/controllers/cards/goldnesses_controller_test.rb @@ -18,4 +18,26 @@ class Cards::GoldnessesControllerTest < ActionDispatch::IntegrationTest assert_card_container_rerendered(cards(:logo)) end end + + test "create as JSON" do + card = cards(:text) + + assert_not card.golden? + + post card_goldness_path(card), as: :json + + assert_response :no_content + assert card.reload.golden? + end + + test "destroy as JSON" do + card = cards(:logo) + + assert card.golden? + + delete card_goldness_path(card), as: :json + + assert_response :no_content + assert_not card.reload.golden? + end end diff --git a/test/controllers/cards/images_controller_test.rb b/test/controllers/cards/images_controller_test.rb new file mode 100644 index 000000000..5dbedcf22 --- /dev/null +++ b/test/controllers/cards/images_controller_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class Cards::ImagesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "destroy" do + card = cards(:logo) + card.image.attach(io: file_fixture("moon.jpg").open, filename: "moon.jpg") + + assert card.image.attached? + + delete card_image_path(card) + + assert_redirected_to card + assert_not card.reload.image.attached? + end + + test "destroy as JSON" do + card = cards(:logo) + card.image.attach(io: file_fixture("moon.jpg").open, filename: "moon.jpg") + + assert card.image.attached? + + delete card_image_path(card), as: :json + + assert_response :no_content + assert_not card.reload.image.attached? + end +end diff --git a/test/controllers/cards/not_nows_controller_test.rb b/test/controllers/cards/not_nows_controller_test.rb index 0e1313802..e53edabfb 100644 --- a/test/controllers/cards/not_nows_controller_test.rb +++ b/test/controllers/cards/not_nows_controller_test.rb @@ -9,8 +9,19 @@ class Cards::NotNowsControllerTest < ActionDispatch::IntegrationTest card = cards(:logo) assert_changes -> { card.reload.postponed? }, from: false, to: true do - post card_not_now_path(card) + post card_not_now_path(card), as: :turbo_stream assert_card_container_rerendered(card) end end + + test "create as JSON" do + card = cards(:logo) + + assert_not card.postponed? + + post card_not_now_path(card), as: :json + + assert_response :no_content + assert card.reload.postponed? + end end diff --git a/test/controllers/cards/steps_controller_test.rb b/test/controllers/cards/steps_controller_test.rb index 72c24f071..039fbf4c4 100644 --- a/test/controllers/cards/steps_controller_test.rb +++ b/test/controllers/cards/steps_controller_test.rb @@ -52,4 +52,47 @@ class Cards::StepsControllerTest < ActionDispatch::IntegrationTest assert_turbo_stream action: :replace, target: dom_id(step) end end + + test "create as JSON" do + card = cards(:logo) + + assert_difference -> { card.steps.count }, +1 do + post card_steps_path(card), params: { step: { content: "New step" } }, as: :json + end + + assert_response :created + assert_equal card_step_path(card, Step.last, format: :json), @response.headers["Location"] + end + + test "show as JSON" do + card = cards(:logo) + step = card.steps.create!(content: "Test step") + + get card_step_path(card, step), as: :json + + assert_response :success + assert_equal step.id, @response.parsed_body["id"] + assert_equal "Test step", @response.parsed_body["content"] + end + + test "update as JSON" do + card = cards(:logo) + step = card.steps.create!(content: "Original") + + put card_step_path(card, step), params: { step: { content: "Updated" } }, as: :json + + assert_response :success + assert_equal "Updated", step.reload.content + assert_equal "Updated", @response.parsed_body["content"] + end + + test "destroy as JSON" do + card = cards(:logo) + step = card.steps.create!(content: "To delete") + + delete card_step_path(card, step), as: :json + + assert_response :no_content + assert_not Step.exists?(step.id) + end end diff --git a/test/controllers/cards/taggings_controller_test.rb b/test/controllers/cards/taggings_controller_test.rb index 5424dcc0d..0cd214509 100644 --- a/test/controllers/cards/taggings_controller_test.rb +++ b/test/controllers/cards/taggings_controller_test.rb @@ -23,4 +23,26 @@ class Cards::TaggingsControllerTest < ActionDispatch::IntegrationTest assert_turbo_stream action: :replace, target: dom_id(cards(:logo), :tags) end end + + test "toggle tag on as JSON" do + card = cards(:logo) + + assert_not card.tagged_with?(tags(:mobile)) + + post card_taggings_path(card), params: { tag_title: tags(:mobile).title }, as: :json + + assert_response :no_content + assert card.reload.tagged_with?(tags(:mobile)) + end + + test "toggle tag off as JSON" do + card = cards(:logo) + + assert card.tagged_with?(tags(:web)) + + post card_taggings_path(card), params: { tag_title: tags(:web).title }, as: :json + + assert_response :no_content + assert_not card.reload.tagged_with?(tags(:web)) + end end diff --git a/test/controllers/cards/triages_controller_test.rb b/test/controllers/cards/triages_controller_test.rb index 9076cdade..02b6d8178 100644 --- a/test/controllers/cards/triages_controller_test.rb +++ b/test/controllers/cards/triages_controller_test.rb @@ -24,4 +24,25 @@ class Cards::TriagesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to card end end + + test "create as JSON" do + card = cards(:logo) + column = columns(:writebook_in_progress) + + post card_triage_path(card, column_id: column.id), as: :json + + assert_response :no_content + assert_equal column, card.reload.column + end + + test "destroy as JSON" do + card = cards(:shipping) + + assert card.column.present? + + delete card_triage_path(card), as: :json + + assert_response :no_content + assert_nil card.reload.column + end end diff --git a/test/controllers/cards/watches_controller_test.rb b/test/controllers/cards/watches_controller_test.rb index 5440a326b..7bcd82c3d 100644 --- a/test/controllers/cards/watches_controller_test.rb +++ b/test/controllers/cards/watches_controller_test.rb @@ -9,7 +9,7 @@ class Cards::WatchesControllerTest < ActionDispatch::IntegrationTest cards(:logo).unwatch_by users(:kevin) assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: false, to: true do - post card_watch_path(cards(:logo)) + post card_watch_path(cards(:logo)), as: :turbo_stream end end @@ -17,7 +17,31 @@ class Cards::WatchesControllerTest < ActionDispatch::IntegrationTest cards(:logo).watch_by users(:kevin) assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: true, to: false do - delete card_watch_path(cards(:logo)) + delete card_watch_path(cards(:logo)), as: :turbo_stream end end + + test "create as JSON" do + card = cards(:logo) + card.unwatch_by users(:kevin) + + assert_not card.watched_by?(users(:kevin)) + + post card_watch_path(card), as: :json + + assert_response :no_content + assert card.reload.watched_by?(users(:kevin)) + end + + test "destroy as JSON" do + card = cards(:logo) + card.watch_by users(:kevin) + + assert card.watched_by?(users(:kevin)) + + delete card_watch_path(card), as: :json + + assert_response :no_content + assert_not card.reload.watched_by?(users(:kevin)) + end end diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index ea5d4e50e..3800c0652 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -132,4 +132,47 @@ class CardsControllerTest < ActionDispatch::IntegrationTest get card_path(card) assert_response :success end + + test "show as JSON" do + card = cards(:logo) + card.steps.create!(content: "First step") + card.steps.create!(content: "Second step", completed: true) + + get card_path(card), as: :json + assert_response :success + assert_equal card.title, @response.parsed_body["title"] + assert_equal 2, @response.parsed_body["steps"].size + end + + test "create as JSON" do + assert_difference -> { Card.count }, +1 do + post board_cards_path(boards(:writebook)), + params: { card: { title: "My new card", description: "Big if true", tag_ids: [ tags(:web).id, tags(:mobile).id ] } }, + as: :json + end + + assert_response :created + assert_equal card_path(Card.last, format: :json), @response.headers["Location"] + + card = Card.last + assert_equal "My new card", card.title + assert_equal "Big if true", card.description.to_plain_text + assert_equal [ tags(:mobile), tags(:web) ].sort, card.tags.sort + end + + test "update as JSON" do + card = cards(:logo) + put card_path(card, format: :json), params: { card: { title: "Update test" } } + + assert_response :success + assert_equal "Update test", card.reload.title + end + + test "delete as JSON" do + card = cards(:logo) + delete card_path(card, format: :json) + + assert_response :no_content + assert_not Card.exists?(card.id) + end end diff --git a/test/controllers/my/access_tokens_controller_test.rb b/test/controllers/my/access_tokens_controller_test.rb new file mode 100644 index 000000000..43a90fac5 --- /dev/null +++ b/test/controllers/my/access_tokens_controller_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "create new token" do + get my_access_tokens_path + assert_response :success + + get new_my_access_token_path + assert_response :success + + assert_changes -> { identities(:kevin).access_tokens.count }, +1 do + post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } } + follow_redirect! + assert_in_body identities(:kevin).access_tokens.last.token + end + end + + test "accessing new token after reveal window redirects to index" do + assert_changes -> { identities(:kevin).access_tokens.count }, +1 do + post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } } + travel_to 15.seconds.from_now + follow_redirect! + assert_equal "Token is no longer visible", flash[:alert] + end + end +end diff --git a/test/controllers/my/identities_controller_test.rb b/test/controllers/my/identities_controller_test.rb new file mode 100644 index 000000000..29e17cb85 --- /dev/null +++ b/test/controllers/my/identities_controller_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class My::IdentitiesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "show as JSON" do + identity = identities(:kevin) + + untenanted do + get my_identity_path, as: :json + assert_response :success + assert_equal identity.accounts.count, @response.parsed_body["accounts"].count + end + end +end diff --git a/test/controllers/notifications/bulk_readings_controller_test.rb b/test/controllers/notifications/bulk_readings_controller_test.rb index 124490f53..3512b7b43 100644 --- a/test/controllers/notifications/bulk_readings_controller_test.rb +++ b/test/controllers/notifications/bulk_readings_controller_test.rb @@ -22,4 +22,14 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes post bulk_reading_path, params: { from_tray: true } assert_response :ok end + + test "create as JSON" do + assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do + post bulk_reading_path, as: :json + end + end + + assert_response :no_content + end end diff --git a/test/controllers/notifications/readings_controller_test.rb b/test/controllers/notifications/readings_controller_test.rb index f25d77ccc..3f2a690a9 100644 --- a/test/controllers/notifications/readings_controller_test.rb +++ b/test/controllers/notifications/readings_controller_test.rb @@ -21,4 +21,21 @@ class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest assert_response :success end end + + test "create as JSON" do + assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + post notification_reading_path(notifications(:logo_published_kevin)), as: :json + assert_response :no_content + end + end + + test "destroy as JSON" do + notification = notifications(:logo_published_kevin) + notification.read + + assert_changes -> { notification.reload.read? }, from: true, to: false do + delete notification_reading_path(notification), as: :json + assert_response :no_content + end + end end diff --git a/test/controllers/notifications_controller_test.rb b/test/controllers/notifications_controller_test.rb index 13c211783..e4e7bec38 100644 --- a/test/controllers/notifications_controller_test.rb +++ b/test/controllers/notifications_controller_test.rb @@ -1,4 +1,27 @@ require "test_helper" class NotificationsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index as JSON" do + get notifications_path, as: :json + + assert_response :success + assert_kind_of Array, @response.parsed_body + assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_published_kevin).id } + end + + test "index as JSON includes notification attributes" do + get notifications_path, as: :json + + notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_published_kevin).id } + + assert_not_nil notification["title"] + assert_not_nil notification["body"] + assert_not_nil notification["created_at"] + assert_not_nil notification["card"] + assert_not_nil notification["creator"] + end end diff --git a/test/controllers/tags_controller_test.rb b/test/controllers/tags_controller_test.rb new file mode 100644 index 000000000..d99939c60 --- /dev/null +++ b/test/controllers/tags_controller_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class TagsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index as JSON" do + tags = users(:kevin).account.tags.alphabetically + + get tags_path, as: :json + assert_response :success + assert_equal tags.count, @response.parsed_body.count + assert_equal tags.pluck(:title), @response.parsed_body.pluck("title") + end +end diff --git a/test/controllers/users/push_subscriptions_controller_test.rb b/test/controllers/users/push_subscriptions_controller_test.rb index c41d8313e..4b4dd0a09 100644 --- a/test/controllers/users/push_subscriptions_controller_test.rb +++ b/test/controllers/users/push_subscriptions_controller_test.rb @@ -14,30 +14,12 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest post user_push_subscriptions_path(users(:david)), params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" } - assert_response :ok + assert_response :no_content assert_equal subscription_params, users(:david).push_subscriptions.last.attributes.slice("endpoint", "p256dh_key", "auth_key") assert_equal "Mozilla/5.0", users(:david).push_subscriptions.last.user_agent end - test "touch existing subscription" do - existing_subscription = users(:david).push_subscriptions.create!( - endpoint: "https://fcm.googleapis.com/fcm/send/abc123", - p256dh_key: "123", - auth_key: "456" - ) - - assert_no_difference -> { users(:david).push_subscriptions.count } do - assert_changes -> { existing_subscription.reload.updated_at } do - post user_push_subscriptions_path(users(:david)), params: { - push_subscription: existing_subscription.attributes.slice("endpoint", "p256dh_key", "auth_key") - } - end - end - - assert_response :ok - end - test "destroy a push subscription" do subscription = users(:david).push_subscriptions.create!( endpoint: "https://fcm.googleapis.com/fcm/send/abc123", diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 385bacaf3..20008dcee 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -86,4 +86,50 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert users(:kevin).reload.avatar.attached? assert_equal "image/png", users(:kevin).avatar.content_type end + + test "index as JSON" do + sign_in_as :kevin + + get users_path, as: :json + assert_response :success + assert_equal users(:kevin).account.users.active.count, @response.parsed_body.count + end + + test "show as JSON" do + sign_in_as :kevin + + get user_path(users(:david)), as: :json + assert_response :success + assert_equal users(:david).name, @response.parsed_body["name"] + end + + test "update as JSON" do + sign_in_as :kevin + + put user_path(users(:david)), params: { user: { name: "New David" } }, as: :json + + assert_response :no_content + assert_equal "New David", users(:david).reload.name + end + + test "update as JSON with invalid avatar returns errors" do + sign_in_as :kevin + + svg_file = fixture_file_upload("avatar.svg", "image/svg+xml") + + put user_path(users(:kevin), format: :json), params: { user: { avatar: svg_file } } + + assert_response :unprocessable_entity + assert @response.parsed_body["avatar"].present? + end + + test "destroy as JSON" do + sign_in_as :kevin + + assert_difference -> { User.active.count }, -1 do + delete user_path(users(:david)), as: :json + end + + assert_response :no_content + end end diff --git a/test/fixtures/identity/access_tokens.yml b/test/fixtures/identity/access_tokens.yml new file mode 100644 index 000000000..cfbbb6248 --- /dev/null +++ b/test/fixtures/identity/access_tokens.yml @@ -0,0 +1,11 @@ +jasons_api_token: + identity: jason + token: 018cf1425682700098f24f0799e3fe20 + description: My Superscript + permission: read + +davids_api_token: + identity: david + token: x18cf1425682700098f24f0799e3fe20 + description: My Superscript + permission: write diff --git a/test/helpers/html_helper_test.rb b/test/helpers/html_helper_test.rb index e6389a5fa..4011cbf70 100644 --- a/test/helpers/html_helper_test.rb +++ b/test/helpers/html_helper_test.rb @@ -10,25 +10,65 @@ class HtmlHelperTest < ActionView::TestCase format_html("

    Check this: https://example.com/

    ") end - test "don't' include punctuation in URL autolinking" do + test "don't include punctuation in URL autolinking" do assert_equal_html \ - %(

    Check this: https://example.com!

    ), - format_html("

    Check this: https://example.com!

    ") + %(

    Check this: https://example.com/!

    ), + format_html("

    Check this: https://example.com/!

    ") assert_equal_html \ - %(

    Check this: https://example.com.

    ), - format_html("

    Check this: https://example.com.

    ") + %(

    Check this: https://example.com/.

    ), + format_html("

    Check this: https://example.com/.

    ") assert_equal_html \ - %(

    Check this: https://example.com?

    ), - format_html("

    Check this: https://example.com?

    ") + %(

    Check this: https://example.com/?

    ), + format_html("

    Check this: https://example.com/?

    ") assert_equal_html \ - %(

    Check this: https://example.com,

    ), - format_html("

    Check this: https://example.com,

    ") + %(

    Check this: https://example.com/,

    ), + format_html("

    Check this: https://example.com/,

    ") assert_equal_html \ - %(

    Check this: https://example.com:

    ), - format_html("

    Check this: https://example.com:

    ") + %(

    Check this: https://example.com/:

    ), + format_html("

    Check this: https://example.com/:

    ") assert_equal_html \ - %(

    Check this: https://example.com;

    ), - format_html("

    Check this: https://example.com;

    ") + %(

    Check this: https://example.com/;

    ), + format_html("

    Check this: https://example.com/;

    ") + assert_equal_html \ + %(

    Check this: https://example.com/"

    ), + format_html("

    Check this: https://example.com/\"

    ") + assert_equal_html \ + %(

    Check this: https://example.com/'

    ), + format_html("

    Check this: https://example.com/'

    ") + + # trailing entities that decode to punctuation + # use assert_equal and not assert_equal_html to make sure we're getting entities correct + assert_equal \ + %(

    Check this: https://example.com/<

    ), + format_html("

    Check this: https://example.com/<

    ") + assert_equal \ + %(

    Check this: https://example.com/>

    ), + format_html("

    Check this: https://example.com/>

    ") + assert_equal \ + %(

    Check this: https://example.com/"

    ), + format_html("

    Check this: https://example.com/"

    ") + + # multiple punctuation characters including entities + assert_equal_html \ + %(

    Check this: https://example.com/!?;

    ), + format_html("

    Check this: https://example.com/!?;

    ") + assert_equal_html \ + %(<img src="https://example.com/">), + format_html(%(<img src="https://example.com/">)) + assert_equal_html \ + %(<img src="https://example.com/"!>), + format_html(%(<img src="https://example.com/"!>)) + end + + test "handle URLs with query parameters" do + # use assert_equal and not assert_equal_html to make sure we're getting entities correct + assert_equal \ + %(

    Check this: https://example.com/a?b=c&d=e

    ), + format_html("

    Check this: https://example.com/a?b=c&d=e

    ") + + assert_equal \ + %(

    Check this: https://example.com/a?b=c&d=e

    ), + format_html("

    Check this: https://example.com/a?b=c&d=e

    ") end test "respect existing links" do @@ -56,4 +96,12 @@ class HtmlHelperTest < ActionView::TestCase format_html("<#{element}>Check this: https://example.com") end end + + test "preserve escaped HTML containing URLs" do + input = 'before text <img src="https://example.com/image.png"> after text' + output = format_html(input) + + assert_no_match(/Injected" + Current.session = sessions(:david) + + assert_difference -> { @card.comments.count }, 1 do + @card.toggle_assignment users(:kevin) + end + + comment = @card.comments.last + html = comment.body.body.to_html + assert_includes html, "<em>Injected</em> assigned this to Kevin." + refute_includes html, "Injected" + end + test "don't notify on system comments" do @card.watch_by(users(:david)) diff --git a/test/models/identity/access_token_test.rb b/test/models/identity/access_token_test.rb new file mode 100644 index 000000000..7324d97ab --- /dev/null +++ b/test/models/identity/access_token_test.rb @@ -0,0 +1,4 @@ +require "test_helper" + +class Identity::AccessTokenTest < ActiveSupport::TestCase +end diff --git a/test/models/push/subscription_test.rb b/test/models/push/subscription_test.rb index 9f4650614..89887599f 100644 --- a/test/models/push/subscription_test.rb +++ b/test/models/push/subscription_test.rb @@ -98,6 +98,7 @@ class Push::SubscriptionTest < ActiveSupport::TestCase test "accepts all permitted push service domains" do permitted_endpoints = [ "https://fcm.googleapis.com/fcm/send/token123", + "https://jmt17.google.com/fcm/send/token123", "https://updates.push.services.mozilla.com/wpush/v2/token123", "https://web.push.apple.com/QaBC123", "https://wns2-db5p.notify.windows.com/w/?token=abc123" diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index 30f91390e..d4d95528c 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -296,12 +296,15 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase stub_dns_resolution(PUBLIC_TEST_IP) # Verify Net::HTTP.new is called with the pinned IP + response_mock = stub(code: "200") + response_mock.stubs(:read_body) + http_mock = mock("http") http_mock.stubs(:use_ssl=) http_mock.stubs(:ipaddr=) http_mock.stubs(:open_timeout=) http_mock.stubs(:read_timeout=) - http_mock.stubs(:request).returns(stub(code: "200")) + http_mock.stubs(:request).yields(response_mock).returns(response_mock) Net::HTTP.expects(:new).with("example.com", 443).returns(http_mock) @@ -310,6 +313,32 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert delivery.succeeded? end + test "handles response too large error" do + delivery = webhook_deliveries(:pending) + + large_body = "x" * 200.kilobytes + stub_request(:post, delivery.webhook.url).to_return(status: 200, body: large_body) + + delivery.deliver + + assert_equal "completed", delivery.state + assert_equal "response_too_large", delivery.response[:error] + assert_not delivery.succeeded? + end + + test "allows responses within size limit" do + delivery = webhook_deliveries(:pending) + + small_body = "x" * 50.kilobytes + stub_request(:post, delivery.webhook.url).to_return(status: 200, body: small_body) + + delivery.deliver + + assert_equal "completed", delivery.state + assert_equal 200, delivery.response[:code] + assert delivery.succeeded? + end + private def stub_dns_resolution(*ips) dns_mock = mock("dns") diff --git a/test/test_helper.rb b/test/test_helper.rb index bc856d815..12ab491ed 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -38,7 +38,7 @@ end module ActiveSupport class TestCase - parallelize(workers: :number_of_processors) + parallelize workers: :number_of_processors, work_stealing: ENV["WORK_STEALING"] != "false" # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all @@ -66,9 +66,11 @@ class ActionDispatch::IntegrationTest def without_action_dispatch_exception_handling original = Rails.application.config.action_dispatch.show_exceptions Rails.application.config.action_dispatch.show_exceptions = :none + Rails.application.instance_variable_set(:@app_env_config, nil) # Clear memoized env_config yield ensure Rails.application.config.action_dispatch.show_exceptions = original + Rails.application.instance_variable_set(:@app_env_config, nil) # Reset env_config end end