diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3802125d237b6..66f98384d2400 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ruby/.devcontainer/base.Dockerfile # [Choice] Ruby version: 3.4, 3.3, 3.2 -ARG VARIANT="3.4.2" +ARG VARIANT="3.4.7" FROM ghcr.io/rails/devcontainer/images/ruby:${VARIANT} RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ @@ -9,7 +9,7 @@ RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ mariadb-client libmariadb-dev \ postgresql-client postgresql-contrib libpq-dev \ ffmpeg mupdf mupdf-tools libvips-dev poppler-utils \ - libxml2-dev sqlite3 imagemagick + libxml2-dev sqlite3 imagemagick tzdata-legacy # Add the Rails main Gemfile and install the gems. This means the gem install can be done # during build instead of on start. When a fork or branch has different gems, we still have an @@ -33,6 +33,10 @@ COPY tools/releaser/releaser.gemspec /tmp/rails/tools/releaser/ # can bundle as vscode user and then remove the tmp dir RUN sudo chown -R vscode:vscode /tmp/rails USER vscode +RUN cat <<-EOF > $HOME/.my.cnf && chmod 600 $HOME/.my.cnf +[client] +ssl=OFF +EOF RUN cd /tmp/rails \ - && /home/vscode/.rbenv/shims/bundle install \ + && bash -i -c 'bundle install' \ && rm -rf /tmp/rails diff --git a/.devcontainer/boot.sh b/.devcontainer/boot.sh index e84447064ebb8..ee03e3678a5e6 100755 --- a/.devcontainer/boot.sh +++ b/.devcontainer/boot.sh @@ -1,5 +1,6 @@ #!/bin/sh +bundle update --bundler bundle install if [ -n "${NVM_DIR}" ]; then diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index c80a4645102b8..2872f87dd19a3 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -12,7 +12,7 @@ services: depends_on: - postgres - - mariadb + - mysql - redis - memcached @@ -26,19 +26,19 @@ services: image: postgres:latest restart: unless-stopped volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql environment: POSTGRES_USER: postgres POSTGRES_DB: postgres POSTGRES_PASSWORD: postgres - mariadb: - image: mariadb:lts + mysql: + image: mysql:latest restart: unless-stopped volumes: - - mariadb-data:/var/lib/mysql + - mysql-data:/var/lib/mysql environment: - MARIADB_ROOT_PASSWORD: root + MYSQL_ROOT_PASSWORD: root redis: image: valkey/valkey:8 @@ -53,5 +53,5 @@ services: volumes: postgres-data: - mariadb-data: + mysql-data: redis-data: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b58a47fa95163..302b4c600027d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,8 +13,8 @@ "ghcr.io/devcontainers/features/node:1": { "version": "latest" }, - "ghcr.io/rails/devcontainer/features/postgres-client:1.1.1": { - "version": "17" + "ghcr.io/rails/devcontainer/features/postgres-client:1.1.3": { + "version": "18" } }, @@ -22,7 +22,7 @@ "PGHOST": "postgres", "PGUSER": "postgres", "PGPASSWORD": "postgres", - "MYSQL_HOST": "mariadb", + "MYSQL_HOST": "mysql", "REDIS_URL": "redis://redis/0", "MEMCACHE_SERVERS": "memcached:11211" }, diff --git a/.github/autolabeler.yml b/.github/autolabeler.yml deleted file mode 100644 index 6eb6f354e57bc..0000000000000 --- a/.github/autolabeler.yml +++ /dev/null @@ -1,28 +0,0 @@ -actioncable: - - "actioncable/**/*" -actionmailbox: - - "actionmailbox/**/*" -actionmailer: - - "actionmailer/**/*" -actionpack: - - "actionpack/**/*" -actiontext: - - "actiontext/**/*" -actionview: - - "actionview/**/*" -activejob: - - "activejob/**/*" -activemodel: - - "activemodel/**/*" -activerecord: - - "activerecord/**/*" -activestorage: - - "activestorage/**/*" -activesupport: - - "activesupport/**/*" -rails-ujs: - - "actionview/app/assets/javascripts/rails-ujs*/*" -railties: - - "railties/**/*" -docs: - - "guides/**/*" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000000000..b9d0d268fc062 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,42 @@ +actioncable: +- changed-files: + - any-glob-to-any-file: "actioncable/**/*" +actionmailbox: +- changed-files: + - any-glob-to-any-file: "actionmailbox/**/*" +actionmailer: +- changed-files: + - any-glob-to-any-file: "actionmailer/**/*" +actionpack: +- changed-files: + - any-glob-to-any-file: "actionpack/**/*" +actiontext: +- changed-files: + - any-glob-to-any-file: "actiontext/**/*" +actionview: +- changed-files: + - any-glob-to-any-file: "actionview/**/*" +activejob: +- changed-files: + - any-glob-to-any-file: "activejob/**/*" +activemodel: +- changed-files: + - any-glob-to-any-file: "activemodel/**/*" +activerecord: +- changed-files: + - any-glob-to-any-file: "activerecord/**/*" +activestorage: +- changed-files: + - any-glob-to-any-file: "activestorage/**/*" +activesupport: +- changed-files: + - any-glob-to-any-file: "activesupport/**/*" +rails-ujs: +- changed-files: + - any-glob-to-any-file: "actionview/app/assets/javascripts/rails-ujs/**/*" +railties: +- changed-files: + - any-glob-to-any-file: "railties/**/*" +docs: +- changed-files: + - any-glob-to-any-file: "guides/**/*" diff --git a/.github/no-response.yml b/.github/no-response.yml deleted file mode 100644 index 326fa84b7e740..0000000000000 --- a/.github/no-response.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Configuration for probot-no-response - https://github.com/probot/no-response - -# Number of days of inactivity before an Issue is closed for lack of response -daysUntilClose: 14 -# Label requiring a response -responseRequiredLabel: more-information-needed -# Comment to post when closing an Issue for lack of response. Set to `false` to disable -closeComment: > - This issue has been automatically closed because there has been no follow-up - response from the original author. We currently don't have enough - information in order to take action. Please reach out if you have any additional - information that will help us move this issue forward. diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 2b40308582bee..0000000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 90 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - pinned - - security - - With reproduction steps - - attached PR - - regression - - release blocker -# Issues on a milestone will never be considered stale -exemptMilestones: true -# Label to use when marking an issue as stale -staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not been commented on for at least three months. - - The resources of the Rails team are limited, and so we are asking for your help. - - If you can still reproduce this error on the `8-0-stable` branch or on `main`, - please reply with all of the information you have about it in order to keep the issue open. - - Thank you for all your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false -# Limit to only `issues` or `pulls` -only: issues diff --git a/.github/verba-sequentur.yml b/.github/verba-sequentur.yml deleted file mode 100644 index 0fc1a9586a9a4..0000000000000 --- a/.github/verba-sequentur.yml +++ /dev/null @@ -1,21 +0,0 @@ -# Documentation: https://github.com/jonathanhefner/verba-sequentur - -"support request": - comment: > - This appears to be a request for technical support. We reserve the - issue tracker for issues only. For technical support questions, - please use the [rubyonrails-talk](https://discuss.rubyonrails.org/c/rubyonrails-talk/7) - forum or [Stack Overflow](https://stackoverflow.com/questions/tagged/ruby-on-rails), - where a wider community can help you. - close: true - -"feature request": - comment: > - This appears to be a feature request. We generally do not take - feature requests, and we reserve the issue tracker for issues only. - We recommend you [try to implement the feature]( - https://guides.rubyonrails.org/contributing_to_ruby_on_rails.html#contributing-to-the-rails-code), - and send us a pull request instead. If you are unsure if the feature - would be accepted, please ask on the [rubyonrails-core]( - https://discuss.rubyonrails.org/c/rubyonrails-core/5) forum. - close: true diff --git a/.github/workflows/devcontainer-shellcheck.yml b/.github/workflows/devcontainer-shellcheck.yml index 57eb1c3aa8ca4..cb5f314072889 100644 --- a/.github/workflows/devcontainer-shellcheck.yml +++ b/.github/workflows/devcontainer-shellcheck.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout (GitHub) - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Lint Devcontainer Scripts run: | diff --git a/.github/workflows/devcontainer-smoke-test.yml b/.github/workflows/devcontainer-smoke-test.yml index ae5c1a2e58b7c..872980edcd8f2 100644 --- a/.github/workflows/devcontainer-smoke-test.yml +++ b/.github/workflows/devcontainer-smoke-test.yml @@ -15,10 +15,10 @@ jobs: steps: - name: Checkout (GitHub) - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -34,7 +34,7 @@ jobs: run: bundle exec railties/exe/rails new myapp_sqlite --database="sqlite3" --dev --devcontainer - name: Test devcontainer sqlite3 - uses: devcontainers/ci@v0.3 + uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.1900000417 with: subFolder: myapp_sqlite imageName: ghcr.io/rails/smoke-test-devcontainer @@ -51,7 +51,7 @@ jobs: run: bundle exec railties/exe/rails new myapp_postgresql --database="postgresql" --dev --devcontainer - name: Test devcontainer postgresql - uses: devcontainers/ci@v0.3 + uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.1900000417 with: subFolder: myapp_postgresql imageName: ghcr.io/rails/smoke-test-devcontainer @@ -68,7 +68,7 @@ jobs: run: bundle exec railties/exe/rails new myapp_mysql --database="mysql" --dev --devcontainer - name: Test devcontainer mysql - uses: devcontainers/ci@v0.3 + uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.1900000417 with: subFolder: myapp_mysql imageName: ghcr.io/rails/smoke-test-devcontainer @@ -85,7 +85,7 @@ jobs: run: bundle exec railties/exe/rails new myapp_trilogy --database="trilogy" --dev --devcontainer - name: Test devcontainer trilogy - uses: devcontainers/ci@v0.3 + uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.1900000417 with: subFolder: myapp_trilogy imageName: ghcr.io/rails/smoke-test-devcontainer diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000000000..ae48b12554d25 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,24 @@ +name: "rails-bot: Label PRs" +on: + # This event runs in the context of the base of the pull request, rather than + # in the context of the merge commit, as the pull_request event does. This + # prevents execution of unsafe code from the head of the pull request that + # could alter your repository or steal any secrets you use in your workflow. + # This event allows your workflow to do things like label or comment on pull + # requests from forks. Avoid using this event if you need to build or run + # code from the pull request. + # + # https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request_target + pull_request_target: + +jobs: + labeler: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/more-info-needed.yml b/.github/workflows/more-info-needed.yml new file mode 100644 index 0000000000000..37bf30ffc4f40 --- /dev/null +++ b/.github/workflows/more-info-needed.yml @@ -0,0 +1,27 @@ +name: "rails-bot: More Info Needed" + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + more-info-needed: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: -1 + days-before-close: 14 + operations-per-run: 100 + stale-issue-label: "more-information-needed" + close-issue-message: > + This issue has been automatically closed because there has been no follow-up + response from the original author. We currently don't have enough + information in order to take action. Please reach out if you have any additional + information that will help us move this issue forward. + only-labels: "more-information-needed" + only-pr-labels: false diff --git a/.github/workflows/rail_inspector.yml b/.github/workflows/rail_inspector.yml index 9bc240995ded5..aa6a83bc6ed23 100644 --- a/.github/workflows/rail_inspector.yml +++ b/.github/workflows/rail_inspector.yml @@ -16,7 +16,7 @@ jobs: name: rail_inspector tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Remove Gemfile.lock run: rm -f Gemfile.lock - name: Set up Ruby diff --git a/.github/workflows/rails-new-docker.yml b/.github/workflows/rails-new-docker.yml index 8b36f0897085c..082ead2216ff8 100644 --- a/.github/workflows/rails-new-docker.yml +++ b/.github/workflows/rails-new-docker.yml @@ -14,7 +14,7 @@ jobs: rails-new-docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Remove Gemfile.lock run: rm -f Gemfile.lock - name: Set up Ruby @@ -34,6 +34,7 @@ jobs: - name: Run container run: | podman run --name $APP_NAME \ + --user root \ -v $(pwd):$(pwd) \ -e SECRET_KEY_BASE_DUMMY=1 \ -e DATABASE_URL=sqlite3:storage/production.sqlite3 \ @@ -41,7 +42,7 @@ jobs: - name: Test container run: ruby -r ./.github/workflows/scripts/test-container.rb - - uses: zzak/action-discord@v8 + - uses: zzak/action-discord@4cd181470664aa174b7252e5afb2ecf896001817 # v8 continue-on-error: true if: failure() && github.ref_name == 'main' with: diff --git a/.github/workflows/rails_releaser_tests.yml b/.github/workflows/rails_releaser_tests.yml index f3c2a0f13bb47..92b19b30ddc60 100644 --- a/.github/workflows/rails_releaser_tests.yml +++ b/.github/workflows/rails_releaser_tests.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c84b75eda69..47da6d936a9d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -26,6 +26,9 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Configure trusted publishing credentials uses: rubygems/configure-rubygems-credentials@v1.0.0 + # Ensure npm 11.5.1 or later is installed + - name: Update npm + run: npm install -g npm@latest - name: Bundle install run: bundle install working-directory: tools/releaser @@ -33,8 +36,6 @@ jobs: run: bundle exec rake push shell: bash working-directory: tools/releaser - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Wait for release to propagate run: gem exec rubygems-await pkg/*.gem shell: bash diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000000..6fbbc6c33b25e --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,33 @@ +name: "rails-bot: Stale Issues" +on: + schedule: + - cron: '0 * * * *' + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: 90 + days-before-close: 7 + operations-per-run: 100 + exempt-milestones: true + exempt-issue-labels: 'pinned,security,With reproduction steps,attached PR,regression,release blocker,more-information-needed' + stale-issue-label: 'stale' + stale-pr-label: 'stale' + exempt-all-milestones: true + stale-issue-message: > + This issue has been automatically marked as stale because it has not been commented on for at least three months. + + The resources of the Rails team are limited, and so we are asking for your help. + + If you can still reproduce this error on the `8-1-stable` branch or on `main`, + please reply with all of the information you have about it in order to keep the issue open. + + Thank you for all your contributions. + only-pr-labels: false diff --git a/.mdlrc.rb b/.mdlrc.rb index 78d01e5ccde72..2788e6a1ffcbd 100644 --- a/.mdlrc.rb +++ b/.mdlrc.rb @@ -11,6 +11,7 @@ exclude_rule "MD014" exclude_rule "MD024" exclude_rule "MD026" +exclude_rule "MD032" exclude_rule "MD033" exclude_rule "MD034" exclude_rule "MD036" diff --git a/.rubocop.yml b/.rubocop.yml index 12bbd017ba524..a1bae07239aa7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,12 +1,10 @@ plugins: - rubocop-minitest + - rubocop-packaging - rubocop-performance - rubocop-rails - rubocop-md -require: - - rubocop-packaging - AllCops: # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop # to ignore them, so only the ones explicitly set in this file are enabled. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51e1236001b8a..cd339c4e34d53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ Changes that are cosmetic in nature and do not add anything substantial to the s #### **Do you intend to add a new feature or change an existing one?** -* Suggest your change in the [rubyonrails-core mailing list](https://discuss.rubyonrails.org/c/rubyonrails-core) and start writing code. +* Suggest your change in the [rubyonrails-core forum](https://discuss.rubyonrails.org/c/rubyonrails-core) and start writing code. * Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. diff --git a/Gemfile b/Gemfile index 180be0482251e..6fefb2c143557 100644 --- a/Gemfile +++ b/Gemfile @@ -28,10 +28,11 @@ gem "solid_queue" gem "solid_cable" gem "kamal", ">= 2.1.0", require: false gem "thruster", require: false -# require: false so bcrypt is loaded only when has_secure_password is used. +# require: false so bcrypt and argon2 are loaded only when has_secure_password is used. # This is to avoid Active Model (and by extension the entire framework) -# being dependent on a binary library. +# being dependent on binary libraries. gem "bcrypt", "~> 3.1.11", require: false +gem "argon2", "~> 2.3.2", require: false # This needs to be with require false to avoid it being automatically loaded by # sprockets. @@ -46,9 +47,7 @@ gem "uri", ">= 0.13.1", require: false gem "prism" group :rubocop do - # Rubocop has to be locked in the Gemfile because CI ignores Gemfile.lock - # We don't want rubocop to start failing whenever rubocop makes a new release. - gem "rubocop", "< 1.73", require: false + gem "rubocop", "1.79.2", require: false gem "rubocop-minitest", require: false gem "rubocop-packaging", require: false gem "rubocop-performance", require: false @@ -64,8 +63,7 @@ group :mdl do end group :doc do - gem "sdoc", git: "https://github.com/rails/sdoc.git", branch: "main" - gem "rdoc", "< 6.10" + gem "sdoc", "~> 2.6.4" gem "redcarpet", "~> 3.6.1", platforms: :ruby gem "w3c_validators", "~> 1.3.6" gem "rouge" @@ -101,7 +99,6 @@ group :job do gem "resque", require: false gem "resque-scheduler", require: false gem "sidekiq", require: false - gem "sucker_punch", require: false gem "queue_classic", ">= 4.0.0", require: false, platforms: :ruby gem "sneakers", require: false gem "backburner", require: false @@ -122,7 +119,6 @@ end group :storage do gem "aws-sdk-s3", require: false gem "google-cloud-storage", "~> 1.11", require: false - gem "azure-storage-blob", "~> 2.0", require: false gem "image_processing", "~> 1.2" end @@ -158,7 +154,7 @@ platforms :ruby, :windows do group :db do gem "pg", "~> 1.3" - gem "mysql2", "~> 0.5" + gem "mysql2", "~> 0.5", "< 0.5.7" gem "trilogy", ">= 2.7.0" end end diff --git a/Gemfile.lock b/Gemfile.lock index 3858e76f44d65..1a6a1ca90bdd7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,39 +1,29 @@ -GIT - remote: https://github.com/rails/sdoc.git - revision: cd75e36ce2d1acb66734c1390ffe33aa05479380 - branch: main - specs: - sdoc (3.0.0.alpha) - nokogiri - rdoc (>= 5.0) - rouge - PATH remote: . specs: - actioncable (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actioncable (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionmailbox (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) mail (>= 2.8.0) - actionmailer (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - actionview (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionmailer (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.0.alpha) - actionview (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionpack (8.2.0.alpha) + actionview (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -41,68 +31,70 @@ PATH rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actiontext (8.2.0.alpha) + action_text-trix (~> 2.1.15) + actionpack (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionview (8.2.0.alpha) + activesupport (= 8.2.0.alpha) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activejob (8.2.0.alpha) + activesupport (= 8.2.0.alpha) globalid (>= 0.3.6) - activemodel (8.1.0.alpha) - activesupport (= 8.1.0.alpha) - activerecord (8.1.0.alpha) - activemodel (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activemodel (8.2.0.alpha) + activesupport (= 8.2.0.alpha) + activerecord (8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) timeout (>= 0.4.0) - activestorage (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activestorage (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) marcel (~> 1.0) - activesupport (8.1.0.alpha) + activesupport (8.2.0.alpha) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - rails (8.1.0.alpha) - actioncable (= 8.1.0.alpha) - actionmailbox (= 8.1.0.alpha) - actionmailer (= 8.1.0.alpha) - actionpack (= 8.1.0.alpha) - actiontext (= 8.1.0.alpha) - actionview (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activemodel (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + rails (8.2.0.alpha) + actioncable (= 8.2.0.alpha) + actionmailbox (= 8.2.0.alpha) + actionmailer (= 8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actiontext (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) bundler (>= 1.15.0) - railties (= 8.1.0.alpha) - railties (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + railties (= 8.2.0.alpha) + railties (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) PATH @@ -115,55 +107,54 @@ PATH GEM remote: https://rubygems.org/ specs: + action_text-trix (2.1.15) + railties addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) amq-protocol (2.3.2) + argon2 (2.3.2) + ffi (~> 1.15) + ffi-compiler (~> 1.0) ast (2.4.2) - aws-eventstream (1.3.0) - aws-partitions (1.1037.0) - aws-sdk-core (3.215.1) + aws-eventstream (1.4.0) + aws-partitions (1.1150.0) + aws-sdk-core (3.230.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.96.0) - aws-sdk-core (~> 3, >= 3.210.0) + logger + aws-sdk-kms (1.110.0) + aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.177.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.197.0) + aws-sdk-core (~> 3, >= 3.228.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sdk-sns (1.92.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) - azure-storage-blob (2.0.3) - azure-storage-common (~> 2.0) - nokogiri (~> 1, >= 1.10.8) - azure-storage-common (2.0.4) - faraday (~> 1.0) - faraday_middleware (~> 1.0, >= 1.0.0.rc1) - net-http-persistent (~> 4.0) - nokogiri (~> 1, >= 1.10.8) backburner (1.6.1) beaneater (~> 1.0) concurrent-ruby (~> 1.0, >= 1.0.1) dante (> 0.1.5) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) beaneater (1.1.3) - benchmark (0.4.0) - bigdecimal (3.1.9) + bigdecimal (3.2.3) bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) brakeman (7.0.0) racc builder (3.3.0) - bundler-audit (0.9.2) - bundler (>= 1.2.0, < 3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) bunny (2.23.0) amq-protocol (~> 2.3, >= 2.3.1) @@ -181,8 +172,8 @@ GEM concurrent-ruby childprocess (5.1.0) logger (~> 1.5) - concurrent-ruby (1.3.4) - connection_pool (2.5.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) crack (1.0.0) bigdecimal rexml @@ -202,8 +193,9 @@ GEM digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) dotenv (3.1.7) - drb (2.2.1) + drb (2.2.3) ed25519 (1.3.0) + erb (5.1.1) erubi (1.13.1) et-orbi (1.2.11) tzinfo @@ -232,11 +224,14 @@ GEM faraday-patron (1.0.0) faraday-rack (1.0.0) faraday-retry (1.0.3) - faraday_middleware (1.2.1) - faraday (~> 1.0) ffi (1.17.1) + ffi (1.17.1-aarch64-linux-gnu) + ffi (1.17.1-arm64-darwin) ffi (1.17.1-x86_64-darwin) ffi (1.17.1-x86_64-linux-gnu) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) + rake fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -273,6 +268,12 @@ GEM google-protobuf (4.29.3) bigdecimal rake (>= 13) + google-protobuf (4.29.3-aarch64-linux) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-arm64-darwin) + bigdecimal + rake (>= 13) google-protobuf (4.29.3-x86_64-darwin) bigdecimal rake (>= 13) @@ -290,17 +291,18 @@ GEM hashdiff (1.1.2) httpclient (2.9.0) mutex_m - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) - image_processing (1.13.0) - mini_magick (>= 4.9.5, < 5) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) importmap-rails (2.1.0) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) - io-console (0.8.0) - irb (1.14.3) + io-console (0.8.1) + irb (1.15.2) + pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.13.0) @@ -309,7 +311,7 @@ GEM jmespath (1.6.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.10.0) + json (2.15.2) jwt (2.10.1) base64 kamal (2.4.0) @@ -331,13 +333,13 @@ GEM launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) - libxml-ruby (5.0.3) + libxml-ruby (5.0.4) lint_roller (1.1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.5) - loofah (2.24.0) + logger (1.7.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -353,10 +355,11 @@ GEM mixlib-cli (~> 2.1, >= 2.1.1) mixlib-config (>= 2.2.1, < 4) mixlib-shellout - mini_magick (4.13.2) + mini_magick (5.3.1) + logger mini_mime (1.1.5) - mini_portile2 (2.8.8) - minitest (5.25.4) + mini_portile2 (2.8.9) + minitest (5.25.5) minitest-bisect (1.7.0) minitest-server (~> 1.0) path_expander (~> 1.1) @@ -376,12 +379,10 @@ GEM msgpack (1.7.5) multi_json (1.15.0) multipart-post (2.4.1) - mustermann (3.0.3) + mustermann (3.0.4) ruby2_keywords (~> 0.0.1) mutex_m (0.3.0) mysql2 (0.5.6) - net-http-persistent (4.0.5) - connection_pool (~> 2.2) net-imap (0.5.5) date net-protocol @@ -397,28 +398,38 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.4) - nokogiri (1.18.1) + nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.1-x86_64-darwin) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-linux-gnu) + nokogiri (1.18.10-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) ostruct (0.6.1) parallel (1.26.3) - parser (3.3.6.0) + parser (3.3.9.0) ast (~> 2.4.1) racc path_expander (1.1.3) - pg (1.5.9) - prism (1.3.0) - propshaft (1.1.0) + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-darwin) + pg (1.6.2-x86_64-linux) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prism (1.4.0) + propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - railties (>= 7.0.0) - psych (5.2.2) + psych (5.2.6) date stringio public_suffix (6.0.1) @@ -428,21 +439,21 @@ GEM pg (>= 1.1, < 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.8) + rack (3.2.1) rack-cache (1.17.0) rack (>= 0.4) rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-session (2.1.0) + rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) @@ -450,22 +461,24 @@ GEM loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) - rake (13.2.1) + rake (13.3.0) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) rbtree (0.4.6) - rdoc (6.9.1) + rdoc (6.15.0) + erb psych (>= 4.0.0) + tsort redcarpet (3.6.1) - redis (5.3.0) + redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.23.1) + redis-client (0.25.2) connection_pool redis-namespace (1.11.0) redis (>= 4) regexp_parser (2.10.0) - reline (0.6.0) + reline (0.6.1) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -483,8 +496,8 @@ GEM rufus-scheduler (~> 3.2, != 3.3) retriable (3.1.2) rexml (3.4.0) - rouge (4.5.1) - rubocop (1.72.2) + rouge (4.6.1) + rubocop (1.79.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -492,20 +505,22 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.38.1) - parser (>= 3.3.1.0) - rubocop-md (2.0.0) + rubocop-ast (1.46.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-md (2.0.1) lint_roller (~> 1.1) rubocop (>= 1.72.1) rubocop-minitest (0.37.1) lint_roller (~> 1.1) rubocop (>= 1.72.1, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) - rubocop-packaging (0.5.2) - rubocop (>= 1.33, < 2.0) + rubocop-packaging (0.6.0) + lint_roller (~> 1.1.0) + rubocop (>= 1.72.1, < 2.0) rubocop-performance (1.24.0) lint_roller (~> 1.1) rubocop (>= 1.72.1, < 2.0) @@ -532,12 +547,18 @@ GEM sass-embedded (1.83.4) google-protobuf (~> 4.29) rake (>= 13) + sass-embedded (1.83.4-aarch64-linux-gnu) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-arm64-darwin) + google-protobuf (~> 4.29) sass-embedded (1.83.4-x86_64-darwin) google-protobuf (~> 4.29) sass-embedded (1.83.4-x86_64-linux-gnu) google-protobuf (~> 4.29) + sdoc (2.6.5) + rdoc (>= 5.0) securerandom (0.4.1) - selenium-webdriver (4.29.1) + selenium-webdriver (4.32.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -545,12 +566,13 @@ GEM websocket (~> 1.0) serverengine (2.0.7) sigdump (~> 0.2.2) - set (1.1.1) - sidekiq (7.3.7) - connection_pool (>= 2.3.0) - logger - rack (>= 2.2.4) - redis-client (>= 0.22.2) + set (1.1.2) + sidekiq (8.0.7) + connection_pool (>= 2.5.0) + json (>= 2.9.0) + logger (>= 1.6.2) + rack (>= 3.1.0) + redis-client (>= 0.23.2) sigdump (0.2.5) signet (0.19.0) addressable (~> 2.8) @@ -598,6 +620,8 @@ GEM sprockets (>= 3.0.0) sqlite3 (2.5.0) mini_portile2 (~> 2.8.0) + sqlite3 (2.5.0-aarch64-linux-gnu) + sqlite3 (2.5.0-arm64-darwin) sqlite3 (2.5.0-x86_64-darwin) sqlite3 (2.5.0-x86_64-linux-gnu) sshkit (1.23.2) @@ -609,26 +633,29 @@ GEM stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.2) - sucker_punch (3.2.0) - concurrent-ruby (~> 1.0) + stringio (3.1.7) tailwindcss-rails (3.2.0) railties (>= 7.0.0) tailwindcss-ruby tailwindcss-ruby (3.4.17) + tailwindcss-ruby (3.4.17-aarch64-linux) + tailwindcss-ruby (3.4.17-arm64-darwin) tailwindcss-ruby (3.4.17-x86_64-darwin) tailwindcss-ruby (3.4.17-x86_64-linux) terser (1.2.4) execjs (>= 0.3.0, < 3) thor (1.3.2) - thruster (0.1.10) - thruster (0.1.10-x86_64-darwin) - thruster (0.1.10-x86_64-linux) - tilt (2.6.0) + thruster (0.1.16) + thruster (0.1.16-aarch64-linux) + thruster (0.1.16-arm64-darwin) + thruster (0.1.16-x86_64-darwin) + thruster (0.1.16-x86_64-linux) + tilt (2.6.1) timeout (0.4.3) tomlrb (2.0.3) trailblazer-option (0.1.2) trilogy (2.9.0) + tsort (0.2.0) turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) @@ -638,7 +665,7 @@ GEM unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) - uri (1.0.2) + uri (1.0.3) useragent (0.16.11) w3c_validators (1.3.7) json (>= 1.8) @@ -666,17 +693,19 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.1) + zeitwerk (2.7.3) PLATFORMS + aarch64-linux + arm64-darwin ruby x86_64-darwin x86_64-linux DEPENDENCIES + argon2 (~> 2.3.2) aws-sdk-s3 aws-sdk-sns - azure-storage-blob (~> 2.0) backburner bcrypt (~> 3.1.11) bootsnap (>= 1.4.4) @@ -704,7 +733,7 @@ DEPENDENCIES minitest-ci minitest-retry msgpack (>= 1.7.0) - mysql2 (~> 0.5) + mysql2 (~> 0.5, < 0.5.7) nokogiri (>= 1.8.1, != 1.11.0) pg (~> 1.3) prism @@ -715,7 +744,6 @@ DEPENDENCIES rack-cache (~> 1.2) rails! rake (>= 13) - rdoc (< 6.10) redcarpet (~> 3.6.1) redis (>= 4.0.1) redis-namespace @@ -724,7 +752,7 @@ DEPENDENCIES resque-scheduler rexml rouge - rubocop (< 1.73) + rubocop (= 1.79.2) rubocop-md rubocop-minitest rubocop-packaging @@ -732,7 +760,7 @@ DEPENDENCIES rubocop-rails rubocop-rails-omakase rubyzip (~> 2.0) - sdoc! + sdoc (~> 2.6.4) selenium-webdriver (>= 4.20.0) sidekiq sneakers @@ -743,7 +771,6 @@ DEPENDENCIES sqlite3 (>= 2.1) stackprof stimulus-rails - sucker_punch tailwindcss-rails terser (>= 1.1.4) thruster @@ -760,4 +787,4 @@ DEPENDENCIES websocket-client-simple BUNDLED WITH - 2.5.16 + 2.7.2 diff --git a/RAILS_VERSION b/RAILS_VERSION index cd8590d8d7c72..69640086a3d93 100644 --- a/RAILS_VERSION +++ b/RAILS_VERSION @@ -1 +1 @@ -8.1.0.alpha +8.2.0.alpha diff --git a/README.md b/README.md index d084726abf892..213345ff10a7f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## What's Rails? -Rails is a web-application framework that includes everything needed to +Rails is a web application framework that includes everything needed to create database-backed web applications according to the [Model-View-Controller (MVC)](https://en.wikipedia.org/wiki/Model-view-controller) pattern. diff --git a/RELEASING_RAILS.md b/RELEASING_RAILS.md index eeb0c2ba29fa0..3d05c0409a984 100644 --- a/RELEASING_RAILS.md +++ b/RELEASING_RAILS.md @@ -99,7 +99,7 @@ lists where you should announce: * [rubyonrails-talk](https://discuss.rubyonrails.org/c/rubyonrails-talk) Use Markdown format for your announcement. Remember to ask people to report -issues with the release candidate to the rails-core mailing list. +issues with the release candidate to the rubyonrails-core forum. NOTE: For patch releases, there's a `rake announce` task to generate the release post. It supports multiple patch releases too: diff --git a/Rakefile b/Rakefile index a99fdeb5144da..8c58b65d8c467 100644 --- a/Rakefile +++ b/Rakefile @@ -21,6 +21,151 @@ task default: %w(test test:isolated) end end +Releaser::FRAMEWORKS.each do |framework| + namespace framework do + desc "Run tests for #{framework}" + task :test do + ok = system(%(cd #{framework} && #{$0} test --trace)) + fail("Errors in #{framework}") unless ok + end + + desc "Run isolated tests for #{framework}" + task :isolated do + # Active Storage doesn't define a test:isolated task; explicitly fail + if framework == "activestorage" + abort "activestorage:isolated is not supported" + else + ok = system(%(cd #{framework} && #{$0} test:isolated --trace)) + fail("Errors in #{framework}") unless ok + end + end + end +end + +namespace :activejob do + activejob_adapters = %w(async inline queue_classic resque sidekiq sneakers backburner test) + activejob_adapters.delete("queue_classic") if defined?(JRUBY_VERSION) + + desc "Run Active Job integration tests for all adapters" + task :integration do + ok = system(%(cd activejob && #{$0} test:integration --trace)) + fail("Errors in activejob integration") unless ok + end + + activejob_adapters.each do |adapter| + namespace adapter do + desc "Run tests for activejob #{adapter} adapter" + task :test do + ok = system(%(cd activejob && #{$0} test:#{adapter} --trace)) + fail("Errors in activejob:#{adapter}") unless ok + end + + desc "Run isolated tests for activejob #{adapter} adapter" + task :isolated do + ok = system(%(cd activejob && #{$0} test:isolated:#{adapter} --trace)) + fail("Errors in activejob:#{adapter}") unless ok + end + + desc "Run Active Job #{adapter} adapter integration tests" + task :integration do + ok = system(%(cd activejob && #{$0} test:integration:#{adapter} --trace)) + fail("Errors in activejob:#{adapter} integration") unless ok + end + end + end +end + +namespace :activerecord do + %w(mysql2 trilogy postgresql sqlite3 sqlite3_mem).each do |adapter| + namespace adapter do + desc "Run Active Record #{adapter} adapter tests" + task :test do + ok = system(%(cd activerecord && #{$0} test:#{adapter} --trace)) + fail("Errors in activerecord:#{adapter}") unless ok + end + + desc "Run Active Record #{adapter} adapter isolated tests" + task :isolated do + ok = system(%(cd activerecord && #{$0} test:isolated:#{adapter} --trace)) + fail("Errors in activerecord:#{adapter} isolated") unless ok + end + + desc "Run Active Record #{adapter} adapter integration tests" + task :integration do + ok = system(%(cd activerecord && #{$0} test:integration:active_job:#{adapter} --trace)) + fail("Errors in activerecord:#{adapter} integration") unless ok + end + end + end + + desc "Run Active Record integration tests for all adapters" + task :integration do + ok = system(%(cd activerecord && #{$0} test:integration:active_job --trace)) + fail("Errors in activerecord integration") unless ok + end + + namespace :db do + desc "Build MySQL and PostgreSQL test databases" + task :create do + ok = system(%(cd activerecord && #{$0} db:create --trace)) + fail("Errors in activerecord db:create") unless ok + end + + desc "Drop MySQL and PostgreSQL test databases" + task :drop do + ok = system(%(cd activerecord && #{$0} db:drop --trace)) + fail("Errors in activerecord db:drop") unless ok + end + + desc "Rebuild MySQL and PostgreSQL test databases" + task :rebuild do + ok = system(%(cd activerecord && #{$0} db:mysql:rebuild --trace)) + ok &&= system(%(cd activerecord && #{$0} db:postgresql:rebuild --trace)) + fail("Errors in activerecord db:rebuild") unless ok + end + + namespace :mysql do + desc "Build Active Record MySQL test databases" + task :build do + ok = system(%(cd activerecord && #{$0} db:mysql:build --trace)) + fail("Errors in activerecord db:mysql:build") unless ok + end + + desc "Drop Active Record MySQL test databases" + task :drop do + ok = system(%(cd activerecord && #{$0} db:mysql:drop --trace)) + fail("Errors in activerecord db:mysql:drop") unless ok + end + + desc "Rebuild Active Record MySQL test databases" + task :rebuild do + ok = system(%(cd activerecord && #{$0} db:mysql:rebuild --trace)) + fail("Errors in activerecord db:mysql:rebuild") unless ok + end + end + + namespace :postgresql do + desc "Build Active Record PostgreSQL test databases" + task :build do + ok = system(%(cd activerecord && #{$0} db:postgresql:build --trace)) + fail("Errors in activerecord db:postgresql:build") unless ok + end + + desc "Drop Active Record PostgreSQL test databases" + task :drop do + ok = system(%(cd activerecord && #{$0} db:postgresql:drop --trace)) + fail("Errors in activerecord db:postgresql:drop") unless ok + end + + desc "Rebuild Active Record PostgreSQL test databases" + task :rebuild do + ok = system(%(cd activerecord && #{$0} db:postgresql:rebuild --trace)) + fail("Errors in activerecord db:postgresql:rebuild") unless ok + end + end + end +end + desc "Smoke-test all projects" task :smoke, [:frameworks, :isolated] do |task, args| frameworks = args[:frameworks] ? args[:frameworks].split(" ") : Releaser::FRAMEWORKS @@ -29,7 +174,7 @@ task :smoke, [:frameworks, :isolated] do |task, args| frameworks = Releaser::FRAMEWORKS end - isolated = args[:isolated].nil? ? true : args[:isolated] == "true" + isolated = args[:isolated].nil? || args[:isolated] == "true" test_task = isolated ? "test:isolated" : "test" (frameworks - ["activerecord"]).each do |project| @@ -54,7 +199,8 @@ task :preview_docs do FileUtils.mkdir_p("preview") PreviewDocs.new.render("preview") - require "guides/rails_guides" + system(%(cd guides && #{$0} guides:generate --trace)) + Rake::Task[:rdoc].invoke FileUtils.mv("doc/rdoc", "preview/api") diff --git a/actioncable/.eslintrc b/actioncable/.eslintrc deleted file mode 100644 index b85ef26b314cd..0000000000000 --- a/actioncable/.eslintrc +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "eslint:recommended", - "rules": { - "semi": ["error", "never"], - "quotes": ["error", "double"], - "no-unused-vars": ["error", { "vars": "all", "args": "none" }], - "no-console": "off" - }, - "plugins": [ - "import" - ], - "env": { - "browser": true, - "es6": true - }, - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" - } -} diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index ac140c3da7632..ca72310999d75 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,2 +1,2 @@ -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actioncable/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actioncable/CHANGELOG.md) for previous changes. diff --git a/actioncable/README.md b/actioncable/README.md index 418c55bdc3b96..0ae89cb41e5a2 100644 --- a/actioncable/README.md +++ b/actioncable/README.md @@ -19,6 +19,6 @@ Bug reports for the Ruby on \Rails project can be filed here: * https://github.com/rails/rails/issues -Feature requests should be discussed on the rails-core mailing list here: +Feature requests should be discussed on the rubyonrails-core forum here: * https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/actioncable/Rakefile b/actioncable/Rakefile index 1b766a1162a0d..f4ac5d83d49a8 100644 --- a/actioncable/Rakefile +++ b/actioncable/Rakefile @@ -20,7 +20,7 @@ Rake::TestTask.new do |t| end namespace :test do - task :isolated do + task isolated: :railties do Dir.glob("test/**/*_test.rb").all? do |file| sh(Gem.ruby, "-w", "-Ilib:test", file) end || raise("Failures") @@ -30,6 +30,12 @@ namespace :test do system(Hash[*Base64.decode64(ENV.fetch("ENCODED", "")).split(/[ =]/)], "yarn", "test") exit($?.exitstatus) unless $?.success? end + + task :railties do + ["action_cable/engine"].all? do |railtie| + sh(Gem.ruby, "-r", railtie, "-e", "'OK'") + end || raise("Failures") + end end namespace :assets do diff --git a/actioncable/app/javascript/action_cable/index.js b/actioncable/app/javascript/action_cable/index.js index 3e650bc120bc0..62b904f5f7ef3 100644 --- a/actioncable/app/javascript/action_cable/index.js +++ b/actioncable/app/javascript/action_cable/index.js @@ -1,12 +1,12 @@ +import adapters from "./adapters" import Connection from "./connection" import ConnectionMonitor from "./connection_monitor" import Consumer, { createWebSocketURL } from "./consumer" import INTERNAL from "./internal" +import logger from "./logger" import Subscription from "./subscription" -import Subscriptions from "./subscriptions" import SubscriptionGuarantor from "./subscription_guarantor" -import adapters from "./adapters" -import logger from "./logger" +import Subscriptions from "./subscriptions" export { Connection, diff --git a/actioncable/app/javascript/action_cable/subscriptions.js b/actioncable/app/javascript/action_cable/subscriptions.js index ec41ccbf75ba6..0f166057ad6ea 100644 --- a/actioncable/app/javascript/action_cable/subscriptions.js +++ b/actioncable/app/javascript/action_cable/subscriptions.js @@ -1,6 +1,6 @@ +import logger from "./logger" import Subscription from "./subscription" import SubscriptionGuarantor from "./subscription_guarantor" -import logger from "./logger" // Collection class for creating (and internally managing) channel subscriptions. // The only method intended to be triggered by the user is ActionCable.Subscriptions#create, diff --git a/actioncable/karma.conf.js b/actioncable/karma.conf.js index a6d370788f9cb..a6e03f8023760 100644 --- a/actioncable/karma.conf.js +++ b/actioncable/karma.conf.js @@ -25,8 +25,8 @@ if (process.env.CI) { config.customLaunchers = { sl_chrome: sauce("chrome", 70), sl_ff: sauce("firefox", 63), - sl_safari: sauce("safari", "latest"), - sl_edge: sauce("microsoftedge", 17.17134, "Windows 10"), + sl_safari: sauce("safari", "16", "macOS 13"), + sl_edge: sauce("microsoftedge", "latest", "Windows 11"), } config.browsers = Object.keys(config.customLaunchers) diff --git a/actioncable/lib/action_cable/channel/base.rb b/actioncable/lib/action_cable/channel/base.rb index 6b0ff57e3109e..de2040769416c 100644 --- a/actioncable/lib/action_cable/channel/base.rb +++ b/actioncable/lib/action_cable/channel/base.rb @@ -132,7 +132,11 @@ def action_methods # Except for public instance methods of Base and its ancestors ActionCable::Channel::Base.public_instance_methods(true) + # Be sure to include shadowed public instance methods of this class - public_instance_methods(false)).uniq.map(&:to_s) + public_instance_methods(false) - + # Except the internal methods + internal_methods).uniq + + methods.map!(&:name) methods.to_set end end @@ -150,6 +154,10 @@ def method_added(name) # :doc: super clear_action_methods! end + + def internal_methods + super + end end def initialize(connection, identifier, params = {}) @@ -165,6 +173,7 @@ def initialize(connection, identifier, params = {}) @reject_subscription = nil @subscription_confirmation_sent = nil + @unsubscribed = false delegate_connection_identifiers end @@ -200,11 +209,16 @@ def subscribe_to_channel # cleanup with callbacks. This method is not intended to be called directly by # the user. Instead, override the #unsubscribed callback. def unsubscribe_from_channel # :nodoc: + @unsubscribed = true run_callbacks :unsubscribe do unsubscribed end end + def unsubscribed? # :nodoc: + @unsubscribed + end + private # Called once a consumer has become a subscriber of the channel. Usually the # place to set up any streams you want this channel to be sending to the diff --git a/actioncable/lib/action_cable/channel/broadcasting.rb b/actioncable/lib/action_cable/channel/broadcasting.rb index 1a22e1fb307f3..4717f914a4fbf 100644 --- a/actioncable/lib/action_cable/channel/broadcasting.rb +++ b/actioncable/lib/action_cable/channel/broadcasting.rb @@ -10,19 +10,19 @@ module Broadcasting extend ActiveSupport::Concern module ClassMethods - # Broadcast a hash to a unique broadcasting for this `model` in this channel. - def broadcast_to(model, message) - ActionCable.server.broadcast(broadcasting_for(model), message) + # Broadcast a hash to a unique broadcasting for this array of `broadcastables` in this channel. + def broadcast_to(broadcastables, message) + ActionCable.server.broadcast(broadcasting_for(broadcastables), message) end # Returns a unique broadcasting identifier for this `model` in this channel: # # CommentsChannel.broadcasting_for("all") # => "comments:all" # - # You can pass any object as a target (e.g. Active Record model), and it would + # You can pass an array of objects as a target (e.g. Active Record model), and it would # be serialized into a string under the hood. - def broadcasting_for(model) - serialize_broadcasting([ channel_name, model ]) + def broadcasting_for(broadcastables) + serialize_broadcasting([ channel_name ] + Array(broadcastables)) end private diff --git a/actioncable/lib/action_cable/channel/callbacks.rb b/actioncable/lib/action_cable/channel/callbacks.rb index 5df7bc16943f5..8412a5421e260 100644 --- a/actioncable/lib/action_cable/channel/callbacks.rb +++ b/actioncable/lib/action_cable/channel/callbacks.rb @@ -39,6 +39,8 @@ module Callbacks extend ActiveSupport::Concern include ActiveSupport::Callbacks + INTERNAL_METHODS = [:_run_subscribe_callbacks, :_run_unsubscribe_callbacks] # :nodoc: + included do define_callbacks :subscribe define_callbacks :unsubscribe @@ -70,6 +72,11 @@ def after_unsubscribe(*methods, &block) set_callback(:unsubscribe, :after, *methods, &block) end alias_method :on_unsubscribe, :after_unsubscribe + + private + def internal_methods + INTERNAL_METHODS + end end end end diff --git a/actioncable/lib/action_cable/channel/streams.rb b/actioncable/lib/action_cable/channel/streams.rb index 7ab3ab20d7a2f..6f517b6d0adb8 100644 --- a/actioncable/lib/action_cable/channel/streams.rb +++ b/actioncable/lib/action_cable/channel/streams.rb @@ -88,6 +88,8 @@ module Streams # callback. Defaults to `coder: nil` which does no decoding, passes raw # messages. def stream_from(broadcasting, callback = nil, coder: nil, &block) + return if unsubscribed? + broadcasting = String(broadcasting) # Don't send the confirmation until pubsub#subscribe is successful @@ -106,15 +108,15 @@ def stream_from(broadcasting, callback = nil, coder: nil, &block) end end - # Start streaming the pubsub queue for the `model` in this channel. Optionally, + # Start streaming the pubsub queue for the `broadcastables` in this channel. Optionally, # you can pass a `callback` that'll be used instead of the default of just # transmitting the updates straight to the subscriber. # # Pass `coder: ActiveSupport::JSON` to decode messages as JSON before passing to # the callback. Defaults to `coder: nil` which does no decoding, passes raw # messages. - def stream_for(model, callback = nil, coder: nil, &block) - stream_from(broadcasting_for(model), callback || block, coder: coder) + def stream_for(broadcastables, callback = nil, coder: nil, &block) + stream_from(broadcasting_for(broadcastables), callback || block, coder: coder) end # Unsubscribes streams from the named `broadcasting`. diff --git a/actioncable/lib/action_cable/gem_version.rb b/actioncable/lib/action_cable/gem_version.rb index fd6a9c80aabce..0ad4fd88bbeaf 100644 --- a/actioncable/lib/action_cable/gem_version.rb +++ b/actioncable/lib/action_cable/gem_version.rb @@ -10,7 +10,7 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/actioncable/lib/action_cable/server/broadcasting.rb b/actioncable/lib/action_cable/server/broadcasting.rb index 34ef50992b226..cedd0fb58fb0b 100644 --- a/actioncable/lib/action_cable/server/broadcasting.rb +++ b/actioncable/lib/action_cable/server/broadcasting.rb @@ -21,10 +21,12 @@ module Server # ActionCable.server.broadcast \ # "web_notifications_1", { title: "New things!", body: "All that's fit for print" } # - # # Client-side CoffeeScript, which assumes you've already requested the right to send web notifications: - # App.cable.subscriptions.create "WebNotificationsChannel", - # received: (data) -> - # new Notification data['title'], body: data['body'] + # # Client-side JavaScript, which assumes you've already requested the right to send web notifications: + # App.cable.subscriptions.create("WebNotificationsChannel", { + # received: function(data) { + # new Notification(data['title'], { body: data['body'] }) + # } + # }) module Broadcasting # Broadcast a hash directly to a named `broadcasting`. This will later be JSON # encoded. diff --git a/actioncable/lib/action_cable/subscription_adapter/base.rb b/actioncable/lib/action_cable/subscription_adapter/base.rb index 7de9fc2a88829..2df2667b2336c 100644 --- a/actioncable/lib/action_cable/subscription_adapter/base.rb +++ b/actioncable/lib/action_cable/subscription_adapter/base.rb @@ -29,7 +29,8 @@ def shutdown end def identifier - @server.config.cable[:id] ||= "ActionCable-PID-#{$$}" + @server.config.cable[:id] = "ActionCable-PID-#{$$}" unless @server.config.cable.key?(:id) + @server.config.cable[:id] end end end diff --git a/actioncable/lib/action_cable/subscription_adapter/postgresql.rb b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb index 32c63bbebf50a..2ac063a2e63b1 100644 --- a/actioncable/lib/action_cable/subscription_adapter/postgresql.rb +++ b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb @@ -37,7 +37,7 @@ def shutdown def with_subscriptions_connection(&block) # :nodoc: # Action Cable is taking ownership over this database connection, and will # perform the necessary cleanup tasks. - # We purposedly avoid #checkout to not end up with a pinned connection + # We purposely avoid #checkout to not end up with a pinned connection ar_conn = ActiveRecord::Base.connection_pool.new_connection pg_conn = ar_conn.raw_connection diff --git a/actioncable/lib/action_cable/subscription_adapter/redis.rb b/actioncable/lib/action_cable/subscription_adapter/redis.rb index da58e8652ce8c..784a7ee0911da 100644 --- a/actioncable/lib/action_cable/subscription_adapter/redis.rb +++ b/actioncable/lib/action_cable/subscription_adapter/redis.rb @@ -164,7 +164,7 @@ def ensure_listener_running begin conn = @adapter.redis_connection_for_subscriptions listen conn - rescue ConnectionError + rescue *CONNECTION_ERRORS reset if retry_connecting? when_connected { resubscribe } @@ -210,7 +210,7 @@ def reset end if ::Redis::VERSION < "5" - ConnectionError = ::Redis::BaseConnectionError + CONNECTION_ERRORS = [::Redis::BaseConnectionError].freeze class SubscribedClient def initialize(raw_client) @@ -244,7 +244,12 @@ def extract_subscribed_client(conn) SubscribedClient.new(conn._client) end else - ConnectionError = RedisClient::ConnectionError + CONNECTION_ERRORS = [ + ::Redis::BaseConnectionError, + + # Some older versions of redis-rb sometime leak underlying exceptions + RedisClient::ConnectionError, + ].freeze def extract_subscribed_client(conn) conn diff --git a/actioncable/package.json b/actioncable/package.json index 0a6c0d20b2830..1f1535a5bbdc1 100644 --- a/actioncable/package.json +++ b/actioncable/package.json @@ -1,6 +1,6 @@ { "name": "@rails/actioncable", - "version": "8.1.0-alpha", + "version": "8.2.0-alpha", "description": "WebSocket framework for Ruby on Rails.", "module": "app/assets/javascripts/actioncable.esm.js", "main": "app/assets/javascripts/actioncable.js", @@ -24,10 +24,12 @@ }, "homepage": "https://rubyonrails.org/", "devDependencies": { + "@eslint/js": "^9.24.0", "@rollup/plugin-commonjs": "^19.0.1", "@rollup/plugin-node-resolve": "^11.0.1", - "eslint": "^8.40.0", - "eslint-plugin-import": "^2.29.0", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "globals": "^14.0.0", "karma": "^6.4.2", "karma-chrome-launcher": "^2.2.0", "karma-qunit": "^2.1.0", diff --git a/actioncable/test/channel/base_test.rb b/actioncable/test/channel/base_test.rb index 4394338d38a25..62e6958fcd8d0 100644 --- a/actioncable/test/channel/base_test.rb +++ b/actioncable/test/channel/base_test.rb @@ -126,6 +126,16 @@ def error_handler assert_not_predicate @channel, :subscribed? end + test "unsubscribed? method returns correct status" do + assert_not @channel.unsubscribed? + + @channel.subscribe_to_channel + assert_not @channel.unsubscribed? + + @channel.unsubscribe_from_channel + assert @channel.unsubscribed? + end + test "connection identifiers" do assert_equal @user.name, @channel.current_user.name end diff --git a/actioncable/test/channel/broadcasting_test.rb b/actioncable/test/channel/broadcasting_test.rb index fb501a1bc277a..a241ec6a20977 100644 --- a/actioncable/test/channel/broadcasting_test.rb +++ b/actioncable/test/channel/broadcasting_test.rb @@ -12,7 +12,7 @@ class ChatChannel < ActionCable::Channel::Base @connection = TestConnection.new end - test "broadcasts_to" do + test "broadcasts_to with an object" do assert_called_with( ActionCable.server, :broadcast, @@ -25,6 +25,32 @@ class ChatChannel < ActionCable::Channel::Base end end + test "broadcasts_to with an array" do + assert_called_with( + ActionCable.server, + :broadcast, + [ + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire:Room#2-Campfire", + "Hello World" + ] + ) do + ChatChannel.broadcast_to([ Room.new(1), Room.new(2) ], "Hello World") + end + end + + test "broadcasts_to with a string" do + assert_called_with( + ActionCable.server, + :broadcast, + [ + "action_cable:channel:broadcasting_test:chat:hello", + "Hello World" + ] + ) do + ChatChannel.broadcast_to("hello", "Hello World") + end + end + test "broadcasting_for with an object" do assert_equal( "action_cable:channel:broadcasting_test:chat:Room#1-Campfire", diff --git a/actioncable/test/channel/stream_test.rb b/actioncable/test/channel/stream_test.rb index 6b520695e5f0e..3aa42297d68ff 100644 --- a/actioncable/test/channel/stream_test.rb +++ b/actioncable/test/channel/stream_test.rb @@ -4,6 +4,7 @@ require "minitest/mock" require "stubs/test_connection" require "stubs/room" +require "concurrent/atomic/cyclic_barrier" module ActionCable::StreamTests class Connection < ActionCable::Connection::Base @@ -280,6 +281,63 @@ class StreamTest < ActionCable::TestCase end end + test "concurrent unsubscribe_from_channel and stream_from do not raise RuntimeError" do + threads = [] + run_in_eventmachine do + connection = TestConnection.new + connection.pubsub.unsubscribe_latency = 0.1 + + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel + + # Set up initial streams + channel.stream_from "room_one" + channel.stream_from "room_two" + wait_for_async + + # Create barriers to synchronize thread execution + barrier = Concurrent::CyclicBarrier.new(2) + + exception_caught = nil + + # Thread 1: calls unsubscribe_from_channel + thread1 = Thread.new do + barrier.wait + # Add a small delay to increase the chance of concurrent execution + sleep 0.001 + channel.unsubscribe_from_channel + rescue => e + exception_caught = e + ensure + barrier.wait + end + threads << thread1 + + # Thread 2: calls stream_from during unsubscribe_from_channel iteration + thread2 = Thread.new do + barrier.wait + # Try to add streams while unsubscribe_from_channel is potentially iterating + 10.times do |i| + channel.stream_from "concurrent_room_#{i}" + sleep 0.0001 # Small delay to interleave with unsubscribe_from_channel + end + rescue => e + exception_caught = e + ensure + barrier.wait + end + threads << thread2 + + thread1.join + thread2.join + + # Ensure no RuntimeError was raised during concurrent access + assert_nil exception_caught, "Concurrent unsubscribe_from_channel and stream_from should not raise RuntimeError: #{exception_caught}" + end + ensure + threads.each(&:kill) + end + private def subscribers_of(connection) connection diff --git a/actioncable/test/stubs/test_adapter.rb b/actioncable/test/stubs/test_adapter.rb index 22822acdffaa5..1ae5976fa0011 100644 --- a/actioncable/test/stubs/test_adapter.rb +++ b/actioncable/test/stubs/test_adapter.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true class SuccessAdapter < ActionCable::SubscriptionAdapter::Base + attr_accessor :unsubscribe_latency + + def initialize(...) + super + @unsubscribe_latency = nil + end + def broadcast(channel, payload) end @@ -10,6 +17,7 @@ def subscribe(channel, callback, success_callback = nil) end def unsubscribe(channel, callback) + sleep @unsubscribe_latency if @unsubscribe_latency subscriber_map[channel].delete(callback) subscriber_map.delete(channel) if subscriber_map[channel].empty? @@unsubscribe_called = { channel: channel, callback: callback } diff --git a/actioncable/test/subscription_adapter/redis_test.rb b/actioncable/test/subscription_adapter/redis_test.rb index dcc9ecab57b20..e90e4129ce89e 100644 --- a/actioncable/test/subscription_adapter/redis_test.rb +++ b/actioncable/test/subscription_adapter/redis_test.rb @@ -113,6 +113,16 @@ def connection_id end end +class RedisAdapterTest::ConnectorCustomIDNil < RedisAdapterTest::ConnectorDefaultID + def cable_config + super.merge(id: connection_id) + end + + def connection_id + nil + end +end + class RedisAdapterTest::ConnectorWithExcluded < RedisAdapterTest::ConnectorDefaultID def cable_config super.merge(adapter: "redis", channel_prefix: "custom") diff --git a/actionmailbox/CHANGELOG.md b/actionmailbox/CHANGELOG.md index 33398e8780b7f..a4d7342c05681 100644 --- a/actionmailbox/CHANGELOG.md +++ b/actionmailbox/CHANGELOG.md @@ -1,5 +1,2 @@ -* Add `reply_to_address` extension method on `Mail::Message`. - *Mr0grog* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionmailbox/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionmailbox/CHANGELOG.md) for previous changes. diff --git a/actionmailbox/Rakefile b/actionmailbox/Rakefile index 45e88e82e16d2..4590a03128eac 100644 --- a/actionmailbox/Rakefile +++ b/actionmailbox/Rakefile @@ -14,11 +14,17 @@ Rake::TestTask.new do |t| end namespace :test do - task :isolated do + task isolated: :railties do FileList["test/**/*_test.rb"].exclude("test/dummy/**/*").all? do |file| sh(Gem.ruby, "-w", "-Ilib", "-Itest", file) end || raise("Failures") end + + task :railties do + ["action_mailbox/engine"].all? do |railtie| + sh(Gem.ruby, "-r", railtie, "-e", "'OK'") + end || raise("Failures") + end end task default: :test diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb index f72b10d7baa38..15249c3803007 100644 --- a/actionmailbox/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -22,7 +22,7 @@ def create head :ok rescue JSON::ParserError => error logger.error error.message - head :unprocessable_entity + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT end def health_check diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb index db4e141627cea..cb32a58767cf7 100644 --- a/actionmailbox/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb @@ -57,7 +57,7 @@ def create When configuring your Postmark inbound webhook, be sure to check the box labeled "Include raw email content in JSON payload". MESSAGE - head :unprocessable_entity + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT end private diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb index d569e04334bd1..2cf5ceece8f51 100644 --- a/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb @@ -55,7 +55,7 @@ def create if request.body ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read else - head :unprocessable_entity + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT end end diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb index fc2d7aba926fa..19904c1aae634 100644 --- a/actionmailbox/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb @@ -52,7 +52,7 @@ def create ActionMailbox::InboundEmail.create_and_extract_message_id! mail rescue JSON::ParserError => error logger.error error.message - head :unprocessable_entity + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT end private diff --git a/actionmailbox/lib/action_mailbox/engine.rb b/actionmailbox/lib/action_mailbox/engine.rb index d0dc5c8ce29a7..4f745acd815f3 100644 --- a/actionmailbox/lib/action_mailbox/engine.rb +++ b/actionmailbox/lib/action_mailbox/engine.rb @@ -29,7 +29,7 @@ class Engine < Rails::Engine initializer "action_mailbox.config" do config.after_initialize do |app| ActionMailbox.logger = app.config.action_mailbox.logger || Rails.logger - ActionMailbox.incinerate = app.config.action_mailbox.incinerate.nil? ? true : app.config.action_mailbox.incinerate + ActionMailbox.incinerate = app.config.action_mailbox.incinerate.nil? || app.config.action_mailbox.incinerate ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days ActionMailbox.queues = app.config.action_mailbox.queues || {} ActionMailbox.ingress = app.config.action_mailbox.ingress diff --git a/actionmailbox/lib/action_mailbox/gem_version.rb b/actionmailbox/lib/action_mailbox/gem_version.rb index d351800d34bbb..d6e0f3264a7f6 100644 --- a/actionmailbox/lib/action_mailbox/gem_version.rb +++ b/actionmailbox/lib/action_mailbox/gem_version.rb @@ -8,7 +8,7 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb index c371ad0cbae80..11982f74e8319 100644 --- a/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb +++ b/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb @@ -53,7 +53,7 @@ class ActionMailbox::Ingresses::Postmark::InboundEmailsControllerTest < ActionDi headers: { authorization: credentials }, params: { From: "someone@example.com" } end - assert_response :unprocessable_entity + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT end test "rejecting an unauthorized inbound email from Postmark" do diff --git a/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb index ac0a6fab75093..a0b121bb4bf1c 100644 --- a/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb +++ b/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb @@ -37,7 +37,7 @@ class ActionMailbox::Ingresses::Relay::InboundEmailsControllerTest < ActionDispa env: { "rack.input" => nil } end - assert_response :unprocessable_entity + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT end test "rejecting an unauthorized inbound email" do diff --git a/actionmailbox/test/dummy/config/storage.yml b/actionmailbox/test/dummy/config/storage.yml index c26dd89d229af..b8a59e9a53a4d 100644 --- a/actionmailbox/test/dummy/config/storage.yml +++ b/actionmailbox/test/dummy/config/storage.yml @@ -25,13 +25,6 @@ test_email: # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> # bucket: your_own_bucket-<%= Rails.env %> -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name-<%= Rails.env %> - # mirror: # service: Mirror # primary: local diff --git a/actionmailbox/test/dummy/db/schema.rb b/actionmailbox/test/dummy/db/schema.rb index acbc0de9d3715..4981e05a0b94c 100644 --- a/actionmailbox/test/dummy/db/schema.rb +++ b/actionmailbox/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2018_02_12_164506) do +ActiveRecord::Schema[8.2].define(version: 2018_02_12_164506) do create_table "action_mailbox_inbound_emails", force: :cascade do |t| t.integer "status", default: 0, null: false t.string "message_id", null: false diff --git a/actionmailer/CHANGELOG.md b/actionmailer/CHANGELOG.md index 96299d1fedd08..9163901e39662 100644 --- a/actionmailer/CHANGELOG.md +++ b/actionmailer/CHANGELOG.md @@ -1,2 +1,18 @@ +* Add `assert_part` and `assert_no_part` to `ActionMailer::TestCase` -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionmailer/CHANGELOG.md) for previous changes. + ```ruby + test "assert MyMailer.welcome HTML and text parts" do + mail = MyMailer.welcome("Hello, world") + + assert_part :text, mail do |text| + assert_includes text, "Hello, world" + end + assert_part :html, mail do |html| + assert_dom html.root, "p", "Hello, world" + end + end + ``` + + *Sean Doyle* + +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionmailer/CHANGELOG.md) for previous changes. diff --git a/actionmailer/README.rdoc b/actionmailer/README.rdoc index d26365048b4f7..5259b952e13d7 100644 --- a/actionmailer/README.rdoc +++ b/actionmailer/README.rdoc @@ -136,6 +136,6 @@ Bug reports for the Ruby on \Rails project can be filed here: * https://github.com/rails/rails/issues -Feature requests should be discussed on the rails-core mailing list here: +Feature requests should be discussed on the rubyonrails-core forum here: * https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/actionmailer/Rakefile b/actionmailer/Rakefile index 8c6181e659337..3b55b05c19f0b 100644 --- a/actionmailer/Rakefile +++ b/actionmailer/Rakefile @@ -18,9 +18,15 @@ Rake::TestTask.new { |t| } namespace :test do - task :isolated do + task isolated: :railties do Dir.glob("test/**/*_test.rb").all? do |file| sh(Gem.ruby, "-w", "-Ilib:test", file) end || raise("Failures") end + + task :railties do + ["action_mailer/railtie"].all? do |railtie| + sh(Gem.ruby, "-r", railtie, "-e", "'OK'") + end || raise("Failures") + end end diff --git a/actionmailer/lib/action_mailer/base.rb b/actionmailer/lib/action_mailer/base.rb index bc3e8f306ba52..4ad5145995a96 100644 --- a/actionmailer/lib/action_mailer/base.rb +++ b/actionmailer/lib/action_mailer/base.rb @@ -7,6 +7,7 @@ require "active_support/core_ext/module/anonymous" require "action_mailer/log_subscriber" +require "action_mailer/structured_event_subscriber" require "action_mailer/rescuable" module ActionMailer diff --git a/actionmailer/lib/action_mailer/callbacks.rb b/actionmailer/lib/action_mailer/callbacks.rb index fa71f0b4f1b2e..b92842b442460 100644 --- a/actionmailer/lib/action_mailer/callbacks.rb +++ b/actionmailer/lib/action_mailer/callbacks.rb @@ -4,6 +4,8 @@ module ActionMailer module Callbacks extend ActiveSupport::Concern + DEFAULT_INTERNAL_METHODS = [:_run_deliver_callbacks].freeze # :nodoc: + included do include ActiveSupport::Callbacks define_callbacks :deliver, skip_after_callbacks_if_terminated: true @@ -26,6 +28,10 @@ def after_deliver(*filters, &blk) def around_deliver(*filters, &blk) set_callback(:deliver, :around, *filters, &blk) end + + def internal_methods # :nodoc: + super.concat(DEFAULT_INTERNAL_METHODS) + end end end end diff --git a/actionmailer/lib/action_mailer/gem_version.rb b/actionmailer/lib/action_mailer/gem_version.rb index 0317b4ebdf600..0a366740204f0 100644 --- a/actionmailer/lib/action_mailer/gem_version.rb +++ b/actionmailer/lib/action_mailer/gem_version.rb @@ -8,7 +8,7 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/actionmailer/lib/action_mailer/log_subscriber.rb b/actionmailer/lib/action_mailer/log_subscriber.rb index 130d5d83e62df..bc521a4bc43af 100644 --- a/actionmailer/lib/action_mailer/log_subscriber.rb +++ b/actionmailer/lib/action_mailer/log_subscriber.rb @@ -3,42 +3,42 @@ require "active_support/log_subscriber" module ActionMailer - # = Action Mailer \LogSubscriber - # - # Implements the ActiveSupport::LogSubscriber for logging notifications when - # email is delivered or received. - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::EventReporter::LogSubscriber # :nodoc: + self.namespace = "action_mailer" + # An email was delivered. - def deliver(event) + def delivered(event) + payload = event[:payload] info do - if exception = event.payload[:exception_object] - "Failed delivery of mail #{event.payload[:message_id]} error_class=#{exception.class} error_message=#{exception.message.inspect}" - elsif event.payload[:perform_deliveries] - "Delivered mail #{event.payload[:message_id]} (#{event.duration.round(1)}ms)" + if payload[:exception_class] + "Failed delivery of mail #{payload[:message_id]} error_class=#{payload[:exception_class]} error_message=#{payload[:exception_message].inspect}" + elsif payload[:perform_deliveries] + "Delivered mail #{payload[:message_id]} (#{payload[:duration_ms].round(1)}ms)" else - "Skipped delivery of mail #{event.payload[:message_id]} as `perform_deliveries` is false" + "Skipped delivery of mail #{payload[:message_id]} as `perform_deliveries` is false" end end - debug { event.payload[:mail] } + debug { payload[:mail] } end - subscribe_log_level :deliver, :debug + event_log_level :delivered, :debug # An email was generated. - def process(event) + def processed(event) debug do - mailer = event.payload[:mailer] - action = event.payload[:action] - "#{mailer}##{action}: processed outbound mail in #{event.duration.round(1)}ms" + mailer = event[:payload][:mailer] + action = event[:payload][:action] + "#{mailer}##{action}: processed outbound mail in #{event[:payload][:duration_ms].round(1)}ms" end end - subscribe_log_level :process, :debug + event_log_level :processed, :debug - # Use the logger configured for ActionMailer::Base. - def logger + def self.default_logger ActionMailer::Base.logger end end end -ActionMailer::LogSubscriber.attach_to :action_mailer +ActiveSupport.event_reporter.subscribe( + ActionMailer::LogSubscriber.new, &ActionMailer::LogSubscriber.subscription_filter +) diff --git a/actionmailer/lib/action_mailer/message_delivery.rb b/actionmailer/lib/action_mailer/message_delivery.rb index 504bb25312765..4020807182086 100644 --- a/actionmailer/lib/action_mailer/message_delivery.rb +++ b/actionmailer/lib/action_mailer/message_delivery.rb @@ -3,6 +3,38 @@ require "delegate" module ActionMailer + class << self + # Enqueue many emails at once to be delivered through Active Job. + # When the individual job runs, it will send the email using +deliver_now+. + def deliver_all_later(*deliveries, **options) + _deliver_all_later("deliver_now", *deliveries, **options) + end + + # Enqueue many emails at once to be delivered through Active Job. + # When the individual job runs, it will send the email using +deliver_now!+. + # That means that the message will be sent bypassing checking +perform_deliveries+ + # and +raise_delivery_errors+, so use with caution. + def deliver_all_later!(*deliveries, **options) + _deliver_all_later("deliver_now!", *deliveries, **options) + end + + private + def _deliver_all_later(delivery_method, *deliveries, **options) + deliveries = deliveries.first if deliveries.first.is_a?(Array) + + jobs = deliveries.map do |delivery| + mailer_class = delivery.mailer_class + delivery_job = mailer_class.delivery_job + + delivery_job + .new(mailer_class.name, delivery.action.to_s, delivery_method, params: delivery.params, args: delivery.args) + .set(options) + end + + ActiveJob.perform_all_later(jobs) + end + end + # = Action Mailer \MessageDelivery # # The +ActionMailer::MessageDelivery+ class is used by @@ -17,6 +49,8 @@ module ActionMailer # Notifier.welcome(User.first).deliver_later # enqueue email delivery as a job through Active Job # Notifier.welcome(User.first).message # a Mail::Message object class MessageDelivery < Delegator + attr_reader :mailer_class, :action, :params, :args # :nodoc: + def initialize(mailer_class, action, *args) # :nodoc: @mailer_class, @action, @args = mailer_class, action, args diff --git a/actionmailer/lib/action_mailer/railtie.rb b/actionmailer/lib/action_mailer/railtie.rb index 1f1a41e1ce3ed..3c8d638b34c80 100644 --- a/actionmailer/lib/action_mailer/railtie.rb +++ b/actionmailer/lib/action_mailer/railtie.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true +require "rails" require "active_job/railtie" require "action_mailer" -require "rails" require "abstract_controller/railties/routes_helpers" module ActionMailer diff --git a/actionmailer/lib/action_mailer/structured_event_subscriber.rb b/actionmailer/lib/action_mailer/structured_event_subscriber.rb new file mode 100644 index 0000000000000..f9ae6c8c440ca --- /dev/null +++ b/actionmailer/lib/action_mailer/structured_event_subscriber.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "active_support/structured_event_subscriber" + +module ActionMailer + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + # An email was delivered. + def deliver(event) + exception = event.payload[:exception_object] + payload = { + message_id: event.payload[:message_id], + duration_ms: event.duration.round(2), + mail: event.payload[:mail], + perform_deliveries: event.payload[:perform_deliveries], + } + + if exception + payload[:exception_class] = exception.class.name + payload[:exception_message] = exception.message + end + + emit_debug_event("action_mailer.delivered", payload) + end + debug_only :deliver + + # An email was generated. + def process(event) + emit_debug_event("action_mailer.processed", + mailer: event.payload[:mailer], + action: event.payload[:action], + duration_ms: event.duration.round(2), + ) + end + debug_only :process + end +end + +ActionMailer::StructuredEventSubscriber.attach_to :action_mailer diff --git a/actionmailer/lib/action_mailer/test_case.rb b/actionmailer/lib/action_mailer/test_case.rb index 1a07d8e79242e..8ec38edef00a0 100644 --- a/actionmailer/lib/action_mailer/test_case.rb +++ b/actionmailer/lib/action_mailer/test_case.rb @@ -38,6 +38,9 @@ module Behavior include Rails::Dom::Testing::Assertions::DomAssertions included do + class_attribute :_decoders, default: Hash.new(->(body) { body }).merge!( + Mime[:html] => ->(body) { Rails::Dom::Testing.html_document.parse(body) } + ).freeze # :nodoc: class_attribute :_mailer_class setup :initialize_test_deliveries setup :set_expected_mail @@ -83,6 +86,53 @@ def read_fixture(action) IO.readlines(File.join(Rails.root, "test", "fixtures", self.class.mailer_class.name.underscore, action)) end + # Assert that a Mail instance has a part matching the content type. + # If the Mail is multipart, extract and decode the appropriate part. Yield the decoded part to the block. + # + # By default, assert against the last delivered Mail. + # + # UsersMailer.create(user).deliver_now + # assert_part :text do |text| + # assert_includes text, "Welcome, #{user.email}" + # end + # assert_part :html do |html| + # assert_dom html.root, "h1", text: "Welcome, #{user.email}" + # end + # + # Assert against a Mail instance when provided + # + # mail = UsersMailer.create(user) + # assert_part :text, mail do |text| + # assert_includes text, "Welcome, #{user.email}" + # end + # assert_part :html, mail do |html| + # assert_dom html.root, "h1", text: "Welcome, #{user.email}" + # end + def assert_part(content_type, mail = last_delivered_mail!) + mime_type = Mime[content_type] + part = [*mail.parts, mail].find { |part| mime_type.match?(part.mime_type) } + decoder = _decoders[mime_type] + + assert_not_nil part, "expected part matching #{mime_type} in #{mail.inspect}" + + yield decoder.call(part.decoded) if block_given? + end + + # Assert that a Mail instance does not have a part with a matching MIME type + # + # By default, assert against the last delivered Mail. + # + # UsersMailer.create(user).deliver_now + # + # assert_no_part :html + # assert_no_part :text + def assert_no_part(content_type, mail = last_delivered_mail!) + mime_type = Mime[content_type] + part = [*mail.parts, mail].find { |part| mime_type.match?(part.mime_type) } + + assert_nil part, "expected no part matching #{mime_type} in #{mail.inspect}" + end + private def initialize_test_deliveries set_delivery_method :test @@ -119,6 +169,16 @@ def charset def encode(subject) Mail::Encodings.q_value_encode(subject, charset) end + + def last_delivered_mail + self.class.mailer_class.deliveries.last + end + + def last_delivered_mail! + last_delivered_mail.tap do |mail| + flunk "No e-mail in delivery list" if mail.nil? + end + end end include Behavior diff --git a/actionmailer/test/assert_select_email_test.rb b/actionmailer/test/assert_select_email_test.rb index 9699fe4000ded..5052f64d13016 100644 --- a/actionmailer/test/assert_select_email_test.rb +++ b/actionmailer/test/assert_select_email_test.rb @@ -10,15 +10,65 @@ def test(html) end end + tests AssertSelectMailer + + # + # Test assert_select_email + # + + def test_assert_select_email + assert_raise ActiveSupport::TestCase::Assertion do + assert_select_email { } + end + + AssertSelectMailer.test("

foo

bar

").deliver_now + assert_select_email do + assert_select "div:root" do + assert_select "p:first-child", "foo" + assert_select "p:last-child", "bar" + end + end + end + + def test_assert_part_last_mail_delivery + AssertSelectMailer.test("

foo

bar

").deliver_now + + assert_part :html do |html| + assert_kind_of Rails::Dom::Testing.html_document, html + + assert_dom html, "div" do + assert_dom "p:first-child", "foo" + assert_dom "p:last-child", "bar" + end + end + end + + def test_assert_part_with_mail_argument + mail = AssertSelectMailer.test("

foo

bar

") + + assert_part :html, mail do |html| + assert_kind_of Rails::Dom::Testing.html_document, html + + assert_dom html, "div" do + assert_dom "p:first-child", "foo" + assert_dom "p:last-child", "bar" + end + end + end +end + +class AssertMultipartSelectEmailTest < ActionMailer::TestCase class AssertMultipartSelectMailer < ActionMailer::Base def test(options) mail subject: "Test e-mail", from: "test@test.host", to: "test " do |format| - format.text { render plain: options[:text] } - format.html { render plain: options[:html] } + format.text { render plain: options[:text] } if options.key?(:text) + format.html { render plain: options[:html] } if options.key?(:html) end end end + tests AssertMultipartSelectMailer + # # Test assert_select_email # @@ -28,7 +78,7 @@ def test_assert_select_email assert_select_email { } end - AssertSelectMailer.test("

foo

bar

").deliver_now + AssertMultipartSelectMailer.test(html: "

foo

bar

", text: "foo bar").deliver_now assert_select_email do assert_select "div:root" do assert_select "p:first-child", "foo" @@ -46,4 +96,60 @@ def test_assert_select_email_multipart end end end + + def test_assert_part_last_mail_delivery + AssertMultipartSelectMailer.test(html: "

foo

bar

", text: "foo bar").deliver_now + + assert_part :text do |text| + assert_includes text, "foo bar" + end + assert_part :html do |html| + assert_kind_of Rails::Dom::Testing.html_document, html + + assert_dom html, "div" do + assert_dom "p:first-child", "foo" + assert_dom "p:last-child", "bar" + end + end + end + + def test_assert_part_with_mail_argument + mail = AssertMultipartSelectMailer.test(html: "

foo

bar

", text: "foo bar") + + assert_part :text, mail do |text| + assert_includes text, "foo bar" + end + assert_part :html, mail do |html| + assert_kind_of Rails::Dom::Testing.html_document, html + + assert_dom html, "div" do + assert_dom "p:first-child", "foo" + assert_dom "p:last-child", "bar" + end + end + end + + def test_assert_part_without_block + assert_part :html, AssertMultipartSelectMailer.test(html: "html") + assert_part :text, AssertMultipartSelectMailer.test(text: "text") + + assert_raises Minitest::Assertion, match: "expected part matching text/html" do + assert_part :html, AssertMultipartSelectMailer.test(text: "text") + end + assert_raises Minitest::Assertion, match: "expected part matching text/plain" do + assert_part :text, AssertMultipartSelectMailer.test(html: "html") + end + end + + def test_assert_no_part + assert_no_part :html, AssertMultipartSelectMailer.test(text: "text") + assert_no_part :text, AssertMultipartSelectMailer.test(html: "html") + + assert_raises Minitest::Assertion, match: "expected no part matching text/html" do + assert_no_part :html, AssertMultipartSelectMailer.test(html: "html") + end + assert_raises Minitest::Assertion, match: "expected no part matching text/plain" do + assert_no_part :text, AssertMultipartSelectMailer.test(text: "text") + end + end end diff --git a/actionmailer/test/base_test.rb b/actionmailer/test/base_test.rb index 49e0cba9fb61a..74e33709ff608 100644 --- a/actionmailer/test/base_test.rb +++ b/actionmailer/test/base_test.rb @@ -903,6 +903,8 @@ class FooMailer < ActionMailer::Base # This triggers action_methods. respond_to?(:foo) + after_deliver :foo + def notify end end diff --git a/actionmailer/test/log_subscriber_test.rb b/actionmailer/test/log_subscriber_test.rb index 4272430a9bfd2..89f87a470ca34 100644 --- a/actionmailer/test/log_subscriber_test.rb +++ b/actionmailer/test/log_subscriber_test.rb @@ -4,13 +4,26 @@ require "mailers/base_mailer" require "active_support/log_subscriber/test_helper" require "action_mailer/log_subscriber" +require "active_support/testing/event_reporter_assertions" +require "action_mailer/structured_event_subscriber" class AMLogSubscriberTest < ActionMailer::TestCase - include ActiveSupport::LogSubscriber::TestHelper + include ActiveSupport::Testing::EventReporterAssertions - def setup - super - ActionMailer::LogSubscriber.attach_to :action_mailer + setup do + @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + @old_logger = ActionMailer::LogSubscriber.logger + ActionMailer::LogSubscriber.logger = @logger + end + + teardown do + ActionMailer::LogSubscriber.logger = @old_logger + end + + def run(*) + with_debug_event_reporting do + super + end end class BogusDelivery @@ -22,13 +35,8 @@ def deliver!(mail) end end - def set_logger(logger) - ActionMailer::Base.logger = logger - end - def test_deliver_is_notified BaseMailer.welcome(message_id: "123@abc").deliver_now - wait assert_equal(1, @logger.logged(:info).size) assert_match(/Delivered mail 123@abc/, @logger.logged(:info).first) @@ -42,7 +50,6 @@ def test_deliver_is_notified def test_deliver_message_when_perform_deliveries_is_false BaseMailer.welcome_without_deliveries(message_id: "123@abc").deliver_now - wait assert_equal(1, @logger.logged(:info).size) assert_match("Skipped delivery of mail 123@abc as `perform_deliveries` is false", @logger.logged(:info).first) @@ -59,7 +66,6 @@ def test_deliver_message_when_exception_happened BaseMailer.delivery_method = BogusDelivery assert_raises(RuntimeError) { BaseMailer.welcome(message_id: "123@abc").deliver_now } - wait assert_equal(1, @logger.logged(:info).size) assert_equal('Failed delivery of mail 123@abc error_class=RuntimeError error_message="failed"', @logger.logged(:info).first) diff --git a/actionmailer/test/mail_helper_test.rb b/actionmailer/test/mail_helper_test.rb index 06729826f8480..b0c74c80561ce 100644 --- a/actionmailer/test/mail_helper_test.rb +++ b/actionmailer/test/mail_helper_test.rb @@ -67,6 +67,12 @@ def use_cache end end + def use_stylesheet_link_tag + mail_with_defaults do |format| + format.html { render(inline: "<%= stylesheet_link_tag 'mailer' %>") } + end + end + private def mail_with_defaults(&block) mail(to: "test@localhost", from: "tester@example.com", @@ -122,6 +128,18 @@ def test_use_cache end end + def test_stylesheet_link_tag_without_nonce_method + original_auto_include_nonce_for_styles = ActionView::Helpers::AssetTagHelper.auto_include_nonce_for_styles + ActionView::Helpers::AssetTagHelper.auto_include_nonce_for_styles = true + + mail = HelperMailer.use_stylesheet_link_tag + + assert_includes mail.body.encoded, %( 0 + ensure + BaseMailer.deliveries.clear + end + + def test_deliver_message_when_perform_deliveries_is_false + assert_event_reported("action_mailer.delivered", payload: { message_id: "123@abc", mail: /.*/, perform_deliveries: false }) do + BaseMailer.welcome_without_deliveries(message_id: "123@abc").deliver_now + end + ensure + BaseMailer.deliveries.clear + end + + def test_deliver_message_when_exception_happened + previous_delivery_method = BaseMailer.delivery_method + BaseMailer.delivery_method = BogusDelivery + payload = { message_id: "123@abc", mail: /.*/, exception_class: "RuntimeError", exception_message: "failed" } + + assert_event_reported("action_mailer.delivered", payload:) do + assert_raises(RuntimeError) { BaseMailer.welcome(message_id: "123@abc").deliver_now } + end + ensure + BaseMailer.delivery_method = previous_delivery_method + end + end +end diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 622f59cfb8534..029ebc4aca120 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,83 +1,58 @@ -* Raise `AbstractController::DoubleRenderError` if `head` is called after rendering. +* Support `text/markdown` format in `DebugExceptions` middleware. - After this change, invoking `head` will lead to an error if response body is already set: + When `text/markdown` is requested via the Accept header, error responses + are returned with `Content-Type: text/markdown` instead of HTML. + The existing text templates are reused for markdown output, allowing + CLI tools and other clients to receive byte-efficient error information. - ```ruby - class PostController < ApplicationController - def index - render locals: {} - head :ok - end - end - ``` - - *Iaroslav Kurbatov* + *Guillermo Iguaran* -* The Cookie Serializer can now serialize an Active Support SafeBuffer when using message pack. +* Support dynamic `to:` and `within:` options in `rate_limit`. - Such code would previously produce an error if an application was using messagepack as its cookie serializer. + The `to:` and `within:` options now accept callables (lambdas or procs) and + method names (as symbols), in addition to static values. This allows for + dynamic rate limiting based on user attributes or other runtime conditions. ```ruby - class PostController < ApplicationController - def index - flash.notice = t(:hello_html) # This would try to serialize a SafeBuffer, which was not possible. - end - end - ``` - - *Edouard Chin* + class APIController < ApplicationController + rate_limit to: :max_requests, within: :time_window, by: -> { current_user.id } -* Fix `Rails.application.reload_routes!` from clearing almost all routes. + private + def max_requests + current_user.premium? ? 1000 : 100 + end - When calling `Rails.application.reload_routes!` inside a middleware of - a Rake task, it was possible under certain conditions that all routes would be cleared. - If ran inside a middleware, this would result in getting a 404 on most page you visit. - This issue was only happening in development. - - *Edouard Chin* - -* Add resource name to the `ArgumentError` that's raised when invalid `:only` or `:except` options are given to `#resource` or `#resources` - - This makes it easier to locate the source of the problem, especially for routes drawn by gems. - - Before: - ``` - :only and :except must include only [:index, :create, :new, :show, :update, :destroy, :edit], but also included [:foo, :bar] - ``` - - After: - ``` - Route `resources :products` - :only and :except must include only [:index, :create, :new, :show, :update, :destroy, :edit], but also included [:foo, :bar] + def time_window + current_user.premium? ? 1.hour : 1.minute + end + end ``` - *Jeremy Green* - -* Add `check_collisions` option to `ActionDispatch::Session::CacheStore`. - - Newly generated session ids use 128 bits of randomness, which is more than - enough to ensure collisions can't happen, but if you need to harden sessions - even more, you can enable this option to check in the session store that the id - is indeed free you can enable that option. This however incurs an extra write - on session creation. - - *Shia* + *Murilo Duarte* -* In ExceptionWrapper, match backtrace lines with built templates more often, - allowing improved highlighting of errors within do-end blocks in templates. - Fix for Ruby 3.4 to match new method labels in backtrace. - - *Martin Emde* - -* Allow setting content type with a symbol of the Mime type. +* Define `ActionController::Parameters#deconstruct_keys` to support pattern matching ```ruby - # Before - response.content_type = "text/html" + if params in { search:, page: } + Article.search(search).limit(page) + else + … + end - # After - response.content_type = :html + case (value = params[:string_or_hash_with_nested_key]) + in String + # do something with a String `value`… + in { nested_key: } + # do something with `nested_key` or `value` + else + # … + end ``` - *Petrik de Heus* + *Sean Doyle* + +* Submit test requests using `as: :html` with `Content-Type: x-www-form-urlencoded` + + *Sean Doyle* -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionpack/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionpack/CHANGELOG.md) for previous changes. diff --git a/actionpack/README.rdoc b/actionpack/README.rdoc index 028f5910d383a..8c209108804d6 100644 --- a/actionpack/README.rdoc +++ b/actionpack/README.rdoc @@ -52,6 +52,6 @@ Bug reports for the Ruby on \Rails project can be filed here: * https://github.com/rails/rails/issues -Feature requests should be discussed on the rails-core mailing list here: +Feature requests should be discussed on the rubyonrails-core forum here: * https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/actionpack/Rakefile b/actionpack/Rakefile index 1ee11d7f1c509..fd087a55a46ea 100644 --- a/actionpack/Rakefile +++ b/actionpack/Rakefile @@ -21,11 +21,17 @@ Rake::TestTask.new do |t| end namespace :test do - task :isolated do + task isolated: :railties do test_files.all? do |file| sh(Gem.ruby, "-w", "-Ilib:test", file) end || raise("Failures") end + + task :railties do + ["action_dispatch/railtie", "action_controller/railtie"].all? do |railtie| + sh(Gem.ruby, "-r", railtie, "-e", "'OK'") + end || raise("Failures") + end end task :lines do diff --git a/actionpack/lib/abstract_controller/base.rb b/actionpack/lib/abstract_controller/base.rb index c16601d35fa65..622cafa939bdf 100644 --- a/actionpack/lib/abstract_controller/base.rb +++ b/actionpack/lib/abstract_controller/base.rb @@ -86,22 +86,16 @@ def internal_methods controller.public_instance_methods(true) - methods end - # A list of method names that should be considered actions. This includes all + # A `Set` of method names that should be considered actions. This includes all # public instance methods on a controller, less any internal methods (see # internal_methods), adding back in any methods that are internal, but still # exist on the class itself. - # - # #### Returns - # * `Set` - A set of all methods that should be considered actions. - # def action_methods @action_methods ||= begin # All public instance methods of this class, including ancestors except for # public instance methods of Base and its ancestors. methods = public_instance_methods(true) - internal_methods - # Be sure to include shadowed public instance methods of this class. - methods.concat(public_instance_methods(false)) - methods.map!(&:to_s) + methods.map!(&:name) methods.to_set end end @@ -121,9 +115,6 @@ def clear_action_methods! # # MyApp::MyPostsController.controller_path # => "my_app/my_posts" # - # #### Returns - # * `String` - # def controller_path @controller_path ||= name.delete_suffix("Controller").underscore unless anonymous? end @@ -151,10 +142,6 @@ def eager_load! # :nodoc: # The actual method that is called is determined by calling #method_for_action. # If no method can handle the action, then an AbstractController::ActionNotFound # error is raised. - # - # #### Returns - # * `self` - # def process(action, ...) @_action_name = action.to_s diff --git a/actionpack/lib/abstract_controller/callbacks.rb b/actionpack/lib/abstract_controller/callbacks.rb index cba63b2e38634..31bbb1b5ce9cd 100644 --- a/actionpack/lib/abstract_controller/callbacks.rb +++ b/actionpack/lib/abstract_controller/callbacks.rb @@ -29,6 +29,8 @@ module Callbacks # ActiveSupport::Callbacks. include ActiveSupport::Callbacks + DEFAULT_INTERNAL_METHODS = [:_run_process_action_callbacks].freeze # :nodoc: + included do define_callbacks :process_action, terminator: ->(controller, result_lambda) { result_lambda.call; controller.performed? }, @@ -251,6 +253,10 @@ def _insert_callbacks(callbacks, block = nil) # *_action is the same as append_*_action alias_method :"append_#{callback}_action", :"#{callback}_action" end + + def internal_methods # :nodoc: + super.concat(DEFAULT_INTERNAL_METHODS) + end end private diff --git a/actionpack/lib/abstract_controller/collector.rb b/actionpack/lib/abstract_controller/collector.rb index dee0b49708bd5..d9f41d4ef4879 100644 --- a/actionpack/lib/abstract_controller/collector.rb +++ b/actionpack/lib/abstract_controller/collector.rb @@ -27,7 +27,7 @@ def #{sym}(...) def method_missing(symbol, ...) unless mime_constant = Mime[symbol] raise NoMethodError, "To respond to a custom format, register it as a MIME type first: " \ - "https://guides.rubyonrails.org/action_controller_overview.html#restful-downloads. " \ + "https://guides.rubyonrails.org/action_controller_advanced_topics.html#restful-downloads. " \ "If you meant to respond to a variant like :tablet or :phone, not a custom format, " \ "be sure to nest your variant response within a format response: " \ "format.html { |html| html.tablet { ... } }" diff --git a/actionpack/lib/abstract_controller/helpers.rb b/actionpack/lib/abstract_controller/helpers.rb index 8be59d05ddf6e..fc807f12a9cfd 100644 --- a/actionpack/lib/abstract_controller/helpers.rb +++ b/actionpack/lib/abstract_controller/helpers.rb @@ -90,7 +90,7 @@ def inherited(klass) #-- # Implemented by Resolution#modules_for_helpers. - # :method: # all_helpers_from_path + # :method: all_helpers_from_path # :call-seq: all_helpers_from_path(path) # # Returns a list of helper names in a given path. diff --git a/actionpack/lib/action_controller/api.rb b/actionpack/lib/action_controller/api.rb index b52430467b13f..a44aa2232611c 100644 --- a/actionpack/lib/action_controller/api.rb +++ b/actionpack/lib/action_controller/api.rb @@ -5,6 +5,7 @@ require "action_view" require "action_controller" require "action_controller/log_subscriber" +require "action_controller/structured_event_subscriber" module ActionController # # Action Controller API diff --git a/actionpack/lib/action_controller/base.rb b/actionpack/lib/action_controller/base.rb index 279dbdd71ecb2..99e080b0c5d88 100644 --- a/actionpack/lib/action_controller/base.rb +++ b/actionpack/lib/action_controller/base.rb @@ -4,6 +4,7 @@ require "action_view" require "action_controller/log_subscriber" +require "action_controller/structured_event_subscriber" require "action_controller/metal/params_wrapper" module ActionController diff --git a/actionpack/lib/action_controller/log_subscriber.rb b/actionpack/lib/action_controller/log_subscriber.rb index 02f8493cb68d6..b926d17660a8d 100644 --- a/actionpack/lib/action_controller/log_subscriber.rb +++ b/actionpack/lib/action_controller/log_subscriber.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true -# :markup: markdown - module ActionController - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::EventReporter::LogSubscriber # :nodoc: INTERNAL_PARAMS = %w(controller action format _method only_path) - def start_processing(event) - return unless logger.info? + class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new + + self.namespace = "action_controller" - payload = event.payload + def request_started(event) + payload = event[:payload] params = {} payload[:params].each_pair do |k, v| params[k] = v unless INTERNAL_PARAMS.include?(k) @@ -21,11 +21,11 @@ def start_processing(event) info "Processing by #{payload[:controller]}##{payload[:action]} as #{format}" info " Parameters: #{params.inspect}" unless params.empty? end - subscribe_log_level :start_processing, :info + event_log_level :request_started, :info - def process_action(event) + def request_completed(event) info do - payload = event.payload + payload = event[:payload] additions = ActionController::Base.log_process_action(payload) status = payload[:status] @@ -33,64 +33,81 @@ def process_action(event) status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception_class_name) end - additions << "GC: #{event.gc_time.round(1)}ms" + additions << "GC: #{payload[:gc_time_ms].round(1)}ms" - message = +"Completed #{status} #{Rack::Utils::HTTP_STATUS_CODES[status]} in #{event.duration.round}ms" \ + message = +"Completed #{status} #{Rack::Utils::HTTP_STATUS_CODES[status]} in #{payload[:duration_ms].round(0)}ms" \ " (#{additions.join(" | ")})" message << "\n\n" if defined?(Rails.env) && Rails.env.development? message end end - subscribe_log_level :process_action, :info + event_log_level :request_completed, :info - def halted_callback(event) - info { "Filter chain halted as #{event.payload[:filter].inspect} rendered or redirected" } + def callback_halted(event) + info { "Filter chain halted as #{event[:payload][:filter].inspect} rendered or redirected" } + end + event_log_level :callback_halted, :info + + # Manually subscribed below + def rescue_from_handled(event) + exception_class = event[:payload][:exception_class] + exception_message = event[:payload][:exception_message] + exception_backtrace = event[:payload][:exception_backtrace] + info { "rescue_from handled #{exception_class} (#{exception_message}) - #{exception_backtrace}" } end - subscribe_log_level :halted_callback, :info + event_log_level :rescue_from_handled, :info - def send_file(event) - info { "Sent file #{event.payload[:path]} (#{event.duration.round(1)}ms)" } + def file_sent(event) + info { "Sent file #{event[:payload][:path]} (#{event[:payload][:duration_ms].round(1)}ms)" } end - subscribe_log_level :send_file, :info + event_log_level :file_sent, :info - def redirect_to(event) - info { "Redirected to #{event.payload[:location]}" } + def redirected(event) + info { "Redirected to #{event[:payload][:location]}" } + + if ActionDispatch.verbose_redirect_logs && (source = redirect_source_location) + info { "↳ #{source}" } + end end - subscribe_log_level :redirect_to, :info + event_log_level :redirected, :info - def send_data(event) - info { "Sent data #{event.payload[:filename]} (#{event.duration.round(1)}ms)" } + def data_sent(event) + info { "Sent data #{event[:payload][:filename]} (#{event[:payload][:duration_ms].round(1)}ms)" } end - subscribe_log_level :send_data, :info + event_log_level :data_sent, :info def unpermitted_parameters(event) debug do - unpermitted_keys = event.payload[:keys] + unpermitted_keys = event[:payload][:unpermitted_keys] display_unpermitted_keys = unpermitted_keys.map { |e| ":#{e}" }.join(", ") - context = event.payload[:context].map { |k, v| "#{k}: #{v}" }.join(", ") + context = event[:payload][:context].map { |k, v| "#{k}: #{v}" }.join(", ") color("Unpermitted parameter#{'s' if unpermitted_keys.size > 1}: #{display_unpermitted_keys}. Context: { #{context} }", RED) end end - subscribe_log_level :unpermitted_parameters, :debug - - %w(write_fragment read_fragment exist_fragment? expire_fragment).each do |method| - class_eval <<-METHOD, __FILE__, __LINE__ + 1 - # frozen_string_literal: true - def #{method}(event) - return unless ActionController::Base.enable_fragment_cache_logging - key = ActiveSupport::Cache.expand_cache_key(event.payload[:key] || event.payload[:path]) - human_name = #{method.to_s.humanize.inspect} - info("\#{human_name} \#{key} (\#{event.duration.round(1)}ms)") - end - subscribe_log_level :#{method}, :info - METHOD + event_log_level :unpermitted_parameters, :debug + + def fragment_cache(event) + return unless ActionController::Base.enable_fragment_cache_logging + + key = event[:payload][:key] + human_name = event[:payload][:method].to_s.humanize + + info("#{human_name} #{key} (#{event[:payload][:duration_ms]}ms)") end + event_log_level :fragment_cache, :info - def logger + def self.default_logger ActionController::Base.logger end + + private + def redirect_source_location + backtrace_cleaner.first_clean_frame + end end end -ActionController::LogSubscriber.attach_to :action_controller +ActiveSupport.event_reporter.subscribe( + ActionController::LogSubscriber.new, &ActionController::LogSubscriber.subscription_filter +) diff --git a/actionpack/lib/action_controller/metal/allow_browser.rb b/actionpack/lib/action_controller/metal/allow_browser.rb index 33afed24a60d4..f32a7cb91b08b 100644 --- a/actionpack/lib/action_controller/metal/allow_browser.rb +++ b/actionpack/lib/action_controller/metal/allow_browser.rb @@ -14,7 +14,7 @@ module ClassMethods # aren't reporting a user-agent header, will be allowed access. # # A browser that's blocked will by default be served the file in - # public/406-unsupported-browser.html with a HTTP status code of "406 Not + # public/406-unsupported-browser.html with an HTTP status code of "406 Not # Acceptable". # # In addition to specifically named browser versions, you can also pass diff --git a/actionpack/lib/action_controller/metal/conditional_get.rb b/actionpack/lib/action_controller/metal/conditional_get.rb index 890eea8472a3b..2f172279a312c 100644 --- a/actionpack/lib/action_controller/metal/conditional_get.rb +++ b/actionpack/lib/action_controller/metal/conditional_get.rb @@ -332,6 +332,31 @@ def no_store response.cache_control.replace(no_store: true) end + # Adds the `must-understand` directive to the `Cache-Control` header, which indicates + # that a cache MUST understand the semantics of the response status code that has been + # received, or discard the response. + # + # This is particularly useful when returning responses with new or uncommon + # status codes that might not be properly interpreted by older caches. + # + # #### Example + # + # def show + # @article = Article.find(params[:id]) + # + # if @article.early_access? + # must_understand + # render status: 203 # Non-Authoritative Information + # else + # fresh_when @article + # end + # end + # + def must_understand + response.cache_control[:must_understand] = true + response.cache_control[:no_store] = true + end + private def combine_etags(validator, options) [validator, *etaggers.map { |etagger| instance_exec(options, &etagger) }].compact diff --git a/actionpack/lib/action_controller/metal/exceptions.rb b/actionpack/lib/action_controller/metal/exceptions.rb index 35dd1a9138eaf..bed189eb77396 100644 --- a/actionpack/lib/action_controller/metal/exceptions.rb +++ b/actionpack/lib/action_controller/metal/exceptions.rb @@ -103,4 +103,9 @@ def initialize(message, controller, action_name) super(message) end end + + # Raised when a Rate Limit is exceeded by too many requests within a period of + # time. + class TooManyRequests < ActionControllerError + end end diff --git a/actionpack/lib/action_controller/metal/live.rb b/actionpack/lib/action_controller/metal/live.rb index 23cd5d87fe4a4..4838ed70163a5 100644 --- a/actionpack/lib/action_controller/metal/live.rb +++ b/actionpack/lib/action_controller/metal/live.rb @@ -133,15 +133,16 @@ def write(object, options = {}) private def perform_write(json, options) current_options = @options.merge(options).stringify_keys - + event = +"" PERMITTED_OPTIONS.each do |option_name| if (option_value = current_options[option_name]) - @stream.write "#{option_name}: #{option_value}\n" + event << "#{option_name}: #{option_value}\n" end end message = json.gsub("\n", "\ndata: ") - @stream.write "data: #{message}\n\n" + event << "data: #{message}\n\n" + @stream.write event end end @@ -236,12 +237,7 @@ def call_on_error private def each_chunk(&block) - loop do - str = nil - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - str = @buf.pop - end - break unless str + while str = @buf.pop yield str end end @@ -275,16 +271,14 @@ def process(name) # This processes the action in a child thread. It lets us return the response # code and headers back up the Rack stack, and still process the body in # parallel with sending data to the client. - new_controller_thread { + new_controller_thread do ActiveSupport::Dependencies.interlock.running do t2 = Thread.current # Since we're processing the view in a different thread, copy the thread locals # from the main thread to the child thread. :'( locals.each { |k, v| t2[k] = v } - ActiveSupport::IsolatedExecutionState.share_with(t1) - - begin + ActiveSupport::IsolatedExecutionState.share_with(t1) do super(name) rescue => e if @_response.committed? @@ -301,18 +295,15 @@ def process(name) error = e end ensure - ActiveSupport::IsolatedExecutionState.clear clean_up_thread_locals(locals, t2) @_response.commit! end end - } - - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @_response.await_commit end + @_response.await_commit + raise error if error end diff --git a/actionpack/lib/action_controller/metal/rate_limiting.rb b/actionpack/lib/action_controller/metal/rate_limiting.rb index 33940664cbebe..3592827cccedd 100644 --- a/actionpack/lib/action_controller/metal/rate_limiting.rb +++ b/actionpack/lib/action_controller/metal/rate_limiting.rb @@ -10,16 +10,25 @@ module ClassMethods # Applies a rate limit to all actions or those specified by the normal # `before_action` filters with `only:` and `except:`. # - # The maximum number of requests allowed is specified `to:` and constrained to + # The maximum number of requests allowed is specified by `to:` and constrained to # the window of time given by `within:`. # + # Both `to:` and `within:` can be static values, callables, + # or method names (as symbols) that will be evaluated in the context of the + # controller processing the request. + # # Rate limits are by default unique to the ip address making the request, but # you can provide your own identity function by passing a callable in the `by:` # parameter. It's evaluated within the context of the controller processing the # request. # - # Requests that exceed the rate limit are refused with a `429 Too Many Requests` - # response. You can specialize this by passing a callable in the `with:` + # By default, rate limits are scoped to the controller's path. If you want to + # share rate limits across multiple controllers, you can provide your own scope, + # by passing value in the `scope:` parameter. + # + # Requests that exceed the rate limit will raise an `ActionController::TooManyRequests` + # error. By default, Action Dispatch will rescue from the error and refuse the request + # with a `429 Too Many Requests` response. You can specialize this by passing a callable in the `with:` # parameter. It's evaluated within the context of the controller processing the # request. # @@ -40,30 +49,58 @@ module ClassMethods # # class SignupsController < ApplicationController # rate_limit to: 1000, within: 10.seconds, - # by: -> { request.domain }, with: -> { redirect_to busy_controller_url, alert: "Too many signups on domain!" }, only: :new + # by: -> { request.domain }, with: :redirect_to_busy, only: :new + # + # private + # def redirect_to_busy + # redirect_to busy_controller_url, alert: "Too many signups on domain!" + # end # end # # class APIController < ApplicationController # RATE_LIMIT_STORE = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"]) # rate_limit to: 10, within: 3.minutes, store: RATE_LIMIT_STORE + # rate_limit to: 100, within: 5.minutes, scope: :api_global + # rate_limit to: :max_requests, within: :time_window, by: -> { current_user.id } + # + # private + # def max_requests + # current_user.premium? ? 1000 : 100 + # end + # + # def time_window + # current_user.premium? ? 1.hour : 1.minute + # end # end # # class SessionsController < ApplicationController # rate_limit to: 3, within: 2.seconds, name: "short-term" # rate_limit to: 10, within: 5.minutes, name: "long-term" # end - def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { head :too_many_requests }, store: cache_store, name: nil, **options) - before_action -> { rate_limiting(to: to, within: within, by: by, with: with, store: store, name: name) }, **options + def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { raise TooManyRequests }, store: cache_store, name: nil, scope: nil, **options) + before_action -> { rate_limiting(to: to, within: within, by: by, with: with, store: store, name: name, scope: scope || controller_path) }, **options end end private - def rate_limiting(to:, within:, by:, with:, store:, name:) - cache_key = ["rate-limit", controller_path, name, instance_exec(&by)].compact.join(":") + def rate_limiting(to:, within:, by:, with:, store:, name:, scope:) + by = by.is_a?(Symbol) ? send(by) : instance_exec(&by) + to = to.is_a?(Symbol) ? send(to) : (to.respond_to?(:call) ? instance_exec(&to) : to) + within = within.is_a?(Symbol) ? send(within) : (within.respond_to?(:call) ? instance_exec(&within) : within) + + cache_key = ["rate-limit", scope, name, by].compact.join(":") count = store.increment(cache_key, 1, expires_in: within) if count && count > to - ActiveSupport::Notifications.instrument("rate_limit.action_controller", request: request) do - instance_exec(&with) + ActiveSupport::Notifications.instrument("rate_limit.action_controller", + request: request, + count: count, + to: to, + within: within, + by: by, + name: name, + scope: scope, + cache_key: cache_key) do + with.is_a?(Symbol) ? send(with) : instance_exec(&with) end end end diff --git a/actionpack/lib/action_controller/metal/redirecting.rb b/actionpack/lib/action_controller/metal/redirecting.rb index 68241cdbc0ec8..6363d82b5dd80 100644 --- a/actionpack/lib/action_controller/metal/redirecting.rb +++ b/actionpack/lib/action_controller/metal/redirecting.rb @@ -11,10 +11,36 @@ module Redirecting class UnsafeRedirectError < StandardError; end + class OpenRedirectError < UnsafeRedirectError + def initialize(location) + super("Unsafe redirect to #{location.to_s.truncate(100).inspect}, pass allow_other_host: true to redirect anyway.") + end + end + + class PathRelativeRedirectError < UnsafeRedirectError + def initialize(url) + super("Path relative URL redirect detected: #{url.inspect}") + end + end + ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/ included do mattr_accessor :raise_on_open_redirects, default: false + mattr_accessor :action_on_open_redirect, default: :log + mattr_accessor :action_on_path_relative_redirect, default: :log + class_attribute :_allowed_redirect_hosts, :allowed_redirect_hosts_permissions, instance_accessor: false, instance_predicate: false + singleton_class.alias_method :allowed_redirect_hosts, :_allowed_redirect_hosts + end + + module ClassMethods # :nodoc: + def allowed_redirect_hosts=(hosts) + hosts = hosts.dup.freeze + self._allowed_redirect_hosts = hosts + self.allowed_redirect_hosts_permissions = if hosts.present? + ActionDispatch::HostAuthorization::Permissions.new(hosts) + end + end end # Redirects the browser to the target specified in `options`. This parameter can @@ -82,16 +108,17 @@ class UnsafeRedirectError < StandardError; end # ### Open Redirect protection # # By default, Rails protects against redirecting to external hosts for your - # app's safety, so called open redirects. Note: this was a new default in Rails - # 7.0, after upgrading opt-in by uncommenting the line with - # `raise_on_open_redirects` in - # `config/initializers/new_framework_defaults_7_0.rb` + # app's safety, so called open redirects. # # Here #redirect_to automatically validates the potentially-unsafe URL: # # redirect_to params[:redirect_url] # - # Raises UnsafeRedirectError in the case of an unsafe redirect. + # The `action_on_open_redirect` configuration option controls the behavior when an unsafe + # redirect is detected: + # * `:log` - Logs a warning but allows the redirect + # * `:notify` - Sends an Active Support notification for monitoring + # * `:raise` - Raises an UnsafeRedirectError # # To allow any external redirects pass `allow_other_host: true`, though using a # user-provided param in that case is unsafe. @@ -100,11 +127,31 @@ class UnsafeRedirectError < StandardError; end # # See #url_from for more information on what an internal and safe URL is, or how # to fall back to an alternate redirect URL in the unsafe case. + # + # ### Path Relative URL Redirect Protection + # + # Rails also protects against potentially unsafe path relative URL redirects that don't + # start with a leading slash. These can create security vulnerabilities: + # + # redirect_to "example.com" # Creates http://yourdomain.comexample.com + # redirect_to "@attacker.com" # Creates http://yourdomain.com@attacker.com + # # which browsers interpret as user@host + # + # You can configure how Rails handles these cases using: + # + # config.action_controller.action_on_path_relative_redirect = :log # default + # config.action_controller.action_on_path_relative_redirect = :notify + # config.action_controller.action_on_path_relative_redirect = :raise + # + # * `:log` - Logs a warning but allows the redirect + # * `:notify` - Sends an Active Support notification but allows the redirect + # (includes stack trace to help identify the source) + # * `:raise` - Raises an UnsafeRedirectError def redirect_to(options = {}, response_options = {}) raise ActionControllerError.new("Cannot redirect to nil!") unless options raise AbstractController::DoubleRenderError if response_body - allow_other_host = response_options.delete(:allow_other_host) { _allow_other_host } + allow_other_host = response_options.delete(:allow_other_host) proposed_status = _extract_redirect_to_status(options, response_options) @@ -166,6 +213,10 @@ def _compute_redirect_to_location(request, options) # :nodoc: when /\A([a-z][a-z\d\-+.]*:|\/\/).*/i options.to_str when String + if !options.start_with?("/", "?") && !options.empty? + _handle_path_relative_redirect(options) + end + request.protocol + request.host_with_port + options when Proc _compute_redirect_to_location request, instance_eval(&options) @@ -207,34 +258,58 @@ def url_from(location) private def _allow_other_host - !raise_on_open_redirects + return false if raise_on_open_redirects + + action_on_open_redirect != :raise end def _extract_redirect_to_status(options, response_options) if options.is_a?(Hash) && options.key?(:status) - Rack::Utils.status_code(options.delete(:status)) + ActionDispatch::Response.rack_status_code(options.delete(:status)) elsif response_options.key?(:status) - Rack::Utils.status_code(response_options[:status]) + ActionDispatch::Response.rack_status_code(response_options[:status]) else 302 end end def _enforce_open_redirect_protection(location, allow_other_host:) + # Explicitly allowed other host or host is in allow list allow redirect if allow_other_host || _url_host_allowed?(location) location + # Explicitly disallowed other host + elsif allow_other_host == false + raise OpenRedirectError.new(location) + # Configuration disallows other hosts + elsif !_allow_other_host + raise OpenRedirectError.new(location) + # Log but allow redirect + elsif action_on_open_redirect == :log + logger.warn "Open redirect to #{location.inspect} detected" if logger + location + # Notify but allow redirect + elsif action_on_open_redirect == :notify + ActiveSupport::Notifications.instrument("open_redirect.action_controller", + location: location, + request: request, + stack_trace: caller, + ) + location + # Fall through, should not happen but raise for safety else - raise UnsafeRedirectError, "Unsafe redirect to #{location.truncate(100).inspect}, pass allow_other_host: true to redirect anyway." + raise OpenRedirectError.new(location) end end def _url_host_allowed?(url) - host = URI(url.to_s).host + url_to_s = url.to_s + host = URI(url_to_s).host - return true if host == request.host - return false unless host.nil? - return false unless url.to_s.start_with?("/") - !url.to_s.start_with?("//") + if host.nil? + url_to_s.start_with?("/") && !url_to_s.start_with?("//") + else + host == request.host || self.class.allowed_redirect_hosts_permissions&.allows?(host) + end rescue ArgumentError, URI::Error false end @@ -248,5 +323,22 @@ def _ensure_url_is_http_header_safe(url) raise UnsafeRedirectError, msg end end + + def _handle_path_relative_redirect(url) + message = "Path relative URL redirect detected: #{url.inspect}" + + case action_on_path_relative_redirect + when :log + logger&.warn message + when :notify + ActiveSupport::Notifications.instrument("unsafe_redirect.action_controller", + url: url, + message: message, + stack_trace: caller + ) + when :raise + raise PathRelativeRedirectError.new(url) + end + end end end diff --git a/actionpack/lib/action_controller/metal/renderers.rb b/actionpack/lib/action_controller/metal/renderers.rb index d3d802d8dd1c0..2065a14e089c0 100644 --- a/actionpack/lib/action_controller/metal/renderers.rb +++ b/actionpack/lib/action_controller/metal/renderers.rb @@ -27,8 +27,23 @@ module Renderers # Default values are `:json`, `:js`, `:xml`. RENDERERS = Set.new + module DeprecatedEscapeJsonResponses # :nodoc: + def escape_json_responses=(value) + if value + ActionController.deprecator.warn(<<~MSG.squish) + Setting action_controller.escape_json_responses = true is deprecated and will have no effect in Rails 8.2. + Set it to `false`, or remove the config. + MSG + end + super + end + end + included do class_attribute :_renderers, default: Set.new.freeze + class_attribute :escape_json_responses, instance_writer: false, instance_accessor: false, default: true + + singleton_class.prepend DeprecatedEscapeJsonResponses end # Used in ActionController::Base and ActionController::API to include all @@ -86,7 +101,7 @@ def self.remove(key) remove_possible_method(method_name) end - def self._render_with_renderer_method_name(key) + def self._render_with_renderer_method_name(key) # :nodoc: "_render_with_renderer_#{key}" end @@ -140,7 +155,7 @@ def render_to_body(options) _render_to_body_with_renderer(options) || super end - def _render_to_body_with_renderer(options) + def _render_to_body_with_renderer(options) # :nodoc: _renderers.each do |name| if options.key?(name) _process_options(options) @@ -153,6 +168,7 @@ def _render_to_body_with_renderer(options) add :json do |json, options| json_options = options.except(:callback, :content_type, :status) + json_options[:escape] ||= false if !self.class.escape_json_responses? && options[:callback].blank? json = json.to_json(json_options) unless json.kind_of?(String) if options[:callback].present? @@ -176,5 +192,10 @@ def _render_to_body_with_renderer(options) self.content_type = :xml if media_type.nil? xml.respond_to?(:to_xml) ? xml.to_xml(options) : xml end + + add :markdown do |md, options| + self.content_type = :md if media_type.nil? + md.respond_to?(:to_markdown) ? md.to_markdown : md + end end end diff --git a/actionpack/lib/action_controller/metal/rendering.rb b/actionpack/lib/action_controller/metal/rendering.rb index 1c8fb1f80a18a..eb2ac71d2b285 100644 --- a/actionpack/lib/action_controller/metal/rendering.rb +++ b/actionpack/lib/action_controller/metal/rendering.rb @@ -238,7 +238,7 @@ def _normalize_options(options) end if options[:status] - options[:status] = Rack::Utils.status_code(options[:status]) + options[:status] = ActionDispatch::Response.rack_status_code(options[:status]) end super diff --git a/actionpack/lib/action_controller/metal/request_forgery_protection.rb b/actionpack/lib/action_controller/metal/request_forgery_protection.rb index 5aaed166fc544..0bd6d91261e01 100644 --- a/actionpack/lib/action_controller/metal/request_forgery_protection.rb +++ b/actionpack/lib/action_controller/metal/request_forgery_protection.rb @@ -642,7 +642,7 @@ def valid_request_origin? # :doc: end end - def normalize_action_path(action_path) # :doc: + def normalize_action_path(action_path) uri = URI.parse(action_path) if uri.relative? && (action_path.blank? || !action_path.start_with?("/")) @@ -652,7 +652,7 @@ def normalize_action_path(action_path) # :doc: end end - def normalize_relative_action_path(rel_action_path) # :doc: + def normalize_relative_action_path(rel_action_path) uri = URI.parse(request.path) # add the action path to the request.path uri.path += "/#{rel_action_path}" diff --git a/actionpack/lib/action_controller/metal/rescue.rb b/actionpack/lib/action_controller/metal/rescue.rb index 634dd5dcd0a26..4affddde7e8dd 100644 --- a/actionpack/lib/action_controller/metal/rescue.rb +++ b/actionpack/lib/action_controller/metal/rescue.rb @@ -13,6 +13,15 @@ module Rescue extend ActiveSupport::Concern include ActiveSupport::Rescuable + module ClassMethods + def handler_for_rescue(exception, ...) # :nodoc: + if handler = super + ActiveSupport::Notifications.instrument("rescue_from_callback.action_controller", exception: exception) + handler + end + end + end + # Override this method if you want to customize when detailed exceptions must be # shown. This method is only called when `consider_all_requests_local` is # `false`. By default, it returns `false`, but someone may set it to diff --git a/actionpack/lib/action_controller/metal/strong_parameters.rb b/actionpack/lib/action_controller/metal/strong_parameters.rb index e81f82a90d4ac..3180e8e3b38df 100644 --- a/actionpack/lib/action_controller/metal/strong_parameters.rb +++ b/actionpack/lib/action_controller/metal/strong_parameters.rb @@ -316,6 +316,10 @@ def hash [self.class, @parameters, @permitted].hash end + def deconstruct_keys(keys) + slice(*keys).each.with_object({}) { |(key, value), hash| hash.merge!(key.to_sym => value) } + end + # Returns a safe ActiveSupport::HashWithIndifferentAccess representation of the # parameters with all unpermitted keys removed. # diff --git a/actionpack/lib/action_controller/railtie.rb b/actionpack/lib/action_controller/railtie.rb index 4b0619402e5ad..fc4f3cbfbdba2 100644 --- a/actionpack/lib/action_controller/railtie.rb +++ b/actionpack/lib/action_controller/railtie.rb @@ -12,9 +12,11 @@ module ActionController class Railtie < Rails::Railtie # :nodoc: config.action_controller = ActiveSupport::OrderedOptions.new - config.action_controller.raise_on_open_redirects = false + config.action_controller.action_on_open_redirect = :log + config.action_controller.action_on_path_relative_redirect = :log config.action_controller.log_query_tags_around_actions = true config.action_controller.wrap_parameters_by_default = false + config.action_controller.allowed_redirect_hosts = [] config.eager_load_namespaces << AbstractController config.eager_load_namespaces << ActionController @@ -55,7 +57,8 @@ class Railtie < Rails::Railtie # :nodoc: paths = app.config.paths options = app.config.action_controller - options.logger ||= Rails.logger + options.logger = options.fetch(:logger, Rails.logger) + options.cache_store ||= Rails.cache options.javascripts_dir ||= paths["public/javascripts"].first @@ -101,6 +104,22 @@ class Railtie < Rails::Railtie # :nodoc: end end + initializer "action_controller.open_redirects" do |app| + ActiveSupport.on_load(:action_controller, run_once: true) do + if app.config.action_controller.has_key?(:raise_on_open_redirects) + ActiveSupport.deprecator.warn(<<~MSG.squish) + `raise_on_open_redirects` is deprecated and will be removed in a future Rails version. + Use `config.action_controller.action_on_open_redirect = :raise` instead. + MSG + + # Fallback to the default behavior in case of `load_default` set `action_on_open_redirect`, but apps set `raise_on_open_redirects`. + if app.config.action_controller.raise_on_open_redirects == false && app.config.action_controller.action_on_open_redirect == :raise + self.action_on_open_redirect = :log + end + end + end + end + initializer "action_controller.query_log_tags" do |app| query_logs_tags_enabled = app.config.respond_to?(:active_record) && app.config.active_record.query_log_tags_enabled && @@ -114,15 +133,7 @@ class Railtie < Rails::Railtie # :nodoc: ActiveRecord::QueryLogs.taggings = ActiveRecord::QueryLogs.taggings.merge( controller: ->(context) { context[:controller]&.controller_name }, action: ->(context) { context[:controller]&.action_name }, - namespaced_controller: ->(context) { - if context[:controller] - controller_class = context[:controller].class - # based on ActionController::Metal#controller_name, but does not demodulize - unless controller_class.anonymous? - controller_class.name.delete_suffix("Controller").underscore - end - end - } + namespaced_controller: ->(context) { context[:controller]&.controller_path } ) end end @@ -133,5 +144,11 @@ class Railtie < Rails::Railtie # :nodoc: ActionController::TestCase.executor_around_each_request = app.config.active_support.executor_around_test_case end end + + initializer "action_controller.backtrace_cleaner" do + ActiveSupport.on_load(:action_controller) do + ActionController::LogSubscriber.backtrace_cleaner = Rails.backtrace_cleaner + end + end end end diff --git a/actionpack/lib/action_controller/renderer.rb b/actionpack/lib/action_controller/renderer.rb index 068d023bc9585..dd0f01c345b56 100644 --- a/actionpack/lib/action_controller/renderer.rb +++ b/actionpack/lib/action_controller/renderer.rb @@ -96,7 +96,6 @@ def with_defaults(defaults) # * `:script_name` - The portion of the incoming request's URL path that # corresponds to the application. Converts to Rack's `SCRIPT_NAME`. # * `:input` - The input stream. Converts to Rack's `rack.input`. - # # * `defaults` - Default values for the Rack env. Entries are specified in the # same format as `env`. `env` will be merged on top of these values. # `defaults` will be retained when calling #new on a renderer instance. diff --git a/actionpack/lib/action_controller/structured_event_subscriber.rb b/actionpack/lib/action_controller/structured_event_subscriber.rb new file mode 100644 index 0000000000000..8bee0acffeb2f --- /dev/null +++ b/actionpack/lib/action_controller/structured_event_subscriber.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module ActionController + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + INTERNAL_PARAMS = %w(controller action format _method only_path) + + def start_processing(event) + payload = event.payload + params = {} + payload[:params].each_pair do |k, v| + params[k] = v unless INTERNAL_PARAMS.include?(k) + end + format = payload[:format] + format = format.to_s.upcase if format.is_a?(Symbol) + format = "*/*" if format.nil? + + emit_event("action_controller.request_started", + controller: payload[:controller], + action: payload[:action], + format:, + params:, + ) + end + + def process_action(event) + payload = event.payload + status = payload[:status] + + if status.nil? && (exception_class_name = payload[:exception]&.first) + status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception_class_name) + end + + emit_event("action_controller.request_completed", { + controller: payload[:controller], + action: payload[:action], + status: status, + **additions_for(payload), + duration_ms: event.duration.round(2), + gc_time_ms: event.gc_time.round(1), + }.compact) + end + + def halted_callback(event) + emit_event("action_controller.callback_halted", filter: event.payload[:filter]) + end + + def rescue_from_callback(event) + exception = event.payload[:exception] + + exception_backtrace = exception.backtrace&.first + exception_backtrace = exception_backtrace&.delete_prefix("#{Rails.root}/") if defined?(Rails.root) && Rails.root + + emit_event("action_controller.rescue_from_handled", + exception_class: exception.class.name, + exception_message: exception.message, + exception_backtrace: + ) + end + + def send_file(event) + emit_event("action_controller.file_sent", path: event.payload[:path], duration_ms: event.duration.round(1)) + end + + def redirect_to(event) + emit_event("action_controller.redirected", location: event.payload[:location]) + end + + def send_data(event) + emit_event("action_controller.data_sent", filename: event.payload[:filename], duration_ms: event.duration.round(1)) + end + + def unpermitted_parameters(event) + unpermitted_keys = event.payload[:keys] + context = event.payload[:context] + + emit_debug_event("action_controller.unpermitted_parameters", + unpermitted_keys:, + context: context.except(:request) + ) + end + debug_only :unpermitted_parameters + + def write_fragment(event) + fragment_cache(__method__, event) + end + + def read_fragment(event) + fragment_cache(__method__, event) + end + + def exist_fragment?(event) + fragment_cache(__method__, event) + end + + def expire_fragment(event) + fragment_cache(__method__, event) + end + + private + def fragment_cache(method_name, event) + key = ActiveSupport::Cache.expand_cache_key(event.payload[:key] || event.payload[:path]) + + emit_event("action_controller.fragment_cache", + method: "#{method_name}", + key: key, + duration_ms: event.duration.round(1) + ) + end + + def additions_for(payload) + payload.slice(:view_runtime, :db_runtime, :queries_count, :cached_queries_count) + end + end +end + +ActionController::StructuredEventSubscriber.attach_to :action_controller diff --git a/actionpack/lib/action_dispatch.rb b/actionpack/lib/action_dispatch.rb index 24107c901b15c..c24348d13e7e5 100644 --- a/actionpack/lib/action_dispatch.rb +++ b/actionpack/lib/action_dispatch.rb @@ -138,6 +138,14 @@ def self.resolve_store(session_store) # :nodoc: autoload :SystemTestCase, "action_dispatch/system_test_case" + ## + # :singleton-method: + # + # Specifies if the methods calling redirects in controllers and routes should + # be logged below their relevant log lines. Defaults to false. + singleton_class.attr_accessor :verbose_redirect_logs + self.verbose_redirect_logs = false + def eager_load! super Routing.eager_load! diff --git a/actionpack/lib/action_dispatch/constants.rb b/actionpack/lib/action_dispatch/constants.rb index c1b53150e1ebc..325040400305e 100644 --- a/actionpack/lib/action_dispatch/constants.rb +++ b/actionpack/lib/action_dispatch/constants.rb @@ -30,5 +30,11 @@ module Constants SERVER_TIMING = "server-timing" STRICT_TRANSPORT_SECURITY = "strict-transport-security" end + + if Gem::Version.new(Rack::RELEASE) < Gem::Version.new("3.1") + UNPROCESSABLE_CONTENT = :unprocessable_entity + else + UNPROCESSABLE_CONTENT = :unprocessable_content + end end end diff --git a/actionpack/lib/action_dispatch/http/cache.rb b/actionpack/lib/action_dispatch/http/cache.rb index 8f9bfc8528878..1486309d85edd 100644 --- a/actionpack/lib/action_dispatch/http/cache.rb +++ b/actionpack/lib/action_dispatch/http/cache.rb @@ -63,6 +63,114 @@ def fresh?(response) success end end + + def cache_control_directives + @cache_control_directives ||= CacheControlDirectives.new(get_header("HTTP_CACHE_CONTROL")) + end + + # Represents the HTTP Cache-Control header for requests, + # providing methods to access various cache control directives + # Reference: https://www.rfc-editor.org/rfc/rfc9111.html#name-request-directives + class CacheControlDirectives + def initialize(cache_control_header) + @only_if_cached = false + @no_cache = false + @no_store = false + @no_transform = false + @max_age = nil + @max_stale = nil + @min_fresh = nil + @stale_if_error = false + parse_directives(cache_control_header) + end + + # Returns true if the only-if-cached directive is present. + # This directive indicates that the client only wishes to obtain a + # stored response. If a valid stored response is not available, + # the server should respond with a 504 (Gateway Timeout) status. + def only_if_cached? + @only_if_cached + end + + # Returns true if the no-cache directive is present. + # This directive indicates that a cache must not use the response + # to satisfy subsequent requests without successful validation on the origin server. + def no_cache? + @no_cache + end + + # Returns true if the no-store directive is present. + # This directive indicates that a cache must not store any part of the + # request or response. + def no_store? + @no_store + end + + # Returns true if the no-transform directive is present. + # This directive indicates that a cache or proxy must not transform the payload. + def no_transform? + @no_transform + end + + # Returns the value of the max-age directive. + # This directive indicates that the client is willing to accept a response + # whose age is no greater than the specified number of seconds. + attr_reader :max_age + + # Returns the value of the max-stale directive. + # When max-stale is present with a value, returns that integer value. + # When max-stale is present without a value, returns true (unlimited staleness). + # When max-stale is not present, returns nil. + attr_reader :max_stale + + # Returns true if max-stale directive is present (with or without a value) + def max_stale? + !@max_stale.nil? + end + + # Returns true if max-stale directive is present without a value (unlimited staleness) + def max_stale_unlimited? + @max_stale == true + end + + # Returns the value of the min-fresh directive. + # This directive indicates that the client is willing to accept a response + # whose freshness lifetime is no less than its current age plus the specified time in seconds. + attr_reader :min_fresh + + # Returns the value of the stale-if-error directive. + # This directive indicates that the client is willing to accept a stale response + # if the check for a fresh one fails with an error for the specified number of seconds. + attr_reader :stale_if_error + + private + def parse_directives(header_value) + return unless header_value + + header_value.delete(" ").downcase.split(",").each do |directive| + name, value = directive.split("=", 2) + + case name + when "max-age" + @max_age = value.to_i + when "min-fresh" + @min_fresh = value.to_i + when "stale-if-error" + @stale_if_error = value.to_i + when "no-cache" + @no_cache = true + when "no-store" + @no_store = true + when "no-transform" + @no_transform = true + when "only-if-cached" + @only_if_cached = true + when "max-stale" + @max_stale = value ? value.to_i : true + end + end + end + end end module Response @@ -142,7 +250,7 @@ def strong_etag? private DATE = "Date" LAST_MODIFIED = "Last-Modified" - SPECIAL_KEYS = Set.new(%w[extras no-store no-cache max-age public private must-revalidate]) + SPECIAL_KEYS = Set.new(%w[extras no-store no-cache max-age public private must-revalidate must-understand]) def generate_weak_etag(validators) "W/#{generate_strong_etag(validators)}" @@ -187,6 +295,7 @@ def prepare_cache_control! PRIVATE = "private" MUST_REVALIDATE = "must-revalidate" IMMUTABLE = "immutable" + MUST_UNDERSTAND = "must-understand" def handle_conditional_get! # Normally default cache control setting is handled by ETag middleware. But, if @@ -221,6 +330,7 @@ def merge_and_normalize_cache_control!(cache_control) if control[:no_store] options << PRIVATE if control[:private] + options << MUST_UNDERSTAND if control[:must_understand] options << NO_STORE elsif control[:no_cache] options << PUBLIC if control[:public] diff --git a/actionpack/lib/action_dispatch/http/content_security_policy.rb b/actionpack/lib/action_dispatch/http/content_security_policy.rb index 9194765a2e27e..2c21ff9a4120f 100644 --- a/actionpack/lib/action_dispatch/http/content_security_policy.rb +++ b/actionpack/lib/action_dispatch/http/content_security_policy.rb @@ -171,6 +171,8 @@ def generate_content_security_policy_nonce worker_src: "worker-src" }.freeze + HASH_SOURCE_ALGORITHM_PREFIXES = ["sha256-", "sha384-", "sha512-"].freeze + DEFAULT_NONCE_DIRECTIVES = %w[script-src style-src].freeze private_constant :MAPPINGS, :DIRECTIVES, :DEFAULT_NONCE_DIRECTIVES @@ -305,7 +307,13 @@ def apply_mappings(sources) case source when Symbol apply_mapping(source) - when String, Proc + when String + if hash_source?(source) + "'#{source}'" + else + source + end + when Proc source else raise ArgumentError, "Invalid content security policy source: #{source.inspect}" @@ -374,5 +382,9 @@ def resolve_source(source, context) def nonce_directive?(directive, nonce_directives) nonce_directives.include?(directive) end + + def hash_source?(source) + source.start_with?(*HASH_SOURCE_ALGORITHM_PREFIXES) + end end end diff --git a/actionpack/lib/action_dispatch/http/mime_negotiation.rb b/actionpack/lib/action_dispatch/http/mime_negotiation.rb index ce44efb965353..2947c719f17cd 100644 --- a/actionpack/lib/action_dispatch/http/mime_negotiation.rb +++ b/actionpack/lib/action_dispatch/http/mime_negotiation.rb @@ -56,9 +56,14 @@ def accepts # Returns the MIME type for the format used in the request. # - # GET /posts/5.xml | request.format => Mime[:xml] - # GET /posts/5.xhtml | request.format => Mime[:html] - # GET /posts/5 | request.format => Mime[:html] or Mime[:js], or request.accepts.first + # # GET /posts/5.xml + # request.format # => Mime[:xml] + # + # # GET /posts/5.xhtml + # request.format # => Mime[:html] + # + # # GET /posts/5 + # request.format # => Mime[:html] or Mime[:js], or request.accepts.first # def format(_view_path = nil) formats.first || Mime::NullType.instance @@ -86,7 +91,49 @@ def formats end end - # Sets the variant for template. + # Sets the \variant for the response template. + # + # When determining which template to render, Action View will incorporate + # all variants from the request. For example, if an + # `ArticlesController#index` action needs to respond to + # `request.variant = [:ios, :turbo_native]`, it will render the + # first template file it can find in the following list: + # + # - `app/views/articles/index.html+ios.erb` + # - `app/views/articles/index.html+turbo_native.erb` + # - `app/views/articles/index.html.erb` + # + # Variants add context to the requests that views render appropriately. + # Variant names are arbitrary, and can communicate anything from the + # request's platform (`:android`, `:ios`, `:linux`, `:macos`, `:windows`) + # to its browser (`:chrome`, `:edge`, `:firefox`, `:safari`), to the type + # of user (`:admin`, `:guest`, `:user`). + # + # Note: Adding many new variant templates with similarities to existing + # template files can make maintaining your view code more difficult. + # + # #### Parameters + # + # * `variant` - a symbol name or an array of symbol names for variants + # used to render the response template + # + # #### Examples + # + # class ApplicationController < ActionController::Base + # before_action :determine_variants + # + # private + # def determine_variants + # variants = [] + # + # # some code to determine the variant(s) to use + # + # variants << :ios if request.user_agent.include?("iOS") + # variants << :turbo_native if request.user_agent.include?("Turbo Native") + # + # request.variant = variants + # end + # end def variant=(variant) variant = Array(variant) @@ -97,6 +144,18 @@ def variant=(variant) end end + # Returns the \variant for the response template as an instance of + # ActiveSupport::ArrayInquirer. + # + # request.variant = :phone + # request.variant.phone? # => true + # request.variant.tablet? # => false + # + # request.variant = [:phone, :tablet] + # request.variant.phone? # => true + # request.variant.desktop? # => false + # request.variant.any?(:phone, :desktop) # => true + # request.variant.any?(:desktop, :watch) # => false def variant @variant ||= ActiveSupport::ArrayInquirer.new end diff --git a/actionpack/lib/action_dispatch/http/mime_types.rb b/actionpack/lib/action_dispatch/http/mime_types.rb index 426154fe57dea..56e00af88d4ec 100644 --- a/actionpack/lib/action_dispatch/http/mime_types.rb +++ b/actionpack/lib/action_dispatch/http/mime_types.rb @@ -13,6 +13,7 @@ Mime::Type.register "text/csv", :csv Mime::Type.register "text/vcard", :vcf Mime::Type.register "text/vtt", :vtt, %w(vtt) +Mime::Type.register "text/markdown", :md, [], %w(md markdown) Mime::Type.register "image/png", :png, [], %w(png) Mime::Type.register "image/jpeg", :jpeg, [], %w(jpg jpeg jpe pjpeg) diff --git a/actionpack/lib/action_dispatch/http/param_builder.rb b/actionpack/lib/action_dispatch/http/param_builder.rb index cf6cd6f9da16e..6e8a9e1281293 100644 --- a/actionpack/lib/action_dispatch/http/param_builder.rb +++ b/actionpack/lib/action_dispatch/http/param_builder.rb @@ -16,15 +16,27 @@ def initialize(param_depth_limit) @param_depth_limit = param_depth_limit end - cattr_accessor :ignore_leading_brackets - - LEADING_BRACKETS_COMPAT = defined?(::Rack::RELEASE) && ::Rack::RELEASE.to_s.start_with?("2.") - cattr_accessor :default self.default = make_default(100) class << self delegate :from_query_string, :from_pairs, :from_hash, to: :default + + def ignore_leading_brackets + ActionDispatch.deprecator.warn <<~MSG + ActionDispatch::ParamBuilder.ignore_leading_brackets is deprecated and have no effect and will be removed in Rails 8.2. + MSG + + @ignore_leading_brackets + end + + def ignore_leading_brackets=(value) + ActionDispatch.deprecator.warn <<~MSG + ActionDispatch::ParamBuilder.ignore_leading_brackets is deprecated and have no effect and will be removed in Rails 8.2. + MSG + + @ignore_leading_brackets = value + end end def from_query_string(qs, separator: nil, encoding_template: nil) @@ -69,30 +81,15 @@ def store_nested_param(params, name, v, depth, encoding_template = nil) # nil name, treat same as empty string (required by tests) k = after = "" elsif depth == 0 - if ignore_leading_brackets || (ignore_leading_brackets.nil? && LEADING_BRACKETS_COMPAT) - # Rack 2 compatible behavior, ignore leading brackets - if name =~ /\A[\[\]]*([^\[\]]+)\]*/ - k = $1 - after = $' || "" - - if !ignore_leading_brackets && (k != $& || !after.empty? && !after.start_with?("[")) - ActionDispatch.deprecator.warn("Skipping over leading brackets in parameter name #{name.inspect} is deprecated and will parse differently in Rails 8.1 or Rack 3.0.") - end - else - k = name - after = "" - end + # Start of parsing, don't treat [] or [ at start of string specially + if start = name.index("[", 1) + # Start of parameter nesting, use part before brackets as key + k = name[0, start] + after = name[start, name.length] else - # Start of parsing, don't treat [] or [ at start of string specially - if start = name.index("[", 1) - # Start of parameter nesting, use part before brackets as key - k = name[0, start] - after = name[start, name.length] - else - # Plain parameter with no nesting - k = name - after = "" - end + # Plain parameter with no nesting + k = name + after = "" end elsif name.start_with?("[]") # Array nesting @@ -111,6 +108,10 @@ def store_nested_param(params, name, v, depth, encoding_template = nil) return if k.empty? + unless k.valid_encoding? + raise InvalidParameterError, "Invalid encoding for parameter: #{k}" + end + if depth == 0 && String === v # We have to wait until we've found the top part of the name, # because that's what the encoding template is configured with diff --git a/actionpack/lib/action_dispatch/http/query_parser.rb b/actionpack/lib/action_dispatch/http/query_parser.rb index 55488b6170858..6afdb64434fc0 100644 --- a/actionpack/lib/action_dispatch/http/query_parser.rb +++ b/actionpack/lib/action_dispatch/http/query_parser.rb @@ -6,12 +6,21 @@ module ActionDispatch class QueryParser DEFAULT_SEP = /& */n - COMPAT_SEP = /[&;] */n COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n, "&;" => /[&;] */n } - cattr_accessor :strict_query_string_separator + def self.strict_query_string_separator + ActionDispatch.deprecator.warn <<~MSG + The `strict_query_string_separator` configuration is deprecated have no effect and will be removed in Rails 8.2. + MSG + @strict_query_string_separator + end - SEMICOLON_COMPAT = defined?(::Rack::QueryParser::DEFAULT_SEP) && ::Rack::QueryParser::DEFAULT_SEP.to_s.include?(";") + def self.strict_query_string_separator=(value) + ActionDispatch.deprecator.warn <<~MSG + The `strict_query_string_separator` configuration is deprecated have no effect and will be removed in Rails 8.2. + MSG + @strict_query_string_separator = value + end #-- # Note this departs from WHATWG's specified parsing algorithm by @@ -25,13 +34,6 @@ def self.each_pair(s, separator = nil) splitter = if separator COMMON_SEP[separator] || /[#{separator}] */n - elsif strict_query_string_separator - DEFAULT_SEP - elsif SEMICOLON_COMPAT && s.include?(";") - if strict_query_string_separator.nil? - ActionDispatch.deprecator.warn("Using semicolon as a query string separator is deprecated and will not be supported in Rails 8.1 or Rack 3.0. Use `&` instead.") - end - COMPAT_SEP else DEFAULT_SEP end diff --git a/actionpack/lib/action_dispatch/http/response.rb b/actionpack/lib/action_dispatch/http/response.rb index e3792aea8f7da..a29b99cf8eb88 100644 --- a/actionpack/lib/action_dispatch/http/response.rb +++ b/actionpack/lib/action_dispatch/http/response.rb @@ -46,6 +46,20 @@ class Response Headers = ::Rack::Utils::HeaderHash end + class << self + if ActionDispatch::Constants::UNPROCESSABLE_CONTENT == :unprocessable_content + def rack_status_code(status) # :nodoc: + status = :unprocessable_content if status == :unprocessable_entity + Rack::Utils.status_code(status) + end + else + def rack_status_code(status) # :nodoc: + status = :unprocessable_entity if status == :unprocessable_content + Rack::Utils.status_code(status) + end + end + end + # To be deprecated: Header = Headers @@ -257,7 +271,7 @@ def sent?; synchronize { @sent }; end # Sets the HTTP status code. def status=(status) - @status = Rack::Utils.status_code(status) + @status = Response.rack_status_code(status) end # Sets the HTTP response's content MIME type. For example, in the controller you diff --git a/actionpack/lib/action_dispatch/http/url.rb b/actionpack/lib/action_dispatch/http/url.rb index 669c34f9fc9e1..e7fecc7b756a7 100644 --- a/actionpack/lib/action_dispatch/http/url.rb +++ b/actionpack/lib/action_dispatch/http/url.rb @@ -11,8 +11,105 @@ module URL HOST_REGEXP = /(^[^:]+:\/\/)?(\[[^\]]+\]|[^:]+)(?::(\d+$))?/ PROTOCOL_REGEXP = /^([^:]+)(:)?(\/\/)?$/ + # DomainExtractor provides utility methods for extracting domain and subdomain + # information from host strings. This module is used internally by Action Dispatch + # to parse host names and separate the domain from subdomains based on the + # top-level domain (TLD) length. + # + # The module assumes a standard domain structure where domains consist of: + # - Subdomains (optional, can be multiple levels) + # - Domain name + # - Top-level domain (TLD, can be multiple levels like .co.uk) + # + # For example, in "api.staging.example.co.uk": + # - Subdomains: ["api", "staging"] + # - Domain: "example.co.uk" (with tld_length=2) + # - TLD: "co.uk" + module DomainExtractor + extend self + + # Extracts the domain part from a host string, including the specified + # number of top-level domain components. + # + # The domain includes the main domain name plus the TLD components. + # The +tld_length+ parameter specifies how many components from the right + # should be considered part of the TLD. + # + # ==== Parameters + # + # [+host+] + # The host string to extract the domain from. + # + # [+tld_length+] + # The number of domain components that make up the TLD. For example, + # use 1 for ".com" or 2 for ".co.uk". + # + # ==== Examples + # + # # Standard TLD (tld_length = 1) + # DomainExtractor.domain_from("www.example.com", 1) + # # => "example.com" + # + # # Country-code TLD (tld_length = 2) + # DomainExtractor.domain_from("www.example.co.uk", 2) + # # => "example.co.uk" + # + # # Multiple subdomains + # DomainExtractor.domain_from("api.staging.myapp.herokuapp.com", 1) + # # => "herokuapp.com" + # + # # Single component (returns the host itself) + # DomainExtractor.domain_from("localhost", 1) + # # => "localhost" + def domain_from(host, tld_length) + host.split(".").last(1 + tld_length).join(".") + end + + # Extracts the subdomain components from a host string as an Array. + # + # Returns all the components that come before the domain and TLD parts. + # The +tld_length+ parameter is used to determine where the domain begins + # so that everything before it is considered a subdomain. + # + # ==== Parameters + # + # [+host+] + # The host string to extract subdomains from. + # + # [+tld_length+] + # The number of domain components that make up the TLD. This affects + # where the domain boundary is calculated. + # + # ==== Examples + # + # # Standard TLD (tld_length = 1) + # DomainExtractor.subdomains_from("www.example.com", 1) + # # => ["www"] + # + # # Country-code TLD (tld_length = 2) + # DomainExtractor.subdomains_from("api.staging.example.co.uk", 2) + # # => ["api", "staging"] + # + # # No subdomains + # DomainExtractor.subdomains_from("example.com", 1) + # # => [] + # + # # Single subdomain with complex TLD + # DomainExtractor.subdomains_from("www.mysite.co.uk", 2) + # # => ["www"] + # + # # Multiple levels of subdomains + # DomainExtractor.subdomains_from("dev.api.staging.example.com", 1) + # # => ["dev", "api", "staging"] + def subdomains_from(host, tld_length) + parts = host.split(".") + parts[0..-(tld_length + 2)] + end + end + mattr_accessor :secure_protocol, default: false mattr_accessor :tld_length, default: 1 + mattr_accessor :domain_extractor, default: DomainExtractor class << self # Returns the domain part of a host given the domain level. @@ -96,34 +193,33 @@ def add_anchor(path, anchor) end def extract_domain_from(host, tld_length) - host.split(".").last(1 + tld_length).join(".") + domain_extractor.domain_from(host, tld_length) end def extract_subdomains_from(host, tld_length) - parts = host.split(".") - parts[0..-(tld_length + 2)] + domain_extractor.subdomains_from(host, tld_length) end def build_host_url(host, port, protocol, options, path) if match = host.match(HOST_REGEXP) - protocol ||= match[1] unless protocol == false - host = match[2] - port = match[3] unless options.key? :port + protocol_from_host = match[1] if protocol.nil? + host = match[2] + port = match[3] unless options.key? :port end - protocol = normalize_protocol protocol + protocol = protocol_from_host || normalize_protocol(protocol).dup host = normalize_host(host, options) + port = normalize_port(port, protocol) - result = protocol.dup + result = protocol if options[:user] && options[:password] result << "#{Rack::Utils.escape(options[:user])}:#{Rack::Utils.escape(options[:password])}@" end result << host - normalize_port(port, protocol) { |normalized_port| - result << ":#{normalized_port}" - } + + result << ":" << port.to_s if port result.concat path end @@ -169,11 +265,11 @@ def normalize_port(port, protocol) return unless port case protocol - when "//" then yield port + when "//" then port when "https://" - yield port unless port.to_i == 443 + port unless port.to_i == 443 else - yield port unless port.to_i == 80 + port unless port.to_i == 80 end end end @@ -272,7 +368,7 @@ def standard_port end end - # Returns whether this request is using the standard port + # Returns whether this request is using the standard port. # # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' # req.standard_port? # => true @@ -307,7 +403,7 @@ def port_string standard_port? ? "" : ":#{port}" end - # Returns the requested port, such as 8080, based on SERVER_PORT + # Returns the requested port, such as 8080, based on SERVER_PORT. # # req = ActionDispatch::Request.new 'SERVER_PORT' => '80' # req.server_port # => 80 diff --git a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb index ca3d05599e1e4..239135ec8af0d 100644 --- a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb +++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb @@ -13,7 +13,6 @@ class TransitionTable # :nodoc: attr_reader :memos DEFAULT_EXP = /[^.\/?]+/ - DEFAULT_EXP_ANCHORED = /\A#{DEFAULT_EXP}\Z/ def initialize @stdparam_states = {} @@ -111,10 +110,10 @@ def as_json(options = nil) end { - regexp_states: simple_regexp, - string_states: @string_states, - stdparam_states: @stdparam_states, - accepting: @accepting + regexp_states: simple_regexp.stringify_keys, + string_states: @string_states.stringify_keys, + stdparam_states: @stdparam_states.stringify_keys, + accepting: @accepting.stringify_keys } end @@ -193,12 +192,15 @@ def states end def transitions + # double escaped because dot evaluates escapes + default_exp_anchored = "\\\\A#{DEFAULT_EXP.source}\\\\Z" + @string_states.flat_map { |from, hash| hash.map { |s, to| [from, s, to] } } + @stdparam_states.map { |from, to| - [from, DEFAULT_EXP_ANCHORED, to] + [from, default_exp_anchored, to] } + @regexp_states.flat_map { |from, hash| - hash.map { |s, to| [from, s, to] } + hash.map { |r, to| [from, r.source.gsub("\\") { "\\\\" }, to] } } end end diff --git a/actionpack/lib/action_dispatch/journey/path/pattern.rb b/actionpack/lib/action_dispatch/journey/path/pattern.rb index 291a9d21b0145..c0f24d3145bec 100644 --- a/actionpack/lib/action_dispatch/journey/path/pattern.rb +++ b/actionpack/lib/action_dispatch/journey/path/pattern.rb @@ -6,6 +6,14 @@ module ActionDispatch module Journey # :nodoc: module Path # :nodoc: class Pattern # :nodoc: + REGEXP_CACHE = {} + + class << self + def dedup_regexp(regexp) + REGEXP_CACHE[regexp.source] ||= regexp + end + end + attr_reader :ast, :names, :requirements, :anchored, :spec def initialize(ast, requirements, separators, anchored) @@ -74,7 +82,7 @@ def initialize(separator, matchers) end def accept(node) - %r{\A#{visit node}\Z} + Pattern.dedup_regexp(%r{\A#{visit node}\Z}) end def visit_CAT(node) @@ -117,7 +125,7 @@ def visit_OR(node) class UnanchoredRegexp < AnchoredRegexp # :nodoc: def accept(node) path = visit node - path == "/" ? %r{\A/} : %r{\A#{path}(?:\b|\Z|/)} + path == "/" ? %r{\A/} : Pattern.dedup_regexp(%r{\A#{path}(?:\b|\Z|/)}) end end @@ -176,7 +184,7 @@ def to_regexp def requirements_for_missing_keys_check @requirements_for_missing_keys_check ||= requirements.transform_values do |regex| - /\A#{regex}\Z/ + Pattern.dedup_regexp(/\A#{regex}\Z/) end end @@ -193,7 +201,7 @@ def offsets node = node.to_sym if @requirements.key?(node) - re = /#{Regexp.union(@requirements[node])}|/ + re = Pattern.dedup_regexp(/#{Regexp.union(@requirements[node])}|/) offsets.push((re.match("").length - 1) + offsets.last) else offsets << offsets.last diff --git a/actionpack/lib/action_dispatch/journey/router.rb b/actionpack/lib/action_dispatch/journey/router.rb index 7f27131030980..fc8ffd0f3a1ae 100644 --- a/actionpack/lib/action_dispatch/journey/router.rb +++ b/actionpack/lib/action_dispatch/journey/router.rb @@ -2,7 +2,8 @@ # :markup: markdown -require "cgi" +require "cgi/escape" +require "cgi/util" if RUBY_VERSION < "3.5" require "action_dispatch/journey/router/utils" require "action_dispatch/journey/routes" require "action_dispatch/journey/formatter" diff --git a/actionpack/lib/action_dispatch/log_subscriber.rb b/actionpack/lib/action_dispatch/log_subscriber.rb index c9d5d4ba7fb06..94161c9563214 100644 --- a/actionpack/lib/action_dispatch/log_subscriber.rb +++ b/actionpack/lib/action_dispatch/log_subscriber.rb @@ -1,25 +1,38 @@ # frozen_string_literal: true -# :markup: markdown - module ActionDispatch - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::EventReporter::LogSubscriber # :nodoc: + class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new + + self.namespace = "action_dispatch" + def redirect(event) - payload = event.payload + payload = event[:payload] info { "Redirected to #{payload[:location]}" } + if ActionDispatch.verbose_redirect_logs + info { "↳ #{payload[:source_location]}" } + end + info do status = payload[:status] + status_name = payload[:status_name] - message = +"Completed #{status} #{Rack::Utils::HTTP_STATUS_CODES[status]} in #{event.duration.round}ms" + message = +"Completed #{status} #{status_name} in #{payload[:duration_ms].round}ms" message << "\n\n" if defined?(Rails.env) && Rails.env.development? message end end - subscribe_log_level :redirect, :info + event_log_level :redirect, :info + + def self.default_logger + ActionController::Base.logger + end end end -ActionDispatch::LogSubscriber.attach_to :action_dispatch +ActiveSupport.event_reporter.subscribe( + ActionDispatch::LogSubscriber.new, &ActionDispatch::LogSubscriber.subscription_filter +) diff --git a/actionpack/lib/action_dispatch/middleware/cookies.rb b/actionpack/lib/action_dispatch/middleware/cookies.rb index dbf07d52545f0..351daf9224167 100644 --- a/actionpack/lib/action_dispatch/middleware/cookies.rb +++ b/actionpack/lib/action_dispatch/middleware/cookies.rb @@ -610,8 +610,10 @@ def commit(name, options) end def check_for_overflow!(name, options) - if options[:value].bytesize > MAX_COOKIE_SIZE - raise CookieOverflow, "#{name} cookie overflowed with size #{options[:value].bytesize} bytes" + total_size = name.to_s.bytesize + options[:value].bytesize + + if total_size > MAX_COOKIE_SIZE + raise CookieOverflow, "#{name} cookie overflowed with size #{total_size} bytes" end end end diff --git a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb index baa7e3d27ca90..819bc4a25af66 100644 --- a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb @@ -65,21 +65,26 @@ def render_exception(request, exception, wrapper) content_type = Mime[:text] end - if api_request?(content_type) + if request.head? + render(wrapper.status_code, "", content_type) + elsif api_request?(content_type) render_for_api_request(content_type, wrapper) else - render_for_browser_request(request, wrapper) + render_for_browser_request(request, wrapper, content_type) end else raise exception end end - def render_for_browser_request(request, wrapper) + def render_for_browser_request(request, wrapper, content_type) template = create_template(request, wrapper) file = "rescues/#{wrapper.rescue_template}" - if request.xhr? + if content_type == Mime[:md] + body = template.render(template: file, layout: false, formats: [:text]) + format = "text/markdown" + elsif request.xhr? body = template.render(template: file, layout: false, formats: [:text]) format = "text/plain" else @@ -125,6 +130,7 @@ def create_template(request, wrapper) trace_to_show: wrapper.trace_to_show, routes_inspector: routes_inspector(wrapper), source_extracts: wrapper.source_extracts, + exception_message_for_copy: compose_exception_message(wrapper).join("\n"), ) end @@ -138,6 +144,11 @@ def log_error(request, wrapper) return unless logger return if !log_rescued_responses?(request) && wrapper.rescue_response? + message = compose_exception_message(wrapper) + log_array(logger, message, request) + end + + def compose_exception_message(wrapper) trace = wrapper.exception_trace message = [] @@ -166,7 +177,7 @@ def log_error(request, wrapper) end end - log_array(logger, message, request) + message end def log_array(logger, lines, request) diff --git a/actionpack/lib/action_dispatch/middleware/debug_view.rb b/actionpack/lib/action_dispatch/middleware/debug_view.rb index 883c5104d1a05..758c0d607649b 100644 --- a/actionpack/lib/action_dispatch/middleware/debug_view.rb +++ b/actionpack/lib/action_dispatch/middleware/debug_view.rb @@ -55,6 +55,17 @@ def render(*) end end + def editor_url(location, line: nil) + if editor = ActiveSupport::Editor.current + line ||= location&.lineno + absolute_path = location&.absolute_path + + if absolute_path && line && File.exist?(absolute_path) + editor.url_for(absolute_path, line) + end + end + end + def protect_against_forgery? false end diff --git a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb index 627186f828d9d..ab4b69288b006 100644 --- a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb +++ b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb @@ -18,11 +18,12 @@ class ExceptionWrapper "ActionController::UnknownFormat" => :not_acceptable, "ActionDispatch::Http::MimeNegotiation::InvalidType" => :not_acceptable, "ActionController::MissingExactTemplate" => :not_acceptable, - "ActionController::InvalidAuthenticityToken" => :unprocessable_entity, - "ActionController::InvalidCrossOriginRequest" => :unprocessable_entity, + "ActionController::InvalidAuthenticityToken" => ActionDispatch::Constants::UNPROCESSABLE_CONTENT, + "ActionController::InvalidCrossOriginRequest" => ActionDispatch::Constants::UNPROCESSABLE_CONTENT, "ActionDispatch::Http::Parameters::ParseError" => :bad_request, "ActionController::BadRequest" => :bad_request, "ActionController::ParameterMissing" => :bad_request, + "ActionController::TooManyRequests" => :too_many_requests, "Rack::QueryParser::ParameterTypeError" => :bad_request, "Rack::QueryParser::InvalidParameterError" => :bad_request ) @@ -148,15 +149,20 @@ def traces application_trace_with_ids = [] framework_trace_with_ids = [] full_trace_with_ids = [] + application_traces = application_trace.map(&:to_s) + full_trace = backtrace_cleaner&.clean_locations(backtrace, :all).presence || backtrace full_trace.each_with_index do |trace, idx| + filtered_trace = backtrace_cleaner&.clean_frame(trace, :all) || trace + trace_with_id = { exception_object_id: @exception.object_id, id: idx, - trace: trace + trace: trace, + filtered_trace: filtered_trace, } - if application_trace.include?(trace) + if application_traces.include?(filtered_trace.to_s) application_trace_with_ids << trace_with_id else framework_trace_with_ids << trace_with_id @@ -173,7 +179,7 @@ def traces end def self.status_code_for_exception(class_name) - Rack::Utils.status_code(@@rescue_responses[class_name]) + ActionDispatch::Response.rack_status_code(@@rescue_responses[class_name]) end def show?(request) @@ -197,7 +203,7 @@ def rescue_response? def source_extracts backtrace.map do |trace| - extract_source(trace) + extract_source(trace).merge(trace: trace) end end @@ -230,7 +236,7 @@ def exception_id end private - class SourceMapLocation < DelegateClass(Thread::Backtrace::Location) # :nodoc: + class SourceMapLocation < ActiveSupport::Delegation::DelegateClass(Thread::Backtrace::Location) # :nodoc: def initialize(location, template) super(location) @template = template diff --git a/actionpack/lib/action_dispatch/middleware/executor.rb b/actionpack/lib/action_dispatch/middleware/executor.rb index 285f1dc472343..bc83ecfbf3ff1 100644 --- a/actionpack/lib/action_dispatch/middleware/executor.rb +++ b/actionpack/lib/action_dispatch/middleware/executor.rb @@ -12,6 +12,10 @@ def initialize(app, executor) def call(env) state = @executor.run!(reset: true) + if response_finished = env["rack.response_finished"] + response_finished << proc { state.complete! } + end + begin response = @app.call(env) @@ -20,7 +24,11 @@ def call(env) @executor.error_reporter.report(error, handled: false, source: "application.action_dispatch") end - returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! } + unless response_finished + response << ::Rack::BodyProxy.new(response.pop) { state.complete! } + end + returned = true + response rescue Exception => error request = ActionDispatch::Request.new env backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner") @@ -28,7 +36,9 @@ def call(env) @executor.error_reporter.report(wrapper.unwrapped_exception, handled: false, source: "application.action_dispatch") raise ensure - state.complete! unless returned + if !returned && !response_finished + state.complete! + end end end end diff --git a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb index 621d82f1e17ab..51a5b13d0d018 100644 --- a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb @@ -25,14 +25,14 @@ def initialize(public_path) def call(env) request = ActionDispatch::Request.new(env) status = request.path_info[1..-1].to_i - begin - content_type = request.formats.first - rescue ActionDispatch::Http::MimeNegotiation::InvalidType - content_type = Mime[:text] - end + content_type = request.formats.first body = { status: status, error: Rack::Utils::HTTP_STATUS_CODES.fetch(status, Rack::Utils::HTTP_STATUS_CODES[500]) } - render(status, content_type, body) + if env["action_dispatch.original_request_method"] == "HEAD" + render_format(status, content_type, "") + else + render(status, content_type, body) + end end private diff --git a/actionpack/lib/action_dispatch/middleware/remote_ip.rb b/actionpack/lib/action_dispatch/middleware/remote_ip.rb index d665a7fdabe43..c3aaefbd1a2a5 100644 --- a/actionpack/lib/action_dispatch/middleware/remote_ip.rb +++ b/actionpack/lib/action_dispatch/middleware/remote_ip.rb @@ -44,6 +44,8 @@ class IpSpoofAttackError < StandardError; end "10.0.0.0/8", # private IPv4 range 10.x.x.x "172.16.0.0/12", # private IPv4 range 172.16.0.0 .. 172.31.255.255 "192.168.0.0/16", # private IPv4 range 192.168.x.x + "169.254.0.0/16", # link-local IPv4 range 169.254.x.x + "fe80::/10", # link-local IPv6 range fe80::/10 ].map { |proxy| IPAddr.new(proxy) } attr_reader :check_ip, :proxies @@ -126,11 +128,11 @@ def initialize(req, check_ip, proxies) # left, which was presumably set by one of those proxies. def calculate_ip # Set by the Rack web server, this is a single value. - remote_addr = ips_from(@req.remote_addr).last + remote_addr = sanitize_ips(ips_from(@req.remote_addr)).last # Could be a CSV list and/or repeated headers that were concatenated. - client_ips = ips_from(@req.client_ip).reverse! - forwarded_ips = ips_from(@req.x_forwarded_for).reverse! + client_ips = sanitize_ips(ips_from(@req.client_ip)).reverse! + forwarded_ips = sanitize_ips(@req.forwarded_for || []).reverse! # `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they # are both set, it means that either: @@ -150,7 +152,8 @@ def calculate_ip # We don't know which came from the proxy, and which from the user raise IpSpoofAttackError, "IP spoofing attack?! " \ "HTTP_CLIENT_IP=#{@req.client_ip.inspect} " \ - "HTTP_X_FORWARDED_FOR=#{@req.x_forwarded_for.inspect}" + "HTTP_X_FORWARDED_FOR=#{@req.x_forwarded_for.inspect}" \ + " HTTP_FORWARDED=" + @req.forwarded_for.map { "for=#{_1}" }.join(", ").inspect if @req.forwarded_for.any? end # We assume these things about the IP headers: @@ -176,7 +179,10 @@ def to_s def ips_from(header) # :doc: return [] unless header # Split the comma-separated list into an array of strings. - ips = header.strip.split(/[,\s]+/) + header.strip.split(/[,\s]+/) + end + + def sanitize_ips(ips) # :doc: ips.select! do |ip| # Only return IPs that are valid according to the IPAddr#new method. range = IPAddr.new(ip).to_range diff --git a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb index c6ee12684bfe7..191ab27624270 100644 --- a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb +++ b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb @@ -50,7 +50,7 @@ module Session # would set the session cookie to expire automatically 14 days after creation. # Other useful options include `:key`, `:secure`, `:httponly`, and `:same_site`. class CookieStore < AbstractSecureStore - class SessionId < DelegateClass(Rack::Session::SessionId) + class SessionId < ActiveSupport::Delegation::DelegateClass(Rack::Session::SessionId) attr_reader :cookie_value def initialize(session_id, cookie_value = {}) diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb new file mode 100644 index 0000000000000..66257c371927f --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb @@ -0,0 +1 @@ + diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb index 9f168357ed252..9a02e0d5ca986 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb @@ -11,8 +11,9 @@
-                <% source_extract[:code].each_key do |line_number| %>
-<%= line_number -%>
+                <% source_extract[:code].each_key do |line| %>
+                  <% file_url = editor_url(source_extract[:trace], line: line) %>
+<%= link_to_if file_url, line, file_url -%>
                 <% end %>
               
diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb index ee0bc10f3739a..77948a466629c 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb @@ -13,13 +13,17 @@ <% end %> <% traces.each do |name, trace| %> -
" style="display: <%= (name == trace_to_show) ? 'block' : 'none' %>;"> +
" class="trace-container" style="display: <%= (name == trace_to_show) ? 'block' : 'none' %>;"> <% trace.each do |frame| %> - - <%= frame[:trace] %> - -
+
+ <% file_url = editor_url(frame[:trace]) %> + <%= file_url && link_to("✏️", file_url, class: "edit-icon") %> + + <%= frame[:trace] %> + +
+
<% end %>
diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb index e452947306145..6ee17f4ad8999 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

Blocked hosts: <%= @hosts.join(", ") %>

diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb index 84cc8ab030218..e21e3914ba003 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

<%= @exception_wrapper.exception_class_name %> <% if params_valid? && @request.parameters['controller'] %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb index 3743994e483d1..030838860d925 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

<%= @exception.class.to_s %> <% if @request.parameters['controller'] %> @@ -10,6 +11,9 @@

<%= h @exception.message %> + <% if defined?(ActionText) && @exception.message.match?(%r{#{ActionText::RichText.table_name}}) %> +
To resolve this issue run: bin/rails action_text:install + <% end %> <% if defined?(ActiveStorage) && @exception.message.match?(%r{#{ActiveStorage::Blob.table_name}|#{ActiveStorage::Attachment.table_name}}) %>
To resolve this issue run: bin/rails active_storage:install <% end %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb index d30facd3123c5..dc3a05a1a523b 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb @@ -4,6 +4,9 @@ <% end %> <%= @exception.message %> +<% if defined?(ActionText) && @exception.message.match?(%r{#{ActionText::RichText.table_name}}) %> +To resolve this issue run: bin/rails action_text:install +<% end %> <% if defined?(ActiveStorage) && @exception.message.match?(%r{#{ActiveStorage::Blob.table_name}|#{ActiveStorage::Attachment.table_name}}) %> To resolve this issue run: bin/rails active_storage:install <% end %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb index 92fdee59b4b3f..34ffdb79d5a87 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb @@ -38,6 +38,22 @@ padding: 0.5em 1.5em; } + header button { + appearance: none; + background-color: hsl(0 0% 0% / 0.2); + border: 0; + border-radius: 14px; + color: white; + float: right; + font-weight: 500; + height: 28px; + padding-inline: 14px; + margin: 0.35em 0; + } + header button:active { + background-color: hsl(0 0% 0% / 0.25); + } + h1 { overflow-wrap: break-word; margin: 0.2em 0; @@ -54,6 +70,30 @@ font-size: 11px; } + .trace-container { + margin-top: 10px; + } + + code.traces .trace { + display: flex; + align-items: center; + gap: 2px; + } + + .edit-icon { + width: 16px; + height: 16px; + display: flex; + font-size: 13px; + align-items: center; + justify-content: center; + text-decoration: none; + } + + .edit-icon:hover { + scale: 1.05; + } + .response-heading, .request-heading { margin-top: 30px; } @@ -274,11 +314,21 @@ var toggleEnvDump = function() { return toggle('env_dump'); } + var copyAsText = function() { + const text = document.getElementById("exception-message-for-copy").textContent; + + navigator.clipboard.writeText(text).then(() => { + const beforeText = this.innerText; + this.innerText = "Copied!" + setTimeout(() => this.innerText = beforeText, 1000) + }) + } <%= yield %> + diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb index e5563034143bf..ffafefcd29d91 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

No view template for interactive request

diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb index 6081dcf3759b6..e227c5dca7406 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

Template is missing

diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb index 25264a41dc514..85ff514f158db 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

Routing Error

diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb index 4ee6ad0c908e9..d203a5d111302 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

<%= @exception_wrapper.exception_name %> in <%= @request.parameters["controller"].camelize if @request.parameters["controller"] %>#<%= @request.parameters["action"] %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb index 2c2d1a94d751a..30b9f7edd7388 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

Unknown action

diff --git a/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb b/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb index 8b6b8df227e39..7e8ffc9760945 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb @@ -122,6 +122,7 @@ function getJSON(url, success) { var xhr = new XMLHttpRequest(); xhr.open('GET', url); + xhr.setRequestHeader('accept', 'application/json'); xhr.onload = function() { if (this.status == 200) success(JSON.parse(this.response)); @@ -152,6 +153,7 @@ if (searchElem.value === "") { exactSection.innerHTML = ""; fuzzySection.innerHTML = ""; + updateQueryState(""); } } @@ -164,6 +166,12 @@ return tr; } + function updateQueryState(query) { + var currentUrl = new URL(location); + currentUrl.searchParams.set('query', query) + history.pushState({}, '', currentUrl.toString()); + } + // On key press perform a search for matching paths delayedKeyup(searchElem, function() { var query = sanitizeQuery(searchElem.value), @@ -175,6 +183,7 @@ if (!query) return searchElem.onblur(); + updateQueryState(query); getJSON('/rails/info/routes?query=' + query, function(matches){ // Clear out results section exactSection.replaceChildren(defaultExactMatch); @@ -224,9 +233,21 @@ }); } + // Pre-fills the search input with existing query + function setupPrefilledQuery() { + let urlParams = new URLSearchParams(location.search); + let query = urlParams.get('query'); + + if (query) { + search.value = query; + search.dispatchEvent(new KeyboardEvent('keyup')); + } + } + setupMatchingRoutes(); setupRouteToggleHelperLinks(); + setupPrefilledQuery(); // Focus the search input after page has loaded - document.getElementById('search').focus(); + search.focus(); diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index d79f952d40815..8c4863f78a46f 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -4,7 +4,9 @@ require "action_dispatch" require "action_dispatch/log_subscriber" +require "action_dispatch/structured_event_subscriber" require "active_support/messages/rotation_configuration" +require "rails/railtie" module ActionDispatch class Railtie < Rails::Railtie # :nodoc: @@ -33,6 +35,7 @@ class Railtie < Rails::Railtie # :nodoc: config.action_dispatch.ignore_leading_brackets = nil config.action_dispatch.strict_query_string_separator = nil + config.action_dispatch.verbose_redirect_logs = false config.action_dispatch.default_headers = { "X-Frame-Options" => "SAMEORIGIN", @@ -55,8 +58,18 @@ class Railtie < Rails::Railtie # :nodoc: ActionDispatch::Http::URL.secure_protocol = app.config.force_ssl ActionDispatch::Http::URL.tld_length = app.config.action_dispatch.tld_length - ActionDispatch::ParamBuilder.ignore_leading_brackets = app.config.action_dispatch.ignore_leading_brackets - ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator + unless app.config.action_dispatch.domain_extractor.nil? + ActionDispatch::Http::URL.domain_extractor = app.config.action_dispatch.domain_extractor + end + + unless app.config.action_dispatch.ignore_leading_brackets.nil? + ActionDispatch::ParamBuilder.ignore_leading_brackets = app.config.action_dispatch.ignore_leading_brackets + end + unless app.config.action_dispatch.strict_query_string_separator.nil? + ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator + end + + ActionDispatch.verbose_redirect_logs = app.config.action_dispatch.verbose_redirect_logs ActiveSupport.on_load(:action_dispatch_request) do self.ignore_accept_header = app.config.action_dispatch.ignore_accept_header diff --git a/actionpack/lib/action_dispatch/routing/inspector.rb b/actionpack/lib/action_dispatch/routing/inspector.rb index c06a0053c8d76..e5e25dbeec648 100644 --- a/actionpack/lib/action_dispatch/routing/inspector.rb +++ b/actionpack/lib/action_dispatch/routing/inspector.rb @@ -64,6 +64,14 @@ def internal? def engine? app.engine? end + + def to_h + { name: name, + verb: verb, + path: path, + reqs: reqs, + source_location: source_location } + end end ## @@ -72,30 +80,51 @@ def engine? # not use this class. class RoutesInspector # :nodoc: def initialize(routes) - @engines = {} - @routes = routes + @routes = wrap_routes(routes) + @engines = load_engines_routes end def format(formatter, filter = {}) - routes_to_display = filter_routes(normalize_filter(filter)) - routes = collect_routes(routes_to_display) - if routes.none? - formatter.no_routes(collect_routes(@routes), filter) - return formatter.result - end + all_routes = { nil => @routes }.merge(@engines) - formatter.header routes - formatter.section routes - - @engines.each do |name, engine_routes| - formatter.section_title "Routes for #{name}" - formatter.section engine_routes + all_routes.each do |engine_name, routes| + format_routes(formatter, filter, engine_name, routes) end formatter.result end private + def format_routes(formatter, filter, engine_name, routes) + routes = filter_routes(routes, normalize_filter(filter)).map(&:to_h) + + formatter.section_title "Routes for #{engine_name || "application"}" if @engines.any? + if routes.any? + formatter.header routes + formatter.section routes + else + formatter.no_routes engine_name, routes, filter + end + formatter.footer routes + end + + def wrap_routes(routes) + routes.routes.map { |route| RouteWrapper.new(route) }.reject(&:internal?) + end + + def load_engines_routes + engine_routes = @routes.select(&:engine?) + + engines = engine_routes.to_h do |engine_route| + engine_app_routes = engine_route.rack_app.routes + engine_app_routes = engine_app_routes.routes if engine_app_routes.is_a?(ActionDispatch::Routing::RouteSet) + + [engine_route.endpoint, wrap_routes(engine_app_routes)] + end + + engines + end + def normalize_filter(filter) if filter[:controller] { controller: /#{filter[:controller].underscore.sub(/_?controller\z/, "")}/ } @@ -115,39 +144,13 @@ def normalize_filter(filter) end end - def filter_routes(filter) + def filter_routes(routes, filter) if filter - @routes.select do |route| - route_wrapper = RouteWrapper.new(route) - filter.any? { |filter_type, value| route_wrapper.matches_filter?(filter_type, value) } + routes.select do |route| + filter.any? { |filter_type, value| route.matches_filter?(filter_type, value) } end else - @routes - end - end - - def collect_routes(routes) - routes.collect do |route| - RouteWrapper.new(route) - end.reject(&:internal?).collect do |route| - collect_engine_routes(route) - - { name: route.name, - verb: route.verb, - path: route.path, - reqs: route.reqs, - source_location: route.source_location } - end - end - - def collect_engine_routes(route) - name = route.endpoint - return unless route.engine? - return if @engines[name] - - routes = route.rack_app.routes - if routes.is_a?(ActionDispatch::Routing::RouteSet) - @engines[name] = collect_routes(routes.routes) + routes end end end @@ -171,27 +174,36 @@ def section(routes) def header(routes) end - def no_routes(routes, filter) - @buffer << - if routes.none? - <<~MESSAGE - You don't have any routes defined! + def footer(routes) + end - Please add some routes in config/routes.rb. - MESSAGE - elsif filter.key?(:controller) + def no_routes(engine, routes, filter) + @buffer << + if filter.key?(:controller) "No routes were found for this controller." elsif filter.key?(:grep) "No routes were found for this grep pattern." + elsif routes.none? + if engine + "No routes defined." + else + <<~MESSAGE + You don't have any routes defined! + + Please add some routes in config/routes.rb. + MESSAGE + end end - @buffer << "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." + unless engine + @buffer << "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." + end end end class Sheet < Base def section_title(title) - @buffer << "\n#{title}:" + @buffer << "#{title}:" end def section(routes) @@ -202,6 +214,10 @@ def header(routes) @buffer << draw_header(routes) end + def footer(routes) + @buffer << "" + end + private def draw_section(routes) header_lengths = ["Prefix", "Verb", "URI Pattern"].map(&:length) @@ -232,13 +248,17 @@ def initialize(width: IO.console_size[1]) end def section_title(title) - @buffer << "\n#{"[ #{title} ]"}" + @buffer << "#{"[ #{title} ]"}" end def section(routes) @buffer << draw_expanded_section(routes) end + def footer(routes) + @buffer << "" + end + private def draw_expanded_section(routes) routes.map.each_with_index do |r, i| @@ -269,7 +289,7 @@ def header(routes) super end - def no_routes(routes, filter) + def no_routes(engine, routes, filter) @buffer << if filter.none? "No unused routes found." @@ -300,6 +320,9 @@ def section(routes) def header(routes) end + def footer(routes) + end + def no_routes(*) @buffer << <<~MESSAGE

You don't have any routes defined!

diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index 6c2fa455d1c04..9b96de1b1a9b4 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -667,7 +667,7 @@ def has_named_route?(name) def assign_deprecated_option(deprecated_options, key, method_name) if (deprecated_value = deprecated_options.delete(key)) ActionDispatch.deprecator.warn(<<~MSG.squish) - #{method_name} received a hash argument #{key}. Please use a keyword instead. + #{method_name} received a hash argument #{key}. Please use a keyword instead. Support to hash argument will be removed in Rails 8.2. MSG deprecated_value end @@ -676,7 +676,7 @@ def assign_deprecated_option(deprecated_options, key, method_name) def assign_deprecated_options(deprecated_options, options, method_name) deprecated_options.each do |key, value| ActionDispatch.deprecator.warn(<<~MSG.squish) - #{method_name} received a hash argument #{key}. Please use a keyword instead. + #{method_name} received a hash argument #{key}. Please use a keyword instead. Support to hash argument will be removed in Rails 8.2. MSG options[key] = value end @@ -1833,7 +1833,7 @@ def draw(name) # [match](rdoc-ref:Base#match). # # match 'path', to: 'controller#action', via: :post - # match 'path', 'otherpath', on: :member, via: :get + # match 'otherpath', on: :member, via: :get def match(*path_or_actions, as: DEFAULT, via: nil, to: nil, controller: nil, action: nil, on: nil, defaults: nil, constraints: nil, anchor: nil, format: nil, path: nil, internal: nil, **mapping, &block) if path_or_actions.grep(Hash).any? && (deprecated_options = path_or_actions.extract_options!) as = assign_deprecated_option(deprecated_options, :as, :match) if deprecated_options.key?(:as) @@ -1851,10 +1851,7 @@ def match(*path_or_actions, as: DEFAULT, via: nil, to: nil, controller: nil, act assign_deprecated_options(deprecated_options, mapping, :match) end - ActionDispatch.deprecator.warn(<<-MSG.squish) if path_or_actions.count > 1 - Mapping a route with multiple paths is deprecated and - will be removed in Rails 8.1. Please use multiple method calls instead. - MSG + raise ArgumentError, "Wrong number of arguments (expect 1, got #{path_or_actions.count})" if path_or_actions.count > 1 if path_or_actions.none? && mapping.any? hash_path, hash_to = mapping.find { |key, _| key.is_a?(String) } @@ -1951,8 +1948,10 @@ def apply_common_behavior_for(method, resources, shallow: nil, **options, &block end scope_options = options.slice!(*RESOURCE_OPTIONS) - if !scope_options.empty? || !shallow.nil? - scope(**scope_options, shallow:) do + scope_options[:shallow] = shallow unless shallow.nil? + + unless scope_options.empty? + scope(**scope_options) do public_send(method, resources.pop, **options, &block) end return true diff --git a/actionpack/lib/action_dispatch/routing/redirection.rb b/actionpack/lib/action_dispatch/routing/redirection.rb index 83c9e58bcbe2f..78fd4209ee2ff 100644 --- a/actionpack/lib/action_dispatch/routing/redirection.rb +++ b/actionpack/lib/action_dispatch/routing/redirection.rb @@ -12,9 +12,10 @@ module Routing class Redirect < Endpoint # :nodoc: attr_reader :status, :block - def initialize(status, block) + def initialize(status, block, source_location) @status = status @block = block + @source_location = source_location end def redirect?; true; end @@ -27,6 +28,7 @@ def call(env) payload[:status] = @status payload[:location] = response.headers["Location"] payload[:request] = request + payload[:source_location] = @source_location if @source_location response.to_a end @@ -202,16 +204,17 @@ module Redirection # get 'accounts/:name' => redirect(SubdomainRedirector.new('api')) # def redirect(*args, &block) - options = args.extract_options! - status = options.delete(:status) || 301 - path = args.shift + options = args.extract_options! + status = options.delete(:status) || 301 + path = args.shift + source_location = caller[0] if ActionDispatch.verbose_redirect_logs - return OptionRedirect.new(status, options) if options.any? - return PathRedirect.new(status, path) if String === path + return OptionRedirect.new(status, options, source_location) if options.any? + return PathRedirect.new(status, path, source_location) if String === path block = path if path.respond_to? :call raise ArgumentError, "redirection argument not supported" unless block - Redirect.new status, block + Redirect.new status, block, source_location end end end diff --git a/actionpack/lib/action_dispatch/routing/route_set.rb b/actionpack/lib/action_dispatch/routing/route_set.rb index da8a6ad67fff9..a8bea98432708 100644 --- a/actionpack/lib/action_dispatch/routing/route_set.rb +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -29,7 +29,7 @@ class RouteSet def from_requirements(requirements) routes.find { |route| route.requirements == requirements } end - # :stopdoc: + # :enddoc: # Since the router holds references to many parts of the system like engines, # controllers and the application itself, inspecting the route set can actually @@ -59,8 +59,6 @@ def serve(req) private def controller(req) req.controller_class - rescue NameError => e - raise ActionController::RoutingError, e.message, e.backtrace end def dispatch(controller, action, req, res) @@ -659,14 +657,14 @@ def add_route(mapping, name) if route.segment_keys.include?(:controller) ActionDispatch.deprecator.warn(<<-MSG.squish) Using a dynamic :controller segment in a route is deprecated and - will be removed in Rails 8.1. + will be removed in Rails 9.0. MSG end if route.segment_keys.include?(:action) ActionDispatch.deprecator.warn(<<-MSG.squish) Using a dynamic :action segment in a route is deprecated and - will be removed in Rails 8.1. + will be removed in Rails 9.0. MSG end @@ -953,6 +951,5 @@ def recognize_path_with_request(req, path, extras, raise_on_missing: true) end end end - # :startdoc: end end diff --git a/actionpack/lib/action_dispatch/routing/routes_proxy.rb b/actionpack/lib/action_dispatch/routing/routes_proxy.rb index fe9ba93cdea39..44868c5db6663 100644 --- a/actionpack/lib/action_dispatch/routing/routes_proxy.rb +++ b/actionpack/lib/action_dispatch/routing/routes_proxy.rb @@ -54,6 +54,7 @@ def method_missing(method, *args) # dependent part. def merge_script_names(previous_script_name, new_script_name) return new_script_name unless previous_script_name + new_script_name = new_script_name.chomp("/") resolved_parts = new_script_name.count("/") previous_parts = previous_script_name.count("/") diff --git a/actionpack/lib/action_dispatch/structured_event_subscriber.rb b/actionpack/lib/action_dispatch/structured_event_subscriber.rb new file mode 100644 index 0000000000000..6cb3ccea2ff54 --- /dev/null +++ b/actionpack/lib/action_dispatch/structured_event_subscriber.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ActionDispatch + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + def redirect(event) + payload = event.payload + status = payload[:status] + + emit_event("action_dispatch.redirect", { + location: payload[:location], + status: status, + status_name: Rack::Utils::HTTP_STATUS_CODES[status], + duration_ms: event.duration.round(2), + source_location: payload[:source_location] + }) + end + end +end + +ActionDispatch::StructuredEventSubscriber.attach_to :action_dispatch diff --git a/actionpack/lib/action_dispatch/testing/assertion_response.rb b/actionpack/lib/action_dispatch/testing/assertion_response.rb index bc81475b2db84..753285ac89cf3 100644 --- a/actionpack/lib/action_dispatch/testing/assertion_response.rb +++ b/actionpack/lib/action_dispatch/testing/assertion_response.rb @@ -38,7 +38,7 @@ def code_and_name private def code_from_name(name) - GENERIC_RESPONSE_CODES[name] || Rack::Utils.status_code(name) + GENERIC_RESPONSE_CODES[name] || ActionDispatch::Response.rack_status_code(name) end def name_from_code(code) diff --git a/actionpack/lib/action_dispatch/testing/assertions/response.rb b/actionpack/lib/action_dispatch/testing/assertions/response.rb index d7598b7cf7075..816d5f6192728 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/response.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/response.rb @@ -71,6 +71,20 @@ def assert_redirected_to(url_options = {}, options = {}, message = nil) assert_operator redirect_expected, :===, redirect_is, message end + # Asserts that the given +text+ is present somewhere in the response body. + # + # assert_in_body fixture(:name).description + def assert_in_body(text) + assert_match(/#{Regexp.escape(text)}/, @response.body) + end + + # Asserts that the given +text+ is not present anywhere in the response body. + # + # assert_not_in_body fixture(:name).description + def assert_not_in_body(text) + assert_no_match(/#{Regexp.escape(text)}/, @response.body) + end + private # Proxy to to_param if the object will respond to it. def parameterize(value) diff --git a/actionpack/lib/action_dispatch/testing/assertions/routing.rb b/actionpack/lib/action_dispatch/testing/assertions/routing.rb index 0a79d5a296576..af1131d519e48 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/routing.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/routing.rb @@ -25,7 +25,7 @@ def with_routing(&block) old_integration_session = nil setup do - old_routes = app.routes + old_routes = initialize_lazy_routes(app.routes) old_routes_call_method = old_routes.method(:call) old_integration_session = integration_session create_routes(&block) @@ -38,7 +38,7 @@ def with_routing(&block) end def with_routing(&block) - old_routes = app.routes + old_routes = initialize_lazy_routes(app.routes) old_routes_call_method = old_routes.method(:call) old_integration_session = integration_session create_routes(&block) @@ -47,6 +47,14 @@ def with_routing(&block) end private + def initialize_lazy_routes(routes) + if defined?(Rails::Engine::LazyRouteSet) && routes.is_a?(Rails::Engine::LazyRouteSet) + routes.tap(&:routes) + else + routes + end + end + def create_routes app = self.app routes = ActionDispatch::Routing::RouteSet.new diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb index ff2cd6761b414..2cd77cbc321e7 100644 --- a/actionpack/lib/action_dispatch/testing/integration.rb +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -604,9 +604,8 @@ def method_missing(method, ...) # end # end # - # See the [request helpers documentation] - # (rdoc-ref:ActionDispatch::Integration::RequestHelpers) for help - # on how to use `get`, etc. + # See the [request helpers documentation](rdoc-ref:ActionDispatch::Integration::RequestHelpers) + # for help on how to use `get`, etc. # # ### Changing the request encoding # @@ -622,7 +621,7 @@ def method_missing(method, ...) # end # # assert_response :success - # assert_equal({ id: Article.last.id, title: "Ahoy!" }, response.parsed_body) + # assert_equal({ "id" => Article.last.id, "title" => "Ahoy!" }, response.parsed_body) # end # end # diff --git a/actionpack/lib/action_dispatch/testing/request_encoder.rb b/actionpack/lib/action_dispatch/testing/request_encoder.rb index f1b6ad82e8885..e0ce6b30af113 100644 --- a/actionpack/lib/action_dispatch/testing/request_encoder.rb +++ b/actionpack/lib/action_dispatch/testing/request_encoder.rb @@ -3,6 +3,7 @@ # :markup: markdown require "nokogiri" +require "action_dispatch/http/mime_type" module ActionDispatch class RequestEncoder # :nodoc: @@ -15,9 +16,9 @@ def response_parser; -> body { body }; end @encoders = { identity: IdentityEncoder.new } - attr_reader :response_parser + attr_reader :response_parser, :content_type - def initialize(mime_name, param_encoder, response_parser) + def initialize(mime_name, param_encoder, response_parser, content_type) @mime = Mime[mime_name] unless @mime @@ -27,10 +28,7 @@ def initialize(mime_name, param_encoder, response_parser) @response_parser = response_parser || -> body { body } @param_encoder = param_encoder || :"to_#{@mime.symbol}".to_proc - end - - def content_type - @mime.to_s + @content_type = content_type || @mime.to_s end def accept_header @@ -50,11 +48,13 @@ def self.encoder(name) @encoders[name] || @encoders[:identity] end - def self.register_encoder(mime_name, param_encoder: nil, response_parser: nil) - @encoders[mime_name] = new(mime_name, param_encoder, response_parser) + def self.register_encoder(mime_name, param_encoder: nil, response_parser: nil, content_type: nil) + @encoders[mime_name] = new(mime_name, param_encoder, response_parser, content_type) end - register_encoder :html, response_parser: -> body { Rails::Dom::Testing.html_document.parse(body) } + register_encoder :html, response_parser: -> body { Rails::Dom::Testing.html_document.parse(body) }, + param_encoder: -> param { param }, + content_type: Mime[:url_encoded_form].to_s register_encoder :json, response_parser: -> body { JSON.parse(body, object_class: ActiveSupport::HashWithIndifferentAccess) } end end diff --git a/actionpack/lib/action_pack/gem_version.rb b/actionpack/lib/action_pack/gem_version.rb index 83380b6205c94..a812190bf5d8e 100644 --- a/actionpack/lib/action_pack/gem_version.rb +++ b/actionpack/lib/action_pack/gem_version.rb @@ -10,7 +10,7 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/actionpack/test/abstract_unit.rb b/actionpack/test/abstract_unit.rb index 65fc4470ce062..c3376814239df 100644 --- a/actionpack/test/abstract_unit.rb +++ b/actionpack/test/abstract_unit.rb @@ -314,7 +314,7 @@ def make_set(strict = true) end class TestSet < ActionDispatch::Routing::RouteSet - class Request < DelegateClass(ActionDispatch::Request) + class Request < ActiveSupport::Delegation::DelegateClass(ActionDispatch::Request) def initialize(target, helpers, block, strict) super(target) @helpers = helpers @@ -515,20 +515,4 @@ def assert_header_value(expected, header) end end -class DrivenByRackTest < ActionDispatch::SystemTestCase - driven_by :rack_test -end - -class DrivenBySeleniumWithChrome < ActionDispatch::SystemTestCase - driven_by :selenium, using: :chrome -end - -class DrivenBySeleniumWithHeadlessChrome < ActionDispatch::SystemTestCase - driven_by :selenium, using: :headless_chrome -end - -class DrivenBySeleniumWithHeadlessFirefox < ActionDispatch::SystemTestCase - driven_by :selenium, using: :headless_firefox -end - require_relative "../../tools/test_common" diff --git a/actionpack/test/controller/action_pack_assertions_test.rb b/actionpack/test/controller/action_pack_assertions_test.rb index aa44b36d9e1bc..46306d5a66c22 100644 --- a/actionpack/test/controller/action_pack_assertions_test.rb +++ b/actionpack/test/controller/action_pack_assertions_test.rb @@ -496,6 +496,12 @@ def test_assert_response_failure_response_with_no_exception assert_response 500 assert_equal "Boom", response.body end + + def test_assert_in_body + post :raise_exception_on_get + assert_in_body "request method: POST" + assert_not_in_body "request method: GET" + end end class ActionPackHeaderTest < ActionController::TestCase diff --git a/actionpack/test/controller/api/rate_limiting_test.rb b/actionpack/test/controller/api/rate_limiting_test.rb index 7710b285970b3..fa8da78ec3db9 100644 --- a/actionpack/test/controller/api/rate_limiting_test.rb +++ b/actionpack/test/controller/api/rate_limiting_test.rb @@ -23,8 +23,9 @@ class ApiRateLimitingTest < ActionController::TestCase get :limited_to_two assert_response :ok - get :limited_to_two - assert_response :too_many_requests + assert_raises ActionController::TooManyRequests do + get :limited_to_two + end end test "limit resets after time" do diff --git a/actionpack/test/controller/caching_test.rb b/actionpack/test/controller/caching_test.rb index eb2ab3451cab9..3540de0942211 100644 --- a/actionpack/test/controller/caching_test.rb +++ b/actionpack/test/controller/caching_test.rb @@ -414,7 +414,7 @@ def test_preserves_order_when_reading_from_cache_plus_rendering get :index_ordered assert_equal 3, @controller.partial_rendered_times - assert_select ":root", "david, 1\n david, 2\n david, 3" + assert_select ":root", html: "

david, 1\n david, 2\n david, 3\n\n

" end def test_explicit_render_call_with_options diff --git a/actionpack/test/controller/conditional_get_directives_test.rb b/actionpack/test/controller/conditional_get_directives_test.rb new file mode 100644 index 0000000000000..5deae8cf13e40 --- /dev/null +++ b/actionpack/test/controller/conditional_get_directives_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ConditionalGetDirectivesController < ActionController::Base + def must_understand_action + must_understand + render plain: "using must-understand directive" + end + + def cache_control_with_must_understand + fresh_when etag: "123", cache_control: { must_understand: true } + render plain: "with must-understand via cache_control" unless performed? + end + + def must_understand_without_no_store + response.cache_control[:no_cache] = true + response.cache_control[:must_understand] = true + render plain: "no-cache with must-understand" + end +end + +class ConditionalGetDirectivesTest < ActionController::TestCase + tests ConditionalGetDirectivesController + + def test_must_understand + get :must_understand_action + assert_response :success + assert_includes @response.headers["Cache-Control"], "must-understand" + end + + def test_cache_control_with_must_understand + get :cache_control_with_must_understand + assert_response :success + assert_not_includes @response.headers["Cache-Control"], "must-understand" + end + + def test_must_understand_without_no_store + get :must_understand_without_no_store + assert_response :success + assert_not_includes @response.headers["Cache-Control"], "must-understand" + assert_includes @response.headers["Cache-Control"], "no-cache" + end +end diff --git a/actionpack/test/controller/integration_test.rb b/actionpack/test/controller/integration_test.rb index 556c7d328d53b..9457b38eb8d7b 100644 --- a/actionpack/test/controller/integration_test.rb +++ b/actionpack/test/controller/integration_test.rb @@ -1098,6 +1098,12 @@ def foos render plain: "ok" end + def foos_html + render inline: <<~ERB + <%= params.permit(:foo) %> + ERB + end + def foos_json render json: params.permit(:foo) end @@ -1127,6 +1133,17 @@ def test_standard_json_encoding_works end end + def test_encoding_as_html + post_to_foos as: :html do + assert_response :success + assert_equal "application/x-www-form-urlencoded", request.media_type + assert_equal "text/html", request.accepts.first.to_s + assert_equal :html, request.format.ref + assert_equal({ "foo" => "fighters" }, request.request_parameters) + assert_equal({ "foo" => "fighters" }.to_s, response.parsed_body.at("code").text) + end + end + def test_encoding_as_json post_to_foos as: :json do assert_response :success diff --git a/actionpack/test/controller/live_stream_test.rb b/actionpack/test/controller/live_stream_test.rb index 2dcc20012a513..d1f5c6bc371e8 100644 --- a/actionpack/test/controller/live_stream_test.rb +++ b/actionpack/test/controller/live_stream_test.rb @@ -684,6 +684,14 @@ def test_thread_locals_do_not_get_reset_in_test_environment assert_equal "aaron", Thread.current[:setting] end + + def test_isolated_state_does_not_get_reset_in_test_environment + ActiveSupport::IsolatedExecutionState[:setting] = "aaron" + + get :greet + + assert_equal "aaron", ActiveSupport::IsolatedExecutionState[:setting] + end end class BufferTest < ActionController::TestCase diff --git a/actionpack/test/controller/log_subscriber_test.rb b/actionpack/test/controller/log_subscriber_test.rb index 581b8a4ad73f9..dd58e495f87aa 100644 --- a/actionpack/test/controller/log_subscriber_test.rb +++ b/actionpack/test/controller/log_subscriber_test.rb @@ -4,122 +4,121 @@ require "active_support/log_subscriber/test_helper" require "action_controller/log_subscriber" -module Another - class LogSubscribersController < ActionController::Base - wrap_parameters :person, include: :name, format: :json +class ACLogSubscriberTest < ActionController::TestCase + module Another + class LogSubscribersController < ActionController::Base + wrap_parameters :person, include: :name, format: :json - class SpecialException < Exception - end + class SpecialException < Exception + end - rescue_from SpecialException do - head 406 - end + rescue_from SpecialException do + head 406 + end - before_action :redirector, only: :never_executed + before_action :redirector, only: :never_executed - def never_executed - end + def never_executed + end - def show - head :ok - end + def show + head :ok + end - def redirector - redirect_to "http://foo.bar/" - end + def redirector + redirect_to "http://foo.bar/" + end - def filterable_redirector - redirect_to "http://secret.foo.bar/" - end + def filterable_redirector + redirect_to "http://secret.foo.bar/" + end - def filterable_redirector_with_params - redirect_to "http://secret.foo.bar?username=repinel&password=1234" - end + def filterable_redirector_with_params + redirect_to "http://secret.foo.bar?username=repinel&password=1234" + end - def filterable_redirector_bad_uri - redirect_to " s:/invalid-string0uri" - end + def filterable_redirector_bad_uri + redirect_to " s:/invalid-string0uri" + end - def data_sender - send_data "cool data", filename: "file.txt" - end + def data_sender + send_data "cool data", filename: "file.txt" + end - def file_sender - send_file File.expand_path("company.rb", FIXTURE_LOAD_PATH) - end + def file_sender + send_file File.expand_path("company.rb", FIXTURE_LOAD_PATH) + end - def with_fragment_cache - render inline: "<%= cache('foo'){ 'bar' } %>" - end + def with_fragment_cache + render inline: "<%= cache('foo'){ 'bar' } %>" + end - def with_fragment_cache_and_percent_in_key - render inline: "<%= cache('foo%bar'){ 'Contains % sign in key' } %>" - end + def with_fragment_cache_and_percent_in_key + render inline: "<%= cache('foo%bar'){ 'Contains % sign in key' } %>" + end - def with_fragment_cache_if_with_true_condition - render inline: "<%= cache_if(true, 'foo') { 'bar' } %>" - end + def with_fragment_cache_if_with_true_condition + render inline: "<%= cache_if(true, 'foo') { 'bar' } %>" + end - def with_fragment_cache_if_with_false_condition - render inline: "<%= cache_if(false, 'foo') { 'bar' } %>" - end + def with_fragment_cache_if_with_false_condition + render inline: "<%= cache_if(false, 'foo') { 'bar' } %>" + end - def with_fragment_cache_unless_with_false_condition - render inline: "<%= cache_unless(false, 'foo') { 'bar' } %>" - end + def with_fragment_cache_unless_with_false_condition + render inline: "<%= cache_unless(false, 'foo') { 'bar' } %>" + end - def with_fragment_cache_unless_with_true_condition - render inline: "<%= cache_unless(true, 'foo') { 'bar' } %>" - end + def with_fragment_cache_unless_with_true_condition + render inline: "<%= cache_unless(true, 'foo') { 'bar' } %>" + end - def with_throw - throw :halt - end + def with_throw + throw :halt + end - def with_exception - raise Exception - end + def with_exception + raise Exception, "Oopsie" + end - def with_rescued_exception - raise SpecialException - end + def with_rescued_exception + raise SpecialException, "Oops" + end - def with_action_not_found - raise AbstractController::ActionNotFound - end + def with_action_not_found + raise AbstractController::ActionNotFound + end - def append_info_to_payload(payload) - super - payload[:test_key] = "test_value" - @last_payload = payload - end + def append_info_to_payload(payload) + super + payload[:test_key] = "test_value" + @last_payload = payload + end - attr_reader :last_payload + attr_reader :last_payload + end end -end -class ACLogSubscriberTest < ActionController::TestCase tests Another::LogSubscribersController - include ActiveSupport::LogSubscriber::TestHelper - def setup - super + setup do + @old_fragment_cache_logging = ActionController::Base.enable_fragment_cache_logging ActionController::Base.enable_fragment_cache_logging = true - - @old_logger = ActionController::Base.logger + @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + @old_logger = ActionController::LogSubscriber.logger + ActionController::LogSubscriber.logger = @logger @cache_path = Dir.mktmpdir(%w[tmp cache]) @controller.cache_store = :file_store, @cache_path @controller.config.perform_caching = true - ActionController::LogSubscriber.attach_to :action_controller end - def teardown - super - ActiveSupport::LogSubscriber.log_subscribers.clear + teardown do + ActionController::LogSubscriber.logger = @old_logger + FileUtils.rm_rf(@cache_path) ActionController::Base.logger = @old_logger - ActionController::Base.enable_fragment_cache_logging = true + ActionController::Base.enable_fragment_cache_logging = @old_fragment_cache_logging end def set_logger(logger) @@ -128,35 +127,36 @@ def set_logger(logger) def test_start_processing get :show - wait assert_equal 2, logs.size - assert_equal "Processing by Another::LogSubscribersController#show as HTML", logs.first + assert_equal "Processing by #{Another::LogSubscribersController}#show as HTML", logs.first end def test_start_processing_as_json get :show, format: "json" - wait assert_equal 2, logs.size - assert_equal "Processing by Another::LogSubscribersController#show as JSON", logs.first + assert_equal "Processing by #{Another::LogSubscribersController}#show as JSON", logs.first end def test_start_processing_as_non_exten get :show, format: "noext" - wait assert_equal 2, logs.size - assert_equal "Processing by Another::LogSubscribersController#show as */*", logs.first + assert_equal "Processing by #{Another::LogSubscribersController}#show as */*", logs.first end def test_halted_callback get :never_executed - wait assert_equal 4, logs.size assert_equal "Filter chain halted as :redirector rendered or redirected", logs.third end + def test_rescue_from_callback + get :with_rescued_exception + assert_equal 3, logs.size + assert_match(/rescue_from handled #{Another::LogSubscribersController}::SpecialException \(Oops\) - .*log_subscriber_test/, logs.second) + end + def test_process_action get :show - wait assert_equal 2, logs.size assert_match(/Completed/, logs.last) assert_match(/200 OK/, logs.last) @@ -164,13 +164,11 @@ def test_process_action def test_process_action_without_parameters get :show - wait assert_nil logs.detect { |l| /Parameters/.match?(l) } end def test_process_action_with_parameters get :show, params: { id: "10" } - wait assert_equal 3, logs.size assert_equal "Parameters: #{{ "id" => "10" }}", logs[1] @@ -180,8 +178,6 @@ def test_multiple_process_with_parameters get :show, params: { id: "10" } get :show, params: { id: "20" } - wait - assert_equal 6, logs.size assert_equal "Parameters: #{{ "id" => "10" }}", logs[1] assert_equal "Parameters: #{{ "id" => "20" }}", logs[4] @@ -190,7 +186,6 @@ def test_multiple_process_with_parameters def test_process_action_with_wrapped_parameters @request.env["CONTENT_TYPE"] = "application/json" post :show, params: { id: "10", name: "jose" } - wait assert_equal 3, logs.size assert_match({ "person" => { "name" => "jose" } }.inspect[1..-2], logs[1]) @@ -198,21 +193,18 @@ def test_process_action_with_wrapped_parameters def test_process_action_with_view_runtime get :show - wait assert_match(/Completed 200 OK in \d+ms/, logs[1]) end def test_process_action_with_path @request.env["action_dispatch.parameter_filter"] = [:password] get :show, params: { password: "test" } - wait assert_match(/\/show\?password=\[FILTERED\]/, @controller.last_payload[:path]) end def test_process_action_with_throw catch(:halt) do get :with_throw - wait end assert_match(/Completed in \d+ms/, logs[1]) end @@ -220,7 +212,6 @@ def test_process_action_with_throw def test_append_info_to_payload_is_called_even_with_exception begin get :with_exception - wait rescue Exception end @@ -229,7 +220,6 @@ def test_append_info_to_payload_is_called_even_with_exception def test_process_action_headers get :show - wait assert_equal "Rails Testing", @controller.last_payload[:headers]["User-Agent"] end @@ -239,7 +229,6 @@ def test_process_action_with_filter_parameters get :show, params: { lifo: "Pratik", amount: "420", step: "1" } - wait params = logs[1] assert_match({ "amount" => "[FILTERED]" }.inspect[1..-2], params) @@ -249,7 +238,6 @@ def test_process_action_with_filter_parameters def test_redirect_to get :redirector - wait assert_equal 3, logs.size assert_equal "Redirected to http://foo.bar/", logs[1] @@ -259,7 +247,6 @@ def test_redirect_to def test_filter_redirect_url_by_string @request.env["action_dispatch.redirect_filter"] = ["secret"] get :filterable_redirector - wait assert_equal 3, logs.size assert_equal "Redirected to [FILTERED]", logs[1] @@ -268,7 +255,6 @@ def test_filter_redirect_url_by_string def test_filter_redirect_url_by_regexp @request.env["action_dispatch.redirect_filter"] = [/secret\.foo.+/] get :filterable_redirector - wait assert_equal 3, logs.size assert_equal "Redirected to [FILTERED]", logs[1] @@ -276,7 +262,6 @@ def test_filter_redirect_url_by_regexp def test_does_not_filter_redirect_params_by_default get :filterable_redirector_with_params - wait assert_equal 3, logs.size assert_equal "Redirected to http://secret.foo.bar?username=repinel&password=1234", logs[1] @@ -285,7 +270,6 @@ def test_does_not_filter_redirect_params_by_default def test_filter_redirect_params_by_string @request.env["action_dispatch.parameter_filter"] = ["password"] get :filterable_redirector_with_params - wait assert_equal 3, logs.size assert_equal "Redirected to http://secret.foo.bar?username=repinel&password=[FILTERED]", logs[1] @@ -294,7 +278,6 @@ def test_filter_redirect_params_by_string def test_filter_redirect_params_by_regexp @request.env["action_dispatch.parameter_filter"] = [/pass.+/] get :filterable_redirector_with_params - wait assert_equal 3, logs.size assert_equal "Redirected to http://secret.foo.bar?username=repinel&password=[FILTERED]", logs[1] @@ -304,15 +287,29 @@ def test_filter_redirect_bad_uri @request.env["action_dispatch.parameter_filter"] = [/pass.+/] get :filterable_redirector_bad_uri - wait assert_equal 3, logs.size assert_equal "Redirected to [FILTERED]", logs[1] end + def test_verbose_redirect_logs + line = Another::LogSubscribersController.instance_method(:redirector).source_location[1] + 1 + old_cleaner = ActionController::LogSubscriber.backtrace_cleaner + ActionController::LogSubscriber.backtrace_cleaner = ActionController::LogSubscriber.backtrace_cleaner.dup + ActionController::LogSubscriber.backtrace_cleaner.add_silencer { |location| !location.include?(__FILE__) } + ActionDispatch.verbose_redirect_logs = true + + get :redirector + + assert_equal 4, logs.size + assert_match(/↳ #{__FILE__}:#{line}/, logs[2]) + ensure + ActionDispatch.verbose_redirect_logs = false + ActionController::LogSubscriber.backtrace_cleaner = old_cleaner + end + def test_send_data get :data_sender - wait assert_equal 3, logs.size assert_match(/Sent data file\.txt/, logs[1]) @@ -320,7 +317,6 @@ def test_send_data def test_send_file get :file_sender - wait assert_equal 3, logs.size assert_match(/Sent file/, logs[1]) @@ -329,7 +325,6 @@ def test_send_file def test_with_fragment_cache get :with_fragment_cache - wait assert_equal 4, logs.size assert_match(/Read fragment views\/foo/, logs[1]) @@ -339,17 +334,15 @@ def test_with_fragment_cache def test_with_fragment_cache_when_log_disabled ActionController::Base.enable_fragment_cache_logging = false get :with_fragment_cache - wait assert_equal 2, logs.size - assert_equal "Processing by Another::LogSubscribersController#with_fragment_cache as HTML", logs[0] + assert_equal "Processing by #{Another::LogSubscribersController}#with_fragment_cache as HTML", logs[0] assert_match(/Completed 200 OK in \d+ms/, logs[1]) ActionController::Base.enable_fragment_cache_logging = true end def test_with_fragment_cache_if_with_true get :with_fragment_cache_if_with_true_condition - wait assert_equal 4, logs.size assert_match(/Read fragment views\/foo/, logs[1]) @@ -358,7 +351,6 @@ def test_with_fragment_cache_if_with_true def test_with_fragment_cache_if_with_false get :with_fragment_cache_if_with_false_condition - wait assert_equal 2, logs.size assert_no_match(/Read fragment views\/foo/, logs[1]) @@ -367,7 +359,6 @@ def test_with_fragment_cache_if_with_false def test_with_fragment_cache_unless_with_true get :with_fragment_cache_unless_with_true_condition - wait assert_equal 2, logs.size assert_no_match(/Read fragment views\/foo/, logs[1]) @@ -376,7 +367,6 @@ def test_with_fragment_cache_unless_with_true def test_with_fragment_cache_unless_with_false get :with_fragment_cache_unless_with_false_condition - wait assert_equal 4, logs.size assert_match(/Read fragment views\/foo/, logs[1]) @@ -385,7 +375,6 @@ def test_with_fragment_cache_unless_with_false def test_with_fragment_cache_and_percent_in_key get :with_fragment_cache_and_percent_in_key - wait assert_equal 4, logs.size assert_match(/Read fragment views\/foo/, logs[1]) @@ -395,7 +384,6 @@ def test_with_fragment_cache_and_percent_in_key def test_process_action_with_exception_includes_http_status_code begin get :with_exception - wait rescue Exception end assert_equal 2, logs.size @@ -404,16 +392,14 @@ def test_process_action_with_exception_includes_http_status_code def test_process_action_with_rescued_exception_includes_http_status_code get :with_rescued_exception - wait - assert_equal 2, logs.size + assert_equal 3, logs.size assert_match(/Completed 406/, logs.last) end def test_process_action_with_with_action_not_found_logs_404 begin get :with_action_not_found - wait rescue AbstractController::ActionNotFound end diff --git a/actionpack/test/controller/new_base/base_test.rb b/actionpack/test/controller/new_base/base_test.rb index 280134f8d2633..cdcb52891a0f1 100644 --- a/actionpack/test/controller/new_base/base_test.rb +++ b/actionpack/test/controller/new_base/base_test.rb @@ -27,6 +27,10 @@ def show_actions render body: "actions: #{action_methods.to_a.sort.join(', ')}" end + # Shadow one of the internal methods + def translate + end + private def authenticate end @@ -119,13 +123,14 @@ class BaseTest < Rack::TestCase modify_response_body_twice modify_response_body show_actions + translate )), SimpleController.action_methods assert_equal Set.new, EmptyController.action_methods assert_equal Set.new, Submodule::ContainedEmptyController.action_methods get "/dispatching/simple/show_actions" - assert_body "actions: index, modify_response_body, modify_response_body_twice, modify_response_headers, show_actions" + assert_body "actions: index, modify_response_body, modify_response_body_twice, modify_response_headers, show_actions, translate" end end end diff --git a/actionpack/test/controller/parameters/equality_test.rb b/actionpack/test/controller/parameters/equality_test.rb index 153794a26ad48..2c836a61480a6 100644 --- a/actionpack/test/controller/parameters/equality_test.rb +++ b/actionpack/test/controller/parameters/equality_test.rb @@ -57,4 +57,17 @@ class ParametersAccessorsTest < ActiveSupport::TestCase params = ActionController::Parameters.new(foo: { bar: "baz" }) assert params.has_value?(ActionController::Parameters.new("bar" => "baz")) end + + test "deconstruct_keys works with parameters" do + assert_pattern { @params => { person: { age: "32" } } } + refute_pattern { @params => { person: { addresses: ["does not match"] } } } + end + + test "deconstruct_keys returns instances of ActionController::Parameters for nested values" do + @params => { person: } + person => { addresses: } + + assert_kind_of ActionController::Parameters, person + assert_kind_of ActionController::Parameters, addresses.first + end end diff --git a/actionpack/test/controller/parameters/log_on_unpermitted_params_test.rb b/actionpack/test/controller/parameters/log_on_unpermitted_params_test.rb index 1d532fe82583c..26435ea01de9e 100644 --- a/actionpack/test/controller/parameters/log_on_unpermitted_params_test.rb +++ b/actionpack/test/controller/parameters/log_on_unpermitted_params_test.rb @@ -1,9 +1,12 @@ # frozen_string_literal: true require "abstract_unit" +require "active_support/testing/event_reporter_assertions" require "action_controller/metal/strong_parameters" class LogOnUnpermittedParamsTest < ActiveSupport::TestCase + include ActiveSupport::Testing::EventReporterAssertions + def setup ActionController::Parameters.action_on_unpermitted_parameters = :log end @@ -12,6 +15,12 @@ def teardown ActionController::Parameters.action_on_unpermitted_parameters = false end + def run(*) + with_debug_event_reporting do + super + end + end + test "logs on unexpected param" do request_params = { book: { pages: 65 }, fishing: "Turnips" } context = { "action" => "my_action", "controller" => "my_controller" } @@ -196,9 +205,9 @@ def teardown private def assert_logged(message) - old_logger = ActionController::Base.logger + old_logger = ActionController::LogSubscriber.logger log = StringIO.new - ActionController::Base.logger = Logger.new(log) + ActionController::LogSubscriber.logger = Logger.new(log) begin yield @@ -206,7 +215,7 @@ def assert_logged(message) log.rewind assert_match message, log.read ensure - ActionController::Base.logger = old_logger + ActionController::LogSubscriber.logger = old_logger end end end diff --git a/actionpack/test/controller/rate_limiting_test.rb b/actionpack/test/controller/rate_limiting_test.rb index 0c757189739cc..a6bb3bbfdf0a0 100644 --- a/actionpack/test/controller/rate_limiting_test.rb +++ b/actionpack/test/controller/rate_limiting_test.rb @@ -15,6 +15,75 @@ def limited def limited_with head :ok end + + rate_limit to: 2, within: 2.seconds, by: :by_method, with: :head_forbidden, only: :limited_with_methods + def limited_with_methods + head :ok + end + + rate_limit to: :dynamic_to, within: :dynamic_within, only: :limited_with_dynamic_to_within + def limited_with_dynamic_to_within + head :ok + end + + rate_limit to: -> { params[:max_requests]&.to_i || 2 }, within: -> { params[:time_window]&.to_i&.seconds || 2.seconds }, only: :limited_with_callable_to_within + def limited_with_callable_to_within + head :ok + end + + private + def by_method + params[:rate_limit_key] + end + + def head_forbidden + head :forbidden + end + + def dynamic_to + params[:max_requests]&.to_i || 2 + end + + def dynamic_within + params[:time_window]&.to_i&.seconds || 2.seconds + end +end + +class RateLimitedBaseController < ActionController::Base + self.cache_store = ActiveSupport::Cache::MemoryStore.new +end + +class RateLimitedSharedOneController < RateLimitedBaseController + rate_limit to: 2, within: 2.seconds, scope: "shared" + + def limited_shared_one + head :ok + end +end + +class RateLimitedSharedTwoController < RateLimitedBaseController + rate_limit to: 2, within: 2.seconds, scope: "shared" + + def limited_shared_two + head :ok + end +end + +class RateLimitedSharedController < ActionController::Base + self.cache_store = ActiveSupport::Cache::MemoryStore.new + rate_limit to: 2, within: 2.seconds +end + +class RateLimitedSharedThreeController < RateLimitedSharedController + def limited_shared_three + head :ok + end +end + +class RateLimitedSharedFourController < RateLimitedSharedController + def limited_shared_four + head :ok + end end class RateLimitingTest < ActionController::TestCase @@ -29,25 +98,42 @@ class RateLimitingTest < ActionController::TestCase get :limited assert_response :ok + assert_raises ActionController::TooManyRequests do + get :limited + end + end + + test "notification on limit action" do get :limited - assert_response :too_many_requests + get :limited + + assert_notification("rate_limit.action_controller", + count: 3, + to: 2, + within: 2.seconds, + name: nil, + by: request.remote_ip) do + assert_raises ActionController::TooManyRequests do + get :limited + end + end end test "multiple rate limits" do + freeze_time get :limited get :limited assert_response :ok - travel_to 3.seconds.from_now do - get :limited - get :limited - assert_response :ok - end + travel 3.seconds + get :limited + get :limited + assert_response :ok - travel_to 3.seconds.from_now do - get :limited + travel 3.seconds + get :limited + assert_raises ActionController::TooManyRequests do get :limited - assert_response :too_many_requests end end @@ -62,7 +148,7 @@ class RateLimitingTest < ActionController::TestCase end end - test "limit by" do + test "limit by callable" do get :limited_with get :limited_with get :limited_with @@ -72,10 +158,116 @@ class RateLimitingTest < ActionController::TestCase assert_response :ok end - test "limited with" do + test "limited with callable" do get :limited_with get :limited_with get :limited_with assert_response :forbidden end + + test "limit by method" do + get :limited_with_methods + get :limited_with_methods + get :limited_with_methods + assert_response :forbidden + + get :limited_with_methods, params: { rate_limit_key: "other" } + assert_response :ok + end + + test "limited with method" do + get :limited_with_methods + get :limited_with_methods + get :limited_with_methods + assert_response :forbidden + end + + test "dynamic to and within with methods" do + get :limited_with_dynamic_to_within + get :limited_with_dynamic_to_within + assert_response :ok + + assert_raises ActionController::TooManyRequests do + get :limited_with_dynamic_to_within + end + end + + test "dynamic to and within with methods using custom values" do + get :limited_with_dynamic_to_within, params: { max_requests: 5, time_window: 10 } + get :limited_with_dynamic_to_within, params: { max_requests: 5, time_window: 10 } + get :limited_with_dynamic_to_within, params: { max_requests: 5, time_window: 10 } + get :limited_with_dynamic_to_within, params: { max_requests: 5, time_window: 10 } + get :limited_with_dynamic_to_within, params: { max_requests: 5, time_window: 10 } + assert_response :ok + + assert_raises ActionController::TooManyRequests do + get :limited_with_dynamic_to_within, params: { max_requests: 5, time_window: 10 } + end + end + + test "dynamic to and within with callables" do + get :limited_with_callable_to_within + get :limited_with_callable_to_within + assert_response :ok + + assert_raises ActionController::TooManyRequests do + get :limited_with_callable_to_within + end + end + + test "dynamic to and within with callables using custom values" do + get :limited_with_callable_to_within, params: { max_requests: 3, time_window: 5 } + get :limited_with_callable_to_within, params: { max_requests: 3, time_window: 5 } + get :limited_with_callable_to_within, params: { max_requests: 3, time_window: 5 } + assert_response :ok + + assert_raises ActionController::TooManyRequests do + get :limited_with_callable_to_within, params: { max_requests: 3, time_window: 5 } + end + end + + test "cross-controller rate limit" do + @controller = RateLimitedSharedOneController.new + get :limited_shared_one + assert_response :ok + + get :limited_shared_one + assert_response :ok + + @controller = RateLimitedSharedTwoController.new + + assert_raises ActionController::TooManyRequests do + get :limited_shared_two + end + + @controller = RateLimitedSharedOneController.new + + assert_raises ActionController::TooManyRequests do + get :limited_shared_one + end + ensure + RateLimitedBaseController.cache_store.clear + end + + test "inherited rate limit isn't shared between controllers" do + @controller = RateLimitedSharedThreeController.new + get :limited_shared_three + assert_response :ok + + get :limited_shared_three + assert_response :ok + + @controller = RateLimitedSharedFourController.new + + get :limited_shared_four + assert_response :ok + + @controller = RateLimitedSharedThreeController.new + + assert_raises ActionController::TooManyRequests do + get :limited_shared_three + end + ensure + RateLimitedSharedController.cache_store.clear + end end diff --git a/actionpack/test/controller/redirect_test.rb b/actionpack/test/controller/redirect_test.rb index babca7127dc4d..a198528a6d4c4 100644 --- a/actionpack/test/controller/redirect_test.rb +++ b/actionpack/test/controller/redirect_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "abstract_unit" +require "active_support/log_subscriber/test_helper" class Workshop extend ActiveModel::Naming @@ -154,6 +155,18 @@ def redirect_to_url_with_network_path_reference redirect_to "//www.rubyonrails.org/" end + def redirect_to_path_relative_url + redirect_to "example.com" + end + + def redirect_to_path_relative_url_starting_with_an_at + redirect_to "@example.com" + end + + def redirect_to_query_string_url + redirect_to "?foo=bar" + end + def redirect_to_existing_record redirect_to Workshop.new(5) end @@ -335,6 +348,24 @@ def test_redirect_to_url_with_complex_scheme assert_equal "x-test+scheme.complex:redirect", redirect_to_url end + def test_redirect_to_path_relative_url + get :redirect_to_path_relative_url + assert_response :redirect + assert_equal "http://test.hostexample.com", redirect_to_url + end + + def test_redirect_to_url_with_path_relative_url_starting_with_an_at + get :redirect_to_path_relative_url_starting_with_an_at + assert_response :redirect + assert_equal "http://test.host@example.com", redirect_to_url + end + + def test_redirect_to_query_string_url + get :redirect_to_query_string_url + assert_response :redirect + assert_equal "http://test.host?foo=bar", redirect_to_url + end + def test_redirect_to_url_with_network_path_reference get :redirect_to_url_with_network_path_reference assert_response :redirect @@ -620,7 +651,359 @@ def test_redirect_to_external_with_rescue assert_response :ok end + def test_redirect_to_path_relative_url_with_log + with_path_relative_redirect(:log) do + with_logger do |logger| + get :redirect_to_path_relative_url + assert_response :redirect + assert_equal "http://test.hostexample.com", redirect_to_url + assert_logged(/Path relative URL redirect detected: "example.com"/, logger) + end + end + end + + def test_redirect_to_path_relative_url_starting_with_an_at_with_log + with_path_relative_redirect(:log) do + with_logger do |logger| + get :redirect_to_path_relative_url_starting_with_an_at + assert_response :redirect + assert_equal "http://test.host@example.com", redirect_to_url + assert_logged(/Path relative URL redirect detected: "@example.com"/, logger) + end + end + end + + def test_redirect_to_path_relative_url_starting_with_an_at_with_notify + with_path_relative_redirect(:notify) do + events = [] + subscriber = ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + get :redirect_to_path_relative_url_starting_with_an_at + + assert_response :redirect + assert_equal "http://test.host@example.com", redirect_to_url + + assert_equal 1, events.size + event = events.first + assert_equal "@example.com", event.payload[:url] + assert_equal 'Path relative URL redirect detected: "@example.com"', event.payload[:message] + assert_kind_of Array, event.payload[:stack_trace] + assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url_starting_with_an_at") } + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + end + + def test_redirect_to_path_relative_url_with_notify + with_path_relative_redirect(:notify) do + events = [] + subscriber = ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + get :redirect_to_path_relative_url + + assert_response :redirect + assert_equal "http://test.hostexample.com", redirect_to_url + + assert_equal 1, events.size + event = events.first + assert_equal "example.com", event.payload[:url] + assert_equal 'Path relative URL redirect detected: "example.com"', event.payload[:message] + assert_kind_of Array, event.payload[:stack_trace] + assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url") } + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + end + + def test_redirect_to_path_relative_url_with_raise + with_path_relative_redirect(:raise) do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :redirect_to_path_relative_url + end + + assert_equal 'Path relative URL redirect detected: "example.com"', error.message + end + end + + def test_redirect_to_path_relative_url_starting_with_an_at_with_raise + with_path_relative_redirect(:raise) do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :redirect_to_path_relative_url_starting_with_an_at + end + + assert_equal 'Path relative URL redirect detected: "@example.com"', error.message + end + end + + def test_redirect_to_absolute_url_does_not_log + with_path_relative_redirect(:log) do + with_logger do |logger| + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url + assert_not_logged(/Path relative URL redirect detected/, logger) + end + + with_logger do |logger| + get :relative_url_redirect_with_status + assert_response :redirect + assert_equal "http://test.host/things/stuff", redirect_to_url + assert_empty logger.logged(:warn) + end + end + end + + def test_redirect_to_absolute_url_does_not_notify + with_path_relative_redirect(:notify) do + events = [] + subscriber = ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url + assert_empty events + + get :relative_url_redirect_with_status + assert_response :redirect + assert_equal "http://test.host/things/stuff", redirect_to_url + assert_empty events + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + end + + def test_redirect_to_absolute_url_does_not_raise + with_path_relative_redirect(:raise) do + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url + + get :relative_url_redirect_with_status + assert_response :redirect + assert_equal "http://test.host/things/stuff", redirect_to_url + + get :redirect_to_url_with_network_path_reference + assert_response :redirect + assert_equal "//www.rubyonrails.org/", redirect_to_url + end + end + + def test_redirect_to_query_string_url_does_not_trigger_path_relative_warning_with_log + with_path_relative_redirect(:log) do + with_logger do |logger| + get :redirect_to_query_string_url + assert_response :redirect + assert_equal "http://test.host?foo=bar", redirect_to_url + assert_not_logged(/Path relative URL redirect detected/, logger) + end + end + end + + def test_redirect_to_query_string_url_does_not_trigger_path_relative_warning_with_notify + with_path_relative_redirect(:notify) do + events = [] + subscriber = ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + get :redirect_to_query_string_url + assert_response :redirect + assert_equal "http://test.host?foo=bar", redirect_to_url + + assert_empty events.select { |e| e.payload[:message]&.include?("Path relative URL redirect detected") } + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + end + + def test_redirect_to_query_string_url_does_not_trigger_path_relative_warning_with_raise + with_path_relative_redirect(:raise) do + get :redirect_to_query_string_url + assert_response :redirect + assert_equal "http://test.host?foo=bar", redirect_to_url + end + end + + def test_redirect_with_allowed_redirect_hosts + with_raise_on_open_redirects do + with_allowed_redirect_hosts(hosts: ["www.rubyonrails.org"]) do + get :redirect_to_url + assert_response :redirect + assert_redirected_to "http://www.rubyonrails.org/" + end + end + end + + def test_not_redirect_with_allowed_redirect_hosts + with_raise_on_open_redirects do + with_allowed_redirect_hosts(hosts: ["www.ruby-lang.org"]) do + assert_raise ActionController::Redirecting::UnsafeRedirectError do + get :redirect_to_url + end + end + end + end + + def test_redirect_to_external_with_action_on_open_redirect_log + with_action_on_open_redirect(:log) do + with_logger do |logger| + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url + assert_logged(/Open redirect to "http:\/\/www.rubyonrails.org\/" detected/, logger) + end + end + end + + def test_redirect_to_external_with_action_on_open_redirect_notify + with_action_on_open_redirect(:notify) do + events = [] + subscriber = ActiveSupport::Notifications.subscribe("open_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url + + assert_equal 1, events.size + event = events.first + assert_equal "http://www.rubyonrails.org/", event.payload[:location] + assert_kind_of ActionDispatch::Request, event.payload[:request] + assert_kind_of Array, event.payload[:stack_trace] + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + end + + def test_redirect_to_external_with_action_on_open_redirect_raise + with_action_on_open_redirect(:raise) do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :redirect_to_url + end + assert_equal "Unsafe redirect to \"http://www.rubyonrails.org/\", pass allow_other_host: true to redirect anyway.", error.message + end + end + + def test_redirect_to_external_with_explicit_allow_other_host_false_always_raises + with_action_on_open_redirect(:log) do + get :redirect_to_external_with_rescue + assert_response :ok + assert_equal "caught error", response.body + end + + with_action_on_open_redirect(:notify) do + get :redirect_to_external_with_rescue + assert_response :ok + assert_equal "caught error", response.body + end + + with_action_on_open_redirect(:raise) do + get :redirect_to_external_with_rescue + assert_response :ok + assert_equal "caught error", response.body + end + end + + def test_redirect_back_with_external_referer_and_action_on_open_redirect_log + with_action_on_open_redirect(:log) do + @request.env["HTTP_REFERER"] = "http://www.rubyonrails.org/" + get :redirect_back_with_status + assert_response 307 + assert_equal "http://www.rubyonrails.org/", redirect_to_url + end + end + + def test_redirect_back_with_external_referer_and_action_on_open_redirect_notify + with_action_on_open_redirect(:notify) do + @request.env["HTTP_REFERER"] = "http://www.rubyonrails.org/" + get :redirect_back_with_status + assert_response 307 + assert_equal "http://www.rubyonrails.org/", redirect_to_url + end + end + + def test_redirect_back_with_external_referer_and_action_on_open_redirect_raise + with_action_on_open_redirect(:raise) do + @request.env["HTTP_REFERER"] = "http://www.rubyonrails.org/" + get :redirect_back_with_status + assert_response 307 + assert_equal "http://test.host/things/stuff", redirect_to_url + end + end + + def test_redirect_back_with_external_referer_and_explicit_allow_other_host_false + with_action_on_open_redirect(:log) do + @request.env["HTTP_REFERER"] = "http://another.host/coming/from" + get :safe_redirect_back_with_status + assert_response 307 + assert_equal "http://test.host/things/stuff", redirect_to_url + end + end + + def test_raise_on_open_redirects_overrides_action_on_open_redirect + with_action_on_open_redirect(:log) do + with_raise_on_open_redirects do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :redirect_to_url + end + assert_match(/Unsafe redirect/, error.message) + end + end + end + + def test_action_on_open_redirect_does_not_affect_internal_redirects + with_action_on_open_redirect(:raise) do + get :simple_redirect + assert_response :redirect + assert_equal "http://test.host/redirect/hello_world", redirect_to_url + end + end + + def test_action_on_open_redirect_with_allowed_redirect_hosts + with_action_on_open_redirect(:raise) do + with_allowed_redirect_hosts(hosts: ["www.rubyonrails.org"]) do + get :redirect_to_url + assert_response :redirect + assert_redirected_to "http://www.rubyonrails.org/" + end + end + end + private + def with_logger + logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + old_logger = ActionController::Base.logger + ActionController::Base.logger = logger + yield logger + ensure + ActionController::Base.logger = old_logger + end + + def assert_logged(pattern, logger) + assert logger.logged(:warn).any? { |msg| msg.match?(pattern) }, + "Expected to find log matching #{pattern.inspect} in: #{logger.logged(:warn).inspect}" + end + + def assert_not_logged(pattern, logger) + assert logger.logged(:warn).none? { |msg| msg.match?(pattern) }, + "Expected not to find log matching #{pattern.inspect} in: #{logger.logged(:warn).inspect}" + end + + def with_path_relative_redirect(action) + old_config = ActionController::Base.action_on_path_relative_redirect + ActionController::Base.action_on_path_relative_redirect = action + yield + ensure + ActionController::Base.action_on_path_relative_redirect = old_config + end + def with_raise_on_open_redirects old_raise_on_open_redirects = ActionController::Base.raise_on_open_redirects ActionController::Base.raise_on_open_redirects = true @@ -628,6 +1011,22 @@ def with_raise_on_open_redirects ensure ActionController::Base.raise_on_open_redirects = old_raise_on_open_redirects end + + def with_action_on_open_redirect(action) + old_action = ActionController::Base.action_on_open_redirect + ActionController::Base.action_on_open_redirect = action + yield + ensure + ActionController::Base.action_on_open_redirect = old_action + end + + def with_allowed_redirect_hosts(hosts:) + old_allowed_redirect_hosts = ActionController::Base.allowed_redirect_hosts + ActionController::Base.allowed_redirect_hosts = hosts + yield + ensure + ActionController::Base.allowed_redirect_hosts = old_allowed_redirect_hosts + end end module ModuleTest diff --git a/actionpack/test/controller/render_json_test.rb b/actionpack/test/controller/render_json_test.rb index be40698fb99cd..900ed069e4feb 100644 --- a/actionpack/test/controller/render_json_test.rb +++ b/actionpack/test/controller/render_json_test.rb @@ -3,6 +3,7 @@ require "abstract_unit" require "controller/fake_models" require "active_support/logger" +require "active_support/core_ext/object/with" class RenderJsonTest < ActionController::TestCase class JsonRenderable @@ -46,6 +47,14 @@ def render_json_hello_world_with_callback render json: ActiveSupport::JSON.encode(hello: "world"), callback: "alert" end + def render_json_unsafe_chars_with_callback + render json: { hello: "\u2028\u2029}m, 1] + assert_match %r{Third error}, script_content + assert_match %r{Caused by:.*Second error}m, script_content + end end diff --git a/actionpack/test/dispatch/exception_wrapper_test.rb b/actionpack/test/dispatch/exception_wrapper_test.rb index 7d8ad69eb833d..88953d3e1ceb6 100644 --- a/actionpack/test/dispatch/exception_wrapper_test.rb +++ b/actionpack/test/dispatch/exception_wrapper_test.rb @@ -56,10 +56,12 @@ def translate_location(backtrace_location, spot) test "#source_extracts fetches source fragments for every backtrace entry" do exception = begin index; rescue TestError => ex; ex; end + wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exception, 1)) + trace = wrapper.source_extracts.first[:trace] assert_called_with(wrapper, :source_fragment, ["lib/file.rb", 42], returns: "foo") do - assert_equal [ code: "foo", line_number: 42 ], wrapper.source_extracts + assert_equal [ code: "foo", line_number: 42, trace: trace ], wrapper.source_extracts end end @@ -69,9 +71,10 @@ def translate_location(backtrace_location, spot) exc = begin ms_index; rescue TestError => ex; ex; end wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exc, 1)) + trace = wrapper.source_extracts.first[:trace] assert_called_with(wrapper, :source_fragment, ["c:/path/to/rails/app/controller.rb", 27], returns: "nothing") do - assert_equal [ code: "nothing", line_number: 27 ], wrapper.source_extracts + assert_equal [ code: "nothing", line_number: 27, trace: trace ], wrapper.source_extracts end end @@ -81,9 +84,10 @@ def translate_location(backtrace_location, spot) exc = begin invalid_ex; rescue TestError => ex; ex; end wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exc, 1)) + trace = wrapper.source_extracts.first[:trace] assert_called_with(wrapper, :source_fragment, ["invalid", 0], returns: "nothing") do - assert_equal [ code: "nothing", line_number: 0 ], wrapper.source_extracts + assert_equal [ code: "nothing", line_number: 0, trace: trace ], wrapper.source_extracts end end @@ -95,9 +99,10 @@ def translate_location(backtrace_location, spot) exception = begin throw_syntax_error; rescue SyntaxError => ex; ex; end wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exception, 1)) + trace = wrapper.source_extracts.first[:trace] assert_called_with(wrapper, :source_fragment, ["lib/file.rb", 42], returns: "foo") do - assert_equal [ code: "foo", line_number: 42 ], wrapper.source_extracts + assert_equal [ code: "foo", line_number: 42, trace: trace ], wrapper.source_extracts end end @@ -123,7 +128,7 @@ def translate_location(backtrace_location, spot) code[lineno + i] = line end code[lineno + 2] = [" 1", ".time", "\n"] - assert_equal({ code: code, line_number: lineno + 2 }, wrapper.source_extracts.first) + assert_equal({ code: code, line_number: lineno + 2, trace: wrapper.source_extracts.first[:trace] }, wrapper.source_extracts.first) end class_eval "def _app_views_tests_show_html_erb; @@ -142,7 +147,8 @@ def translate_location(backtrace_location, spot) assert_equal [{ code: { 1 => "translated @ _app_views_tests_show_html_erb:3" }, - line_number: 1 + line_number: 1, + trace: wrapper.source_extracts.first[:trace] }], wrapper.source_extracts end @@ -168,17 +174,20 @@ def translate_location(backtrace_location, spot) extracts = wrapper.source_extracts assert_equal({ code: { 1 => "translated @ _app_views_tests_nested_html_erb:5" }, - line_number: 1 + line_number: 1, + trace: extracts[0][:trace] }, extracts[0]) # extracts[1] is Array#each (unreliable backtrace across rubies) assert_equal({ code: { 1 => "translated @ _app_views_tests_nested_html_erb:4" }, - line_number: 1 + line_number: 1, + trace: extracts[2][:trace] }, extracts[2]) # extracts[3] is Array#each (unreliable backtrace across rubies) assert_equal({ code: { 1 => "translated @ _app_views_tests_nested_html_erb:3" }, - line_number: 1 + line_number: 1, + trace: extracts[4][:trace] }, extracts[4]) end @@ -263,23 +272,27 @@ def translate_location(backtrace_location, spot) "Application Trace" => [ exception_object_id: exception.object_id, id: 0, - trace: "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'" + trace: wrapper.source_extracts.first[:trace], + filtered_trace: "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'" ], "Framework Trace" => [ exception_object_id: exception.object_id, id: 1, - trace: "/gems/rack.rb:43:in 'ActionDispatch::ExceptionWrapperTest#in_rack'" + trace: wrapper.source_extracts.second[:trace], + filtered_trace: "/gems/rack.rb:43:in 'ActionDispatch::ExceptionWrapperTest#in_rack'" ], "Full Trace" => [ { exception_object_id: exception.object_id, id: 0, - trace: "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'" + trace: wrapper.source_extracts.first[:trace], + filtered_trace: "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'" }, { exception_object_id: exception.object_id, id: 1, - trace: "/gems/rack.rb:43:in 'ActionDispatch::ExceptionWrapperTest#in_rack'" + trace: wrapper.source_extracts.second[:trace], + filtered_trace: "/gems/rack.rb:43:in 'ActionDispatch::ExceptionWrapperTest#in_rack'" } ] }.inspect, wrapper.traces.inspect) @@ -288,23 +301,27 @@ def translate_location(backtrace_location, spot) "Application Trace" => [ exception_object_id: exception.object_id, id: 0, - trace: "lib/file.rb:42:in `index'" + trace: wrapper.source_extracts.first[:trace], + filtered_trace: "lib/file.rb:42:in `index'" ], "Framework Trace" => [ exception_object_id: exception.object_id, id: 1, - trace: "/gems/rack.rb:43:in `in_rack'" + trace: wrapper.source_extracts.last[:trace], + filtered_trace: "/gems/rack.rb:43:in `in_rack'" ], "Full Trace" => [ { exception_object_id: exception.object_id, id: 0, - trace: "lib/file.rb:42:in `index'" + trace: wrapper.source_extracts.first[:trace], + filtered_trace: "lib/file.rb:42:in `index'" }, { exception_object_id: exception.object_id, id: 1, - trace: "/gems/rack.rb:43:in `in_rack'" + trace: wrapper.source_extracts.last[:trace], + filtered_trace: "/gems/rack.rb:43:in `in_rack'" } ] }.inspect, wrapper.traces.inspect) diff --git a/actionpack/test/dispatch/executor_test.rb b/actionpack/test/dispatch/executor_test.rb index 3fbd51a2640d2..c458bbaa00bdc 100644 --- a/actionpack/test/dispatch/executor_test.rb +++ b/actionpack/test/dispatch/executor_test.rb @@ -170,10 +170,46 @@ def test_handled_error_is_not_reported end end + def test_complete_callbacks_are_called_on_rack_response_finished + completed = false + executor.to_complete { completed = true } + + env = Rack::MockRequest.env_for + env["rack.response_finished"] = [] + + call_and_return_body(env) + + assert_not completed + + assert_equal 1, env["rack.response_finished"].size + env["rack.response_finished"].first.call(env, 200, {}, nil) + + assert completed + end + + def test_complete_callbacks_are_called_once_on_rack_response_finished_when_exception_is_raised + completed_count = 0 + executor.to_complete { completed_count += 1 } + + env = Rack::MockRequest.env_for + env["rack.response_finished"] = [] + + begin + call_and_return_body(env) do + raise "error" + end + rescue + end + + assert_equal 1, env["rack.response_finished"].size + env["rack.response_finished"].first.call(env, 200, {}, nil) + + assert_equal 1, completed_count + end + private - def call_and_return_body(&block) + def call_and_return_body(env = Rack::MockRequest.env_for, &block) app = block || proc { [200, {}, []] } - env = Rack::MockRequest.env_for("", {}) _, _, body = middleware(app).call(env) body end diff --git a/actionpack/test/dispatch/mapper_test.rb b/actionpack/test/dispatch/mapper_test.rb index 707d2e29618de..206579bf949bc 100644 --- a/actionpack/test/dispatch/mapper_test.rb +++ b/actionpack/test/dispatch/mapper_test.rb @@ -14,10 +14,6 @@ def request_class ActionDispatch::Request end - def dispatcher_class - RouteSet::Dispatcher - end - def defaults routes.map(&:defaults) end diff --git a/actionpack/test/dispatch/mime_type_test.rb b/actionpack/test/dispatch/mime_type_test.rb index 32245bda328e6..fe2d7da5ad40f 100644 --- a/actionpack/test/dispatch/mime_type_test.rb +++ b/actionpack/test/dispatch/mime_type_test.rb @@ -30,21 +30,21 @@ class MimeTypeTest < ActiveSupport::TestCase test "parse text with trailing star at the beginning" do accept = "text/*, text/html, application/json, multipart/form-data" - expect = [Mime[:html], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:xml], Mime[:yaml], Mime[:json], Mime[:multipart_form]] + expect = [Mime[:html], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:markdown], Mime[:xml], Mime[:yaml], Mime[:json], Mime[:multipart_form]] parsed = Mime::Type.parse(accept) assert_equal expect.map(&:to_s), parsed.map(&:to_s) end test "parse text with trailing star in the end" do accept = "text/html, application/json, multipart/form-data, text/*" - expect = [Mime[:html], Mime[:json], Mime[:multipart_form], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:xml], Mime[:yaml]] + expect = [Mime[:html], Mime[:json], Mime[:multipart_form], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:markdown], Mime[:xml], Mime[:yaml]] parsed = Mime::Type.parse(accept) assert_equal expect.map(&:to_s), parsed.map(&:to_s) end test "parse text with trailing star" do accept = "text/*" - expect = [Mime[:html], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:xml], Mime[:yaml], Mime[:json]] + expect = [Mime[:html], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:xml], Mime[:yaml], Mime[:json], Mime[:markdown]] parsed = Mime::Type.parse(accept) assert_equal expect.map(&:to_s).sort!, parsed.map(&:to_s).sort! end diff --git a/actionpack/test/dispatch/param_builder_test.rb b/actionpack/test/dispatch/param_builder_test.rb index ae496a12d1080..544c4ef70d4fc 100644 --- a/actionpack/test/dispatch/param_builder_test.rb +++ b/actionpack/test/dispatch/param_builder_test.rb @@ -21,51 +21,11 @@ class ParamBuilderTest < ActiveSupport::TestCase assert_instance_of ActiveSupport::HashWithIndifferentAccess, result[:foo] end - if ::Rack::RELEASE.start_with?("2.") - test "(rack 2) defaults to ignoring leading bracket" do - assert_deprecated(ActionDispatch.deprecator) do - result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") - assert_equal({ "foo" => "bar" }, result) - end - - assert_deprecated(ActionDispatch.deprecator) do - result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") - assert_equal({ "foo" => { "bar" => "baz" } }, result) - end - end - else - test "(rack 3) defaults to retaining leading bracket" do - result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") - assert_equal({ "[foo]" => "bar" }, result) - - result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") - assert_equal({ "[foo]" => { "bar" => "baz" } }, result) - end - end - - test "configured for strict brackets" do - previous_brackets = ActionDispatch::ParamBuilder.ignore_leading_brackets - ActionDispatch::ParamBuilder.ignore_leading_brackets = false - + test "retaining leading bracket" do result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") assert_equal({ "[foo]" => "bar" }, result) result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") assert_equal({ "[foo]" => { "bar" => "baz" } }, result) - ensure - ActionDispatch::ParamBuilder.ignore_leading_brackets = previous_brackets - end - - test "configured for ignoring leading brackets" do - previous_brackets = ActionDispatch::ParamBuilder.ignore_leading_brackets - ActionDispatch::ParamBuilder.ignore_leading_brackets = true - - result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") - assert_equal({ "foo" => "bar" }, result) - - result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") - assert_equal({ "foo" => { "bar" => "baz" } }, result) - ensure - ActionDispatch::ParamBuilder.ignore_leading_brackets = previous_brackets end end diff --git a/actionpack/test/dispatch/query_parser_test.rb b/actionpack/test/dispatch/query_parser_test.rb index f300dc9920392..02a1e25238bfa 100644 --- a/actionpack/test/dispatch/query_parser_test.rb +++ b/actionpack/test/dispatch/query_parser_test.rb @@ -23,32 +23,8 @@ class QueryParserTest < ActiveSupport::TestCase assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&;") end - if ::Rack::RELEASE.start_with?("2.") - test "(rack 2) defaults to mixed separators" do - assert_deprecated(ActionDispatch.deprecator) do - assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc") - end - end - else - test "(rack 3) defaults to ampersand separator only" do - assert_equal [["a", "aa"], ["b", "bb;c=cc"]], parsed_pairs("a=aa&b=bb;c=cc") - end - end - - test "configured for strict separator" do - previous_separator = ActionDispatch::QueryParser.strict_query_string_separator - ActionDispatch::QueryParser.strict_query_string_separator = true - assert_equal [["a", "aa"], ["b", "bb;c=cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&") - ensure - ActionDispatch::QueryParser.strict_query_string_separator = previous_separator - end - - test "configured for mixed separator" do - previous_separator = ActionDispatch::QueryParser.strict_query_string_separator - ActionDispatch::QueryParser.strict_query_string_separator = false - assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&;") - ensure - ActionDispatch::QueryParser.strict_query_string_separator = previous_separator + test "defaults to ampersand separator only" do + assert_equal [["a", "aa"], ["b", "bb;c=cc"]], parsed_pairs("a=aa&b=bb;c=cc") end private diff --git a/actionpack/test/dispatch/request_test.rb b/actionpack/test/dispatch/request_test.rb index 7120c8d05cd82..27d3cb25f1396 100644 --- a/actionpack/test/dispatch/request_test.rb +++ b/actionpack/test/dispatch/request_test.rb @@ -105,6 +105,9 @@ class RequestIP < BaseRequestTest request = stub_request "HTTP_X_FORWARDED_FOR" => "3.4.5.6,127.0.0.1" assert_equal "3.4.5.6", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "3.4.5.6:1234,127.0.0.1" + assert_equal "3.4.5.6", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "unknown,192.168.0.1" assert_equal "192.168.0.1", request.remote_ip @@ -131,6 +134,23 @@ class RequestIP < BaseRequestTest assert_match(/HTTP_CLIENT_IP="2\.2\.2\.2"/, e.message) end + test "remote ip spoof detection with both headers" do + request = stub_request "HTTP_X_FORWARDED_FOR" => "1.1.1.1", + "HTTP_FORWARDED" => "for=2.2.2.2, for=3.3.3.3", + "HTTP_CLIENT_IP" => "127.0.0.1" + e = assert_raise(ActionDispatch::RemoteIp::IpSpoofAttackError) { + request.remote_ip + } + assert_match(/IP spoofing attack/, e.message) + assert_match(/HTTP_X_FORWARDED_FOR="1\.1\.1\.1"/, e.message) + if Rack.release < "3" + assert_match(/HTTP_FORWARDED="for=1\.1\.1\.1"/, e.message) + else + assert_match(/HTTP_FORWARDED="for=2\.2\.2\.2, for=3\.3\.3\.3"/, e.message) + end + assert_match(/HTTP_CLIENT_IP="127\.0\.0\.1"/, e.message) + end + test "remote ip with spoof detection disabled" do request = stub_request "HTTP_X_FORWARDED_FOR" => "1.1.1.1", "HTTP_CLIENT_IP" => "2.2.2.2", @@ -150,31 +170,34 @@ class RequestIP < BaseRequestTest request = stub_request "REMOTE_ADDR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7334" assert_equal "2001:0db8:85a3:0000:0000:8a2e:0370:7334", request.remote_ip - request = stub_request "REMOTE_ADDR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329,2001:0db8:85a3:0000:0000:8a2e:0370:7334" + request = stub_request "REMOTE_ADDR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7335,2001:0db8:85a3:0000:0000:8a2e:0370:7334" assert_equal "2001:0db8:85a3:0000:0000:8a2e:0370:7334", request.remote_ip request = stub_request "REMOTE_ADDR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7334", - "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329" - assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip + "HTTP_X_FORWARDED_FOR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7335" + assert_equal "2001:0db8:85a3:0000:0000:8a2e:0370:7335", request.remote_ip request = stub_request "REMOTE_ADDR" => "::1", - "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329" - assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip + "HTTP_X_FORWARDED_FOR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7335" + assert_equal "2001:0db8:85a3:0000:0000:8a2e:0370:7335", request.remote_ip + + request = stub_request "HTTP_X_FORWARDED_FOR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7335,unknown" + assert_equal "2001:0db8:85a3:0000:0000:8a2e:0370:7335", request.remote_ip - request = stub_request "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329,unknown" + request = stub_request "HTTP_X_FORWARDED_FOR" => "[fe80:0000:0000:0000:0202:b3ff:fe1e:8329]:3000,unknown" assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip request = stub_request "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329,::1" assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip - request = stub_request "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329, ::1, ::1" - assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7335, ::1, ::1" + assert_equal "2001:0db8:85a3:0000:0000:8a2e:0370:7335", request.remote_ip request = stub_request "HTTP_X_FORWARDED_FOR" => "unknown,::1" assert_equal "::1", request.remote_ip - request = stub_request "HTTP_X_FORWARDED_FOR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7334, fe80:0000:0000:0000:0202:b3ff:fe1e:8329, ::1, fc00::, fc01::, fdff" - assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7334, 2001:0db8:85a3:0000:0000:8a2e:0370:7335, ::1, fc00::, fc01::, fdff" + assert_equal "2001:0db8:85a3:0000:0000:8a2e:0370:7335", request.remote_ip request = stub_request "HTTP_X_FORWARDED_FOR" => "FE00::, FDFF::" assert_equal "FE00::", request.remote_ip @@ -184,21 +207,21 @@ class RequestIP < BaseRequestTest end test "remote ip v6 spoof detection" do - request = stub_request "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", + request = stub_request "HTTP_X_FORWARDED_FOR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7335", "HTTP_CLIENT_IP" => "2001:0db8:85a3:0000:0000:8a2e:0370:7334" e = assert_raise(ActionDispatch::RemoteIp::IpSpoofAttackError) { request.remote_ip } assert_match(/IP spoofing attack/, e.message) - assert_match(/HTTP_X_FORWARDED_FOR="fe80:0000:0000:0000:0202:b3ff:fe1e:8329"/, e.message) + assert_match(/HTTP_X_FORWARDED_FOR="2001:0db8:85a3:0000:0000:8a2e:0370:7335"/, e.message) assert_match(/HTTP_CLIENT_IP="2001:0db8:85a3:0000:0000:8a2e:0370:7334"/, e.message) end test "remote ip v6 spoof detection disabled" do - request = stub_request "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", + request = stub_request "HTTP_X_FORWARDED_FOR" => "2001:0db8:85a3:0000:0000:8a2e:0370:7335", "HTTP_CLIENT_IP" => "2001:0db8:85a3:0000:0000:8a2e:0370:7334", :ip_spoofing_check => false - assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip + assert_equal "2001:0db8:85a3:0000:0000:8a2e:0370:7335", request.remote_ip end test "remote ip with user specified trusted proxies String" do @@ -333,6 +356,34 @@ class RequestDomain < BaseRequestTest end end +class RequestDomainExtractor < BaseRequestTest + module CustomExtractor + extend self + + def domain_from(_, _) + "world" + end + + def subdomains_from(_, _) + ["hello"] + end + end + + setup { ActionDispatch::Http::URL.domain_extractor = CustomExtractor } + + teardown { ActionDispatch::Http::URL.domain_extractor = ActionDispatch::Http::URL::DomainExtractor } + + test "domain" do + request = stub_request "HTTP_HOST" => "foobar.foobar.com" + assert_equal "world", request.domain + end + + test "subdomains" do + request = stub_request "HTTP_HOST" => "foobar.foobar.com" + assert_equal "hello", request.subdomain + end +end + class RequestPort < BaseRequestTest test "standard_port" do request = stub_request @@ -1121,6 +1172,11 @@ class RequestParameters < BaseRequestTest assert_raises(ActionController::BadRequest) { request.parameters } end + test "parameters key containing an invalid UTF8 character" do + request = stub_request("QUERY_STRING" => "%81E=bar") + assert_raises(ActionController::BadRequest) { request.parameters } + end + test "parameters containing a deeply nested invalid UTF8 character" do request = stub_request("QUERY_STRING" => "foo[bar]=%81E") assert_raises(ActionController::BadRequest) { request.parameters } @@ -1446,3 +1502,105 @@ def setup assert_instance_of(ActionDispatch::Request::Session::Options, ActionDispatch::Request::Session::Options.find(@request)) end end + +class RequestCacheControlDirectives < BaseRequestTest + test "lazily initializes cache_control_directives" do + request = stub_request + assert_not_includes request.instance_variables, :@cache_control_directives + + request.cache_control_directives + assert_includes request.instance_variables, :@cache_control_directives + end + + test "only_if_cached? is true when only-if-cached is the sole directive" do + request = stub_request("HTTP_CACHE_CONTROL" => "only-if-cached") + assert_predicate request.cache_control_directives, :only_if_cached? + end + + test "only_if_cached? is true when only-if-cached appears among multiple directives" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-age=60, only-if-cached") + assert_predicate request.cache_control_directives, :only_if_cached? + end + + test "only_if_cached? is false when Cache-Control header is missing" do + request = stub_request + assert_not_predicate request.cache_control_directives, :only_if_cached? + end + + test "no_cache? properly detects the no-cache directive" do + request = stub_request("HTTP_CACHE_CONTROL" => "no-cache") + assert_predicate request.cache_control_directives, :no_cache? + end + + test "no_store? properly detects the no-store directive" do + request = stub_request("HTTP_CACHE_CONTROL" => "no-store") + assert_predicate request.cache_control_directives, :no_store? + end + + test "no_transform? properly detects the no-transform directive" do + request = stub_request("HTTP_CACHE_CONTROL" => "no-transform") + assert_predicate request.cache_control_directives, :no_transform? + end + + test "max_age properly returns the max-age directive value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-age=60") + assert_equal 60, request.cache_control_directives.max_age + end + + test "max_stale properly returns the max-stale directive value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale=300") + assert_equal 300, request.cache_control_directives.max_stale + end + + test "max_stale returns true when max-stale is present without a value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale") + assert_equal true, request.cache_control_directives.max_stale + end + + test "max_stale? returns true when max-stale is present with or without a value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale=300") + assert_predicate request.cache_control_directives, :max_stale? + + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale") + assert_predicate request.cache_control_directives, :max_stale? + end + + test "max_stale? returns false when max-stale is not present" do + request = stub_request + assert_not_predicate request.cache_control_directives, :max_stale? + end + + test "max_stale_unlimited? returns true only when max-stale is present without a value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale") + assert_predicate request.cache_control_directives, :max_stale_unlimited? + + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale=300") + assert_not_predicate request.cache_control_directives, :max_stale_unlimited? + + request = stub_request + assert_not_predicate request.cache_control_directives, :max_stale_unlimited? + end + + test "min_fresh properly returns the min-fresh directive value" do + request = stub_request("HTTP_CACHE_CONTROL" => "min-fresh=120") + assert_equal 120, request.cache_control_directives.min_fresh + end + + test "stale_if_error properly returns the stale-if-error directive value" do + request = stub_request("HTTP_CACHE_CONTROL" => "stale-if-error=600") + assert_equal 600, request.cache_control_directives.stale_if_error + end + + test "handles Cache-Control header with whitespace and case insensitivity" do + request = stub_request("HTTP_CACHE_CONTROL" => " Max-Age=60 , No-Cache ") + assert_equal 60, request.cache_control_directives.max_age + assert_predicate request.cache_control_directives, :no_cache? + end + + test "ignores unrecognized directives" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-age=60, unknown-directive, foo=bar") + assert_equal 60, request.cache_control_directives.max_age + assert_not_predicate request.cache_control_directives, :no_cache? + assert_not_predicate request.cache_control_directives, :no_store? + end +end diff --git a/actionpack/test/dispatch/routing/inspector_test.rb b/actionpack/test/dispatch/routing/inspector_test.rb index ccd862e46a42d..5cf4ed6ec7ddd 100644 --- a/actionpack/test/dispatch/routing/inspector_test.rb +++ b/actionpack/test/dispatch/routing/inspector_test.rb @@ -36,11 +36,13 @@ def self.inspect end assert_equal [ + "Routes for application:", " Prefix Verb URI Pattern Controller#Action", "custom_assets GET /custom/assets(.:format) custom_assets#show", " blog /blog Blog::Engine", "", "Routes for Blog::Engine:", + "Prefix Verb URI Pattern Controller#Action", " cart GET /cart(.:format) cart#show" ], output end @@ -59,10 +61,12 @@ def self.inspect end assert_equal [ + "Routes for application:", "Prefix Verb URI Pattern Controller#Action", " blog /blog Blog::Engine", "", - "Routes for Blog::Engine:" + "Routes for Blog::Engine:", + "No routes defined.", ], output end @@ -335,7 +339,8 @@ def self.inspect mount engine => "/blog", :as => "blog" end - expected = ["--[ Route 1 ]----------", + expected = [ "[ Routes for application ]", + "--[ Route 1 ]----------", "Prefix | custom_assets", "Verb | GET", "URI | /custom/assets(.:format)", @@ -379,7 +384,7 @@ def test_no_routes_matched_filter_when_expanded end def test_not_routes_when_expanded - output = draw(grep: "rails/dummy", formatter: ActionDispatch::Routing::ConsoleFormatter::Expanded.new) { } + output = draw(formatter: ActionDispatch::Routing::ConsoleFormatter::Expanded.new) { } assert_equal [ "You don't have any routes defined!", @@ -443,7 +448,7 @@ def test_no_routes_matched_filter end def test_no_routes_were_defined - output = draw(grep: "Rails::DummyController") { } + output = draw { } assert_equal [ "You don't have any routes defined!", @@ -487,6 +492,57 @@ def test_route_with_proc_handler ], output end + def test_displaying_routes_for_engines_with_filter + engine = Class.new(Rails::Engine) do + def self.inspect + "Blog::Engine" + end + end + engine.routes.draw do + get "/cart", to: "cart#show" + end + + output = draw(grep: "cart") do + get "/custom/assets", to: "custom_assets#show" + mount engine => "/blog", :as => "blog" + end + + assert_equal [ + "Routes for application:", + "No routes were found for this grep pattern.", + "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html.", + "", + "Routes for Blog::Engine:", + "Prefix Verb URI Pattern Controller#Action", + " cart GET /cart(.:format) cart#show" + ], output + end + + def test_displaying_routes_for_engines_with_filter_not_matched + engine = Class.new(Rails::Engine) do + def self.inspect + "Blog::Engine" + end + end + engine.routes.draw do + get "/cart", to: "cart#show" + end + + output = draw(grep: "dummy") do + get "/custom/assets", to: "custom_assets#show" + mount engine => "/blog", :as => "blog" + end + + assert_equal [ + "Routes for application:", + "No routes were found for this grep pattern.", + "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html.", + "", + "Routes for Blog::Engine:", + "No routes were found for this grep pattern.", + ], output + end + private def draw(formatter: ActionDispatch::Routing::ConsoleFormatter::Sheet.new, **options, &block) @set.draw(&block) diff --git a/actionpack/test/dispatch/routing/log_subscriber_test.rb b/actionpack/test/dispatch/routing/log_subscriber_test.rb index e62153802f974..a228457c2eb3f 100644 --- a/actionpack/test/dispatch/routing/log_subscriber_test.rb +++ b/actionpack/test/dispatch/routing/log_subscriber_test.rb @@ -2,14 +2,18 @@ require "abstract_unit" require "active_support/log_subscriber/test_helper" +require "action_dispatch/structured_event_subscriber" require "action_dispatch/log_subscriber" class RoutingLogSubscriberTest < ActionDispatch::IntegrationTest - include ActiveSupport::LogSubscriber::TestHelper + setup do + @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + @old_logger = ActionDispatch::LogSubscriber.logger + ActionDispatch::LogSubscriber.logger = @logger + end - def setup - super - ActionDispatch::LogSubscriber.attach_to :action_dispatch + teardown do + ActionDispatch::LogSubscriber.logger = @old_logger end test "redirect is logged" do @@ -18,13 +22,32 @@ def setup end get "/redirect" - wait assert_equal 2, logs.size assert_equal "Redirected to http://www.example.com/login", logs.first assert_match(/Completed 301/, logs.last) end + test "verbose redirect logs" do + line = __LINE__ + 7 + old_cleaner = ActionDispatch::LogSubscriber.backtrace_cleaner + ActionDispatch::LogSubscriber.backtrace_cleaner = ActionDispatch::LogSubscriber.backtrace_cleaner.dup + ActionDispatch::LogSubscriber.backtrace_cleaner.add_silencer { |location| !location.include?(__FILE__) } + ActionDispatch.verbose_redirect_logs = true + + draw do + get "redirect", to: redirect("/login") + end + + get "/redirect" + + assert_equal 3, logs.size + assert_match(/↳ #{__FILE__}:#{line}/, logs[1]) + ensure + ActionDispatch.verbose_redirect_logs = false + ActionDispatch::LogSubscriber.backtrace_cleaner = old_cleaner + end + private def draw(&block) self.class.stub_controllers do |routes| @@ -34,6 +57,10 @@ def draw(&block) end end + def get(path, **options) + super(path, **options.merge(headers: { "action_dispatch.routes" => @app.routes })) + end + def logs @logs ||= @logger.logged(:info) end diff --git a/actionpack/test/dispatch/routing_test.rb b/actionpack/test/dispatch/routing_test.rb index 488ef423c5ce9..11570c53e07da 100644 --- a/actionpack/test/dispatch/routing_test.rb +++ b/actionpack/test/dispatch/routing_test.rb @@ -1549,20 +1549,11 @@ def test_index end def test_match_with_many_paths_containing_a_slash - assert_deprecated(ActionDispatch.deprecator) do + assert_raises(ArgumentError) do draw do get "get/first", "get/second", "get/third", to: "get#show" end end - - get "/get/first" - assert_equal "get#show", @response.body - - get "/get/second" - assert_equal "get#show", @response.body - - get "/get/third" - assert_equal "get#show", @response.body end def test_match_shorthand_with_no_scope @@ -1588,19 +1579,13 @@ def test_match_shorthand_inside_namespace end def test_match_shorthand_with_multiple_paths_inside_namespace - assert_deprecated(ActionDispatch.deprecator) do + assert_raises(ArgumentError) do draw do namespace :proposals do put "activate", "inactivate" end end end - - put "/proposals/activate" - assert_equal "proposals#activate", @response.body - - put "/proposals/inactivate" - assert_equal "proposals#inactivate", @response.body end def test_match_shorthand_inside_namespace_with_controller @@ -4998,49 +4983,6 @@ def test_positional_args_with_format_false end end -class TestErrorsInController < ActionDispatch::IntegrationTest - class ::PostsController < ActionController::Base - def foo - nil.i_do_not_exist - end - - def bar - NonExistingClass.new - end - end - - Routes = ActionDispatch::Routing::RouteSet.new - Routes.draw do - ActionDispatch.deprecator.silence do - get "/:controller(/:action)" - end - end - - APP = build_app Routes - - def app - APP - end - - def test_legit_no_method_errors_are_not_caught - get "/posts/foo" - assert_equal 500, response.status - end - - def test_legit_name_errors_are_not_caught - get "/posts/bar" - assert_equal 500, response.status - end - - def test_legit_routing_not_found_responses - get "/posts/baz" - assert_equal 404, response.status - - get "/i_do_not_exist" - assert_equal 404, response.status - end -end - class TestPartialDynamicPathSegments < ActionDispatch::IntegrationTest Routes = ActionDispatch::Routing::RouteSet.new Routes.draw do @@ -5063,7 +5005,7 @@ def app APP end - def test_paths_with_partial_dynamic_segments_are_recognised + def test_paths_with_partial_dynamic_segments_are_recognized get "/david-bowie/changes-song" assert_equal 200, response.status assert_params artist: "david-bowie", song: "changes" @@ -5204,7 +5146,7 @@ def app APP end - def test_paths_with_partial_dynamic_segments_are_recognised + def test_paths_with_partial_dynamic_segments_are_recognized get "/test_internal/123" assert_equal 200, response.status diff --git a/actionpack/test/dispatch/session/cookie_store_test.rb b/actionpack/test/dispatch/session/cookie_store_test.rb index ea080d336a368..f5239cd2cc366 100644 --- a/actionpack/test/dispatch/session/cookie_store_test.rb +++ b/actionpack/test/dispatch/session/cookie_store_test.rb @@ -203,7 +203,7 @@ def test_close_raises_when_data_overflows error = assert_raise(ActionDispatch::Cookies::CookieOverflow) { get "/raise_data_overflow" } - assert_equal "_myapp_session cookie overflowed with size 5612 bytes", error.message + assert_equal "_myapp_session cookie overflowed with size 5626 bytes", error.message end end diff --git a/actionpack/test/dispatch/show_exceptions_test.rb b/actionpack/test/dispatch/show_exceptions_test.rb index 7115f6a93a8d9..05a33bfddadea 100644 --- a/actionpack/test/dispatch/show_exceptions_test.rb +++ b/actionpack/test/dispatch/show_exceptions_test.rb @@ -27,6 +27,8 @@ def call(env) rescue raise ActionView::Template::Error.new("template") end + when "/rate_limited" + raise ActionController::TooManyRequests.new else raise "puke!" end @@ -41,6 +43,10 @@ def setup assert_raise RuntimeError do get "/", env: { "action_dispatch.show_exceptions" => :none } end + + assert_raise ActionController::TooManyRequests do + get "/rate_limited", headers: { "action_dispatch.show_exceptions" => :none } + end end test "rescue with error page" do @@ -67,6 +73,36 @@ def setup get "/invalid_mimetype", headers: { "Accept" => "text/html,*", "action_dispatch.show_exceptions" => :all } assert_response 406 assert_equal "", body + + get "/rate_limited", headers: { "action_dispatch.show_exceptions" => :all } + assert_response 429 + assert_equal "", body + end + + test "rescue with no body for HEAD requests" do + head "/", env: { "action_dispatch.show_exceptions" => :all } + assert_response 500 + assert_equal "", body + + head "/bad_params", env: { "action_dispatch.show_exceptions" => :all } + assert_response 400 + assert_equal "", body + + head "/not_found", env: { "action_dispatch.show_exceptions" => :all } + assert_response 404 + assert_equal "", body + + head "/method_not_allowed", env: { "action_dispatch.show_exceptions" => :all } + assert_response 405 + assert_equal "", body + + head "/unknown_http_method", env: { "action_dispatch.show_exceptions" => :all } + assert_response 405 + assert_equal "", body + + head "/invalid_mimetype", headers: { "Accept" => "text/html,*", "action_dispatch.show_exceptions" => :all } + assert_response 406 + assert_equal "", body end test "localize rescue error page" do diff --git a/actionpack/test/dispatch/structured_event_subscriber_test.rb b/actionpack/test/dispatch/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..5e115d7008022 --- /dev/null +++ b/actionpack/test/dispatch/structured_event_subscriber_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/testing/event_reporter_assertions" +require "action_dispatch/structured_event_subscriber" + +module ActionDispatch + class StructuredEventSubscriberTest < ActionDispatch::IntegrationTest + include ActiveSupport::Testing::EventReporterAssertions + + test "redirect is reported as structured event" do + draw do + get "redirect", to: redirect("/login") + end + + event = assert_event_reported("action_dispatch.redirect", payload: { + location: "http://www.example.com/login", + status: 301, + status_name: "Moved Permanently" + }) do + get "/redirect" + end + + assert event[:payload][:duration_ms].is_a?(Numeric) + end + + test "redirect with custom status is reported correctly" do + draw do + get "redirect", to: redirect("/moved", status: 302) + end + + assert_event_reported("action_dispatch.redirect", payload: { + location: "http://www.example.com/moved", + status: 302, + status_name: "Found" + }) do + get "/redirect" + end + end + + private + def draw(&block) + self.class.stub_controllers do |routes| + routes.default_url_options = { host: "www.example.com" } + routes.draw(&block) + @app = RoutedRackApp.new routes + end + end + end +end diff --git a/actionpack/test/dispatch/system_testing/driver_test.rb b/actionpack/test/dispatch/system_testing/driver_test.rb index fc79701026e71..848893f2b4c57 100644 --- a/actionpack/test/dispatch/system_testing/driver_test.rb +++ b/actionpack/test/dispatch/system_testing/driver_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "abstract_unit" +require "support/system_helper" require "action_dispatch/system_testing/driver" require "selenium/webdriver" @@ -120,7 +121,7 @@ class DriverTest < ActiveSupport::TestCase expected = { "moz:firefoxOptions" => { "args" => ["--host=127.0.0.1"], - "prefs" => { "remote.active-protocols" => 3, "browser.startup.homepage" => "http://www.seleniumhq.com/" } + "prefs" => { "remote.active-protocols" => 1, "browser.startup.homepage" => "http://www.seleniumhq.com/" } }, "browserName" => "firefox" } @@ -137,13 +138,28 @@ class DriverTest < ActiveSupport::TestCase expected = { "moz:firefoxOptions" => { "args" => ["-headless", "--host=127.0.0.1"], - "prefs" => { "remote.active-protocols" => 3, "browser.startup.homepage" => "http://www.seleniumhq.com/" } + "prefs" => { "remote.active-protocols" => 1, "browser.startup.homepage" => "http://www.seleniumhq.com/" } }, "browserName" => "firefox" } assert_driver_capabilities driver, expected end + test "assert_driver_capabilities ignores unexpected options" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :chrome) do |option| + option.binary = "/usr/bin/chromium-browser" + end + driver.use + + expected = { + "goog:chromeOptions" => { + "args" => ["--disable-search-engine-choice-screen"], + }, + "browserName" => "chrome" + } + assert_driver_capabilities driver, expected + end + test "does not define extra capabilities" do driver = ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :firefox) @@ -202,6 +218,20 @@ class DriverTest < ActiveSupport::TestCase def assert_driver_capabilities(driver, expected_capabilities) capabilities = driver.__send__(:browser_options)[:options].as_json - assert_equal expected_capabilities, capabilities.slice(*expected_capabilities.keys) + expected_capabilities.each do |key, expected_value| + actual_value = capabilities[key] + + case expected_value + when Array + expected_value.each { |item| assert_includes actual_value, item, "Expected #{key} to include #{item}" } + when Hash + expected_value.each do |sub_key, sub_value| + real_value = actual_value&.dig(sub_key) + assert_equal sub_value, real_value, "Expected #{key}[#{sub_key}] to be #{sub_value}, got #{real_value}" + end + else + assert_equal expected_value, actual_value, "Expected #{key} to be #{expected_value}, got #{actual_value}" + end + end end end diff --git a/actionpack/test/dispatch/system_testing/screenshot_helper_test.rb b/actionpack/test/dispatch/system_testing/screenshot_helper_test.rb index adb0a2428ea5f..bcf7eca1f369d 100644 --- a/actionpack/test/dispatch/system_testing/screenshot_helper_test.rb +++ b/actionpack/test/dispatch/system_testing/screenshot_helper_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "abstract_unit" +require "support/system_helper" require "action_dispatch/system_testing/test_helpers/screenshot_helper" require "capybara/dsl" require "selenium/webdriver" diff --git a/actionpack/test/dispatch/system_testing/system_test_case_test.rb b/actionpack/test/dispatch/system_testing/system_test_case_test.rb index 855fb89e671e4..eb97f98b8dd3a 100644 --- a/actionpack/test/dispatch/system_testing/system_test_case_test.rb +++ b/actionpack/test/dispatch/system_testing/system_test_case_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "abstract_unit" +require "support/system_helper" require "selenium/webdriver" class SetDriverToRackTestTest < DrivenByRackTest diff --git a/actionpack/test/dispatch/test_response_test.rb b/actionpack/test/dispatch/test_response_test.rb index c96194782cb90..81acc429fbf39 100644 --- a/actionpack/test/dispatch/test_response_test.rb +++ b/actionpack/test/dispatch/test_response_test.rb @@ -62,7 +62,7 @@ def assert_response_code_range(range, predicate) HTML html = response.parsed_body - html.at("main") => {name:, content:} + html.at("main") => { name:, content: } assert_equal "main", name assert_equal "Some main content", content diff --git a/actionpack/test/support/system_helper.rb b/actionpack/test/support/system_helper.rb new file mode 100644 index 0000000000000..55467595d91e3 --- /dev/null +++ b/actionpack/test/support/system_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class DrivenByRackTest < ActionDispatch::SystemTestCase + driven_by :rack_test +end + +class DrivenBySeleniumWithChrome < ActionDispatch::SystemTestCase + driven_by :selenium, using: :chrome +end + +class DrivenBySeleniumWithHeadlessChrome < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome +end + +class DrivenBySeleniumWithHeadlessFirefox < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_firefox +end diff --git a/actiontext/CHANGELOG.md b/actiontext/CHANGELOG.md index 632794ce57592..99290fd5856f4 100644 --- a/actiontext/CHANGELOG.md +++ b/actiontext/CHANGELOG.md @@ -1,5 +1,21 @@ -* Change `ActionText::RichText#embeds` assignment from `before_save` to `before_validation` +* Deprecate Trix-specific classes, modules, and methods + + * `ActionText::Attachable#to_trix_content_attachment_partial_path`. Override + `#to_editor_content_attachment_partial_path` instead. + * `ActionText::Attachments::TrixConversion` + * `ActionText::Content#to_trix_html`. + * `ActionText::RichText#to_trix_html`. + * `ActionText::TrixAttachment` *Sean Doyle* -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actiontext/CHANGELOG.md) for previous changes. +* Validate `RemoteImage` URLs at creation time. + + `RemoteImage.from_node` now validates the URL before creating a `RemoteImage` object, using the + same regex that `AssetUrlHelper` uses during rendering. URLs like "image.png" that would + previously have been passed to the asset pipeline and raised a `ActionView::Template::Error` are + rejected early, and gracefully fail by resulting in a `MissingAttachable`. + + *Mike Dalessio* + +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actiontext/CHANGELOG.md) for previous changes. diff --git a/actiontext/Rakefile b/actiontext/Rakefile index 5df8ce5437792..1964853ac9bb3 100644 --- a/actiontext/Rakefile +++ b/actiontext/Rakefile @@ -21,33 +21,16 @@ Rake::TestTask.new "test:system" do |t| end namespace :test do - task :isolated do + task isolated: :railties do FileList["test/**/*_test.rb"].exclude("test/system/**/*", "test/dummy/**/*").all? do |file| sh(Gem.ruby, "-w", "-Ilib", "-Itest", file) end || raise("Failures") end -end - -task :vendor_trix do - require "importmap-rails" - require "importmap/packager" - - packager = Importmap::Packager.new(vendor_path: "app/assets/javascripts") - imports = packager.import("trix", from: "unpkg") - imports.each do |package, url| - url.gsub!("esm.min.js", "umd.js") - puts %(Vendoring "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url}) - packager.download(package, url) - css_url = url.gsub("umd.js", "css") - puts %(Vendoring "#{package}" to #{packager.vendor_path}/#{package}.css via download from #{css_url}) - - response = Net::HTTP.get_response(URI(css_url)) - if response.code == "200" - File.open(Pathname.new("app/assets/stylesheets/trix.css"), "w+") do |file| - file.write response.body - end - end + task :railties do + ["action_text/engine"].all? do |railtie| + sh(Gem.ruby, "-r", railtie, "-e", "'OK'") + end || raise("Failures") end end diff --git a/actiontext/actiontext.gemspec b/actiontext/actiontext.gemspec index c80c43ab092ca..e351bba47fd17 100644 --- a/actiontext/actiontext.gemspec +++ b/actiontext/actiontext.gemspec @@ -39,4 +39,5 @@ Gem::Specification.new do |s| s.add_dependency "nokogiri", ">= 1.8.5" s.add_dependency "globalid", ">= 0.6.0" + s.add_dependency "action_text-trix", "~> 2.1.15" end diff --git a/actiontext/app/assets/javascripts/.gitattributes b/actiontext/app/assets/javascripts/.gitattributes index 65a3ad3f8bcdc..1f28b2bca67c9 100644 --- a/actiontext/app/assets/javascripts/.gitattributes +++ b/actiontext/app/assets/javascripts/.gitattributes @@ -1,3 +1,2 @@ actiontext.js linguist-generated actiontext.esm.js linguist-generated -trix.js linguist-vendored diff --git a/actiontext/app/assets/javascripts/actiontext.esm.js b/actiontext/app/assets/javascripts/actiontext.esm.js index 79028350380a3..281013756ceaf 100644 --- a/actiontext/app/assets/javascripts/actiontext.esm.js +++ b/actiontext/app/assets/javascripts/actiontext.esm.js @@ -672,7 +672,7 @@ class DirectUploadController { })); } uploadRequestDidProgress(event) { - const progress = event.loaded / event.total * 100; + const progress = event.loaded / event.total * 90; if (progress) { this.dispatch("progress", { progress: progress @@ -707,6 +707,42 @@ class DirectUploadController { xhr: xhr }); xhr.upload.addEventListener("progress", (event => this.uploadRequestDidProgress(event))); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); + } + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } } } @@ -846,31 +882,69 @@ function autostart() { setTimeout(autostart, 1); class AttachmentUpload { - constructor(attachment, element) { + constructor(attachment, element, file = attachment.file) { this.attachment = attachment; this.element = element; - this.directUpload = new DirectUpload(attachment.file, this.directUploadUrl, this); + this.directUpload = new DirectUpload(file, this.directUploadUrl, this); + this.file = file; } start() { - this.directUpload.create(this.directUploadDidComplete.bind(this)); - this.dispatch("start"); + return new Promise(((resolve, reject) => { + this.directUpload.create(((error, attributes) => this.directUploadDidComplete(error, attributes, resolve, reject))); + this.dispatch("start"); + })); } directUploadWillStoreFileWithXHR(xhr) { xhr.upload.addEventListener("progress", (event => { - const progress = event.loaded / event.total * 100; - this.attachment.setUploadProgress(progress); + const progress = event.loaded / event.total * 90; if (progress) { this.dispatch("progress", { progress: progress }); } })); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); } - directUploadDidComplete(error, attributes) { + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } + } + directUploadDidComplete(error, attributes, resolve, reject) { if (error) { - this.dispatchError(error); + this.dispatchError(error, reject); } else { - this.attachment.setAttributes({ + resolve({ sgid: attributes.attachable_sgid, url: this.createBlobUrl(attributes.signed_id, attributes.filename) }); @@ -886,12 +960,12 @@ class AttachmentUpload { detail: detail }); } - dispatchError(error) { + dispatchError(error, reject) { const event = this.dispatch("error", { error: error }); if (!event.defaultPrevented) { - alert(error); + reject(error); } } get directUploadUrl() { @@ -905,7 +979,11 @@ class AttachmentUpload { addEventListener("trix-attachment-add", (event => { const {attachment: attachment, target: target} = event; if (attachment.file) { - const upload = new AttachmentUpload(attachment, target); - upload.start(); + const upload = new AttachmentUpload(attachment, target, attachment.file); + const onProgress = event => attachment.setUploadProgress(event.detail.progress); + target.addEventListener("direct-upload:progress", onProgress); + upload.start().then((attributes => attachment.setAttributes(attributes))).catch((error => alert(error))).finally((() => target.removeEventListener("direct-upload:progress", onProgress))); } })); + +export { AttachmentUpload }; diff --git a/actiontext/app/assets/javascripts/actiontext.js b/actiontext/app/assets/javascripts/actiontext.js index e61f03ce103fa..8de57d54fe345 100644 --- a/actiontext/app/assets/javascripts/actiontext.js +++ b/actiontext/app/assets/javascripts/actiontext.js @@ -1,6 +1,7 @@ -(function(factory) { - typeof define === "function" && define.amd ? define(factory) : factory(); -})((function() { +(function(global, factory) { + typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, + factory(global.ActionText = {})); +})(this, (function(exports) { "use strict"; var sparkMd5 = { exports: {} @@ -661,7 +662,7 @@ })); } uploadRequestDidProgress(event) { - const progress = event.loaded / event.total * 100; + const progress = event.loaded / event.total * 90; if (progress) { this.dispatch("progress", { progress: progress @@ -696,6 +697,42 @@ xhr: xhr }); xhr.upload.addEventListener("progress", (event => this.uploadRequestDidProgress(event))); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); + } + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } } } const inputSelector = "input[type=file][data-direct-upload-url]:not([disabled])"; @@ -819,31 +856,69 @@ } setTimeout(autostart, 1); class AttachmentUpload { - constructor(attachment, element) { + constructor(attachment, element, file = attachment.file) { this.attachment = attachment; this.element = element; - this.directUpload = new DirectUpload(attachment.file, this.directUploadUrl, this); + this.directUpload = new DirectUpload(file, this.directUploadUrl, this); + this.file = file; } start() { - this.directUpload.create(this.directUploadDidComplete.bind(this)); - this.dispatch("start"); + return new Promise(((resolve, reject) => { + this.directUpload.create(((error, attributes) => this.directUploadDidComplete(error, attributes, resolve, reject))); + this.dispatch("start"); + })); } directUploadWillStoreFileWithXHR(xhr) { xhr.upload.addEventListener("progress", (event => { - const progress = event.loaded / event.total * 100; - this.attachment.setUploadProgress(progress); + const progress = event.loaded / event.total * 90; if (progress) { this.dispatch("progress", { progress: progress }); } })); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); + } + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } } - directUploadDidComplete(error, attributes) { + directUploadDidComplete(error, attributes, resolve, reject) { if (error) { - this.dispatchError(error); + this.dispatchError(error, reject); } else { - this.attachment.setAttributes({ + resolve({ sgid: attributes.attachable_sgid, url: this.createBlobUrl(attributes.signed_id, attributes.filename) }); @@ -859,12 +934,12 @@ detail: detail }); } - dispatchError(error) { + dispatchError(error, reject) { const event = this.dispatch("error", { error: error }); if (!event.defaultPrevented) { - alert(error); + reject(error); } } get directUploadUrl() { @@ -877,8 +952,14 @@ addEventListener("trix-attachment-add", (event => { const {attachment: attachment, target: target} = event; if (attachment.file) { - const upload = new AttachmentUpload(attachment, target); - upload.start(); + const upload = new AttachmentUpload(attachment, target, attachment.file); + const onProgress = event => attachment.setUploadProgress(event.detail.progress); + target.addEventListener("direct-upload:progress", onProgress); + upload.start().then((attributes => attachment.setAttributes(attributes))).catch((error => alert(error))).finally((() => target.removeEventListener("direct-upload:progress", onProgress))); } })); + exports.AttachmentUpload = AttachmentUpload; + Object.defineProperty(exports, "__esModule", { + value: true + }); })); diff --git a/actiontext/app/assets/javascripts/trix.js b/actiontext/app/assets/javascripts/trix.js deleted file mode 100644 index 460ff32326ddc..0000000000000 --- a/actiontext/app/assets/javascripts/trix.js +++ /dev/null @@ -1,13743 +0,0 @@ -/* -Trix 2.1.12 -Copyright © 2024 37signals, LLC - */ -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : - typeof define === 'function' && define.amd ? define(factory) : - (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Trix = factory()); -})(this, (function () { 'use strict'; - - var name = "trix"; - var version = "2.1.12"; - var description = "A rich text editor for everyday writing"; - var main = "dist/trix.umd.min.js"; - var module = "dist/trix.esm.min.js"; - var style = "dist/trix.css"; - var files = [ - "dist/*.css", - "dist/*.js", - "dist/*.map", - "src/{inspector,trix}/*.js" - ]; - var repository = { - type: "git", - url: "git+https://github.com/basecamp/trix.git" - }; - var keywords = [ - "rich text", - "wysiwyg", - "editor" - ]; - var author = "37signals, LLC"; - var license = "MIT"; - var bugs = { - url: "https://github.com/basecamp/trix/issues" - }; - var homepage = "https://trix-editor.org/"; - var devDependencies = { - "@babel/core": "^7.16.0", - "@babel/preset-env": "^7.16.4", - "@rollup/plugin-babel": "^5.3.0", - "@rollup/plugin-commonjs": "^22.0.2", - "@rollup/plugin-json": "^4.1.0", - "@rollup/plugin-node-resolve": "^13.3.0", - "@web/dev-server": "^0.1.34", - "babel-eslint": "^10.1.0", - chokidar: "^4.0.2", - concurrently: "^7.4.0", - eslint: "^7.32.0", - esm: "^3.2.25", - karma: "6.4.1", - "karma-chrome-launcher": "3.2.0", - "karma-qunit": "^4.1.2", - "karma-sauce-launcher": "^4.3.6", - qunit: "2.19.1", - rangy: "^1.3.0", - rollup: "^2.56.3", - "rollup-plugin-includepaths": "^0.2.4", - "rollup-plugin-terser": "^7.0.2", - sass: "^1.83.0", - svgo: "^2.8.0", - webdriverio: "^7.19.5" - }; - var resolutions = { - webdriverio: "^7.19.5" - }; - var scripts = { - "build-css": "bin/sass-build assets/trix.scss dist/trix.css", - "build-js": "rollup -c", - "build-assets": "cp -f assets/*.html dist/", - build: "yarn run build-js && yarn run build-css && yarn run build-assets", - watch: "rollup -c -w", - lint: "eslint .", - pretest: "yarn run lint && yarn run build", - test: "karma start", - prerelease: "yarn version && yarn test", - release: "npm adduser && npm publish", - postrelease: "git push && git push --tags", - dev: "web-dev-server --app-index index.html --root-dir dist --node-resolve --open", - start: "yarn build-assets && concurrently --kill-others --names js,css,dev-server 'yarn watch' 'yarn build-css --watch' 'yarn dev'" - }; - var dependencies = { - dompurify: "^3.2.3" - }; - var _package = { - name: name, - version: version, - description: description, - main: main, - module: module, - style: style, - files: files, - repository: repository, - keywords: keywords, - author: author, - license: license, - bugs: bugs, - homepage: homepage, - devDependencies: devDependencies, - resolutions: resolutions, - scripts: scripts, - dependencies: dependencies - }; - - const attachmentSelector = "[data-trix-attachment]"; - const attachments = { - preview: { - presentation: "gallery", - caption: { - name: true, - size: true - } - }, - file: { - caption: { - size: true - } - } - }; - - const attributes = { - default: { - tagName: "div", - parse: false - }, - quote: { - tagName: "blockquote", - nestable: true - }, - heading1: { - tagName: "h1", - terminal: true, - breakOnReturn: true, - group: false - }, - code: { - tagName: "pre", - terminal: true, - htmlAttributes: ["language"], - text: { - plaintext: true - } - }, - bulletList: { - tagName: "ul", - parse: false - }, - bullet: { - tagName: "li", - listAttribute: "bulletList", - group: false, - nestable: true, - test(element) { - return tagName$1(element.parentNode) === attributes[this.listAttribute].tagName; - } - }, - numberList: { - tagName: "ol", - parse: false - }, - number: { - tagName: "li", - listAttribute: "numberList", - group: false, - nestable: true, - test(element) { - return tagName$1(element.parentNode) === attributes[this.listAttribute].tagName; - } - }, - attachmentGallery: { - tagName: "div", - exclusive: true, - terminal: true, - parse: false, - group: false - } - }; - const tagName$1 = element => { - var _element$tagName; - return element === null || element === void 0 || (_element$tagName = element.tagName) === null || _element$tagName === void 0 ? void 0 : _element$tagName.toLowerCase(); - }; - - const androidVersionMatch = navigator.userAgent.match(/android\s([0-9]+.*Chrome)/i); - const androidVersion = androidVersionMatch && parseInt(androidVersionMatch[1]); - var browser$1 = { - // Android emits composition events when moving the cursor through existing text - // Introduced in Chrome 65: https://bugs.chromium.org/p/chromium/issues/detail?id=764439#c9 - composesExistingText: /Android.*Chrome/.test(navigator.userAgent), - // Android 13, especially on Samsung keyboards, emits extra compositionend and beforeinput events - // that can make the input handler lose the current selection or enter an infinite input -> render -> input - // loop. - recentAndroid: androidVersion && androidVersion > 12, - samsungAndroid: androidVersion && navigator.userAgent.match(/Android.*SM-/), - // IE 11 activates resizing handles on editable elements that have "layout" - forcesObjectResizing: /Trident.*rv:11/.test(navigator.userAgent), - // https://www.w3.org/TR/input-events-1/ + https://www.w3.org/TR/input-events-2/ - supportsInputEvents: typeof InputEvent !== "undefined" && ["data", "getTargetRanges", "inputType"].every(prop => prop in InputEvent.prototype) - }; - - var css$3 = { - attachment: "attachment", - attachmentCaption: "attachment__caption", - attachmentCaptionEditor: "attachment__caption-editor", - attachmentMetadata: "attachment__metadata", - attachmentMetadataContainer: "attachment__metadata-container", - attachmentName: "attachment__name", - attachmentProgress: "attachment__progress", - attachmentSize: "attachment__size", - attachmentToolbar: "attachment__toolbar", - attachmentGallery: "attachment-gallery" - }; - - var dompurify = { - ADD_ATTR: ["language"], - SAFE_FOR_XML: false, - RETURN_DOM: true - }; - - var lang$1 = { - attachFiles: "Attach Files", - bold: "Bold", - bullets: "Bullets", - byte: "Byte", - bytes: "Bytes", - captionPlaceholder: "Add a caption…", - code: "Code", - heading1: "Heading", - indent: "Increase Level", - italic: "Italic", - link: "Link", - numbers: "Numbers", - outdent: "Decrease Level", - quote: "Quote", - redo: "Redo", - remove: "Remove", - strike: "Strikethrough", - undo: "Undo", - unlink: "Unlink", - url: "URL", - urlPlaceholder: "Enter a URL…", - GB: "GB", - KB: "KB", - MB: "MB", - PB: "PB", - TB: "TB" - }; - - /* eslint-disable - no-case-declarations, - */ - const sizes = [lang$1.bytes, lang$1.KB, lang$1.MB, lang$1.GB, lang$1.TB, lang$1.PB]; - var file_size_formatting = { - prefix: "IEC", - precision: 2, - formatter(number) { - switch (number) { - case 0: - return "0 ".concat(lang$1.bytes); - case 1: - return "1 ".concat(lang$1.byte); - default: - let base; - if (this.prefix === "SI") { - base = 1000; - } else if (this.prefix === "IEC") { - base = 1024; - } - const exp = Math.floor(Math.log(number) / Math.log(base)); - const humanSize = number / Math.pow(base, exp); - const string = humanSize.toFixed(this.precision); - const withoutInsignificantZeros = string.replace(/0*$/, "").replace(/\.$/, ""); - return "".concat(withoutInsignificantZeros, " ").concat(sizes[exp]); - } - } - }; - - const ZERO_WIDTH_SPACE = "\uFEFF"; - const NON_BREAKING_SPACE = "\u00A0"; - const OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"; - - const extend = function (properties) { - for (const key in properties) { - const value = properties[key]; - this[key] = value; - } - return this; - }; - - const html$2 = document.documentElement; - const match = html$2.matches; - const handleEvent = function (eventName) { - let { - onElement, - matchingSelector, - withCallback, - inPhase, - preventDefault, - times - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const element = onElement ? onElement : html$2; - const selector = matchingSelector; - const useCapture = inPhase === "capturing"; - const handler = function (event) { - if (times != null && --times === 0) { - handler.destroy(); - } - const target = findClosestElementFromNode(event.target, { - matchingSelector: selector - }); - if (target != null) { - withCallback === null || withCallback === void 0 || withCallback.call(target, event, target); - if (preventDefault) { - event.preventDefault(); - } - } - }; - handler.destroy = () => element.removeEventListener(eventName, handler, useCapture); - element.addEventListener(eventName, handler, useCapture); - return handler; - }; - const handleEventOnce = function (eventName) { - let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - options.times = 1; - return handleEvent(eventName, options); - }; - const triggerEvent = function (eventName) { - let { - onElement, - bubbles, - cancelable, - attributes - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const element = onElement != null ? onElement : html$2; - bubbles = bubbles !== false; - cancelable = cancelable !== false; - const event = document.createEvent("Events"); - event.initEvent(eventName, bubbles, cancelable); - if (attributes != null) { - extend.call(event, attributes); - } - return element.dispatchEvent(event); - }; - const elementMatchesSelector = function (element, selector) { - if ((element === null || element === void 0 ? void 0 : element.nodeType) === 1) { - return match.call(element, selector); - } - }; - const findClosestElementFromNode = function (node) { - let { - matchingSelector, - untilNode - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - while (node && node.nodeType !== Node.ELEMENT_NODE) { - node = node.parentNode; - } - if (node == null) { - return; - } - if (matchingSelector != null) { - if (node.closest && untilNode == null) { - return node.closest(matchingSelector); - } else { - while (node && node !== untilNode) { - if (elementMatchesSelector(node, matchingSelector)) { - return node; - } - node = node.parentNode; - } - } - } else { - return node; - } - }; - const findInnerElement = function (element) { - while ((_element = element) !== null && _element !== void 0 && _element.firstElementChild) { - var _element; - element = element.firstElementChild; - } - return element; - }; - const innerElementIsActive = element => document.activeElement !== element && elementContainsNode(element, document.activeElement); - const elementContainsNode = function (element, node) { - if (!element || !node) { - return; - } - while (node) { - if (node === element) { - return true; - } - node = node.parentNode; - } - }; - const findNodeFromContainerAndOffset = function (container, offset) { - if (!container) { - return; - } - if (container.nodeType === Node.TEXT_NODE) { - return container; - } else if (offset === 0) { - return container.firstChild != null ? container.firstChild : container; - } else { - return container.childNodes.item(offset - 1); - } - }; - const findElementFromContainerAndOffset = function (container, offset) { - const node = findNodeFromContainerAndOffset(container, offset); - return findClosestElementFromNode(node); - }; - const findChildIndexOfNode = function (node) { - var _node; - if (!((_node = node) !== null && _node !== void 0 && _node.parentNode)) { - return; - } - let childIndex = 0; - node = node.previousSibling; - while (node) { - childIndex++; - node = node.previousSibling; - } - return childIndex; - }; - const removeNode = node => { - var _node$parentNode; - return node === null || node === void 0 || (_node$parentNode = node.parentNode) === null || _node$parentNode === void 0 ? void 0 : _node$parentNode.removeChild(node); - }; - const walkTree = function (tree) { - let { - onlyNodesOfType, - usingFilter, - expandEntityReferences - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const whatToShow = (() => { - switch (onlyNodesOfType) { - case "element": - return NodeFilter.SHOW_ELEMENT; - case "text": - return NodeFilter.SHOW_TEXT; - case "comment": - return NodeFilter.SHOW_COMMENT; - default: - return NodeFilter.SHOW_ALL; - } - })(); - return document.createTreeWalker(tree, whatToShow, usingFilter != null ? usingFilter : null, expandEntityReferences === true); - }; - const tagName = element => { - var _element$tagName; - return element === null || element === void 0 || (_element$tagName = element.tagName) === null || _element$tagName === void 0 ? void 0 : _element$tagName.toLowerCase(); - }; - const makeElement = function (tag) { - let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let key, value; - if (typeof tag === "object") { - options = tag; - tag = options.tagName; - } else { - options = { - attributes: options - }; - } - const element = document.createElement(tag); - if (options.editable != null) { - if (options.attributes == null) { - options.attributes = {}; - } - options.attributes.contenteditable = options.editable; - } - if (options.attributes) { - for (key in options.attributes) { - value = options.attributes[key]; - element.setAttribute(key, value); - } - } - if (options.style) { - for (key in options.style) { - value = options.style[key]; - element.style[key] = value; - } - } - if (options.data) { - for (key in options.data) { - value = options.data[key]; - element.dataset[key] = value; - } - } - if (options.className) { - options.className.split(" ").forEach(className => { - element.classList.add(className); - }); - } - if (options.textContent) { - element.textContent = options.textContent; - } - if (options.childNodes) { - [].concat(options.childNodes).forEach(childNode => { - element.appendChild(childNode); - }); - } - return element; - }; - let blockTagNames = undefined; - const getBlockTagNames = function () { - if (blockTagNames != null) { - return blockTagNames; - } - blockTagNames = []; - for (const key in attributes) { - const attributes$1 = attributes[key]; - if (attributes$1.tagName) { - blockTagNames.push(attributes$1.tagName); - } - } - return blockTagNames; - }; - const nodeIsBlockContainer = node => nodeIsBlockStartComment(node === null || node === void 0 ? void 0 : node.firstChild); - const nodeProbablyIsBlockContainer = function (node) { - return getBlockTagNames().includes(tagName(node)) && !getBlockTagNames().includes(tagName(node.firstChild)); - }; - const nodeIsBlockStart = function (node) { - let { - strict - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { - strict: true - }; - if (strict) { - return nodeIsBlockStartComment(node); - } else { - return nodeIsBlockStartComment(node) || !nodeIsBlockStartComment(node.firstChild) && nodeProbablyIsBlockContainer(node); - } - }; - const nodeIsBlockStartComment = node => nodeIsCommentNode(node) && (node === null || node === void 0 ? void 0 : node.data) === "block"; - const nodeIsCommentNode = node => (node === null || node === void 0 ? void 0 : node.nodeType) === Node.COMMENT_NODE; - const nodeIsCursorTarget = function (node) { - let { - name - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - if (!node) { - return; - } - if (nodeIsTextNode(node)) { - if (node.data === ZERO_WIDTH_SPACE) { - if (name) { - return node.parentNode.dataset.trixCursorTarget === name; - } else { - return true; - } - } - } else { - return nodeIsCursorTarget(node.firstChild); - } - }; - const nodeIsAttachmentElement = node => elementMatchesSelector(node, attachmentSelector); - const nodeIsEmptyTextNode = node => nodeIsTextNode(node) && (node === null || node === void 0 ? void 0 : node.data) === ""; - const nodeIsTextNode = node => (node === null || node === void 0 ? void 0 : node.nodeType) === Node.TEXT_NODE; - - const input = { - level2Enabled: true, - getLevel() { - if (this.level2Enabled && browser$1.supportsInputEvents) { - return 2; - } else { - return 0; - } - }, - pickFiles(callback) { - const input = makeElement("input", { - type: "file", - multiple: true, - hidden: true, - id: this.fileInputId - }); - input.addEventListener("change", () => { - callback(input.files); - removeNode(input); - }); - removeNode(document.getElementById(this.fileInputId)); - document.body.appendChild(input); - input.click(); - } - }; - - var key_names = { - 8: "backspace", - 9: "tab", - 13: "return", - 27: "escape", - 37: "left", - 39: "right", - 46: "delete", - 68: "d", - 72: "h", - 79: "o" - }; - - var parser = { - removeBlankTableCells: false, - tableCellSeparator: " | ", - tableRowSeparator: "\n" - }; - - var text_attributes = { - bold: { - tagName: "strong", - inheritable: true, - parser(element) { - const style = window.getComputedStyle(element); - return style.fontWeight === "bold" || style.fontWeight >= 600; - } - }, - italic: { - tagName: "em", - inheritable: true, - parser(element) { - const style = window.getComputedStyle(element); - return style.fontStyle === "italic"; - } - }, - href: { - groupTagName: "a", - parser(element) { - const matchingSelector = "a:not(".concat(attachmentSelector, ")"); - const link = element.closest(matchingSelector); - if (link) { - return link.getAttribute("href"); - } - } - }, - strike: { - tagName: "del", - inheritable: true - }, - frozen: { - style: { - backgroundColor: "highlight" - } - } - }; - - var toolbar = { - getDefaultHTML() { - return "
\n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n\n \n \n \n \n
\n\n
\n
\n \n
\n
"); - } - }; - - const undo = { - interval: 5000 - }; - - var config = /*#__PURE__*/Object.freeze({ - __proto__: null, - attachments: attachments, - blockAttributes: attributes, - browser: browser$1, - css: css$3, - dompurify: dompurify, - fileSize: file_size_formatting, - input: input, - keyNames: key_names, - lang: lang$1, - parser: parser, - textAttributes: text_attributes, - toolbar: toolbar, - undo: undo - }); - - class BasicObject { - static proxyMethod(expression) { - const { - name, - toMethod, - toProperty, - optional - } = parseProxyMethodExpression(expression); - this.prototype[name] = function () { - let subject; - let object; - if (toMethod) { - if (optional) { - var _this$toMethod; - object = (_this$toMethod = this[toMethod]) === null || _this$toMethod === void 0 ? void 0 : _this$toMethod.call(this); - } else { - object = this[toMethod](); - } - } else if (toProperty) { - object = this[toProperty]; - } - if (optional) { - var _object; - subject = (_object = object) === null || _object === void 0 ? void 0 : _object[name]; - if (subject) { - return apply$1.call(subject, object, arguments); - } - } else { - subject = object[name]; - return apply$1.call(subject, object, arguments); - } - }; - } - } - const parseProxyMethodExpression = function (expression) { - const match = expression.match(proxyMethodExpressionPattern); - if (!match) { - throw new Error("can't parse @proxyMethod expression: ".concat(expression)); - } - const args = { - name: match[4] - }; - if (match[2] != null) { - args.toMethod = match[1]; - } else { - args.toProperty = match[1]; - } - if (match[3] != null) { - args.optional = true; - } - return args; - }; - const { - apply: apply$1 - } = Function.prototype; - const proxyMethodExpressionPattern = new RegExp("\ -^\ -(.+?)\ -(\\(\\))?\ -(\\?)?\ -\\.\ -(.+?)\ -$\ -"); - - var _Array$from, _$codePointAt$1, _$1, _String$fromCodePoint; - class UTF16String extends BasicObject { - static box() { - let value = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - if (value instanceof this) { - return value; - } else { - return this.fromUCS2String(value === null || value === void 0 ? void 0 : value.toString()); - } - } - static fromUCS2String(ucs2String) { - return new this(ucs2String, ucs2decode(ucs2String)); - } - static fromCodepoints(codepoints) { - return new this(ucs2encode(codepoints), codepoints); - } - constructor(ucs2String, codepoints) { - super(...arguments); - this.ucs2String = ucs2String; - this.codepoints = codepoints; - this.length = this.codepoints.length; - this.ucs2Length = this.ucs2String.length; - } - offsetToUCS2Offset(offset) { - return ucs2encode(this.codepoints.slice(0, Math.max(0, offset))).length; - } - offsetFromUCS2Offset(ucs2Offset) { - return ucs2decode(this.ucs2String.slice(0, Math.max(0, ucs2Offset))).length; - } - slice() { - return this.constructor.fromCodepoints(this.codepoints.slice(...arguments)); - } - charAt(offset) { - return this.slice(offset, offset + 1); - } - isEqualTo(value) { - return this.constructor.box(value).ucs2String === this.ucs2String; - } - toJSON() { - return this.ucs2String; - } - getCacheKey() { - return this.ucs2String; - } - toString() { - return this.ucs2String; - } - } - const hasArrayFrom = ((_Array$from = Array.from) === null || _Array$from === void 0 ? void 0 : _Array$from.call(Array, "\ud83d\udc7c").length) === 1; - const hasStringCodePointAt$1 = ((_$codePointAt$1 = (_$1 = " ").codePointAt) === null || _$codePointAt$1 === void 0 ? void 0 : _$codePointAt$1.call(_$1, 0)) != null; - const hasStringFromCodePoint = ((_String$fromCodePoint = String.fromCodePoint) === null || _String$fromCodePoint === void 0 ? void 0 : _String$fromCodePoint.call(String, 32, 128124)) === " \ud83d\udc7c"; - - // UCS-2 conversion helpers ported from Mathias Bynens' Punycode.js: - // https://github.com/bestiejs/punycode.js#punycodeucs2 - - let ucs2decode, ucs2encode; - - // Creates an array containing the numeric code points of each Unicode - // character in the string. While JavaScript uses UCS-2 internally, - // this function will convert a pair of surrogate halves (each of which - // UCS-2 exposes as separate characters) into a single code point, - // matching UTF-16. - if (hasArrayFrom && hasStringCodePointAt$1) { - ucs2decode = string => Array.from(string).map(char => char.codePointAt(0)); - } else { - ucs2decode = function (string) { - const output = []; - let counter = 0; - const { - length - } = string; - while (counter < length) { - let value = string.charCodeAt(counter++); - if (0xd800 <= value && value <= 0xdbff && counter < length) { - // high surrogate, and there is a next character - const extra = string.charCodeAt(counter++); - if ((extra & 0xfc00) === 0xdc00) { - // low surrogate - value = ((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000; - } else { - // unmatched surrogate; only append this code unit, in case the - // next code unit is the high surrogate of a surrogate pair - counter--; - } - } - output.push(value); - } - return output; - }; - } - - // Creates a string based on an array of numeric code points. - if (hasStringFromCodePoint) { - ucs2encode = array => String.fromCodePoint(...Array.from(array || [])); - } else { - ucs2encode = function (array) { - const characters = (() => { - const result = []; - Array.from(array).forEach(value => { - let output = ""; - if (value > 0xffff) { - value -= 0x10000; - output += String.fromCharCode(value >>> 10 & 0x3ff | 0xd800); - value = 0xdc00 | value & 0x3ff; - } - result.push(output + String.fromCharCode(value)); - }); - return result; - })(); - return characters.join(""); - }; - } - - let id$2 = 0; - class TrixObject extends BasicObject { - static fromJSONString(jsonString) { - return this.fromJSON(JSON.parse(jsonString)); - } - constructor() { - super(...arguments); - this.id = ++id$2; - } - hasSameConstructorAs(object) { - return this.constructor === (object === null || object === void 0 ? void 0 : object.constructor); - } - isEqualTo(object) { - return this === object; - } - inspect() { - const parts = []; - const contents = this.contentsForInspection() || {}; - for (const key in contents) { - const value = contents[key]; - parts.push("".concat(key, "=").concat(value)); - } - return "#<".concat(this.constructor.name, ":").concat(this.id).concat(parts.length ? " ".concat(parts.join(", ")) : "", ">"); - } - contentsForInspection() {} - toJSONString() { - return JSON.stringify(this); - } - toUTF16String() { - return UTF16String.box(this); - } - getCacheKey() { - return this.id.toString(); - } - } - - /* eslint-disable - id-length, - */ - const arraysAreEqual = function () { - let a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; - if (a.length !== b.length) { - return false; - } - for (let index = 0; index < a.length; index++) { - const value = a[index]; - if (value !== b[index]) { - return false; - } - } - return true; - }; - const arrayStartsWith = function () { - let a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; - return arraysAreEqual(a.slice(0, b.length), b); - }; - const spliceArray = function (array) { - const result = array.slice(0); - for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { - args[_key - 1] = arguments[_key]; - } - result.splice(...args); - return result; - }; - const summarizeArrayChange = function () { - let oldArray = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let newArray = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; - const added = []; - const removed = []; - const existingValues = new Set(); - oldArray.forEach(value => { - existingValues.add(value); - }); - const currentValues = new Set(); - newArray.forEach(value => { - currentValues.add(value); - if (!existingValues.has(value)) { - added.push(value); - } - }); - oldArray.forEach(value => { - if (!currentValues.has(value)) { - removed.push(value); - } - }); - return { - added, - removed - }; - }; - - // https://github.com/mathiasbynens/unicode-2.1.8/blob/master/Bidi_Class/Right_To_Left/regex.js - const RTL_PATTERN = /[\u05BE\u05C0\u05C3\u05D0-\u05EA\u05F0-\u05F4\u061B\u061F\u0621-\u063A\u0640-\u064A\u066D\u0671-\u06B7\u06BA-\u06BE\u06C0-\u06CE\u06D0-\u06D5\u06E5\u06E6\u200F\u202B\u202E\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE72\uFE74\uFE76-\uFEFC]/; - const getDirection = function () { - const input = makeElement("input", { - dir: "auto", - name: "x", - dirName: "x.dir" - }); - const textArea = makeElement("textarea", { - dir: "auto", - name: "y", - dirName: "y.dir" - }); - const form = makeElement("form"); - form.appendChild(input); - form.appendChild(textArea); - const supportsDirName = function () { - try { - return new FormData(form).has(textArea.dirName); - } catch (error) { - return false; - } - }(); - const supportsDirSelector = function () { - try { - return input.matches(":dir(ltr),:dir(rtl)"); - } catch (error) { - return false; - } - }(); - if (supportsDirName) { - return function (string) { - textArea.value = string; - return new FormData(form).get(textArea.dirName); - }; - } else if (supportsDirSelector) { - return function (string) { - input.value = string; - if (input.matches(":dir(rtl)")) { - return "rtl"; - } else { - return "ltr"; - } - }; - } else { - return function (string) { - const char = string.trim().charAt(0); - if (RTL_PATTERN.test(char)) { - return "rtl"; - } else { - return "ltr"; - } - }; - } - }(); - - let allAttributeNames = null; - let blockAttributeNames = null; - let textAttributeNames = null; - let listAttributeNames = null; - const getAllAttributeNames = () => { - if (!allAttributeNames) { - allAttributeNames = getTextAttributeNames().concat(getBlockAttributeNames()); - } - return allAttributeNames; - }; - const getBlockConfig = attributeName => attributes[attributeName]; - const getBlockAttributeNames = () => { - if (!blockAttributeNames) { - blockAttributeNames = Object.keys(attributes); - } - return blockAttributeNames; - }; - const getTextConfig = attributeName => text_attributes[attributeName]; - const getTextAttributeNames = () => { - if (!textAttributeNames) { - textAttributeNames = Object.keys(text_attributes); - } - return textAttributeNames; - }; - const getListAttributeNames = () => { - if (!listAttributeNames) { - listAttributeNames = []; - for (const key in attributes) { - const { - listAttribute - } = attributes[key]; - if (listAttribute != null) { - listAttributeNames.push(listAttribute); - } - } - } - return listAttributeNames; - }; - - /* eslint-disable - */ - const installDefaultCSSForTagName = function (tagName, defaultCSS) { - const styleElement = insertStyleElementForTagName(tagName); - styleElement.textContent = defaultCSS.replace(/%t/g, tagName); - }; - const insertStyleElementForTagName = function (tagName) { - const element = document.createElement("style"); - element.setAttribute("type", "text/css"); - element.setAttribute("data-tag-name", tagName.toLowerCase()); - const nonce = getCSPNonce(); - if (nonce) { - element.setAttribute("nonce", nonce); - } - document.head.insertBefore(element, document.head.firstChild); - return element; - }; - const getCSPNonce = function () { - const element = getMetaElement("trix-csp-nonce") || getMetaElement("csp-nonce"); - if (element) { - const { - nonce, - content - } = element; - return nonce == "" ? content : nonce; - } - }; - const getMetaElement = name => document.head.querySelector("meta[name=".concat(name, "]")); - - const testTransferData = { - "application/x-trix-feature-detection": "test" - }; - const dataTransferIsPlainText = function (dataTransfer) { - const text = dataTransfer.getData("text/plain"); - const html = dataTransfer.getData("text/html"); - if (text && html) { - const { - body - } = new DOMParser().parseFromString(html, "text/html"); - if (body.textContent === text) { - return !body.querySelector("*"); - } - } else { - return text === null || text === void 0 ? void 0 : text.length; - } - }; - const dataTransferIsMsOfficePaste = _ref => { - let { - dataTransfer - } = _ref; - return dataTransfer.types.includes("Files") && dataTransfer.types.includes("text/html") && dataTransfer.getData("text/html").includes("urn:schemas-microsoft-com:office:office"); - }; - const dataTransferIsWritable = function (dataTransfer) { - if (!(dataTransfer !== null && dataTransfer !== void 0 && dataTransfer.setData)) return false; - for (const key in testTransferData) { - const value = testTransferData[key]; - try { - dataTransfer.setData(key, value); - if (!dataTransfer.getData(key) === value) return false; - } catch (error) { - return false; - } - } - return true; - }; - const keyEventIsKeyboardCommand = function () { - if (/Mac|^iP/.test(navigator.platform)) { - return event => event.metaKey; - } else { - return event => event.ctrlKey; - } - }(); - function shouldRenderInmmediatelyToDealWithIOSDictation(inputEvent) { - if (/iPhone|iPad/.test(navigator.userAgent)) { - // Handle garbled content and duplicated newlines when using dictation on iOS 18+. Upon dictation completion, iOS sends - // the list of insertText / insertParagraph events in a quick sequence. If we don't render - // the editor synchronously, the internal range fails to update and results in garbled content or duplicated newlines. - // - // This workaround is necessary because iOS doesn't send composing events as expected while dictating: - // https://bugs.webkit.org/show_bug.cgi?id=261764 - return !inputEvent.inputType || inputEvent.inputType === "insertParagraph"; - } else { - return false; - } - } - - const defer = fn => setTimeout(fn, 1); - - /* eslint-disable - id-length, - */ - const copyObject = function () { - let object = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - const result = {}; - for (const key in object) { - const value = object[key]; - result[key] = value; - } - return result; - }; - const objectsAreEqual = function () { - let a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - let b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - if (Object.keys(a).length !== Object.keys(b).length) { - return false; - } - for (const key in a) { - const value = a[key]; - if (value !== b[key]) { - return false; - } - } - return true; - }; - - const normalizeRange = function (range) { - if (range == null) return; - if (!Array.isArray(range)) { - range = [range, range]; - } - return [copyValue(range[0]), copyValue(range[1] != null ? range[1] : range[0])]; - }; - const rangeIsCollapsed = function (range) { - if (range == null) return; - const [start, end] = normalizeRange(range); - return rangeValuesAreEqual(start, end); - }; - const rangesAreEqual = function (leftRange, rightRange) { - if (leftRange == null || rightRange == null) return; - const [leftStart, leftEnd] = normalizeRange(leftRange); - const [rightStart, rightEnd] = normalizeRange(rightRange); - return rangeValuesAreEqual(leftStart, rightStart) && rangeValuesAreEqual(leftEnd, rightEnd); - }; - const copyValue = function (value) { - if (typeof value === "number") { - return value; - } else { - return copyObject(value); - } - }; - const rangeValuesAreEqual = function (left, right) { - if (typeof left === "number") { - return left === right; - } else { - return objectsAreEqual(left, right); - } - }; - - class SelectionChangeObserver extends BasicObject { - constructor() { - super(...arguments); - this.update = this.update.bind(this); - this.selectionManagers = []; - } - start() { - if (!this.started) { - this.started = true; - document.addEventListener("selectionchange", this.update, true); - } - } - stop() { - if (this.started) { - this.started = false; - return document.removeEventListener("selectionchange", this.update, true); - } - } - registerSelectionManager(selectionManager) { - if (!this.selectionManagers.includes(selectionManager)) { - this.selectionManagers.push(selectionManager); - return this.start(); - } - } - unregisterSelectionManager(selectionManager) { - this.selectionManagers = this.selectionManagers.filter(sm => sm !== selectionManager); - if (this.selectionManagers.length === 0) { - return this.stop(); - } - } - notifySelectionManagersOfSelectionChange() { - return this.selectionManagers.map(selectionManager => selectionManager.selectionDidChange()); - } - update() { - this.notifySelectionManagersOfSelectionChange(); - } - reset() { - this.update(); - } - } - const selectionChangeObserver = new SelectionChangeObserver(); - const getDOMSelection = function () { - const selection = window.getSelection(); - if (selection.rangeCount > 0) { - return selection; - } - }; - const getDOMRange = function () { - var _getDOMSelection; - const domRange = (_getDOMSelection = getDOMSelection()) === null || _getDOMSelection === void 0 ? void 0 : _getDOMSelection.getRangeAt(0); - if (domRange) { - if (!domRangeIsPrivate(domRange)) { - return domRange; - } - } - }; - const setDOMRange = function (domRange) { - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(domRange); - return selectionChangeObserver.update(); - }; - - // In Firefox, clicking certain elements changes the selection to a - // private element used to draw its UI. Attempting to access properties of those - // elements throws an error. - // https://bugzilla.mozilla.org/show_bug.cgi?id=208427 - const domRangeIsPrivate = domRange => nodeIsPrivate(domRange.startContainer) || nodeIsPrivate(domRange.endContainer); - const nodeIsPrivate = node => !Object.getPrototypeOf(node); - - /* eslint-disable - id-length, - no-useless-escape, - */ - const normalizeSpaces = string => string.replace(new RegExp("".concat(ZERO_WIDTH_SPACE), "g"), "").replace(new RegExp("".concat(NON_BREAKING_SPACE), "g"), " "); - const normalizeNewlines = string => string.replace(/\r\n?/g, "\n"); - const breakableWhitespacePattern = new RegExp("[^\\S".concat(NON_BREAKING_SPACE, "]")); - const squishBreakableWhitespace = string => string - // Replace all breakable whitespace characters with a space - .replace(new RegExp("".concat(breakableWhitespacePattern.source), "g"), " ") - // Replace two or more spaces with a single space - .replace(/\ {2,}/g, " "); - const summarizeStringChange = function (oldString, newString) { - let added, removed; - oldString = UTF16String.box(oldString); - newString = UTF16String.box(newString); - if (newString.length < oldString.length) { - [removed, added] = utf16StringDifferences(oldString, newString); - } else { - [added, removed] = utf16StringDifferences(newString, oldString); - } - return { - added, - removed - }; - }; - const utf16StringDifferences = function (a, b) { - if (a.isEqualTo(b)) { - return ["", ""]; - } - const diffA = utf16StringDifference(a, b); - const { - length - } = diffA.utf16String; - let diffB; - if (length) { - const { - offset - } = diffA; - const codepoints = a.codepoints.slice(0, offset).concat(a.codepoints.slice(offset + length)); - diffB = utf16StringDifference(b, UTF16String.fromCodepoints(codepoints)); - } else { - diffB = utf16StringDifference(b, a); - } - return [diffA.utf16String.toString(), diffB.utf16String.toString()]; - }; - const utf16StringDifference = function (a, b) { - let leftIndex = 0; - let rightIndexA = a.length; - let rightIndexB = b.length; - while (leftIndex < rightIndexA && a.charAt(leftIndex).isEqualTo(b.charAt(leftIndex))) { - leftIndex++; - } - while (rightIndexA > leftIndex + 1 && a.charAt(rightIndexA - 1).isEqualTo(b.charAt(rightIndexB - 1))) { - rightIndexA--; - rightIndexB--; - } - return { - utf16String: a.slice(leftIndex, rightIndexA), - offset: leftIndex - }; - }; - - class Hash extends TrixObject { - static fromCommonAttributesOfObjects() { - let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - if (!objects.length) { - return new this(); - } - let hash = box(objects[0]); - let keys = hash.getKeys(); - objects.slice(1).forEach(object => { - keys = hash.getKeysCommonToHash(box(object)); - hash = hash.slice(keys); - }); - return hash; - } - static box(values) { - return box(values); - } - constructor() { - let values = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - super(...arguments); - this.values = copy(values); - } - add(key, value) { - return this.merge(object(key, value)); - } - remove(key) { - return new Hash(copy(this.values, key)); - } - get(key) { - return this.values[key]; - } - has(key) { - return key in this.values; - } - merge(values) { - return new Hash(merge(this.values, unbox(values))); - } - slice(keys) { - const values = {}; - Array.from(keys).forEach(key => { - if (this.has(key)) { - values[key] = this.values[key]; - } - }); - return new Hash(values); - } - getKeys() { - return Object.keys(this.values); - } - getKeysCommonToHash(hash) { - hash = box(hash); - return this.getKeys().filter(key => this.values[key] === hash.values[key]); - } - isEqualTo(values) { - return arraysAreEqual(this.toArray(), box(values).toArray()); - } - isEmpty() { - return this.getKeys().length === 0; - } - toArray() { - if (!this.array) { - const result = []; - for (const key in this.values) { - const value = this.values[key]; - result.push(result.push(key, value)); - } - this.array = result.slice(0); - } - return this.array; - } - toObject() { - return copy(this.values); - } - toJSON() { - return this.toObject(); - } - contentsForInspection() { - return { - values: JSON.stringify(this.values) - }; - } - } - const object = function (key, value) { - const result = {}; - result[key] = value; - return result; - }; - const merge = function (object, values) { - const result = copy(object); - for (const key in values) { - const value = values[key]; - result[key] = value; - } - return result; - }; - const copy = function (object, keyToRemove) { - const result = {}; - const sortedKeys = Object.keys(object).sort(); - sortedKeys.forEach(key => { - if (key !== keyToRemove) { - result[key] = object[key]; - } - }); - return result; - }; - const box = function (object) { - if (object instanceof Hash) { - return object; - } else { - return new Hash(object); - } - }; - const unbox = function (object) { - if (object instanceof Hash) { - return object.values; - } else { - return object; - } - }; - - class ObjectGroup { - static groupObjects() { - let ungroupedObjects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let { - depth, - asTree - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let group; - if (asTree) { - if (depth == null) { - depth = 0; - } - } - const objects = []; - Array.from(ungroupedObjects).forEach(object => { - var _object$canBeGrouped2; - if (group) { - var _object$canBeGrouped, _group$canBeGroupedWi, _group; - if ((_object$canBeGrouped = object.canBeGrouped) !== null && _object$canBeGrouped !== void 0 && _object$canBeGrouped.call(object, depth) && (_group$canBeGroupedWi = (_group = group[group.length - 1]).canBeGroupedWith) !== null && _group$canBeGroupedWi !== void 0 && _group$canBeGroupedWi.call(_group, object, depth)) { - group.push(object); - return; - } else { - objects.push(new this(group, { - depth, - asTree - })); - group = null; - } - } - if ((_object$canBeGrouped2 = object.canBeGrouped) !== null && _object$canBeGrouped2 !== void 0 && _object$canBeGrouped2.call(object, depth)) { - group = [object]; - } else { - objects.push(object); - } - }); - if (group) { - objects.push(new this(group, { - depth, - asTree - })); - } - return objects; - } - constructor() { - let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let { - depth, - asTree - } = arguments.length > 1 ? arguments[1] : undefined; - this.objects = objects; - if (asTree) { - this.depth = depth; - this.objects = this.constructor.groupObjects(this.objects, { - asTree, - depth: this.depth + 1 - }); - } - } - getObjects() { - return this.objects; - } - getDepth() { - return this.depth; - } - getCacheKey() { - const keys = ["objectGroup"]; - Array.from(this.getObjects()).forEach(object => { - keys.push(object.getCacheKey()); - }); - return keys.join("/"); - } - } - - class ObjectMap extends BasicObject { - constructor() { - let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - this.objects = {}; - Array.from(objects).forEach(object => { - const hash = JSON.stringify(object); - if (this.objects[hash] == null) { - this.objects[hash] = object; - } - }); - } - find(object) { - const hash = JSON.stringify(object); - return this.objects[hash]; - } - } - - class ElementStore { - constructor(elements) { - this.reset(elements); - } - add(element) { - const key = getKey(element); - this.elements[key] = element; - } - remove(element) { - const key = getKey(element); - const value = this.elements[key]; - if (value) { - delete this.elements[key]; - return value; - } - } - reset() { - let elements = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - this.elements = {}; - Array.from(elements).forEach(element => { - this.add(element); - }); - return elements; - } - } - const getKey = element => element.dataset.trixStoreKey; - - class Operation extends BasicObject { - isPerforming() { - return this.performing === true; - } - hasPerformed() { - return this.performed === true; - } - hasSucceeded() { - return this.performed && this.succeeded; - } - hasFailed() { - return this.performed && !this.succeeded; - } - getPromise() { - if (!this.promise) { - this.promise = new Promise((resolve, reject) => { - this.performing = true; - return this.perform((succeeded, result) => { - this.succeeded = succeeded; - this.performing = false; - this.performed = true; - if (this.succeeded) { - resolve(result); - } else { - reject(result); - } - }); - }); - } - return this.promise; - } - perform(callback) { - return callback(false); - } - release() { - var _this$promise, _this$promise$cancel; - (_this$promise = this.promise) === null || _this$promise === void 0 || (_this$promise$cancel = _this$promise.cancel) === null || _this$promise$cancel === void 0 || _this$promise$cancel.call(_this$promise); - this.promise = null; - this.performing = null; - this.performed = null; - this.succeeded = null; - } - } - Operation.proxyMethod("getPromise().then"); - Operation.proxyMethod("getPromise().catch"); - - class ObjectView extends BasicObject { - constructor(object) { - let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - super(...arguments); - this.object = object; - this.options = options; - this.childViews = []; - this.rootView = this; - } - getNodes() { - if (!this.nodes) { - this.nodes = this.createNodes(); - } - return this.nodes.map(node => node.cloneNode(true)); - } - invalidate() { - var _this$parentView; - this.nodes = null; - this.childViews = []; - return (_this$parentView = this.parentView) === null || _this$parentView === void 0 ? void 0 : _this$parentView.invalidate(); - } - invalidateViewForObject(object) { - var _this$findViewForObje; - return (_this$findViewForObje = this.findViewForObject(object)) === null || _this$findViewForObje === void 0 ? void 0 : _this$findViewForObje.invalidate(); - } - findOrCreateCachedChildView(viewClass, object, options) { - let view = this.getCachedViewForObject(object); - if (view) { - this.recordChildView(view); - } else { - view = this.createChildView(...arguments); - this.cacheViewForObject(view, object); - } - return view; - } - createChildView(viewClass, object) { - let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; - if (object instanceof ObjectGroup) { - options.viewClass = viewClass; - viewClass = ObjectGroupView; - } - const view = new viewClass(object, options); - return this.recordChildView(view); - } - recordChildView(view) { - view.parentView = this; - view.rootView = this.rootView; - this.childViews.push(view); - return view; - } - getAllChildViews() { - let views = []; - this.childViews.forEach(childView => { - views.push(childView); - views = views.concat(childView.getAllChildViews()); - }); - return views; - } - findElement() { - return this.findElementForObject(this.object); - } - findElementForObject(object) { - const id = object === null || object === void 0 ? void 0 : object.id; - if (id) { - return this.rootView.element.querySelector("[data-trix-id='".concat(id, "']")); - } - } - findViewForObject(object) { - for (const view of this.getAllChildViews()) { - if (view.object === object) { - return view; - } - } - } - getViewCache() { - if (this.rootView === this) { - if (this.isViewCachingEnabled()) { - if (!this.viewCache) { - this.viewCache = {}; - } - return this.viewCache; - } - } else { - return this.rootView.getViewCache(); - } - } - isViewCachingEnabled() { - return this.shouldCacheViews !== false; - } - enableViewCaching() { - this.shouldCacheViews = true; - } - disableViewCaching() { - this.shouldCacheViews = false; - } - getCachedViewForObject(object) { - var _this$getViewCache; - return (_this$getViewCache = this.getViewCache()) === null || _this$getViewCache === void 0 ? void 0 : _this$getViewCache[object.getCacheKey()]; - } - cacheViewForObject(view, object) { - const cache = this.getViewCache(); - if (cache) { - cache[object.getCacheKey()] = view; - } - } - garbageCollectCachedViews() { - const cache = this.getViewCache(); - if (cache) { - const views = this.getAllChildViews().concat(this); - const objectKeys = views.map(view => view.object.getCacheKey()); - for (const key in cache) { - if (!objectKeys.includes(key)) { - delete cache[key]; - } - } - } - } - } - class ObjectGroupView extends ObjectView { - constructor() { - super(...arguments); - this.objectGroup = this.object; - this.viewClass = this.options.viewClass; - delete this.options.viewClass; - } - getChildViews() { - if (!this.childViews.length) { - Array.from(this.objectGroup.getObjects()).forEach(object => { - this.findOrCreateCachedChildView(this.viewClass, object, this.options); - }); - } - return this.childViews; - } - createNodes() { - const element = this.createContainerElement(); - this.getChildViews().forEach(view => { - Array.from(view.getNodes()).forEach(node => { - element.appendChild(node); - }); - }); - return [element]; - } - createContainerElement() { - let depth = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.objectGroup.getDepth(); - return this.getChildViews()[0].createContainerElement(depth); - } - } - - /*! @license DOMPurify 3.2.3 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.3/LICENSE */ - - const { - entries, - setPrototypeOf, - isFrozen, - getPrototypeOf, - getOwnPropertyDescriptor - } = Object; - let { - freeze, - seal, - create - } = Object; // eslint-disable-line import/no-mutable-exports - let { - apply, - construct - } = typeof Reflect !== 'undefined' && Reflect; - if (!freeze) { - freeze = function freeze(x) { - return x; - }; - } - if (!seal) { - seal = function seal(x) { - return x; - }; - } - if (!apply) { - apply = function apply(fun, thisValue, args) { - return fun.apply(thisValue, args); - }; - } - if (!construct) { - construct = function construct(Func, args) { - return new Func(...args); - }; - } - const arrayForEach = unapply(Array.prototype.forEach); - const arrayPop = unapply(Array.prototype.pop); - const arrayPush = unapply(Array.prototype.push); - const stringToLowerCase = unapply(String.prototype.toLowerCase); - const stringToString = unapply(String.prototype.toString); - const stringMatch = unapply(String.prototype.match); - const stringReplace = unapply(String.prototype.replace); - const stringIndexOf = unapply(String.prototype.indexOf); - const stringTrim = unapply(String.prototype.trim); - const objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty); - const regExpTest = unapply(RegExp.prototype.test); - const typeErrorCreate = unconstruct(TypeError); - /** - * Creates a new function that calls the given function with a specified thisArg and arguments. - * - * @param func - The function to be wrapped and called. - * @returns A new function that calls the given function with a specified thisArg and arguments. - */ - function unapply(func) { - return function (thisArg) { - for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { - args[_key - 1] = arguments[_key]; - } - return apply(func, thisArg, args); - }; - } - /** - * Creates a new function that constructs an instance of the given constructor function with the provided arguments. - * - * @param func - The constructor function to be wrapped and called. - * @returns A new function that constructs an instance of the given constructor function with the provided arguments. - */ - function unconstruct(func) { - return function () { - for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { - args[_key2] = arguments[_key2]; - } - return construct(func, args); - }; - } - /** - * Add properties to a lookup table - * - * @param set - The set to which elements will be added. - * @param array - The array containing elements to be added to the set. - * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set. - * @returns The modified set with added elements. - */ - function addToSet(set, array) { - let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase; - if (setPrototypeOf) { - // Make 'in' and truthy checks like Boolean(set.constructor) - // independent of any properties defined on Object.prototype. - // Prevent prototype setters from intercepting set as a this value. - setPrototypeOf(set, null); - } - let l = array.length; - while (l--) { - let element = array[l]; - if (typeof element === 'string') { - const lcElement = transformCaseFunc(element); - if (lcElement !== element) { - // Config presets (e.g. tags.js, attrs.js) are immutable. - if (!isFrozen(array)) { - array[l] = lcElement; - } - element = lcElement; - } - } - set[element] = true; - } - return set; - } - /** - * Clean up an array to harden against CSPP - * - * @param array - The array to be cleaned. - * @returns The cleaned version of the array - */ - function cleanArray(array) { - for (let index = 0; index < array.length; index++) { - const isPropertyExist = objectHasOwnProperty(array, index); - if (!isPropertyExist) { - array[index] = null; - } - } - return array; - } - /** - * Shallow clone an object - * - * @param object - The object to be cloned. - * @returns A new object that copies the original. - */ - function clone(object) { - const newObject = create(null); - for (const [property, value] of entries(object)) { - const isPropertyExist = objectHasOwnProperty(object, property); - if (isPropertyExist) { - if (Array.isArray(value)) { - newObject[property] = cleanArray(value); - } else if (value && typeof value === 'object' && value.constructor === Object) { - newObject[property] = clone(value); - } else { - newObject[property] = value; - } - } - } - return newObject; - } - /** - * This method automatically checks if the prop is function or getter and behaves accordingly. - * - * @param object - The object to look up the getter function in its prototype chain. - * @param prop - The property name for which to find the getter function. - * @returns The getter function found in the prototype chain or a fallback function. - */ - function lookupGetter(object, prop) { - while (object !== null) { - const desc = getOwnPropertyDescriptor(object, prop); - if (desc) { - if (desc.get) { - return unapply(desc.get); - } - if (typeof desc.value === 'function') { - return unapply(desc.value); - } - } - object = getPrototypeOf(object); - } - function fallbackValue() { - return null; - } - return fallbackValue; - } - const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']); - const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']); - const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']); - // List of SVG elements that are disallowed by default. - // We still need to know them so that we can do namespace - // checks properly in case one wants to add them to - // allow-list. - const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']); - const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']); - // Similarly to SVG, we want to know all MathML elements, - // even those that we disallow by default. - const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']); - const text = freeze(['#text']); - const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns', 'slot']); - const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']); - const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']); - const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']); - - // eslint-disable-next-line unicorn/better-regex - const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode - const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm); - const TMPLIT_EXPR = seal(/\$\{[\w\W]*}/gm); // eslint-disable-line unicorn/better-regex - const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape - const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape - const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape - ); - - const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); - const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex - ); - - const DOCTYPE_NAME = seal(/^html$/i); - const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i); - var EXPRESSIONS = /*#__PURE__*/Object.freeze({ - __proto__: null, - ARIA_ATTR: ARIA_ATTR, - ATTR_WHITESPACE: ATTR_WHITESPACE, - CUSTOM_ELEMENT: CUSTOM_ELEMENT, - DATA_ATTR: DATA_ATTR, - DOCTYPE_NAME: DOCTYPE_NAME, - ERB_EXPR: ERB_EXPR, - IS_ALLOWED_URI: IS_ALLOWED_URI, - IS_SCRIPT_OR_DATA: IS_SCRIPT_OR_DATA, - MUSTACHE_EXPR: MUSTACHE_EXPR, - TMPLIT_EXPR: TMPLIT_EXPR - }); - - /* eslint-disable @typescript-eslint/indent */ - // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType - const NODE_TYPE = { - element: 1, - attribute: 2, - text: 3, - cdataSection: 4, - entityReference: 5, - // Deprecated - entityNode: 6, - // Deprecated - progressingInstruction: 7, - comment: 8, - document: 9, - documentType: 10, - documentFragment: 11, - notation: 12 // Deprecated - }; - - const getGlobal = function getGlobal() { - return typeof window === 'undefined' ? null : window; - }; - /** - * Creates a no-op policy for internal use only. - * Don't export this function outside this module! - * @param trustedTypes The policy factory. - * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix). - * @return The policy created (or null, if Trusted Types - * are not supported or creating the policy failed). - */ - const _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) { - if (typeof trustedTypes !== 'object' || typeof trustedTypes.createPolicy !== 'function') { - return null; - } - // Allow the callers to control the unique policy name - // by adding a data-tt-policy-suffix to the script element with the DOMPurify. - // Policy creation with duplicate names throws in Trusted Types. - let suffix = null; - const ATTR_NAME = 'data-tt-policy-suffix'; - if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) { - suffix = purifyHostElement.getAttribute(ATTR_NAME); - } - const policyName = 'dompurify' + (suffix ? '#' + suffix : ''); - try { - return trustedTypes.createPolicy(policyName, { - createHTML(html) { - return html; - }, - createScriptURL(scriptUrl) { - return scriptUrl; - } - }); - } catch (_) { - // Policy creation failed (most likely another DOMPurify script has - // already run). Skip creating the policy, as this will only cause errors - // if TT are enforced. - console.warn('TrustedTypes policy ' + policyName + ' could not be created.'); - return null; - } - }; - const _createHooksMap = function _createHooksMap() { - return { - afterSanitizeAttributes: [], - afterSanitizeElements: [], - afterSanitizeShadowDOM: [], - beforeSanitizeAttributes: [], - beforeSanitizeElements: [], - beforeSanitizeShadowDOM: [], - uponSanitizeAttribute: [], - uponSanitizeElement: [], - uponSanitizeShadowNode: [] - }; - }; - function createDOMPurify() { - let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal(); - const DOMPurify = root => createDOMPurify(root); - DOMPurify.version = '3.2.3'; - DOMPurify.removed = []; - if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document) { - // Not running in a browser, provide a factory function - // so that you can pass your own Window - DOMPurify.isSupported = false; - return DOMPurify; - } - let { - document - } = window; - const originalDocument = document; - const currentScript = originalDocument.currentScript; - const { - DocumentFragment, - HTMLTemplateElement, - Node, - Element, - NodeFilter, - NamedNodeMap = window.NamedNodeMap || window.MozNamedAttrMap, - HTMLFormElement, - DOMParser, - trustedTypes - } = window; - const ElementPrototype = Element.prototype; - const cloneNode = lookupGetter(ElementPrototype, 'cloneNode'); - const remove = lookupGetter(ElementPrototype, 'remove'); - const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling'); - const getChildNodes = lookupGetter(ElementPrototype, 'childNodes'); - const getParentNode = lookupGetter(ElementPrototype, 'parentNode'); - // As per issue #47, the web-components registry is inherited by a - // new document created via createHTMLDocument. As per the spec - // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries) - // a new empty registry is used when creating a template contents owner - // document, so we use that as our parent document to ensure nothing - // is inherited. - if (typeof HTMLTemplateElement === 'function') { - const template = document.createElement('template'); - if (template.content && template.content.ownerDocument) { - document = template.content.ownerDocument; - } - } - let trustedTypesPolicy; - let emptyHTML = ''; - const { - implementation, - createNodeIterator, - createDocumentFragment, - getElementsByTagName - } = document; - const { - importNode - } = originalDocument; - let hooks = _createHooksMap(); - /** - * Expose whether this browser supports running the full DOMPurify. - */ - DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined; - const { - MUSTACHE_EXPR, - ERB_EXPR, - TMPLIT_EXPR, - DATA_ATTR, - ARIA_ATTR, - IS_SCRIPT_OR_DATA, - ATTR_WHITESPACE, - CUSTOM_ELEMENT - } = EXPRESSIONS; - let { - IS_ALLOWED_URI: IS_ALLOWED_URI$1 - } = EXPRESSIONS; - /** - * We consider the elements and attributes below to be safe. Ideally - * don't add any new ones but feel free to remove unwanted ones. - */ - /* allowed element names */ - let ALLOWED_TAGS = null; - const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]); - /* Allowed attribute names */ - let ALLOWED_ATTR = null; - const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]); - /* - * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements. - * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements) - * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list) - * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`. - */ - let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, { - tagNameCheck: { - writable: true, - configurable: false, - enumerable: true, - value: null - }, - attributeNameCheck: { - writable: true, - configurable: false, - enumerable: true, - value: null - }, - allowCustomizedBuiltInElements: { - writable: true, - configurable: false, - enumerable: true, - value: false - } - })); - /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */ - let FORBID_TAGS = null; - /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */ - let FORBID_ATTR = null; - /* Decide if ARIA attributes are okay */ - let ALLOW_ARIA_ATTR = true; - /* Decide if custom data attributes are okay */ - let ALLOW_DATA_ATTR = true; - /* Decide if unknown protocols are okay */ - let ALLOW_UNKNOWN_PROTOCOLS = false; - /* Decide if self-closing tags in attributes are allowed. - * Usually removed due to a mXSS issue in jQuery 3.0 */ - let ALLOW_SELF_CLOSE_IN_ATTR = true; - /* Output should be safe for common template engines. - * This means, DOMPurify removes data attributes, mustaches and ERB - */ - let SAFE_FOR_TEMPLATES = false; - /* Output should be safe even for XML used within HTML and alike. - * This means, DOMPurify removes comments when containing risky content. - */ - let SAFE_FOR_XML = true; - /* Decide if document with ... should be returned */ - let WHOLE_DOCUMENT = false; - /* Track whether config is already set on this instance of DOMPurify. */ - let SET_CONFIG = false; - /* Decide if all elements (e.g. style, script) must be children of - * document.body. By default, browsers might move them to document.head */ - let FORCE_BODY = false; - /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html - * string (or a TrustedHTML object if Trusted Types are supported). - * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead - */ - let RETURN_DOM = false; - /* Decide if a DOM `DocumentFragment` should be returned, instead of a html - * string (or a TrustedHTML object if Trusted Types are supported) */ - let RETURN_DOM_FRAGMENT = false; - /* Try to return a Trusted Type object instead of a string, return a string in - * case Trusted Types are not supported */ - let RETURN_TRUSTED_TYPE = false; - /* Output should be free from DOM clobbering attacks? - * This sanitizes markups named with colliding, clobberable built-in DOM APIs. - */ - let SANITIZE_DOM = true; - /* Achieve full DOM Clobbering protection by isolating the namespace of named - * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules. - * - * HTML/DOM spec rules that enable DOM Clobbering: - * - Named Access on Window (§7.3.3) - * - DOM Tree Accessors (§3.1.5) - * - Form Element Parent-Child Relations (§4.10.3) - * - Iframe srcdoc / Nested WindowProxies (§4.8.5) - * - HTMLCollection (§4.2.10.2) - * - * Namespace isolation is implemented by prefixing `id` and `name` attributes - * with a constant string, i.e., `user-content-` - */ - let SANITIZE_NAMED_PROPS = false; - const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-'; - /* Keep element content when removing element? */ - let KEEP_CONTENT = true; - /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead - * of importing it into a new Document and returning a sanitized copy */ - let IN_PLACE = false; - /* Allow usage of profiles like html, svg and mathMl */ - let USE_PROFILES = {}; - /* Tags to ignore content of when KEEP_CONTENT is true */ - let FORBID_CONTENTS = null; - const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']); - /* Tags that are safe for data: URIs */ - let DATA_URI_TAGS = null; - const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']); - /* Attributes safe for values like "javascript:" */ - let URI_SAFE_ATTRIBUTES = null; - const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']); - const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; - const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; - const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; - /* Document namespace */ - let NAMESPACE = HTML_NAMESPACE; - let IS_EMPTY_INPUT = false; - /* Allowed XHTML+XML namespaces */ - let ALLOWED_NAMESPACES = null; - const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString); - let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']); - let HTML_INTEGRATION_POINTS = addToSet({}, ['annotation-xml']); - // Certain elements are allowed in both SVG and HTML - // namespace. We need to specify them explicitly - // so that they don't get erroneously deleted from - // HTML namespace. - const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ['title', 'style', 'font', 'a', 'script']); - /* Parsing of strict XHTML documents */ - let PARSER_MEDIA_TYPE = null; - const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html']; - const DEFAULT_PARSER_MEDIA_TYPE = 'text/html'; - let transformCaseFunc = null; - /* Keep a reference to config to pass to hooks */ - let CONFIG = null; - /* Ideally, do not touch anything below this line */ - /* ______________________________________________ */ - const formElement = document.createElement('form'); - const isRegexOrFunction = function isRegexOrFunction(testValue) { - return testValue instanceof RegExp || testValue instanceof Function; - }; - /** - * _parseConfig - * - * @param cfg optional config literal - */ - // eslint-disable-next-line complexity - const _parseConfig = function _parseConfig() { - let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - if (CONFIG && CONFIG === cfg) { - return; - } - /* Shield configuration object from tampering */ - if (!cfg || typeof cfg !== 'object') { - cfg = {}; - } - /* Shield configuration object from prototype pollution */ - cfg = clone(cfg); - PARSER_MEDIA_TYPE = - // eslint-disable-next-line unicorn/prefer-includes - SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE; - // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is. - transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase; - /* Set configuration parameters */ - ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS; - ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR; - ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES; - URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR, transformCaseFunc) : DEFAULT_URI_SAFE_ATTRIBUTES; - DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') ? addToSet(clone(DEFAULT_DATA_URI_TAGS), cfg.ADD_DATA_URI_TAGS, transformCaseFunc) : DEFAULT_DATA_URI_TAGS; - FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS; - FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : {}; - FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : {}; - USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false; - ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true - ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true - ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false - ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; // Default true - SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false - SAFE_FOR_XML = cfg.SAFE_FOR_XML !== false; // Default true - WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false - RETURN_DOM = cfg.RETURN_DOM || false; // Default false - RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false - RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false - FORCE_BODY = cfg.FORCE_BODY || false; // Default false - SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true - SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false - KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true - IN_PLACE = cfg.IN_PLACE || false; // Default false - IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI; - NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE; - MATHML_TEXT_INTEGRATION_POINTS = cfg.MATHML_TEXT_INTEGRATION_POINTS || MATHML_TEXT_INTEGRATION_POINTS; - HTML_INTEGRATION_POINTS = cfg.HTML_INTEGRATION_POINTS || HTML_INTEGRATION_POINTS; - CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {}; - if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)) { - CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck; - } - if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)) { - CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck; - } - if (cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements === 'boolean') { - CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements; - } - if (SAFE_FOR_TEMPLATES) { - ALLOW_DATA_ATTR = false; - } - if (RETURN_DOM_FRAGMENT) { - RETURN_DOM = true; - } - /* Parse profile info */ - if (USE_PROFILES) { - ALLOWED_TAGS = addToSet({}, text); - ALLOWED_ATTR = []; - if (USE_PROFILES.html === true) { - addToSet(ALLOWED_TAGS, html$1); - addToSet(ALLOWED_ATTR, html); - } - if (USE_PROFILES.svg === true) { - addToSet(ALLOWED_TAGS, svg$1); - addToSet(ALLOWED_ATTR, svg); - addToSet(ALLOWED_ATTR, xml); - } - if (USE_PROFILES.svgFilters === true) { - addToSet(ALLOWED_TAGS, svgFilters); - addToSet(ALLOWED_ATTR, svg); - addToSet(ALLOWED_ATTR, xml); - } - if (USE_PROFILES.mathMl === true) { - addToSet(ALLOWED_TAGS, mathMl$1); - addToSet(ALLOWED_ATTR, mathMl); - addToSet(ALLOWED_ATTR, xml); - } - } - /* Merge configuration parameters */ - if (cfg.ADD_TAGS) { - if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) { - ALLOWED_TAGS = clone(ALLOWED_TAGS); - } - addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc); - } - if (cfg.ADD_ATTR) { - if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) { - ALLOWED_ATTR = clone(ALLOWED_ATTR); - } - addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc); - } - if (cfg.ADD_URI_SAFE_ATTR) { - addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc); - } - if (cfg.FORBID_CONTENTS) { - if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) { - FORBID_CONTENTS = clone(FORBID_CONTENTS); - } - addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc); - } - /* Add #text in case KEEP_CONTENT is set to true */ - if (KEEP_CONTENT) { - ALLOWED_TAGS['#text'] = true; - } - /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */ - if (WHOLE_DOCUMENT) { - addToSet(ALLOWED_TAGS, ['html', 'head', 'body']); - } - /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */ - if (ALLOWED_TAGS.table) { - addToSet(ALLOWED_TAGS, ['tbody']); - delete FORBID_TAGS.tbody; - } - if (cfg.TRUSTED_TYPES_POLICY) { - if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') { - throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.'); - } - if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') { - throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.'); - } - // Overwrite existing TrustedTypes policy. - trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY; - // Sign local variables required by `sanitize`. - emptyHTML = trustedTypesPolicy.createHTML(''); - } else { - // Uninitialized policy, attempt to initialize the internal dompurify policy. - if (trustedTypesPolicy === undefined) { - trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript); - } - // If creating the internal policy succeeded sign internal variables. - if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') { - emptyHTML = trustedTypesPolicy.createHTML(''); - } - } - // Prevent further manipulation of configuration. - // Not available in IE8, Safari 5, etc. - if (freeze) { - freeze(cfg); - } - CONFIG = cfg; - }; - /* Keep track of all possible SVG and MathML tags - * so that we can perform the namespace checks - * correctly. */ - const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]); - const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]); - /** - * @param element a DOM element whose namespace is being checked - * @returns Return false if the element has a - * namespace that a spec-compliant parser would never - * return. Return true otherwise. - */ - const _checkValidNamespace = function _checkValidNamespace(element) { - let parent = getParentNode(element); - // In JSDOM, if we're inside shadow DOM, then parentNode - // can be null. We just simulate parent in this case. - if (!parent || !parent.tagName) { - parent = { - namespaceURI: NAMESPACE, - tagName: 'template' - }; - } - const tagName = stringToLowerCase(element.tagName); - const parentTagName = stringToLowerCase(parent.tagName); - if (!ALLOWED_NAMESPACES[element.namespaceURI]) { - return false; - } - if (element.namespaceURI === SVG_NAMESPACE) { - // The only way to switch from HTML namespace to SVG - // is via . If it happens via any other tag, then - // it should be killed. - if (parent.namespaceURI === HTML_NAMESPACE) { - return tagName === 'svg'; - } - // The only way to switch from MathML to SVG is via` - // svg if parent is either or MathML - // text integration points. - if (parent.namespaceURI === MATHML_NAMESPACE) { - return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]); - } - // We only allow elements that are defined in SVG - // spec. All others are disallowed in SVG namespace. - return Boolean(ALL_SVG_TAGS[tagName]); - } - if (element.namespaceURI === MATHML_NAMESPACE) { - // The only way to switch from HTML namespace to MathML - // is via . If it happens via any other tag, then - // it should be killed. - if (parent.namespaceURI === HTML_NAMESPACE) { - return tagName === 'math'; - } - // The only way to switch from SVG to MathML is via - // and HTML integration points - if (parent.namespaceURI === SVG_NAMESPACE) { - return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName]; - } - // We only allow elements that are defined in MathML - // spec. All others are disallowed in MathML namespace. - return Boolean(ALL_MATHML_TAGS[tagName]); - } - if (element.namespaceURI === HTML_NAMESPACE) { - // The only way to switch from SVG to HTML is via - // HTML integration points, and from MathML to HTML - // is via MathML text integration points - if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) { - return false; - } - if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) { - return false; - } - // We disallow tags that are specific for MathML - // or SVG and should never appear in HTML namespace - return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]); - } - // For XHTML and XML documents that support custom namespaces - if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && ALLOWED_NAMESPACES[element.namespaceURI]) { - return true; - } - // The code should never reach this place (this means - // that the element somehow got namespace that is not - // HTML, SVG, MathML or allowed via ALLOWED_NAMESPACES). - // Return false just in case. - return false; - }; - /** - * _forceRemove - * - * @param node a DOM node - */ - const _forceRemove = function _forceRemove(node) { - arrayPush(DOMPurify.removed, { - element: node - }); - try { - // eslint-disable-next-line unicorn/prefer-dom-node-remove - getParentNode(node).removeChild(node); - } catch (_) { - remove(node); - } - }; - /** - * _removeAttribute - * - * @param name an Attribute name - * @param element a DOM node - */ - const _removeAttribute = function _removeAttribute(name, element) { - try { - arrayPush(DOMPurify.removed, { - attribute: element.getAttributeNode(name), - from: element - }); - } catch (_) { - arrayPush(DOMPurify.removed, { - attribute: null, - from: element - }); - } - element.removeAttribute(name); - // We void attribute values for unremovable "is" attributes - if (name === 'is') { - if (RETURN_DOM || RETURN_DOM_FRAGMENT) { - try { - _forceRemove(element); - } catch (_) {} - } else { - try { - element.setAttribute(name, ''); - } catch (_) {} - } - } - }; - /** - * _initDocument - * - * @param dirty - a string of dirty markup - * @return a DOM, filled with the dirty markup - */ - const _initDocument = function _initDocument(dirty) { - /* Create a HTML document */ - let doc = null; - let leadingWhitespace = null; - if (FORCE_BODY) { - dirty = '' + dirty; - } else { - /* If FORCE_BODY isn't used, leading whitespace needs to be preserved manually */ - const matches = stringMatch(dirty, /^[\r\n\t ]+/); - leadingWhitespace = matches && matches[0]; - } - if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && NAMESPACE === HTML_NAMESPACE) { - // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict) - dirty = '' + dirty + ''; - } - const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty; - /* - * Use the DOMParser API by default, fallback later if needs be - * DOMParser not work for svg when has multiple root element. - */ - if (NAMESPACE === HTML_NAMESPACE) { - try { - doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE); - } catch (_) {} - } - /* Use createHTMLDocument in case DOMParser is not available */ - if (!doc || !doc.documentElement) { - doc = implementation.createDocument(NAMESPACE, 'template', null); - try { - doc.documentElement.innerHTML = IS_EMPTY_INPUT ? emptyHTML : dirtyPayload; - } catch (_) { - // Syntax error if dirtyPayload is invalid xml - } - } - const body = doc.body || doc.documentElement; - if (dirty && leadingWhitespace) { - body.insertBefore(document.createTextNode(leadingWhitespace), body.childNodes[0] || null); - } - /* Work on whole document or just its body */ - if (NAMESPACE === HTML_NAMESPACE) { - return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? 'html' : 'body')[0]; - } - return WHOLE_DOCUMENT ? doc.documentElement : body; - }; - /** - * Creates a NodeIterator object that you can use to traverse filtered lists of nodes or elements in a document. - * - * @param root The root element or node to start traversing on. - * @return The created NodeIterator - */ - const _createNodeIterator = function _createNodeIterator(root) { - return createNodeIterator.call(root.ownerDocument || root, root, - // eslint-disable-next-line no-bitwise - NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null); - }; - /** - * _isClobbered - * - * @param element element to check for clobbering attacks - * @return true if clobbered, false if safe - */ - const _isClobbered = function _isClobbered(element) { - return element instanceof HTMLFormElement && (typeof element.nodeName !== 'string' || typeof element.textContent !== 'string' || typeof element.removeChild !== 'function' || !(element.attributes instanceof NamedNodeMap) || typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function'); - }; - /** - * Checks whether the given object is a DOM node. - * - * @param value object to check whether it's a DOM node - * @return true is object is a DOM node - */ - const _isNode = function _isNode(value) { - return typeof Node === 'function' && value instanceof Node; - }; - function _executeHooks(hooks, currentNode, data) { - arrayForEach(hooks, hook => { - hook.call(DOMPurify, currentNode, data, CONFIG); - }); - } - /** - * _sanitizeElements - * - * @protect nodeName - * @protect textContent - * @protect removeChild - * @param currentNode to check for permission to exist - * @return true if node was killed, false if left alive - */ - const _sanitizeElements = function _sanitizeElements(currentNode) { - let content = null; - /* Execute a hook if present */ - _executeHooks(hooks.beforeSanitizeElements, currentNode, null); - /* Check if element is clobbered or can clobber */ - if (_isClobbered(currentNode)) { - _forceRemove(currentNode); - return true; - } - /* Now let's check the element's type and name */ - const tagName = transformCaseFunc(currentNode.nodeName); - /* Execute a hook if present */ - _executeHooks(hooks.uponSanitizeElement, currentNode, { - tagName, - allowedTags: ALLOWED_TAGS - }); - /* Detect mXSS attempts abusing namespace confusion */ - if (currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\w]/g, currentNode.innerHTML) && regExpTest(/<[/\w]/g, currentNode.textContent)) { - _forceRemove(currentNode); - return true; - } - /* Remove any occurrence of processing instructions */ - if (currentNode.nodeType === NODE_TYPE.progressingInstruction) { - _forceRemove(currentNode); - return true; - } - /* Remove any kind of possibly harmful comments */ - if (SAFE_FOR_XML && currentNode.nodeType === NODE_TYPE.comment && regExpTest(/<[/\w]/g, currentNode.data)) { - _forceRemove(currentNode); - return true; - } - /* Remove element if anything forbids its presence */ - if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { - /* Check if we have a custom element to handle */ - if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) { - if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) { - return false; - } - if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) { - return false; - } - } - /* Keep content except for bad-listed elements */ - if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) { - const parentNode = getParentNode(currentNode) || currentNode.parentNode; - const childNodes = getChildNodes(currentNode) || currentNode.childNodes; - if (childNodes && parentNode) { - const childCount = childNodes.length; - for (let i = childCount - 1; i >= 0; --i) { - const childClone = cloneNode(childNodes[i], true); - childClone.__removalCount = (currentNode.__removalCount || 0) + 1; - parentNode.insertBefore(childClone, getNextSibling(currentNode)); - } - } - } - _forceRemove(currentNode); - return true; - } - /* Check whether element has a valid namespace */ - if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) { - _forceRemove(currentNode); - return true; - } - /* Make sure that older browsers don't get fallback-tag mXSS */ - if ((tagName === 'noscript' || tagName === 'noembed' || tagName === 'noframes') && regExpTest(/<\/no(script|embed|frames)/i, currentNode.innerHTML)) { - _forceRemove(currentNode); - return true; - } - /* Sanitize element content to be template-safe */ - if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) { - /* Get the element's text content */ - content = currentNode.textContent; - arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { - content = stringReplace(content, expr, ' '); - }); - if (currentNode.textContent !== content) { - arrayPush(DOMPurify.removed, { - element: currentNode.cloneNode() - }); - currentNode.textContent = content; - } - } - /* Execute a hook if present */ - _executeHooks(hooks.afterSanitizeElements, currentNode, null); - return false; - }; - /** - * _isValidAttribute - * - * @param lcTag Lowercase tag name of containing element. - * @param lcName Lowercase attribute name. - * @param value Attribute value. - * @return Returns true if `value` is valid, otherwise false. - */ - // eslint-disable-next-line complexity - const _isValidAttribute = function _isValidAttribute(lcTag, lcName, value) { - /* Make sure attribute cannot clobber */ - if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) { - return false; - } - /* Allow valid data-* attributes: At least one character after "-" - (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes) - XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804) - We don't need to check the value; it's always URI safe. */ - if (ALLOW_DATA_ATTR && !FORBID_ATTR[lcName] && regExpTest(DATA_ATTR, lcName)) ;else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) ;else if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) { - if ( - // First condition does a very basic check if a) it's basically a valid custom element tagname AND - // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck - // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck - _isBasicCustomElement(lcTag) && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag)) && (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName) || CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName)) || - // Alternative, second condition checks if it's an `is`-attribute, AND - // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck - lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))) ;else { - return false; - } - /* Check value is safe. First, is attr inert? If so, is safe */ - } else if (URI_SAFE_ATTRIBUTES[lcName]) ;else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE, ''))) ;else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ;else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))) ;else if (value) { - return false; - } else ; - return true; - }; - /** - * _isBasicCustomElement - * checks if at least one dash is included in tagName, and it's not the first char - * for more sophisticated checking see https://github.com/sindresorhus/validate-element-name - * - * @param tagName name of the tag of the node to sanitize - * @returns Returns true if the tag name meets the basic criteria for a custom element, otherwise false. - */ - const _isBasicCustomElement = function _isBasicCustomElement(tagName) { - return tagName !== 'annotation-xml' && stringMatch(tagName, CUSTOM_ELEMENT); - }; - /** - * _sanitizeAttributes - * - * @protect attributes - * @protect nodeName - * @protect removeAttribute - * @protect setAttribute - * - * @param currentNode to sanitize - */ - const _sanitizeAttributes = function _sanitizeAttributes(currentNode) { - /* Execute a hook if present */ - _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null); - const { - attributes - } = currentNode; - /* Check if we have attributes; if not we might have a text node */ - if (!attributes || _isClobbered(currentNode)) { - return; - } - const hookEvent = { - attrName: '', - attrValue: '', - keepAttr: true, - allowedAttributes: ALLOWED_ATTR, - forceKeepAttr: undefined - }; - let l = attributes.length; - /* Go backwards over all attributes; safely remove bad ones */ - while (l--) { - const attr = attributes[l]; - const { - name, - namespaceURI, - value: attrValue - } = attr; - const lcName = transformCaseFunc(name); - let value = name === 'value' ? attrValue : stringTrim(attrValue); - /* Execute a hook if present */ - hookEvent.attrName = lcName; - hookEvent.attrValue = value; - hookEvent.keepAttr = true; - hookEvent.forceKeepAttr = undefined; // Allows developers to see this is a property they can set - _executeHooks(hooks.uponSanitizeAttribute, currentNode, hookEvent); - value = hookEvent.attrValue; - /* Full DOM Clobbering protection via namespace isolation, - * Prefix id and name attributes with `user-content-` - */ - if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name')) { - // Remove the attribute with this value - _removeAttribute(name, currentNode); - // Prefix the value and later re-create the attribute with the sanitized value - value = SANITIZE_NAMED_PROPS_PREFIX + value; - } - /* Work around a security issue with comments inside attributes */ - if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|title)/i, value)) { - _removeAttribute(name, currentNode); - continue; - } - /* Did the hooks approve of the attribute? */ - if (hookEvent.forceKeepAttr) { - continue; - } - /* Remove attribute */ - _removeAttribute(name, currentNode); - /* Did the hooks approve of the attribute? */ - if (!hookEvent.keepAttr) { - continue; - } - /* Work around a security issue in jQuery 3.0 */ - if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\/>/i, value)) { - _removeAttribute(name, currentNode); - continue; - } - /* Sanitize attribute content to be template-safe */ - if (SAFE_FOR_TEMPLATES) { - arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { - value = stringReplace(value, expr, ' '); - }); - } - /* Is `value` valid for this attribute? */ - const lcTag = transformCaseFunc(currentNode.nodeName); - if (!_isValidAttribute(lcTag, lcName, value)) { - continue; - } - /* Handle attributes that require Trusted Types */ - if (trustedTypesPolicy && typeof trustedTypes === 'object' && typeof trustedTypes.getAttributeType === 'function') { - if (namespaceURI) ;else { - switch (trustedTypes.getAttributeType(lcTag, lcName)) { - case 'TrustedHTML': - { - value = trustedTypesPolicy.createHTML(value); - break; - } - case 'TrustedScriptURL': - { - value = trustedTypesPolicy.createScriptURL(value); - break; - } - } - } - } - /* Handle invalid data-* attribute set by try-catching it */ - try { - if (namespaceURI) { - currentNode.setAttributeNS(namespaceURI, name, value); - } else { - /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */ - currentNode.setAttribute(name, value); - } - if (_isClobbered(currentNode)) { - _forceRemove(currentNode); - } else { - arrayPop(DOMPurify.removed); - } - } catch (_) {} - } - /* Execute a hook if present */ - _executeHooks(hooks.afterSanitizeAttributes, currentNode, null); - }; - /** - * _sanitizeShadowDOM - * - * @param fragment to iterate over recursively - */ - const _sanitizeShadowDOM = function _sanitizeShadowDOM(fragment) { - let shadowNode = null; - const shadowIterator = _createNodeIterator(fragment); - /* Execute a hook if present */ - _executeHooks(hooks.beforeSanitizeShadowDOM, fragment, null); - while (shadowNode = shadowIterator.nextNode()) { - /* Execute a hook if present */ - _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null); - /* Sanitize tags and elements */ - _sanitizeElements(shadowNode); - /* Check attributes next */ - _sanitizeAttributes(shadowNode); - /* Deep shadow DOM detected */ - if (shadowNode.content instanceof DocumentFragment) { - _sanitizeShadowDOM(shadowNode.content); - } - } - /* Execute a hook if present */ - _executeHooks(hooks.afterSanitizeShadowDOM, fragment, null); - }; - // eslint-disable-next-line complexity - DOMPurify.sanitize = function (dirty) { - let cfg = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let body = null; - let importedNode = null; - let currentNode = null; - let returnNode = null; - /* Make sure we have a string to sanitize. - DO NOT return early, as this will return the wrong type if - the user has requested a DOM object rather than a string */ - IS_EMPTY_INPUT = !dirty; - if (IS_EMPTY_INPUT) { - dirty = ''; - } - /* Stringify, in case dirty is an object */ - if (typeof dirty !== 'string' && !_isNode(dirty)) { - if (typeof dirty.toString === 'function') { - dirty = dirty.toString(); - if (typeof dirty !== 'string') { - throw typeErrorCreate('dirty is not a string, aborting'); - } - } else { - throw typeErrorCreate('toString is not a function'); - } - } - /* Return dirty HTML if DOMPurify cannot run */ - if (!DOMPurify.isSupported) { - return dirty; - } - /* Assign config vars */ - if (!SET_CONFIG) { - _parseConfig(cfg); - } - /* Clean up removed elements */ - DOMPurify.removed = []; - /* Check if dirty is correctly typed for IN_PLACE */ - if (typeof dirty === 'string') { - IN_PLACE = false; - } - if (IN_PLACE) { - /* Do some early pre-sanitization to avoid unsafe root nodes */ - if (dirty.nodeName) { - const tagName = transformCaseFunc(dirty.nodeName); - if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { - throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place'); - } - } - } else if (dirty instanceof Node) { - /* If dirty is a DOM element, append to an empty document to avoid - elements being stripped by the parser */ - body = _initDocument(''); - importedNode = body.ownerDocument.importNode(dirty, true); - if (importedNode.nodeType === NODE_TYPE.element && importedNode.nodeName === 'BODY') { - /* Node is already a body, use as is */ - body = importedNode; - } else if (importedNode.nodeName === 'HTML') { - body = importedNode; - } else { - // eslint-disable-next-line unicorn/prefer-dom-node-append - body.appendChild(importedNode); - } - } else { - /* Exit directly if we have nothing to do */ - if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT && - // eslint-disable-next-line unicorn/prefer-includes - dirty.indexOf('<') === -1) { - return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty; - } - /* Initialize the document to work on */ - body = _initDocument(dirty); - /* Check we have a DOM node from the data */ - if (!body) { - return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : ''; - } - } - /* Remove first element node (ours) if FORCE_BODY is set */ - if (body && FORCE_BODY) { - _forceRemove(body.firstChild); - } - /* Get node iterator */ - const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body); - /* Now start iterating over the created document */ - while (currentNode = nodeIterator.nextNode()) { - /* Sanitize tags and elements */ - _sanitizeElements(currentNode); - /* Check attributes next */ - _sanitizeAttributes(currentNode); - /* Shadow DOM detected, sanitize it */ - if (currentNode.content instanceof DocumentFragment) { - _sanitizeShadowDOM(currentNode.content); - } - } - /* If we sanitized `dirty` in-place, return it. */ - if (IN_PLACE) { - return dirty; - } - /* Return sanitized string or DOM */ - if (RETURN_DOM) { - if (RETURN_DOM_FRAGMENT) { - returnNode = createDocumentFragment.call(body.ownerDocument); - while (body.firstChild) { - // eslint-disable-next-line unicorn/prefer-dom-node-append - returnNode.appendChild(body.firstChild); - } - } else { - returnNode = body; - } - if (ALLOWED_ATTR.shadowroot || ALLOWED_ATTR.shadowrootmode) { - /* - AdoptNode() is not used because internal state is not reset - (e.g. the past names map of a HTMLFormElement), this is safe - in theory but we would rather not risk another attack vector. - The state that is cloned by importNode() is explicitly defined - by the specs. - */ - returnNode = importNode.call(originalDocument, returnNode, true); - } - return returnNode; - } - let serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML; - /* Serialize doctype if allowed */ - if (WHOLE_DOCUMENT && ALLOWED_TAGS['!doctype'] && body.ownerDocument && body.ownerDocument.doctype && body.ownerDocument.doctype.name && regExpTest(DOCTYPE_NAME, body.ownerDocument.doctype.name)) { - serializedHTML = '\n' + serializedHTML; - } - /* Sanitize final string template-safe */ - if (SAFE_FOR_TEMPLATES) { - arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { - serializedHTML = stringReplace(serializedHTML, expr, ' '); - }); - } - return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML; - }; - DOMPurify.setConfig = function () { - let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - _parseConfig(cfg); - SET_CONFIG = true; - }; - DOMPurify.clearConfig = function () { - CONFIG = null; - SET_CONFIG = false; - }; - DOMPurify.isValidAttribute = function (tag, attr, value) { - /* Initialize shared config vars if necessary. */ - if (!CONFIG) { - _parseConfig({}); - } - const lcTag = transformCaseFunc(tag); - const lcName = transformCaseFunc(attr); - return _isValidAttribute(lcTag, lcName, value); - }; - DOMPurify.addHook = function (entryPoint, hookFunction) { - if (typeof hookFunction !== 'function') { - return; - } - arrayPush(hooks[entryPoint], hookFunction); - }; - DOMPurify.removeHook = function (entryPoint) { - return arrayPop(hooks[entryPoint]); - }; - DOMPurify.removeHooks = function (entryPoint) { - hooks[entryPoint] = []; - }; - DOMPurify.removeAllHooks = function () { - hooks = _createHooksMap(); - }; - return DOMPurify; - } - var purify = createDOMPurify(); - - purify.addHook("uponSanitizeAttribute", function (node, data) { - const allowedAttributePattern = /^data-trix-/; - if (allowedAttributePattern.test(data.attrName)) { - data.forceKeepAttr = true; - } - }); - const DEFAULT_ALLOWED_ATTRIBUTES = "style href src width height language class".split(" "); - const DEFAULT_FORBIDDEN_PROTOCOLS = "javascript:".split(" "); - const DEFAULT_FORBIDDEN_ELEMENTS = "script iframe form noscript".split(" "); - class HTMLSanitizer extends BasicObject { - static setHTML(element, html) { - const sanitizedElement = new this(html).sanitize(); - const sanitizedHtml = sanitizedElement.getHTML ? sanitizedElement.getHTML() : sanitizedElement.outerHTML; - element.innerHTML = sanitizedHtml; - } - static sanitize(html, options) { - const sanitizer = new this(html, options); - sanitizer.sanitize(); - return sanitizer; - } - constructor(html) { - let { - allowedAttributes, - forbiddenProtocols, - forbiddenElements - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - super(...arguments); - this.allowedAttributes = allowedAttributes || DEFAULT_ALLOWED_ATTRIBUTES; - this.forbiddenProtocols = forbiddenProtocols || DEFAULT_FORBIDDEN_PROTOCOLS; - this.forbiddenElements = forbiddenElements || DEFAULT_FORBIDDEN_ELEMENTS; - this.body = createBodyElementForHTML(html); - } - sanitize() { - this.sanitizeElements(); - this.normalizeListElementNesting(); - purify.setConfig(dompurify); - this.body = purify.sanitize(this.body); - return this.body; - } - getHTML() { - return this.body.innerHTML; - } - getBody() { - return this.body; - } - - // Private - - sanitizeElements() { - const walker = walkTree(this.body); - const nodesToRemove = []; - while (walker.nextNode()) { - const node = walker.currentNode; - switch (node.nodeType) { - case Node.ELEMENT_NODE: - if (this.elementIsRemovable(node)) { - nodesToRemove.push(node); - } else { - this.sanitizeElement(node); - } - break; - case Node.COMMENT_NODE: - nodesToRemove.push(node); - break; - } - } - nodesToRemove.forEach(node => removeNode(node)); - return this.body; - } - sanitizeElement(element) { - if (element.hasAttribute("href")) { - if (this.forbiddenProtocols.includes(element.protocol)) { - element.removeAttribute("href"); - } - } - Array.from(element.attributes).forEach(_ref => { - let { - name - } = _ref; - if (!this.allowedAttributes.includes(name) && name.indexOf("data-trix") !== 0) { - element.removeAttribute(name); - } - }); - return element; - } - normalizeListElementNesting() { - Array.from(this.body.querySelectorAll("ul,ol")).forEach(listElement => { - const previousElement = listElement.previousElementSibling; - if (previousElement) { - if (tagName(previousElement) === "li") { - previousElement.appendChild(listElement); - } - } - }); - return this.body; - } - elementIsRemovable(element) { - if ((element === null || element === void 0 ? void 0 : element.nodeType) !== Node.ELEMENT_NODE) return; - return this.elementIsForbidden(element) || this.elementIsntSerializable(element); - } - elementIsForbidden(element) { - return this.forbiddenElements.includes(tagName(element)); - } - elementIsntSerializable(element) { - return element.getAttribute("data-trix-serialize") === "false" && !nodeIsAttachmentElement(element); - } - } - const createBodyElementForHTML = function () { - let html = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - // Remove everything after - html = html.replace(/<\/html[^>]*>[^]*$/i, ""); - const doc = document.implementation.createHTMLDocument(""); - doc.documentElement.innerHTML = html; - Array.from(doc.head.querySelectorAll("style")).forEach(element => { - doc.body.appendChild(element); - }); - return doc.body; - }; - - const { - css: css$2 - } = config; - class AttachmentView extends ObjectView { - constructor() { - super(...arguments); - this.attachment = this.object; - this.attachment.uploadProgressDelegate = this; - this.attachmentPiece = this.options.piece; - } - createContentNodes() { - return []; - } - createNodes() { - let innerElement; - const figure = innerElement = makeElement({ - tagName: "figure", - className: this.getClassName(), - data: this.getData(), - editable: false - }); - const href = this.getHref(); - if (href) { - innerElement = makeElement({ - tagName: "a", - editable: false, - attributes: { - href, - tabindex: -1 - } - }); - figure.appendChild(innerElement); - } - if (this.attachment.hasContent()) { - HTMLSanitizer.setHTML(innerElement, this.attachment.getContent()); - } else { - this.createContentNodes().forEach(node => { - innerElement.appendChild(node); - }); - } - innerElement.appendChild(this.createCaptionElement()); - if (this.attachment.isPending()) { - this.progressElement = makeElement({ - tagName: "progress", - attributes: { - class: css$2.attachmentProgress, - value: this.attachment.getUploadProgress(), - max: 100 - }, - data: { - trixMutable: true, - trixStoreKey: ["progressElement", this.attachment.id].join("/") - } - }); - figure.appendChild(this.progressElement); - } - return [createCursorTarget("left"), figure, createCursorTarget("right")]; - } - createCaptionElement() { - const figcaption = makeElement({ - tagName: "figcaption", - className: css$2.attachmentCaption - }); - const caption = this.attachmentPiece.getCaption(); - if (caption) { - figcaption.classList.add("".concat(css$2.attachmentCaption, "--edited")); - figcaption.textContent = caption; - } else { - let name, size; - const captionConfig = this.getCaptionConfig(); - if (captionConfig.name) { - name = this.attachment.getFilename(); - } - if (captionConfig.size) { - size = this.attachment.getFormattedFilesize(); - } - if (name) { - const nameElement = makeElement({ - tagName: "span", - className: css$2.attachmentName, - textContent: name - }); - figcaption.appendChild(nameElement); - } - if (size) { - if (name) { - figcaption.appendChild(document.createTextNode(" ")); - } - const sizeElement = makeElement({ - tagName: "span", - className: css$2.attachmentSize, - textContent: size - }); - figcaption.appendChild(sizeElement); - } - } - return figcaption; - } - getClassName() { - const names = [css$2.attachment, "".concat(css$2.attachment, "--").concat(this.attachment.getType())]; - const extension = this.attachment.getExtension(); - if (extension) { - names.push("".concat(css$2.attachment, "--").concat(extension)); - } - return names.join(" "); - } - getData() { - const data = { - trixAttachment: JSON.stringify(this.attachment), - trixContentType: this.attachment.getContentType(), - trixId: this.attachment.id - }; - const { - attributes - } = this.attachmentPiece; - if (!attributes.isEmpty()) { - data.trixAttributes = JSON.stringify(attributes); - } - if (this.attachment.isPending()) { - data.trixSerialize = false; - } - return data; - } - getHref() { - if (!htmlContainsTagName(this.attachment.getContent(), "a")) { - return this.attachment.getHref(); - } - } - getCaptionConfig() { - var _config$attachments$t; - const type = this.attachment.getType(); - const captionConfig = copyObject((_config$attachments$t = attachments[type]) === null || _config$attachments$t === void 0 ? void 0 : _config$attachments$t.caption); - if (type === "file") { - captionConfig.name = true; - } - return captionConfig; - } - findProgressElement() { - var _this$findElement; - return (_this$findElement = this.findElement()) === null || _this$findElement === void 0 ? void 0 : _this$findElement.querySelector("progress"); - } - - // Attachment delegate - - attachmentDidChangeUploadProgress() { - const value = this.attachment.getUploadProgress(); - const progressElement = this.findProgressElement(); - if (progressElement) { - progressElement.value = value; - } - } - } - const createCursorTarget = name => makeElement({ - tagName: "span", - textContent: ZERO_WIDTH_SPACE, - data: { - trixCursorTarget: name, - trixSerialize: false - } - }); - const htmlContainsTagName = function (html, tagName) { - const div = makeElement("div"); - HTMLSanitizer.setHTML(div, html || ""); - return div.querySelector(tagName); - }; - - class PreviewableAttachmentView extends AttachmentView { - constructor() { - super(...arguments); - this.attachment.previewDelegate = this; - } - createContentNodes() { - this.image = makeElement({ - tagName: "img", - attributes: { - src: "" - }, - data: { - trixMutable: true - } - }); - this.refresh(this.image); - return [this.image]; - } - createCaptionElement() { - const figcaption = super.createCaptionElement(...arguments); - if (!figcaption.textContent) { - figcaption.setAttribute("data-trix-placeholder", lang$1.captionPlaceholder); - } - return figcaption; - } - refresh(image) { - if (!image) { - var _this$findElement; - image = (_this$findElement = this.findElement()) === null || _this$findElement === void 0 ? void 0 : _this$findElement.querySelector("img"); - } - if (image) { - return this.updateAttributesForImage(image); - } - } - updateAttributesForImage(image) { - const url = this.attachment.getURL(); - const previewURL = this.attachment.getPreviewURL(); - image.src = previewURL || url; - if (previewURL === url) { - image.removeAttribute("data-trix-serialized-attributes"); - } else { - const serializedAttributes = JSON.stringify({ - src: url - }); - image.setAttribute("data-trix-serialized-attributes", serializedAttributes); - } - const width = this.attachment.getWidth(); - const height = this.attachment.getHeight(); - if (width != null) { - image.width = width; - } - if (height != null) { - image.height = height; - } - const storeKey = ["imageElement", this.attachment.id, image.src, image.width, image.height].join("/"); - image.dataset.trixStoreKey = storeKey; - } - - // Attachment delegate - - attachmentDidChangeAttributes() { - this.refresh(this.image); - return this.refresh(); - } - } - - /* eslint-disable - no-useless-escape, - no-var, - */ - class PieceView extends ObjectView { - constructor() { - super(...arguments); - this.piece = this.object; - this.attributes = this.piece.getAttributes(); - this.textConfig = this.options.textConfig; - this.context = this.options.context; - if (this.piece.attachment) { - this.attachment = this.piece.attachment; - } else { - this.string = this.piece.toString(); - } - } - createNodes() { - let nodes = this.attachment ? this.createAttachmentNodes() : this.createStringNodes(); - const element = this.createElement(); - if (element) { - const innerElement = findInnerElement(element); - Array.from(nodes).forEach(node => { - innerElement.appendChild(node); - }); - nodes = [element]; - } - return nodes; - } - createAttachmentNodes() { - const constructor = this.attachment.isPreviewable() ? PreviewableAttachmentView : AttachmentView; - const view = this.createChildView(constructor, this.piece.attachment, { - piece: this.piece - }); - return view.getNodes(); - } - createStringNodes() { - var _this$textConfig; - if ((_this$textConfig = this.textConfig) !== null && _this$textConfig !== void 0 && _this$textConfig.plaintext) { - return [document.createTextNode(this.string)]; - } else { - const nodes = []; - const iterable = this.string.split("\n"); - for (let index = 0; index < iterable.length; index++) { - const substring = iterable[index]; - if (index > 0) { - const element = makeElement("br"); - nodes.push(element); - } - if (substring.length) { - const node = document.createTextNode(this.preserveSpaces(substring)); - nodes.push(node); - } - } - return nodes; - } - } - createElement() { - let element, key, value; - const styles = {}; - for (key in this.attributes) { - value = this.attributes[key]; - const config = getTextConfig(key); - if (config) { - if (config.tagName) { - var innerElement; - const pendingElement = makeElement(config.tagName); - if (innerElement) { - innerElement.appendChild(pendingElement); - innerElement = pendingElement; - } else { - element = innerElement = pendingElement; - } - } - if (config.styleProperty) { - styles[config.styleProperty] = value; - } - if (config.style) { - for (key in config.style) { - value = config.style[key]; - styles[key] = value; - } - } - } - } - if (Object.keys(styles).length) { - if (!element) { - element = makeElement("span"); - } - for (key in styles) { - value = styles[key]; - element.style[key] = value; - } - } - return element; - } - createContainerElement() { - for (const key in this.attributes) { - const value = this.attributes[key]; - const config = getTextConfig(key); - if (config) { - if (config.groupTagName) { - const attributes = {}; - attributes[key] = value; - return makeElement(config.groupTagName, attributes); - } - } - } - } - preserveSpaces(string) { - if (this.context.isLast) { - string = string.replace(/\ $/, NON_BREAKING_SPACE); - } - string = string.replace(/(\S)\ {3}(\S)/g, "$1 ".concat(NON_BREAKING_SPACE, " $2")).replace(/\ {2}/g, "".concat(NON_BREAKING_SPACE, " ")).replace(/\ {2}/g, " ".concat(NON_BREAKING_SPACE)); - if (this.context.isFirst || this.context.followsWhitespace) { - string = string.replace(/^\ /, NON_BREAKING_SPACE); - } - return string; - } - } - - /* eslint-disable - no-var, - */ - class TextView extends ObjectView { - constructor() { - super(...arguments); - this.text = this.object; - this.textConfig = this.options.textConfig; - } - createNodes() { - const nodes = []; - const pieces = ObjectGroup.groupObjects(this.getPieces()); - const lastIndex = pieces.length - 1; - for (let index = 0; index < pieces.length; index++) { - const piece = pieces[index]; - const context = {}; - if (index === 0) { - context.isFirst = true; - } - if (index === lastIndex) { - context.isLast = true; - } - if (endsWithWhitespace(previousPiece)) { - context.followsWhitespace = true; - } - const view = this.findOrCreateCachedChildView(PieceView, piece, { - textConfig: this.textConfig, - context - }); - nodes.push(...Array.from(view.getNodes() || [])); - var previousPiece = piece; - } - return nodes; - } - getPieces() { - return Array.from(this.text.getPieces()).filter(piece => !piece.hasAttribute("blockBreak")); - } - } - const endsWithWhitespace = piece => /\s$/.test(piece === null || piece === void 0 ? void 0 : piece.toString()); - - const { - css: css$1 - } = config; - class BlockView extends ObjectView { - constructor() { - super(...arguments); - this.block = this.object; - this.attributes = this.block.getAttributes(); - } - createNodes() { - const comment = document.createComment("block"); - const nodes = [comment]; - if (this.block.isEmpty()) { - nodes.push(makeElement("br")); - } else { - var _getBlockConfig; - const textConfig = (_getBlockConfig = getBlockConfig(this.block.getLastAttribute())) === null || _getBlockConfig === void 0 ? void 0 : _getBlockConfig.text; - const textView = this.findOrCreateCachedChildView(TextView, this.block.text, { - textConfig - }); - nodes.push(...Array.from(textView.getNodes() || [])); - if (this.shouldAddExtraNewlineElement()) { - nodes.push(makeElement("br")); - } - } - if (this.attributes.length) { - return nodes; - } else { - let attributes$1; - const { - tagName - } = attributes.default; - if (this.block.isRTL()) { - attributes$1 = { - dir: "rtl" - }; - } - const element = makeElement({ - tagName, - attributes: attributes$1 - }); - nodes.forEach(node => element.appendChild(node)); - return [element]; - } - } - createContainerElement(depth) { - const attributes = {}; - let className; - const attributeName = this.attributes[depth]; - const { - tagName, - htmlAttributes = [] - } = getBlockConfig(attributeName); - if (depth === 0 && this.block.isRTL()) { - Object.assign(attributes, { - dir: "rtl" - }); - } - if (attributeName === "attachmentGallery") { - const size = this.block.getBlockBreakPosition(); - className = "".concat(css$1.attachmentGallery, " ").concat(css$1.attachmentGallery, "--").concat(size); - } - Object.entries(this.block.htmlAttributes).forEach(_ref => { - let [name, value] = _ref; - if (htmlAttributes.includes(name)) { - attributes[name] = value; - } - }); - return makeElement({ - tagName, - className, - attributes - }); - } - - // A single
at the end of a block element has no visual representation - // so add an extra one. - shouldAddExtraNewlineElement() { - return /\n\n$/.test(this.block.toString()); - } - } - - class DocumentView extends ObjectView { - static render(document) { - const element = makeElement("div"); - const view = new this(document, { - element - }); - view.render(); - view.sync(); - return element; - } - constructor() { - super(...arguments); - this.element = this.options.element; - this.elementStore = new ElementStore(); - this.setDocument(this.object); - } - setDocument(document) { - if (!document.isEqualTo(this.document)) { - this.document = this.object = document; - } - } - render() { - this.childViews = []; - this.shadowElement = makeElement("div"); - if (!this.document.isEmpty()) { - const objects = ObjectGroup.groupObjects(this.document.getBlocks(), { - asTree: true - }); - Array.from(objects).forEach(object => { - const view = this.findOrCreateCachedChildView(BlockView, object); - Array.from(view.getNodes()).map(node => this.shadowElement.appendChild(node)); - }); - } - } - isSynced() { - return elementsHaveEqualHTML(this.shadowElement, this.element); - } - sync() { - const fragment = this.createDocumentFragmentForSync(); - while (this.element.lastChild) { - this.element.removeChild(this.element.lastChild); - } - this.element.appendChild(fragment); - return this.didSync(); - } - - // Private - - didSync() { - this.elementStore.reset(findStoredElements(this.element)); - return defer(() => this.garbageCollectCachedViews()); - } - createDocumentFragmentForSync() { - const fragment = document.createDocumentFragment(); - Array.from(this.shadowElement.childNodes).forEach(node => { - fragment.appendChild(node.cloneNode(true)); - }); - Array.from(findStoredElements(fragment)).forEach(element => { - const storedElement = this.elementStore.remove(element); - if (storedElement) { - element.parentNode.replaceChild(storedElement, element); - } - }); - return fragment; - } - } - const findStoredElements = element => element.querySelectorAll("[data-trix-store-key]"); - const elementsHaveEqualHTML = (element, otherElement) => ignoreSpaces(element.innerHTML) === ignoreSpaces(otherElement.innerHTML); - const ignoreSpaces = html => html.replace(/ /g, " "); - - function _AsyncGenerator(e) { - var r, t; - function resume(r, t) { - try { - var n = e[r](t), - o = n.value, - u = o instanceof _OverloadYield; - Promise.resolve(u ? o.v : o).then(function (t) { - if (u) { - var i = "return" === r ? "return" : "next"; - if (!o.k || t.done) return resume(i, t); - t = e[i](t).value; - } - settle(n.done ? "return" : "normal", t); - }, function (e) { - resume("throw", e); - }); - } catch (e) { - settle("throw", e); - } - } - function settle(e, n) { - switch (e) { - case "return": - r.resolve({ - value: n, - done: !0 - }); - break; - case "throw": - r.reject(n); - break; - default: - r.resolve({ - value: n, - done: !1 - }); - } - (r = r.next) ? resume(r.key, r.arg) : t = null; - } - this._invoke = function (e, n) { - return new Promise(function (o, u) { - var i = { - key: e, - arg: n, - resolve: o, - reject: u, - next: null - }; - t ? t = t.next = i : (r = t = i, resume(e, n)); - }); - }, "function" != typeof e.return && (this.return = void 0); - } - _AsyncGenerator.prototype["function" == typeof Symbol && Symbol.asyncIterator || "@@asyncIterator"] = function () { - return this; - }, _AsyncGenerator.prototype.next = function (e) { - return this._invoke("next", e); - }, _AsyncGenerator.prototype.throw = function (e) { - return this._invoke("throw", e); - }, _AsyncGenerator.prototype.return = function (e) { - return this._invoke("return", e); - }; - function _OverloadYield(t, e) { - this.v = t, this.k = e; - } - function old_createMetadataMethodsForProperty(e, t, a, r) { - return { - getMetadata: function (o) { - old_assertNotFinished(r, "getMetadata"), old_assertMetadataKey(o); - var i = e[o]; - if (void 0 !== i) if (1 === t) { - var n = i.public; - if (void 0 !== n) return n[a]; - } else if (2 === t) { - var l = i.private; - if (void 0 !== l) return l.get(a); - } else if (Object.hasOwnProperty.call(i, "constructor")) return i.constructor; - }, - setMetadata: function (o, i) { - old_assertNotFinished(r, "setMetadata"), old_assertMetadataKey(o); - var n = e[o]; - if (void 0 === n && (n = e[o] = {}), 1 === t) { - var l = n.public; - void 0 === l && (l = n.public = {}), l[a] = i; - } else if (2 === t) { - var s = n.priv; - void 0 === s && (s = n.private = new Map()), s.set(a, i); - } else n.constructor = i; - } - }; - } - function old_convertMetadataMapToFinal(e, t) { - var a = e[Symbol.metadata || Symbol.for("Symbol.metadata")], - r = Object.getOwnPropertySymbols(t); - if (0 !== r.length) { - for (var o = 0; o < r.length; o++) { - var i = r[o], - n = t[i], - l = a ? a[i] : null, - s = n.public, - c = l ? l.public : null; - s && c && Object.setPrototypeOf(s, c); - var d = n.private; - if (d) { - var u = Array.from(d.values()), - f = l ? l.private : null; - f && (u = u.concat(f)), n.private = u; - } - l && Object.setPrototypeOf(n, l); - } - a && Object.setPrototypeOf(t, a), e[Symbol.metadata || Symbol.for("Symbol.metadata")] = t; - } - } - function old_createAddInitializerMethod(e, t) { - return function (a) { - old_assertNotFinished(t, "addInitializer"), old_assertCallable(a, "An initializer"), e.push(a); - }; - } - function old_memberDec(e, t, a, r, o, i, n, l, s) { - var c; - switch (i) { - case 1: - c = "accessor"; - break; - case 2: - c = "method"; - break; - case 3: - c = "getter"; - break; - case 4: - c = "setter"; - break; - default: - c = "field"; - } - var d, - u, - f = { - kind: c, - name: l ? "#" + t : t, - isStatic: n, - isPrivate: l - }, - p = { - v: !1 - }; - if (0 !== i && (f.addInitializer = old_createAddInitializerMethod(o, p)), l) { - d = 2, u = Symbol(t); - var v = {}; - 0 === i ? (v.get = a.get, v.set = a.set) : 2 === i ? v.get = function () { - return a.value; - } : (1 !== i && 3 !== i || (v.get = function () { - return a.get.call(this); - }), 1 !== i && 4 !== i || (v.set = function (e) { - a.set.call(this, e); - })), f.access = v; - } else d = 1, u = t; - try { - return e(s, Object.assign(f, old_createMetadataMethodsForProperty(r, d, u, p))); - } finally { - p.v = !0; - } - } - function old_assertNotFinished(e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - } - function old_assertMetadataKey(e) { - if ("symbol" != typeof e) throw new TypeError("Metadata keys must be symbols, received: " + e); - } - function old_assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function old_assertValidReturnValue(e, t) { - var a = typeof t; - if (1 === e) { - if ("object" !== a || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && old_assertCallable(t.get, "accessor.get"), void 0 !== t.set && old_assertCallable(t.set, "accessor.set"), void 0 !== t.init && old_assertCallable(t.init, "accessor.init"), void 0 !== t.initializer && old_assertCallable(t.initializer, "accessor.initializer"); - } else if ("function" !== a) { - var r; - throw r = 0 === e ? "field" : 10 === e ? "class" : "method", new TypeError(r + " decorators must return a function or void 0"); - } - } - function old_getInit(e) { - var t; - return null == (t = e.init) && (t = e.initializer) && "undefined" != typeof console && console.warn(".initializer has been renamed to .init as of March 2022"), t; - } - function old_applyMemberDec(e, t, a, r, o, i, n, l, s) { - var c, - d, - u, - f, - p, - v, - h = a[0]; - if (n ? c = 0 === o || 1 === o ? { - get: a[3], - set: a[4] - } : 3 === o ? { - get: a[3] - } : 4 === o ? { - set: a[3] - } : { - value: a[3] - } : 0 !== o && (c = Object.getOwnPropertyDescriptor(t, r)), 1 === o ? u = { - get: c.get, - set: c.set - } : 2 === o ? u = c.value : 3 === o ? u = c.get : 4 === o && (u = c.set), "function" == typeof h) void 0 !== (f = old_memberDec(h, r, c, l, s, o, i, n, u)) && (old_assertValidReturnValue(o, f), 0 === o ? d = f : 1 === o ? (d = old_getInit(f), p = f.get || u.get, v = f.set || u.set, u = { - get: p, - set: v - }) : u = f);else for (var y = h.length - 1; y >= 0; y--) { - var b; - if (void 0 !== (f = old_memberDec(h[y], r, c, l, s, o, i, n, u))) old_assertValidReturnValue(o, f), 0 === o ? b = f : 1 === o ? (b = old_getInit(f), p = f.get || u.get, v = f.set || u.set, u = { - get: p, - set: v - }) : u = f, void 0 !== b && (void 0 === d ? d = b : "function" == typeof d ? d = [d, b] : d.push(b)); - } - if (0 === o || 1 === o) { - if (void 0 === d) d = function (e, t) { - return t; - };else if ("function" != typeof d) { - var g = d; - d = function (e, t) { - for (var a = t, r = 0; r < g.length; r++) a = g[r].call(e, a); - return a; - }; - } else { - var m = d; - d = function (e, t) { - return m.call(e, t); - }; - } - e.push(d); - } - 0 !== o && (1 === o ? (c.get = u.get, c.set = u.set) : 2 === o ? c.value = u : 3 === o ? c.get = u : 4 === o && (c.set = u), n ? 1 === o ? (e.push(function (e, t) { - return u.get.call(e, t); - }), e.push(function (e, t) { - return u.set.call(e, t); - })) : 2 === o ? e.push(u) : e.push(function (e, t) { - return u.call(e, t); - }) : Object.defineProperty(t, r, c)); - } - function old_applyMemberDecs(e, t, a, r, o) { - for (var i, n, l = new Map(), s = new Map(), c = 0; c < o.length; c++) { - var d = o[c]; - if (Array.isArray(d)) { - var u, - f, - p, - v = d[1], - h = d[2], - y = d.length > 3, - b = v >= 5; - if (b ? (u = t, f = r, 0 !== (v -= 5) && (p = n = n || [])) : (u = t.prototype, f = a, 0 !== v && (p = i = i || [])), 0 !== v && !y) { - var g = b ? s : l, - m = g.get(h) || 0; - if (!0 === m || 3 === m && 4 !== v || 4 === m && 3 !== v) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + h); - !m && v > 2 ? g.set(h, v) : g.set(h, !0); - } - old_applyMemberDec(e, u, d, h, v, b, y, f, p); - } - } - old_pushInitializers(e, i), old_pushInitializers(e, n); - } - function old_pushInitializers(e, t) { - t && e.push(function (e) { - for (var a = 0; a < t.length; a++) t[a].call(e); - return e; - }); - } - function old_applyClassDecs(e, t, a, r) { - if (r.length > 0) { - for (var o = [], i = t, n = t.name, l = r.length - 1; l >= 0; l--) { - var s = { - v: !1 - }; - try { - var c = Object.assign({ - kind: "class", - name: n, - addInitializer: old_createAddInitializerMethod(o, s) - }, old_createMetadataMethodsForProperty(a, 0, n, s)), - d = r[l](i, c); - } finally { - s.v = !0; - } - void 0 !== d && (old_assertValidReturnValue(10, d), i = d); - } - e.push(i, function () { - for (var e = 0; e < o.length; e++) o[e].call(i); - }); - } - } - function _applyDecs(e, t, a) { - var r = [], - o = {}, - i = {}; - return old_applyMemberDecs(r, e, i, o, t), old_convertMetadataMapToFinal(e.prototype, i), old_applyClassDecs(r, e, o, a), old_convertMetadataMapToFinal(e, o), r; - } - function applyDecs2203Factory() { - function createAddInitializerMethod(e, t) { - return function (r) { - !function (e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - }(t, "addInitializer"), assertCallable(r, "An initializer"), e.push(r); - }; - } - function memberDec(e, t, r, a, n, i, s, o) { - var c; - switch (n) { - case 1: - c = "accessor"; - break; - case 2: - c = "method"; - break; - case 3: - c = "getter"; - break; - case 4: - c = "setter"; - break; - default: - c = "field"; - } - var l, - u, - f = { - kind: c, - name: s ? "#" + t : t, - static: i, - private: s - }, - p = { - v: !1 - }; - 0 !== n && (f.addInitializer = createAddInitializerMethod(a, p)), 0 === n ? s ? (l = r.get, u = r.set) : (l = function () { - return this[t]; - }, u = function (e) { - this[t] = e; - }) : 2 === n ? l = function () { - return r.value; - } : (1 !== n && 3 !== n || (l = function () { - return r.get.call(this); - }), 1 !== n && 4 !== n || (u = function (e) { - r.set.call(this, e); - })), f.access = l && u ? { - get: l, - set: u - } : l ? { - get: l - } : { - set: u - }; - try { - return e(o, f); - } finally { - p.v = !0; - } - } - function assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function assertValidReturnValue(e, t) { - var r = typeof t; - if (1 === e) { - if ("object" !== r || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && assertCallable(t.get, "accessor.get"), void 0 !== t.set && assertCallable(t.set, "accessor.set"), void 0 !== t.init && assertCallable(t.init, "accessor.init"); - } else if ("function" !== r) { - var a; - throw a = 0 === e ? "field" : 10 === e ? "class" : "method", new TypeError(a + " decorators must return a function or void 0"); - } - } - function applyMemberDec(e, t, r, a, n, i, s, o) { - var c, - l, - u, - f, - p, - d, - h = r[0]; - if (s ? c = 0 === n || 1 === n ? { - get: r[3], - set: r[4] - } : 3 === n ? { - get: r[3] - } : 4 === n ? { - set: r[3] - } : { - value: r[3] - } : 0 !== n && (c = Object.getOwnPropertyDescriptor(t, a)), 1 === n ? u = { - get: c.get, - set: c.set - } : 2 === n ? u = c.value : 3 === n ? u = c.get : 4 === n && (u = c.set), "function" == typeof h) void 0 !== (f = memberDec(h, a, c, o, n, i, s, u)) && (assertValidReturnValue(n, f), 0 === n ? l = f : 1 === n ? (l = f.init, p = f.get || u.get, d = f.set || u.set, u = { - get: p, - set: d - }) : u = f);else for (var v = h.length - 1; v >= 0; v--) { - var g; - if (void 0 !== (f = memberDec(h[v], a, c, o, n, i, s, u))) assertValidReturnValue(n, f), 0 === n ? g = f : 1 === n ? (g = f.init, p = f.get || u.get, d = f.set || u.set, u = { - get: p, - set: d - }) : u = f, void 0 !== g && (void 0 === l ? l = g : "function" == typeof l ? l = [l, g] : l.push(g)); - } - if (0 === n || 1 === n) { - if (void 0 === l) l = function (e, t) { - return t; - };else if ("function" != typeof l) { - var y = l; - l = function (e, t) { - for (var r = t, a = 0; a < y.length; a++) r = y[a].call(e, r); - return r; - }; - } else { - var m = l; - l = function (e, t) { - return m.call(e, t); - }; - } - e.push(l); - } - 0 !== n && (1 === n ? (c.get = u.get, c.set = u.set) : 2 === n ? c.value = u : 3 === n ? c.get = u : 4 === n && (c.set = u), s ? 1 === n ? (e.push(function (e, t) { - return u.get.call(e, t); - }), e.push(function (e, t) { - return u.set.call(e, t); - })) : 2 === n ? e.push(u) : e.push(function (e, t) { - return u.call(e, t); - }) : Object.defineProperty(t, a, c)); - } - function pushInitializers(e, t) { - t && e.push(function (e) { - for (var r = 0; r < t.length; r++) t[r].call(e); - return e; - }); - } - return function (e, t, r) { - var a = []; - return function (e, t, r) { - for (var a, n, i = new Map(), s = new Map(), o = 0; o < r.length; o++) { - var c = r[o]; - if (Array.isArray(c)) { - var l, - u, - f = c[1], - p = c[2], - d = c.length > 3, - h = f >= 5; - if (h ? (l = t, 0 != (f -= 5) && (u = n = n || [])) : (l = t.prototype, 0 !== f && (u = a = a || [])), 0 !== f && !d) { - var v = h ? s : i, - g = v.get(p) || 0; - if (!0 === g || 3 === g && 4 !== f || 4 === g && 3 !== f) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + p); - !g && f > 2 ? v.set(p, f) : v.set(p, !0); - } - applyMemberDec(e, l, c, p, f, h, d, u); - } - } - pushInitializers(e, a), pushInitializers(e, n); - }(a, e, t), function (e, t, r) { - if (r.length > 0) { - for (var a = [], n = t, i = t.name, s = r.length - 1; s >= 0; s--) { - var o = { - v: !1 - }; - try { - var c = r[s](n, { - kind: "class", - name: i, - addInitializer: createAddInitializerMethod(a, o) - }); - } finally { - o.v = !0; - } - void 0 !== c && (assertValidReturnValue(10, c), n = c); - } - e.push(n, function () { - for (var e = 0; e < a.length; e++) a[e].call(n); - }); - } - }(a, e, r), a; - }; - } - var applyDecs2203Impl; - function _applyDecs2203(e, t, r) { - return (applyDecs2203Impl = applyDecs2203Impl || applyDecs2203Factory())(e, t, r); - } - function applyDecs2203RFactory() { - function createAddInitializerMethod(e, t) { - return function (r) { - !function (e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - }(t, "addInitializer"), assertCallable(r, "An initializer"), e.push(r); - }; - } - function memberDec(e, t, r, n, a, i, s, o) { - var c; - switch (a) { - case 1: - c = "accessor"; - break; - case 2: - c = "method"; - break; - case 3: - c = "getter"; - break; - case 4: - c = "setter"; - break; - default: - c = "field"; - } - var l, - u, - f = { - kind: c, - name: s ? "#" + t : t, - static: i, - private: s - }, - p = { - v: !1 - }; - 0 !== a && (f.addInitializer = createAddInitializerMethod(n, p)), 0 === a ? s ? (l = r.get, u = r.set) : (l = function () { - return this[t]; - }, u = function (e) { - this[t] = e; - }) : 2 === a ? l = function () { - return r.value; - } : (1 !== a && 3 !== a || (l = function () { - return r.get.call(this); - }), 1 !== a && 4 !== a || (u = function (e) { - r.set.call(this, e); - })), f.access = l && u ? { - get: l, - set: u - } : l ? { - get: l - } : { - set: u - }; - try { - return e(o, f); - } finally { - p.v = !0; - } - } - function assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function assertValidReturnValue(e, t) { - var r = typeof t; - if (1 === e) { - if ("object" !== r || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && assertCallable(t.get, "accessor.get"), void 0 !== t.set && assertCallable(t.set, "accessor.set"), void 0 !== t.init && assertCallable(t.init, "accessor.init"); - } else if ("function" !== r) { - var n; - throw n = 0 === e ? "field" : 10 === e ? "class" : "method", new TypeError(n + " decorators must return a function or void 0"); - } - } - function applyMemberDec(e, t, r, n, a, i, s, o) { - var c, - l, - u, - f, - p, - d, - h = r[0]; - if (s ? c = 0 === a || 1 === a ? { - get: r[3], - set: r[4] - } : 3 === a ? { - get: r[3] - } : 4 === a ? { - set: r[3] - } : { - value: r[3] - } : 0 !== a && (c = Object.getOwnPropertyDescriptor(t, n)), 1 === a ? u = { - get: c.get, - set: c.set - } : 2 === a ? u = c.value : 3 === a ? u = c.get : 4 === a && (u = c.set), "function" == typeof h) void 0 !== (f = memberDec(h, n, c, o, a, i, s, u)) && (assertValidReturnValue(a, f), 0 === a ? l = f : 1 === a ? (l = f.init, p = f.get || u.get, d = f.set || u.set, u = { - get: p, - set: d - }) : u = f);else for (var v = h.length - 1; v >= 0; v--) { - var g; - if (void 0 !== (f = memberDec(h[v], n, c, o, a, i, s, u))) assertValidReturnValue(a, f), 0 === a ? g = f : 1 === a ? (g = f.init, p = f.get || u.get, d = f.set || u.set, u = { - get: p, - set: d - }) : u = f, void 0 !== g && (void 0 === l ? l = g : "function" == typeof l ? l = [l, g] : l.push(g)); - } - if (0 === a || 1 === a) { - if (void 0 === l) l = function (e, t) { - return t; - };else if ("function" != typeof l) { - var y = l; - l = function (e, t) { - for (var r = t, n = 0; n < y.length; n++) r = y[n].call(e, r); - return r; - }; - } else { - var m = l; - l = function (e, t) { - return m.call(e, t); - }; - } - e.push(l); - } - 0 !== a && (1 === a ? (c.get = u.get, c.set = u.set) : 2 === a ? c.value = u : 3 === a ? c.get = u : 4 === a && (c.set = u), s ? 1 === a ? (e.push(function (e, t) { - return u.get.call(e, t); - }), e.push(function (e, t) { - return u.set.call(e, t); - })) : 2 === a ? e.push(u) : e.push(function (e, t) { - return u.call(e, t); - }) : Object.defineProperty(t, n, c)); - } - function applyMemberDecs(e, t) { - for (var r, n, a = [], i = new Map(), s = new Map(), o = 0; o < t.length; o++) { - var c = t[o]; - if (Array.isArray(c)) { - var l, - u, - f = c[1], - p = c[2], - d = c.length > 3, - h = f >= 5; - if (h ? (l = e, 0 !== (f -= 5) && (u = n = n || [])) : (l = e.prototype, 0 !== f && (u = r = r || [])), 0 !== f && !d) { - var v = h ? s : i, - g = v.get(p) || 0; - if (!0 === g || 3 === g && 4 !== f || 4 === g && 3 !== f) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + p); - !g && f > 2 ? v.set(p, f) : v.set(p, !0); - } - applyMemberDec(a, l, c, p, f, h, d, u); - } - } - return pushInitializers(a, r), pushInitializers(a, n), a; - } - function pushInitializers(e, t) { - t && e.push(function (e) { - for (var r = 0; r < t.length; r++) t[r].call(e); - return e; - }); - } - return function (e, t, r) { - return { - e: applyMemberDecs(e, t), - get c() { - return function (e, t) { - if (t.length > 0) { - for (var r = [], n = e, a = e.name, i = t.length - 1; i >= 0; i--) { - var s = { - v: !1 - }; - try { - var o = t[i](n, { - kind: "class", - name: a, - addInitializer: createAddInitializerMethod(r, s) - }); - } finally { - s.v = !0; - } - void 0 !== o && (assertValidReturnValue(10, o), n = o); - } - return [n, function () { - for (var e = 0; e < r.length; e++) r[e].call(n); - }]; - } - }(e, r); - } - }; - }; - } - function _applyDecs2203R(e, t, r) { - return (_applyDecs2203R = applyDecs2203RFactory())(e, t, r); - } - function applyDecs2301Factory() { - function createAddInitializerMethod(e, t) { - return function (r) { - !function (e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - }(t, "addInitializer"), assertCallable(r, "An initializer"), e.push(r); - }; - } - function assertInstanceIfPrivate(e, t) { - if (!e(t)) throw new TypeError("Attempted to access private element on non-instance"); - } - function memberDec(e, t, r, n, a, i, s, o, c) { - var u; - switch (a) { - case 1: - u = "accessor"; - break; - case 2: - u = "method"; - break; - case 3: - u = "getter"; - break; - case 4: - u = "setter"; - break; - default: - u = "field"; - } - var l, - f, - p = { - kind: u, - name: s ? "#" + t : t, - static: i, - private: s - }, - d = { - v: !1 - }; - if (0 !== a && (p.addInitializer = createAddInitializerMethod(n, d)), s || 0 !== a && 2 !== a) { - if (2 === a) l = function (e) { - return assertInstanceIfPrivate(c, e), r.value; - };else { - var h = 0 === a || 1 === a; - (h || 3 === a) && (l = s ? function (e) { - return assertInstanceIfPrivate(c, e), r.get.call(e); - } : function (e) { - return r.get.call(e); - }), (h || 4 === a) && (f = s ? function (e, t) { - assertInstanceIfPrivate(c, e), r.set.call(e, t); - } : function (e, t) { - r.set.call(e, t); - }); - } - } else l = function (e) { - return e[t]; - }, 0 === a && (f = function (e, r) { - e[t] = r; - }); - var v = s ? c.bind() : function (e) { - return t in e; - }; - p.access = l && f ? { - get: l, - set: f, - has: v - } : l ? { - get: l, - has: v - } : { - set: f, - has: v - }; - try { - return e(o, p); - } finally { - d.v = !0; - } - } - function assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function assertValidReturnValue(e, t) { - var r = typeof t; - if (1 === e) { - if ("object" !== r || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && assertCallable(t.get, "accessor.get"), void 0 !== t.set && assertCallable(t.set, "accessor.set"), void 0 !== t.init && assertCallable(t.init, "accessor.init"); - } else if ("function" !== r) { - var n; - throw n = 0 === e ? "field" : 10 === e ? "class" : "method", new TypeError(n + " decorators must return a function or void 0"); - } - } - function curryThis2(e) { - return function (t) { - e(this, t); - }; - } - function applyMemberDec(e, t, r, n, a, i, s, o, c) { - var u, - l, - f, - p, - d, - h, - v, - g = r[0]; - if (s ? u = 0 === a || 1 === a ? { - get: (p = r[3], function () { - return p(this); - }), - set: curryThis2(r[4]) - } : 3 === a ? { - get: r[3] - } : 4 === a ? { - set: r[3] - } : { - value: r[3] - } : 0 !== a && (u = Object.getOwnPropertyDescriptor(t, n)), 1 === a ? f = { - get: u.get, - set: u.set - } : 2 === a ? f = u.value : 3 === a ? f = u.get : 4 === a && (f = u.set), "function" == typeof g) void 0 !== (d = memberDec(g, n, u, o, a, i, s, f, c)) && (assertValidReturnValue(a, d), 0 === a ? l = d : 1 === a ? (l = d.init, h = d.get || f.get, v = d.set || f.set, f = { - get: h, - set: v - }) : f = d);else for (var y = g.length - 1; y >= 0; y--) { - var m; - if (void 0 !== (d = memberDec(g[y], n, u, o, a, i, s, f, c))) assertValidReturnValue(a, d), 0 === a ? m = d : 1 === a ? (m = d.init, h = d.get || f.get, v = d.set || f.set, f = { - get: h, - set: v - }) : f = d, void 0 !== m && (void 0 === l ? l = m : "function" == typeof l ? l = [l, m] : l.push(m)); - } - if (0 === a || 1 === a) { - if (void 0 === l) l = function (e, t) { - return t; - };else if ("function" != typeof l) { - var b = l; - l = function (e, t) { - for (var r = t, n = 0; n < b.length; n++) r = b[n].call(e, r); - return r; - }; - } else { - var I = l; - l = function (e, t) { - return I.call(e, t); - }; - } - e.push(l); - } - 0 !== a && (1 === a ? (u.get = f.get, u.set = f.set) : 2 === a ? u.value = f : 3 === a ? u.get = f : 4 === a && (u.set = f), s ? 1 === a ? (e.push(function (e, t) { - return f.get.call(e, t); - }), e.push(function (e, t) { - return f.set.call(e, t); - })) : 2 === a ? e.push(f) : e.push(function (e, t) { - return f.call(e, t); - }) : Object.defineProperty(t, n, u)); - } - function applyMemberDecs(e, t, r) { - for (var n, a, i, s = [], o = new Map(), c = new Map(), u = 0; u < t.length; u++) { - var l = t[u]; - if (Array.isArray(l)) { - var f, - p, - d = l[1], - h = l[2], - v = l.length > 3, - g = d >= 5, - y = r; - if (g ? (f = e, 0 !== (d -= 5) && (p = a = a || []), v && !i && (i = function (t) { - return _checkInRHS(t) === e; - }), y = i) : (f = e.prototype, 0 !== d && (p = n = n || [])), 0 !== d && !v) { - var m = g ? c : o, - b = m.get(h) || 0; - if (!0 === b || 3 === b && 4 !== d || 4 === b && 3 !== d) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + h); - !b && d > 2 ? m.set(h, d) : m.set(h, !0); - } - applyMemberDec(s, f, l, h, d, g, v, p, y); - } - } - return pushInitializers(s, n), pushInitializers(s, a), s; - } - function pushInitializers(e, t) { - t && e.push(function (e) { - for (var r = 0; r < t.length; r++) t[r].call(e); - return e; - }); - } - return function (e, t, r, n) { - return { - e: applyMemberDecs(e, t, n), - get c() { - return function (e, t) { - if (t.length > 0) { - for (var r = [], n = e, a = e.name, i = t.length - 1; i >= 0; i--) { - var s = { - v: !1 - }; - try { - var o = t[i](n, { - kind: "class", - name: a, - addInitializer: createAddInitializerMethod(r, s) - }); - } finally { - s.v = !0; - } - void 0 !== o && (assertValidReturnValue(10, o), n = o); - } - return [n, function () { - for (var e = 0; e < r.length; e++) r[e].call(n); - }]; - } - }(e, r); - } - }; - }; - } - function _applyDecs2301(e, t, r, n) { - return (_applyDecs2301 = applyDecs2301Factory())(e, t, r, n); - } - function createAddInitializerMethod(e, t) { - return function (r) { - assertNotFinished(t, "addInitializer"), assertCallable(r, "An initializer"), e.push(r); - }; - } - function assertInstanceIfPrivate(e, t) { - if (!e(t)) throw new TypeError("Attempted to access private element on non-instance"); - } - function memberDec(e, t, r, a, n, i, s, o, c, l, u) { - var f; - switch (i) { - case 1: - f = "accessor"; - break; - case 2: - f = "method"; - break; - case 3: - f = "getter"; - break; - case 4: - f = "setter"; - break; - default: - f = "field"; - } - var d, - p, - h = { - kind: f, - name: o ? "#" + r : r, - static: s, - private: o, - metadata: u - }, - v = { - v: !1 - }; - if (0 !== i && (h.addInitializer = createAddInitializerMethod(n, v)), o || 0 !== i && 2 !== i) { - if (2 === i) d = function (e) { - return assertInstanceIfPrivate(l, e), a.value; - };else { - var y = 0 === i || 1 === i; - (y || 3 === i) && (d = o ? function (e) { - return assertInstanceIfPrivate(l, e), a.get.call(e); - } : function (e) { - return a.get.call(e); - }), (y || 4 === i) && (p = o ? function (e, t) { - assertInstanceIfPrivate(l, e), a.set.call(e, t); - } : function (e, t) { - a.set.call(e, t); - }); - } - } else d = function (e) { - return e[r]; - }, 0 === i && (p = function (e, t) { - e[r] = t; - }); - var m = o ? l.bind() : function (e) { - return r in e; - }; - h.access = d && p ? { - get: d, - set: p, - has: m - } : d ? { - get: d, - has: m - } : { - set: p, - has: m - }; - try { - return e.call(t, c, h); - } finally { - v.v = !0; - } - } - function assertNotFinished(e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - } - function assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function assertValidReturnValue(e, t) { - var r = typeof t; - if (1 === e) { - if ("object" !== r || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && assertCallable(t.get, "accessor.get"), void 0 !== t.set && assertCallable(t.set, "accessor.set"), void 0 !== t.init && assertCallable(t.init, "accessor.init"); - } else if ("function" !== r) { - var a; - throw a = 0 === e ? "field" : 5 === e ? "class" : "method", new TypeError(a + " decorators must return a function or void 0"); - } - } - function curryThis1(e) { - return function () { - return e(this); - }; - } - function curryThis2(e) { - return function (t) { - e(this, t); - }; - } - function applyMemberDec(e, t, r, a, n, i, s, o, c, l, u) { - var f, - d, - p, - h, - v, - y, - m = r[0]; - a || Array.isArray(m) || (m = [m]), o ? f = 0 === i || 1 === i ? { - get: curryThis1(r[3]), - set: curryThis2(r[4]) - } : 3 === i ? { - get: r[3] - } : 4 === i ? { - set: r[3] - } : { - value: r[3] - } : 0 !== i && (f = Object.getOwnPropertyDescriptor(t, n)), 1 === i ? p = { - get: f.get, - set: f.set - } : 2 === i ? p = f.value : 3 === i ? p = f.get : 4 === i && (p = f.set); - for (var g = a ? 2 : 1, b = m.length - 1; b >= 0; b -= g) { - var I; - if (void 0 !== (h = memberDec(m[b], a ? m[b - 1] : void 0, n, f, c, i, s, o, p, l, u))) assertValidReturnValue(i, h), 0 === i ? I = h : 1 === i ? (I = h.init, v = h.get || p.get, y = h.set || p.set, p = { - get: v, - set: y - }) : p = h, void 0 !== I && (void 0 === d ? d = I : "function" == typeof d ? d = [d, I] : d.push(I)); - } - if (0 === i || 1 === i) { - if (void 0 === d) d = function (e, t) { - return t; - };else if ("function" != typeof d) { - var w = d; - d = function (e, t) { - for (var r = t, a = w.length - 1; a >= 0; a--) r = w[a].call(e, r); - return r; - }; - } else { - var M = d; - d = function (e, t) { - return M.call(e, t); - }; - } - e.push(d); - } - 0 !== i && (1 === i ? (f.get = p.get, f.set = p.set) : 2 === i ? f.value = p : 3 === i ? f.get = p : 4 === i && (f.set = p), o ? 1 === i ? (e.push(function (e, t) { - return p.get.call(e, t); - }), e.push(function (e, t) { - return p.set.call(e, t); - })) : 2 === i ? e.push(p) : e.push(function (e, t) { - return p.call(e, t); - }) : Object.defineProperty(t, n, f)); - } - function applyMemberDecs(e, t, r, a) { - for (var n, i, s, o = [], c = new Map(), l = new Map(), u = 0; u < t.length; u++) { - var f = t[u]; - if (Array.isArray(f)) { - var d, - p, - h = f[1], - v = f[2], - y = f.length > 3, - m = 16 & h, - g = !!(8 & h), - b = r; - if (h &= 7, g ? (d = e, 0 !== h && (p = i = i || []), y && !s && (s = function (t) { - return _checkInRHS(t) === e; - }), b = s) : (d = e.prototype, 0 !== h && (p = n = n || [])), 0 !== h && !y) { - var I = g ? l : c, - w = I.get(v) || 0; - if (!0 === w || 3 === w && 4 !== h || 4 === w && 3 !== h) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + v); - I.set(v, !(!w && h > 2) || h); - } - applyMemberDec(o, d, f, m, v, h, g, y, p, b, a); - } - } - return pushInitializers(o, n), pushInitializers(o, i), o; - } - function pushInitializers(e, t) { - t && e.push(function (e) { - for (var r = 0; r < t.length; r++) t[r].call(e); - return e; - }); - } - function applyClassDecs(e, t, r, a) { - if (t.length) { - for (var n = [], i = e, s = e.name, o = r ? 2 : 1, c = t.length - 1; c >= 0; c -= o) { - var l = { - v: !1 - }; - try { - var u = t[c].call(r ? t[c - 1] : void 0, i, { - kind: "class", - name: s, - addInitializer: createAddInitializerMethod(n, l), - metadata: a - }); - } finally { - l.v = !0; - } - void 0 !== u && (assertValidReturnValue(5, u), i = u); - } - return [defineMetadata(i, a), function () { - for (var e = 0; e < n.length; e++) n[e].call(i); - }]; - } - } - function defineMetadata(e, t) { - return Object.defineProperty(e, Symbol.metadata || Symbol.for("Symbol.metadata"), { - configurable: !0, - enumerable: !0, - value: t - }); - } - function _applyDecs2305(e, t, r, a, n, i) { - if (arguments.length >= 6) var s = i[Symbol.metadata || Symbol.for("Symbol.metadata")]; - var o = Object.create(void 0 === s ? null : s), - c = applyMemberDecs(e, t, n, o); - return r.length || defineMetadata(e, o), { - e: c, - get c() { - return applyClassDecs(e, r, a, o); - } - }; - } - function _asyncGeneratorDelegate(t) { - var e = {}, - n = !1; - function pump(e, r) { - return n = !0, r = new Promise(function (n) { - n(t[e](r)); - }), { - done: !1, - value: new _OverloadYield(r, 1) - }; - } - return e["undefined" != typeof Symbol && Symbol.iterator || "@@iterator"] = function () { - return this; - }, e.next = function (t) { - return n ? (n = !1, t) : pump("next", t); - }, "function" == typeof t.throw && (e.throw = function (t) { - if (n) throw n = !1, t; - return pump("throw", t); - }), "function" == typeof t.return && (e.return = function (t) { - return n ? (n = !1, t) : pump("return", t); - }), e; - } - function _asyncIterator(r) { - var n, - t, - o, - e = 2; - for ("undefined" != typeof Symbol && (t = Symbol.asyncIterator, o = Symbol.iterator); e--;) { - if (t && null != (n = r[t])) return n.call(r); - if (o && null != (n = r[o])) return new AsyncFromSyncIterator(n.call(r)); - t = "@@asyncIterator", o = "@@iterator"; - } - throw new TypeError("Object is not async iterable"); - } - function AsyncFromSyncIterator(r) { - function AsyncFromSyncIteratorContinuation(r) { - if (Object(r) !== r) return Promise.reject(new TypeError(r + " is not an object.")); - var n = r.done; - return Promise.resolve(r.value).then(function (r) { - return { - value: r, - done: n - }; - }); - } - return AsyncFromSyncIterator = function (r) { - this.s = r, this.n = r.next; - }, AsyncFromSyncIterator.prototype = { - s: null, - n: null, - next: function () { - return AsyncFromSyncIteratorContinuation(this.n.apply(this.s, arguments)); - }, - return: function (r) { - var n = this.s.return; - return void 0 === n ? Promise.resolve({ - value: r, - done: !0 - }) : AsyncFromSyncIteratorContinuation(n.apply(this.s, arguments)); - }, - throw: function (r) { - var n = this.s.return; - return void 0 === n ? Promise.reject(r) : AsyncFromSyncIteratorContinuation(n.apply(this.s, arguments)); - } - }, new AsyncFromSyncIterator(r); - } - function _awaitAsyncGenerator(e) { - return new _OverloadYield(e, 0); - } - function _checkInRHS(e) { - if (Object(e) !== e) throw TypeError("right-hand side of 'in' should be an object, got " + (null !== e ? typeof e : "null")); - return e; - } - function _defineAccessor(e, r, n, t) { - var c = { - configurable: !0, - enumerable: !0 - }; - return c[e] = t, Object.defineProperty(r, n, c); - } - function dispose_SuppressedError(r, e) { - return "undefined" != typeof SuppressedError ? dispose_SuppressedError = SuppressedError : (dispose_SuppressedError = function (r, e) { - this.suppressed = r, this.error = e, this.stack = new Error().stack; - }, dispose_SuppressedError.prototype = Object.create(Error.prototype, { - constructor: { - value: dispose_SuppressedError, - writable: !0, - configurable: !0 - } - })), new dispose_SuppressedError(r, e); - } - function _dispose(r, e, s) { - function next() { - for (; r.length > 0;) try { - var o = r.pop(), - p = o.d.call(o.v); - if (o.a) return Promise.resolve(p).then(next, err); - } catch (r) { - return err(r); - } - if (s) throw e; - } - function err(r) { - return e = s ? new dispose_SuppressedError(r, e) : r, s = !0, next(); - } - return next(); - } - function _importDeferProxy(e) { - var t = null, - constValue = function (e) { - return function () { - return e; - }; - }, - proxy = function (r) { - return function (n, o, f) { - return null === t && (t = e()), r(t, o, f); - }; - }; - return new Proxy({}, { - defineProperty: constValue(!1), - deleteProperty: constValue(!1), - get: proxy(Reflect.get), - getOwnPropertyDescriptor: proxy(Reflect.getOwnPropertyDescriptor), - getPrototypeOf: constValue(null), - isExtensible: constValue(!1), - has: proxy(Reflect.has), - ownKeys: proxy(Reflect.ownKeys), - preventExtensions: constValue(!0), - set: constValue(!1), - setPrototypeOf: constValue(!1) - }); - } - function _iterableToArrayLimit(r, l) { - var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; - if (null != t) { - var e, - n, - i, - u, - a = [], - f = !0, - o = !1; - try { - if (i = (t = t.call(r)).next, 0 === l) { - if (Object(t) !== t) return; - f = !1; - } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); - } catch (r) { - o = !0, n = r; - } finally { - try { - if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return; - } finally { - if (o) throw n; - } - } - return a; - } - } - function _iterableToArrayLimitLoose(e, r) { - var t = e && ("undefined" != typeof Symbol && e[Symbol.iterator] || e["@@iterator"]); - if (null != t) { - var o, - l = []; - for (t = t.call(e); e.length < r && !(o = t.next()).done;) l.push(o.value); - return l; - } - } - var REACT_ELEMENT_TYPE; - function _jsx(e, r, E, l) { - REACT_ELEMENT_TYPE || (REACT_ELEMENT_TYPE = "function" == typeof Symbol && Symbol.for && Symbol.for("react.element") || 60103); - var o = e && e.defaultProps, - n = arguments.length - 3; - if (r || 0 === n || (r = { - children: void 0 - }), 1 === n) r.children = l;else if (n > 1) { - for (var t = new Array(n), f = 0; f < n; f++) t[f] = arguments[f + 3]; - r.children = t; - } - if (r && o) for (var i in o) void 0 === r[i] && (r[i] = o[i]);else r || (r = o || {}); - return { - $$typeof: REACT_ELEMENT_TYPE, - type: e, - key: void 0 === E ? null : "" + E, - ref: null, - props: r, - _owner: null - }; - } - function ownKeys(e, r) { - var t = Object.keys(e); - if (Object.getOwnPropertySymbols) { - var o = Object.getOwnPropertySymbols(e); - r && (o = o.filter(function (r) { - return Object.getOwnPropertyDescriptor(e, r).enumerable; - })), t.push.apply(t, o); - } - return t; - } - function _objectSpread2(e) { - for (var r = 1; r < arguments.length; r++) { - var t = null != arguments[r] ? arguments[r] : {}; - r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { - _defineProperty(e, r, t[r]); - }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { - Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); - }); - } - return e; - } - function _regeneratorRuntime() { - "use strict"; /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ - _regeneratorRuntime = function () { - return e; - }; - var t, - e = {}, - r = Object.prototype, - n = r.hasOwnProperty, - o = Object.defineProperty || function (t, e, r) { - t[e] = r.value; - }, - i = "function" == typeof Symbol ? Symbol : {}, - a = i.iterator || "@@iterator", - c = i.asyncIterator || "@@asyncIterator", - u = i.toStringTag || "@@toStringTag"; - function define(t, e, r) { - return Object.defineProperty(t, e, { - value: r, - enumerable: !0, - configurable: !0, - writable: !0 - }), t[e]; - } - try { - define({}, ""); - } catch (t) { - define = function (t, e, r) { - return t[e] = r; - }; - } - function wrap(t, e, r, n) { - var i = e && e.prototype instanceof Generator ? e : Generator, - a = Object.create(i.prototype), - c = new Context(n || []); - return o(a, "_invoke", { - value: makeInvokeMethod(t, r, c) - }), a; - } - function tryCatch(t, e, r) { - try { - return { - type: "normal", - arg: t.call(e, r) - }; - } catch (t) { - return { - type: "throw", - arg: t - }; - } - } - e.wrap = wrap; - var h = "suspendedStart", - l = "suspendedYield", - f = "executing", - s = "completed", - y = {}; - function Generator() {} - function GeneratorFunction() {} - function GeneratorFunctionPrototype() {} - var p = {}; - define(p, a, function () { - return this; - }); - var d = Object.getPrototypeOf, - v = d && d(d(values([]))); - v && v !== r && n.call(v, a) && (p = v); - var g = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(p); - function defineIteratorMethods(t) { - ["next", "throw", "return"].forEach(function (e) { - define(t, e, function (t) { - return this._invoke(e, t); - }); - }); - } - function AsyncIterator(t, e) { - function invoke(r, o, i, a) { - var c = tryCatch(t[r], t, o); - if ("throw" !== c.type) { - var u = c.arg, - h = u.value; - return h && "object" == typeof h && n.call(h, "__await") ? e.resolve(h.__await).then(function (t) { - invoke("next", t, i, a); - }, function (t) { - invoke("throw", t, i, a); - }) : e.resolve(h).then(function (t) { - u.value = t, i(u); - }, function (t) { - return invoke("throw", t, i, a); - }); - } - a(c.arg); - } - var r; - o(this, "_invoke", { - value: function (t, n) { - function callInvokeWithMethodAndArg() { - return new e(function (e, r) { - invoke(t, n, e, r); - }); - } - return r = r ? r.then(callInvokeWithMethodAndArg, callInvokeWithMethodAndArg) : callInvokeWithMethodAndArg(); - } - }); - } - function makeInvokeMethod(e, r, n) { - var o = h; - return function (i, a) { - if (o === f) throw new Error("Generator is already running"); - if (o === s) { - if ("throw" === i) throw a; - return { - value: t, - done: !0 - }; - } - for (n.method = i, n.arg = a;;) { - var c = n.delegate; - if (c) { - var u = maybeInvokeDelegate(c, n); - if (u) { - if (u === y) continue; - return u; - } - } - if ("next" === n.method) n.sent = n._sent = n.arg;else if ("throw" === n.method) { - if (o === h) throw o = s, n.arg; - n.dispatchException(n.arg); - } else "return" === n.method && n.abrupt("return", n.arg); - o = f; - var p = tryCatch(e, r, n); - if ("normal" === p.type) { - if (o = n.done ? s : l, p.arg === y) continue; - return { - value: p.arg, - done: n.done - }; - } - "throw" === p.type && (o = s, n.method = "throw", n.arg = p.arg); - } - }; - } - function maybeInvokeDelegate(e, r) { - var n = r.method, - o = e.iterator[n]; - if (o === t) return r.delegate = null, "throw" === n && e.iterator.return && (r.method = "return", r.arg = t, maybeInvokeDelegate(e, r), "throw" === r.method) || "return" !== n && (r.method = "throw", r.arg = new TypeError("The iterator does not provide a '" + n + "' method")), y; - var i = tryCatch(o, e.iterator, r.arg); - if ("throw" === i.type) return r.method = "throw", r.arg = i.arg, r.delegate = null, y; - var a = i.arg; - return a ? a.done ? (r[e.resultName] = a.value, r.next = e.nextLoc, "return" !== r.method && (r.method = "next", r.arg = t), r.delegate = null, y) : a : (r.method = "throw", r.arg = new TypeError("iterator result is not an object"), r.delegate = null, y); - } - function pushTryEntry(t) { - var e = { - tryLoc: t[0] - }; - 1 in t && (e.catchLoc = t[1]), 2 in t && (e.finallyLoc = t[2], e.afterLoc = t[3]), this.tryEntries.push(e); - } - function resetTryEntry(t) { - var e = t.completion || {}; - e.type = "normal", delete e.arg, t.completion = e; - } - function Context(t) { - this.tryEntries = [{ - tryLoc: "root" - }], t.forEach(pushTryEntry, this), this.reset(!0); - } - function values(e) { - if (e || "" === e) { - var r = e[a]; - if (r) return r.call(e); - if ("function" == typeof e.next) return e; - if (!isNaN(e.length)) { - var o = -1, - i = function next() { - for (; ++o < e.length;) if (n.call(e, o)) return next.value = e[o], next.done = !1, next; - return next.value = t, next.done = !0, next; - }; - return i.next = i; - } - } - throw new TypeError(typeof e + " is not iterable"); - } - return GeneratorFunction.prototype = GeneratorFunctionPrototype, o(g, "constructor", { - value: GeneratorFunctionPrototype, - configurable: !0 - }), o(GeneratorFunctionPrototype, "constructor", { - value: GeneratorFunction, - configurable: !0 - }), GeneratorFunction.displayName = define(GeneratorFunctionPrototype, u, "GeneratorFunction"), e.isGeneratorFunction = function (t) { - var e = "function" == typeof t && t.constructor; - return !!e && (e === GeneratorFunction || "GeneratorFunction" === (e.displayName || e.name)); - }, e.mark = function (t) { - return Object.setPrototypeOf ? Object.setPrototypeOf(t, GeneratorFunctionPrototype) : (t.__proto__ = GeneratorFunctionPrototype, define(t, u, "GeneratorFunction")), t.prototype = Object.create(g), t; - }, e.awrap = function (t) { - return { - __await: t - }; - }, defineIteratorMethods(AsyncIterator.prototype), define(AsyncIterator.prototype, c, function () { - return this; - }), e.AsyncIterator = AsyncIterator, e.async = function (t, r, n, o, i) { - void 0 === i && (i = Promise); - var a = new AsyncIterator(wrap(t, r, n, o), i); - return e.isGeneratorFunction(r) ? a : a.next().then(function (t) { - return t.done ? t.value : a.next(); - }); - }, defineIteratorMethods(g), define(g, u, "Generator"), define(g, a, function () { - return this; - }), define(g, "toString", function () { - return "[object Generator]"; - }), e.keys = function (t) { - var e = Object(t), - r = []; - for (var n in e) r.push(n); - return r.reverse(), function next() { - for (; r.length;) { - var t = r.pop(); - if (t in e) return next.value = t, next.done = !1, next; - } - return next.done = !0, next; - }; - }, e.values = values, Context.prototype = { - constructor: Context, - reset: function (e) { - if (this.prev = 0, this.next = 0, this.sent = this._sent = t, this.done = !1, this.delegate = null, this.method = "next", this.arg = t, this.tryEntries.forEach(resetTryEntry), !e) for (var r in this) "t" === r.charAt(0) && n.call(this, r) && !isNaN(+r.slice(1)) && (this[r] = t); - }, - stop: function () { - this.done = !0; - var t = this.tryEntries[0].completion; - if ("throw" === t.type) throw t.arg; - return this.rval; - }, - dispatchException: function (e) { - if (this.done) throw e; - var r = this; - function handle(n, o) { - return a.type = "throw", a.arg = e, r.next = n, o && (r.method = "next", r.arg = t), !!o; - } - for (var o = this.tryEntries.length - 1; o >= 0; --o) { - var i = this.tryEntries[o], - a = i.completion; - if ("root" === i.tryLoc) return handle("end"); - if (i.tryLoc <= this.prev) { - var c = n.call(i, "catchLoc"), - u = n.call(i, "finallyLoc"); - if (c && u) { - if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); - if (this.prev < i.finallyLoc) return handle(i.finallyLoc); - } else if (c) { - if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); - } else { - if (!u) throw new Error("try statement without catch or finally"); - if (this.prev < i.finallyLoc) return handle(i.finallyLoc); - } - } - } - }, - abrupt: function (t, e) { - for (var r = this.tryEntries.length - 1; r >= 0; --r) { - var o = this.tryEntries[r]; - if (o.tryLoc <= this.prev && n.call(o, "finallyLoc") && this.prev < o.finallyLoc) { - var i = o; - break; - } - } - i && ("break" === t || "continue" === t) && i.tryLoc <= e && e <= i.finallyLoc && (i = null); - var a = i ? i.completion : {}; - return a.type = t, a.arg = e, i ? (this.method = "next", this.next = i.finallyLoc, y) : this.complete(a); - }, - complete: function (t, e) { - if ("throw" === t.type) throw t.arg; - return "break" === t.type || "continue" === t.type ? this.next = t.arg : "return" === t.type ? (this.rval = this.arg = t.arg, this.method = "return", this.next = "end") : "normal" === t.type && e && (this.next = e), y; - }, - finish: function (t) { - for (var e = this.tryEntries.length - 1; e >= 0; --e) { - var r = this.tryEntries[e]; - if (r.finallyLoc === t) return this.complete(r.completion, r.afterLoc), resetTryEntry(r), y; - } - }, - catch: function (t) { - for (var e = this.tryEntries.length - 1; e >= 0; --e) { - var r = this.tryEntries[e]; - if (r.tryLoc === t) { - var n = r.completion; - if ("throw" === n.type) { - var o = n.arg; - resetTryEntry(r); - } - return o; - } - } - throw new Error("illegal catch attempt"); - }, - delegateYield: function (e, r, n) { - return this.delegate = { - iterator: values(e), - resultName: r, - nextLoc: n - }, "next" === this.method && (this.arg = t), y; - } - }, e; - } - function _typeof(o) { - "@babel/helpers - typeof"; - - return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { - return typeof o; - } : function (o) { - return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; - }, _typeof(o); - } - function _using(o, e, n) { - if (null == e) return e; - if ("object" != typeof e) throw new TypeError("using declarations can only be used with objects, null, or undefined."); - if (n) var r = e[Symbol.asyncDispose || Symbol.for("Symbol.asyncDispose")]; - if (null == r && (r = e[Symbol.dispose || Symbol.for("Symbol.dispose")]), "function" != typeof r) throw new TypeError("Property [Symbol.dispose] is not a function."); - return o.push({ - v: e, - d: r, - a: n - }), e; - } - function _wrapRegExp() { - _wrapRegExp = function (e, r) { - return new BabelRegExp(e, void 0, r); - }; - var e = RegExp.prototype, - r = new WeakMap(); - function BabelRegExp(e, t, p) { - var o = new RegExp(e, t); - return r.set(o, p || r.get(e)), _setPrototypeOf(o, BabelRegExp.prototype); - } - function buildGroups(e, t) { - var p = r.get(t); - return Object.keys(p).reduce(function (r, t) { - var o = p[t]; - if ("number" == typeof o) r[t] = e[o];else { - for (var i = 0; void 0 === e[o[i]] && i + 1 < o.length;) i++; - r[t] = e[o[i]]; - } - return r; - }, Object.create(null)); - } - return _inherits(BabelRegExp, RegExp), BabelRegExp.prototype.exec = function (r) { - var t = e.exec.call(this, r); - if (t) { - t.groups = buildGroups(t, this); - var p = t.indices; - p && (p.groups = buildGroups(p, this)); - } - return t; - }, BabelRegExp.prototype[Symbol.replace] = function (t, p) { - if ("string" == typeof p) { - var o = r.get(this); - return e[Symbol.replace].call(this, t, p.replace(/\$<([^>]+)>/g, function (e, r) { - var t = o[r]; - return "$" + (Array.isArray(t) ? t.join("$") : t); - })); - } - if ("function" == typeof p) { - var i = this; - return e[Symbol.replace].call(this, t, function () { - var e = arguments; - return "object" != typeof e[e.length - 1] && (e = [].slice.call(e)).push(buildGroups(e, i)), p.apply(this, e); - }); - } - return e[Symbol.replace].call(this, t, p); - }, _wrapRegExp.apply(this, arguments); - } - function _AwaitValue(value) { - this.wrapped = value; - } - function _wrapAsyncGenerator(fn) { - return function () { - return new _AsyncGenerator(fn.apply(this, arguments)); - }; - } - function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { - try { - var info = gen[key](arg); - var value = info.value; - } catch (error) { - reject(error); - return; - } - if (info.done) { - resolve(value); - } else { - Promise.resolve(value).then(_next, _throw); - } - } - function _asyncToGenerator(fn) { - return function () { - var self = this, - args = arguments; - return new Promise(function (resolve, reject) { - var gen = fn.apply(self, args); - function _next(value) { - asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); - } - function _throw(err) { - asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); - } - _next(undefined); - }); - }; - } - function _classCallCheck(instance, Constructor) { - if (!(instance instanceof Constructor)) { - throw new TypeError("Cannot call a class as a function"); - } - } - function _defineProperties(target, props) { - for (var i = 0; i < props.length; i++) { - var descriptor = props[i]; - descriptor.enumerable = descriptor.enumerable || false; - descriptor.configurable = true; - if ("value" in descriptor) descriptor.writable = true; - Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); - } - } - function _createClass(Constructor, protoProps, staticProps) { - if (protoProps) _defineProperties(Constructor.prototype, protoProps); - if (staticProps) _defineProperties(Constructor, staticProps); - Object.defineProperty(Constructor, "prototype", { - writable: false - }); - return Constructor; - } - function _defineEnumerableProperties(obj, descs) { - for (var key in descs) { - var desc = descs[key]; - desc.configurable = desc.enumerable = true; - if ("value" in desc) desc.writable = true; - Object.defineProperty(obj, key, desc); - } - if (Object.getOwnPropertySymbols) { - var objectSymbols = Object.getOwnPropertySymbols(descs); - for (var i = 0; i < objectSymbols.length; i++) { - var sym = objectSymbols[i]; - var desc = descs[sym]; - desc.configurable = desc.enumerable = true; - if ("value" in desc) desc.writable = true; - Object.defineProperty(obj, sym, desc); - } - } - return obj; - } - function _defaults(obj, defaults) { - var keys = Object.getOwnPropertyNames(defaults); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - var value = Object.getOwnPropertyDescriptor(defaults, key); - if (value && value.configurable && obj[key] === undefined) { - Object.defineProperty(obj, key, value); - } - } - return obj; - } - function _defineProperty(obj, key, value) { - key = _toPropertyKey(key); - if (key in obj) { - Object.defineProperty(obj, key, { - value: value, - enumerable: true, - configurable: true, - writable: true - }); - } else { - obj[key] = value; - } - return obj; - } - function _extends() { - _extends = Object.assign ? Object.assign.bind() : function (target) { - for (var i = 1; i < arguments.length; i++) { - var source = arguments[i]; - for (var key in source) { - if (Object.prototype.hasOwnProperty.call(source, key)) { - target[key] = source[key]; - } - } - } - return target; - }; - return _extends.apply(this, arguments); - } - function _objectSpread(target) { - for (var i = 1; i < arguments.length; i++) { - var source = arguments[i] != null ? Object(arguments[i]) : {}; - var ownKeys = Object.keys(source); - if (typeof Object.getOwnPropertySymbols === 'function') { - ownKeys.push.apply(ownKeys, Object.getOwnPropertySymbols(source).filter(function (sym) { - return Object.getOwnPropertyDescriptor(source, sym).enumerable; - })); - } - ownKeys.forEach(function (key) { - _defineProperty(target, key, source[key]); - }); - } - return target; - } - function _inherits(subClass, superClass) { - if (typeof superClass !== "function" && superClass !== null) { - throw new TypeError("Super expression must either be null or a function"); - } - subClass.prototype = Object.create(superClass && superClass.prototype, { - constructor: { - value: subClass, - writable: true, - configurable: true - } - }); - Object.defineProperty(subClass, "prototype", { - writable: false - }); - if (superClass) _setPrototypeOf(subClass, superClass); - } - function _inheritsLoose(subClass, superClass) { - subClass.prototype = Object.create(superClass.prototype); - subClass.prototype.constructor = subClass; - _setPrototypeOf(subClass, superClass); - } - function _getPrototypeOf(o) { - _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function _getPrototypeOf(o) { - return o.__proto__ || Object.getPrototypeOf(o); - }; - return _getPrototypeOf(o); - } - function _setPrototypeOf(o, p) { - _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function _setPrototypeOf(o, p) { - o.__proto__ = p; - return o; - }; - return _setPrototypeOf(o, p); - } - function _isNativeReflectConstruct() { - if (typeof Reflect === "undefined" || !Reflect.construct) return false; - if (Reflect.construct.sham) return false; - if (typeof Proxy === "function") return true; - try { - Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); - return true; - } catch (e) { - return false; - } - } - function _construct(Parent, args, Class) { - if (_isNativeReflectConstruct()) { - _construct = Reflect.construct.bind(); - } else { - _construct = function _construct(Parent, args, Class) { - var a = [null]; - a.push.apply(a, args); - var Constructor = Function.bind.apply(Parent, a); - var instance = new Constructor(); - if (Class) _setPrototypeOf(instance, Class.prototype); - return instance; - }; - } - return _construct.apply(null, arguments); - } - function _isNativeFunction(fn) { - return Function.toString.call(fn).indexOf("[native code]") !== -1; - } - function _wrapNativeSuper(Class) { - var _cache = typeof Map === "function" ? new Map() : undefined; - _wrapNativeSuper = function _wrapNativeSuper(Class) { - if (Class === null || !_isNativeFunction(Class)) return Class; - if (typeof Class !== "function") { - throw new TypeError("Super expression must either be null or a function"); - } - if (typeof _cache !== "undefined") { - if (_cache.has(Class)) return _cache.get(Class); - _cache.set(Class, Wrapper); - } - function Wrapper() { - return _construct(Class, arguments, _getPrototypeOf(this).constructor); - } - Wrapper.prototype = Object.create(Class.prototype, { - constructor: { - value: Wrapper, - enumerable: false, - writable: true, - configurable: true - } - }); - return _setPrototypeOf(Wrapper, Class); - }; - return _wrapNativeSuper(Class); - } - function _instanceof(left, right) { - if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) { - return !!right[Symbol.hasInstance](left); - } else { - return left instanceof right; - } - } - function _interopRequireDefault(obj) { - return obj && obj.__esModule ? obj : { - default: obj - }; - } - function _getRequireWildcardCache(nodeInterop) { - if (typeof WeakMap !== "function") return null; - var cacheBabelInterop = new WeakMap(); - var cacheNodeInterop = new WeakMap(); - return (_getRequireWildcardCache = function (nodeInterop) { - return nodeInterop ? cacheNodeInterop : cacheBabelInterop; - })(nodeInterop); - } - function _interopRequireWildcard(obj, nodeInterop) { - if (!nodeInterop && obj && obj.__esModule) { - return obj; - } - if (obj === null || typeof obj !== "object" && typeof obj !== "function") { - return { - default: obj - }; - } - var cache = _getRequireWildcardCache(nodeInterop); - if (cache && cache.has(obj)) { - return cache.get(obj); - } - var newObj = {}; - var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; - for (var key in obj) { - if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { - var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; - if (desc && (desc.get || desc.set)) { - Object.defineProperty(newObj, key, desc); - } else { - newObj[key] = obj[key]; - } - } - } - newObj.default = obj; - if (cache) { - cache.set(obj, newObj); - } - return newObj; - } - function _newArrowCheck(innerThis, boundThis) { - if (innerThis !== boundThis) { - throw new TypeError("Cannot instantiate an arrow function"); - } - } - function _objectDestructuringEmpty(obj) { - if (obj == null) throw new TypeError("Cannot destructure " + obj); - } - function _objectWithoutPropertiesLoose(source, excluded) { - if (source == null) return {}; - var target = {}; - var sourceKeys = Object.keys(source); - var key, i; - for (i = 0; i < sourceKeys.length; i++) { - key = sourceKeys[i]; - if (excluded.indexOf(key) >= 0) continue; - target[key] = source[key]; - } - return target; - } - function _objectWithoutProperties(source, excluded) { - if (source == null) return {}; - var target = _objectWithoutPropertiesLoose(source, excluded); - var key, i; - if (Object.getOwnPropertySymbols) { - var sourceSymbolKeys = Object.getOwnPropertySymbols(source); - for (i = 0; i < sourceSymbolKeys.length; i++) { - key = sourceSymbolKeys[i]; - if (excluded.indexOf(key) >= 0) continue; - if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; - target[key] = source[key]; - } - } - return target; - } - function _assertThisInitialized(self) { - if (self === void 0) { - throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); - } - return self; - } - function _possibleConstructorReturn(self, call) { - if (call && (typeof call === "object" || typeof call === "function")) { - return call; - } else if (call !== void 0) { - throw new TypeError("Derived constructors may only return object or undefined"); - } - return _assertThisInitialized(self); - } - function _createSuper(Derived) { - var hasNativeReflectConstruct = _isNativeReflectConstruct(); - return function _createSuperInternal() { - var Super = _getPrototypeOf(Derived), - result; - if (hasNativeReflectConstruct) { - var NewTarget = _getPrototypeOf(this).constructor; - result = Reflect.construct(Super, arguments, NewTarget); - } else { - result = Super.apply(this, arguments); - } - return _possibleConstructorReturn(this, result); - }; - } - function _superPropBase(object, property) { - while (!Object.prototype.hasOwnProperty.call(object, property)) { - object = _getPrototypeOf(object); - if (object === null) break; - } - return object; - } - function _get() { - if (typeof Reflect !== "undefined" && Reflect.get) { - _get = Reflect.get.bind(); - } else { - _get = function _get(target, property, receiver) { - var base = _superPropBase(target, property); - if (!base) return; - var desc = Object.getOwnPropertyDescriptor(base, property); - if (desc.get) { - return desc.get.call(arguments.length < 3 ? target : receiver); - } - return desc.value; - }; - } - return _get.apply(this, arguments); - } - function set(target, property, value, receiver) { - if (typeof Reflect !== "undefined" && Reflect.set) { - set = Reflect.set; - } else { - set = function set(target, property, value, receiver) { - var base = _superPropBase(target, property); - var desc; - if (base) { - desc = Object.getOwnPropertyDescriptor(base, property); - if (desc.set) { - desc.set.call(receiver, value); - return true; - } else if (!desc.writable) { - return false; - } - } - desc = Object.getOwnPropertyDescriptor(receiver, property); - if (desc) { - if (!desc.writable) { - return false; - } - desc.value = value; - Object.defineProperty(receiver, property, desc); - } else { - _defineProperty(receiver, property, value); - } - return true; - }; - } - return set(target, property, value, receiver); - } - function _set(target, property, value, receiver, isStrict) { - var s = set(target, property, value, receiver || target); - if (!s && isStrict) { - throw new TypeError('failed to set property'); - } - return value; - } - function _taggedTemplateLiteral(strings, raw) { - if (!raw) { - raw = strings.slice(0); - } - return Object.freeze(Object.defineProperties(strings, { - raw: { - value: Object.freeze(raw) - } - })); - } - function _taggedTemplateLiteralLoose(strings, raw) { - if (!raw) { - raw = strings.slice(0); - } - strings.raw = raw; - return strings; - } - function _readOnlyError(name) { - throw new TypeError("\"" + name + "\" is read-only"); - } - function _writeOnlyError(name) { - throw new TypeError("\"" + name + "\" is write-only"); - } - function _classNameTDZError(name) { - throw new ReferenceError("Class \"" + name + "\" cannot be referenced in computed property keys."); - } - function _temporalUndefined() {} - function _tdz(name) { - throw new ReferenceError(name + " is not defined - temporal dead zone"); - } - function _temporalRef(val, name) { - return val === _temporalUndefined ? _tdz(name) : val; - } - function _slicedToArray(arr, i) { - return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); - } - function _slicedToArrayLoose(arr, i) { - return _arrayWithHoles(arr) || _iterableToArrayLimitLoose(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); - } - function _toArray(arr) { - return _arrayWithHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableRest(); - } - function _toConsumableArray(arr) { - return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); - } - function _arrayWithoutHoles(arr) { - if (Array.isArray(arr)) return _arrayLikeToArray(arr); - } - function _arrayWithHoles(arr) { - if (Array.isArray(arr)) return arr; - } - function _maybeArrayLike(next, arr, i) { - if (arr && !Array.isArray(arr) && typeof arr.length === "number") { - var len = arr.length; - return _arrayLikeToArray(arr, i !== void 0 && i < len ? i : len); - } - return next(arr, i); - } - function _iterableToArray(iter) { - if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); - } - function _unsupportedIterableToArray(o, minLen) { - if (!o) return; - if (typeof o === "string") return _arrayLikeToArray(o, minLen); - var n = Object.prototype.toString.call(o).slice(8, -1); - if (n === "Object" && o.constructor) n = o.constructor.name; - if (n === "Map" || n === "Set") return Array.from(o); - if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); - } - function _arrayLikeToArray(arr, len) { - if (len == null || len > arr.length) len = arr.length; - for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; - return arr2; - } - function _nonIterableSpread() { - throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - function _nonIterableRest() { - throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - function _createForOfIteratorHelper(o, allowArrayLike) { - var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; - if (!it) { - if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { - if (it) o = it; - var i = 0; - var F = function () {}; - return { - s: F, - n: function () { - if (i >= o.length) return { - done: true - }; - return { - done: false, - value: o[i++] - }; - }, - e: function (e) { - throw e; - }, - f: F - }; - } - throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - var normalCompletion = true, - didErr = false, - err; - return { - s: function () { - it = it.call(o); - }, - n: function () { - var step = it.next(); - normalCompletion = step.done; - return step; - }, - e: function (e) { - didErr = true; - err = e; - }, - f: function () { - try { - if (!normalCompletion && it.return != null) it.return(); - } finally { - if (didErr) throw err; - } - } - }; - } - function _createForOfIteratorHelperLoose(o, allowArrayLike) { - var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; - if (it) return (it = it.call(o)).next.bind(it); - if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { - if (it) o = it; - var i = 0; - return function () { - if (i >= o.length) return { - done: true - }; - return { - done: false, - value: o[i++] - }; - }; - } - throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - function _skipFirstGeneratorNext(fn) { - return function () { - var it = fn.apply(this, arguments); - it.next(); - return it; - }; - } - function _toPrimitive(input, hint) { - if (typeof input !== "object" || input === null) return input; - var prim = input[Symbol.toPrimitive]; - if (prim !== undefined) { - var res = prim.call(input, hint || "default"); - if (typeof res !== "object") return res; - throw new TypeError("@@toPrimitive must return a primitive value."); - } - return (hint === "string" ? String : Number)(input); - } - function _toPropertyKey(arg) { - var key = _toPrimitive(arg, "string"); - return typeof key === "symbol" ? key : String(key); - } - function _initializerWarningHelper(descriptor, context) { - throw new Error('Decorating class property failed. Please ensure that ' + 'transform-class-properties is enabled and runs after the decorators transform.'); - } - function _initializerDefineProperty(target, property, descriptor, context) { - if (!descriptor) return; - Object.defineProperty(target, property, { - enumerable: descriptor.enumerable, - configurable: descriptor.configurable, - writable: descriptor.writable, - value: descriptor.initializer ? descriptor.initializer.call(context) : void 0 - }); - } - function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) { - var desc = {}; - Object.keys(descriptor).forEach(function (key) { - desc[key] = descriptor[key]; - }); - desc.enumerable = !!desc.enumerable; - desc.configurable = !!desc.configurable; - if ('value' in desc || desc.initializer) { - desc.writable = true; - } - desc = decorators.slice().reverse().reduce(function (desc, decorator) { - return decorator(target, property, desc) || desc; - }, desc); - if (context && desc.initializer !== void 0) { - desc.value = desc.initializer ? desc.initializer.call(context) : void 0; - desc.initializer = undefined; - } - if (desc.initializer === void 0) { - Object.defineProperty(target, property, desc); - desc = null; - } - return desc; - } - var id$1 = 0; - function _classPrivateFieldLooseKey(name) { - return "__private_" + id$1++ + "_" + name; - } - function _classPrivateFieldLooseBase(receiver, privateKey) { - if (!Object.prototype.hasOwnProperty.call(receiver, privateKey)) { - throw new TypeError("attempted to use private field on non-instance"); - } - return receiver; - } - function _classPrivateFieldGet(receiver, privateMap) { - var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "get"); - return _classApplyDescriptorGet(receiver, descriptor); - } - function _classPrivateFieldSet(receiver, privateMap, value) { - var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "set"); - _classApplyDescriptorSet(receiver, descriptor, value); - return value; - } - function _classPrivateFieldDestructureSet(receiver, privateMap) { - var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "set"); - return _classApplyDescriptorDestructureSet(receiver, descriptor); - } - function _classExtractFieldDescriptor(receiver, privateMap, action) { - if (!privateMap.has(receiver)) { - throw new TypeError("attempted to " + action + " private field on non-instance"); - } - return privateMap.get(receiver); - } - function _classStaticPrivateFieldSpecGet(receiver, classConstructor, descriptor) { - _classCheckPrivateStaticAccess(receiver, classConstructor); - _classCheckPrivateStaticFieldDescriptor(descriptor, "get"); - return _classApplyDescriptorGet(receiver, descriptor); - } - function _classStaticPrivateFieldSpecSet(receiver, classConstructor, descriptor, value) { - _classCheckPrivateStaticAccess(receiver, classConstructor); - _classCheckPrivateStaticFieldDescriptor(descriptor, "set"); - _classApplyDescriptorSet(receiver, descriptor, value); - return value; - } - function _classStaticPrivateMethodGet(receiver, classConstructor, method) { - _classCheckPrivateStaticAccess(receiver, classConstructor); - return method; - } - function _classStaticPrivateMethodSet() { - throw new TypeError("attempted to set read only static private field"); - } - function _classApplyDescriptorGet(receiver, descriptor) { - if (descriptor.get) { - return descriptor.get.call(receiver); - } - return descriptor.value; - } - function _classApplyDescriptorSet(receiver, descriptor, value) { - if (descriptor.set) { - descriptor.set.call(receiver, value); - } else { - if (!descriptor.writable) { - throw new TypeError("attempted to set read only private field"); - } - descriptor.value = value; - } - } - function _classApplyDescriptorDestructureSet(receiver, descriptor) { - if (descriptor.set) { - if (!("__destrObj" in descriptor)) { - descriptor.__destrObj = { - set value(v) { - descriptor.set.call(receiver, v); - } - }; - } - return descriptor.__destrObj; - } else { - if (!descriptor.writable) { - throw new TypeError("attempted to set read only private field"); - } - return descriptor; - } - } - function _classStaticPrivateFieldDestructureSet(receiver, classConstructor, descriptor) { - _classCheckPrivateStaticAccess(receiver, classConstructor); - _classCheckPrivateStaticFieldDescriptor(descriptor, "set"); - return _classApplyDescriptorDestructureSet(receiver, descriptor); - } - function _classCheckPrivateStaticAccess(receiver, classConstructor) { - if (receiver !== classConstructor) { - throw new TypeError("Private static access of wrong provenance"); - } - } - function _classCheckPrivateStaticFieldDescriptor(descriptor, action) { - if (descriptor === undefined) { - throw new TypeError("attempted to " + action + " private static field before its declaration"); - } - } - function _decorate(decorators, factory, superClass, mixins) { - var api = _getDecoratorsApi(); - if (mixins) { - for (var i = 0; i < mixins.length; i++) { - api = mixins[i](api); - } - } - var r = factory(function initialize(O) { - api.initializeInstanceElements(O, decorated.elements); - }, superClass); - var decorated = api.decorateClass(_coalesceClassElements(r.d.map(_createElementDescriptor)), decorators); - api.initializeClassElements(r.F, decorated.elements); - return api.runClassFinishers(r.F, decorated.finishers); - } - function _getDecoratorsApi() { - _getDecoratorsApi = function () { - return api; - }; - var api = { - elementsDefinitionOrder: [["method"], ["field"]], - initializeInstanceElements: function (O, elements) { - ["method", "field"].forEach(function (kind) { - elements.forEach(function (element) { - if (element.kind === kind && element.placement === "own") { - this.defineClassElement(O, element); - } - }, this); - }, this); - }, - initializeClassElements: function (F, elements) { - var proto = F.prototype; - ["method", "field"].forEach(function (kind) { - elements.forEach(function (element) { - var placement = element.placement; - if (element.kind === kind && (placement === "static" || placement === "prototype")) { - var receiver = placement === "static" ? F : proto; - this.defineClassElement(receiver, element); - } - }, this); - }, this); - }, - defineClassElement: function (receiver, element) { - var descriptor = element.descriptor; - if (element.kind === "field") { - var initializer = element.initializer; - descriptor = { - enumerable: descriptor.enumerable, - writable: descriptor.writable, - configurable: descriptor.configurable, - value: initializer === void 0 ? void 0 : initializer.call(receiver) - }; - } - Object.defineProperty(receiver, element.key, descriptor); - }, - decorateClass: function (elements, decorators) { - var newElements = []; - var finishers = []; - var placements = { - static: [], - prototype: [], - own: [] - }; - elements.forEach(function (element) { - this.addElementPlacement(element, placements); - }, this); - elements.forEach(function (element) { - if (!_hasDecorators(element)) return newElements.push(element); - var elementFinishersExtras = this.decorateElement(element, placements); - newElements.push(elementFinishersExtras.element); - newElements.push.apply(newElements, elementFinishersExtras.extras); - finishers.push.apply(finishers, elementFinishersExtras.finishers); - }, this); - if (!decorators) { - return { - elements: newElements, - finishers: finishers - }; - } - var result = this.decorateConstructor(newElements, decorators); - finishers.push.apply(finishers, result.finishers); - result.finishers = finishers; - return result; - }, - addElementPlacement: function (element, placements, silent) { - var keys = placements[element.placement]; - if (!silent && keys.indexOf(element.key) !== -1) { - throw new TypeError("Duplicated element (" + element.key + ")"); - } - keys.push(element.key); - }, - decorateElement: function (element, placements) { - var extras = []; - var finishers = []; - for (var decorators = element.decorators, i = decorators.length - 1; i >= 0; i--) { - var keys = placements[element.placement]; - keys.splice(keys.indexOf(element.key), 1); - var elementObject = this.fromElementDescriptor(element); - var elementFinisherExtras = this.toElementFinisherExtras((0, decorators[i])(elementObject) || elementObject); - element = elementFinisherExtras.element; - this.addElementPlacement(element, placements); - if (elementFinisherExtras.finisher) { - finishers.push(elementFinisherExtras.finisher); - } - var newExtras = elementFinisherExtras.extras; - if (newExtras) { - for (var j = 0; j < newExtras.length; j++) { - this.addElementPlacement(newExtras[j], placements); - } - extras.push.apply(extras, newExtras); - } - } - return { - element: element, - finishers: finishers, - extras: extras - }; - }, - decorateConstructor: function (elements, decorators) { - var finishers = []; - for (var i = decorators.length - 1; i >= 0; i--) { - var obj = this.fromClassDescriptor(elements); - var elementsAndFinisher = this.toClassDescriptor((0, decorators[i])(obj) || obj); - if (elementsAndFinisher.finisher !== undefined) { - finishers.push(elementsAndFinisher.finisher); - } - if (elementsAndFinisher.elements !== undefined) { - elements = elementsAndFinisher.elements; - for (var j = 0; j < elements.length - 1; j++) { - for (var k = j + 1; k < elements.length; k++) { - if (elements[j].key === elements[k].key && elements[j].placement === elements[k].placement) { - throw new TypeError("Duplicated element (" + elements[j].key + ")"); - } - } - } - } - } - return { - elements: elements, - finishers: finishers - }; - }, - fromElementDescriptor: function (element) { - var obj = { - kind: element.kind, - key: element.key, - placement: element.placement, - descriptor: element.descriptor - }; - var desc = { - value: "Descriptor", - configurable: true - }; - Object.defineProperty(obj, Symbol.toStringTag, desc); - if (element.kind === "field") obj.initializer = element.initializer; - return obj; - }, - toElementDescriptors: function (elementObjects) { - if (elementObjects === undefined) return; - return _toArray(elementObjects).map(function (elementObject) { - var element = this.toElementDescriptor(elementObject); - this.disallowProperty(elementObject, "finisher", "An element descriptor"); - this.disallowProperty(elementObject, "extras", "An element descriptor"); - return element; - }, this); - }, - toElementDescriptor: function (elementObject) { - var kind = String(elementObject.kind); - if (kind !== "method" && kind !== "field") { - throw new TypeError('An element descriptor\'s .kind property must be either "method" or' + ' "field", but a decorator created an element descriptor with' + ' .kind "' + kind + '"'); - } - var key = _toPropertyKey(elementObject.key); - var placement = String(elementObject.placement); - if (placement !== "static" && placement !== "prototype" && placement !== "own") { - throw new TypeError('An element descriptor\'s .placement property must be one of "static",' + ' "prototype" or "own", but a decorator created an element descriptor' + ' with .placement "' + placement + '"'); - } - var descriptor = elementObject.descriptor; - this.disallowProperty(elementObject, "elements", "An element descriptor"); - var element = { - kind: kind, - key: key, - placement: placement, - descriptor: Object.assign({}, descriptor) - }; - if (kind !== "field") { - this.disallowProperty(elementObject, "initializer", "A method descriptor"); - } else { - this.disallowProperty(descriptor, "get", "The property descriptor of a field descriptor"); - this.disallowProperty(descriptor, "set", "The property descriptor of a field descriptor"); - this.disallowProperty(descriptor, "value", "The property descriptor of a field descriptor"); - element.initializer = elementObject.initializer; - } - return element; - }, - toElementFinisherExtras: function (elementObject) { - var element = this.toElementDescriptor(elementObject); - var finisher = _optionalCallableProperty(elementObject, "finisher"); - var extras = this.toElementDescriptors(elementObject.extras); - return { - element: element, - finisher: finisher, - extras: extras - }; - }, - fromClassDescriptor: function (elements) { - var obj = { - kind: "class", - elements: elements.map(this.fromElementDescriptor, this) - }; - var desc = { - value: "Descriptor", - configurable: true - }; - Object.defineProperty(obj, Symbol.toStringTag, desc); - return obj; - }, - toClassDescriptor: function (obj) { - var kind = String(obj.kind); - if (kind !== "class") { - throw new TypeError('A class descriptor\'s .kind property must be "class", but a decorator' + ' created a class descriptor with .kind "' + kind + '"'); - } - this.disallowProperty(obj, "key", "A class descriptor"); - this.disallowProperty(obj, "placement", "A class descriptor"); - this.disallowProperty(obj, "descriptor", "A class descriptor"); - this.disallowProperty(obj, "initializer", "A class descriptor"); - this.disallowProperty(obj, "extras", "A class descriptor"); - var finisher = _optionalCallableProperty(obj, "finisher"); - var elements = this.toElementDescriptors(obj.elements); - return { - elements: elements, - finisher: finisher - }; - }, - runClassFinishers: function (constructor, finishers) { - for (var i = 0; i < finishers.length; i++) { - var newConstructor = (0, finishers[i])(constructor); - if (newConstructor !== undefined) { - if (typeof newConstructor !== "function") { - throw new TypeError("Finishers must return a constructor."); - } - constructor = newConstructor; - } - } - return constructor; - }, - disallowProperty: function (obj, name, objectType) { - if (obj[name] !== undefined) { - throw new TypeError(objectType + " can't have a ." + name + " property."); - } - } - }; - return api; - } - function _createElementDescriptor(def) { - var key = _toPropertyKey(def.key); - var descriptor; - if (def.kind === "method") { - descriptor = { - value: def.value, - writable: true, - configurable: true, - enumerable: false - }; - } else if (def.kind === "get") { - descriptor = { - get: def.value, - configurable: true, - enumerable: false - }; - } else if (def.kind === "set") { - descriptor = { - set: def.value, - configurable: true, - enumerable: false - }; - } else if (def.kind === "field") { - descriptor = { - configurable: true, - writable: true, - enumerable: true - }; - } - var element = { - kind: def.kind === "field" ? "field" : "method", - key: key, - placement: def.static ? "static" : def.kind === "field" ? "own" : "prototype", - descriptor: descriptor - }; - if (def.decorators) element.decorators = def.decorators; - if (def.kind === "field") element.initializer = def.value; - return element; - } - function _coalesceGetterSetter(element, other) { - if (element.descriptor.get !== undefined) { - other.descriptor.get = element.descriptor.get; - } else { - other.descriptor.set = element.descriptor.set; - } - } - function _coalesceClassElements(elements) { - var newElements = []; - var isSameElement = function (other) { - return other.kind === "method" && other.key === element.key && other.placement === element.placement; - }; - for (var i = 0; i < elements.length; i++) { - var element = elements[i]; - var other; - if (element.kind === "method" && (other = newElements.find(isSameElement))) { - if (_isDataDescriptor(element.descriptor) || _isDataDescriptor(other.descriptor)) { - if (_hasDecorators(element) || _hasDecorators(other)) { - throw new ReferenceError("Duplicated methods (" + element.key + ") can't be decorated."); - } - other.descriptor = element.descriptor; - } else { - if (_hasDecorators(element)) { - if (_hasDecorators(other)) { - throw new ReferenceError("Decorators can't be placed on different accessors with for " + "the same property (" + element.key + ")."); - } - other.decorators = element.decorators; - } - _coalesceGetterSetter(element, other); - } - } else { - newElements.push(element); - } - } - return newElements; - } - function _hasDecorators(element) { - return element.decorators && element.decorators.length; - } - function _isDataDescriptor(desc) { - return desc !== undefined && !(desc.value === undefined && desc.writable === undefined); - } - function _optionalCallableProperty(obj, name) { - var value = obj[name]; - if (value !== undefined && typeof value !== "function") { - throw new TypeError("Expected '" + name + "' to be a function"); - } - return value; - } - function _classPrivateMethodGet(receiver, privateSet, fn) { - if (!privateSet.has(receiver)) { - throw new TypeError("attempted to get private field on non-instance"); - } - return fn; - } - function _checkPrivateRedeclaration(obj, privateCollection) { - if (privateCollection.has(obj)) { - throw new TypeError("Cannot initialize the same private elements twice on an object"); - } - } - function _classPrivateFieldInitSpec(obj, privateMap, value) { - _checkPrivateRedeclaration(obj, privateMap); - privateMap.set(obj, value); - } - function _classPrivateMethodInitSpec(obj, privateSet) { - _checkPrivateRedeclaration(obj, privateSet); - privateSet.add(obj); - } - function _classPrivateMethodSet() { - throw new TypeError("attempted to reassign private method"); - } - function _identity(x) { - return x; - } - function _nullishReceiverError(r) { - throw new TypeError("Cannot set property of null or undefined."); - } - - class Piece extends TrixObject { - static registerType(type, constructor) { - constructor.type = type; - this.types[type] = constructor; - } - static fromJSON(pieceJSON) { - const constructor = this.types[pieceJSON.type]; - if (constructor) { - return constructor.fromJSON(pieceJSON); - } - } - constructor(value) { - let attributes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - super(...arguments); - this.attributes = Hash.box(attributes); - } - copyWithAttributes(attributes) { - return new this.constructor(this.getValue(), attributes); - } - copyWithAdditionalAttributes(attributes) { - return this.copyWithAttributes(this.attributes.merge(attributes)); - } - copyWithoutAttribute(attribute) { - return this.copyWithAttributes(this.attributes.remove(attribute)); - } - copy() { - return this.copyWithAttributes(this.attributes); - } - getAttribute(attribute) { - return this.attributes.get(attribute); - } - getAttributesHash() { - return this.attributes; - } - getAttributes() { - return this.attributes.toObject(); - } - hasAttribute(attribute) { - return this.attributes.has(attribute); - } - hasSameStringValueAsPiece(piece) { - return piece && this.toString() === piece.toString(); - } - hasSameAttributesAsPiece(piece) { - return piece && (this.attributes === piece.attributes || this.attributes.isEqualTo(piece.attributes)); - } - isBlockBreak() { - return false; - } - isEqualTo(piece) { - return super.isEqualTo(...arguments) || this.hasSameConstructorAs(piece) && this.hasSameStringValueAsPiece(piece) && this.hasSameAttributesAsPiece(piece); - } - isEmpty() { - return this.length === 0; - } - isSerializable() { - return true; - } - toJSON() { - return { - type: this.constructor.type, - attributes: this.getAttributes() - }; - } - contentsForInspection() { - return { - type: this.constructor.type, - attributes: this.attributes.inspect() - }; - } - - // Grouping - - canBeGrouped() { - return this.hasAttribute("href"); - } - canBeGroupedWith(piece) { - return this.getAttribute("href") === piece.getAttribute("href"); - } - - // Splittable - - getLength() { - return this.length; - } - canBeConsolidatedWith(piece) { - return false; - } - } - _defineProperty(Piece, "types", {}); - - class ImagePreloadOperation extends Operation { - constructor(url) { - super(...arguments); - this.url = url; - } - perform(callback) { - const image = new Image(); - image.onload = () => { - image.width = this.width = image.naturalWidth; - image.height = this.height = image.naturalHeight; - return callback(true, image); - }; - image.onerror = () => callback(false); - image.src = this.url; - } - } - - class Attachment extends TrixObject { - static attachmentForFile(file) { - const attributes = this.attributesForFile(file); - const attachment = new this(attributes); - attachment.setFile(file); - return attachment; - } - static attributesForFile(file) { - return new Hash({ - filename: file.name, - filesize: file.size, - contentType: file.type - }); - } - static fromJSON(attachmentJSON) { - return new this(attachmentJSON); - } - constructor() { - let attributes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - super(attributes); - this.releaseFile = this.releaseFile.bind(this); - this.attributes = Hash.box(attributes); - this.didChangeAttributes(); - } - getAttribute(attribute) { - return this.attributes.get(attribute); - } - hasAttribute(attribute) { - return this.attributes.has(attribute); - } - getAttributes() { - return this.attributes.toObject(); - } - setAttributes() { - let attributes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - const newAttributes = this.attributes.merge(attributes); - if (!this.attributes.isEqualTo(newAttributes)) { - var _this$previewDelegate, _this$previewDelegate2, _this$delegate, _this$delegate$attach; - this.attributes = newAttributes; - this.didChangeAttributes(); - (_this$previewDelegate = this.previewDelegate) === null || _this$previewDelegate === void 0 || (_this$previewDelegate2 = _this$previewDelegate.attachmentDidChangeAttributes) === null || _this$previewDelegate2 === void 0 || _this$previewDelegate2.call(_this$previewDelegate, this); - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$attach = _this$delegate.attachmentDidChangeAttributes) === null || _this$delegate$attach === void 0 ? void 0 : _this$delegate$attach.call(_this$delegate, this); - } - } - didChangeAttributes() { - if (this.isPreviewable()) { - return this.preloadURL(); - } - } - isPending() { - return this.file != null && !(this.getURL() || this.getHref()); - } - isPreviewable() { - if (this.attributes.has("previewable")) { - return this.attributes.get("previewable"); - } else { - return Attachment.previewablePattern.test(this.getContentType()); - } - } - getType() { - if (this.hasContent()) { - return "content"; - } else if (this.isPreviewable()) { - return "preview"; - } else { - return "file"; - } - } - getURL() { - return this.attributes.get("url"); - } - getHref() { - return this.attributes.get("href"); - } - getFilename() { - return this.attributes.get("filename") || ""; - } - getFilesize() { - return this.attributes.get("filesize"); - } - getFormattedFilesize() { - const filesize = this.attributes.get("filesize"); - if (typeof filesize === "number") { - return file_size_formatting.formatter(filesize); - } else { - return ""; - } - } - getExtension() { - var _this$getFilename$mat; - return (_this$getFilename$mat = this.getFilename().match(/\.(\w+)$/)) === null || _this$getFilename$mat === void 0 ? void 0 : _this$getFilename$mat[1].toLowerCase(); - } - getContentType() { - return this.attributes.get("contentType"); - } - hasContent() { - return this.attributes.has("content"); - } - getContent() { - return this.attributes.get("content"); - } - getWidth() { - return this.attributes.get("width"); - } - getHeight() { - return this.attributes.get("height"); - } - getFile() { - return this.file; - } - setFile(file) { - this.file = file; - if (this.isPreviewable()) { - return this.preloadFile(); - } - } - releaseFile() { - this.releasePreloadedFile(); - this.file = null; - } - getUploadProgress() { - return this.uploadProgress != null ? this.uploadProgress : 0; - } - setUploadProgress(value) { - if (this.uploadProgress !== value) { - var _this$uploadProgressD, _this$uploadProgressD2; - this.uploadProgress = value; - return (_this$uploadProgressD = this.uploadProgressDelegate) === null || _this$uploadProgressD === void 0 || (_this$uploadProgressD2 = _this$uploadProgressD.attachmentDidChangeUploadProgress) === null || _this$uploadProgressD2 === void 0 ? void 0 : _this$uploadProgressD2.call(_this$uploadProgressD, this); - } - } - toJSON() { - return this.getAttributes(); - } - getCacheKey() { - return [super.getCacheKey(...arguments), this.attributes.getCacheKey(), this.getPreviewURL()].join("/"); - } - - // Previewable - - getPreviewURL() { - return this.previewURL || this.preloadingURL; - } - setPreviewURL(url) { - if (url !== this.getPreviewURL()) { - var _this$previewDelegate3, _this$previewDelegate4, _this$delegate2, _this$delegate2$attac; - this.previewURL = url; - (_this$previewDelegate3 = this.previewDelegate) === null || _this$previewDelegate3 === void 0 || (_this$previewDelegate4 = _this$previewDelegate3.attachmentDidChangeAttributes) === null || _this$previewDelegate4 === void 0 || _this$previewDelegate4.call(_this$previewDelegate3, this); - return (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$attac = _this$delegate2.attachmentDidChangePreviewURL) === null || _this$delegate2$attac === void 0 ? void 0 : _this$delegate2$attac.call(_this$delegate2, this); - } - } - preloadURL() { - return this.preload(this.getURL(), this.releaseFile); - } - preloadFile() { - if (this.file) { - this.fileObjectURL = URL.createObjectURL(this.file); - return this.preload(this.fileObjectURL); - } - } - releasePreloadedFile() { - if (this.fileObjectURL) { - URL.revokeObjectURL(this.fileObjectURL); - this.fileObjectURL = null; - } - } - preload(url, callback) { - if (url && url !== this.getPreviewURL()) { - this.preloadingURL = url; - const operation = new ImagePreloadOperation(url); - return operation.then(_ref => { - let { - width, - height - } = _ref; - if (!this.getWidth() || !this.getHeight()) { - this.setAttributes({ - width, - height - }); - } - this.preloadingURL = null; - this.setPreviewURL(url); - return callback === null || callback === void 0 ? void 0 : callback(); - }).catch(() => { - this.preloadingURL = null; - return callback === null || callback === void 0 ? void 0 : callback(); - }); - } - } - } - _defineProperty(Attachment, "previewablePattern", /^image(\/(gif|png|webp|jpe?g)|$)/); - - class AttachmentPiece extends Piece { - static fromJSON(pieceJSON) { - return new this(Attachment.fromJSON(pieceJSON.attachment), pieceJSON.attributes); - } - constructor(attachment) { - super(...arguments); - this.attachment = attachment; - this.length = 1; - this.ensureAttachmentExclusivelyHasAttribute("href"); - if (!this.attachment.hasContent()) { - this.removeProhibitedAttributes(); - } - } - ensureAttachmentExclusivelyHasAttribute(attribute) { - if (this.hasAttribute(attribute)) { - if (!this.attachment.hasAttribute(attribute)) { - this.attachment.setAttributes(this.attributes.slice([attribute])); - } - this.attributes = this.attributes.remove(attribute); - } - } - removeProhibitedAttributes() { - const attributes = this.attributes.slice(AttachmentPiece.permittedAttributes); - if (!attributes.isEqualTo(this.attributes)) { - this.attributes = attributes; - } - } - getValue() { - return this.attachment; - } - isSerializable() { - return !this.attachment.isPending(); - } - getCaption() { - return this.attributes.get("caption") || ""; - } - isEqualTo(piece) { - var _piece$attachment; - return super.isEqualTo(piece) && this.attachment.id === (piece === null || piece === void 0 || (_piece$attachment = piece.attachment) === null || _piece$attachment === void 0 ? void 0 : _piece$attachment.id); - } - toString() { - return OBJECT_REPLACEMENT_CHARACTER; - } - toJSON() { - const json = super.toJSON(...arguments); - json.attachment = this.attachment; - return json; - } - getCacheKey() { - return [super.getCacheKey(...arguments), this.attachment.getCacheKey()].join("/"); - } - toConsole() { - return JSON.stringify(this.toString()); - } - } - _defineProperty(AttachmentPiece, "permittedAttributes", ["caption", "presentation"]); - Piece.registerType("attachment", AttachmentPiece); - - class StringPiece extends Piece { - static fromJSON(pieceJSON) { - return new this(pieceJSON.string, pieceJSON.attributes); - } - constructor(string) { - super(...arguments); - this.string = normalizeNewlines(string); - this.length = this.string.length; - } - getValue() { - return this.string; - } - toString() { - return this.string.toString(); - } - isBlockBreak() { - return this.toString() === "\n" && this.getAttribute("blockBreak") === true; - } - toJSON() { - const result = super.toJSON(...arguments); - result.string = this.string; - return result; - } - - // Splittable - - canBeConsolidatedWith(piece) { - return piece && this.hasSameConstructorAs(piece) && this.hasSameAttributesAsPiece(piece); - } - consolidateWith(piece) { - return new this.constructor(this.toString() + piece.toString(), this.attributes); - } - splitAtOffset(offset) { - let left, right; - if (offset === 0) { - left = null; - right = this; - } else if (offset === this.length) { - left = this; - right = null; - } else { - left = new this.constructor(this.string.slice(0, offset), this.attributes); - right = new this.constructor(this.string.slice(offset), this.attributes); - } - return [left, right]; - } - toConsole() { - let { - string - } = this; - if (string.length > 15) { - string = string.slice(0, 14) + "…"; - } - return JSON.stringify(string.toString()); - } - } - Piece.registerType("string", StringPiece); - - /* eslint-disable - prefer-const, - */ - class SplittableList extends TrixObject { - static box(objects) { - if (objects instanceof this) { - return objects; - } else { - return new this(objects); - } - } - constructor() { - let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - this.objects = objects.slice(0); - this.length = this.objects.length; - } - indexOf(object) { - return this.objects.indexOf(object); - } - splice() { - for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { - args[_key] = arguments[_key]; - } - return new this.constructor(spliceArray(this.objects, ...args)); - } - eachObject(callback) { - return this.objects.map((object, index) => callback(object, index)); - } - insertObjectAtIndex(object, index) { - return this.splice(index, 0, object); - } - insertSplittableListAtIndex(splittableList, index) { - return this.splice(index, 0, ...splittableList.objects); - } - insertSplittableListAtPosition(splittableList, position) { - const [objects, index] = this.splitObjectAtPosition(position); - return new this.constructor(objects).insertSplittableListAtIndex(splittableList, index); - } - editObjectAtIndex(index, callback) { - return this.replaceObjectAtIndex(callback(this.objects[index]), index); - } - replaceObjectAtIndex(object, index) { - return this.splice(index, 1, object); - } - removeObjectAtIndex(index) { - return this.splice(index, 1); - } - getObjectAtIndex(index) { - return this.objects[index]; - } - getSplittableListInRange(range) { - const [objects, leftIndex, rightIndex] = this.splitObjectsAtRange(range); - return new this.constructor(objects.slice(leftIndex, rightIndex + 1)); - } - selectSplittableList(test) { - const objects = this.objects.filter(object => test(object)); - return new this.constructor(objects); - } - removeObjectsInRange(range) { - const [objects, leftIndex, rightIndex] = this.splitObjectsAtRange(range); - return new this.constructor(objects).splice(leftIndex, rightIndex - leftIndex + 1); - } - transformObjectsInRange(range, transform) { - const [objects, leftIndex, rightIndex] = this.splitObjectsAtRange(range); - const transformedObjects = objects.map((object, index) => leftIndex <= index && index <= rightIndex ? transform(object) : object); - return new this.constructor(transformedObjects); - } - splitObjectsAtRange(range) { - let rightOuterIndex; - let [objects, leftInnerIndex, offset] = this.splitObjectAtPosition(startOfRange(range)); - [objects, rightOuterIndex] = new this.constructor(objects).splitObjectAtPosition(endOfRange(range) + offset); - return [objects, leftInnerIndex, rightOuterIndex - 1]; - } - getObjectAtPosition(position) { - const { - index - } = this.findIndexAndOffsetAtPosition(position); - return this.objects[index]; - } - splitObjectAtPosition(position) { - let splitIndex, splitOffset; - const { - index, - offset - } = this.findIndexAndOffsetAtPosition(position); - const objects = this.objects.slice(0); - if (index != null) { - if (offset === 0) { - splitIndex = index; - splitOffset = 0; - } else { - const object = this.getObjectAtIndex(index); - const [leftObject, rightObject] = object.splitAtOffset(offset); - objects.splice(index, 1, leftObject, rightObject); - splitIndex = index + 1; - splitOffset = leftObject.getLength() - offset; - } - } else { - splitIndex = objects.length; - splitOffset = 0; - } - return [objects, splitIndex, splitOffset]; - } - consolidate() { - const objects = []; - let pendingObject = this.objects[0]; - this.objects.slice(1).forEach(object => { - var _pendingObject$canBeC, _pendingObject; - if ((_pendingObject$canBeC = (_pendingObject = pendingObject).canBeConsolidatedWith) !== null && _pendingObject$canBeC !== void 0 && _pendingObject$canBeC.call(_pendingObject, object)) { - pendingObject = pendingObject.consolidateWith(object); - } else { - objects.push(pendingObject); - pendingObject = object; - } - }); - if (pendingObject) { - objects.push(pendingObject); - } - return new this.constructor(objects); - } - consolidateFromIndexToIndex(startIndex, endIndex) { - const objects = this.objects.slice(0); - const objectsInRange = objects.slice(startIndex, endIndex + 1); - const consolidatedInRange = new this.constructor(objectsInRange).consolidate().toArray(); - return this.splice(startIndex, objectsInRange.length, ...consolidatedInRange); - } - findIndexAndOffsetAtPosition(position) { - let index; - let currentPosition = 0; - for (index = 0; index < this.objects.length; index++) { - const object = this.objects[index]; - const nextPosition = currentPosition + object.getLength(); - if (currentPosition <= position && position < nextPosition) { - return { - index, - offset: position - currentPosition - }; - } - currentPosition = nextPosition; - } - return { - index: null, - offset: null - }; - } - findPositionAtIndexAndOffset(index, offset) { - let position = 0; - for (let currentIndex = 0; currentIndex < this.objects.length; currentIndex++) { - const object = this.objects[currentIndex]; - if (currentIndex < index) { - position += object.getLength(); - } else if (currentIndex === index) { - position += offset; - break; - } - } - return position; - } - getEndPosition() { - if (this.endPosition == null) { - this.endPosition = 0; - this.objects.forEach(object => this.endPosition += object.getLength()); - } - return this.endPosition; - } - toString() { - return this.objects.join(""); - } - toArray() { - return this.objects.slice(0); - } - toJSON() { - return this.toArray(); - } - isEqualTo(splittableList) { - return super.isEqualTo(...arguments) || objectArraysAreEqual(this.objects, splittableList === null || splittableList === void 0 ? void 0 : splittableList.objects); - } - contentsForInspection() { - return { - objects: "[".concat(this.objects.map(object => object.inspect()).join(", "), "]") - }; - } - } - const objectArraysAreEqual = function (left) { - let right = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; - if (left.length !== right.length) { - return false; - } - let result = true; - for (let index = 0; index < left.length; index++) { - const object = left[index]; - if (result && !object.isEqualTo(right[index])) { - result = false; - } - } - return result; - }; - const startOfRange = range => range[0]; - const endOfRange = range => range[1]; - - class Text extends TrixObject { - static textForAttachmentWithAttributes(attachment, attributes) { - const piece = new AttachmentPiece(attachment, attributes); - return new this([piece]); - } - static textForStringWithAttributes(string, attributes) { - const piece = new StringPiece(string, attributes); - return new this([piece]); - } - static fromJSON(textJSON) { - const pieces = Array.from(textJSON).map(pieceJSON => Piece.fromJSON(pieceJSON)); - return new this(pieces); - } - constructor() { - let pieces = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - const notEmpty = pieces.filter(piece => !piece.isEmpty()); - this.pieceList = new SplittableList(notEmpty); - } - copy() { - return this.copyWithPieceList(this.pieceList); - } - copyWithPieceList(pieceList) { - return new this.constructor(pieceList.consolidate().toArray()); - } - copyUsingObjectMap(objectMap) { - const pieces = this.getPieces().map(piece => objectMap.find(piece) || piece); - return new this.constructor(pieces); - } - appendText(text) { - return this.insertTextAtPosition(text, this.getLength()); - } - insertTextAtPosition(text, position) { - return this.copyWithPieceList(this.pieceList.insertSplittableListAtPosition(text.pieceList, position)); - } - removeTextAtRange(range) { - return this.copyWithPieceList(this.pieceList.removeObjectsInRange(range)); - } - replaceTextAtRange(text, range) { - return this.removeTextAtRange(range).insertTextAtPosition(text, range[0]); - } - moveTextFromRangeToPosition(range, position) { - if (range[0] <= position && position <= range[1]) return; - const text = this.getTextAtRange(range); - const length = text.getLength(); - if (range[0] < position) { - position -= length; - } - return this.removeTextAtRange(range).insertTextAtPosition(text, position); - } - addAttributeAtRange(attribute, value, range) { - const attributes = {}; - attributes[attribute] = value; - return this.addAttributesAtRange(attributes, range); - } - addAttributesAtRange(attributes, range) { - return this.copyWithPieceList(this.pieceList.transformObjectsInRange(range, piece => piece.copyWithAdditionalAttributes(attributes))); - } - removeAttributeAtRange(attribute, range) { - return this.copyWithPieceList(this.pieceList.transformObjectsInRange(range, piece => piece.copyWithoutAttribute(attribute))); - } - setAttributesAtRange(attributes, range) { - return this.copyWithPieceList(this.pieceList.transformObjectsInRange(range, piece => piece.copyWithAttributes(attributes))); - } - getAttributesAtPosition(position) { - var _this$pieceList$getOb; - return ((_this$pieceList$getOb = this.pieceList.getObjectAtPosition(position)) === null || _this$pieceList$getOb === void 0 ? void 0 : _this$pieceList$getOb.getAttributes()) || {}; - } - getCommonAttributes() { - const objects = Array.from(this.pieceList.toArray()).map(piece => piece.getAttributes()); - return Hash.fromCommonAttributesOfObjects(objects).toObject(); - } - getCommonAttributesAtRange(range) { - return this.getTextAtRange(range).getCommonAttributes() || {}; - } - getExpandedRangeForAttributeAtOffset(attributeName, offset) { - let right; - let left = right = offset; - const length = this.getLength(); - while (left > 0 && this.getCommonAttributesAtRange([left - 1, right])[attributeName]) { - left--; - } - while (right < length && this.getCommonAttributesAtRange([offset, right + 1])[attributeName]) { - right++; - } - return [left, right]; - } - getTextAtRange(range) { - return this.copyWithPieceList(this.pieceList.getSplittableListInRange(range)); - } - getStringAtRange(range) { - return this.pieceList.getSplittableListInRange(range).toString(); - } - getStringAtPosition(position) { - return this.getStringAtRange([position, position + 1]); - } - startsWithString(string) { - return this.getStringAtRange([0, string.length]) === string; - } - endsWithString(string) { - const length = this.getLength(); - return this.getStringAtRange([length - string.length, length]) === string; - } - getAttachmentPieces() { - return this.pieceList.toArray().filter(piece => !!piece.attachment); - } - getAttachments() { - return this.getAttachmentPieces().map(piece => piece.attachment); - } - getAttachmentAndPositionById(attachmentId) { - let position = 0; - for (const piece of this.pieceList.toArray()) { - var _piece$attachment; - if (((_piece$attachment = piece.attachment) === null || _piece$attachment === void 0 ? void 0 : _piece$attachment.id) === attachmentId) { - return { - attachment: piece.attachment, - position - }; - } - position += piece.length; - } - return { - attachment: null, - position: null - }; - } - getAttachmentById(attachmentId) { - const { - attachment - } = this.getAttachmentAndPositionById(attachmentId); - return attachment; - } - getRangeOfAttachment(attachment) { - const attachmentAndPosition = this.getAttachmentAndPositionById(attachment.id); - const position = attachmentAndPosition.position; - attachment = attachmentAndPosition.attachment; - if (attachment) { - return [position, position + 1]; - } - } - updateAttributesForAttachment(attributes, attachment) { - const range = this.getRangeOfAttachment(attachment); - if (range) { - return this.addAttributesAtRange(attributes, range); - } else { - return this; - } - } - getLength() { - return this.pieceList.getEndPosition(); - } - isEmpty() { - return this.getLength() === 0; - } - isEqualTo(text) { - var _text$pieceList; - return super.isEqualTo(text) || (text === null || text === void 0 || (_text$pieceList = text.pieceList) === null || _text$pieceList === void 0 ? void 0 : _text$pieceList.isEqualTo(this.pieceList)); - } - isBlockBreak() { - return this.getLength() === 1 && this.pieceList.getObjectAtIndex(0).isBlockBreak(); - } - eachPiece(callback) { - return this.pieceList.eachObject(callback); - } - getPieces() { - return this.pieceList.toArray(); - } - getPieceAtPosition(position) { - return this.pieceList.getObjectAtPosition(position); - } - contentsForInspection() { - return { - pieceList: this.pieceList.inspect() - }; - } - toSerializableText() { - const pieceList = this.pieceList.selectSplittableList(piece => piece.isSerializable()); - return this.copyWithPieceList(pieceList); - } - toString() { - return this.pieceList.toString(); - } - toJSON() { - return this.pieceList.toJSON(); - } - toConsole() { - return JSON.stringify(this.pieceList.toArray().map(piece => JSON.parse(piece.toConsole()))); - } - - // BIDI - - getDirection() { - return getDirection(this.toString()); - } - isRTL() { - return this.getDirection() === "rtl"; - } - } - - class Block extends TrixObject { - static fromJSON(blockJSON) { - const text = Text.fromJSON(blockJSON.text); - return new this(text, blockJSON.attributes, blockJSON.htmlAttributes); - } - constructor(text, attributes, htmlAttributes) { - super(...arguments); - this.text = applyBlockBreakToText(text || new Text()); - this.attributes = attributes || []; - this.htmlAttributes = htmlAttributes || {}; - } - isEmpty() { - return this.text.isBlockBreak(); - } - isEqualTo(block) { - if (super.isEqualTo(block)) return true; - return this.text.isEqualTo(block === null || block === void 0 ? void 0 : block.text) && arraysAreEqual(this.attributes, block === null || block === void 0 ? void 0 : block.attributes) && objectsAreEqual(this.htmlAttributes, block === null || block === void 0 ? void 0 : block.htmlAttributes); - } - copyWithText(text) { - return new Block(text, this.attributes, this.htmlAttributes); - } - copyWithoutText() { - return this.copyWithText(null); - } - copyWithAttributes(attributes) { - return new Block(this.text, attributes, this.htmlAttributes); - } - copyWithoutAttributes() { - return this.copyWithAttributes(null); - } - copyUsingObjectMap(objectMap) { - const mappedText = objectMap.find(this.text); - if (mappedText) { - return this.copyWithText(mappedText); - } else { - return this.copyWithText(this.text.copyUsingObjectMap(objectMap)); - } - } - addAttribute(attribute) { - const attributes = this.attributes.concat(expandAttribute(attribute)); - return this.copyWithAttributes(attributes); - } - addHTMLAttribute(attribute, value) { - const htmlAttributes = Object.assign({}, this.htmlAttributes, { - [attribute]: value - }); - return new Block(this.text, this.attributes, htmlAttributes); - } - removeAttribute(attribute) { - const { - listAttribute - } = getBlockConfig(attribute); - const attributes = removeLastValue(removeLastValue(this.attributes, attribute), listAttribute); - return this.copyWithAttributes(attributes); - } - removeLastAttribute() { - return this.removeAttribute(this.getLastAttribute()); - } - getLastAttribute() { - return getLastElement(this.attributes); - } - getAttributes() { - return this.attributes.slice(0); - } - getAttributeLevel() { - return this.attributes.length; - } - getAttributeAtLevel(level) { - return this.attributes[level - 1]; - } - hasAttribute(attributeName) { - return this.attributes.includes(attributeName); - } - hasAttributes() { - return this.getAttributeLevel() > 0; - } - getLastNestableAttribute() { - return getLastElement(this.getNestableAttributes()); - } - getNestableAttributes() { - return this.attributes.filter(attribute => getBlockConfig(attribute).nestable); - } - getNestingLevel() { - return this.getNestableAttributes().length; - } - decreaseNestingLevel() { - const attribute = this.getLastNestableAttribute(); - if (attribute) { - return this.removeAttribute(attribute); - } else { - return this; - } - } - increaseNestingLevel() { - const attribute = this.getLastNestableAttribute(); - if (attribute) { - const index = this.attributes.lastIndexOf(attribute); - const attributes = spliceArray(this.attributes, index + 1, 0, ...expandAttribute(attribute)); - return this.copyWithAttributes(attributes); - } else { - return this; - } - } - getListItemAttributes() { - return this.attributes.filter(attribute => getBlockConfig(attribute).listAttribute); - } - isListItem() { - var _getBlockConfig; - return (_getBlockConfig = getBlockConfig(this.getLastAttribute())) === null || _getBlockConfig === void 0 ? void 0 : _getBlockConfig.listAttribute; - } - isTerminalBlock() { - var _getBlockConfig2; - return (_getBlockConfig2 = getBlockConfig(this.getLastAttribute())) === null || _getBlockConfig2 === void 0 ? void 0 : _getBlockConfig2.terminal; - } - breaksOnReturn() { - var _getBlockConfig3; - return (_getBlockConfig3 = getBlockConfig(this.getLastAttribute())) === null || _getBlockConfig3 === void 0 ? void 0 : _getBlockConfig3.breakOnReturn; - } - findLineBreakInDirectionFromPosition(direction, position) { - const string = this.toString(); - let result; - switch (direction) { - case "forward": - result = string.indexOf("\n", position); - break; - case "backward": - result = string.slice(0, position).lastIndexOf("\n"); - } - if (result !== -1) { - return result; - } - } - contentsForInspection() { - return { - text: this.text.inspect(), - attributes: this.attributes - }; - } - toString() { - return this.text.toString(); - } - toJSON() { - return { - text: this.text, - attributes: this.attributes, - htmlAttributes: this.htmlAttributes - }; - } - - // BIDI - - getDirection() { - return this.text.getDirection(); - } - isRTL() { - return this.text.isRTL(); - } - - // Splittable - - getLength() { - return this.text.getLength(); - } - canBeConsolidatedWith(block) { - return !this.hasAttributes() && !block.hasAttributes() && this.getDirection() === block.getDirection(); - } - consolidateWith(block) { - const newlineText = Text.textForStringWithAttributes("\n"); - const text = this.getTextWithoutBlockBreak().appendText(newlineText); - return this.copyWithText(text.appendText(block.text)); - } - splitAtOffset(offset) { - let left, right; - if (offset === 0) { - left = null; - right = this; - } else if (offset === this.getLength()) { - left = this; - right = null; - } else { - left = this.copyWithText(this.text.getTextAtRange([0, offset])); - right = this.copyWithText(this.text.getTextAtRange([offset, this.getLength()])); - } - return [left, right]; - } - getBlockBreakPosition() { - return this.text.getLength() - 1; - } - getTextWithoutBlockBreak() { - if (textEndsInBlockBreak(this.text)) { - return this.text.getTextAtRange([0, this.getBlockBreakPosition()]); - } else { - return this.text.copy(); - } - } - - // Grouping - - canBeGrouped(depth) { - return this.attributes[depth]; - } - canBeGroupedWith(otherBlock, depth) { - const otherAttributes = otherBlock.getAttributes(); - const otherAttribute = otherAttributes[depth]; - const attribute = this.attributes[depth]; - return attribute === otherAttribute && !(getBlockConfig(attribute).group === false && !getListAttributeNames().includes(otherAttributes[depth + 1])) && (this.getDirection() === otherBlock.getDirection() || otherBlock.isEmpty()); - } - } - - // Block breaks - - const applyBlockBreakToText = function (text) { - text = unmarkExistingInnerBlockBreaksInText(text); - text = addBlockBreakToText(text); - return text; - }; - const unmarkExistingInnerBlockBreaksInText = function (text) { - let modified = false; - const pieces = text.getPieces(); - let innerPieces = pieces.slice(0, pieces.length - 1); - const lastPiece = pieces[pieces.length - 1]; - if (!lastPiece) return text; - innerPieces = innerPieces.map(piece => { - if (piece.isBlockBreak()) { - modified = true; - return unmarkBlockBreakPiece(piece); - } else { - return piece; - } - }); - if (modified) { - return new Text([...innerPieces, lastPiece]); - } else { - return text; - } - }; - const blockBreakText = Text.textForStringWithAttributes("\n", { - blockBreak: true - }); - const addBlockBreakToText = function (text) { - if (textEndsInBlockBreak(text)) { - return text; - } else { - return text.appendText(blockBreakText); - } - }; - const textEndsInBlockBreak = function (text) { - const length = text.getLength(); - if (length === 0) { - return false; - } - const endText = text.getTextAtRange([length - 1, length]); - return endText.isBlockBreak(); - }; - const unmarkBlockBreakPiece = piece => piece.copyWithoutAttribute("blockBreak"); - - // Attributes - - const expandAttribute = function (attribute) { - const { - listAttribute - } = getBlockConfig(attribute); - if (listAttribute) { - return [listAttribute, attribute]; - } else { - return [attribute]; - } - }; - - // Array helpers - - const getLastElement = array => array.slice(-1)[0]; - const removeLastValue = function (array, value) { - const index = array.lastIndexOf(value); - if (index === -1) { - return array; - } else { - return spliceArray(array, index, 1); - } - }; - - class Document extends TrixObject { - static fromJSON(documentJSON) { - const blocks = Array.from(documentJSON).map(blockJSON => Block.fromJSON(blockJSON)); - return new this(blocks); - } - static fromString(string, textAttributes) { - const text = Text.textForStringWithAttributes(string, textAttributes); - return new this([new Block(text)]); - } - constructor() { - let blocks = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - if (blocks.length === 0) { - blocks = [new Block()]; - } - this.blockList = SplittableList.box(blocks); - } - isEmpty() { - const block = this.getBlockAtIndex(0); - return this.blockList.length === 1 && block.isEmpty() && !block.hasAttributes(); - } - copy() { - let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - const blocks = options.consolidateBlocks ? this.blockList.consolidate().toArray() : this.blockList.toArray(); - return new this.constructor(blocks); - } - copyUsingObjectsFromDocument(sourceDocument) { - const objectMap = new ObjectMap(sourceDocument.getObjects()); - return this.copyUsingObjectMap(objectMap); - } - copyUsingObjectMap(objectMap) { - const blocks = this.getBlocks().map(block => { - const mappedBlock = objectMap.find(block); - return mappedBlock || block.copyUsingObjectMap(objectMap); - }); - return new this.constructor(blocks); - } - copyWithBaseBlockAttributes() { - let blockAttributes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - const blocks = this.getBlocks().map(block => { - const attributes = blockAttributes.concat(block.getAttributes()); - return block.copyWithAttributes(attributes); - }); - return new this.constructor(blocks); - } - replaceBlock(oldBlock, newBlock) { - const index = this.blockList.indexOf(oldBlock); - if (index === -1) { - return this; - } - return new this.constructor(this.blockList.replaceObjectAtIndex(newBlock, index)); - } - insertDocumentAtRange(document, range) { - const { - blockList - } = document; - range = normalizeRange(range); - let [position] = range; - const { - index, - offset - } = this.locationFromPosition(position); - let result = this; - const block = this.getBlockAtPosition(position); - if (rangeIsCollapsed(range) && block.isEmpty() && !block.hasAttributes()) { - result = new this.constructor(result.blockList.removeObjectAtIndex(index)); - } else if (block.getBlockBreakPosition() === offset) { - position++; - } - result = result.removeTextAtRange(range); - return new this.constructor(result.blockList.insertSplittableListAtPosition(blockList, position)); - } - mergeDocumentAtRange(document, range) { - let formattedDocument, result; - range = normalizeRange(range); - const [startPosition] = range; - const startLocation = this.locationFromPosition(startPosition); - const blockAttributes = this.getBlockAtIndex(startLocation.index).getAttributes(); - const baseBlockAttributes = document.getBaseBlockAttributes(); - const trailingBlockAttributes = blockAttributes.slice(-baseBlockAttributes.length); - if (arraysAreEqual(baseBlockAttributes, trailingBlockAttributes)) { - const leadingBlockAttributes = blockAttributes.slice(0, -baseBlockAttributes.length); - formattedDocument = document.copyWithBaseBlockAttributes(leadingBlockAttributes); - } else { - formattedDocument = document.copy({ - consolidateBlocks: true - }).copyWithBaseBlockAttributes(blockAttributes); - } - const blockCount = formattedDocument.getBlockCount(); - const firstBlock = formattedDocument.getBlockAtIndex(0); - if (arraysAreEqual(blockAttributes, firstBlock.getAttributes())) { - const firstText = firstBlock.getTextWithoutBlockBreak(); - result = this.insertTextAtRange(firstText, range); - if (blockCount > 1) { - formattedDocument = new this.constructor(formattedDocument.getBlocks().slice(1)); - const position = startPosition + firstText.getLength(); - result = result.insertDocumentAtRange(formattedDocument, position); - } - } else { - result = this.insertDocumentAtRange(formattedDocument, range); - } - return result; - } - insertTextAtRange(text, range) { - range = normalizeRange(range); - const [startPosition] = range; - const { - index, - offset - } = this.locationFromPosition(startPosition); - const document = this.removeTextAtRange(range); - return new this.constructor(document.blockList.editObjectAtIndex(index, block => block.copyWithText(block.text.insertTextAtPosition(text, offset)))); - } - removeTextAtRange(range) { - let blocks; - range = normalizeRange(range); - const [leftPosition, rightPosition] = range; - if (rangeIsCollapsed(range)) { - return this; - } - const [leftLocation, rightLocation] = Array.from(this.locationRangeFromRange(range)); - const leftIndex = leftLocation.index; - const leftOffset = leftLocation.offset; - const leftBlock = this.getBlockAtIndex(leftIndex); - const rightIndex = rightLocation.index; - const rightOffset = rightLocation.offset; - const rightBlock = this.getBlockAtIndex(rightIndex); - const removeRightNewline = rightPosition - leftPosition === 1 && leftBlock.getBlockBreakPosition() === leftOffset && rightBlock.getBlockBreakPosition() !== rightOffset && rightBlock.text.getStringAtPosition(rightOffset) === "\n"; - if (removeRightNewline) { - blocks = this.blockList.editObjectAtIndex(rightIndex, block => block.copyWithText(block.text.removeTextAtRange([rightOffset, rightOffset + 1]))); - } else { - let block; - const leftText = leftBlock.text.getTextAtRange([0, leftOffset]); - const rightText = rightBlock.text.getTextAtRange([rightOffset, rightBlock.getLength()]); - const text = leftText.appendText(rightText); - const removingLeftBlock = leftIndex !== rightIndex && leftOffset === 0; - const useRightBlock = removingLeftBlock && leftBlock.getAttributeLevel() >= rightBlock.getAttributeLevel(); - if (useRightBlock) { - block = rightBlock.copyWithText(text); - } else { - block = leftBlock.copyWithText(text); - } - const affectedBlockCount = rightIndex + 1 - leftIndex; - blocks = this.blockList.splice(leftIndex, affectedBlockCount, block); - } - return new this.constructor(blocks); - } - moveTextFromRangeToPosition(range, position) { - let text; - range = normalizeRange(range); - const [startPosition, endPosition] = range; - if (startPosition <= position && position <= endPosition) { - return this; - } - let document = this.getDocumentAtRange(range); - let result = this.removeTextAtRange(range); - const movingRightward = startPosition < position; - if (movingRightward) { - position -= document.getLength(); - } - const [firstBlock, ...blocks] = document.getBlocks(); - if (blocks.length === 0) { - text = firstBlock.getTextWithoutBlockBreak(); - if (movingRightward) { - position += 1; - } - } else { - text = firstBlock.text; - } - result = result.insertTextAtRange(text, position); - if (blocks.length === 0) { - return result; - } - document = new this.constructor(blocks); - position += text.getLength(); - return result.insertDocumentAtRange(document, position); - } - addAttributeAtRange(attribute, value, range) { - let { - blockList - } = this; - this.eachBlockAtRange(range, (block, textRange, index) => blockList = blockList.editObjectAtIndex(index, function () { - if (getBlockConfig(attribute)) { - return block.addAttribute(attribute, value); - } else { - if (textRange[0] === textRange[1]) { - return block; - } else { - return block.copyWithText(block.text.addAttributeAtRange(attribute, value, textRange)); - } - } - })); - return new this.constructor(blockList); - } - addAttribute(attribute, value) { - let { - blockList - } = this; - this.eachBlock((block, index) => blockList = blockList.editObjectAtIndex(index, () => block.addAttribute(attribute, value))); - return new this.constructor(blockList); - } - removeAttributeAtRange(attribute, range) { - let { - blockList - } = this; - this.eachBlockAtRange(range, function (block, textRange, index) { - if (getBlockConfig(attribute)) { - blockList = blockList.editObjectAtIndex(index, () => block.removeAttribute(attribute)); - } else if (textRange[0] !== textRange[1]) { - blockList = blockList.editObjectAtIndex(index, () => block.copyWithText(block.text.removeAttributeAtRange(attribute, textRange))); - } - }); - return new this.constructor(blockList); - } - updateAttributesForAttachment(attributes, attachment) { - const range = this.getRangeOfAttachment(attachment); - const [startPosition] = Array.from(range); - const { - index - } = this.locationFromPosition(startPosition); - const text = this.getTextAtIndex(index); - return new this.constructor(this.blockList.editObjectAtIndex(index, block => block.copyWithText(text.updateAttributesForAttachment(attributes, attachment)))); - } - removeAttributeForAttachment(attribute, attachment) { - const range = this.getRangeOfAttachment(attachment); - return this.removeAttributeAtRange(attribute, range); - } - setHTMLAttributeAtPosition(position, name, value) { - const block = this.getBlockAtPosition(position); - const updatedBlock = block.addHTMLAttribute(name, value); - return this.replaceBlock(block, updatedBlock); - } - insertBlockBreakAtRange(range) { - let blocks; - range = normalizeRange(range); - const [startPosition] = range; - const { - offset - } = this.locationFromPosition(startPosition); - const document = this.removeTextAtRange(range); - if (offset === 0) { - blocks = [new Block()]; - } - return new this.constructor(document.blockList.insertSplittableListAtPosition(new SplittableList(blocks), startPosition)); - } - applyBlockAttributeAtRange(attributeName, value, range) { - const expanded = this.expandRangeToLineBreaksAndSplitBlocks(range); - let document = expanded.document; - range = expanded.range; - const blockConfig = getBlockConfig(attributeName); - if (blockConfig.listAttribute) { - document = document.removeLastListAttributeAtRange(range, { - exceptAttributeName: attributeName - }); - const converted = document.convertLineBreaksToBlockBreaksInRange(range); - document = converted.document; - range = converted.range; - } else if (blockConfig.exclusive) { - document = document.removeBlockAttributesAtRange(range); - } else if (blockConfig.terminal) { - document = document.removeLastTerminalAttributeAtRange(range); - } else { - document = document.consolidateBlocksAtRange(range); - } - return document.addAttributeAtRange(attributeName, value, range); - } - removeLastListAttributeAtRange(range) { - let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let { - blockList - } = this; - this.eachBlockAtRange(range, function (block, textRange, index) { - const lastAttributeName = block.getLastAttribute(); - if (!lastAttributeName) { - return; - } - if (!getBlockConfig(lastAttributeName).listAttribute) { - return; - } - if (lastAttributeName === options.exceptAttributeName) { - return; - } - blockList = blockList.editObjectAtIndex(index, () => block.removeAttribute(lastAttributeName)); - }); - return new this.constructor(blockList); - } - removeLastTerminalAttributeAtRange(range) { - let { - blockList - } = this; - this.eachBlockAtRange(range, function (block, textRange, index) { - const lastAttributeName = block.getLastAttribute(); - if (!lastAttributeName) { - return; - } - if (!getBlockConfig(lastAttributeName).terminal) { - return; - } - blockList = blockList.editObjectAtIndex(index, () => block.removeAttribute(lastAttributeName)); - }); - return new this.constructor(blockList); - } - removeBlockAttributesAtRange(range) { - let { - blockList - } = this; - this.eachBlockAtRange(range, function (block, textRange, index) { - if (block.hasAttributes()) { - blockList = blockList.editObjectAtIndex(index, () => block.copyWithoutAttributes()); - } - }); - return new this.constructor(blockList); - } - expandRangeToLineBreaksAndSplitBlocks(range) { - let position; - range = normalizeRange(range); - let [startPosition, endPosition] = range; - const startLocation = this.locationFromPosition(startPosition); - const endLocation = this.locationFromPosition(endPosition); - let document = this; - const startBlock = document.getBlockAtIndex(startLocation.index); - startLocation.offset = startBlock.findLineBreakInDirectionFromPosition("backward", startLocation.offset); - if (startLocation.offset != null) { - position = document.positionFromLocation(startLocation); - document = document.insertBlockBreakAtRange([position, position + 1]); - endLocation.index += 1; - endLocation.offset -= document.getBlockAtIndex(startLocation.index).getLength(); - startLocation.index += 1; - } - startLocation.offset = 0; - if (endLocation.offset === 0 && endLocation.index > startLocation.index) { - endLocation.index -= 1; - endLocation.offset = document.getBlockAtIndex(endLocation.index).getBlockBreakPosition(); - } else { - const endBlock = document.getBlockAtIndex(endLocation.index); - if (endBlock.text.getStringAtRange([endLocation.offset - 1, endLocation.offset]) === "\n") { - endLocation.offset -= 1; - } else { - endLocation.offset = endBlock.findLineBreakInDirectionFromPosition("forward", endLocation.offset); - } - if (endLocation.offset !== endBlock.getBlockBreakPosition()) { - position = document.positionFromLocation(endLocation); - document = document.insertBlockBreakAtRange([position, position + 1]); - } - } - startPosition = document.positionFromLocation(startLocation); - endPosition = document.positionFromLocation(endLocation); - range = normalizeRange([startPosition, endPosition]); - return { - document, - range - }; - } - convertLineBreaksToBlockBreaksInRange(range) { - range = normalizeRange(range); - let [position] = range; - const string = this.getStringAtRange(range).slice(0, -1); - let document = this; - string.replace(/.*?\n/g, function (match) { - position += match.length; - document = document.insertBlockBreakAtRange([position - 1, position]); - }); - return { - document, - range - }; - } - consolidateBlocksAtRange(range) { - range = normalizeRange(range); - const [startPosition, endPosition] = range; - const startIndex = this.locationFromPosition(startPosition).index; - const endIndex = this.locationFromPosition(endPosition).index; - return new this.constructor(this.blockList.consolidateFromIndexToIndex(startIndex, endIndex)); - } - getDocumentAtRange(range) { - range = normalizeRange(range); - const blocks = this.blockList.getSplittableListInRange(range).toArray(); - return new this.constructor(blocks); - } - getStringAtRange(range) { - let endIndex; - const array = range = normalizeRange(range), - endPosition = array[array.length - 1]; - if (endPosition !== this.getLength()) { - endIndex = -1; - } - return this.getDocumentAtRange(range).toString().slice(0, endIndex); - } - getBlockAtIndex(index) { - return this.blockList.getObjectAtIndex(index); - } - getBlockAtPosition(position) { - const { - index - } = this.locationFromPosition(position); - return this.getBlockAtIndex(index); - } - getTextAtIndex(index) { - var _this$getBlockAtIndex; - return (_this$getBlockAtIndex = this.getBlockAtIndex(index)) === null || _this$getBlockAtIndex === void 0 ? void 0 : _this$getBlockAtIndex.text; - } - getTextAtPosition(position) { - const { - index - } = this.locationFromPosition(position); - return this.getTextAtIndex(index); - } - getPieceAtPosition(position) { - const { - index, - offset - } = this.locationFromPosition(position); - return this.getTextAtIndex(index).getPieceAtPosition(offset); - } - getCharacterAtPosition(position) { - const { - index, - offset - } = this.locationFromPosition(position); - return this.getTextAtIndex(index).getStringAtRange([offset, offset + 1]); - } - getLength() { - return this.blockList.getEndPosition(); - } - getBlocks() { - return this.blockList.toArray(); - } - getBlockCount() { - return this.blockList.length; - } - getEditCount() { - return this.editCount; - } - eachBlock(callback) { - return this.blockList.eachObject(callback); - } - eachBlockAtRange(range, callback) { - let block, textRange; - range = normalizeRange(range); - const [startPosition, endPosition] = range; - const startLocation = this.locationFromPosition(startPosition); - const endLocation = this.locationFromPosition(endPosition); - if (startLocation.index === endLocation.index) { - block = this.getBlockAtIndex(startLocation.index); - textRange = [startLocation.offset, endLocation.offset]; - return callback(block, textRange, startLocation.index); - } else { - for (let index = startLocation.index; index <= endLocation.index; index++) { - block = this.getBlockAtIndex(index); - if (block) { - switch (index) { - case startLocation.index: - textRange = [startLocation.offset, block.text.getLength()]; - break; - case endLocation.index: - textRange = [0, endLocation.offset]; - break; - default: - textRange = [0, block.text.getLength()]; - } - callback(block, textRange, index); - } - } - } - } - getCommonAttributesAtRange(range) { - range = normalizeRange(range); - const [startPosition] = range; - if (rangeIsCollapsed(range)) { - return this.getCommonAttributesAtPosition(startPosition); - } else { - const textAttributes = []; - const blockAttributes = []; - this.eachBlockAtRange(range, function (block, textRange) { - if (textRange[0] !== textRange[1]) { - textAttributes.push(block.text.getCommonAttributesAtRange(textRange)); - return blockAttributes.push(attributesForBlock(block)); - } - }); - return Hash.fromCommonAttributesOfObjects(textAttributes).merge(Hash.fromCommonAttributesOfObjects(blockAttributes)).toObject(); - } - } - getCommonAttributesAtPosition(position) { - let key, value; - const { - index, - offset - } = this.locationFromPosition(position); - const block = this.getBlockAtIndex(index); - if (!block) { - return {}; - } - const commonAttributes = attributesForBlock(block); - const attributes = block.text.getAttributesAtPosition(offset); - const attributesLeft = block.text.getAttributesAtPosition(offset - 1); - const inheritableAttributes = Object.keys(text_attributes).filter(key => { - return text_attributes[key].inheritable; - }); - for (key in attributesLeft) { - value = attributesLeft[key]; - if (value === attributes[key] || inheritableAttributes.includes(key)) { - commonAttributes[key] = value; - } - } - return commonAttributes; - } - getRangeOfCommonAttributeAtPosition(attributeName, position) { - const { - index, - offset - } = this.locationFromPosition(position); - const text = this.getTextAtIndex(index); - const [startOffset, endOffset] = Array.from(text.getExpandedRangeForAttributeAtOffset(attributeName, offset)); - const start = this.positionFromLocation({ - index, - offset: startOffset - }); - const end = this.positionFromLocation({ - index, - offset: endOffset - }); - return normalizeRange([start, end]); - } - getBaseBlockAttributes() { - let baseBlockAttributes = this.getBlockAtIndex(0).getAttributes(); - for (let blockIndex = 1; blockIndex < this.getBlockCount(); blockIndex++) { - const blockAttributes = this.getBlockAtIndex(blockIndex).getAttributes(); - const lastAttributeIndex = Math.min(baseBlockAttributes.length, blockAttributes.length); - baseBlockAttributes = (() => { - const result = []; - for (let index = 0; index < lastAttributeIndex; index++) { - if (blockAttributes[index] !== baseBlockAttributes[index]) { - break; - } - result.push(blockAttributes[index]); - } - return result; - })(); - } - return baseBlockAttributes; - } - getAttachmentById(attachmentId) { - for (const attachment of this.getAttachments()) { - if (attachment.id === attachmentId) { - return attachment; - } - } - } - getAttachmentPieces() { - let attachmentPieces = []; - this.blockList.eachObject(_ref => { - let { - text - } = _ref; - return attachmentPieces = attachmentPieces.concat(text.getAttachmentPieces()); - }); - return attachmentPieces; - } - getAttachments() { - return this.getAttachmentPieces().map(piece => piece.attachment); - } - getRangeOfAttachment(attachment) { - let position = 0; - const iterable = this.blockList.toArray(); - for (let index = 0; index < iterable.length; index++) { - const { - text - } = iterable[index]; - const textRange = text.getRangeOfAttachment(attachment); - if (textRange) { - return normalizeRange([position + textRange[0], position + textRange[1]]); - } - position += text.getLength(); - } - } - getLocationRangeOfAttachment(attachment) { - const range = this.getRangeOfAttachment(attachment); - return this.locationRangeFromRange(range); - } - getAttachmentPieceForAttachment(attachment) { - for (const piece of this.getAttachmentPieces()) { - if (piece.attachment === attachment) { - return piece; - } - } - } - findRangesForBlockAttribute(attributeName) { - let position = 0; - const ranges = []; - this.getBlocks().forEach(block => { - const length = block.getLength(); - if (block.hasAttribute(attributeName)) { - ranges.push([position, position + length]); - } - position += length; - }); - return ranges; - } - findRangesForTextAttribute(attributeName) { - let { - withValue - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let position = 0; - let range = []; - const ranges = []; - const match = function (piece) { - if (withValue) { - return piece.getAttribute(attributeName) === withValue; - } else { - return piece.hasAttribute(attributeName); - } - }; - this.getPieces().forEach(piece => { - const length = piece.getLength(); - if (match(piece)) { - if (range[1] === position) { - range[1] = position + length; - } else { - ranges.push(range = [position, position + length]); - } - } - position += length; - }); - return ranges; - } - locationFromPosition(position) { - const location = this.blockList.findIndexAndOffsetAtPosition(Math.max(0, position)); - if (location.index != null) { - return location; - } else { - const blocks = this.getBlocks(); - return { - index: blocks.length - 1, - offset: blocks[blocks.length - 1].getLength() - }; - } - } - positionFromLocation(location) { - return this.blockList.findPositionAtIndexAndOffset(location.index, location.offset); - } - locationRangeFromPosition(position) { - return normalizeRange(this.locationFromPosition(position)); - } - locationRangeFromRange(range) { - range = normalizeRange(range); - if (!range) return; - const [startPosition, endPosition] = Array.from(range); - const startLocation = this.locationFromPosition(startPosition); - const endLocation = this.locationFromPosition(endPosition); - return normalizeRange([startLocation, endLocation]); - } - rangeFromLocationRange(locationRange) { - let rightPosition; - locationRange = normalizeRange(locationRange); - const leftPosition = this.positionFromLocation(locationRange[0]); - if (!rangeIsCollapsed(locationRange)) { - rightPosition = this.positionFromLocation(locationRange[1]); - } - return normalizeRange([leftPosition, rightPosition]); - } - isEqualTo(document) { - return this.blockList.isEqualTo(document === null || document === void 0 ? void 0 : document.blockList); - } - getTexts() { - return this.getBlocks().map(block => block.text); - } - getPieces() { - const pieces = []; - Array.from(this.getTexts()).forEach(text => { - pieces.push(...Array.from(text.getPieces() || [])); - }); - return pieces; - } - getObjects() { - return this.getBlocks().concat(this.getTexts()).concat(this.getPieces()); - } - toSerializableDocument() { - const blocks = []; - this.blockList.eachObject(block => blocks.push(block.copyWithText(block.text.toSerializableText()))); - return new this.constructor(blocks); - } - toString() { - return this.blockList.toString(); - } - toJSON() { - return this.blockList.toJSON(); - } - toConsole() { - return JSON.stringify(this.blockList.toArray().map(block => JSON.parse(block.text.toConsole()))); - } - } - const attributesForBlock = function (block) { - const attributes = {}; - const attributeName = block.getLastAttribute(); - if (attributeName) { - attributes[attributeName] = true; - } - return attributes; - }; - - /* eslint-disable - no-case-declarations, - no-irregular-whitespace, - */ - const pieceForString = function (string) { - let attributes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const type = "string"; - string = normalizeSpaces(string); - return { - string, - attributes, - type - }; - }; - const pieceForAttachment = function (attachment) { - let attributes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const type = "attachment"; - return { - attachment, - attributes, - type - }; - }; - const blockForAttributes = function () { - let attributes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - let htmlAttributes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const text = []; - return { - text, - attributes, - htmlAttributes - }; - }; - const parseTrixDataAttribute = (element, name) => { - try { - return JSON.parse(element.getAttribute("data-trix-".concat(name))); - } catch (error) { - return {}; - } - }; - const getImageDimensions = element => { - const width = element.getAttribute("width"); - const height = element.getAttribute("height"); - const dimensions = {}; - if (width) { - dimensions.width = parseInt(width, 10); - } - if (height) { - dimensions.height = parseInt(height, 10); - } - return dimensions; - }; - class HTMLParser extends BasicObject { - static parse(html, options) { - const parser = new this(html, options); - parser.parse(); - return parser; - } - constructor(html) { - let { - referenceElement - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - super(...arguments); - this.html = html; - this.referenceElement = referenceElement; - this.blocks = []; - this.blockElements = []; - this.processedElements = []; - } - getDocument() { - return Document.fromJSON(this.blocks); - } - - // HTML parsing - - parse() { - try { - this.createHiddenContainer(); - HTMLSanitizer.setHTML(this.containerElement, this.html); - const walker = walkTree(this.containerElement, { - usingFilter: nodeFilter - }); - while (walker.nextNode()) { - this.processNode(walker.currentNode); - } - return this.translateBlockElementMarginsToNewlines(); - } finally { - this.removeHiddenContainer(); - } - } - createHiddenContainer() { - if (this.referenceElement) { - this.containerElement = this.referenceElement.cloneNode(false); - this.containerElement.removeAttribute("id"); - this.containerElement.setAttribute("data-trix-internal", ""); - this.containerElement.style.display = "none"; - return this.referenceElement.parentNode.insertBefore(this.containerElement, this.referenceElement.nextSibling); - } else { - this.containerElement = makeElement({ - tagName: "div", - style: { - display: "none" - } - }); - return document.body.appendChild(this.containerElement); - } - } - removeHiddenContainer() { - return removeNode(this.containerElement); - } - processNode(node) { - switch (node.nodeType) { - case Node.TEXT_NODE: - if (!this.isInsignificantTextNode(node)) { - this.appendBlockForTextNode(node); - return this.processTextNode(node); - } - break; - case Node.ELEMENT_NODE: - this.appendBlockForElement(node); - return this.processElement(node); - } - } - appendBlockForTextNode(node) { - const element = node.parentNode; - if (element === this.currentBlockElement && this.isBlockElement(node.previousSibling)) { - return this.appendStringWithAttributes("\n"); - } else if (element === this.containerElement || this.isBlockElement(element)) { - var _this$currentBlock; - const attributes = this.getBlockAttributes(element); - const htmlAttributes = this.getBlockHTMLAttributes(element); - if (!arraysAreEqual(attributes, (_this$currentBlock = this.currentBlock) === null || _this$currentBlock === void 0 ? void 0 : _this$currentBlock.attributes)) { - this.currentBlock = this.appendBlockForAttributesWithElement(attributes, element, htmlAttributes); - this.currentBlockElement = element; - } - } - } - appendBlockForElement(element) { - const elementIsBlockElement = this.isBlockElement(element); - const currentBlockContainsElement = elementContainsNode(this.currentBlockElement, element); - if (elementIsBlockElement && !this.isBlockElement(element.firstChild)) { - if (!this.isInsignificantTextNode(element.firstChild) || !this.isBlockElement(element.firstElementChild)) { - const attributes = this.getBlockAttributes(element); - const htmlAttributes = this.getBlockHTMLAttributes(element); - if (element.firstChild) { - if (!(currentBlockContainsElement && arraysAreEqual(attributes, this.currentBlock.attributes))) { - this.currentBlock = this.appendBlockForAttributesWithElement(attributes, element, htmlAttributes); - this.currentBlockElement = element; - } else { - return this.appendStringWithAttributes("\n"); - } - } - } - } else if (this.currentBlockElement && !currentBlockContainsElement && !elementIsBlockElement) { - const parentBlockElement = this.findParentBlockElement(element); - if (parentBlockElement) { - return this.appendBlockForElement(parentBlockElement); - } else { - this.currentBlock = this.appendEmptyBlock(); - this.currentBlockElement = null; - } - } - } - findParentBlockElement(element) { - let { - parentElement - } = element; - while (parentElement && parentElement !== this.containerElement) { - if (this.isBlockElement(parentElement) && this.blockElements.includes(parentElement)) { - return parentElement; - } else { - parentElement = parentElement.parentElement; - } - } - return null; - } - processTextNode(node) { - let string = node.data; - if (!elementCanDisplayPreformattedText(node.parentNode)) { - var _node$previousSibling; - string = squishBreakableWhitespace(string); - if (stringEndsWithWhitespace((_node$previousSibling = node.previousSibling) === null || _node$previousSibling === void 0 ? void 0 : _node$previousSibling.textContent)) { - string = leftTrimBreakableWhitespace(string); - } - } - return this.appendStringWithAttributes(string, this.getTextAttributes(node.parentNode)); - } - processElement(element) { - let attributes; - if (nodeIsAttachmentElement(element)) { - attributes = parseTrixDataAttribute(element, "attachment"); - if (Object.keys(attributes).length) { - const textAttributes = this.getTextAttributes(element); - this.appendAttachmentWithAttributes(attributes, textAttributes); - // We have everything we need so avoid processing inner nodes - element.innerHTML = ""; - } - return this.processedElements.push(element); - } else { - switch (tagName(element)) { - case "br": - if (!this.isExtraBR(element) && !this.isBlockElement(element.nextSibling)) { - this.appendStringWithAttributes("\n", this.getTextAttributes(element)); - } - return this.processedElements.push(element); - case "img": - attributes = { - url: element.getAttribute("src"), - contentType: "image" - }; - const object = getImageDimensions(element); - for (const key in object) { - const value = object[key]; - attributes[key] = value; - } - this.appendAttachmentWithAttributes(attributes, this.getTextAttributes(element)); - return this.processedElements.push(element); - case "tr": - if (this.needsTableSeparator(element)) { - return this.appendStringWithAttributes(parser.tableRowSeparator); - } - break; - case "td": - if (this.needsTableSeparator(element)) { - return this.appendStringWithAttributes(parser.tableCellSeparator); - } - break; - } - } - } - - // Document construction - - appendBlockForAttributesWithElement(attributes, element) { - let htmlAttributes = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; - this.blockElements.push(element); - const block = blockForAttributes(attributes, htmlAttributes); - this.blocks.push(block); - return block; - } - appendEmptyBlock() { - return this.appendBlockForAttributesWithElement([], null); - } - appendStringWithAttributes(string, attributes) { - return this.appendPiece(pieceForString(string, attributes)); - } - appendAttachmentWithAttributes(attachment, attributes) { - return this.appendPiece(pieceForAttachment(attachment, attributes)); - } - appendPiece(piece) { - if (this.blocks.length === 0) { - this.appendEmptyBlock(); - } - return this.blocks[this.blocks.length - 1].text.push(piece); - } - appendStringToTextAtIndex(string, index) { - const { - text - } = this.blocks[index]; - const piece = text[text.length - 1]; - if ((piece === null || piece === void 0 ? void 0 : piece.type) === "string") { - piece.string += string; - } else { - return text.push(pieceForString(string)); - } - } - prependStringToTextAtIndex(string, index) { - const { - text - } = this.blocks[index]; - const piece = text[0]; - if ((piece === null || piece === void 0 ? void 0 : piece.type) === "string") { - piece.string = string + piece.string; - } else { - return text.unshift(pieceForString(string)); - } - } - - // Attribute parsing - - getTextAttributes(element) { - let value; - const attributes = {}; - for (const attribute in text_attributes) { - const configAttr = text_attributes[attribute]; - if (configAttr.tagName && findClosestElementFromNode(element, { - matchingSelector: configAttr.tagName, - untilNode: this.containerElement - })) { - attributes[attribute] = true; - } else if (configAttr.parser) { - value = configAttr.parser(element); - if (value) { - let attributeInheritedFromBlock = false; - for (const blockElement of this.findBlockElementAncestors(element)) { - if (configAttr.parser(blockElement) === value) { - attributeInheritedFromBlock = true; - break; - } - } - if (!attributeInheritedFromBlock) { - attributes[attribute] = value; - } - } - } else if (configAttr.styleProperty) { - value = element.style[configAttr.styleProperty]; - if (value) { - attributes[attribute] = value; - } - } - } - if (nodeIsAttachmentElement(element)) { - const object = parseTrixDataAttribute(element, "attributes"); - for (const key in object) { - value = object[key]; - attributes[key] = value; - } - } - return attributes; - } - getBlockAttributes(element) { - const attributes$1 = []; - while (element && element !== this.containerElement) { - for (const attribute in attributes) { - const attrConfig = attributes[attribute]; - if (attrConfig.parse !== false) { - if (tagName(element) === attrConfig.tagName) { - var _attrConfig$test; - if ((_attrConfig$test = attrConfig.test) !== null && _attrConfig$test !== void 0 && _attrConfig$test.call(attrConfig, element) || !attrConfig.test) { - attributes$1.push(attribute); - if (attrConfig.listAttribute) { - attributes$1.push(attrConfig.listAttribute); - } - } - } - } - } - element = element.parentNode; - } - return attributes$1.reverse(); - } - getBlockHTMLAttributes(element) { - const attributes$1 = {}; - const blockConfig = Object.values(attributes).find(settings => settings.tagName === tagName(element)); - const allowedAttributes = (blockConfig === null || blockConfig === void 0 ? void 0 : blockConfig.htmlAttributes) || []; - allowedAttributes.forEach(attribute => { - if (element.hasAttribute(attribute)) { - attributes$1[attribute] = element.getAttribute(attribute); - } - }); - return attributes$1; - } - findBlockElementAncestors(element) { - const ancestors = []; - while (element && element !== this.containerElement) { - const tag = tagName(element); - if (getBlockTagNames().includes(tag)) { - ancestors.push(element); - } - element = element.parentNode; - } - return ancestors; - } - - // Element inspection - - isBlockElement(element) { - if ((element === null || element === void 0 ? void 0 : element.nodeType) !== Node.ELEMENT_NODE) return; - if (nodeIsAttachmentElement(element)) return; - if (findClosestElementFromNode(element, { - matchingSelector: "td", - untilNode: this.containerElement - })) return; - return getBlockTagNames().includes(tagName(element)) || window.getComputedStyle(element).display === "block"; - } - isInsignificantTextNode(node) { - if ((node === null || node === void 0 ? void 0 : node.nodeType) !== Node.TEXT_NODE) return; - if (!stringIsAllBreakableWhitespace(node.data)) return; - const { - parentNode, - previousSibling, - nextSibling - } = node; - if (nodeEndsWithNonWhitespace(parentNode.previousSibling) && !this.isBlockElement(parentNode.previousSibling)) return; - if (elementCanDisplayPreformattedText(parentNode)) return; - return !previousSibling || this.isBlockElement(previousSibling) || !nextSibling || this.isBlockElement(nextSibling); - } - isExtraBR(element) { - return tagName(element) === "br" && this.isBlockElement(element.parentNode) && element.parentNode.lastChild === element; - } - needsTableSeparator(element) { - if (parser.removeBlankTableCells) { - var _element$previousSibl; - const content = (_element$previousSibl = element.previousSibling) === null || _element$previousSibl === void 0 ? void 0 : _element$previousSibl.textContent; - return content && /\S/.test(content); - } else { - return element.previousSibling; - } - } - - // Margin translation - - translateBlockElementMarginsToNewlines() { - const defaultMargin = this.getMarginOfDefaultBlockElement(); - for (let index = 0; index < this.blocks.length; index++) { - const margin = this.getMarginOfBlockElementAtIndex(index); - if (margin) { - if (margin.top > defaultMargin.top * 2) { - this.prependStringToTextAtIndex("\n", index); - } - if (margin.bottom > defaultMargin.bottom * 2) { - this.appendStringToTextAtIndex("\n", index); - } - } - } - } - getMarginOfBlockElementAtIndex(index) { - const element = this.blockElements[index]; - if (element) { - if (element.textContent) { - if (!getBlockTagNames().includes(tagName(element)) && !this.processedElements.includes(element)) { - return getBlockElementMargin(element); - } - } - } - } - getMarginOfDefaultBlockElement() { - const element = makeElement(attributes.default.tagName); - this.containerElement.appendChild(element); - return getBlockElementMargin(element); - } - } - - // Helpers - - const elementCanDisplayPreformattedText = function (element) { - const { - whiteSpace - } = window.getComputedStyle(element); - return ["pre", "pre-wrap", "pre-line"].includes(whiteSpace); - }; - const nodeEndsWithNonWhitespace = node => node && !stringEndsWithWhitespace(node.textContent); - const getBlockElementMargin = function (element) { - const style = window.getComputedStyle(element); - if (style.display === "block") { - return { - top: parseInt(style.marginTop), - bottom: parseInt(style.marginBottom) - }; - } - }; - const nodeFilter = function (node) { - if (tagName(node) === "style") { - return NodeFilter.FILTER_REJECT; - } else { - return NodeFilter.FILTER_ACCEPT; - } - }; - - // Whitespace - - const leftTrimBreakableWhitespace = string => string.replace(new RegExp("^".concat(breakableWhitespacePattern.source, "+")), ""); - const stringIsAllBreakableWhitespace = string => new RegExp("^".concat(breakableWhitespacePattern.source, "*$")).test(string); - const stringEndsWithWhitespace = string => /\s$/.test(string); - - /* eslint-disable - no-empty, - */ - const unserializableElementSelector = "[data-trix-serialize=false]"; - const unserializableAttributeNames = ["contenteditable", "data-trix-id", "data-trix-store-key", "data-trix-mutable", "data-trix-placeholder", "tabindex"]; - const serializedAttributesAttribute = "data-trix-serialized-attributes"; - const serializedAttributesSelector = "[".concat(serializedAttributesAttribute, "]"); - const blockCommentPattern = new RegExp("", "g"); - const serializers = { - "application/json": function (serializable) { - let document; - if (serializable instanceof Document) { - document = serializable; - } else if (serializable instanceof HTMLElement) { - document = HTMLParser.parse(serializable.innerHTML).getDocument(); - } else { - throw new Error("unserializable object"); - } - return document.toSerializableDocument().toJSONString(); - }, - "text/html": function (serializable) { - let element; - if (serializable instanceof Document) { - element = DocumentView.render(serializable); - } else if (serializable instanceof HTMLElement) { - element = serializable.cloneNode(true); - } else { - throw new Error("unserializable object"); - } - - // Remove unserializable elements - Array.from(element.querySelectorAll(unserializableElementSelector)).forEach(el => { - removeNode(el); - }); - - // Remove unserializable attributes - unserializableAttributeNames.forEach(attribute => { - Array.from(element.querySelectorAll("[".concat(attribute, "]"))).forEach(el => { - el.removeAttribute(attribute); - }); - }); - - // Rewrite elements with serialized attribute overrides - Array.from(element.querySelectorAll(serializedAttributesSelector)).forEach(el => { - try { - const attributes = JSON.parse(el.getAttribute(serializedAttributesAttribute)); - el.removeAttribute(serializedAttributesAttribute); - for (const name in attributes) { - const value = attributes[name]; - el.setAttribute(name, value); - } - } catch (error) {} - }); - return element.innerHTML.replace(blockCommentPattern, ""); - } - }; - const deserializers = { - "application/json": function (string) { - return Document.fromJSONString(string); - }, - "text/html": function (string) { - return HTMLParser.parse(string).getDocument(); - } - }; - const serializeToContentType = function (serializable, contentType) { - const serializer = serializers[contentType]; - if (serializer) { - return serializer(serializable); - } else { - throw new Error("unknown content type: ".concat(contentType)); - } - }; - const deserializeFromContentType = function (string, contentType) { - const deserializer = deserializers[contentType]; - if (deserializer) { - return deserializer(string); - } else { - throw new Error("unknown content type: ".concat(contentType)); - } - }; - - var core = /*#__PURE__*/Object.freeze({ - __proto__: null - }); - - class ManagedAttachment extends BasicObject { - constructor(attachmentManager, attachment) { - super(...arguments); - this.attachmentManager = attachmentManager; - this.attachment = attachment; - this.id = this.attachment.id; - this.file = this.attachment.file; - } - remove() { - return this.attachmentManager.requestRemovalOfAttachment(this.attachment); - } - } - ManagedAttachment.proxyMethod("attachment.getAttribute"); - ManagedAttachment.proxyMethod("attachment.hasAttribute"); - ManagedAttachment.proxyMethod("attachment.setAttribute"); - ManagedAttachment.proxyMethod("attachment.getAttributes"); - ManagedAttachment.proxyMethod("attachment.setAttributes"); - ManagedAttachment.proxyMethod("attachment.isPending"); - ManagedAttachment.proxyMethod("attachment.isPreviewable"); - ManagedAttachment.proxyMethod("attachment.getURL"); - ManagedAttachment.proxyMethod("attachment.getHref"); - ManagedAttachment.proxyMethod("attachment.getFilename"); - ManagedAttachment.proxyMethod("attachment.getFilesize"); - ManagedAttachment.proxyMethod("attachment.getFormattedFilesize"); - ManagedAttachment.proxyMethod("attachment.getExtension"); - ManagedAttachment.proxyMethod("attachment.getContentType"); - ManagedAttachment.proxyMethod("attachment.getFile"); - ManagedAttachment.proxyMethod("attachment.setFile"); - ManagedAttachment.proxyMethod("attachment.releaseFile"); - ManagedAttachment.proxyMethod("attachment.getUploadProgress"); - ManagedAttachment.proxyMethod("attachment.setUploadProgress"); - - class AttachmentManager extends BasicObject { - constructor() { - let attachments = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - this.managedAttachments = {}; - Array.from(attachments).forEach(attachment => { - this.manageAttachment(attachment); - }); - } - getAttachments() { - const result = []; - for (const id in this.managedAttachments) { - const attachment = this.managedAttachments[id]; - result.push(attachment); - } - return result; - } - manageAttachment(attachment) { - if (!this.managedAttachments[attachment.id]) { - this.managedAttachments[attachment.id] = new ManagedAttachment(this, attachment); - } - return this.managedAttachments[attachment.id]; - } - attachmentIsManaged(attachment) { - return attachment.id in this.managedAttachments; - } - requestRemovalOfAttachment(attachment) { - if (this.attachmentIsManaged(attachment)) { - var _this$delegate, _this$delegate$attach; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$attach = _this$delegate.attachmentManagerDidRequestRemovalOfAttachment) === null || _this$delegate$attach === void 0 ? void 0 : _this$delegate$attach.call(_this$delegate, attachment); - } - } - unmanageAttachment(attachment) { - const managedAttachment = this.managedAttachments[attachment.id]; - delete this.managedAttachments[attachment.id]; - return managedAttachment; - } - } - - class LineBreakInsertion { - constructor(composition) { - this.composition = composition; - this.document = this.composition.document; - const selectedRange = this.composition.getSelectedRange(); - this.startPosition = selectedRange[0]; - this.endPosition = selectedRange[1]; - this.startLocation = this.document.locationFromPosition(this.startPosition); - this.endLocation = this.document.locationFromPosition(this.endPosition); - this.block = this.document.getBlockAtIndex(this.endLocation.index); - this.breaksOnReturn = this.block.breaksOnReturn(); - this.previousCharacter = this.block.text.getStringAtPosition(this.endLocation.offset - 1); - this.nextCharacter = this.block.text.getStringAtPosition(this.endLocation.offset); - } - shouldInsertBlockBreak() { - if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) { - return this.startLocation.offset !== 0; - } else { - return this.breaksOnReturn && this.nextCharacter !== "\n"; - } - } - shouldBreakFormattedBlock() { - return this.block.hasAttributes() && !this.block.isListItem() && (this.breaksOnReturn && this.nextCharacter === "\n" || this.previousCharacter === "\n"); - } - shouldDecreaseListLevel() { - return this.block.hasAttributes() && this.block.isListItem() && this.block.isEmpty(); - } - shouldPrependListItem() { - return this.block.isListItem() && this.startLocation.offset === 0 && !this.block.isEmpty(); - } - shouldRemoveLastBlockAttribute() { - return this.block.hasAttributes() && !this.block.isListItem() && this.block.isEmpty(); - } - } - - const PLACEHOLDER = " "; - class Composition extends BasicObject { - constructor() { - super(...arguments); - this.document = new Document(); - this.attachments = []; - this.currentAttributes = {}; - this.revision = 0; - } - setDocument(document) { - if (!document.isEqualTo(this.document)) { - var _this$delegate, _this$delegate$compos; - this.document = document; - this.refreshAttachments(); - this.revision++; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$compos = _this$delegate.compositionDidChangeDocument) === null || _this$delegate$compos === void 0 ? void 0 : _this$delegate$compos.call(_this$delegate, document); - } - } - - // Snapshots - - getSnapshot() { - return { - document: this.document, - selectedRange: this.getSelectedRange() - }; - } - loadSnapshot(_ref) { - var _this$delegate2, _this$delegate2$compo, _this$delegate3, _this$delegate3$compo; - let { - document, - selectedRange - } = _ref; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$compo = _this$delegate2.compositionWillLoadSnapshot) === null || _this$delegate2$compo === void 0 || _this$delegate2$compo.call(_this$delegate2); - this.setDocument(document != null ? document : new Document()); - this.setSelection(selectedRange != null ? selectedRange : [0, 0]); - return (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || (_this$delegate3$compo = _this$delegate3.compositionDidLoadSnapshot) === null || _this$delegate3$compo === void 0 ? void 0 : _this$delegate3$compo.call(_this$delegate3); - } - - // Responder protocol - - insertText(text) { - let { - updatePosition - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { - updatePosition: true - }; - const selectedRange = this.getSelectedRange(); - this.setDocument(this.document.insertTextAtRange(text, selectedRange)); - const startPosition = selectedRange[0]; - const endPosition = startPosition + text.getLength(); - if (updatePosition) { - this.setSelection(endPosition); - } - return this.notifyDelegateOfInsertionAtRange([startPosition, endPosition]); - } - insertBlock() { - let block = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : new Block(); - const document = new Document([block]); - return this.insertDocument(document); - } - insertDocument() { - let document = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : new Document(); - const selectedRange = this.getSelectedRange(); - this.setDocument(this.document.insertDocumentAtRange(document, selectedRange)); - const startPosition = selectedRange[0]; - const endPosition = startPosition + document.getLength(); - this.setSelection(endPosition); - return this.notifyDelegateOfInsertionAtRange([startPosition, endPosition]); - } - insertString(string, options) { - const attributes = this.getCurrentTextAttributes(); - const text = Text.textForStringWithAttributes(string, attributes); - return this.insertText(text, options); - } - insertBlockBreak() { - const selectedRange = this.getSelectedRange(); - this.setDocument(this.document.insertBlockBreakAtRange(selectedRange)); - const startPosition = selectedRange[0]; - const endPosition = startPosition + 1; - this.setSelection(endPosition); - return this.notifyDelegateOfInsertionAtRange([startPosition, endPosition]); - } - insertLineBreak() { - const insertion = new LineBreakInsertion(this); - if (insertion.shouldDecreaseListLevel()) { - this.decreaseListLevel(); - return this.setSelection(insertion.startPosition); - } else if (insertion.shouldPrependListItem()) { - const document = new Document([insertion.block.copyWithoutText()]); - return this.insertDocument(document); - } else if (insertion.shouldInsertBlockBreak()) { - return this.insertBlockBreak(); - } else if (insertion.shouldRemoveLastBlockAttribute()) { - return this.removeLastBlockAttribute(); - } else if (insertion.shouldBreakFormattedBlock()) { - return this.breakFormattedBlock(insertion); - } else { - return this.insertString("\n"); - } - } - insertHTML(html) { - const document = HTMLParser.parse(html).getDocument(); - const selectedRange = this.getSelectedRange(); - this.setDocument(this.document.mergeDocumentAtRange(document, selectedRange)); - const startPosition = selectedRange[0]; - const endPosition = startPosition + document.getLength() - 1; - this.setSelection(endPosition); - return this.notifyDelegateOfInsertionAtRange([startPosition, endPosition]); - } - replaceHTML(html) { - const document = HTMLParser.parse(html).getDocument().copyUsingObjectsFromDocument(this.document); - const locationRange = this.getLocationRange({ - strict: false - }); - const selectedRange = this.document.rangeFromLocationRange(locationRange); - this.setDocument(document); - return this.setSelection(selectedRange); - } - insertFile(file) { - return this.insertFiles([file]); - } - insertFiles(files) { - const attachments = []; - Array.from(files).forEach(file => { - var _this$delegate4; - if ((_this$delegate4 = this.delegate) !== null && _this$delegate4 !== void 0 && _this$delegate4.compositionShouldAcceptFile(file)) { - const attachment = Attachment.attachmentForFile(file); - attachments.push(attachment); - } - }); - return this.insertAttachments(attachments); - } - insertAttachment(attachment) { - return this.insertAttachments([attachment]); - } - insertAttachments(attachments$1) { - let text = new Text(); - Array.from(attachments$1).forEach(attachment => { - var _config$attachments$t; - const type = attachment.getType(); - const presentation = (_config$attachments$t = attachments[type]) === null || _config$attachments$t === void 0 ? void 0 : _config$attachments$t.presentation; - const attributes = this.getCurrentTextAttributes(); - if (presentation) { - attributes.presentation = presentation; - } - const attachmentText = Text.textForAttachmentWithAttributes(attachment, attributes); - text = text.appendText(attachmentText); - }); - return this.insertText(text); - } - shouldManageDeletingInDirection(direction) { - const locationRange = this.getLocationRange(); - if (rangeIsCollapsed(locationRange)) { - if (direction === "backward" && locationRange[0].offset === 0) { - return true; - } - if (this.shouldManageMovingCursorInDirection(direction)) { - return true; - } - } else { - if (locationRange[0].index !== locationRange[1].index) { - return true; - } - } - return false; - } - deleteInDirection(direction) { - let { - length - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let attachment, deletingIntoPreviousBlock, selectionSpansBlocks; - const locationRange = this.getLocationRange(); - let range = this.getSelectedRange(); - const selectionIsCollapsed = rangeIsCollapsed(range); - if (selectionIsCollapsed) { - deletingIntoPreviousBlock = direction === "backward" && locationRange[0].offset === 0; - } else { - selectionSpansBlocks = locationRange[0].index !== locationRange[1].index; - } - if (deletingIntoPreviousBlock) { - if (this.canDecreaseBlockAttributeLevel()) { - const block = this.getBlock(); - if (block.isListItem()) { - this.decreaseListLevel(); - } else { - this.decreaseBlockAttributeLevel(); - } - this.setSelection(range[0]); - if (block.isEmpty()) { - return false; - } - } - } - if (selectionIsCollapsed) { - range = this.getExpandedRangeInDirection(direction, { - length - }); - if (direction === "backward") { - attachment = this.getAttachmentAtRange(range); - } - } - if (attachment) { - this.editAttachment(attachment); - return false; - } else { - this.setDocument(this.document.removeTextAtRange(range)); - this.setSelection(range[0]); - if (deletingIntoPreviousBlock || selectionSpansBlocks) { - return false; - } - } - } - moveTextFromRange(range) { - const [position] = Array.from(this.getSelectedRange()); - this.setDocument(this.document.moveTextFromRangeToPosition(range, position)); - return this.setSelection(position); - } - removeAttachment(attachment) { - const range = this.document.getRangeOfAttachment(attachment); - if (range) { - this.stopEditingAttachment(); - this.setDocument(this.document.removeTextAtRange(range)); - return this.setSelection(range[0]); - } - } - removeLastBlockAttribute() { - const [startPosition, endPosition] = Array.from(this.getSelectedRange()); - const block = this.document.getBlockAtPosition(endPosition); - this.removeCurrentAttribute(block.getLastAttribute()); - return this.setSelection(startPosition); - } - insertPlaceholder() { - this.placeholderPosition = this.getPosition(); - return this.insertString(PLACEHOLDER); - } - selectPlaceholder() { - if (this.placeholderPosition != null) { - this.setSelectedRange([this.placeholderPosition, this.placeholderPosition + PLACEHOLDER.length]); - return this.getSelectedRange(); - } - } - forgetPlaceholder() { - this.placeholderPosition = null; - } - - // Current attributes - - hasCurrentAttribute(attributeName) { - const value = this.currentAttributes[attributeName]; - return value != null && value !== false; - } - toggleCurrentAttribute(attributeName) { - const value = !this.currentAttributes[attributeName]; - if (value) { - return this.setCurrentAttribute(attributeName, value); - } else { - return this.removeCurrentAttribute(attributeName); - } - } - canSetCurrentAttribute(attributeName) { - if (getBlockConfig(attributeName)) { - return this.canSetCurrentBlockAttribute(attributeName); - } else { - return this.canSetCurrentTextAttribute(attributeName); - } - } - canSetCurrentTextAttribute(attributeName) { - const document = this.getSelectedDocument(); - if (!document) return; - for (const attachment of Array.from(document.getAttachments())) { - if (!attachment.hasContent()) { - return false; - } - } - return true; - } - canSetCurrentBlockAttribute(attributeName) { - const block = this.getBlock(); - if (!block) return; - return !block.isTerminalBlock(); - } - setCurrentAttribute(attributeName, value) { - if (getBlockConfig(attributeName)) { - return this.setBlockAttribute(attributeName, value); - } else { - this.setTextAttribute(attributeName, value); - this.currentAttributes[attributeName] = value; - return this.notifyDelegateOfCurrentAttributesChange(); - } - } - setHTMLAtributeAtPosition(position, attributeName, value) { - var _getBlockConfig; - const block = this.document.getBlockAtPosition(position); - const allowedHTMLAttributes = (_getBlockConfig = getBlockConfig(block.getLastAttribute())) === null || _getBlockConfig === void 0 ? void 0 : _getBlockConfig.htmlAttributes; - if (block && allowedHTMLAttributes !== null && allowedHTMLAttributes !== void 0 && allowedHTMLAttributes.includes(attributeName)) { - const newDocument = this.document.setHTMLAttributeAtPosition(position, attributeName, value); - this.setDocument(newDocument); - } - } - setTextAttribute(attributeName, value) { - const selectedRange = this.getSelectedRange(); - if (!selectedRange) return; - const [startPosition, endPosition] = Array.from(selectedRange); - if (startPosition === endPosition) { - if (attributeName === "href") { - const text = Text.textForStringWithAttributes(value, { - href: value - }); - return this.insertText(text); - } - } else { - return this.setDocument(this.document.addAttributeAtRange(attributeName, value, selectedRange)); - } - } - setBlockAttribute(attributeName, value) { - const selectedRange = this.getSelectedRange(); - if (this.canSetCurrentAttribute(attributeName)) { - this.setDocument(this.document.applyBlockAttributeAtRange(attributeName, value, selectedRange)); - return this.setSelection(selectedRange); - } - } - removeCurrentAttribute(attributeName) { - if (getBlockConfig(attributeName)) { - this.removeBlockAttribute(attributeName); - return this.updateCurrentAttributes(); - } else { - this.removeTextAttribute(attributeName); - delete this.currentAttributes[attributeName]; - return this.notifyDelegateOfCurrentAttributesChange(); - } - } - removeTextAttribute(attributeName) { - const selectedRange = this.getSelectedRange(); - if (!selectedRange) return; - return this.setDocument(this.document.removeAttributeAtRange(attributeName, selectedRange)); - } - removeBlockAttribute(attributeName) { - const selectedRange = this.getSelectedRange(); - if (!selectedRange) return; - return this.setDocument(this.document.removeAttributeAtRange(attributeName, selectedRange)); - } - canDecreaseNestingLevel() { - var _this$getBlock; - return ((_this$getBlock = this.getBlock()) === null || _this$getBlock === void 0 ? void 0 : _this$getBlock.getNestingLevel()) > 0; - } - canIncreaseNestingLevel() { - var _getBlockConfig2; - const block = this.getBlock(); - if (!block) return; - if ((_getBlockConfig2 = getBlockConfig(block.getLastNestableAttribute())) !== null && _getBlockConfig2 !== void 0 && _getBlockConfig2.listAttribute) { - const previousBlock = this.getPreviousBlock(); - if (previousBlock) { - return arrayStartsWith(previousBlock.getListItemAttributes(), block.getListItemAttributes()); - } - } else { - return block.getNestingLevel() > 0; - } - } - decreaseNestingLevel() { - const block = this.getBlock(); - if (!block) return; - return this.setDocument(this.document.replaceBlock(block, block.decreaseNestingLevel())); - } - increaseNestingLevel() { - const block = this.getBlock(); - if (!block) return; - return this.setDocument(this.document.replaceBlock(block, block.increaseNestingLevel())); - } - canDecreaseBlockAttributeLevel() { - var _this$getBlock2; - return ((_this$getBlock2 = this.getBlock()) === null || _this$getBlock2 === void 0 ? void 0 : _this$getBlock2.getAttributeLevel()) > 0; - } - decreaseBlockAttributeLevel() { - var _this$getBlock3; - const attribute = (_this$getBlock3 = this.getBlock()) === null || _this$getBlock3 === void 0 ? void 0 : _this$getBlock3.getLastAttribute(); - if (attribute) { - return this.removeCurrentAttribute(attribute); - } - } - decreaseListLevel() { - let [startPosition] = Array.from(this.getSelectedRange()); - const { - index - } = this.document.locationFromPosition(startPosition); - let endIndex = index; - const attributeLevel = this.getBlock().getAttributeLevel(); - let block = this.document.getBlockAtIndex(endIndex + 1); - while (block) { - if (!block.isListItem() || block.getAttributeLevel() <= attributeLevel) { - break; - } - endIndex++; - block = this.document.getBlockAtIndex(endIndex + 1); - } - startPosition = this.document.positionFromLocation({ - index, - offset: 0 - }); - const endPosition = this.document.positionFromLocation({ - index: endIndex, - offset: 0 - }); - return this.setDocument(this.document.removeLastListAttributeAtRange([startPosition, endPosition])); - } - updateCurrentAttributes() { - const selectedRange = this.getSelectedRange({ - ignoreLock: true - }); - if (selectedRange) { - const currentAttributes = this.document.getCommonAttributesAtRange(selectedRange); - Array.from(getAllAttributeNames()).forEach(attributeName => { - if (!currentAttributes[attributeName]) { - if (!this.canSetCurrentAttribute(attributeName)) { - currentAttributes[attributeName] = false; - } - } - }); - if (!objectsAreEqual(currentAttributes, this.currentAttributes)) { - this.currentAttributes = currentAttributes; - return this.notifyDelegateOfCurrentAttributesChange(); - } - } - } - getCurrentAttributes() { - return extend.call({}, this.currentAttributes); - } - getCurrentTextAttributes() { - const attributes = {}; - for (const key in this.currentAttributes) { - const value = this.currentAttributes[key]; - if (value !== false) { - if (getTextConfig(key)) { - attributes[key] = value; - } - } - } - return attributes; - } - - // Selection freezing - - freezeSelection() { - return this.setCurrentAttribute("frozen", true); - } - thawSelection() { - return this.removeCurrentAttribute("frozen"); - } - hasFrozenSelection() { - return this.hasCurrentAttribute("frozen"); - } - setSelection(selectedRange) { - var _this$delegate5; - const locationRange = this.document.locationRangeFromRange(selectedRange); - return (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 ? void 0 : _this$delegate5.compositionDidRequestChangingSelectionToLocationRange(locationRange); - } - getSelectedRange() { - const locationRange = this.getLocationRange(); - if (locationRange) { - return this.document.rangeFromLocationRange(locationRange); - } - } - setSelectedRange(selectedRange) { - const locationRange = this.document.locationRangeFromRange(selectedRange); - return this.getSelectionManager().setLocationRange(locationRange); - } - getPosition() { - const locationRange = this.getLocationRange(); - if (locationRange) { - return this.document.positionFromLocation(locationRange[0]); - } - } - getLocationRange(options) { - if (this.targetLocationRange) { - return this.targetLocationRange; - } else { - return this.getSelectionManager().getLocationRange(options) || normalizeRange({ - index: 0, - offset: 0 - }); - } - } - withTargetLocationRange(locationRange, fn) { - let result; - this.targetLocationRange = locationRange; - try { - result = fn(); - } finally { - this.targetLocationRange = null; - } - return result; - } - withTargetRange(range, fn) { - const locationRange = this.document.locationRangeFromRange(range); - return this.withTargetLocationRange(locationRange, fn); - } - withTargetDOMRange(domRange, fn) { - const locationRange = this.createLocationRangeFromDOMRange(domRange, { - strict: false - }); - return this.withTargetLocationRange(locationRange, fn); - } - getExpandedRangeInDirection(direction) { - let { - length - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let [startPosition, endPosition] = Array.from(this.getSelectedRange()); - if (direction === "backward") { - if (length) { - startPosition -= length; - } else { - startPosition = this.translateUTF16PositionFromOffset(startPosition, -1); - } - } else { - if (length) { - endPosition += length; - } else { - endPosition = this.translateUTF16PositionFromOffset(endPosition, 1); - } - } - return normalizeRange([startPosition, endPosition]); - } - shouldManageMovingCursorInDirection(direction) { - if (this.editingAttachment) { - return true; - } - const range = this.getExpandedRangeInDirection(direction); - return this.getAttachmentAtRange(range) != null; - } - moveCursorInDirection(direction) { - let canEditAttachment, range; - if (this.editingAttachment) { - range = this.document.getRangeOfAttachment(this.editingAttachment); - } else { - const selectedRange = this.getSelectedRange(); - range = this.getExpandedRangeInDirection(direction); - canEditAttachment = !rangesAreEqual(selectedRange, range); - } - if (direction === "backward") { - this.setSelectedRange(range[0]); - } else { - this.setSelectedRange(range[1]); - } - if (canEditAttachment) { - const attachment = this.getAttachmentAtRange(range); - if (attachment) { - return this.editAttachment(attachment); - } - } - } - expandSelectionInDirection(direction) { - let { - length - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const range = this.getExpandedRangeInDirection(direction, { - length - }); - return this.setSelectedRange(range); - } - expandSelectionForEditing() { - if (this.hasCurrentAttribute("href")) { - return this.expandSelectionAroundCommonAttribute("href"); - } - } - expandSelectionAroundCommonAttribute(attributeName) { - const position = this.getPosition(); - const range = this.document.getRangeOfCommonAttributeAtPosition(attributeName, position); - return this.setSelectedRange(range); - } - selectionContainsAttachments() { - var _this$getSelectedAtta; - return ((_this$getSelectedAtta = this.getSelectedAttachments()) === null || _this$getSelectedAtta === void 0 ? void 0 : _this$getSelectedAtta.length) > 0; - } - selectionIsInCursorTarget() { - return this.editingAttachment || this.positionIsCursorTarget(this.getPosition()); - } - positionIsCursorTarget(position) { - const location = this.document.locationFromPosition(position); - if (location) { - return this.locationIsCursorTarget(location); - } - } - positionIsBlockBreak(position) { - var _this$document$getPie; - return (_this$document$getPie = this.document.getPieceAtPosition(position)) === null || _this$document$getPie === void 0 ? void 0 : _this$document$getPie.isBlockBreak(); - } - getSelectedDocument() { - const selectedRange = this.getSelectedRange(); - if (selectedRange) { - return this.document.getDocumentAtRange(selectedRange); - } - } - getSelectedAttachments() { - var _this$getSelectedDocu; - return (_this$getSelectedDocu = this.getSelectedDocument()) === null || _this$getSelectedDocu === void 0 ? void 0 : _this$getSelectedDocu.getAttachments(); - } - - // Attachments - - getAttachments() { - return this.attachments.slice(0); - } - refreshAttachments() { - const attachments = this.document.getAttachments(); - const { - added, - removed - } = summarizeArrayChange(this.attachments, attachments); - this.attachments = attachments; - Array.from(removed).forEach(attachment => { - var _this$delegate6, _this$delegate6$compo; - attachment.delegate = null; - (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 || (_this$delegate6$compo = _this$delegate6.compositionDidRemoveAttachment) === null || _this$delegate6$compo === void 0 || _this$delegate6$compo.call(_this$delegate6, attachment); - }); - return (() => { - const result = []; - Array.from(added).forEach(attachment => { - var _this$delegate7, _this$delegate7$compo; - attachment.delegate = this; - result.push((_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || (_this$delegate7$compo = _this$delegate7.compositionDidAddAttachment) === null || _this$delegate7$compo === void 0 ? void 0 : _this$delegate7$compo.call(_this$delegate7, attachment)); - }); - return result; - })(); - } - - // Attachment delegate - - attachmentDidChangeAttributes(attachment) { - var _this$delegate8, _this$delegate8$compo; - this.revision++; - return (_this$delegate8 = this.delegate) === null || _this$delegate8 === void 0 || (_this$delegate8$compo = _this$delegate8.compositionDidEditAttachment) === null || _this$delegate8$compo === void 0 ? void 0 : _this$delegate8$compo.call(_this$delegate8, attachment); - } - attachmentDidChangePreviewURL(attachment) { - var _this$delegate9, _this$delegate9$compo; - this.revision++; - return (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 || (_this$delegate9$compo = _this$delegate9.compositionDidChangeAttachmentPreviewURL) === null || _this$delegate9$compo === void 0 ? void 0 : _this$delegate9$compo.call(_this$delegate9, attachment); - } - - // Attachment editing - - editAttachment(attachment, options) { - var _this$delegate10, _this$delegate10$comp; - if (attachment === this.editingAttachment) return; - this.stopEditingAttachment(); - this.editingAttachment = attachment; - return (_this$delegate10 = this.delegate) === null || _this$delegate10 === void 0 || (_this$delegate10$comp = _this$delegate10.compositionDidStartEditingAttachment) === null || _this$delegate10$comp === void 0 ? void 0 : _this$delegate10$comp.call(_this$delegate10, this.editingAttachment, options); - } - stopEditingAttachment() { - var _this$delegate11, _this$delegate11$comp; - if (!this.editingAttachment) return; - (_this$delegate11 = this.delegate) === null || _this$delegate11 === void 0 || (_this$delegate11$comp = _this$delegate11.compositionDidStopEditingAttachment) === null || _this$delegate11$comp === void 0 || _this$delegate11$comp.call(_this$delegate11, this.editingAttachment); - this.editingAttachment = null; - } - updateAttributesForAttachment(attributes, attachment) { - return this.setDocument(this.document.updateAttributesForAttachment(attributes, attachment)); - } - removeAttributeForAttachment(attribute, attachment) { - return this.setDocument(this.document.removeAttributeForAttachment(attribute, attachment)); - } - - // Private - - breakFormattedBlock(insertion) { - let { - document - } = insertion; - const { - block - } = insertion; - let position = insertion.startPosition; - let range = [position - 1, position]; - if (block.getBlockBreakPosition() === insertion.startLocation.offset) { - if (block.breaksOnReturn() && insertion.nextCharacter === "\n") { - position += 1; - } else { - document = document.removeTextAtRange(range); - } - range = [position, position]; - } else if (insertion.nextCharacter === "\n") { - if (insertion.previousCharacter === "\n") { - range = [position - 1, position + 1]; - } else { - range = [position, position + 1]; - position += 1; - } - } else if (insertion.startLocation.offset - 1 !== 0) { - position += 1; - } - const newDocument = new Document([block.removeLastAttribute().copyWithoutText()]); - this.setDocument(document.insertDocumentAtRange(newDocument, range)); - return this.setSelection(position); - } - getPreviousBlock() { - const locationRange = this.getLocationRange(); - if (locationRange) { - const { - index - } = locationRange[0]; - if (index > 0) { - return this.document.getBlockAtIndex(index - 1); - } - } - } - getBlock() { - const locationRange = this.getLocationRange(); - if (locationRange) { - return this.document.getBlockAtIndex(locationRange[0].index); - } - } - getAttachmentAtRange(range) { - const document = this.document.getDocumentAtRange(range); - if (document.toString() === "".concat(OBJECT_REPLACEMENT_CHARACTER, "\n")) { - return document.getAttachments()[0]; - } - } - notifyDelegateOfCurrentAttributesChange() { - var _this$delegate12, _this$delegate12$comp; - return (_this$delegate12 = this.delegate) === null || _this$delegate12 === void 0 || (_this$delegate12$comp = _this$delegate12.compositionDidChangeCurrentAttributes) === null || _this$delegate12$comp === void 0 ? void 0 : _this$delegate12$comp.call(_this$delegate12, this.currentAttributes); - } - notifyDelegateOfInsertionAtRange(range) { - var _this$delegate13, _this$delegate13$comp; - return (_this$delegate13 = this.delegate) === null || _this$delegate13 === void 0 || (_this$delegate13$comp = _this$delegate13.compositionDidPerformInsertionAtRange) === null || _this$delegate13$comp === void 0 ? void 0 : _this$delegate13$comp.call(_this$delegate13, range); - } - translateUTF16PositionFromOffset(position, offset) { - const utf16string = this.document.toUTF16String(); - const utf16position = utf16string.offsetFromUCS2Offset(position); - return utf16string.offsetToUCS2Offset(utf16position + offset); - } - } - Composition.proxyMethod("getSelectionManager().getPointRange"); - Composition.proxyMethod("getSelectionManager().setLocationRangeFromPointRange"); - Composition.proxyMethod("getSelectionManager().createLocationRangeFromDOMRange"); - Composition.proxyMethod("getSelectionManager().locationIsCursorTarget"); - Composition.proxyMethod("getSelectionManager().selectionIsExpanded"); - Composition.proxyMethod("delegate?.getSelectionManager"); - - class UndoManager extends BasicObject { - constructor(composition) { - super(...arguments); - this.composition = composition; - this.undoEntries = []; - this.redoEntries = []; - } - recordUndoEntry(description) { - let { - context, - consolidatable - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const previousEntry = this.undoEntries.slice(-1)[0]; - if (!consolidatable || !entryHasDescriptionAndContext(previousEntry, description, context)) { - const undoEntry = this.createEntry({ - description, - context - }); - this.undoEntries.push(undoEntry); - this.redoEntries = []; - } - } - undo() { - const undoEntry = this.undoEntries.pop(); - if (undoEntry) { - const redoEntry = this.createEntry(undoEntry); - this.redoEntries.push(redoEntry); - return this.composition.loadSnapshot(undoEntry.snapshot); - } - } - redo() { - const redoEntry = this.redoEntries.pop(); - if (redoEntry) { - const undoEntry = this.createEntry(redoEntry); - this.undoEntries.push(undoEntry); - return this.composition.loadSnapshot(redoEntry.snapshot); - } - } - canUndo() { - return this.undoEntries.length > 0; - } - canRedo() { - return this.redoEntries.length > 0; - } - - // Private - - createEntry() { - let { - description, - context - } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - return { - description: description === null || description === void 0 ? void 0 : description.toString(), - context: JSON.stringify(context), - snapshot: this.composition.getSnapshot() - }; - } - } - const entryHasDescriptionAndContext = (entry, description, context) => (entry === null || entry === void 0 ? void 0 : entry.description) === (description === null || description === void 0 ? void 0 : description.toString()) && (entry === null || entry === void 0 ? void 0 : entry.context) === JSON.stringify(context); - - const BLOCK_ATTRIBUTE_NAME = "attachmentGallery"; - const TEXT_ATTRIBUTE_NAME = "presentation"; - const TEXT_ATTRIBUTE_VALUE = "gallery"; - class Filter { - constructor(snapshot) { - this.document = snapshot.document; - this.selectedRange = snapshot.selectedRange; - } - perform() { - this.removeBlockAttribute(); - return this.applyBlockAttribute(); - } - getSnapshot() { - return { - document: this.document, - selectedRange: this.selectedRange - }; - } - - // Private - - removeBlockAttribute() { - return this.findRangesOfBlocks().map(range => this.document = this.document.removeAttributeAtRange(BLOCK_ATTRIBUTE_NAME, range)); - } - applyBlockAttribute() { - let offset = 0; - this.findRangesOfPieces().forEach(range => { - if (range[1] - range[0] > 1) { - range[0] += offset; - range[1] += offset; - if (this.document.getCharacterAtPosition(range[1]) !== "\n") { - this.document = this.document.insertBlockBreakAtRange(range[1]); - if (range[1] < this.selectedRange[1]) { - this.moveSelectedRangeForward(); - } - range[1]++; - offset++; - } - if (range[0] !== 0) { - if (this.document.getCharacterAtPosition(range[0] - 1) !== "\n") { - this.document = this.document.insertBlockBreakAtRange(range[0]); - if (range[0] < this.selectedRange[0]) { - this.moveSelectedRangeForward(); - } - range[0]++; - offset++; - } - } - this.document = this.document.applyBlockAttributeAtRange(BLOCK_ATTRIBUTE_NAME, true, range); - } - }); - } - findRangesOfBlocks() { - return this.document.findRangesForBlockAttribute(BLOCK_ATTRIBUTE_NAME); - } - findRangesOfPieces() { - return this.document.findRangesForTextAttribute(TEXT_ATTRIBUTE_NAME, { - withValue: TEXT_ATTRIBUTE_VALUE - }); - } - moveSelectedRangeForward() { - this.selectedRange[0] += 1; - this.selectedRange[1] += 1; - } - } - - const attachmentGalleryFilter = function (snapshot) { - const filter = new Filter(snapshot); - filter.perform(); - return filter.getSnapshot(); - }; - - const DEFAULT_FILTERS = [attachmentGalleryFilter]; - class Editor { - constructor(composition, selectionManager, element) { - this.insertFiles = this.insertFiles.bind(this); - this.composition = composition; - this.selectionManager = selectionManager; - this.element = element; - this.undoManager = new UndoManager(this.composition); - this.filters = DEFAULT_FILTERS.slice(0); - } - loadDocument(document) { - return this.loadSnapshot({ - document, - selectedRange: [0, 0] - }); - } - loadHTML() { - let html = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - const document = HTMLParser.parse(html, { - referenceElement: this.element - }).getDocument(); - return this.loadDocument(document); - } - loadJSON(_ref) { - let { - document, - selectedRange - } = _ref; - document = Document.fromJSON(document); - return this.loadSnapshot({ - document, - selectedRange - }); - } - loadSnapshot(snapshot) { - this.undoManager = new UndoManager(this.composition); - return this.composition.loadSnapshot(snapshot); - } - getDocument() { - return this.composition.document; - } - getSelectedDocument() { - return this.composition.getSelectedDocument(); - } - getSnapshot() { - return this.composition.getSnapshot(); - } - toJSON() { - return this.getSnapshot(); - } - - // Document manipulation - - deleteInDirection(direction) { - return this.composition.deleteInDirection(direction); - } - insertAttachment(attachment) { - return this.composition.insertAttachment(attachment); - } - insertAttachments(attachments) { - return this.composition.insertAttachments(attachments); - } - insertDocument(document) { - return this.composition.insertDocument(document); - } - insertFile(file) { - return this.composition.insertFile(file); - } - insertFiles(files) { - return this.composition.insertFiles(files); - } - insertHTML(html) { - return this.composition.insertHTML(html); - } - insertString(string) { - return this.composition.insertString(string); - } - insertText(text) { - return this.composition.insertText(text); - } - insertLineBreak() { - return this.composition.insertLineBreak(); - } - - // Selection - - getSelectedRange() { - return this.composition.getSelectedRange(); - } - getPosition() { - return this.composition.getPosition(); - } - getClientRectAtPosition(position) { - const locationRange = this.getDocument().locationRangeFromRange([position, position + 1]); - return this.selectionManager.getClientRectAtLocationRange(locationRange); - } - expandSelectionInDirection(direction) { - return this.composition.expandSelectionInDirection(direction); - } - moveCursorInDirection(direction) { - return this.composition.moveCursorInDirection(direction); - } - setSelectedRange(selectedRange) { - return this.composition.setSelectedRange(selectedRange); - } - - // Attributes - - activateAttribute(name) { - let value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; - return this.composition.setCurrentAttribute(name, value); - } - attributeIsActive(name) { - return this.composition.hasCurrentAttribute(name); - } - canActivateAttribute(name) { - return this.composition.canSetCurrentAttribute(name); - } - deactivateAttribute(name) { - return this.composition.removeCurrentAttribute(name); - } - - // HTML attributes - setHTMLAtributeAtPosition(position, name, value) { - this.composition.setHTMLAtributeAtPosition(position, name, value); - } - - // Nesting level - - canDecreaseNestingLevel() { - return this.composition.canDecreaseNestingLevel(); - } - canIncreaseNestingLevel() { - return this.composition.canIncreaseNestingLevel(); - } - decreaseNestingLevel() { - if (this.canDecreaseNestingLevel()) { - return this.composition.decreaseNestingLevel(); - } - } - increaseNestingLevel() { - if (this.canIncreaseNestingLevel()) { - return this.composition.increaseNestingLevel(); - } - } - - // Undo/redo - - canRedo() { - return this.undoManager.canRedo(); - } - canUndo() { - return this.undoManager.canUndo(); - } - recordUndoEntry(description) { - let { - context, - consolidatable - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - return this.undoManager.recordUndoEntry(description, { - context, - consolidatable - }); - } - redo() { - if (this.canRedo()) { - return this.undoManager.redo(); - } - } - undo() { - if (this.canUndo()) { - return this.undoManager.undo(); - } - } - } - - /* eslint-disable - no-var, - prefer-const, - */ - class LocationMapper { - constructor(element) { - this.element = element; - } - findLocationFromContainerAndOffset(container, offset) { - let { - strict - } = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : { - strict: true - }; - let childIndex = 0; - let foundBlock = false; - const location = { - index: 0, - offset: 0 - }; - const attachmentElement = this.findAttachmentElementParentForNode(container); - if (attachmentElement) { - container = attachmentElement.parentNode; - offset = findChildIndexOfNode(attachmentElement); - } - const walker = walkTree(this.element, { - usingFilter: rejectAttachmentContents - }); - while (walker.nextNode()) { - const node = walker.currentNode; - if (node === container && nodeIsTextNode(container)) { - if (!nodeIsCursorTarget(node)) { - location.offset += offset; - } - break; - } else { - if (node.parentNode === container) { - if (childIndex++ === offset) { - break; - } - } else if (!elementContainsNode(container, node)) { - if (childIndex > 0) { - break; - } - } - if (nodeIsBlockStart(node, { - strict - })) { - if (foundBlock) { - location.index++; - } - location.offset = 0; - foundBlock = true; - } else { - location.offset += nodeLength(node); - } - } - } - return location; - } - findContainerAndOffsetFromLocation(location) { - let container, offset; - if (location.index === 0 && location.offset === 0) { - container = this.element; - offset = 0; - while (container.firstChild) { - container = container.firstChild; - if (nodeIsBlockContainer(container)) { - offset = 1; - break; - } - } - return [container, offset]; - } - let [node, nodeOffset] = this.findNodeAndOffsetFromLocation(location); - if (!node) return; - if (nodeIsTextNode(node)) { - if (nodeLength(node) === 0) { - container = node.parentNode.parentNode; - offset = findChildIndexOfNode(node.parentNode); - if (nodeIsCursorTarget(node, { - name: "right" - })) { - offset++; - } - } else { - container = node; - offset = location.offset - nodeOffset; - } - } else { - container = node.parentNode; - if (!nodeIsBlockStart(node.previousSibling)) { - if (!nodeIsBlockContainer(container)) { - while (node === container.lastChild) { - node = container; - container = container.parentNode; - if (nodeIsBlockContainer(container)) { - break; - } - } - } - } - offset = findChildIndexOfNode(node); - if (location.offset !== 0) { - offset++; - } - } - return [container, offset]; - } - findNodeAndOffsetFromLocation(location) { - let node, nodeOffset; - let offset = 0; - for (const currentNode of this.getSignificantNodesForIndex(location.index)) { - const length = nodeLength(currentNode); - if (location.offset <= offset + length) { - if (nodeIsTextNode(currentNode)) { - node = currentNode; - nodeOffset = offset; - if (location.offset === nodeOffset && nodeIsCursorTarget(node)) { - break; - } - } else if (!node) { - node = currentNode; - nodeOffset = offset; - } - } - offset += length; - if (offset > location.offset) { - break; - } - } - return [node, nodeOffset]; - } - - // Private - - findAttachmentElementParentForNode(node) { - while (node && node !== this.element) { - if (nodeIsAttachmentElement(node)) { - return node; - } - node = node.parentNode; - } - } - getSignificantNodesForIndex(index) { - const nodes = []; - const walker = walkTree(this.element, { - usingFilter: acceptSignificantNodes - }); - let recordingNodes = false; - while (walker.nextNode()) { - const node = walker.currentNode; - if (nodeIsBlockStartComment(node)) { - var blockIndex; - if (blockIndex != null) { - blockIndex++; - } else { - blockIndex = 0; - } - if (blockIndex === index) { - recordingNodes = true; - } else if (recordingNodes) { - break; - } - } else if (recordingNodes) { - nodes.push(node); - } - } - return nodes; - } - } - const nodeLength = function (node) { - if (node.nodeType === Node.TEXT_NODE) { - if (nodeIsCursorTarget(node)) { - return 0; - } else { - const string = node.textContent; - return string.length; - } - } else if (tagName(node) === "br" || nodeIsAttachmentElement(node)) { - return 1; - } else { - return 0; - } - }; - const acceptSignificantNodes = function (node) { - if (rejectEmptyTextNodes(node) === NodeFilter.FILTER_ACCEPT) { - return rejectAttachmentContents(node); - } else { - return NodeFilter.FILTER_REJECT; - } - }; - const rejectEmptyTextNodes = function (node) { - if (nodeIsEmptyTextNode(node)) { - return NodeFilter.FILTER_REJECT; - } else { - return NodeFilter.FILTER_ACCEPT; - } - }; - const rejectAttachmentContents = function (node) { - if (nodeIsAttachmentElement(node.parentNode)) { - return NodeFilter.FILTER_REJECT; - } else { - return NodeFilter.FILTER_ACCEPT; - } - }; - - /* eslint-disable - id-length, - no-empty, - */ - class PointMapper { - createDOMRangeFromPoint(_ref) { - let { - x, - y - } = _ref; - let domRange; - if (document.caretPositionFromPoint) { - const { - offsetNode, - offset - } = document.caretPositionFromPoint(x, y); - domRange = document.createRange(); - domRange.setStart(offsetNode, offset); - return domRange; - } else if (document.caretRangeFromPoint) { - return document.caretRangeFromPoint(x, y); - } else if (document.body.createTextRange) { - const originalDOMRange = getDOMRange(); - try { - // IE 11 throws "Unspecified error" when using moveToPoint - // during a drag-and-drop operation. - const textRange = document.body.createTextRange(); - textRange.moveToPoint(x, y); - textRange.select(); - } catch (error) {} - domRange = getDOMRange(); - setDOMRange(originalDOMRange); - return domRange; - } - } - getClientRectsForDOMRange(domRange) { - const array = Array.from(domRange.getClientRects()); - const start = array[0]; - const end = array[array.length - 1]; - return [start, end]; - } - } - - /* eslint-disable - */ - class SelectionManager extends BasicObject { - constructor(element) { - super(...arguments); - this.didMouseDown = this.didMouseDown.bind(this); - this.selectionDidChange = this.selectionDidChange.bind(this); - this.element = element; - this.locationMapper = new LocationMapper(this.element); - this.pointMapper = new PointMapper(); - this.lockCount = 0; - handleEvent("mousedown", { - onElement: this.element, - withCallback: this.didMouseDown - }); - } - getLocationRange() { - let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - if (options.strict === false) { - return this.createLocationRangeFromDOMRange(getDOMRange()); - } else if (options.ignoreLock) { - return this.currentLocationRange; - } else if (this.lockedLocationRange) { - return this.lockedLocationRange; - } else { - return this.currentLocationRange; - } - } - setLocationRange(locationRange) { - if (this.lockedLocationRange) return; - locationRange = normalizeRange(locationRange); - const domRange = this.createDOMRangeFromLocationRange(locationRange); - if (domRange) { - setDOMRange(domRange); - this.updateCurrentLocationRange(locationRange); - } - } - setLocationRangeFromPointRange(pointRange) { - pointRange = normalizeRange(pointRange); - const startLocation = this.getLocationAtPoint(pointRange[0]); - const endLocation = this.getLocationAtPoint(pointRange[1]); - this.setLocationRange([startLocation, endLocation]); - } - getClientRectAtLocationRange(locationRange) { - const domRange = this.createDOMRangeFromLocationRange(locationRange); - if (domRange) { - return this.getClientRectsForDOMRange(domRange)[1]; - } - } - locationIsCursorTarget(location) { - const node = Array.from(this.findNodeAndOffsetFromLocation(location))[0]; - return nodeIsCursorTarget(node); - } - lock() { - if (this.lockCount++ === 0) { - this.updateCurrentLocationRange(); - this.lockedLocationRange = this.getLocationRange(); - } - } - unlock() { - if (--this.lockCount === 0) { - const { - lockedLocationRange - } = this; - this.lockedLocationRange = null; - if (lockedLocationRange != null) { - return this.setLocationRange(lockedLocationRange); - } - } - } - clearSelection() { - var _getDOMSelection; - return (_getDOMSelection = getDOMSelection()) === null || _getDOMSelection === void 0 ? void 0 : _getDOMSelection.removeAllRanges(); - } - selectionIsCollapsed() { - var _getDOMRange; - return ((_getDOMRange = getDOMRange()) === null || _getDOMRange === void 0 ? void 0 : _getDOMRange.collapsed) === true; - } - selectionIsExpanded() { - return !this.selectionIsCollapsed(); - } - createLocationRangeFromDOMRange(domRange, options) { - if (domRange == null || !this.domRangeWithinElement(domRange)) return; - const start = this.findLocationFromContainerAndOffset(domRange.startContainer, domRange.startOffset, options); - if (!start) return; - const end = domRange.collapsed ? undefined : this.findLocationFromContainerAndOffset(domRange.endContainer, domRange.endOffset, options); - return normalizeRange([start, end]); - } - didMouseDown() { - return this.pauseTemporarily(); - } - pauseTemporarily() { - let resumeHandlers; - this.paused = true; - const resume = () => { - this.paused = false; - clearTimeout(resumeTimeout); - Array.from(resumeHandlers).forEach(handler => { - handler.destroy(); - }); - if (elementContainsNode(document, this.element)) { - return this.selectionDidChange(); - } - }; - const resumeTimeout = setTimeout(resume, 200); - resumeHandlers = ["mousemove", "keydown"].map(eventName => handleEvent(eventName, { - onElement: document, - withCallback: resume - })); - } - selectionDidChange() { - if (!this.paused && !innerElementIsActive(this.element)) { - return this.updateCurrentLocationRange(); - } - } - updateCurrentLocationRange(locationRange) { - if (locationRange != null ? locationRange : locationRange = this.createLocationRangeFromDOMRange(getDOMRange())) { - if (!rangesAreEqual(locationRange, this.currentLocationRange)) { - var _this$delegate, _this$delegate$locati; - this.currentLocationRange = locationRange; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$locati = _this$delegate.locationRangeDidChange) === null || _this$delegate$locati === void 0 ? void 0 : _this$delegate$locati.call(_this$delegate, this.currentLocationRange.slice(0)); - } - } - } - createDOMRangeFromLocationRange(locationRange) { - const rangeStart = this.findContainerAndOffsetFromLocation(locationRange[0]); - const rangeEnd = rangeIsCollapsed(locationRange) ? rangeStart : this.findContainerAndOffsetFromLocation(locationRange[1]) || rangeStart; - if (rangeStart != null && rangeEnd != null) { - const domRange = document.createRange(); - domRange.setStart(...Array.from(rangeStart || [])); - domRange.setEnd(...Array.from(rangeEnd || [])); - return domRange; - } - } - getLocationAtPoint(point) { - const domRange = this.createDOMRangeFromPoint(point); - if (domRange) { - var _this$createLocationR; - return (_this$createLocationR = this.createLocationRangeFromDOMRange(domRange)) === null || _this$createLocationR === void 0 ? void 0 : _this$createLocationR[0]; - } - } - domRangeWithinElement(domRange) { - if (domRange.collapsed) { - return elementContainsNode(this.element, domRange.startContainer); - } else { - return elementContainsNode(this.element, domRange.startContainer) && elementContainsNode(this.element, domRange.endContainer); - } - } - } - SelectionManager.proxyMethod("locationMapper.findLocationFromContainerAndOffset"); - SelectionManager.proxyMethod("locationMapper.findContainerAndOffsetFromLocation"); - SelectionManager.proxyMethod("locationMapper.findNodeAndOffsetFromLocation"); - SelectionManager.proxyMethod("pointMapper.createDOMRangeFromPoint"); - SelectionManager.proxyMethod("pointMapper.getClientRectsForDOMRange"); - - var models = /*#__PURE__*/Object.freeze({ - __proto__: null, - Attachment: Attachment, - AttachmentManager: AttachmentManager, - AttachmentPiece: AttachmentPiece, - Block: Block, - Composition: Composition, - Document: Document, - Editor: Editor, - HTMLParser: HTMLParser, - HTMLSanitizer: HTMLSanitizer, - LineBreakInsertion: LineBreakInsertion, - LocationMapper: LocationMapper, - ManagedAttachment: ManagedAttachment, - Piece: Piece, - PointMapper: PointMapper, - SelectionManager: SelectionManager, - SplittableList: SplittableList, - StringPiece: StringPiece, - Text: Text, - UndoManager: UndoManager - }); - - var views = /*#__PURE__*/Object.freeze({ - __proto__: null, - ObjectView: ObjectView, - AttachmentView: AttachmentView, - BlockView: BlockView, - DocumentView: DocumentView, - PieceView: PieceView, - PreviewableAttachmentView: PreviewableAttachmentView, - TextView: TextView - }); - - const { - lang, - css, - keyNames: keyNames$1 - } = config; - const undoable = function (fn) { - return function () { - const commands = fn.apply(this, arguments); - commands.do(); - if (!this.undos) { - this.undos = []; - } - this.undos.push(commands.undo); - }; - }; - class AttachmentEditorController extends BasicObject { - constructor(attachmentPiece, _element, container) { - let options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; - super(...arguments); - // Installing and uninstalling - _defineProperty(this, "makeElementMutable", undoable(() => { - return { - do: () => { - this.element.dataset.trixMutable = true; - }, - undo: () => delete this.element.dataset.trixMutable - }; - })); - _defineProperty(this, "addToolbar", undoable(() => { - //
- //
- // - // - // - //
- //
- const element = makeElement({ - tagName: "div", - className: css.attachmentToolbar, - data: { - trixMutable: true - }, - childNodes: makeElement({ - tagName: "div", - className: "trix-button-row", - childNodes: makeElement({ - tagName: "span", - className: "trix-button-group trix-button-group--actions", - childNodes: makeElement({ - tagName: "button", - className: "trix-button trix-button--remove", - textContent: lang.remove, - attributes: { - title: lang.remove - }, - data: { - trixAction: "remove" - } - }) - }) - }) - }); - if (this.attachment.isPreviewable()) { - //
- // - // #{name} - // #{size} - // - //
- element.appendChild(makeElement({ - tagName: "div", - className: css.attachmentMetadataContainer, - childNodes: makeElement({ - tagName: "span", - className: css.attachmentMetadata, - childNodes: [makeElement({ - tagName: "span", - className: css.attachmentName, - textContent: this.attachment.getFilename(), - attributes: { - title: this.attachment.getFilename() - } - }), makeElement({ - tagName: "span", - className: css.attachmentSize, - textContent: this.attachment.getFormattedFilesize() - })] - }) - })); - } - handleEvent("click", { - onElement: element, - withCallback: this.didClickToolbar - }); - handleEvent("click", { - onElement: element, - matchingSelector: "[data-trix-action]", - withCallback: this.didClickActionButton - }); - triggerEvent("trix-attachment-before-toolbar", { - onElement: this.element, - attributes: { - toolbar: element, - attachment: this.attachment - } - }); - return { - do: () => this.element.appendChild(element), - undo: () => removeNode(element) - }; - })); - _defineProperty(this, "installCaptionEditor", undoable(() => { - const textarea = makeElement({ - tagName: "textarea", - className: css.attachmentCaptionEditor, - attributes: { - placeholder: lang.captionPlaceholder - }, - data: { - trixMutable: true - } - }); - textarea.value = this.attachmentPiece.getCaption(); - const textareaClone = textarea.cloneNode(); - textareaClone.classList.add("trix-autoresize-clone"); - textareaClone.tabIndex = -1; - const autoresize = function () { - textareaClone.value = textarea.value; - textarea.style.height = textareaClone.scrollHeight + "px"; - }; - handleEvent("input", { - onElement: textarea, - withCallback: autoresize - }); - handleEvent("input", { - onElement: textarea, - withCallback: this.didInputCaption - }); - handleEvent("keydown", { - onElement: textarea, - withCallback: this.didKeyDownCaption - }); - handleEvent("change", { - onElement: textarea, - withCallback: this.didChangeCaption - }); - handleEvent("blur", { - onElement: textarea, - withCallback: this.didBlurCaption - }); - const figcaption = this.element.querySelector("figcaption"); - const editingFigcaption = figcaption.cloneNode(); - return { - do: () => { - figcaption.style.display = "none"; - editingFigcaption.appendChild(textarea); - editingFigcaption.appendChild(textareaClone); - editingFigcaption.classList.add("".concat(css.attachmentCaption, "--editing")); - figcaption.parentElement.insertBefore(editingFigcaption, figcaption); - autoresize(); - if (this.options.editCaption) { - return defer(() => textarea.focus()); - } - }, - undo() { - removeNode(editingFigcaption); - figcaption.style.display = null; - } - }; - })); - this.didClickToolbar = this.didClickToolbar.bind(this); - this.didClickActionButton = this.didClickActionButton.bind(this); - this.didKeyDownCaption = this.didKeyDownCaption.bind(this); - this.didInputCaption = this.didInputCaption.bind(this); - this.didChangeCaption = this.didChangeCaption.bind(this); - this.didBlurCaption = this.didBlurCaption.bind(this); - this.attachmentPiece = attachmentPiece; - this.element = _element; - this.container = container; - this.options = options; - this.attachment = this.attachmentPiece.attachment; - if (tagName(this.element) === "a") { - this.element = this.element.firstChild; - } - this.install(); - } - install() { - this.makeElementMutable(); - this.addToolbar(); - if (this.attachment.isPreviewable()) { - this.installCaptionEditor(); - } - } - uninstall() { - var _this$delegate; - let undo = this.undos.pop(); - this.savePendingCaption(); - while (undo) { - undo(); - undo = this.undos.pop(); - } - (_this$delegate = this.delegate) === null || _this$delegate === void 0 || _this$delegate.didUninstallAttachmentEditor(this); - } - - // Private - - savePendingCaption() { - if (this.pendingCaption != null) { - const caption = this.pendingCaption; - this.pendingCaption = null; - if (caption) { - var _this$delegate2, _this$delegate2$attac; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$attac = _this$delegate2.attachmentEditorDidRequestUpdatingAttributesForAttachment) === null || _this$delegate2$attac === void 0 || _this$delegate2$attac.call(_this$delegate2, { - caption - }, this.attachment); - } else { - var _this$delegate3, _this$delegate3$attac; - (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || (_this$delegate3$attac = _this$delegate3.attachmentEditorDidRequestRemovingAttributeForAttachment) === null || _this$delegate3$attac === void 0 || _this$delegate3$attac.call(_this$delegate3, "caption", this.attachment); - } - } - } - // Event handlers - - didClickToolbar(event) { - event.preventDefault(); - return event.stopPropagation(); - } - didClickActionButton(event) { - var _this$delegate4; - const action = event.target.getAttribute("data-trix-action"); - switch (action) { - case "remove": - return (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 ? void 0 : _this$delegate4.attachmentEditorDidRequestRemovalOfAttachment(this.attachment); - } - } - didKeyDownCaption(event) { - if (keyNames$1[event.keyCode] === "return") { - var _this$delegate5, _this$delegate5$attac; - event.preventDefault(); - this.savePendingCaption(); - return (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || (_this$delegate5$attac = _this$delegate5.attachmentEditorDidRequestDeselectingAttachment) === null || _this$delegate5$attac === void 0 ? void 0 : _this$delegate5$attac.call(_this$delegate5, this.attachment); - } - } - didInputCaption(event) { - this.pendingCaption = event.target.value.replace(/\s/g, " ").trim(); - } - didChangeCaption(event) { - return this.savePendingCaption(); - } - didBlurCaption(event) { - return this.savePendingCaption(); - } - } - - class CompositionController extends BasicObject { - constructor(element, composition) { - super(...arguments); - this.didFocus = this.didFocus.bind(this); - this.didBlur = this.didBlur.bind(this); - this.didClickAttachment = this.didClickAttachment.bind(this); - this.element = element; - this.composition = composition; - this.documentView = new DocumentView(this.composition.document, { - element: this.element - }); - handleEvent("focus", { - onElement: this.element, - withCallback: this.didFocus - }); - handleEvent("blur", { - onElement: this.element, - withCallback: this.didBlur - }); - handleEvent("click", { - onElement: this.element, - matchingSelector: "a[contenteditable=false]", - preventDefault: true - }); - handleEvent("mousedown", { - onElement: this.element, - matchingSelector: attachmentSelector, - withCallback: this.didClickAttachment - }); - handleEvent("click", { - onElement: this.element, - matchingSelector: "a".concat(attachmentSelector), - preventDefault: true - }); - } - didFocus(event) { - var _this$blurPromise; - const perform = () => { - if (!this.focused) { - var _this$delegate, _this$delegate$compos; - this.focused = true; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$compos = _this$delegate.compositionControllerDidFocus) === null || _this$delegate$compos === void 0 ? void 0 : _this$delegate$compos.call(_this$delegate); - } - }; - return ((_this$blurPromise = this.blurPromise) === null || _this$blurPromise === void 0 ? void 0 : _this$blurPromise.then(perform)) || perform(); - } - didBlur(event) { - this.blurPromise = new Promise(resolve => { - return defer(() => { - if (!innerElementIsActive(this.element)) { - var _this$delegate2, _this$delegate2$compo; - this.focused = null; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$compo = _this$delegate2.compositionControllerDidBlur) === null || _this$delegate2$compo === void 0 || _this$delegate2$compo.call(_this$delegate2); - } - this.blurPromise = null; - return resolve(); - }); - }); - } - didClickAttachment(event, target) { - var _this$delegate3, _this$delegate3$compo; - const attachment = this.findAttachmentForElement(target); - const editCaption = !!findClosestElementFromNode(event.target, { - matchingSelector: "figcaption" - }); - return (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || (_this$delegate3$compo = _this$delegate3.compositionControllerDidSelectAttachment) === null || _this$delegate3$compo === void 0 ? void 0 : _this$delegate3$compo.call(_this$delegate3, attachment, { - editCaption - }); - } - getSerializableElement() { - if (this.isEditingAttachment()) { - return this.documentView.shadowElement; - } else { - return this.element; - } - } - render() { - var _this$delegate6, _this$delegate6$compo; - if (this.revision !== this.composition.revision) { - this.documentView.setDocument(this.composition.document); - this.documentView.render(); - this.revision = this.composition.revision; - } - if (this.canSyncDocumentView() && !this.documentView.isSynced()) { - var _this$delegate4, _this$delegate4$compo, _this$delegate5, _this$delegate5$compo; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || (_this$delegate4$compo = _this$delegate4.compositionControllerWillSyncDocumentView) === null || _this$delegate4$compo === void 0 || _this$delegate4$compo.call(_this$delegate4); - this.documentView.sync(); - (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || (_this$delegate5$compo = _this$delegate5.compositionControllerDidSyncDocumentView) === null || _this$delegate5$compo === void 0 || _this$delegate5$compo.call(_this$delegate5); - } - return (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 || (_this$delegate6$compo = _this$delegate6.compositionControllerDidRender) === null || _this$delegate6$compo === void 0 ? void 0 : _this$delegate6$compo.call(_this$delegate6); - } - rerenderViewForObject(object) { - this.invalidateViewForObject(object); - return this.render(); - } - invalidateViewForObject(object) { - return this.documentView.invalidateViewForObject(object); - } - isViewCachingEnabled() { - return this.documentView.isViewCachingEnabled(); - } - enableViewCaching() { - return this.documentView.enableViewCaching(); - } - disableViewCaching() { - return this.documentView.disableViewCaching(); - } - refreshViewCache() { - return this.documentView.garbageCollectCachedViews(); - } - - // Attachment editor management - - isEditingAttachment() { - return !!this.attachmentEditor; - } - installAttachmentEditorForAttachment(attachment, options) { - var _this$attachmentEdito; - if (((_this$attachmentEdito = this.attachmentEditor) === null || _this$attachmentEdito === void 0 ? void 0 : _this$attachmentEdito.attachment) === attachment) return; - const element = this.documentView.findElementForObject(attachment); - if (!element) return; - this.uninstallAttachmentEditor(); - const attachmentPiece = this.composition.document.getAttachmentPieceForAttachment(attachment); - this.attachmentEditor = new AttachmentEditorController(attachmentPiece, element, this.element, options); - this.attachmentEditor.delegate = this; - } - uninstallAttachmentEditor() { - var _this$attachmentEdito2; - return (_this$attachmentEdito2 = this.attachmentEditor) === null || _this$attachmentEdito2 === void 0 ? void 0 : _this$attachmentEdito2.uninstall(); - } - - // Attachment controller delegate - - didUninstallAttachmentEditor() { - this.attachmentEditor = null; - return this.render(); - } - attachmentEditorDidRequestUpdatingAttributesForAttachment(attributes, attachment) { - var _this$delegate7, _this$delegate7$compo; - (_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || (_this$delegate7$compo = _this$delegate7.compositionControllerWillUpdateAttachment) === null || _this$delegate7$compo === void 0 || _this$delegate7$compo.call(_this$delegate7, attachment); - return this.composition.updateAttributesForAttachment(attributes, attachment); - } - attachmentEditorDidRequestRemovingAttributeForAttachment(attribute, attachment) { - var _this$delegate8, _this$delegate8$compo; - (_this$delegate8 = this.delegate) === null || _this$delegate8 === void 0 || (_this$delegate8$compo = _this$delegate8.compositionControllerWillUpdateAttachment) === null || _this$delegate8$compo === void 0 || _this$delegate8$compo.call(_this$delegate8, attachment); - return this.composition.removeAttributeForAttachment(attribute, attachment); - } - attachmentEditorDidRequestRemovalOfAttachment(attachment) { - var _this$delegate9, _this$delegate9$compo; - return (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 || (_this$delegate9$compo = _this$delegate9.compositionControllerDidRequestRemovalOfAttachment) === null || _this$delegate9$compo === void 0 ? void 0 : _this$delegate9$compo.call(_this$delegate9, attachment); - } - attachmentEditorDidRequestDeselectingAttachment(attachment) { - var _this$delegate10, _this$delegate10$comp; - return (_this$delegate10 = this.delegate) === null || _this$delegate10 === void 0 || (_this$delegate10$comp = _this$delegate10.compositionControllerDidRequestDeselectingAttachment) === null || _this$delegate10$comp === void 0 ? void 0 : _this$delegate10$comp.call(_this$delegate10, attachment); - } - - // Private - - canSyncDocumentView() { - return !this.isEditingAttachment(); - } - findAttachmentForElement(element) { - return this.composition.document.getAttachmentById(parseInt(element.dataset.trixId, 10)); - } - } - - class Controller extends BasicObject {} - - const mutableAttributeName = "data-trix-mutable"; - const mutableSelector = "[".concat(mutableAttributeName, "]"); - const options = { - attributes: true, - childList: true, - characterData: true, - characterDataOldValue: true, - subtree: true - }; - class MutationObserver extends BasicObject { - constructor(element) { - super(element); - this.didMutate = this.didMutate.bind(this); - this.element = element; - this.observer = new window.MutationObserver(this.didMutate); - this.start(); - } - start() { - this.reset(); - return this.observer.observe(this.element, options); - } - stop() { - return this.observer.disconnect(); - } - didMutate(mutations) { - this.mutations.push(...Array.from(this.findSignificantMutations(mutations) || [])); - if (this.mutations.length) { - var _this$delegate, _this$delegate$elemen; - (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$elemen = _this$delegate.elementDidMutate) === null || _this$delegate$elemen === void 0 || _this$delegate$elemen.call(_this$delegate, this.getMutationSummary()); - return this.reset(); - } - } - - // Private - - reset() { - this.mutations = []; - } - findSignificantMutations(mutations) { - return mutations.filter(mutation => { - return this.mutationIsSignificant(mutation); - }); - } - mutationIsSignificant(mutation) { - if (this.nodeIsMutable(mutation.target)) { - return false; - } - for (const node of Array.from(this.nodesModifiedByMutation(mutation))) { - if (this.nodeIsSignificant(node)) return true; - } - return false; - } - nodeIsSignificant(node) { - return node !== this.element && !this.nodeIsMutable(node) && !nodeIsEmptyTextNode(node); - } - nodeIsMutable(node) { - return findClosestElementFromNode(node, { - matchingSelector: mutableSelector - }); - } - nodesModifiedByMutation(mutation) { - const nodes = []; - switch (mutation.type) { - case "attributes": - if (mutation.attributeName !== mutableAttributeName) { - nodes.push(mutation.target); - } - break; - case "characterData": - // Changes to text nodes should consider the parent element - nodes.push(mutation.target.parentNode); - nodes.push(mutation.target); - break; - case "childList": - // Consider each added or removed node - nodes.push(...Array.from(mutation.addedNodes || [])); - nodes.push(...Array.from(mutation.removedNodes || [])); - break; - } - return nodes; - } - getMutationSummary() { - return this.getTextMutationSummary(); - } - getTextMutationSummary() { - const { - additions, - deletions - } = this.getTextChangesFromCharacterData(); - const textChanges = this.getTextChangesFromChildList(); - Array.from(textChanges.additions).forEach(addition => { - if (!Array.from(additions).includes(addition)) { - additions.push(addition); - } - }); - deletions.push(...Array.from(textChanges.deletions || [])); - const summary = {}; - const added = additions.join(""); - if (added) { - summary.textAdded = added; - } - const deleted = deletions.join(""); - if (deleted) { - summary.textDeleted = deleted; - } - return summary; - } - getMutationsByType(type) { - return Array.from(this.mutations).filter(mutation => mutation.type === type); - } - getTextChangesFromChildList() { - let textAdded, textRemoved; - const addedNodes = []; - const removedNodes = []; - Array.from(this.getMutationsByType("childList")).forEach(mutation => { - addedNodes.push(...Array.from(mutation.addedNodes || [])); - removedNodes.push(...Array.from(mutation.removedNodes || [])); - }); - const singleBlockCommentRemoved = addedNodes.length === 0 && removedNodes.length === 1 && nodeIsBlockStartComment(removedNodes[0]); - if (singleBlockCommentRemoved) { - textAdded = []; - textRemoved = ["\n"]; - } else { - textAdded = getTextForNodes(addedNodes); - textRemoved = getTextForNodes(removedNodes); - } - const additions = textAdded.filter((text, index) => text !== textRemoved[index]).map(normalizeSpaces); - const deletions = textRemoved.filter((text, index) => text !== textAdded[index]).map(normalizeSpaces); - return { - additions, - deletions - }; - } - getTextChangesFromCharacterData() { - let added, removed; - const characterMutations = this.getMutationsByType("characterData"); - if (characterMutations.length) { - const startMutation = characterMutations[0], - endMutation = characterMutations[characterMutations.length - 1]; - const oldString = normalizeSpaces(startMutation.oldValue); - const newString = normalizeSpaces(endMutation.target.data); - const summarized = summarizeStringChange(oldString, newString); - added = summarized.added; - removed = summarized.removed; - } - return { - additions: added ? [added] : [], - deletions: removed ? [removed] : [] - }; - } - } - const getTextForNodes = function () { - let nodes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - const text = []; - for (const node of Array.from(nodes)) { - switch (node.nodeType) { - case Node.TEXT_NODE: - text.push(node.data); - break; - case Node.ELEMENT_NODE: - if (tagName(node) === "br") { - text.push("\n"); - } else { - text.push(...Array.from(getTextForNodes(node.childNodes) || [])); - } - break; - } - } - return text; - }; - - /* eslint-disable - no-empty, - */ - class FileVerificationOperation extends Operation { - constructor(file) { - super(...arguments); - this.file = file; - } - perform(callback) { - const reader = new FileReader(); - reader.onerror = () => callback(false); - reader.onload = () => { - reader.onerror = null; - try { - reader.abort(); - } catch (error) {} - return callback(true, this.file); - }; - return reader.readAsArrayBuffer(this.file); - } - } - - // Each software keyboard on Android emits its own set of events and some of them can be buggy. - // This class detects when some buggy events are being emitted and lets know the input controller - // that they should be ignored. - class FlakyAndroidKeyboardDetector { - constructor(element) { - this.element = element; - } - shouldIgnore(event) { - if (!browser$1.samsungAndroid) return false; - this.previousEvent = this.event; - this.event = event; - this.checkSamsungKeyboardBuggyModeStart(); - this.checkSamsungKeyboardBuggyModeEnd(); - return this.buggyMode; - } - - // private - - // The Samsung keyboard on Android can enter a buggy state in which it emits a flurry of confused events that, - // if processed, corrupts the editor. The buggy mode always starts with an insertText event, right after a - // keydown event with for an "Unidentified" key, with the same text as the editor element, except for a few - // extra whitespace, or exotic utf8, characters. - checkSamsungKeyboardBuggyModeStart() { - if (this.insertingLongTextAfterUnidentifiedChar() && differsInWhitespace(this.element.innerText, this.event.data)) { - this.buggyMode = true; - this.event.preventDefault(); - } - } - - // The flurry of buggy events are always insertText. If we see any other type, it means it's over. - checkSamsungKeyboardBuggyModeEnd() { - if (this.buggyMode && this.event.inputType !== "insertText") { - this.buggyMode = false; - } - } - insertingLongTextAfterUnidentifiedChar() { - var _this$event$data; - return this.isBeforeInputInsertText() && this.previousEventWasUnidentifiedKeydown() && ((_this$event$data = this.event.data) === null || _this$event$data === void 0 ? void 0 : _this$event$data.length) > 50; - } - isBeforeInputInsertText() { - return this.event.type === "beforeinput" && this.event.inputType === "insertText"; - } - previousEventWasUnidentifiedKeydown() { - var _this$previousEvent, _this$previousEvent2; - return ((_this$previousEvent = this.previousEvent) === null || _this$previousEvent === void 0 ? void 0 : _this$previousEvent.type) === "keydown" && ((_this$previousEvent2 = this.previousEvent) === null || _this$previousEvent2 === void 0 ? void 0 : _this$previousEvent2.key) === "Unidentified"; - } - } - const differsInWhitespace = (text1, text2) => { - return normalize(text1) === normalize(text2); - }; - const whiteSpaceNormalizerRegexp = new RegExp("(".concat(OBJECT_REPLACEMENT_CHARACTER, "|").concat(ZERO_WIDTH_SPACE, "|").concat(NON_BREAKING_SPACE, "|\\s)+"), "g"); - const normalize = text => text.replace(whiteSpaceNormalizerRegexp, " ").trim(); - - class InputController extends BasicObject { - constructor(element) { - super(...arguments); - this.element = element; - this.mutationObserver = new MutationObserver(this.element); - this.mutationObserver.delegate = this; - this.flakyKeyboardDetector = new FlakyAndroidKeyboardDetector(this.element); - for (const eventName in this.constructor.events) { - handleEvent(eventName, { - onElement: this.element, - withCallback: this.handlerFor(eventName) - }); - } - } - elementDidMutate(mutationSummary) {} - editorWillSyncDocumentView() { - return this.mutationObserver.stop(); - } - editorDidSyncDocumentView() { - return this.mutationObserver.start(); - } - requestRender() { - var _this$delegate, _this$delegate$inputC; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$inputC = _this$delegate.inputControllerDidRequestRender) === null || _this$delegate$inputC === void 0 ? void 0 : _this$delegate$inputC.call(_this$delegate); - } - requestReparse() { - var _this$delegate2, _this$delegate2$input; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$input = _this$delegate2.inputControllerDidRequestReparse) === null || _this$delegate2$input === void 0 || _this$delegate2$input.call(_this$delegate2); - return this.requestRender(); - } - attachFiles(files) { - const operations = Array.from(files).map(file => new FileVerificationOperation(file)); - return Promise.all(operations).then(files => { - this.handleInput(function () { - var _this$delegate3, _this$responder; - (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || _this$delegate3.inputControllerWillAttachFiles(); - (_this$responder = this.responder) === null || _this$responder === void 0 || _this$responder.insertFiles(files); - return this.requestRender(); - }); - }); - } - - // Private - - handlerFor(eventName) { - return event => { - if (!event.defaultPrevented) { - this.handleInput(() => { - if (!innerElementIsActive(this.element)) { - if (this.flakyKeyboardDetector.shouldIgnore(event)) return; - this.eventName = eventName; - this.constructor.events[eventName].call(this, event); - } - }); - } - }; - } - handleInput(callback) { - try { - var _this$delegate4; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || _this$delegate4.inputControllerWillHandleInput(); - callback.call(this); - } finally { - var _this$delegate5; - (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || _this$delegate5.inputControllerDidHandleInput(); - } - } - createLinkHTML(href, text) { - const link = document.createElement("a"); - link.href = href; - link.textContent = text ? text : href; - return link.outerHTML; - } - } - _defineProperty(InputController, "events", {}); - - var _$codePointAt, _; - const { - browser, - keyNames - } = config; - let pastedFileCount = 0; - class Level0InputController extends InputController { - constructor() { - super(...arguments); - this.resetInputSummary(); - } - setInputSummary() { - let summary = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - this.inputSummary.eventName = this.eventName; - for (const key in summary) { - const value = summary[key]; - this.inputSummary[key] = value; - } - return this.inputSummary; - } - resetInputSummary() { - this.inputSummary = {}; - } - reset() { - this.resetInputSummary(); - return selectionChangeObserver.reset(); - } - - // Mutation observer delegate - - elementDidMutate(mutationSummary) { - if (this.isComposing()) { - var _this$delegate, _this$delegate$inputC; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$inputC = _this$delegate.inputControllerDidAllowUnhandledInput) === null || _this$delegate$inputC === void 0 ? void 0 : _this$delegate$inputC.call(_this$delegate); - } else { - return this.handleInput(function () { - if (this.mutationIsSignificant(mutationSummary)) { - if (this.mutationIsExpected(mutationSummary)) { - this.requestRender(); - } else { - this.requestReparse(); - } - } - return this.reset(); - }); - } - } - mutationIsExpected(_ref) { - let { - textAdded, - textDeleted - } = _ref; - if (this.inputSummary.preferDocument) { - return true; - } - const mutationAdditionMatchesSummary = textAdded != null ? textAdded === this.inputSummary.textAdded : !this.inputSummary.textAdded; - const mutationDeletionMatchesSummary = textDeleted != null ? this.inputSummary.didDelete : !this.inputSummary.didDelete; - const unexpectedNewlineAddition = ["\n", " \n"].includes(textAdded) && !mutationAdditionMatchesSummary; - const unexpectedNewlineDeletion = textDeleted === "\n" && !mutationDeletionMatchesSummary; - const singleUnexpectedNewline = unexpectedNewlineAddition && !unexpectedNewlineDeletion || unexpectedNewlineDeletion && !unexpectedNewlineAddition; - if (singleUnexpectedNewline) { - const range = this.getSelectedRange(); - if (range) { - var _this$responder; - const offset = unexpectedNewlineAddition ? textAdded.replace(/\n$/, "").length || -1 : (textAdded === null || textAdded === void 0 ? void 0 : textAdded.length) || 1; - if ((_this$responder = this.responder) !== null && _this$responder !== void 0 && _this$responder.positionIsBlockBreak(range[1] + offset)) { - return true; - } - } - } - return mutationAdditionMatchesSummary && mutationDeletionMatchesSummary; - } - mutationIsSignificant(mutationSummary) { - var _this$compositionInpu; - const textChanged = Object.keys(mutationSummary).length > 0; - const composedEmptyString = ((_this$compositionInpu = this.compositionInput) === null || _this$compositionInpu === void 0 ? void 0 : _this$compositionInpu.getEndData()) === ""; - return textChanged || !composedEmptyString; - } - - // Private - - getCompositionInput() { - if (this.isComposing()) { - return this.compositionInput; - } else { - this.compositionInput = new CompositionInput(this); - } - } - isComposing() { - return this.compositionInput && !this.compositionInput.isEnded(); - } - deleteInDirection(direction, event) { - var _this$responder2; - if (((_this$responder2 = this.responder) === null || _this$responder2 === void 0 ? void 0 : _this$responder2.deleteInDirection(direction)) === false) { - if (event) { - event.preventDefault(); - return this.requestRender(); - } - } else { - return this.setInputSummary({ - didDelete: true - }); - } - } - serializeSelectionToDataTransfer(dataTransfer) { - var _this$responder3; - if (!dataTransferIsWritable(dataTransfer)) return; - const document = (_this$responder3 = this.responder) === null || _this$responder3 === void 0 ? void 0 : _this$responder3.getSelectedDocument().toSerializableDocument(); - dataTransfer.setData("application/x-trix-document", JSON.stringify(document)); - dataTransfer.setData("text/html", DocumentView.render(document).innerHTML); - dataTransfer.setData("text/plain", document.toString().replace(/\n$/, "")); - return true; - } - canAcceptDataTransfer(dataTransfer) { - const types = {}; - Array.from((dataTransfer === null || dataTransfer === void 0 ? void 0 : dataTransfer.types) || []).forEach(type => { - types[type] = true; - }); - return types.Files || types["application/x-trix-document"] || types["text/html"] || types["text/plain"]; - } - getPastedHTMLUsingHiddenElement(callback) { - const selectedRange = this.getSelectedRange(); - const style = { - position: "absolute", - left: "".concat(window.pageXOffset, "px"), - top: "".concat(window.pageYOffset, "px"), - opacity: 0 - }; - const element = makeElement({ - style, - tagName: "div", - editable: true - }); - document.body.appendChild(element); - element.focus(); - return requestAnimationFrame(() => { - const html = element.innerHTML; - removeNode(element); - this.setSelectedRange(selectedRange); - return callback(html); - }); - } - } - _defineProperty(Level0InputController, "events", { - keydown(event) { - if (!this.isComposing()) { - this.resetInputSummary(); - } - this.inputSummary.didInput = true; - const keyName = keyNames[event.keyCode]; - if (keyName) { - var _context2; - let context = this.keys; - ["ctrl", "alt", "shift", "meta"].forEach(modifier => { - if (event["".concat(modifier, "Key")]) { - var _context; - if (modifier === "ctrl") { - modifier = "control"; - } - context = (_context = context) === null || _context === void 0 ? void 0 : _context[modifier]; - } - }); - if (((_context2 = context) === null || _context2 === void 0 ? void 0 : _context2[keyName]) != null) { - this.setInputSummary({ - keyName - }); - selectionChangeObserver.reset(); - context[keyName].call(this, event); - } - } - if (keyEventIsKeyboardCommand(event)) { - const character = String.fromCharCode(event.keyCode).toLowerCase(); - if (character) { - var _this$delegate3; - const keys = ["alt", "shift"].map(modifier => { - if (event["".concat(modifier, "Key")]) { - return modifier; - } - }).filter(key => key); - keys.push(character); - if ((_this$delegate3 = this.delegate) !== null && _this$delegate3 !== void 0 && _this$delegate3.inputControllerDidReceiveKeyboardCommand(keys)) { - event.preventDefault(); - } - } - } - }, - keypress(event) { - if (this.inputSummary.eventName != null) return; - if (event.metaKey) return; - if (event.ctrlKey && !event.altKey) return; - const string = stringFromKeyEvent(event); - if (string) { - var _this$delegate4, _this$responder9; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || _this$delegate4.inputControllerWillPerformTyping(); - (_this$responder9 = this.responder) === null || _this$responder9 === void 0 || _this$responder9.insertString(string); - return this.setInputSummary({ - textAdded: string, - didDelete: this.selectionIsExpanded() - }); - } - }, - textInput(event) { - // Handle autocapitalization - const { - data - } = event; - const { - textAdded - } = this.inputSummary; - if (textAdded && textAdded !== data && textAdded.toUpperCase() === data) { - var _this$responder10; - const range = this.getSelectedRange(); - this.setSelectedRange([range[0], range[1] + textAdded.length]); - (_this$responder10 = this.responder) === null || _this$responder10 === void 0 || _this$responder10.insertString(data); - this.setInputSummary({ - textAdded: data - }); - return this.setSelectedRange(range); - } - }, - dragenter(event) { - event.preventDefault(); - }, - dragstart(event) { - var _this$delegate5, _this$delegate5$input; - this.serializeSelectionToDataTransfer(event.dataTransfer); - this.draggedRange = this.getSelectedRange(); - return (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || (_this$delegate5$input = _this$delegate5.inputControllerDidStartDrag) === null || _this$delegate5$input === void 0 ? void 0 : _this$delegate5$input.call(_this$delegate5); - }, - dragover(event) { - if (this.draggedRange || this.canAcceptDataTransfer(event.dataTransfer)) { - event.preventDefault(); - const draggingPoint = { - x: event.clientX, - y: event.clientY - }; - if (!objectsAreEqual(draggingPoint, this.draggingPoint)) { - var _this$delegate6, _this$delegate6$input; - this.draggingPoint = draggingPoint; - return (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 || (_this$delegate6$input = _this$delegate6.inputControllerDidReceiveDragOverPoint) === null || _this$delegate6$input === void 0 ? void 0 : _this$delegate6$input.call(_this$delegate6, this.draggingPoint); - } - } - }, - dragend(event) { - var _this$delegate7, _this$delegate7$input; - (_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || (_this$delegate7$input = _this$delegate7.inputControllerDidCancelDrag) === null || _this$delegate7$input === void 0 || _this$delegate7$input.call(_this$delegate7); - this.draggedRange = null; - this.draggingPoint = null; - }, - drop(event) { - var _event$dataTransfer, _this$responder11; - event.preventDefault(); - const files = (_event$dataTransfer = event.dataTransfer) === null || _event$dataTransfer === void 0 ? void 0 : _event$dataTransfer.files; - const documentJSON = event.dataTransfer.getData("application/x-trix-document"); - const point = { - x: event.clientX, - y: event.clientY - }; - (_this$responder11 = this.responder) === null || _this$responder11 === void 0 || _this$responder11.setLocationRangeFromPointRange(point); - if (files !== null && files !== void 0 && files.length) { - this.attachFiles(files); - } else if (this.draggedRange) { - var _this$delegate8, _this$responder12; - (_this$delegate8 = this.delegate) === null || _this$delegate8 === void 0 || _this$delegate8.inputControllerWillMoveText(); - (_this$responder12 = this.responder) === null || _this$responder12 === void 0 || _this$responder12.moveTextFromRange(this.draggedRange); - this.draggedRange = null; - this.requestRender(); - } else if (documentJSON) { - var _this$responder13; - const document = Document.fromJSONString(documentJSON); - (_this$responder13 = this.responder) === null || _this$responder13 === void 0 || _this$responder13.insertDocument(document); - this.requestRender(); - } - this.draggedRange = null; - this.draggingPoint = null; - }, - cut(event) { - var _this$responder14; - if ((_this$responder14 = this.responder) !== null && _this$responder14 !== void 0 && _this$responder14.selectionIsExpanded()) { - var _this$delegate9; - if (this.serializeSelectionToDataTransfer(event.clipboardData)) { - event.preventDefault(); - } - (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 || _this$delegate9.inputControllerWillCutText(); - this.deleteInDirection("backward"); - if (event.defaultPrevented) { - return this.requestRender(); - } - } - }, - copy(event) { - var _this$responder15; - if ((_this$responder15 = this.responder) !== null && _this$responder15 !== void 0 && _this$responder15.selectionIsExpanded()) { - if (this.serializeSelectionToDataTransfer(event.clipboardData)) { - event.preventDefault(); - } - } - }, - paste(event) { - const clipboard = event.clipboardData || event.testClipboardData; - const paste = { - clipboard - }; - if (!clipboard || pasteEventIsCrippledSafariHTMLPaste(event)) { - this.getPastedHTMLUsingHiddenElement(html => { - var _this$delegate10, _this$responder16, _this$delegate11; - paste.type = "text/html"; - paste.html = html; - (_this$delegate10 = this.delegate) === null || _this$delegate10 === void 0 || _this$delegate10.inputControllerWillPaste(paste); - (_this$responder16 = this.responder) === null || _this$responder16 === void 0 || _this$responder16.insertHTML(paste.html); - this.requestRender(); - return (_this$delegate11 = this.delegate) === null || _this$delegate11 === void 0 ? void 0 : _this$delegate11.inputControllerDidPaste(paste); - }); - return; - } - const href = clipboard.getData("URL"); - const html = clipboard.getData("text/html"); - const name = clipboard.getData("public.url-name"); - if (href) { - var _this$delegate12, _this$responder17, _this$delegate13; - let string; - paste.type = "text/html"; - if (name) { - string = squishBreakableWhitespace(name).trim(); - } else { - string = href; - } - paste.html = this.createLinkHTML(href, string); - (_this$delegate12 = this.delegate) === null || _this$delegate12 === void 0 || _this$delegate12.inputControllerWillPaste(paste); - this.setInputSummary({ - textAdded: string, - didDelete: this.selectionIsExpanded() - }); - (_this$responder17 = this.responder) === null || _this$responder17 === void 0 || _this$responder17.insertHTML(paste.html); - this.requestRender(); - (_this$delegate13 = this.delegate) === null || _this$delegate13 === void 0 || _this$delegate13.inputControllerDidPaste(paste); - } else if (dataTransferIsPlainText(clipboard)) { - var _this$delegate14, _this$responder18, _this$delegate15; - paste.type = "text/plain"; - paste.string = clipboard.getData("text/plain"); - (_this$delegate14 = this.delegate) === null || _this$delegate14 === void 0 || _this$delegate14.inputControllerWillPaste(paste); - this.setInputSummary({ - textAdded: paste.string, - didDelete: this.selectionIsExpanded() - }); - (_this$responder18 = this.responder) === null || _this$responder18 === void 0 || _this$responder18.insertString(paste.string); - this.requestRender(); - (_this$delegate15 = this.delegate) === null || _this$delegate15 === void 0 || _this$delegate15.inputControllerDidPaste(paste); - } else if (html) { - var _this$delegate16, _this$responder19, _this$delegate17; - paste.type = "text/html"; - paste.html = html; - (_this$delegate16 = this.delegate) === null || _this$delegate16 === void 0 || _this$delegate16.inputControllerWillPaste(paste); - (_this$responder19 = this.responder) === null || _this$responder19 === void 0 || _this$responder19.insertHTML(paste.html); - this.requestRender(); - (_this$delegate17 = this.delegate) === null || _this$delegate17 === void 0 || _this$delegate17.inputControllerDidPaste(paste); - } else if (Array.from(clipboard.types).includes("Files")) { - var _clipboard$items, _clipboard$items$getA; - const file = (_clipboard$items = clipboard.items) === null || _clipboard$items === void 0 || (_clipboard$items = _clipboard$items[0]) === null || _clipboard$items === void 0 || (_clipboard$items$getA = _clipboard$items.getAsFile) === null || _clipboard$items$getA === void 0 ? void 0 : _clipboard$items$getA.call(_clipboard$items); - if (file) { - var _this$delegate18, _this$responder20, _this$delegate19; - const extension = extensionForFile(file); - if (!file.name && extension) { - file.name = "pasted-file-".concat(++pastedFileCount, ".").concat(extension); - } - paste.type = "File"; - paste.file = file; - (_this$delegate18 = this.delegate) === null || _this$delegate18 === void 0 || _this$delegate18.inputControllerWillAttachFiles(); - (_this$responder20 = this.responder) === null || _this$responder20 === void 0 || _this$responder20.insertFile(paste.file); - this.requestRender(); - (_this$delegate19 = this.delegate) === null || _this$delegate19 === void 0 || _this$delegate19.inputControllerDidPaste(paste); - } - } - event.preventDefault(); - }, - compositionstart(event) { - return this.getCompositionInput().start(event.data); - }, - compositionupdate(event) { - return this.getCompositionInput().update(event.data); - }, - compositionend(event) { - return this.getCompositionInput().end(event.data); - }, - beforeinput(event) { - this.inputSummary.didInput = true; - }, - input(event) { - this.inputSummary.didInput = true; - return event.stopPropagation(); - } - }); - _defineProperty(Level0InputController, "keys", { - backspace(event) { - var _this$delegate20; - (_this$delegate20 = this.delegate) === null || _this$delegate20 === void 0 || _this$delegate20.inputControllerWillPerformTyping(); - return this.deleteInDirection("backward", event); - }, - delete(event) { - var _this$delegate21; - (_this$delegate21 = this.delegate) === null || _this$delegate21 === void 0 || _this$delegate21.inputControllerWillPerformTyping(); - return this.deleteInDirection("forward", event); - }, - return(event) { - var _this$delegate22, _this$responder21; - this.setInputSummary({ - preferDocument: true - }); - (_this$delegate22 = this.delegate) === null || _this$delegate22 === void 0 || _this$delegate22.inputControllerWillPerformTyping(); - return (_this$responder21 = this.responder) === null || _this$responder21 === void 0 ? void 0 : _this$responder21.insertLineBreak(); - }, - tab(event) { - var _this$responder22; - if ((_this$responder22 = this.responder) !== null && _this$responder22 !== void 0 && _this$responder22.canIncreaseNestingLevel()) { - var _this$responder23; - (_this$responder23 = this.responder) === null || _this$responder23 === void 0 || _this$responder23.increaseNestingLevel(); - this.requestRender(); - event.preventDefault(); - } - }, - left(event) { - if (this.selectionIsInCursorTarget()) { - var _this$responder24; - event.preventDefault(); - return (_this$responder24 = this.responder) === null || _this$responder24 === void 0 ? void 0 : _this$responder24.moveCursorInDirection("backward"); - } - }, - right(event) { - if (this.selectionIsInCursorTarget()) { - var _this$responder25; - event.preventDefault(); - return (_this$responder25 = this.responder) === null || _this$responder25 === void 0 ? void 0 : _this$responder25.moveCursorInDirection("forward"); - } - }, - control: { - d(event) { - var _this$delegate23; - (_this$delegate23 = this.delegate) === null || _this$delegate23 === void 0 || _this$delegate23.inputControllerWillPerformTyping(); - return this.deleteInDirection("forward", event); - }, - h(event) { - var _this$delegate24; - (_this$delegate24 = this.delegate) === null || _this$delegate24 === void 0 || _this$delegate24.inputControllerWillPerformTyping(); - return this.deleteInDirection("backward", event); - }, - o(event) { - var _this$delegate25, _this$responder26; - event.preventDefault(); - (_this$delegate25 = this.delegate) === null || _this$delegate25 === void 0 || _this$delegate25.inputControllerWillPerformTyping(); - (_this$responder26 = this.responder) === null || _this$responder26 === void 0 || _this$responder26.insertString("\n", { - updatePosition: false - }); - return this.requestRender(); - } - }, - shift: { - return(event) { - var _this$delegate26, _this$responder27; - (_this$delegate26 = this.delegate) === null || _this$delegate26 === void 0 || _this$delegate26.inputControllerWillPerformTyping(); - (_this$responder27 = this.responder) === null || _this$responder27 === void 0 || _this$responder27.insertString("\n"); - this.requestRender(); - event.preventDefault(); - }, - tab(event) { - var _this$responder28; - if ((_this$responder28 = this.responder) !== null && _this$responder28 !== void 0 && _this$responder28.canDecreaseNestingLevel()) { - var _this$responder29; - (_this$responder29 = this.responder) === null || _this$responder29 === void 0 || _this$responder29.decreaseNestingLevel(); - this.requestRender(); - event.preventDefault(); - } - }, - left(event) { - if (this.selectionIsInCursorTarget()) { - event.preventDefault(); - return this.expandSelectionInDirection("backward"); - } - }, - right(event) { - if (this.selectionIsInCursorTarget()) { - event.preventDefault(); - return this.expandSelectionInDirection("forward"); - } - } - }, - alt: { - backspace(event) { - var _this$delegate27; - this.setInputSummary({ - preferDocument: false - }); - return (_this$delegate27 = this.delegate) === null || _this$delegate27 === void 0 ? void 0 : _this$delegate27.inputControllerWillPerformTyping(); - } - }, - meta: { - backspace(event) { - var _this$delegate28; - this.setInputSummary({ - preferDocument: false - }); - return (_this$delegate28 = this.delegate) === null || _this$delegate28 === void 0 ? void 0 : _this$delegate28.inputControllerWillPerformTyping(); - } - } - }); - Level0InputController.proxyMethod("responder?.getSelectedRange"); - Level0InputController.proxyMethod("responder?.setSelectedRange"); - Level0InputController.proxyMethod("responder?.expandSelectionInDirection"); - Level0InputController.proxyMethod("responder?.selectionIsInCursorTarget"); - Level0InputController.proxyMethod("responder?.selectionIsExpanded"); - const extensionForFile = file => { - var _file$type; - return (_file$type = file.type) === null || _file$type === void 0 || (_file$type = _file$type.match(/\/(\w+)$/)) === null || _file$type === void 0 ? void 0 : _file$type[1]; - }; - const hasStringCodePointAt = !!((_$codePointAt = (_ = " ").codePointAt) !== null && _$codePointAt !== void 0 && _$codePointAt.call(_, 0)); - const stringFromKeyEvent = function (event) { - if (event.key && hasStringCodePointAt && event.key.codePointAt(0) === event.keyCode) { - return event.key; - } else { - let code; - if (event.which === null) { - code = event.keyCode; - } else if (event.which !== 0 && event.charCode !== 0) { - code = event.charCode; - } - if (code != null && keyNames[code] !== "escape") { - return UTF16String.fromCodepoints([code]).toString(); - } - } - }; - const pasteEventIsCrippledSafariHTMLPaste = function (event) { - const paste = event.clipboardData; - if (paste) { - if (paste.types.includes("text/html")) { - // Answer is yes if there's any possibility of Paste and Match Style in Safari, - // which is nearly impossible to detect confidently: https://bugs.webkit.org/show_bug.cgi?id=174165 - for (const type of paste.types) { - const hasPasteboardFlavor = /^CorePasteboardFlavorType/.test(type); - const hasReadableDynamicData = /^dyn\./.test(type) && paste.getData(type); - const mightBePasteAndMatchStyle = hasPasteboardFlavor || hasReadableDynamicData; - if (mightBePasteAndMatchStyle) { - return true; - } - } - return false; - } else { - const isExternalHTMLPaste = paste.types.includes("com.apple.webarchive"); - const isExternalRichTextPaste = paste.types.includes("com.apple.flat-rtfd"); - return isExternalHTMLPaste || isExternalRichTextPaste; - } - } - }; - class CompositionInput extends BasicObject { - constructor(inputController) { - super(...arguments); - this.inputController = inputController; - this.responder = this.inputController.responder; - this.delegate = this.inputController.delegate; - this.inputSummary = this.inputController.inputSummary; - this.data = {}; - } - start(data) { - this.data.start = data; - if (this.isSignificant()) { - var _this$responder5; - if (this.inputSummary.eventName === "keypress" && this.inputSummary.textAdded) { - var _this$responder4; - (_this$responder4 = this.responder) === null || _this$responder4 === void 0 || _this$responder4.deleteInDirection("left"); - } - if (!this.selectionIsExpanded()) { - this.insertPlaceholder(); - this.requestRender(); - } - this.range = (_this$responder5 = this.responder) === null || _this$responder5 === void 0 ? void 0 : _this$responder5.getSelectedRange(); - } - } - update(data) { - this.data.update = data; - if (this.isSignificant()) { - const range = this.selectPlaceholder(); - if (range) { - this.forgetPlaceholder(); - this.range = range; - } - } - } - end(data) { - this.data.end = data; - if (this.isSignificant()) { - this.forgetPlaceholder(); - if (this.canApplyToDocument()) { - var _this$delegate2, _this$responder6, _this$responder7, _this$responder8; - this.setInputSummary({ - preferDocument: true, - didInput: false - }); - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || _this$delegate2.inputControllerWillPerformTyping(); - (_this$responder6 = this.responder) === null || _this$responder6 === void 0 || _this$responder6.setSelectedRange(this.range); - (_this$responder7 = this.responder) === null || _this$responder7 === void 0 || _this$responder7.insertString(this.data.end); - return (_this$responder8 = this.responder) === null || _this$responder8 === void 0 ? void 0 : _this$responder8.setSelectedRange(this.range[0] + this.data.end.length); - } else if (this.data.start != null || this.data.update != null) { - this.requestReparse(); - return this.inputController.reset(); - } - } else { - return this.inputController.reset(); - } - } - getEndData() { - return this.data.end; - } - isEnded() { - return this.getEndData() != null; - } - isSignificant() { - if (browser.composesExistingText) { - return this.inputSummary.didInput; - } else { - return true; - } - } - - // Private - - canApplyToDocument() { - var _this$data$start, _this$data$end; - return ((_this$data$start = this.data.start) === null || _this$data$start === void 0 ? void 0 : _this$data$start.length) === 0 && ((_this$data$end = this.data.end) === null || _this$data$end === void 0 ? void 0 : _this$data$end.length) > 0 && this.range; - } - } - CompositionInput.proxyMethod("inputController.setInputSummary"); - CompositionInput.proxyMethod("inputController.requestRender"); - CompositionInput.proxyMethod("inputController.requestReparse"); - CompositionInput.proxyMethod("responder?.selectionIsExpanded"); - CompositionInput.proxyMethod("responder?.insertPlaceholder"); - CompositionInput.proxyMethod("responder?.selectPlaceholder"); - CompositionInput.proxyMethod("responder?.forgetPlaceholder"); - - class Level2InputController extends InputController { - constructor() { - super(...arguments); - this.render = this.render.bind(this); - } - elementDidMutate() { - if (this.scheduledRender) { - if (this.composing) { - var _this$delegate, _this$delegate$inputC; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$inputC = _this$delegate.inputControllerDidAllowUnhandledInput) === null || _this$delegate$inputC === void 0 ? void 0 : _this$delegate$inputC.call(_this$delegate); - } - } else { - return this.reparse(); - } - } - scheduleRender() { - return this.scheduledRender ? this.scheduledRender : this.scheduledRender = requestAnimationFrame(this.render); - } - render() { - var _this$afterRender; - cancelAnimationFrame(this.scheduledRender); - this.scheduledRender = null; - if (!this.composing) { - var _this$delegate2; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || _this$delegate2.render(); - } - (_this$afterRender = this.afterRender) === null || _this$afterRender === void 0 || _this$afterRender.call(this); - this.afterRender = null; - } - reparse() { - var _this$delegate3; - return (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 ? void 0 : _this$delegate3.reparse(); - } - - // Responder helpers - - insertString() { - var _this$delegate4; - let string = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - let options = arguments.length > 1 ? arguments[1] : undefined; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || _this$delegate4.inputControllerWillPerformTyping(); - return this.withTargetDOMRange(function () { - var _this$responder; - return (_this$responder = this.responder) === null || _this$responder === void 0 ? void 0 : _this$responder.insertString(string, options); - }); - } - toggleAttributeIfSupported(attributeName) { - if (getAllAttributeNames().includes(attributeName)) { - var _this$delegate5; - (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || _this$delegate5.inputControllerWillPerformFormatting(attributeName); - return this.withTargetDOMRange(function () { - var _this$responder2; - return (_this$responder2 = this.responder) === null || _this$responder2 === void 0 ? void 0 : _this$responder2.toggleCurrentAttribute(attributeName); - }); - } - } - activateAttributeIfSupported(attributeName, value) { - if (getAllAttributeNames().includes(attributeName)) { - var _this$delegate6; - (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 || _this$delegate6.inputControllerWillPerformFormatting(attributeName); - return this.withTargetDOMRange(function () { - var _this$responder3; - return (_this$responder3 = this.responder) === null || _this$responder3 === void 0 ? void 0 : _this$responder3.setCurrentAttribute(attributeName, value); - }); - } - } - deleteInDirection(direction) { - let { - recordUndoEntry - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { - recordUndoEntry: true - }; - if (recordUndoEntry) { - var _this$delegate7; - (_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || _this$delegate7.inputControllerWillPerformTyping(); - } - const perform = () => { - var _this$responder4; - return (_this$responder4 = this.responder) === null || _this$responder4 === void 0 ? void 0 : _this$responder4.deleteInDirection(direction); - }; - const domRange = this.getTargetDOMRange({ - minLength: this.composing ? 1 : 2 - }); - if (domRange) { - return this.withTargetDOMRange(domRange, perform); - } else { - return perform(); - } - } - - // Selection helpers - - withTargetDOMRange(domRange, fn) { - if (typeof domRange === "function") { - fn = domRange; - domRange = this.getTargetDOMRange(); - } - if (domRange) { - var _this$responder5; - return (_this$responder5 = this.responder) === null || _this$responder5 === void 0 ? void 0 : _this$responder5.withTargetDOMRange(domRange, fn.bind(this)); - } else { - selectionChangeObserver.reset(); - return fn.call(this); - } - } - getTargetDOMRange() { - var _this$event$getTarget, _this$event; - let { - minLength - } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : { - minLength: 0 - }; - const targetRanges = (_this$event$getTarget = (_this$event = this.event).getTargetRanges) === null || _this$event$getTarget === void 0 ? void 0 : _this$event$getTarget.call(_this$event); - if (targetRanges) { - if (targetRanges.length) { - const domRange = staticRangeToRange(targetRanges[0]); - if (minLength === 0 || domRange.toString().length >= minLength) { - return domRange; - } - } - } - } - withEvent(event, fn) { - let result; - this.event = event; - try { - result = fn.call(this); - } finally { - this.event = null; - } - return result; - } - } - _defineProperty(Level2InputController, "events", { - keydown(event) { - if (keyEventIsKeyboardCommand(event)) { - var _this$delegate8; - const command = keyboardCommandFromKeyEvent(event); - if ((_this$delegate8 = this.delegate) !== null && _this$delegate8 !== void 0 && _this$delegate8.inputControllerDidReceiveKeyboardCommand(command)) { - event.preventDefault(); - } - } else { - let name = event.key; - if (event.altKey) { - name += "+Alt"; - } - if (event.shiftKey) { - name += "+Shift"; - } - const handler = this.constructor.keys[name]; - if (handler) { - return this.withEvent(event, handler); - } - } - }, - // Handle paste event to work around beforeinput.insertFromPaste browser bugs. - // Safe to remove each condition once fixed upstream. - paste(event) { - var _event$clipboardData; - // https://bugs.webkit.org/show_bug.cgi?id=194921 - let paste; - const href = (_event$clipboardData = event.clipboardData) === null || _event$clipboardData === void 0 ? void 0 : _event$clipboardData.getData("URL"); - if (pasteEventHasFilesOnly(event)) { - event.preventDefault(); - return this.attachFiles(event.clipboardData.files); - - // https://bugs.chromium.org/p/chromium/issues/detail?id=934448 - } else if (pasteEventHasPlainTextOnly(event)) { - var _this$delegate9, _this$responder6, _this$delegate10; - event.preventDefault(); - paste = { - type: "text/plain", - string: event.clipboardData.getData("text/plain") - }; - (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 || _this$delegate9.inputControllerWillPaste(paste); - (_this$responder6 = this.responder) === null || _this$responder6 === void 0 || _this$responder6.insertString(paste.string); - this.render(); - return (_this$delegate10 = this.delegate) === null || _this$delegate10 === void 0 ? void 0 : _this$delegate10.inputControllerDidPaste(paste); - - // https://bugs.webkit.org/show_bug.cgi?id=196702 - } else if (href) { - var _this$delegate11, _this$responder7, _this$delegate12; - event.preventDefault(); - paste = { - type: "text/html", - html: this.createLinkHTML(href) - }; - (_this$delegate11 = this.delegate) === null || _this$delegate11 === void 0 || _this$delegate11.inputControllerWillPaste(paste); - (_this$responder7 = this.responder) === null || _this$responder7 === void 0 || _this$responder7.insertHTML(paste.html); - this.render(); - return (_this$delegate12 = this.delegate) === null || _this$delegate12 === void 0 ? void 0 : _this$delegate12.inputControllerDidPaste(paste); - } - }, - beforeinput(event) { - const handler = this.constructor.inputTypes[event.inputType]; - const immmediateRender = shouldRenderInmmediatelyToDealWithIOSDictation(event); - if (handler) { - this.withEvent(event, handler); - if (!immmediateRender) { - this.scheduleRender(); - } - } - if (immmediateRender) { - this.render(); - } - }, - input(event) { - selectionChangeObserver.reset(); - }, - dragstart(event) { - var _this$responder8; - if ((_this$responder8 = this.responder) !== null && _this$responder8 !== void 0 && _this$responder8.selectionContainsAttachments()) { - var _this$responder9; - event.dataTransfer.setData("application/x-trix-dragging", true); - this.dragging = { - range: (_this$responder9 = this.responder) === null || _this$responder9 === void 0 ? void 0 : _this$responder9.getSelectedRange(), - point: pointFromEvent(event) - }; - } - }, - dragenter(event) { - if (dragEventHasFiles(event)) { - event.preventDefault(); - } - }, - dragover(event) { - if (this.dragging) { - event.preventDefault(); - const point = pointFromEvent(event); - if (!objectsAreEqual(point, this.dragging.point)) { - var _this$responder10; - this.dragging.point = point; - return (_this$responder10 = this.responder) === null || _this$responder10 === void 0 ? void 0 : _this$responder10.setLocationRangeFromPointRange(point); - } - } else if (dragEventHasFiles(event)) { - event.preventDefault(); - } - }, - drop(event) { - if (this.dragging) { - var _this$delegate13, _this$responder11; - event.preventDefault(); - (_this$delegate13 = this.delegate) === null || _this$delegate13 === void 0 || _this$delegate13.inputControllerWillMoveText(); - (_this$responder11 = this.responder) === null || _this$responder11 === void 0 || _this$responder11.moveTextFromRange(this.dragging.range); - this.dragging = null; - return this.scheduleRender(); - } else if (dragEventHasFiles(event)) { - var _this$responder12; - event.preventDefault(); - const point = pointFromEvent(event); - (_this$responder12 = this.responder) === null || _this$responder12 === void 0 || _this$responder12.setLocationRangeFromPointRange(point); - return this.attachFiles(event.dataTransfer.files); - } - }, - dragend() { - if (this.dragging) { - var _this$responder13; - (_this$responder13 = this.responder) === null || _this$responder13 === void 0 || _this$responder13.setSelectedRange(this.dragging.range); - this.dragging = null; - } - }, - compositionend(event) { - if (this.composing) { - this.composing = false; - if (!browser$1.recentAndroid) this.scheduleRender(); - } - } - }); - _defineProperty(Level2InputController, "keys", { - ArrowLeft() { - var _this$responder14; - if ((_this$responder14 = this.responder) !== null && _this$responder14 !== void 0 && _this$responder14.shouldManageMovingCursorInDirection("backward")) { - var _this$responder15; - this.event.preventDefault(); - return (_this$responder15 = this.responder) === null || _this$responder15 === void 0 ? void 0 : _this$responder15.moveCursorInDirection("backward"); - } - }, - ArrowRight() { - var _this$responder16; - if ((_this$responder16 = this.responder) !== null && _this$responder16 !== void 0 && _this$responder16.shouldManageMovingCursorInDirection("forward")) { - var _this$responder17; - this.event.preventDefault(); - return (_this$responder17 = this.responder) === null || _this$responder17 === void 0 ? void 0 : _this$responder17.moveCursorInDirection("forward"); - } - }, - Backspace() { - var _this$responder18; - if ((_this$responder18 = this.responder) !== null && _this$responder18 !== void 0 && _this$responder18.shouldManageDeletingInDirection("backward")) { - var _this$delegate14, _this$responder19; - this.event.preventDefault(); - (_this$delegate14 = this.delegate) === null || _this$delegate14 === void 0 || _this$delegate14.inputControllerWillPerformTyping(); - (_this$responder19 = this.responder) === null || _this$responder19 === void 0 || _this$responder19.deleteInDirection("backward"); - return this.render(); - } - }, - Tab() { - var _this$responder20; - if ((_this$responder20 = this.responder) !== null && _this$responder20 !== void 0 && _this$responder20.canIncreaseNestingLevel()) { - var _this$responder21; - this.event.preventDefault(); - (_this$responder21 = this.responder) === null || _this$responder21 === void 0 || _this$responder21.increaseNestingLevel(); - return this.render(); - } - }, - "Tab+Shift"() { - var _this$responder22; - if ((_this$responder22 = this.responder) !== null && _this$responder22 !== void 0 && _this$responder22.canDecreaseNestingLevel()) { - var _this$responder23; - this.event.preventDefault(); - (_this$responder23 = this.responder) === null || _this$responder23 === void 0 || _this$responder23.decreaseNestingLevel(); - return this.render(); - } - } - }); - _defineProperty(Level2InputController, "inputTypes", { - deleteByComposition() { - return this.deleteInDirection("backward", { - recordUndoEntry: false - }); - }, - deleteByCut() { - return this.deleteInDirection("backward"); - }, - deleteByDrag() { - this.event.preventDefault(); - return this.withTargetDOMRange(function () { - var _this$responder24; - this.deleteByDragRange = (_this$responder24 = this.responder) === null || _this$responder24 === void 0 ? void 0 : _this$responder24.getSelectedRange(); - }); - }, - deleteCompositionText() { - return this.deleteInDirection("backward", { - recordUndoEntry: false - }); - }, - deleteContent() { - return this.deleteInDirection("backward"); - }, - deleteContentBackward() { - return this.deleteInDirection("backward"); - }, - deleteContentForward() { - return this.deleteInDirection("forward"); - }, - deleteEntireSoftLine() { - return this.deleteInDirection("forward"); - }, - deleteHardLineBackward() { - return this.deleteInDirection("backward"); - }, - deleteHardLineForward() { - return this.deleteInDirection("forward"); - }, - deleteSoftLineBackward() { - return this.deleteInDirection("backward"); - }, - deleteSoftLineForward() { - return this.deleteInDirection("forward"); - }, - deleteWordBackward() { - return this.deleteInDirection("backward"); - }, - deleteWordForward() { - return this.deleteInDirection("forward"); - }, - formatBackColor() { - return this.activateAttributeIfSupported("backgroundColor", this.event.data); - }, - formatBold() { - return this.toggleAttributeIfSupported("bold"); - }, - formatFontColor() { - return this.activateAttributeIfSupported("color", this.event.data); - }, - formatFontName() { - return this.activateAttributeIfSupported("font", this.event.data); - }, - formatIndent() { - var _this$responder25; - if ((_this$responder25 = this.responder) !== null && _this$responder25 !== void 0 && _this$responder25.canIncreaseNestingLevel()) { - return this.withTargetDOMRange(function () { - var _this$responder26; - return (_this$responder26 = this.responder) === null || _this$responder26 === void 0 ? void 0 : _this$responder26.increaseNestingLevel(); - }); - } - }, - formatItalic() { - return this.toggleAttributeIfSupported("italic"); - }, - formatJustifyCenter() { - return this.toggleAttributeIfSupported("justifyCenter"); - }, - formatJustifyFull() { - return this.toggleAttributeIfSupported("justifyFull"); - }, - formatJustifyLeft() { - return this.toggleAttributeIfSupported("justifyLeft"); - }, - formatJustifyRight() { - return this.toggleAttributeIfSupported("justifyRight"); - }, - formatOutdent() { - var _this$responder27; - if ((_this$responder27 = this.responder) !== null && _this$responder27 !== void 0 && _this$responder27.canDecreaseNestingLevel()) { - return this.withTargetDOMRange(function () { - var _this$responder28; - return (_this$responder28 = this.responder) === null || _this$responder28 === void 0 ? void 0 : _this$responder28.decreaseNestingLevel(); - }); - } - }, - formatRemove() { - this.withTargetDOMRange(function () { - for (const attributeName in (_this$responder29 = this.responder) === null || _this$responder29 === void 0 ? void 0 : _this$responder29.getCurrentAttributes()) { - var _this$responder29, _this$responder30; - (_this$responder30 = this.responder) === null || _this$responder30 === void 0 || _this$responder30.removeCurrentAttribute(attributeName); - } - }); - }, - formatSetBlockTextDirection() { - return this.activateAttributeIfSupported("blockDir", this.event.data); - }, - formatSetInlineTextDirection() { - return this.activateAttributeIfSupported("textDir", this.event.data); - }, - formatStrikeThrough() { - return this.toggleAttributeIfSupported("strike"); - }, - formatSubscript() { - return this.toggleAttributeIfSupported("sub"); - }, - formatSuperscript() { - return this.toggleAttributeIfSupported("sup"); - }, - formatUnderline() { - return this.toggleAttributeIfSupported("underline"); - }, - historyRedo() { - var _this$delegate15; - return (_this$delegate15 = this.delegate) === null || _this$delegate15 === void 0 ? void 0 : _this$delegate15.inputControllerWillPerformRedo(); - }, - historyUndo() { - var _this$delegate16; - return (_this$delegate16 = this.delegate) === null || _this$delegate16 === void 0 ? void 0 : _this$delegate16.inputControllerWillPerformUndo(); - }, - insertCompositionText() { - this.composing = true; - return this.insertString(this.event.data); - }, - insertFromComposition() { - this.composing = false; - return this.insertString(this.event.data); - }, - insertFromDrop() { - const range = this.deleteByDragRange; - if (range) { - var _this$delegate17; - this.deleteByDragRange = null; - (_this$delegate17 = this.delegate) === null || _this$delegate17 === void 0 || _this$delegate17.inputControllerWillMoveText(); - return this.withTargetDOMRange(function () { - var _this$responder31; - return (_this$responder31 = this.responder) === null || _this$responder31 === void 0 ? void 0 : _this$responder31.moveTextFromRange(range); - }); - } - }, - insertFromPaste() { - const { - dataTransfer - } = this.event; - const paste = { - dataTransfer - }; - const href = dataTransfer.getData("URL"); - const html = dataTransfer.getData("text/html"); - if (href) { - var _this$delegate18; - let string; - this.event.preventDefault(); - paste.type = "text/html"; - const name = dataTransfer.getData("public.url-name"); - if (name) { - string = squishBreakableWhitespace(name).trim(); - } else { - string = href; - } - paste.html = this.createLinkHTML(href, string); - (_this$delegate18 = this.delegate) === null || _this$delegate18 === void 0 || _this$delegate18.inputControllerWillPaste(paste); - this.withTargetDOMRange(function () { - var _this$responder32; - return (_this$responder32 = this.responder) === null || _this$responder32 === void 0 ? void 0 : _this$responder32.insertHTML(paste.html); - }); - this.afterRender = () => { - var _this$delegate19; - return (_this$delegate19 = this.delegate) === null || _this$delegate19 === void 0 ? void 0 : _this$delegate19.inputControllerDidPaste(paste); - }; - } else if (dataTransferIsPlainText(dataTransfer)) { - var _this$delegate20; - paste.type = "text/plain"; - paste.string = dataTransfer.getData("text/plain"); - (_this$delegate20 = this.delegate) === null || _this$delegate20 === void 0 || _this$delegate20.inputControllerWillPaste(paste); - this.withTargetDOMRange(function () { - var _this$responder33; - return (_this$responder33 = this.responder) === null || _this$responder33 === void 0 ? void 0 : _this$responder33.insertString(paste.string); - }); - this.afterRender = () => { - var _this$delegate21; - return (_this$delegate21 = this.delegate) === null || _this$delegate21 === void 0 ? void 0 : _this$delegate21.inputControllerDidPaste(paste); - }; - } else if (processableFilePaste(this.event)) { - var _this$delegate22; - paste.type = "File"; - paste.file = dataTransfer.files[0]; - (_this$delegate22 = this.delegate) === null || _this$delegate22 === void 0 || _this$delegate22.inputControllerWillPaste(paste); - this.withTargetDOMRange(function () { - var _this$responder34; - return (_this$responder34 = this.responder) === null || _this$responder34 === void 0 ? void 0 : _this$responder34.insertFile(paste.file); - }); - this.afterRender = () => { - var _this$delegate23; - return (_this$delegate23 = this.delegate) === null || _this$delegate23 === void 0 ? void 0 : _this$delegate23.inputControllerDidPaste(paste); - }; - } else if (html) { - var _this$delegate24; - this.event.preventDefault(); - paste.type = "text/html"; - paste.html = html; - (_this$delegate24 = this.delegate) === null || _this$delegate24 === void 0 || _this$delegate24.inputControllerWillPaste(paste); - this.withTargetDOMRange(function () { - var _this$responder35; - return (_this$responder35 = this.responder) === null || _this$responder35 === void 0 ? void 0 : _this$responder35.insertHTML(paste.html); - }); - this.afterRender = () => { - var _this$delegate25; - return (_this$delegate25 = this.delegate) === null || _this$delegate25 === void 0 ? void 0 : _this$delegate25.inputControllerDidPaste(paste); - }; - } - }, - insertFromYank() { - return this.insertString(this.event.data); - }, - insertLineBreak() { - return this.insertString("\n"); - }, - insertLink() { - return this.activateAttributeIfSupported("href", this.event.data); - }, - insertOrderedList() { - return this.toggleAttributeIfSupported("number"); - }, - insertParagraph() { - var _this$delegate26; - (_this$delegate26 = this.delegate) === null || _this$delegate26 === void 0 || _this$delegate26.inputControllerWillPerformTyping(); - return this.withTargetDOMRange(function () { - var _this$responder36; - return (_this$responder36 = this.responder) === null || _this$responder36 === void 0 ? void 0 : _this$responder36.insertLineBreak(); - }); - }, - insertReplacementText() { - const replacement = this.event.dataTransfer.getData("text/plain"); - const domRange = this.event.getTargetRanges()[0]; - this.withTargetDOMRange(domRange, () => { - this.insertString(replacement, { - updatePosition: false - }); - }); - }, - insertText() { - var _this$event$dataTrans; - return this.insertString(this.event.data || ((_this$event$dataTrans = this.event.dataTransfer) === null || _this$event$dataTrans === void 0 ? void 0 : _this$event$dataTrans.getData("text/plain"))); - }, - insertTranspose() { - return this.insertString(this.event.data); - }, - insertUnorderedList() { - return this.toggleAttributeIfSupported("bullet"); - } - }); - const staticRangeToRange = function (staticRange) { - const range = document.createRange(); - range.setStart(staticRange.startContainer, staticRange.startOffset); - range.setEnd(staticRange.endContainer, staticRange.endOffset); - return range; - }; - - // Event helpers - - const dragEventHasFiles = event => { - var _event$dataTransfer; - return Array.from(((_event$dataTransfer = event.dataTransfer) === null || _event$dataTransfer === void 0 ? void 0 : _event$dataTransfer.types) || []).includes("Files"); - }; - const processableFilePaste = event => { - var _event$dataTransfer$f; - // Paste events that only have files are handled by the paste event handler, - // to work around Safari not supporting beforeinput.insertFromPaste for files. - - // MS Office text pastes include a file with a screenshot of the text, but we should - // handle them as text pastes. - return ((_event$dataTransfer$f = event.dataTransfer.files) === null || _event$dataTransfer$f === void 0 ? void 0 : _event$dataTransfer$f[0]) && !pasteEventHasFilesOnly(event) && !dataTransferIsMsOfficePaste(event); - }; - const pasteEventHasFilesOnly = function (event) { - const clipboard = event.clipboardData; - if (clipboard) { - const fileTypes = Array.from(clipboard.types).filter(type => type.match(/file/i)); // "Files", "application/x-moz-file" - return fileTypes.length === clipboard.types.length && clipboard.files.length >= 1; - } - }; - const pasteEventHasPlainTextOnly = function (event) { - const clipboard = event.clipboardData; - if (clipboard) { - return clipboard.types.includes("text/plain") && clipboard.types.length === 1; - } - }; - const keyboardCommandFromKeyEvent = function (event) { - const command = []; - if (event.altKey) { - command.push("alt"); - } - if (event.shiftKey) { - command.push("shift"); - } - command.push(event.key); - return command; - }; - const pointFromEvent = event => ({ - x: event.clientX, - y: event.clientY - }); - - const attributeButtonSelector = "[data-trix-attribute]"; - const actionButtonSelector = "[data-trix-action]"; - const toolbarButtonSelector = "".concat(attributeButtonSelector, ", ").concat(actionButtonSelector); - const dialogSelector = "[data-trix-dialog]"; - const activeDialogSelector = "".concat(dialogSelector, "[data-trix-active]"); - const dialogButtonSelector = "".concat(dialogSelector, " [data-trix-method]"); - const dialogInputSelector = "".concat(dialogSelector, " [data-trix-input]"); - const getInputForDialog = (element, attributeName) => { - if (!attributeName) { - attributeName = getAttributeName(element); - } - return element.querySelector("[data-trix-input][name='".concat(attributeName, "']")); - }; - const getActionName = element => element.getAttribute("data-trix-action"); - const getAttributeName = element => { - return element.getAttribute("data-trix-attribute") || element.getAttribute("data-trix-dialog-attribute"); - }; - const getDialogName = element => element.getAttribute("data-trix-dialog"); - class ToolbarController extends BasicObject { - constructor(element) { - super(element); - this.didClickActionButton = this.didClickActionButton.bind(this); - this.didClickAttributeButton = this.didClickAttributeButton.bind(this); - this.didClickDialogButton = this.didClickDialogButton.bind(this); - this.didKeyDownDialogInput = this.didKeyDownDialogInput.bind(this); - this.element = element; - this.attributes = {}; - this.actions = {}; - this.resetDialogInputs(); - handleEvent("mousedown", { - onElement: this.element, - matchingSelector: actionButtonSelector, - withCallback: this.didClickActionButton - }); - handleEvent("mousedown", { - onElement: this.element, - matchingSelector: attributeButtonSelector, - withCallback: this.didClickAttributeButton - }); - handleEvent("click", { - onElement: this.element, - matchingSelector: toolbarButtonSelector, - preventDefault: true - }); - handleEvent("click", { - onElement: this.element, - matchingSelector: dialogButtonSelector, - withCallback: this.didClickDialogButton - }); - handleEvent("keydown", { - onElement: this.element, - matchingSelector: dialogInputSelector, - withCallback: this.didKeyDownDialogInput - }); - } - - // Event handlers - - didClickActionButton(event, element) { - var _this$delegate; - (_this$delegate = this.delegate) === null || _this$delegate === void 0 || _this$delegate.toolbarDidClickButton(); - event.preventDefault(); - const actionName = getActionName(element); - if (this.getDialog(actionName)) { - return this.toggleDialog(actionName); - } else { - var _this$delegate2; - return (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 ? void 0 : _this$delegate2.toolbarDidInvokeAction(actionName, element); - } - } - didClickAttributeButton(event, element) { - var _this$delegate3; - (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || _this$delegate3.toolbarDidClickButton(); - event.preventDefault(); - const attributeName = getAttributeName(element); - if (this.getDialog(attributeName)) { - this.toggleDialog(attributeName); - } else { - var _this$delegate4; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || _this$delegate4.toolbarDidToggleAttribute(attributeName); - } - return this.refreshAttributeButtons(); - } - didClickDialogButton(event, element) { - const dialogElement = findClosestElementFromNode(element, { - matchingSelector: dialogSelector - }); - const method = element.getAttribute("data-trix-method"); - return this[method].call(this, dialogElement); - } - didKeyDownDialogInput(event, element) { - if (event.keyCode === 13) { - // Enter key - event.preventDefault(); - const attribute = element.getAttribute("name"); - const dialog = this.getDialog(attribute); - this.setAttribute(dialog); - } - if (event.keyCode === 27) { - // Escape key - event.preventDefault(); - return this.hideDialog(); - } - } - - // Action buttons - - updateActions(actions) { - this.actions = actions; - return this.refreshActionButtons(); - } - refreshActionButtons() { - return this.eachActionButton((element, actionName) => { - element.disabled = this.actions[actionName] === false; - }); - } - eachActionButton(callback) { - return Array.from(this.element.querySelectorAll(actionButtonSelector)).map(element => callback(element, getActionName(element))); - } - - // Attribute buttons - - updateAttributes(attributes) { - this.attributes = attributes; - return this.refreshAttributeButtons(); - } - refreshAttributeButtons() { - return this.eachAttributeButton((element, attributeName) => { - element.disabled = this.attributes[attributeName] === false; - if (this.attributes[attributeName] || this.dialogIsVisible(attributeName)) { - element.setAttribute("data-trix-active", ""); - return element.classList.add("trix-active"); - } else { - element.removeAttribute("data-trix-active"); - return element.classList.remove("trix-active"); - } - }); - } - eachAttributeButton(callback) { - return Array.from(this.element.querySelectorAll(attributeButtonSelector)).map(element => callback(element, getAttributeName(element))); - } - applyKeyboardCommand(keys) { - const keyString = JSON.stringify(keys.sort()); - for (const button of Array.from(this.element.querySelectorAll("[data-trix-key]"))) { - const buttonKeys = button.getAttribute("data-trix-key").split("+"); - const buttonKeyString = JSON.stringify(buttonKeys.sort()); - if (buttonKeyString === keyString) { - triggerEvent("mousedown", { - onElement: button - }); - return true; - } - } - return false; - } - - // Dialogs - - dialogIsVisible(dialogName) { - const element = this.getDialog(dialogName); - if (element) { - return element.hasAttribute("data-trix-active"); - } - } - toggleDialog(dialogName) { - if (this.dialogIsVisible(dialogName)) { - return this.hideDialog(); - } else { - return this.showDialog(dialogName); - } - } - showDialog(dialogName) { - var _this$delegate5, _this$delegate6; - this.hideDialog(); - (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || _this$delegate5.toolbarWillShowDialog(); - const element = this.getDialog(dialogName); - element.setAttribute("data-trix-active", ""); - element.classList.add("trix-active"); - Array.from(element.querySelectorAll("input[disabled]")).forEach(disabledInput => { - disabledInput.removeAttribute("disabled"); - }); - const attributeName = getAttributeName(element); - if (attributeName) { - const input = getInputForDialog(element, dialogName); - if (input) { - input.value = this.attributes[attributeName] || ""; - input.select(); - } - } - return (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 ? void 0 : _this$delegate6.toolbarDidShowDialog(dialogName); - } - setAttribute(dialogElement) { - var _this$delegate7; - const attributeName = getAttributeName(dialogElement); - const input = getInputForDialog(dialogElement, attributeName); - if (input.willValidate) { - input.setCustomValidity(""); - if (!input.checkValidity() || !this.isSafeAttribute(input)) { - input.setCustomValidity("Invalid value"); - input.setAttribute("data-trix-validate", ""); - input.classList.add("trix-validate"); - return input.focus(); - } - } - (_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || _this$delegate7.toolbarDidUpdateAttribute(attributeName, input.value); - return this.hideDialog(); - } - isSafeAttribute(input) { - if (input.hasAttribute("data-trix-validate-href")) { - return purify.isValidAttribute("a", "href", input.value); - } else { - return true; - } - } - removeAttribute(dialogElement) { - var _this$delegate8; - const attributeName = getAttributeName(dialogElement); - (_this$delegate8 = this.delegate) === null || _this$delegate8 === void 0 || _this$delegate8.toolbarDidRemoveAttribute(attributeName); - return this.hideDialog(); - } - hideDialog() { - const element = this.element.querySelector(activeDialogSelector); - if (element) { - var _this$delegate9; - element.removeAttribute("data-trix-active"); - element.classList.remove("trix-active"); - this.resetDialogInputs(); - return (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 ? void 0 : _this$delegate9.toolbarDidHideDialog(getDialogName(element)); - } - } - resetDialogInputs() { - Array.from(this.element.querySelectorAll(dialogInputSelector)).forEach(input => { - input.setAttribute("disabled", "disabled"); - input.removeAttribute("data-trix-validate"); - input.classList.remove("trix-validate"); - }); - } - getDialog(dialogName) { - return this.element.querySelector("[data-trix-dialog=".concat(dialogName, "]")); - } - } - - const snapshotsAreEqual = (a, b) => rangesAreEqual(a.selectedRange, b.selectedRange) && a.document.isEqualTo(b.document); - class EditorController extends Controller { - constructor(_ref) { - let { - editorElement, - document, - html - } = _ref; - super(...arguments); - this.editorElement = editorElement; - this.selectionManager = new SelectionManager(this.editorElement); - this.selectionManager.delegate = this; - this.composition = new Composition(); - this.composition.delegate = this; - this.attachmentManager = new AttachmentManager(this.composition.getAttachments()); - this.attachmentManager.delegate = this; - this.inputController = input.getLevel() === 2 ? new Level2InputController(this.editorElement) : new Level0InputController(this.editorElement); - this.inputController.delegate = this; - this.inputController.responder = this.composition; - this.compositionController = new CompositionController(this.editorElement, this.composition); - this.compositionController.delegate = this; - this.toolbarController = new ToolbarController(this.editorElement.toolbarElement); - this.toolbarController.delegate = this; - this.editor = new Editor(this.composition, this.selectionManager, this.editorElement); - if (document) { - this.editor.loadDocument(document); - } else { - this.editor.loadHTML(html); - } - } - registerSelectionManager() { - return selectionChangeObserver.registerSelectionManager(this.selectionManager); - } - unregisterSelectionManager() { - return selectionChangeObserver.unregisterSelectionManager(this.selectionManager); - } - render() { - return this.compositionController.render(); - } - reparse() { - return this.composition.replaceHTML(this.editorElement.innerHTML); - } - - // Composition delegate - - compositionDidChangeDocument(document) { - this.notifyEditorElement("document-change"); - if (!this.handlingInput) { - return this.render(); - } - } - compositionDidChangeCurrentAttributes(currentAttributes) { - this.currentAttributes = currentAttributes; - this.toolbarController.updateAttributes(this.currentAttributes); - this.updateCurrentActions(); - return this.notifyEditorElement("attributes-change", { - attributes: this.currentAttributes - }); - } - compositionDidPerformInsertionAtRange(range) { - if (this.pasting) { - this.pastedRange = range; - } - } - compositionShouldAcceptFile(file) { - return this.notifyEditorElement("file-accept", { - file - }); - } - compositionDidAddAttachment(attachment) { - const managedAttachment = this.attachmentManager.manageAttachment(attachment); - return this.notifyEditorElement("attachment-add", { - attachment: managedAttachment - }); - } - compositionDidEditAttachment(attachment) { - this.compositionController.rerenderViewForObject(attachment); - const managedAttachment = this.attachmentManager.manageAttachment(attachment); - this.notifyEditorElement("attachment-edit", { - attachment: managedAttachment - }); - return this.notifyEditorElement("change"); - } - compositionDidChangeAttachmentPreviewURL(attachment) { - this.compositionController.invalidateViewForObject(attachment); - return this.notifyEditorElement("change"); - } - compositionDidRemoveAttachment(attachment) { - const managedAttachment = this.attachmentManager.unmanageAttachment(attachment); - return this.notifyEditorElement("attachment-remove", { - attachment: managedAttachment - }); - } - compositionDidStartEditingAttachment(attachment, options) { - this.attachmentLocationRange = this.composition.document.getLocationRangeOfAttachment(attachment); - this.compositionController.installAttachmentEditorForAttachment(attachment, options); - return this.selectionManager.setLocationRange(this.attachmentLocationRange); - } - compositionDidStopEditingAttachment(attachment) { - this.compositionController.uninstallAttachmentEditor(); - this.attachmentLocationRange = null; - } - compositionDidRequestChangingSelectionToLocationRange(locationRange) { - if (this.loadingSnapshot && !this.isFocused()) return; - this.requestedLocationRange = locationRange; - this.compositionRevisionWhenLocationRangeRequested = this.composition.revision; - if (!this.handlingInput) { - return this.render(); - } - } - compositionWillLoadSnapshot() { - this.loadingSnapshot = true; - } - compositionDidLoadSnapshot() { - this.compositionController.refreshViewCache(); - this.render(); - this.loadingSnapshot = false; - } - getSelectionManager() { - return this.selectionManager; - } - - // Attachment manager delegate - - attachmentManagerDidRequestRemovalOfAttachment(attachment) { - return this.removeAttachment(attachment); - } - - // Document controller delegate - - compositionControllerWillSyncDocumentView() { - this.inputController.editorWillSyncDocumentView(); - this.selectionManager.lock(); - return this.selectionManager.clearSelection(); - } - compositionControllerDidSyncDocumentView() { - this.inputController.editorDidSyncDocumentView(); - this.selectionManager.unlock(); - this.updateCurrentActions(); - return this.notifyEditorElement("sync"); - } - compositionControllerDidRender() { - if (this.requestedLocationRange) { - if (this.compositionRevisionWhenLocationRangeRequested === this.composition.revision) { - this.selectionManager.setLocationRange(this.requestedLocationRange); - } - this.requestedLocationRange = null; - this.compositionRevisionWhenLocationRangeRequested = null; - } - if (this.renderedCompositionRevision !== this.composition.revision) { - this.runEditorFilters(); - this.composition.updateCurrentAttributes(); - this.notifyEditorElement("render"); - } - this.renderedCompositionRevision = this.composition.revision; - } - compositionControllerDidFocus() { - if (this.isFocusedInvisibly()) { - this.setLocationRange({ - index: 0, - offset: 0 - }); - } - this.toolbarController.hideDialog(); - return this.notifyEditorElement("focus"); - } - compositionControllerDidBlur() { - return this.notifyEditorElement("blur"); - } - compositionControllerDidSelectAttachment(attachment, options) { - this.toolbarController.hideDialog(); - return this.composition.editAttachment(attachment, options); - } - compositionControllerDidRequestDeselectingAttachment(attachment) { - const locationRange = this.attachmentLocationRange || this.composition.document.getLocationRangeOfAttachment(attachment); - return this.selectionManager.setLocationRange(locationRange[1]); - } - compositionControllerWillUpdateAttachment(attachment) { - return this.editor.recordUndoEntry("Edit Attachment", { - context: attachment.id, - consolidatable: true - }); - } - compositionControllerDidRequestRemovalOfAttachment(attachment) { - return this.removeAttachment(attachment); - } - - // Input controller delegate - - inputControllerWillHandleInput() { - this.handlingInput = true; - this.requestedRender = false; - } - inputControllerDidRequestRender() { - this.requestedRender = true; - } - inputControllerDidHandleInput() { - this.handlingInput = false; - if (this.requestedRender) { - this.requestedRender = false; - return this.render(); - } - } - inputControllerDidAllowUnhandledInput() { - return this.notifyEditorElement("change"); - } - inputControllerDidRequestReparse() { - return this.reparse(); - } - inputControllerWillPerformTyping() { - return this.recordTypingUndoEntry(); - } - inputControllerWillPerformFormatting(attributeName) { - return this.recordFormattingUndoEntry(attributeName); - } - inputControllerWillCutText() { - return this.editor.recordUndoEntry("Cut"); - } - inputControllerWillPaste(paste) { - this.editor.recordUndoEntry("Paste"); - this.pasting = true; - return this.notifyEditorElement("before-paste", { - paste - }); - } - inputControllerDidPaste(paste) { - paste.range = this.pastedRange; - this.pastedRange = null; - this.pasting = null; - return this.notifyEditorElement("paste", { - paste - }); - } - inputControllerWillMoveText() { - return this.editor.recordUndoEntry("Move"); - } - inputControllerWillAttachFiles() { - return this.editor.recordUndoEntry("Drop Files"); - } - inputControllerWillPerformUndo() { - return this.editor.undo(); - } - inputControllerWillPerformRedo() { - return this.editor.redo(); - } - inputControllerDidReceiveKeyboardCommand(keys) { - return this.toolbarController.applyKeyboardCommand(keys); - } - inputControllerDidStartDrag() { - this.locationRangeBeforeDrag = this.selectionManager.getLocationRange(); - } - inputControllerDidReceiveDragOverPoint(point) { - return this.selectionManager.setLocationRangeFromPointRange(point); - } - inputControllerDidCancelDrag() { - this.selectionManager.setLocationRange(this.locationRangeBeforeDrag); - this.locationRangeBeforeDrag = null; - } - - // Selection manager delegate - - locationRangeDidChange(locationRange) { - this.composition.updateCurrentAttributes(); - this.updateCurrentActions(); - if (this.attachmentLocationRange && !rangesAreEqual(this.attachmentLocationRange, locationRange)) { - this.composition.stopEditingAttachment(); - } - return this.notifyEditorElement("selection-change"); - } - - // Toolbar controller delegate - - toolbarDidClickButton() { - if (!this.getLocationRange()) { - return this.setLocationRange({ - index: 0, - offset: 0 - }); - } - } - toolbarDidInvokeAction(actionName, invokingElement) { - return this.invokeAction(actionName, invokingElement); - } - toolbarDidToggleAttribute(attributeName) { - this.recordFormattingUndoEntry(attributeName); - this.composition.toggleCurrentAttribute(attributeName); - this.render(); - if (!this.selectionFrozen) { - return this.editorElement.focus(); - } - } - toolbarDidUpdateAttribute(attributeName, value) { - this.recordFormattingUndoEntry(attributeName); - this.composition.setCurrentAttribute(attributeName, value); - this.render(); - if (!this.selectionFrozen) { - return this.editorElement.focus(); - } - } - toolbarDidRemoveAttribute(attributeName) { - this.recordFormattingUndoEntry(attributeName); - this.composition.removeCurrentAttribute(attributeName); - this.render(); - if (!this.selectionFrozen) { - return this.editorElement.focus(); - } - } - toolbarWillShowDialog(dialogElement) { - this.composition.expandSelectionForEditing(); - return this.freezeSelection(); - } - toolbarDidShowDialog(dialogName) { - return this.notifyEditorElement("toolbar-dialog-show", { - dialogName - }); - } - toolbarDidHideDialog(dialogName) { - this.thawSelection(); - this.editorElement.focus(); - return this.notifyEditorElement("toolbar-dialog-hide", { - dialogName - }); - } - - // Selection - - freezeSelection() { - if (!this.selectionFrozen) { - this.selectionManager.lock(); - this.composition.freezeSelection(); - this.selectionFrozen = true; - return this.render(); - } - } - thawSelection() { - if (this.selectionFrozen) { - this.composition.thawSelection(); - this.selectionManager.unlock(); - this.selectionFrozen = false; - return this.render(); - } - } - canInvokeAction(actionName) { - if (this.actionIsExternal(actionName)) { - return true; - } else { - var _this$actions$actionN; - return !!((_this$actions$actionN = this.actions[actionName]) !== null && _this$actions$actionN !== void 0 && (_this$actions$actionN = _this$actions$actionN.test) !== null && _this$actions$actionN !== void 0 && _this$actions$actionN.call(this)); - } - } - invokeAction(actionName, invokingElement) { - if (this.actionIsExternal(actionName)) { - return this.notifyEditorElement("action-invoke", { - actionName, - invokingElement - }); - } else { - var _this$actions$actionN2; - return (_this$actions$actionN2 = this.actions[actionName]) === null || _this$actions$actionN2 === void 0 || (_this$actions$actionN2 = _this$actions$actionN2.perform) === null || _this$actions$actionN2 === void 0 ? void 0 : _this$actions$actionN2.call(this); - } - } - actionIsExternal(actionName) { - return /^x-./.test(actionName); - } - getCurrentActions() { - const result = {}; - for (const actionName in this.actions) { - result[actionName] = this.canInvokeAction(actionName); - } - return result; - } - updateCurrentActions() { - const currentActions = this.getCurrentActions(); - if (!objectsAreEqual(currentActions, this.currentActions)) { - this.currentActions = currentActions; - this.toolbarController.updateActions(this.currentActions); - return this.notifyEditorElement("actions-change", { - actions: this.currentActions - }); - } - } - - // Editor filters - - runEditorFilters() { - let snapshot = this.composition.getSnapshot(); - Array.from(this.editor.filters).forEach(filter => { - const { - document, - selectedRange - } = snapshot; - snapshot = filter.call(this.editor, snapshot) || {}; - if (!snapshot.document) { - snapshot.document = document; - } - if (!snapshot.selectedRange) { - snapshot.selectedRange = selectedRange; - } - }); - if (!snapshotsAreEqual(snapshot, this.composition.getSnapshot())) { - return this.composition.loadSnapshot(snapshot); - } - } - - // Private - - updateInputElement() { - const element = this.compositionController.getSerializableElement(); - const value = serializeToContentType(element, "text/html"); - return this.editorElement.setFormValue(value); - } - notifyEditorElement(message, data) { - switch (message) { - case "document-change": - this.documentChangedSinceLastRender = true; - break; - case "render": - if (this.documentChangedSinceLastRender) { - this.documentChangedSinceLastRender = false; - this.notifyEditorElement("change"); - } - break; - case "change": - case "attachment-add": - case "attachment-edit": - case "attachment-remove": - this.updateInputElement(); - break; - } - return this.editorElement.notify(message, data); - } - removeAttachment(attachment) { - this.editor.recordUndoEntry("Delete Attachment"); - this.composition.removeAttachment(attachment); - return this.render(); - } - recordFormattingUndoEntry(attributeName) { - const blockConfig = getBlockConfig(attributeName); - const locationRange = this.selectionManager.getLocationRange(); - if (blockConfig || !rangeIsCollapsed(locationRange)) { - return this.editor.recordUndoEntry("Formatting", { - context: this.getUndoContext(), - consolidatable: true - }); - } - } - recordTypingUndoEntry() { - return this.editor.recordUndoEntry("Typing", { - context: this.getUndoContext(this.currentAttributes), - consolidatable: true - }); - } - getUndoContext() { - for (var _len = arguments.length, context = new Array(_len), _key = 0; _key < _len; _key++) { - context[_key] = arguments[_key]; - } - return [this.getLocationContext(), this.getTimeContext(), ...Array.from(context)]; - } - getLocationContext() { - const locationRange = this.selectionManager.getLocationRange(); - if (rangeIsCollapsed(locationRange)) { - return locationRange[0].index; - } else { - return locationRange; - } - } - getTimeContext() { - if (undo.interval > 0) { - return Math.floor(new Date().getTime() / undo.interval); - } else { - return 0; - } - } - isFocused() { - var _this$editorElement$o; - return this.editorElement === ((_this$editorElement$o = this.editorElement.ownerDocument) === null || _this$editorElement$o === void 0 ? void 0 : _this$editorElement$o.activeElement); - } - - // Detect "Cursor disappears sporadically" Firefox bug. - // - https://bugzilla.mozilla.org/show_bug.cgi?id=226301 - isFocusedInvisibly() { - return this.isFocused() && !this.getLocationRange(); - } - get actions() { - return this.constructor.actions; - } - } - _defineProperty(EditorController, "actions", { - undo: { - test() { - return this.editor.canUndo(); - }, - perform() { - return this.editor.undo(); - } - }, - redo: { - test() { - return this.editor.canRedo(); - }, - perform() { - return this.editor.redo(); - } - }, - link: { - test() { - return this.editor.canActivateAttribute("href"); - } - }, - increaseNestingLevel: { - test() { - return this.editor.canIncreaseNestingLevel(); - }, - perform() { - return this.editor.increaseNestingLevel() && this.render(); - } - }, - decreaseNestingLevel: { - test() { - return this.editor.canDecreaseNestingLevel(); - }, - perform() { - return this.editor.decreaseNestingLevel() && this.render(); - } - }, - attachFiles: { - test() { - return true; - }, - perform() { - return input.pickFiles(this.editor.insertFiles); - } - } - }); - EditorController.proxyMethod("getSelectionManager().setLocationRange"); - EditorController.proxyMethod("getSelectionManager().getLocationRange"); - - var controllers = /*#__PURE__*/Object.freeze({ - __proto__: null, - AttachmentEditorController: AttachmentEditorController, - CompositionController: CompositionController, - Controller: Controller, - EditorController: EditorController, - InputController: InputController, - Level0InputController: Level0InputController, - Level2InputController: Level2InputController, - ToolbarController: ToolbarController - }); - - var observers = /*#__PURE__*/Object.freeze({ - __proto__: null, - MutationObserver: MutationObserver, - SelectionChangeObserver: SelectionChangeObserver - }); - - var operations = /*#__PURE__*/Object.freeze({ - __proto__: null, - FileVerificationOperation: FileVerificationOperation, - ImagePreloadOperation: ImagePreloadOperation - }); - - installDefaultCSSForTagName("trix-toolbar", "%t {\n display: block;\n}\n\n%t {\n white-space: nowrap;\n}\n\n%t [data-trix-dialog] {\n display: none;\n}\n\n%t [data-trix-dialog][data-trix-active] {\n display: block;\n}\n\n%t [data-trix-dialog] [data-trix-validate]:invalid {\n background-color: #ffdddd;\n}"); - class TrixToolbarElement extends HTMLElement { - // Element lifecycle - - connectedCallback() { - if (this.innerHTML === "") { - this.innerHTML = toolbar.getDefaultHTML(); - } - } - } - - let id = 0; - - // Contenteditable support helpers - - const autofocus = function (element) { - if (!document.querySelector(":focus")) { - if (element.hasAttribute("autofocus") && document.querySelector("[autofocus]") === element) { - return element.focus(); - } - } - }; - const makeEditable = function (element) { - if (element.hasAttribute("contenteditable")) { - return; - } - element.setAttribute("contenteditable", ""); - return handleEventOnce("focus", { - onElement: element, - withCallback() { - return configureContentEditable(element); - } - }); - }; - const configureContentEditable = function (element) { - disableObjectResizing(element); - return setDefaultParagraphSeparator(element); - }; - const disableObjectResizing = function (element) { - var _document$queryComman, _document; - if ((_document$queryComman = (_document = document).queryCommandSupported) !== null && _document$queryComman !== void 0 && _document$queryComman.call(_document, "enableObjectResizing")) { - document.execCommand("enableObjectResizing", false, false); - return handleEvent("mscontrolselect", { - onElement: element, - preventDefault: true - }); - } - }; - const setDefaultParagraphSeparator = function (element) { - var _document$queryComman2, _document2; - if ((_document$queryComman2 = (_document2 = document).queryCommandSupported) !== null && _document$queryComman2 !== void 0 && _document$queryComman2.call(_document2, "DefaultParagraphSeparator")) { - const { - tagName - } = attributes.default; - if (["div", "p"].includes(tagName)) { - return document.execCommand("DefaultParagraphSeparator", false, tagName); - } - } - }; - - // Accessibility helpers - - const addAccessibilityRole = function (element) { - if (element.hasAttribute("role")) { - return; - } - return element.setAttribute("role", "textbox"); - }; - const ensureAriaLabel = function (element) { - if (element.hasAttribute("aria-label") || element.hasAttribute("aria-labelledby")) { - return; - } - const update = function () { - const texts = Array.from(element.labels).map(label => { - if (!label.contains(element)) return label.textContent; - }).filter(text => text); - const text = texts.join(" "); - if (text) { - return element.setAttribute("aria-label", text); - } else { - return element.removeAttribute("aria-label"); - } - }; - update(); - return handleEvent("focus", { - onElement: element, - withCallback: update - }); - }; - - // Style - - const cursorTargetStyles = function () { - if (browser$1.forcesObjectResizing) { - return { - display: "inline", - width: "auto" - }; - } else { - return { - display: "inline-block", - width: "1px" - }; - } - }(); - installDefaultCSSForTagName("trix-editor", "%t {\n display: block;\n}\n\n%t:empty::before {\n content: attr(placeholder);\n color: graytext;\n cursor: text;\n pointer-events: none;\n white-space: pre-line;\n}\n\n%t a[contenteditable=false] {\n cursor: text;\n}\n\n%t img {\n max-width: 100%;\n height: auto;\n}\n\n%t ".concat(attachmentSelector, " figcaption textarea {\n resize: none;\n}\n\n%t ").concat(attachmentSelector, " figcaption textarea.trix-autoresize-clone {\n position: absolute;\n left: -9999px;\n max-height: 0px;\n}\n\n%t ").concat(attachmentSelector, " figcaption[data-trix-placeholder]:empty::before {\n content: attr(data-trix-placeholder);\n color: graytext;\n}\n\n%t [data-trix-cursor-target] {\n display: ").concat(cursorTargetStyles.display, " !important;\n width: ").concat(cursorTargetStyles.width, " !important;\n padding: 0 !important;\n margin: 0 !important;\n border: none !important;\n}\n\n%t [data-trix-cursor-target=left] {\n vertical-align: top !important;\n margin-left: -1px !important;\n}\n\n%t [data-trix-cursor-target=right] {\n vertical-align: bottom !important;\n margin-right: -1px !important;\n}")); - var _internals = /*#__PURE__*/new WeakMap(); - var _validate = /*#__PURE__*/new WeakSet(); - class ElementInternalsDelegate { - constructor(element) { - _classPrivateMethodInitSpec(this, _validate); - _classPrivateFieldInitSpec(this, _internals, { - writable: true, - value: void 0 - }); - this.element = element; - _classPrivateFieldSet(this, _internals, element.attachInternals()); - } - connectedCallback() { - _classPrivateMethodGet(this, _validate, _validate2).call(this); - } - disconnectedCallback() {} - get labels() { - return _classPrivateFieldGet(this, _internals).labels; - } - get disabled() { - var _this$element$inputEl; - return (_this$element$inputEl = this.element.inputElement) === null || _this$element$inputEl === void 0 ? void 0 : _this$element$inputEl.disabled; - } - set disabled(value) { - this.element.toggleAttribute("disabled", value); - } - get required() { - return this.element.hasAttribute("required"); - } - set required(value) { - this.element.toggleAttribute("required", value); - _classPrivateMethodGet(this, _validate, _validate2).call(this); - } - get validity() { - return _classPrivateFieldGet(this, _internals).validity; - } - get validationMessage() { - return _classPrivateFieldGet(this, _internals).validationMessage; - } - get willValidate() { - return _classPrivateFieldGet(this, _internals).willValidate; - } - setFormValue(value) { - _classPrivateMethodGet(this, _validate, _validate2).call(this); - } - checkValidity() { - return _classPrivateFieldGet(this, _internals).checkValidity(); - } - reportValidity() { - return _classPrivateFieldGet(this, _internals).reportValidity(); - } - setCustomValidity(validationMessage) { - _classPrivateMethodGet(this, _validate, _validate2).call(this, validationMessage); - } - } - function _validate2() { - let customValidationMessage = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - const { - required, - value - } = this.element; - const valueMissing = required && !value; - const customError = !!customValidationMessage; - const input = makeElement("input", { - required - }); - const validationMessage = customValidationMessage || input.validationMessage; - _classPrivateFieldGet(this, _internals).setValidity({ - valueMissing, - customError - }, validationMessage); - } - var _focusHandler = /*#__PURE__*/new WeakMap(); - var _resetBubbled = /*#__PURE__*/new WeakMap(); - var _clickBubbled = /*#__PURE__*/new WeakMap(); - class LegacyDelegate { - constructor(element) { - _classPrivateFieldInitSpec(this, _focusHandler, { - writable: true, - value: void 0 - }); - _classPrivateFieldInitSpec(this, _resetBubbled, { - writable: true, - value: event => { - if (event.defaultPrevented) return; - if (event.target !== this.element.form) return; - this.element.reset(); - } - }); - _classPrivateFieldInitSpec(this, _clickBubbled, { - writable: true, - value: event => { - if (event.defaultPrevented) return; - if (this.element.contains(event.target)) return; - const label = findClosestElementFromNode(event.target, { - matchingSelector: "label" - }); - if (!label) return; - if (!Array.from(this.labels).includes(label)) return; - this.element.focus(); - } - }); - this.element = element; - } - connectedCallback() { - _classPrivateFieldSet(this, _focusHandler, ensureAriaLabel(this.element)); - window.addEventListener("reset", _classPrivateFieldGet(this, _resetBubbled), false); - window.addEventListener("click", _classPrivateFieldGet(this, _clickBubbled), false); - } - disconnectedCallback() { - var _classPrivateFieldGet2; - (_classPrivateFieldGet2 = _classPrivateFieldGet(this, _focusHandler)) === null || _classPrivateFieldGet2 === void 0 || _classPrivateFieldGet2.destroy(); - window.removeEventListener("reset", _classPrivateFieldGet(this, _resetBubbled), false); - window.removeEventListener("click", _classPrivateFieldGet(this, _clickBubbled), false); - } - get labels() { - const labels = []; - if (this.element.id && this.element.ownerDocument) { - labels.push(...Array.from(this.element.ownerDocument.querySelectorAll("label[for='".concat(this.element.id, "']")) || [])); - } - const label = findClosestElementFromNode(this.element, { - matchingSelector: "label" - }); - if (label) { - if ([this.element, null].includes(label.control)) { - labels.push(label); - } - } - return labels; - } - get disabled() { - console.warn("This browser does not support the [disabled] attribute for trix-editor elements."); - return false; - } - set disabled(value) { - console.warn("This browser does not support the [disabled] attribute for trix-editor elements."); - } - get required() { - console.warn("This browser does not support the [required] attribute for trix-editor elements."); - return false; - } - set required(value) { - console.warn("This browser does not support the [required] attribute for trix-editor elements."); - } - get validity() { - console.warn("This browser does not support the validity property for trix-editor elements."); - return null; - } - get validationMessage() { - console.warn("This browser does not support the validationMessage property for trix-editor elements."); - return ""; - } - get willValidate() { - console.warn("This browser does not support the willValidate property for trix-editor elements."); - return false; - } - setFormValue(value) {} - checkValidity() { - console.warn("This browser does not support checkValidity() for trix-editor elements."); - return true; - } - reportValidity() { - console.warn("This browser does not support reportValidity() for trix-editor elements."); - return true; - } - setCustomValidity(validationMessage) { - console.warn("This browser does not support setCustomValidity(validationMessage) for trix-editor elements."); - } - } - var _delegate = /*#__PURE__*/new WeakMap(); - class TrixEditorElement extends HTMLElement { - constructor() { - super(); - _classPrivateFieldInitSpec(this, _delegate, { - writable: true, - value: void 0 - }); - _classPrivateFieldSet(this, _delegate, this.constructor.formAssociated ? new ElementInternalsDelegate(this) : new LegacyDelegate(this)); - } - - // Properties - - get trixId() { - if (this.hasAttribute("trix-id")) { - return this.getAttribute("trix-id"); - } else { - this.setAttribute("trix-id", ++id); - return this.trixId; - } - } - get labels() { - return _classPrivateFieldGet(this, _delegate).labels; - } - get disabled() { - return _classPrivateFieldGet(this, _delegate).disabled; - } - set disabled(value) { - _classPrivateFieldGet(this, _delegate).disabled = value; - } - get required() { - return _classPrivateFieldGet(this, _delegate).required; - } - set required(value) { - _classPrivateFieldGet(this, _delegate).required = value; - } - get validity() { - return _classPrivateFieldGet(this, _delegate).validity; - } - get validationMessage() { - return _classPrivateFieldGet(this, _delegate).validationMessage; - } - get willValidate() { - return _classPrivateFieldGet(this, _delegate).willValidate; - } - get type() { - return this.localName; - } - get toolbarElement() { - if (this.hasAttribute("toolbar")) { - var _this$ownerDocument; - return (_this$ownerDocument = this.ownerDocument) === null || _this$ownerDocument === void 0 ? void 0 : _this$ownerDocument.getElementById(this.getAttribute("toolbar")); - } else if (this.parentNode) { - const toolbarId = "trix-toolbar-".concat(this.trixId); - this.setAttribute("toolbar", toolbarId); - const element = makeElement("trix-toolbar", { - id: toolbarId - }); - this.parentNode.insertBefore(element, this); - return element; - } else { - return undefined; - } - } - get form() { - var _this$inputElement; - return (_this$inputElement = this.inputElement) === null || _this$inputElement === void 0 ? void 0 : _this$inputElement.form; - } - get inputElement() { - if (this.hasAttribute("input")) { - var _this$ownerDocument2; - return (_this$ownerDocument2 = this.ownerDocument) === null || _this$ownerDocument2 === void 0 ? void 0 : _this$ownerDocument2.getElementById(this.getAttribute("input")); - } else if (this.parentNode) { - const inputId = "trix-input-".concat(this.trixId); - this.setAttribute("input", inputId); - const element = makeElement("input", { - type: "hidden", - id: inputId - }); - this.parentNode.insertBefore(element, this.nextElementSibling); - return element; - } else { - return undefined; - } - } - get editor() { - var _this$editorControlle; - return (_this$editorControlle = this.editorController) === null || _this$editorControlle === void 0 ? void 0 : _this$editorControlle.editor; - } - get name() { - var _this$inputElement2; - return (_this$inputElement2 = this.inputElement) === null || _this$inputElement2 === void 0 ? void 0 : _this$inputElement2.name; - } - get value() { - var _this$inputElement3; - return (_this$inputElement3 = this.inputElement) === null || _this$inputElement3 === void 0 ? void 0 : _this$inputElement3.value; - } - set value(defaultValue) { - var _this$editor; - this.defaultValue = defaultValue; - (_this$editor = this.editor) === null || _this$editor === void 0 || _this$editor.loadHTML(this.defaultValue); - } - - // Controller delegate methods - - notify(message, data) { - if (this.editorController) { - return triggerEvent("trix-".concat(message), { - onElement: this, - attributes: data - }); - } - } - setFormValue(value) { - if (this.inputElement) { - this.inputElement.value = value; - _classPrivateFieldGet(this, _delegate).setFormValue(value); - } - } - - // Element lifecycle - - connectedCallback() { - if (!this.hasAttribute("data-trix-internal")) { - makeEditable(this); - addAccessibilityRole(this); - if (!this.editorController) { - triggerEvent("trix-before-initialize", { - onElement: this - }); - this.editorController = new EditorController({ - editorElement: this, - html: this.defaultValue = this.value - }); - requestAnimationFrame(() => triggerEvent("trix-initialize", { - onElement: this - })); - } - this.editorController.registerSelectionManager(); - _classPrivateFieldGet(this, _delegate).connectedCallback(); - autofocus(this); - } - } - disconnectedCallback() { - var _this$editorControlle2; - (_this$editorControlle2 = this.editorController) === null || _this$editorControlle2 === void 0 || _this$editorControlle2.unregisterSelectionManager(); - _classPrivateFieldGet(this, _delegate).disconnectedCallback(); - } - - // Form support - - checkValidity() { - return _classPrivateFieldGet(this, _delegate).checkValidity(); - } - reportValidity() { - return _classPrivateFieldGet(this, _delegate).reportValidity(); - } - setCustomValidity(validationMessage) { - _classPrivateFieldGet(this, _delegate).setCustomValidity(validationMessage); - } - formDisabledCallback(disabled) { - if (this.inputElement) { - this.inputElement.disabled = disabled; - } - this.toggleAttribute("contenteditable", !disabled); - } - formResetCallback() { - this.reset(); - } - reset() { - this.value = this.defaultValue; - } - } - _defineProperty(TrixEditorElement, "formAssociated", "ElementInternals" in window); - - var elements = /*#__PURE__*/Object.freeze({ - __proto__: null, - TrixEditorElement: TrixEditorElement, - TrixToolbarElement: TrixToolbarElement - }); - - var filters = /*#__PURE__*/Object.freeze({ - __proto__: null, - Filter: Filter, - attachmentGalleryFilter: attachmentGalleryFilter - }); - - const Trix = { - VERSION: version, - config, - core, - models, - views, - controllers, - observers, - operations, - elements, - filters - }; - - // Expose models under the Trix constant for compatibility with v1 - Object.assign(Trix, models); - function start() { - if (!customElements.get("trix-toolbar")) { - customElements.define("trix-toolbar", TrixToolbarElement); - } - if (!customElements.get("trix-editor")) { - customElements.define("trix-editor", TrixEditorElement); - } - } - window.Trix = Trix; - setTimeout(start, 0); - - return Trix; - -})); diff --git a/actiontext/app/assets/stylesheets/trix.css b/actiontext/app/assets/stylesheets/trix.css deleted file mode 100644 index 84da0eafcfefc..0000000000000 --- a/actiontext/app/assets/stylesheets/trix.css +++ /dev/null @@ -1,470 +0,0 @@ -@charset "UTF-8"; -trix-editor { - border: 1px solid #bbb; - border-radius: 3px; - margin: 0; - padding: 0.4em 0.6em; - min-height: 5em; - outline: none; -} - -trix-toolbar * { - box-sizing: border-box; -} -trix-toolbar .trix-button-row { - display: flex; - flex-wrap: nowrap; - justify-content: space-between; - overflow-x: auto; -} -trix-toolbar .trix-button-group { - display: flex; - margin-bottom: 10px; - border: 1px solid #bbb; - border-top-color: #ccc; - border-bottom-color: #888; - border-radius: 3px; -} -trix-toolbar .trix-button-group:not(:first-child) { - margin-left: 1.5vw; -} -@media (max-width: 768px) { - trix-toolbar .trix-button-group:not(:first-child) { - margin-left: 0; - } -} -trix-toolbar .trix-button-group-spacer { - flex-grow: 1; -} -@media (max-width: 768px) { - trix-toolbar .trix-button-group-spacer { - display: none; - } -} -trix-toolbar .trix-button { - position: relative; - float: left; - color: rgba(0, 0, 0, 0.6); - font-size: 0.75em; - font-weight: 600; - white-space: nowrap; - padding: 0 0.5em; - margin: 0; - outline: none; - border: none; - border-bottom: 1px solid #ddd; - border-radius: 0; - background: transparent; -} -trix-toolbar .trix-button:not(:first-child) { - border-left: 1px solid #ccc; -} -trix-toolbar .trix-button.trix-active { - background: #cbeefa; - color: rgb(0, 0, 0); -} -trix-toolbar .trix-button:not(:disabled) { - cursor: pointer; -} -trix-toolbar .trix-button:disabled { - color: rgba(0, 0, 0, 0.125); -} -@media (max-width: 768px) { - trix-toolbar .trix-button { - letter-spacing: -0.01em; - padding: 0 0.3em; - } -} -trix-toolbar .trix-button--icon { - font-size: inherit; - width: 2.6em; - height: 1.6em; - max-width: calc(0.8em + 4vw); - text-indent: -9999px; -} -@media (max-width: 768px) { - trix-toolbar .trix-button--icon { - height: 2em; - max-width: calc(0.8em + 3.5vw); - } -} -trix-toolbar .trix-button--icon::before { - display: inline-block; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - opacity: 0.6; - content: ""; - background-position: center; - background-repeat: no-repeat; - background-size: contain; -} -@media (max-width: 768px) { - trix-toolbar .trix-button--icon::before { - right: 6%; - left: 6%; - } -} -trix-toolbar .trix-button--icon.trix-active::before { - opacity: 1; -} -trix-toolbar .trix-button--icon:disabled::before { - opacity: 0.125; -} -trix-toolbar .trix-button--icon-attach::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M10.5%2018V7.5c0-2.25%203-2.25%203%200V18c0%204.125-6%204.125-6%200V7.5c0-6.375%209-6.375%209%200V18%22%20stroke%3D%22%23000%22%20stroke-width%3D%222%22%20stroke-miterlimit%3D%2210%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E"); - top: 8%; - bottom: 4%; -} -trix-toolbar .trix-button--icon-bold::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6.522%2019.242a.5.5%200%200%201-.5-.5V5.35a.5.5%200%200%201%20.5-.5h5.783c1.347%200%202.46.345%203.24.982.783.64%201.216%201.562%201.216%202.683%200%201.13-.587%202.129-1.476%202.71a.35.35%200%200%200%20.049.613c1.259.56%202.101%201.742%202.101%203.22%200%201.282-.483%202.334-1.363%203.063-.876.726-2.132%201.12-3.66%201.12h-5.89ZM9.27%207.347v3.362h1.97c.766%200%201.347-.17%201.733-.464.38-.291.587-.716.587-1.27%200-.53-.183-.928-.513-1.198-.334-.273-.838-.43-1.505-.43H9.27Zm0%205.606v3.791h2.389c.832%200%201.448-.177%201.853-.497.399-.315.614-.786.614-1.423%200-.62-.22-1.077-.63-1.385-.418-.313-1.053-.486-1.905-.486H9.27Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-italic::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M9%205h6.5v2h-2.23l-2.31%2010H13v2H6v-2h2.461l2.306-10H9V5Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-link::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M18.948%205.258a4.337%204.337%200%200%200-6.108%200L11.217%206.87a.993.993%200%200%200%200%201.41c.392.39%201.027.39%201.418%200l1.623-1.613a2.323%202.323%200%200%201%203.271%200%202.29%202.29%200%200%201%200%203.251l-2.393%202.38a3.021%203.021%200%200%201-4.255%200l-.05-.049a1.007%201.007%200%200%200-1.418%200%20.993.993%200%200%200%200%201.41l.05.049a5.036%205.036%200%200%200%207.091%200l2.394-2.38a4.275%204.275%200%200%200%200-6.072Zm-13.683%2013.6a4.337%204.337%200%200%200%206.108%200l1.262-1.255a.993.993%200%200%200%200-1.41%201.007%201.007%200%200%200-1.418%200L9.954%2017.45a2.323%202.323%200%200%201-3.27%200%202.29%202.29%200%200%201%200-3.251l2.344-2.331a2.579%202.579%200%200%201%203.631%200c.392.39%201.027.39%201.419%200a.993.993%200%200%200%200-1.41%204.593%204.593%200%200%200-6.468%200l-2.345%202.33a4.275%204.275%200%200%200%200%206.072Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-strike::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6%2014.986c.088%202.647%202.246%204.258%205.635%204.258%203.496%200%205.713-1.728%205.713-4.463%200-.275-.02-.536-.062-.781h-3.461c.398.293.573.654.573%201.123%200%201.035-1.074%201.787-2.646%201.787-1.563%200-2.773-.762-2.91-1.924H6ZM6.432%2010h3.763c-.632-.314-.914-.715-.914-1.273%200-1.045.977-1.739%202.432-1.739%201.475%200%202.52.723%202.617%201.914h2.764c-.05-2.548-2.11-4.238-5.39-4.238-3.145%200-5.392%201.719-5.392%204.316%200%20.363.04.703.12%201.02ZM4%2011a1%201%200%201%200%200%202h15a1%201%200%201%200%200-2H4Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-quote::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M4.581%208.471c.44-.5%201.056-.834%201.758-.995C8.074%207.17%209.201%207.822%2010%208.752c1.354%201.578%201.33%203.555.394%205.277-.941%201.731-2.788%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.121-.49.16-.764.294-.286.567-.566.791-.835.222-.266.413-.54.524-.815.113-.28.156-.597.026-.908-.128-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.674-2.7c0-.905.283-1.59.72-2.088Zm9.419%200c.44-.5%201.055-.834%201.758-.995%201.734-.306%202.862.346%203.66%201.276%201.355%201.578%201.33%203.555.395%205.277-.941%201.731-2.789%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.122-.49.16-.764.294-.286.567-.566.791-.835.222-.266.412-.54.523-.815.114-.28.157-.597.026-.908-.127-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.672-2.701c0-.905.283-1.59.72-2.088Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-heading-1::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.5%207.5v-3h-12v3H14v13h3v-13h4.5ZM9%2013.5h3.5v-3h-10v3H6v7h3v-7Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-code::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3.293%2011.293a1%201%200%200%200%200%201.414l4%204a1%201%200%201%200%201.414-1.414L5.414%2012l3.293-3.293a1%201%200%200%200-1.414-1.414l-4%204Zm13.414%205.414%204-4a1%201%200%200%200%200-1.414l-4-4a1%201%200%201%200-1.414%201.414L18.586%2012l-3.293%203.293a1%201%200%200%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-bullet-list::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%207.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203ZM8%206a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-2.5-5a1.5%201.5%200%201%201-3%200%201.5%201.5%200%200%201%203%200ZM5%2019.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-number-list::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%204h2v4H4V5H3V4Zm5%202a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-3.5-7H6v1l-1.5%202H6v1H3v-1l1.667-2H3v-1h2.5ZM3%2017v-1h3v4H3v-1h2v-.5H4v-1h1V17H3Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-undo::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%2014a1%201%200%200%200%201%201h6a1%201%200%201%200%200-2H6.257c2.247-2.764%205.151-3.668%207.579-3.264%202.589.432%204.739%202.356%205.174%205.405a1%201%200%200%200%201.98-.283c-.564-3.95-3.415-6.526-6.825-7.095C11.084%207.25%207.63%208.377%205%2011.39V8a1%201%200%200%200-2%200v6Zm2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-redo::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21%2014a1%201%200%200%201-1%201h-6a1%201%200%201%201%200-2h3.743c-2.247-2.764-5.151-3.668-7.579-3.264-2.589.432-4.739%202.356-5.174%205.405a1%201%200%200%201-1.98-.283c.564-3.95%203.415-6.526%206.826-7.095%203.08-.513%206.534.614%209.164%203.626V8a1%201%200%201%201%202%200v6Zm-2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-decrease-nesting-level::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-3.707-5.707a1%201%200%200%200%200%201.414l2%202a1%201%200%201%200%201.414-1.414L4.414%2012l1.293-1.293a1%201%200%200%200-1.414-1.414l-2%202Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-increase-nesting-level::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-2.293-2.293%202-2a1%201%200%200%200%200-1.414l-2-2a1%201%200%201%200-1.414%201.414L3.586%2012l-1.293%201.293a1%201%200%201%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-dialogs { - position: relative; -} -trix-toolbar .trix-dialog { - position: absolute; - top: 0; - left: 0; - right: 0; - font-size: 0.75em; - padding: 15px 10px; - background: #fff; - box-shadow: 0 0.3em 1em #ccc; - border-top: 2px solid #888; - border-radius: 5px; - z-index: 5; -} -trix-toolbar .trix-input--dialog { - font-size: inherit; - font-weight: normal; - padding: 0.5em 0.8em; - margin: 0 10px 0 0; - border-radius: 3px; - border: 1px solid #bbb; - background-color: #fff; - box-shadow: none; - outline: none; - -webkit-appearance: none; - -moz-appearance: none; -} -trix-toolbar .trix-input--dialog.validate:invalid { - box-shadow: #F00 0px 0px 1.5px 1px; -} -trix-toolbar .trix-button--dialog { - font-size: inherit; - padding: 0.5em; - border-bottom: none; -} -trix-toolbar .trix-dialog--link { - max-width: 600px; -} -trix-toolbar .trix-dialog__link-fields { - display: flex; - align-items: baseline; -} -trix-toolbar .trix-dialog__link-fields .trix-input { - flex: 1; -} -trix-toolbar .trix-dialog__link-fields .trix-button-group { - flex: 0 0 content; - margin: 0; -} - -trix-editor [data-trix-mutable]:not(.attachment__caption-editor) { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -trix-editor [data-trix-mutable] ::-moz-selection, trix-editor [data-trix-mutable]::-moz-selection, -trix-editor [data-trix-cursor-target]::-moz-selection { - background: none; -} -trix-editor [data-trix-mutable] ::selection, trix-editor [data-trix-mutable]::selection, -trix-editor [data-trix-cursor-target]::selection { - background: none; -} - -trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection { - background: highlight; -} -trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection { - background: highlight; -} - -trix-editor [data-trix-mutable].attachment.attachment--file { - box-shadow: 0 0 0 2px highlight; - border-color: transparent; -} -trix-editor [data-trix-mutable].attachment img { - box-shadow: 0 0 0 2px highlight; -} -trix-editor .attachment { - position: relative; -} -trix-editor .attachment:hover { - cursor: default; -} -trix-editor .attachment--preview .attachment__caption:hover { - cursor: text; -} -trix-editor .attachment__progress { - position: absolute; - z-index: 1; - height: 20px; - top: calc(50% - 10px); - left: 5%; - width: 90%; - opacity: 0.9; - transition: opacity 200ms ease-in; -} -trix-editor .attachment__progress[value="100"] { - opacity: 0; -} -trix-editor .attachment__caption-editor { - display: inline-block; - width: 100%; - margin: 0; - padding: 0; - font-size: inherit; - font-family: inherit; - line-height: inherit; - color: inherit; - text-align: center; - vertical-align: top; - border: none; - outline: none; - -webkit-appearance: none; - -moz-appearance: none; -} -trix-editor .attachment__toolbar { - position: absolute; - z-index: 1; - top: -0.9em; - left: 0; - width: 100%; - text-align: center; -} -trix-editor .trix-button-group { - display: inline-flex; -} -trix-editor .trix-button { - position: relative; - float: left; - color: #666; - white-space: nowrap; - font-size: 80%; - padding: 0 0.8em; - margin: 0; - outline: none; - border: none; - border-radius: 0; - background: transparent; -} -trix-editor .trix-button:not(:first-child) { - border-left: 1px solid #ccc; -} -trix-editor .trix-button.trix-active { - background: #cbeefa; -} -trix-editor .trix-button:not(:disabled) { - cursor: pointer; -} -trix-editor .trix-button--remove { - text-indent: -9999px; - display: inline-block; - padding: 0; - outline: none; - width: 1.8em; - height: 1.8em; - line-height: 1.8em; - border-radius: 50%; - background-color: #fff; - border: 2px solid highlight; - box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25); -} -trix-editor .trix-button--remove::before { - display: inline-block; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - opacity: 0.7; - content: ""; - background-image: url("data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.41%2017.59%205%2012%2010.59%206.41%205%205%206.41%2010.59%2012%205%2017.59%206.41%2019%2012%2013.41%2017.59%2019%2019%2017.59%2013.41%2012z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E"); - background-position: center; - background-repeat: no-repeat; - background-size: 90%; -} -trix-editor .trix-button--remove:hover { - border-color: #333; -} -trix-editor .trix-button--remove:hover::before { - opacity: 1; -} -trix-editor .attachment__metadata-container { - position: relative; -} -trix-editor .attachment__metadata { - position: absolute; - left: 50%; - top: 2em; - transform: translate(-50%, 0); - max-width: 90%; - padding: 0.1em 0.6em; - font-size: 0.8em; - color: #fff; - background-color: rgba(0, 0, 0, 0.7); - border-radius: 3px; -} -trix-editor .attachment__metadata .attachment__name { - display: inline-block; - max-width: 100%; - vertical-align: bottom; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -trix-editor .attachment__metadata .attachment__size { - margin-left: 0.2em; - white-space: nowrap; -} - -.trix-content { - line-height: 1.5; - overflow-wrap: break-word; - word-break: break-word; -} -.trix-content * { - box-sizing: border-box; - margin: 0; - padding: 0; -} -.trix-content h1 { - font-size: 1.2em; - line-height: 1.2; -} -.trix-content blockquote { - border: 0 solid #ccc; - border-left-width: 0.3em; - margin-left: 0.3em; - padding-left: 0.6em; -} -.trix-content [dir=rtl] blockquote, -.trix-content blockquote[dir=rtl] { - border-width: 0; - border-right-width: 0.3em; - margin-right: 0.3em; - padding-right: 0.6em; -} -.trix-content li { - margin-left: 1em; -} -.trix-content [dir=rtl] li { - margin-right: 1em; -} -.trix-content pre { - display: inline-block; - width: 100%; - vertical-align: top; - font-family: monospace; - font-size: 0.9em; - padding: 0.5em; - white-space: pre; - background-color: #eee; - overflow-x: auto; -} -.trix-content img { - max-width: 100%; - height: auto; -} -.trix-content .attachment { - display: inline-block; - position: relative; - max-width: 100%; -} -.trix-content .attachment a { - color: inherit; - text-decoration: none; -} -.trix-content .attachment a:hover, .trix-content .attachment a:visited:hover { - color: inherit; -} -.trix-content .attachment__caption { - text-align: center; -} -.trix-content .attachment__caption .attachment__name + .attachment__size::before { - content: " •"; -} -.trix-content .attachment--preview { - width: 100%; - text-align: center; -} -.trix-content .attachment--preview .attachment__caption { - color: #666; - font-size: 0.9em; - line-height: 1.2; -} -.trix-content .attachment--file { - color: #333; - line-height: 1; - margin: 0 2px 2px 2px; - padding: 0.4em 1em; - border: 1px solid #bbb; - border-radius: 5px; -} -.trix-content .attachment-gallery { - display: flex; - flex-wrap: wrap; - position: relative; -} -.trix-content .attachment-gallery .attachment { - flex: 1 0 33%; - padding: 0 0.5em; - max-width: 33%; -} -.trix-content .attachment-gallery.attachment-gallery--2 .attachment, .trix-content .attachment-gallery.attachment-gallery--4 .attachment { - flex-basis: 50%; - max-width: 50%; -} \ No newline at end of file diff --git a/actiontext/app/helpers/action_text/tag_helper.rb b/actiontext/app/helpers/action_text/tag_helper.rb index ecd937dadb861..0d24dbf48639b 100644 --- a/actiontext/app/helpers/action_text/tag_helper.rb +++ b/actiontext/app/helpers/action_text/tag_helper.rb @@ -7,8 +7,6 @@ module ActionText module TagHelper - cattr_accessor(:id, instance_accessor: false) { 0 } - # Returns a `trix-editor` tag that instantiates the Trix JavaScript editor as # well as a hidden field that Trix will write to on changes, so the content will # be sent on form submissions. @@ -27,21 +25,24 @@ module TagHelper # rich_textarea_tag "content", message.content # # # # - def rich_textarea_tag(name, value = nil, options = {}) + # + # rich_textarea_tag "content", nil do + # "

Default content

" + # end + # # + # # + def rich_textarea_tag(name, value = nil, options = {}, &block) + value = capture(&block) if value.nil? && block_given? options = options.symbolize_keys - form = options.delete(:form) - options[:input] ||= "trix_input_#{ActionText::TagHelper.id += 1}" - options[:class] ||= "trix-content" + options[:value] ||= value.try(:to_editor_html) || value + options[:name] ||= name options[:data] ||= {} options[:data][:direct_upload_url] ||= main_app.rails_direct_uploads_url options[:data][:blob_url_template] ||= main_app.rails_service_blob_url(":signed_id", ":filename") - editor_tag = content_tag("trix-editor", "", options) - input_tag = hidden_field_tag(name, value.try(:to_trix_html) || value, id: options[:input], form: form) - - input_tag + editor_tag + render RichText.editor.editor_tag(options) end alias_method :rich_text_area_tag, :rich_textarea_tag end @@ -51,13 +52,10 @@ module ActionView::Helpers class Tags::ActionText < Tags::Base include Tags::Placeholderable - delegate :dom_id, to: ActionView::RecordIdentifier - - def render + def render(&block) options = @options.stringify_keys - add_default_name_and_id(options) - options["input"] ||= dom_id(object, [options["id"], :trix_input].compact.join("_")) if object - html_tag = @template_object.rich_textarea_tag(options.delete("name"), options.fetch("value") { value }, options.except("value")) + add_default_name_and_field(options) + html_tag = @template_object.rich_textarea_tag(options.delete("name"), options.fetch("value") { value }, options.except("value"), &block) error_wrapping(html_tag) end end @@ -82,10 +80,16 @@ module FormHelper # # # # rich_textarea :message, :content, value: "

Default message

" - # # + # # + # # + # + # rich_textarea :message, :content do + # "

Default message

" + # end + # # # # - def rich_textarea(object_name, method, options = {}) - Tags::ActionText.new(object_name, method, self, options).render + def rich_textarea(object_name, method, options = {}, &block) + Tags::ActionText.new(object_name, method, self, options).render(&block) end alias_method :rich_text_area, :rich_textarea end @@ -98,8 +102,8 @@ class FormBuilder # <% end %> # # Please refer to the documentation of the base helper for details. - def rich_textarea(method, options = {}) - @template.rich_textarea(@object_name, method, objectify_options(options)) + def rich_textarea(method, options = {}, &block) + @template.rich_textarea(@object_name, method, objectify_options(options), &block) end alias_method :rich_text_area, :rich_textarea end diff --git a/actiontext/app/javascript/actiontext/attachment_upload.js b/actiontext/app/javascript/actiontext/attachment_upload.js index d2c0a3af2e15c..34a8802e4c1f4 100644 --- a/actiontext/app/javascript/actiontext/attachment_upload.js +++ b/actiontext/app/javascript/actiontext/attachment_upload.js @@ -1,32 +1,81 @@ import { DirectUpload, dispatchEvent } from "@rails/activestorage" export class AttachmentUpload { - constructor(attachment, element) { + constructor(attachment, element, file = attachment.file) { this.attachment = attachment this.element = element - this.directUpload = new DirectUpload(attachment.file, this.directUploadUrl, this) + this.directUpload = new DirectUpload(file, this.directUploadUrl, this) + this.file = file } start() { - this.directUpload.create(this.directUploadDidComplete.bind(this)) - this.dispatch("start") + return new Promise((resolve, reject) => { + this.directUpload.create((error, attributes) => this.directUploadDidComplete(error, attributes, resolve, reject)) + this.dispatch("start") + }) } directUploadWillStoreFileWithXHR(xhr) { xhr.upload.addEventListener("progress", event => { - const progress = event.loaded / event.total * 100 - this.attachment.setUploadProgress(progress) + // Scale upload progress to 0-90% range + const progress = (event.loaded / event.total) * 90 if (progress) { this.dispatch("progress", { progress: progress }) } }) + + // Start simulating progress after upload completes + xhr.upload.addEventListener("loadend", () => { + this.simulateResponseProgress(xhr) + }) + } + + simulateResponseProgress(xhr) { + let progress = 90 + const startTime = Date.now() + + const updateProgress = () => { + // Simulate progress from 90% to 99% over estimated time + const elapsed = Date.now() - startTime + const estimatedResponseTime = this.estimateResponseTime() + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1) + progress = 90 + (responseProgress * 9) // 90% to 99% + + this.dispatch("progress", { progress }) + + // Continue until response arrives or we hit 99% + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress) + } + } + + // Stop simulation when response arrives + xhr.addEventListener("loadend", () => { + this.dispatch("progress", { progress: 100 }) + }) + + requestAnimationFrame(updateProgress) + } + + estimateResponseTime() { + // Base estimate: 1 second for small files, scaling up for larger files + const fileSize = this.file.size + const MB = 1024 * 1024 + + if (fileSize < MB) { + return 1000 // 1 second for files under 1MB + } else if (fileSize < 10 * MB) { + return 2000 // 2 seconds for files 1-10MB + } else { + return 3000 + (fileSize / MB * 50) // 3+ seconds for larger files + } } - directUploadDidComplete(error, attributes) { + directUploadDidComplete(error, attributes, resolve, reject) { if (error) { - this.dispatchError(error) + this.dispatchError(error, reject) } else { - this.attachment.setAttributes({ + resolve({ sgid: attributes.attachable_sgid, url: this.createBlobUrl(attributes.signed_id, attributes.filename) }) @@ -45,10 +94,10 @@ export class AttachmentUpload { return dispatchEvent(this.element, `direct-upload:${name}`, { detail }) } - dispatchError(error) { + dispatchError(error, reject) { const event = this.dispatch("error", { error }) if (!event.defaultPrevented) { - alert(error); + reject(error) } } diff --git a/actiontext/app/javascript/actiontext/index.js b/actiontext/app/javascript/actiontext/index.js index 0e9251018ae16..86a57189f0880 100644 --- a/actiontext/app/javascript/actiontext/index.js +++ b/actiontext/app/javascript/actiontext/index.js @@ -4,7 +4,16 @@ addEventListener("trix-attachment-add", event => { const { attachment, target } = event if (attachment.file) { - const upload = new AttachmentUpload(attachment, target) + const upload = new AttachmentUpload(attachment, target, attachment.file) + const onProgress = event => attachment.setUploadProgress(event.detail.progress) + + target.addEventListener("direct-upload:progress", onProgress) + upload.start() + .then(attributes => attachment.setAttributes(attributes)) + .catch(error => alert(error)) + .finally(() => target.removeEventListener("direct-upload:progress", onProgress)) } }) + +export { AttachmentUpload } diff --git a/actiontext/app/models/action_text/rich_text.rb b/actiontext/app/models/action_text/rich_text.rb index 2bca9cf446046..47de7c7645948 100644 --- a/actiontext/app/models/action_text/rich_text.rb +++ b/actiontext/app/models/action_text/rich_text.rb @@ -36,6 +36,9 @@ class RichText < Record # message = Message.create!(content: "
safe
") # message.content.to_s # => "
safeunsafe
" + cattr_accessor :editors, instance_accessor: false, default: {}.freeze + cattr_accessor :editor, instance_accessor: false + serialize :body, coder: ActionText::Content delegate :to_s, :nil?, to: :body @@ -48,10 +51,10 @@ class RichText < Record ## # :method: embeds # - # Returns the `ActiveStorage::Attachment` records from the embedded files. + # Returns the ActiveStorage::Attachment records from the embedded files. # - # Attached `ActiveStorage::Blob` records are extracted from the `body` - # in a # [before_validation](/classes/ActiveModel/Validations/Callbacks/ClassMethods.html#method-i-before_validation) callback. + # Attached ActiveStorage::Blob records are extracted from the `body` + # in a {before_validation}[rdoc-ref:ActiveModel::Validations::Callbacks::ClassMethods#before_validation] callback. has_many_attached :embeds before_validation do @@ -86,7 +89,24 @@ def to_plain_text # # # #

def to_trix_html - body&.to_trix_html + to_editor_html + end + deprecate to_trix_html: :to_editor_html, deprecator: ActionText.deprecator + + # Returns the `body` attribute in a format that makes it editable in the + # editor. Previews of attachments are rendered inline. + # + # content = "

Funny Times!

" + # message = Message.create!(content: content) + # message.content.to_editor_html # => + # #
+ # #

Funny times!

+ # #
+ # # + # #
+ # #
+ def to_editor_html + body&.to_editor_html end delegate :blank?, :empty?, :present?, to: :to_plain_text diff --git a/actiontext/lib/action_text.rb b/actiontext/lib/action_text.rb index 1d23ebaadf881..0fdb37511c227 100644 --- a/actiontext/lib/action_text.rb +++ b/actiontext/lib/action_text.rb @@ -17,12 +17,15 @@ module ActionText autoload :AttachmentGallery autoload :Attachment autoload :Attribute + autoload :Configurator autoload :Content + autoload :Editor autoload :Encryption autoload :Fragment autoload :FixtureSet autoload :HtmlConversion autoload :PlainTextConversion + autoload :Registry autoload :Rendering autoload :Serialization autoload :TrixAttachment @@ -39,6 +42,7 @@ module Attachments extend ActiveSupport::Autoload autoload :Caching + autoload :Conversion autoload :Minification autoload :TrixConversion end diff --git a/actiontext/lib/action_text/attachable.rb b/actiontext/lib/action_text/attachable.rb index 48cb4670c2795..3fc621ab21f08 100644 --- a/actiontext/lib/action_text/attachable.rb +++ b/actiontext/lib/action_text/attachable.rb @@ -111,6 +111,19 @@ def previewable_attachable? # end # end def to_trix_content_attachment_partial_path + to_editor_content_attachment_partial_path + end + deprecate to_trix_content_attachment_partial_path: :to_editor_content_attachment_partial_path, deprecator: ActionText.deprecator + + # Returns the path to the partial that is used for rendering the attachable in + # the rich text editor. Defaults to `to_partial_path`. + # + # Override to render a different partial: + # + # class User < ApplicationRecord + # "users/editor_content_attachment" + # end + def to_editor_content_attachment_partial_path to_partial_path end diff --git a/actiontext/lib/action_text/attachables/remote_image.rb b/actiontext/lib/action_text/attachables/remote_image.rb index 4d893e4c3e0c6..d5e99f157950c 100644 --- a/actiontext/lib/action_text/attachables/remote_image.rb +++ b/actiontext/lib/action_text/attachables/remote_image.rb @@ -9,12 +9,16 @@ class RemoteImage class << self def from_node(node) - if node["url"] && content_type_is_image?(node["content-type"]) + if remote_url?(node["url"]) && content_type_is_image?(node["content-type"]) new(attributes_from_node(node)) end end private + def remote_url?(url) + url && ActionView::Helpers::AssetUrlHelper::URI_REGEXP.match?(url) + end + def content_type_is_image?(content_type) content_type.to_s.match?(/^image(\/.+|$)/) end diff --git a/actiontext/lib/action_text/attachment.rb b/actiontext/lib/action_text/attachment.rb index fb039276aed44..93081d8ce3827 100644 --- a/actiontext/lib/action_text/attachment.rb +++ b/actiontext/lib/action_text/attachment.rb @@ -17,7 +17,7 @@ module ActionText # attachment = ActionText::Attachment.from_attachable(attachable) # attachment.to_html # => ") @@ -78,7 +78,7 @@ def gallery_attachments @gallery_attachments ||= attachment_galleries.flat_map(&:attachments) end - # Extracts +ActionText::Attachable+s from the HTML fragment: + # Extracts ActionText::Attachable objects from the HTML fragment: # # attachable = ActiveStorage::Blob.first # html = %Q() @@ -133,7 +133,15 @@ def to_plain_text end def to_trix_html - render_attachments(&:to_trix_attachment).to_html + to_editor_html + end + deprecate :to_trix_html, deprecator: ActionText.deprecator + + def to_editor_html # :nodoc: + canonical_content = render_attachments(&:to_editor_attachment) + canonical_fragment = Fragment.wrap(canonical_content.fragment) + + RichText.editor.as_editable(canonical_fragment).to_html end def to_html diff --git a/actiontext/lib/action_text/editor.rb b/actiontext/lib/action_text/editor.rb new file mode 100644 index 0000000000000..d06941b7e3ead --- /dev/null +++ b/actiontext/lib/action_text/editor.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module ActionText + class Editor # :nodoc: + extend ActiveSupport::Autoload + + autoload :Configurator + autoload :Registry + + attr_reader :options + + def initialize(options = {}) + @options = options + end + + # Convert fragments served by the editor into the canonical form that Action Text stores. + # + # def as_canonical(editable_fragment) + # editable_fragment.replace "my-editor-attachment" do |editor_attachment| + # ActionText::Attachment.from_attributes( + # "sgid" => editor_attachment["sgid"], + # "content-type" => editor_attachment["content-type"] + # ) + # end + # end + def as_canonical(editable_fragment) + editable_fragment + end + + # Convert fragments from the canonical form that Action Text stores into a format that is supported by the editor. + # + # def as_editable(canonical_fragment) + # canonical_fragment.replace ActionText::Attachment.tag_name do |action_text_attachment| + # attachment_attributes = { + # "sgid" => action_text_attachment["sgid"], + # "content-type" => action_text_attachment["content-type"] + # } + # + # ActionText::HtmlConversion.create_element("my-editor-attachment", attachment_attributes) + # end + # end + def as_editable(canonical_fragment) + canonical_fragment + end + + def editor_name + self.class.name.demodulize.delete_suffix("Editor").underscore + end + + def editor_tag(...) + Tag.new(editor_name, ...) + end + end + + class Editor::Tag # :nodoc: + cattr_accessor(:id, instance_accessor: false) { 0 } + + attr_reader :editor_name + attr_reader :options + + def initialize(editor_name, options = {}) + @editor_name = editor_name + @options = options + end + + def element_name + "#{editor_name}-editor" + end + + def render_in(view_context) + options[:class] ||= "#{editor_name}-content" + + view_context.content_tag(element_name, nil, options) + end + end +end diff --git a/actiontext/lib/action_text/editor/configurator.rb b/actiontext/lib/action_text/editor/configurator.rb new file mode 100644 index 0000000000000..36ac26d00ef20 --- /dev/null +++ b/actiontext/lib/action_text/editor/configurator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module ActionText + class Editor::Configurator # :nodoc: + attr_reader :configurations + + def initialize(configurations) + @configurations = configurations + end + + def build(editor_name) + editor_class = resolve(editor_name.to_s) + options = config_for(editor_name.to_sym) + + editor_class.new(options) + end + + def inspect # :nodoc: + attrs = configurations.any? ? + " configurations=[#{configurations.keys.map(&:inspect).join(", ")}]" : "" + "#<#{self.class}#{attrs}>" + end + + private + def config_for(name) + configurations.fetch name do + raise "Missing configuration for the #{name.inspect} Action Text editor. Configurations available for #{configurations.keys.inspect}" + end + end + + def resolve(class_name) + require "action_text/editor/#{class_name.underscore}_editor" + + Editor.const_get(:"#{class_name.camelize}Editor") + rescue LoadError + raise "Missing editor adapter for #{class_name.inspect}" + end + end +end diff --git a/actiontext/lib/action_text/editor/registry.rb b/actiontext/lib/action_text/editor/registry.rb new file mode 100644 index 0000000000000..bd60e5fd0e2d1 --- /dev/null +++ b/actiontext/lib/action_text/editor/registry.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ActionText + class Editor::Registry # :nodoc: + def initialize(configurations) + @configurations = configurations.to_h + @editors = {} + end + + def fetch(name) + editors.fetch(name.to_sym) do |key| + if configurations.include?(key) + editors[key] = configurator.build(key) + else + if block_given? + yield key + else + raise KeyError, "Missing configuration for the #{key} Action Text editor. " \ + "Configurations available for the #{configurations.keys.to_sentence} editors." + end + end + end + end + + def inspect # :nodoc: + attrs = configurations.any? ? + " configurations=[#{configurations.keys.map(&:inspect).join(", ")}]" : "" + "#<#{self.class}#{attrs}>" + end + + private + attr_reader :configurations, :editors + + def configurator + @configurator ||= Editor::Configurator.new(configurations) + end + end +end diff --git a/actiontext/lib/action_text/editor/trix_editor.rb b/actiontext/lib/action_text/editor/trix_editor.rb new file mode 100644 index 0000000000000..fff76c96e6b73 --- /dev/null +++ b/actiontext/lib/action_text/editor/trix_editor.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ActionText + class Editor::TrixEditor < Editor # :nodoc: + def as_canonical(editable_fragment) + editable_fragment.replace(TrixAttachment::SELECTOR, &method(:from_trix_attachment)) + end + + def as_editable(canonical_fragment) + canonical_fragment.replace(Attachment.tag_name, &method(:to_trix_attachment)) + end + + def editor_tag(...) + Tag.new(editor_name, ...) + end + + private + def to_trix_attachment(node) + attachment_attributes = node.attributes + TrixAttachment.from_attributes(attachment_attributes) + end + + def from_trix_attachment(node) + trix_attachment = TrixAttachment.new(node) + Attachment.from_attributes(trix_attachment.attributes) + end + end + + class Editor::TrixEditor::Tag < Editor::Tag # :nodoc: + def render_in(view_context, ...) + name = options.delete(:name) + form = options.delete(:form) + value = options.delete(:value) + + options[:input] ||= options[:id] ? + "#{options[:id]}_#{editor_name}_input_#{name.to_s.gsub(/\[.*\]/, "")}" : + "#{editor_name}_input_#{self.class.id += 1}" + input_tag = view_context.hidden_field_tag(name, value, id: options[:input], form: form) + + input_tag + super + end + end +end diff --git a/actiontext/lib/action_text/engine.rb b/actiontext/lib/action_text/engine.rb index ae524dd8bb504..c57c02c014e4a 100644 --- a/actiontext/lib/action_text/engine.rb +++ b/actiontext/lib/action_text/engine.rb @@ -8,6 +8,7 @@ require "active_storage/engine" require "action_text" +require "action_text/trix" module ActionText class Engine < Rails::Engine @@ -15,6 +16,10 @@ class Engine < Rails::Engine config.eager_load_namespaces << ActionText config.action_text = ActiveSupport::OrderedOptions.new + config.action_text.editors = ActiveSupport::InheritableOptions.new( + trix: {} + ) + config.action_text.editor = :trix config.action_text.attachment_tag_name = "action-text-attachment" config.autoload_once_paths = %W( #{root}/app/helpers @@ -34,7 +39,7 @@ class Engine < Rails::Engine initializer "action_text.asset" do if Rails.application.config.respond_to?(:assets) - Rails.application.config.assets.precompile += %w( actiontext.js actiontext.esm.js trix.js trix.css ) + Rails.application.config.assets.precompile += %w( actiontext.js actiontext.esm.js ) end end @@ -51,6 +56,11 @@ def attachable_plain_text_representation(caption = nil) end def to_trix_content_attachment_partial_path + to_editor_content_attachment_partial_path + end + deprecate :to_trix_content_attachment_partial_path, deprecator: ActionText.deprecator + + def to_editor_content_attachment_partial_path nil end end @@ -64,6 +74,16 @@ def to_trix_content_attachment_partial_path end end + initializer "action_text.editors" do |app| + ActiveSupport.on_load :action_text_rich_text do + self.editors = Editor::Registry.new(app.config.action_text.editors) + + if (editor_name = app.config.action_text.editor) + self.editor = editors.fetch(editor_name) + end + end + end + initializer "action_text.renderer" do %i[action_controller_base action_mailer].each do |base| ActiveSupport.on_load(base) do @@ -87,7 +107,9 @@ def to_trix_content_attachment_partial_path config.after_initialize do |app| if klass = app.config.action_text.sanitizer_vendor - ActionText::ContentHelper.sanitizer = klass.safe_list_sanitizer.new + ActiveSupport.on_load(:action_view) do + ActionText::ContentHelper.sanitizer = klass.safe_list_sanitizer.new + end end end end diff --git a/actiontext/lib/action_text/fixture_set.rb b/actiontext/lib/action_text/fixture_set.rb index 21e57b3e70e1f..68492d828460c 100644 --- a/actiontext/lib/action_text/fixture_set.rb +++ b/actiontext/lib/action_text/fixture_set.rb @@ -62,7 +62,7 @@ def self.attachment(fixture_set_name, label, column_type: :integer) signed_global_id = ActiveRecord::FixtureSet.signed_global_id fixture_set_name, label, column_type: column_type, for: ActionText::Attachable::LOCATOR_NAME - %() + %(<#{Attachment.tag_name} sgid="#{signed_global_id}">) end end end diff --git a/actiontext/lib/action_text/gem_version.rb b/actiontext/lib/action_text/gem_version.rb index e008c92fbf4e7..9bad1e6af8884 100644 --- a/actiontext/lib/action_text/gem_version.rb +++ b/actiontext/lib/action_text/gem_version.rb @@ -10,7 +10,7 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/actiontext/lib/action_text/plain_text_conversion.rb b/actiontext/lib/action_text/plain_text_conversion.rb index 4a895ba5412ea..dc69e1309ef38 100644 --- a/actiontext/lib/action_text/plain_text_conversion.rb +++ b/actiontext/lib/action_text/plain_text_conversion.rb @@ -7,70 +7,70 @@ module PlainTextConversion extend self def node_to_plain_text(node) - remove_trailing_newlines(plain_text_for_node(node)) + BottomUpReducer.new(node).reduce do |n, child_values| + plain_text_for_node(n, child_values) + end.then(&method(:remove_trailing_newlines)) end private - def plain_text_for_node(node, index = 0) + def plain_text_for_node(node, child_values) if respond_to?(plain_text_method_for_node(node), true) - send(plain_text_method_for_node(node), node, index) + send(plain_text_method_for_node(node), node, child_values) else - plain_text_for_node_children(node) + plain_text_for_child_values(child_values) end end - def plain_text_for_node_children(node) - texts = [] - node.children.each_with_index do |child, index| - next if skippable?(child) + def plain_text_method_for_node(node) + :"plain_text_for_#{node.name}_node" + end - texts << plain_text_for_node(child, index) - end - texts.join + def plain_text_for_child_values(child_values) + child_values.join end - def skippable?(node) - node.name == "script" || node.name == "style" + def plain_text_for_unsupported_node(node, _child_values) + "" end - def plain_text_method_for_node(node) - :"plain_text_for_#{node.name}_node" + %i[ script style].each do |element| + alias_method :"plain_text_for_#{element}_node", :plain_text_for_unsupported_node end - def plain_text_for_block(node, index = 0) - "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n\n" + def plain_text_for_block(node, child_values) + "#{remove_trailing_newlines(plain_text_for_child_values(child_values))}\n\n" end %i[ h1 p ].each do |element| alias_method :"plain_text_for_#{element}_node", :plain_text_for_block end - def plain_text_for_list(node, index) - "#{break_if_nested_list(node, plain_text_for_block(node))}" + def plain_text_for_list(node, child_values) + "#{break_if_nested_list(node, plain_text_for_block(node, child_values))}" end %i[ ul ol ].each do |element| alias_method :"plain_text_for_#{element}_node", :plain_text_for_list end - def plain_text_for_br_node(node, index) + def plain_text_for_br_node(node, _child_values) "\n" end - def plain_text_for_text_node(node, index) + def plain_text_for_text_node(node, _child_values) remove_trailing_newlines(node.text) end - def plain_text_for_div_node(node, index) - "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n" + def plain_text_for_div_node(node, child_values) + "#{remove_trailing_newlines(plain_text_for_child_values(child_values))}\n" end - def plain_text_for_figcaption_node(node, index) - "[#{remove_trailing_newlines(plain_text_for_node_children(node))}]" + def plain_text_for_figcaption_node(node, child_values) + "[#{remove_trailing_newlines(plain_text_for_child_values(child_values))}]" end - def plain_text_for_blockquote_node(node, index) - text = plain_text_for_block(node) + def plain_text_for_blockquote_node(node, child_values) + text = plain_text_for_block(node, child_values) return "“”" if text.blank? text = text.dup @@ -79,9 +79,9 @@ def plain_text_for_blockquote_node(node, index) text end - def plain_text_for_li_node(node, index) - bullet = bullet_for_li_node(node, index) - text = remove_trailing_newlines(plain_text_for_node_children(node)) + def plain_text_for_li_node(node, child_values) + bullet = bullet_for_li_node(node) + text = remove_trailing_newlines(plain_text_for_child_values(child_values)) indentation = indentation_for_li_node(node) "#{indentation}#{bullet} #{text}\n" @@ -91,8 +91,9 @@ def remove_trailing_newlines(text) text.chomp("") end - def bullet_for_li_node(node, index) + def bullet_for_li_node(node) if list_node_name_for_li_node(node) == "ol" + index = node.parent.elements.index(node) "#{index + 1}." else "•" @@ -121,5 +122,33 @@ def break_if_nested_list(node, text) text end end + + class BottomUpReducer # :nodoc: + def initialize(node) + @node = node + @values = {} + end + + def reduce(&block) + traverse_bottom_up(@node) do |n| + child_values = @values.values_at(*n.children) + @values[n] = block.call(n, child_values) + end + @values[@node] + end + + private + def traverse_bottom_up(node, &block) + call_stack, processing_stack = [ node ], [] + + until call_stack.empty? + node = call_stack.pop + processing_stack.push(node) + call_stack.concat node.children + end + + processing_stack.reverse_each(&block) + end + end end end diff --git a/actiontext/lib/action_text/system_test_helper.rb b/actiontext/lib/action_text/system_test_helper.rb index 5ac2348079f87..bbe99d9fe6590 100644 --- a/actiontext/lib/action_text/system_test_helper.rb +++ b/actiontext/lib/action_text/system_test_helper.rb @@ -14,6 +14,7 @@ module SystemTestHelper # * its `aria-label` # * the `name` of its input # + # Additional options are forwarded to Capybara as filters # # Examples: # @@ -33,8 +34,14 @@ module SystemTestHelper # # # # # fill_in_rich_textarea "message[content]", with: "Hello world!" - def fill_in_rich_textarea(locator = nil, with:) - find(:rich_textarea, locator).execute_script("this.editor.loadHTML(arguments[0])", with.to_s) + def fill_in_rich_textarea(locator = nil, with:, **) + find(:rich_textarea, locator, **).execute_script(<<~JS, with.to_s) + if ("value" in this) { + this.value = arguments[0] + } else { + this.editor.loadHTML(arguments[0]) + } + JS end alias_method :fill_in_rich_text_area, :fill_in_rich_textarea end @@ -44,13 +51,18 @@ def fill_in_rich_textarea(locator = nil, with:) Capybara.add_selector rich_textarea do label "rich-text area" xpath do |locator| + xpath = XPath.descendant[[ + XPath.attribute(:role) == "textbox", + (XPath.attribute(:contenteditable) == "") | (XPath.attribute(:contenteditable) == "true") + ].reduce(:&)] + if locator.nil? - XPath.descendant(:"trix-editor") + xpath else input_located_by_name = XPath.anywhere(:input).where(XPath.attr(:name) == locator).attr(:id) input_located_by_label = XPath.anywhere(:label).where(XPath.string.n.is(locator)).attr(:for) - XPath.descendant(:"trix-editor").where \ + xpath.where \ XPath.attr(:id).equals(locator) | XPath.attr(:placeholder).equals(locator) | XPath.attr(:"aria-label").equals(locator) | diff --git a/actiontext/lib/action_text/trix_attachment.rb b/actiontext/lib/action_text/trix_attachment.rb index d8223ef9a1bed..53c8ec2f522fa 100644 --- a/actiontext/lib/action_text/trix_attachment.rb +++ b/actiontext/lib/action_text/trix_attachment.rb @@ -3,6 +3,7 @@ # :markup: markdown module ActionText + # DEPRECATED class TrixAttachment TAG_NAME = "figure" SELECTOR = "[data-trix-attachment]" diff --git a/actiontext/lib/generators/action_text/install/install_generator.rb b/actiontext/lib/generators/action_text/install/install_generator.rb index 0dc5216975c64..15348505eb4dc 100644 --- a/actiontext/lib/generators/action_text/install/install_generator.rb +++ b/actiontext/lib/generators/action_text/install/install_generator.rb @@ -10,12 +10,42 @@ module Generators class InstallGenerator < ::Rails::Generators::Base source_root File.expand_path("templates", __dir__) + class_option :editor, type: :string, default: "trix" + + def install_editor + editor = options[:editor] + + say "Installing #{editor} JavaScript dependency", :green + if using_bun? + run "bun add #{editor}" + elsif using_node? + run "yarn add #{editor}" + end + end + + def append_editor + destination = Pathname(destination_root) + editor = options[:editor] + + if (application_javascript_path = destination.join("app/javascript/application.js")).exist? + insert_into_file application_javascript_path.to_s, %(\nimport "#{editor}"\n) + else + say <<~INSTRUCTIONS, :green + You must import the #{editor} JavaScript module in your application entrypoint. + INSTRUCTIONS + end + + if (importmap_path = destination.join("config/importmap.rb")).exist? + append_to_file importmap_path.to_s, %(pin "#{editor}"\n) + end + end + def install_javascript_dependencies say "Installing JavaScript dependencies", :green if using_bun? - run "bun add @rails/actiontext trix" + run "bun add @rails/actiontext" elsif using_node? - run "yarn add @rails/actiontext trix" + run "yarn add @rails/actiontext" end end @@ -23,15 +53,15 @@ def append_javascript_dependencies destination = Pathname(destination_root) if (application_javascript_path = destination.join("app/javascript/application.js")).exist? - insert_into_file application_javascript_path.to_s, %(\nimport "trix"\nimport "@rails/actiontext"\n) + insert_into_file application_javascript_path.to_s, %(\nimport "@rails/actiontext"\n) else say <<~INSTRUCTIONS, :green - You must import the @rails/actiontext and trix JavaScript modules in your application entrypoint. + You must import the @rails/actiontext JavaScript module in your application entrypoint. INSTRUCTIONS end if (importmap_path = destination.join("config/importmap.rb")).exist? - append_to_file importmap_path.to_s, %(pin "trix"\npin "@rails/actiontext", to: "actiontext.esm.js"\n) + append_to_file importmap_path.to_s, %(pin "@rails/actiontext", to: "actiontext.esm.js"\n) end end @@ -47,18 +77,6 @@ def create_actiontext_files "app/views/layouts/action_text/contents/_content.html.erb" end - def enable_image_processing_gem - if (gemfile_path = Pathname(destination_root).join("Gemfile")).exist? - say "Ensure image_processing gem has been enabled so image uploads will work (remember to bundle!)" - image_processing_regex = /gem ["']image_processing["']/ - if File.readlines(gemfile_path).grep(image_processing_regex).any? - uncomment_lines gemfile_path, image_processing_regex - else - run "bundle add --skip-install image_processing" - end - end - end - def create_migrations rails_command "railties:install:migrations FROM=active_storage,action_text", inline: true end diff --git a/actiontext/package.json b/actiontext/package.json index df1bfee80aea4..51de2cdac5927 100644 --- a/actiontext/package.json +++ b/actiontext/package.json @@ -1,6 +1,6 @@ { "name": "@rails/actiontext", - "version": "8.1.0-alpha", + "version": "8.2.0-alpha", "description": "Edit and display rich text in Rails applications", "module": "app/assets/javascripts/actiontext.esm.js", "main": "app/assets/javascripts/actiontext.js", @@ -31,6 +31,7 @@ "@rollup/plugin-commonjs": "^19.0.1", "@rollup/plugin-node-resolve": "^11.0.1", "rollup": "^2.35.1", + "rollup-plugin-terser": "^7.0.2", "trix": "^2.0.0" }, "scripts": { diff --git a/actiontext/test/dummy/app/javascript/application.js b/actiontext/test/dummy/app/javascript/application.js index 091bb5839e9cb..807fb4ba44de6 100644 --- a/actiontext/test/dummy/app/javascript/application.js +++ b/actiontext/test/dummy/app/javascript/application.js @@ -1,2 +1,17 @@ import "trix" import "@rails/actiontext" + +addEventListener("click", ({ target }) => { + if (target.matches(`[data-trix-action~="x-attach"]`)) { + const toolbar = target.closest("trix-toolbar") + const template = target.querySelector("template") + const actionTextAttachment = { + ...JSON.parse(template.getAttribute("data-action-text-attachment")), + content: template.innerHTML + } + + for (const editorElement of document.querySelectorAll(`trix-editor[toolbar="${toolbar.id}"]`)) { + editorElement.editor.insertAttachment(new Trix.Attachment(actionTextAttachment)) + } + } +}) diff --git a/actiontext/test/dummy/app/models/person.rb b/actiontext/test/dummy/app/models/person.rb index 25ec36f895db7..71e35949e739a 100644 --- a/actiontext/test/dummy/app/models/person.rb +++ b/actiontext/test/dummy/app/models/person.rb @@ -6,6 +6,10 @@ def self.to_missing_attachable_partial_path end def to_trix_content_attachment_partial_path + to_editor_content_attachment_partial_path + end + + def to_editor_content_attachment_partial_path "people/trix_content_attachment" end diff --git a/actiontext/test/dummy/app/views/active_storage/blobs/_attachable.html.erb b/actiontext/test/dummy/app/views/active_storage/blobs/_attachable.html.erb new file mode 100644 index 0000000000000..1e23d356df8b5 --- /dev/null +++ b/actiontext/test/dummy/app/views/active_storage/blobs/_attachable.html.erb @@ -0,0 +1 @@ +<%= image_tag blob %> diff --git a/actiontext/test/dummy/app/views/messages/_form.html.erb b/actiontext/test/dummy/app/views/messages/_form.html.erb index 93bd10f614dd8..d3a36cd7b631b 100644 --- a/actiontext/test/dummy/app/views/messages/_form.html.erb +++ b/actiontext/test/dummy/app/views/messages/_form.html.erb @@ -17,8 +17,22 @@
+ + + <% Person.all.each do |person| %> + + <% end %> + + <%= form.label :content, "Message content label" %> - <%= form.rich_textarea :content, class: "trix-content", + <%= form.rich_textarea :content, class: "trix-content", toolbar: "toolbar", placeholder: "Your message here", aria: { label: "Message content aria-label" } %>
diff --git a/actiontext/test/dummy/config/storage.yml b/actiontext/test/dummy/config/storage.yml index 4942ab66948b7..927dc537c8a6c 100644 --- a/actiontext/test/dummy/config/storage.yml +++ b/actiontext/test/dummy/config/storage.yml @@ -21,13 +21,6 @@ local: # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> # bucket: your_own_bucket-<%= Rails.env %> -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name-<%= Rails.env %> - # mirror: # service: Mirror # primary: local diff --git a/actiontext/test/dummy/db/schema.rb b/actiontext/test/dummy/db/schema.rb index 2e3f39ad970af..8ad24850b0635 100644 --- a/actiontext/test/dummy/db/schema.rb +++ b/actiontext/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2019_03_17_200724) do +ActiveRecord::Schema[8.2].define(version: 2019_03_17_200724) do create_table "action_text_rich_texts", force: :cascade do |t| t.string "name", null: false t.text "body" diff --git a/actiontext/test/dummy/test/fixtures/files/racecar.jpg b/actiontext/test/dummy/test/fixtures/files/racecar.jpg new file mode 100644 index 0000000000000..934b4caa22704 Binary files /dev/null and b/actiontext/test/dummy/test/fixtures/files/racecar.jpg differ diff --git a/actiontext/test/fixtures/action_text/rich_texts.yml b/actiontext/test/fixtures/action_text/rich_texts.yml index c1d917a1bb27f..c3ad06d4d7877 100644 --- a/actiontext/test/fixtures/action_text/rich_texts.yml +++ b/actiontext/test/fixtures/action_text/rich_texts.yml @@ -1,9 +1,9 @@ hello_alice_message_content: record: hello_alice (Message) name: content - body:

Hello, <%= ActionText::FixtureSet.attachment("people", :alice) %>

+ body:
Hello, <%= ActionText::FixtureSet.attachment("people", :alice) %>
hello_world_review_content: record: hello_world (Review) name: content - body:

<%= ActionText::FixtureSet.attachment("messages", :hello_world) %> is great!

+ body:
<%= ActionText::FixtureSet.attachment("messages", :hello_world) %> is great!
diff --git a/actiontext/test/fixtures/messages.yml b/actiontext/test/fixtures/messages.yml index bb5171f6d45df..003b2ac100713 100644 --- a/actiontext/test/fixtures/messages.yml +++ b/actiontext/test/fixtures/messages.yml @@ -3,3 +3,6 @@ hello_alice: hello_world: subject: "A greeting" + +racecar: + subject: "A racecar" diff --git a/actiontext/test/system/system_test_helper_test.rb b/actiontext/test/system/system_test_helper_test.rb index 954a0969370a6..35c6ed65613fa 100644 --- a/actiontext/test/system/system_test_helper_test.rb +++ b/actiontext/test/system/system_test_helper_test.rb @@ -10,35 +10,48 @@ def setup test "filling in a rich-text area by ID" do assert_selector :element, "trix-editor", id: "message_content" fill_in_rich_textarea "message_content", with: "Hello world!" + assert_selector :rich_text_area, "message_content", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in a rich-text area by placeholder" do assert_selector :element, "trix-editor", placeholder: "Your message here" fill_in_rich_textarea "Your message here", with: "Hello world!" + assert_selector :rich_text_area, "Your message here", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in a rich-text area by aria-label" do assert_selector :element, "trix-editor", "aria-label": "Message content aria-label" fill_in_rich_textarea "Message content aria-label", with: "Hello world!" + assert_selector :rich_text_area, "Message content aria-label", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in a rich-text area by label" do assert_selector :label, "Message content label", for: "message_content" - fill_in_rich_textarea "Message content label", with: "Hello world!" + fill_in_rich_textarea "Message content label", id: "message_content", with: "Hello world!" + assert_selector :rich_text_area, "Message content label", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in a rich-text area by input name" do assert_selector :element, "trix-editor", input: true fill_in_rich_textarea "message[content]", with: "Hello world!" + assert_selector :rich_text_area, "message[content]", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in the only rich-text area" do fill_in_rich_textarea with: "Hello world!" + assert_selector :rich_text_area, text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end + + test "filling in a rich-text area with nil" do + fill_in_rich_textarea "message_content", with: nil + assert_selector :rich_text_area do |rich_text_area| + assert_empty rich_text_area.text + end + end end diff --git a/actiontext/test/system/trix_editor_test.rb b/actiontext/test/system/trix_editor_test.rb new file mode 100644 index 0000000000000..68007359fa2bd --- /dev/null +++ b/actiontext/test/system/trix_editor_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "application_system_test_case" +require "active_support/core_ext/object/with" + +class TrixEditorTest < ApplicationSystemTestCase + test "uploads, attaches, and edits image file" do + image_file = file_fixture("racecar.jpg") + + with_editor :trix do + visit new_message_url + attach_file(image_file) { click_button "Attach Files" } + + within :rich_text_area do + assert_active_storage_blob image_file + end + + click_button "Create Message" + + within class: "trix-content" do + assert_active_storage_representation image_file + end + + click_link "Edit" + + within :rich_text_area do + assert_active_storage_blob image_file + end + + click_button "Update Message" + + within class: "trix-content" do + assert_active_storage_representation image_file + end + end + end + + test "attaches attachable Record" do + alice = people(:alice) + + with_editor :trix do + visit new_message_url + click_button "Mention #{alice.name}" + + within :rich_text_area do + assert_editor_attachment alice do + assert_css "span", text: alice.name, class: "mentionable-person" + end + end + + click_button "Create Message" + + within class: "trix-content" do + assert_css "span", text: alice.name, class: "mentioned-person" + end + + click_link "Edit" + + within :rich_text_area do + assert_editor_attachment alice do + assert_css "span", text: alice.name, class: "mentionable-person" + end + end + + click_button "Update Message" + + within class: "trix-content" do + assert_css "span", text: alice.name, class: "mentioned-person" + end + end + end + + def assert_editor_attachment(attachable, &block) + attachment_attribute = "data-trix-attachment" + + assert_element "figure", :contenteditable => "false", attachment_attribute.to_sym => true do |figure| + attachment = JSON.parse(figure[attachment_attribute]) + + attachment["sgid"] == attachable.attachable_sgid && within(figure, &block) + end + end + + def assert_active_storage_blob(image_file) + src = %r{/rails/active_storage/blobs/redirect/.*/#{image_file.basename}\Z} + + assert_selector :element, "img", src: src + end + + def assert_active_storage_representation(image_file) + src = %r{/rails/active_storage/representations/redirect/.*/#{image_file.basename}\Z} + + assert_selector :element, "img", src: src + end + + def with_editor(editor_name, &block) + Rails.configuration.action_text.with(editor: editor_name, &block) + end +end diff --git a/actiontext/test/template/form_helper_test.rb b/actiontext/test/template/form_helper_test.rb index 3eb5d6f766848..da69d4bfe6675 100644 --- a/actiontext/test/template/form_helper_test.rb +++ b/actiontext/test/template/form_helper_test.rb @@ -30,21 +30,49 @@ def form_with(*, **) concat rich_textarea_tag :content, message.content, { input: "trix_input_1" } - assert_dom_equal \ - '' \ - '' \ - "", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) + + + + HTML + end + + test "#rich_textarea_tag helper with block" do + concat( + rich_textarea_tag(:content, nil, { input: "trix_input_1" }) do + concat "

hello world

" + end + ) + + assert_dom_equal(<<~HTML, output_buffer) + + + + HTML end test "#rich_textarea helper" do concat rich_textarea :message, :content, input: "trix_input_1" - assert_dom_equal \ - '' \ - '' \ - "", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) + + + + HTML + end + + test "#rich_textarea helper with block" do + concat( + rich_textarea(:message, :content, input: "trix_input_1") do + concat "

hello world

" + end + ) + + assert_dom_equal(<<~HTML, output_buffer) + + + + HTML end test "#rich_textarea helper renders the :value argument into the hidden field" do @@ -52,11 +80,11 @@ def form_with(*, **) concat rich_textarea :message, :title, value: message.content, input: "trix_input_1" - assert_dom_equal \ - '' \ - '' \ - "", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) + + + + HTML end test "form with rich text area" do @@ -64,13 +92,29 @@ def form_with(*, **) form.rich_textarea :content end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + + test "form with rich text area with block" do + form_with model: Message.new do |form| + form.rich_textarea :content do + "

hello world

" + end + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end test "form with rich text area having class" do @@ -78,13 +122,13 @@ def form_with(*, **) form.rich_textarea :content, class: "custom-class" end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end test "form with rich text area and error wrapper" do @@ -95,15 +139,15 @@ def form_with(*, **) form.rich_textarea :content end - assert_dom_equal \ - '
' \ - '
' \ - '' \ - '' \ - "" \ - "
" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+
+ + + +
+
+ HTML end test "form with rich text area for non-attribute" do @@ -111,13 +155,13 @@ def form_with(*, **) form.rich_textarea :not_an_attribute end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end test "modelless form with rich text area" do @@ -125,13 +169,29 @@ def form_with(*, **) form.rich_textarea :content, { input: "trix_input_2" } end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + + test "modelless form with rich text area with block" do + form_with url: "/messages", scope: :message do |form| + form.rich_textarea :content, input: "trix_input_1" do + "

hello world

" + end + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end test "form with rich text area having placeholder without locale" do @@ -139,13 +199,13 @@ def form_with(*, **) form.rich_textarea :content, placeholder: true end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end test "form with rich text area having placeholder with locale" do @@ -155,13 +215,13 @@ def form_with(*, **) end end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end test "form with rich text area with value" do @@ -169,13 +229,31 @@ def form_with(*, **) form.rich_textarea :title, value: "

hello world

" end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + + test "form with rich text area with value with block" do + model = Message.new content: "

ignored

" + + form_with model: model, scope: :message do |form| + form.rich_textarea :title do + "

hello world

" + end + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end test "form with rich text area with form attribute" do @@ -183,13 +261,13 @@ def form_with(*, **) form.rich_textarea :title, form: "other_form" end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end test "form with rich text area with data[direct_upload_url]" do @@ -197,13 +275,13 @@ def form_with(*, **) form.rich_textarea :content, data: { direct_upload_url: "http://test.host/direct_uploads" } end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end test "form with rich text area with data[blob_url_template]" do @@ -211,12 +289,12 @@ def form_with(*, **) form.rich_textarea :content, data: { blob_url_template: "http://test.host/blobs/:signed_id/:filename" } end - assert_dom_equal \ - '
' \ - '' \ - '' \ - "" \ - "
", - output_buffer + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML end end diff --git a/actiontext/test/unit/attachment_test.rb b/actiontext/test/unit/attachment_test.rb index f6e2eda052169..416994ed948c9 100644 --- a/actiontext/test/unit/attachment_test.rb +++ b/actiontext/test/unit/attachment_test.rb @@ -23,7 +23,7 @@ class ActionText::AttachmentTest < ActiveSupport::TestCase test "converts to TrixAttachment" do attachment = attachment_from_html(%Q()) - trix_attachment = attachment.to_trix_attachment + trix_attachment = assert_deprecated(ActionText.deprecator) { attachment.to_trix_attachment } assert_kind_of ActionText::TrixAttachment, trix_attachment assert_equal attachable.attachable_sgid, trix_attachment.attributes["sgid"] @@ -32,7 +32,8 @@ class ActionText::AttachmentTest < ActiveSupport::TestCase assert_equal attachable.byte_size, trix_attachment.attributes["filesize"] assert_equal "Captioned", trix_attachment.attributes["caption"] - assert_nil attachable.to_trix_content_attachment_partial_path + assert_nil ActionText.deprecator.silence { attachable.to_trix_content_attachment_partial_path } + assert_nil attachable.to_editor_content_attachment_partial_path assert_nil trix_attachment.attributes["content"] end @@ -40,13 +41,14 @@ class ActionText::AttachmentTest < ActiveSupport::TestCase attachable = Person.create! name: "Javan" attachment = attachment_from_html(%Q()) - trix_attachment = attachment.to_trix_attachment + trix_attachment = assert_deprecated(ActionText.deprecator) { attachment.to_trix_attachment } assert_kind_of ActionText::TrixAttachment, trix_attachment assert_equal attachable.attachable_sgid, trix_attachment.attributes["sgid"] assert_equal attachable.attachable_content_type, trix_attachment.attributes["contentType"] - assert_not_nil attachable.to_trix_content_attachment_partial_path + assert_not_nil ActionText.deprecator.silence { attachable.to_trix_content_attachment_partial_path } + assert_not_nil attachable.to_editor_content_attachment_partial_path assert_not_nil trix_attachment.attributes["content"] end @@ -63,8 +65,9 @@ class ActionText::AttachmentTest < ActiveSupport::TestCase assert_equal "text/html", attachable.content_type assert_equal "abc", attachable.content - trix_attachment = attachment.to_trix_attachment + trix_attachment = assert_deprecated(ActionText.deprecator) { attachment.to_trix_attachment } assert_kind_of ActionText::TrixAttachment, trix_attachment + assert_equal "text/html", trix_attachment.attributes["contentType"] assert_equal "abc", trix_attachment.attributes["content"] end @@ -81,13 +84,15 @@ class ActionText::AttachmentTest < ActiveSupport::TestCase test "to_trix_html sanitizes action-text HTML content attachment" do attachment = ActionText::Content.new("\">") + attachment_to_trix_html = assert_deprecated(ActionText.deprecator) { attachment.to_trix_html } - assert_equal "
"}\">
", attachment.to_trix_html + assert_equal "
"}\">
", attachment_to_trix_html end test "defaults trix partial to model partial" do attachable = Page.create! title: "Homepage" - assert_equal "pages/page", attachable.to_trix_content_attachment_partial_path + assert_equal "pages/page", assert_deprecated(ActionText.deprecator) { attachable.to_trix_content_attachment_partial_path } + assert_equal "pages/page", attachable.to_editor_content_attachment_partial_path end private diff --git a/actiontext/test/unit/content_test.rb b/actiontext/test/unit/content_test.rb index b2c40e19e4752..7c8bfc4375ed8 100644 --- a/actiontext/test/unit/content_test.rb +++ b/actiontext/test/unit/content_test.rb @@ -55,6 +55,16 @@ class ActionText::ContentTest < ActiveSupport::TestCase assert_equal "100", attachable.height end + test "treats image attachments with non-URL paths as missing" do + html = '' + + content = content_from_html(html) + assert_equal 1, content.attachments.size + + attachable = content.attachments.first.attachable + assert_kind_of ActionText::Attachables::MissingAttachable, attachable + end + test "identifies destroyed attachables as missing" do file = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") html = %Q() @@ -161,25 +171,29 @@ class ActionText::ContentTest < ActiveSupport::TestCase test "sanitizes attachment markup for Trix" do html = '' trix_html = '
' - assert_equal trix_html, content_from_html(html).to_trix_html.strip + content_to_trix_html = assert_deprecated(ActionText.deprecator) { content_from_html(html).to_trix_html } + assert_equal trix_html, content_to_trix_html.strip end test "removes content attribute if it's value is empty" do html = '' trix_html = '
' - assert_equal trix_html, content_from_html(html).to_trix_html.strip + content_to_trix_html = assert_deprecated(ActionText.deprecator) { content_from_html(html).to_trix_html } + assert_equal trix_html, content_to_trix_html.strip end test "removes content attribute if it's value is empty after sanitization" do html = '' trix_html = '
' - assert_equal trix_html, content_from_html(html).to_trix_html.strip + content_to_trix_html = assert_deprecated(ActionText.deprecator) { content_from_html(html).to_trix_html } + assert_equal trix_html, content_to_trix_html.strip end test "does not add missing content attribute" do html = '' trix_html = '
' - assert_equal trix_html, content_from_html(html).to_trix_html.strip + content_to_trix_html = assert_deprecated(ActionText.deprecator) { content_from_html(html).to_trix_html } + assert_equal trix_html, content_to_trix_html.strip end test "renders with layout when in a new thread" do diff --git a/actiontext/test/unit/editor/configurator_test.rb b/actiontext/test/unit/editor/configurator_test.rb new file mode 100644 index 0000000000000..ce5ab88294d8e --- /dev/null +++ b/actiontext/test/unit/editor/configurator_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::Editor::ConfiguratorTest < ActiveSupport::TestCase + test "builds correct editor instance based on editor name" do + configurator = ActionText::Editor::Configurator.new(trix: {}) + editor = configurator.build(:trix) + assert_instance_of ActionText::Editor::TrixEditor, editor + end + + test "raises error when passing non-existent editor name" do + configurator = ActionText::Editor::Configurator.new({}) + assert_raise RuntimeError do + configurator.build(:bigfoot) + end + end + + test "inspect attributes" do + config = { + trix: {}, + lexxy: {} + } + + configurator = ActionText::Editor::Configurator.new(config) + assert_match(/#/, configurator.inspect) + + configurator = ActionText::Editor::Configurator.new({}) + assert_match(/#/, configurator.inspect) + end +end diff --git a/actiontext/test/unit/editor/registry_test.rb b/actiontext/test/unit/editor/registry_test.rb new file mode 100644 index 0000000000000..6af8655f03176 --- /dev/null +++ b/actiontext/test/unit/editor/registry_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::Editor::RegistryTest < ActiveSupport::TestCase + test "inspect attributes" do + registry = ActionText::Editor::Registry.new({}) + assert_match(/#/, registry.inspect) + end + + test "inspect attributes with config" do + config = { + trix: {}, + lexxy: {} + } + + registry = ActionText::Editor::Registry.new(config) + assert_match(/#/, registry.inspect) + end +end diff --git a/actiontext/test/unit/editor/trix_editor_test.rb b/actiontext/test/unit/editor/trix_editor_test.rb new file mode 100644 index 0000000000000..e524fff21a475 --- /dev/null +++ b/actiontext/test/unit/editor/trix_editor_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" + +module ActionText + class Editor::TrixEditorTest < ActionView::TestCase + test "#as_canonical transforms Fragment for storage" do + expected = "
hello, world
" + fragment = Fragment.wrap(expected) + editor = ActionText::Editor::TrixEditor.new + + actual = editor.as_canonical(fragment) + + assert_kind_of Fragment, actual + assert_dom_equal expected, actual.to_html + end + + test "#as_editable transforms Fragment for editing" do + expected = "
hello, world
" + fragment = Fragment.wrap(expected) + editor = ActionText::Editor::TrixEditor.new + + actual = editor.as_editable(fragment) + + assert_kind_of Fragment, actual + assert_dom_equal expected, actual.to_html + end + + test "#editor_name removes the Editor suffix" do + editor = ActionText::Editor::TrixEditor.new + + assert_equal "trix", editor.editor_name + end + + test "#editor_tag returns a renderable" do + editor = ActionText::Editor::TrixEditor.new + + render(editor.editor_tag(name: "message[body]")) + + trix_editor = rendered.html.at("trix-editor") + input = rendered.html.at("input[id][type=hidden]") + assert_not trix_editor.key?("name") + assert_equal "trix-content", trix_editor["class"] + assert_equal input["id"], trix_editor["input"] + assert_equal "message[body]", input["name"] + end + + test "#editor_tag forwards the :form to its input element" do + editor = ActionText::Editor::TrixEditor.new + + render(editor.editor_tag(form: "form_id")) + + assert_dom "trix-editor[form]", count: 0 + assert_dom "input[form=?]", "form_id" + end + + test "#editor_tag forwards the :value attribute to its input element" do + editor = ActionText::Editor::TrixEditor.new + + render(editor.editor_tag(value: "
hello
")) + + assert_dom "trix-editor[value]", count: 0 + assert_dom "input[value=?]", "
hello
" + end + end +end diff --git a/actiontext/test/unit/editor_test.rb b/actiontext/test/unit/editor_test.rb new file mode 100644 index 0000000000000..5fe81e99647cc --- /dev/null +++ b/actiontext/test/unit/editor_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::EditorTest < ActionView::TestCase + test "#as_canonical returns Fragment for storage" do + expected = "
hello, world
" + fragment = Fragment.wrap(expected) + editor = Editor.new + + actual = editor.as_canonical(fragment) + + assert_kind_of Fragment, actual + assert_dom_equal expected, actual.to_html + end + + test "#as_editable returns Fragment for editing" do + expected = "
hello, world
" + fragment = Fragment.wrap(expected) + editor = Editor.new + + actual = editor.as_editable(fragment) + + assert_kind_of Fragment, actual + assert_dom_equal expected, actual.to_html + end +end + +class ActionText::Editor::SubclassTest < ActionView::TestCase + class TestEditor < ActionText::Editor + def as_canonical(editable_fragment) + editable_fragment = editable_fragment.replace "test-editor-attachment" do |editor_attachment| + ActionText::Attachment.from_attributes( + "sgid" => editor_attachment["sgid"], + "content-type" => editor_attachment["content-type"] + ) + end + + super + end + + def as_editable(canonical_fragment) + canonical_fragment = canonical_fragment.replace ActionText::Attachment.tag_name do |action_text_attachment| + attachment_attributes = { + "sgid" => action_text_attachment["sgid"], + "content-type" => action_text_attachment["content-type"] + } + + ActionText::HtmlConversion.create_element("test-editor-attachment", attachment_attributes) + end + + super + end + end + + test "#as_canonical transforms Fragment for storage" do + fragment = Fragment.wrap(<<~HTML) + + HTML + editor = TestEditor.new + + actual = editor.as_canonical(fragment) + + assert_kind_of Fragment, actual + assert_dom_equal <<~HTML, actual.to_html + + HTML + end + + test "#as_editable transforms Fragment for editing" do + fragment = Fragment.wrap(<<~HTML) + + HTML + editor = TestEditor.new + + actual = editor.as_editable(fragment) + + assert_kind_of Fragment, actual + assert_dom_equal <<~HTML, actual.to_html + + HTML + end + + test "#editor_name removes the Editor suffix" do + editor = TestEditor.new + + assert_equal "test", editor.editor_name + end + + test "#editor_tag returns a renderable" do + editor = TestEditor.new + editor_tag = editor.editor_tag(id: "test_editor_id", name: "message[body]", value: "
hello
") + + render(editor_tag) + + element = rendered.html.at("test-editor") + assert_equal "message[body]", element["name"] + assert_equal "test_editor_id", element["id"] + assert_equal "test-content", element["class"] + assert_equal "
hello
", element["value"] + end +end diff --git a/actiontext/test/unit/plain_text_conversion_test.rb b/actiontext/test/unit/plain_text_conversion_test.rb index a60111b2faddc..7889e6a4855a3 100644 --- a/actiontext/test/unit/plain_text_conversion_test.rb +++ b/actiontext/test/unit/plain_text_conversion_test.rb @@ -120,7 +120,7 @@ class ActionText::PlainTextConversionTest < ActiveSupport::TestCase "Hello world!\nHow are you?", ActionText::Fragment.wrap("
Hello world!
").tap do |fragment| node = fragment.source.children.last - 1_000.times do + 10_000.times do child = node.clone child.parent = node node = child diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index eb5f3c14eaeff..ec1ca93f5d1db 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,54 +1,14 @@ -* Respect `html_options[:form]` when `collection_checkboxes` generates the - hidden ``. +* Fix tag parameter content being overwritten instead of combined with tag block content. + Before `tag.div("Hello ") { "World" }` would just return `
World
`, now it returns `
Hello World
`. - *Riccardo Odone* + *DHH* -* Layouts have access to local variables passed to `render`. +* Add ability to pass a block when rendering collection. The block will be executed for each rendered element in the collection. - This fixes #31680 which was a regression in Rails 5.1. + *Vincent Robert* - *Mike Dalessio* +* Add `key:` and `expires_in:` options under `cached:` to `render` when used with `collection:` -* Argument errors related to strict locals in templates now raise an - `ActionView::StrictLocalsError`, and all other argument errors are reraised as-is. + *Jarrett Lusso* - Previously, any `ArgumentError` raised during template rendering was swallowed during strict - local error handling, so that an `ArgumentError` unrelated to strict locals (e.g., a helper - method invoked with incorrect arguments) would be replaced by a similar `ArgumentError` with an - unrelated backtrace, making it difficult to debug templates. - - Now, any `ArgumentError` unrelated to strict locals is reraised, preserving the original - backtrace for developers. - - Also note that `ActionView::StrictLocalsError` is a subclass of `ArgumentError`, so any existing - code that rescues `ArgumentError` will continue to work. - - Fixes #52227. - - *Mike Dalessio* - -* Improve error highlighting of multi-line methods in ERB templates or - templates where the error occurs within a do-end block. - - *Martin Emde* - -* Fix a crash in ERB template error highlighting when the error occurs on a - line in the compiled template that is past the end of the source template. - - *Martin Emde* - -* Improve reliability of ERB template error highlighting. - Fix infinite loops and crashes in highlighting and - improve tolerance for alternate ERB handlers. - - *Martin Emde* - -* Allow `hidden_field` and `hidden_field_tag` to accept a custom autocomplete value. - - *brendon* - -* Add a new configuration `content_security_policy_nonce_auto` for automatically adding a nonce to the tags affected by the directives specified by the `content_security_policy_nonce_directives` configuration option. - - *francktrouillez* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionview/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionview/CHANGELOG.md) for previous changes. diff --git a/actionview/README.rdoc b/actionview/README.rdoc index 498dbaaffa7f8..dcf47e491ebe7 100644 --- a/actionview/README.rdoc +++ b/actionview/README.rdoc @@ -35,6 +35,6 @@ Bug reports for the Ruby on \Rails project can be filed here: * https://github.com/rails/rails/issues -Feature requests should be discussed on the rails-core mailing list here: +Feature requests should be discussed on the rubyonrails-core forum here: * https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/actionview/Rakefile b/actionview/Rakefile index b7d8008ad808e..ac2db05951ab1 100644 --- a/actionview/Rakefile +++ b/actionview/Rakefile @@ -16,12 +16,18 @@ desc "Run all unit tests" task test: ["test:template", "test:integration:action_pack", "test:integration:active_record"] namespace :test do - task :isolated do + task isolated: :railties do Dir.glob("test/{actionpack,activerecord,template}/**/*_test.rb").all? do |file| sh(Gem.ruby, "-w", "-Ilib:test", file) end || raise("Failures") end + task :railties do + ["action_view/railtie"].all? do |railtie| + sh(Gem.ruby, "-r", railtie, "-e", "'OK'") + end || raise("Failures") + end + Rake::TestTask.new(:template) do |t| t.libs << "test" t.test_files = FileList["test/template/**/*_test.rb"] diff --git a/actionview/lib/action_view.rb b/actionview/lib/action_view.rb index 2fe9b3af6184d..5a1ef0e847cf1 100644 --- a/actionview/lib/action_view.rb +++ b/actionview/lib/action_view.rb @@ -81,6 +81,7 @@ module ActionView autoload :MissingTemplate autoload :ActionViewError autoload :EncodingError + autoload :StrictLocalsError autoload :TemplateError autoload :SyntaxErrorInTemplate autoload :WrongEncodingError @@ -90,6 +91,9 @@ module ActionView autoload :CacheExpiry autoload :TestCase + singleton_class.attr_accessor :render_tracker + self.render_tracker = :regex + def self.eager_load! super ActionView::Helpers.eager_load! diff --git a/actionview/lib/action_view/base.rb b/actionview/lib/action_view/base.rb index 59ae9bd8d16a4..1e865a872dc02 100644 --- a/actionview/lib/action_view/base.rb +++ b/actionview/lib/action_view/base.rb @@ -4,6 +4,7 @@ require "active_support/core_ext/module/attribute_accessors" require "active_support/ordered_options" require "action_view/log_subscriber" +require "action_view/structured_event_subscriber" require "action_view/helpers" require "action_view/context" require "action_view/template" @@ -181,6 +182,10 @@ class Base class_attribute :_routes class_attribute :logger + # Specify whether to omit autocomplete="off" on hidden inputs generated by helpers. + # Configured via `config.action_view.remove_hidden_field_autocomplete` + cattr_accessor :remove_hidden_field_autocomplete, default: false + class << self delegate :erb_trim_mode=, to: "ActionView::Template::Handlers::ERB" @@ -242,8 +247,6 @@ def self.with_context(context, assigns = {}, controller = nil) # :startdoc: def initialize(lookup_context, assigns, controller) # :nodoc: - @_config = ActiveSupport::InheritableOptions.new - @lookup_context = lookup_context @view_renderer = ActionView::Renderer.new @lookup_context diff --git a/actionview/lib/action_view/buffers.rb b/actionview/lib/action_view/buffers.rb index 90ddcc81c3979..08bb3e2709eb3 100644 --- a/actionview/lib/action_view/buffers.rb +++ b/actionview/lib/action_view/buffers.rb @@ -45,7 +45,7 @@ def <<(value) @raw_buffer << if value.html_safe? value else - CGI.escapeHTML(value) + ERB::Util.unwrapped_html_escape(value) end end self diff --git a/actionview/lib/action_view/dependency_tracker.rb b/actionview/lib/action_view/dependency_tracker.rb index dd54c5f61b361..1b076f1debf6c 100644 --- a/actionview/lib/action_view/dependency_tracker.rb +++ b/actionview/lib/action_view/dependency_tracker.rb @@ -36,6 +36,11 @@ def self.remove_tracker(handler) @trackers.delete(handler) end - register_tracker :erb, ERBTracker + case ActionView.render_tracker + when :ruby + register_tracker :erb, RubyTracker + else + register_tracker :erb, ERBTracker + end end end diff --git a/actionview/lib/action_view/dependency_tracker/erb_tracker.rb b/actionview/lib/action_view/dependency_tracker/erb_tracker.rb index d805fcc521cdc..2432ad99b3062 100644 --- a/actionview/lib/action_view/dependency_tracker/erb_tracker.rb +++ b/actionview/lib/action_view/dependency_tracker/erb_tracker.rb @@ -91,7 +91,7 @@ def directory def render_dependencies dependencies = [] - render_calls = source.split(/\brender\b/).drop(1) + render_calls = source.scan(/<%(?:(?:(?!<%).)*?\brender\b((?:(?!%>).)*?))%>/m).flatten render_calls.each do |arguments| add_dependencies(dependencies, arguments, LAYOUT_DEPENDENCY) diff --git a/actionview/lib/action_view/gem_version.rb b/actionview/lib/action_view/gem_version.rb index cc8e22f6645f1..bbcf66d8d7615 100644 --- a/actionview/lib/action_view/gem_version.rb +++ b/actionview/lib/action_view/gem_version.rb @@ -8,7 +8,7 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/actionview/lib/action_view/helpers/asset_tag_helper.rb b/actionview/lib/action_view/helpers/asset_tag_helper.rb index 1a004a759f2b2..61fa394733aa5 100644 --- a/actionview/lib/action_view/helpers/asset_tag_helper.rb +++ b/actionview/lib/action_view/helpers/asset_tag_helper.rb @@ -117,11 +117,11 @@ def javascript_include_tag(*sources) path_options = options.extract!("protocol", "extname", "host", "skip_pipeline").symbolize_keys preload_links = [] use_preload_links_header = options["preload_links_header"].nil? ? preload_links_header : options.delete("preload_links_header") - nopush = options["nopush"].nil? ? true : options.delete("nopush") + nopush = options["nopush"].nil? || options.delete("nopush") crossorigin = options.delete("crossorigin") crossorigin = "anonymous" if crossorigin == true integrity = options["integrity"] - rel = options["type"] == "module" ? "modulepreload" : "preload" + rel = options["type"] == "module" || options["type"] == :module ? "modulepreload" : "preload" sources_tags = sources.uniq.map { |source| href = path_to_javascript(source, path_options) @@ -139,6 +139,8 @@ def javascript_include_tag(*sources) }.merge!(options) if tag_options["nonce"] == true || (!tag_options.key?("nonce") && auto_include_nonce_for_scripts) tag_options["nonce"] = content_security_policy_nonce + elsif tag_options["nonce"] == false + tag_options.delete("nonce") end content_tag("script", "", tag_options) }.join("\n").html_safe @@ -209,7 +211,7 @@ def stylesheet_link_tag(*sources) preload_links = [] crossorigin = options.delete("crossorigin") crossorigin = "anonymous" if crossorigin == true - nopush = options["nopush"].nil? ? true : options.delete("nopush") + nopush = options["nopush"].nil? || options.delete("nopush") integrity = options["integrity"] sources_tags = sources.uniq.map { |source| @@ -227,8 +229,10 @@ def stylesheet_link_tag(*sources) "crossorigin" => crossorigin, "href" => href }.merge!(options) - if tag_options["nonce"] == true || (!tag_options.key?("nonce") && auto_include_nonce_for_styles) + if tag_options["nonce"] == true || (!tag_options.key?("nonce") && auto_include_nonce_for_styles && respond_to?(:content_security_policy_nonce)) tag_options["nonce"] = content_security_policy_nonce + elsif tag_options["nonce"] == false + tag_options.delete("nonce") end if apply_stylesheet_media_default && tag_options["media"].blank? @@ -364,8 +368,9 @@ def preload_link_tag(source, options = {}) crossorigin = options.delete(:crossorigin) crossorigin = "anonymous" if crossorigin == true || (crossorigin.blank? && as_type == "font") integrity = options[:integrity] + fetchpriority = options.delete(:fetchpriority) nopush = options.delete(:nopush) || false - rel = mime_type == "module" ? "modulepreload" : "preload" + rel = mime_type == "module" || mime_type == :module ? "modulepreload" : "preload" add_nonce = content_security_policy_nonce && respond_to?(:request) && request.content_security_policy_nonce_directives&.include?("#{as_type}-src") @@ -380,11 +385,13 @@ def preload_link_tag(source, options = {}) as: as_type, type: mime_type, crossorigin: crossorigin, + fetchpriority: fetchpriority, **options.symbolize_keys) preload_link = "<#{href}>; rel=#{rel}; as=#{as_type}" preload_link += "; type=#{mime_type}" if mime_type preload_link += "; crossorigin=#{crossorigin}" if crossorigin + preload_link += "; fetchpriority=#{fetchpriority}" if fetchpriority preload_link += "; integrity=#{integrity}" if integrity preload_link += "; nonce=#{content_security_policy_nonce}" if add_nonce preload_link += "; nopush" if nopush diff --git a/actionview/lib/action_view/helpers/atom_feed_helper.rb b/actionview/lib/action_view/helpers/atom_feed_helper.rb index 66e3216d6d3e2..a1eeee93ef971 100644 --- a/actionview/lib/action_view/helpers/atom_feed_helper.rb +++ b/actionview/lib/action_view/helpers/atom_feed_helper.rb @@ -170,7 +170,7 @@ def updated(date_or_time = nil) # Creates an entry tag for a specific record and prefills the id using class and id. # - # Options: + # ==== Options # # * :published: Time first published. Defaults to the created_at attribute on the record if one such exists. # * :updated: Time of update. Defaults to the updated_at attribute on the record if one such exists. diff --git a/actionview/lib/action_view/helpers/controller_helper.rb b/actionview/lib/action_view/helpers/controller_helper.rb index 38aa015a911da..d6e45076df91a 100644 --- a/actionview/lib/action_view/helpers/controller_helper.rb +++ b/actionview/lib/action_view/helpers/controller_helper.rb @@ -20,11 +20,15 @@ module ControllerHelper # :nodoc: def assign_controller(controller) if @_controller = controller @_request = controller.request if controller.respond_to?(:request) - @_config = controller.config.inheritable_copy if controller.respond_to?(:config) + if controller.respond_to?(:config) + @_config = controller.config.inheritable_copy + else + @_config = ActiveSupport::InheritableOptions.new + end @_default_form_builder = controller.default_form_builder if controller.respond_to?(:default_form_builder) else @_request ||= nil - @_config ||= nil + @_config = ActiveSupport::InheritableOptions.new @_default_form_builder ||= nil end end diff --git a/actionview/lib/action_view/helpers/date_helper.rb b/actionview/lib/action_view/helpers/date_helper.rb index c5b08a05cb2d6..d9a363c3d5744 100644 --- a/actionview/lib/action_view/helpers/date_helper.rb +++ b/actionview/lib/action_view/helpers/date_helper.rb @@ -136,8 +136,15 @@ def distance_of_time_in_words(from_time, to_time = 0, options = {}) from_year += 1 if from_time.month >= 3 to_year = to_time.year to_year -= 1 if to_time.month < 3 - leap_years = (from_year > to_year) ? 0 : (from_year..to_year).count { |x| Date.leap?(x) } + + leap_years = if from_year > to_year + 0 + else + fyear = from_year - 1 + (to_year / 4 - to_year / 100 + to_year / 400) - (fyear / 4 - fyear / 100 + fyear / 400) + end minute_offset_for_leap_year = leap_years * 1440 + # Discount the leap year days when calculating year distance. # e.g. if there are 20 leap year days between 2 dates having the same day # and month then based on 365 days calculation @@ -179,6 +186,23 @@ def time_ago_in_words(from_time, options = {}) alias_method :distance_of_time_in_words_to_now, :time_ago_in_words + # Like time_ago_in_words, but adds a prefix/suffix depending on whether the time is in the past or future. + # You can use the scope option to customize the translation scope. All other options + # are forwarded to time_ago_in_words. + # + # relative_time_in_words(3.minutes.from_now) # => "in 3 minutes" + # relative_time_in_words(3.minutes.ago) # => "3 minutes ago" + # relative_time_in_words(10.seconds.ago, include_seconds: true) # => "less than 10 seconds ago" + # + # See also #time_ago_in_words + def relative_time_in_words(from_time, options = {}) + now = Time.now + time = distance_of_time_in_words(from_time, now, options.except(:scope)) + key = from_time > now ? :future : :past + + I18n.t(key, time: time, scope: options.fetch(:scope, :'datetime.relative'), locale: options[:locale]) + end + # Returns a set of select tags (one for year, month, and day) pre-selected for accessing a specified date-based # attribute (identified by +method+) on an object assigned to the template (identified by +object+). # diff --git a/actionview/lib/action_view/helpers/form_helper.rb b/actionview/lib/action_view/helpers/form_helper.rb index 4967766029a80..8b97b55156a27 100644 --- a/actionview/lib/action_view/helpers/form_helper.rb +++ b/actionview/lib/action_view/helpers/form_helper.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "cgi" require "action_view/helpers/date_helper" require "action_view/helpers/url_helper" require "action_view/helpers/form_tag_helper" @@ -1632,7 +1631,8 @@ def default_form_builder_class # # A +FormBuilder+ object is associated with a particular model object and # allows you to generate fields associated with the model object. The - # +FormBuilder+ object is yielded when using #form_with or #fields_for. + # +FormBuilder+ object is yielded when using + # {form_with}[rdof-ref:ActionView::Helpers::FormHelper#form_with] or #fields_for. # For example: # # <%= form_with model: @person do |person_form| %> diff --git a/actionview/lib/action_view/helpers/form_options_helper.rb b/actionview/lib/action_view/helpers/form_options_helper.rb index e0256a4a3d4fd..8462b36a5dddb 100644 --- a/actionview/lib/action_view/helpers/form_options_helper.rb +++ b/actionview/lib/action_view/helpers/form_options_helper.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "cgi" require "erb" require "active_support/core_ext/string/output_safety" require "active_support/core_ext/array/extract_options" @@ -370,7 +369,7 @@ def options_for_select(container, selected = nil) html_attributes[:disabled] ||= disabled && option_value_selected?(value, disabled) html_attributes[:value] = value - tag_builder.content_tag_string(:option, text, html_attributes) + tag_builder.option(text, **html_attributes) end.join("\n").html_safe end @@ -496,7 +495,8 @@ def option_groups_from_collection_for_select(collection, group_method, group_lab # # # - # Parameters: + # ==== Parameters + # # * +grouped_options+ - Accepts a nested array or hash of strings. The first value serves as the # label while the second value must be an array of options. The second value can be a # nested array of text-value pairs. See options_for_select for more info. @@ -507,7 +507,8 @@ def option_groups_from_collection_for_select(collection, group_method, group_lab # which will have the +selected+ attribute set. Note: It is possible for this value to match multiple options # as you might have the same option in multiple groups. Each will then get selected="selected". # - # Options: + # ==== Options + # # * :prompt - set to true or a prompt string. When the select element doesn't have a value yet, this # prepends an option with a generic prompt - "Please select" - or the given prompt string. # * :divider - the divider for the options groups. @@ -599,7 +600,8 @@ def time_zone_options_for_select(selected = nil, priority_zones = nil, model = : # Returns a string of option tags for the days of the week. # - # Options: + # ====Options + # # * :index_as_value - Defaults to false, set to true to use the indexes from # I18n.translate("date.day_names") as the values. By default, Sunday is always 0. # * :day_format - The I18n key of the array to use for the weekday options. diff --git a/actionview/lib/action_view/helpers/form_tag_helper.rb b/actionview/lib/action_view/helpers/form_tag_helper.rb index 30cacd8a5aec3..ee0ed105f6092 100644 --- a/actionview/lib/action_view/helpers/form_tag_helper.rb +++ b/actionview/lib/action_view/helpers/form_tag_helper.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "cgi" require "action_view/helpers/content_exfiltration_prevention_helper" require "action_view/helpers/url_helper" require "action_view/helpers/text_helper" @@ -306,7 +305,11 @@ def label_tag(name = nil, content_or_options = nil, options = nil, &block) # # => def hidden_field_tag(name, value = nil, options = {}) - text_field_tag(name, value, options.merge(type: :hidden).with_defaults!(autocomplete: "off")) + html_options = options.merge(type: :hidden) + unless ActionView::Base.remove_hidden_field_autocomplete + html_options[:autocomplete] = "off" unless html_options.key?(:autocomplete) + end + text_field_tag(name, value, html_options) end # Creates a file upload field. If you are using file uploads then you will also need @@ -976,10 +979,15 @@ def range_field_tag(name, value = nil, options = {}) # Creates the hidden UTF-8 enforcer tag. Override this method in a helper # to customize the tag. def utf8_enforcer_tag - # Use raw HTML to ensure the value is written as an HTML entity; it - # needs to be the right character regardless of which encoding the - # browser infers. - ''.html_safe + options = { + type: "hidden", + name: "utf8", + value: "✓".html_safe + } + + options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + + tag(:input, options) end private @@ -1047,9 +1055,9 @@ def form_tag_html(html_options) end def form_tag_with_body(html_options, content) - output = form_tag_html(html_options) - output << content.to_s if content - output.safe_concat("") + extra_tags = extra_tags_for_form(html_options) + html = content_tag(:form, safe_join([extra_tags, content]), html_options) + prevent_content_exfiltration(html) end # see http://www.w3.org/TR/html4/types.html#type-name diff --git a/actionview/lib/action_view/helpers/javascript_helper.rb b/actionview/lib/action_view/helpers/javascript_helper.rb index 97a17d8a933a4..47bffba8f938a 100644 --- a/actionview/lib/action_view/helpers/javascript_helper.rb +++ b/actionview/lib/action_view/helpers/javascript_helper.rb @@ -85,6 +85,8 @@ def javascript_tag(content_or_options_with_block = nil, html_options = {}, &bloc if html_options[:nonce] == true || (!html_options.key?(:nonce) && auto_include_nonce) html_options[:nonce] = content_security_policy_nonce + elsif html_options[:nonce] == false + html_options.delete(:nonce) end content_tag("script", javascript_cdata_section(content), html_options) diff --git a/actionview/lib/action_view/helpers/tag_helper.rb b/actionview/lib/action_view/helpers/tag_helper.rb index 57a18d28e7f97..5d57eee6aedd6 100644 --- a/actionview/lib/action_view/helpers/tag_helper.rb +++ b/actionview/lib/action_view/helpers/tag_helper.rb @@ -44,9 +44,6 @@ module TagHelper PRE_CONTENT_STRINGS["textarea"] = "\n" class TagBuilder # :nodoc: - include CaptureHelper - include OutputSafetyHelper - def self.define_element(name, code_generator:, method_name: name) return if method_defined?(name) @@ -226,17 +223,7 @@ def attributes(attributes) tag_options(attributes.to_h).to_s.strip.html_safe end - def tag_string(name, content = nil, options, escape: true, &block) - content = @view_context.capture(self, &block) if block - - content_tag_string(name, content, options, escape) - end - - def self_closing_tag_string(name, options, escape = true, tag_suffix = " />") - "<#{name}#{tag_options(options, escape)}#{tag_suffix}".html_safe - end - - def content_tag_string(name, content, options, escape = true) + def content_tag_string(name, content, options, escape = true) # :nodoc: tag_options = tag_options(options, escape) if options if escape && content.present? @@ -245,7 +232,7 @@ def content_tag_string(name, content, options, escape = true) "<#{name}#{tag_options}>#{PRE_CONTENT_STRINGS[name]}#{content}".html_safe end - def tag_options(options, escape = true) + def tag_options(options, escape = true) # :nodoc: return if options.blank? output = +"" sep = " " @@ -266,7 +253,7 @@ def tag_options(options, escape = true) tokens = TagHelper.build_tag_values(v) next if tokens.none? - v = safe_join(tokens, " ") + v = @view_context.safe_join(tokens, " ") else v = v.to_s end @@ -287,28 +274,42 @@ def tag_options(options, escape = true) output unless output.empty? end - def boolean_tag_option(key) - %(#{key}="#{key}") - end + private + def tag_string(name, content = nil, options, escape: true, &block) + if content && block_given? + content += @view_context.capture(self, &block) + elsif block_given? + content = @view_context.capture(self, &block) + end - def tag_option(key, value, escape) - key = ERB::Util.xml_name_escape(key) if escape - - case value - when Array, Hash - value = TagHelper.build_tag_values(value) if key.to_s == "class" - value = escape ? safe_join(value, " ") : value.join(" ") - when Regexp - value = escape ? ERB::Util.unwrapped_html_escape(value.source) : value.source - else - value = escape ? ERB::Util.unwrapped_html_escape(value) : value.to_s + content_tag_string(name, content, options, escape) end - value = value.gsub('"', """) if value.include?('"') - %(#{key}="#{value}") - end + def self_closing_tag_string(name, options, escape = true, tag_suffix = " />") + "<#{name}#{tag_options(options, escape)}#{tag_suffix}".html_safe + end + + def boolean_tag_option(key) + %(#{key}="#{key}") + end + + def tag_option(key, value, escape) + key = ERB::Util.xml_name_escape(key) if escape + + case value + when Array, Hash + value = TagHelper.build_tag_values(value) if key.to_s == "class" + value = escape ? @view_context.safe_join(value, " ") : value.join(" ") + when Regexp + value = escape ? ERB::Util.unwrapped_html_escape(value.source) : value.source + else + value = escape ? ERB::Util.unwrapped_html_escape(value) : value.to_s + end + value = value.gsub('"', """) if value.include?('"') + + %(#{key}="#{value}") + end - private def prefix_tag_option(prefix, key, value, escape) key = "#{prefix}-#{key.to_s.dasherize}" unless value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(BigDecimal) diff --git a/actionview/lib/action_view/helpers/tags/base.rb b/actionview/lib/action_view/helpers/tags/base.rb index 02225aad2de22..86f413760a72d 100644 --- a/actionview/lib/action_view/helpers/tags/base.rb +++ b/actionview/lib/action_view/helpers/tags/base.rb @@ -80,30 +80,32 @@ def retrieve_autoindex(pre_match) end end - def add_default_name_and_id_for_value(tag_value, options) + def add_default_name_and_field_for_value(tag_value, options, field = "id") if tag_value.nil? - add_default_name_and_id(options) + add_default_name_and_field(options, field) else - specified_id = options["id"] - add_default_name_and_id(options) + specified_field = options[field] + add_default_name_and_field(options, field) - if specified_id.blank? && options["id"].present? - options["id"] += "_#{sanitized_value(tag_value)}" + if specified_field.blank? && options[field].present? + options[field] += "_#{sanitized_value(tag_value)}" end end end + alias_method :add_default_name_and_id_for_value, :add_default_name_and_field_for_value - def add_default_name_and_id(options) + def add_default_name_and_field(options, field = "id") index = name_and_id_index(options) options["name"] = options.fetch("name") { tag_name(options["multiple"], index) } if generate_ids? - options["id"] = options.fetch("id") { tag_id(index, options.delete("namespace")) } + options[field] = options.fetch(field) { tag_id(index, options.delete("namespace")) } if namespace = options.delete("namespace") - options["id"] = options["id"] ? "#{namespace}_#{options['id']}" : namespace + options[field] = options[field] ? "#{namespace}_#{options[field]}" : namespace end end end + alias_method :add_default_name_and_id, :add_default_name_and_field def tag_name(multiple = false, index = nil) @template_object.field_name(@object_name, sanitized_method_name, multiple: multiple, index: index) diff --git a/actionview/lib/action_view/helpers/tags/check_box.rb b/actionview/lib/action_view/helpers/tags/check_box.rb index cf347b5e01f07..3976c23d98575 100644 --- a/actionview/lib/action_view/helpers/tags/check_box.rb +++ b/actionview/lib/action_view/helpers/tags/check_box.rb @@ -21,10 +21,10 @@ def render options["checked"] = "checked" if input_checked?(options) if options["multiple"] - add_default_name_and_id_for_value(@checked_value, options) + add_default_name_and_field_for_value(@checked_value, options) options.delete("multiple") else - add_default_name_and_id(options) + add_default_name_and_field(options) end include_hidden = options.delete("include_hidden") { true } @@ -57,7 +57,13 @@ def checked?(value) end def hidden_field_for_checkbox(options) - @unchecked_value ? tag("input", options.slice("name", "disabled", "form").merge!("type" => "hidden", "value" => @unchecked_value, "autocomplete" => "off")) : "".html_safe + if @unchecked_value + tag_options = options.slice("name", "disabled", "form").merge!("type" => "hidden", "value" => @unchecked_value) + tag_options["autocomplete"] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + tag("input", tag_options) + else + "".html_safe + end end end end diff --git a/actionview/lib/action_view/helpers/tags/file_field.rb b/actionview/lib/action_view/helpers/tags/file_field.rb index 236078ad615b0..d34def5c01f51 100644 --- a/actionview/lib/action_view/helpers/tags/file_field.rb +++ b/actionview/lib/action_view/helpers/tags/file_field.rb @@ -7,7 +7,7 @@ class FileField < TextField # :nodoc: def render include_hidden = @options.delete(:include_hidden) options = @options.stringify_keys - add_default_name_and_id(options) + add_default_name_and_field(options) if options["multiple"] && include_hidden hidden_field_for_multiple_file(options) + super @@ -18,7 +18,9 @@ def render private def hidden_field_for_multiple_file(options) - tag("input", "name" => options["name"], "type" => "hidden", "value" => "", "autocomplete" => "off") + tag_options = { "name" => options["name"], "type" => "hidden", "value" => "" } + tag_options["autocomplete"] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + tag("input", tag_options) end end end diff --git a/actionview/lib/action_view/helpers/tags/hidden_field.rb b/actionview/lib/action_view/helpers/tags/hidden_field.rb index 696e7446a8597..9735cc60a4f11 100644 --- a/actionview/lib/action_view/helpers/tags/hidden_field.rb +++ b/actionview/lib/action_view/helpers/tags/hidden_field.rb @@ -5,7 +5,7 @@ module Helpers module Tags # :nodoc: class HiddenField < TextField # :nodoc: def render - @options.reverse_merge!(autocomplete: "off") + @options.reverse_merge!(autocomplete: "off") unless ActionView::Base.remove_hidden_field_autocomplete super end end diff --git a/actionview/lib/action_view/helpers/tags/label.rb b/actionview/lib/action_view/helpers/tags/label.rb index 157fca057ebfa..c1c8c8efe381f 100644 --- a/actionview/lib/action_view/helpers/tags/label.rb +++ b/actionview/lib/action_view/helpers/tags/label.rb @@ -48,18 +48,11 @@ def initialize(object_name, method_name, template_object, content_or_options = n def render(&block) options = @options.stringify_keys tag_value = options.delete("value") - name_and_id = options.dup - if name_and_id["for"] - name_and_id["id"] = name_and_id["for"] - else - name_and_id.delete("id") - end - - add_default_name_and_id_for_value(tag_value, name_and_id) + add_default_name_and_field_for_value(tag_value, options, "for") options.delete("index") + options.delete("name") options.delete("namespace") - options["for"] = name_and_id["id"] unless options.key?("for") builder = LabelBuilder.new(@template_object, @object_name, @method_name, @object, tag_value) @@ -71,7 +64,7 @@ def render(&block) render_component(builder) end - label_tag(name_and_id["id"], content, options) + label_tag(options["for"], content, options) end private diff --git a/actionview/lib/action_view/helpers/tags/radio_button.rb b/actionview/lib/action_view/helpers/tags/radio_button.rb index 4ce6c9f6bcf77..aafec1e086d39 100644 --- a/actionview/lib/action_view/helpers/tags/radio_button.rb +++ b/actionview/lib/action_view/helpers/tags/radio_button.rb @@ -18,7 +18,7 @@ def render options["type"] = "radio" options["value"] = @tag_value options["checked"] = "checked" if input_checked?(options) - add_default_name_and_id_for_value(@tag_value, options) + add_default_name_and_field_for_value(@tag_value, options) tag("input", options) end diff --git a/actionview/lib/action_view/helpers/tags/select.rb b/actionview/lib/action_view/helpers/tags/select.rb index 0e3de4afd8540..e0a3e69ce4298 100644 --- a/actionview/lib/action_view/helpers/tags/select.rb +++ b/actionview/lib/action_view/helpers/tags/select.rb @@ -37,7 +37,12 @@ def render # [nil, []] # { nil => [] } def grouped_choices? - !@choices.blank? && @choices.first.respond_to?(:second) && Array === @choices.first.second + return false if @choices.blank? + + first_choice = @choices.first + return false unless first_choice.is_a?(Enumerable) + + first_choice.second.is_a?(Array) end end end diff --git a/actionview/lib/action_view/helpers/tags/select_renderer.rb b/actionview/lib/action_view/helpers/tags/select_renderer.rb index 0c80694e5a30b..bfa09f2bed3bb 100644 --- a/actionview/lib/action_view/helpers/tags/select_renderer.rb +++ b/actionview/lib/action_view/helpers/tags/select_renderer.rb @@ -11,7 +11,7 @@ def select_content_tag(option_tags, options, html_options) html_options[prop.to_s] = options.delete(prop) if options.key?(prop) && !html_options.key?(prop.to_s) end - add_default_name_and_id(html_options) + add_default_name_and_field(html_options) if placeholder_required?(html_options) raise ArgumentError, "include_blank cannot be false for a required field." if options[:include_blank] == false @@ -22,7 +22,9 @@ def select_content_tag(option_tags, options, html_options) select = content_tag("select", add_options(option_tags, options, value), html_options) if html_options["multiple"] && options.fetch(:include_hidden, true) - tag("input", disabled: html_options["disabled"], name: html_options["name"], type: "hidden", value: "", autocomplete: "off") + select + tag_options = { disabled: html_options["disabled"], name: html_options["name"], type: "hidden", value: "" } + tag_options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + tag("input", tag_options) + select else select end @@ -37,7 +39,7 @@ def add_options(option_tags, options, value = nil) if options[:include_blank] content = (options[:include_blank] if options[:include_blank].is_a?(String)) label = (" " unless content) - option_tags = tag_builder.content_tag_string("option", content, value: "", label: label) + "\n" + option_tags + option_tags = tag_builder.option(content, value: "", label: label) + "\n" + option_tags end if value.blank? && options[:prompt] @@ -45,7 +47,7 @@ def add_options(option_tags, options, value = nil) prompt_opts[:disabled] = true if options[:disabled] == "" prompt_opts[:selected] = true if options[:selected] == "" end - option_tags = tag_builder.content_tag_string("option", prompt_text(options[:prompt]), tag_options) + "\n" + option_tags + option_tags = tag_builder.option(prompt_text(options[:prompt]), **tag_options) + "\n" + option_tags end option_tags diff --git a/actionview/lib/action_view/helpers/tags/text_area.rb b/actionview/lib/action_view/helpers/tags/text_area.rb index 4519082ff6faa..75a8510d4ca79 100644 --- a/actionview/lib/action_view/helpers/tags/text_area.rb +++ b/actionview/lib/action_view/helpers/tags/text_area.rb @@ -10,7 +10,7 @@ class TextArea < Base # :nodoc: def render options = @options.stringify_keys - add_default_name_and_id(options) + add_default_name_and_field(options) if size = options.delete("size") options["cols"], options["rows"] = size.split("x") if size.respond_to?(:split) diff --git a/actionview/lib/action_view/helpers/tags/text_field.rb b/actionview/lib/action_view/helpers/tags/text_field.rb index c579e9e79fa29..9b56d1564eed2 100644 --- a/actionview/lib/action_view/helpers/tags/text_field.rb +++ b/actionview/lib/action_view/helpers/tags/text_field.rb @@ -13,7 +13,7 @@ def render options["size"] = options["maxlength"] unless options.key?("size") options["type"] ||= field_type options["value"] = options.fetch("value") { value_before_type_cast } unless field_type == "file" - add_default_name_and_id(options) + add_default_name_and_field(options) tag("input", options) end diff --git a/actionview/lib/action_view/helpers/text_helper.rb b/actionview/lib/action_view/helpers/text_helper.rb index bf837d5275aa2..3139ee410c44f 100644 --- a/actionview/lib/action_view/helpers/text_helper.rb +++ b/actionview/lib/action_view/helpers/text_helper.rb @@ -260,7 +260,14 @@ def excerpt(text, phrase, options = {}) prefix, first_part = cut_excerpt_part(:first, first_part, separator, options) postfix, second_part = cut_excerpt_part(:second, second_part, separator, options) - affix = [first_part, separator, phrase, separator, second_part].join.strip + affix = [ + first_part, + !first_part.empty? ? separator : "", + phrase, + !second_part.empty? ? separator : "", + second_part + ].join.strip + [prefix, affix, postfix].join end @@ -271,7 +278,7 @@ def excerpt(text, phrase, options = {}) # # The word will be pluralized using rules defined for the locale # (you must define your own inflection rules for languages other than English). - # See ActiveSupport::Inflector.pluralize + # See ActiveSupport::Inflector.pluralize. # # pluralize(1, 'person') # # => "1 person" @@ -346,7 +353,7 @@ def word_wrap(text, line_width: 80, break_sequence: "\n") # ==== Options # * :sanitize - If +false+, does not sanitize +text+. # * :sanitize_options - Any extra options you want appended to the sanitize. - # * :wrapper_tag - String representing the wrapper tag, defaults to "p" + # * :wrapper_tag - String representing the wrapper tag, defaults to "p". # # ==== Examples # my_text = "Here is some basic text...\n...with a line break." diff --git a/actionview/lib/action_view/helpers/url_helper.rb b/actionview/lib/action_view/helpers/url_helper.rb index cfeff96838bd4..f305c11a275ad 100644 --- a/actionview/lib/action_view/helpers/url_helper.rb +++ b/actionview/lib/action_view/helpers/url_helper.rb @@ -341,8 +341,9 @@ def button_to(name = nil, options = nil, html_options = nil, &block) inner_tags = method_tag.safe_concat(button).safe_concat(request_token_tag) if params to_form_params(params).each do |param| - inner_tags.safe_concat tag(:input, type: "hidden", name: param[:name], value: param[:value], - autocomplete: "off") + options = { type: "hidden", name: param[:name], value: param[:value] } + options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + inner_tags.safe_concat tag(:input, **options) end end html = content_tag("form", inner_tags, form_options) @@ -538,24 +539,47 @@ def mail_to(email_address, name = nil, html_options = {}, &block) # current_page?('http://www.example.com/shop/checkout?order=desc&page=1') # # => true # - # Let's say we're in the http://www.example.com/products action with method POST in case of invalid product. + # Different actions may share the same URL path but have a different HTTP method. Let's say we + # sent a POST to http://www.example.com/products and rendered a validation error. # # current_page?(controller: 'product', action: 'index') # # => false # + # current_page?(controller: 'product', action: 'create') + # # => false + # + # current_page?(controller: 'product', action: 'create', method: :post) + # # => true + # + # current_page?(controller: 'product', action: 'index', method: [:get, :post]) + # # => true + # # We can also pass in the symbol arguments instead of strings. # - def current_page?(options = nil, check_parameters: false, **options_as_kwargs) + def current_page?(options = nil, check_parameters: false, method: :get, **options_as_kwargs) unless request raise "You cannot use helpers that need to determine the current " \ "page unless your view context provides a Request object " \ "in a #request method" end - return false unless request.get? || request.head? + if options.is_a?(Hash) + check_parameters = options.delete(:check_parameters) { check_parameters } + method = options.delete(:method) { method } + else + options ||= options_as_kwargs + end + + method_matches = case method + when :get + request.get? || request.head? + when Array + method.include?(request.method_symbol) || (method.include?(:get) && request.head?) + else + method == request.method_symbol + end + return false unless method_matches - options ||= options_as_kwargs - check_parameters ||= options.is_a?(Hash) && options.delete(:check_parameters) url_string = URI::RFC2396_PARSER.unescape(url_for(options)).force_encoding(Encoding::BINARY) # We ignore any extra parameters in the request_uri if the @@ -751,14 +775,18 @@ def token_tag(token = nil, form_options: {}) else token end - tag(:input, type: "hidden", name: request_forgery_protection_token.to_s, value: token, autocomplete: "off") + options = { type: "hidden", name: request_forgery_protection_token.to_s, value: token } + options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + tag(:input, **options) else "" end end def method_tag(method) - tag("input", type: "hidden", name: "_method", value: method.to_s, autocomplete: "off") + options = { type: "hidden", name: "_method", value: method.to_s } + options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + tag("input", **options) end # Returns an array of hashes each containing :name and :value keys diff --git a/actionview/lib/action_view/locale/en.yml b/actionview/lib/action_view/locale/en.yml index 8a56f147b8ca1..ae1e0c1d17fa3 100644 --- a/actionview/lib/action_view/locale/en.yml +++ b/actionview/lib/action_view/locale/en.yml @@ -43,6 +43,9 @@ hour: "Hour" minute: "Minute" second: "Seconds" + relative: + future: "in %{time}" + past: "%{time} ago" helpers: select: diff --git a/actionview/lib/action_view/log_subscriber.rb b/actionview/lib/action_view/log_subscriber.rb index 3582aee4e1e3a..024b59984fe4c 100644 --- a/actionview/lib/action_view/log_subscriber.rb +++ b/actionview/lib/action_view/log_subscriber.rb @@ -3,12 +3,11 @@ require "active_support/log_subscriber" module ActionView - # = Action View Log Subscriber - # - # Provides functionality so that \Rails can output logs from Action View. - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::EventReporter::LogSubscriber # :nodoc: VIEWS_PATTERN = /^app\/views\// + self.namespace = "action_view" + def initialize @root = nil super @@ -16,48 +15,58 @@ def initialize def render_template(event) info do - message = +" Rendered #{from_rails_root(event.payload[:identifier])}" - message << " within #{from_rails_root(event.payload[:layout])}" if event.payload[:layout] - message << " (Duration: #{event.duration.round(1)}ms | GC: #{event.gc_time.round(1)}ms)" + message = +" Rendered #{from_rails_root(event[:payload][:identifier])}" + message << " within #{from_rails_root(event[:payload][:layout])}" if event[:payload][:layout] + message << " (Duration: #{event[:payload][:duration_ms].round(1)}ms | GC: #{event[:payload][:gc_ms].round(1)}ms)" end end - subscribe_log_level :render_template, :debug + event_log_level :render_template, :debug def render_partial(event) debug do - message = +" Rendered #{from_rails_root(event.payload[:identifier])}" - message << " within #{from_rails_root(event.payload[:layout])}" if event.payload[:layout] - message << " (Duration: #{event.duration.round(1)}ms | GC: #{event.gc_time.round(1)}ms)" - message << " #{cache_message(event.payload)}" unless event.payload[:cache_hit].nil? + message = +" Rendered #{from_rails_root(event[:payload][:identifier])}" + message << " within #{from_rails_root(event[:payload][:layout])}" if event[:payload][:layout] + message << " (Duration: #{event[:payload][:duration_ms].round(1)}ms | GC: #{event[:payload][:gc_ms].round(1)}ms)" + message << " #{cache_message(event[:payload])}" unless event[:payload][:cache_hit].nil? message end end - subscribe_log_level :render_partial, :debug + event_log_level :render_partial, :debug def render_layout(event) info do - message = +" Rendered layout #{from_rails_root(event.payload[:identifier])}" - message << " (Duration: #{event.duration.round(1)}ms | GC: #{event.gc_time.round(1)}ms)" + message = +" Rendered layout #{from_rails_root(event[:payload][:identifier])}" + message << " (Duration: #{event[:payload][:duration_ms].round(1)}ms | GC: #{event[:payload][:gc_ms].round(1)}ms)" end end - subscribe_log_level :render_layout, :info + event_log_level :render_layout, :info def render_collection(event) - identifier = event.payload[:identifier] || "templates" + identifier = event[:payload][:identifier] || "templates" debug do message = +" Rendered collection of #{from_rails_root(identifier)}" - message << " within #{from_rails_root(event.payload[:layout])}" if event.payload[:layout] - message << " #{render_count(event.payload)} (Duration: #{event.duration.round(1)}ms | GC: #{event.gc_time.round(1)}ms)" + message << " within #{from_rails_root(event[:payload][:layout])}" if event[:payload][:layout] + message << " #{render_count(event[:payload])} (Duration: #{event[:payload][:duration_ms].round(1)}ms | GC: #{event[:payload][:gc_ms].round(1)}ms)" message end end - subscribe_log_level :render_collection, :debug + event_log_level :render_collection, :debug + + def render_start(event) + debug do + payload = event[:payload] - module Utils # :nodoc: - def logger - ActionView::Base.logger + message = +" Rendering #{payload[:is_layout] ? "layout " : ""}#{from_rails_root(payload[:identifier])}" + message << " within #{from_rails_root(payload[:layout])}" if payload[:layout] + message end + end + event_log_level :render_start, :debug + + def self.default_logger + ActionView::Base.logger + end private def from_rails_root(string) @@ -69,64 +78,26 @@ def from_rails_root(string) def rails_root # :doc: @root ||= "#{Rails.root}/" end - end - - include Utils - - class Start # :nodoc: - include Utils - def start(name, id, payload) - return unless logger - logger.debug do - qualifier = - if name == "render_template.action_view" - "" - elsif name == "render_layout.action_view" - "layout " - end - - return unless qualifier - - message = +" Rendering #{qualifier}#{from_rails_root(payload[:identifier])}" - message << " within #{from_rails_root(payload[:layout])}" if payload[:layout] - message + def render_count(payload) # :doc: + if payload[:cache_hits] + "[#{payload[:cache_hits]} / #{payload[:count]} cache hits]" + else + "[#{payload[:count]} times]" end end - def finish(name, id, payload) - end - - def silenced?(_) - logger.nil? || !logger.debug? - end - end - - def self.attach_to(*) - ActiveSupport::Notifications.subscribe("render_template.action_view", ActionView::LogSubscriber::Start.new) - ActiveSupport::Notifications.subscribe("render_layout.action_view", ActionView::LogSubscriber::Start.new) - - super - end - - private - def render_count(payload) # :doc: - if payload[:cache_hits] - "[#{payload[:cache_hits]} / #{payload[:count]} cache hits]" - else - "[#{payload[:count]} times]" - end - end - - def cache_message(payload) # :doc: - case payload[:cache_hit] - when :hit - "[cache hit]" - when :miss - "[cache miss]" + def cache_message(payload) # :doc: + case payload[:cache_hit] + when :hit + "[cache hit]" + when :miss + "[cache miss]" + end end - end end end -ActionView::LogSubscriber.attach_to :action_view +ActiveSupport.event_reporter.subscribe( + ActionView::LogSubscriber.new, &ActionView::LogSubscriber.subscription_filter +) diff --git a/actionview/lib/action_view/railtie.rb b/actionview/lib/action_view/railtie.rb index 79af7e8247800..01df2c4100bd6 100644 --- a/actionview/lib/action_view/railtie.rb +++ b/actionview/lib/action_view/railtie.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "action_view" require "rails" +require "action_view" module ActionView # = Action View Railtie @@ -78,8 +78,13 @@ class Railtie < Rails::Engine # :nodoc: end config.after_initialize do |app| + config.after_initialize do + ActionView.render_tracker = config.action_view.render_tracker + end + ActiveSupport.on_load(:action_view) do app.config.action_view.each do |k, v| + next if k == :render_tracker send "#{k}=", v end end diff --git a/actionview/lib/action_view/record_identifier.rb b/actionview/lib/action_view/record_identifier.rb index 6796997cdbb89..46e2c214a465a 100644 --- a/actionview/lib/action_view/record_identifier.rb +++ b/actionview/lib/action_view/record_identifier.rb @@ -101,6 +101,27 @@ def dom_id(record_or_class, prefix = nil) end end + # The DOM target convention is to concatenate any number of parameters into a string. + # Records are passed through dom_id, while string and symbols are retained. + # + # dom_target(Post.find(45)) # => "post_45" + # dom_target(Post.find(45), :edit) # => "post_45_edit" + # dom_target(Post.find(45), :edit, :special) # => "post_45_edit_special" + # dom_target(Post.find(45), Comment.find(1)) # => "post_45_comment_1" + def dom_target(*objects) + objects.map! do |object| + case object + when Symbol, String + object + when Class + dom_class(object) + else + dom_id(object) + end + end + objects.join(JOIN) + end + private # Returns a string representation of the key attribute(s) that is suitable for use in an HTML DOM id. # This can be overwritten to customize the default generated string representation if desired. diff --git a/actionview/lib/action_view/renderer/collection_renderer.rb b/actionview/lib/action_view/renderer/collection_renderer.rb index 64e3f8668d30a..820ac3c9f0658 100644 --- a/actionview/lib/action_view/renderer/collection_renderer.rb +++ b/actionview/lib/action_view/renderer/collection_renderer.rb @@ -167,10 +167,10 @@ def render_collection(collection, view, path, template, layout, block) collection_body = if template cache_collection_render(payload, view, template, collection) do |filtered_collection| - collection_with_template(view, template, layout, filtered_collection) + collection_with_template(view, template, layout, filtered_collection, &block) end else - collection_with_template(view, nil, layout, collection) + collection_with_template(view, nil, layout, collection, &block) end return RenderedCollection.empty(@lookup_context.formats.first) if collection_body.empty? @@ -179,7 +179,7 @@ def render_collection(collection, view, path, template, layout, block) end end - def collection_with_template(view, template, layout, collection) + def collection_with_template(view, template, layout, collection, &block) locals = @locals cache = {} @@ -194,7 +194,7 @@ def collection_with_template(view, template, layout, collection) _template = (cache[path] ||= (template || find_template(path, @locals.keys + [as, counter, iteration]))) - content = _template.render(view, locals, implicit_locals: [counter, iteration]) + content = _template.render(view, locals, implicit_locals: [counter, iteration], &block) content = layout.render(view, locals) { content } if layout partial_iteration.iterate! build_rendered_template(content, _template) diff --git a/actionview/lib/action_view/renderer/partial_renderer.rb b/actionview/lib/action_view/renderer/partial_renderer.rb index 8f138264c27fc..2f0ad2d310a37 100644 --- a/actionview/lib/action_view/renderer/partial_renderer.rb +++ b/actionview/lib/action_view/renderer/partial_renderer.rb @@ -48,6 +48,22 @@ module ActionView # # <%= render partial: "account", locals: { user: @buyer } %> # + # == \Rendering variants of a partial + # + # The :variants option can be used to render a different template variant of a partial. For instance: + # + # <%= render partial: "account", variants: :mobile %> + # + # This will render _account.html+mobile.erb. This option also accepts multiple variants + # like so: + # + # <%= render partial: "account", variants: [:desktop, :mobile] %> + # + # This will look for the following templates and render the first one that exists: + # * _account.html+desktop.erb + # * _account.html+mobile.erb + # * _account.html.erb + # # == \Rendering a collection of partials # # The example of partial use describes a familiar pattern where a template needs to iterate over an array and diff --git a/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb index 6ee720aa4896b..94350f4f74dc1 100644 --- a/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb +++ b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb @@ -51,12 +51,20 @@ def cache_collection_render(instrumentation_payload, view, template, collection) end end + def callable_cache_key + if @options[:cached].is_a?(Hash) && @options[:cached][:key].respond_to?(:call) + @options[:cached][:key] + elsif @options[:cached].respond_to?(:call) + @options[:cached] + end + end + def callable_cache_key? - @options[:cached].respond_to?(:call) + callable_cache_key.present? end def collection_by_cache_keys(view, template, collection) - seed = callable_cache_key? ? @options[:cached] : ->(i) { i } + seed = callable_cache_key? ? callable_cache_key : ->(i) { i } digest_path = view.digest_path_from_template(template) collection.preload! if callable_cache_key? @@ -111,7 +119,8 @@ def fetch_or_cache_partial(cached_partials, template, order_by:) end unless entries_to_write.empty? - collection_cache.write_multi(entries_to_write) + expires_in = @options[:cached][:expires_in] if @options[:cached].is_a?(Hash) + collection_cache.write_multi(entries_to_write, expires_in: expires_in) end keyed_partials diff --git a/actionview/lib/action_view/structured_event_subscriber.rb b/actionview/lib/action_view/structured_event_subscriber.rb new file mode 100644 index 0000000000000..0b16b944bf3db --- /dev/null +++ b/actionview/lib/action_view/structured_event_subscriber.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "active_support/structured_event_subscriber" + +module ActionView + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + VIEWS_PATTERN = /^app\/views\// + + def initialize + @root = nil + super + end + + def render_template(event) + emit_debug_event("action_view.render_template", + identifier: from_rails_root(event.payload[:identifier]), + layout: from_rails_root(event.payload[:layout]), + duration_ms: event.duration.round(2), + gc_ms: event.gc_time.round(2), + ) + end + debug_only :render_template + + def render_partial(event) + emit_debug_event("action_view.render_partial", + identifier: from_rails_root(event.payload[:identifier]), + layout: from_rails_root(event.payload[:layout]), + duration_ms: event.duration.round(2), + gc_ms: event.gc_time.round(2), + cache_hit: event.payload[:cache_hit], + ) + end + debug_only :render_partial + + def render_layout(event) + emit_event("action_view.render_layout", + identifier: from_rails_root(event.payload[:identifier]), + duration_ms: event.duration.round(2), + gc_ms: event.gc_time.round(2), + ) + end + debug_only :render_layout + + def render_collection(event) + emit_debug_event("action_view.render_collection", + identifier: from_rails_root(event.payload[:identifier] || "templates"), + layout: from_rails_root(event.payload[:layout]), + duration_ms: event.duration.round(2), + gc_ms: event.gc_time.round(2), + cache_hits: event.payload[:cache_hits], + count: event.payload[:count], + ) + end + debug_only :render_collection + + module Utils # :nodoc: + private + def from_rails_root(string) + return unless string + + string = string.sub("#{rails_root}/", "") + string.sub!(VIEWS_PATTERN, "") + string + end + + def rails_root # :doc: + @root ||= Rails.try(:root) + end + end + + include Utils + + class Start # :nodoc: + include Utils + + def start(name, id, payload) + ActiveSupport.event_reporter.debug("action_view.render_start", + is_layout: name == "render_layout.action_view", + identifier: from_rails_root(payload[:identifier]), + layout: from_rails_root(payload[:layout]), + ) + end + + def finish(name, id, payload) + end + end + + def self.attach_to(*) + ActiveSupport::Notifications.subscribe("render_template.action_view", Start.new) + ActiveSupport::Notifications.subscribe("render_layout.action_view", Start.new) + + super + end + end +end + +ActionView::StructuredEventSubscriber.attach_to :action_view diff --git a/actionview/lib/action_view/template.rb b/actionview/lib/action_view/template.rb index b2cdf21cb186b..faa44e3f131e7 100644 --- a/actionview/lib/action_view/template.rb +++ b/actionview/lib/action_view/template.rb @@ -7,7 +7,7 @@ module ActionView class Template extend ActiveSupport::Autoload - STRICT_LOCALS_REGEX = /\#\s+locals:\s+\((.*)\)/ + STRICT_LOCALS_REGEX = /\#\s+locals:\s+\((.*?)\)(?=\s*-?%>|\s*$)/m # === Encodings in ActionView::Template # @@ -366,7 +366,7 @@ def encode! def strict_locals! if @strict_locals == NONE self.source.sub!(STRICT_LOCALS_REGEX, "") - @strict_locals = $1 + @strict_locals = $1&.rstrip return if @strict_locals.nil? # Magic comment not found diff --git a/actionview/lib/action_view/template/handlers/erb.rb b/actionview/lib/action_view/template/handlers/erb.rb index 45c9270d96b9e..2272bb4bc19ec 100644 --- a/actionview/lib/action_view/template/handlers/erb.rb +++ b/actionview/lib/action_view/template/handlers/erb.rb @@ -86,7 +86,7 @@ def call(template, source) } if ActionView::Base.annotate_rendered_view_with_filenames && template.format == :html - options[:preamble] = "@output_buffer.safe_append='';" + options[:preamble] = "@output_buffer.safe_append='';" options[:postamble] = "@output_buffer.safe_append='';@output_buffer" end diff --git a/actionview/lib/action_view/template/handlers/erb/erubi.rb b/actionview/lib/action_view/template/handlers/erb/erubi.rb index b26a38ac1c849..174acfe07e79b 100644 --- a/actionview/lib/action_view/template/handlers/erb/erubi.rb +++ b/actionview/lib/action_view/template/handlers/erb/erubi.rb @@ -18,7 +18,7 @@ def initialize(input, properties = {}) properties[:preamble] ||= "" properties[:postamble] ||= "#{properties[:bufvar]}" - # Tell Eruby that whether template will be compiled with `frozen_string_literal: true` + # Tell Erubi whether the template will be compiled with `frozen_string_literal: true` properties[:freeze_template_literals] = !Template.frozen_string_literal properties[:escapefunc] = "" diff --git a/actionview/lib/action_view/test_case.rb b/actionview/lib/action_view/test_case.rb index 623db125a65f1..c0cc707845f9a 100644 --- a/actionview/lib/action_view/test_case.rb +++ b/actionview/lib/action_view/test_case.rb @@ -299,7 +299,6 @@ def _routes class RenderedViewContent < String # :nodoc: end - # Need to experiment if this priority is the best one: rendered => output_buffer class RenderedViewsCollection def initialize @rendered_views ||= Hash.new { |hash, key| hash[key] = [] } diff --git a/actionview/test/actionpack/controller/render_test.rb b/actionview/test/actionpack/controller/render_test.rb index 8c2a9df0b4adc..278a9a1b0b990 100644 --- a/actionview/test/actionpack/controller/render_test.rb +++ b/actionview/test/actionpack/controller/render_test.rb @@ -1513,6 +1513,8 @@ def test_template_annotations get :greeting end + assert_match(/| -->)/, @response.body) + assert_includes @response.body, " + # <%= image_tag user.avatar.variant(resize_to_fill: [100, 100, { crop: :centre }]) %> + # + # If migrating an existing application between MiniMagick and Vips, you will + # need to update processor-specific options: + # + # + # <%= image_tag user.avatar.variant(resize_to_limit: [100, 100], format: :jpeg, + # sampling_factor: "4:2:0", strip: true, interlace: "JPEG", colorspace: "sRGB", quality: 80) %> + # + # + # <%= image_tag user.avatar.variant(resize_to_limit: [100, 100], format: :jpeg, + # saver: { subsample_mode: "on", strip: true, interlace: true, quality: 80 }) %> + # def variant(transformations) if variable? variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(default_variant_transformations)) @@ -98,18 +164,6 @@ def representable? variable? || previewable? end - def preview_image_needed_before_processing_variants? # :nodoc: - previewable? && !preview_image.attached? - end - - def create_preview_image_later(variations) # :nodoc: - ActiveStorage::PreviewImageJob.perform_later(self, variations) if representable? - end - - def preprocessed(transformations) # :nodoc: - ActiveStorage::TransformJob.perform_later(self, transformations) if representable? - end - private def default_variant_transformations { format: default_variant_format } diff --git a/activestorage/app/models/active_storage/named_variant.rb b/activestorage/app/models/active_storage/named_variant.rb index 8bd684c6cf4e0..17599746f212c 100644 --- a/activestorage/app/models/active_storage/named_variant.rb +++ b/activestorage/app/models/active_storage/named_variant.rb @@ -3,19 +3,34 @@ class ActiveStorage::NamedVariant # :nodoc: attr_reader :transformations, :preprocessed - def initialize(transformations) - @preprocessed = transformations[:preprocessed] - @transformations = transformations.except(:preprocessed) - end + def initialize(options) + @preprocessed = options[:preprocessed] + @process_option = options[:process] + @transformations = options.except(:preprocessed, :process) - def preprocessed?(record) - case preprocessed - when Symbol - record.send(preprocessed) - when Proc - preprocessed.call(record) - else - preprocessed + if options.key?(:preprocessed) + ActiveStorage.deprecator.warn(<<~MSG.squish) + The :preprocessed option is deprecated and will be removed in Rails 9.0. + Use the :process option instead. Replace `preprocessed: true` with `process: :later` + and `preprocessed: false` with `process: :lazily`. + MSG end end + + def process(record) + return @process_option if @process_option + preprocessed?(record) ? :later : :lazily + end + + private + def preprocessed?(record) + case preprocessed + when Symbol + record.send(preprocessed) + when Proc + preprocessed.call(record) + else + preprocessed + end + end end diff --git a/activestorage/app/models/active_storage/preview.rb b/activestorage/app/models/active_storage/preview.rb index d48dfd99594d3..ad9b409020ed8 100644 --- a/activestorage/app/models/active_storage/preview.rb +++ b/activestorage/app/models/active_storage/preview.rb @@ -95,11 +95,11 @@ def download(&block) end end - private - def processed? - image.attached? - end + def processed? + image.attached? + end + private def process previewer.preview(service_name: blob.service_name) do |attachable| ActiveRecord::Base.connected_to(role: ActiveRecord.writing_role) do @@ -109,7 +109,7 @@ def process end def variant - image.variant(variation) + @variant ||= image.variant(variation) end def variant? diff --git a/activestorage/app/models/active_storage/variant.rb b/activestorage/app/models/active_storage/variant.rb index 8e5eadcce7e9c..9af829b024afc 100644 --- a/activestorage/app/models/active_storage/variant.rb +++ b/activestorage/app/models/active_storage/variant.rb @@ -8,29 +8,29 @@ # # Variants rely on {ImageProcessing}[https://github.com/janko/image_processing] gem for the actual transformations # of the file, so you must add gem "image_processing" to your Gemfile if you wish to use variants. By -# default, images will be processed with {ImageMagick}[http://imagemagick.org] using the -# {MiniMagick}[https://github.com/minimagick/minimagick] gem, but you can also switch to the -# {libvips}[http://libvips.github.io/libvips/] processor operated by the {ruby-vips}[https://github.com/libvips/ruby-vips] +# default, images will be processed with {libvips}[http://libvips.github.io/libvips/] using the +# {ruby-vips}[https://github.com/libvips/ruby-vips] gem, but you can also switch to the +# {ImageMagick}[http://imagemagick.org] processor operated by the {MiniMagick}[https://github.com/minimagick/minimagick] # gem). # # Rails.application.config.active_storage.variant_processor -# # => :mini_magick -# -# Rails.application.config.active_storage.variant_processor = :vips # # => :vips # +# Rails.application.config.active_storage.variant_processor = :mini_magick +# # => :mini_magick +# # Note that to create a variant it's necessary to download the entire blob file from the service. Because of this process, # you also want to be considerate about when the variant is actually processed. You shouldn't be processing variants inline # in a template, for example. Delay the processing to an on-demand controller, like the one provided in -# ActiveStorage::RepresentationsController. +# ActiveStorage::Representations::ProxyController and ActiveStorage::Representations::RedirectController. # # To refer to such a delayed on-demand variant, simply link to the variant through the resolved route provided # by Active Storage like so: # # <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %> # -# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController -# can then produce on-demand. +# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::Representations::ProxyController +# or ActiveStorage::Representations::RedirectController can then produce on-demand. # # When you do want to actually produce the variant needed, call +processed+. This will check that the variant # has already been processed and uploaded to the service, and, if so, just return that. Otherwise it will perform @@ -74,11 +74,11 @@ def key "variants/#{blob.key}/#{OpenSSL::Digest::SHA256.hexdigest(variation.key)}" end - # Returns the URL of the blob variant on the service. See {ActiveStorage::Blob#url} for details. + # Returns the URL of the blob variant on the service. See ActiveStorage::Blob#url for details. # # Use url_for(variant) (or the implied form, like link_to variant or redirect_to variant) to get the stable URL - # for a variant that points to the ActiveStorage::RepresentationsController, which in turn will use this +service_call+ method - # for its redirection. + # for a variant that points to the ActiveStorage::Representations::ProxyController or ActiveStorage::Representations::RedirectController, + # which in turn will use this +service_call+ method for its redirection. def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline) service.url key, expires_in: expires_in, disposition: disposition, filename: filename, content_type: content_type end @@ -103,11 +103,12 @@ def destroy service.delete(key) end - private - def processed? - service.exist?(key) - end + # Returns true if the variant has already been processed and uploaded to the service. + def processed? + service.exist?(key) + end + private def process blob.open do |input| variation.transform(input) do |output| diff --git a/activestorage/app/models/active_storage/variant_with_record.rb b/activestorage/app/models/active_storage/variant_with_record.rb index fa8e650581e39..913d9d68cf6c1 100644 --- a/activestorage/app/models/active_storage/variant_with_record.rb +++ b/activestorage/app/models/active_storage/variant_with_record.rb @@ -3,7 +3,7 @@ # = Active Storage \Variant With Record # # Like an ActiveStorage::Variant, but keeps detail about the variant in the database as an -# ActiveStorage::VariantRecord. This is only used if +ActiveStorage.track_variants+ is enabled. +# ActiveStorage::VariantRecord. This is used if +ActiveStorage.track_variants+ is enabled. class ActiveStorage::VariantWithRecord include ActiveStorage::Blob::Servable @@ -35,11 +35,12 @@ def destroy delegate :key, :url, :download, to: :image, allow_nil: true - private - def processed? - record.present? - end + # Returns true if the variant has already been processed and stored. + def processed? + record.present? + end + private def process transform_blob { |image| create_or_find_record(image: image) } end diff --git a/activestorage/lib/active_storage.rb b/activestorage/lib/active_storage.rb index fcda880c218bc..df8b398f34686 100644 --- a/activestorage/lib/active_storage.rb +++ b/activestorage/lib/active_storage.rb @@ -74,7 +74,6 @@ module ActiveStorage "annotate", "antialias", "append", - "apply", "attenuate", "authenticate", "auto_gamma", @@ -215,7 +214,6 @@ module ActiveStorage "linewidth", "liquid_rescale", "list", - "loader", "log", "loop", "lowlight_color", @@ -278,7 +276,6 @@ module ActiveStorage "rotate", "sample", "sampling_factor", - "saver", "scale", "scene", "screen", @@ -365,21 +362,13 @@ module ActiveStorage mattr_accessor :track_variants, default: false - singleton_class.attr_accessor :checksum_implementation - @checksum_implementation = OpenSSL::Digest::MD5 - begin - @checksum_implementation.hexdigest("test") - rescue # OpenSSL may have MD5 disabled - require "digest/md5" - @checksum_implementation = Digest::MD5 - end - mattr_accessor :video_preview_arguments, default: "-y -vframes 1 -f image2" module Transformers extend ActiveSupport::Autoload autoload :Transformer + autoload :NullTransformer autoload :ImageProcessingTransformer autoload :Vips autoload :ImageMagick diff --git a/activestorage/lib/active_storage/analyzer.rb b/activestorage/lib/active_storage/analyzer.rb index 4463a2e117606..75723ce9818f3 100644 --- a/activestorage/lib/active_storage/analyzer.rb +++ b/activestorage/lib/active_storage/analyzer.rb @@ -30,7 +30,7 @@ def metadata end private - # Downloads the blob to a tempfile on disk. Yields the tempfile. + # Downloads the blob to a tempfile on disk. See ActiveStorage::Blob#open for details. def download_blob_to_tempfile(&block) # :doc: blob.open tmpdir: tmpdir, &block end diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb b/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb index 65c843415f1e9..63a53a8f7ed2c 100644 --- a/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb +++ b/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb @@ -3,7 +3,9 @@ begin gem "mini_magick" require "mini_magick" + ActiveStorage::MINIMAGICK_AVAILABLE = true # :nodoc: rescue LoadError => error + ActiveStorage::MINIMAGICK_AVAILABLE = false # :nodoc: raise error unless error.message.include?("mini_magick") end @@ -11,8 +13,17 @@ module ActiveStorage # This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem. MiniMagick requires # the {ImageMagick}[http://www.imagemagick.org] system library. class Analyzer::ImageAnalyzer::ImageMagick < Analyzer::ImageAnalyzer + def self.accept?(blob) + super && ActiveStorage.variant_processor == :mini_magick + end + private def read_image + unless MINIMAGICK_AVAILABLE + logger.error "Skipping image analysis because the mini_magick gem isn't installed" + return {} + end + download_blob_to_tempfile do |file| image = instrument("mini_magick") do MiniMagick::Image.new(file.path) diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb index 730bea19cce6a..57b9858ffbdc6 100644 --- a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb +++ b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb @@ -1,18 +1,37 @@ # frozen_string_literal: true +begin + require "nokogiri" +rescue LoadError + # Ensure nokogiri is loaded before vips, which also depends on libxml2. + # See Nokogiri RFC: Stop exporting symbols: + # https://github.com/sparklemotion/nokogiri/discussions/2746 +end + begin gem "ruby-vips" require "ruby-vips" + ActiveStorage::VIPS_AVAILABLE = true # :nodoc: rescue LoadError => error - raise error unless error.message.include?("ruby-vips") + ActiveStorage::VIPS_AVAILABLE = false # :nodoc: + raise error unless error.message.match?(/libvips|ruby-vips/) end module ActiveStorage # This analyzer relies on the third-party {ruby-vips}[https://github.com/libvips/ruby-vips] gem. Ruby-vips requires # the {libvips}[https://libvips.github.io/libvips/] system library. class Analyzer::ImageAnalyzer::Vips < Analyzer::ImageAnalyzer + def self.accept?(blob) + super && ActiveStorage.variant_processor == :vips + end + private def read_image + unless VIPS_AVAILABLE + logger.error "Skipping image analysis because the ruby-vips gem isn't installed" + return {} + end + download_blob_to_tempfile do |file| image = instrument("vips") do # ruby-vips will raise Vips::Error if it can't find an appropriate loader for the file diff --git a/activestorage/lib/active_storage/attached/model.rb b/activestorage/lib/active_storage/attached/model.rb index e57cd860c779c..e8fa2fa58b20d 100644 --- a/activestorage/lib/active_storage/attached/model.rb +++ b/activestorage/lib/active_storage/attached/model.rb @@ -61,8 +61,8 @@ module Attached::Model # There is no column defined on the model side, Active Storage takes # care of the mapping between your records and the attachment. # - # Under the covers, this relationship is implemented as a +has_one+ association to a - # ActiveStorage::Attachment record and a +has_one-through+ association to a + # Under the covers, this relationship is implemented as a +has_one+ association to an + # ActiveStorage::Attachment record and a +has_one-through+ association to an # ActiveStorage::Blob record. These associations are available as +avatar_attachment+ # and +avatar_blob+. But you shouldn't need to work with these associations directly in # most circumstances. @@ -163,8 +163,8 @@ def #{name}=(attachable) # There are no columns defined on the model side, Active Storage takes # care of the mapping between your records and the attachments. # - # Under the covers, this relationship is implemented as a +has_many+ association to a - # ActiveStorage::Attachment record and a +has_many-through+ association to a + # Under the covers, this relationship is implemented as a +has_many+ association to an + # ActiveStorage::Attachment record and a +has_many-through+ association to an # ActiveStorage::Blob record. These associations are available as +photos_attachments+ # and +photos_blobs+. But you shouldn't need to work with these associations directly in # most circumstances. diff --git a/activestorage/lib/active_storage/downloader.rb b/activestorage/lib/active_storage/downloader.rb index 29a5e2433915c..1f52f7755da25 100644 --- a/activestorage/lib/active_storage/downloader.rb +++ b/activestorage/lib/active_storage/downloader.rb @@ -8,34 +8,31 @@ def initialize(service) @service = service end - def open(key, checksum: nil, verify: true, name: "ActiveStorage-", tmpdir: nil) - open_tempfile(name, tmpdir) do |file| - download key, file - verify_integrity_of(file, checksum: checksum) if verify - yield file - end - end - - private - def open_tempfile(name, tmpdir = nil) - file = Tempfile.open(name, tmpdir) + def open(key, checksum: nil, verify: true, name: "ActiveStorage-", tmpdir: nil, &block) + tempfile = Tempfile.new(name, tmpdir, binmode: true) + download(key, tempfile) + verify_integrity_of(tempfile, checksum: checksum) if verify + if block_given? begin - yield file + yield tempfile ensure - file.close! + tempfile.close! end + else + tempfile end + end + private def download(key, file) - file.binmode service.download(key) { |chunk| file.write(chunk) } file.flush file.rewind end def verify_integrity_of(file, checksum:) - unless ActiveStorage.checksum_implementation.file(file).base64digest == checksum + unless OpenSSL::Digest::MD5.file(file).base64digest == checksum raise ActiveStorage::IntegrityError end end diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb index 77b3ff8990958..dafc598454a1b 100644 --- a/activestorage/lib/active_storage/engine.rb +++ b/activestorage/lib/active_storage/engine.rb @@ -25,7 +25,7 @@ class Engine < Rails::Engine # :nodoc: config.active_storage = ActiveSupport::OrderedOptions.new config.active_storage.previewers = [ ActiveStorage::Previewer::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ] - config.active_storage.analyzers = [ ActiveStorage::Analyzer::VideoAnalyzer, ActiveStorage::Analyzer::AudioAnalyzer ] + config.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer::Vips, ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick, ActiveStorage::Analyzer::VideoAnalyzer, ActiveStorage::Analyzer::AudioAnalyzer ] config.active_storage.paths = ActiveSupport::OrderedOptions.new config.active_storage.queues = ActiveSupport::InheritableOptions.new config.active_storage.precompile_assets = true @@ -82,28 +82,26 @@ class Engine < Rails::Engine # :nodoc: end initializer "active_storage.configs" do + config.before_initialize do |app| + ActiveStorage.touch_attachment_records = app.config.active_storage.touch_attachment_records != false + end + config.after_initialize do |app| ActiveStorage.logger = app.config.active_storage.logger || Rails.logger - ActiveStorage.variant_processor = app.config.active_storage.variant_processor + ActiveStorage.variant_processor = app.config.active_storage.variant_processor || :mini_magick ActiveStorage.previewers = app.config.active_storage.previewers || [] + ActiveStorage.analyzers = app.config.active_storage.analyzers || [] begin - analyzer, transformer = + ActiveStorage.variant_transformer = case ActiveStorage.variant_processor + when :disabled + ActiveStorage::Transformers::NullTransformer when :vips - [ - ActiveStorage::Analyzer::ImageAnalyzer::Vips, - ActiveStorage::Transformers::Vips - ] + ActiveStorage::Transformers::Vips when :mini_magick - [ - ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick, - ActiveStorage::Transformers::ImageMagick - ] + ActiveStorage::Transformers::ImageMagick end - - ActiveStorage.analyzers = [analyzer].compact.concat(app.config.active_storage.analyzers || []) - ActiveStorage.variant_transformer = transformer rescue LoadError => error case error.message when /libvips/ @@ -114,7 +112,8 @@ class Engine < Rails::Engine # :nodoc: when /image_processing/ ActiveStorage.logger.warn <<~WARNING.squish Generating image variants require the image_processing gem. - Please add `gem 'image_processing', '~> 1.2'` to your Gemfile. + Please add `gem "image_processing", "~> 1.2"` to your Gemfile + or set `config.active_storage.variant_processor = :disabled`. WARNING else raise @@ -144,16 +143,12 @@ class Engine < Rails::Engine # :nodoc: ActiveStorage.variable_content_types = app.config.active_storage.variable_content_types || [] ActiveStorage.web_image_content_types = app.config.active_storage.web_image_content_types || [] ActiveStorage.content_types_to_serve_as_binary = app.config.active_storage.content_types_to_serve_as_binary || [] - ActiveStorage.touch_attachment_records = app.config.active_storage.touch_attachment_records != false ActiveStorage.service_urls_expire_in = app.config.active_storage.service_urls_expire_in || 5.minutes ActiveStorage.urls_expire_in = app.config.active_storage.urls_expire_in ActiveStorage.content_types_allowed_inline = app.config.active_storage.content_types_allowed_inline || [] ActiveStorage.binary_content_type = app.config.active_storage.binary_content_type || "application/octet-stream" ActiveStorage.video_preview_arguments = app.config.active_storage.video_preview_arguments || "-y -vframes 1 -f image2" ActiveStorage.track_variants = app.config.active_storage.track_variants || false - if app.config.active_storage.checksum_implementation - ActiveStorage.checksum_implementation = app.config.active_storage.checksum_implementation - end end end @@ -171,9 +166,9 @@ class Engine < Rails::Engine # :nodoc: end end - initializer "active_storage.services" do + initializer "active_storage.services" do |app| ActiveSupport.on_load(:active_storage_blob) do - configs = Rails.configuration.active_storage.service_configurations ||= + configs = app.config.active_storage.service_configurations ||= begin config_file = Rails.root.join("config/storage/#{Rails.env}.yml") config_file = Rails.root.join("config/storage.yml") unless config_file.exist? @@ -184,7 +179,7 @@ class Engine < Rails::Engine # :nodoc: ActiveStorage::Blob.services = ActiveStorage::Service::Registry.new(configs) - if config_choice = Rails.configuration.active_storage.service + if config_choice = app.config.active_storage.service ActiveStorage::Blob.service = ActiveStorage::Blob.services.fetch(config_choice) end end @@ -206,7 +201,7 @@ class Engine < Rails::Engine # :nodoc: initializer "action_view.configuration" do config.after_initialize do |app| ActiveSupport.on_load(:action_view) do - multiple_file_field_include_hidden = app.config.active_storage.delete(:multiple_file_field_include_hidden) + multiple_file_field_include_hidden = app.config.active_storage.multiple_file_field_include_hidden unless multiple_file_field_include_hidden.nil? ActionView::Helpers::FormHelper.multiple_file_field_include_hidden = multiple_file_field_include_hidden diff --git a/activestorage/lib/active_storage/fixture_set.rb b/activestorage/lib/active_storage/fixture_set.rb index b046b6859a225..b2923a261178d 100644 --- a/activestorage/lib/active_storage/fixture_set.rb +++ b/activestorage/lib/active_storage/fixture_set.rb @@ -50,7 +50,7 @@ class FixtureSet # by ActiveSupport::Testing::FileFixtures.file_fixture, and upload # the file to the Service # - # === Examples + # ==== Examples # # # tests/fixtures/active_storage/blobs.yml # second_thumbnail_blob: <%= ActiveStorage::FixtureSet.blob( diff --git a/activestorage/lib/active_storage/gem_version.rb b/activestorage/lib/active_storage/gem_version.rb index 9abd357c44af6..08407ada9e7b8 100644 --- a/activestorage/lib/active_storage/gem_version.rb +++ b/activestorage/lib/active_storage/gem_version.rb @@ -8,7 +8,7 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/activestorage/lib/active_storage/log_subscriber.rb b/activestorage/lib/active_storage/log_subscriber.rb index 376173c8b969d..16ee0d177c0ea 100644 --- a/activestorage/lib/active_storage/log_subscriber.rb +++ b/activestorage/lib/active_storage/log_subscriber.rb @@ -3,54 +3,59 @@ require "active_support/log_subscriber" module ActiveStorage - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::EventReporter::LogSubscriber # :nodoc: + self.namespace = "active_storage" + def service_upload(event) message = "Uploaded file to key: #{key_in(event)}" - message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum] + message += " (checksum: #{event[:payload][:checksum]})" if event[:payload][:checksum] info event, color(message, GREEN) end - subscribe_log_level :service_upload, :info + event_log_level :service_upload, :info def service_download(event) info event, color("Downloaded file from key: #{key_in(event)}", BLUE) end - subscribe_log_level :service_download, :info + event_log_level :service_download, :info - alias_method :service_streaming_download, :service_download + def service_streaming_download(event) + info event, color("Downloaded file from key: #{key_in(event)}", BLUE) + end + event_log_level :service_streaming_download, :info def preview(event) info event, color("Previewed file from key: #{key_in(event)}", BLUE) end - subscribe_log_level :preview, :info + event_log_level :preview, :info def service_delete(event) info event, color("Deleted file from key: #{key_in(event)}", RED) end - subscribe_log_level :service_delete, :info + event_log_level :service_delete, :info def service_delete_prefixed(event) - info event, color("Deleted files by key prefix: #{event.payload[:prefix]}", RED) + info event, color("Deleted files by key prefix: #{event[:payload][:prefix]}", RED) end - subscribe_log_level :service_delete_prefixed, :info + event_log_level :service_delete_prefixed, :info def service_exist(event) - debug event, color("Checked if file exists at key: #{key_in(event)} (#{event.payload[:exist] ? "yes" : "no"})", BLUE) + debug event, color("Checked if file exists at key: #{key_in(event)} (#{event[:payload][:exist] ? "yes" : "no"})", BLUE) end - subscribe_log_level :service_exist, :debug + event_log_level :service_exist, :debug def service_url(event) - debug event, color("Generated URL for file at key: #{key_in(event)} (#{event.payload[:url]})", BLUE) + debug event, color("Generated URL for file at key: #{key_in(event)} (#{event[:payload][:url]})", BLUE) end - subscribe_log_level :service_url, :debug + event_log_level :service_url, :debug def service_mirror(event) message = "Mirrored file at key: #{key_in(event)}" - message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum] + message += " (checksum: #{event[:payload][:checksum]})" if event[:payload][:checksum] debug event, color(message, GREEN) end - subscribe_log_level :service_mirror, :debug + event_log_level :service_mirror, :debug - def logger + def self.default_logger ActiveStorage.logger end @@ -64,13 +69,15 @@ def debug(event, colored_message) end def log_prefix_for_service(event) - color " #{event.payload[:service]} Storage (#{event.duration.round(1)}ms) ", CYAN + color " #{event[:payload][:service]} Storage (#{event[:payload][:duration_ms].round(1)}ms) ", CYAN end def key_in(event) - event.payload[:key] + event[:payload][:key] end end end -ActiveStorage::LogSubscriber.attach_to :active_storage +ActiveSupport.event_reporter.subscribe( + ActiveStorage::LogSubscriber.new, &ActiveStorage::LogSubscriber.subscription_filter +) diff --git a/activestorage/lib/active_storage/previewer.rb b/activestorage/lib/active_storage/previewer.rb index 05146de704b59..75c9ddf15ca0c 100644 --- a/activestorage/lib/active_storage/previewer.rb +++ b/activestorage/lib/active_storage/previewer.rb @@ -27,7 +27,7 @@ def preview(**options) end private - # Downloads the blob to a tempfile on disk. Yields the tempfile. + # Downloads the blob to a tempfile on disk. See ActiveStorage::Blob#open for details. def download_blob_to_tempfile(&block) # :doc: blob.open tmpdir: tmpdir, &block end diff --git a/activestorage/lib/active_storage/service.rb b/activestorage/lib/active_storage/service.rb index 6ed3563cda76f..e02de9e51a2d0 100644 --- a/activestorage/lib/active_storage/service.rb +++ b/activestorage/lib/active_storage/service.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_storage/log_subscriber" +require "active_storage/structured_event_subscriber" require "active_storage/downloader" require "action_dispatch" require "action_dispatch/http/content_disposition" @@ -15,7 +16,6 @@ module ActiveStorage # * +Disk+, to manage attachments saved directly on the hard drive. # * +GCS+, to manage attachments through Google Cloud Storage. # * +S3+, to manage attachments through Amazon S3. - # * +AzureStorage+, to manage attachments through Microsoft Azure Storage. # * +Mirror+, to be able to use several services to manage attachments. # # Inside a \Rails application, you can set-up your services through the @@ -148,6 +148,10 @@ def public? @public end + def inspect # :nodoc: + "#<#{self.class}#{name.present? ? " name=#{name.inspect}" : ""}>" + end + private def private_url(key, expires_in:, filename:, disposition:, content_type:, **) raise NotImplementedError diff --git a/activestorage/lib/active_storage/service/azure_storage_service.rb b/activestorage/lib/active_storage/service/azure_storage_service.rb deleted file mode 100644 index 72feb25a6e015..0000000000000 --- a/activestorage/lib/active_storage/service/azure_storage_service.rb +++ /dev/null @@ -1,201 +0,0 @@ -# frozen_string_literal: true - -gem "azure-storage-blob", ">= 2.0" - -require "active_support/core_ext/numeric/bytes" -require "azure/storage/blob" -require "azure/storage/common/core/auth/shared_access_signature" - -module ActiveStorage - # = Active Storage \Azure Storage \Service - # - # Wraps the Microsoft Azure Storage Blob Service as an Active Storage service. - # See ActiveStorage::Service for the generic API documentation that applies to all services. - class Service::AzureStorageService < Service - attr_reader :client, :container, :signer - - def initialize(storage_account_name:, storage_access_key:, container:, public: false, **options) - ActiveStorage.deprecator.warn <<~MSG.squish - `ActiveStorage::Service::AzureStorageService` is deprecated and will be - removed in Rails 8.1. - Please try the `azure-blob` gem instead. - This gem is not maintained by the Rails team, so please test your applications before deploying to production. - MSG - - @client = Azure::Storage::Blob::BlobService.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key, **options) - @signer = Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key) - @container = container - @public = public - end - - def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}, **) - instrument :upload, key: key, checksum: checksum do - handle_errors do - content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename - - client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata) - end - end - end - - def download(key, &block) - if block_given? - instrument :streaming_download, key: key do - stream(key, &block) - end - else - instrument :download, key: key do - handle_errors do - _, io = client.get_blob(container, key) - io.force_encoding(Encoding::BINARY) - end - end - end - end - - def download_chunk(key, range) - instrument :download_chunk, key: key, range: range do - handle_errors do - _, io = client.get_blob(container, key, start_range: range.begin, end_range: range.exclude_end? ? range.end - 1 : range.end) - io.force_encoding(Encoding::BINARY) - end - end - end - - def delete(key) - instrument :delete, key: key do - client.delete_blob(container, key) - rescue Azure::Core::Http::HTTPError => e - raise unless e.type == "BlobNotFound" - # Ignore files already deleted - end - end - - def delete_prefixed(prefix) - instrument :delete_prefixed, prefix: prefix do - marker = nil - - loop do - results = client.list_blobs(container, prefix: prefix, marker: marker) - - results.each do |blob| - client.delete_blob(container, blob.name) - end - - break unless marker = results.continuation_token.presence - end - end - end - - def exist?(key) - instrument :exist, key: key do |payload| - answer = blob_for(key).present? - payload[:exist] = answer - answer - end - end - - def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {}) - instrument :url, key: key do |payload| - generated_url = signer.signed_uri( - uri_for(key), false, - service: "b", - permissions: "rw", - expiry: format_expiry(expires_in) - ).to_s - - payload[:url] = generated_url - - generated_url - end - end - - def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **) - content_disposition = content_disposition_with(type: disposition, filename: filename) if filename - - { "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob", **custom_metadata_headers(custom_metadata) } - end - - def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}) - content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename - - client.create_append_blob( - container, - destination_key, - content_type: content_type, - content_disposition: content_disposition, - metadata: custom_metadata, - ).tap do |blob| - source_keys.each do |source_key| - stream(source_key) do |chunk| - client.append_blob_block(container, blob.name, chunk) - end - end - end - end - - private - def private_url(key, expires_in:, filename:, disposition:, content_type:, **) - signer.signed_uri( - uri_for(key), false, - service: "b", - permissions: "r", - expiry: format_expiry(expires_in), - content_disposition: content_disposition_with(type: disposition, filename: filename), - content_type: content_type - ).to_s - end - - def public_url(key, **) - uri_for(key).to_s - end - - - def uri_for(key) - client.generate_uri("#{container}/#{key}") - end - - def blob_for(key) - client.get_blob_properties(container, key) - rescue Azure::Core::Http::HTTPError - false - end - - def format_expiry(expires_in) - expires_in ? Time.now.utc.advance(seconds: expires_in).iso8601 : nil - end - - # Reads the object for the given key in chunks, yielding each to the block. - def stream(key) - blob = blob_for(key) - - chunk_size = 5.megabytes - offset = 0 - - raise ActiveStorage::FileNotFoundError unless blob.present? - - while offset < blob.properties[:content_length] - _, chunk = client.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1) - yield chunk.force_encoding(Encoding::BINARY) - offset += chunk_size - end - end - - def handle_errors - yield - rescue Azure::Core::Http::HTTPError => e - case e.type - when "BlobNotFound" - raise ActiveStorage::FileNotFoundError - when "Md5Mismatch" - raise ActiveStorage::IntegrityError - else - raise - end - end - - def custom_metadata_headers(metadata) - metadata.transform_keys { |key| "x-ms-meta-#{key}" } - end - end -end diff --git a/activestorage/lib/active_storage/service/configurator.rb b/activestorage/lib/active_storage/service/configurator.rb index 4efb2940f961e..a899ce1e61f20 100644 --- a/activestorage/lib/active_storage/service/configurator.rb +++ b/activestorage/lib/active_storage/service/configurator.rb @@ -19,6 +19,12 @@ def build(service_name) ) end + def inspect # :nodoc: + attrs = configurations.any? ? + " configurations=[#{configurations.keys.map(&:inspect).join(", ")}]" : "" + "#<#{self.class}#{attrs}>" + end + private def config_for(name) configurations.fetch name do diff --git a/activestorage/lib/active_storage/service/disk_service.rb b/activestorage/lib/active_storage/service/disk_service.rb index 3f5a323855a8a..ee49bf6b6c59f 100644 --- a/activestorage/lib/active_storage/service/disk_service.rb +++ b/activestorage/lib/active_storage/service/disk_service.rb @@ -161,7 +161,7 @@ def make_path_for(key) end def ensure_integrity_of(key, checksum) - unless ActiveStorage.checksum_implementation.file(path_for(key)).base64digest == checksum + unless OpenSSL::Digest::MD5.file(path_for(key)).base64digest == checksum delete key raise ActiveStorage::IntegrityError end diff --git a/activestorage/lib/active_storage/service/gcs_service.rb b/activestorage/lib/active_storage/service/gcs_service.rb index adf748438affd..cc2ade14f79e1 100644 --- a/activestorage/lib/active_storage/service/gcs_service.rb +++ b/activestorage/lib/active_storage/service/gcs_service.rb @@ -14,8 +14,8 @@ class MetadataServerError < ActiveStorage::Error; end class MetadataServerNotFoundError < ActiveStorage::Error; end def initialize(public: false, **config) - @config = config @public = public + @config = config end def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil, custom_metadata: {}) @@ -144,6 +144,14 @@ def compose(source_keys, destination_key, filename: nil, content_type: nil, disp end end + def bucket + @bucket ||= client.bucket(@config.fetch(:bucket), skip_lookup: true) + end + + def client + @client ||= Google::Cloud::Storage.new(**@config.except(:bucket, :cache_control, :iam, :gsa_email)) + end + private def private_url(key, expires_in:, filename:, content_type:, disposition:, **) args = { @@ -166,9 +174,6 @@ def public_url(key, **) file_for(key).public_url end - - attr_reader :config - def file_for(key, skip_lookup: true) bucket.file(key, skip_lookup: skip_lookup) end @@ -188,14 +193,6 @@ def stream(key) end end - def bucket - @bucket ||= client.bucket(config.fetch(:bucket), skip_lookup: true) - end - - def client - @client ||= Google::Cloud::Storage.new(**config.except(:bucket, :cache_control, :iam, :gsa_email)) - end - def issuer @issuer ||= @config[:gsa_email].presence || email_from_metadata_server end @@ -213,8 +210,16 @@ def signer lambda do |string_to_sign| iam_client = Google::Apis::IamcredentialsV1::IAMCredentialsService.new - scopes = ["https://www.googleapis.com/auth/iam"] - iam_client.authorization = Google::Auth.get_application_default(scopes) + # We explicitly do not set iam_client.authorization so that it uses the + # credentials set by the application at Google::Apis::RequestOptions.default.authorization. + # If the application does not set it, the GCP libraries will automatically + # determine it on each call. This code previously explicitly set the + # authorization to Google::Auth.get_application_default which triggers + # an explicit call to the metadata server - given this lambda is called + # for a significant number of file operations, it can lead to considerable + # tail latencies and even metadata server overloads. Additionally, that + # prevented applications from being able to configure the credentials + # used to perform the signature operation. request = Google::Apis::IamcredentialsV1::SignBlobRequest.new( payload: string_to_sign diff --git a/activestorage/lib/active_storage/service/registry.rb b/activestorage/lib/active_storage/service/registry.rb index 4ed200e7ca103..fff0b6a950ca4 100644 --- a/activestorage/lib/active_storage/service/registry.rb +++ b/activestorage/lib/active_storage/service/registry.rb @@ -22,6 +22,12 @@ def fetch(name) end end + def inspect # :nodoc: + attrs = configurations.any? ? + " configurations=[#{configurations.keys.map(&:inspect).join(", ")}]" : "" + "#<#{self.class}#{attrs}>" + end + private attr_reader :configurations, :services diff --git a/activestorage/lib/active_storage/service/s3_service.rb b/activestorage/lib/active_storage/service/s3_service.rb index 2f1c124f67cbc..729cf50c25868 100644 --- a/activestorage/lib/active_storage/service/s3_service.rb +++ b/activestorage/lib/active_storage/service/s3_service.rb @@ -16,6 +16,7 @@ class Service::S3Service < Service def initialize(bucket:, upload: {}, public: false, **options) @client = Aws::S3::Resource.new(**options) + @transfer_manager = Aws::S3::TransferManager.new(client: @client.client) if defined?(Aws::S3::TransferManager) @bucket = @client.bucket(bucket) @multipart_upload_threshold = upload.delete(:multipart_threshold) || 100.megabytes @@ -100,7 +101,8 @@ def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disp def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}) content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename - object_for(destination_key).upload_stream( + upload_stream( + key: destination_key, content_type: content_type, content_disposition: content_disposition, part_size: MINIMUM_UPLOAD_PART_SIZE, @@ -116,6 +118,14 @@ def compose(source_keys, destination_key, filename: nil, content_type: nil, disp end private + def upload_stream(key:, **options, &block) + if @transfer_manager + @transfer_manager.upload_stream(key: key, bucket: bucket.name, **options, &block) + else + object_for(key).upload_stream(**options, &block) + end + end + def private_url(key, expires_in:, filename:, disposition:, content_type:, **client_opts) object_for(key).presigned_url :get, expires_in: expires_in.to_i, response_content_disposition: content_disposition_with(type: disposition, filename: filename), @@ -126,7 +136,6 @@ def public_url(key, **client_opts) object_for(key).public_url(**client_opts) end - MAXIMUM_UPLOAD_PARTS_COUNT = 10000 MINIMUM_UPLOAD_PART_SIZE = 5.megabytes @@ -139,12 +148,18 @@ def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_d def upload_with_multipart(key, io, content_type: nil, content_disposition: nil, custom_metadata: {}) part_size = [ io.size.fdiv(MAXIMUM_UPLOAD_PARTS_COUNT).ceil, MINIMUM_UPLOAD_PART_SIZE ].max - object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, metadata: custom_metadata, **upload_options) do |out| + upload_stream( + key: key, + content_type: content_type, + content_disposition: content_disposition, + part_size: part_size, + metadata: custom_metadata, + **upload_options + ) do |out| IO.copy_stream(io, out) end end - def object_for(key) bucket.object(key) end diff --git a/activestorage/lib/active_storage/structured_event_subscriber.rb b/activestorage/lib/active_storage/structured_event_subscriber.rb new file mode 100644 index 0000000000000..361734d75ca1b --- /dev/null +++ b/activestorage/lib/active_storage/structured_event_subscriber.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "active_support/structured_event_subscriber" + +module ActiveStorage + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + def service_upload(event) + emit_event("active_storage.service_upload", + key: event.payload[:key], + checksum: event.payload[:checksum], + duration_ms: event.duration.round(2), + ) + end + + def service_download(event) + emit_event("active_storage.service_download", + key: event.payload[:key], + duration_ms: event.duration.round(2), + ) + end + + def service_streaming_download(event) + emit_event("active_storage.service_streaming_download", + key: event.payload[:key], + duration_ms: event.duration.round(2), + ) + end + + def preview(event) + emit_event("active_storage.preview", + key: event.payload[:key], + duration_ms: event.duration.round(2), + ) + end + + def service_delete(event) + emit_event("active_storage.service_delete", + key: event.payload[:key], + duration_ms: event.duration.round(2), + ) + end + + def service_delete_prefixed(event) + emit_event("active_storage.service_delete_prefixed", + prefix: event.payload[:prefix], + duration_ms: event.duration.round(2), + ) + end + + def service_exist(event) + emit_debug_event("active_storage.service_exist", + key: event.payload[:key], + exist: event.payload[:exist], + duration_ms: event.duration.round(2), + ) + end + debug_only :service_exist + + def service_url(event) + emit_debug_event("active_storage.service_url", + key: event.payload[:key], + url: event.payload[:url], + duration_ms: event.duration.round(2), + ) + end + debug_only :service_url + + def service_mirror(event) + emit_debug_event("active_storage.service_mirror", + key: event.payload[:key], + checksum: event.payload[:checksum], + duration_ms: event.duration.round(2), + ) + end + debug_only :service_mirror + end +end + +ActiveStorage::StructuredEventSubscriber.attach_to :active_storage diff --git a/activestorage/lib/active_storage/transformers/null_transformer.rb b/activestorage/lib/active_storage/transformers/null_transformer.rb new file mode 100644 index 0000000000000..6879fd5641604 --- /dev/null +++ b/activestorage/lib/active_storage/transformers/null_transformer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ActiveStorage + module Transformers + class NullTransformer < Transformer # :nodoc: + private + def process(file, format:) + file + end + end + end +end diff --git a/activestorage/package.json b/activestorage/package.json index cebe328a95a9a..575ba8cae6784 100644 --- a/activestorage/package.json +++ b/activestorage/package.json @@ -1,6 +1,6 @@ { "name": "@rails/activestorage", - "version": "8.1.0-alpha", + "version": "8.2.0-alpha", "description": "Attach cloud and local files in Rails applications", "module": "app/assets/javascripts/activestorage.esm.js", "main": "app/assets/javascripts/activestorage.js", @@ -22,10 +22,12 @@ "spark-md5": "^3.0.1" }, "devDependencies": { + "@eslint/js": "^9.24.0", "@rollup/plugin-node-resolve": "^11.0.1", "@rollup/plugin-commonjs": "^19.0.1", - "eslint": "^8.40.0", - "eslint-plugin-import": "^2.29.0", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "globals": "^14.0.0", "rollup": "^2.35.1", "rollup-plugin-terser": "^7.0.2" }, diff --git a/activestorage/test/analyzer/audio_analyzer_test.rb b/activestorage/test/analyzer/audio_analyzer_test.rb index dcaa0bbfcfd50..bfffaa0b21d67 100644 --- a/activestorage/test/analyzer/audio_analyzer_test.rb +++ b/activestorage/test/analyzer/audio_analyzer_test.rb @@ -10,7 +10,8 @@ class ActiveStorage::Analyzer::AudioAnalyzerTest < ActiveSupport::TestCase blob = create_file_blob(filename: "audio.mp3", content_type: "audio/mp3") metadata = extract_metadata_from(blob) - assert_equal 0.914286, metadata[:duration] + assert (0.863379..0.914286).include?(metadata[:duration]) + assert_equal 128000, metadata[:bit_rate] assert_equal 44100, metadata[:sample_rate] assert_not_nil metadata[:tags] diff --git a/activestorage/test/analyzer/image_analyzer/image_magick_test.rb b/activestorage/test/analyzer/image_analyzer/image_magick_test.rb index 5b78a8e7a375f..9635e7b83d581 100644 --- a/activestorage/test/analyzer/image_analyzer/image_magick_test.rb +++ b/activestorage/test/analyzer/image_analyzer/image_magick_test.rb @@ -46,6 +46,18 @@ class ActiveStorage::Analyzer::ImageAnalyzer::ImageMagickTest < ActiveSupport::T end end + test "analyzing with ruby-vips unavailable" do + stub_const(Object, :Vips, Module.new) do + analyze_with_image_magick do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + metadata = extract_metadata_from(blob) + + assert_equal 4104, metadata[:width] + assert_equal 2736, metadata[:height] + end + end + end + test "instrumenting analysis" do blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") @@ -58,14 +70,31 @@ class ActiveStorage::Analyzer::ImageAnalyzer::ImageMagickTest < ActiveSupport::T end end + test "when image_magick is not installed" do + stub_const(ActiveStorage, :MINIMAGICK_AVAILABLE, false) do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + + output = StringIO.new + logger = ActiveSupport::Logger.new(output) + + ActiveStorage.with(logger: logger) do + analyze_with_image_magick do + blob.analyze + end + end + + assert_includes output.string, "Skipping image analysis because the mini_magick gem isn't installed" + end + end + private def analyze_with_image_magick - previous_analyzers, ActiveStorage.analyzers = ActiveStorage.analyzers, [ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick] + previous_processor, ActiveStorage.variant_processor = ActiveStorage.variant_processor, :mini_magick yield rescue LoadError ENV["BUILDKITE"] ? raise : skip("Variant processor image_magick is not installed") ensure - ActiveStorage.analyzers = previous_analyzers + ActiveStorage.variant_processor = previous_processor end end diff --git a/activestorage/test/analyzer/image_analyzer/vips_test.rb b/activestorage/test/analyzer/image_analyzer/vips_test.rb index 5c4387d2d11bb..47bbd8942d566 100644 --- a/activestorage/test/analyzer/image_analyzer/vips_test.rb +++ b/activestorage/test/analyzer/image_analyzer/vips_test.rb @@ -58,14 +58,31 @@ class ActiveStorage::Analyzer::ImageAnalyzer::VipsTest < ActiveSupport::TestCase end end + test "when ruby-vips is not installed" do + stub_const(ActiveStorage, :VIPS_AVAILABLE, false) do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + + output = StringIO.new + logger = ActiveSupport::Logger.new(output) + + ActiveStorage.with(logger: logger) do + analyze_with_vips do + blob.analyze + end + end + + assert_includes output.string, "Skipping image analysis because the ruby-vips gem isn't installed" + end + end + private def analyze_with_vips - previous_analyzers, ActiveStorage.analyzers = ActiveStorage.analyzers, [ActiveStorage::Analyzer::ImageAnalyzer::Vips] + previous_processor, ActiveStorage.variant_processor = ActiveStorage.variant_processor, :vips yield rescue LoadError ENV["BUILDKITE"] ? raise : skip("Variant processor vips is not installed") ensure - ActiveStorage.analyzers = previous_analyzers + ActiveStorage.variant_processor = previous_processor end end diff --git a/activestorage/test/controllers/blobs/proxy_controller_test.rb b/activestorage/test/controllers/blobs/proxy_controller_test.rb index 8afb85ff6df34..5d63f929bb776 100644 --- a/activestorage/test/controllers/blobs/proxy_controller_test.rb +++ b/activestorage/test/controllers/blobs/proxy_controller_test.rb @@ -137,6 +137,17 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes assert request.session_options[:skip], "Expected request.session_options[:skip] to be true" end + + test "rails_storage_proxy include Content-Length header" do + Rails.application.config.active_storage.resolve_model_to_route = :rails_storage_proxy + blob = create_file_blob(filename: "racecar.jpg") + + get rails_storage_proxy_url(blob) + + assert_response :success + assert_not_nil response.headers["Content-Length"], "Content-Length header should be included in proxy mode" + assert_equal blob.byte_size.to_s, response.headers["Content-Length"], "Content-Length header should match blob size" + end end class ActiveStorage::Blobs::ExpiringProxyControllerTest < ActionDispatch::IntegrationTest diff --git a/activestorage/test/controllers/blobs/redirect_controller_test.rb b/activestorage/test/controllers/blobs/redirect_controller_test.rb index e4543b9eb6bc3..ab9217d04643c 100644 --- a/activestorage/test/controllers/blobs/redirect_controller_test.rb +++ b/activestorage/test/controllers/blobs/redirect_controller_test.rb @@ -80,17 +80,6 @@ class ActiveStorage::Blobs::RedirectControllerWithOpenRedirectTest < ActionDispa end end - if SERVICE_CONFIGURATIONS[:azure] - test "showing existing blob stored in azure" do - with_raise_on_open_redirects(:azure) do - blob = create_file_blob filename: "racecar.jpg", service_name: :azure - - get rails_storage_redirect_url(blob) - assert_redirected_to(/racecar\.jpg/) - end - end - end - if SERVICE_CONFIGURATIONS[:gcs] test "showing existing blob stored in gcs" do with_raise_on_open_redirects(:gcs) do diff --git a/activestorage/test/controllers/direct_uploads_controller_test.rb b/activestorage/test/controllers/direct_uploads_controller_test.rb index 7bd151e694549..d8422c409d962 100644 --- a/activestorage/test/controllers/direct_uploads_controller_test.rb +++ b/activestorage/test/controllers/direct_uploads_controller_test.rb @@ -15,7 +15,7 @@ class ActiveStorage::S3DirectUploadsControllerTest < ActionDispatch::Integration end test "creating new direct upload" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") + checksum = OpenSSL::Digest::MD5.base64digest("Hello") metadata = { "foo" => "bar", "my_key_1" => "my_value_1", @@ -61,7 +61,7 @@ class ActiveStorage::GCSDirectUploadsControllerTest < ActionDispatch::Integratio end test "creating new direct upload" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") + checksum = OpenSSL::Digest::MD5.base64digest("Hello") metadata = { "foo" => "bar", "my_key_1" => "my_value_1", @@ -92,51 +92,9 @@ class ActiveStorage::GCSDirectUploadsControllerTest < ActionDispatch::Integratio puts "Skipping GCS Direct Upload tests because no GCS configuration was supplied" end -if SERVICE_CONFIGURATIONS[:azure] - class ActiveStorage::AzureStorageDirectUploadsControllerTest < ActionDispatch::IntegrationTest - setup do - @config = SERVICE_CONFIGURATIONS[:azure] - - @old_service = ActiveStorage::Blob.service - ActiveStorage::Blob.service = ActiveStorage::Service.configure(:azure, SERVICE_CONFIGURATIONS) - end - - teardown do - ActiveStorage::Blob.service = @old_service - end - - test "creating new direct upload" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") - metadata = { - "foo" => "bar", - "my_key_1" => "my_value_1", - "my_key_2" => "my_value_2", - "platform" => "my_platform", - "library_ID" => "12345" - } - - post rails_direct_uploads_url, params: { blob: { - filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: metadata } } - - response.parsed_body.tap do |details| - assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"]) - assert_equal "hello.txt", details["filename"] - assert_equal 6, details["byte_size"] - assert_equal checksum, details["checksum"] - assert_equal metadata, details["metadata"] - assert_equal "text/plain", details["content_type"] - assert_match %r{#{@config[:storage_account_name]}\.blob\.core\.windows\.net/#{@config[:container]}}, details["direct_upload"]["url"] - assert_equal({ "Content-Type" => "text/plain", "Content-MD5" => checksum, "x-ms-blob-content-disposition" => "inline; filename=\"hello.txt\"; filename*=UTF-8''hello.txt", "x-ms-blob-type" => "BlockBlob" }, details["direct_upload"]["headers"]) - end - end - end -else - puts "Skipping Azure Storage Direct Upload tests because no Azure Storage configuration was supplied" -end - class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::IntegrationTest test "creating new direct upload" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") + checksum = OpenSSL::Digest::MD5.base64digest("Hello") metadata = { "foo" => "bar", "my_key_1" => "my_value_1", @@ -161,7 +119,7 @@ class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::Integrati end test "creating new direct upload does not include root in json" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") + checksum = OpenSSL::Digest::MD5.base64digest("Hello") metadata = { "foo" => "bar", "my_key_1" => "my_value_1", diff --git a/activestorage/test/controllers/disk_controller_test.rb b/activestorage/test/controllers/disk_controller_test.rb index ea563e636392d..9bc4a19adf76c 100644 --- a/activestorage/test/controllers/disk_controller_test.rb +++ b/activestorage/test/controllers/disk_controller_test.rb @@ -74,7 +74,7 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest test "directly uploading blob with integrity" do data = "Something else entirely!" - blob = create_blob_before_direct_upload byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest(data) + blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data) put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "text/plain" } assert_response :no_content @@ -83,26 +83,26 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest test "directly uploading blob without integrity" do data = "Something else entirely!" - blob = create_blob_before_direct_upload byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest("bad data") + blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest("bad data") put blob.service_url_for_direct_upload, params: data - assert_response :unprocessable_entity + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT assert_not blob.service.exist?(blob.key) end test "directly uploading blob with mismatched content type" do data = "Something else entirely!" - blob = create_blob_before_direct_upload byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest(data) + blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data) put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "application/octet-stream" } - assert_response :unprocessable_entity + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT assert_not blob.service.exist?(blob.key) end test "directly uploading blob with different but equivalent content type" do data = "Something else entirely!" blob = create_blob_before_direct_upload( - byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest(data), content_type: "application/x-gzip") + byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data), content_type: "application/x-gzip") put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "application/x-gzip" } assert_response :no_content @@ -111,10 +111,10 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest test "directly uploading blob with mismatched content length" do data = "Something else entirely!" - blob = create_blob_before_direct_upload byte_size: data.size - 1, checksum: ActiveStorage.checksum_implementation.base64digest(data) + blob = create_blob_before_direct_upload byte_size: data.size - 1, checksum: OpenSSL::Digest::MD5.base64digest(data) put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "text/plain" } - assert_response :unprocessable_entity + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT assert_not blob.service.exist?(blob.key) end diff --git a/activestorage/test/controllers/representations/redirect_controller_test.rb b/activestorage/test/controllers/representations/redirect_controller_test.rb index e4b0d2e613dcd..cda2cfab3e15a 100644 --- a/activestorage/test/controllers/representations/redirect_controller_test.rb +++ b/activestorage/test/controllers/representations/redirect_controller_test.rb @@ -88,6 +88,30 @@ class ActiveStorage::Representations::RedirectControllerWithPreviewsTest < Actio assert_equal 100, image.height end + test "processing and recording variant for preview just once" do + variant_record_created = false + variant_record_loaded_count = 0 + + query_subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event| + case event.payload[:sql] + when /INSERT INTO "active_storage_variant_records"/ + variant_record_created = true + when /SELECT "active_storage_variant_records".* FROM "active_storage_variant_records"/ + next unless variant_record_created + variant_record_loaded_count += 1 + end + end + + get rails_blob_representation_url( + filename: @blob.filename, + signed_blob_id: @blob.signed_id, + variation_key: ActiveStorage::Variation.encode(resize_to_limit: [100, 100])) + + assert_equal 0, variant_record_loaded_count + ensure + ActiveSupport::Notifications.unsubscribe(query_subscriber) if query_subscriber + end + test "showing preview with invalid signed blob ID" do get rails_blob_representation_url( filename: @blob.filename, @@ -149,21 +173,6 @@ class ActiveStorage::Representations::RedirectControllerWithOpenRedirectTest < A end end - if SERVICE_CONFIGURATIONS[:azure] - test "showing existing variant stored in azure" do - with_raise_on_open_redirects(:azure) do - blob = create_file_blob filename: "racecar.jpg", service_name: :azure - - get rails_blob_representation_url( - filename: blob.filename, - signed_blob_id: blob.signed_id, - variation_key: ActiveStorage::Variation.encode(resize_to_limit: [100, 100])) - - assert_redirected_to(/racecar\.jpg/) - end - end - end - if SERVICE_CONFIGURATIONS[:gcs] test "showing existing variant stored in gcs" do with_raise_on_open_redirects(:gcs) do diff --git a/activestorage/test/dummy/config/environments/test.rb b/activestorage/test/dummy/config/environments/test.rb index 7286f46b43d3c..bdc6e053126bf 100644 --- a/activestorage/test/dummy/config/environments/test.rb +++ b/activestorage/test/dummy/config/environments/test.rb @@ -43,12 +43,6 @@ puts "Missing service configuration file in #{config_file}" {} end - # Azure service tests are currently failing on the main branch. - # We temporarily disable them while we get things working again. - if ENV["BUILDKITE"] - SERVICE_CONFIGURATIONS.delete(:azure) - SERVICE_CONFIGURATIONS.delete(:azure_public) - end config.active_storage.service_configurations = SERVICE_CONFIGURATIONS.merge( "local" => { "service" => "Disk", "root" => Dir.mktmpdir("active_storage_tests") }, diff --git a/activestorage/test/dummy/config/storage.yml b/activestorage/test/dummy/config/storage.yml index 4942ab66948b7..927dc537c8a6c 100644 --- a/activestorage/test/dummy/config/storage.yml +++ b/activestorage/test/dummy/config/storage.yml @@ -21,13 +21,6 @@ local: # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> # bucket: your_own_bucket-<%= Rails.env %> -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name-<%= Rails.env %> - # mirror: # service: Mirror # primary: local diff --git a/activestorage/test/jobs/create_variants_job_test.rb b/activestorage/test/jobs/create_variants_job_test.rb new file mode 100644 index 0000000000000..4e6eb46ee133e --- /dev/null +++ b/activestorage/test/jobs/create_variants_job_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "test_helper" +require "database/setup" + +class ActiveStorage::CreateVariantsJobTest < ActiveJob::TestCase + setup do + @variants = [{ resize_to_limit: [ 100, 100 ] }, { resize_to_limit: [ 200, 200 ] }] + end + + test "creates preview when previewable" do + blob = create_file_blob(filename: "report.pdf", content_type: "application/pdf") + + assert_changes -> { blob.preview_image.attached? }, from: false, to: true do + ActiveStorage::CreateVariantsJob.perform_now blob, variants: @variants, process: :later + end + end + + test "enqueues individual transform jobs for each transformation when process: :later" do + blob = create_file_blob + ActiveStorage::CreateVariantsJob.perform_now blob, variants: @variants, process: :later + + @variants.each do |transformations| + assert_enqueued_with job: ActiveStorage::TransformJob, args: [ blob, transformations ] + end + end + + test "does not transform when process: :later" do + blob = create_file_blob + ActiveStorage::CreateVariantsJob.perform_now blob, variants: @variants, process: :later + + @variants.each do |transformations| + assert_not blob.variant(transformations).processed? + end + end + + test "performs transformations immediately when process: :immediately" do + blob = create_file_blob + ActiveStorage::CreateVariantsJob.perform_now blob, variants: @variants, process: :immediately + + @variants.each do |transformations| + assert blob.variant(transformations).processed? + end + end +end diff --git a/activestorage/test/jobs/preview_image_job_test.rb b/activestorage/test/jobs/preview_image_job_test.rb deleted file mode 100644 index ea8040f18f43a..0000000000000 --- a/activestorage/test/jobs/preview_image_job_test.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "database/setup" - -class ActiveStorage::PreviewImageJobTest < ActiveJob::TestCase - setup do - @blob = create_file_blob(filename: "report.pdf", content_type: "application/pdf") - @transformation = { resize_to_limit: [ 100, 100 ] } - end - - test "creates preview" do - assert_changes -> { @blob.preview_image.attached? }, from: false, to: true do - ActiveStorage::PreviewImageJob.perform_now @blob, [ @transformation ] - end - end - - test "enqueues transform variant jobs" do - assert_enqueued_with job: ActiveStorage::TransformJob, args: [ @blob, @transformation ] do - ActiveStorage::PreviewImageJob.perform_now @blob, [ @transformation ] - end - end -end diff --git a/activestorage/test/jobs/transform_job_test.rb b/activestorage/test/jobs/transform_job_test.rb index 174d358169380..3f26302b33e4d 100644 --- a/activestorage/test/jobs/transform_job_test.rb +++ b/activestorage/test/jobs/transform_job_test.rb @@ -9,7 +9,7 @@ class ActiveStorage::TransformJobTest < ActiveJob::TestCase test "creates variant" do transformations = { resize_to_limit: [100, 100] } - assert_changes -> { @blob.variant(transformations).send(:processed?) }, from: false, to: true do + assert_changes -> { @blob.variant(transformations).processed? }, from: false, to: true do perform_enqueued_jobs do ActiveStorage::TransformJob.perform_later @blob, transformations end @@ -20,14 +20,14 @@ class ActiveStorage::TransformJobTest < ActiveJob::TestCase @blob = create_file_blob(filename: "report.pdf", content_type: "application/pdf") transformations = { resize_to_limit: [100, 100] } - assert_changes -> { @blob.preview(transformations).send(:processed?) }, from: false, to: true do + assert_changes -> { @blob.preview(transformations).processed? }, from: false, to: true do perform_enqueued_jobs do ActiveStorage::TransformJob.perform_later @blob, transformations end @blob.reload end - assert @blob.preview(transformations).image.variant(transformations).send(:processed?) + assert @blob.preview(transformations).image.variant(transformations).processed? end test "creates variant when untracked" do @@ -35,7 +35,7 @@ class ActiveStorage::TransformJobTest < ActiveJob::TestCase transformations = { resize_to_limit: [100, 100] } begin - assert_changes -> { @blob.variant(transformations).send(:processed?) }, from: false, to: true do + assert_changes -> { @blob.variant(transformations).processed? }, from: false, to: true do perform_enqueued_jobs do ActiveStorage::TransformJob.perform_later @blob, transformations end @@ -45,14 +45,21 @@ class ActiveStorage::TransformJobTest < ActiveJob::TestCase end end - test "ignores unrepresentable blob" do - unrepresentable_blob = create_blob(content_type: "text/plain") - transformations = { resize_to_limit: [100, 100] } + test "null transformer returns original file" do + @was_transformer = ActiveStorage.variant_transformer + ActiveStorage.variant_transformer = ActiveStorage::Transformers::NullTransformer - perform_enqueued_jobs do - assert_nothing_raised do - ActiveStorage::TransformJob.perform_later unrepresentable_blob, transformations + transformations = { resize_to_limit: [100, 100] } + assert_changes -> { @blob.variant(transformations).processed? }, from: false, to: true do + perform_enqueued_jobs do + ActiveStorage::TransformJob.perform_later @blob, transformations end end + + original = @blob.download + result = @blob.variant(transformations).processed.download + assert_equal original, result + ensure + ActiveStorage.variant_transformer = @was_transformer end end diff --git a/activestorage/test/log_subscriber_test.rb b/activestorage/test/log_subscriber_test.rb new file mode 100644 index 0000000000000..63ccd8387bf69 --- /dev/null +++ b/activestorage/test/log_subscriber_test.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_support/log_subscriber/test_helper" +require "active_support/testing/event_reporter_assertions" +require "active_storage/structured_event_subscriber" +require "active_storage/log_subscriber" +require "database/setup" + +module ActiveStorage + class LogSubscriberTest < ActiveSupport::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + setup do + @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + @old_logger = ActiveStorage::LogSubscriber.logger + ActiveStorage::LogSubscriber.logger = @logger + end + + teardown do + ActiveStorage::LogSubscriber.logger = @old_logger + end + + def run(*) + with_debug_event_reporting do + super + end + end + + test "service_upload" do + User.create!(name: "Test", avatar: { io: StringIO.new, filename: "avatar.jpg" }) + + assert_equal 1, @logger.logged(:info).count + assert_match(/Uploaded file/, @logger.logged(:info).first) + end + + test "service_download" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + user.avatar.download + + assert_equal 2, @logger.logged(:info).count + assert_match(/Downloaded file/, @logger.logged(:info).last) + end + + test "service_streaming_download" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + user.avatar.download { } + + assert_equal 2, @logger.logged(:info).count + assert_match(/Downloaded file/, @logger.logged(:info).last) + end + + test "preview" do + blob = create_file_blob(filename: "cropped.pdf", content_type: "application/pdf") + user = User.create!(name: "Test", avatar: blob) + + user.avatar.preview(resize_to_limit: [640, 280]).processed + + assert_equal 6, @logger.logged(:info).count + assert_match(/Previewed file/, @logger.logged(:info)[2]) + end + + test "service_delete" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + user.avatar.purge + + assert_equal 2, @logger.logged(:info).count + assert_match(/Deleted file/, @logger.logged(:info).last) + end + + test "service_delete_prefixed" do + blob = create_file_blob(fixture: "colors.bmp") + user = User.create!(name: "Test", avatar: blob) + + user.avatar.purge + + assert_equal 3, @logger.logged(:info).count + assert_match(/Deleted files by key prefix/, @logger.logged(:info).last) + end + + test "service_exist" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + user.avatar.service.exist? user.avatar.key + + assert_equal 1, @logger.logged(:debug).count + assert_match(/Checked if file exists/, @logger.logged(:debug).last) + end + + test "service_url" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + user.avatar.url + + assert_equal 1, @logger.logged(:debug).count + assert_match(/Generated URL for file/, @logger.logged(:debug).last) + end + + test "service_mirror" do + blob = create_blob(filename: "avatar.jpg") + + mirror_config = (1..3).to_h do |i| + [ "mirror_#{i}", + service: "Disk", + root: Dir.mktmpdir("active_storage_tests_mirror_#{i}") ] + end + + config = mirror_config.merge \ + mirror: { service: "Mirror", primary: "primary", mirrors: mirror_config.keys }, + primary: { service: "Disk", root: Dir.mktmpdir("active_storage_tests_primary") } + + service = ActiveStorage::Service.configure :mirror, config + service.upload blob.key, StringIO.new(blob.download), checksum: blob.checksum + + service.mirror blob.key, checksum: blob.checksum + + assert_equal 4, @logger.logged(:debug).count + assert_match(/Mirrored file/, @logger.logged(:debug).last) + end + end +end diff --git a/activestorage/test/models/attached/many_test.rb b/activestorage/test/models/attached/many_test.rb index e9655037ef502..646a5c46e2ab3 100644 --- a/activestorage/test/models/attached/many_test.rb +++ b/activestorage/test/models/attached/many_test.rb @@ -916,50 +916,6 @@ def self.name; superclass.name; end assert_match(/Cannot find variant :unknown for User#highlights_with_variants/, error.message) end - test "transforms variants later" do - blob = create_file_blob(filename: "racecar.jpg") - - assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [1, 1]] do - @user.highlights_with_preprocessed.attach blob - end - end - - test "transforms variants later conditionally via proc" do - assert_no_enqueued_jobs only: [ ActiveStorage::TransformJob, ActiveStorage::PreviewImageJob ] do - @user.highlights_with_conditional_preprocessed.attach create_file_blob(filename: "racecar.jpg") - end - - blob = create_file_blob(filename: "racecar.jpg") - @user.update(name: "transform via proc") - - assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [2, 2]] do - @user.highlights_with_conditional_preprocessed.attach blob - end - end - - test "transforms variants later conditionally via method" do - assert_no_enqueued_jobs only: [ ActiveStorage::TransformJob, ActiveStorage::PreviewImageJob ] do - @user.highlights_with_conditional_preprocessed.attach create_file_blob(filename: "racecar.jpg") - end - - blob = create_file_blob(filename: "racecar.jpg") - @user.update(name: "transform via method") - - assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [3, 3]] do - assert_no_enqueued_jobs only: ActiveStorage::PreviewImageJob do - @user.highlights_with_conditional_preprocessed.attach blob - end - end - end - - test "avoids enqueuing transform later and create preview job job when blob is not representable" do - unrepresentable_blob = create_blob(filename: "hello.txt") - - assert_no_enqueued_jobs only: [ ActiveStorage::TransformJob, ActiveStorage::PreviewImageJob ] do - @user.highlights_with_preprocessed.attach unrepresentable_blob - end - end - test "successfully attaches new blobs and destroys attachments marked for destruction via nested attributes" do town_blob = create_blob(filename: "town.jpg") @user.highlights.attach(town_blob) diff --git a/activestorage/test/models/attached/one_test.rb b/activestorage/test/models/attached/one_test.rb index 9755b6910767e..a625c7faf33a2 100644 --- a/activestorage/test/models/attached/one_test.rb +++ b/activestorage/test/models/attached/one_test.rb @@ -857,64 +857,4 @@ def self.name; superclass.name; end assert_match(/Cannot find variant :unknown for User#avatar_with_variants/, error.message) end - - test "transforms variants later" do - blob = create_file_blob(filename: "racecar.jpg") - - assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [1, 1]] do - @user.avatar_with_preprocessed.attach blob - end - end - - test "transforms variants later conditionally via proc" do - assert_no_enqueued_jobs only: [ ActiveStorage::TransformJob, ActiveStorage::PreviewImageJob ] do - @user.avatar_with_conditional_preprocessed.attach create_file_blob(filename: "racecar.jpg") - end - - blob = create_file_blob(filename: "racecar.jpg") - @user.update(name: "transform via proc") - - assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [2, 2]] do - @user.avatar_with_conditional_preprocessed.attach blob - end - end - - test "transforms variants later conditionally via method" do - assert_no_enqueued_jobs only: [ ActiveStorage::TransformJob, ActiveStorage::PreviewImageJob ] do - @user.avatar_with_conditional_preprocessed.attach create_file_blob(filename: "racecar.jpg") - end - - blob = create_file_blob(filename: "racecar.jpg") - @user.update(name: "transform via method") - - assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [3, 3]] do - @user.avatar_with_conditional_preprocessed.attach blob - end - end - - test "avoids enqueuing transform later job or preview image job when blob is not representable" do - unrepresentable_blob = create_blob(filename: "hello.txt") - - assert_no_enqueued_jobs only: [ ActiveStorage::TransformJob, ActiveStorage::PreviewImageJob ] do - @user.avatar_with_preprocessed.attach unrepresentable_blob - end - end - - test "avoids enqueuing transform later job or preview later job if there aren't any variants to preprocess" do - blob = create_file_blob(filename: "report.pdf") - - assert_no_enqueued_jobs only: [ ActiveStorage::TransformJob, ActiveStorage::PreviewImageJob ] do - @user.resume.attach blob - end - end - - test "creates preview later without transforming variants if required and there are variants to preprocess" do - blob = create_file_blob(filename: "report.pdf") - - assert_enqueued_with job: ActiveStorage::PreviewImageJob, args: [blob, [resize_to_fill: [400, 400]]] do - assert_no_enqueued_jobs only: ActiveStorage::TransformJob do - @user.resume_with_preprocessing.attach blob - end - end - end end diff --git a/activestorage/test/models/attachment_test.rb b/activestorage/test/models/attachment_test.rb index a39615c35387d..61fd2ac26b1a5 100644 --- a/activestorage/test/models/attachment_test.rb +++ b/activestorage/test/models/attachment_test.rb @@ -41,7 +41,7 @@ class ActiveStorage::AttachmentTest < ActiveSupport::TestCase test "attaching a blob doesn't touch the record" do data = "Something else entirely!" io = StringIO.new(data) - blob = create_blob_before_direct_upload byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest(data) + blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data) blob.upload(io) user = User.create!( @@ -184,6 +184,45 @@ class ActiveStorage::AttachmentTest < ActiveSupport::TestCase assert_nothing_raised { ActiveStorage::Attachment.create!(name: "whatever", record: @user, blob: create_blob) } end + test "create immediate variants on attach" do + blob = create_file_blob + + assert_changes -> { @user.avatar_with_immediate_variants.variant(:immediate_thumb)&.processed? }, from: nil, to: true do + @user.avatar_with_immediate_variants.attach blob + end + end + + test "enqueues create variants job to delay transformations after attach" do + blob = create_file_blob + assert_create_variants_job blob:, variants: [{ resize_to_limit: [2, 2] }] do + @user.avatar_with_later_variants.attach blob + end + end + + test "avoids enqueuing create variants job when lazy" do + blob = create_file_blob + + assert_no_enqueued_jobs only: ActiveStorage::CreateVariantsJob do + @user.avatar_with_lazy_variants.attach blob + end + end + + test "avoids enqueuing create variants job when blob is not representable" do + unrepresentable_blob = create_blob(filename: "hello.txt") + + assert_no_enqueued_jobs only: ActiveStorage::CreateVariantsJob do + @user.avatar_with_later_variants.attach unrepresentable_blob + end + end + + test "avoids enqueuing create variants job if there aren't any variants" do + blob = create_file_blob + + assert_no_enqueued_jobs only: ActiveStorage::CreateVariantsJob do + @user.resume.attach blob + end + end + private def assert_blob_identified_before_owner_validated(owner, blob, content_type) validated_content_type = nil @@ -210,4 +249,11 @@ def assert_blob_identified_outside_transaction(blob, &block) assert_equal 0, (max_transaction_depth - baseline_transaction_depth) end + + def assert_create_variants_job(blob:, variants:, &block) + assert_enqueued_with( + job: ActiveStorage::CreateVariantsJob, + args: [ blob, variants:, process: :later ], &block + ) + end end diff --git a/activestorage/test/models/blob_test.rb b/activestorage/test/models/blob_test.rb index e28f9957460ed..0849dc985d97d 100644 --- a/activestorage/test/models/blob_test.rb +++ b/activestorage/test/models/blob_test.rb @@ -38,7 +38,7 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase assert_equal data, blob.download assert_equal data.length, blob.byte_size - assert_equal ActiveStorage.checksum_implementation.base64digest(data), blob.checksum + assert_equal OpenSSL::Digest::MD5.base64digest(data), blob.checksum end test "create_and_upload extracts content type from data" do @@ -174,7 +174,7 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase assert_equal "a" * 64.kilobytes, chunks.second end - test "open with integrity" do + test "open yielding with integrity" do create_file_blob(filename: "racecar.jpg").tap do |blob| blob.open do |file| assert_predicate file, :binmode? @@ -186,9 +186,24 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase end end + test "open returning with integrity" do + file = nil + create_file_blob(filename: "racecar.jpg").tap do |blob| + file = blob.open + + assert_predicate file, :binmode? + assert_equal 0, file.pos + assert File.basename(file.path).start_with?("ActiveStorage-#{blob.id}-") + assert file.path.end_with?(".jpg") + assert_equal file_fixture("racecar.jpg").binread, file.read, "Expected downloaded file to match fixture file" + ensure + file&.close! + end + end + test "open without integrity" do create_blob(data: "Hello, world!").tap do |blob| - blob.update! checksum: ActiveStorage.checksum_implementation.base64digest("Goodbye, world!") + blob.update! checksum: OpenSSL::Digest::MD5.base64digest("Goodbye, world!") assert_raises ActiveStorage::IntegrityError do blob.open { |file| flunk "Expected integrity check to fail" } diff --git a/activestorage/test/models/named_variant_test.rb b/activestorage/test/models/named_variant_test.rb new file mode 100644 index 0000000000000..7738950558e2f --- /dev/null +++ b/activestorage/test/models/named_variant_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "test_helper" +require "database/setup" +require "minitest/mock" + +class ActiveStorage::NamedVariantTest < ActiveSupport::TestCase + setup do + @user = User.new(name: "joe") + end + + test "explicity sets the process to immediate" do + named_variant = @user.attachment_reflections["avatar_with_immediate_variants"].named_variants[:immediate_thumb] + assert_equal :immediately, named_variant.process(@user) + end + + test "explicity sets the process to later" do + named_variant = @user.attachment_reflections["avatar_with_later_variants"].named_variants[:later_thumb] + assert_equal :later, named_variant.process(@user) + end + + test "explicity sets the process to on_demand" do + named_variant = @user.attachment_reflections["avatar_with_lazy_variants"].named_variants[:lazy_thumb] + assert_equal :lazily, named_variant.process(@user) + end + + test "defaults process to lazy" do + named_variant = @user.attachment_reflections["avatar_with_lazy_variants"].named_variants[:default_thumb] + assert_equal :lazily, named_variant.process(@user) + end + + test "sets the process to later conditionally via preprocessed method" do + named_variant = @user.attachment_reflections["avatar_with_conditional_preprocessed"].named_variants[:method] + assert_not_equal :later, named_variant.process(@user) + + @user.name = "transform via method" + assert_equal :later, named_variant.process(@user) + end + + test "sets the process to later conditionally via preprocessed proc" do + named_variant = @user.attachment_reflections["avatar_with_conditional_preprocessed"].named_variants[:proc] + assert_not_equal :later, named_variant.process(@user) + + @user.update(name: "transform via proc") + assert_equal :later, named_variant.process(@user) + end + + test "sets the process to later conditionally via preprocessed boolean" do + @user = User.create(name: "joe") + named_variant = @user.attachment_reflections["avatar_with_preprocessed"].named_variants[:bool] + assert_equal :later, named_variant.process(@user) + end +end diff --git a/activestorage/test/models/preview_test.rb b/activestorage/test/models/preview_test.rb index 13788d4943668..5e91ef82daa9c 100644 --- a/activestorage/test/models/preview_test.rb +++ b/activestorage/test/models/preview_test.rb @@ -79,7 +79,7 @@ class ActiveStorage::PreviewTest < ActiveSupport::TestCase transformations = { resize_to_limit: [640, 280] } preview = blob.preview(transformations) - assert_changes -> { !!preview.image.variant(transformations)&.send(:processed?) }, to: true do + assert_changes -> { !!preview.image.variant(transformations)&.processed? }, to: true do preview.processed end end diff --git a/activestorage/test/models/reflection_test.rb b/activestorage/test/models/reflection_test.rb index 2303212ff1276..8fbc29a5329fd 100644 --- a/activestorage/test/models/reflection_test.rb +++ b/activestorage/test/models/reflection_test.rb @@ -39,8 +39,8 @@ class ActiveStorage::ReflectionTest < ActiveSupport::TestCase test "reflecting on all attachments" do reflections = User.reflect_on_all_attachments.sort_by(&:name) assert_equal [ User ], reflections.collect(&:active_record).uniq - assert_equal %i[ avatar avatar_with_conditional_preprocessed avatar_with_preprocessed avatar_with_variants cover_photo highlights highlights_with_conditional_preprocessed highlights_with_preprocessed highlights_with_variants intro_video name_pronunciation_audio resume resume_with_preprocessing vlogs ], reflections.collect(&:name) - assert_equal %i[ has_one_attached has_one_attached has_one_attached has_one_attached has_one_attached has_many_attached has_many_attached has_many_attached has_many_attached has_one_attached has_one_attached has_one_attached has_one_attached has_many_attached ], reflections.collect(&:macro) - assert_equal [ :purge_later, :purge_later, :purge_later, :purge_later, false, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, false ], reflections.collect { |reflection| reflection.options[:dependent] } + assert_equal [ :avatar, :avatar_with_conditional_preprocessed, :avatar_with_immediate_variants, :avatar_with_later_variants, :avatar_with_lazy_variants, :avatar_with_preprocessed, :avatar_with_variants, :cover_photo, :highlights, :highlights_with_conditional_preprocessed, :highlights_with_preprocessed, :highlights_with_variants, :intro_video, :name_pronunciation_audio, :resume, :resume_with_preprocessing, :vlogs ], reflections.collect(&:name) + assert_equal [ :has_one_attached, :has_one_attached, :has_one_attached, :has_one_attached, :has_one_attached, :has_one_attached, :has_one_attached, :has_one_attached, :has_many_attached, :has_many_attached, :has_many_attached, :has_many_attached, :has_one_attached, :has_one_attached, :has_one_attached, :has_one_attached, :has_many_attached ], reflections.collect(&:macro) + assert_equal [ :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, false, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, false ], reflections.collect { |reflection| reflection.options[:dependent] } end end diff --git a/activestorage/test/models/variant_test.rb b/activestorage/test/models/variant_test.rb index c7a402b1b125d..08945713b0daf 100644 --- a/activestorage/test/models/variant_test.rb +++ b/activestorage/test/models/variant_test.rb @@ -257,7 +257,7 @@ class ActiveStorage::VariantTest < ActiveSupport::TestCase process_variants_with :mini_magick do blob = create_file_blob(filename: "racecar.jpg") assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do - blob.variant(saver: { "-write": "/tmp/file.erb" }).processed + blob.variant(resize: { "-write": "/tmp/file.erb" }).processed end end end @@ -266,11 +266,11 @@ class ActiveStorage::VariantTest < ActiveSupport::TestCase process_variants_with :mini_magick do blob = create_file_blob(filename: "racecar.jpg") assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do - blob.variant(saver: { "something": { "-write": "/tmp/file.erb" } }).processed + blob.variant(resize: { "something": { "-write": "/tmp/file.erb" } }).processed end assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do - blob.variant(saver: { "something": ["-write", "/tmp/file.erb"] }).processed + blob.variant(resize: { "something": ["-write", "/tmp/file.erb"] }).processed end end end diff --git a/activestorage/test/service/azure_storage_public_service_test.rb b/activestorage/test/service/azure_storage_public_service_test.rb deleted file mode 100644 index ca8e932d68f44..0000000000000 --- a/activestorage/test/service/azure_storage_public_service_test.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -require "service/shared_service_tests" -require "uri" - -if SERVICE_CONFIGURATIONS[:azure_public] - class ActiveStorage::Service::AzureStoragePublicServiceTest < ActiveSupport::TestCase - SERVICE = ActiveStorage::Service.configure(:azure_public, SERVICE_CONFIGURATIONS) - - include ActiveStorage::Service::SharedServiceTests - - test "public URL generation" do - url = @service.url(@key, filename: ActiveStorage::Filename.new("avatar.png")) - - assert_match(/.*\.blob\.core\.windows\.net\/.*\/#{@key}/, url) - - response = Net::HTTP.get_response(URI(url)) - assert_equal "200", response.code - end - - test "direct upload" do - key = SecureRandom.base58(24) - data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) - content_type = "text/xml" - url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: content_type, content_length: data.size, checksum: checksum) - - uri = URI.parse url - request = Net::HTTP::Put.new uri.request_uri - request.body = data - @service.headers_for_direct_upload(key, checksum: checksum, content_type: content_type, filename: ActiveStorage::Filename.new("test.txt")).each do |k, v| - request.add_field k, v - end - Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| - http.request request - end - - response = Net::HTTP.get_response(URI(@service.url(key))) - assert_equal "200", response.code - assert_equal data, response.body - ensure - @service.delete key - end - end -else - puts "Skipping Azure Storage Public Service tests because no Azure configuration was supplied" -end diff --git a/activestorage/test/service/azure_storage_service_test.rb b/activestorage/test/service/azure_storage_service_test.rb deleted file mode 100644 index 186cae3ae0651..0000000000000 --- a/activestorage/test/service/azure_storage_service_test.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -require "service/shared_service_tests" -require "uri" - -if SERVICE_CONFIGURATIONS[:azure] - class ActiveStorage::Service::AzureStorageServiceTest < ActiveSupport::TestCase - SERVICE = ActiveStorage::Service.configure(:azure, SERVICE_CONFIGURATIONS) - - include ActiveStorage::Service::SharedServiceTests - - test "direct upload with content type" do - key = SecureRandom.base58(24) - data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) - content_type = "text/xml" - url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: content_type, content_length: data.size, checksum: checksum) - - uri = URI.parse url - request = Net::HTTP::Put.new uri.request_uri - request.body = data - @service.headers_for_direct_upload(key, checksum: checksum, content_type: content_type, filename: ActiveStorage::Filename.new("test.txt")).each do |k, v| - request.add_field k, v - end - Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| - http.request request - end - - assert_equal(content_type, @service.client.get_blob_properties(@service.container, key).properties[:content_type]) - ensure - @service.delete key - end - - test "direct upload with content disposition" do - key = SecureRandom.base58(24) - data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) - url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) - - uri = URI.parse url - request = Net::HTTP::Put.new uri.request_uri - request.body = data - @service.headers_for_direct_upload(key, checksum: checksum, content_type: "text/plain", filename: ActiveStorage::Filename.new("test.txt"), disposition: :attachment).each do |k, v| - request.add_field k, v - end - Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| - http.request request - end - - assert_equal("attachment; filename=\"test.txt\"; filename*=UTF-8''test.txt", @service.client.get_blob_properties(@service.container, key).properties[:content_disposition]) - ensure - @service.delete key - end - - test "upload with content_type" do - key = SecureRandom.base58(24) - data = "Foobar" - - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain") - - url = @service.url(key, expires_in: 2.minutes, disposition: :attachment, content_type: nil, filename: ActiveStorage::Filename.new("test.html")) - response = Net::HTTP.get_response(URI(url)) - assert_equal "text/plain", response.content_type - assert_match(/attachment;.*test\.html/, response["Content-Disposition"]) - ensure - @service.delete key - end - - test "upload with content disposition" do - key = SecureRandom.base58(24) - data = "Foobar" - - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), disposition: :inline) - - assert_equal("inline; filename=\"test.txt\"; filename*=UTF-8''test.txt", @service.client.get_blob_properties(@service.container, key).properties[:content_disposition]) - - url = @service.url(key, expires_in: 2.minutes, disposition: :attachment, content_type: nil, filename: ActiveStorage::Filename.new("test.html")) - response = Net::HTTP.get_response(URI(url)) - assert_match(/attachment;.*test\.html/, response["Content-Disposition"]) - ensure - @service.delete key - end - - test "upload with custom_metadata" do - key = SecureRandom.base58(24) - data = "Foobar" - - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), custom_metadata: { "foo" => "baz" }) - url = @service.url(key, expires_in: 2.minutes, disposition: :inline, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html")) - - response = Net::HTTP.get_response(URI(url)) - assert_equal("baz", response["x-ms-meta-foo"]) - ensure - @service.delete key - end - - test "signed URL generation" do - url = @service.url(@key, expires_in: 5.minutes, - disposition: :inline, filename: ActiveStorage::Filename.new("avatar.png"), content_type: "image/png") - - assert_match(/(\S+)&rscd=inline%3B\+filename%3D%22avatar\.png%22%3B\+filename\*%3DUTF-8%27%27avatar\.png&rsct=image%2Fpng/, url) - assert_match SERVICE_CONFIGURATIONS[:azure][:container], url - end - - test "uploading a tempfile" do - key = SecureRandom.base58(24) - data = "Something else entirely!" - - Tempfile.open do |file| - file.write(data) - file.rewind - @service.upload(key, file) - end - - assert_equal data, @service.download(key) - ensure - @service.delete(key) - end - end -else - puts "Skipping Azure Storage Service tests because no Azure configuration was supplied" -end diff --git a/activestorage/test/service/configurations.example.yml b/activestorage/test/service/configurations.example.yml index 48813f81052c6..6473082842cb7 100644 --- a/activestorage/test/service/configurations.example.yml +++ b/activestorage/test/service/configurations.example.yml @@ -23,9 +23,3 @@ # bucket: # iam: false # gsa_email: "foobar@baz.iam.gserviceaccount.com" -# -# azure: -# service: AzureStorage -# storage_account_name: "" -# storage_access_key: "" -# container: "" diff --git a/activestorage/test/service/configurations.yml.enc b/activestorage/test/service/configurations.yml.enc index 648924a562399..2dc37e3511018 100644 Binary files a/activestorage/test/service/configurations.yml.enc and b/activestorage/test/service/configurations.yml.enc differ diff --git a/activestorage/test/service/configurator_test.rb b/activestorage/test/service/configurator_test.rb index 2f5e06cd0556c..4e009174c78c4 100644 --- a/activestorage/test/service/configurator_test.rb +++ b/activestorage/test/service/configurator_test.rb @@ -21,21 +21,16 @@ class ActiveStorage::Service::ConfiguratorTest < ActiveSupport::TestCase end end - test "azure service is deprecated" do - msg = <<~MSG.squish - `ActiveStorage::Service::AzureStorageService` is deprecated and will be - removed in Rails 8.1. - Please try the `azure-blob` gem instead. - This gem is not maintained by the Rails team, so please test your applications before deploying to production. - MSG + test "inspect attributes" do + config = { + local: { service: "Disk", root: "/tmp/active_storage_configurator_test" }, + tmp: { service: "Disk", root: "/tmp/active_storage_configurator_test_tmp" }, + } - assert_deprecated(msg, ActiveStorage.deprecator) do - ActiveStorage::Service::Configurator.build(:azure, azure: { - service: "AzureStorage", - storage_account_name: "test_account", - storage_access_key: Base64.encode64("test_access_key").strip, - container: "container" - }) - end + configurator = ActiveStorage::Service::Configurator.new(config) + assert_match(/#/, configurator.inspect) + + configurator = ActiveStorage::Service::Configurator.new({}) + assert_match(/#/, configurator.inspect) end end diff --git a/activestorage/test/service/gcs_public_service_test.rb b/activestorage/test/service/gcs_public_service_test.rb index 683d6511f3b9c..ed6af9ccbcab5 100644 --- a/activestorage/test/service/gcs_public_service_test.rb +++ b/activestorage/test/service/gcs_public_service_test.rb @@ -21,7 +21,7 @@ class ActiveStorage::Service::GCSPublicServiceTest < ActiveSupport::TestCase test "direct upload" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url diff --git a/activestorage/test/service/gcs_service_test.rb b/activestorage/test/service/gcs_service_test.rb index 006bbd30a946c..93a2bb69a1dd2 100644 --- a/activestorage/test/service/gcs_service_test.rb +++ b/activestorage/test/service/gcs_service_test.rb @@ -16,7 +16,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase test "direct upload" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url @@ -36,7 +36,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase test "direct upload with content disposition" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url @@ -91,7 +91,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = "Something else entirely!" - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), disposition: :attachment, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain") + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), disposition: :attachment, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain") url = @service.url(key, expires_in: 2.minutes, disposition: :inline, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html")) response = Net::HTTP.get_response(URI(url)) @@ -105,7 +105,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = "Something else entirely!" - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), content_type: "text/plain") + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), content_type: "text/plain") url = @service.url(key, expires_in: 2.minutes, disposition: :inline, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html")) response = Net::HTTP.get_response(URI(url)) @@ -148,7 +148,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase test "update custom_metadata" do key = SecureRandom.base58(24) data = "Something else entirely!" - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), disposition: :attachment, filename: ActiveStorage::Filename.new("test.html"), content_type: "text/html", custom_metadata: { "foo" => "baz" }) + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), disposition: :attachment, filename: ActiveStorage::Filename.new("test.html"), content_type: "text/html", custom_metadata: { "foo" => "baz" }) @service.update_metadata(key, disposition: :inline, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain", custom_metadata: { "foo" => "bar" }) url = @service.url(key, expires_in: 2.minutes, disposition: :attachment, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html")) diff --git a/activestorage/test/service/mirror_service_test.rb b/activestorage/test/service/mirror_service_test.rb index 58e58092a3ee8..dc2ebc0eeeb96 100644 --- a/activestorage/test/service/mirror_service_test.rb +++ b/activestorage/test/service/mirror_service_test.rb @@ -29,7 +29,7 @@ class ActiveStorage::Service::MirrorServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = "Something else entirely!" io = StringIO.new(data) - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) assert_performed_jobs 1, only: ActiveStorage::MirrorJob do @service.upload key, io.tap(&:read), checksum: checksum @@ -49,7 +49,7 @@ class ActiveStorage::Service::MirrorServiceTest < ActiveSupport::TestCase test "downloading from primary service" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) @service.primary.upload key, StringIO.new(data), checksum: checksum @@ -68,7 +68,7 @@ class ActiveStorage::Service::MirrorServiceTest < ActiveSupport::TestCase test "mirroring a file from the primary service to secondary services where it doesn't exist" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) @service.primary.upload key, StringIO.new(data), checksum: checksum @service.mirrors.third.upload key, StringIO.new("Surprise!") diff --git a/activestorage/test/service/registry_test.rb b/activestorage/test/service/registry_test.rb new file mode 100644 index 0000000000000..d754eddd75b03 --- /dev/null +++ b/activestorage/test/service/registry_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActiveStorage::Service::RegistryTest < ActiveSupport::TestCase + test "inspect attributes" do + registry = ActiveStorage::Service::Registry.new({}) + assert_match(/#/, registry.inspect) + end + + test "inspect attributes with config" do + config = { + local: { service: "Disk", root: "/tmp/active_storage_registry_test" }, + tmp: { service: "Disk", root: "/tmp/active_storage_registry_test_tmp" }, + } + + registry = ActiveStorage::Service::Registry.new(config) + assert_match(/#/, registry.inspect) + end +end diff --git a/activestorage/test/service/s3_public_service_test.rb b/activestorage/test/service/s3_public_service_test.rb index 062d31444d1be..8f671bb3cd4b9 100644 --- a/activestorage/test/service/s3_public_service_test.rb +++ b/activestorage/test/service/s3_public_service_test.rb @@ -35,7 +35,7 @@ class ActiveStorage::Service::S3PublicServiceTest < ActiveSupport::TestCase test "direct upload" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url diff --git a/activestorage/test/service/s3_service_test.rb b/activestorage/test/service/s3_service_test.rb index ef80a7d11a933..11ca847b52dce 100644 --- a/activestorage/test/service/s3_service_test.rb +++ b/activestorage/test/service/s3_service_test.rb @@ -17,7 +17,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase test "direct upload" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url @@ -37,7 +37,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase test "direct upload with content disposition" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url @@ -58,7 +58,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase test "directly uploading file larger than the provided content-length does not work" do key = SecureRandom.base58(24) data = "Some text that is longer than the specified content length" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size - 1, checksum: checksum) uri = URI.parse url @@ -99,7 +99,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase begin key = SecureRandom.base58(24) data = "Something else entirely!" - service.upload key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data) + service.upload key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data) assert_equal "AES256", service.bucket.object(key).server_side_encryption ensure @@ -115,7 +115,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase @service.upload( key, StringIO.new(data), - checksum: ActiveStorage.checksum_implementation.base64digest(data), + checksum: OpenSSL::Digest::MD5.base64digest(data), filename: "cool_data.txt", content_type: content_type ) @@ -152,7 +152,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase @service.upload( key, StringIO.new(data), - checksum: ActiveStorage.checksum_implementation.base64digest(data), + checksum: OpenSSL::Digest::MD5.base64digest(data), filename: ActiveStorage::Filename.new("cool_data.txt"), disposition: :attachment ) @@ -169,7 +169,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = SecureRandom.bytes(8.megabytes) - service.upload key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data) + service.upload key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data) assert data == service.download(key) ensure service.delete key @@ -183,7 +183,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = SecureRandom.bytes(3.megabytes) - service.upload key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data) + service.upload key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data) assert data == service.download(key) ensure service.delete key diff --git a/activestorage/test/service/shared_service_tests.rb b/activestorage/test/service/shared_service_tests.rb index 9900bd990e2a7..4695af821a927 100644 --- a/activestorage/test/service/shared_service_tests.rb +++ b/activestorage/test/service/shared_service_tests.rb @@ -22,7 +22,7 @@ module ActiveStorage::Service::SharedServiceTests test "uploading with integrity" do key = SecureRandom.base58(24) data = "Something else entirely!" - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data)) + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data)) assert_equal data, @service.download(key) ensure @@ -34,7 +34,7 @@ module ActiveStorage::Service::SharedServiceTests data = "Something else entirely!" assert_raises(ActiveStorage::IntegrityError) do - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest("bad data")) + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest("bad data")) end assert_not @service.exist?(key) @@ -48,7 +48,7 @@ module ActiveStorage::Service::SharedServiceTests @service.upload( key, StringIO.new(data), - checksum: ActiveStorage.checksum_implementation.base64digest(data), + checksum: OpenSSL::Digest::MD5.base64digest(data), filename: "racecar.jpg", content_type: "image/jpeg" ) diff --git a/activestorage/test/service_test.rb b/activestorage/test/service_test.rb new file mode 100644 index 0000000000000..5bc250b48a783 --- /dev/null +++ b/activestorage/test/service_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActiveStorage::ServiceTest < ActiveSupport::TestCase + test "inspect attributes" do + config = { + local: { service: "Disk", root: "/tmp/active_storage_service_test" }, + tmp: { service: "Disk", root: "/tmp/active_storage_service_test_tmp" }, + } + + service = ActiveStorage::Service.configure(:local, config) + assert_match(/#/, service.inspect) + + service = ActiveStorage::Service.new + assert_match(/#/, service.inspect) + end +end diff --git a/activestorage/test/structured_event_subscriber_test.rb b/activestorage/test/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..22293d8469bed --- /dev/null +++ b/activestorage/test/structured_event_subscriber_test.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_support/testing/event_reporter_assertions" +require "active_storage/structured_event_subscriber" +require "database/setup" + +module ActiveStorage + class StructuredEventSubscriberTest < ActiveSupport::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + test "service_upload" do + event = assert_event_reported("active_storage.service_upload", payload: { key: /.*/, checksum: /.*/ }) do + User.create!(name: "Test", avatar: { io: StringIO.new, filename: "avatar.jpg" }) + end + + assert(event[:payload][:duration_ms] > 0) + end + + test "service_download" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + event = assert_event_reported("active_storage.service_download", payload: { key: user.avatar.key }) do + user.avatar.download + end + + assert(event[:payload][:duration_ms] > 0) + end + + test "service_streaming_download" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + event = assert_event_reported("active_storage.service_streaming_download", payload: { key: user.avatar.key }) do + user.avatar.download { } + end + + assert(event[:payload][:duration_ms] > 0) + end + + test "preview" do + blob = create_file_blob(filename: "cropped.pdf", content_type: "application/pdf") + user = User.create!(name: "Test", avatar: blob) + + event = assert_event_reported("active_storage.preview", payload: { key: user.avatar.key }) do + user.avatar.preview(resize_to_limit: [640, 280]).processed + end + + assert(event[:payload][:duration_ms] > 0) + end + + test "service_delete" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + event = assert_event_reported("active_storage.service_delete", payload: { key: user.avatar.key }) do + user.avatar.purge + end + + assert(event[:payload][:duration_ms] > 0) + end + + test "service_delete_prefixed" do + blob = create_file_blob(fixture: "colors.bmp") + user = User.create!(name: "Test", avatar: blob) + + event = assert_event_reported("active_storage.service_delete_prefixed", payload: { prefix: /variants\/.*/ }) do + user.avatar.purge + end + + assert(event[:payload][:duration_ms] > 0) + end + + test "service_exist" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + event = with_debug_event_reporting do + assert_event_reported("active_storage.service_exist", payload: { key: /.*/, exist: true }) do + user.avatar.service.exist? user.avatar.key + end + end + + assert(event[:payload][:duration_ms] > 0) + end + + test "service_url" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + event = with_debug_event_reporting do + assert_event_reported("active_storage.service_url", payload: { key: /.*/, url: /.*/ }) do + user.avatar.url + end + end + + assert(event[:payload][:duration_ms] > 0) + end + + test "service_mirror" do + blob = create_blob(filename: "avatar.jpg") + + mirror_config = (1..3).to_h do |i| + [ "mirror_#{i}", + service: "Disk", + root: Dir.mktmpdir("active_storage_tests_mirror_#{i}") ] + end + + config = mirror_config.merge \ + mirror: { service: "Mirror", primary: "primary", mirrors: mirror_config.keys }, + primary: { service: "Disk", root: Dir.mktmpdir("active_storage_tests_primary") } + + service = ActiveStorage::Service.configure :mirror, config + service.upload blob.key, StringIO.new(blob.download), checksum: blob.checksum + + event = with_debug_event_reporting do + assert_event_reported("active_storage.service_mirror", payload: { key: /.*/, url: /.*/ }) do + service.mirror blob.key, checksum: blob.checksum + end + end + + assert(event[:payload][:duration_ms] > 0) + end + end +end diff --git a/activestorage/test/test_helper.rb b/activestorage/test/test_helper.rb index ba45d38d37407..6bd6ae91491d9 100644 --- a/activestorage/test/test_helper.rb +++ b/activestorage/test/test_helper.rb @@ -12,6 +12,7 @@ require "active_support/testing/autorun" require "image_processing/mini_magick" +require "active_support/current_attributes/test_helper" require "active_record/testing/query_assertions" require "active_job" @@ -25,6 +26,7 @@ class ActiveSupport::TestCase self.file_fixture_path = ActiveStorage::FixtureSet.file_fixture_path + include ActiveSupport::CurrentAttributes::TestHelper include ActiveRecord::TestFixtures include ActiveRecord::Assertions::QueryAssertions @@ -34,10 +36,6 @@ class ActiveSupport::TestCase ActiveStorage::Current.url_options = { protocol: "https://", host: "example.com", port: nil } end - teardown do - ActiveStorage::Current.reset - end - private def create_blob(key: nil, data: "Hello world!", filename: "hello.txt", content_type: "text/plain", identify: true, service_name: nil, record: nil) ActiveStorage::Blob.create_and_upload! key: key, io: StringIO.new(data), filename: filename, content_type: content_type, identify: identify, service_name: service_name, record: record @@ -58,7 +56,7 @@ def build_blob_after_unfurling(key: nil, data: "Hello world!", filename: "hello. def directly_upload_file_blob(filename: "racecar.jpg", content_type: "image/jpeg", record: nil) file = file_fixture(filename) byte_size = file.size - checksum = ActiveStorage.checksum_implementation.file(file).base64digest + checksum = OpenSSL::Digest::MD5.file(file).base64digest create_blob_before_direct_upload(filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, record: record).tap do |blob| service = ActiveStorage::Blob.service.try(:primary) || ActiveStorage::Blob.service @@ -131,6 +129,16 @@ class User < ActiveRecord::Base has_one_attached :avatar_with_variants do |attachable| attachable.variant :thumb, resize_to_limit: [100, 100] end + has_one_attached :avatar_with_immediate_variants do |attachable| + attachable.variant :immediate_thumb, resize_to_limit: [1, 1], process: :immediately + end + has_one_attached :avatar_with_later_variants do |attachable| + attachable.variant :later_thumb, resize_to_limit: [2, 2], process: :later + end + has_one_attached :avatar_with_lazy_variants do |attachable| + attachable.variant :lazy_thumb, resize_to_limit: [3, 3], process: :lazily + attachable.variant :default_thumb, resize_to_limit: [4, 4] + end has_one_attached :avatar_with_preprocessed do |attachable| attachable.variant :bool, resize_to_limit: [1, 1], preprocessed: true end diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index c19cabb00ac53..80e3bcb369071 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,123 +1,34 @@ -* Introduce ActiveSupport::ErrorReporter#add_middleware +* Add `SecureRandom.base32` for generating case-insensitive keys that are unambiguous to humans. - When reporting an error, the error context middleware will be called with the reported error - and base execution context. The stack may mutate the context hash. The mutated context will - then be passed to error subscribers. Middleware receives the same parameters as `ErrorReporter#report`. + *Stanko Krtalic Rusendic & Miha Rekar* - *Andrew Novoselac*, *Sam Schmidt* +* Add a fast failure mode to `ActiveSupport::ContinuousIntegration` that stops the rest of + the run after a step fails. Invoke by running `bin/ci --fail-fast` or `bin/ci -f`. -* Change execution wrapping to report all exceptions, including `Exception`. + *Dennis Paagman* - If a more serious error like `SystemStackError` or `NoMemoryError` happens, - the error reporter should be able to report these kinds of exceptions. +* Implement LocalCache strategy on `ActiveSupport::Cache::MemoryStore`. The memory store + needs to respond to the same interface as other cache stores (e.g. `ActiveSupport::NullStore`). - *Gannon McGibbon* + *Mikey Gough* -* `ActiveSupport::Testing::Parallelization.before_fork_hook` allows declaration of callbacks that - are invoked immediately before forking test workers. +* Add a detailed failure summary to `ActiveSupport::ContinuousIntegration`. *Mike Dalessio* -* Allow the `#freeze_time` testing helper to accept a date or time argument. - - ```ruby - Time.current # => Sun, 09 Jul 2024 15:34:49 EST -05:00 - freeze_time Time.current + 1.day - sleep 1 - Time.current # => Mon, 10 Jul 2024 15:34:49 EST -05:00 - ``` - - *Joshua Young* - -* `ActiveSupport::JSON` now accepts options - - It is now possible to pass options to `ActiveSupport::JSON`: - ```ruby - ActiveSupport::JSON.decode('{"key": "value"}', symbolize_names: true) # => { key: "value" } - ``` - - *matthaigh27* - -* `ActiveSupport::Testing::NotificationAssertions`'s `assert_notification` now matches against payload subsets by default. - - Previously the following assertion would fail due to excess key vals in the notification payload. Now with payload subset matching, it will pass. +* Introduce `ActiveSupport::EventReporter::LogSubscriber` structured event logging. ```ruby - assert_notification("post.submitted", title: "Cool Post") do - ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post", body: "Cool Body") - end - ``` - - Additionally, you can now persist a matched notification for more customized assertions. + class MyLogSubscriber < ActiveSupport::EventReporter::LogSubscriber + self.namespace = "test" - ```ruby - notification = assert_notification("post.submitted", title: "Cool Post") do - ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post", body: Body.new("Cool Body")) + def something(event) + info { "Event #{event[:name]} emitted." } + end end - - assert_instance_of(Body, notification.payload[:body]) - ``` - - *Nicholas La Roux* - -* Deprecate `String#mb_chars` and `ActiveSupport::Multibyte::Chars`. - - These APIs are a relic of the Ruby 1.8 days when Ruby strings weren't encoding - aware. There is no legitimate reasons to need these APIs today. - - *Jean Boussier* - -* Deprecate `ActiveSupport::Configurable` - - *Sean Doyle* - -* `nil.to_query("key")` now returns `key`. - - Previously it would return `key=`, preventing round tripping with `Rack::Utils.parse_nested_query`. - - *Erol Fornoles* - -* Avoid wrapping redis in a `ConnectionPool` when using `ActiveSupport::Cache::RedisCacheStore` if the `:redis` - option is already a `ConnectionPool`. - - *Joshua Young* - -* Alter `ERB::Util.tokenize` to return :PLAIN token with full input string when string doesn't contain ERB tags. - - *Martin Emde* - -* Fix a bug in `ERB::Util.tokenize` that causes incorrect tokenization when ERB tags are preceeded by multibyte characters. - - *Martin Emde* - -* Add `ActiveSupport::Testing::NotificationAssertions` module to help with testing `ActiveSupport::Notifications`. - - *Nicholas La Roux*, *Yishu See*, *Sean Doyle* - -* `ActiveSupport::CurrentAttributes#attributes` now will return a new hash object on each call. - - Previously, the same hash object was returned each time that method was called. - - *fatkodima* - -* `ActiveSupport::JSON.encode` supports CIDR notation. - - Previously: - - ```ruby - ActiveSupport::JSON.encode(IPAddr.new("172.16.0.0/24")) # => "\"172.16.0.0\"" ``` - After this change: - - ```ruby - ActiveSupport::JSON.encode(IPAddr.new("172.16.0.0/24")) # => "\"172.16.0.0/24\"" - ``` - - *Taketo Takashima* - -* Make `ActiveSupport::FileUpdateChecker` faster when checking many file-extensions. + *Gannon McGibbon* - *Jonathan del Strother* -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activesupport/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/activesupport/CHANGELOG.md) for previous changes. diff --git a/activesupport/README.rdoc b/activesupport/README.rdoc index 04e1f778b000d..b8de6afadbf2e 100644 --- a/activesupport/README.rdoc +++ b/activesupport/README.rdoc @@ -35,6 +35,6 @@ Bug reports for the Ruby on \Rails project can be filed here: * https://github.com/rails/rails/issues -Feature requests should be discussed on the rails-core mailing list here: +Feature requests should be discussed on the rubyonrails-core forum here: * https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/activesupport/Rakefile b/activesupport/Rakefile index 9b33a91cd02d8..90034a3642069 100644 --- a/activesupport/Rakefile +++ b/activesupport/Rakefile @@ -15,9 +15,15 @@ Rake::TestTask.new do |t| end namespace :test do - task :isolated do + task isolated: :railties do Dir.glob("test/**/*_test.rb").all? do |file| sh(Gem.ruby, "-w", file) end || raise("Failures") end + + task :railties do + ["active_support/i18n_railtie", "active_support/railtie"].all? do |railtie| + sh(Gem.ruby, "-r", railtie, "-e", "'OK'") + end || raise("Failures") + end end diff --git a/activesupport/activesupport.gemspec b/activesupport/activesupport.gemspec index 13e4a79c7335b..8aac4535db0e1 100644 --- a/activesupport/activesupport.gemspec +++ b/activesupport/activesupport.gemspec @@ -42,8 +42,8 @@ Gem::Specification.new do |s| s.add_dependency "base64" s.add_dependency "drb" s.add_dependency "bigdecimal" + s.add_dependency "json" s.add_dependency "logger", ">= 1.4.2" s.add_dependency "securerandom", ">= 0.3" s.add_dependency "uri", ">= 0.13.1" - s.add_dependency "benchmark", ">= 0.3" end diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb index c5ab56fde7861..70722f472260d 100644 --- a/activesupport/lib/active_support.rb +++ b/activesupport/lib/active_support.rb @@ -39,18 +39,22 @@ module ActiveSupport autoload :Concern autoload :CodeGenerator autoload :ActionableError + autoload :Configurable autoload :ConfigurationFile autoload :ContinuousIntegration autoload :CurrentAttributes autoload :Dependencies autoload :DescendantsTracker + autoload :Editor autoload :ExecutionWrapper autoload :Executor autoload :ErrorReporter + autoload :EventReporter autoload :FileUpdateChecker autoload :EventedFileUpdateChecker autoload :ForkTracker autoload :LogSubscriber + autoload :StructuredEventSubscriber autoload :IsolatedExecutionState autoload :Notifications autoload :Reloader @@ -62,7 +66,7 @@ module ActiveSupport autoload :Benchmarkable autoload :Cache autoload :Callbacks - autoload :Configurable + autoload :ColorizeLogging autoload :ClassAttribute autoload :Deprecation autoload :Delegation @@ -92,10 +96,6 @@ module ActiveSupport autoload :SafeBuffer, "active_support/core_ext/string/output_safety" autoload :TestCase - include Deprecation::DeprecatedConstantAccessor - - deprecate_constant :Configurable, "class_attribute :config, default: {}", deprecator: ActiveSupport.deprecator - def self.eager_load! super @@ -104,10 +104,19 @@ def self.eager_load! cattr_accessor :test_order # :nodoc: cattr_accessor :test_parallelization_threshold, default: 50 # :nodoc: + cattr_accessor :parallelize_test_databases, default: true # :nodoc: @error_reporter = ActiveSupport::ErrorReporter.new singleton_class.attr_accessor :error_reporter # :nodoc: + @event_reporter = ActiveSupport::EventReporter.new + singleton_class.attr_accessor :event_reporter # :nodoc: + + cattr_accessor :filter_parameters, default: [] # :nodoc: + + @colorize_logging = true + singleton_class.attr_accessor :colorize_logging + def self.cache_format_version Cache.format_version end @@ -117,23 +126,18 @@ def self.cache_format_version=(value) end def self.to_time_preserves_timezone - DateAndTime::Compatibility.preserve_timezone + ActiveSupport.deprecator.warn( + "`config.active_support.to_time_preserves_timezone` is deprecated and will be removed in Rails 8.2" + ) + @to_time_preserves_timezone end def self.to_time_preserves_timezone=(value) - if !value - ActiveSupport.deprecator.warn( - "`to_time` will always preserve the receiver timezone rather than system local time in Rails 8.1. " \ - "To opt in to the new behavior, set `config.active_support.to_time_preserves_timezone = :zone`." - ) - elsif value != :zone - ActiveSupport.deprecator.warn( - "`to_time` will always preserve the full timezone rather than offset of the receiver in Rails 8.1. " \ - "To opt in to the new behavior, set `config.active_support.to_time_preserves_timezone = :zone`." - ) - end - - DateAndTime::Compatibility.preserve_timezone = value + ActiveSupport.deprecator.warn( + "`config.active_support.to_time_preserves_timezone` is deprecated and will be removed in Rails 8.2" + ) + + @to_time_preserves_timezone = value end def self.utc_to_local_returns_utc_offset_times diff --git a/activesupport/lib/active_support/backtrace_cleaner.rb b/activesupport/lib/active_support/backtrace_cleaner.rb index 0b9ec3461d288..a47852947f010 100644 --- a/activesupport/lib/active_support/backtrace_cleaner.rb +++ b/activesupport/lib/active_support/backtrace_cleaner.rb @@ -56,6 +56,18 @@ def clean(backtrace, kind = :silent) end alias :filter :clean + # Given an array of Thread::Backtrace::Location objects, returns an array + # with the clean ones: + # + # clean_locations = backtrace_cleaner.clean_locations(caller_locations) + # + # Filters and silencers receive strings as usual. However, the +path+ + # attributes of the locations in the returned array are the original, + # unfiltered ones, since locations are immutable. + def clean_locations(locations, kind = :silent) + locations.select { |location| clean_frame(location, kind) } + end + # Returns the frame with all filters applied. # returns +nil+ if the frame was silenced. def clean_frame(frame, kind = :silent) @@ -74,6 +86,65 @@ def clean_frame(frame, kind = :silent) end end + # Thread.each_caller_location does not accept a start in Ruby < 3.4. + if Thread.method(:each_caller_location).arity == 0 + # Returns the first clean frame of the caller's backtrace, or +nil+. + # + # Frames are strings. + def first_clean_frame(kind = :silent) + caller_location_skipped = false + + Thread.each_caller_location do |location| + unless caller_location_skipped + caller_location_skipped = true + next + end + + frame = clean_frame(location, kind) + return frame if frame + end + end + + # Returns the first clean location of the caller's call stack, or +nil+. + # + # Locations are Thread::Backtrace::Location objects. Since they are + # immutable, their +path+ attributes are the original ones, but filters + # are applied internally so silencers can still rely on them. + def first_clean_location(kind = :silent) + caller_location_skipped = false + + Thread.each_caller_location do |location| + unless caller_location_skipped + caller_location_skipped = true + next + end + + return location if clean_frame(location, kind) + end + end + else + # Returns the first clean frame of the caller's backtrace, or +nil+. + # + # Frames are strings. + def first_clean_frame(kind = :silent) + Thread.each_caller_location(2) do |location| + frame = clean_frame(location, kind) + return frame if frame + end + end + + # Returns the first clean location of the caller's call stack, or +nil+. + # + # Locations are Thread::Backtrace::Location objects. Since they are + # immutable, their +path+ attributes are the original ones, but filters + # are applied internally so silencers can still rely on them. + def first_clean_location(kind = :silent) + Thread.each_caller_location(2) do |location| + return location if clean_frame(location, kind) + end + end + end + # Adds a filter from the block provided. Each line in the backtrace will be # mapped against this filter. # diff --git a/activesupport/lib/active_support/broadcast_logger.rb b/activesupport/lib/active_support/broadcast_logger.rb index ae3db20509dd7..749a0bb11864b 100644 --- a/activesupport/lib/active_support/broadcast_logger.rb +++ b/activesupport/lib/active_support/broadcast_logger.rb @@ -76,7 +76,6 @@ class BroadcastLogger # Returns all the logger that are part of this broadcast. attr_reader :broadcasts - attr_reader :formatter attr_accessor :progname def initialize(*loggers) @@ -105,62 +104,36 @@ def stop_broadcasting_to(logger) @broadcasts.delete(logger) end - def level - @broadcasts.map(&:level).min - end - - def <<(message) - dispatch { |logger| logger.<<(message) } - end - - def add(...) - dispatch { |logger| logger.add(...) } - end - alias_method :log, :add - - def debug(...) - dispatch { |logger| logger.debug(...) } - end - - def info(...) - dispatch { |logger| logger.info(...) } - end - - def warn(...) - dispatch { |logger| logger.warn(...) } - end - - def error(...) - dispatch { |logger| logger.error(...) } - end - - def fatal(...) - dispatch { |logger| logger.fatal(...) } - end - - def unknown(...) - dispatch { |logger| logger.unknown(...) } + def local_level=(level) + @broadcasts.each do |logger| + logger.local_level = level if logger.respond_to?(:local_level=) + end end - def formatter=(formatter) - dispatch { |logger| logger.formatter = formatter } - - @formatter = formatter - end + def local_level + loggers = @broadcasts.select { |logger| logger.respond_to?(:local_level) } - def level=(level) - dispatch { |logger| logger.level = level } + loggers.map do |logger| + logger.local_level + end.first end - alias_method :sev_threshold=, :level= - def local_level=(level) - dispatch do |logger| - logger.local_level = level if logger.respond_to?(:local_level=) - end + LOGGER_METHODS = %w[ + << log add debug info warn error fatal unknown + level= sev_threshold= close + formatter formatter= + ] # :nodoc: + LOGGER_METHODS.each do |method| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{method}(...) + dispatch(:#{method}, ...) + end + RUBY end - def close - dispatch { |logger| logger.close } + # Returns the lowest level of all the loggers in the broadcast. + def level + @broadcasts.map(&:level).min end # True if the log level allows entries with severity +Logger::DEBUG+ to be written @@ -171,7 +144,7 @@ def debug? # Sets the log level to +Logger::DEBUG+ for the whole broadcast. def debug! - dispatch { |logger| logger.debug! } + dispatch(:debug!) end # True if the log level allows entries with severity +Logger::INFO+ to be written @@ -182,7 +155,7 @@ def info? # Sets the log level to +Logger::INFO+ for the whole broadcast. def info! - dispatch { |logger| logger.info! } + dispatch(:info!) end # True if the log level allows entries with severity +Logger::WARN+ to be written @@ -193,7 +166,7 @@ def warn? # Sets the log level to +Logger::WARN+ for the whole broadcast. def warn! - dispatch { |logger| logger.warn! } + dispatch(:warn!) end # True if the log level allows entries with severity +Logger::ERROR+ to be written @@ -204,7 +177,7 @@ def error? # Sets the log level to +Logger::ERROR+ for the whole broadcast. def error! - dispatch { |logger| logger.error! } + dispatch(:error!) end # True if the log level allows entries with severity +Logger::FATAL+ to be written @@ -215,21 +188,35 @@ def fatal? # Sets the log level to +Logger::FATAL+ for the whole broadcast. def fatal! - dispatch { |logger| logger.fatal! } + dispatch(:fatal!) end def initialize_copy(other) @broadcasts = [] @progname = other.progname.dup - @formatter = other.formatter.dup broadcast_to(*other.broadcasts.map(&:dup)) end private - def dispatch(&block) - @broadcasts.each { |logger| block.call(logger) } - true + def dispatch(method, *args, **kwargs, &block) + if block_given? + # Maintain semantics that the first logger yields the block + # as normal, but subsequent loggers won't re-execute the block. + # Instead, the initial result is immediately returned. + called, result = false, nil + block = proc { |*args, **kwargs| + if called then result + else + called = true + result = yield(*args, **kwargs) + end + } + end + + @broadcasts.map { |logger| + logger.send(method, *args, **kwargs, &block) + }.first end def method_missing(name, ...) diff --git a/activesupport/lib/active_support/cache.rb b/activesupport/lib/active_support/cache.rb index ea7abc53619c3..195a038c7eb7c 100644 --- a/activesupport/lib/active_support/cache.rb +++ b/activesupport/lib/active_support/cache.rb @@ -35,6 +35,8 @@ module Cache :race_condition_ttl, :serializer, :skip_nil, + :raw, + :max_key_size, ] # Mapping of canonical option names to aliases that a store will recognize. @@ -189,6 +191,9 @@ class Store # Default +ConnectionPool+ options DEFAULT_POOL_OPTIONS = { size: 5, timeout: 5 }.freeze + # Keys are truncated with the Active Support digest if they exceed the limit. + MAX_KEY_SIZE = 250 + cattr_accessor :logger, instance_writer: true cattr_accessor :raise_on_invalid_cache_expiration_time, default: false @@ -298,6 +303,9 @@ def initialize(options = nil) @options[:compress] = true unless @options.key?(:compress) @options[:compress_threshold] ||= DEFAULT_COMPRESS_LIMIT + @max_key_size = @options.delete(:max_key_size) + @max_key_size = MAX_KEY_SIZE if @max_key_size.nil? # allow 'false' as a value + @coder = @options.delete(:coder) do legacy_serializer = Cache.format_version < 7.1 && !@options[:serializer] serializer = @options.delete(:serializer) || default_serializer @@ -743,6 +751,32 @@ def decrement(name, amount = 1, options = nil) raise NotImplementedError.new("#{self.class.name} does not support decrement") end + # Reads a counter that was set by #increment / #decrement. + # + # cache.write_counter("foo", 1) + # cache.read_counter("foo") # => 1 + # cache.increment("foo") + # cache.read_counter("foo") # => 2 + # + # Options are passed to the underlying cache implementation. + def read_counter(name, **options) + options = merged_options(options).merge(raw: true) + read(name, **options)&.to_i + end + + # Writes a counter that can then be modified by #increment / #decrement. + # + # cache.write_counter("foo", 1) + # cache.read_counter("foo") # => 1 + # cache.increment("foo") + # cache.read_counter("foo") # => 2 + # + # Options are passed to the underlying cache implementation. + def write_counter(name, value, **options) + options = merged_options(options).merge(raw: true) + write(name, value.to_i, **options) + end + # Cleans up the cache by removing expired entries. # # Options are passed to the underlying cache implementation. @@ -762,6 +796,17 @@ def clear(options = nil) raise NotImplementedError.new("#{self.class.name} does not support clear") end + # Get the current namespace + def namespace + @options[:namespace] + end + + # Set the current namespace. Note, this will be ignored if custom + # options are passed to cache wills with a namespace key. + def namespace=(namespace) + @options[:namespace] = namespace + end + private def default_serializer case Cache.format_version @@ -928,16 +973,33 @@ def validate_options(options) options end - # Expands and namespaces the cache key. + # Expands, namespaces and truncates the cache key. # Raises an exception when the key is +nil+ or an empty string. # May be overridden by cache stores to do additional normalization. def normalize_key(key, options = nil) + key = expand_and_namespace_key(key, options) + truncate_key(key) + end + + def expand_and_namespace_key(key, options = nil) str_key = expanded_key(key) raise(ArgumentError, "key cannot be blank") if !str_key || str_key.empty? namespace_key str_key, options end + def truncate_key(key) + if key && @max_key_size && key.bytesize > @max_key_size + suffix = ":hash:#{ActiveSupport::Digest.hexdigest(key)}" + truncate_at = @max_key_size - suffix.bytesize + key = key.byteslice(0, truncate_at) + key.scrub!("") + "#{key}#{suffix}" + else + key + end + end + # Prefix the key with a namespace string: # # namespace_key 'foo', namespace: 'cache' diff --git a/activesupport/lib/active_support/cache/mem_cache_store.rb b/activesupport/lib/active_support/cache/mem_cache_store.rb index 8fbd0ad9bf99a..eb4a6bbdc169c 100644 --- a/activesupport/lib/active_support/cache/mem_cache_store.rb +++ b/activesupport/lib/active_support/cache/mem_cache_store.rb @@ -41,7 +41,6 @@ def self.supports_cache_versioning? prepend Strategy::LocalCache - KEY_MAX_SIZE = 250 ESCAPE_KEY_CHARS = /[\x00-\x20%\x7F-\xFF]/n # Creates a new Dalli::Client instance with specified addresses and options. @@ -60,7 +59,7 @@ def self.build_mem_cache(*addresses) # :nodoc: pool_options = retrieve_pool_options(options) if pool_options - ConnectionPool.new(pool_options) { Dalli::Client.new(addresses, options.merge(threadsafe: false)) } + ConnectionPool.new(**pool_options) { Dalli::Client.new(addresses, options.merge(threadsafe: false)) } else Dalli::Client.new(addresses, options) end @@ -80,6 +79,7 @@ def initialize(*addresses) if options.key?(:cache_nils) options[:skip_nil] = !options.delete(:cache_nils) end + options[:max_key_size] ||= MAX_KEY_SIZE super(options) unless [String, Dalli::Client, NilClass].include?(addresses.first.class) @@ -126,6 +126,11 @@ def inspect # # Incrementing a non-numeric value, or a value written without # raw: true, will fail and return +nil+. + # + # To read the value later, call #read_counter: + # + # cache.increment("baz") # => 7 + # cache.read_counter("baz") # 7 def increment(name, amount = 1, options = nil) options = merged_options(options) key = normalize_key(name, options) @@ -152,6 +157,11 @@ def increment(name, amount = 1, options = nil) # # Decrementing a non-numeric value, or a value written without # raw: true, will fail and return +nil+. + # + # To read the value later, call #read_counter: + # + # cache.decrement("baz") # => 3 + # cache.read_counter("baz") # 3 def decrement(name, amount = 1, options = nil) options = merged_options(options) key = normalize_key(name, options) @@ -209,26 +219,24 @@ def write_serialized_entry(key, payload, **options) def read_multi_entries(names, **options) keys_to_names = names.index_by { |name| normalize_key(name, options) } - raw_values = begin - @data.with { |c| c.get_multi(keys_to_names.keys) } - rescue Dalli::UnmarshalError - {} - end + rescue_error_with({}) do + raw_values = @data.with { |c| c.get_multi(keys_to_names.keys) } - values = {} + values = {} - raw_values.each do |key, value| - entry = deserialize_entry(value, raw: options[:raw]) + raw_values.each do |key, value| + entry = deserialize_entry(value, raw: options[:raw]) - unless entry.nil? || entry.expired? || entry.mismatched?(normalize_version(keys_to_names[key], options)) - begin - values[keys_to_names[key]] = entry.value - rescue DeserializationError + unless entry.nil? || entry.expired? || entry.mismatched?(normalize_version(keys_to_names[key], options)) + begin + values[keys_to_names[key]] = entry.value + rescue DeserializationError + end end end - end - values + values + end end # Delete an entry from the cache. @@ -248,19 +256,12 @@ def serialize_entry(entry, raw: false, **options) # before applying the regular expression to ensure we are escaping all # characters properly. def normalize_key(key, options) - key = super + key = expand_and_namespace_key(key, options) if key key = key.dup.force_encoding(Encoding::ASCII_8BIT) key = key.gsub(ESCAPE_KEY_CHARS) { |match| "%#{match.getbyte(0).to_s(16).upcase}" } - - if key.size > KEY_MAX_SIZE - key_separator = ":hash:" - key_hash = ActiveSupport::Digest.hexdigest(key) - key_trim_size = KEY_MAX_SIZE - key_separator.size - key_hash.size - key = "#{key[0, key_trim_size]}#{key_separator}#{key_hash}" - end end - key + truncate_key(key) end def deserialize_entry(payload, raw: false, **) diff --git a/activesupport/lib/active_support/cache/memory_store.rb b/activesupport/lib/active_support/cache/memory_store.rb index 3317dcd45e3d2..8d22856bc560a 100644 --- a/activesupport/lib/active_support/cache/memory_store.rb +++ b/activesupport/lib/active_support/cache/memory_store.rb @@ -26,6 +26,8 @@ module Cache # # +MemoryStore+ is thread-safe. class MemoryStore < Store + prepend Strategy::LocalCache + module DupCoder # :nodoc: extend self diff --git a/activesupport/lib/active_support/cache/redis_cache_store.rb b/activesupport/lib/active_support/cache/redis_cache_store.rb index 2254335342411..fdfb2a68e337f 100644 --- a/activesupport/lib/active_support/cache/redis_cache_store.rb +++ b/activesupport/lib/active_support/cache/redis_cache_store.rb @@ -35,9 +35,6 @@ module Cache # +Redis::Distributed+ 4.0.1+ for distributed mget support. # * +delete_matched+ support for Redis KEYS globs. class RedisCacheStore < Store - # Keys are truncated with the Active Support digest if they exceed 1kB - MAX_KEY_BYTESIZE = 1024 - DEFAULT_REDIS_OPTIONS = { connect_timeout: 1, read_timeout: 1, @@ -106,7 +103,6 @@ def build_redis_client(**redis_options) end end - attr_reader :max_key_bytesize attr_reader :redis # Creates a new Redis cache store. @@ -164,12 +160,11 @@ def initialize(error_handler: DEFAULT_ERROR_HANDLER, **redis_options) (redis.respond_to?(:wrapped_pool) && redis.wrapped_pool.instance_of?(::ConnectionPool)) if !already_pool && pool_options = self.class.send(:retrieve_pool_options, redis_options) - @redis = ::ConnectionPool.new(pool_options) { self.class.build_redis(**redis_options) } + @redis = ::ConnectionPool.new(**pool_options) { self.class.build_redis(**redis_options) } else @redis = self.class.build_redis(**redis_options) end - @max_key_bytesize = MAX_KEY_BYTESIZE @error_handler = error_handler super(universal_options) @@ -227,7 +222,7 @@ def delete_matched(matcher, options = nil) nodes.each do |node| begin cursor, keys = node.scan(cursor, match: pattern, count: SCAN_BATCH_SIZE) - node.del(*keys) unless keys.empty? + node.unlink(*keys) unless keys.empty? end until cursor == "0" end end @@ -250,6 +245,11 @@ def delete_matched(matcher, options = nil) # Incrementing a non-numeric value, or a value written without # raw: true, will fail and return +nil+. # + # To read the value later, call #read_counter: + # + # cache.increment("baz") # => 7 + # cache.read_counter("baz") # 7 + # # Failsafe: Raises errors. def increment(name, amount = 1, options = nil) options = merged_options(options) @@ -277,6 +277,11 @@ def increment(name, amount = 1, options = nil) # Decrementing a non-numeric value, or a value written without # raw: true, will fail and return +nil+. # + # To read the value later, call #read_counter: + # + # cache.decrement("baz") # => 3 + # cache.read_counter("baz") # 3 + # # Failsafe: Raises errors. def decrement(name, amount = 1, options = nil) options = merged_options(options) @@ -398,14 +403,16 @@ def write_serialized_entry(key, payload, raw: false, unless_exist: false, expire # Delete an entry from the cache. def delete_entry(key, **options) failsafe :delete_entry, returning: false do - redis.then { |c| c.del(key) == 1 } + redis.then { |c| c.unlink(key) == 1 } end end # Deletes multiple entries in the cache. Returns the number of entries deleted. def delete_multi_entries(entries, **_options) + return 0 if entries.empty? + failsafe :delete_multi_entries, returning: 0 do - redis.then { |c| c.del(entries) } + redis.then { |c| c.unlink(*entries) } end end @@ -424,21 +431,6 @@ def write_multi_entries(entries, **options) end end - # Truncate keys that exceed 1kB. - def normalize_key(key, options) - truncate_key super&.b - end - - def truncate_key(key) - if key && key.bytesize > max_key_bytesize - suffix = ":hash:#{ActiveSupport::Digest.hexdigest(key)}" - truncate_at = max_key_bytesize - suffix.bytesize - "#{key.byteslice(0, truncate_at)}#{suffix}" - else - key - end - end - def deserialize_entry(payload, raw: false, **) if raw && !payload.nil? Entry.new(payload) diff --git a/activesupport/lib/active_support/cache/strategy/local_cache.rb b/activesupport/lib/active_support/cache/strategy/local_cache.rb index d40d5efd44be6..b306b3392c03d 100644 --- a/activesupport/lib/active_support/cache/strategy/local_cache.rb +++ b/activesupport/lib/active_support/cache/strategy/local_cache.rb @@ -68,12 +68,25 @@ def with_local_cache(&block) use_temporary_local_cache(LocalStore.new, &block) end + # Set a new local cache. + def new_local_cache + LocalCacheRegistry.set_cache_for(local_cache_key, LocalStore.new) + end + + # Unset the current local cache. + def unset_local_cache + LocalCacheRegistry.set_cache_for(local_cache_key, nil) + end + + # The current local cache. + def local_cache + LocalCacheRegistry.cache_for(local_cache_key) + end + # Middleware class can be inserted as a Rack handler to be local cache for the # duration of request. def middleware - @middleware ||= Middleware.new( - "ActiveSupport::Cache::Strategy::LocalCache", - local_cache_key) + @middleware ||= Middleware.new("ActiveSupport::Cache::Strategy::LocalCache", self) end def clear(options = nil) # :nodoc: @@ -214,10 +227,6 @@ def local_cache_key @local_cache_key ||= "#{self.class.name.underscore}_local_cache_#{object_id}".gsub(/[\/-]/, "_").to_sym end - def local_cache - LocalCacheRegistry.cache_for(local_cache_key) - end - def bypass_local_cache(&block) use_temporary_local_cache(nil, &block) end diff --git a/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb b/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb index 62542bdb22428..d5476731eb3de 100644 --- a/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb +++ b/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb @@ -11,11 +11,12 @@ module LocalCache # This class wraps up local storage for middlewares. Only the middleware method should # construct them. class Middleware # :nodoc: - attr_reader :name, :local_cache_key + attr_reader :name + attr_accessor :cache - def initialize(name, local_cache_key) + def initialize(name, cache) @name = name - @local_cache_key = local_cache_key + @cache = cache @app = nil end @@ -25,18 +26,17 @@ def new(app) end def call(env) - LocalCacheRegistry.set_cache_for(local_cache_key, LocalStore.new) + cache.new_local_cache response = @app.call(env) response[2] = ::Rack::BodyProxy.new(response[2]) do - LocalCacheRegistry.set_cache_for(local_cache_key, nil) + cache.unset_local_cache end cleanup_on_body_close = true response rescue Rack::Utils::InvalidParameterError [400, {}, []] ensure - LocalCacheRegistry.set_cache_for(local_cache_key, nil) unless - cleanup_on_body_close + cache.unset_local_cache unless cleanup_on_body_close end end end diff --git a/activesupport/lib/active_support/callbacks.rb b/activesupport/lib/active_support/callbacks.rb index ea3c3ccba2273..1ca6002043ffa 100644 --- a/activesupport/lib/active_support/callbacks.rb +++ b/activesupport/lib/active_support/callbacks.rb @@ -4,6 +4,7 @@ require "active_support/descendants_tracker" require "active_support/core_ext/array/extract_options" require "active_support/core_ext/class/attribute" +require "active_support/core_ext/module/redefine_method" require "active_support/core_ext/string/filters" require "active_support/core_ext/object/blank" @@ -230,7 +231,7 @@ def apply(callback_sequence) class Callback # :nodoc: def self.build(chain, filter, kind, options) if filter.is_a?(String) - raise ArgumentError, <<-MSG.squish + raise ArgumentError, <<~MSG.squish Passing string to define a callback is not supported. See the `.set_callback` documentation to see supported values. MSG @@ -313,7 +314,7 @@ def check_conditionals(conditionals) conditionals = Array(conditionals) if conditionals.any?(String) - raise ArgumentError, <<-MSG.squish + raise ArgumentError, <<~MSG.squish Passing string to be evaluated in :if and :unless conditional options is not supported. Pass a symbol for an instance method, or a lambda, proc or block, instead. @@ -573,7 +574,7 @@ def initialize(name, config) @name = name @config = { scope: [:kind], - terminator: default_terminator + terminator: DEFAULT_TERMINATOR }.merge!(config) @chain = [] @all_callbacks = nil @@ -661,8 +662,8 @@ def remove_duplicates(callback) @chain.delete_if { |c| callback.duplicates?(c) } end - def default_terminator - Proc.new do |target, result_lambda| + class DefaultTerminator # :nodoc: + def call(target, result_lambda) terminate = true catch(:abort) do result_lambda.call @@ -671,6 +672,7 @@ def default_terminator terminate end end + DEFAULT_TERMINATOR = DefaultTerminator.new.freeze end module ClassMethods @@ -904,12 +906,13 @@ def define_callbacks(*names) names.each do |name| name = name.to_sym - ([self] + self.descendants).each do |target| - target.set_callbacks name, CallbackChain.new(name, options) - end + module_eval <<~RUBY, __FILE__, __LINE__ + 1 + def _run_#{name}_callbacks + yield if block_given? + end + silence_redefinition_of_method(:_run_#{name}_callbacks) - module_eval <<-RUBY, __FILE__, __LINE__ + 1 - def _run_#{name}_callbacks(&block) + def _run_#{name}_callbacks!(&block) run_callbacks #{name.inspect}, &block end @@ -925,6 +928,10 @@ def _#{name}_callbacks __callbacks[#{name.inspect}] end RUBY + + ([self] + self.descendants).each do |target| + target.set_callbacks name, CallbackChain.new(name, options) + end end end @@ -940,6 +947,11 @@ def set_callbacks(name, callbacks) # :nodoc: unless singleton_class.private_method_defined?(:__class_attr__callbacks, false) self.__callbacks = __callbacks.dup end + name = name.to_sym + callbacks_was = self.__callbacks[name.to_sym] + if (callbacks_was.nil? || callbacks_was.empty?) && !callbacks.empty? + alias_method("_run_#{name}_callbacks", "_run_#{name}_callbacks!") + end self.__callbacks[name.to_sym] = callbacks self.__callbacks end diff --git a/activesupport/lib/active_support/colorize_logging.rb b/activesupport/lib/active_support/colorize_logging.rb new file mode 100644 index 0000000000000..8f0d727c8ebcd --- /dev/null +++ b/activesupport/lib/active_support/colorize_logging.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module ActiveSupport + module ColorizeLogging # :nodoc: + extend ActiveSupport::Concern + + # ANSI sequence modes + MODES = { + clear: 0, + bold: 1, + italic: 3, + underline: 4, + } + + # ANSI sequence colors + BLACK = "\e[30m" + RED = "\e[31m" + GREEN = "\e[32m" + YELLOW = "\e[33m" + BLUE = "\e[34m" + MAGENTA = "\e[35m" + CYAN = "\e[36m" + WHITE = "\e[37m" + + def info(progname = nil, &block) + logger.info(progname, &block) if logger + end + + def debug(progname = nil, &block) + logger.debug(progname, &block) if logger + end + + def warn(progname = nil, &block) + logger.warn(progname, &block) if logger + end + + def error(progname = nil, &block) + logger.error(progname, &block) if logger + end + + def fatal(progname = nil, &block) + logger.fatal(progname, &block) if logger + end + + def unknown(progname = nil, &block) + logger.unknown(progname, &block) if logger + end + + # Set color by using a symbol or one of the defined constants. Set modes + # by specifying bold, italic, or underline options. Inspired by Highline, + # this method will automatically clear formatting at the end of the returned String. + def color(text, color, mode_options = {}) # :doc: + return text unless colorize_logging + color = self.class.const_get(color.upcase) if color.is_a?(Symbol) + mode = mode_from(mode_options) + clear = "\e[#{MODES[:clear]}m" + "#{mode}#{color}#{text}#{clear}" + end + + def mode_from(options) + modes = MODES.values_at(*options.compact_blank.keys) + + "\e[#{modes.join(";")}m" if modes.any? + end + + def colorize_logging + ActiveSupport.colorize_logging + end + end +end diff --git a/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb b/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb index 84729e25f4ef0..92b31323b7cd5 100644 --- a/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb +++ b/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb @@ -4,69 +4,15 @@ module ActiveSupport module Concurrency - module LoadInterlockAwareMonitorMixin # :nodoc: - EXCEPTION_NEVER = { Exception => :never }.freeze - EXCEPTION_IMMEDIATE = { Exception => :immediate }.freeze - private_constant :EXCEPTION_NEVER, :EXCEPTION_IMMEDIATE - - # Enters an exclusive section, but allows dependency loading while blocked - def mon_enter - mon_try_enter || - ActiveSupport::Dependencies.interlock.permit_concurrent_loads { super } - end - - def synchronize(&block) - Thread.handle_interrupt(EXCEPTION_NEVER) do - mon_enter - - begin - Thread.handle_interrupt(EXCEPTION_IMMEDIATE, &block) - ensure - mon_exit - end - end - end - end # A monitor that will permit dependency loading while blocked waiting for # the lock. - class LoadInterlockAwareMonitor < Monitor - include LoadInterlockAwareMonitorMixin - end - - class ThreadLoadInterlockAwareMonitor # :nodoc: - prepend LoadInterlockAwareMonitorMixin - - def initialize - @owner = nil - @count = 0 - @mutex = Mutex.new - end - - private - def mon_try_enter - if @owner != Thread.current - return false unless @mutex.try_lock - @owner = Thread.current - end - @count += 1 - end - - def mon_enter - @mutex.lock if @owner != Thread.current - @owner = Thread.current - @count += 1 - end - - def mon_exit - unless @owner == Thread.current - raise ThreadError, "current thread not owner" - end - - @count -= 1 - return unless @count == 0 - @owner = nil - @mutex.unlock - end - end + LoadInterlockAwareMonitor = ActiveSupport::Deprecation::DeprecatedConstantProxy.new( + "ActiveSupport::Concurrency::LoadInterlockAwareMonitor", + "::Monitor", + ActiveSupport.deprecator, + message: "ActiveSupport::Concurrency::LoadInterlockAwareMonitor is deprecated and will be " \ + "removed in Rails 9.0. Use Monitor directly instead, as the loading interlock is " \ + "no longer used." + ) end end diff --git a/activesupport/lib/active_support/concurrency/thread_monitor.rb b/activesupport/lib/active_support/concurrency/thread_monitor.rb new file mode 100644 index 0000000000000..d28c8893c75a1 --- /dev/null +++ b/activesupport/lib/active_support/concurrency/thread_monitor.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ActiveSupport + module Concurrency + class ThreadMonitor # :nodoc: + EXCEPTION_NEVER = { Exception => :never }.freeze + EXCEPTION_IMMEDIATE = { Exception => :immediate }.freeze + private_constant :EXCEPTION_NEVER, :EXCEPTION_IMMEDIATE + + def initialize + @owner = nil + @count = 0 + @mutex = Mutex.new + end + + def synchronize(&block) + Thread.handle_interrupt(EXCEPTION_NEVER) do + mon_enter + + begin + Thread.handle_interrupt(EXCEPTION_IMMEDIATE, &block) + ensure + mon_exit + end + end + end + + private + def mon_try_enter + if @owner != Thread.current + return false unless @mutex.try_lock + @owner = Thread.current + end + @count += 1 + end + + def mon_enter + @mutex.lock if @owner != Thread.current + @owner = Thread.current + @count += 1 + end + + def mon_exit + unless @owner == Thread.current + raise ThreadError, "current thread not owner" + end + + @count -= 1 + return unless @count == 0 + @owner = nil + @mutex.unlock + end + end + end +end diff --git a/activesupport/lib/active_support/configurable.rb b/activesupport/lib/active_support/configurable.rb index ac1f055ad28f1..4ee776bd39c1e 100644 --- a/activesupport/lib/active_support/configurable.rb +++ b/activesupport/lib/active_support/configurable.rb @@ -1,5 +1,11 @@ # frozen_string_literal: true +ActiveSupport.deprecator.warn <<~MSG + ActiveSupport::Configurable is deprecated without replacement, and will be removed in Rails 8.2. + + You can emulate the previous behavior with `class_attribute`. +MSG + require "active_support/concern" require "active_support/ordered_options" @@ -19,7 +25,7 @@ def compile_methods! # Compiles reader methods so we don't have to go through method_missing. def self.compile_methods!(keys) keys.reject { |m| method_defined?(m) }.each do |key| - class_eval <<-RUBY, __FILE__, __LINE__ + 1 + class_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{key}; _get(#{key.inspect}); end RUBY end diff --git a/activesupport/lib/active_support/continuous_integration.rb b/activesupport/lib/active_support/continuous_integration.rb index c88d3e114a902..ac6d2567b65c5 100644 --- a/activesupport/lib/active_support/continuous_integration.rb +++ b/activesupport/lib/active_support/continuous_integration.rb @@ -38,6 +38,8 @@ class ContinuousIntegration # # Sets the CI environment variable to "true" to allow for conditional behavior in the app, like enabling eager loading and disabling logging. # + # A 'fail fast' option can be passed as a CLI argument (-f or --fail-fast). This exits with a non-zero status directly after a step fails. + # # Example: # # ActiveSupport::ContinuousIntegration.run do @@ -74,12 +76,12 @@ def initialize # step "Single test", "bin/rails", "test", "--name", "test_that_is_one" def step(title, *command) heading title, command.join(" "), type: :title - report(title) { results << system(*command) } + report(title) { results << [ system(*command), title ] } end # Returns true if all steps were successful. def success? - results.all? + results.map(&:first).all? end # Display an error heading with the title and optional subtitle to reflect that the run failed. @@ -114,7 +116,7 @@ def echo(text, type:) # :nodoc: def report(title, &block) - Signal.trap("INT") { abort colorize(:error, "\n❌ #{title} interrupted") } + Signal.trap("INT") { abort colorize("\n❌ #{title} interrupted", :error) } ci = self.class.new elapsed = timing { ci.instance_eval(&block) } @@ -123,6 +125,16 @@ def report(title, &block) echo "\n✅ #{title} passed in #{elapsed}", type: :success else echo "\n❌ #{title} failed in #{elapsed}", type: :error + + abort if ci.fail_fast? + + if ci.multiple_results? + ci.failures.each do |success, title| + unless success + echo " ↳ #{title} failed", type: :error + end + end + end end results.concat ci.results @@ -130,6 +142,21 @@ def report(title, &block) Signal.trap("INT", "-") end + # :nodoc: + def failures + results.reject(&:first) + end + + # :nodoc: + def multiple_results? + results.size > 1 + end + + # :nodoc: + def fail_fast? + ARGV.include?("-f") || ARGV.include?("--fail-fast") + end + private def timing started_at = Time.now.to_f diff --git a/activesupport/lib/active_support/core_ext.rb b/activesupport/lib/active_support/core_ext.rb index 3f5d08186e28a..2ed62bfa0f8e1 100644 --- a/activesupport/lib/active_support/core_ext.rb +++ b/activesupport/lib/active_support/core_ext.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -Dir.glob(File.expand_path("core_ext/*.rb", __dir__)).sort.each do |path| +(Dir.glob(File.expand_path("core_ext/*.rb", __dir__)).sort - [File.expand_path("core_ext/benchmark.rb", __dir__)]).each do |path| require path end diff --git a/activesupport/lib/active_support/core_ext/array.rb b/activesupport/lib/active_support/core_ext/array.rb index 88b6567712379..913c798c75b20 100644 --- a/activesupport/lib/active_support/core_ext/array.rb +++ b/activesupport/lib/active_support/core_ext/array.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require "active_support/core_ext/array/wrap" -require "active_support/core_ext/array/access" -require "active_support/core_ext/array/conversions" -require "active_support/core_ext/array/extract" -require "active_support/core_ext/array/extract_options" -require "active_support/core_ext/array/grouping" -require "active_support/core_ext/array/inquiry" +require_relative "array/wrap" +require_relative "array/access" +require_relative "array/conversions" +require_relative "array/extract" +require_relative "array/extract_options" +require_relative "array/grouping" +require_relative "array/inquiry" diff --git a/activesupport/lib/active_support/core_ext/benchmark.rb b/activesupport/lib/active_support/core_ext/benchmark.rb index 20675e302c7bd..a3864671460ee 100644 --- a/activesupport/lib/active_support/core_ext/benchmark.rb +++ b/activesupport/lib/active_support/core_ext/benchmark.rb @@ -1,13 +1,6 @@ # frozen_string_literal: true -require "benchmark" - -class << Benchmark - def ms(&block) # :nodoc - # NOTE: Please also remove the Active Support `benchmark` dependency when removing this - ActiveSupport.deprecator.warn <<~TEXT - `Benchmark.ms` is deprecated and will be removed in Rails 8.1 without replacement. - TEXT - ActiveSupport::Benchmark.realtime(:float_millisecond, &block) - end -end +# Remove this file from activesupport/lib/active_support/core_ext.rb when deleting the deprecation. +ActiveSupport.deprecator.warn <<~TEXT + active_support/core_ext/benchmark.rb is deprecated and will be removed in Rails 8.2 without replacement. +TEXT diff --git a/activesupport/lib/active_support/core_ext/big_decimal.rb b/activesupport/lib/active_support/core_ext/big_decimal.rb index 9e6a9d6331692..5568db689b42f 100644 --- a/activesupport/lib/active_support/core_ext/big_decimal.rb +++ b/activesupport/lib/active_support/core_ext/big_decimal.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require "active_support/core_ext/big_decimal/conversions" +require_relative "big_decimal/conversions" diff --git a/activesupport/lib/active_support/core_ext/class.rb b/activesupport/lib/active_support/core_ext/class.rb index 1c110fd07bd5c..65e1d683999ac 100644 --- a/activesupport/lib/active_support/core_ext/class.rb +++ b/activesupport/lib/active_support/core_ext/class.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true -require "active_support/core_ext/class/attribute" -require "active_support/core_ext/class/subclasses" +require_relative "class/attribute" +require_relative "class/subclasses" diff --git a/activesupport/lib/active_support/core_ext/class/attribute.rb b/activesupport/lib/active_support/core_ext/class/attribute.rb index 57264dfe25c33..e52bf99fbfda1 100644 --- a/activesupport/lib/active_support/core_ext/class/attribute.rb +++ b/activesupport/lib/active_support/core_ext/class/attribute.rb @@ -96,14 +96,16 @@ def class_attribute(*attrs, instance_accessor: true, namespaced_name = :"__class_attr_#{name}" ::ActiveSupport::ClassAttribute.redefine(self, name, namespaced_name, default) - delegators = [ - "def #{name}; #{namespaced_name}; end", - "def #{name}=(value); self.#{namespaced_name} = value; end", - ] + class_methods << "def #{name}; #{namespaced_name}; end" + class_methods << "def #{name}=(value); self.#{namespaced_name} = value; end" - class_methods.concat(delegators) if singleton_class? - methods.concat(delegators) + methods << <<~RUBY if instance_reader + silence_redefinition_of_method(:#{name}) + def #{name} + self.singleton_class.#{name} + end + RUBY else methods << <<~RUBY if instance_reader silence_redefinition_of_method def #{name} diff --git a/activesupport/lib/active_support/core_ext/date.rb b/activesupport/lib/active_support/core_ext/date.rb index cce73f2db2232..2a2ed5496f4b2 100644 --- a/activesupport/lib/active_support/core_ext/date.rb +++ b/activesupport/lib/active_support/core_ext/date.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "active_support/core_ext/date/acts_like" -require "active_support/core_ext/date/blank" -require "active_support/core_ext/date/calculations" -require "active_support/core_ext/date/conversions" -require "active_support/core_ext/date/zones" +require_relative "date/acts_like" +require_relative "date/blank" +require_relative "date/calculations" +require_relative "date/conversions" +require_relative "date/zones" diff --git a/activesupport/lib/active_support/core_ext/date/calculations.rb b/activesupport/lib/active_support/core_ext/date/calculations.rb index 7dea469756255..4edcb823423f9 100644 --- a/activesupport/lib/active_support/core_ext/date/calculations.rb +++ b/activesupport/lib/active_support/core_ext/date/calculations.rb @@ -44,7 +44,7 @@ def tomorrow ::Date.current.tomorrow end - # Returns Time.zone.today when Time.zone or config.time_zone are set, otherwise just returns Date.today. + # Returns Time.zone.today when Time.zone or config.time_zone are set, otherwise just returns Date.today. def current ::Time.zone ? ::Time.zone.today : ::Date.today end diff --git a/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb index 68fb42c5cf717..4a4ef5bafae17 100644 --- a/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb +++ b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb @@ -5,41 +5,6 @@ module DateAndTime module Compatibility - # If true, +to_time+ preserves the timezone offset of receiver. - # - # NOTE: With Ruby 2.4+ the default for +to_time+ changed from - # converting to the local system time, to preserving the offset - # of the receiver. For backwards compatibility we're overriding - # this behavior, but new apps will have an initializer that sets - # this to true, because the new behavior is preferred. - mattr_accessor :preserve_timezone, instance_accessor: false, default: nil - - singleton_class.silence_redefinition_of_method :preserve_timezone - - #-- - # This re-implements the behaviour of the mattr_reader, instead - # of prepending on to it, to avoid overcomplicating a module that - # is in turn included in several places. This will all go away in - # Rails 8.0 anyway. - def self.preserve_timezone # :nodoc: - if @@preserve_timezone.nil? - # Only warn once, the first time the value is used (which should - # be the first time #to_time is called). - ActiveSupport.deprecator.warn( - "`to_time` will always preserve the receiver timezone rather than system local time in Rails 8.1." \ - "To opt in to the new behavior, set `config.active_support.to_time_preserves_timezone = :zone`." - ) - - @@preserve_timezone = false - end - - @@preserve_timezone - end - - def preserve_timezone # :nodoc: - Compatibility.preserve_timezone - end - # Change the output of ActiveSupport::TimeZone.utc_to_local. # # When +true+, it returns local times with a UTC offset, with +false+ local diff --git a/activesupport/lib/active_support/core_ext/date_time.rb b/activesupport/lib/active_support/core_ext/date_time.rb index 790dbeec1b479..b3d6773e4fc2b 100644 --- a/activesupport/lib/active_support/core_ext/date_time.rb +++ b/activesupport/lib/active_support/core_ext/date_time.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "active_support/core_ext/date_time/acts_like" -require "active_support/core_ext/date_time/blank" -require "active_support/core_ext/date_time/calculations" -require "active_support/core_ext/date_time/compatibility" -require "active_support/core_ext/date_time/conversions" +require_relative "date_time/acts_like" +require_relative "date_time/blank" +require_relative "date_time/calculations" +require_relative "date_time/compatibility" +require_relative "date_time/conversions" diff --git a/activesupport/lib/active_support/core_ext/date_time/compatibility.rb b/activesupport/lib/active_support/core_ext/date_time/compatibility.rb index 7600a067cc579..3b8d82b794935 100644 --- a/activesupport/lib/active_support/core_ext/date_time/compatibility.rb +++ b/activesupport/lib/active_support/core_ext/date_time/compatibility.rb @@ -8,11 +8,9 @@ class DateTime silence_redefinition_of_method :to_time - # Either return an instance of +Time+ with the same UTC offset - # as +self+ or an instance of +Time+ representing the same time - # in the local system timezone depending on the setting of - # on the setting of +ActiveSupport.to_time_preserves_timezone+. + # Return an instance of +Time+ with the same UTC offset + # as +self+. def to_time - preserve_timezone ? getlocal(utc_offset) : getlocal + getlocal(utc_offset) end end diff --git a/activesupport/lib/active_support/core_ext/date_time/conversions.rb b/activesupport/lib/active_support/core_ext/date_time/conversions.rb index 57c8d88de1b10..0aed6f9dc52ad 100644 --- a/activesupport/lib/active_support/core_ext/date_time/conversions.rb +++ b/activesupport/lib/active_support/core_ext/date_time/conversions.rb @@ -11,7 +11,8 @@ class DateTime # # This method is aliased to to_formatted_s. # - # === Examples + # ==== Examples + # # datetime = DateTime.civil(2007, 12, 4, 0, 0, 0, 0) # => Tue, 04 Dec 2007 00:00:00 +0000 # # datetime.to_fs(:db) # => "2007-12-04 00:00:00" @@ -23,7 +24,8 @@ class DateTime # datetime.to_fs(:rfc822) # => "Tue, 04 Dec 2007 00:00:00 +0000" # datetime.to_fs(:iso8601) # => "2007-12-04T00:00:00+00:00" # - # == Adding your own datetime formats to to_fs + # ==== Adding your own datetime formats to +to_fs+ + # # DateTime formats are shared with Time. You can add your own to the # Time::DATE_FORMATS hash. Use the format name as the hash key and # either a strftime string or Proc instance that takes a time or diff --git a/activesupport/lib/active_support/core_ext/digest.rb b/activesupport/lib/active_support/core_ext/digest.rb index ce1427e13a0d6..3f1d38e418fb2 100644 --- a/activesupport/lib/active_support/core_ext/digest.rb +++ b/activesupport/lib/active_support/core_ext/digest.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require "active_support/core_ext/digest/uuid" +require_relative "digest/uuid" diff --git a/activesupport/lib/active_support/core_ext/enumerable.rb b/activesupport/lib/active_support/core_ext/enumerable.rb index 7ae0dc6e77b64..3c6fa964de414 100644 --- a/activesupport/lib/active_support/core_ext/enumerable.rb +++ b/activesupport/lib/active_support/core_ext/enumerable.rb @@ -209,10 +209,22 @@ def in_order_of(key, series, filter: true) # Set.new.sole # => Enumerable::SoleItemExpectedError: no item found # { a: 1, b: 2 }.sole # => Enumerable::SoleItemExpectedError: multiple items found def sole - case count - when 1 then return first # rubocop:disable Style/RedundantReturn - when 0 then raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "no item found" - when 2.. then raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "multiple items found" + result = nil + found = false + + each do |*element| + if found + raise SoleItemExpectedError, "multiple items found" + end + + result = element.size == 1 ? element[0] : element + found = true + end + + if found + result + else + raise SoleItemExpectedError, "no item found" end end end diff --git a/activesupport/lib/active_support/core_ext/erb/util.rb b/activesupport/lib/active_support/core_ext/erb/util.rb index f25d9c2be8abb..cbf5cb4a8c88a 100644 --- a/activesupport/lib/active_support/core_ext/erb/util.rb +++ b/activesupport/lib/active_support/core_ext/erb/util.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "strscan" require "erb" module ActiveSupport @@ -12,7 +13,7 @@ def html_escape(s) # :nodoc: if s.html_safe? s else - super(ActiveSupport::Multibyte::Unicode.tidy_bytes(s)) + super(s) end end alias :unwrapped_html_escape :html_escape # :nodoc: @@ -61,7 +62,7 @@ module Util # html_escape_once('<< Accept & Checkout') # # => "<< Accept & Checkout" def html_escape_once(s) - ActiveSupport::Multibyte::Unicode.tidy_bytes(s.to_s).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE).html_safe + s.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE).html_safe end module_function :html_escape_once @@ -159,7 +160,6 @@ def xml_name_escape(name) # Tokenizes a line of ERB. This is really just for error reporting and # nobody should use it. def self.tokenize(source) # :nodoc: - require "strscan" source = StringScanner.new(source.chomp) tokens = [] diff --git a/activesupport/lib/active_support/core_ext/file.rb b/activesupport/lib/active_support/core_ext/file.rb index 64553bfa4e82e..3c2364167dd6e 100644 --- a/activesupport/lib/active_support/core_ext/file.rb +++ b/activesupport/lib/active_support/core_ext/file.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require "active_support/core_ext/file/atomic" +require_relative "file/atomic" diff --git a/activesupport/lib/active_support/core_ext/hash.rb b/activesupport/lib/active_support/core_ext/hash.rb index 2f0901d8534b4..363cd8bf10ca6 100644 --- a/activesupport/lib/active_support/core_ext/hash.rb +++ b/activesupport/lib/active_support/core_ext/hash.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "active_support/core_ext/hash/conversions" -require "active_support/core_ext/hash/deep_merge" -require "active_support/core_ext/hash/deep_transform_values" -require "active_support/core_ext/hash/except" -require "active_support/core_ext/hash/indifferent_access" -require "active_support/core_ext/hash/keys" -require "active_support/core_ext/hash/reverse_merge" -require "active_support/core_ext/hash/slice" +require_relative "hash/conversions" +require_relative "hash/deep_merge" +require_relative "hash/deep_transform_values" +require_relative "hash/except" +require_relative "hash/indifferent_access" +require_relative "hash/keys" +require_relative "hash/reverse_merge" +require_relative "hash/slice" diff --git a/activesupport/lib/active_support/core_ext/hash/conversions.rb b/activesupport/lib/active_support/core_ext/hash/conversions.rb index 8ab2fd6911bd1..30d194eda46b4 100644 --- a/activesupport/lib/active_support/core_ext/hash/conversions.rb +++ b/activesupport/lib/active_support/core_ext/hash/conversions.rb @@ -69,7 +69,7 @@ class Hash # By default the root node is "hash", but that's configurable via the :root option. # # The default XML builder is a fresh instance of +Builder::XmlMarkup+. You can - # configure your own builder with the :builder option. The method also accepts + # configure your own builder with the +:builder+ option. The method also accepts # options like :dasherize and friends, they are forwarded to the builder. def to_xml(options = {}) require "active_support/builder" unless defined?(Builder::XmlMarkup) @@ -94,7 +94,7 @@ class << self # Returns a Hash containing a collection of pairs when the key is the node name and the value is # its content # - # xml = <<-XML + # xml = <<~XML # # # 1 @@ -112,7 +112,7 @@ class << self # Custom +disallowed_types+ can also be passed in the form of an # array. # - # xml = <<-XML + # xml = <<~XML # # # 1 diff --git a/activesupport/lib/active_support/core_ext/integer.rb b/activesupport/lib/active_support/core_ext/integer.rb index d22701306a1ca..df80e7ffdba70 100644 --- a/activesupport/lib/active_support/core_ext/integer.rb +++ b/activesupport/lib/active_support/core_ext/integer.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require "active_support/core_ext/integer/multiple" -require "active_support/core_ext/integer/inflections" -require "active_support/core_ext/integer/time" +require_relative "integer/multiple" +require_relative "integer/inflections" +require_relative "integer/time" diff --git a/activesupport/lib/active_support/core_ext/kernel.rb b/activesupport/lib/active_support/core_ext/kernel.rb index 7708069301d71..55a44fdef9dc4 100644 --- a/activesupport/lib/active_support/core_ext/kernel.rb +++ b/activesupport/lib/active_support/core_ext/kernel.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require "active_support/core_ext/kernel/concern" -require "active_support/core_ext/kernel/reporting" -require "active_support/core_ext/kernel/singleton_class" +require_relative "kernel/concern" +require_relative "kernel/reporting" +require_relative "kernel/singleton_class" diff --git a/activesupport/lib/active_support/core_ext/module.rb b/activesupport/lib/active_support/core_ext/module.rb index 542af98c04f7d..9a30fe2727730 100644 --- a/activesupport/lib/active_support/core_ext/module.rb +++ b/activesupport/lib/active_support/core_ext/module.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -require "active_support/core_ext/module/aliasing" -require "active_support/core_ext/module/introspection" -require "active_support/core_ext/module/anonymous" -require "active_support/core_ext/module/attribute_accessors" -require "active_support/core_ext/module/attribute_accessors_per_thread" -require "active_support/core_ext/module/attr_internal" -require "active_support/core_ext/module/concerning" -require "active_support/core_ext/module/delegation" -require "active_support/core_ext/module/deprecation" -require "active_support/core_ext/module/redefine_method" -require "active_support/core_ext/module/remove_method" +require_relative "module/aliasing" +require_relative "module/introspection" +require_relative "module/anonymous" +require_relative "module/attribute_accessors" +require_relative "module/attribute_accessors_per_thread" +require_relative "module/attr_internal" +require_relative "module/concerning" +require_relative "module/delegation" +require_relative "module/deprecation" +require_relative "module/redefine_method" +require_relative "module/remove_method" diff --git a/activesupport/lib/active_support/core_ext/module/aliasing.rb b/activesupport/lib/active_support/core_ext/module/aliasing.rb index 6f64d11627197..cdc57336aa16c 100644 --- a/activesupport/lib/active_support/core_ext/module/aliasing.rb +++ b/activesupport/lib/active_support/core_ext/module/aliasing.rb @@ -22,10 +22,10 @@ def alias_attribute(new_name, old_name) # The following reader methods use an explicit `self` receiver in order to # support aliases that start with an uppercase letter. Otherwise, they would # be resolved as constants instead. - module_eval <<-STR, __FILE__, __LINE__ + 1 + module_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{new_name}; self.#{old_name}; end # def subject; self.title; end def #{new_name}?; self.#{old_name}?; end # def subject?; self.title?; end def #{new_name}=(v); self.#{old_name} = v; end # def subject=(v); self.title = v; end - STR + RUBY end end diff --git a/activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb b/activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb index 628da9c9bdcdf..fd3db525fd521 100644 --- a/activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb +++ b/activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb @@ -45,17 +45,17 @@ def thread_mattr_reader(*syms, instance_reader: true, instance_accessor: true, d # The following generated method concatenates `object_id` because we want # subclasses to maintain independent values. if default.nil? - class_eval(<<-EOS, __FILE__, __LINE__ + 1) + class_eval(<<~RUBY, __FILE__, __LINE__ + 1) def self.#{sym} @__thread_mattr_#{sym} ||= "attr_#{sym}_\#{object_id}" ::ActiveSupport::IsolatedExecutionState[@__thread_mattr_#{sym}] end - EOS + RUBY else default = default.dup.freeze unless default.frozen? singleton_class.define_method("#{sym}_default_value") { default } - class_eval(<<-EOS, __FILE__, __LINE__ + 1) + class_eval(<<~RUBY, __FILE__, __LINE__ + 1) def self.#{sym} @__thread_mattr_#{sym} ||= "attr_#{sym}_\#{object_id}" value = ::ActiveSupport::IsolatedExecutionState[@__thread_mattr_#{sym}] @@ -66,15 +66,15 @@ def self.#{sym} value end end - EOS + RUBY end if instance_reader && instance_accessor - class_eval(<<-EOS, __FILE__, __LINE__ + 1) + class_eval(<<~RUBY, __FILE__, __LINE__ + 1) def #{sym} self.class.#{sym} end - EOS + RUBY end end end @@ -104,19 +104,19 @@ def thread_mattr_writer(*syms, instance_writer: true, instance_accessor: true) # # The following generated method concatenates `object_id` because we want # subclasses to maintain independent values. - class_eval(<<-EOS, __FILE__, __LINE__ + 1) + class_eval(<<~RUBY, __FILE__, __LINE__ + 1) def self.#{sym}=(obj) @__thread_mattr_#{sym} ||= "attr_#{sym}_\#{object_id}" ::ActiveSupport::IsolatedExecutionState[@__thread_mattr_#{sym}] = obj end - EOS + RUBY if instance_writer && instance_accessor - class_eval(<<-EOS, __FILE__, __LINE__ + 1) + class_eval(<<~RUBY, __FILE__, __LINE__ + 1) def #{sym}=(obj) self.class.#{sym} = obj end - EOS + RUBY end end end diff --git a/activesupport/lib/active_support/core_ext/numeric.rb b/activesupport/lib/active_support/core_ext/numeric.rb index fe778470f1649..949efcb726c1f 100644 --- a/activesupport/lib/active_support/core_ext/numeric.rb +++ b/activesupport/lib/active_support/core_ext/numeric.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require "active_support/core_ext/numeric/bytes" -require "active_support/core_ext/numeric/time" -require "active_support/core_ext/numeric/conversions" +require_relative "numeric/bytes" +require_relative "numeric/time" +require_relative "numeric/conversions" diff --git a/activesupport/lib/active_support/core_ext/object.rb b/activesupport/lib/active_support/core_ext/object.rb index 7a7f0d99817ec..097e4712f05ca 100644 --- a/activesupport/lib/active_support/core_ext/object.rb +++ b/activesupport/lib/active_support/core_ext/object.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true -require "active_support/core_ext/object/acts_like" -require "active_support/core_ext/object/blank" -require "active_support/core_ext/object/duplicable" -require "active_support/core_ext/object/deep_dup" -require "active_support/core_ext/object/try" -require "active_support/core_ext/object/inclusion" +require_relative "object/acts_like" +require_relative "object/blank" +require_relative "object/duplicable" +require_relative "object/deep_dup" +require_relative "object/try" +require_relative "object/inclusion" -require "active_support/core_ext/object/conversions" -require "active_support/core_ext/object/instance_variables" +require_relative "object/conversions" +require_relative "object/instance_variables" -require "active_support/core_ext/object/json" -require "active_support/core_ext/object/to_param" -require "active_support/core_ext/object/to_query" -require "active_support/core_ext/object/with" -require "active_support/core_ext/object/with_options" +require_relative "object/json" +require_relative "object/to_param" +require_relative "object/to_query" +require_relative "object/with" +require_relative "object/with_options" diff --git a/activesupport/lib/active_support/core_ext/object/to_query.rb b/activesupport/lib/active_support/core_ext/object/to_query.rb index 42284d207bce2..5358e29a19fdb 100644 --- a/activesupport/lib/active_support/core_ext/object/to_query.rb +++ b/activesupport/lib/active_support/core_ext/object/to_query.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require "cgi" +require "cgi/escape" +require "cgi/util" if RUBY_VERSION < "3.5" class Object # Alias of to_s. diff --git a/activesupport/lib/active_support/core_ext/object/try.rb b/activesupport/lib/active_support/core_ext/object/try.rb index c2c76254ae4c9..5a8d546386f62 100644 --- a/activesupport/lib/active_support/core_ext/object/try.rb +++ b/activesupport/lib/active_support/core_ext/object/try.rb @@ -145,14 +145,14 @@ class NilClass # # With +try+ # @person.try(:children).try(:first).try(:name) - def try(*) + def try(*, &) nil end # Calling +try!+ on +nil+ always returns +nil+. # # nil.try!(:name) # => nil - def try!(*) + def try!(*, &) nil end end diff --git a/activesupport/lib/active_support/core_ext/pathname.rb b/activesupport/lib/active_support/core_ext/pathname.rb index 10fa1903bead7..8d1f42b5acb9c 100644 --- a/activesupport/lib/active_support/core_ext/pathname.rb +++ b/activesupport/lib/active_support/core_ext/pathname.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true -require "active_support/core_ext/pathname/blank" -require "active_support/core_ext/pathname/existence" +require_relative "pathname/blank" +require_relative "pathname/existence" diff --git a/activesupport/lib/active_support/core_ext/range.rb b/activesupport/lib/active_support/core_ext/range.rb index 6643255cc0572..d9fc27183ca4e 100644 --- a/activesupport/lib/active_support/core_ext/range.rb +++ b/activesupport/lib/active_support/core_ext/range.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -require "active_support/core_ext/range/conversions" -require "active_support/core_ext/range/compare_range" -require "active_support/core_ext/range/overlap" +require_relative "range/conversions" +require_relative "range/compare_range" +require_relative "range/overlap" +require_relative "range/sole" diff --git a/activesupport/lib/active_support/core_ext/range/overlap.rb b/activesupport/lib/active_support/core_ext/range/overlap.rb index b08cdb39cfaa1..c26b8b064b859 100644 --- a/activesupport/lib/active_support/core_ext/range/overlap.rb +++ b/activesupport/lib/active_support/core_ext/range/overlap.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class Range - # Compare two ranges and see if they overlap each other - # (1..5).overlap?(4..6) # => true - # (1..5).overlap?(7..9) # => false unless Range.method_defined?(:overlap?) # Ruby 3.3+ + # Compare two ranges and see if they overlap each other + # (1..5).overlap?(4..6) # => true + # (1..5).overlap?(7..9) # => false def overlap?(other) raise TypeError unless other.is_a? Range diff --git a/activesupport/lib/active_support/core_ext/range/sole.rb b/activesupport/lib/active_support/core_ext/range/sole.rb new file mode 100644 index 0000000000000..6d2f9c62c8ee1 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/range/sole.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Range + # Returns the sole item in the range. If there are no items, or more + # than one item, raises Enumerable::SoleItemExpectedError. + # + # (1..1).sole # => 1 + # (2..1).sole # => Enumerable::SoleItemExpectedError: no item found + # (..1).sole # => Enumerable::SoleItemExpectedError: infinite range cannot represent a sole item + def sole + if self.begin.nil? || self.end.nil? + raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "infinite range '#{inspect}' cannot represent a sole item" + end + + super + end +end diff --git a/activesupport/lib/active_support/core_ext/securerandom.rb b/activesupport/lib/active_support/core_ext/securerandom.rb index aae75290d2bdc..33fa19586aa79 100644 --- a/activesupport/lib/active_support/core_ext/securerandom.rb +++ b/activesupport/lib/active_support/core_ext/securerandom.rb @@ -5,6 +5,7 @@ module SecureRandom BASE58_ALPHABET = ("0".."9").to_a + ("A".."Z").to_a + ("a".."z").to_a - ["0", "O", "I", "l"] BASE36_ALPHABET = ("0".."9").to_a + ("a".."z").to_a + BASE32_ALPHABET = ("0".."9").to_a + ("A".."Z").to_a - ["I", "L", "O", "U"] # SecureRandom.base58 generates a random base58 string. # @@ -54,4 +55,29 @@ def self.base36(n = 16) end.join end end + + # SecureRandom.base32 generates a random Crockford base32 string in uppercase. + # + # The argument _n_ specifies the length of the random string to be generated. + # + # If _n_ is not specified or is +nil+, 16 is assumed. It may be larger in the future. + # This method can be used over +base58+ if a case-insensitive key that's unambiguous to humans is necessary. + # + # The result may contain alphanumeric characters in uppercase except I, L, O, and U. + # + # p SecureRandom.base32 # => "PAK1NG78CM1HJ44A" + # p SecureRandom.base32(24) # => "BN9EAB8RG9BNTTC9BX7P5JGJ" + if SecureRandom.method(:alphanumeric).parameters.size == 2 # Remove check when Ruby 3.3 is the minimum supported version + def self.base32(n = 16) + alphanumeric(n, chars: BASE32_ALPHABET) + end + else + def self.base32(n = 16) + SecureRandom.random_bytes(n).unpack("C*").map do |byte| + idx = byte % 64 + idx = SecureRandom.random_number(32) if idx >= 32 + BASE32_ALPHABET[idx] + end.join + end + end end diff --git a/activesupport/lib/active_support/core_ext/string.rb b/activesupport/lib/active_support/core_ext/string.rb index 757d15c51ae49..491eec2fc9955 100644 --- a/activesupport/lib/active_support/core_ext/string.rb +++ b/activesupport/lib/active_support/core_ext/string.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true -require "active_support/core_ext/string/conversions" -require "active_support/core_ext/string/filters" -require "active_support/core_ext/string/multibyte" -require "active_support/core_ext/string/starts_ends_with" -require "active_support/core_ext/string/inflections" -require "active_support/core_ext/string/access" -require "active_support/core_ext/string/behavior" -require "active_support/core_ext/string/output_safety" -require "active_support/core_ext/string/exclude" -require "active_support/core_ext/string/strip" -require "active_support/core_ext/string/inquiry" -require "active_support/core_ext/string/indent" -require "active_support/core_ext/string/zones" +require_relative "string/conversions" +require_relative "string/filters" +require_relative "string/multibyte" +require_relative "string/starts_ends_with" +require_relative "string/inflections" +require_relative "string/access" +require_relative "string/behavior" +require_relative "string/output_safety" +require_relative "string/exclude" +require_relative "string/strip" +require_relative "string/inquiry" +require_relative "string/indent" +require_relative "string/zones" diff --git a/activesupport/lib/active_support/core_ext/string/filters.rb b/activesupport/lib/active_support/core_ext/string/filters.rb index 9e7e5511068c0..82c630f278726 100644 --- a/activesupport/lib/active_support/core_ext/string/filters.rb +++ b/activesupport/lib/active_support/core_ext/string/filters.rb @@ -88,11 +88,11 @@ def truncate(truncate_to, options = {}) # characters. # # >> "🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪".size - # => 20 + # # => 20 # >> "🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪".bytesize - # => 80 + # # => 80 # >> "🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪".truncate_bytes(20) - # => "🔪🔪🔪🔪…" + # # => "🔪🔪🔪🔪…" # # The truncated text ends with the :omission string, defaulting # to "…", for a total length not exceeding truncate_to. diff --git a/activesupport/lib/active_support/core_ext/string/multibyte.rb b/activesupport/lib/active_support/core_ext/string/multibyte.rb index e1ea6ac4b6b55..c869066192c90 100644 --- a/activesupport/lib/active_support/core_ext/string/multibyte.rb +++ b/activesupport/lib/active_support/core_ext/string/multibyte.rb @@ -12,12 +12,12 @@ class String # class. If the proxy class doesn't respond to a certain method, it's forwarded to the encapsulated string. # # >> "lj".mb_chars.upcase.to_s - # => "LJ" + # # => "LJ" # # NOTE: Ruby 2.4 and later support native Unicode case mappings: # # >> "lj".upcase - # => "LJ" + # # => "LJ" # # == \Method chaining # diff --git a/activesupport/lib/active_support/core_ext/string/output_safety.rb b/activesupport/lib/active_support/core_ext/string/output_safety.rb index 74e89b33b0764..080d62b9b2683 100644 --- a/activesupport/lib/active_support/core_ext/string/output_safety.rb +++ b/activesupport/lib/active_support/core_ext/string/output_safety.rb @@ -67,14 +67,13 @@ def safe_concat(value) original_concat(value) end - def initialize(str = "") - @html_safe = true + def initialize(_str = "") super end def initialize_copy(other) super - @html_safe = other.html_safe? + @html_unsafe = true unless other.html_safe? end def concat(value) @@ -116,7 +115,9 @@ def +(other) def *(_) new_string = super new_safe_buffer = new_string.is_a?(SafeBuffer) ? new_string : SafeBuffer.new(new_string) - new_safe_buffer.instance_variable_set(:@html_safe, @html_safe) + if @html_unsafe + new_safe_buffer.instance_variable_set(:@html_unsafe, true) + end new_safe_buffer end @@ -131,9 +132,9 @@ def %(args) self.class.new(super(escaped_args)) end - attr_reader :html_safe - alias_method :html_safe?, :html_safe - remove_method :html_safe + def html_safe? + @html_unsafe.nil? + end def to_s self @@ -153,21 +154,21 @@ def encode_with(coder) UNSAFE_STRING_METHODS.each do |unsafe_method| if unsafe_method.respond_to?(unsafe_method) - class_eval <<-EOT, __FILE__, __LINE__ + 1 + class_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{unsafe_method}(*args, &block) # def capitalize(*args, &block) to_str.#{unsafe_method}(*args, &block) # to_str.capitalize(*args, &block) end # end def #{unsafe_method}!(*args) # def capitalize!(*args) - @html_safe = false # @html_safe = false + @html_unsafe = true # @html_unsafe = true super # super end # end - EOT + RUBY end end UNSAFE_STRING_METHODS_WITH_BACKREF.each do |unsafe_method| - class_eval <<-EOT, __FILE__, __LINE__ + 1 + class_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{unsafe_method}(*args, &block) # def gsub(*args, &block) if block # if block to_str.#{unsafe_method}(*args) { |*params| # to_str.gsub(*args) { |*params| @@ -180,7 +181,7 @@ def #{unsafe_method}(*args, &block) # def gsub(*args, &block) end # end def #{unsafe_method}!(*args, &block) # def gsub!(*args, &block) - @html_safe = false # @html_safe = false + @html_unsafe = true # @html_unsafe = true if block # if block super(*args) { |*params| # super(*args) { |*params| set_block_back_references(block, $~) # set_block_back_references(block, $~) @@ -190,19 +191,19 @@ def #{unsafe_method}!(*args, &block) # def gsub!(*args, &block) super # super end # end end # end - EOT + RUBY end private def explicit_html_escape_interpolated_argument(arg) - (!html_safe? || arg.html_safe?) ? arg : CGI.escapeHTML(arg.to_s) + (!html_safe? || arg.html_safe?) ? arg : ERB::Util.unwrapped_html_escape(arg) end def implicit_html_escape_interpolated_argument(arg) if !html_safe? || arg.html_safe? arg else - CGI.escapeHTML(arg.to_str) + ERB::Util.unwrapped_html_escape(arg.to_str) end end @@ -214,7 +215,9 @@ def set_block_back_references(block, match_data) def string_into_safe_buffer(new_string, is_html_safe) new_safe_buffer = new_string.is_a?(SafeBuffer) ? new_string : SafeBuffer.new(new_string) - new_safe_buffer.instance_variable_set :@html_safe, is_html_safe + unless is_html_safe + new_safe_buffer.instance_variable_set :@html_unsafe, true + end new_safe_buffer end end diff --git a/activesupport/lib/active_support/core_ext/string/strip.rb b/activesupport/lib/active_support/core_ext/string/strip.rb index 60e9952ee62c6..a6708d7dbf8a6 100644 --- a/activesupport/lib/active_support/core_ext/string/strip.rb +++ b/activesupport/lib/active_support/core_ext/string/strip.rb @@ -1,8 +1,14 @@ # frozen_string_literal: true +require "strscan" + class String # Strips indentation in heredocs. # + # Note that since Ruby 2.3, heredocs can directly created with their indentation striped + # by using the <<~ syntax instead of <<-. + # Hence the strip_heredoc method is rarely useful nowadays. + # # For example in # # if options[:usage] @@ -20,8 +26,32 @@ class String # Technically, it looks for the least indented non-empty line # in the whole string, and removes that amount of leading whitespace. def strip_heredoc - gsub(/^#{scan(/^[ \t]*(?=\S)/).min}/, "").tap do |stripped| - stripped.freeze if frozen? + scanner = StringScanner.new("") + + min_indent_len = nil + lines = self.lines + + lines.each do |line| + scanner.string = line + indent_len = scanner.skip(/[ \t]*/) + + next unless scanner.match?(/[^\r\n]/) + + min_indent_len = indent_len if indent_len < (min_indent_len || Float::INFINITY) end + + + if min_indent_len.nil? || min_indent_len.zero? + return frozen? ? self : dup + end + + lines.each do |line| + line[0, min_indent_len] = "" unless line == "\n" + end + + result = lines.join + + result.freeze if frozen? + result end end diff --git a/activesupport/lib/active_support/core_ext/symbol.rb b/activesupport/lib/active_support/core_ext/symbol.rb index 709fed2024f2b..f5379ff3a394e 100644 --- a/activesupport/lib/active_support/core_ext/symbol.rb +++ b/activesupport/lib/active_support/core_ext/symbol.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require "active_support/core_ext/symbol/starts_ends_with" +require_relative "symbol/starts_ends_with" diff --git a/activesupport/lib/active_support/core_ext/time.rb b/activesupport/lib/active_support/core_ext/time.rb index c809def05f35b..4e16274443d71 100644 --- a/activesupport/lib/active_support/core_ext/time.rb +++ b/activesupport/lib/active_support/core_ext/time.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "active_support/core_ext/time/acts_like" -require "active_support/core_ext/time/calculations" -require "active_support/core_ext/time/compatibility" -require "active_support/core_ext/time/conversions" -require "active_support/core_ext/time/zones" +require_relative "time/acts_like" +require_relative "time/calculations" +require_relative "time/compatibility" +require_relative "time/conversions" +require_relative "time/zones" diff --git a/activesupport/lib/active_support/core_ext/time/calculations.rb b/activesupport/lib/active_support/core_ext/time/calculations.rb index 471a56c9ba891..9e9a1c0af3b7d 100644 --- a/activesupport/lib/active_support/core_ext/time/calculations.rb +++ b/activesupport/lib/active_support/core_ext/time/calculations.rb @@ -224,13 +224,6 @@ def ago(seconds) # Returns a new Time representing the time a number of seconds since the instance time def since(seconds) self + seconds - rescue TypeError - result = to_datetime.since(seconds) - ActiveSupport.deprecator.warn( - "Passing an instance of #{seconds.class} to #{self.class}#since is deprecated. This behavior will raise " \ - "a `TypeError` in Rails 8.1." - ) - result end alias :in :since diff --git a/activesupport/lib/active_support/core_ext/time/compatibility.rb b/activesupport/lib/active_support/core_ext/time/compatibility.rb index 4e6c8ca3ca4dd..de5290c1a5ad9 100644 --- a/activesupport/lib/active_support/core_ext/time/compatibility.rb +++ b/activesupport/lib/active_support/core_ext/time/compatibility.rb @@ -8,33 +8,8 @@ class Time silence_redefinition_of_method :to_time - # Either return +self+ or the time in the local system timezone depending - # on the setting of +ActiveSupport.to_time_preserves_timezone+. + # Return +self+. def to_time - preserve_timezone ? self : getlocal + self end - - def preserve_timezone # :nodoc: - system_local_time? || super - end - - private - def system_local_time? - if ::Time.equal?(self.class) - zone = self.zone - String === zone && - (zone != "UTC" || active_support_local_zone == "UTC") - end - end - - @@active_support_local_tz = nil - - def active_support_local_zone - @@active_support_local_zone = nil if @@active_support_local_tz != ENV["TZ"] - @@active_support_local_zone ||= - begin - @@active_support_local_tz = ENV["TZ"] - Time.new.zone - end - end end diff --git a/activesupport/lib/active_support/current_attributes.rb b/activesupport/lib/active_support/current_attributes.rb index 5415bfdc82a8c..25330fd3f6275 100644 --- a/activesupport/lib/active_support/current_attributes.rb +++ b/activesupport/lib/active_support/current_attributes.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_support/callbacks" +require "active_support/execution_context" require "active_support/core_ext/object/with" require "active_support/core_ext/enumerable" require "active_support/core_ext/module/delegation" @@ -108,15 +109,18 @@ def instance # ==== Options # # * :default - The default value for the attributes. If the value - # is a proc or lambda, it will be called whenever an instance is - # constructed. Otherwise, the value will be duplicated with +#dup+. - # Default values are re-assigned when the attributes are reset. + # is a proc or lambda, it will be called whenever an instance is + # constructed. Otherwise, the value will be duplicated with +#dup+. + # Default values are re-assigned when the attributes are reset. def attribute(*names, default: NOT_SET) invalid_attribute_names = names.map(&:to_sym) & INVALID_ATTRIBUTE_NAMES if invalid_attribute_names.any? raise ArgumentError, "Restricted attribute names: #{invalid_attribute_names.join(", ")}" end + Delegation.generate(singleton_class, names, to: :instance, nilable: false, signature: "") + Delegation.generate(singleton_class, names.map { |n| "#{n}=" }, to: :instance, nilable: false, signature: "value") + ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |owner| names.each do |name| owner.define_cached_method(name, namespace: :current_attributes) do |batch| @@ -134,9 +138,6 @@ def attribute(*names, default: NOT_SET) end end - Delegation.generate(singleton_class, names, to: :instance, nilable: false, signature: "") - Delegation.generate(singleton_class, names.map { |n| "#{n}=" }, to: :instance, nilable: false, signature: "value") - self.defaults = defaults.merge(names.index_with { default }) end @@ -153,13 +154,11 @@ def resets(*methods, &block) delegate :set, :reset, to: :instance - def reset_all # :nodoc: - current_instances.each_value(&:reset) - end - def clear_all # :nodoc: - reset_all - current_instances.clear + if instances = current_instances + instances.values.each(&:reset) + instances.clear + end end private @@ -168,7 +167,7 @@ def generated_attribute_methods end def current_instances - IsolatedExecutionState[:current_attributes_instances] ||= {} + ExecutionContext.current_attributes_instances end def current_instances_key @@ -185,9 +184,16 @@ def respond_to_missing?(name, _) def method_added(name) super + + # We try to generate instance delegators early to not rely on method_missing. return if name == :initialize + + # If the added method isn't public, we don't delegate it. return unless public_method_defined?(name) + + # If we already have a class method by that name, we don't override it. return if singleton_class.method_defined?(name) || singleton_class.private_method_defined?(name) + Delegation.generate(singleton_class, [name], to: :instance, as: self, nilable: false) end end diff --git a/activesupport/lib/active_support/current_attributes/test_helper.rb b/activesupport/lib/active_support/current_attributes/test_helper.rb index 2016384a80d89..681003240813c 100644 --- a/activesupport/lib/active_support/current_attributes/test_helper.rb +++ b/activesupport/lib/active_support/current_attributes/test_helper.rb @@ -2,12 +2,12 @@ module ActiveSupport::CurrentAttributes::TestHelper # :nodoc: def before_setup - ActiveSupport::CurrentAttributes.reset_all + ActiveSupport::CurrentAttributes.clear_all super end def after_teardown super - ActiveSupport::CurrentAttributes.reset_all + ActiveSupport::CurrentAttributes.clear_all end end diff --git a/activesupport/lib/active_support/delegation.rb b/activesupport/lib/active_support/delegation.rb index 261cc0d42357f..6106b0ab1801f 100644 --- a/activesupport/lib/active_support/delegation.rb +++ b/activesupport/lib/active_support/delegation.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "delegate" + module ActiveSupport # Error generated by +delegate+ when a method is called on +nil+ and +allow_nil+ # option is not used. @@ -17,6 +19,21 @@ module Delegation # :nodoc: not or redo rescue retry return self super then true undef unless until when while yield) RESERVED_METHOD_NAMES = (RUBY_RESERVED_KEYWORDS + %w(_ arg args block)).to_set.freeze + class ClassDelegator < ::Delegator + attr_reader :delegate_dc_obj + alias_method :__getobj__, :delegate_dc_obj + undef_method :delegate_dc_obj + + def initialize(delegated_obj) + @delegate_dc_obj = delegated_obj + end + + def __setobj__(obj) + __raise__ ::ArgumentError, "cannot delegate to self" if equal?(obj) + @delegate_dc_obj = obj + end + end + class << self def generate(owner, methods, location: nil, to: nil, prefix: nil, allow_nil: nil, nilable: true, private: nil, as: nil, signature: nil) unless to @@ -195,6 +212,62 @@ def method_missing(method, ...) RUBY end end + + def DelegateClass(superclass, &block) + klass = Class.new(ClassDelegator) + + ignores = [*::Delegator.public_api, :to_s, :inspect, :=~, :!~, :===] + protected_instance_methods = superclass.protected_instance_methods - ignores + public_instance_methods = superclass.public_instance_methods - ignores + delegated_methods = public_instance_methods + protected_instance_methods + + method_def = [] + + delegated_methods.each do |method_name| + if method_name.match?(/\A[a-zA-Z]\w+[!?]?\z/) + parameters = superclass.instance_method(method_name).parameters + + signature = if parameters.empty? + "" + elsif parameters.all? { |type, _name| type == :req || type == :keyreq || type == :block } + anonymous = 0 + parameters.map do |type, name| + case type + when :req + name || "__anonymous_arg_#{anonymous += 1}" + when :keyreq + "#{name}:" + when :block + if name == :& + name + else + "&#{name}" + end + end + end.join(", ") + else + "..." + end + + method_def << + "def #{method_name}(#{signature})" << + " __getobj__.#{method_name}(#{signature})" << + "end" + else + method_def << + "def #{method_name}(...)" << + " __getobj__.send(#{method_name.inspect}, ...)" << + "end" + end + end + + location = caller_locations(1, 1).first + file, line = location.path, location.lineno + klass.module_eval(method_def.join(";"), file, line) + + klass.class_eval(&block) if block + klass + end end end end diff --git a/activesupport/lib/active_support/dependencies.rb b/activesupport/lib/active_support/dependencies.rb index aed692339f20f..c43cd5a1bc481 100644 --- a/activesupport/lib/active_support/dependencies.rb +++ b/activesupport/lib/active_support/dependencies.rb @@ -21,7 +21,12 @@ def self.run_interlock(&block) # preventing any other thread from being inside a #run_interlock # block at the same time. def self.load_interlock(&block) - interlock.loading(&block) + ActiveSupport.deprecator.warn( + "ActiveSupport::Dependencies.load_interlock is deprecated and " \ + "will be removed in Rails 9.0. The loading interlock is no longer " \ + "used since Rails switched to Zeitwerk for autoloading." + ) + yield if block end # Execute the supplied block while holding an exclusive lock, diff --git a/activesupport/lib/active_support/dependencies/interlock.rb b/activesupport/lib/active_support/dependencies/interlock.rb index e0e32e821c918..88b69b065e0d6 100644 --- a/activesupport/lib/active_support/dependencies/interlock.rb +++ b/activesupport/lib/active_support/dependencies/interlock.rb @@ -10,19 +10,24 @@ def initialize # :nodoc: end def loading(&block) - @lock.exclusive(purpose: :load, compatible: [:load], after_compatible: [:load], &block) + ActiveSupport.deprecator.warn( + "ActiveSupport::Dependencies::Interlock#loading is deprecated and " \ + "will be removed in Rails 9.0. The loading interlock is no longer " \ + "used since Rails switched to Zeitwerk for autoloading." + ) + yield if block end def unloading(&block) - @lock.exclusive(purpose: :unload, compatible: [:load, :unload], after_compatible: [:load, :unload], &block) + @lock.exclusive(purpose: :unload, compatible: [:unload], after_compatible: [:unload], &block) end def start_unloading - @lock.start_exclusive(purpose: :unload, compatible: [:load, :unload]) + @lock.start_exclusive(purpose: :unload, compatible: [:unload]) end def done_unloading - @lock.stop_exclusive(compatible: [:load, :unload]) + @lock.stop_exclusive(compatible: [:unload]) end def start_running @@ -38,7 +43,8 @@ def running(&block) end def permit_concurrent_loads(&block) - @lock.yield_shares(compatible: [:load], &block) + # Soft deprecated: no deprecation warning for now, but this is a no-op. + yield if block end def raw_state(&block) # :nodoc: diff --git a/activesupport/lib/active_support/deprecation.rb b/activesupport/lib/active_support/deprecation.rb index e66653d89084f..ba50de3f85082 100644 --- a/activesupport/lib/active_support/deprecation.rb +++ b/activesupport/lib/active_support/deprecation.rb @@ -68,7 +68,7 @@ def self._instance # :nodoc: # and the second is a library name. # # ActiveSupport::Deprecation.new('2.0', 'MyLibrary') - def initialize(deprecation_horizon = "8.2", gem_name = "Rails") + def initialize(deprecation_horizon = "8.3", gem_name = "Rails") self.gem_name = gem_name self.deprecation_horizon = deprecation_horizon # By default, warnings are not silenced and debugging is off. diff --git a/activesupport/lib/active_support/editor.rb b/activesupport/lib/active_support/editor.rb new file mode 100644 index 0000000000000..5a52df01ef847 --- /dev/null +++ b/activesupport/lib/active_support/editor.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActiveSupport + class Editor + @editors = {} + @current = false + + class << self + # Registers a URL pattern for opening file in a given editor. + # This allows Rails to generate clickable links to control known editors. + # + # Example: + # + # ActiveSupport::Editor.register("myeditor", "myeditor://%s:%d") + def register(name, url_pattern, aliases: []) + editor = new(url_pattern) + @editors[name] = editor + aliases.each do |a| + @editors[a] = editor + end + end + + # Returns the current editor pattern if it is known. + # First check for the `RAILS_EDITOR` environment variable, and if it's + # missing, check for the `EDITOR` environment variable. + def current + if @current == false + @current = if editor_name = ENV["RAILS_EDITOR"] || ENV["EDITOR"] + @editors[editor_name] + end + end + @current + end + + # :nodoc: + + def find(name) + @editors[name] + end + + def reset + @current = false + end + end + + def initialize(url_pattern) + @url_pattern = url_pattern + end + + def url_for(path, line) + sprintf(@url_pattern, path, line) + end + + register "atom", "atom://core/open/file?filename=%s&line=%d" + register "cursor", "cursor://file/%s:%f" + register "emacs", "emacs://open?url=file://%s&line=%d", aliases: ["emacsclient"] + register "idea", "idea://open?file=%s&line=%d" + register "macvim", "mvim://open?url=file://%s&line=%d", aliases: ["mvim"] + register "nova", "nova://open?path=%s&line=%d" + register "rubymine", "x-mine://open?file=%s&line=%d" + register "sublime", "subl://open?url=file://%s&line=%d", aliases: ["subl"] + register "textmate", "txmt://open?url=file://%s&line=%d", aliases: ["mate"] + register "vscode", "vscode://file/%s:%d", aliases: ["code"] + register "vscodium", "vscodium://file/%s:%d", aliases: ["codium"] + register "windsurf", "windsurf://file/%s:%d" + register "zed", "zed://file/%s:%d" + end +end diff --git a/activesupport/lib/active_support/error_reporter.rb b/activesupport/lib/active_support/error_reporter.rb index dfcf486949406..68559f062f01b 100644 --- a/activesupport/lib/active_support/error_reporter.rb +++ b/activesupport/lib/active_support/error_reporter.rb @@ -276,14 +276,12 @@ def report(error, handled: true, severity: handled ? :warning : :error, context: private def ensure_backtrace(error) - return if error.frozen? # re-raising won't add a backtrace + return if error.frozen? # re-raising won't add a backtrace or set the cause return unless error.backtrace.nil? begin - # We could use Exception#set_backtrace, but until Ruby 3.4 - # it only support setting `Exception#backtrace` and not - # `Exception#backtrace_locations`. So raising the exception - # is a good way to build a real backtrace. + # As of Ruby 3.4, we could use Exception#set_backtrace to set the backtrace, + # but there's nothing like Exception#set_cause. Raising+rescuing is the only way to set the cause. raise error rescue error.class => error end diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb new file mode 100644 index 0000000000000..d2ab32103a7c9 --- /dev/null +++ b/activesupport/lib/active_support/event_reporter.rb @@ -0,0 +1,603 @@ +# frozen_string_literal: true + +require "active_support/parameter_filter" + +module ActiveSupport + class TagStack # :nodoc: + EMPTY_TAGS = {}.freeze + FIBER_KEY = :event_reporter_tags + + class << self + def tags + Fiber[FIBER_KEY] || EMPTY_TAGS + end + + def with_tags(*args, **kwargs) + existing_tags = tags + tags = existing_tags.dup + tags.merge!(resolve_tags(args, kwargs)) + new_tags = tags.freeze + + begin + Fiber[FIBER_KEY] = new_tags + yield + ensure + Fiber[FIBER_KEY] = existing_tags + end + end + + private + def resolve_tags(args, kwargs) + tags = args.each_with_object({}) do |arg, tags| + case arg + when String + tags[arg.to_sym] = true + when Symbol + tags[arg] = true + when Hash + arg.each { |key, value| tags[key.to_sym] = value } + else + tags[arg.class.name.to_sym] = arg + end + end + kwargs.each { |key, value| tags[key.to_sym] = value } + tags + end + end + end + + class EventContext # :nodoc: + EMPTY_CONTEXT = {}.freeze + FIBER_KEY = :event_reporter_context + + class << self + def context + Fiber[FIBER_KEY] || EMPTY_CONTEXT + end + + def set_context(context_hash) + new_context = self.context.dup + context_hash.each { |key, value| new_context[key.to_sym] = value } + + Fiber[FIBER_KEY] = new_context.freeze + end + + def clear + Fiber[FIBER_KEY] = EMPTY_CONTEXT + end + end + end + + # = Active Support \Event Reporter + # + # +ActiveSupport::EventReporter+ provides an interface for reporting structured events to subscribers. + # + # To report an event, you can use the +notify+ method: + # + # Rails.event.notify("user_created", { id: 123 }) + # # Emits event: + # # { + # # name: "user_created", + # # payload: { id: 123 }, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # The +notify+ API can receive either an event name and a payload hash, or an event object. Names are coerced to strings. + # + # === Event Objects + # + # If an event object is passed to the +notify+ API, it will be passed through to subscribers as-is, and the name of the + # object's class will be used as the event name. + # + # class UserCreatedEvent + # def initialize(id:, name:) + # @id = id + # @name = name + # end + # + # def serialize + # { + # id: @id, + # name: @name + # } + # end + # end + # + # Rails.event.notify(UserCreatedEvent.new(id: 123, name: "John Doe")) + # # Emits event: + # # { + # # name: "UserCreatedEvent", + # # payload: #, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # An event is any Ruby object representing a schematized event. While payload hashes allow arbitrary, + # implicitly-structured data, event objects are intended to enforce a particular schema. + # + # Subscribers are responsible for serializing event objects. + # + # === Subscribers + # + # Subscribers must implement the +emit+ method, which will be called with the event hash. + # + # The event hash has the following keys: + # + # name: String (The name of the event) + # payload: Hash, Object (The payload of the event, or the event object itself) + # tags: Hash (The tags of the event) + # context: Hash (The context of the event) + # timestamp: Float (The timestamp of the event, in nanoseconds) + # source_location: Hash (The source location of the event, containing the filepath, lineno, and label) + # + # Subscribers are responsible for encoding events to their desired format before emitting them to their + # target destination, such as a streaming platform, a log device, or an alerting service. + # + # class JSONEventSubscriber + # def emit(event) + # json_data = JSON.generate(event) + # LogExporter.export(json_data) + # end + # end + # + # class LogSubscriber + # def emit(event) + # payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ") + # source_location = event[:source_location] + # log = "[#{event[:name]}] #{payload} at #{source_location[:filepath]}:#{source_location[:lineno]}" + # Rails.logger.info(log) + # end + # end + # + # Note that event objects are passed through to subscribers as-is, and may need to be serialized before being encoded: + # + # class UserCreatedEvent + # def initialize(id:, name:) + # @id = id + # @name = name + # end + # + # def serialize + # { + # id: @id, + # name: @name + # } + # end + # end + # + # class LogSubscriber + # def emit(event) + # payload = event[:payload] + # json_data = JSON.generate(payload.serialize) + # LogExporter.export(json_data) + # end + # end + # + # ==== Filtered Subscriptions + # + # Subscribers can be configured with an optional filter proc to only receive a subset of events: + # + # # Only receive events with names starting with "user." + # Rails.event.subscribe(user_subscriber) { |event| event[:name].start_with?("user.") } + # + # # Only receive events with specific payload types + # Rails.event.subscribe(audit_subscriber) { |event| event[:payload].is_a?(AuditEvent) } + # + # === Debug Events + # + # You can use the +debug+ method to report an event that will only be reported if the + # event reporter is in debug mode: + # + # Rails.event.debug("my_debug_event", { foo: "bar" }) + # + # === Tags + # + # To add additional context to an event, separate from the event payload, you can add + # tags via the +tagged+ method: + # + # Rails.event.tagged("graphql") do + # Rails.event.notify("user_created", { id: 123 }) + # end + # + # # Emits event: + # # { + # # name: "user_created", + # # payload: { id: 123 }, + # # tags: { graphql: true }, + # # context: {}, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # === Context Store + # + # You may want to attach metadata to every event emitted by the reporter. While tags + # provide domain-specific context for a series of events, context is scoped to the job / request + # and should be used for metadata associated with the execution context. + # Context can be set via the +set_context+ method: + # + # Rails.event.set_context(request_id: "abcd123", user_agent: "TestAgent") + # Rails.event.notify("user_created", { id: 123 }) + # + # # Emits event: + # # { + # # name: "user_created", + # # payload: { id: 123 }, + # # tags: {}, + # # context: { request_id: "abcd123", user_agent: "TestAgent" }, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # Context is reset automatically before and after each request. + # + # A custom context store can be configured via +config.active_support.event_reporter_context_store+. + # + # # config/application.rb + # config.active_support.event_reporter_context_store = CustomContextStore + # + # class CustomContextStore + # class << self + # def context + # # Return the context. + # end + # + # def set_context(context_hash) + # # Append context_hash to the existing context store. + # end + # + # def clear + # # Delete the stored context. + # end + # end + # end + # + # The Event Reporter standardizes on symbol keys for all payload data, tags, and context store entries. + # String keys are automatically converted to symbols for consistency. + # + # Rails.event.notify("user.created", { "id" => 123 }) + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # } + # + # === Security + # + # When reporting events, Hash-based payloads are automatically filtered to remove sensitive data based on {Rails.application.filter_parameters}[https://guides.rubyonrails.org/configuring.html#config-filter-parameters]. + # + # If an {event object}[rdoc-ref:EventReporter@Event+Objects] is given instead, subscribers will need to filter sensitive data themselves, e.g. with ActiveSupport::ParameterFilter. + class EventReporter + extend ActiveSupport::Autoload + + autoload :LogSubscriber + + # Sets whether to raise an error if a subscriber raises an error during + # event emission, or when unexpected arguments are passed to +notify+. + attr_writer :raise_on_error + + attr_writer :debug_mode # :nodoc: + + attr_reader :subscribers # :nodoc + + class << self + # Filter parameters used to filter event payloads. If nil, + # Active Support's filter parameters will be used instead. + attr_accessor :filter_parameters + attr_accessor :context_store # :nodoc: + end + + self.context_store = EventContext + + def initialize(*subscribers, raise_on_error: false) + @subscribers = [] + subscribers.each { |subscriber| subscribe(subscriber) } + @debug_mode = false + @raise_on_error = raise_on_error + end + + # Registers a new event subscriber. The subscriber must respond to + # + # emit(event: Hash) + # + # The event hash will have the following keys: + # + # name: String (The name of the event) + # payload: Hash, Object (The payload of the event, or the event object itself) + # tags: Hash (The tags of the event) + # context: Hash (The context of the event) + # timestamp: Float (The timestamp of the event, in nanoseconds) + # source_location: Hash (The source location of the event, containing the filepath, lineno, and label) + # + # An optional filter proc can be provided to only receive a subset of events: + # + # Rails.event.subscribe(subscriber) { |event| event[:name].start_with?("user.") } + # Rails.event.subscribe(subscriber) { |event| event[:payload].is_a?(UserEvent) } + # + def subscribe(subscriber, &filter) + unless subscriber.respond_to?(:emit) + raise ArgumentError, "Event subscriber #{subscriber.class.name} must respond to #emit" + end + @subscribers << { subscriber: subscriber, filter: filter } + end + + # Unregister an event subscriber. Accepts either a subscriber or a class. + # + # subscriber = MyEventSubscriber.new + # Rails.event.subscribe(subscriber) + # + # Rails.event.unsubscribe(subscriber) + # # or + # Rails.event.unsubscribe(MyEventSubscriber) + def unsubscribe(subscriber) + @subscribers.delete_if { |s| subscriber === s[:subscriber] } + end + + # Reports an event to all registered subscribers. An event name and payload can be provided: + # + # Rails.event.notify("user.created", { id: 123 }) + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # tags: {}, + # # context: {}, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # Alternatively, an event object can be provided: + # + # Rails.event.notify(UserCreatedEvent.new(id: 123)) + # # Emits event: + # # { + # # name: "UserCreatedEvent", + # # payload: #, + # # tags: {}, + # # context: {}, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # ==== Arguments + # + # * +:payload+ - The event payload when using string/symbol event names. + # + # * +:caller_depth+ - The stack depth to use for source location (default: 1). + # + # * +:kwargs+ - Additional payload data when using string/symbol event names. + def notify(name_or_object, payload = nil, caller_depth: 1, **kwargs) + name = resolve_name(name_or_object) + payload = resolve_payload(name_or_object, payload, **kwargs) + + event = { + name: name, + payload: payload, + tags: TagStack.tags, + context: context_store.context, + timestamp: Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond), + } + + caller_location = caller_locations(caller_depth, 1)&.first + + if caller_location + source_location = { + filepath: caller_location.path, + lineno: caller_location.lineno, + label: caller_location.label, + } + event[:source_location] = source_location + end + + @subscribers.each do |subscriber_entry| + subscriber = subscriber_entry[:subscriber] + filter = subscriber_entry[:filter] + + next if filter && !filter.call(event) + + subscriber.emit(event) + rescue => subscriber_error + if raise_on_error? + raise + else + ActiveSupport.error_reporter.report(subscriber_error, handled: true) + end + end + + nil + end + + # Temporarily enables debug mode for the duration of the block. + # Calls to +debug+ will only be reported if debug mode is enabled. + # + # Rails.event.with_debug do + # Rails.event.debug("sql.query", { sql: "SELECT * FROM users" }) + # end + def with_debug + prior = Fiber[:event_reporter_debug_mode] + Fiber[:event_reporter_debug_mode] = true + yield + ensure + Fiber[:event_reporter_debug_mode] = prior + end + + # Check if debug mode is currently enabled. Debug mode is enabled on the reporter + # via +with_debug+, and in local environments. + def debug_mode? + @debug_mode || Fiber[:event_reporter_debug_mode] + end + + # Report an event only when in debug mode. For example: + # + # Rails.event.debug("sql.query", { sql: "SELECT * FROM users" }) + # + # ==== Arguments + # + # * +:payload+ - The event payload when using string/symbol event names. + # + # * +:caller_depth+ - The stack depth to use for source location (default: 1). + # + # * +:kwargs+ - Additional payload data when using string/symbol event names. + def debug(name_or_object, payload = nil, caller_depth: 1, **kwargs) + if debug_mode? + if block_given? + notify(name_or_object, payload, caller_depth: caller_depth + 1, **kwargs.merge(yield)) + else + notify(name_or_object, payload, caller_depth: caller_depth + 1, **kwargs) + end + end + end + + # Add tags to events to supply additional context. Tags operate in a stack-oriented manner, + # so all events emitted within the block inherit the same set of tags. For example: + # + # Rails.event.tagged("graphql") do + # Rails.event.notify("user.created", { id: 123 }) + # end + # + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # tags: { graphql: true }, + # # context: {}, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # Tags can be provided as arguments or as keyword arguments, and can be nested: + # + # Rails.event.tagged("graphql") do + # # Other code here... + # Rails.event.tagged(section: "admin") do + # Rails.event.notify("user.created", { id: 123 }) + # end + # end + # + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # tags: { section: "admin", graphql: true }, + # # context: {}, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # The +tagged+ API can also receive a tag object: + # + # graphql_tag = GraphqlTag.new(operation_name: "user_created", operation_type: "mutation") + # Rails.event.tagged(graphql_tag) do + # Rails.event.notify("user.created", { id: 123 }) + # end + # + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # tags: { "GraphqlTag": # }, + # # context: {}, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + def tagged(*args, **kwargs, &block) + TagStack.with_tags(*args, **kwargs, &block) + end + + # Sets context data that will be included with all events emitted by the reporter. + # Context data should be scoped to the job or request, and is reset automatically + # before and after each request and job. + # + # Rails.event.set_context(user_agent: "TestAgent") + # Rails.event.set_context(job_id: "abc123") + # Rails.event.tagged("graphql") do + # Rails.event.notify("user_created", { id: 123 }) + # end + # + # # Emits event: + # # { + # # name: "user_created", + # # payload: { id: 123 }, + # # tags: { graphql: true }, + # # context: { user_agent: "TestAgent", job_id: "abc123" }, + # # timestamp: 1738964843208679035 + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + def set_context(context) + context_store.set_context(context) + end + + # Clears all context data. + def clear_context + context_store.clear + end + + # Returns the current context data. + def context + context_store.context + end + + def reload_payload_filter # :nodoc: + @payload_filter = nil + payload_filter + end + + private + def filter_parameters + self.class.filter_parameters || ActiveSupport.filter_parameters + end + + def raise_on_error? + @raise_on_error + end + + def context_store + self.class.context_store + end + + def payload_filter + @payload_filter ||= begin + mask = ActiveSupport::ParameterFilter::FILTERED + ActiveSupport::ParameterFilter.new(filter_parameters, mask: mask) + end + end + + def resolve_name(name_or_object) + case name_or_object + when String, Symbol + name_or_object.to_s + else + name_or_object.class.name + end + end + + def resolve_payload(name_or_object, payload, **kwargs) + case name_or_object + when String, Symbol + handle_unexpected_args(name_or_object, payload, kwargs) if payload && kwargs.any? + if kwargs.any? + payload_filter.filter(kwargs.transform_keys(&:to_sym)) + elsif payload + payload_filter.filter(payload.transform_keys(&:to_sym)) + end + else + handle_unexpected_args(name_or_object, payload, kwargs) if payload || kwargs.any? + name_or_object + end + end + + def handle_unexpected_args(name_or_object, payload, kwargs) + message = <<~MESSAGE + Rails.event.notify accepts either an event object, a payload hash, or keyword arguments. + Received: #{name_or_object.inspect}, #{payload.inspect}, #{kwargs.inspect} + MESSAGE + + if raise_on_error? + raise ArgumentError, message + else + ActiveSupport.error_reporter.report(ArgumentError.new(message), handled: true) + end + end + end +end diff --git a/activesupport/lib/active_support/event_reporter/log_subscriber.rb b/activesupport/lib/active_support/event_reporter/log_subscriber.rb new file mode 100644 index 0000000000000..ddc0c372f2cf5 --- /dev/null +++ b/activesupport/lib/active_support/event_reporter/log_subscriber.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module ActiveSupport + class EventReporter + class LogSubscriber + include ColorizeLogging + + LEVEL_CHECKS = { + debug: -> (logger) { logger.debug? }, + info: -> (logger) { logger.info? }, + error: -> (logger) { logger.error? }, + } + + class << self + def event_log_level(method_name, level) + log_levels[method_name.to_s] = level + end + + def logger + @logger || default_logger + end + + def default_logger + raise NotImplementedError + end + + attr_writer :logger + attr_accessor :namespace + + def subscription_filter + namespace = self.namespace.to_s + proc do |event| + name = event[:name] + if (dot_idx = name.index(".")) + event_namespace = name[0, dot_idx] + namespace == event_namespace + end + end + end + end + + class_attribute :log_levels, default: {} # :nodoc: + + def emit(event) + name = event[:name] + event_method = name[name.index(".") + 1, name.length] + public_send(event_method, event) if LEVEL_CHECKS[log_levels[event_method]]&.call(logger) + end + + def logger + self.class.logger + end + + private + def namespace + self.class.namespace + end + end + end +end diff --git a/activesupport/lib/active_support/event_reporter/test_helper.rb b/activesupport/lib/active_support/event_reporter/test_helper.rb new file mode 100644 index 0000000000000..8d317f26c9384 --- /dev/null +++ b/activesupport/lib/active_support/event_reporter/test_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActiveSupport::EventReporter::TestHelper # :nodoc: + class EventSubscriber # :nodoc: + attr_reader :events + + def initialize + @events = [] + end + + def emit(event) + @events << event + end + end + + def event_matcher(name:, payload: nil, tags: {}, context: {}, source_location: nil) + ->(event) { + return false unless event[:name] == name + return false unless event[:payload] == payload + return false unless event[:tags] == tags + return false unless event[:context] == context + + [:filepath, :lineno, :label].each do |key| + if source_location && source_location[key] + return false unless event[:source_location][key] == source_location[key] + end + end + + true + } + end +end diff --git a/activesupport/lib/active_support/evented_file_update_checker.rb b/activesupport/lib/active_support/evented_file_update_checker.rb index 4bfc42d401d4b..f1aa1f4e99d84 100644 --- a/activesupport/lib/active_support/evented_file_update_checker.rb +++ b/activesupport/lib/active_support/evented_file_update_checker.rb @@ -73,9 +73,13 @@ class Core attr_reader :updated, :files def initialize(files, dirs) - @files = files.map { |file| Pathname(file).expand_path }.to_set + gem_paths = Gem.path + files = files.map { |f| Pathname(f).expand_path } + files.reject! { |f| f.to_s.start_with?(*gem_paths) } + @files = files.to_set @dirs = dirs.each_with_object({}) do |(dir, exts), hash| + next if dir.start_with?(*gem_paths) hash[Pathname(dir).expand_path] = Array(exts).map { |ext| ext.to_s.sub(/\A\.?/, ".") }.to_set end diff --git a/activesupport/lib/active_support/execution_context.rb b/activesupport/lib/active_support/execution_context.rb index 1c95188bae5cd..e720b7b397fab 100644 --- a/activesupport/lib/active_support/execution_context.rb +++ b/activesupport/lib/active_support/execution_context.rb @@ -2,8 +2,41 @@ module ActiveSupport module ExecutionContext # :nodoc: + class Record + attr_reader :store, :current_attributes_instances + + def initialize + @store = {} + @current_attributes_instances = {} + @stack = [] + end + + def push + @stack << @store << @current_attributes_instances + @store = {} + @current_attributes_instances = {} + self + end + + def pop + @current_attributes_instances = @stack.pop + @store = @stack.pop + self + end + end + @after_change_callbacks = [] + + # Execution context nesting should only legitimately happen during test + # because the test case itself is wrapped in an executor, and it might call + # into a controller or job which should be executed with their own fresh context. + # However in production this should never happen, and for extra safety we make sure to + # fully clear the state at the end of the request or job cycle. + @nestable = false + class << self + attr_accessor :nestable + def after_change(&block) @after_change_callbacks << block end @@ -14,9 +47,11 @@ def set(**options) options.symbolize_keys! keys = options.keys - store = self.store + store = record.store - previous_context = keys.zip(store.values_at(*keys)).to_h + previous_context = if block_given? + keys.zip(store.values_at(*keys)).to_h + end store.merge!(options) @after_change_callbacks.each(&:call) @@ -32,21 +67,43 @@ def set(**options) end def []=(key, value) - store[key.to_sym] = value + record.store[key.to_sym] = value @after_change_callbacks.each(&:call) end def to_h - store.dup + record.store.dup + end + + def push + if @nestable + record.push + else + clear + end + self + end + + def pop + if @nestable + record.pop + else + clear + end + self end def clear - store.clear + IsolatedExecutionState[:active_support_execution_context] = nil + end + + def current_attributes_instances + record.current_attributes_instances end private - def store - IsolatedExecutionState[:active_support_execution_context] ||= {} + def record + IsolatedExecutionState[:active_support_execution_context] ||= Record.new end end end diff --git a/activesupport/lib/active_support/file_update_checker.rb b/activesupport/lib/active_support/file_update_checker.rb index 94edbfff32578..93acc09cbc2bf 100644 --- a/activesupport/lib/active_support/file_update_checker.rb +++ b/activesupport/lib/active_support/file_update_checker.rb @@ -46,8 +46,11 @@ def initialize(files, dirs = {}, &block) raise ArgumentError, "A block is required to initialize a FileUpdateChecker" end - @files = files.freeze - @globs = compile_glob(dirs) + gem_paths = Gem.path + @files = files.reject { |file| File.expand_path(file).start_with?(*gem_paths) }.freeze + + @globs = compile_glob(dirs)&.reject { |dir| dir.start_with?(*gem_paths) } + @block = block @watched = nil @@ -120,7 +123,7 @@ def updated_at(paths) # healthy to consider this edge case because with mtimes in the future # reloading is not triggered. def max_mtime(paths) - time_now = Time.now + time_now = Time.at(0, Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond), :nanosecond) max_mtime = nil # Time comparisons are performed with #compare_without_coercion because diff --git a/activesupport/lib/active_support/gem_version.rb b/activesupport/lib/active_support/gem_version.rb index e2aa6f7e20ce9..f8622f25faeed 100644 --- a/activesupport/lib/active_support/gem_version.rb +++ b/activesupport/lib/active_support/gem_version.rb @@ -8,7 +8,7 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/activesupport/lib/active_support/gzip.rb b/activesupport/lib/active_support/gzip.rb index f6ecc29c0460e..73e6ef60d4cb7 100644 --- a/activesupport/lib/active_support/gzip.rb +++ b/activesupport/lib/active_support/gzip.rb @@ -32,6 +32,7 @@ def self.decompress(source) def self.compress(source, level = Zlib::DEFAULT_COMPRESSION, strategy = Zlib::DEFAULT_STRATEGY) output = Stream.new gz = Zlib::GzipWriter.new(output, level, strategy) + gz.mtime = 0 gz.write(source) gz.close output.string diff --git a/activesupport/lib/active_support/hash_with_indifferent_access.rb b/activesupport/lib/active_support/hash_with_indifferent_access.rb index 25638d7934617..5558ec946c4dd 100644 --- a/activesupport/lib/active_support/hash_with_indifferent_access.rb +++ b/activesupport/lib/active_support/hash_with_indifferent_access.rb @@ -278,9 +278,7 @@ def fetch_values(*indices, &block) # hash[:a][:c] # => "c" # dup[:a][:c] # => "c" def dup - self.class.new(self).tap do |new_hash| - set_defaults(new_hash) - end + copy_defaults(self.class.new(self)) end # This method has the same semantics of +update+, except it does not @@ -297,13 +295,13 @@ def merge(*hashes, &block) # hash['a'] = nil # hash.reverse_merge(a: 0, b: 1) # => {"a"=>nil, "b"=>1} def reverse_merge(other_hash) - super(self.class.new(other_hash)) + super(cast(other_hash)) end alias_method :with_defaults, :reverse_merge # Same semantics as +reverse_merge+ but modifies the receiver in-place. def reverse_merge!(other_hash) - super(self.class.new(other_hash)) + super(cast(other_hash)) end alias_method :with_defaults!, :reverse_merge! @@ -312,7 +310,7 @@ def reverse_merge!(other_hash) # h = { "a" => 100, "b" => 200 } # h.replace({ "c" => 300, "d" => 400 }) # => {"c"=>300, "d"=>400} def replace(other_hash) - super(self.class.new(other_hash)) + super(cast(other_hash)) end # Removes the specified key from the hash. @@ -354,21 +352,26 @@ def transform_values(&block) NOT_GIVEN = Object.new # :nodoc: def transform_keys(hash = NOT_GIVEN, &block) - return to_enum(:transform_keys) if NOT_GIVEN.equal?(hash) && !block_given? - dup.tap { |h| h.transform_keys!(hash, &block) } + if NOT_GIVEN.equal?(hash) + if block_given? + self.class.new(super(&block)) + else + to_enum(:transform_keys) + end + else + self.class.new(super) + end end def transform_keys!(hash = NOT_GIVEN, &block) - return to_enum(:transform_keys!) if NOT_GIVEN.equal?(hash) && !block_given? - - if hash.nil? - super - elsif NOT_GIVEN.equal?(hash) - keys.each { |key| self[yield(key)] = delete(key) } - elsif block_given? - keys.each { |key| self[hash[key] || yield(key)] = delete(key) } + if NOT_GIVEN.equal?(hash) + if block_given? + replace(copy_defaults(transform_keys(&block))) + else + return to_enum(:transform_keys!) + end else - keys.each { |key| self[hash[key] || key] = delete(key) } + replace(copy_defaults(transform_keys(hash, &block))) end self @@ -392,8 +395,7 @@ def compact def to_hash copy = Hash[self] copy.transform_values! { |v| convert_value_to_hash(v) } - set_defaults(copy) - copy + copy_defaults(copy) end def to_proc @@ -401,6 +403,10 @@ def to_proc end private + def cast(other) + self.class === other ? other : self.class.new(other) + end + def convert_key(key) Symbol === key ? key.name : key end @@ -429,12 +435,13 @@ def convert_value_to_hash(value) end - def set_defaults(target) + def copy_defaults(target) if default_proc target.default_proc = default_proc.dup else target.default = default end + target end def update_with_single_argument(other_hash, block) diff --git a/activesupport/lib/active_support/i18n_railtie.rb b/activesupport/lib/active_support/i18n_railtie.rb index de2ac0780f12b..ed32655076e97 100644 --- a/activesupport/lib/active_support/i18n_railtie.rb +++ b/activesupport/lib/active_support/i18n_railtie.rb @@ -2,6 +2,7 @@ require "active_support" require "active_support/core_ext/array/wrap" +require "rails/railtie" # :enddoc: @@ -66,8 +67,7 @@ def self.initialize_i18n(app) if app.config.reloading_enabled? directories = watched_dirs_with_extensions(reloadable_paths) - root_load_paths = I18n.load_path.select { |path| path.to_s.start_with?(Rails.root.to_s) } - reloader = app.config.file_watcher.new(root_load_paths, directories) do + reloader = app.config.file_watcher.new(I18n.load_path, directories) do I18n.load_path.delete_if { |path| path.to_s.start_with?(Rails.root.to_s) && !File.exist?(path) } I18n.load_path |= reloadable_paths.flat_map(&:existent) end diff --git a/activesupport/lib/active_support/inflector/inflections.rb b/activesupport/lib/active_support/inflector/inflections.rb index 5a1b64a5e6279..24d0e6e241906 100644 --- a/activesupport/lib/active_support/inflector/inflections.rb +++ b/activesupport/lib/active_support/inflector/inflections.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "concurrent/map" +require "active_support/core_ext/module/delegation" require "active_support/i18n" module ActiveSupport @@ -29,44 +30,59 @@ module Inflector # before any of the rules that may already have been loaded. class Inflections @__instance__ = Concurrent::Map.new + @__en_instance__ = nil + + class Uncountables # :nodoc: + include Enumerable + + delegate :each, :pop, :empty?, :to_s, :==, :to_a, :to_ary, to: :@members - class Uncountables < Array def initialize - @regex_array = [] - super + @members = [] + @pattern = nil end def delete(entry) - super entry - @regex_array.delete(to_regex(entry)) + @members.delete(entry) + @pattern = nil + end + + def <<(word) + word = word.downcase + @members << word + @pattern = nil + self end - def <<(*word) - add(word) + def flatten + @members.dup end def add(words) words = words.flatten.map(&:downcase) - concat(words) - @regex_array += words.map { |word| to_regex(word) } + @members.concat(words) + @pattern = nil self end def uncountable?(str) - @regex_array.any? { |regex| regex.match? str } - end - - private - def to_regex(string) - /\b#{::Regexp.escape(string)}\Z/i + if @pattern.nil? + members_pattern = Regexp.union(@members.map { |w| /#{Regexp.escape(w)}/i }) + @pattern = /\b#{members_pattern}\Z/i end + @pattern.match?(str) + end end def self.instance(locale = :en) + return @__en_instance__ ||= new if locale == :en + @__instance__[locale] ||= new end def self.instance_or_fallback(locale) + return @__en_instance__ ||= new if locale == :en + I18n.fallbacks[locale].each do |k| return @__instance__[k] if @__instance__.key?(k) end diff --git a/activesupport/lib/active_support/inflector/methods.rb b/activesupport/lib/active_support/inflector/methods.rb index e61afcf5d3659..e9fd05ae3bdf9 100644 --- a/activesupport/lib/active_support/inflector/methods.rb +++ b/activesupport/lib/active_support/inflector/methods.rb @@ -143,13 +143,13 @@ def humanize(lower_case_and_underscored_word, capitalize: true, keep_id_suffix: result.delete_suffix!(" id") end - result.gsub!(/([a-z\d]+)/i) do |match| + result.gsub!(/([[[:alpha:]]\d]+)/i) do |match| match.downcase! inflections.acronyms[match] || match end if capitalize - result.sub!(/\A\w/) do |match| + result.sub!(/\A[[:alpha:]]/) do |match| match.upcase! match end diff --git a/activesupport/lib/active_support/isolated_execution_state.rb b/activesupport/lib/active_support/isolated_execution_state.rb index 77f6822d5cc62..709aa8b457804 100644 --- a/activesupport/lib/active_support/isolated_execution_state.rb +++ b/activesupport/lib/active_support/isolated_execution_state.rb @@ -28,10 +28,6 @@ def isolation_level=(level) @isolation_level = level end - def unique_id - self[:__id__] ||= Object.new - end - def [](key) if state = @scope.current.active_support_execution_state state[key] @@ -59,11 +55,14 @@ def context scope.current end - def share_with(other) + def share_with(other, &block) # Action Controller streaming spawns a new thread and copy thread locals. # We do the same here for backward compatibility, but this is very much a hack # and streaming should be rethought. - context.active_support_execution_state = other.active_support_execution_state.dup + old_state, context.active_support_execution_state = context.active_support_execution_state, other.active_support_execution_state.dup + block.call + ensure + context.active_support_execution_state = old_state end end diff --git a/activesupport/lib/active_support/json/decoding.rb b/activesupport/lib/active_support/json/decoding.rb index e568d61c99a0d..75605eb31ff88 100644 --- a/activesupport/lib/active_support/json/decoding.rb +++ b/activesupport/lib/active_support/json/decoding.rb @@ -14,11 +14,13 @@ module JSON DATETIME_REGEX = /\A(?:\d{4}-\d{2}-\d{2}|\d{4}-\d{1,2}-\d{1,2}[T \t]+\d{1,2}:\d{2}:\d{2}(\.[0-9]*)?(([ \t]*)Z|[-+]\d{2}?(:\d{2})?)?)\z/ class << self - # Parses a JSON string (JavaScript Object Notation) into a hash. + # Parses a JSON string (JavaScript Object Notation) into a Ruby object. # See http://www.json.org for more info. # # ActiveSupport::JSON.decode("{\"team\":\"rails\",\"players\":\"36\"}") - # => {"team" => "rails", "players" => "36"} + # # => {"team" => "rails", "players" => "36"} + # ActiveSupport::JSON.decode("2.39") + # # => 2.39 def decode(json, options = {}) data = ::JSON.parse(json, options) diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb index 550ae0137e8ee..d9826dee4933d 100644 --- a/activesupport/lib/active_support/json/encoding.rb +++ b/activesupport/lib/active_support/json/encoding.rb @@ -8,6 +8,7 @@ class << self delegate :use_standard_json_time_format, :use_standard_json_time_format=, :time_precision, :time_precision=, :escape_html_entities_in_json, :escape_html_entities_in_json=, + :escape_js_separators_in_json, :escape_js_separators_in_json=, :json_encoder, :json_encoder=, to: :'ActiveSupport::JSON::Encoding' end @@ -20,8 +21,8 @@ class << self # ActiveSupport::JSON.encode({ team: 'rails', players: '36' }) # # => "{\"team\":\"rails\",\"players\":\"36\"}" # - # Generates JSON that is safe to include in JavaScript as it escapes - # U+2028 (Line Separator) and U+2029 (Paragraph Separator): + # By default, it generates JSON that is safe to include in JavaScript, as + # it escapes U+2028 (Line Separator) and U+2029 (Paragraph Separator): # # ActiveSupport::JSON.encode({ key: "\u2028" }) # # => "{\"key\":\"\\u2028\"}" @@ -32,14 +33,22 @@ class << self # ActiveSupport::JSON.encode({ key: "<>&" }) # # => "{\"key\":\"\\u003c\\u003e\\u0026\"}" # - # This can be changed with the +escape_html_entities+ option, or the + # This behavior can be changed with the +escape_html_entities+ option, or the # global escape_html_entities_in_json configuration option. # # ActiveSupport::JSON.encode({ key: "<>&" }, escape_html_entities: false) # # => "{\"key\":\"<>&\"}" + # + # For performance reasons, you can set the +escape+ option to false, + # which will skip all escaping: + # + # ActiveSupport::JSON.encode({ key: "\u2028<>&" }, escape: false) + # # => "{\"key\":\"\u2028<>&\"}" def encode(value, options = nil) - if options.nil? + if options.nil? || options.empty? Encoding.encode_without_options(value) + elsif options == { escape: false }.freeze + Encoding.encode_without_escape(value) else Encoding.json_encoder.new(options).encode(value) end @@ -59,8 +68,9 @@ module Encoding # :nodoc: "&".b => '\u0026'.b, } - ESCAPE_REGEX_WITH_HTML_ENTITIES = Regexp.union(*ESCAPED_CHARS.keys) - ESCAPE_REGEX_WITHOUT_HTML_ENTITIES = Regexp.union(U2028, U2029) + HTML_ENTITIES_REGEX = Regexp.union(*(ESCAPED_CHARS.keys - [U2028, U2029])) + FULL_ESCAPE_REGEX = Regexp.union(*ESCAPED_CHARS.keys) + JS_SEPARATORS_REGEX = Regexp.union(U2028, U2029) class JSONGemEncoder # :nodoc: attr_reader :options @@ -76,14 +86,17 @@ def encode(value) end json = stringify(jsonify(value)) - # Rails does more escaping than the JSON gem natively does (we - # escape \u2028 and \u2029 and optionally >, <, & to work around - # certain browser problems). + return json unless @options.fetch(:escape, true) + json.force_encoding(::Encoding::BINARY) if @options.fetch(:escape_html_entities, Encoding.escape_html_entities_in_json) - json.gsub!(ESCAPE_REGEX_WITH_HTML_ENTITIES, ESCAPED_CHARS) - else - json.gsub!(ESCAPE_REGEX_WITHOUT_HTML_ENTITIES, ESCAPED_CHARS) + if Encoding.escape_js_separators_in_json + json.gsub!(FULL_ESCAPE_REGEX, ESCAPED_CHARS) + else + json.gsub!(HTML_ENTITIES_REGEX, ESCAPED_CHARS) + end + elsif Encoding.escape_js_separators_in_json + json.gsub!(JS_SEPARATORS_REGEX, ESCAPED_CHARS) end json.force_encoding(::Encoding::UTF_8) end @@ -132,11 +145,14 @@ def stringify(jsonified) end end - if defined?(::JSON::Coder) + # ruby/json 2.14.x yields non-String keys but doesn't let us know it's a key + if defined?(::JSON::Coder) && Gem::Version.new(::JSON::VERSION) >= Gem::Version.new("2.15.2") class JSONGemCoderEncoder # :nodoc: JSON_NATIVE_TYPES = [Hash, Array, Float, String, Symbol, Integer, NilClass, TrueClass, FalseClass, ::JSON::Fragment].freeze - CODER = ::JSON::Coder.new do |value| + CODER = ::JSON::Coder.new do |value, is_key| json_value = value.as_json + # Keep compatibility by calling to_s on non-String keys + next json_value.to_s if is_key # Handle objects returning self from as_json if json_value.equal?(value) next ::JSON::Fragment.new(::JSON.generate(json_value)) @@ -153,7 +169,14 @@ class JSONGemCoderEncoder # :nodoc: def initialize(options = nil) - @options = options ? options.dup.freeze : {}.freeze + if options + options = options.dup + @escape = options.delete(:escape) { true } + @options = options.freeze + else + @escape = true + @options = {}.freeze + end end # Encode the given object into a JSON string @@ -162,14 +185,17 @@ def encode(value) json = CODER.dump(value) - # Rails does more escaping than the JSON gem natively does (we - # escape \u2028 and \u2029 and optionally >, <, & to work around - # certain browser problems). + return json unless @escape + json.force_encoding(::Encoding::BINARY) if @options.fetch(:escape_html_entities, Encoding.escape_html_entities_in_json) - json.gsub!(ESCAPE_REGEX_WITH_HTML_ENTITIES, ESCAPED_CHARS) - else - json.gsub!(ESCAPE_REGEX_WITHOUT_HTML_ENTITIES, ESCAPED_CHARS) + if Encoding.escape_js_separators_in_json + json.gsub!(FULL_ESCAPE_REGEX, ESCAPED_CHARS) + else + json.gsub!(HTML_ENTITIES_REGEX, ESCAPED_CHARS) + end + elsif Encoding.escape_js_separators_in_json + json.gsub!(JS_SEPARATORS_REGEX, ESCAPED_CHARS) end json.force_encoding(::Encoding::UTF_8) end @@ -185,6 +211,13 @@ class << self # as a safety measure. attr_accessor :escape_html_entities_in_json + # If true, encode LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) + # as escaped unicode sequences ('\u2028' and '\u2029'). + # Historically these characters were not valid inside JavaScript strings + # but that changed in ECMAScript 2019. As such it's no longer a concern in + # modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset. + attr_accessor :escape_js_separators_in_json + # Sets the precision of encoded time values. # Defaults to 3 (equivalent to millisecond precision) attr_accessor :time_precision @@ -196,17 +229,23 @@ class << self def json_encoder=(encoder) @json_encoder = encoder @encoder_without_options = encoder.new + @encoder_without_escape = encoder.new(escape: false) end def encode_without_options(value) # :nodoc: @encoder_without_options.encode(value) end + + def encode_without_escape(value) # :nodoc: + @encoder_without_escape.encode(value) + end end self.use_standard_json_time_format = true self.escape_html_entities_in_json = true + self.escape_js_separators_in_json = true self.json_encoder = - if defined?(::JSON::Coder) + if defined?(JSONGemCoderEncoder) JSONGemCoderEncoder else JSONGemEncoder diff --git a/activesupport/lib/active_support/lazy_load_hooks.rb b/activesupport/lib/active_support/lazy_load_hooks.rb index 399071565631f..456e7be84f3da 100644 --- a/activesupport/lib/active_support/lazy_load_hooks.rb +++ b/activesupport/lib/active_support/lazy_load_hooks.rb @@ -53,7 +53,7 @@ def self.extended(base) # :nodoc: # loaded. If the component has already loaded, the block is executed # immediately. # - # Options: + # ==== Options # # * :yield - Yields the object that run_load_hooks to +block+. # * :run_once - Given +block+ will run only once. diff --git a/activesupport/lib/active_support/log_subscriber.rb b/activesupport/lib/active_support/log_subscriber.rb index 4e0db5225c457..729ddec619593 100644 --- a/activesupport/lib/active_support/log_subscriber.rb +++ b/activesupport/lib/active_support/log_subscriber.rb @@ -62,25 +62,8 @@ module ActiveSupport # that all logs are flushed, and it is called in Rails::Rack::Logger after a # request finishes. class LogSubscriber < Subscriber - # ANSI sequence modes - MODES = { - clear: 0, - bold: 1, - italic: 3, - underline: 4, - } + include ColorizeLogging - # ANSI sequence colors - BLACK = "\e[30m" - RED = "\e[31m" - GREEN = "\e[32m" - YELLOW = "\e[33m" - BLUE = "\e[34m" - MAGENTA = "\e[35m" - CYAN = "\e[36m" - WHITE = "\e[37m" - - mattr_accessor :colorize_logging, default: true class_attribute :log_levels, instance_accessor: false, default: {} # :nodoc: LEVEL_CHECKS = { @@ -96,14 +79,14 @@ def logger end end + attr_writer :logger + def attach_to(...) # :nodoc: result = super set_event_levels result end - attr_writer :logger - def log_subscribers subscribers end @@ -130,15 +113,15 @@ def subscribe_log_level(method, level) end end + def logger + LogSubscriber.logger + end + def initialize super @event_levels = {} end - def logger - LogSubscriber.logger - end - def silenced?(event) logger.nil? || @event_levels[event]&.call(logger) end @@ -149,40 +132,9 @@ def call(event) log_exception(event.name, e) end - def publish_event(event) - super if logger - rescue => e - log_exception(event.name, e) - end - attr_writer :event_levels # :nodoc: private - %w(info debug warn error fatal unknown).each do |level| - class_eval <<-METHOD, __FILE__, __LINE__ + 1 - def #{level}(progname = nil, &block) - logger.#{level}(progname, &block) if logger - end - METHOD - end - - # Set color by using a symbol or one of the defined constants. Set modes - # by specifying bold, italic, or underline options. Inspired by Highline, - # this method will automatically clear formatting at the end of the returned String. - def color(text, color, mode_options = {}) # :doc: - return text unless colorize_logging - color = self.class.const_get(color.upcase) if color.is_a?(Symbol) - mode = mode_from(mode_options) - clear = "\e[#{MODES[:clear]}m" - "#{mode}#{color}#{text}#{clear}" - end - - def mode_from(options) - modes = MODES.values_at(*options.compact_blank.keys) - - "\e[#{modes.join(";")}m" if modes.any? - end - def log_exception(name, e) ActiveSupport.error_reporter.report(e, source: name) diff --git a/activesupport/lib/active_support/log_subscriber/test_helper.rb b/activesupport/lib/active_support/log_subscriber/test_helper.rb index b528a7fc10f5f..85d45e1883624 100644 --- a/activesupport/lib/active_support/log_subscriber/test_helper.rb +++ b/activesupport/lib/active_support/log_subscriber/test_helper.rb @@ -39,7 +39,7 @@ def setup # :nodoc: @logger = MockLogger.new @notifier = ActiveSupport::Notifications::Fanout.new - ActiveSupport::LogSubscriber.colorize_logging = false + ActiveSupport.colorize_logging = false @old_notifier = ActiveSupport::Notifications.notifier set_logger(@logger) @@ -80,11 +80,11 @@ def flush end ActiveSupport::Logger::Severity.constants.each do |severity| - class_eval <<-EOT, __FILE__, __LINE__ + 1 + class_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{severity.downcase}? #{severity} >= @level end - EOT + RUBY end end diff --git a/activesupport/lib/active_support/logger_thread_safe_level.rb b/activesupport/lib/active_support/logger_thread_safe_level.rb index 39d6fe9d2c77e..71d32d5978733 100644 --- a/activesupport/lib/active_support/logger_thread_safe_level.rb +++ b/activesupport/lib/active_support/logger_thread_safe_level.rb @@ -7,6 +7,11 @@ module ActiveSupport module LoggerThreadSafeLevel # :nodoc: extend ActiveSupport::Concern + def initialize(...) + super + @local_level_key = :"logger_thread_safe_level_#{object_id}" + end + def local_level IsolatedExecutionState[local_level_key] end @@ -40,8 +45,6 @@ def log_at(level) end private - def local_level_key - @local_level_key ||= :"logger_thread_safe_level_#{object_id}" - end + attr_reader :local_level_key end end diff --git a/activesupport/lib/active_support/messages/rotator.rb b/activesupport/lib/active_support/messages/rotator.rb index d54a11e4ed9e2..31cfad9371ce6 100644 --- a/activesupport/lib/active_support/messages/rotator.rb +++ b/activesupport/lib/active_support/messages/rotator.rb @@ -45,6 +45,11 @@ def read_message(message, on_rotation: @on_rotation, **options) end end + def initialize_dup(*) + super + @rotations = @rotations.dup + end + private def build_rotation(*args, **options) self.class.new(*args, *@args.drop(args.length), **@options, **options) diff --git a/activesupport/lib/active_support/notifications/fanout.rb b/activesupport/lib/active_support/notifications/fanout.rb index ecf4b7c97b439..4df967158f385 100644 --- a/activesupport/lib/active_support/notifications/fanout.rb +++ b/activesupport/lib/active_support/notifications/fanout.rb @@ -17,24 +17,30 @@ def initialize(exceptions) module FanoutIteration # :nodoc: private - def iterate_guarding_exceptions(collection) - exceptions = nil - - collection.each do |s| - yield s - rescue Exception => e - exceptions ||= [] - exceptions << e - end + def iterate_guarding_exceptions(collection, &block) + case collection.size + when 0 + when 1 + collection.each(&block) + else + exceptions = nil - if exceptions - exceptions = exceptions.flat_map do |exception| - exception.is_a?(InstrumentationSubscriberError) ? exception.exceptions : [exception] + collection.each do |s| + yield s + rescue Exception => e + exceptions ||= [] + exceptions << e end - if exceptions.size == 1 - raise exceptions.first - else - raise InstrumentationSubscriberError.new(exceptions), cause: exceptions.first + + if exceptions + exceptions = exceptions.flat_map do |exception| + exception.is_a?(InstrumentationSubscriberError) ? exception.exceptions : [exception] + end + if exceptions.size == 1 + raise exceptions.first + else + raise InstrumentationSubscriberError.new(exceptions), cause: exceptions.first + end end end @@ -53,7 +59,6 @@ def initialize @other_subscribers = [] @all_listeners_for = Concurrent::Map.new @groups_for = Concurrent::Map.new - @silenceable_groups_for = Concurrent::Map.new end def inspect # :nodoc: @@ -102,11 +107,9 @@ def clear_cache(key = nil) # :nodoc: if key @all_listeners_for.delete(key) @groups_for.delete(key) - @silenceable_groups_for.delete(key) else @all_listeners_for.clear @groups_for.clear - @silenceable_groups_for.clear end end @@ -184,25 +187,25 @@ def build_event(name, id, payload) end end - def groups_for(name) # :nodoc: - groups = @groups_for.compute_if_absent(name) do - all_listeners_for(name).reject(&:silenceable).group_by(&:group_class).transform_values do |s| - s.map(&:delegate) - end - end + def group_listeners(listeners) # :nodoc: + listeners.group_by(&:group_class).transform_values do |s| + s.map(&:delegate).freeze + end.freeze + end - silenceable_groups = @silenceable_groups_for.compute_if_absent(name) do - all_listeners_for(name).select(&:silenceable).group_by(&:group_class).transform_values do |s| - s.map(&:delegate) - end + def groups_for(name) # :nodoc: + silenceable_groups, groups = @groups_for.compute_if_absent(name) do + listeners = all_listeners_for(name) + listeners.partition(&:silenceable).map { |l| group_listeners(l) } end unless silenceable_groups.empty? - groups = groups.dup silenceable_groups.each do |group_class, subscriptions| active_subscriptions = subscriptions.reject { |s| s.silenced?(name) } unless active_subscriptions.empty? - groups[group_class] = (groups[group_class] || []) + active_subscriptions + groups = groups.dup if groups.frozen? + base_groups = groups[group_class] + groups[group_class] = base_groups ? base_groups + active_subscriptions : active_subscriptions end end end @@ -227,13 +230,11 @@ def groups_for(name) # :nodoc: class Handle include FanoutIteration - def initialize(notifier, name, id, payload) # :nodoc: + def initialize(notifier, name, id, groups, payload) # :nodoc: @name = name @id = id @payload = payload - @groups = notifier.groups_for(name).map do |group_klass, grouped_listeners| - group_klass.new(grouped_listeners, name, id, payload) - end + @groups = groups @state = :initialized end @@ -267,10 +268,31 @@ def ensure_state!(expected) end end + module NullHandle # :nodoc: + extend self + + def start + end + + def finish + end + + def finish_with_values(_name, _id, _payload) + end + end + include FanoutIteration def build_handle(name, id, payload) - Handle.new(self, name, id, payload) + groups = groups_for(name).map do |group_klass, grouped_listeners| + group_klass.new(grouped_listeners, name, id, payload) + end + + if groups.empty? + NullHandle + else + Handle.new(self, name, id, groups, payload) + end end def start(name, id, payload) @@ -286,8 +308,8 @@ def finish(name, id, payload, listeners = nil) handle.finish_with_values(name, id, payload) end - def publish(name, *args) - iterate_guarding_exceptions(listeners_for(name)) { |s| s.publish(name, *args) } + def publish(name, ...) + iterate_guarding_exceptions(listeners_for(name)) { |s| s.publish(name, ...) } end def publish_event(event) @@ -387,9 +409,9 @@ def group_class EventedGroup end - def publish(name, *args) + def publish(...) if @can_publish - @delegate.publish name, *args + @delegate.publish(...) end end @@ -419,8 +441,8 @@ def group_class TimedGroup end - def publish(name, *args) - @delegate.call name, *args + def publish(...) + @delegate.call(...) end end diff --git a/activesupport/lib/active_support/notifications/instrumenter.rb b/activesupport/lib/active_support/notifications/instrumenter.rb index 6143d1b83d4de..140685cb2fcf0 100644 --- a/activesupport/lib/active_support/notifications/instrumenter.rb +++ b/activesupport/lib/active_support/notifications/instrumenter.rb @@ -164,7 +164,7 @@ def cpu_time @cpu_time_finish - @cpu_time_start end - # Returns the idle time time (in milliseconds) passed between the call to + # Returns the idle time (in milliseconds) passed between the call to # #start! and the call to #finish!. def idle_time diff = duration - cpu_time diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb index e5d89caf18ee2..fbe9ff63a95bb 100644 --- a/activesupport/lib/active_support/railtie.rb +++ b/activesupport/lib/active_support/railtie.rb @@ -15,7 +15,7 @@ class Railtie < Rails::Railtie # :nodoc: initializer "active_support.isolation_level" do |app| config.after_initialize do - if level = app.config.active_support.delete(:isolation_level) + if level = app.config.active_support.isolation_level ActiveSupport::IsolatedExecutionState.isolation_level = level end end @@ -38,19 +38,35 @@ class Railtie < Rails::Railtie # :nodoc: end end - initializer "active_support.reset_execution_context" do |app| - app.reloader.before_class_unload { ActiveSupport::ExecutionContext.clear } - app.executor.to_run { ActiveSupport::ExecutionContext.clear } - app.executor.to_complete { ActiveSupport::ExecutionContext.clear } + initializer "active_support.set_event_reporter_context_store" do |app| + config.after_initialize do + if klass = app.config.active_support.event_reporter_context_store + ActiveSupport::EventReporter.context_store = klass + end + end end - initializer "active_support.reset_all_current_attributes_instances" do |app| - app.reloader.before_class_unload { ActiveSupport::CurrentAttributes.clear_all } - app.executor.to_run { ActiveSupport::CurrentAttributes.reset_all } - app.executor.to_complete { ActiveSupport::CurrentAttributes.reset_all } + initializer "active_support.reset_execution_context" do |app| + app.reloader.before_class_unload do + ActiveSupport::CurrentAttributes.clear_all + ActiveSupport::ExecutionContext.clear + ActiveSupport.event_reporter.clear_context + end + + app.executor.to_run do + ActiveSupport::ExecutionContext.push + end + + app.executor.to_complete do + ActiveSupport::CurrentAttributes.clear_all + ActiveSupport::ExecutionContext.pop + ActiveSupport.event_reporter.clear_context + end ActiveSupport.on_load(:active_support_test_case) do if app.config.active_support.executor_around_test_case + ActiveSupport::ExecutionContext.nestable = true + require "active_support/executor/test_helper" include ActiveSupport::Executor::TestHelper else @@ -63,6 +79,13 @@ class Railtie < Rails::Railtie # :nodoc: end end + initializer "active_support.set_filter_parameters" do |app| + config.after_initialize do + ActiveSupport.filter_parameters += Rails.application.config.filter_parameters + ActiveSupport.event_reporter.reload_payload_filter + end + end + initializer "active_support.deprecation_behavior" do |app| if app.config.active_support.report_deprecations == false app.deprecators.silenced = true @@ -96,10 +119,6 @@ class Railtie < Rails::Railtie # :nodoc: config.eager_load_namespaces << TZInfo end - initializer "active_support.to_time_preserves_timezone" do |app| - ActiveSupport.to_time_preserves_timezone = app.config.active_support.to_time_preserves_timezone - end - # Sets the default week start # If assigned value is not a valid day symbol (e.g. :sunday, :monday, ...), an exception will be raised. initializer "active_support.initialize_beginning_of_week" do |app| diff --git a/activesupport/lib/active_support/structured_event_subscriber.rb b/activesupport/lib/active_support/structured_event_subscriber.rb new file mode 100644 index 0000000000000..513919d5dd534 --- /dev/null +++ b/activesupport/lib/active_support/structured_event_subscriber.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "active_support/subscriber" + +module ActiveSupport + # = Active Support Structured Event \Subscriber + # + # +ActiveSupport::StructuredEventSubscriber+ consumes ActiveSupport::Notifications + # in order to emit structured events via +Rails.event+. + # + # An example would be the Action Controller structured event subscriber, responsible for + # emitting request processing events: + # + # module ActionController + # class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber + # attach_to :action_controller + # + # def start_processing(event) + # emit_event("controller.request_started", + # controller: event.payload[:controller], + # action: event.payload[:action], + # format: event.payload[:format] + # ) + # end + # end + # end + # + # After configured, whenever a "start_processing.action_controller" notification is published, + # it will properly dispatch the event (+ActiveSupport::Notifications::Event+) to the +start_processing+ method. + # The subscriber can then emit a structured event via the +emit_event+ method. + class StructuredEventSubscriber < Subscriber + class_attribute :debug_methods, instance_accessor: false, default: [] # :nodoc: + + DEBUG_CHECK = proc { !ActiveSupport.event_reporter.debug_mode? } + + class << self + def attach_to(...) # :nodoc: + result = super + set_silenced_events + result + end + + private + def set_silenced_events + if subscriber + subscriber.silenced_events = debug_methods.to_h { |method| ["#{method}.#{namespace}", DEBUG_CHECK] } + end + end + + def debug_only(method) + self.debug_methods << method + set_silenced_events + end + end + + def initialize + super + @silenced_events = {} + end + + def silenced?(event) + ActiveSupport.event_reporter.subscribers.none? || @silenced_events[event]&.call + end + + attr_writer :silenced_events # :nodoc: + + # Emit a structured event via Rails.event.notify. + # + # ==== Arguments + # + # * +name+ - The event name as a string or symbol + # * +payload+ - The event payload as a hash or object + # * +caller_depth+ - Stack depth for source location (default: 1) + # * +kwargs+ - Additional payload data merged with the payload hash + def emit_event(name, payload = nil, caller_depth: 1, **kwargs) + ActiveSupport.event_reporter.notify(name, payload, caller_depth: caller_depth + 1, **kwargs) + rescue => e + handle_event_error(name, e) + end + + # Like +emit_event+, but only emits when the event reporter is in debug mode + def emit_debug_event(name, payload = nil, caller_depth: 1, **kwargs) + ActiveSupport.event_reporter.debug(name, payload, caller_depth: caller_depth + 1, **kwargs) + rescue => e + handle_event_error(name, e) + end + + def call(event) + super + rescue => e + handle_event_error(event.name, e) + end + + private + def handle_event_error(name, error) + ActiveSupport.error_reporter.report(error, source: name) + end + end +end diff --git a/activesupport/lib/active_support/subscriber.rb b/activesupport/lib/active_support/subscriber.rb index 19c28906abdce..cf0ed610eada0 100644 --- a/activesupport/lib/active_support/subscriber.rb +++ b/activesupport/lib/active_support/subscriber.rb @@ -137,10 +137,5 @@ def call(event) method = event.name[0, event.name.index(".")] send(method, event) end - - def publish_event(event) # :nodoc: - method = event.name[0, event.name.index(".")] - send(method, event) - end end end diff --git a/activesupport/lib/active_support/syntax_error_proxy.rb b/activesupport/lib/active_support/syntax_error_proxy.rb index 75d5879f51971..08b125553a250 100644 --- a/activesupport/lib/active_support/syntax_error_proxy.rb +++ b/activesupport/lib/active_support/syntax_error_proxy.rb @@ -7,7 +7,7 @@ module ActiveSupport # is to enhance the backtraces on SyntaxError exceptions to include the # source location of the syntax error. That way we can display the error # source on error pages in development. - class SyntaxErrorProxy < DelegateClass(SyntaxError) # :nodoc: + class SyntaxErrorProxy < ActiveSupport::Delegation::DelegateClass(SyntaxError) # :nodoc: def backtrace parse_message_for_trace + super end @@ -21,9 +21,13 @@ def label def base_label end + + def absolute_path + path + end end - class BacktraceLocationProxy < DelegateClass(Thread::Backtrace::Location) # :nodoc: + class BacktraceLocationProxy < ActiveSupport::Delegation::DelegateClass(Thread::Backtrace::Location) # :nodoc: def initialize(loc, ex) super(loc) @ex = ex diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb index 8554a09ed474f..58f59d8d97292 100644 --- a/activesupport/lib/active_support/test_case.rb +++ b/activesupport/lib/active_support/test_case.rb @@ -6,6 +6,7 @@ require "active_support/testing/tests_without_assertions" require "active_support/testing/assertions" require "active_support/testing/error_reporter_assertions" +require "active_support/testing/event_reporter_assertions" require "active_support/testing/deprecation" require "active_support/testing/declarative" require "active_support/testing/isolation" @@ -22,7 +23,22 @@ module ActiveSupport class TestCase < ::Minitest::Test Assertion = Minitest::Assertion + # Class variable to store the parallel worker ID + @@parallel_worker_id = nil + class << self + # Returns the current parallel worker ID if tests are running in parallel, + # nil otherwise. + # + # ActiveSupport::TestCase.parallel_worker_id # => 2 + def parallel_worker_id + @@parallel_worker_id + end + + def parallel_worker_id=(value) # :nodoc: + @@parallel_worker_id = value + end + # Sets the order in which test cases are run. # # ActiveSupport::TestCase.test_order = :random # => :random @@ -79,7 +95,16 @@ def test_order # Because parallelization presents an overhead, it is only enabled when the # number of tests to run is above the +threshold+ param. The default value is # 50, and it's configurable via +config.active_support.test_parallelization_threshold+. - def parallelize(workers: :number_of_processors, with: :processes, threshold: ActiveSupport.test_parallelization_threshold) + # + # If you want to skip Rails default creation of one database per process in favor of + # writing your own implementation, you can set +parallelize_databases+, or configure it + # via +config.active_support.parallelize_test_databases+. + # + # parallelize(workers: :number_of_processors, parallelize_databases: false) + # + # Note that your test suite may deadlock if you attempt to use only one database + # with multiple processes. + def parallelize(workers: :number_of_processors, with: :processes, threshold: ActiveSupport.test_parallelization_threshold, parallelize_databases: ActiveSupport.parallelize_test_databases) case when ENV["PARALLEL_WORKERS"] workers = ENV["PARALLEL_WORKERS"].to_i @@ -87,10 +112,28 @@ def parallelize(workers: :number_of_processors, with: :processes, threshold: Act workers = (Concurrent.available_processor_count || Concurrent.processor_count).floor end + if with == :processes + ActiveSupport.parallelize_test_databases = parallelize_databases + end + Minitest.parallel_executor = ActiveSupport::Testing::ParallelizeExecutor.new(size: workers, with: with, threshold: threshold) end - # Set up hook for parallel testing. This can be used if you have multiple + # Before fork hook for parallel testing. This can be used to run anything + # before the processes are forked. + # + # In your +test_helper.rb+ add the following: + # + # class ActiveSupport::TestCase + # parallelize_before_fork do + # # run this before fork + # end + # end + def parallelize_before_fork(&block) + ActiveSupport::Testing::Parallelization.before_fork_hook(&block) + end + + # Setup hook for parallel testing. This can be used if you have multiple # databases or any behavior that needs to be run after the process is forked # but before the tests run. # @@ -146,11 +189,17 @@ def parallelize_teardown(&block) alias_method :method_name, :name + # Returns the current parallel worker ID if tests are running in parallel + def parallel_worker_id + self.class.parallel_worker_id + end + include ActiveSupport::Testing::TaggedLogging prepend ActiveSupport::Testing::SetupAndTeardown prepend ActiveSupport::Testing::TestsWithoutAssertions include ActiveSupport::Testing::Assertions include ActiveSupport::Testing::ErrorReporterAssertions + include ActiveSupport::Testing::EventReporterAssertions include ActiveSupport::Testing::NotificationAssertions include ActiveSupport::Testing::Deprecation include ActiveSupport::Testing::ConstantStubbing diff --git a/activesupport/lib/active_support/testing/assertions.rb b/activesupport/lib/active_support/testing/assertions.rb index 49139c45f1e2c..178b5b350abba 100644 --- a/activesupport/lib/active_support/testing/assertions.rb +++ b/activesupport/lib/active_support/testing/assertions.rb @@ -71,19 +71,19 @@ def assert_nothing_raised # post :delete, params: { id: ... } # end # - # An array of expressions can also be passed in and evaluated. + # An array of expressions can be passed in and evaluated. # # assert_difference [ 'Article.count', 'Post.count' ], 2 do # post :create, params: { article: {...} } # end # - # A hash of expressions/numeric differences can also be passed in and evaluated. + # A hash of expressions/numeric differences can be passed in and evaluated. # - # assert_difference ->{ Article.count } => 1, ->{ Notification.count } => 2 do + # assert_difference({ 'Article.count' => 1, 'Notification.count' => 2 }) do # post :create, params: { article: {...} } # end # - # A lambda or a list of lambdas can be passed in and evaluated: + # A lambda, a list of lambdas or a hash of lambdas/numeric differences can be passed in and evaluated: # # assert_difference ->{ Article.count }, 2 do # post :create, params: { article: {...} } @@ -93,6 +93,10 @@ def assert_nothing_raised # post :create, params: { article: {...} } # end # + # assert_difference ->{ Article.count } => 1, ->{ Notification.count } => 2 do + # post :create, params: { article: {...} } + # end + # # An error message can be specified. # # assert_difference 'Article.count', -1, 'An Article should be destroyed' do diff --git a/activesupport/lib/active_support/testing/error_reporter_assertions.rb b/activesupport/lib/active_support/testing/error_reporter_assertions.rb index 41b7a97a79acc..28b6d07859a87 100644 --- a/activesupport/lib/active_support/testing/error_reporter_assertions.rb +++ b/activesupport/lib/active_support/testing/error_reporter_assertions.rb @@ -102,6 +102,23 @@ def assert_error_reported(error_class = StandardError, &block) assert(false, message) end end + + # Captures reported errors from within the block that match the given + # error class. + # + # reports = capture_error_reports(IOError) do + # Rails.error.report(IOError.new("Oops")) + # Rails.error.report(IOError.new("Oh no")) + # Rails.error.report(StandardError.new) + # end + # + # assert_equal 2, reports.size + # assert_equal "Oops", reports.first.error.message + # assert_equal "Oh no", reports.last.error.message + def capture_error_reports(error_class = StandardError, &block) + reports = ErrorCollector.record(&block) + reports.select { |r| error_class === r.error } + end end end end diff --git a/activesupport/lib/active_support/testing/event_reporter_assertions.rb b/activesupport/lib/active_support/testing/event_reporter_assertions.rb new file mode 100644 index 0000000000000..65e69761fc672 --- /dev/null +++ b/activesupport/lib/active_support/testing/event_reporter_assertions.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +module ActiveSupport + module Testing + # Provides test helpers for asserting on ActiveSupport::EventReporter events. + module EventReporterAssertions + module EventCollector # :nodoc: + @subscribed = false + @mutex = Mutex.new + + class Event # :nodoc: + attr_reader :event_data + + def initialize(event_data) + @event_data = event_data + end + + def inspect + "#{event_data[:name]} (payload: #{event_data[:payload].inspect}, tags: #{event_data[:tags].inspect})" + end + + def matches?(name, payload, tags) + return false unless name.to_s == event_data[:name] + + if payload && payload.is_a?(Hash) + return false unless matches_hash?(payload, :payload) + end + + return false unless matches_hash?(tags, :tags) + true + end + + private + def matches_hash?(expected_hash, event_key) + expected_hash.all? do |k, v| + if v.is_a?(Regexp) + event_data.dig(event_key, k).to_s.match?(v) + else + event_data.dig(event_key, k) == v + end + end + end + end + + class << self + def emit(event) + event_recorders&.each do |events| + events << Event.new(event) + end + true + end + + def record + subscribe + events = [] + event_recorders << events + begin + yield + events + ensure + event_recorders.delete_if { |r| events.equal?(r) } + end + end + + private + def subscribe + return if @subscribed + + @mutex.synchronize do + unless @subscribed + if ActiveSupport.event_reporter + ActiveSupport.event_reporter.subscribe(self) + @subscribed = true + else + raise Minitest::Assertion, "No event reporter is configured" + end + end + end + end + + def event_recorders + ActiveSupport::IsolatedExecutionState[:active_support_event_reporter_assertions] ||= [] + end + end + end + + # Asserts that the block does not cause an event to be reported to +Rails.event+. + # + # If no name is provided, passes if evaluated code in the yielded block reports no events. + # + # assert_no_event_reported do + # service_that_does_not_report_events.perform + # end + # + # If a name is provided, passes if evaluated code in the yielded block reports no events + # with that name. + # + # assert_no_event_reported("user.created") do + # service_that_does_not_report_events.perform + # end + def assert_no_event_reported(name = nil, payload: {}, tags: {}, &block) + events = EventCollector.record(&block) + + if name.nil? + assert_predicate(events, :empty?) + else + matching_event = events.find { |event| event.matches?(name, payload, tags) } + if matching_event + message = "Expected no '#{name}' event to be reported, but found:\n " \ + "#{matching_event.inspect}" + flunk(message) + end + assert(true) + end + end + + # Asserts that the block causes an event with the given name to be reported + # to +Rails.event+. + # + # Passes if the evaluated code in the yielded block reports a matching event. + # + # assert_event_reported("user.created") do + # Rails.event.notify("user.created", { id: 123 }) + # end + # + # To test further details about the reported event, you can specify payload and tag matchers. + # + # assert_event_reported("user.created", + # payload: { id: 123, name: "John Doe" }, + # tags: { request_id: /[0-9]+/ } + # ) do + # Rails.event.tagged(request_id: "123") do + # Rails.event.notify("user.created", { id: 123, name: "John Doe" }) + # end + # end + # + # The matchers support partial matching - only the specified keys need to match. + # + # assert_event_reported("user.created", payload: { id: 123 }) do + # Rails.event.notify("user.created", { id: 123, name: "John Doe" }) + # end + def assert_event_reported(name, payload: nil, tags: {}, &block) + events = EventCollector.record(&block) + + if events.empty? + flunk("Expected an event to be reported, but there were no events reported.") + elsif (event = events.find { |event| event.matches?(name, payload, tags) }) + assert(true) + event.event_data + else + message = "Expected an event to be reported matching:\n " \ + "name: #{name.inspect}\n " \ + "payload: #{payload.inspect}\n " \ + "tags: #{tags.inspect}\n" \ + "but none of the #{events.size} reported events matched:\n " \ + "#{events.map(&:inspect).join("\n ")}" + flunk(message) + end + end + + # Asserts that the provided events were reported, regardless of order. + # + # assert_events_reported([ + # { name: "user.created", payload: { id: 123 } }, + # { name: "email.sent", payload: { to: "user@example.com" } } + # ]) do + # create_user_and_send_welcome_email + # end + # + # Supports the same payload and tag matching as +assert_event_reported+. + # + # assert_events_reported([ + # { + # name: "process.started", + # payload: { id: 123 }, + # tags: { request_id: /[0-9]+/ } + # }, + # { name: "process.completed" } + # ]) do + # Rails.event.tagged(request_id: "456") do + # start_and_complete_process(123) + # end + # end + def assert_events_reported(expected_events, &block) + events = EventCollector.record(&block) + + if events.empty? && expected_events.size > 0 + flunk("Expected #{expected_events.size} events to be reported, but there were no events reported.") + end + + events_copy = events.dup + + expected_events.each do |expected_event| + name = expected_event[:name] + payload = expected_event[:payload] || {} + tags = expected_event[:tags] || {} + + matching_event_index = events_copy.find_index { |event| event.matches?(name, payload, tags) } + + if matching_event_index + events_copy.delete_at(matching_event_index) + else + message = "Expected an event to be reported matching:\n " \ + "name: #{name.inspect}\n " \ + "payload: #{payload.inspect}\n " \ + "tags: #{tags.inspect}\n" \ + "but none of the #{events.size} reported events matched:\n " \ + "#{events.map(&:inspect).join("\n ")}" + flunk(message) + end + end + + assert(true) + end + + # Allows debug events to be reported to +Rails.event+ for the duration of a given block. + # + # with_debug_event_reporting do + # service_that_reports_debug_events.perform + # end + # + def with_debug_event_reporting(&block) + ActiveSupport.event_reporter.with_debug(&block) + end + end + end +end diff --git a/activesupport/lib/active_support/testing/parallelization.rb b/activesupport/lib/active_support/testing/parallelization.rb index 9bdc7a72f1667..ed8b20406076f 100644 --- a/activesupport/lib/active_support/testing/parallelization.rb +++ b/activesupport/lib/active_support/testing/parallelization.rb @@ -60,8 +60,19 @@ def size end def shutdown + dead_worker_pids = @worker_pool.filter_map do |pid| + Process.waitpid(pid, Process::WNOHANG) + rescue Errno::ECHILD + pid + end + @queue_server.remove_dead_workers(dead_worker_pids) + @queue_server.shutdown - @worker_pool.each { |pid| Process.waitpid pid } + @worker_pool.each do |pid| + Process.waitpid(pid) + rescue Errno::ECHILD + nil + end end end end diff --git a/activesupport/lib/active_support/testing/parallelization/server.rb b/activesupport/lib/active_support/testing/parallelization/server.rb index dc70c9616db58..e060327d8aad4 100644 --- a/activesupport/lib/active_support/testing/parallelization/server.rb +++ b/activesupport/lib/active_support/testing/parallelization/server.rb @@ -14,6 +14,7 @@ class Server def initialize @queue = Queue.new @active_workers = Concurrent::Map.new + @worker_pids = Concurrent::Map.new @in_flight = Concurrent::Map.new end @@ -40,12 +41,24 @@ def pop end end - def start_worker(worker_id) + def start_worker(worker_id, worker_pid) @active_workers[worker_id] = true + @worker_pids[worker_id] = worker_pid end - def stop_worker(worker_id) + def stop_worker(worker_id, worker_pid) @active_workers.delete(worker_id) + @worker_pids.delete(worker_id) + end + + def remove_dead_workers(dead_pids) + dead_pids.each do |dead_pid| + worker_id = @worker_pids.key(dead_pid) + if worker_id + @active_workers.delete(worker_id) + @worker_pids.delete(worker_id) + end + end end def active_workers? @@ -64,10 +77,7 @@ def shutdown @queue.close - # Wait until all workers have finished - while active_workers? - sleep 0.1 - end + wait_for_active_workers @in_flight.values.each do |(klass, name, reporter)| result = Minitest::Result.from(klass.new(name)) @@ -78,7 +88,20 @@ def shutdown reporter.record(result) end end + rescue Interrupt + warn "Interrupted. Exiting..." + + @queue.close + + wait_for_active_workers end + + private + def wait_for_active_workers + while active_workers? + sleep 0.1 + end + end end end end diff --git a/activesupport/lib/active_support/testing/parallelization/worker.rb b/activesupport/lib/active_support/testing/parallelization/worker.rb index 393355a25fe15..d008277f8924c 100644 --- a/activesupport/lib/active_support/testing/parallelization/worker.rb +++ b/activesupport/lib/active_support/testing/parallelization/worker.rb @@ -18,18 +18,20 @@ def start DRb.stop_service @queue = DRbObject.new_with_uri(@url) - @queue.start_worker(@id) + @queue.start_worker(@id, Process.pid) begin after_fork rescue => @setup_exception; end work_from_queue + rescue Interrupt + @queue.interrupt ensure set_process_title("(stopping)") run_cleanup - @queue.stop_worker(@id) + @queue.stop_worker(@id, Process.pid) end end @@ -69,15 +71,14 @@ def safe_record(reporter, result) Minitest::UnexpectedError.new(error) end @queue.record(reporter, result) - rescue Interrupt - @queue.interrupt - raise end set_process_title("(idle)") end def after_fork + ActiveSupport::TestCase.parallel_worker_id = @number + Parallelization.after_fork_hooks.each do |cb| cb.call(@number) end diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb index eb25fd46487c3..193d6b6c85645 100644 --- a/activesupport/lib/active_support/time_with_zone.rb +++ b/activesupport/lib/active_support/time_with_zone.rb @@ -49,9 +49,15 @@ class TimeWithZone attr_reader :time_zone def initialize(utc_time, time_zone, local_time = nil, period = nil) - @utc = utc_time ? transfer_time_values_to_utc_constructor(utc_time) : nil @time_zone, @time = time_zone, local_time - @period = @utc ? period : get_period_and_ensure_valid_local_time(period) + if utc_time + @utc = transfer_time_values_to_utc_constructor(utc_time) + @period = period + else + @utc = nil + @period = get_period_and_ensure_valid_local_time(period) + end + @is_utc = zone == "UTC" || zone == "UCT" end # Returns a Time instance that represents the time in +time_zone+. @@ -103,7 +109,7 @@ def dst? # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)' # Time.zone.now.utc? # => false def utc? - zone == "UTC" || zone == "UCT" + @is_utc end alias_method :gmt?, :utc? @@ -146,7 +152,17 @@ def inspect # # Time.zone.now.xmlschema # => "2014-12-04T11:02:37-05:00" def xmlschema(fraction_digits = 0) - "#{time.strftime(PRECISIONS[fraction_digits.to_i])}#{formatted_offset(true, 'Z')}" + precision = fraction_digits || 0 + + if @is_utc + utc.iso8601(precision) + else + str = time.iso8601(precision) + offset = formatted_offset(true, "Z") + + str.sub!(/(Z|[+-]\d{2}:\d{2})\z/, offset) + str + end end alias_method :iso8601, :xmlschema alias_method :rfc3339, :xmlschema @@ -165,7 +181,7 @@ def xmlschema(fraction_digits = 0) # # => "2005/02/01 05:15:10 -1000" def as_json(options = nil) if ActiveSupport::JSON::Encoding.use_standard_json_time_format - xmlschema(ActiveSupport::JSON::Encoding.time_precision) + xmlschema(ActiveSupport::JSON::Encoding.time_precision).force_encoding(Encoding::UTF_8) else %(#{time.strftime("%Y/%m/%d %H:%M:%S")} #{formatted_offset(false)}) end @@ -299,16 +315,8 @@ def +(other) if duration_of_variable_length?(other) method_missing(:+, other) else - begin - result = utc + other - rescue TypeError - result = utc.to_datetime.since(other) - ActiveSupport.deprecator.warn( - "Adding an instance of #{other.class} to an instance of #{self.class} is deprecated. This behavior will raise " \ - "a `TypeError` in Rails 8.1." - ) - result.in_time_zone(time_zone) - end + result = utc + other + result.in_time_zone(time_zone) end end @@ -438,11 +446,11 @@ def advance(options) end %w(year mon month day mday wday yday hour min sec usec nsec to_date).each do |method_name| - class_eval <<-EOV, __FILE__, __LINE__ + 1 + class_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{method_name} # def month time.#{method_name} # time.month end # end - EOV + RUBY end # Returns Array of parts of Time in sequence of @@ -491,13 +499,7 @@ def to_datetime # with the same UTC offset as +self+ or in the local system timezone # depending on the setting of +ActiveSupport.to_time_preserves_timezone+. def to_time - if preserve_timezone == :zone - @to_time_with_timezone ||= getlocal(time_zone) - elsif preserve_timezone - @to_time_with_instance_offset ||= getlocal(utc_offset) - else - @to_time_with_system_offset ||= getlocal - end + @to_time_with_timezone ||= getlocal(time_zone) end # So that +self+ acts_like?(:time). @@ -534,14 +536,6 @@ def marshal_load(variables) initialize(variables[0].utc, ::Time.find_zone(variables[1]), variables[2].utc) end - # respond_to_missing? is not called in some cases, such as when type conversion is - # performed with Kernel#String - def respond_to?(sym, include_priv = false) - # ensure that we're not going to throw and rescue from NoMethodError in method_missing which is slow - return false if sym.to_sym == :to_str - super - end - # Ensure proxy class responds to all methods that underlying time instance # responds to. def respond_to_missing?(sym, include_priv) @@ -560,7 +554,9 @@ def method_missing(...) SECONDS_PER_DAY = 86400 def incorporate_utc_offset(time, offset) - if time.kind_of?(Date) + if offset.zero? + time + elsif time.kind_of?(Date) time + Rational(offset, SECONDS_PER_DAY) else time + offset diff --git a/activesupport/lib/active_support/values/time_zone.rb b/activesupport/lib/active_support/values/time_zone.rb index cb7206405f2be..7934a8505b4ba 100644 --- a/activesupport/lib/active_support/values/time_zone.rb +++ b/activesupport/lib/active_support/values/time_zone.rb @@ -64,7 +64,7 @@ class TimeZone "Montevideo" => "America/Montevideo", "Georgetown" => "America/Guyana", "Puerto Rico" => "America/Puerto_Rico", - "Greenland" => "America/Godthab", + "Greenland" => "America/Nuuk", "Mid-Atlantic" => "Atlantic/South_Georgia", "Azores" => "Atlantic/Azores", "Cape Verde Is." => "Atlantic/Cape_Verde", @@ -314,6 +314,12 @@ def initialize(name, utc_offset = nil, tzinfo = nil) end # :startdoc: + # Returns a standard time zone name defined by IANA + # https://www.iana.org/time-zones + def standard_name + MAPPING[name] || name + end + # Returns the offset of this time zone from UTC in seconds. def utc_offset @utc_offset || tzinfo&.current_period&.base_utc_offset diff --git a/activesupport/lib/active_support/xml_mini.rb b/activesupport/lib/active_support/xml_mini.rb index 380d0156b685e..c6d7ce5b7c251 100644 --- a/activesupport/lib/active_support/xml_mini.rb +++ b/activesupport/lib/active_support/xml_mini.rb @@ -62,11 +62,10 @@ def content_type "yaml" => Proc.new { |yaml| yaml.to_yaml } } unless defined?(FORMATTING) - # TODO use regexp instead of Date.parse unless defined?(PARSING) PARSING = { "symbol" => Proc.new { |symbol| symbol.to_s.to_sym }, - "date" => Proc.new { |date| ::Date.parse(date) }, + "date" => Proc.new { |date| ::Date.strptime(date, "%Y-%m-%d") }, "datetime" => Proc.new { |time| Time.xmlschema(time).utc rescue ::DateTime.parse(time).utc }, "duration" => Proc.new { |duration| Duration.parse(duration) }, "integer" => Proc.new { |integer| integer.to_i }, @@ -74,6 +73,8 @@ def content_type "decimal" => Proc.new do |number| if String === number number.to_d + elsif Float === number + BigDecimal(number, 0) else BigDecimal(number) end diff --git a/activesupport/test/abstract_unit.rb b/activesupport/test/abstract_unit.rb index 5aa633451e881..a03d2a99bae70 100644 --- a/activesupport/test/abstract_unit.rb +++ b/activesupport/test/abstract_unit.rb @@ -24,11 +24,6 @@ # Show backtraces for deprecated behavior for quicker cleanup. ActiveSupport.deprecator.behavior = :raise -# Default to Ruby 2.4+ to_time behavior but allow running tests with old behavior -ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = ENV.fetch("PRESERVE_TIMEZONES", "1") == "1" -end - ActiveSupport::Cache.format_version = 7.1 # Disable available locale checks to avoid warnings running the test suite. diff --git a/activesupport/test/backtrace_cleaner_test.rb b/activesupport/test/backtrace_cleaner_test.rb index d05035413d671..d894e33c051a1 100644 --- a/activesupport/test/backtrace_cleaner_test.rb +++ b/activesupport/test/backtrace_cleaner_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "abstract_unit" +require "rails/test_unit/line_filtering" class BacktraceCleanerFilterTest < ActiveSupport::TestCase def setup @@ -136,3 +137,147 @@ def setup assert_equal backtrace, @bc.clean(backtrace) end end + +class BacktraceCleanerFirstCleanFrameTest < ActiveSupport::TestCase + def setup + @bc = ActiveSupport::BacktraceCleaner.new + end + + def invoke_first_clean_frame_defaults + -> do + @bc.first_clean_frame.tap { @line = __LINE__ + 1 } + end.call + end + + def invoke_first_clean_frame(kind = :silent) + -> do + @bc.first_clean_frame(kind).tap { @line = __LINE__ + 1 } + end.call + end + + test "returns the first clean frame (defaults)" do + result = invoke_first_clean_frame_defaults + assert_match(/\A#{__FILE__}:#@line:in [`'](#{self.class}#)?invoke_first_clean_frame_defaults[`']\z/, result) + end + + test "returns the first clean frame (:silent)" do + result = invoke_first_clean_frame(:silent) + assert_match(/\A#{__FILE__}:#@line:in [`'](#{self.class}#)?invoke_first_clean_frame[`']\z/, result) + end + + test "returns the first clean frame (:noise)" do + @bc.add_silencer { true } + result = invoke_first_clean_frame(:noise) + assert_match(/\A#{__FILE__}:#@line:in [`'](#{self.class}#)?invoke_first_clean_frame[`']\z/, result) + end + + test "returns the first clean frame (:any)" do + result = invoke_first_clean_frame(:any) # fallback of the case statement + assert_match(/\A#{__FILE__}:#@line:in [`'](#{self.class}#)?invoke_first_clean_frame[`']\z/, result) + end + + test "returns nil if there is no clean frame" do + @bc.add_silencer { true } + assert_nil invoke_first_clean_frame_defaults + end +end + +class BacktraceCleanerFirstCleanLocationTest < ActiveSupport::TestCase + def setup + @bc = ActiveSupport::BacktraceCleaner.new + end + + def invoke_first_clean_location_defaults + -> do + @bc.first_clean_location.tap { @line = __LINE__ + 1 } + end.call + end + + def invoke_first_clean_location(kind = :silent) + -> do + @bc.first_clean_location(kind).tap { @line = __LINE__ + 1 } + end.call + end + + test "returns the first clean location (defaults)" do + location = invoke_first_clean_location_defaults + + assert_equal __FILE__, location.path + assert_equal @line, location.lineno + end + + test "returns the first clean location (:silent)" do + location = invoke_first_clean_location(:silent) + + assert_equal __FILE__, location.path + assert_equal @line, location.lineno + end + + test "returns the first clean location (:noise)" do + @bc.add_silencer { true } + location = invoke_first_clean_location(:noise) + + assert_equal __FILE__, location.path + assert_equal @line, location.lineno + end + + test "returns the first clean location (:any)" do + location = invoke_first_clean_location(:any) # fallback of the case statement + + assert_equal __FILE__, location.path + assert_equal @line, location.lineno + end + + test "returns nil if there is no clean location" do + @bc.add_silencer { true } + assert_nil invoke_first_clean_location_defaults + end +end + +class BacktraceCleanerCleanLocationsTest < ActiveSupport::TestCase + def setup + @bc = ActiveSupport::BacktraceCleaner.new + @locations = indirect_caller_locations + end + + # Adds a frame from this file to the call stack. + def indirect_caller_locations + line_filtering_path = Object.const_source_location("Rails::LineFiltering")&.first + caller_locations.reject do |loc| + loc.path == line_filtering_path + end + end + + test "returns all clean locations (defaults)" do + cleaned_locations = @bc.clean_locations(@locations) + assert_equal [__FILE__], cleaned_locations.map(&:path) + end + + test "returns all clean locations (:silent)" do + cleaned_locations = @bc.clean_locations(@locations, :silent) + assert_equal [__FILE__], cleaned_locations.map(&:path) + end + + test "returns all clean locations (:noise)" do + cleaned_locations = @bc.clean_locations(@locations, :noise) + assert_not_includes cleaned_locations.map(&:path), __FILE__ + end + + test "returns an empty array if there are no clean locations" do + @bc.add_silencer { true } + assert_equal [], @bc.clean_locations(@locations) + end + + test "filters and silencers are applied" do + @bc.remove_filters! + @bc.remove_silencers! + + # We filter all locations as "foo", then we silence filtered strings that + # are exactly "foo". If filters and silencers are correctly applied, we + # should get no locations back. + @bc.add_filter { "foo" } + @bc.add_silencer { "foo" == _1 } + + assert_equal [], @bc.clean_locations(@locations) + end +end diff --git a/activesupport/test/broadcast_logger_test.rb b/activesupport/test/broadcast_logger_test.rb index 4959359894b01..bf379a61be1bd 100644 --- a/activesupport/test/broadcast_logger_test.rb +++ b/activesupport/test/broadcast_logger_test.rb @@ -268,14 +268,28 @@ def info(msg, &block) assert(logger.foo) end - test "calling a method that accepts a block" do - logger = BroadcastLogger.new(CustomLogger.new) + test "methods are called on each logger" do + calls = 0 + loggers = [CustomLogger.new, FakeLogger.new, CustomLogger.new].each do |logger| + logger.define_singleton_method(:special_method) do + calls += 1 + end + end + logger = BroadcastLogger.new(*loggers) + logger.special_method + assert_equal(3, calls) + end - called = false - logger.bar do - called = true + test "calling a method that accepts a block is yielded only once" do + called = 0 + logger.info do + called += 1 + "Hello" end - assert(called) + + assert_equal 1, called, "block should be called just once" + assert_equal [[::Logger::INFO, "Hello", nil]], log1.adds + assert_equal [[::Logger::INFO, "Hello", nil]], log2.adds end test "calling a method that accepts args" do @@ -356,27 +370,27 @@ def qux(param:) true end - def debug(message, &block) + def debug(message = nil, &block) add(::Logger::DEBUG, message, &block) end - def info(message, &block) + def info(message = nil, &block) add(::Logger::INFO, message, &block) end - def warn(message, &block) + def warn(message = nil, &block) add(::Logger::WARN, message, &block) end - def error(message, &block) + def error(message = nil, &block) add(::Logger::ERROR, message, &block) end - def fatal(message, &block) + def fatal(message = nil, &block) add(::Logger::FATAL, message, &block) end - def unknown(message, &block) + def unknown(message = nil, &block) add(::Logger::UNKNOWN, message, &block) end @@ -385,7 +399,8 @@ def <<(x) end def add(message_level, message = nil, progname = nil, &block) - @adds << [message_level, message, progname] if message_level >= local_level + @adds << [message_level, block_given? ? block.call : message, progname] if message_level >= local_level + true end def debug? diff --git a/activesupport/test/cache/behaviors/cache_increment_decrement_behavior.rb b/activesupport/test/cache/behaviors/cache_increment_decrement_behavior.rb index dcd45ea65e475..5f1f0abcdf7d4 100644 --- a/activesupport/test/cache/behaviors/cache_increment_decrement_behavior.rb +++ b/activesupport/test/cache/behaviors/cache_increment_decrement_behavior.rb @@ -31,6 +31,18 @@ def test_decrement assert_equal @cache.is_a?(ActiveSupport::Cache::MemCacheStore) ? 0 : -100, missing end + def test_read_counter_and_write_counter + key = SecureRandom.uuid + @cache.write_counter(key, 1) + assert_equal 1, @cache.read(key, raw: true).to_i + + assert_equal 1, @cache.read_counter(key) + assert_equal 2, @cache.increment(key) + assert_equal 2, @cache.read_counter(key) + + assert_nil @cache.read_counter(SecureRandom.alphanumeric) + end + def test_ttl_isnt_updated key = SecureRandom.uuid diff --git a/activesupport/test/cache/behaviors/cache_store_behavior.rb b/activesupport/test/cache/behaviors/cache_store_behavior.rb index bbe4ace4688ea..4b81c8256204b 100644 --- a/activesupport/test/cache/behaviors/cache_store_behavior.rb +++ b/activesupport/test/cache/behaviors/cache_store_behavior.rb @@ -742,6 +742,26 @@ def test_setting_options_in_fetch_block_does_not_change_cache_options end end + def test_configuring_store_with_raw + cache = lookup_store(raw: true) + cache.write("foo", "bar") + assert_equal "bar", cache.read("foo") + end + + def test_max_key_size + cache = lookup_store(max_key_size: 64) + key = "foobar" * 20 + cache.write(key, "bar") + assert_equal "bar", cache.read(key) + end + + def test_max_key_size_disabled + cache = lookup_store(max_key_size: false) + key = "a" * 1000 + cache.write(key, "bar") + assert_equal "bar", cache.read(key) + end + private def with_raise_on_invalid_cache_expiration_time(new_value, &block) old_value = ActiveSupport::Cache::Store.raise_on_invalid_cache_expiration_time diff --git a/activesupport/test/cache/cache_key_test.rb b/activesupport/test/cache/cache_key_test.rb index e66e55d2d7f67..90e0b48c1d770 100644 --- a/activesupport/test/cache/cache_key_test.rb +++ b/activesupport/test/cache/cache_key_test.rb @@ -78,6 +78,16 @@ def test_expand_cache_key_of_array_like_object assert_equal "foo/bar/baz", ActiveSupport::Cache.expand_cache_key(%w{foo bar baz}.to_enum) end + def test_set_and_get_namespace + cache = ActiveSupport::Cache::MemoryStore.new + assert_nil cache.namespace + cache.namespace = "test" + assert_equal "test", cache.namespace + + cache.namespace = "test2" + assert_equal "test2", cache.namespace + end + private def with_env(kv) old_values = {} diff --git a/activesupport/test/cache/local_cache_middleware_test.rb b/activesupport/test/cache/local_cache_middleware_test.rb index c39e41832c8fe..7ed6c4874c004 100644 --- a/activesupport/test/cache/local_cache_middleware_test.rb +++ b/activesupport/test/cache/local_cache_middleware_test.rb @@ -8,53 +8,69 @@ module Cache module Strategy module LocalCache class MiddlewareTest < ActiveSupport::TestCase + class Cache + include LocalCache + end + def test_local_cache_cleared_on_close - key = "super awesome key" - assert_nil LocalCacheRegistry.cache_for key - middleware = Middleware.new("<3", key).new(->(env) { - assert LocalCacheRegistry.cache_for(key), "should have a cache" + cache = Cache.new + assert_nil cache.local_cache + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" [200, {}, []] }) _, _, body = middleware.call({}) - assert LocalCacheRegistry.cache_for(key), "should still have a cache" + assert cache.local_cache, "should still have a cache" body.each { } - assert LocalCacheRegistry.cache_for(key), "should still have a cache" + assert cache.local_cache, "should still have a cache" body.close - assert_nil LocalCacheRegistry.cache_for(key) + assert_nil cache.local_cache end def test_local_cache_cleared_and_response_should_be_present_on_invalid_parameters_error - key = "super awesome key" - assert_nil LocalCacheRegistry.cache_for key - middleware = Middleware.new("<3", key).new(->(env) { - assert LocalCacheRegistry.cache_for(key), "should have a cache" + cache = Cache.new + assert_nil cache.local_cache + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" raise Rack::Utils::InvalidParameterError }) response = middleware.call({}) assert response, "response should exist" - assert_nil LocalCacheRegistry.cache_for(key) + assert_nil cache.local_cache end def test_local_cache_cleared_on_exception - key = "super awesome key" - assert_nil LocalCacheRegistry.cache_for key - middleware = Middleware.new("<3", key).new(->(env) { - assert LocalCacheRegistry.cache_for(key), "should have a cache" + cache = Cache.new + assert_nil cache.local_cache + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" raise }) assert_raises(RuntimeError) { middleware.call({}) } - assert_nil LocalCacheRegistry.cache_for(key) + assert_nil cache.local_cache end def test_local_cache_cleared_on_throw - key = "super awesome key" - assert_nil LocalCacheRegistry.cache_for key - middleware = Middleware.new("<3", key).new(->(env) { - assert LocalCacheRegistry.cache_for(key), "should have a cache" + cache = Cache.new + assert_nil cache.local_cache + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" throw :warden }) assert_throws(:warden) { middleware.call({}) } - assert_nil LocalCacheRegistry.cache_for(key) + assert_nil cache.local_cache + end + + def test_local_cache_middlewre_can_reassign_cache + cache = Cache.new + new_cache = Cache.new + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" + throw :warden + }) + middleware.cache = new_cache + + assert_same(new_cache, middleware.cache) end end end diff --git a/activesupport/test/cache/stores/mem_cache_store_test.rb b/activesupport/test/cache/stores/mem_cache_store_test.rb index 976225bb3da4d..7b8d69a298e41 100644 --- a/activesupport/test/cache/stores/mem_cache_store_test.rb +++ b/activesupport/test/cache/stores/mem_cache_store_test.rb @@ -371,6 +371,30 @@ def test_can_read_multi_entries_raw_values_from_dalli_store assert_equal({}, @cache.send(:read_multi_entries, [key])) end + def test_falls_back_to_default_value_when_client_raises_dalli_error + cache = lookup_store + client = cache.instance_variable_get(:@data) + client.stub(:get_multi, lambda { |*_args| raise Dalli::DalliError.new("test error") }) do + assert_equal({}, cache.read_multi("key1", "key2")) + end + end + + def test_falls_back_to_default_value_when_client_raises_connection_pool_timeout_error + cache = lookup_store + client = cache.instance_variable_get(:@data) + client.stub(:get_multi, lambda { |*_args| raise ConnectionPool::TimeoutError.new("test error") }) do + assert_equal({}, cache.read_multi("key1", "key2")) + end + end + + def test_falls_back_to_default_value_when_client_raises_connection_pool_error + cache = lookup_store + client = cache.instance_variable_get(:@data) + client.stub(:get_multi, lambda { |*_args| raise ConnectionPool::Error.new("test error") }) do + assert_equal({}, cache.read_multi("key1", "key2")) + end + end + def test_pool_options_work cache = ActiveSupport::Cache.lookup_store(:mem_cache_store, pool: { size: 2, timeout: 1 }) pool = cache.instance_variable_get(:@data) # loads 'connection_pool' gem diff --git a/activesupport/test/cache/stores/memory_store_test.rb b/activesupport/test/cache/stores/memory_store_test.rb index c265d3f664438..684ff48db67e4 100644 --- a/activesupport/test/cache/stores/memory_store_test.rb +++ b/activesupport/test/cache/stores/memory_store_test.rb @@ -4,15 +4,13 @@ require "active_support/cache" require_relative "../behaviors" -class MemoryStoreTest < ActiveSupport::TestCase - def setup - @cache = lookup_store(expires_in: 60) - end - +class StoreTest < ActiveSupport::TestCase def lookup_store(options = {}) ActiveSupport::Cache.lookup_store(:memory_store, options) end +end +class MemoryStoreTest < StoreTest include CacheStoreBehavior include CacheStoreVersionBehavior include CacheStoreCoderBehavior @@ -23,6 +21,10 @@ def lookup_store(options = {}) include CacheInstrumentationBehavior include CacheLoggingBehavior + def setup + @cache = lookup_store(expires_in: 60) + end + def test_increment_preserves_expiry @cache = lookup_store @cache.write("counter", 1, raw: true, expires_in: 30.seconds) @@ -92,7 +94,7 @@ def compression_always_disabled_by_default? end end -class MemoryStorePruningTest < ActiveSupport::TestCase +class MemoryStorePruningTest < StoreTest def setup @record_size = ActiveSupport::Cache.lookup_store(:memory_store).send(:cached_size, 1, ActiveSupport::Cache::Entry.new("aaaaaaaaaa")) @cache = ActiveSupport::Cache.lookup_store(:memory_store, expires_in: 60, size: @record_size * 10 + 1) @@ -158,7 +160,7 @@ def test_prune_size_on_write_based_on_key_length assert @cache.exist?(8) assert @cache.exist?(7) assert @cache.exist?(6) - assert_not @cache.exist?(5), "no entry" + assert @cache.exist?(5) assert_not @cache.exist?(4), "no entry" assert_not @cache.exist?(3), "no entry" assert_not @cache.exist?(2), "no entry" @@ -212,4 +214,25 @@ def test_cache_different_object_ids_string assert_not_equal item.object_id, read_item.object_id assert_not_equal read_item.object_id, @cache.read(key).object_id end + + def test_local_store_strategy + @cache.with_local_cache do + @cache.write("name", "value") + assert_equal "value", @cache.read("name") + @cache.delete("name") + assert_nil @cache.read("name") + @cache.write("name", "value") + end + assert_equal "value", @cache.read("name") + end + + def test_local_store_repeated_reads + @cache.with_local_cache do + @cache.read("foo") + assert_nil @cache.read("foo") + + @cache.read_multi("foo", "bar") + assert_equal({}, @cache.read_multi("foo", "bar")) + end + end end diff --git a/activesupport/test/concurrency/load_interlock_aware_monitor_test.rb b/activesupport/test/concurrency/load_interlock_aware_monitor_test.rb index c421b66587574..1a744376f1904 100644 --- a/activesupport/test/concurrency/load_interlock_aware_monitor_test.rb +++ b/activesupport/test/concurrency/load_interlock_aware_monitor_test.rb @@ -1,76 +1,27 @@ # frozen_string_literal: true require_relative "../abstract_unit" -require "concurrent/atomic/count_down_latch" require "active_support/concurrency/load_interlock_aware_monitor" module ActiveSupport module Concurrency - module LoadInterlockAwareMonitorTests - def test_entering_with_no_blocking - assert @monitor.mon_enter - end - - def test_entering_with_blocking - load_interlock_latch = Concurrent::CountDownLatch.new - monitor_latch = Concurrent::CountDownLatch.new - - able_to_use_monitor = false - able_to_load = false - - thread_with_load_interlock = Thread.new do - ActiveSupport::Dependencies.interlock.running do - load_interlock_latch.count_down - monitor_latch.wait - - @monitor.synchronize do - able_to_use_monitor = true - end - end - end - - thread_with_monitor_lock = Thread.new do - @monitor.synchronize do - monitor_latch.count_down - load_interlock_latch.wait - - ActiveSupport::Dependencies.interlock.loading do - able_to_load = true - end - end - end - - thread_with_load_interlock.join - thread_with_monitor_lock.join - - assert able_to_use_monitor - assert able_to_load - end - end - class LoadInterlockAwareMonitorTest < ActiveSupport::TestCase - include LoadInterlockAwareMonitorTests - - def setup - @monitor = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new - end - end - - class ThreadLoadInterlockAwareMonitorTest < ActiveSupport::TestCase - include LoadInterlockAwareMonitorTests - - def setup - @monitor = ActiveSupport::Concurrency::ThreadLoadInterlockAwareMonitor.new + def test_deprecated_constant_resolves_to_monitor + monitor = nil + assert_deprecated(/ActiveSupport::Concurrency::LoadInterlockAwareMonitor is deprecated/, ActiveSupport.deprecator) do + monitor = LoadInterlockAwareMonitor.new + end + assert_instance_of ::Monitor, monitor end - def test_lock_owned_by_thread - @monitor.synchronize do - enumerator = Enumerator.new do |yielder| - @monitor.synchronize do - yielder.yield 42 - end + def test_deprecated_constant_can_synchronize + assert_deprecated(/ActiveSupport::Concurrency::LoadInterlockAwareMonitor is deprecated/, ActiveSupport.deprecator) do + monitor = LoadInterlockAwareMonitor.new + result = nil + monitor.synchronize do + result = 42 end - assert_equal 42, enumerator.next + assert_equal 42, result end end end diff --git a/activesupport/test/concurrency/thread_monitor_test.rb b/activesupport/test/concurrency/thread_monitor_test.rb new file mode 100644 index 0000000000000..ee08c83703c4f --- /dev/null +++ b/activesupport/test/concurrency/thread_monitor_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require_relative "../abstract_unit" +require "concurrent/atomic/count_down_latch" +require "active_support/concurrency/thread_monitor" + +module ActiveSupport + module Concurrency + class ThreadMonitorTest < ActiveSupport::TestCase + def setup + @monitor = ThreadMonitor.new + end + + def test_synchronize_blocks_other_threads + blocked = false + ready_latch = Concurrent::CountDownLatch.new + blocked_latch = Concurrent::CountDownLatch.new + + thread1 = Thread.new do + @monitor.synchronize do + ready_latch.count_down + blocked_latch.wait + sleep 0.1 + end + end + + thread2 = Thread.new do + ready_latch.wait + @monitor.synchronize do + blocked = true + end + end + + sleep 0.05 # Give thread2 time to try to acquire the lock + assert_not blocked, "Thread should be blocked waiting for monitor" + + blocked_latch.count_down + thread1.join + thread2.join + + assert blocked, "Thread should have acquired monitor after first thread released it" + end + + def test_reentrant_locking + count = 0 + @monitor.synchronize do + count += 1 + @monitor.synchronize do + count += 1 + @monitor.synchronize do + count += 1 + end + end + end + assert_equal 3, count + end + + def test_lock_owned_by_current_thread + @monitor.synchronize do + # Test that we can create an enumerator that also tries to acquire the lock + # This should work because the same thread already owns the lock + enumerator = Enumerator.new do |yielder| + @monitor.synchronize do + yielder.yield 42 + end + end + assert_equal 42, enumerator.next + end + end + + def test_exception_handling_releases_lock + exception_raised = false + subsequent_lock_acquired = false + + begin + @monitor.synchronize do + raise StandardError, "test exception" + end + rescue StandardError + exception_raised = true + end + + assert exception_raised + + # Ensure the lock was properly released + @monitor.synchronize do + subsequent_lock_acquired = true + end + + assert subsequent_lock_acquired + end + + def test_thread_error_on_wrong_thread_unlock + @monitor.synchronize do + thread = Thread.new do + assert_raises(ThreadError) do + @monitor.send(:mon_exit) + end + end + thread.join + end + end + end + end +end diff --git a/activesupport/test/configurable_test.rb b/activesupport/test/configurable_test.rb index b41e05c7b22b0..fc7ebe5812986 100644 --- a/activesupport/test/configurable_test.rb +++ b/activesupport/test/configurable_test.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true require_relative "abstract_unit" -require "active_support/configurable" + +ActiveSupport.deprecator.silence do + require "active_support/configurable" +end class ConfigurableActiveSupport < ActiveSupport::TestCase class Parent diff --git a/activesupport/test/continuous_integration_test.rb b/activesupport/test/continuous_integration_test.rb index a58c30a160d50..31e8466c0a756 100644 --- a/activesupport/test/continuous_integration_test.rb +++ b/activesupport/test/continuous_integration_test.rb @@ -42,6 +42,32 @@ class ContinuousIntegrationTest < ActiveSupport::TestCase assert_not @CI.success? end + test "report with successful and failed steps combined presents a failure summary" do + output = capture_io do + @CI.report("CI") do + step "Success!", "true" + step "Failed!", "false" + step "Also success!", "true" + step "Also failed!", "false" + end + end.to_s + + assert_no_match(/↳ Success/, output) + assert_no_match(/↳ Also success/, output) + assert_match(/↳ Failed! failed/, output) + assert_match(/↳ Also failed! failed/, output) + end + + test "report with only one failing step does not print a failure summary" do + output = capture_io do + @CI.report("CI") do + step "Failed!", "false" + end + end.to_s + + assert_no_match(/↳ Failed/, output) + end + test "echo uses terminal coloring" do output = capture_io { @CI.echo "Hello", type: :success }.first.to_s assert_equal "\e[1;32mHello\e[0m\n", output @@ -56,4 +82,32 @@ class ContinuousIntegrationTest < ActiveSupport::TestCase output = capture_io { @CI.failure "This sucks", "But such is the life of programming sometimes" }.first.to_s assert_equal "\e[1;31m\n\nThis sucks\e[0m\n\e[1;90mBut such is the life of programming sometimes\n\e[0m\n", output end + + %w[-f --fail-fast].each do |flag| + test "report aborts immediately on failure with #{flag} flag" do + output = with_argv([flag]) do + capture_io do + assert_raises SystemExit do + @CI.report("CI") do + step "Success!", "true" + step "Failed!", "false" + step "Should not run", "true" + end + end + end + end.to_s + + assert_no_match(/Should not run/, output) + end + end + + private + def with_argv(argv) + original_argv = ARGV.dup + ARGV.replace(argv) + + yield + ensure + ARGV.replace(original_argv) + end end diff --git a/activesupport/test/core_ext/array/extract_test.rb b/activesupport/test/core_ext/array/extract_test.rb index 069ab5c8f7d73..5fb42e73a93c0 100644 --- a/activesupport/test/core_ext/array/extract_test.rb +++ b/activesupport/test/core_ext/array/extract_test.rb @@ -40,5 +40,6 @@ def test_extract_on_empty_array assert_equal [], new_empty_array assert_equal [], empty_array assert_equal array_id, empty_array.object_id + assert_not_same new_empty_array, empty_array end end diff --git a/activesupport/test/core_ext/benchmark_test.rb b/activesupport/test/core_ext/benchmark_test.rb deleted file mode 100644 index a662dd758d189..0000000000000 --- a/activesupport/test/core_ext/benchmark_test.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -require_relative "../abstract_unit" -require "active_support/core_ext/benchmark" - -class BenchmarkTest < ActiveSupport::TestCase - def test_is_deprecated - assert_deprecated(ActiveSupport.deprecator) do - assert_kind_of Numeric, Benchmark.ms { } - end - end -end diff --git a/activesupport/test/core_ext/class/attribute_test.rb b/activesupport/test/core_ext/class/attribute_test.rb index a185e61f5061d..dc881c0e365d6 100644 --- a/activesupport/test/core_ext/class/attribute_test.rb +++ b/activesupport/test/core_ext/class/attribute_test.rb @@ -116,7 +116,7 @@ def setup assert_equal "plop", @klass.setting end - test "when defined in a class's singleton" do + test "when defined in a class's singleton class" do @klass = Class.new do class << self class_attribute :__callbacks, default: 1 @@ -130,6 +130,26 @@ class << self @klass.__callbacks = 4 assert_equal 1, @klass.__callbacks assert_equal 1, @klass.singleton_class.__callbacks + + @klass.singleton_class.__callbacks = 4 + assert_equal 4, @klass.__callbacks + assert_equal 4, @klass.singleton_class.__callbacks + end + + test "when defined on an instance's singleton class" do + object = @klass.new + + object.singleton_class.class_attribute :external_attr, default: "default_value" + assert_equal "default_value", object.external_attr + assert_equal "default_value", object.singleton_class.external_attr + + object.external_attr = "new_value" + assert_equal "default_value", object.external_attr + assert_equal "default_value", object.singleton_class.external_attr + + object.singleton_class.external_attr = "another_value" + assert_equal "another_value", object.external_attr + assert_equal "another_value", object.singleton_class.external_attr end test "works well with module singleton classes" do diff --git a/activesupport/test/core_ext/date_and_time_compatibility_test.rb b/activesupport/test/core_ext/date_and_time_compatibility_test.rb index d00f3ea34ec19..a684a94709e79 100644 --- a/activesupport/test/core_ext/date_and_time_compatibility_test.rb +++ b/activesupport/test/core_ext/date_and_time_compatibility_test.rb @@ -17,410 +17,115 @@ def setup end def test_time_to_time_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12, 3600) - time = source.to_time + with_env_tz "US/Eastern" do + source = Time.new(2016, 4, 23, 15, 11, 12, 3600) + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_equal source.object_id, time.object_id - end - end - end - - def test_time_to_time_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12, 3600) - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_equal source.object_id, time.object_id - end - end - end - - def test_time_to_time_on_utc_value_without_preserve_configured - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12) - # No warning because it's already local - base_time = source.to_time - - utc_time = base_time.getutc - converted_time = assert_deprecated(ActiveSupport.deprecator) { utc_time.to_time } - - assert_equal source, base_time - assert_equal source, converted_time - assert_equal @system_offset, base_time.utc_offset - assert_equal @system_offset, converted_time.utc_offset - end - end - - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 11, 23, 15, 11, 12) - # No warning because it's already local - base_time = source.to_time - - utc_time = base_time.getutc - converted_time = assert_deprecated(ActiveSupport.deprecator) { utc_time.to_time } - - assert_equal source, base_time - assert_equal source, converted_time - assert_equal @system_dst_offset, base_time.utc_offset - assert_equal @system_dst_offset, converted_time.utc_offset - end - end - end - - def test_time_to_time_on_offset_value_without_preserve_configured - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - foreign_time = Time.new(2016, 4, 23, 15, 11, 12, in: "-0700") - converted_time = assert_deprecated(ActiveSupport.deprecator) { foreign_time.to_time } - - assert_equal foreign_time, converted_time - assert_equal @system_offset, converted_time.utc_offset - assert_not_equal foreign_time.utc_offset, converted_time.utc_offset - end - end - - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - foreign_time = Time.new(2016, 11, 23, 15, 11, 12, in: "-0700") - converted_time = assert_deprecated(ActiveSupport.deprecator) { foreign_time.to_time } - - assert_equal foreign_time, converted_time - assert_equal @system_dst_offset, converted_time.utc_offset - assert_not_equal foreign_time.utc_offset, converted_time.utc_offset - end - end - end - - def test_time_to_time_on_tzinfo_value_without_preserve_configured - foreign_zone = ActiveSupport::TimeZone["America/Phoenix"] - - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - foreign_time = foreign_zone.tzinfo.utc_to_local(Time.new(2016, 4, 23, 15, 11, 12, in: "-0700")) - converted_time = assert_deprecated(ActiveSupport.deprecator) { foreign_time.to_time } - - assert_equal foreign_time, converted_time - assert_equal @system_offset, converted_time.utc_offset - assert_not_equal foreign_time.utc_offset, converted_time.utc_offset - end - end - - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - foreign_time = foreign_zone.tzinfo.utc_to_local(Time.new(2016, 11, 23, 15, 11, 12, in: "-0700")) - converted_time = assert_deprecated(ActiveSupport.deprecator) { foreign_time.to_time } - - assert_equal foreign_time, converted_time - assert_equal @system_dst_offset, converted_time.utc_offset - assert_not_equal foreign_time.utc_offset, converted_time.utc_offset - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_equal source.object_id, time.object_id end end def test_time_to_time_frozen_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12, 3600).freeze - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_equal source.object_id, time.object_id - assert_predicate time, :frozen? - end - end - end - - def test_time_to_time_frozen_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12, 3600).freeze - time = source.to_time + with_env_tz "US/Eastern" do + source = Time.new(2016, 4, 23, 15, 11, 12, 3600).freeze + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_equal source.object_id, time.object_id - assert_not_predicate time, :frozen? - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_equal source.object_id, time.object_id + assert_predicate time, :frozen? end end def test_datetime_to_time_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)) - time = source.to_time + with_env_tz "US/Eastern" do + source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)) + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - end - end - end - - def test_datetime_to_time_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)) - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset end end def test_datetime_to_time_frozen_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)).freeze - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_not_predicate time, :frozen? - end - end - end - - def test_datetime_to_time_frozen_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)).freeze - time = source.to_time + with_env_tz "US/Eastern" do + source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)).freeze + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_predicate time, :frozen? - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_not_predicate time, :frozen? end end def test_twz_to_time_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = ActiveSupport::TimeWithZone.new(@utc_time, @zone) - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @utc_offset, time.utc_offset - - source = ActiveSupport::TimeWithZone.new(@date_time, @zone) - time = source.to_time - - assert_instance_of Time, time - assert_equal @date_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @utc_offset, time.utc_offset - end - end - end - - def test_twz_to_time_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = ActiveSupport::TimeWithZone.new(@utc_time, @zone) - time = source.to_time + with_env_tz "US/Eastern" do + source = ActiveSupport::TimeWithZone.new(@utc_time, @zone) + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @system_offset, time.utc_offset + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_instance_of Time, time.getutc + assert_equal @utc_offset, time.utc_offset - source = ActiveSupport::TimeWithZone.new(@date_time, @zone) - time = source.to_time + source = ActiveSupport::TimeWithZone.new(@date_time, @zone) + time = source.to_time - assert_instance_of Time, time - assert_equal @date_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @system_offset, time.utc_offset - end + assert_instance_of Time, time + assert_equal @date_time, time.getutc + assert_instance_of Time, time.getutc + assert_equal @utc_offset, time.utc_offset end end def test_twz_to_time_frozen_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = ActiveSupport::TimeWithZone.new(@utc_time, @zone).freeze - time = source.to_time + with_env_tz "US/Eastern" do + source = ActiveSupport::TimeWithZone.new(@utc_time, @zone).freeze + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_not_predicate time, :frozen? + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_instance_of Time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_not_predicate time, :frozen? - source = ActiveSupport::TimeWithZone.new(@date_time, @zone).freeze - time = source.to_time + source = ActiveSupport::TimeWithZone.new(@date_time, @zone).freeze + time = source.to_time - assert_instance_of Time, time - assert_equal @date_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_not_predicate time, :frozen? - end - end - end - - def test_twz_to_time_frozen_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = ActiveSupport::TimeWithZone.new(@utc_time, @zone).freeze - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_predicate time, :frozen? - - source = ActiveSupport::TimeWithZone.new(@date_time, @zone).freeze - time = source.to_time - - assert_instance_of Time, time - assert_equal @date_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_predicate time, :frozen? - end + assert_instance_of Time, time + assert_equal @date_time, time.getutc + assert_instance_of Time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_not_predicate time, :frozen? end end def test_string_to_time_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = "2016-04-23T15:11:12+01:00" - time = source.to_time + with_env_tz "US/Eastern" do + source = "2016-04-23T15:11:12+01:00" + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - end - end - end - - def test_string_to_time_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = "2016-04-23T15:11:12+01:00" - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset end end def test_string_to_time_frozen_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = "2016-04-23T15:11:12+01:00" - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_not_predicate time, :frozen? - end - end - end - - def test_string_to_time_frozen_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = "2016-04-23T15:11:12+01:00" - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_predicate time, :frozen? - end - end - end - - def test_to_time_preserves_timezone_is_deprecated - current_preserve_tz = ActiveSupport.to_time_preserves_timezone - - assert_not_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = :offset - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = false - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = nil - end - - # When set to nil, the first call will report a deprecation, - # then switch the configured value to (and return) false. - assert_deprecated(ActiveSupport.deprecator) do - assert_equal false, ActiveSupport.to_time_preserves_timezone - end - - assert_not_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone - end - ensure - ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = current_preserve_tz - end - end - - def test_to_time_preserves_timezone_supports_new_values - current_preserve_tz = ActiveSupport.to_time_preserves_timezone - - assert_not_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone - end - - assert_not_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = :zone - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = :offset - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = true - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = "offset" - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = :foo - end - ensure - ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = current_preserve_tz + with_env_tz "US/Eastern" do + source = "2016-04-23T15:11:12+01:00" + time = source.to_time + + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_not_predicate time, :frozen? end end end diff --git a/activesupport/test/core_ext/date_time_ext_test.rb b/activesupport/test/core_ext/date_time_ext_test.rb index 320b683686379..bbff9a515395e 100644 --- a/activesupport/test/core_ext/date_time_ext_test.rb +++ b/activesupport/test/core_ext/date_time_ext_test.rb @@ -78,13 +78,8 @@ def test_to_time with_env_tz "US/Eastern" do assert_instance_of Time, DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time - if ActiveSupport.to_time_preserves_timezone - assert_equal Time.local(2005, 2, 21, 5, 11, 12).getlocal(0), DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time - assert_equal Time.local(2005, 2, 21, 5, 11, 12).getlocal(0).utc_offset, DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time.utc_offset - else - assert_equal Time.local(2005, 2, 21, 5, 11, 12), DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time - assert_equal Time.local(2005, 2, 21, 5, 11, 12).utc_offset, DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time.utc_offset - end + assert_equal Time.local(2005, 2, 21, 5, 11, 12).getlocal(0), DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time + assert_equal Time.local(2005, 2, 21, 5, 11, 12).getlocal(0).utc_offset, DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time.utc_offset end end diff --git a/activesupport/test/core_ext/enumerable_test.rb b/activesupport/test/core_ext/enumerable_test.rb index c7f12363e2bcc..ea702d73a9cf8 100644 --- a/activesupport/test/core_ext/enumerable_test.rb +++ b/activesupport/test/core_ext/enumerable_test.rb @@ -398,6 +398,27 @@ def test_sole assert_equal 1, GenericEnumerable.new([1]).sole assert_raise(expected_raise) { GenericEnumerable.new([1, 2]).sole } assert_raise(expected_raise) { GenericEnumerable.new([1, nil]).sole } + assert_raise(expected_raise) { GenericEnumerable.new(1..).sole } + end + + def test_sole_returns_same_value_as_first_for_tuples + enum = Enumerator.new(1) { |yielder| yielder.yield(1, "one") } + assert_equal [1, "one"], enum.sole + assert_equal enum.first, enum.sole + end + + class KeywordYielder + include Enumerable + + def each + yield 1, two: 3 + end + end + + def test_sole_keyword_arguments + yielder = KeywordYielder.new + assert_equal [1, { two: 3 }], yielder.sole + assert_equal yielder.first, yielder.sole end def test_doesnt_bust_constant_cache diff --git a/activesupport/test/core_ext/object/json_gem_encoding_test.rb b/activesupport/test/core_ext/object/json_gem_encoding_test.rb index 56c5f81343d9d..638ec54d99389 100644 --- a/activesupport/test/core_ext/object/json_gem_encoding_test.rb +++ b/activesupport/test/core_ext/object/json_gem_encoding_test.rb @@ -45,7 +45,7 @@ def require_or_skip(file) def assert_same_with_or_without_active_support(subject) begin - expected = JSON.generate(subject, quirks_mode: true) + expected = JSON.generate(subject) rescue JSON::GeneratorError => e exception = e end @@ -54,10 +54,10 @@ def assert_same_with_or_without_active_support(subject) if exception assert_raises JSON::GeneratorError, match: e.message do - JSON.generate(subject, quirks_mode: true) + JSON.generate(subject) end else - assert_equal expected, JSON.generate(subject, quirks_mode: true) + assert_equal expected, JSON.generate(subject) end end end diff --git a/activesupport/test/core_ext/range_ext_test.rb b/activesupport/test/core_ext/range_ext_test.rb index 718deca2735a1..2e5994e6bf7cf 100644 --- a/activesupport/test/core_ext/range_ext_test.rb +++ b/activesupport/test/core_ext/range_ext_test.rb @@ -287,4 +287,16 @@ def test_date_time_with_step datetime = DateTime.now assert(((datetime - 1.hour)..datetime).step(1) { }) end + + def test_sole + assert_equal 1, (1..1).sole + + assert_raises(Enumerable::SoleItemExpectedError, match: "no item found") do + (2..1).sole + end + + assert_raises(Enumerable::SoleItemExpectedError, match: "infinite range '..1' cannot represent a sole item") do + (..1).sole + end + end end diff --git a/activesupport/test/core_ext/secure_random_test.rb b/activesupport/test/core_ext/secure_random_test.rb index f2e1c2ba5cebb..9428f26338c37 100644 --- a/activesupport/test/core_ext/secure_random_test.rb +++ b/activesupport/test/core_ext/secure_random_test.rb @@ -69,4 +69,40 @@ def test_base36_with_nil assert_match(/^[a-z0-9]+$/, s1) assert_match(/^[a-z0-9]+$/, s2) end + + def test_base32 + s1 = SecureRandom.base32 + s2 = SecureRandom.base32 + + assert_not_equal s1, s2 + assert_equal 16, s1.length + assert_match(/^[A-Z0-9]+$/, s1) + assert_match(/^[A-Z0-9]+$/, s2) + assert_match(/^[^ILOU]+$/, s1) + assert_match(/^[^ILOU]+$/, s2) + end + + def test_base32_with_length + s1 = SecureRandom.base32(24) + s2 = SecureRandom.base32(24) + + assert_not_equal s1, s2 + assert_equal 24, s1.length + assert_match(/^[A-Z0-9]+$/, s1) + assert_match(/^[A-Z0-9]+$/, s2) + assert_match(/^[^ILOU]+$/, s1) + assert_match(/^[^ILOU]+$/, s2) + end + + def test_base32_with_nil + s1 = SecureRandom.base32(nil) + s2 = SecureRandom.base32(nil) + + assert_not_equal s1, s2 + assert_equal 16, s1.length + assert_match(/^[A-Z0-9]+$/, s1) + assert_match(/^[A-Z0-9]+$/, s2) + assert_match(/^[^ILOU]+$/, s1) + assert_match(/^[^ILOU]+$/, s2) + end end diff --git a/activesupport/test/core_ext/string_ext_test.rb b/activesupport/test/core_ext/string_ext_test.rb index 5220713bcc3b1..a085918fc38c4 100644 --- a/activesupport/test/core_ext/string_ext_test.rb +++ b/activesupport/test/core_ext/string_ext_test.rb @@ -611,17 +611,10 @@ def test_timestamp_string_to_time def test_string_to_time_utc_offset with_env_tz "US/Eastern" do - if ActiveSupport.to_time_preserves_timezone - assert_equal 0, "2005-02-27 23:50".to_time(:utc).utc_offset - assert_equal(-18000, "2005-02-27 23:50".to_time.utc_offset) - assert_equal 0, "2005-02-27 22:50 -0100".to_time(:utc).utc_offset - assert_equal(-3600, "2005-02-27 22:50 -0100".to_time.utc_offset) - else - assert_equal 0, "2005-02-27 23:50".to_time(:utc).utc_offset - assert_equal(-18000, "2005-02-27 23:50".to_time.utc_offset) - assert_equal 0, "2005-02-27 22:50 -0100".to_time(:utc).utc_offset - assert_equal(-18000, "2005-02-27 22:50 -0100".to_time.utc_offset) - end + assert_equal 0, "2005-02-27 23:50".to_time(:utc).utc_offset + assert_equal(-18000, "2005-02-27 23:50".to_time.utc_offset) + assert_equal 0, "2005-02-27 22:50 -0100".to_time(:utc).utc_offset + assert_equal(-3600, "2005-02-27 22:50 -0100".to_time.utc_offset) end end @@ -1102,12 +1095,6 @@ def to_s assert_equal expected, ERB::Util.html_escape(string) end - test "ERB::Util.html_escape should correctly handle invalid UTF-8 strings" do - string = "\251 <" - expected = "© <" - assert_equal expected, ERB::Util.html_escape(string) - end - test "ERB::Util.html_escape should not escape safe strings" do string = "hello".html_safe assert_equal string, ERB::Util.html_escape(string) @@ -1121,12 +1108,6 @@ def to_s assert_equal escaped_string, ERB::Util.html_escape_once(escaped_string) end - test "ERB::Util.html_escape_once should correctly handle invalid UTF-8 strings" do - string = "\251 <" - expected = "© <" - assert_equal expected, ERB::Util.html_escape_once(string) - end - test "ERB::Util.xml_name_escape should escape unsafe characters for XML names" do unsafe_char = ">" safe_char = "Á" diff --git a/activesupport/test/core_ext/time_ext_test.rb b/activesupport/test/core_ext/time_ext_test.rb index 681d4b148d46c..7d4cb4c95633b 100644 --- a/activesupport/test/core_ext/time_ext_test.rb +++ b/activesupport/test/core_ext/time_ext_test.rb @@ -282,12 +282,6 @@ def test_daylight_savings_time_crossings_backward_end_1day end end - def test_since_with_instance_of_time_deprecated - assert_deprecated(ActiveSupport.deprecator) do - Time.now.since(Time.now) - end - end - def test_since assert_equal Time.local(2005, 2, 22, 10, 10, 11), Time.local(2005, 2, 22, 10, 10, 10).since(1) assert_equal Time.local(2005, 2, 22, 11, 10, 10), Time.local(2005, 2, 22, 10, 10, 10).since(3600) diff --git a/activesupport/test/core_ext/time_with_zone_test.rb b/activesupport/test/core_ext/time_with_zone_test.rb index 6ef7027704417..7b964b2b6819e 100644 --- a/activesupport/test/core_ext/time_with_zone_test.rb +++ b/activesupport/test/core_ext/time_with_zone_test.rb @@ -172,6 +172,13 @@ def test_xmlschema_with_nil_fractional_seconds assert_equal "1999-12-31T19:00:00-05:00", @twz.xmlschema(nil) end + def test_xmlschema_with_datetime_local_time + tz = ActiveSupport::TimeZone["America/New_York"] + twz = ActiveSupport::TimeWithZone.new(nil, tz, DateTime.new(2025, 11, 7, 12)) + + assert_equal "2025-11-07T12:00:00-05:00", twz.xmlschema + end + def test_iso8601_with_fractional_seconds @twz += Rational(1, 8) assert_equal "1999-12-31T19:00:00.125-05:00", @twz.iso8601(3) @@ -400,13 +407,6 @@ def test_no_limit_on_times assert_equal [0, 0, 19, 31, 12, -8001], (twz - 10_000.years).to_a[0, 6] end - def test_plus_two_time_instances_raises_deprecation_warning - twz = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 1), @time_zone) - assert_deprecated(ActiveSupport.deprecator) do - twz + 10.days.ago - end - end - def test_plus_with_invalid_argument twz = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 1), @time_zone) assert_not_deprecated(ActiveSupport.deprecator) do @@ -451,13 +451,11 @@ def test_minus_with_time_with_zone end def test_minus_with_time_with_zone_without_preserve_configured - with_preserve_timezone(nil) do - twz1 = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 1), ActiveSupport::TimeZone["UTC"]) - twz2 = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 2), ActiveSupport::TimeZone["UTC"]) + twz1 = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 1), ActiveSupport::TimeZone["UTC"]) + twz2 = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 2), ActiveSupport::TimeZone["UTC"]) - difference = assert_not_deprecated(ActiveSupport.deprecator) { twz2 - twz1 } - assert_equal 86_400.0, difference - end + difference = assert_not_deprecated(ActiveSupport.deprecator) { twz2 - twz1 } + assert_equal 86_400.0, difference end def test_minus_with_time_with_zone_precision @@ -544,75 +542,15 @@ def test_time_at assert_equal time, Time.at(time) end - def test_to_time_with_preserve_timezone_using_zone - with_preserve_timezone(:zone) do - time = @twz.to_time - local_time = with_env_tz("US/Eastern") { Time.local(1999, 12, 31, 19) } + def test_to_time_preserve_timezone + time = @twz.to_time + local_time = with_env_tz("US/Eastern") { Time.local(1999, 12, 31, 19) } - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal local_time, time - assert_equal local_time.utc_offset, time.utc_offset - assert_equal @time_zone, time.zone - end - end - - def test_to_time_with_preserve_timezone_using_offset - with_preserve_timezone(:offset) do - with_env_tz "US/Eastern" do - time = @twz.to_time - - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal Time.local(1999, 12, 31, 19), time - assert_equal Time.local(1999, 12, 31, 19).utc_offset, time.utc_offset - assert_nil time.zone - end - end - end - - def test_to_time_with_preserve_timezone_using_true - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - time = @twz.to_time - - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal Time.local(1999, 12, 31, 19), time - assert_equal Time.local(1999, 12, 31, 19).utc_offset, time.utc_offset - assert_nil time.zone - end - end - end - - def test_to_time_without_preserve_timezone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - time = @twz.to_time - - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal Time.local(1999, 12, 31, 19), time - assert_equal Time.local(1999, 12, 31, 19).utc_offset, time.utc_offset - assert_equal Time.local(1999, 12, 31, 19).zone, time.zone - end - end - end - - def test_to_time_without_preserve_timezone_configured - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - time = assert_deprecated(ActiveSupport.deprecator) { @twz.to_time } - - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal Time.local(1999, 12, 31, 19), time - assert_equal Time.local(1999, 12, 31, 19).utc_offset, time.utc_offset - assert_equal Time.local(1999, 12, 31, 19).zone, time.zone - - assert_equal false, ActiveSupport.to_time_preserves_timezone - end - end + assert_equal Time, time.class + assert_equal time.object_id, @twz.to_time.object_id + assert_equal local_time, time + assert_equal local_time.utc_offset, time.utc_offset + assert_equal @time_zone, time.zone end def test_to_date @@ -736,12 +674,6 @@ def test_nsec_returns_sec_fraction_when_datetime_is_wrapped assert_equal 500000000, twz.nsec end - def test_utc_to_local_conversion_saves_period_in_instance_variable - assert_nil @twz.instance_variable_get("@period") - @twz.time - assert_kind_of TZInfo::TimezonePeriod, @twz.instance_variable_get("@period") - end - def test_instance_created_with_local_time_returns_correct_utc_time twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(1999, 12, 31, 19)) assert_equal Time.utc(2000), twz.utc @@ -861,7 +793,7 @@ def test_beginning_of_minute utc = Time.utc(2000, 1, 1, 0, 30, 10) twz = ActiveSupport::TimeWithZone.new(utc, @time_zone) assert_equal "1999-12-31 19:30:10.000000000 EST -05:00", twz.inspect - assert_equal "1999-12-31 19:00:00.000000000 EST -05:00", twz.beginning_of_hour.inspect + assert_equal "1999-12-31 19:30:00.000000000 EST -05:00", twz.beginning_of_minute.inspect end def test_end_of_minute diff --git a/activesupport/test/current_attributes_test.rb b/activesupport/test/current_attributes_test.rb index 56d1c225e37a0..fdcfbe0cb7e24 100644 --- a/activesupport/test/current_attributes_test.rb +++ b/activesupport/test/current_attributes_test.rb @@ -66,9 +66,6 @@ class Session < ActiveSupport::CurrentAttributes attribute :current, :previous end - # Eagerly set-up `instance`s by reference. - [ Current.instance, Session.instance ] - # Use library specific minitest hook to catch Time.zone before reset is called via TestHelper def before_setup @original_time_zone = Time.zone @@ -276,4 +273,81 @@ def foo; end # Sets the cache because of a `method_added` hook assert_instance_of(Hash, current.bar) end + + test "instance delegators are eagerly defined" do + current = Class.new(ActiveSupport::CurrentAttributes) do + def self.name + "MyCurrent" + end + + def regular + :regular + end + + attribute :attr, default: :att + end + + assert current.singleton_class.method_defined?(:attr) + assert current.singleton_class.method_defined?(:attr=) + assert current.singleton_class.method_defined?(:regular) + end + + test "attribute delegators have precise signature" do + current = Class.new(ActiveSupport::CurrentAttributes) do + def self.name + "MyCurrent" + end + + attribute :attr, default: :att + end + + assert_equal [], current.method(:attr).parameters + assert_equal [[:req, :value]], current.method(:attr=).parameters + end + + + test "set and restore attributes when re-entering the executor" do + ActiveSupport::ExecutionContext.with(nestable: true) do + # simulate executor hooks from active_support/railtie.rb + executor = Class.new(ActiveSupport::Executor) + executor.to_run do + ActiveSupport::ExecutionContext.push + end + + executor.to_complete do + ActiveSupport::CurrentAttributes.clear_all + ActiveSupport::ExecutionContext.pop + end + + Current.world = "world/1" + Current.account = "account/1" + + assert_equal "world/1", Current.world + assert_equal "account/1", Current.account + + Current.set(world: "world/2", account: "account/2") do + assert_equal "world/2", Current.world + assert_equal "account/2", Current.account + + executor.wrap do + assert_nil Current.world + assert_nil Current.account + + Current.world = "world/3" + Current.account = "account/3" + + assert_equal "world/3", Current.world + assert_equal "account/3", Current.account + + ActiveSupport::CurrentAttributes.clear_all + + assert_nil Current.world + assert_nil Current.account + end + end + + assert_equal "world/1", Current.world + assert_equal "account/1", Current.account + end + end end diff --git a/activesupport/test/editor_test.rb b/activesupport/test/editor_test.rb new file mode 100644 index 0000000000000..946c495967a04 --- /dev/null +++ b/activesupport/test/editor_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "abstract_unit" + +module ActiveSupport + class EditorTest < ActiveSupport::TestCase + setup do + Editor.reset + end + + teardown do + Editor.reset + end + + def test_current + with_env("EDITOR" => nil, "RAILS_EDITOR" => nil) do + assert_nil Editor.current + end + + with_env("EDITOR" => "mate", "RAILS_EDITOR" => nil) do + assert_equal "txmt://open?url=file://foo.rb&line=42", Editor.current.url_for("foo.rb", 42) + end + + with_env("EDITOR" => "mate", "RAILS_EDITOR" => "unknown") do + assert_nil Editor.current + end + + with_env("EDITOR" => "code", "RAILS_EDITOR" => "mate") do + assert_equal "txmt://open?url=file://foo.rb&line=42", Editor.current.url_for("foo.rb", 42) + end + end + + private + def with_env(kv) + old_values = {} + kv.each { |key, value| old_values[key], ENV[key] = ENV[key], value } + yield + ensure + old_values.each { |key, value| ENV[key] = value } + Editor.reset + end + end +end diff --git a/activesupport/test/error_reporter_test.rb b/activesupport/test/error_reporter_test.rb index 8d904a29bd357..af1f7c192205c 100644 --- a/activesupport/test/error_reporter_test.rb +++ b/activesupport/test/error_reporter_test.rb @@ -166,9 +166,21 @@ class ErrorReporterTest < ActiveSupport::TestCase assert_nil error.backtrace assert_nil error.backtrace_locations - assert_nil @reporter.report(error) - assert_not_predicate error.backtrace, :empty? - assert_not_predicate error.backtrace_locations, :empty? + @reporter.report(error) + + assert error.backtrace.first.start_with?(__FILE__) + assert_equal __FILE__, error.backtrace_locations.first.path + end + + test "#report assigns a cause if it's missing" do + raise "the original cause" + rescue => cause + new_error = StandardError.new("A new error that should wrap the StandardError") + assert_nil new_error.cause + + @reporter.report(new_error) + + assert_same cause, new_error.cause end test "#record passes through the return value" do diff --git a/activesupport/test/event_reporter/log_subscriber_test.rb b/activesupport/test/event_reporter/log_subscriber_test.rb new file mode 100644 index 0000000000000..effb41d7e6383 --- /dev/null +++ b/activesupport/test/event_reporter/log_subscriber_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative "../abstract_unit" +require "active_support/log_subscriber/test_helper" + +class ActiveSupport::EventReporter::LogSubscriberTest < ActiveSupport::TestCase + class MyLogSubscriber < ActiveSupport::EventReporter::LogSubscriber + self.namespace = "test" + + def debug_only(event) + debug "hello #{event[:name]}" + end + event_log_level :debug_only, :debug + + def info_only(event) + info "hello #{event[:name]}" + end + event_log_level :info_only, :info + + def error_only(event) + error "hello #{event[:name]}" + end + event_log_level :error_only, :error + end + + setup do + @old_logger = nil + @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + @log_subscriber = MyLogSubscriber.new + MyLogSubscriber.logger = @logger + ActiveSupport.event_reporter.subscribe(@log_subscriber, &MyLogSubscriber.subscription_filter) + end + + teardown do + MyLogSubscriber.logger = @old_logger + ActiveSupport.event_reporter.unsubscribe(@log_subscriber) + end + + test "info logging" do + ActiveSupport.event_reporter.notify("test.info_only") + assert_equal ["hello test.info_only"], @logger.logged(:info) + end + + test "error logging" do + ActiveSupport.event_reporter.notify("test.error_only") + assert_equal ["hello test.error_only"], @logger.logged(:error) + end + + test "debug logging" do + ActiveSupport.event_reporter.notify("test.debug_only") + assert_equal ["hello test.debug_only"], @logger.logged(:debug) + end + + test "filtered logging" do + @logger.level = :info + ActiveSupport.event_reporter.notify("test.debug_only") + assert_empty @logger.logged(:debug) + end + + test "default logger" do + MyLogSubscriber.logger = nil + + assert_raises(NotImplementedError) do + MyLogSubscriber.logger + end + + subclass = Class.new(MyLogSubscriber) do + def self.default_logger + ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + end + end + + assert_instance_of(ActiveSupport::LogSubscriber::TestHelper::MockLogger, subclass.logger) + end + + test ".subscription_filter" do + event_reporter_raise_on_error do + ActiveSupport.event_reporter.notify("other_namespace_that_shouldnt_work.info_only") + assert_equal [], @logger.logged(:info) + + ActiveSupport.event_reporter.notify("no_namespace_info_only") + assert_equal [], @logger.logged(:info) + end + end + + private + def event_reporter_raise_on_error + ActiveSupport.event_reporter.raise_on_error = true + yield + ensure + ActiveSupport.event_reporter.raise_on_error = false + end +end diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb new file mode 100644 index 0000000000000..3e961dbe79756 --- /dev/null +++ b/activesupport/test/event_reporter_test.rb @@ -0,0 +1,653 @@ +# frozen_string_literal: true + +require_relative "abstract_unit" +require "active_support/event_reporter/test_helper" +require "json" + +module ActiveSupport + class EventReporterTest < ActiveSupport::TestCase + include EventReporter::TestHelper + + setup do + @subscriber = EventReporter::TestHelper::EventSubscriber.new + @reporter = EventReporter.new(@subscriber, raise_on_error: true) + end + + class TestEvent + def initialize(data) + @data = data + end + end + + class HttpRequestTag + def initialize(http_method, http_status) + @http_method = http_method + @http_status = http_status + end + end + + class LoggingAbstraction + def initialize(reporter) + @reporter = reporter + end + + def a_log_method(message) + @reporter.notify(:custom_event, caller_depth: 2, message: message) + end + + def a_debug_method(message) + @reporter.debug(:custom_event, caller_depth: 2, message: message) + end + end + + class ErrorSubscriber + def emit(event) + raise StandardError.new("Uh oh!") + end + end + + test "#subscribe" do + reporter = ActiveSupport::EventReporter.new + subscribers = reporter.subscribe(@subscriber) + assert_equal([{ subscriber: @subscriber, filter: nil }], subscribers) + end + + test "#subscribe with filter" do + reporter = ActiveSupport::EventReporter.new + + filter = ->(event) { event[:name].start_with?("user.") } + subscribers = reporter.subscribe(@subscriber, &filter) + + assert_equal([{ subscriber: @subscriber, filter: filter }], subscribers) + end + + test "#subscribe raises ArgumentError when sink doesn't respond to emit" do + invalid_subscriber = Object.new + + error = assert_raises(ArgumentError) do + @reporter.subscribe(invalid_subscriber) + end + + assert_equal "Event subscriber Object must respond to #emit", error.message + end + + test "#unsubscribe" do + first_subscriber = @subscriber + second_subscriber = EventSubscriber.new + + @reporter.subscribe(second_subscriber) + @reporter.notify(:test_event, key: "value") + + assert event_matcher(name: "test_event", payload: { key: "value" }).call(second_subscriber.events.last) + + @reporter.unsubscribe(second_subscriber) + + assert_not_called(second_subscriber, :emit, [ + event_matcher(name: "another_event") + ]) do + @reporter.notify(:another_event, key: "value") + end + + assert event_matcher(name: "another_event", payload: { key: "value" }).call(first_subscriber.events.last) + + @reporter.unsubscribe(EventSubscriber) + @reporter.notify(:last_event, key: "value") + + assert_empty first_subscriber.events.select(&event_matcher(name: "last_event", payload: { key: "value" })) + assert_empty second_subscriber.events.select(&event_matcher(name: "last_event", payload: { key: "value" })) + end + + test "#notify with name" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event") + ]) do + @reporter.notify(:test_event) + end + end + + test "#notify filters" do + reporter = ActiveSupport::EventReporter.new + reporter.subscribe(@subscriber) { |event| event[:name].start_with?("user_") } + + assert_not_called(@subscriber, :emit) do + reporter.notify(:test_event) + end + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "user_event") + ]) do + reporter.notify(:user_event) + end + end + + test "#notify with name and hash payload" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }) + ]) do + @reporter.notify(:test_event, { key: "value" }) + end + end + + test "#notify with name and kwargs" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + + test "#notify symbolizes keys in hash payload" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }) + ]) do + @reporter.notify(:test_event, { "key" => "value" }) + end + end + + test "#notify with hash payload and kwargs raises" do + error = assert_raises(ArgumentError) do + @reporter.notify(:test_event, { key: "value" }, extra: "arg") + end + + assert_match( + /Rails.event.notify accepts either an event object, a payload hash, or keyword arguments/, + error.message + ) + end + + test "#notify includes source location in event payload" do + filepath = __FILE__ + lineno = __LINE__ + 4 + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", source_location: { filepath:, lineno: }) + ]) do + @reporter.notify("test_event") + end + end + + test "#notify with caller depth option" do + logging_abstraction = LoggingAbstraction.new(@reporter) + filepath = __FILE__ + lineno = __LINE__ + 4 + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "custom_event", payload: { message: "hello" }, source_location: { filepath:, lineno: }) + ]) do + logging_abstraction.a_log_method("hello") + end + end + + test "#notify with event object" do + event = TestEvent.new("value") + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: TestEvent.name, payload: event) + ]) do + @reporter.notify(event) + end + end + + test "#notify with event object and kwargs raises when raise_on_error is true" do + event = TestEvent.new("value") + error = assert_raises(ArgumentError) do + @reporter.notify(event, extra: "arg") + end + + assert_match( + /Rails.event.notify accepts either an event object, a payload hash, or keyword arguments/, + error.message + ) + end + + test "#notify with event object and hash payload raises when raise_on_error is true" do + event = TestEvent.new("value") + error = assert_raises(ArgumentError) do + @reporter.notify(event, { extra: "arg" }) + rescue RailsStrictWarnings::WarningError => _e + # Expected warning + end + + assert_match( + /Rails.event.notify accepts either an event object, a payload hash, or keyword arguments/, + error.message + ) + end + + test "#notify with event object and kwargs warns when raise_on_error is false" do + @reporter = EventReporter.new(@subscriber, raise_on_error: false) + + event = TestEvent.new("value") + + error_report = assert_error_reported do + assert_called_with(@subscriber, :emit, [event_matcher(name: TestEvent.name, payload: event)]) do + @reporter.notify(event, extra: "arg") + end + end + + err = error_report.error.message + assert_match(/Rails.event.notify accepts either an event object, a payload hash, or keyword arguments/, err) + end + + test "#notify warns about subscriber errors when raise_on_error is false" do + @reporter = EventReporter.new(@subscriber, raise_on_error: false) + + @reporter.subscribe(ErrorSubscriber.new) + + error_report = assert_error_reported do + @reporter.notify(:test_event) + end + assert_equal "Uh oh!", error_report.error.message + end + + test "#notify raises subscriber errors when raise_on_error is true" do + @reporter.subscribe(ErrorSubscriber.new) + + error = assert_raises(StandardError) do + @reporter.notify(:test_event) + end + + assert_equal("Uh oh!", error.message) + end + + test "#notify with filtered payloads" do + filter = ActiveSupport::ParameterFilter.new([:zomg], mask: "[FILTERED]") + @reporter.stub(:payload_filter, filter) do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value", zomg: "[FILTERED]" }) + ]) do + @reporter.notify(:test_event, { key: "value", zomg: "secret" }) + end + end + end + + test "default filter_parameters is used by default" do + old_filter_parameters = ActiveSupport.filter_parameters + ActiveSupport.filter_parameters = [:secret] + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value", secret: "[FILTERED]" }) + ]) do + @reporter.notify(:test_event, { key: "value", secret: "hello" }) + end + ensure + ActiveSupport.filter_parameters = old_filter_parameters + end + + test ".filter_parameters is used when present" do + old_filter_parameters = EventReporter.filter_parameters + EventReporter.filter_parameters = [:foo] + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value", foo: "[FILTERED]" }) + ]) do + @reporter.notify(:test_event, { key: "value", foo: "hello" }) + end + ensure + EventReporter.filter_parameters = old_filter_parameters + end + + test "#with_debug" do + @reporter.with_debug do + assert_predicate @reporter, :debug_mode? + end + assert_not_predicate @reporter, :debug_mode? + end + + test "#debug_mode? returns true when debug_mode=true is set" do + @reporter.debug_mode = true + assert_predicate @reporter, :debug_mode? + end + + test "#with_debug works with nested calls" do + @reporter.with_debug do + assert_predicate @reporter, :debug_mode? + + @reporter.with_debug do + assert_predicate @reporter, :debug_mode? + end + + assert_predicate @reporter, :debug_mode? + end + end + + test "#debug emits when in debug mode" do + @reporter.with_debug do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }) + ]) do + @reporter.debug(:test_event, key: "value") + end + end + end + + test "#debug with caller depth" do + logging_abstraction = LoggingAbstraction.new(@reporter) + filepath = __FILE__ + lineno = __LINE__ + 4 + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "custom_event", payload: { message: "hello" }, source_location: { filepath:, lineno: }) + ]) do + @reporter.with_debug { logging_abstraction.a_debug_method("hello") } + end + end + + test "#debug emits in debug mode with block" do + @reporter.with_debug do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { slow_to_compute: "value" }) + ]) do + @reporter.debug(:test_event) do + { slow_to_compute: "value" } + end + end + end + end + + test "#debug does not emit when not in debug mode" do + assert_not_called(@subscriber, :emit) do + @reporter.debug(:test_event, key: "value") + end + end + + test "#debug with block merges kwargs" do + @reporter.with_debug do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value", slow_to_compute: "another_value" }) + ]) do + @reporter.debug(:test_event, key: "value") do + { slow_to_compute: "another_value" } + end + end + end + end + + test "#tagged adds tags to the emitted event" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "admin" }) + ]) do + @reporter.tagged(section: "admin") do + @reporter.notify(:test_event, key: "value") + end + end + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "checkouts" }) + ]) do + @reporter.tagged({ section: "checkouts" }) do + @reporter.notify(:test_event, key: "value") + end + end + end + + test "#tagged with nested tags" do + @reporter.tagged(section: "admin") do + @reporter.tagged(nested: "tag") do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "admin", nested: "tag" }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + @reporter.tagged(hello: "world") do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "admin", hello: "world" }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end + end + + test "#tagged with boolean tags" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { is_for_testing: true }) + ]) do + @reporter.tagged(:is_for_testing) do + @reporter.notify(:test_event, key: "value") + end + end + end + + test "#tagged can overwrite values on collision" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "checkouts" }) + ]) do + @reporter.tagged(section: "admin") do + @reporter.tagged(section: "checkouts") do + @reporter.notify(:test_event, key: "value") + end + end + end + end + + test "#tagged with tag object" do + http_tag = HttpRequestTag.new("GET", 200) + + @reporter.tagged(http_tag) do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { "#{HttpRequestTag.name}": http_tag }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end + + test "#tagged with mixed tags" do + http_tag = HttpRequestTag.new("GET", 200) + @reporter.tagged("foobar", http_tag, shop_id: 123) do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { foobar: true, "#{HttpRequestTag.name}": http_tag, shop_id: 123 }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end + + test "#tagged copies tag stack from parent fiber without mutating parent's tag stack" do + @reporter.tagged(shop_id: 999) do + Fiber.new do + @reporter.tagged(shop_id: 123) do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { shop_id: 123 }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end.resume + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "parent_event", payload: { key: "parent" }, tags: { shop_id: 999 }) + ]) do + @reporter.notify(:parent_event, key: "parent") + end + end + end + + test "#tagged maintains isolation between concurrent fibers" do + @reporter.tagged(shop_id: 123) do + fiber = Fiber.new do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "child_event", payload: { key: "value" }, tags: { shop_id: 123 }) + ]) do + @reporter.notify(:child_event, key: "value") + end + end + + @reporter.tagged(api_client_id: 456) do + fiber.resume + + # Verify parent fiber has both tags + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "parent_event", payload: { key: "parent" }, tags: { shop_id: 123, api_client_id: 456 }) + ]) do + @reporter.notify(:parent_event, key: "parent") + end + end + end + end + end + + class ContextStoreTest < ActiveSupport::TestCase + include EventReporter::TestHelper + + setup do + @subscriber = EventReporter::TestHelper::EventSubscriber.new + @reporter = EventReporter.new(@subscriber, raise_on_error: true) + end + + teardown do + EventContext.clear + end + + test "#context returns empty hash by default" do + assert_equal({}, @reporter.context) + end + + test "#set_context sets context data" do + @reporter.set_context(shop_id: 123) + assert_equal({ shop_id: 123 }, @reporter.context) + end + + test "#set_context merges with existing context" do + @reporter.set_context(shop_id: 123) + @reporter.set_context(user_id: 456) + assert_equal({ shop_id: 123, user_id: 456 }, @reporter.context) + end + + test "#set_context overwrites existing keys" do + @reporter.set_context(shop_id: 123) + @reporter.set_context(shop_id: 456) + assert_equal({ shop_id: 456 }, @reporter.context) + end + + test "#set_context with string keys converts them to symbols" do + @reporter.set_context("shop_id" => 123) + assert_equal({ shop_id: 123 }, @reporter.context) + end + + test "#clear_context removes all context data" do + @reporter.set_context(shop_id: 123, user_id: 456) + @reporter.clear_context + assert_equal({}, @reporter.context) + end + + test "#notify includes context in event" do + @reporter.set_context(shop_id: 123) + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: {}, context: { shop_id: 123 }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + + test "#context inherited by child fibers without mutating parent's context" do + @reporter.set_context(shop_id: 999) + Fiber.new do + @reporter.set_context(shop_id: 123) + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", context: { shop_id: 123 }) + ]) do + @reporter.notify(:test_event) + end + end.resume + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "parent_event", payload: { key: "parent" }, context: { shop_id: 999 }) + ]) do + @reporter.notify(:parent_event, key: "parent") + end + end + + test "#context isolated between concurrent fibers" do + @reporter.set_context(shop_id: 123) + fiber = Fiber.new do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "child_event", context: { shop_id: 123 }) + ]) do + @reporter.notify(:child_event) + end + end + + @reporter.set_context(api_client_id: 456) + fiber.resume + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "parent_event", context: { shop_id: 123, api_client_id: 456 }) + ]) do + @reporter.notify(:parent_event) + end + end + + test "context is preserved when using #tagged" do + @reporter.set_context(shop_id: 123) + + @reporter.tagged(request_id: "abc") do + assert_equal({ shop_id: 123 }, @reporter.context) + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { request_id: "abc" }, context: { shop_id: 123 }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end + + test "payload filter reloading" do + @reporter.notify(:some_event, test: true) + ActiveSupport.filter_parameters << :param_to_be_filtered + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "some_event", payload: { param_to_be_filtered: "test" }) + ]) do + @reporter.notify(:some_event, param_to_be_filtered: "test") + end + + @reporter.reload_payload_filter + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "some_event", payload: { param_to_be_filtered: "[FILTERED]" }) + ]) do + @reporter.notify(:some_event, param_to_be_filtered: "test") + end + ensure + ActiveSupport.filter_parameters.pop + end + end + + class EncodersTest < ActiveSupport::TestCase + class TestEvent + def initialize(data) + @data = data + end + + def to_h + { data: @data } + end + end + + class HttpRequestTag + def initialize(http_method, http_status) + @http_method = http_method + @http_status = http_status + end + + def to_h + { + http_method: @http_method, + http_status: @http_status, + } + end + end + + setup do + @event = { + name: "test_event", + payload: { id: 123, message: "hello" }, + tags: { section: "admin" }, + context: { user_id: 456 }, + timestamp: 1738964843208679035, + source_location: { filepath: "/path/to/file.rb", lineno: 42, label: "test_method" } + } + end + end +end diff --git a/activesupport/test/file_update_checker_shared_tests.rb b/activesupport/test/file_update_checker_shared_tests.rb index 505c81748a054..1ad53f840a092 100644 --- a/activesupport/test/file_update_checker_shared_tests.rb +++ b/activesupport/test/file_update_checker_shared_tests.rb @@ -33,6 +33,51 @@ def run(*args) assert_equal 0, i end + test "should exclude files in gem path" do + fake_gem_dir = File.join(tmpdir, "gemdir") + FileUtils.mkdir_p(fake_gem_dir) + gem_file = File.join(fake_gem_dir, "foo.rb") + local_file = tmpfile("bar.rb") + touched = [] + + Gem.stub(:path, [fake_gem_dir]) do + checker = new_checker([gem_file, local_file]) { touched << :called } + + touch(local_file) + assert checker.execute_if_updated + assert_equal [:called], touched + + touched.clear + touch(gem_file) + assert_not checker.execute_if_updated + assert_empty touched + end + end + + test "should exclude directories in gem path" do + local_dir = Dir.mktmpdir + fake_gem_dir = Dir.mktmpdir + local_file = File.join(local_dir, "foo.rb") + gem_file = File.join(fake_gem_dir, "bar.rb") + touched = [] + + Gem.stub(:path, [fake_gem_dir]) do + checker = new_checker([], { fake_gem_dir => [], local_dir => [] }) { touched << :called } + + touch(local_file) + assert checker.execute_if_updated + assert_equal [:called], touched + + touched.clear + touch(gem_file) + assert_not checker.execute_if_updated + assert_empty touched + end + ensure + FileUtils.remove_entry(local_dir) + FileUtils.remove_entry(fake_gem_dir) + end + test "should not execute the block if no files change" do i = 0 diff --git a/activesupport/test/file_update_checker_test.rb b/activesupport/test/file_update_checker_test.rb index 987cb56672182..1c9abfb160fa2 100644 --- a/activesupport/test/file_update_checker_test.rb +++ b/activesupport/test/file_update_checker_test.rb @@ -14,4 +14,35 @@ def touch(files) sleep 0.1 # let's wait a bit to ensure there's a new mtime super end + + test "should not reload files that appear in the future due to time travel" do + i = 0 + + checker = new_checker(tmpfiles) { i += 1 } + touch(tmpfiles) + + assert checker.execute_if_updated + assert_equal 1, i + + original_last_update_at = checker.instance_variable_get(:@last_update_at) + assert original_last_update_at > Time.utc(2020, 1, 1), "Expected @last_update_at to be recent" + + # Travel to the past, making current files appear to be in the future + travel_to Time.utc(2020, 1, 1) do + # With the old implementation with Time.new, this would corrupt @last_update_at to Time.at(0) + # because max_mtime would use stubbed Time.now and skip all files as "future" in max_mtime, returning nil. + # With Process.clock_gettime, the state should be preserved during time travel. + checker.execute + end + + assert_not checker.updated?, "Should not reload after time travel when state is preserved" + + final_state = checker.instance_variable_get(:@last_update_at) + assert_not_equal Time.at(0), final_state, + "State should not be corrupted after time travel" + + touch(tmpfiles) + assert checker.execute_if_updated + assert_equal 3, i + end end diff --git a/activesupport/test/gzip_test.rb b/activesupport/test/gzip_test.rb index c0dc93f3b6a7f..f507800ede324 100644 --- a/activesupport/test/gzip_test.rb +++ b/activesupport/test/gzip_test.rb @@ -42,4 +42,9 @@ def test_decompress_checks_crc ActiveSupport::Gzip.decompress(compressed) end end + + def test_sets_mtime_to_zero + compressed = ActiveSupport::Gzip.compress("Hello World") + assert_equal Time.at(0), Zlib::GzipReader.new(StringIO.new(compressed)).mtime + end end diff --git a/activesupport/test/hash_with_indifferent_access_test.rb b/activesupport/test/hash_with_indifferent_access_test.rb index 0a3771c9244f1..cb436073316be 100644 --- a/activesupport/test/hash_with_indifferent_access_test.rb +++ b/activesupport/test/hash_with_indifferent_access_test.rb @@ -473,9 +473,31 @@ def test_indifferent_transform_keys assert_equal(["A", "bbb"], hash.keys) # asserting that order of keys is unchanged assert_instance_of ActiveSupport::HashWithIndifferentAccess, hash + hash = ActiveSupport::HashWithIndifferentAccess.new(@integers).transform_keys { |k| k + 1 } + + assert_equal([1, 2], hash.keys) + + repeating_strings = { "a" => 1, "aa" => 2, "aaa" => 3 } + + hash = ActiveSupport::HashWithIndifferentAccess.new(repeating_strings).transform_keys { |k| "#{k}a" } + + assert_equal(%w[aa aaa aaaa], hash.keys) + assert_raise TypeError do hash.transform_keys(nil) end + + hash_with_default = Hash.new(:a) + hash = ActiveSupport::HashWithIndifferentAccess.new(hash_with_default).transform_keys(&:to_s) + assert_nil hash.default + hash = ActiveSupport::HashWithIndifferentAccess.new(hash_with_default).transform_keys { |k| k.to_s } + assert_nil hash.default + + hash_with_default_proc = Hash.new { |h, k| h[k] = :b } + hash = ActiveSupport::HashWithIndifferentAccess.new(hash_with_default_proc).transform_keys(&:to_s) + assert_nil hash.default_proc + hash = ActiveSupport::HashWithIndifferentAccess.new(hash_with_default_proc).transform_keys { |k| k.to_s } + assert_nil hash.default_proc end def test_indifferent_deep_transform_keys @@ -530,6 +552,23 @@ def test_indifferent_transform_keys_bang assert_raise TypeError do hash.transform_keys(nil) end + + hash_with_default = Hash.new(:a) + hash = ActiveSupport::HashWithIndifferentAccess.new(hash_with_default).transform_keys!(&:to_s) + assert_equal :a, hash.default + assert_equal :a, hash_with_default.default + + hash = ActiveSupport::HashWithIndifferentAccess.new(hash_with_default).transform_keys! { |k| k.to_s } + assert_equal :a, hash.default + assert_equal :a, hash_with_default.default + + hash_with_default_proc = Hash.new { |h, k| h[k] = :b } + default_proc = hash_with_default_proc.default_proc + + hash = ActiveSupport::HashWithIndifferentAccess.new(hash_with_default_proc).transform_keys!(&:to_s) + assert_equal default_proc, hash.default_proc + hash = ActiveSupport::HashWithIndifferentAccess.new(hash_with_default_proc).transform_keys! { |k| k.to_s } + assert_equal default_proc, hash.default_proc end def test_indifferent_deep_transform_keys_bang diff --git a/activesupport/test/inflector_test.rb b/activesupport/test/inflector_test.rb index b4b20eb630b0a..0bfc30ea4f03a 100644 --- a/activesupport/test/inflector_test.rb +++ b/activesupport/test/inflector_test.rb @@ -16,12 +16,16 @@ def setup # This helper is implemented by setting @__instance__ because in some tests # there are module functions that access ActiveSupport::Inflector.inflections, # so we need to replace the singleton itself. - @original_inflections = ActiveSupport::Inflector::Inflections.instance_variable_get(:@__instance__)[:en] - ActiveSupport::Inflector::Inflections.instance_variable_set(:@__instance__, en: @original_inflections.dup) + @original_inflections = ActiveSupport::Inflector::Inflections.instance_variable_get(:@__instance__) + @original_inflection_en = ActiveSupport::Inflector::Inflections.instance_variable_get(:@__en_instance__) + + ActiveSupport::Inflector::Inflections.instance_variable_set(:@__instance__, {}) + ActiveSupport::Inflector::Inflections.instance_variable_set(:@__en_instance__, @original_inflection_en.dup) end def teardown - ActiveSupport::Inflector::Inflections.instance_variable_set(:@__instance__, en: @original_inflections) + ActiveSupport::Inflector::Inflections.instance_variable_set(:@__instance__, @original_inflections) + ActiveSupport::Inflector::Inflections.instance_variable_set(:@__en_instance__, @original_inflection_en) end def test_pluralize_plurals @@ -426,6 +430,11 @@ def test_humanize_with_acronyms assert_equal("LAX roundtrip to SFO", ActiveSupport::Inflector.humanize("Lax Roundtrip To Sfo", capitalize: false)) end + def test_humanize_with_international_characters + assert_equal("Áéíóú", ActiveSupport::Inflector.humanize("áÉÍÓÚ")) + assert_equal("Абвгде", ActiveSupport::Inflector.humanize("аБВГДЕ")) + end + def test_constantize run_constantize_tests_on do |string| ActiveSupport::Inflector.constantize(string) @@ -623,7 +632,7 @@ def test_clear_all_resets_camelize_and_underscore_regexes assert_equal [], inflect.singulars assert_equal [], inflect.plurals - assert_equal [], inflect.uncountables + assert_equal [], inflect.uncountables.to_a # restore all the inflections singulars.reverse_each { |singular| inflect.singular(*singular) } diff --git a/activesupport/test/json/encoding_test.rb b/activesupport/test/json/encoding_test.rb index d4b4a8d8de095..5a4c0dbed87a9 100644 --- a/activesupport/test/json/encoding_test.rb +++ b/activesupport/test/json/encoding_test.rb @@ -3,6 +3,7 @@ require "securerandom" require_relative "../abstract_unit" require "active_support/core_ext/string/inflections" +require "active_support/core_ext/object/with" require "active_support/json" require "active_support/time" require_relative "../time_zone_test_helpers" @@ -52,6 +53,14 @@ def test_hash_encoding assert_equal %({\"a\":\"b\",\"c\":\"d\"}), sorted_json(ActiveSupport::JSON.encode(a: :b, c: :d)) end + def test_unicode_escape + assert_equal %{{"\\u2028":"\\u2029"}}, ActiveSupport::JSON.encode("\u2028" => "\u2029") + assert_equal %{{"\u2028":"\u2029"}}, ActiveSupport::JSON.encode({ "\u2028" => "\u2029" }, escape: false) + ActiveSupport::JSON::Encoding.with(escape_js_separators_in_json: false) do + assert_equal %{{"\u2028":"\u2029"}}, ActiveSupport::JSON.encode({ "\u2028" => "\u2029" }) + end + end + def test_hash_keys_encoding ActiveSupport.escape_html_entities_in_json = true assert_equal "{\"\\u003c\\u003e\":\"\\u003c\\u003e\"}", ActiveSupport::JSON.encode("<>" => "<>") @@ -511,6 +520,23 @@ def test_as_json_too_recursive end end + def test_no_nesting_error_on_consecutive_encoding_calls + hash = { a: 1 } + assert_equal '{"a":1}', ActiveSupport::JSON.encode(hash) + + # We simulate a circular reference + circular_array = [] + circular_array << circular_array + + assert_raise(SystemStackError, JSON::NestingError) do + ActiveSupport::JSON.encode(circular_array) + end + + # We should be able to continue to generate JSONs as usual after + # encountering a JSON::NestingError + assert_equal '{"a":1}', ActiveSupport::JSON.encode(hash) + end + private def object_keys(json_object) json_object[1..-2].scan(/([^{}:,\s]+):/).flatten.sort diff --git a/activesupport/test/log_subscriber_test.rb b/activesupport/test/log_subscriber_test.rb index 7e774c15e9b95..59e132f058b78 100644 --- a/activesupport/test/log_subscriber_test.rb +++ b/activesupport/test/log_subscriber_test.rb @@ -56,13 +56,13 @@ def test_proxies_method_to_rails_logger end def test_set_color_for_messages - ActiveSupport::LogSubscriber.colorize_logging = true + ActiveSupport.colorize_logging = true @log_subscriber.bar(nil) assert_equal "\e[31mcool\e[0m, \e[1m\e[34misn't it?\e[0m", @logger.logged(:info).last end def test_set_mode_for_messages - ActiveSupport::LogSubscriber.colorize_logging = true + ActiveSupport.colorize_logging = true @log_subscriber.baz(nil) assert_equal "\e[1;4m\e[32mrad\e[0m, \e[3m\e[33misn't it?\e[0m", @logger.logged(:info).last end diff --git a/activesupport/test/logger_test.rb b/activesupport/test/logger_test.rb index e9e6c75f55ca8..b203a82d4ac8f 100644 --- a/activesupport/test/logger_test.rb +++ b/activesupport/test/logger_test.rb @@ -382,6 +382,37 @@ def test_logger_level_thread_safety ActiveSupport::IsolatedExecutionState.isolation_level = previous_isolation_level end + def test_logger_freeze + logger = @logger.clone + logger.freeze + + assert_nothing_raised do + assert_equal Logger::DEBUG, logger.level + + logger.debug "I am frozen 1" + assert_includes @output.string, "I am frozen 1" + + logger.debug { "I am frozen 2" } + assert_includes @output.string, "I am frozen 2" + + logger.add Logger::INFO, "I am frozen 3" + assert_includes @output.string, "I am frozen 3" + + logger.silence do + logger.debug "I am frozen 4" + end + assert_not_includes @output.string, "I am frozen 4" + end + + assert_raises FrozenError do + logger.level = Logger::INFO + end + + assert_raises FrozenError do + logger.formatter = Logger::Formatter.new + end + end + def test_temporarily_logging_at_a_noisier_level @logger.level = Logger::INFO diff --git a/activesupport/test/messages/message_rotator_tests.rb b/activesupport/test/messages/message_rotator_tests.rb index 4980b9be2f35b..1bc9b5df5f5cb 100644 --- a/activesupport/test/messages/message_rotator_tests.rb +++ b/activesupport/test/messages/message_rotator_tests.rb @@ -81,6 +81,22 @@ def self.load(*); raise Class.new(StandardError); end assert_equal DATA, decode(old_message, codec, on_rotation: proc { called += "via method" }) assert_equal "via method", called end + + test "dup isolates rotations" do + foo_codec = make_codec(secret("foo")) + foo_message = encode(DATA, foo_codec) + bar_codec = make_codec(secret("bar")) + bar_message = encode(DATA, bar_codec) + + foobar_codec = foo_codec.dup + foobar_codec.rotate(secret("bar")) + + assert_equal DATA, decode(foo_message, foobar_codec) + assert_equal DATA, decode(bar_message, foobar_codec) + + assert_equal DATA, decode(foo_message, foo_codec) + assert_nil decode(bar_message, foo_codec) + end end private diff --git a/activesupport/test/multibyte_chars_test.rb b/activesupport/test/multibyte_chars_test.rb index 4c9956f9e2db2..e1580f9883ef0 100644 --- a/activesupport/test/multibyte_chars_test.rb +++ b/activesupport/test/multibyte_chars_test.rb @@ -599,7 +599,7 @@ def test_should_compute_grapheme_length ["", 0], ["abc", 3], ["こにちわ", 4], - [[0x0924, 0x094D, 0x0930].pack("U*"), 2], + [[0x0924, 0x094D, 0x0930].pack("U*"), RbConfig::CONFIG["UNICODE_VERSION"] >= "15.1.0" ? 1 : 2], # GB3 [%w(cr lf), 1], # GB4 diff --git a/activesupport/test/parallelization_test.rb b/activesupport/test/parallelization_test.rb new file mode 100644 index 0000000000000..29675b3665958 --- /dev/null +++ b/activesupport/test/parallelization_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative "abstract_unit" + +class ParallelizationTest < ActiveSupport::TestCase + def setup + @original_worker_id = ActiveSupport::TestCase.parallel_worker_id + end + + def teardown + ActiveSupport::TestCase.parallel_worker_id = @original_worker_id + end + + test "parallel_worker_id is accessible as an attribute and method" do + ActiveSupport::TestCase.parallel_worker_id = nil + assert_nil ActiveSupport::TestCase.parallel_worker_id + assert_nil parallel_worker_id + end + + test "parallel_worker_id is set and accessible from class and instance" do + ActiveSupport::TestCase.parallel_worker_id = 3 + + assert_equal 3, ActiveSupport::TestCase.parallel_worker_id + assert_equal 3, parallel_worker_id + end + + test "parallel_worker_id persists across test subclasses" do + ActiveSupport::TestCase.parallel_worker_id = 5 + + subclass = Class.new(ActiveSupport::TestCase) + assert_equal 5, subclass.parallel_worker_id + + instance = subclass.new("test") + assert_equal 5, instance.parallel_worker_id + end + + test "shutdown handles dead workers gracefully" do + parallelization = ActiveSupport::Testing::Parallelization.new(1) + parallelization.start + + sleep 0.25 + + server = parallelization.instance_variable_get(:@queue_server) + assert server.active_workers? + + worker_pids = parallelization.instance_variable_get(:@worker_pool) + Process.kill("KILL", worker_pids.first) + sleep 0.25 + + Timeout.timeout(2.5, Minitest::Assertion, "Expected shutdown to not hang") { parallelization.shutdown } + assert_not server.active_workers? + end +end diff --git a/activesupport/test/structured_event_subscriber_test.rb b/activesupport/test/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..a56fca679ac71 --- /dev/null +++ b/activesupport/test/structured_event_subscriber_test.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require_relative "abstract_unit" +require "active_support/testing/event_reporter_assertions" + +class StructuredEventSubscriberTest < ActiveSupport::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + class TestEventReporterSubscriber + def emit(payload) + end + end + + class TestSubscriber < ActiveSupport::StructuredEventSubscriber + class DebugOnlyError < StandardError + end + + def event(event) + emit_event("test.event", **event.payload) + end + + def debug_only_event(event) + raise DebugOnlyError + end + debug_only :debug_only_event + end + + def setup + @subscriber = TestSubscriber.new + end + + def test_emit_event_calls_event_reporter_notify + event = assert_event_reported("test.event", payload: { key: "value" }) do + @subscriber.emit_event("test.event", { key: "value" }) + end + + assert_equal "test.event", event[:name] + assert_equal({ key: "value" }, event[:payload]) + end + + def test_emit_debug_event_calls_event_reporter_debug + with_debug_event_reporting do + assert_event_reported("test.debug", payload: { debug: "info" }) do + @subscriber.emit_debug_event("test.debug", { debug: "info" }) + end + end + end + + def test_emit_event_handles_errors + ActiveSupport.event_reporter.stub(:notify, proc { raise StandardError, "event error" }) do + error_report = assert_error_reported(StandardError) do + @subscriber.emit_event("test.error") + end + assert_equal "test.error", error_report.source + assert_equal "event error", error_report.error.message + end + end + + def test_emit_debug_event_handles_errors + ActiveSupport.event_reporter.stub(:debug, proc { raise StandardError, "debug error" }) do + error_report = assert_error_reported(StandardError) do + @subscriber.emit_debug_event("test.debug_error") + end + assert_equal "test.debug_error", error_report.source + assert_equal "debug error", error_report.error.message + end + end + + def test_call_handles_errors + ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber + + event = ActiveSupport::Notifications::Event.new("error_event.test", Time.current, Time.current, "123", {}) + + error_report = assert_error_reported(NoMethodError) do + @subscriber.call(event) + end + assert_match(/undefined method (`|')error_event'/, error_report.error.message) + assert_equal "error_event.test", error_report.source + end + + def test_debug_only_methods + ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber + + event_reporter_subscriber = TestEventReporterSubscriber.new + ActiveSupport.event_reporter.subscribe(event_reporter_subscriber) + + assert_no_error_reported do + ActiveSupport::Notifications.instrument("debug_only_event.test") + end + + assert_error_reported(TestSubscriber::DebugOnlyError) do + with_debug_event_reporting do + ActiveSupport::Notifications.instrument("debug_only_event.test") + end + end + ensure + ActiveSupport.event_reporter.unsubscribe(event_reporter_subscriber) + end + + def test_no_event_reporter_subscribers + ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber + + old_subscribers = ActiveSupport.event_reporter.subscribers.dup + ActiveSupport.event_reporter.subscribers.clear + + assert_not_called @subscriber, :emit_event do + ActiveSupport::Notifications.instrument("event.test") + end + ensure + ActiveSupport.event_reporter.subscribers.push(*old_subscribers) + end +end diff --git a/activesupport/test/testing/event_reporter_assertions_test.rb b/activesupport/test/testing/event_reporter_assertions_test.rb new file mode 100644 index 0000000000000..9eda938ce0db4 --- /dev/null +++ b/activesupport/test/testing/event_reporter_assertions_test.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require "active_support/test_case" + +module ActiveSupport + module Testing + class EventReporterAssertionsTest < ActiveSupport::TestCase + setup do + @reporter = ActiveSupport.event_reporter + end + + test "#assert_event_reported" do + assert_event_reported("user.created") do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + + test "#assert_event_reported with payload" do + assert_event_reported("user.created", payload: { id: 123, name: "John Doe" }) do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + + test "#assert_event_reported with tags" do + assert_event_reported("user.created", tags: { graphql: true }) do + @reporter.tagged(:graphql) do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + end + + test "#assert_event_reported partial matching" do + assert_event_reported("user.created", payload: { id: 123 }, tags: { foo: :bar }) do + @reporter.tagged(foo: :bar, baz: :qux) do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + end + + test "#assert_event_reported with regex payload" do + assert_event_reported("user.created", payload: { id: /[0-9]+/ }) do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + + test "#assert_event_reported with regex tags" do + assert_event_reported("user.created", tags: { foo: /bar/ }) do + @reporter.tagged(foo: :bar, baz: :qux) do + @reporter.notify("user.created") + end + end + end + + test "#assert_no_event_reported" do + assert_no_event_reported do + # No events are reported here + end + end + + test "#assert_no_event_reported with provided name" do + assert_no_event_reported("user.created") do + @reporter.notify("another.event") + end + end + + test "#assert_no_event_reported with payload" do + assert_no_event_reported("user.created", payload: { id: 123, name: "Sazz Pataki" }) do + @reporter.notify("user.created", { id: 123, name: "Mabel Mora" }) + end + + assert_no_event_reported("user.created", payload: { name: "Sazz Pataki" }) do + @reporter.notify("user.created") + end + end + + test "#assert_no_event_reported with tags" do + assert_no_event_reported("user.created", tags: { api: true, zip_code: 10003 }) do + @reporter.tagged(api: false, zip_code: 10003) do + @reporter.notify("user.created") + end + end + + assert_no_event_reported("user.created", tags: { api: true }) do + @reporter.notify("user.created") + end + end + + test "#assert_event_reported fails when event is not reported" do + e = assert_raises(Minitest::Assertion) do + assert_event_reported("user.created") do + # No events are reported here + end + end + + assert_equal "Expected an event to be reported, but there were no events reported.", e.message + end + + test "#assert_event_reported fails when different event is reported" do + e = assert_raises(Minitest::Assertion) do + assert_event_reported("user.created", payload: { id: 123 }) do + @reporter.notify("another.event", { id: 123, name: "John Doe" }) + end + end + + assert_match(/Expected an event to be reported matching:/, e.message) + assert_match(/name: "user\.created"/, e.message) + assert_match(/but none of the 1 reported events matched:/, e.message) + assert_match(/another\.event/, e.message) + end + + test "#assert_no_event_reported fails when event is reported" do + payload = { id: 123, name: "John Doe" } + e = assert_raises(Minitest::Assertion) do + assert_no_event_reported("user.created") do + @reporter.notify("user.created", payload) + end + end + + assert_match(/Expected no 'user\.created' event to be reported, but found:/, e.message) + assert_match(/user\.created/, e.message) + end + + test "assert_events_reported" do + assert_events_reported([ + { name: "user.created" }, + { name: "email.sent" } + ]) do + @reporter.notify("user.created", { id: 123 }) + @reporter.notify("email.sent", { to: "user@example.com" }) + end + end + + test "assert_events_reported is order agnostic" do + assert_events_reported([ + { name: "user.created", payload: { id: 123 } }, + { name: "email.sent" } + ]) do + @reporter.notify("email.sent", { to: "user@example.com" }) + @reporter.notify("user.created", { id: 123, name: "John" }) + end + end + + test "assert_events_reported ignores extra events" do + assert_events_reported([ + { name: "user.created", payload: { id: 123 } } + ]) do + @reporter.notify("extra_event_1") + @reporter.notify("user.created", { id: 123, name: "John" }) + @reporter.notify("extra_event_2") + @reporter.notify("extra_event_3") + end + end + + test "assert_events_reported works with empty expected array" do + assert_events_reported([]) do + @reporter.notify("some.event") + end + end + + test "assert_events_reported fails when one event missing" do + e = assert_raises(Minitest::Assertion) do + assert_events_reported([ + { name: "user.created" }, + { name: "email.sent" } + ]) do + @reporter.notify("user.created", { id: 123 }) + @reporter.notify("other.event") + end + end + + assert_match(/Expected an event to be reported matching:/, e.message) + assert_match(/name: "email.sent"/, e.message) + assert_match(/but none of the .* reported events matched:/, e.message) + end + + test "assert_events_reported fails when no events reported" do + e = assert_raises(Minitest::Assertion) do + assert_events_reported([ + { name: "user.created" }, + { name: "email.sent" } + ]) do + # No events reported + end + end + + assert_equal "Expected 2 events to be reported, but there were no events reported.", e.message + end + + test "assert_events_reported fails when expecting duplicate events but only one reported" do + e = assert_raises(Minitest::Assertion) do + assert_events_reported([ + { name: "user.created" }, + { name: "user.created" } # Expecting 2 identical events + ]) do + @reporter.notify("user.created") + end + end + + assert_match(/Expected an event to be reported matching:/, e.message) + assert_match(/name: "user.created"/, e.message) + assert_match(/but none of the 1 reported events matched:/, e.message) + end + + test "assert_events_reported passes when expecting duplicate events and both are reported" do + assert_events_reported([ + { name: "user.created", payload: { id: 123 } }, + { name: "user.created", payload: { id: 123 } } + ]) do + @reporter.notify("user.created", { id: 123 }) + @reporter.notify("user.created", { id: 123 }) + end + end + end + end +end diff --git a/activesupport/test/time_zone_test.rb b/activesupport/test/time_zone_test.rb index 5afda30e22a9d..e2fa0e988863f 100644 --- a/activesupport/test/time_zone_test.rb +++ b/activesupport/test/time_zone_test.rb @@ -920,4 +920,10 @@ def test_works_as_ruby_time_zone assert_equal "EDT", time.strftime("%Z") assert_equal true, time.isdst end + + def test_standard_name + assert_equal "America/New_York", ActiveSupport::TimeZone["Eastern Time (US & Canada)"].standard_name + assert_equal "America/Montevideo", ActiveSupport::TimeZone["Montevideo"].standard_name + assert_equal "America/Toronto", ActiveSupport::TimeZone["America/Toronto"].standard_name + end end diff --git a/activesupport/test/time_zone_test_helpers.rb b/activesupport/test/time_zone_test_helpers.rb index 312d5a2c9a95e..f9efbe22d76ad 100644 --- a/activesupport/test/time_zone_test_helpers.rb +++ b/activesupport/test/time_zone_test_helpers.rb @@ -15,21 +15,6 @@ def with_env_tz(new_tz = "US/Eastern") ensure old_tz ? ENV["TZ"] = old_tz : ENV.delete("TZ") end - - def with_preserve_timezone(value) - old_preserve_tz = ActiveSupport.to_time_preserves_timezone - - ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = value - end - - yield - ensure - ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = old_preserve_tz - end - end - def with_tz_mappings(mappings) old_mappings = ActiveSupport::TimeZone::MAPPING.dup ActiveSupport::TimeZone.clear diff --git a/activesupport/test/xml_mini_test.rb b/activesupport/test/xml_mini_test.rb index 90e5699dd7d75..56f5d87413b47 100644 --- a/activesupport/test/xml_mini_test.rb +++ b/activesupport/test/xml_mini_test.rb @@ -310,7 +310,7 @@ def test_decimal assert_equal 123.0, parser.call("123,003") assert_equal 0.0, parser.call("") assert_equal 123, parser.call(123) - assert_raises(ArgumentError) { parser.call(123.04) } + assert_equal BigDecimal("123.04"), parser.call(123.04) assert_raises(ArgumentError) { parser.call(Date.new(2013, 11, 12, 02, 11)) } end diff --git a/bin/test b/bin/test new file mode 100755 index 0000000000000..f2a1366e31f3c --- /dev/null +++ b/bin/test @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +raw_directory = ARGV.first.split("/").first +directory = File.join(raw_directory, "/") +args = ARGV.filter_map do |arg| + if arg == raw_directory + nil + else + arg.delete_prefix(directory) + end +end + +ENV["RAILS_TEST_PWD"] = Dir.pwd +Dir.chdir(directory) +exec("bin/test", *args) diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000000..f6f0701543842 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,62 @@ +import importPlugin from "eslint-plugin-import"; +import globals from "globals"; +import pluginJs from "@eslint/js"; + +export default [ + pluginJs.configs.recommended, + importPlugin.flatConfigs.recommended, + { + languageOptions: { + globals: { + ...globals.browser, + }, + + ecmaVersion: 6, + sourceType: "module", + }, + }, + + { + rules: { + semi: ["error", "never"], + quotes: ["error", "double"], + "no-unused-vars": [ + "error", + { + vars: "all", + args: "none", + }, + ], + "import/order": [ + "error", + { + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + groups: [["builtin", "external", "internal"]], + }, + ], + }, + }, + { + files: ["actioncable/**"], + rules: { + "no-console": "off", + }, + }, + { + files: [ + "activestorage/app/javascript/activestorage/direct_upload.js", + "activestorage/app/javascript/activestorage/index.js", + ], + rules: { + "import/order": [ + "error", + { + groups: [["builtin", "external", "internal"]], + }, + ], + }, + }, +]; diff --git a/guides/CHANGELOG.md b/guides/CHANGELOG.md index 16ef144826d7d..116834326b1f1 100644 --- a/guides/CHANGELOG.md +++ b/guides/CHANGELOG.md @@ -1,11 +1,2 @@ -* In the Active Job bug report template set the queue adapter to the - test adapter so that `assert_enqueued_with` can pass. - *Andrew White* - -* Ensure all bug report templates set `config.secret_key_base` to avoid - generation of `tmp/local_secret.txt` files when running the report template. - - *Andrew White* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/guides/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/guides/CHANGELOG.md) for previous changes. diff --git a/guides/Rakefile b/guides/Rakefile index 72cf1e413583e..e7132191a534c 100644 --- a/guides/Rakefile +++ b/guides/Rakefile @@ -1,5 +1,14 @@ # frozen_string_literal: true +require "rake/testtask" + +Rake::TestTask.new do |t| + t.libs << "test" + t.test_files = FileList["test/**/*_test.rb"] + t.verbose = true + t.options = "--profile" if ENV["CI"] +end + namespace :guides do desc 'Generate guides (for authors), use ONLY=foo to process just "foo.md"' task generate: "generate:html" @@ -7,7 +16,7 @@ namespace :guides do namespace :generate do desc "Generate HTML guides" task :html do - ruby "-Eutf-8:utf-8", "rails_guides.rb" + generate_guides end desc "Generate .mobi file" @@ -30,7 +39,7 @@ namespace :guides do desc "Check links in generated HTML guides" task :check_links do ENV["GUIDES_LINT"] = "1" - ruby "-Eutf-8:utf-8", "rails_guides.rb" + generate_guides end desc "Run mdl to check Markdown files for style guide violations and lint errors" @@ -45,7 +54,8 @@ namespace :guides do # Validate guides ------------------------------------------------------------------------- desc 'Validate guides, use ONLY=foo to process just "foo.html"' task :validate do - ruby "w3c_validator.rb" + require_relative "w3c_validator.rb" + RailsGuides::Validator.new.validate end task :vendor_javascript do @@ -105,15 +115,24 @@ HELP end end -task :test do - templates = Dir.glob("bug_report_templates/*.rb") - counter = templates.count do |file| - puts "--- Running #{file}" - Bundler.unbundled_system(Gem.ruby, "-w", file) || - puts("+++ 💥 FAILED (exit #{$?.exitstatus})") - end - puts "+++ #{counter} / #{templates.size} templates executed successfully" - exit 1 if counter < templates.size -end - task default: "guides:help" + +def generate_guides + require_relative "rails_guides" + env_value = ->(name) { ENV[name].presence } + env_flag = ->(name) { "1" == env_value[name] } + + version = env_value["RAILS_VERSION"] + edge = `git rev-parse HEAD`.strip unless version + + RailsGuides::Generator.new( + edge: edge, + version: version, + all: env_flag["ALL"], + only: env_value["ONLY"], + epub: env_flag["EPUB"], + language: env_value["GUIDES_LANGUAGE"], + direction: env_value["DIRECTION"], + lint: env_flag["GUIDES_LINT"] + ).generate +end diff --git a/guides/assets/javascripts/guides.js b/guides/assets/javascripts/guides.js index e06ae3336f4dc..182ed58c7cbf6 100644 --- a/guides/assets/javascripts/guides.js +++ b/guides/assets/javascripts/guides.js @@ -36,10 +36,10 @@ var scrollBehavior = 'auto' document.addEventListener(event, function () { - // This smooth scrolling behaviour does not work in tandem with the + // This smooth scrolling behavior does not work in tandem with the // scrollIntoView function for some browser-os combinations. Therefore, if // JavaScript is enabled, and scrollIntoView may be called, this style is - // forced to not use smooth scrolling and the behaviour is added to the + // forced to not use smooth scrolling and the behavior is added to the // back-to-top element etc, unless reduced motion is preferred. document.body.parentElement.style.scrollBehavior = 'auto'; @@ -79,13 +79,6 @@ // pressing escape, which is the standard key to collapse expanded elements. var guidesMenuButton = document.getElementById("guides-menu-button"); - // The link is now acting as a button (but still allows for open in new tab). - guidesMenuButton.setAttribute('role', 'button') - guidesMenuButton.setAttribute('aria-controls', guidesMenuButton.getAttribute('data-aria-controls')); - guidesMenuButton.setAttribute('aria-expanded', guidesMenuButton.getAttribute('data-aria-expanded')); - guidesMenuButton.removeAttribute('data-aria-controls'); - guidesMenuButton.removeAttribute('data-aria-expanded'); - var guides = document.getElementById( guidesMenuButton.getAttribute("aria-controls") ); @@ -223,7 +216,7 @@ }) // Automatically browse when the version selector is changed. It is - // important that this behaviour is communicated to the user, for example + // important that this behavior is communicated to the user, for example // via an accessible label. var guidesVersion = document.querySelector("select.guides-version"); guidesVersion.addEventListener("change", function (e) { diff --git a/guides/assets/stylesrc/_main.scss b/guides/assets/stylesrc/_main.scss index 8f788ce20821e..8be40a7214853 100644 --- a/guides/assets/stylesrc/_main.scss +++ b/guides/assets/stylesrc/_main.scss @@ -1,8 +1,8 @@ // Smooth scrolling (back-to-top etc.) @media (prefers-reduced-motion: no-preference) { - // This smooth scrolling behaviour does not work in tandem with scrollIntoView + // This smooth scrolling behavior does not work in tandem with scrollIntoView // for some browser-os combinations. Therefore, if JavaScript is enabled, this - // style is removed and the behaviour is added to the back-to-top element etc. + // style is removed and the behavior is added to the back-to-top element etc. html { scroll-behavior: smooth; } @@ -372,6 +372,13 @@ body.guide { word-wrap: break-word; /* Internet Explorer 5.5+ */ } + // Ensures that 'code' inside 'pre' preserves formatting and does not wrap, makes it horizontally scrollable instead + pre { + code { + white-space: pre; + } + } + // Back to Top element a.back-to-top { diff --git a/guides/assets/stylesrc/components/_code-container.scss b/guides/assets/stylesrc/components/_code-container.scss index e72082707c554..47d517d49320b 100644 --- a/guides/assets/stylesrc/components/_code-container.scss +++ b/guides/assets/stylesrc/components/_code-container.scss @@ -2,7 +2,7 @@ // Containers // // These are interstitial elements used throughout the guides, providing help, -// context, more info, or warnings to readers. +// context, more info, or warnings to readers. // ---------------------------------------------------------------------------- /* Same bottom margin for special boxes than for regular paragraphs, this way @@ -44,10 +44,12 @@ dl dd .interstitial { padding-left: 1em !important; // remove if icon is restored direction: ltr !important; text-align: left !important; + width: 100%; + white-space: normal; pre { margin: 0; - overflow: visible; // allows for the blue highlight to be seen + white-space: pre; } button.clipboard-button { diff --git a/guides/assets/stylesrc/highlight.scss b/guides/assets/stylesrc/highlight.scss index 4f38ce505483b..18f7cec4edfcd 100644 --- a/guides/assets/stylesrc/highlight.scss +++ b/guides/assets/stylesrc/highlight.scss @@ -55,7 +55,7 @@ // The Colors // // These are the inverse of what they used to be, as we use a dark block on the -// light background and a light block on the dark background pages. +// light background and a light block on the dark background pages. .highlight { color: #fff; @@ -63,7 +63,15 @@ &.console, &.erb, &.html { color: #fff; } - .hll { background-color: #3a3939; border-left: 3px solid #00F0FF; margin-left: -5px; padding-left: 5px; padding-right: 5px;} /* $gray-700, $tip */ + .hll { + background-color: #3a3939; + border-left: 3px solid #00F0FF; + padding-right: 5px; + + :first-child { + margin-left: -3px; + } + } /* $gray-700, $tip */ .c { color: #b4b4b3; } /* Comment */ .err { color: #ff0088; background-color: #1e0010 } /* Error */ .k { color: #9decfc; } /* Keyword */ @@ -127,5 +135,5 @@ .gi { color: #d0ff71; background-color: unset; } /* Generic.Inserted & Diff Inserted */ .gr { color: #ff699b; } .go { color: #b4b4b3; } - .gp { color: #fff; } + .gp { color: #fff; } } diff --git a/guides/bin/test b/guides/bin/test new file mode 100755 index 0000000000000..c53377cc970f4 --- /dev/null +++ b/guides/bin/test @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +COMPONENT_ROOT = File.expand_path("..", __dir__) +require_relative "../../tools/test" diff --git a/guides/bug_report_templates/active_storage.rb b/guides/bug_report_templates/active_storage.rb index 03b46a0b0b9e7..c0ebb4262b4f6 100644 --- a/guides/bug_report_templates/active_storage.rb +++ b/guides/bug_report_templates/active_storage.rb @@ -37,6 +37,8 @@ class TestApp < Rails::Application service: "Disk" } } + + config.active_job.queue_adapter = :inline end Rails.application.initialize! diff --git a/guides/rails_guides.rb b/guides/rails_guides.rb index eb84569420fdd..a6257dcacc277 100644 --- a/guides/rails_guides.rb +++ b/guides/rails_guides.rb @@ -2,30 +2,4 @@ $:.unshift __dir__ -as_lib = File.expand_path("../activesupport/lib", __dir__) -ap_lib = File.expand_path("../actionpack/lib", __dir__) -av_lib = File.expand_path("../actionview/lib", __dir__) - -$:.unshift as_lib if File.directory?(as_lib) -$:.unshift ap_lib if File.directory?(ap_lib) -$:.unshift av_lib if File.directory?(av_lib) - require "rails_guides/generator" -require "active_support/core_ext/object/blank" - -env_value = ->(name) { ENV[name].presence } -env_flag = ->(name) { "1" == env_value[name] } - -version = env_value["RAILS_VERSION"] -edge = `git rev-parse HEAD`.strip unless version - -RailsGuides::Generator.new( - edge: edge, - version: version, - all: env_flag["ALL"], - only: env_value["ONLY"], - epub: env_flag["EPUB"], - language: env_value["GUIDES_LANGUAGE"], - direction: env_value["DIRECTION"], - lint: env_flag["GUIDES_LINT"] -).generate diff --git a/guides/rails_guides/markdown.rb b/guides/rails_guides/markdown.rb index 6a1722a17456d..1376bd429b9f3 100644 --- a/guides/rails_guides/markdown.rb +++ b/guides/rails_guides/markdown.rb @@ -4,7 +4,6 @@ require "nokogiri" require "rails_guides/markdown/renderer" require "rails_guides/markdown/epub_renderer" -require "rails-html-sanitizer" module RailsGuides class Markdown diff --git a/guides/rails_guides/markdown/renderer.rb b/guides/rails_guides/markdown/renderer.rb index 8e419113a4c7b..512df2c75e8ac 100644 --- a/guides/rails_guides/markdown/renderer.rb +++ b/guides/rails_guides/markdown/renderer.rb @@ -32,7 +32,7 @@ class Renderer < Redcarpet::Render::HTML # :nodoc: def block_code(code, language) language, lines = split_language_highlights(language) formatter = Rouge::Formatters::HTMLLineHighlighter.new(Rouge::Formatters::HTML.new, highlight_lines: lines) - lexer = ::Rouge::Lexer.find_fancy(lexer_language(language)) + lexer = ::Rouge::Lexer.find_fancy(lexer_language_with_options(language)) formatted_code = formatter.format(lexer.lex(code)) <<~HTML
@@ -97,6 +97,16 @@ def lexer_language(code_type) end end + def lexer_language_with_options(code_type) + language = lexer_language(code_type) + case language + when "console" + "#{language}?comments=true" + else + language + end + end + def clipboard_content(code, language) # Remove prompt and results of commands. prompt_regexp = diff --git a/guides/source/2_2_release_notes.md b/guides/source/2_2_release_notes.md index 3c34f486138ff..0a7c8a220f2b2 100644 --- a/guides/source/2_2_release_notes.md +++ b/guides/source/2_2_release_notes.md @@ -60,7 +60,7 @@ This will put the guides inside `Rails.root/doc/guides` and you may start surfin * Major contributions from [Xavier Noria](http://advogato.org/person/fxn/diary.html) and [Hongli Lai](http://izumi.plan99.net/blog/). * More information: * [Rails Guides hackfest](http://hackfest.rubyonrails.org/guide) - * [Help improve Rails documentation on Git branch](https://weblog.rubyonrails.org/2008/5/2/help-improve-rails-documentation-on-git-branch) + * [Help improve Rails documentation on Git branch](https://rubyonrails.org/2008/5/2/help-improve-rails-documentation-on-git-branch) Better integration with HTTP : Out of the box ETag support ---------------------------------------------------------- @@ -112,7 +112,7 @@ config.threadsafe! * More information : * [Thread safety for your Rails](http://m.onkey.org/2008/10/23/thread-safety-for-your-rails) - * [Thread safety project announcement](https://weblog.rubyonrails.org/2008/8/16/josh-peek-officially-joins-the-rails-core) + * [Thread safety project announcement](https://rubyonrails.org/2008/8/16/josh-peek-officially-joins-the-rails-core) * [Q/A: What Thread-safe Rails Means](http://blog.headius.com/2008/08/qa-what-thread-safe-rails-means.html) Active Record diff --git a/guides/source/2_3_release_notes.md b/guides/source/2_3_release_notes.md index 490292e5472c5..7c2668d764fd0 100644 --- a/guides/source/2_3_release_notes.md +++ b/guides/source/2_3_release_notes.md @@ -54,7 +54,7 @@ Documentation The [Ruby on Rails guides](https://guides.rubyonrails.org/) project has published several additional guides for Rails 2.3. In addition, a [separate site](https://edgeguides.rubyonrails.org/) maintains updated copies of the Guides for Edge Rails. Other documentation efforts include a relaunch of the [Rails wiki](http://newwiki.rubyonrails.org/) and early planning for a Rails Book. -* More Information: [Rails Documentation Projects](https://weblog.rubyonrails.org/2009/1/15/rails-documentation-projects) +* More Information: [Rails Documentation Projects](https://rubyonrails.org/2009/1/15/rails-documentation-projects) Ruby 1.9.1 Support ------------------ @@ -89,7 +89,7 @@ accepts_nested_attributes_for :author, ``` * Lead Contributor: [Eloy Duran](http://superalloy.nl/) -* More Information: [Nested Model Forms](https://weblog.rubyonrails.org/2009/1/26/nested-model-forms) +* More Information: [Nested Model Forms](https://rubyonrails.org/2009/1/26/nested-model-forms) ### Nested Transactions @@ -377,7 +377,7 @@ You can write this view in Rails 2.3: * Lead Contributor: [Eloy Duran](http://superalloy.nl/) * More Information: - * [Nested Model Forms](https://weblog.rubyonrails.org/2009/1/26/nested-model-forms) + * [Nested Model Forms](https://rubyonrails.org/2009/1/26/nested-model-forms) * [complex-form-examples](https://github.com/alloy/complex-form-examples) * [What's New in Edge Rails: Nested Object Forms](http://archives.ryandaigle.com/articles/2009/2/1/what-s-new-in-edge-rails-nested-attributes) @@ -552,7 +552,7 @@ In addition to the Rack changes covered above, Railties (the core code of Rails Rails Metal is a new mechanism that provides superfast endpoints inside of your Rails applications. Metal classes bypass routing and Action Controller to give you raw speed (at the cost of all the things in Action Controller, of course). This builds on all of the recent foundation work to make Rails a Rack application with an exposed middleware stack. Metal endpoints can be loaded from your application or from plugins. * More Information: - * [Introducing Rails Metal](https://weblog.rubyonrails.org/2008/12/17/introducing-rails-metal) + * [Introducing Rails Metal](https://rubyonrails.org/2008/12/17/introducing-rails-metal) * [Rails Metal: a micro-framework with the power of Rails](http://soylentfoo.jnewland.com/articles/2008/12/16/rails-metal-a-micro-framework-with-the-power-of-rails-m) * [Metal: Super-fast Endpoints within your Rails Apps](http://www.railsinside.com/deployment/180-metal-super-fast-endpoints-within-your-rails-apps.html) * [What's New in Edge Rails: Rails Metal](http://archives.ryandaigle.com/articles/2008/12/18/what-s-new-in-edge-rails-rails-metal) diff --git a/guides/source/3_0_release_notes.md b/guides/source/3_0_release_notes.md index 422d59d26e226..172ce8ee004f9 100644 --- a/guides/source/3_0_release_notes.md +++ b/guides/source/3_0_release_notes.md @@ -155,7 +155,7 @@ Documentation The documentation in the Rails tree is being updated with all the API changes, additionally, the [Rails Edge Guides](https://edgeguides.rubyonrails.org/) are being updated one by one to reflect the changes in Rails 3.0. The guides at [guides.rubyonrails.org](https://guides.rubyonrails.org/) however will continue to contain only the stable version of Rails (at this point, version 2.3.5, until 3.0 is released). -More Information: - [Rails Documentation Projects](https://weblog.rubyonrails.org/2009/1/15/rails-documentation-projects) +More Information: - [Rails Documentation Projects](https://rubyonrails.org/2009/1/15/rails-documentation-projects) Internationalization @@ -250,7 +250,7 @@ Deprecations: More Information: * [Render Options in Rails 3](https://blog.engineyard.com/2010/render-options-in-rails-3) -* [Three reasons to love ActionController::Responder](https://weblog.rubyonrails.org/2009/8/31/three-reasons-love-responder) +* [Three reasons to love ActionController::Responder](https://rubyonrails.org/2009/8/31/three-reasons-love-responder) ### Action Dispatch diff --git a/guides/source/7_1_release_notes.md b/guides/source/7_1_release_notes.md index b884d1bc720c1..07351dc51ffa1 100644 --- a/guides/source/7_1_release_notes.md +++ b/guides/source/7_1_release_notes.md @@ -866,7 +866,7 @@ Please refer to the [Changelog][active-job] for detailed changes. * Add `after_discard` method to `ActiveJob::Base` to run a callback when a job is about to be discarded. -* Add support for logging background job enqueue callers. +* Add support for logging background job enqueue callers via `config.active_job.verbose_enqueue_logs`. Action Text ---------- diff --git a/guides/source/7_2_release_notes.md b/guides/source/7_2_release_notes.md index a5cc3cef430b2..71f4ceb8b519d 100644 --- a/guides/source/7_2_release_notes.md +++ b/guides/source/7_2_release_notes.md @@ -80,7 +80,7 @@ specified. This means that all other unknown browsers, as well as agents that aren't reporting a user-agent header, will be allowed access. -A browser that's blocked will by default be served the file in `public/406-unsupported-browser.html` with a HTTP status +A browser that's blocked will by default be served the file in `public/406-unsupported-browser.html` with an HTTP status code of "406 Not Acceptable". Examples: @@ -478,6 +478,8 @@ Please refer to the [Changelog][active-record] for detailed changes. ### Notable changes +* `ActiveRecord::Base.establish_connection` no longer sets `ActiveRecord::Base.connection.active?` to `true`. If you need this behavior, you can use `ActiveRecord::Base.connection.verify!` instead. + Active Storage -------------- @@ -565,7 +567,7 @@ Please refer to the [Changelog][active-job] for detailed changes. ### Deprecations -* Deprecate `Rails.application.config.active_job.use_big_decimal_serialize`. +* Deprecate `Rails.application.config.active_job.use_big_decimal_serializer`. ### Notable changes diff --git a/guides/source/8_0_release_notes.md b/guides/source/8_0_release_notes.md index 158d020fdcc17..b18ac4991f278 100644 --- a/guides/source/8_0_release_notes.md +++ b/guides/source/8_0_release_notes.md @@ -5,6 +5,18 @@ Ruby on Rails 8.0 Release Notes Highlights in Rails 8.0: +* Kamal 2. +* Thruster. +* Solid Cable. +* Solid Cache. +* Propshaft is used by default. +* Authentication system generator. + +These release notes cover only the major changes. To learn about various bug +fixes and changes, please refer to the changelogs or check out the [list of +commits](https://github.com/rails/rails/commits/8-0-stable) in the main Rails +repository on GitHub. + -------------------------------------------------------------------------------- Upgrading to Rails 8.0 @@ -21,6 +33,55 @@ guide. Major Features -------------- +### Kamal 2 + +Rails now comes preconfigured with [Kamal 2](https://kamal-deploy.org/) for +deploying your application. Kamal takes a fresh Linux box and turns it into an +application or accessory server with just a single “kamal setup” command. + +Kamal 2 also includes a proxy called [Kamal Proxy](https://github.com/basecamp/kamal-proxy) +to replace the generic Traefik option it used at launch. + +### Thruster + +The Dockerfile has been upgraded to include a new proxy called +[Thruster](https://github.com/basecamp/thruster), which sits in front of the +Puma web server to provide X-Sendfile acceleration, asset caching, and asset +compression. + +### Solid Cable + +[Solid Cable](https://github.com/rails/solid_cable) replaces Redis to act as +the pubsub server to relay WebSocket messages from the application to clients +connected to different processes. Solid Cable retains the messages sent in +the database for a day by default. + +### Solid Cache + +[Solid Cache](https://github.com/rails/solid_cache) replaces either +Redis or Memcached for storing HTML fragment caches in particular. + +### Solid Queue + +[Solid Queue](https://github.com/rails/solid_queue) replaces the need for +Redis, also a separate job-running framework, like Resque, Delayed Job, or +Sidekiq. + +For high-performance installations, it’s built on the new `FOR UPDATE SKIP LOCKED` +mechanism first introduced in PostgreSQL 9.5, but now also available in MySQL 8.0 +and beyond. It also works with SQLite. + +### Propshaft + +[Propshaft](https://github.com/rails/propshaft) is now the default asset +pipeline, replacing the old Sprockets system. + +### Authentication + +[Authentication system generator](https://github.com/rails/rails/pull/52328), +creates a starting point for a session-based, password-resettable, +metadata-tracking authentication system. + Railties -------- @@ -41,7 +102,7 @@ Please refer to the [Changelog][railties] for detailed changes. * Deprecate requiring `"rails/console/methods"`. * Deprecate modifying `STATS_DIRECTORIES` in favor of - `Rails::CodeStatistics.registery_directory`. + `Rails::CodeStatistics.register_directory`. * Deprecate `bin/rake stats` in favor of `bin/rails stats`. @@ -75,6 +136,9 @@ Please refer to the [Changelog][action-pack] for detailed changes. ### Notable changes +* Introduce safer, more explicit params handling method with [`params#expect`](https://api.rubyonrails.org/classes/ActionController/Parameters.html#method-i-expect) such that + `params.expect(table: [ :attr ])` replaces `params.require(:table).permit(:attr)`. + Action View ----------- @@ -134,7 +198,10 @@ Please refer to the [Changelog][active-record] for detailed changes. ### Notable changes * Running `db:migrate` on a fresh database now loads the schema before running - migrations. (The previous behavior is available as `db:migrate:reset`) + migrations. Subsequent calls will run pending migrations. + (If you need the previous behavior of running migrations from scratch instead of loading the + schema file, this can be done by running `db:migrate:reset` which + _will drop and recreate the database before running migrations_) Active Storage -------------- diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 78f63318a70ee..0e400acd60dfe 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -5,6 +5,19 @@ Ruby on Rails 8.1 Release Notes Highlights in Rails 8.1: +* Active Job Continuations. +* Structured Event Reporting. +* Local CI. +* Markdown Rendering. +* Command-line Credentials Fetching. +* Deprecated Associations. +* Registry-Free Kamal Deployments + +These release notes cover only the major changes. To learn about various bug +fixes and changes, please refer to the changelogs or check out the [list of +commits](https://github.com/rails/rails/commits/8-1-stable) in the main Rails +repository on GitHub. + -------------------------------------------------------------------------------- Upgrading to Rails 8.1 @@ -21,6 +34,190 @@ guide. Major Features -------------- +### Active Job Continuations + +Long-running jobs can now be broken into discrete steps that allow execution to +continue from the last completed step rather than the beginning after a restart. +This is especially helpful when doing deploys with Kamal, which will only give +job-running containers thirty seconds to shut down by default. + +Example: + +```ruby +class ProcessImportJob < ApplicationJob + include ActiveJob::Continuable + + def perform(import_id) + @import = Import.find(import_id) + + # block format + step :initialize do + @import.initialize + end + + # step with cursor, the cursor is saved when the job is interrupted + step :process do |step| + @import.records.find_each(start: step.cursor) do |record| + record.process + step.advance! from: record.id + end + end + + # method format + step :finalize + end + + private + def finalize + @import.finalize + end +end +``` + +### Structured Event Reporting + +The default logger in Rails is great for human consumption, but less ideal for +post-processing. The new Event Reporter provides a unified interface for +producing structured events in Rails applications: + +```ruby +Rails.event.notify("user.signup", user_id: 123, email: "user@example.com") +``` + +It supports adding tags to events: + +```ruby +Rails.event.tagged("graphql") do + # Event includes tags: { graphql: true } + Rails.event.notify("user.signup", user_id: 123, email: "user@example.com") +end +``` + +As well as context: + +```ruby +# All events will contain context: {request_id: "abc123", shop_id: 456} +Rails.event.set_context(request_id: "abc123", shop_id: 456) +``` + +Events are emitted to subscribers. Applications register subscribers to +control how events are serialized and emitted. Subscribers must implement +an `#emit` method, which receives the event hash: + +```ruby +class LogSubscriber + def emit(event) + payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ") + source_location = event[:source_location] + log = "[#{event[:name]}] #{payload} at #{source_location[:filepath]}:#{source_location[:lineno]}" + Rails.logger.info(log) + end +end +``` + +### Local CI + +Developer machines have gotten incredibly quick with loads of cores, which make +them great local runners of even relatively large test suites. + +This makes getting rid of a cloud-setup for all of CI not just feasible but +desirable for many small-to-mid-sized applications, and Rails has therefore +added a default CI declaration DSL, which is defined in `config/ci.rb` and run +by `bin/ci`. It looks like this: + +```ruby +CI.run do + step "Setup", "bin/setup --skip-server" + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + step "Tests: Rails", "bin/rails test" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + if success? + step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + else + failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + end +end +``` + +The optional integration with gh ensures that PRs must be signed off by a +passing CI run in order to be eligible to be merged. + +### Markdown Rendering + +Markdown has become the lingua franca of AI, and Rails has embraced this +adoption by making it easier to respond to markdown requests and render them +directly: + +```ruby +class Page + def to_markdown + body + end +end + +class PagesController < ActionController::Base + def show + @page = Page.find(params[:id]) + + respond_to do |format| + format.html + format.md { render markdown: @page } + end + end +end +``` + +### Command-line Credentials Fetching + +Kamal can now easily grab its secrets from the encrypted Rails credentials store +for deploys. This makes it a low-fi alternative to external secret stores that +only needs the master key available to work: + +```bash +# .kamal/secrets +KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) +``` + +### Deprecated Associations + +Active Record associations can now be marked as being deprecated: + +```ruby +class Author < ApplicationRecord + has_many :posts, deprecated: true +end +``` + +With that, usage of the `posts` association will be reported. This includes +explicit API calls like + +```ruby +author.posts +author.posts = ... +``` + +and others, as well as indirect usage like + +```ruby +author.preload(:posts) +``` + +usage via nested attributes, and more. + +Three reporting modes are supported (`:warn`, `:raise`, and `:notify`), and +backtraces can be enabled or disabled, though you always get the location of the +reported usage regardless. Defaults are `:warn` mode and disabled backtraces. + +### Registry-Free Kamal Deployments + +Kamal no longer needs a remote registry, like Docker Hub or GHCR, to do basic deploys. By default, Kamal 2.8 will now use a local registry for simple deploys. For large-scale deploys, you'll still want to use a remote registry, but this makes it easier to get started and see your first Hello World deployment in the wild. + Railties -------- @@ -28,13 +225,11 @@ Please refer to the [Changelog][railties] for detailed changes. ### Removals -* Remove deprecated `Rails::Generators::Testing::Behaviour`. +* Remove deprecated `rails/console/methods.rb` file. -* Remove deprecated `Rails.application.secrets`. +* Remove deprecated `bin/rake stats` command. -* Remove deprecated `Rails.config.enable_dependency_loading`. - -* Remove deprecated `find_cmd_and_exec` console helper. +* Remove deprecated `STATS_DIRECTORIES`. ### Deprecations @@ -58,24 +253,48 @@ Please refer to the [Changelog][action-pack] for detailed changes. ### Removals -* Remove deprecated constant `ActionDispatch::IllegalStateError`. +* Remove deprecated support to skipping over leading brackets in parameter names in the parameter parser. + + Before: + + ```ruby + ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "foo" => "bar" } + ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "foo" => { "bar" => "baz" } } + ``` + + After: -* Remove deprecated constant `AbstractController::Helpers::MissingHelperError`. + ```ruby + ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "[foo]" => "bar" } + ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "[foo]" => { "bar" => "baz" } } + ``` -* Remove deprecated comparison between `ActionController::Parameters` and `Hash`. +* Remove deprecated support for using semicolons as a query string separator. -* Remove deprecated `Rails.application.config.action_dispatch.return_only_request_media_type_on_content_type`. + Before: -* Remove deprecated `speaker`, `vibrate`, and `vr` permissions policy directives. + ```ruby + ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a + # => [["foo", "bar"], ["baz", "quux"]] + ``` -* Remove deprecated support to set `Rails.application.config.action_dispatch.show_exceptions` to `true` and `false`. + After: + + ```ruby + ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a + # => [["foo", "bar;baz=quux"]] + ``` + +* Remove deprecated support to a route to multiple paths. ### Deprecations -* Deprecate `Rails.application.config.action_controller.allow_deprecated_parameters_hash_equality`. +* Deprecate `Rails.application.config.action_dispatch.ignore_leading_brackets`. ### Notable changes +* Redirects are now verbose in development for new Rails apps. To enable it in an existing app, add `config.action_dispatch.verbose_redirect_logs = true` to your `config/development.rb` file. + Action View ----------- @@ -83,12 +302,8 @@ Please refer to the [Changelog][action-view] for detailed changes. ### Removals -* Remove deprecated `@rails/ujs` in favor of `Turbo`. - ### Deprecations -* Deprecate passing content to void elements when using `tag.br` type tag builders. - ### Notable changes Action Mailer @@ -98,10 +313,6 @@ Please refer to the [Changelog][action-mailer] for detailed changes. ### Removals -* Remove deprecated `config.action_mailer.preview_path`. - -* Remove deprecated params via `:args` for `assert_enqueued_email_with`. - ### Deprecations ### Notable changes @@ -113,75 +324,30 @@ Please refer to the [Changelog][active-record] for detailed changes. ### Removals -* Remove deprecated `Rails.application.config.active_record.suppress_multiple_database_warning`. - -* Remove deprecated support to call `alias_attribute` with non-existent attribute names. - -* Remove deprecated `name` argument from `ActiveRecord::Base.remove_connection`. - -* Remove deprecated `ActiveRecord::Base.clear_active_connections!`. - -* Remove deprecated `ActiveRecord::Base.clear_reloadable_connections!`. - -* Remove deprecated `ActiveRecord::Base.clear_all_connections!`. - -* Remove deprecated `ActiveRecord::Base.flush_idle_connections!`. - -* Remove deprecated `ActiveRecord::ActiveJobRequiredError`. - -* Remove deprecated support to define `explain` in the connection adapter with 2 arguments. - -* Remove deprecated `ActiveRecord::LogSubscriber.runtime` method. - -* Remove deprecated `ActiveRecord::LogSubscriber.runtime=` method. - -* Remove deprecated `ActiveRecord::LogSubscriber.reset_runtime` method. - -* Remove deprecated `ActiveRecord::Migration.check_pending` method. - -* Remove deprecated support to passing `SchemaMigration` and `InternalMetadata` classes as arguments to - `ActiveRecord::MigrationContext`. - -* Remove deprecated behavior to support referring to a singular association by its plural name. +* Remove deprecated `:retries` option for the SQLite3 adapter. -* Remove deprecated `TestFixtures.fixture_path`. +* Remove deprecated `:unsigned_float` and `:unsigned_decimal` column methods for MySQL. -* Remove deprecated support to `ActiveRecord::Base#read_attribute(:id)` to return the custom primary key value. - -* Remove deprecated support to passing coder and class as second argument to `serialize`. - -* Remove deprecated `#all_foreign_keys_valid?` from database adapters. - -* Remove deprecated `ActiveRecord::ConnectionAdapters::SchemaCache.load_from`. - -* Remove deprecated `ActiveRecord::ConnectionAdapters::SchemaCache#data_sources`. - -* Remove deprecated `#all_connection_pools`. - -* Remove deprecated support to apply `#connection_pool_list`, `#active_connections?`, `#clear_active_connections!`, - `#clear_reloadable_connections!`, `#clear_all_connections!` and `#flush_idle_connections!` to the connections pools - for the current role when the `role` argument isn't provided. - -* Remove deprecated `ActiveRecord::ConnectionAdapters::ConnectionPool#connection_klass`. - -* Remove deprecated `#quote_bound_value`. - -* Remove deprecated support to quote `ActiveSupport::Duration`. - -* Remove deprecated support to pass `deferrable: true` to `add_foreign_key`. - -* Remove deprecated support to pass `rewhere` to `ActiveRecord::Relation#merge`. +### Deprecations -* Remove deprecated behavior that would rollback a transaction block when exited using `return`, `break` or `throw`. +* Deprecate using an [order dependent finder + method](https://github.com/rails/rails/pull/54608) (e.g. `#first`) without + an `order`. -### Deprecations +* Deprecate `ActiveRecord::Base.signed_id_verifier_secret` in favor of + `Rails.application.message_verifiers` (or `Model.signed_id_verifier` if the + secret is specific to a model). -* Deprecate `Rails.application.config.active_record.allow_deprecated_singular_associations_name` +* Deprecate using `insert_all`/`upsert_all` with unpersisted records in + associations. -* Deprecate `Rails.application.config.active_record.commit_transaction_on_non_local_return` +* Deprecate usage of `WITH`, `WITH RECURSIVE` and `DISTINCT` with + `update_all`. ### Notable changes +* The table columns inside `schema.rb` are [now sorted alphabetically.](https://github.com/rails/rails/pull/53281) + Active Storage -------------- @@ -189,9 +355,7 @@ Please refer to the [Changelog][active-storage] for detailed changes. ### Removals -* Remove deprecated `config.active_storage.replace_on_assign_to_many`. - -* Remove deprecated `config.active_storage.silence_invalid_content_types_warning`. +* Remove deprecated `:azure` storage service. ### Deprecations @@ -215,48 +379,22 @@ Please refer to the [Changelog][active-support] for detailed changes. ### Removals -* Remove deprecated `ActiveSupport::Notifications::Event#children` and `ActiveSupport::Notifications::Event#parent_of?`. - -* Remove deprecated support to call the following methods without passing a deprecator: - - - `deprecate` - - `deprecate_constant` - - `ActiveSupport::Deprecation::DeprecatedObjectProxy.new` - - `ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new` - - `ActiveSupport::Deprecation::DeprecatedConstantProxy.new` - - `assert_deprecated` - - `assert_not_deprecated` - - `collect_deprecations` +* Remove deprecated passing a Time object to `Time#since`. -* Remove deprecated `ActiveSupport::Deprecation` delegation to instance. +* Remove deprecated `Benchmark.ms` method. It is now defined in the `benchmark` gem. -* Remove deprecated `SafeBuffer#clone_empty`. +* Remove deprecated addition for `Time` instances with `ActiveSupport::TimeWithZone`. -* Remove deprecated `#to_default_s` from `Array`, `Date`, `DateTime` and `Time`. - -* Remove deprecated `:pool_size` and `:pool_timeout` options for the cache storage. - -* Remove deprecated support for `config.active_support.cache_format_version = 6.1`. - -* Remove deprecated constants `ActiveSupport::LogSubscriber::CLEAR` and `ActiveSupport::LogSubscriber::BOLD`. - -* Remove deprecated support to bolding log text with positional boolean in `ActiveSupport::LogSubscriber#color`. - -* Remove deprecated `config.active_support.disable_to_s_conversion`. - -* Remove deprecated `config.active_support.remove_deprecated_time_with_zone_name`. - -* Remove deprecated `config.active_support.use_rfc4122_namespaced_uuids`. - -* Remove deprecated support to passing `Dalli::Client` instances to `MemCacheStore`. - -* Remove deprecated support for the pre-Ruby 2.4 behavior of `to_time` returning a `Time` object with local timezone. +* Remove deprecated support for `to_time` to preserve the system local time. It will now always preserve the receiver + timezone. ### Deprecations * Deprecate `config.active_support.to_time_preserves_timezone`. -* Deprecate `DateAndTime::Compatibility.preserve_timezone`. +* Deprecate `String#mb_chars` and `ActiveSupport::Multibyte::Chars`. + +* Deprecate `ActiveSupport::Configurable`. ### Notable changes @@ -267,15 +405,17 @@ Please refer to the [Changelog][active-job] for detailed changes. ### Removals -* Remove deprecated primitive serializer for `BigDecimal` arguments. +* Remove support to set `ActiveJob::Base.enqueue_after_transaction_commit` to `:never`, `:always` and `:default`. -* Remove deprecated support to set numeric values to `scheduled_at` attribute. +* Remove deprecated `Rails.application.config.active_job.enqueue_after_transaction_commit`. -* Remove deprecated `:exponentially_longer` value for the `:wait` in `retry_on`. +* Remove deprecated internal `SuckerPunch` adapter in favor of the adapter included with the `sucker_punch` gem. ### Deprecations -* Deprecate `Rails.application.config.active_job.use_big_decimal_serialize`. +* Custom Active Job serializers must have a public `#klass` method. + +* Deprecate built-in `sidekiq` adapter (now provided by `sidekiq` gem). ### Notable changes @@ -288,6 +428,16 @@ Please refer to the [Changelog][action-text] for detailed changes. ### Deprecations +* Deprecate the `ActionText::TrixAttachment` class + +* Deprecate the `ActionText::Attachments::TrixConversion` module + +* Deprecate `ActionText::Attachable#to_trix_content_attachment_partial_path`. Override `to_editor_content_attachment_partial_path` instead. + +* Deprecate `ActionText::RichText#to_trix_html`. + +* Deprecate `ActionText::Content#to_trix_html`. + ### Notable changes Action Mailbox diff --git a/guides/source/8_2_release_notes.md b/guides/source/8_2_release_notes.md new file mode 100644 index 0000000000000..05488f588bb49 --- /dev/null +++ b/guides/source/8_2_release_notes.md @@ -0,0 +1,183 @@ +**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON .** + +Ruby on Rails 8.2 Release Notes +=============================== + +Highlights in Rails 8.2: + +-------------------------------------------------------------------------------- + +Upgrading to Rails 8.2 +---------------------- + +If you're upgrading an existing application, it's a great idea to have good test +coverage before going in. You should also first upgrade to Rails 8.1 in case you +haven't and make sure your application still runs as expected before attempting +an update to Rails 8.1. A list of things to watch out for when upgrading is +available in the +[Upgrading Ruby on Rails](upgrading_ruby_on_rails.html#upgrading-from-rails-8-1-to-rails-8-2) +guide. + +Major Features +-------------- + +Railties +-------- + +Please refer to the [Changelog][railties] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Cable +------------ + +Please refer to the [Changelog][action-cable] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Pack +----------- + +Please refer to the [Changelog][action-pack] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action View +----------- + +Please refer to the [Changelog][action-view] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Mailer +------------- + +Please refer to the [Changelog][action-mailer] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Record +------------- + +Please refer to the [Changelog][active-record] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Storage +-------------- + +Please refer to the [Changelog][active-storage] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Model +------------ + +Please refer to the [Changelog][active-model] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Support +-------------- + +Please refer to the [Changelog][active-support] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Job +---------- + +Please refer to the [Changelog][active-job] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Text +---------- + +Please refer to the [Changelog][action-text] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Mailbox +---------- + +Please refer to the [Changelog][action-mailbox] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Ruby on Rails Guides +-------------------- + +Please refer to the [Changelog][guides] for detailed changes. + +### Notable changes + +Credits +------- + +See the +[full list of contributors to Rails](https://contributors.rubyonrails.org/) +for the many people who spent many hours making Rails, the stable and robust +framework it is. Kudos to all of them. + +[railties]: https://github.com/rails/rails/blob/main/railties/CHANGELOG.md +[action-pack]: https://github.com/rails/rails/blob/main/actionpack/CHANGELOG.md +[action-view]: https://github.com/rails/rails/blob/main/actionview/CHANGELOG.md +[action-mailer]: https://github.com/rails/rails/blob/main/actionmailer/CHANGELOG.md +[action-cable]: https://github.com/rails/rails/blob/main/actioncable/CHANGELOG.md +[active-record]: https://github.com/rails/rails/blob/main/activerecord/CHANGELOG.md +[active-storage]: https://github.com/rails/rails/blob/main/activestorage/CHANGELOG.md +[active-model]: https://github.com/rails/rails/blob/main/activemodel/CHANGELOG.md +[active-support]: https://github.com/rails/rails/blob/main/activesupport/CHANGELOG.md +[active-job]: https://github.com/rails/rails/blob/main/activejob/CHANGELOG.md +[action-text]: https://github.com/rails/rails/blob/main/actiontext/CHANGELOG.md +[action-mailbox]: https://github.com/rails/rails/blob/main/actionmailbox/CHANGELOG.md +[guides]: https://github.com/rails/rails/blob/main/guides/CHANGELOG.md diff --git a/guides/source/_welcome.html.erb b/guides/source/_welcome.html.erb index 4fc47c7038cd0..7c1aebea139a5 100644 --- a/guides/source/_welcome.html.erb +++ b/guides/source/_welcome.html.erb @@ -10,7 +10,7 @@

<% else %>

- These are the new guides for Rails 8.1 based on <%= @version %>. + These are the new guides for Rails 8.2 based on <%= @version %>. These guides are designed to make you immediately productive with Rails, and to help you understand how all of the pieces fit together.

<% end %> diff --git a/guides/source/action_cable_overview.md b/guides/source/action_cable_overview.md index 85e5d1bbdc9dc..bb5350d07dd95 100644 --- a/guides/source/action_cable_overview.md +++ b/guides/source/action_cable_overview.md @@ -751,6 +751,10 @@ The async adapter is intended for development/testing and should not be used in NOTE: The async adapter only works within the same process, so for manually triggering cable updates from a console and seeing results in the browser, you must do so from the web console (running inside the dev process), not a terminal started via `bin/rails console`! Add `console` to any action or any ERB template view to make the web console appear. +##### Solid Cable Adapter + +The Solid Cable adapter is a database-backed solution that uses Active Record. It has been tested with MySQL, SQLite, and PostgreSQL. Running `bin/rails solid_cable:install` will automatically set up `config/cable.yml` and create `db/cable_schema.rb`. After that, you must manually update `config/database.yml`, adjusting it based on your database. See [Solid Cable Installation](https://github.com/rails/solid_cable?tab=readme-ov-file#installation). + ##### Redis Adapter The Redis adapter requires users to provide a URL pointing to the Redis server. diff --git a/guides/source/action_controller_advanced_topics.md b/guides/source/action_controller_advanced_topics.md index b33c0fe851b3f..e379d0625bb10 100644 --- a/guides/source/action_controller_advanced_topics.md +++ b/guides/source/action_controller_advanced_topics.md @@ -105,7 +105,7 @@ Starting with version 7.2, Rails controllers use [`allow_browser`](https://api.r ```ruby class ApplicationController < ActionController::Base - # Only allow modern browsers supporting webp images, web push, badges, import # maps, CSS nesting, and CSS :has. + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern end ``` @@ -132,7 +132,7 @@ class MessagesController < ApplicationController end ``` -A browser that’s blocked will, by default, be served the file in `public/406-unsupported-browser.html` with a HTTP status code of “406 Not Acceptable”. +A browser that’s blocked will, by default, be served the file in `public/406-unsupported-browser.html` with an HTTP status code of “406 Not Acceptable”. HTTP Authentication ------------------- diff --git a/guides/source/action_controller_overview.md b/guides/source/action_controller_overview.md index 830a5183639f4..fc536892145af 100644 --- a/guides/source/action_controller_overview.md +++ b/guides/source/action_controller_overview.md @@ -55,7 +55,7 @@ application to add a new client, Rails will create an instance of `ClientsController` and call its `new` method. If the `new` method is empty, Rails will automatically render the `new.html.erb` view by default. -NOTE: The `new` method is an instance method here, called on an instance of `ClientsController`. This should not be confused with the `new` class method (ie`ClientsController.new`). +NOTE: The `new` method is an instance method here, called on an instance of `ClientsController`. This should not be confused with the `new` class method (i.e., `ClientsController.new`). In the `new` method, the controller would typically create an instance of the `Client` model, and make it available as an instance variable called `@client` @@ -1182,7 +1182,7 @@ The Request and Response Objects Every controller has two methods, [`request`][] and [`response`][], which can be used to access the request and response objects associated with the current request cycle. The `request` method returns an instance of -[`ActionDispatch::Request`][]. The [`response`][] method returns an an instance +[`ActionDispatch::Request`][]. The [`response`][] method returns an instance of [`ActionDispatch::Response`][], an object representing what is going to be sent back to the client browser (e.g. from `render` or `redirect` in the controller action). @@ -1200,7 +1200,7 @@ the client. This section describes the purpose of some of the properties of the To get a full list of the available methods, refer to the [Rails API documentation](https://api.rubyonrails.org/classes/ActionDispatch/Request.html) -and [Rack](https://www.rubydoc.info/github/rack/rack/Rack/Request) +and [Rack](https://rack.github.io/rack/main/Rack/Request.html) documentation. | Property of `request` | Purpose | @@ -1234,6 +1234,54 @@ access to the various parameters. [`query_parameters`]: https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-query_parameters [`request_parameters`]: https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-request_parameters +#### `request.variant` + +Controllers might need to tailor a response based on context-specific +information in a request. For example, controllers responding to requests from a +mobile platform might need to render different content than requests from a +desktop browser. One strategy to accomplish this is by customizing a request's +variant. Variant names are arbitrary, and can communicate anything from the +request's platform (`:android`, `:ios`, `:linux`, `:macos`, `:windows`) to its +browser (`:chrome`, `:edge`, `:firefox`, `:safari`), to the type of user +(`:admin`, `:guest`, `:user`). + +You can set the [`request.variant`](https://api.rubyonrails.org/classes/ActionDispatch/Http/MimeNegotiation.html#method-i-variant-3D) in a `before_action`: + +```ruby +request.variant = :tablet if request.user_agent.include?("iPad") +``` + +Responding with a variant in a controller action is like responding with a format: + +```ruby +# app/controllers/projects_controller.rb + +def show + # ... + respond_to do |format| + format.html do |html| + html.tablet # renders app/views/projects/show.html+tablet.erb + html.phone { extra_setup; render } # renders app/views/projects/show.html+phone.erb + end + end +end +``` + +A separate template should be created for each format and variant: + +* `app/views/projects/show.html.erb` +* `app/views/projects/show.html+tablet.erb` +* `app/views/projects/show.html+phone.erb` + +You can also simplify the variants definition using the inline syntax: + +```ruby +respond_to do |format| + format.html.tablet + format.html.phone { extra_setup; render } +end +``` + ### The `response` Object The response object is built up during the execution of the action from @@ -1271,4 +1319,4 @@ Here are some of the properties of the `response` object: To get a full list of the available methods, refer to the [Rails API documentation](https://api.rubyonrails.org/classes/ActionDispatch/Response.html) and [Rack -Documentation](https://www.rubydoc.info/github/rack/rack/Rack/Response). +Documentation](https://rack.github.io/rack/main/Rack/Response.html). diff --git a/guides/source/action_mailer_basics.md b/guides/source/action_mailer_basics.md index 88b6d86ad886c..71d6589be9e34 100644 --- a/guides/source/action_mailer_basics.md +++ b/guides/source/action_mailer_basics.md @@ -104,7 +104,7 @@ class UserMailer < ApplicationMailer def welcome_email @user = params[:user] @url = "http://example.com/login" - mail(to: @user.email, subject: "Welcome to My Awesome Site") + mail(to: @user.email_address, subject: "Welcome to My Awesome Site") end end ``` @@ -156,12 +156,12 @@ Here is a sample HTML template that can be used for the welcome email: your username is: <%= @user.login %>.

- To login to the site, just follow this link: <%= link_to 'login', login_url %>. + To log in to the site, just follow this link: <%= link_to 'login', login_url %>.

Thanks for joining and have a great day!

``` -NOTE: the above is the content of the `` tag. It will be embedded in the +NOTE: The above is the content of the `` tag. It will be embedded in the default mailer layout, which contains the `` tag. See [Mailer layouts](#mailer-views-and-layouts) for more. @@ -178,7 +178,7 @@ Welcome to example.com, <%= @user.name %> You have successfully signed up to example.com, your username is: <%= @user.login %>. -To login to the site, just follow this link: <%= @url %>. +To log in to the site, just follow this link: <%= @url %>. Thanks for joining and have a great day! ``` @@ -187,7 +187,7 @@ Notice that in both HTML and text email templates you can use the instance variables `@user` and `@url`. Now, when you call the `mail` method, Action Mailer will detect the two -templates(text and HTML) and automatically generate a `multipart/alternative` +templates (text and HTML) and automatically generate a `multipart/alternative` email. ### Call the Mailer @@ -204,7 +204,7 @@ user is successfully created. First, let's create a `User` scaffold: ```bash -$ bin/rails generate scaffold user name email login +$ bin/rails generate scaffold user name email_address login $ bin/rails db:migrate ``` @@ -323,7 +323,7 @@ irb> UserMailer.with(user: user).weekly_summary #}, @@ -449,7 +449,7 @@ the mailer method. Mailer views are rendered within a layout, similar to controller views. Mailer layouts are located in `app/views/layouts`. The default layout is -`mailer.html.erb` and `mailer.text.erb`. This sections covers various features +`mailer.html.erb` and `mailer.text.erb`. This section covers various features around mailer views and layouts. ### Configuring Custom View Paths @@ -457,7 +457,7 @@ around mailer views and layouts. It is possible to change the default mailer view for your action in various ways, as shown below. -There is a `template_path` and `template_name` option to the `mail` method: +There are `template_path` and `template_name` options to the `mail` method: ```ruby class UserMailer < ApplicationMailer @@ -466,7 +466,7 @@ class UserMailer < ApplicationMailer def welcome_email @user = params[:user] @url = "http://example.com/login" - mail(to: @user.email, + mail(to: @user.email_address, subject: "Welcome to My Awesome Site", template_path: "notifications", template_name: "hello") @@ -488,7 +488,7 @@ class UserMailer < ApplicationMailer def welcome_email @user = params[:user] @url = "http://example.com/login" - mail(to: @user.email, + mail(to: @user.email_address, subject: "Welcome to My Awesome Site") do |format| format.html { render "another_template" } format.text { render plain: "hello" } @@ -527,7 +527,7 @@ There is also an [`append_view_path`][] method. ### Generating URLs in Action Mailer Views -In order to add URLs to your mailer, you need set the `host` value to your +In order to add URLs to your mailer, you need to set the `host` value to your application's domain first. This is because, unlike controllers, the mailer instance doesn't have any context about the incoming request. @@ -672,7 +672,7 @@ To use a specific layout for a given email, you can pass in a `layout: ```ruby class UserMailer < ApplicationMailer def welcome_email - mail(to: params[:user].email) do |format| + mail(to: params[:user].email_address) do |format| format.html { render layout: "my_layout" } format.text end @@ -699,12 +699,12 @@ For example, to inform all admins of a new registration: ```ruby class AdminMailer < ApplicationMailer - default to: -> { Admin.pluck(:email) }, + default to: -> { Admin.pluck(:email_address) }, from: "notification@example.com" def new_registration(user) @user = user - mail(subject: "New User Signup: #{@user.email}") + mail(subject: "New User Signup: #{@user.email_address}") end end ``` @@ -725,7 +725,7 @@ To show the name of the person when they receive the email, you can use def welcome_email @user = params[:user] mail( - to: email_address_with_name(@user.email, @user.name), + to: email_address_with_name(@user.email_address, @user.name), subject: "Welcome to My Awesome Site" ) end @@ -760,7 +760,7 @@ to `text/html` below. Rails will default to `text/plain` as the content type. ```ruby class UserMailer < ApplicationMailer def welcome_email - mail(to: params[:user].email, + mail(to: params[:user].email_address, body: params[:email_body], content_type: "text/html", subject: "Already rendered!") @@ -783,7 +783,7 @@ class UserMailer < ApplicationMailer delivery_options = { user_name: params[:company].smtp_user, password: params[:company].smtp_password, address: params[:company].smtp_host } - mail(to: @user.email, + mail(to: @user.email_address, subject: "Please see the Terms and Conditions attached", delivery_method_options: delivery_options) end @@ -1013,7 +1013,7 @@ Previewing and Testing Mailers ------------------------------ You can find detailed instructions on how to test your mailers in the [testing -guide](testing.html#testing-your-mailers). +guide](testing.html#testing-mailers). ### Previewing Emails @@ -1036,7 +1036,7 @@ Now the preview will be available at If you change something in the mailer view at `app/views/user_mailer/welcome_email.html.erb` or the mailer itself, the preview -will automatically be updated. A list of previews are also available in +will automatically be updated. A list of previews is also available in . By default, these preview classes live in `test/mailers/previews`. This can be diff --git a/guides/source/action_text_overview.md b/guides/source/action_text_overview.md index 157723b193f2c..d43df38bd9afd 100644 --- a/guides/source/action_text_overview.md +++ b/guides/source/action_text_overview.md @@ -262,6 +262,15 @@ not being installed. #### Attachment Direct Upload JavaScript Events +Action Text dispatches [Active Storage Direct Upload +Events](active_storage_overview.html#direct-upload-javascript-events) during the +File attachment lifecycle. + +In addition to the typical `event.detail` properties, Action Text also +dispatches events with an +[`event.detail.attachment`](https://github.com/basecamp/trix/?tab=readme-ov-file#inserting-a-file) +property. + | Event name | Event target | Event data (`event.detail`) | Description | | --- | --- | --- | --- | | `direct-upload:start` | `` | `{id, file}` | A direct upload is starting. | @@ -269,6 +278,11 @@ not being installed. | `direct-upload:error` | `` | `{id, file, error}` | An error occurred. An `alert` will display unless this event is canceled. | | `direct-upload:end` | `` | `{id, file}` | A direct upload has ended. | +NOTE: It is possible for files uploaded by Action Text through [Active Storage +Direct Uploads](active_storage_overview.html#direct-uploads) to never be +embedded within rich text content. Consider [purging unattached +uploads](active_storage_overview.html#purging-unattached-uploads) regularly. + ### Signed GlobalID In addition to attachments uploaded through Active Storage, Action Text can also diff --git a/guides/source/action_view_helpers.md b/guides/source/action_view_helpers.md index c947e99102a60..ccc3ea3ab7c0a 100644 --- a/guides/source/action_view_helpers.md +++ b/guides/source/action_view_helpers.md @@ -837,7 +837,7 @@ The return of capture is the string generated by the block. ``` ruby @greeting -# => "Welcome to my shiny new web page! The date and time is 2018-09-06 11:09:16 -0500" +# => "Welcome! The date and time is 2018-09-06 11:09:16 -0500" ``` See the [`capture` API diff --git a/guides/source/action_view_overview.md b/guides/source/action_view_overview.md index c1ef680cb084d..c5d7a6d8c466c 100644 --- a/guides/source/action_view_overview.md +++ b/guides/source/action_view_overview.md @@ -524,6 +524,8 @@ view, starting with a value of `0` on the first render. This also works when the local variable name is changed using the `as:` option. So if you did `as: :item`, the counter variable would be `item_counter`. +NOTE: When rendering collections with instances of different models, the counter variable increments for each partial, regardless of the class of the model being rendered. + Note: The following two sections, [Strict Locals](#strict-locals) and [Local Assigns with Pattern Matching](#local-assigns-with-pattern-matching) are more advanced features of using partials, included here for completeness. diff --git a/guides/source/active_job_basics.md b/guides/source/active_job_basics.md index 9caa855721698..f46e0d0ca7ece 100644 --- a/guides/source/active_job_basics.md +++ b/guides/source/active_job_basics.md @@ -427,15 +427,15 @@ enables a powerful yet sharp tool: taking advantage of transactional integrity to ensure some action in your app is not committed unless your job is also committed and vice versa, and ensuring that your job won't be enqueued until the transaction within which you're enqueuing it is committed. This can be very powerful and useful, -but it can also backfire if you base some of your logic on this behaviour, +but it can also backfire if you base some of your logic on this behavior, and in the future, you move to another active job backend, or if you simply move -Solid Queue to its own database, and suddenly the behaviour changes under you. +Solid Queue to its own database, and suddenly the behavior changes under you. Because this can be quite tricky and many people shouldn't need to worry about it, by default Solid Queue is configured in a different database as the main app. However, if you use Solid Queue in the same database as your app, you can make sure you -don't rely accidentallly on transactional integrity with Active Job’s +don't rely accidentally on transactional integrity with Active Job’s `enqueue_after_transaction_commit` option which can be enabled for individual jobs or all jobs through `ApplicationJob`: @@ -671,6 +671,56 @@ number as more important. [`queue_with_priority`]: https://api.rubyonrails.org/classes/ActiveJob/QueuePriority/ClassMethods.html#method-i-queue_with_priority +Job Continuations +----------------- + +Jobs can be split into resumable steps using continuations. This is useful when +a job may be interrupted - for example, during queue shutdown. When using +continuations, the job can resume from the last completed step, avoiding the +need to restart from the beginning. + +To use continuations, include the `ActiveJob::Continuable` module. You can then +define each step using the `step` method inside the `perform` method. Each step can +be declared with a block or by referencing a method name. + +```ruby +class ProcessImportJob < ApplicationJob + include ActiveJob::Continuable + + def perform(import_id) + # Always runs on job start, even when resuming from an interrupted step. + @import = Import.find(import_id) + + # Step defined using a block + step :initialize do + @import.initialize + end + + # Step with a cursor — progress is saved and resumed if the job is interrupted + step :process do |step| + @import.records.find_each(start: step.cursor) do |record| + record.process + step.advance! from: record.id + end + end + + # Step defined by referencing a method + step :finalize + end + + private + def finalize + @import.finalize + end +end +``` + +Each step runs sequentially. If the job is interrupted between steps, or within a +step that uses a cursor, the job resumes from the last recorded position. This +makes it easier to build long-running or multi-phase jobs that can safely pause +and resume without losing progress. +For more details, see [ActiveJob::Continuation](https://api.rubyonrails.org/classes/ActiveJob/Continuation.html). + Callbacks --------- @@ -715,6 +765,7 @@ end * [`before_perform`][] * [`around_perform`][] * [`after_perform`][] +* [`after_discard`][] [`before_enqueue`]: https://api.rubyonrails.org/classes/ActiveJob/Callbacks/ClassMethods.html#method-i-before_enqueue @@ -728,6 +779,8 @@ end https://api.rubyonrails.org/classes/ActiveJob/Callbacks/ClassMethods.html#method-i-around_perform [`after_perform`]: https://api.rubyonrails.org/classes/ActiveJob/Callbacks/ClassMethods.html#method-i-after_perform +[`after_discard`]: + https://api.rubyonrails.org/classes/ActiveJob/Exceptions/ClassMethods.html#method-i-after_discard Please note that when enqueuing jobs in bulk using `perform_all_later`, callbacks such as `around_enqueue` will not be triggered on the individual jobs. @@ -747,24 +800,24 @@ jobs as arguments (note that this is different from `perform_later`). `perform_all_later` does call `perform` under the hood. The arguments passed to `new` will be passed on to `perform` when it's eventually called. -Here is an example calling `perform_all_later` with `GuestCleanupJob` instances: +Here is an example calling `perform_all_later` with `GuestsCleanupJob` instances: ```ruby # Create jobs to pass to `perform_all_later`. # The arguments to `new` are passed on to `perform` -guest_cleanup_jobs = Guest.all.map { |guest| GuestsCleanupJob.new(guest) } +cleanup_jobs = Guest.all.map { |guest| GuestsCleanupJob.new(guest) } -# Will enqueue a separate job for each instance of `GuestCleanupJob` -ActiveJob.perform_all_later(guest_cleanup_jobs) +# Will enqueue a separate job for each instance of `GuestsCleanupJob` +ActiveJob.perform_all_later(cleanup_jobs) # Can also use `set` method to configure options before bulk enqueuing jobs. -guest_cleanup_jobs = Guest.all.map { |guest| GuestsCleanupJob.new(guest).set(wait: 1.day) } +cleanup_jobs = Guest.all.map { |guest| GuestsCleanupJob.new(guest).set(wait: 1.day) } -ActiveJob.perform_all_later(guest_cleanup_jobs) +ActiveJob.perform_all_later(cleanup_jobs) ``` `perform_all_later` logs the number of jobs successfully enqueued, for example -if `Guest.all.map` above resulted in 3 `guest_cleanup_jobs`, it would log +if `Guest.all.map` above resulted in 3 `cleanup_jobs`, it would log `Enqueued 3 jobs to Async (3 GuestsCleanupJob)` (assuming all were enqueued). The return value of `perform_all_later` is `nil`. Note that this is different @@ -935,11 +988,10 @@ class MoneySerializer < ActiveJob::Serializers::ObjectSerializer Money.new(hash["amount"], hash["currency"]) end - private - # Checks if an argument should be serialized by this serializer. - def klass - Money - end + # Checks if an argument should be serialized by this serializer. + def klass + Money + end end ``` diff --git a/guides/source/active_record_basics.md b/guides/source/active_record_basics.md index 81d31958e8b14..8d31b0bca3cfa 100644 --- a/guides/source/active_record_basics.md +++ b/guides/source/active_record_basics.md @@ -205,6 +205,8 @@ SQL. A migration for the `books` table above can be generated like this: $ bin/rails generate migration CreateBooks title:string author:string ``` +NOTE: If you don't specify a type for a field (e.g., `title` instead of `title:string`), Rails will default to type `string`. + and results in this: ```ruby @@ -213,7 +215,7 @@ and results in this: # Columns `created_at` and `updated_at` are added by `t.timestamps`. # db/migrate/20240220143807_create_books.rb -class CreateBooks < ActiveRecord::Migration[8.1] +class CreateBooks < ActiveRecord::Migration[8.2] def change create_table :books do |t| t.string :title @@ -673,7 +675,7 @@ files which are executed against any database that Active Record supports. Here's a migration that creates a new table called `publications`: ```ruby -class CreatePublications < ActiveRecord::Migration[8.1] +class CreatePublications < ActiveRecord::Migration[8.2] def change create_table :publications do |t| t.string :title diff --git a/guides/source/active_record_composite_primary_keys.md b/guides/source/active_record_composite_primary_keys.md index 859d8a4d760f1..7d80ed272e744 100644 --- a/guides/source/active_record_composite_primary_keys.md +++ b/guides/source/active_record_composite_primary_keys.md @@ -36,7 +36,7 @@ You can create a table with a composite primary key by passing the `:primary_key` option to `create_table` with an array value: ```ruby -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products, primary_key: [:store_id, :sku] do |t| t.integer :store_id @@ -118,7 +118,7 @@ Take caution when using `find_by(id:)` on models where `:id` is not the primary key, such as composite primary key models. See the [Active Record Querying][] guide to learn more. -[Active Record Querying]: active_record_querying.html#using-id-as-a-condition +[Active Record Querying]: active_record_querying.html#conditions-with-id Associations between Models with Composite Primary Keys ------------------------------------------------------- @@ -268,7 +268,7 @@ class BooksController < ApplicationController id = params.extract_value(:id) # Find the book using the composite ID. @book = Book.find(id) - # use the default rendering behaviour to render the show view. + # use the default rendering behavior to render the show view. end end ``` diff --git a/guides/source/active_record_encryption.md b/guides/source/active_record_encryption.md index f1d3a93bd702b..5903bcb1ed70e 100644 --- a/guides/source/active_record_encryption.md +++ b/guides/source/active_record_encryption.md @@ -1,92 +1,168 @@ -**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON .** +**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON +.** Active Record Encryption ======================== -This guide covers encrypting your database information using Active Record. +This guide covers how to encrypt data in your database using Active Record. After reading this guide, you will know: * How to set up database encryption with Active Record. * How to migrate unencrypted data. * How to make different encryption schemes coexist. -* How to use the API. -* How to configure the library and how to extend it. +* More about advanced concepts like Encryption Contexts and Key Providers. -------------------------------------------------------------------------------- -Active Record supports application-level encryption. It works by declaring which attributes should be encrypted and seamlessly encrypting and decrypting them when necessary. The encryption layer sits between the database and the application. The application will access unencrypted data, but the database will store it encrypted. +Active Record Encryption exists to protect sensitive information in your +application, such as personally identifiable information (PII) about your users. +Active Record supports application-level encryption by allowing you to declare +which attributes should be encrypted. It enables transparent encryption and +decryption of attributes when saving and retrieving data. ## Why Encrypt Data at the Application Level? -Active Record Encryption exists to protect sensitive information in your application. A typical example is personally identifiable information from users. But why would you want application-level encryption if you are already encrypting your database at rest? +Encrypting specific attributes at the application level adds an additional +security layer. For example, if someone gains access to your application logs or +database backup, the encrypted data remains unreadable. It also helps avoid +accidental exposure of sensitive information in your application console or +logs. -As an immediate practical benefit, encrypting sensitive attributes adds an additional security layer. For example, if an attacker gained access to your database, a snapshot of it, or your application logs, they wouldn't be able to make sense of the encrypted information. Additionally, encryption can prevent developers from unintentionally exposing users' sensitive data in application logs. +Most importantly, this feature lets you explicitly define what data is sensitive +in your code. This enables precise access control throughout your application +and any connected services. For example, you can use tools like +[console1984](https://github.com/basecamp/console1984) to restrict decrypted +data access in the Rails console. You can also take advantage of automatic +[parameter filtering](#filtering-params-named-as-encrypted-attributes) for +encrypted fields. -But more importantly, by using Active Record Encryption, you define what constitutes sensitive information in your application at the code level. Active Record Encryption enables granular control of data access in your application and services consuming data from your application. For example, consider [auditable Rails consoles that protect encrypted data](https://github.com/basecamp/console1984) or check the built-in system to [filter controller params automatically](#filtering-params-named-as-encrypted-columns). +## Setup -## Basic Usage +To start using Active Record Encryption, you need to generate keys and declare +attributes you want to encrypt in the model. -### Setup +### Generate Encryption Key -Run `bin/rails db:encryption:init` to generate a random key set: +You can generate a random key set by running `bin/rails db:encryption:init`: ```bash $ bin/rails db:encryption:init Add this entry to the credentials of the target environment: active_record_encryption: - primary_key: EGY8WhulUOXixybod7ZWwMIL68R9o5kC - deterministic_key: aPA5XyALhf75NNnMzaspW7akTfZp0lPY - key_derivation_salt: xEY0dt6TZcAMg52K7O84wYzkjvbA62Hz + primary_key: YehXdfzxVKpoLvKseJMJIEGs2JxerkB8 + deterministic_key: uhtk2DYS80OweAPnMLtrV2FhYIXaceAy + key_derivation_salt: g7Q66StqUQDQk9SJ81sWbYZXgiRogBwS ``` -These values can be stored by copying and pasting the generated values into your existing [Rails credentials](/security.html#custom-credentials). Alternatively, these values can be configured from other sources, such as environment variables: +These values can be stored by copying and pasting the generated values into your +existing [Rails credentials](/security.html#custom-credentials) file using +`bin/rails credentials:edit`. + +Alternatively, the encryption keys can also be configured from other sources, +such as environment variables: ```ruby +# config/application.rb config.active_record.encryption.primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"] config.active_record.encryption.deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"] config.active_record.encryption.key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"] ``` -NOTE: These generated values are 32 bytes in length. If you generate these yourself, the minimum lengths you should use are 12 bytes for the primary key (this will be used to derive the AES 32 bytes key) and 20 bytes for the salt. +WARNING: It's recommended to use Rails built-in credentials support to store +keys. If you set them manually via configuration properties, make sure you don't +commit them with your code (e.g. use environment variables). + +NOTE: The generated values are 32 bytes in length. If you generate these +yourself, the recommended minimum length is 12 bytes for the primary key and 20 +bytes for the [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)). + +Once the keys are generated and stored, you can start using Active Record +Encryption by declaring attributes to be encrypted in the model. -### Declaration of Encrypted Attributes +### Declare Encrypted Attributes -Encryptable attributes are defined at the model level. These are regular Active Record attributes backed by a column with the same name. +The [`encrypts` +method](https://api.rubyonrails.org/classes/ActiveRecord/Encryption/EncryptableRecord.html#method-i-encrypts) +defines the attributes to be encrypted at the model level. These are regular +Active Record attributes backed by a column with the same name. ```ruby class Article < ApplicationRecord encrypts :title end -```` +``` -The library will transparently encrypt these attributes before saving them in the database and will decrypt them upon retrieval: +Active Record Encryption will transparently encrypt these attributes before +saving them to the database and will decrypt them upon retrieval. For example: ```ruby -article = Article.create title: "Encrypt it all!" +article = Article.create(title: "Encrypt it all!") article.title # => "Encrypt it all!" ``` -But, under the hood, the executed SQL looks like this: +However, in the Rails console, the executed SQL looks like this: ```sql -INSERT INTO `articles` (`title`) VALUES ('{\"p\":\"n7J0/ol+a7DRMeaE\",\"h\":{\"iv\":\"DXZMDWUKfp3bg/Yu\",\"at\":\"X1/YjMHbHD4talgF9dt61A==\"}}') +INSERT INTO "articles" ("title", "created_at", "updated_at") +VALUES ('{"p":"oq+RFYW8CucALxnJ6ccx","h":{"iv":"3nrJAIYcN1+YcGMQ","at":"JBsw7uB90yAyWbQ8E3krjg=="}}', ...) RETURNING "id" +``` + +The value inserted is a JSON object that contains the encrypted value for the +`title` attribute. More specifically, the JSON object stores two keys: `p` for +payload and `h` for headers. The ciphertext, which is compressed and encoded in +Base64, is stored as the payload. The `h` key stores metadata needed to decrypt +the value. The `iv` value is the initialization vector and `at` is +authentication tag (used to ensure the ciphertext has not been tampered with). + +When looking at the `Article` in the Rails console, the encrypted attribute +`title` will also be filtered: + +```irb +my-app(dev)> Article.first + Article Load (0.1ms) SELECT "articles".* FROM "articles" ORDER BY "articles"."id" ASC LIMIT ? [["LIMIT", 1]] +=> # ``` -#### Important: About Storage and Column Size +### Important: Storage Considerations + +Encrypted data takes more storage because Active Record Encryption stores +additional metadata alongside the encrypted payload, and the payload itself is +Base64-encoded so it can fit safely in text-based columns. -Encryption requires extra space because of Base64 encoding and the metadata stored along with the encrypted payloads. When using the built-in envelope encryption key provider, you can estimate the worst-case overhead at around 255 bytes. This overhead is negligible at larger sizes. Not only because it gets diluted but because the library uses compression by default, which can offer up to 30% storage savings over the unencrypted version for larger payloads. +When using the built-in envelope encryption key provider, you can estimate the +worst-case overhead to be around 255 bytes. This overhead is negligible for +larger sizes. Encryption also uses compression by default, which can offer up to +30% storage savings over the unencrypted version for larger payloads. -There is an important concern about string column sizes: in modern databases the column size determines the *number of characters* it can allocate, not the number of bytes. For example, with UTF-8, each character can take up to four bytes, so, potentially, a column in a database using UTF-8 can store up to four times its size in terms of *number of bytes*. Now, encrypted payloads are binary strings serialized as Base64, so they can be stored in regular `string` columns. Because they are a sequence of ASCII bytes, an encrypted column can take up to four times its clear version size. So, even if the bytes stored in the database are the same, the column must be four times bigger. +When using `string` columns, it’s important to know that modern databases define +the column size in terms of *number of characters*, not bytes. With encodings +like UTF-8, a single character can take up to four bytes. This means that a +column defined to hold N characters may actually consume up to 4 × N bytes in +storage. + +Since an encrypted payload is binary data serialized with Base64, it can be +stored in regular a `string` column. Because it's a sequence of ASCII bytes, an +encrypted column can take up to four times its clear version size. So, even if +the bytes stored in the database are the same, the column must be four times +bigger. In practice, this means: -* When encrypting short texts written in western alphabets (mostly ASCII characters), you should account for that 255 additional overhead when defining the column size. -* When encrypting short texts written in non-western alphabets, such as Cyrillic, you should multiply the column size by 4. Notice that the storage overhead is 255 bytes at most. +* When encrypting short texts written in Western alphabets (mostly ASCII + characters), you should account for that 255 additional overhead when defining + the column size. +* When encrypting short texts written in non-Western alphabets, such as + Cyrillic, you should multiply the column size by 4. Notice that the storage + overhead is 255 bytes at most. * When encrypting long texts, you can ignore column size concerns. -Some examples: +For example: | Content to encrypt | Original column size | Recommended encrypted column size | Storage overhead (worst case) | | ------------------------------------------------- | -------------------- | --------------------------------- | ----------------------------- | @@ -95,59 +171,133 @@ Some examples: | Summary of texts written in non-western alphabets | string(500) | string(2000) | 255 bytes | | Arbitrary long text | text | text | negligible | -### Deterministic and Non-deterministic Encryption +## Basic Usage + +### Querying Encrypted Data: Deterministic vs. Non-deterministic Encryption -By default, Active Record Encryption uses a non-deterministic approach to encryption. Non-deterministic, in this context, means that encrypting the same content with the same password twice will result in different ciphertexts. This approach improves security by making crypto-analysis of ciphertexts harder, and querying the database impossible. +By default, Active Record Encryption is non-deterministic, which means that +encrypting the same value with the same key twice will result in *different* +encrypted values (aka ciphertexts). The non-deterministic approach improves +security by making crypto-analysis of ciphertexts harder. However, it also means +that queries (such as `WHERE title = "Encrypt it all!"`) on encrypted values are +not possible, since the same plaintext value can result in a different encrypted +value that does not match the encrypted value previously stored in the JSON +document. -You can use the `deterministic:` option to generate initialization vectors in a deterministic way, effectively enabling querying encrypted data. +You can use deterministic encryption if you need to query using encrypted +values. For example, the `email` field on the `Author` model below: ```ruby class Author < ApplicationRecord encrypts :email, deterministic: true end -Author.find_by_email("some@email.com") # You can query the model normally +# You can only query by email if using deterministic encryption. +Author.find_by_email("tolkien@email.com") ``` -The non-deterministic approach is recommended unless you need to query the data. +The `:deterministic` option generates initialization vectors in a deterministic +way, meaning it will produce the same encrypted output given the same plaintext +input value. This makes querying encrypted attributes possible by string +equality comparison. For example, notice that the `p` and `iv` key in the JSON +document have the same value when we create and when we query an Author's email: -NOTE: In non-deterministic mode, Active Record uses AES-GCM with a 256-bits key and a random initialization vector. In deterministic mode, it also uses AES-GCM, but the initialization vector is generated as an HMAC-SHA-256 digest of the key and contents to encrypt. +```irb +my-app(dev)> author = Author.create(name: "J.R.R. Tolkien", email: "tolkien@email.com") + TRANSACTION (0.1ms) begin transaction + Author Create (0.4ms) INSERT INTO "authors" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["name", "J.R.R. Tolkien"], ["email", "{\"p\":\"8BAc8dGXqxksThLNmKmbWG8=\",\"h\":{\"iv\":\"NgqthINGlvoN+fhP\",\"at\":\"1uVTEDmQmPfpi1ULT9Nznw==\"}}"], ["created_at", "2025-09-19 18:08:40.104634"], ["updated_at", "2025-09-19 18:08:40.104634"]] + TRANSACTION (0.1ms) commit transaction -NOTE: You can disable deterministic encryption by omitting a `deterministic_key`. +my-app(dev)> Author.find_by_email("tolkien@email.com") + Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."email" = ? LIMIT ? [["email", "{\"p\":\"8BAc8dGXqxksThLNmKmbWG8=\",\"h\":{\"iv\":\"NgqthINGlvoN+fhP\",\"at\":\"1uVTEDmQmPfpi1ULT9Nznw==\"}}"], ["LIMIT", 1]] +=> # +``` -## Features +In the above example, the initialization vector, `iv`, has the value +`"NgqthINGlvoN+fhP"` for the same string. Even if you use the same email string +in a different model instance (or different attribute with deterministic +encryption), it will map to the same `p` and `iv` values: -### Action Text +```irb +my-app(dev)> author2 = Author.create(name: "Different Author", email: "tolkien@email.com") + TRANSACTION (0.1ms) begin transaction + Author Create (0.4ms) INSERT INTO "authors" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["name", "Different Author"], ["email", "{\"p\":\"8BAc8dGXqxksThLNmKmbWG8=\",\"h\":{\"iv\":\"NgqthINGlvoN+fhP\",\"at\":\"1uVTEDmQmPfpi1ULT9Nznw==\"}}"], ["created_at", "2025-09-19 18:20:11.291969"], ["updated_at", "2025-09-19 18:20:11.291969"]] + TRANSACTION (0.1ms) commit transaction +``` -You can encrypt Action Text attributes by passing `encrypted: true` in their declaration. +The `:deterministic` option allows for querying by trading off lesser security. +The data is still encrypted but the determinism makes crypto-analysis easier. +For this reason, non-deterministic encryption is recommended for all data unless +you need to query by the encrypted attribute. + +NOTE: In non-deterministic mode, Active Record uses +[AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard)-[GCM](https://en.wikipedia.org/wiki/Galois/Counter_Mode) +with a 256-bits key and a random initialization vector. In deterministic mode, +it also uses AES-GCM, but the initialization vector is not random. It is +generated as a function of the key and the plaintext content +([HMAC](https://en.wikipedia.org/wiki/HMAC)-SHA-256 digest of the two). + +NOTE: If you do not define a `deterministic_key`, then you have effectively +disabled deterministic encryption. + +### Ignoring Case + +You might want to ignore the case when querying deterministically encrypted +data. There are two options for achieving this - `:downcase` and `:ignore_case`. + +When you use the `:downcase` option when declaring the encrypted attribute, it +converts the data to downcase before encryption occurs. This allows to +effectively ignore case when querying data. ```ruby -class Message < ApplicationRecord - has_rich_text :content, encrypted: true +class Person + encrypts :email_address, deterministic: true, downcase: true end ``` -NOTE: Passing individual encryption options to Action Text attributes is not supported yet. It will use non-deterministic encryption with the global encryption options configured. - -### Fixtures +When using `:downcase`, the original case is lost. -You can get Rails fixtures encrypted automatically by adding this option to your `test.rb`: +You can use the `:ignore_case` option when you want to preserve the original +case for displaying but ignore the case when querying data: ```ruby -config.active_record.encryption.encrypt_fixtures = true +class Label + encrypts :name, deterministic: true, ignore_case: true # the encrypted content with the original case will be stored in the column `original_name` +end ``` -When enabled, all the encryptable attributes will be encrypted according to the encryption settings defined in the model. +With the `:ignore_case` option, you need to add a new column named +`original_` to store the encrypted content with the case unchanged. +When reading the `name` attribute, Rails will serve the version with the +original case. When querying `name`, it will ignore case. -#### Action Text Fixtures +### Serialized Attributes -To encrypt Action Text fixtures, you should place them in `fixtures/action_text/encrypted_rich_texts.yml`. +By default, Active Record Encryption will serialize values using the underlying +type before encrypting them as long as the value is serializable as Strings. If +the underlying type is not serializable as a String, you can use a custom +[`message_serializer`](https://edgeapi.rubyonrails.org/classes/ActiveRecord/Encryption/MessageSerializer.html): -### Supported Types +```ruby +class Article < ApplicationRecord + encrypts :metadata, message_serializer: SomeCustomMessageSerializer.new +end +``` -`active_record.encryption` will serialize values using the underlying type before encrypting them, but, unless using a custom `message_serializer`, *they must be serializable as strings*. Structured types like `serialized` are supported out of the box. +Attributes with structured types using the +[`serialized`](https://api.rubyonrails.org/v8.0.2/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html#method-i-serialize) +method can be encrypted as well. The `serialized` method is used when you have +an attribute that needs to be saved to the database as a serialized object +(using `YAML`, `JSON` or such), and retrieved by deserializing into the same +object. -If you need to support a custom type, the recommended way is using a [serialized attribute](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html). The declaration of the serialized attribute should go **before** the encryption declaration: +WARNING: When using serialized attributes for custom types, the declaration of +the serialized attribute should go **before** the encryption declaration: ```ruby # CORRECT @@ -163,136 +313,132 @@ class Article < ApplicationRecord end ``` -### Ignoring Case +### Ensuring Uniqueness with Encrypted Data + +Checking for uniqueness is only supported with deterministically encrypted data. -You might need to ignore casing when querying deterministically encrypted data. Two approaches make accomplishing this easier: +#### Unique Validations -You can use the `:downcase` option when declaring the encrypted attribute to downcase the content before encryption occurs. +If an attribute is deterministically encrypted, a uniqueness validation can be +specified normally, along with encryption: ```ruby class Person + validates :email_address, uniqueness: true encrypts :email_address, deterministic: true, downcase: true end ``` -When using `:downcase`, the original case is lost. In some situations, you might want to ignore the case only when querying while also storing the original case. For those situations, you can use the option `:ignore_case`. This requires you to add a new column named `original_` to store the content with the case unchanged: - -```ruby -class Label - encrypts :name, deterministic: true, ignore_case: true # the content with the original case will be stored in the column `original_name` -end -``` - -### Support for Unencrypted Data - -To ease migrations of unencrypted data, the library includes the option `config.active_record.encryption.support_unencrypted_data`. When set to `true`: +If you want to ignore the case for uniqueness, make sure to use the `:downcase` +or `:ignore_case` option in the `encrypts` declaration. Using the +`:case_sensitive` option in the validation won't work. -* Trying to read encrypted attributes that are not encrypted will work normally, without raising any error. -* Queries with deterministically-encrypted attributes will include the "clear text" version of them to support finding both encrypted and unencrypted content. You need to set `config.active_record.encryption.extend_queries = true` to enable this. +NOTE: If you have a mix of unencrypted and encrypted data or if you have data +that is encrypted using two different sets of keys/schemes, you'll need to +enable [extended +queries](configuring.html#config-active-record-encryption-extend-queries) with +`config.active_record.encryption.extend_queries = true` in order to support +unique validations. -**This option is meant to be used during transition periods** while clear data and encrypted data must coexist. Both are set to `false` by default, which is the recommended goal for any application: errors will be raised when working with unencrypted data. - -### Support for Previous Encryption Schemes - -Changing encryption properties of attributes can break existing data. For example, imagine you want to make a deterministic attribute non-deterministic. If you just change the declaration in the model, reading existing ciphertexts will fail because the encryption method is different now. +#### Unique Indexes -To support these situations, you can declare previous encryption schemes that will be used in two scenarios: +In order to support unique indexes on deterministically encrypted attributes, +it’s important to ensure that a given plaintext always produces the same +ciphertext. This consistency is what makes indexing and querying possible. -* When reading encrypted data, Active Record Encryption will try previous encryption schemes if the current scheme doesn't work. -* When querying deterministic data, it will add ciphertexts using previous schemes so that queries work seamlessly with data encrypted with different schemes. You must set `config.active_record.encryption.extend_queries = true` to enable this. +```ruby +class Person + encrypts :email_address, deterministic: true +end +``` -You can configure previous encryption schemes: +In order for unique indexes to work, you will have to ensure that the encryption +properties for the underlying attributes don't change. -* Globally -* On a per-attribute basis +### Filtering Params Named as Encrypted Attributes -#### Global Previous Encryption Schemes +Encrypted attributes are configured to be automatically +[filtered](configuring.html#config-filter-parameters) out of the Rails logs. So +sensitive information, like encrypted emails or credit card numbers, isn't +stored in your logs. For example, if you are filtering the `email` field, you +will see something like this in the logs: `Parameters: {"email"=>"[FILTERED]", +...}`. -You can add previous encryption schemes by adding them as list of properties using the `previous` config property in your `application.rb`: +In case you need to disable filtering of encrypted parameters, you can use the +following configuration: ```ruby -config.active_record.encryption.previous = [ { key_provider: MyOldKeyProvider.new } ] +# config/application.rb +config.active_record.encryption.add_to_filter_parameters = false ``` -#### Per-attribute Encryption Schemes - -Use `:previous` when declaring the attribute: +When filtering is enabled, if you want to exclude specific attributes from +automatic filtering, you can use this configuration: ```ruby -class Article - encrypts :title, deterministic: true, previous: { deterministic: false } -end +config.active_record.encryption.excluded_from_filter_parameters = [:catchphrase] ``` -#### Encryption Schemes and Deterministic Attributes - -When adding previous encryption schemes: - -* With **non-deterministic encryption**, new information will always be encrypted with the *newest* (current) encryption scheme. -* With **deterministic encryption**, new information will always be encrypted with the *oldest* encryption scheme by default. +NOTE: When generating the filter parameter, Rails will use the model name as a +prefix. E.g: For `User#email`, the filter parameter will be `user.email`. -Typically, with deterministic encryption, you want ciphertexts to remain constant. You can change this behavior by setting `deterministic: { fixed: false }`. In that case, it will use the *newest* encryption scheme for encrypting new data. - -### Unique Constraints - -NOTE: Unique constraints can only be used with deterministically encrypted data. - -#### Unique Validations +### Action Text -Unique validations are supported normally as long as extended queries are enabled (`config.active_record.encryption.extend_queries = true`). +You can encrypt Action Text attributes by passing `encrypted: true` in their +declaration. ```ruby -class Person - validates :email_address, uniqueness: true - encrypts :email_address, deterministic: true, downcase: true +class Message < ApplicationRecord + has_rich_text :content, encrypted: true end ``` -They will also work when combining encrypted and unencrypted data, and when configuring previous encryption schemes. +NOTE: Passing individual encryption options to Action Text attributes is not +supported. It will use non-deterministic encryption with the global encryption +options configured. -NOTE: If you want to ignore case, make sure to use `downcase:` or `ignore_case:` in the `encrypts` declaration. Using the `case_sensitive:` option in the validation won't work. - -#### Unique Indexes - -To support unique indexes on deterministically-encrypted columns, you need to ensure their ciphertext doesn't ever change. +### Fixtures -To encourage this, deterministic attributes will always use the oldest available encryption scheme by default when multiple encryption schemes are configured. Otherwise, it's your job to ensure encryption properties don't change for these attributes, or the unique indexes won't work. +To allow your tests can use plain text values in the YAML fixture files for +encrypted attributes, you can configure fixtures to be automatically encrypted +by adding this configuration to your `config/environments/test.rb` file: ```ruby -class Person - encrypts :email_address, deterministic: true +Rails.application.configure do + config.active_record.encryption.encrypt_fixtures = true + # ... end ``` -### Filtering Params Named as Encrypted Columns - -By default, encrypted columns are configured to be [automatically filtered in Rails logs](action_controller_overview.html#parameters-filtering). You can disable this behavior by adding the following to your `application.rb`: +Without this setting, Rails would load fixture values as is. This wouldn't work +for encrypted attributes and Active Record Encryption expects a JSON value in +that column. However, when `encrypt_fixtures` is enabled, all the encryptable +attributes will be automatically encrypted and also seamlessly decrypted, +according to the encryption settings defined in the model. -```ruby -config.active_record.encryption.add_to_filter_parameters = false -``` - -If filtering is enabled, but you want to exclude specific columns from automatic filtering, add them to `config.active_record.encryption.excluded_from_filter_parameters`: - -```ruby -config.active_record.encryption.excluded_from_filter_parameters = [:catchphrase] -``` +#### Action Text Fixtures -When generating the filter parameter, Rails will use the model name as a prefix. E.g: For `Person#name`, the filter parameter will be `person.name`. +To encrypt Action Text fixtures, you can place them in +`fixtures/action_text/encrypted_rich_texts.yml`. ### Encoding -The library will preserve the encoding for string values encrypted non-deterministically. +When encrypting strings non-deterministically, their original encoding is +preserved automatically. -Because encoding is stored along with the encrypted payload, values encrypted deterministically will force UTF-8 encoding by default. Therefore the same value with a different encoding will result in a different ciphertext when encrypted. You usually want to avoid this to keep queries and uniqueness constraints working, so the library will perform the conversion automatically on your behalf. +For deterministic encryption, Rails stores the string encoding alongside the +ciphertext. However, to ensure consistent encryption output, especially for +querying or enforcing uniqueness, the library forces UTF-8 encoding by default. +This avoids producing different ciphertexts for identical strings with different +encodings. -You can configure the desired default encoding for deterministic encryption with: +You can customize this behavior. To change the default forced encoding: ```ruby config.active_record.encryption.forced_encoding_for_deterministic_encryption = Encoding::US_ASCII ``` -And you can disable this behavior and preserve the encoding in all cases with: +To disable forced encoding and preserve the original encoding in all cases: ```ruby config.active_record.encryption.forced_encoding_for_deterministic_encryption = nil @@ -300,7 +446,15 @@ config.active_record.encryption.forced_encoding_for_deterministic_encryption = n ### Compression -The library compresses encrypted payloads by default. This can save up to 30% of the storage space for larger payloads. You can disable compression by setting `compress: false` for encrypted attributes: +Active Record Encryption enables compression of encrypted payloads by default. +This can save up to 30% of the storage space for larger payloads. + +NOTE: Compression is enabled by default but *not* applied to all payloads. It is +based on a size threshold (such as 140 bytes), which is used as a heuristic to +determine if compression is "worth it". + +You can disable compression by setting the `compress` option to `false` when +encrypting attributes: ```ruby class Article < ApplicationRecord @@ -308,7 +462,10 @@ class Article < ApplicationRecord end ``` -You can also configure the algorithm used for the compression. The default compressor is `Zlib`. You can implement your own compressor by creating a class or module that responds to `#deflate(data)` and `#inflate(data)`. +You can also configure the algorithm used for the compression. The default +compressor is [`Zlib`](https://en.wikipedia.org/wiki/Zlib). You can implement +your own compressor by creating a class or module that responds to `deflate` and +`inflate` methods. For example: ```ruby require "zstd-ruby" @@ -328,281 +485,359 @@ class User end ``` -You can configure the compressor globally: +You can also configure the desired compression method globally: ```ruby config.active_record.encryption.compressor = ZstdCompressor ``` -## Key Management +### Using the API -Key providers implement key management strategies. You can configure key providers globally, or on a per attribute basis. +Active Record Encryption is meant to be used declaratively, but there is also an +API for debugging or advanced use cases. -### Built-in Key Providers +You can encrypt and decrypt all relevant attributes of an `article` model like +this: -#### DerivedSecretKeyProvider +```ruby +article.encrypt # encrypt or re-encrypt all the encryptable attributes +article.decrypt # decrypt all the encryptable attributes +``` -A key provider that will serve keys derived from the provided passwords using PBKDF2. +You can check whether a given attribute is encrypted: ```ruby -config.active_record.encryption.key_provider = ActiveRecord::Encryption::DerivedSecretKeyProvider.new(["some passwords", "to derive keys from. ", "These should be in", "credentials"]) +article.encrypted_attribute?(:title) ``` -NOTE: By default, `active_record.encryption` configures a `DerivedSecretKeyProvider` with the keys defined in `active_record.encryption.primary_key`. +You can read the `ciphertext` for an attribute: -#### EnvelopeEncryptionKeyProvider +```ruby +article.ciphertext_for(:title) +``` -Implements a simple [envelope encryption](https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#enveloping) strategy: +## Migrating Existing Data -- It generates a random key for each data-encryption operation -- It stores the data-key with the data itself, encrypted with a primary key defined in the credential `active_record.encryption.primary_key`. +### Support for Unencrypted Data -You can configure Active Record to use this key provider by adding this to your `application.rb`: +To ease the transition from unencrypted to encrypted attributes in your Rails +application, you can enable support for unencrypted data with: ```ruby -config.active_record.encryption.key_provider = ActiveRecord::Encryption::EnvelopeEncryptionKeyProvider.new +config.active_record.encryption.support_unencrypted_data = true ``` -As with other built-in key providers, you can provide a list of primary keys in `active_record.encryption.primary_key` to implement key-rotation schemes. +When enabled: -### Custom Key Providers +* Reading attributes that are still unencrypted will succeed without raising + errors. -For more advanced key-management schemes, you can configure a custom key provider in an initializer: +* Queries on deterministically encrypted attributes can match both encrypted and + cleartext values, if you also enable `extended_queries`: ```ruby -ActiveRecord::Encryption.key_provider = MyKeyProvider.new +config.active_record.encryption.extend_queries = true ``` -A key provider must implement this interface: +This setup is intended only for migration periods during which both encrypted +and unencrypted data need to coexist in your application. Both options default +to `false`, which is the recommended long-term configuration to ensure data is +fully encrypted and enforced. -```ruby -class MyKeyProvider - def encryption_key - end +### Support for Previous Encryption Schemes - def decryption_keys(encrypted_message) - end -end -``` +Changing encryption properties of attributes can break existing data. For +example, imagine you want to make a deterministic attribute non-deterministic. +If you change the declaration in the model, reading existing ciphertexts will +fail because the encryption method is different now. -Both methods return `ActiveRecord::Encryption::Key` objects: +To support these situations, you can specify previous encryption schemes to be +used globally or on a per-attribute basis. -- `encryption_key` returns the key used for encrypting some content -- `decryption_keys` returns a list of potential keys for decrypting a given message +Once you configure the previous scheme, the following will be supported: -A key can include arbitrary tags that will be stored unencrypted with the message. You can use `ActiveRecord::Encryption::Message#headers` to examine those values when decrypting. +* When reading encrypted data, Active Record Encryption will try previous + encryption schemes if the current scheme doesn't work. -### Attribute-specific Key Providers +* When querying deterministic data, it will add ciphertexts using previous + schemes so that queries work seamlessly with data encrypted with different + schemes. -You can configure a key provider on a per-attribute basis with the `:key_provider` option: +You need to enable `extended_queries` configuration for this to work: ```ruby -class Article < ApplicationRecord - encrypts :summary, key_provider: ArticleKeyProvider.new -end +config.active_record.encryption.extend_queries = true ``` -### Attribute-specific Keys +Next, let's see how to configure previous encryption schemes. + +#### Global Previous Encryption Schemes -You can configure a given key on a per-attribute basis with the `:key` option: +You can add previous encryption schemes by adding them as a list of properties +using the `previous` config property in your `config/application.rb`: ```ruby -class Article < ApplicationRecord - encrypts :summary, key: "some secret key for article summaries" -end +config.active_record.encryption.previous = [ { key_provider: MyOldKeyProvider.new } ] ``` -Active Record uses the key to derive the key used to encrypt and decrypt the data. - -### Rotating Keys - -`active_record.encryption` can work with lists of keys to support implementing key-rotation schemes: +#### Per-attribute Previous Encryption Schemes -- The **last key** will be used for encrypting new content. -- All the keys will be tried when decrypting content until one works. +Use the `previous` option when declaring the encrypted attribute: -```yml -active_record_encryption: - primary_key: - - a1cc4d7b9f420e40a337b9e68c5ecec6 # Previous keys can still decrypt existing content - - bc17e7b413fd4720716a7633027f8cc4 # Active, encrypts new content - key_derivation_salt: a3226b97b3b2f8372d1fc6d497a0c0d3 +```ruby +class Article + encrypts :title, deterministic: true, previous: { deterministic: false } +end ``` -This enables workflows in which you keep a short list of keys by adding new keys, re-encrypting content, and deleting old keys. +#### Encryption Schemes and Determinism -NOTE: Rotating keys is not currently supported for deterministic encryption. +With deterministic encryption, you typically want ciphertexts to remain +constant. So when changing encryption schemes, non-deterministic and +deterministic encryption behave differently. -NOTE: Active Record Encryption doesn't provide automatic management of key rotation processes yet. All the pieces are there, but this hasn't been implemented yet. +* With **non-deterministic encryption**, new information will always be + encrypted with the *newest* (current) encryption scheme. -### Storing Key References +* With **deterministic encryption**, new information will be encrypted with the + *oldest* encryption scheme by default. -You can configure `active_record.encryption.store_key_references` to make `active_record.encryption` store a reference to the encryption key in the encrypted message itself. +It is possible to change this behavior for deterministic encryption to use the +*newest* encryption scheme for encrypting new data like this: ```ruby -config.active_record.encryption.store_key_references = true +class Article + encrypts :title, deterministic: { fixed: false } +end ``` -Doing so makes for more performant decryption because the system can now locate keys directly instead of trying lists of keys. The price to pay is storage: encrypted data will be a bit bigger. - -## API +## Encryption Contexts -### Basic API +An encryption context defines the encryption components that are used at a given +moment. There is a default encryption context based on your global +configuration, but you can also configure a custom context for a given attribute +or when running a specific block of code. -ActiveRecord encryption is meant to be used declaratively, but it offers an API for advanced usage scenarios. +NOTE: Encryption contexts are a flexible but advanced configuration mechanism. +Most users would not need to use them. -#### Encrypt and Decrypt +The main components of encryption contexts are: -```ruby -article.encrypt # encrypt or re-encrypt all the encryptable attributes -article.decrypt # decrypt all the encryptable attributes -``` +* `encryptor`: exposes the internal API for encrypting and decrypting data. It + interacts with a `key_provider` to build encrypted messages and deal with + their serialization. The encryption/decryption itself is done by the `cipher` + and the serialization by `message_serializer`. +* `cipher`: the encryption algorithm itself (AES 256 GCM). +* `key_provider`: serves encryption and decryption keys. +* `message_serializer`: serializes and deserializes encrypted payloads. -#### Read Ciphertext +WARNING: If you decide to build your own `message_serializer`, it's important to +use safe mechanisms that can't deserialize arbitrary objects. A commonly +supported scenario is encrypting existing unencrypted data. An attacker can +leverage this to enter a tampered payload before encryption takes place and +perform RCE attacks. This means custom serializers should avoid `Marshal`, +`YAML.load` (use `YAML.safe_load` instead), or `JSON.load` (use `JSON.parse` +instead). -```ruby -article.ciphertext_for(:title) -``` +### Built-In Encryption Context -#### Check if Attribute is Encrypted or Not +The global encryption context is the one used by default and is configured with +other configuration properties in your `config/application.rb` or environment +config files. ```ruby -article.encrypted_attribute?(:title) +config.active_record.encryption.key_provider = ActiveRecord::Encryption::EnvelopeEncryptionKeyProvider.new +config.active_record.encryption.encryptor = MyEncryptor.new ``` -## Configuration - -### Configuration Options +You can use +[`with_encryption_context`](`https://api.rubyonrails.org/classes/ActiveRecord/Encryption/Contexts.html#method-i-with_encryption_context`) +to override any of the properties of the encryption context. -You can configure Active Record Encryption options in your `application.rb` (most common scenario) or in a specific environment config file `config/environments/.rb` if you want to set them on a per-environment basis. +### Encryption Context with a Block of Code -WARNING: It's recommended to use Rails built-in credentials support to store keys. If you prefer to set them manually via config properties, make sure you don't commit them with your code (e.g. use environment variables). +You can set an encryption context for a given block of code using +`with_encryption_context`: -#### `config.active_record.encryption.support_unencrypted_data` - -When true, unencrypted data can be read normally. When false, it will raise errors. Default: `false`. +```ruby +ActiveRecord::Encryption.with_encryption_context(encryptor: ActiveRecord::Encryption::NullEncryptor.new) do + # ... +end +``` -#### `config.active_record.encryption.extend_queries` +### Per-attribute Encryption Contexts -When true, queries referencing deterministically encrypted attributes will be modified to include additional values if needed. Those additional values will be the clean version of the value (when `config.active_record.encryption.support_unencrypted_data` is true) and values encrypted with previous encryption schemes, if any (as provided with the `previous:` option). Default: `false` (experimental). +You can override encryption context configuration by passing options in the +attribute declaration: -#### `config.active_record.encryption.encrypt_fixtures` +```ruby +class Attribute + encrypts :title, encryptor: MyAttributeEncryptor.new +end +``` -When true, encryptable attributes in fixtures will be automatically encrypted when loaded. Default: `false`. +### Encryption Context to Disable Encryption -#### `config.active_record.encryption.store_key_references` +You can run code without encryption: -When true, a reference to the encryption key is stored in the headers of the encrypted message. This makes for faster decryption when multiple keys are in use. Default: `false`. +```ruby +ActiveRecord::Encryption.without_encryption do + # ... +end +``` -#### `config.active_record.encryption.add_to_filter_parameters` +This means that reading encrypted text will return the ciphertext, and saved +content will be stored unencrypted. -When true, encrypted attribute names are added automatically to [`config.filter_parameters`][] and won't be shown in logs. Default: `true`. +### Encryption Context to Protect Encrypted Data -[`config.filter_parameters`]: configuring.html#config-filter-parameters +You can run code in a block without encryption but prevent overwriting encrypted +content: -#### `config.active_record.encryption.excluded_from_filter_parameters` +```ruby +ActiveRecord::Encryption.protecting_encrypted_data do + # ... +end +``` -You can configure a list of params that won't be filtered out when `config.active_record.encryption.add_to_filter_parameters` is true. Default: `[]`. +This can be handy if you want to protect encrypted data while running arbitrary +code against it (e.g. in a Rails console). -#### `config.active_record.encryption.validate_column_size` +## Key Management -Adds a validation based on the column size. This is recommended to prevent storing huge values using highly compressible payloads. Default: `true`. +Key providers implement key management strategies. You can configure key +providers globally or on a per-attribute basis. -#### `config.active_record.encryption.primary_key` +### Built-in Key Providers -The key or lists of keys used to derive root data-encryption keys. The way they are used depends on the key provider configured. It's preferred to configure it via the `active_record_encryption.primary_key` credential. +#### DerivedSecretKeyProvider -#### `config.active_record.encryption.deterministic_key` +The +[`DerivedSecretKeyProvider`](https://api.rubyonrails.org/classes/ActiveRecord/Encryption/DerivedSecretKeyProvider.html) +serves keys derived from the provided passwords using PBKDF2. This is the key +provider configured by default. -The key or list of keys used for deterministic encryption. It's preferred to configure it via the `active_record_encryption.deterministic_key` credential. +```ruby +config.active_record.encryption.key_provider = ActiveRecord::Encryption::DerivedSecretKeyProvider.new(["some passwords", "to derive keys from. ", "These should be in", "credentials"]) +``` -#### `config.active_record.encryption.key_derivation_salt` +NOTE: By default, `active_record.encryption` configures a +`DerivedSecretKeyProvider` with the keys defined in +`active_record.encryption.primary_key`. -The salt used when deriving keys. It's preferred to configure it via the `active_record_encryption.key_derivation_salt` credential. +#### EnvelopeEncryptionKeyProvider -#### `config.active_record.encryption.forced_encoding_for_deterministic_encryption` +The +[`EnvelopeEncryptionKeyProvider`](https://api.rubyonrails.org/classes/ActiveRecord/Encryption/EnvelopeEncryptionKeyProvider.html) +implements a simple [envelope +encryption](https://en.wikipedia.org/wiki/Hybrid_cryptosystem#Envelope_encryption) +strategy, where the data is encrypted with a key, which in turn is also +encrypted. -The default encoding for attributes encrypted deterministically. You can disable forced encoding by setting this option to `nil`. It's `Encoding::UTF_8` by default. +The `EnvelopeEncryptionKeyProvider` generates a random key for each data +encryption operation. It stores the data-key with the data itself. Then, the +data-key is also encrypted with a primary key defined in the credential +`active_record.encryption.primary_key`. -#### `config.active_record.encryption.hash_digest_class` +You can configure Active Record to use this key provider by adding this to your +`config/application.rb`: -The digest algorithm used to derive keys. `OpenSSL::Digest::SHA256` by default. +```ruby +config.active_record.encryption.key_provider = ActiveRecord::Encryption::EnvelopeEncryptionKeyProvider.new +``` -#### `config.active_record.encryption.support_sha1_for_non_deterministic_encryption` +As with other built-in key providers, you can provide a list of primary keys in +`active_record.encryption.primary_key` to implement key-rotation schemes. -Supports decrypting data encrypted non-deterministically with a digest class SHA1. Default is false, which -means it will only support the digest algorithm configured in `config.active_record.encryption.hash_digest_class`. +### Custom Key Providers -#### `config.active_record.encryption.compressor` +For more advanced key-management schemes, you can configure a custom key +provider in an initializer: -The compressor used to compress encrypted payloads. It should respond to `deflate` and `inflate`. Default is `Zlib`. You can find more information about compressors in the [Compression](#compression) section. +```ruby +ActiveRecord::Encryption.key_provider = MyKeyProvider.new +``` -### Encryption Contexts +A key provider must implement this interface: -An encryption context defines the encryption components that are used in a given moment. There is a default encryption context based on your global configuration, but you can configure a custom context for a given attribute or when running a specific block of code. +```ruby +class MyKeyProvider + def encryption_key + end -NOTE: Encryption contexts are a flexible but advanced configuration mechanism. Most users should not have to care about them. + def decryption_keys(encrypted_message) + end +end +``` -The main components of encryption contexts are: +Both methods return `ActiveRecord::Encryption::Key` objects: -* `encryptor`: exposes the internal API for encrypting and decrypting data. It interacts with a `key_provider` to build encrypted messages and deal with their serialization. The encryption/decryption itself is done by the `cipher` and the serialization by `message_serializer`. -* `cipher`: the encryption algorithm itself (AES 256 GCM) -* `key_provider`: serves encryption and decryption keys. -* `message_serializer`: serializes and deserializes encrypted payloads (`Message`). +- `encryption_key` returns the key used for encrypting some content +- `decryption_keys` returns a list of potential keys for decrypting a given + ciphertext. -NOTE: If you decide to build your own `message_serializer`, it's important to use safe mechanisms that can't deserialize arbitrary objects. A common supported scenario is encrypting existing unencrypted data. An attacker can leverage this to enter a tampered payload before encryption takes place and perform RCE attacks. This means custom serializers should avoid `Marshal`, `YAML.load` (use `YAML.safe_load` instead), or `JSON.load` (use `JSON.parse` instead). +A key can include arbitrary tags that will be stored unencrypted with the +message. You can use +[`ActiveRecord::Encryption::Message#headers`](https://edgeapi.rubyonrails.org/classes/ActiveRecord/Encryption/Message.html) +to examine those values when decrypting. -#### Global Encryption Context +### Attribute-specific Key Providers -The global encryption context is the one used by default and is configured as other configuration properties in your `application.rb` or environment config files. +You can configure a key provider on a per-attribute basis with the +`key_provider` option. For example, assuming you have defined a custom key +provider called `ArticleKeyProvider`: ```ruby -config.active_record.encryption.key_provider = ActiveRecord::Encryption::EnvelopeEncryptionKeyProvider.new -config.active_record.encryption.encryptor = MyEncryptor.new +class Article < ApplicationRecord + encrypts :summary, key_provider: ArticleKeyProvider.new +end ``` -#### Per-attribute Encryption Contexts +### Attribute-specific Keys -You can override encryption context params by passing them in the attribute declaration: +You can configure a specific key for a given attribute using the `key` option: ```ruby -class Attribute - encrypts :title, encryptor: MyAttributeEncryptor.new +class Article < ApplicationRecord + encrypts :summary, key: ENV["SOME_SECRET_KEY_FOR_ARTICLE_SUMMARIES"] end ``` -#### Encryption Context When Running a Block of Code +Active Record will use the key passed to `encrypts` to encrypt and decrypt the +`summary` attribute above. -You can use `ActiveRecord::Encryption.with_encryption_context` to set an encryption context for a given block of code: +### Rotating Keys -```ruby -ActiveRecord::Encryption.with_encryption_context(encryptor: ActiveRecord::Encryption::NullEncryptor.new) do - # ... -end -``` +Active Record Encryption can work with lists of keys to support implementing key +rotation schemes. The reason to rotate keys may be as part of your +organization's security policy or if you suspect a key may be compromised. -#### Built-in Encryption Contexts +In the example below, the *last key* is used for encrypting new content and all +keys are tried when decrypting content until one works. -##### Disable Encryption +```yml +active_record_encryption: + primary_key: + - a1cc4d7b9f420e40a337b9e68c5ecec6 # Previous keys can still decrypt existing content + - bc17e7b413fd4720716a7633027f8cc4 # Active, encrypts new content + key_derivation_salt: a3226b97b3b2f8372d1fc6d497a0c0d3 +``` -You can run code without encryption: +This enables key rotation workflow where you keep a short list of keys by adding +new keys, re-encrypting content, and deleting old keys. -```ruby -ActiveRecord::Encryption.without_encryption do - # ... -end -``` +NOTE: Rotating keys is not supported for deterministic encryption. -This means that reading encrypted text will return the ciphertext, and saved content will be stored unencrypted. +### Storing Key References -##### Protect Encrypted Data +You can store a reference to the encryption key in the encrypted message itself. +The advantage of doing this is that decryption can be more performant as the +system does not have to try a list of keys to find one that works. The tradeoff +is that the encryption data will be a bit larger. -You can run code without encryption but prevent overwriting encrypted content: +In order to store a key reference, you need to enable this configuration: ```ruby -ActiveRecord::Encryption.protecting_encrypted_data do - # ... -end +config.active_record.encryption.store_key_references = true ``` -This can be handy if you want to protect encrypted data while still running arbitrary code against it (e.g. in a Rails console). diff --git a/guides/source/active_record_migrations.md b/guides/source/active_record_migrations.md index 7c7445d265b83..8197f877e2426 100644 --- a/guides/source/active_record_migrations.md +++ b/guides/source/active_record_migrations.md @@ -42,7 +42,7 @@ of your database. Here's an example of a migration: ```ruby # db/migrate/20240502100843_create_products.rb -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products do |t| t.string :name @@ -62,9 +62,9 @@ These special columns are automatically managed by Active Record if they exist. ```ruby # db/schema.rb -ActiveRecord::Schema[8.1].define(version: 2024_05_02_100843) do +ActiveRecord::Schema[8.2].define(version: 2024_05_02_100843) do # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" + enable_extension "pg_catalog.plpgsql" create_table "products", force: :cascade do |t| t.string "name" @@ -112,6 +112,9 @@ another application or generating a file yourself, be aware of its position in the order. You can read more about how the timestamps are used in the [Rails Migration Version Control section](#rails-migration-version-control). +NOTE: You can override the directory that migrations are stored in by setting the +`migrations_paths` option in your `config/database.yml`. + When generating a migration, Active Record automatically prepends the current timestamp to the file name of the migration. For example, running the command below will create an empty migration file whereby the filename is made up of a @@ -123,7 +126,7 @@ $ bin/rails generate migration AddPartNumberToProducts ```ruby # db/migrate/20240502101659_add_part_number_to_products.rb -class AddPartNumberToProducts < ActiveRecord::Migration[8.1] +class AddPartNumberToProducts < ActiveRecord::Migration[8.2] def change end end @@ -150,7 +153,7 @@ $ bin/rails generate migration CreateProducts name:string part_number:string generates ```ruby -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products do |t| t.string :name @@ -162,6 +165,8 @@ class CreateProducts < ActiveRecord::Migration[8.1] end ``` +NOTE: If you don't specify a type for a field (e.g., `name` instead of `name:string`), Rails will default to type `string`. + The generated file with its contents is just a starting point, and you can add or remove from it as you see fit by editing the `db/migrate/YYYYMMDDHHMMSS_create_products.rb` file. @@ -180,13 +185,19 @@ $ bin/rails generate migration AddPartNumberToProducts part_number:string This will generate the following migration: ```ruby -class AddPartNumberToProducts < ActiveRecord::Migration[8.1] +class AddPartNumberToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :part_number, :string end end ``` +NOTE: Rails infers the target table from the migration name when it matches the +`add__to_` or `remove__from_
` patterns. Using a +name such as `AddPartNumberToProducts` lets the generator configure +`add_column :products, ...` automatically. For more on these conventions, run +`bin/rails generate migration --help` to see the generator usage and examples. + If you'd like to add an index on the new column, you can do that as well. ```bash @@ -197,7 +208,7 @@ This will generate the appropriate [`add_column`][] and [`add_index`][] statements: ```ruby -class AddPartNumberToProducts < ActiveRecord::Migration[8.1] +class AddPartNumberToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :part_number, :string add_index :products, :part_number @@ -215,7 +226,7 @@ This will generate a schema migration which adds two additional columns to the `products` table. ```ruby -class AddDetailsToProducts < ActiveRecord::Migration[8.1] +class AddDetailsToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :part_number, :string add_column :products, :price, :decimal @@ -236,7 +247,7 @@ $ bin/rails generate migration RemovePartNumberFromProducts part_number:string This will generate the appropriate [`remove_column`][] statements: ```ruby -class RemovePartNumberFromProducts < ActiveRecord::Migration[8.1] +class RemovePartNumberFromProducts < ActiveRecord::Migration[8.2] def change remove_column :products, :part_number, :string end @@ -265,7 +276,7 @@ $ bin/rails generate migration AddUserRefToProducts user:references generates the following [`add_reference`][] call: ```ruby -class AddUserRefToProducts < ActiveRecord::Migration[8.1] +class AddUserRefToProducts < ActiveRecord::Migration[8.2] def change add_reference :products, :user, null: false, foreign_key: true end @@ -302,7 +313,7 @@ $ bin/rails generate migration CreateJoinTableUserProduct user product will produce the following migration: ```ruby -class CreateJoinTableUserProduct < ActiveRecord::Migration[8.1] +class CreateJoinTableUserProduct < ActiveRecord::Migration[8.2] def change create_join_table :users, :products do |t| # t.index [:user_id, :product_id] @@ -336,7 +347,7 @@ $ bin/rails generate model Product name:string description:text This will create a migration that looks like this: ```ruby -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products do |t| t.string :name @@ -367,7 +378,7 @@ $ bin/rails generate migration AddDetailsToProducts 'price:decimal{5,2}' supplie will produce a migration that looks like this ```ruby -class AddDetailsToProducts < ActiveRecord::Migration[8.1] +class AddDetailsToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :price, :decimal, precision: 5, scale: 2 add_reference :products, :supplier, polymorphic: true @@ -385,7 +396,7 @@ $ bin/rails generate migration AddEmailToUsers email:string! will produce this migration ```ruby -class AddEmailToUsers < ActiveRecord::Migration[8.1] +class AddEmailToUsers < ActiveRecord::Migration[8.2] def change add_column :users, :email, :string, null: false end @@ -458,7 +469,7 @@ you. You can change the name of the column with the `:primary_key` option, like below: ```ruby -class CreateUsers < ActiveRecord::Migration[8.1] +class CreateUsers < ActiveRecord::Migration[8.2] def change create_table :users, primary_key: "user_id" do |t| t.string :username @@ -484,7 +495,7 @@ You can also pass an array to `:primary_key` for a composite primary key. Read more about [composite primary keys](active_record_composite_primary_keys.html). ```ruby -class CreateUsers < ActiveRecord::Migration[8.1] +class CreateUsers < ActiveRecord::Migration[8.2] def change create_table :users, primary_key: [:id, :name] do |t| t.string :name @@ -498,7 +509,7 @@ end If you don't want a primary key at all, you can pass the option `id: false`. ```ruby -class CreateUsers < ActiveRecord::Migration[8.1] +class CreateUsers < ActiveRecord::Migration[8.2] def change create_table :users, id: false do |t| t.string :username @@ -546,7 +557,7 @@ with large databases. Currently only the MySQL and PostgreSQL adapters support comments. ```ruby -class AddDetailsToProducts < ActiveRecord::Migration[8.1] +class AddDetailsToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :price, :decimal, precision: 8, scale: 2, comment: "The price of the product in USD" add_column :products, :stock_quantity, :integer, comment: "The current stock quantity of the product" @@ -819,7 +830,7 @@ You can create a table with a composite primary key by passing the `:primary_key` option to `create_table` with an array value: ```ruby -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products, primary_key: [:customer_id, :product_sku] do |t| t.integer :customer_id @@ -840,7 +851,7 @@ If the helpers provided by Active Record aren't enough, you can use the [`execute`][] method to execute SQL commands. For example, ```ruby -class UpdateProductPrices < ActiveRecord::Migration[8.1] +class UpdateProductPrices < ActiveRecord::Migration[8.2] def up execute "UPDATE products SET price = 'free'" end @@ -961,7 +972,7 @@ how to reverse, then you can use `reversible` to specify what to do when running a migration and what else to do when reverting it. ```ruby -class ChangeProductsPrice < ActiveRecord::Migration[8.1] +class ChangeProductsPrice < ActiveRecord::Migration[8.2] def change reversible do |direction| change_table :products do |t| @@ -980,7 +991,7 @@ to an integer when the migration is reverted. Notice the block being passed to Alternatively, you can use `up` and `down` instead of `change`: ```ruby -class ChangeProductsPrice < ActiveRecord::Migration[8.1] +class ChangeProductsPrice < ActiveRecord::Migration[8.2] def up change_table :products do |t| t.change :price, :string @@ -1001,7 +1012,7 @@ ActiveRecord methods. You can use [`reversible`][] to specify what to do when running a migration and what else to do when reverting it. For example: ```ruby -class ExampleMigration < ActiveRecord::Migration[8.1] +class ExampleMigration < ActiveRecord::Migration[8.2] def change create_table :distributors do |t| t.string :zipcode @@ -1052,7 +1063,7 @@ reverse order they were made in the `up` method. The example in the `reversible` section is equivalent to: ```ruby -class ExampleMigration < ActiveRecord::Migration[8.1] +class ExampleMigration < ActiveRecord::Migration[8.2] def up create_table :distributors do |t| t.string :zipcode @@ -1089,7 +1100,7 @@ In such cases, you can raise `ActiveRecord::IrreversibleMigration` in your `down` block. ```ruby -class IrreversibleMigrationExample < ActiveRecord::Migration[8.1] +class IrreversibleMigrationExample < ActiveRecord::Migration[8.2] def up drop_table :example_table end @@ -1111,7 +1122,7 @@ You can use Active Record's ability to rollback migrations using the ```ruby require_relative "20121212123456_example_migration" -class FixupExampleMigration < ActiveRecord::Migration[8.1] +class FixupExampleMigration < ActiveRecord::Migration[8.2] def change revert ExampleMigration @@ -1129,7 +1140,7 @@ For example, let's imagine that `ExampleMigration` is committed and it is later decided that a Distributors view is no longer needed. ```ruby -class DontUseDistributorsViewMigration < ActiveRecord::Migration[8.1] +class DontUseDistributorsViewMigration < ActiveRecord::Migration[8.2] def change revert do # copy-pasted code from ExampleMigration @@ -1254,7 +1265,7 @@ these situations you can turn the automatic transactions off with `disable_ddl_transaction!`: ```ruby -class ChangeEnum < ActiveRecord::Migration[8.1] +class ChangeEnum < ActiveRecord::Migration[8.2] disable_ddl_transaction! def up @@ -1377,7 +1388,7 @@ Several methods are provided in migrations that allow you to control all this: For example, take the following migration: ```ruby -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change suppress_messages do create_table :products do |t| @@ -1516,7 +1527,7 @@ look at this file you'll find that it looks an awful lot like one very big migration: ```ruby -ActiveRecord::Schema[8.1].define(version: 2008_09_06_171750) do +ActiveRecord::Schema[8.2].define(version: 2008_09_06_171750) do create_table "authors", force: true do |t| t.string "name" t.datetime "created_at" @@ -1614,7 +1625,7 @@ modify data. This is useful in an existing database that can't be destroyed and recreated, such as a production database. ```ruby -class AddInitialProducts < ActiveRecord::Migration[8.1] +class AddInitialProducts < ActiveRecord::Migration[8.2] def up 5.times do |i| Product.create(name: "Product ##{i}", description: "A product.") @@ -1749,7 +1760,7 @@ to enable the pgcrypto extension to access the `gen_random_uuid()` function. ``` ```ruby - class CreateAuthors < ActiveRecord::Migration[8.1] + class CreateAuthors < ActiveRecord::Migration[8.2] def change create_table :authors, id: :uuid do |t| t.timestamps diff --git a/guides/source/active_record_multiple_databases.md b/guides/source/active_record_multiple_databases.md index 33f729fc8244b..375f4671550c9 100644 --- a/guides/source/active_record_multiple_databases.md +++ b/guides/source/active_record_multiple_databases.md @@ -86,6 +86,13 @@ production: replica: true ``` +Connection URLs for databases can also be configured using environment variables. The variable +name is formed by concatenating the connection name with `_DATABASE_URL`. For example, setting +`ANIMALS_DATABASE_URL="mysql2://username:password@host/database"` is merged into the `animals` +configuration in `database.yml` in the `production` environment. See +[Configuring a Database](configuring.html#configuring-a-database) for details about how the +merging works. + When using multiple databases, there are a few important settings. First, the database name for `primary` and `primary_replica` should be the same because they contain @@ -138,7 +145,7 @@ class Person < PrimaryApplicationRecord end ``` -On the other hand, we need to setup our models persisted in the "animals" database: +On the other hand, we need to set up our models persisted in the "animals" database: ```ruby class AnimalsRecord < ApplicationRecord @@ -212,7 +219,7 @@ db:setup:primary # Create the primary database, loads the sche Running a command like `bin/rails db:create` will create both the primary and animals databases. Note that there is no command for creating the database users, and you'll need to do that manually -to support the readonly users for your replicas. If you want to create just the animals +to support the read-only users for your replicas. If you want to create just the animals database you can run `bin/rails db:create:animals`. ## Connecting to Databases without Managing Schema and Migrations @@ -295,8 +302,8 @@ use a different parent class. Finally, in order to use the read-only replica in your application, you'll need to activate the middleware for automatic switching. -Automatic switching allows the application to switch from the writer to replica or replica -to writer based on the HTTP verb and whether there was a recent write by the requesting user. +Automatic switching allows the application to switch from the writer to the replica or the replica +to the writer based on the HTTP verb and whether there was a recent write by the requesting user. If the application receives a POST, PUT, DELETE, or PATCH request, the application will automatically write to the writer database. If the request is not one of those methods, @@ -328,7 +335,7 @@ to the replicas unless they wrote recently. The automatic connection switching in Rails is relatively primitive and deliberately doesn't do a whole lot. The goal is a system that demonstrates how to do automatic connection -switching that was flexible enough to be customizable by app developers. +switching that is flexible enough to be customizable by app developers. The setup in Rails allows you to easily change how the switching is done and what parameters it's based on. Let's say you want to use a cookie instead of a session to @@ -392,7 +399,7 @@ using the connection specification name. This means that if you pass an unknown like `connected_to(role: :nonexistent)` you will get an error that says `ActiveRecord::ConnectionNotEstablished (No connection pool for 'ActiveRecord::Base' found for the 'nonexistent' role.)` -If you want Rails to ensure any queries performed are read only, pass `prevent_writes: true`. +If you want Rails to ensure any queries performed are read-only, pass `prevent_writes: true`. This just prevents queries that look like writes from being sent to the database. You should also configure your replica database to run in read-only mode. @@ -517,7 +524,7 @@ end ``` Applications must provide a resolver to provide application-specific logic. An example resolver that -uses subdomain to determine the shard might look like this: +uses a subdomain to determine the shard might look like this: ```ruby config.active_record.shard_resolver = ->(request) { diff --git a/guides/source/active_record_postgresql.md b/guides/source/active_record_postgresql.md index 6854d3c09dc34..d67a6ba736ddc 100644 --- a/guides/source/active_record_postgresql.md +++ b/guides/source/active_record_postgresql.md @@ -102,7 +102,7 @@ NOTE: You need to enable the `hstore` extension to use hstore. ```ruby # db/migrate/20131009135255_create_profiles.rb -class CreateProfiles < ActiveRecord::Migration[8.1] +class CreateProfiles < ActiveRecord::Migration[8.2] enable_extension "hstore" unless extension_enabled?("hstore") create_table :profiles do |t| t.hstore "settings" @@ -516,6 +516,34 @@ irb> event.duration => 2 days ``` +### Timestamps + +* [Date/Time Types](https://www.postgresql.org/docs/current/datatype-datetime.html) + +Rails migrations with timestamps store the time a model was created or updated. By default and for legacy reasons, the columns use the `timestamp without time zone` data type. + +```ruby +# db/migrate/20241220144913_create_devices.rb +create_table :post, id: :uuid do |t| + t.datetime :published_at + # By default, Active Record will set the data type of this column to `timestamp without time zone`. +end +``` + +While this works ok, [PostgreSQL best practices](https://wiki.postgresql.org/wiki/Don't_Do_This#Don.27t_use_timestamp_.28without_time_zone.29) recommend that `timestamp with time zone` is used instead for timezone-aware timestamps. +This must be configured before it can be used for new migrations. + +To configure `timestamp with time zone` as your new timestamp default data type, place the following configuration in the `config/application.rb` file. + +```ruby +# config/application.rb +ActiveSupport.on_load(:active_record_postgresqladapter) do + self.datetime_type = :timestamptz +end +``` + +With that configuration in place, generate and apply new migrations, then verify their timestamps use the `timestamp with time zone` data type. + UUID Primary Keys ----------------- @@ -551,14 +579,14 @@ To use the Rails model generator for a table using UUID as the primary key, pass For example: ```bash -$ rails generate model Device --primary-key-type=uuid kind:string +$ bin/rails generate model Device --primary-key-type=uuid kind:string ``` When building a model with a foreign key that will reference this UUID, treat `uuid` as the native field type, for example: ```bash -$ rails generate model Case device_id:uuid +$ bin/rails generate model Case device_id:uuid ``` Indexing diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md index 41fa48d97192f..2feaa12242ccf 100644 --- a/guides/source/active_record_querying.md +++ b/guides/source/active_record_querying.md @@ -199,7 +199,7 @@ SELECT * FROM customers WHERE (customers.id IN (1,10)) WARNING: The `find` method will raise an `ActiveRecord::RecordNotFound` exception unless a matching record is found for **all** of the supplied primary keys. -If your table uses a composite primary key, you'll need to pass find an array to find a single item. For instance, if customers were defined with `[:store_id, :id]` as a primary key: +If your table uses a composite primary key, you'll need to pass in an array to find a single item. For instance, if customers were defined with `[:store_id, :id]` as a primary key: ```irb # Find the customer with store_id 3 and id 17 @@ -244,7 +244,7 @@ SELECT * FROM customers LIMIT 1 The `take` method returns `nil` if no record is found and no exception will be raised. -You can pass in a numerical argument to the `take` method to return up to that number of results. For example +You can pass in a numerical argument to the `take` method to return up to that number of results. For example: ```irb irb> customers = Customer.take(2) @@ -283,7 +283,7 @@ The `first` method returns `nil` if no matching record is found and no exception If your [default scope](active_record_querying.html#applying-a-default-scope) contains an order method, `first` will return the first record according to this ordering. -You can pass in a numerical argument to the `first` method to return up to that number of results. For example +You can pass in a numerical argument to the `first` method to return up to that number of results. For example: ```irb irb> customers = Customer.first(3) @@ -361,7 +361,7 @@ SELECT * FROM customers ORDER BY customers.store_id DESC, customers.id DESC LIMI If your [default scope](active_record_querying.html#applying-a-default-scope) contains an order method, `last` will return the last record according to this ordering. -You can pass in a numerical argument to the `last` method to return up to that number of results. For example +You can pass in a numerical argument to the `last` method to return up to that number of results. For example: ```irb irb> customers = Customer.last(3) @@ -978,7 +978,7 @@ Limit and Offset To apply `LIMIT` to the SQL fired by the `Model.find`, you can specify the `LIMIT` using [`limit`][] and [`offset`][] methods on the relation. -You can use `limit` to specify the number of records to be retrieved, and use `offset` to specify the number of records to skip before starting to return the records. For example +You can use `limit` to specify the number of records to be retrieved, and use `offset` to specify the number of records to skip before starting to return the records. For example: ```ruby Customer.limit(5) @@ -1150,7 +1150,7 @@ Compare this to the case where the `reselect` clause is not used: Book.select(:title, :isbn).select(:created_at) ``` -the SQL executed would be: +The SQL executed would be: ```sql SELECT books.title, books.isbn, books.created_at FROM books @@ -1158,7 +1158,7 @@ SELECT books.title, books.isbn, books.created_at FROM books ### `reorder` -The [`reorder`][] method overrides the default scope order. For example if the class definition includes this: +The [`reorder`][] method overrides the default scope order. For example, if the class definition includes this: ```ruby class Author < ApplicationRecord @@ -1240,7 +1240,7 @@ If the `rewhere` clause is not used, the where clauses are ANDed together: Book.where(out_of_print: true).where(out_of_print: false) ``` -the SQL executed would be: +The SQL executed would be: ```sql SELECT * FROM books WHERE out_of_print = 1 AND out_of_print = 0 @@ -1269,7 +1269,7 @@ If the `regroup` clause is not used, the group clauses are combined together: Book.group(:author).group(:id) ``` -the SQL executed would be: +The SQL executed would be: ```sql SELECT * FROM books GROUP BY author, id @@ -1734,7 +1734,7 @@ NOTE: The `preload` method uses an array, hash, or a nested hash of array/hash i With `eager_load`, Active Record loads all specified associations using a `LEFT OUTER JOIN`. -Revisiting the case where N + 1 was occurred using the `eager_load` method, we could rewrite `Book.limit(10)` to authors: +Revisiting the case where N + 1 was occurred using the `eager_load` method, we could rewrite `Book.limit(10)` to eager load authors: ```ruby books = Book.eager_load(:author).limit(10) @@ -2032,6 +2032,55 @@ As you can see above the `default_scope` is being merged in both [`merge`]: https://api.rubyonrails.org/classes/ActiveRecord/SpawnMethods.html#method-i-merge +### Block-Level Scoping + +The [`scoping`][] method allows you to temporarily apply the current relation’s conditions within a block. +Any query executed inside the block will use the scope of the relation. + +#### Basic Usage + +```ruby +Order.where(customer_id: 1).scoping do + Order.first +end + +# SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" = ? ORDER BY "orders"."id" ASC LIMIT ? [["customer_id", 1], ["LIMIT", 1]] +``` + +In this example, the `customer_id: 1` condition is applied automatically because the block is executed within the relation’s scope. + +#### Applying Scope To All Queries In The Block + +By default, scoping applies only to finder methods (such as `first`, `last`, `where`, etc.). +If you want the scope to affect all queries—including `update` and `delete` on individual records, you can pass the option `all_queries: true`. + +```ruby +Order.where(customer_id: 1).scoping(all_queries: true) do + order = Order.first + order.update(status: :complete) +end + +# Order Load (0.1ms) SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" = ? ORDER BY "orders"."id" ASC LIMIT ? [["customer_id", 1], ["LIMIT", 1]] +# TRANSACTION (0.0ms) BEGIN immediate TRANSACTION +# Order Update (0.1ms) UPDATE "orders" SET "status" = ?, "updated_at" = ? WHERE "orders"."id" = ? AND "orders"."customer_id" = ? [["status", 2], ["updated_at", "2025-11-25 11:26:16.089553"], ["id", 1], ["customer_id", 1]] +# TRANSACTION (0.0ms) COMMIT TRANSACTION +``` + +This will ensure that the `customer_id: 1` condition is applied to all queries executed within the block. + +Once a block has been entered with `all_queries: true`, nested blocks cannot disable it: + +```ruby +Order.where(customer_id: 1).scoping(all_queries: true) do + # This will raise an ArgumentError: + Order.scoping(all_queries: false) do + # ... + end +end +``` + +[`scoping`]: https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-scoping + ### Removing All Scoping If we wish to remove scoping for any reason we can use the [`unscoped`][] method. This is @@ -2133,7 +2182,7 @@ Understanding Method Chaining ----------------------------- The Active Record pattern implements [Method Chaining](https://en.wikipedia.org/wiki/Method_chaining), -which allow us to use multiple Active Record methods together in a simple and straightforward way. +which allows us to use multiple Active Record methods together in a simple and straightforward way. You can chain methods in a statement when the previous method called returns an [`ActiveRecord::Relation`][], like `all`, `where`, and `joins`. Methods that return @@ -2294,7 +2343,7 @@ irb> nina.save Finding by SQL -------------- -If you'd like to use your own SQL to find records in a table you can use [`find_by_sql`][]. The `find_by_sql` method will return an array of objects even if the underlying query returns just a single record. For example you could run this query: +If you'd like to use your own SQL to find records in a table you can use [`find_by_sql`][]. The `find_by_sql` method will return an array of objects even if the underlying query returns just a single record. For example, you could run this query: ```irb irb> Customer.find_by_sql("SELECT * FROM customers INNER JOIN orders ON customers.id = orders.customer_id ORDER BY customers.created_at desc") @@ -2635,9 +2684,9 @@ EXPLAIN SELECT `customers`.* FROM `customers` INNER JOIN `orders` ON `orders`.`c 2 rows in set (0.00 sec) ``` -Active Record performs a pretty printing that emulates that of the -corresponding database shell. So, the same query running with the -PostgreSQL adapter would yield instead: +Active Record performs pretty printing that emulates the output of +the corresponding database shell. So, the same query run with the +PostgreSQL adapter would instead yield: ```sql EXPLAIN SELECT "customers".* FROM "customers" INNER JOIN "orders" ON "orders"."customer_id" = "customers"."id" WHERE "customers"."id" = $1 [["id", 1]] diff --git a/guides/source/active_record_validations.md b/guides/source/active_record_validations.md index be5661b189336..8bd6c85984ac8 100644 --- a/guides/source/active_record_validations.md +++ b/guides/source/active_record_validations.md @@ -81,7 +81,7 @@ validations. There are two kinds of Active Record objects - those that correspond to a row inside your database and those that do not. When you instantiate a new object, using the `new` method, the object does not get saved in the database as yet. -Once you call `save` on that object then will it be saved into the appropriate +Once you call `save` on that object, it will be saved into the appropriate database table. Active Record uses an instance method called `persisted?` (and its inverse `new_record?`) to determine whether an object is already in the database or not. Consider the following Active Record class: diff --git a/guides/source/active_storage_overview.md b/guides/source/active_storage_overview.md index cecff699141a9..05e941c835346 100644 --- a/guides/source/active_storage_overview.md +++ b/guides/source/active_storage_overview.md @@ -23,7 +23,7 @@ What is Active Storage? ----------------------- Active Storage facilitates uploading files to a cloud storage service like -Amazon S3, Google Cloud Storage, or Microsoft Azure Storage and attaching those +Amazon S3, or Google Cloud Storage and attaching those files to Active Record objects. It comes with a local disk-based service for development and testing and supports mirroring files to subordinate services for backups and migrations. @@ -41,12 +41,6 @@ will not install, and must be installed separately: * [ffmpeg](http://ffmpeg.org/) v3.4+ for video previews and ffprobe for video/audio analysis * [poppler](https://poppler.freedesktop.org/) or [muPDF](https://mupdf.com/) for PDF previews -Image analysis and transformations also require the `image_processing` gem. Uncomment it in your `Gemfile`, or add it if necessary: - -```ruby -gem "image_processing", ">= 1.2" -``` - TIP: Compared to libvips, ImageMagick is better known and more widely available. However, libvips can be [up to 10x faster and consume 1/10 the memory](https://github.com/libvips/libvips/wiki/Speed-and-memory-use). For JPEG files, this can be further improved by replacing `libjpeg-dev` with `libjpeg-turbo-dev`, which is [2-7x faster](https://libjpeg-turbo.org/About/Performance). WARNING: Before you install and use third-party software, make sure you understand the licensing implications of doing so. MuPDF, in particular, is licensed under AGPL and requires a commercial license for some use. @@ -103,7 +97,7 @@ development environment, you would add the following to config.active_storage.service = :local ``` -To use the S3 service in production, you add the following to +To use the S3 service in production, you would add the following to `config/environments/production.rb`: ```ruby @@ -111,7 +105,7 @@ To use the S3 service in production, you add the following to config.active_storage.service = :amazon ``` -To use the test service when testing, you add the following to +To use the test service when testing, you would add the following to `config/environments/test.rb`: ```ruby @@ -135,11 +129,6 @@ google: service: GCS # ... bucket: your_own_bucket-<%= Rails.env %> - -azure: - service: AzureStorage - # ... - container: your_container_name-<%= Rails.env %> ``` Continue reading for more information on the built-in service adapters (e.g. @@ -215,25 +204,6 @@ digitalocean: There are many other options available. You can check them in [AWS S3 Client](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#initialize-instance_method) documentation. -### Microsoft Azure Storage Service - -Declare an Azure Storage service in `config/storage.yml`: - -```yaml -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -azure: - service: AzureStorage - storage_account_name: your_account_name - storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> - container: your_container_name-<%= Rails.env %> -``` - -Add the [`azure-storage-blob`](https://github.com/Azure/azure-storage-ruby) gem to your `Gemfile`: - -```ruby -gem "azure-storage-blob", "~> 2.0", require: false -``` - ### Google Cloud Storage Service Declare a Google Cloud Storage service in `config/storage.yml`: @@ -369,7 +339,7 @@ public_gcs: public: true ``` -Make sure your buckets are properly configured for public access. See docs on how to enable public read permissions for [Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/block-public-access-bucket.html), [Google Cloud Storage](https://cloud.google.com/storage/docs/access-control/making-data-public#buckets), and [Microsoft Azure](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-manage-access-to-resources#set-container-public-access-level-in-the-azure-portal) storage services. Amazon S3 additionally requires that you have the `s3:PutObjectAcl` permission. +Make sure your buckets are properly configured for public access. See docs on how to enable public read permissions for [Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/block-public-access-bucket.html) and [Google Cloud Storage](https://cloud.google.com/storage/docs/access-control/making-data-public#buckets) storage services. Amazon S3 additionally requires that you have the `s3:PutObjectAcl` permission. When converting an existing application to use `public: true`, make sure to update every individual file in the bucket to be publicly-readable before switching over. @@ -468,19 +438,29 @@ end <%= image_tag user.video.preview(:thumb) %> ``` -If you know in advance that your variants will be accessed, you can specify that -Rails should generate them ahead of time: +#### Variant Generation: Lazily, Later, Immediately + +When you know in advance which variants you'll generate, use the `process` option to control when they're generated: + +* `:lazily` (default) - Variants are created on the fly when first requested +* `:later` - Variants are created in a background job after the attachment is saved +* `:immediately` - Variants are created synchronously when the attachment is created ```ruby class User < ApplicationRecord - has_one_attached :video do |attachable| - attachable.variant :thumb, resize_to_limit: [100, 100], preprocessed: true + has_one_attached :avatar do |attachable| + # Create immediately when the avatar is attached + attachable.variant :thumb, resize_to_limit: [100, 100], process: :immediately + + # Create in a background job after attachment + attachable.variant :medium, resize_to_limit: [300, 300], process: :later + + # Create on demand when first requested (default) + attachable.variant :large, resize_to_limit: [800, 800], process: :lazily end end ``` -Rails will enqueue a job to generate the variant after the attachment is attached to the record. - NOTE: Since Active Storage relies on polymorphic associations, and [polymorphic associations](./association_basics.html#polymorphic-associations) rely on storing class names in the database, that data must remain synchronized with the class name used by the Ruby code. When renaming classes that use `has_one_attached`, make sure to also update the class names in the `active_storage_attachments.record_type` polymorphic type column of the corresponding rows. [`has_one_attached`]: https://api.rubyonrails.org/classes/ActiveStorage/Attached/Model.html#method-i-has_one_attached @@ -662,6 +642,31 @@ are stored before the form is submitted, they can be used to retain uploads when <%= form.file_field :avatar, direct_upload: true %> ``` +## Querying + +Active Storage attachments are Active Record associations behind the scenes, so you can use the usual [query methods](active_record_querying.html) to look up records for attachments that meet specific criteria. + +### `has_one_attached` + +[`has_one_attached`](https://api.rubyonrails.org/classes/ActiveStorage/Attached/Model.html#method-i-has_one_attached) creates a `has_one` association named `"_attachment"` and a `has_one :through` association named `"_blob"`. +To select every user where the avatar is a PNG, run the following: + +```ruby +User.joins(:avatar_blob).where(active_storage_blobs: { content_type: "image/png" }) +``` + +### `has_many_attached` + +[`has_many_attached`](https://api.rubyonrails.org/classes/ActiveStorage/Attached/Model.html#method-i-has_many_attached) creates a `has_many` association called `"_attachments"` and a `has_many :through` association called `"_blobs"` (note the plural). +To select all messages where images are videos rather than photos you can do the following: + +```ruby +Message.joins(:images_blobs).where(active_storage_blobs: { content_type: "video/mp4" }) +``` + +The query will filter on the [**`ActiveStorage::Blob`**](https://api.rubyonrails.org/classes/ActiveStorage/Blob.html), not the [attachment record](https://api.rubyonrails.org/classes/ActiveStorage/Attachment.html) because these are plain SQL joins. You can combine the blob predicates above with any other scope conditions, just as you would with any other Active Record query. + + Removing Files -------------- @@ -693,8 +698,8 @@ require a higher level of protection consider implementing ### Redirect Mode -To generate a permanent URL for a blob, you can pass the blob to the -[`url_for`][ActionView::RoutingUrlFor#url_for] view helper. This generates a +To generate a permanent URL for a blob, you can pass the attachment or the blob to +the [`url_for`][ActionView::RoutingUrlFor#url_for] view helper. This generates a URL with the blob's [`signed_id`][ActiveStorage::Blob#signed_id] that is routed to the blob's [`RedirectController`][`ActiveStorage::Blobs::RedirectController`] @@ -967,6 +972,12 @@ location. <%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %> ``` +WARNING: It should be considered unsafe to provide arbitrary user supplied +transformations or parameters to variant processors. This can potentially +enable command injection vulnerabilities in your app. It is also recommended +to implement a strict [ImageMagick security policy](https://imagemagick.org/script/security-policy.php) +when MiniMagick is the variant processor of choice. + If a variant is requested, Active Storage will automatically apply transformations depending on the image's format: @@ -980,49 +991,11 @@ Active Storage can use either [Vips][] or MiniMagick as the variant processor. The default depends on your `config.load_defaults` target version, and the processor can be changed by setting [`config.active_storage.variant_processor`][]. -The parameters available are defined by the [`image_processing`][] gem and depend on the -variant processor that you are using, but both support the following parameters: - -| Parameter | Example | Description | -| ------------------- | ---------------- | ----- | -| `resize_to_limit` | `resize_to_limit: [100, 100]` | Downsizes the image to fit within the specified dimensions while retaining the original aspect ratio. Will only resize the image if it's larger than the specified dimensions. | -| `resize_to_fit` | `resize_to_fit: [100, 100]` | Resizes the image to fit within the specified dimensions while retaining the original aspect ratio. Will downsize the image if it's larger than the specified dimensions or upsize if it's smaller. | -| `resize_to_fill` | `resize_to_fill: [100, 100]` | Resizes the image to fill the specified dimensions while retaining the original aspect ratio. If necessary, will crop the image in the larger dimension. | -| `resize_and_pad` | `resize_and_pad: [100, 100]` | Resizes the image to fit within the specified dimensions while retaining the original aspect ratio. If necessary, will pad the remaining area with transparent color if source image has alpha channel, black otherwise. | -| `crop` | `crop: [20, 50, 300, 300]` | Extracts an area from an image. The first two arguments are the left and top edges of area to extract, while the last two arguments are the width and height of the area to extract. | -| `rotate` | `rotate: 90` | Rotates the image by the specified angle. | - -[`image_processing`][] has all parameters available in its own documentation -for both the -[Vips](https://github.com/janko/image_processing/blob/master/doc/vips.md) and -[MiniMagick](https://github.com/janko/image_processing/blob/master/doc/minimagick.md) -processors. - -Some parameters, including those listed above, accept additional processor -specific options which can be passed as `key: value` pairs inside a hash: - -```erb - -<%= image_tag user.avatar.variant(resize_to_fill: [100, 100, { crop: :centre }]) %> -``` - -If migrating an existing application between MiniMagick and Vips, processor -specific options will need to be updated: - -```erb - -<%= image_tag user.avatar.variant(resize_to_limit: [100, 100], format: :jpeg, sampling_factor: "4:2:0", strip: true, interlace: "JPEG", colorspace: "sRGB", quality: 80) %> - - -<%= image_tag user.avatar.variant(resize_to_limit: [100, 100], format: :jpeg, saver: { subsample_mode: "on", strip: true, interlace: true, quality: 80 }) %> -``` - [`config.active_storage.variable_content_types`]: configuring.html#config-active-storage-variable-content-types [`config.active_storage.variant_processor`]: configuring.html#config-active-storage-variant-processor [`config.active_storage.web_image_content_types`]: configuring.html#config-active-storage-web-image-content-types [`variant`]: https://api.rubyonrails.org/classes/ActiveStorage/Blob/Representable.html#method-i-variant [Vips]: https://www.rubydoc.info/gems/ruby-vips/Vips/Image -[`image_processing`]: https://github.com/janko/image_processing ### Previewing Files @@ -1049,7 +1022,30 @@ directly from the client to the cloud. ### Usage -1. Include `activestorage.js` in your application's JavaScript bundle. +1. Include the Active Storage JavaScript in your application's JavaScript +bundle or reference it directly. + + Requiring it directly in the application HTML with autostart, instead of + bundling it through the asset pipeline: + + ```erb + <%= javascript_include_tag "activestorage" %> + ``` + + Requiring via importmap-rails as an ESM in the application HTML, instead of + bundling it through the asset pipeline and using autostart: + + ```ruby + # config/importmap.rb + pin "@rails/activestorage", to: "activestorage.esm.js" + ``` + + ```html + + ``` Using the asset pipeline: @@ -1064,7 +1060,9 @@ directly from the client to the cloud. ActiveStorage.start() ``` -2. Add `direct_upload: true` to your [file field](form_helpers.html#uploading-files): +2. Add `direct_upload: true` option to your [`file_field` +helper](form_helpers.html#uploading-files) to automatically annotate the +input field with the direct upload URL via `data-direct-upload-url` attribute. ```erb <%= form.file_field :attachments, multiple: true, direct_upload: true %> @@ -1086,7 +1084,6 @@ To make direct uploads to a third-party service work, you’ll need to configure * [S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html) * [Google Cloud Storage](https://cloud.google.com/storage/docs/configuring-cors) -* [Azure Storage](https://docs.microsoft.com/en-us/rest/api/storageservices/cross-origin-resource-sharing--cors--support-for-the-azure-storage-services) Take care to allow: @@ -1095,9 +1092,7 @@ Take care to allow: * The following headers: * `Content-Type` * `Content-MD5` - * `Content-Disposition` (except for Azure Storage) - * `x-ms-blob-content-disposition` (for Azure Storage only) - * `x-ms-blob-type` (for Azure Storage only) + * `Content-Disposition` * `Cache-Control` (for GCS, only if `cache_control` is set) No CORS configuration is required for the Disk service since it shares your app’s origin. @@ -1136,19 +1131,6 @@ No CORS configuration is required for the Disk service since it shares your app ] ``` -#### Example: Azure Storage CORS Configuration - -```xml - - - https://www.example.com - PUT - Content-Type, Content-MD5, x-ms-blob-content-disposition, x-ms-blob-type - 3600 - - -``` - ### Direct Upload JavaScript Events | Event name | Event target | Event data (`event.detail`) | Description | @@ -1573,7 +1555,7 @@ Minitest.after_run do end ``` -[fixtures]: testing.html#the-low-down-on-fixtures +[fixtures]: testing.html#fixtures [`ActiveStorage::FixtureSet`]: https://api.rubyonrails.org/classes/ActiveStorage/FixtureSet.html ### Configuring services diff --git a/guides/source/active_support_core_extensions.md b/guides/source/active_support_core_extensions.md index 5f5087915fe7b..abb872170ce6b 100644 --- a/guides/source/active_support_core_extensions.md +++ b/guides/source/active_support_core_extensions.md @@ -977,19 +977,23 @@ class MysqlAdapter < AbstractAdapter end ``` -Instance methods are created as well for convenience, they are just proxies to the class attribute. So, instances can change the class attribute, but cannot override it as it happens with `class_attribute` (see above). For example given +Instance methods are also created for convenience, but they are simply proxies to the internal value which is shared among the class. As a result, when an instance modifies the value, this affects the entire class hierarchy. This behavior is different than `class_attribute` (see above). + +For example: ```ruby -module ActionView - class Base - cattr_accessor :field_error_proc, default: Proc.new { - # ... - } - end +class Foo + cattr_accessor :bar end -``` -we can access `field_error_proc` in views. +instance = Foo.new + +Foo.bar = 1 +instance.bar # => 1 + +instance.bar = 2 +Foo.bar # => 2 +``` The generation of the reader instance method can be prevented by setting `:instance_reader` to `false` and the generation of the writer instance method can be prevented by setting `:instance_writer` to `false`. Generation of both methods can be prevented by setting `:instance_accessor` to `false`. In all cases, the value must be exactly `false` and not any false value. @@ -1014,31 +1018,7 @@ NOTE: Defined in `active_support/core_ext/module/attribute_accessors.rb`. [Module#cattr_reader]: https://api.rubyonrails.org/classes/Module.html#method-i-cattr_reader [Module#cattr_writer]: https://api.rubyonrails.org/classes/Module.html#method-i-cattr_writer -### Subclasses and Descendants - -#### `subclasses` - -The [`subclasses`][Class#subclasses] method returns the subclasses of the receiver: - -```ruby -class C; end -C.subclasses # => [] - -class B < C; end -C.subclasses # => [B] - -class A < B; end -C.subclasses # => [B] - -class D < C; end -C.subclasses # => [B, D] -``` - -The order in which these classes are returned is unspecified. - -NOTE: Defined in `active_support/core_ext/class/subclasses.rb`. - -[Class#subclasses]: https://api.rubyonrails.org/classes/Class.html#method-i-subclasses +### Descendants #### `descendants` @@ -2187,13 +2167,13 @@ Extensions to `BigDecimal` ### `to_s` -The method `to_s` provides a default specifier of "F". This means that a simple call to `to_s` will result in floating-point representation instead of engineering notation: +The method `to_s` provides a default specifier of "F". This means that a simple call to `to_s` will result in floating-point representation instead of scientific notation: ```ruby BigDecimal(5.00, 6).to_s # => "5.0" ``` -Engineering notation is still supported: +Scientific notation is still supported: ```ruby BigDecimal(5.00, 6).to_s("e") # => "0.5E1" diff --git a/guides/source/active_support_instrumentation.md b/guides/source/active_support_instrumentation.md index 95e7a176a9fe2..c0b6129702e2a 100644 --- a/guides/source/active_support_instrumentation.md +++ b/guides/source/active_support_instrumentation.md @@ -89,6 +89,44 @@ Rails Framework Hooks Within the Ruby on Rails framework, there are a number of hooks provided for common events. These events and their payloads are detailed below. +### Action Cable + +#### `perform_action.action_cable` + +| Key | Value | +| ---------------- | ------------------------- | +| `:channel_class` | Name of the channel class | +| `:action` | The action | +| `:data` | A hash of data | + +#### `transmit.action_cable` + +| Key | Value | +| ---------------- | ------------------------- | +| `:channel_class` | Name of the channel class | +| `:data` | A hash of data | +| `:via` | Via | + +#### `transmit_subscription_confirmation.action_cable` + +| Key | Value | +| ---------------- | ------------------------- | +| `:channel_class` | Name of the channel class | + +#### `transmit_subscription_rejection.action_cable` + +| Key | Value | +| ---------------- | ------------------------- | +| `:channel_class` | Name of the channel class | + +#### `broadcast.action_cable` + +| Key | Value | +| --------------- | -------------------- | +| `:broadcasting` | A named broadcasting | +| `:message` | A hash of message | +| `:coder` | The coder | + ### Action Controller #### `start_processing.action_controller` @@ -213,6 +251,19 @@ Additional keys may be added by the caller. } ``` +#### `rate_limit.action_controller` + +| Key | Value | +| ------------ | --------------------------------------------- | +| `:request` | The [`ActionDispatch::Request`][] object | +| `:count` | Number of requests made | +| `:to` | Maximum number of requests allowed | +| `:within` | Time window for the rate limit | +| `:by` | Identifier for the rate limit (e.g. IP) | +| `:name` | Name of the rate limit | +| `:scope` | Scope of the rate limit | +| `:cache_key` | The cache key used for storing the rate limit | + ### Action Controller: Caching #### `write_fragment.action_controller` @@ -274,10 +325,11 @@ Additional keys may be added by the caller. #### `redirect.action_dispatch` | Key | Value | -| ----------- | ---------------------------------------- | -| `:status` | HTTP response code | -| `:location` | URL to redirect to | -| `:request` | The [`ActionDispatch::Request`][] object | +| ------------------ | ---------------------------------------- | +| `:status` | HTTP response code | +| `:location` | URL to redirect to | +| `:request` | The [`ActionDispatch::Request`][] object | +| `:source_location` | Source location of redirect in routes | #### `request.action_dispatch` @@ -285,6 +337,77 @@ Additional keys may be added by the caller. | ----------- | ---------------------------------------- | | `:request` | The [`ActionDispatch::Request`][] object | +[`ActionDispatch::Request`]: https://api.rubyonrails.org/classes/ActionDispatch/Request.html +[`ActionDispatch::Response`]: https://api.rubyonrails.org/classes/ActionDispatch/Response.html + +### Action Mailbox + +#### `process.action_mailbox` + +| Key | Value | +| -----------------| ------------------------------------------------------ | +| `:mailbox` | Instance of the Mailbox class inheriting from [`ActionMailbox::Base`][] | +| `:inbound_email` | Hash with data about the inbound email being processed | + +```ruby +{ + mailbox: #, + inbound_email: { + id: 1, + message_id: "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", + status: "processing" + } +} +``` + +[`ActionMailbox::Base`]: https://api.rubyonrails.org/classes/ActionMailbox/Base.html + +### Action Mailer + +#### `deliver.action_mailer` + +| Key | Value | +| --------------------- | ---------------------------------------------------- | +| `:mailer` | Name of the mailer class | +| `:message_id` | ID of the message, generated by the Mail gem | +| `:subject` | Subject of the mail | +| `:to` | To address(es) of the mail | +| `:from` | From address of the mail | +| `:bcc` | BCC addresses of the mail | +| `:cc` | CC addresses of the mail | +| `:date` | Date of the mail | +| `:mail` | The encoded form of the mail | +| `:perform_deliveries` | Whether delivery of this message is performed or not | + +```ruby +{ + mailer: "Notification", + message_id: "4f5b5491f1774_181b23fc3d4434d38138e5@mba.local.mail", + subject: "Rails Guides", + to: ["users@rails.com", "dhh@rails.com"], + from: ["me@rails.com"], + date: Sat, 10 Mar 2012 14:18:09 +0100, + mail: "...", # omitted for brevity + perform_deliveries: true +} +``` + +#### `process.action_mailer` + +| Key | Value | +| ------------- | ------------------------ | +| `:mailer` | Name of the mailer class | +| `:action` | The action | +| `:args` | The arguments | + +```ruby +{ + mailer: "Notification", + action: "welcome_email", + args: [] +} +``` + ### Action View #### `render_template.action_view` @@ -348,8 +471,68 @@ The `:cache_hits` key is only included if the collection is rendered with `cache } ``` -[`ActionDispatch::Request`]: https://api.rubyonrails.org/classes/ActionDispatch/Request.html -[`ActionDispatch::Response`]: https://api.rubyonrails.org/classes/ActionDispatch/Response.html +### Active Job + +#### `enqueue_at.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | + +#### `enqueue.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | + +#### `enqueue_retry.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:job` | Job object | +| `:adapter` | QueueAdapter object processing the job | +| `:error` | The error that caused the retry | +| `:wait` | The delay of the retry | + +#### `enqueue_all.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:jobs` | An array of Job objects | + +#### `perform_start.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | + +#### `perform.active_job` + +| Key | Value | +| ------------- | --------------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | +| `:db_runtime` | Amount spent executing database queries in ms | + +#### `retry_stopped.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | +| `:error` | The error that caused the retry | + +#### `discard.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | +| `:error` | The error that caused the discard | ### Active Record @@ -476,77 +659,137 @@ In practice, you cannot do much with the transaction object, but it may still be helpful for tracing database activity. For example, by tracking `transaction.uuid`. -### Action Mailer +#### `deprecated_association.active_record` -#### `deliver.action_mailer` +This event is emitted when a deprecated association is accessed, and the +configured deprecated associations mode is `:notify`. -| Key | Value | -| --------------------- | ---------------------------------------------------- | -| `:mailer` | Name of the mailer class | -| `:message_id` | ID of the message, generated by the Mail gem | -| `:subject` | Subject of the mail | -| `:to` | To address(es) of the mail | -| `:from` | From address of the mail | -| `:bcc` | BCC addresses of the mail | -| `:cc` | CC addresses of the mail | -| `:date` | Date of the mail | -| `:mail` | The encoded form of the mail | -| `:perform_deliveries` | Whether delivery of this message is performed or not | +| Key | Value | +| -------------------- | ---------------------------------------------------- | +| `:reflection` | The reflection of the association | +| `:message` | A descriptive message about the access | +| `:location` | The application-level location of the access | +| `:backtrace` | Only present if the option `:backtrace` is true | -```ruby -{ - mailer: "Notification", - message_id: "4f5b5491f1774_181b23fc3d4434d38138e5@mba.local.mail", - subject: "Rails Guides", - to: ["users@rails.com", "dhh@rails.com"], - from: ["me@rails.com"], - date: Sat, 10 Mar 2012 14:18:09 +0100, - mail: "...", # omitted for brevity - perform_deliveries: true -} -``` +The `:location` is a `Thread::Backtrace::Location` object, and `:backtrace`, if +present, is an array of `Thread::Backtrace::Location` objects. These are +computed using the Active Record backtrace cleaner. In Rails applications, this +is the same as `Rails.backtrace_cleaner`. -#### `process.action_mailer` +### Active Storage -| Key | Value | -| ------------- | ------------------------ | -| `:mailer` | Name of the mailer class | -| `:action` | The action | -| `:args` | The arguments | +#### `preview.active_storage` -```ruby -{ - mailer: "Notification", - action: "welcome_email", - args: [] -} -``` +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | -### Active Support: Caching +#### `transform.active_storage` -#### `cache_read.active_support` +#### `analyze.active_storage` -| Key | Value | -| ------------------ | ----------------------- | -| `:key` | Key used in the store | -| `:store` | Name of the store class | -| `:hit` | If this read is a hit | -| `:super_operation` | `:fetch` if a read is done with [`fetch`][ActiveSupport::Cache::Store#fetch] | +| Key | Value | +| ------------ | ------------------------------ | +| `:analyzer` | Name of analyzer e.g., ffprobe | -#### `cache_read_multi.active_support` +### Active Storage: Storage Service -| Key | Value | -| ------------------ | ----------------------- | -| `:key` | Keys used in the store | -| `:store` | Name of the store class | -| `:hits` | Keys of cache hits | -| `:super_operation` | `:fetch_multi` if a read is done with [`fetch_multi`][ActiveSupport::Cache::Store#fetch_multi] | +#### `service_upload.active_storage` -#### `cache_generate.active_support` +| Key | Value | +| ------------ | ---------------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:checksum` | Checksum to ensure integrity | -This event is only emitted when [`fetch`][ActiveSupport::Cache::Store#fetch] is called with a block. +#### `service_streaming_download.active_storage` -| Key | Value | +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | + +#### `service_download_chunk.active_storage` + +| Key | Value | +| ------------ | ------------------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:range` | Byte range attempted to be read | + +#### `service_download.active_storage` + +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | + +#### `service_delete.active_storage` + +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | + +#### `service_delete_prefixed.active_storage` + +| Key | Value | +| ------------ | ------------------- | +| `:prefix` | Key prefix | +| `:service` | Name of the service | + +#### `service_exist.active_storage` + +| Key | Value | +| ------------ | --------------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:exist` | File or blob exists or not | + +#### `service_url.active_storage` + +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:url` | Generated URL | + +#### `service_update_metadata.active_storage` + +This event is only emitted when using the Google Cloud Storage service. + +| Key | Value | +| --------------- | -------------------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:content_type` | HTTP `Content-Type` field | +| `:disposition` | HTTP `Content-Disposition` field | + +### Active Support: Caching + +#### `cache_read.active_support` + +| Key | Value | +| ------------------ | ----------------------- | +| `:key` | Key used in the store | +| `:store` | Name of the store class | +| `:hit` | If this read is a hit | +| `:super_operation` | `:fetch` if a read is done with [`fetch`][ActiveSupport::Cache::Store#fetch] | + +#### `cache_read_multi.active_support` + +| Key | Value | +| ------------------ | ----------------------- | +| `:key` | Keys used in the store | +| `:store` | Name of the store class | +| `:hits` | Keys of cache hits | +| `:super_operation` | `:fetch_multi` if a read is done with [`fetch_multi`][ActiveSupport::Cache::Store#fetch_multi] | + +#### `cache_generate.active_support` + +This event is only emitted when [`fetch`][ActiveSupport::Cache::Store#fetch] is called with a block. + +| Key | Value | | -------- | ----------------------- | | `:key` | Key used in the store | | `:store` | Name of the store class | @@ -747,226 +990,6 @@ This event is only emitted when using [`MemoryStore`][ActiveSupport::Cache::Memo } ``` -### Active Job - -#### `enqueue_at.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | - -#### `enqueue.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | - -#### `enqueue_retry.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:job` | Job object | -| `:adapter` | QueueAdapter object processing the job | -| `:error` | The error that caused the retry | -| `:wait` | The delay of the retry | - -#### `enqueue_all.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:jobs` | An array of Job objects | - -#### `perform_start.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | - -#### `perform.active_job` - -| Key | Value | -| ------------- | --------------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | -| `:db_runtime` | Amount spent executing database queries in ms | - -#### `retry_stopped.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | -| `:error` | The error that caused the retry | - -#### `discard.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | -| `:error` | The error that caused the discard | - -### Action Cable - -#### `perform_action.action_cable` - -| Key | Value | -| ---------------- | ------------------------- | -| `:channel_class` | Name of the channel class | -| `:action` | The action | -| `:data` | A hash of data | - -#### `transmit.action_cable` - -| Key | Value | -| ---------------- | ------------------------- | -| `:channel_class` | Name of the channel class | -| `:data` | A hash of data | -| `:via` | Via | - -#### `transmit_subscription_confirmation.action_cable` - -| Key | Value | -| ---------------- | ------------------------- | -| `:channel_class` | Name of the channel class | - -#### `transmit_subscription_rejection.action_cable` - -| Key | Value | -| ---------------- | ------------------------- | -| `:channel_class` | Name of the channel class | - -#### `broadcast.action_cable` - -| Key | Value | -| --------------- | -------------------- | -| `:broadcasting` | A named broadcasting | -| `:message` | A hash of message | -| `:coder` | The coder | - -### Active Storage - -#### `preview.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | - -#### `transform.active_storage` - -#### `analyze.active_storage` - -| Key | Value | -| ------------ | ------------------------------ | -| `:analyzer` | Name of analyzer e.g., ffprobe | - -### Active Storage: Storage Service - -#### `service_upload.active_storage` - -| Key | Value | -| ------------ | ---------------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:checksum` | Checksum to ensure integrity | - -#### `service_streaming_download.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | - -#### `service_download_chunk.active_storage` - -| Key | Value | -| ------------ | ------------------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:range` | Byte range attempted to be read | - -#### `service_download.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | - -#### `service_delete.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | - -#### `service_delete_prefixed.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:prefix` | Key prefix | -| `:service` | Name of the service | - -#### `service_exist.active_storage` - -| Key | Value | -| ------------ | --------------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:exist` | File or blob exists or not | - -#### `service_url.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:url` | Generated URL | - -#### `service_update_metadata.active_storage` - -This event is only emitted when using the Google Cloud Storage service. - -| Key | Value | -| --------------- | -------------------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:content_type` | HTTP `Content-Type` field | -| `:disposition` | HTTP `Content-Disposition` field | - -### Action Mailbox - -#### `process.action_mailbox` - -| Key | Value | -| -----------------| ------------------------------------------------------ | -| `:mailbox` | Instance of the Mailbox class inheriting from [`ActionMailbox::Base`][] | -| `:inbound_email` | Hash with data about the inbound email being processed | - -```ruby -{ - mailbox: #, - inbound_email: { - id: 1, - message_id: "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", - status: "processing" - } -} -``` - -[`ActionMailbox::Base`]: https://api.rubyonrails.org/classes/ActionMailbox/Base.html - -### Railties - -#### `load_config_initializer.railties` - -| Key | Value | -| -------------- | --------------------------------------------------- | -| `:initializer` | Path of loaded initializer in `config/initializers` | - ### Rails #### `deprecation.rails` @@ -978,6 +1001,14 @@ This event is only emitted when using the Google Cloud Storage service. | `:gem_name` | Name of the gem reporting the deprecation | | `:deprecation_horizon` | Version where the deprecated behavior will be removed | +### Railties + +#### `load_config_initializer.railties` + +| Key | Value | +| -------------- | --------------------------------------------------- | +| `:initializer` | Path of loaded initializer in `config/initializers` | + Exceptions ---------- diff --git a/guides/source/api_app.md b/guides/source/api_app.md index ceb85bc644eb2..b50471393d7b4 100644 --- a/guides/source/api_app.md +++ b/guides/source/api_app.md @@ -387,7 +387,7 @@ environment's configuration file. You can learn more about how to use `Rack::Sendfile` with popular front-ends in [the Rack::Sendfile -documentation](https://www.rubydoc.info/gems/rack/Rack/Sendfile). +documentation](https://rack.github.io/rack/main/Rack/Sendfile.html). Here are some values for this header for some popular servers, once these servers are configured to support accelerated file sending: diff --git a/guides/source/asset_pipeline.md b/guides/source/asset_pipeline.md index 7ff662020b794..932c227262a80 100644 --- a/guides/source/asset_pipeline.md +++ b/guides/source/asset_pipeline.md @@ -99,7 +99,7 @@ dependencies: JavaScript code. Just make sure your JavaScript files are set up as modules using ` ``` @@ -174,7 +174,7 @@ paths](#digested-assets-in-views) using helpers like `asset_path`, `image_tag`, automatically converted into their fingerprinted paths using the [`.manifest.json` file](#manifest-files). -Its possible to exclude certain directories from this process, you can read more +It is possible to exclude certain directories from this process, you can read more about it in the [Fingerprinting section](#fingerprinting-versioning-with-digest-based-urls). @@ -359,7 +359,7 @@ serve them efficiently. ### Setup -Follow these steps for setup Propshaft in your Rails application: +Follow these steps for setting up Propshaft in your Rails application: 1. Create a new Rails application: @@ -542,7 +542,7 @@ headers. For Apache: ```apache -# The Expires* directives requires the Apache module +# The Expires* directives require the Apache module # `mod_expires` to be enabled. # Use of ETag is discouraged when Last-Modified is present @@ -642,7 +642,7 @@ it will try to find it at the "origin" `example.com/assets/smile.png`, and then store it for future use. If you want to serve only some assets from your CDN, you can use custom `:host` -option your asset helper, which overwrites value set in +option for your asset helper, which overwrites the value set in [`config.action_controller.asset_host`][]. ```erb @@ -1013,7 +1013,7 @@ delivered via the Rails asset pipeline. 4. In production, the gem ensures your stylesheets are compiled and ready for deployment. During the `assets:precompile` step, it installs all `package.json` dependencies via `bun`, `yarn`, `pnpm` or `npm` and runs the - `build:css` task. to process your stylesheet entry points. The resulting CSS + `build:css` task to process your stylesheet entry points. The resulting CSS output is then digested by the asset pipeline and copied into the `public/assets` directory, just like other asset pipeline files. diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index daa0460eca034..b5d108af4057e 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -44,7 +44,7 @@ Without associations, creating and deleting books for that author would require a tedious and manual process. Here's what that would look like: ```ruby -class CreateAuthors < ActiveRecord::Migration[8.1] +class CreateAuthors < ActiveRecord::Migration[8.2] def change create_table :authors do |t| t.string :name @@ -75,7 +75,7 @@ value when creating the book. @book = Book.create(author_id: @author.id, published_at: Time.now) ``` -To delete an author and ensure all their books are also deleted, you need to +To delete an author and ensure all their books are also deleted, you'd need to retrieve all the author's `books`, loop through each `book` to destroy it, and then destroy the author. @@ -193,7 +193,7 @@ Rails will look for a class named `Authors` instead of `Author`. The corresponding migration might look like this: ```ruby -class CreateBooks < ActiveRecord::Migration[8.1] +class CreateBooks < ActiveRecord::Migration[8.2] def change create_table :authors do |t| t.string :name @@ -217,7 +217,7 @@ then you should use `has_one` instead. When used alone, `belongs_to` produces a one-directional one-to-one relationship. Therefore each book in the above example "knows" its author, but -the authors don't know about their books. To setup a [bi-directional +the authors don't know about their books. To set up a [bi-directional association](#bi-directional-associations) - use `belongs_to` in combination with a `has_one` or `has_many` on the other model, in this case the Author model. @@ -435,7 +435,7 @@ is declared. The corresponding migration might look like this: ```ruby -class CreateSuppliers < ActiveRecord::Migration[8.1] +class CreateSuppliers < ActiveRecord::Migration[8.2] def change create_table :suppliers do |t| t.string :name @@ -560,7 +560,7 @@ the associated object's foreign key to the same value. The `build_association` method returns a new object of the associated type. This object will be instantiated from the passed attributes, and the link through -this objects foreign key will be set, but the associated object will _not_ yet +this object's foreign key will be set, but the associated object will _not_ yet be saved. ```ruby @@ -653,7 +653,7 @@ model is pluralized when declaring a `has_many` association. The corresponding migration might look like this: ```ruby -class CreateAuthors < ActiveRecord::Migration[8.1] +class CreateAuthors < ActiveRecord::Migration[8.2] def change create_table :authors do |t| t.string :name @@ -953,7 +953,7 @@ object, use the `collection.build` method. A [`has_many :through`][`has_many`] association is often used to set up a many-to-many relationship with another model. This association indicates that the declaring model can be matched with zero or more instances of another model -by proceeding _through_ a third model. +by proceeding _through_ an intermediate "join" model. For example, consider a medical practice where patients make appointments to see physicians. The relevant association declarations could look like this: @@ -979,13 +979,16 @@ end allowing instances of one model (Physician) to be associated with multiple instances of another model (Patient) through a third "join" model (Appointment). +We call `Physician.appointments` and `Appointment.patient` the _through_ and +_source_ associations of `Physician.patients`, respectively. + ![has_many :through Association Diagram](images/association_basics/has_many_through.png) The corresponding migration might look like this: ```ruby -class CreateAppointments < ActiveRecord::Migration[8.1] +class CreateAppointments < ActiveRecord::Migration[8.2] def change create_table :physicians do |t| t.string :name @@ -1012,12 +1015,16 @@ In this migration the `physicians` and `patients` tables are created with a created with `physician_id` and `patient_id` columns, establishing the many-to-many relationship between `physicians` and `patients`. +INFO: The through association can be any type of association, including other +through associations, but it cannot be [polymorphic](#polymorphic-associations). +Source associations can be polymorphic as long as you provide a source type. + You could also consider using a [composite primary key](active_record_composite_primary_keys.html) for the join table in the `has_many :through` relationship like below: ```ruby -class CreateAppointments < ActiveRecord::Migration[8.1] +class CreateAppointments < ActiveRecord::Migration[8.2] def change # ... create_table :appointments, primary_key: [:physician_id, :patient_id] do |t| @@ -1122,13 +1129,16 @@ end This setup allows a `supplier` to directly access its `account_history` through its `account`. +We call `Supplier.account` and `Account.account_history` the _through_ and +_source_ associations of `Supplier.account_history`, respectively. + ![has_one :through Association Diagram](images/association_basics/has_one_through.png) The corresponding migration to set up these associations might look like this: ```ruby -class CreateAccountHistories < ActiveRecord::Migration[8.1] +class CreateAccountHistories < ActiveRecord::Migration[8.2] def change create_table :suppliers do |t| t.string :name @@ -1150,6 +1160,11 @@ class CreateAccountHistories < ActiveRecord::Migration[8.1] end ``` +INFO: The through association must be a `has_one`, `has_one :through`, or +non-polymorphic `belongs_to`. That is, a non-polymorphic singular association. +On the other hand, source associations can be polymorphic as long as you provide +a source type. + ### `has_and_belongs_to_many` A [`has_and_belongs_to_many`][] association creates a direct many-to-many @@ -1183,7 +1198,7 @@ manage the relationship between the associated records. The corresponding migration might look like this: ```ruby -class CreateAssembliesAndParts < ActiveRecord::Migration[8.1] +class CreateAssembliesAndParts < ActiveRecord::Migration[8.2] def change create_table :assemblies do |t| t.string :name @@ -1468,7 +1483,7 @@ To implement these associations, you'll need to create the corresponding database tables and set up the foreign key. Here's an example migration: ```ruby -class CreateSuppliers < ActiveRecord::Migration[8.1] +class CreateSuppliers < ActiveRecord::Migration[8.2] def change create_table :suppliers do |t| t.string :name @@ -1599,12 +1614,12 @@ Similarly, you can retrieve a collection of pictures from an instance of the Additionally, if you have an instance of the `Picture` model, you can get its parent via `@picture.imageable`, which could be an `Employee` or a `Product`. -To setup a polymorphic association manually you would need to declare both a +To set up a polymorphic association manually you would need to declare both a foreign key column (`imageable_id`) and a type column (`imageable_type`) in the model: ```ruby -class CreatePictures < ActiveRecord::Migration[8.1] +class CreatePictures < ActiveRecord::Migration[8.2] def change create_table :pictures do |t| t.string :name @@ -1628,7 +1643,7 @@ recommended to use `t.references` or its alias `t.belongs_to` and specify it automatically adds both the foreign key and type columns to the table. ```ruby -class CreatePictures < ActiveRecord::Migration[8.1] +class CreatePictures < ActiveRecord::Migration[8.2] def change create_table :pictures do |t| t.string :name @@ -1701,7 +1716,7 @@ To support this relationship, we need to add a `manager_id` column to the manager). ```ruby -class CreateEmployees < ActiveRecord::Migration[8.1] +class CreateEmployees < ActiveRecord::Migration[8.2] def change create_table :employees do |t| # Add a belongs_to reference to the manager, which is an employee. @@ -1869,7 +1884,7 @@ end class Car < Vehicle end -Car.create +Car.create(color: "Red", price: 10000) # => # ``` @@ -1890,7 +1905,7 @@ class Vehicle < ApplicationRecord self.inheritance_column = nil end -Vehicle.create!(type: "Car") +Vehicle.create!(type: "Car", color: "Red", price: 10000) # => # ``` @@ -1914,8 +1929,7 @@ includes all attributes of all subclasses in a single table. A disadvantage of this approach is that it can result in table bloat, as the table will include attributes specific to each subclass, even if they aren't -used by others. This can be solved by using [`Delegated -Types`](#delegated-types). +used by others. This can be solved by using [`Delegated Types`](#delegated-types). Additionally, if you’re using [polymorphic associations](#polymorphic-associations), where a model can belong to more than @@ -1964,6 +1978,8 @@ $ bin/rails generate model message subject:string body:string $ bin/rails generate model comment content:string ``` +NOTE: If you don't specify a type for a field (e.g., `subject` instead of `subject:string`), Rails will default to type `string`. + After running the generators, our models should look like this: ```ruby @@ -2052,7 +2068,7 @@ Entry.create! entryable: Message.new(subject: "hello!") We can enhance our `Entry` delegator by defining `delegate` and using polymorphism on the subclasses. For example, to delegate the `title` method from -`Entry` to it's subclasses: +`Entry` to its subclasses: ```ruby class Entry < ApplicationRecord @@ -2173,7 +2189,7 @@ the books table. For a brand new table, the migration might look something like this: ```ruby -class CreateBooks < ActiveRecord::Migration[8.1] +class CreateBooks < ActiveRecord::Migration[8.2] def change create_table :books do |t| t.datetime :published_at @@ -2187,7 +2203,7 @@ end Whereas for an existing table, it might look like this: ```ruby -class AddAuthorToBooks < ActiveRecord::Migration[8.1] +class AddAuthorToBooks < ActiveRecord::Migration[8.2] def change add_reference :books, :author end @@ -2227,7 +2243,7 @@ You can then fill out the migration and ensure that the table is created without a primary key. ```ruby -class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[8.1] +class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[8.2] def change create_table :assemblies_parts, id: false do |t| t.bigint :assembly_id @@ -2248,7 +2264,7 @@ are you forgot to set `id: false` when creating your migration. For simplicity, you can also use the method `create_join_table`: ```ruby -class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[8.1] +class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[8.2] def change create_join_table :assemblies, :parts do |t| t.index :assembly_id @@ -2268,7 +2284,7 @@ The main difference in schema implementation between creating a join table for `has_many :through` requires an `id`. ```ruby -class CreateAppointments < ActiveRecord::Migration[8.1] +class CreateAppointments < ActiveRecord::Migration[8.2] def change create_table :appointments do |t| t.belongs_to :physician @@ -2579,6 +2595,9 @@ class Book < ApplicationRecord end ``` +This option is not supported in polymorphic associations, since in that case the +class name of the associated record is stored in the type column. + #### `:dependent` Controls what happens to the associated object when its owner is destroyed: @@ -2790,6 +2809,17 @@ The `:join_table` can be found on a `has_and_belongs_to_many` relationship. If the default name of the join table, based on lexical ordering, is not what you want, you can use the `:join_table` option to override the default. +#### `:deprecated` + +If true, Active Record warns every time the association is used. + +Three reporting modes are supported (`:warn`, `:raise`, and `:notify`), and +backtraces can be enabled or disabled. Defaults are `:warn` mode and disabled +backtraces. + +Please, check the documentation of `ActiveRecord::Associations::ClassMethods` +for further details. + ### Scopes Scopes allow you to specify common queries that can be referenced as method @@ -3134,7 +3164,7 @@ Although the `:counter_cache` option is specified on the model with the `books_count` column to the `Author` model: ```ruby -class AddBooksCountToAuthors < ActiveRecord::Migration[8.1] +class AddBooksCountToAuthors < ActiveRecord::Migration[8.2] def change add_column :authors, :books_count, :integer, default: 0, null: false end diff --git a/guides/source/caching_with_rails.md b/guides/source/caching_with_rails.md index 4ccc5e902aec3..73ef3ee20af1f 100644 --- a/guides/source/caching_with_rails.md +++ b/guides/source/caching_with_rails.md @@ -123,6 +123,14 @@ do not overwrite each other: cached: ->(product) { [I18n.locale, product] } %> ``` +Additionally, you can configure `cached` with an options hash that takes `expires_in` and `key` so you can explicitly set the expiration. + +```html+erb +<%= render partial: 'products/product', + collection: @products, + cached: { expires_in: 1.hour, key: ->(product) { [I18n.locale, product] } } %> +``` + ### Russian Doll Caching You may want to nest cached fragments inside other cached fragments. This is @@ -396,9 +404,13 @@ you'd prefer not to utilize it, you can skip Solid Cache: rails new app_name --skip-solid ``` -WARNING: Both Solid Cache and Solid Queue are bundled behind the `--skip-solid` -flag. If you still want to use Solid Queue but not Solid Cache, you can enable -Solid Queue by running `bin/rails app:enable-solid-queue`. +NOTE: Using the `--skip-solid` flag skips all parts of the Solid +Trifecta (Solid Cache, Solid Queue, and Solid Cable). If you still +want to use some of them, you can install them separately. For +example, if you want to use Solid Queue and Solid Cable but not +Solid Cache, you can follow the installation guides for [Solid +Queue](https://github.com/rails/solid_queue#installation) and +[Solid Cable](https://github.com/rails/solid_cable#installation). ### Configuring the Database @@ -480,7 +492,7 @@ records expire faster than they are written when the cache needs to shrink. The background task only runs when there are writes, so the process stays idle when the cache is not being updated. If you prefer to run the expiry process in -a background job instead of a thread, set `expiry_method` from the[Cache +a background job instead of a thread, set `expiry_method` from the [Cache configuration](https://github.com/rails/solid_cache#cache-configuration) to `:job`. @@ -524,7 +536,7 @@ production: encrypt: true ``` -You will need to set up your application to use[Active Record +You will need to set up your application to use [Active Record Encryption](active_record_encryption.html). ### Caching in Development @@ -554,7 +566,7 @@ and ensure the `cache` database is created and migrated: ```bash development: - <<: * default + <<: *default database: cache ``` diff --git a/guides/source/command_line.md b/guides/source/command_line.md index 450a21f801674..d61e24e946484 100644 --- a/guides/source/command_line.md +++ b/guides/source/command_line.md @@ -1,31 +1,147 @@ -**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON .** +**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON +.** The Rails Command Line ====================== -After reading this guide, you will know: +After reading this guide, you will know how to use the Rails command line: -* How to create a Rails application. -* How to generate models, controllers, database migrations, and unit tests. -* How to start a development server. -* How to experiment with objects through an interactive shell. +* To create a Rails application. +* To generate models, controllers, tests, and database migrations. +* To start a development server. +* To inspect a Rails application through an interactive shell. +* To add and edit credentials. -------------------------------------------------------------------------------- -NOTE: This tutorial assumes you have basic Rails knowledge from reading the [Getting Started with Rails Guide](getting_started.html). +Overview +-------- -Creating a Rails App --------------------- +The Rails command line is a powerful part of the Ruby on Rails framework. It +allows you to quickly start a new application by generating boilerplate code +(that follows Rails conventions). This guide includes an overview of Rails +commands that allow you to manage all aspects of your web application, including +the database. -First, let's create a simple Rails application using the `rails new` command. +You can get a list of commands available to you, which will often depend on your +current directory, by typing `bin/rails --help`. Each command has a description +to help clarify what it does. -We will use this application to play and discover all the commands described in this guide. +```bash +$ bin/rails --help +Usage: + bin/rails COMMAND [options] + +You must specify a command. The most common commands are: + + generate Generate new code (short-cut alias: "g") + console Start the Rails console (short-cut alias: "c") + server Start the Rails server (short-cut alias: "s") + test Run tests except system tests (short-cut alias: "t") + test:system Run system tests + dbconsole Start a console for the database specified in config/database.yml + (short-cut alias: "db") + plugin new Create a new Rails railtie or engine + +All commands can be run with -h (or --help) for more information. +``` + +The output of `bin/rails --help` then proceeds to list all commands in +alphabetical order, with a short description of each: + +```bash +In addition to those commands, there are: +about List versions of all Rails frameworks ... +action_mailbox:ingress:exim Relay an inbound email from Exim to ... +action_mailbox:ingress:postfix Relay an inbound email from Postfix ... +action_mailbox:ingress:qmail Relay an inbound email from Qmail to ... +action_mailbox:install Install Action Mailbox and its ... +... +db:fixtures:load Load fixtures into the ... +db:migrate Migrate the database ... +db:migrate:status Display status of migrations +db:rollback Roll the schema back to ... +... +turbo:install Install Turbo into the app +turbo:install:bun Install Turbo into the app with bun +turbo:install:importmap Install Turbo into the app with asset ... +turbo:install:node Install Turbo into the app with webpacker +turbo:install:redis Switch on Redis and use it in development +version Show the Rails version +yarn:install Install all JavaScript dependencies as ... +zeitwerk:check Check project structure for Zeitwerk ... +``` + +In addition to `bin/rails --help`, running any command from the list above with +the `--help` flag can also be useful. For example, you can learn about the +options that can be used with `bin/rails routes`: + +```bash +$ bin/rails routes --help +Usage: + bin/rails routes + +Options: + -c, [--controller=CONTROLLER] # Filter by a specific controller, e.g. PostsController or Admin::PostsController. + -g, [--grep=GREP] # Grep routes by a specific pattern. + -E, [--expanded], [--no-expanded] # Print routes expanded vertically with parts explained. + -u, [--unused], [--no-unused] # Print unused routes. + +List all the defined routes +``` + +Most Rails command line subcommands can be run with `--help` (or `-h`) and the +output can be very informative. For example `bin/rails generate model --help` +prints two pages of description, in addition to usage and options: + +```bash +$ bin/rails generate model --help +Usage: + bin/rails generate model NAME [field[:type][:index] field[:type][:index]] [options] +Options: +... +Description: + Generates a new model. Pass the model name, either CamelCased or + under_scored, and an optional list of attribute pairs as arguments. + + Attribute pairs are field:type arguments specifying the + model's attributes. Timestamps are added by default, so you don't have to + specify them by hand as 'created_at:datetime updated_at:datetime'. + + As a special case, specifying 'password:digest' will generate a + password_digest field of string type, and configure your generated model and + tests for use with Active Model has_secure_password (assuming the default ORM and test framework are being used). + ... +``` + +Some of the most commonly used commands are: + +* `bin/rails console` +* `bin/rails server` +* `bin/rails test` +* `bin/rails generate` +* `bin/rails db:migrate` +* `bin/rails db:create` +* `bin/rails routes` +* `bin/rails dbconsole` +* `rails new app_name` + +We'll cover the above commands (and more) in the following sections, starting +with the command for creating a new application. + +Creating a New Rails Application +-------------------------------- -INFO: You can install the rails gem by typing `gem install rails`, if you don't have it already. +We can create a brand new Rails application using the `rails new` command. -### `rails new` +INFO: You will need the rails gem installed in order to run the `rails new` +command. You can do this by typing `gem install rails` - for more step-by-step +instructions, see the [Installing Ruby on Rails](install_ruby_on_rails.html) +guide. -The first argument we'll pass to the `rails new` command is the application name. +With the `new` command, Rails will set up the entire default directory structure +along with all the code needed to run a sample application right out of the box. +The first argument to `rails new` is the application name: ```bash $ rails new my_app @@ -42,25 +158,27 @@ $ rails new my_app run bundle install ``` -Rails will set up what seems like a huge amount of stuff for such a tiny command! We've got the entire Rails directory structure now with all the code we need to run our simple application right out of the box. +You can pass options to the `new` command to modify its default behavior. You +can also create [application templates](generators.html#application-templates) +and use them with the `new` command. -### Preconfigure a Different Database +### Configure a Different Database -When creating a new Rails application, you have the option to specify what kind -of database your application is going to use. This will save you a few minutes, -and certainly many keystrokes. - -Let's see what a `--database=postgresql` option will do for us: +When creating a new Rails application, you can specify a preferred database for +your application by using the `--database` option. The default database for +`rails new` is SQLite. For example, you can set up a PostgreSQL database like +this: ```bash -$ rails new petstore --database=postgresql +$ rails new booknotes --database=postgresql create create app/controllers create app/helpers ... ``` -Let's see what it put in our `config/database.yml`: +The main difference is the content of the `config/database.yml` file. With the +PostgreSQL option, it looks like this: ```yaml # PostgreSQL. Versions 9.3 and up are supported. @@ -68,7 +186,7 @@ Let's see what it put in our `config/database.yml`: # Install the pg driver: # gem install pg # On macOS with Homebrew: -# gem install pg -- --with-pg-config=/opt/homebrew/bin/pg_config +# gem install pg -- --with-pg-config=/usr/local/bin/pg_config # On Windows: # gem install pg # Choose the win32 build. @@ -80,26 +198,28 @@ Let's see what it put in our `config/database.yml`: default: &default adapter: postgresql encoding: unicode - # For details on connection pooling, see Rails configuration guide # https://guides.rubyonrails.org/configuring.html#database-pooling - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> + development: <<: *default - database: petstore_development -... + database: booknotes_development + ... ``` -It generated a database configuration corresponding to our choice of PostgreSQL. +The `--database=postgresql` option will also modify other files generated for a +new Rails app appropriately, such as adding the `pg` gem to the `Gemfile`, etc. ### Skipping Defaults -If you wish to skip some files from being generated or skip some libraries -entirely, you can pass one of the `--skip` arguments to the `rails new` command: +The `rails new` command by default creates dozens of files. By using the +`--skip` option, you can skip some files from being generated if you don't need +them. For example, ```bash -$ rails new sas --skip-active-storage +$ rails new no_storage --skip-active-storage Based on the specified options, the following options will also be activated: --skip-action-mailbox [due to --skip-active-storage] @@ -113,81 +233,27 @@ Based on the specified options, the following options will also be activated: In the above example, Action Mailbox and Action Text are skipped in addition to Active Storage because they depend on Active Storage functionality. -For a full list of options (including what can be skipped), use `--help`: - -```bash -$ rails new --help -``` - -Command Line Basics -------------------- - -There are a few commands that are absolutely critical to your everyday usage of Rails. In the order of how much you'll probably use them are: - -* `bin/rails console` -* `bin/rails server` -* `bin/rails test` -* `bin/rails generate` -* `bin/rails db:migrate` -* `bin/rails db:create` -* `bin/rails routes` -* `bin/rails dbconsole` -* `rails new app_name` - -You can get a list of rails commands available to you, which will often depend on your current directory, by typing `rails --help`. Each command has a description, and should help you find the thing you need. - -```bash -$ rails --help -Usage: - bin/rails COMMAND [options] - -You must specify a command. The most common commands are: - - generate Generate new code (short-cut alias: "g") - console Start the Rails console (short-cut alias: "c") - server Start the Rails server (short-cut alias: "s") - ... - -All commands can be run with -h (or --help) for more information. - -In addition to those commands, there are: -about List versions of all Rails ... -assets:clean[keep] Remove old compiled assets -assets:clobber Remove compiled assets -assets:environment Load asset compile environment -assets:precompile Compile all the assets ... -... -db:fixtures:load Load fixtures into the ... -db:migrate Migrate the database ... -db:migrate:status Display status of migrations -db:rollback Roll the schema back to ... -db:schema:cache:clear Clears a db/schema_cache.yml file -db:schema:cache:dump Create a db/schema_cache.yml file -db:schema:dump Create a database schema file (either db/schema.rb or db/structure.sql ... -db:schema:load Load a database schema file (either db/schema.rb or db/structure.sql ... -db:seed Load the seed data ... -db:version Retrieve the current schema ... -... -restart Restart app by touching ... -tmp:create Create tmp directories ... -``` - -### `bin/rails server` +TIP: You can get a full list of what can be skipped in the options section of +`rails new --help` command. -The `bin/rails server` command launches a web server named Puma which comes bundled with Rails. You'll use this any time you want to access your application through a web browser. +Starting a Rails Application Server +----------------------------------- -With no further work, `bin/rails server` will run our new shiny Rails app: +We can start a Rails application using the `bin/rails server` command, which +launches the [Puma](https://github.com/puma/puma) web server that comes bundled +with Rails. You'll use this any time you want to access your application through +a web browser. ```bash $ cd my_app $ bin/rails server => Booting Puma -=> Rails 8.1.0 application starting in development +=> Rails 8.2.0 application starting in development => Run `bin/rails server --help` for more startup options Puma starting in single mode... * Puma version: 6.4.0 (ruby 3.1.3-p185) ("The Eagle of Durango") -* Min threads: 5 -* Max threads: 5 +* Min threads: 3 +* Max threads: 3 * Environment: development * PID: 5295 * Listening on http://127.0.0.1:3000 @@ -195,77 +261,100 @@ Puma starting in single mode... Use Ctrl-C to stop ``` -With just three commands we whipped up a Rails server listening on port 3000. Go to your browser and open [http://localhost:3000](http://localhost:3000), you will see a basic Rails app running. +With just two commands we have a Rails application up and running. The `server` +command starts the application listening on port 3000 by default. You can open +your browser to [http://localhost:3000](http://localhost:3000) to see a basic +Rails application running. -INFO: You can also use the alias "s" to start the server: `bin/rails s`. +INFO: Most common commands have a shortcut aliases. To start the server you can +use the alias "s": `bin/rails s`. -The server can be run on a different port using the `-p` option. The default development environment can be changed using `-e`. +You can run the application on a different port using the `-p` option. You can +also change the environment using `-e` (default is `development`). ```bash $ bin/rails server -e production -p 4000 ``` -The `-b` option binds Rails to the specified IP, by default it is localhost. You can run a server as a daemon by passing a `-d` option. +The `-b` option binds Rails to the specified IP address, by default it is +localhost. You can run a server as a daemon by passing a `-d` option. -### `bin/rails generate` +Generating Code +--------------- -The `bin/rails generate` command uses templates to create a whole lot of things. Running `bin/rails generate` by itself gives a list of available generators: +You can use the `bin/rails generate` command to generate a number of different +files and add functionality to your application, such as models, controllers, +and full scaffolds. -INFO: You can also use the alias "g" to invoke the generator command: `bin/rails g`. +To see a list of built-in generators, you can run `bin/rails generate` (or +`bin/rails g` for short) without any arguments. It lists all available +generators after the usage. You can also learn more about what a specific +generator will do by using the `--pretend` option. ```bash $ bin/rails generate Usage: bin/rails generate GENERATOR [args] [options] -... -... +General options: + -h, [--help] # Print generator's options and usage + -p, [--pretend] # Run but do not make any changes + -f, [--force] # Overwrite files that already exist + -s, [--skip] # Skip files that already exist + -q, [--quiet] # Suppress status output Please choose a generator below. - Rails: - assets + application_record + benchmark channel controller generator - ... - ... + helper +... ``` -NOTE: You can install more generators through generator gems, portions of plugins you'll undoubtedly install, and you can even create your own! +NOTE: When you add certain gems to your application, they may install more +generators. You can also create your own generators, see the [Generators +guide](generators.html) for more information. + +The purpose of Rails' built-in generators is to save you time by freeing you +from having to write repetitive boilerplate code. -Using generators will save you a large amount of time by writing **boilerplate code**, code that is necessary for the app to work. +Let's add a controller with the `controller` generator. -Let's make our own controller with the controller generator. But what command should we use? Let's ask the generator: +### Generating Controllers -INFO: All Rails console utilities have help text. As with most *nix utilities, you can try adding `--help` or `-h` to the end, for example `bin/rails server --help`. +We can find out exactly how to use the `controller` generator with the +`bin/rails generate controller` command (which is the same as using it with +`--help`). There is a "Usage" section and even an example: ```bash $ bin/rails generate controller Usage: bin/rails generate controller NAME [action action] [options] - ... -... - -Description: - ... - - To create a controller within a module, specify the controller name as a path like 'parent_module/controller_name'. - - ... - -Example: - `bin/rails generate controller CreditCards open debit credit close` +Examples: + `bin/rails generate controller credit_cards open debit credit close` - Credit card controller with URLs like /credit_cards/debit. + This generates a `CreditCardsController` with routes like /credit_cards/debit. Controller: app/controllers/credit_cards_controller.rb Test: test/controllers/credit_cards_controller_test.rb Views: app/views/credit_cards/debit.html.erb [...] Helper: app/helpers/credit_cards_helper.rb + + `bin/rails generate controller users index --skip-routes` + + This generates a `UsersController` with an index action and no routes. + + `bin/rails generate controller admin/dashboard --parent=admin_controller` + + This generates a `Admin::DashboardController` with an `AdminController` parent class. ``` -The controller generator is expecting parameters in the form of `generate controller ControllerName action1 action2`. Let's make a `Greetings` controller with an action of **hello**, which will say something nice to us. +The controller generator is expecting parameters in the form of `generate +controller ControllerName action1 action2`. Let's make a `Greetings` controller +with an action of `hello`, which will say something nice to us. ```bash $ bin/rails generate controller Greetings hello @@ -281,9 +370,12 @@ $ bin/rails generate controller Greetings hello invoke test_unit ``` -What did all this generate? It made sure a bunch of directories were in our application, and created a controller file, a view file, a functional test file, a helper for the view, a JavaScript file, and a stylesheet file. +The above command created various files at specific directories. It created a +controller file, a view file, a functional test file, a helper for the view, and +added a route. -Check out the controller and modify it a little (in `app/controllers/greetings_controller.rb`): +To test out the new controller, we can modify the `hello` action and the view to +display a message: ```ruby class GreetingsController < ApplicationController @@ -293,164 +385,357 @@ class GreetingsController < ApplicationController end ``` -Then the view, to display our message (in `app/views/greetings/hello.html.erb`): - -```erb +```html+erb

A Greeting for You!

<%= @message %>

``` -Fire up your server using `bin/rails server`. +Then, we can start the Rails server, with `bin/rails server`, and go to the +added route +[http://localhost:3000/greetings/hello](http://localhost:3000/greetings/hello) +to see the message. -```bash -$ bin/rails server -=> Booting Puma... -``` - -The URL will be [http://localhost:3000/greetings/hello](http://localhost:3000/greetings/hello). +Now let's use the generator to add models to our application. -INFO: With a normal, plain-old Rails application, your URLs will generally follow the pattern of http://(host)/(controller)/(action), and a URL like http://(host)/(controller) will hit the **index** action of that controller. +### Generating Models -Rails comes with a generator for data models too. +The Rails model generator command has a very detailed "Description" section that +is worth reading. Here is the basic usage: ```bash $ bin/rails generate model Usage: bin/rails generate model NAME [field[:type][:index] field[:type][:index]] [options] - ... +``` -ActiveRecord options: - [--migration], [--no-migration] # Indicates when to generate migration - # Default: true +As an example, we can generate a `post` model like this: -... +```bash +$ bin/rails generate model post title:string body:text + invoke active_record + create db/migrate/20250807202154_create_posts.rb + create app/models/post.rb + invoke test_unit + create test/models/post_test.rb + create test/fixtures/posts.yml +``` -Description: - Generates a new model. Pass the model name, either CamelCased or - under_scored, and an optional list of attribute pairs as arguments. +The model generator adds test files as well as a migration, which you'll need to +run with `bin/rails db:migrate`. -... -``` +NOTE: For a list of available field types for the `type` parameter, refer to the +[API +documentation](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_column). +The `index` parameter generates a corresponding index for the column. If you +don't specify a type for a field, Rails will default to type `string`. -NOTE: For a list of available field types for the `type` parameter, refer to the [API documentation](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_column) for the add_column method for the `SchemaStatements` module. The `index` parameter generates a corresponding index for the column. +In addition to generating controllers and models separately, Rails also provides +generators that add code for both at once as well as other files needed for a +standard CRUD resource. There are two generator commands that do this: +`resource` and `scaffold`. The `resource` command is more lightweight than +`scaffold` and generates less code. -But instead of generating a model directly (which we'll be doing later), let's set up a scaffold. A **scaffold** in Rails is a full set of model, database migration for that model, controller to manipulate it, views to view and manipulate the data, and a test suite for each of the above. +### Generating Resources -We will set up a simple resource called "HighScore" that will keep track of our highest score on video games we play. +The `bin/rails generate resource` command generates model, migration, empty +controller, routes, and tests. It does not generate views and it does not fill +in the controller with CRUD methods. + +Here are all the files generated with the `resource` command for `post`: ```bash -$ bin/rails generate scaffold HighScore game:string score:integer - invoke active_record - create db/migrate/20190416145729_create_high_scores.rb - create app/models/high_score.rb - invoke test_unit - create test/models/high_score_test.rb - create test/fixtures/high_scores.yml - invoke resource_route - route resources :high_scores - invoke scaffold_controller - create app/controllers/high_scores_controller.rb - invoke erb - create app/views/high_scores - create app/views/high_scores/index.html.erb - create app/views/high_scores/edit.html.erb - create app/views/high_scores/show.html.erb - create app/views/high_scores/new.html.erb - create app/views/high_scores/_form.html.erb - invoke test_unit - create test/controllers/high_scores_controller_test.rb - create test/system/high_scores_test.rb - invoke helper - create app/helpers/high_scores_helper.rb - invoke test_unit - invoke jbuilder - create app/views/high_scores/index.json.jbuilder - create app/views/high_scores/show.json.jbuilder - create app/views/high_scores/_high_score.json.jbuilder +$ bin/rails generate resource post title:string body:text + invoke active_record + create db/migrate/20250919150856_create_posts.rb + create app/models/post.rb + invoke test_unit + create test/models/post_test.rb + create test/fixtures/posts.yml + invoke controller + create app/controllers/posts_controller.rb + invoke erb + create app/views/posts + invoke test_unit + create test/controllers/posts_controller_test.rb + invoke helper + create app/helpers/posts_helper.rb + invoke test_unit + invoke resource_route + route resources :posts ``` -The generator creates the model, views, controller, **resource** route, and database migration (which creates the `high_scores` table) for HighScore. And it adds tests for those. +Use the `resource` command when you don't need views (e.g. writing an API) or +prefer to add controller actions manually. + +### Generating Scaffolds + +A Rails scaffold generates a full set of files for a resource, including a +model, controller, views (HTML and JSON), routes, migration, tests, and helper +files. It can be used for quickly prototyping CRUD interfaces or when you want +to generate the basic structure of a resource as a starting point that you can +customize. -The migration requires that we **migrate**, that is, run some Ruby code (the `20190416145729_create_high_scores.rb` file from the above output) to modify the schema of our database. Which database? The SQLite3 database that Rails will create for you when we run the `bin/rails db:migrate` command. We'll talk more about that command below. +If you scaffold the `post` resource you can see all of the files mentioned above +being generated: ```bash -$ bin/rails db:migrate -== CreateHighScores: migrating =============================================== --- create_table(:high_scores) - -> 0.0017s -== CreateHighScores: migrated (0.0019s) ====================================== +$ bin/rails generate scaffold post title:string body:text + invoke active_record + create db/migrate/20250919150748_create_posts.rb + create app/models/post.rb + invoke test_unit + create test/models/post_test.rb + create test/fixtures/posts.yml + invoke resource_route + route resources :posts + invoke scaffold_controller + create app/controllers/posts_controller.rb + invoke erb + create app/views/posts + create app/views/posts/index.html.erb + create app/views/posts/edit.html.erb + create app/views/posts/show.html.erb + create app/views/posts/new.html.erb + create app/views/posts/_form.html.erb + create app/views/posts/_post.html.erb + invoke resource_route + invoke test_unit + create test/controllers/posts_controller_test.rb + create test/system/posts_test.rb + invoke helper + create app/helpers/posts_helper.rb + invoke test_unit + invoke jbuilder + create app/views/posts/index.json.jbuilder + create app/views/posts/show.json.jbuilder + create app/views/posts/_post.json.jbuilder ``` -INFO: Let's talk about unit tests. Unit tests are code that tests and makes assertions -about code. In unit testing, we take a little part of code, say a method of a model, -and test its inputs and outputs. Unit tests are your friend. The sooner you make -peace with the fact that your quality of life will drastically increase when you unit -test your code, the better. Seriously. Please visit -[the testing guide](testing.html) for an in-depth -look at unit testing. +At this point, you can run `bin/rails db:migrate` to create the `post` table +(see [Managing the Database](#managing-the-database) for more on that command). +Then, if you start the Rails server with `bin/rails server` and navigate to +[http://localhost:3000/posts](http://localhost:3000/posts), you will be able to +interact with the `post` resource - see a list of posts, create new posts, as +well as edit and delete them. + +INFO: The scaffold generates test files, though you will need to modify them and +actually add test cases for your code. See the [Testing guide](testing.html) for +an in-depth look at creating and running tests. + +### Undoing Code Generation with `bin/rails destroy` + +Imagine you made a typing error when using the `generate` command for a model +(or controller or scaffold or anything), it would be tedious to manually delete +each file that was created by the generator. Rails provides a `destroy` command +for that reason. You can think of `destroy` as the opposite of `generate`. It'll +figure out what generate did, and undo it. + +INFO: You can also use the alias "d" to invoke the destroy command: `bin/rails d`. -Let's see the interface Rails created for us. +For example, if you meant to generate an `article` model but instead typed +`artcle`: ```bash -$ bin/rails server +$ bin/rails generate model Artcle title:string body:text + invoke active_record + create db/migrate/20250808142940_create_artcles.rb + create app/models/artcle.rb + invoke test_unit + create test/models/artcle_test.rb + create test/fixtures/artcles.yml ``` -Go to your browser and open [http://localhost:3000/high_scores](http://localhost:3000/high_scores), now we can create new high scores (55,160 on Space Invaders!) +You can undo the `generate` command with `destroy` like this: -### `bin/rails console` +```bash +$ bin/rails destroy model Artcle title:string body:text + invoke active_record + remove db/migrate/20250808142940_create_artcles.rb + remove app/models/artcle.rb + invoke test_unit + remove test/models/artcle_test.rb + remove test/fixtures/artcles.yml +``` -The `console` command lets you interact with your Rails application from the command line. On the underside, `bin/rails console` uses IRB, so if you've ever used it, you'll be right at home. This is useful for testing out quick ideas with code and changing data server-side without touching the website. +Interacting with a Rails Application +------------------------------------ -INFO: You can also use the alias "c" to invoke the console: `bin/rails c`. +### `bin/rails console` + +The `bin/rails console` command loads a full Rails environment (including +models, database, etc.) into an interactive IRB style shell. It is a powerful +feature of the Ruby on Rails framework as it allows you to interact with, debug +and explore your entire application at the command line. -You can specify the environment in which the `console` command should operate. +The Rails Console can be useful for testing out ideas by prototyping with code +and for creating and updating records in the database without needing to use a +browser. ```bash -$ bin/rails console -e staging +$ bin/rails console +my-app(dev):001:0> Post.create(title: 'First!') ``` -If you wish to test out some code without changing any data, you can do that by invoking `bin/rails console --sandbox`. +The Rails Console has several useful features. For example, if you wish to test +out some code without changing any data, you can use `sandbox` mode with +`bin/rails console --sandbox`. The `sandbox` mode wraps all database operations +in a transaction that rolls back when you exit: ```bash $ bin/rails console --sandbox -Loading development environment in sandbox (Rails 8.1.0) +Loading development environment in sandbox (Rails 8.2.0) Any modifications you make will be rolled back on exit -irb(main):001:0> +my-app(dev):001:0> ``` -#### The `app` and `helper` Objects +The `sandbox` option is great for safely testing destructive changes without +affecting your database. -Inside the `bin/rails console` you have access to the `app` and `helper` instances. +You can also specify the Rails environment for the `console` command with the +`-e` option: -With the `app` method you can access named route helpers, as well as do requests. +```bash +$ bin/rails console -e test +Loading test environment (Rails 8.1.0) +``` + +#### The `app` Object + +Inside the Rails Console you have access to the `app` and `helper` instances. + +With the `app` method you can access named route helpers: ```irb -irb> app.root_path +my-app(dev)> app.root_path => "/" +my-app(dev)> app.edit_user_path +=> "profile/edit" +``` -irb> app.get _ -Started GET "/" for 127.0.0.1 at 2014-06-19 10:41:57 -0300 +You can also use the `app` object to make requests of your application without +starting a real server: + +```irb +my-app(dev)> app.get "/", headers: { "Host" => "localhost" } +Started GET "/" for 127.0.0.1 at 2025-08-11 11:11:34 -0500 ... + +my-app(dev)> app.response.status +=> 200 +``` + +NOTE: You have to pass the "Host" header with the `app.get` request above, +because the Rack client used under-the-hood defaults to "www.example.com" if +"Host" is not specified. You can modify your application to always use `localhost` +using a configuration or an initializer. + +The reason you can "make requests" like above is because the `app` object is the +same one that Rails uses for integration tests: + +```irb +my-app(dev)> app.class +=> ActionDispatch::Integration::Session ``` -With the `helper` method it is possible to access Rails and your application's helpers. +The `app` object exposes methods like `app.cookies`, `app.session`, `app.post`, +and `app.response`. This way you can simulate and debug integration tests in the +Rails Console. + +#### The `helper` Object + +The `helper` object in the Rails console is your direct portal into Rails’ view +layer. It allows you to test out view-related formatting and utility methods in +the console, as well as custom helpers defined in your application (i.e. in +`app/helpers`). ```irb -irb> helper.time_ago_in_words 30.days.ago -=> "about 1 month" +my-app(dev)> helper.time_ago_in_words 3.days.ago +=> "3 days" -irb> helper.my_custom_helper -=> "my custom helper" +my-app(dev)> helper.l(Date.today) +=> "2025-08-11" + +my-app(dev)> helper.pluralize(3, "child") +=> "3 children" + +my-app(dev)> helper.truncate("This is a very long sentence", length: 22) +=> "This is a very long..." + +my-app(dev)> helper.link_to("Home", "/") +=> "Home" +``` + +Assuming a `custom_helper` method is defined in a `app/helpers/*_helper.rb` +file: + +```irb +my-app(dev)> helper.custom_helper +"testing custom_helper" ``` ### `bin/rails dbconsole` -`bin/rails dbconsole` figures out which database you're using and drops you into whichever command line interface you would use with it (and figures out the command line parameters to give to it, too!). It supports MySQL (including MariaDB), PostgreSQL, and SQLite3. +The `bin/rails dbconsole` command figures out which database you're using and +drops you into the command line interface appropriate for that database. It also +figures out the command line parameters to start a session based on your +`config/database.yml` file and current Rails environment. + +Once you're in a `dbconsole` session, you can interact with your database +directly as you normally would. For example, if you're using PostgreSQL, running +`bin/rails dbconsole` may look like this: + +```bash +$ bin/rails dbconsole +psql (17.5 (Homebrew)) +Type "help" for help. + +booknotes_development=# help +You are using psql, the command-line interface to PostgreSQL. +Type: \copyright for distribution terms + \h for help with SQL commands + \? for help with psql commands + \g or terminate with semicolon to execute query + \q to quit +booknotes_development=# \dt + List of relations + Schema | Name | Type | Owner +--------+--------------------------------+-------+------- + public | action_text_rich_texts | table | bhumi + ... +``` + +The `dbconsole` command is a very convenient shorthand, it's equivalent to +running the `psql` command (or `mysql` or `sqlite`) with the appropriate +arguments from your `database.yml`: -INFO: You can also use the alias "db" to invoke the dbconsole: `bin/rails db`. +```bash +psql -h -p -U +``` -If you are using multiple databases, `bin/rails dbconsole` will connect to the primary database by default. You can specify which database to connect to using `--database` or `--db`: +So if your `database.yml` file looks like this: + +```yml +development: + adapter: postgresql + database: myapp_development + username: myuser + password: + host: localhost +``` + +Running the `bin/rails dbconsole` command is the same as: + +```bash +psql -h localhost -U myuser myapp_development +``` + +NOTE: The `dbconsole` command supports MySQL (including MariaDB), PostgreSQL, +and SQLite3. You can also use the alias "db" to invoke the dbconsole: `bin/rails db`. + +If you are using multiple databases, `bin/rails dbconsole` will connect to the +primary database by default. You can specify which database to connect to using +`--database` or `--db`: ```bash $ bin/rails dbconsole --database=animals @@ -458,107 +743,435 @@ $ bin/rails dbconsole --database=animals ### `bin/rails runner` -`runner` runs Ruby code in the context of the Rails application non-interactively, without having to open Rails `console`. For instance: +The `runner` command executes Ruby code in the context of the Rails application +without having to open a Rails Console. This can be useful for one-off tasks +that do not need the interactivity of the Rails Console. For instance: ```bash -$ bin/rails runner "Model.long_running_method" -``` +$ bin/rails runner "puts User.count" +42 -INFO: You can also use the alias "r" to invoke the runner: `bin/rails r`. +$ bin/rails runner 'MyJob.perform_now' +``` -You can specify the environment in which the `runner` command should operate using the `-e` switch. +You can specify the environment in which the `runner` command should operate +using the `-e` switch. ```bash -$ bin/rails runner -e staging "Model.long_running_method" +$ bin/rails runner -e production "puts User.count" ``` -You can even execute ruby code written in a file with runner. +You can also execute code in a Ruby file with the `runner` command, in the +context of your Rails application: ```bash -$ bin/rails runner lib/code_to_be_run.rb +$ bin/rails runner lib/path_to_ruby_script.rb ``` -By default, `bin/rails runner` scripts are automatically wrapped with the Rails Executor, which helps report uncaught exceptions for tasks like cron jobs. +By default, `bin/rails runner` scripts are automatically wrapped with the Rails +Executor (which is an instance of [`ActiveSupport::Executor`][]) associated with +your Rails application. The Executor creates a “safe zone” to run arbitrary +Ruby inside a Rails app so that the autoloader, middleware stack, and Active +Support hooks all behave consistently. -Therefore, executing `bin/rails runner lib/long_running_scripts.rb` is functionally equivalent to the following: +Therefore, executing `bin/rails runner lib/path_to_ruby_script.rb` is +functionally equivalent to the following: ```ruby Rails.application.executor.wrap do - # executes code inside lib/long_running_scripts.rb + # executes code inside lib/path_to_ruby_script.rb end ``` -You can opt out of this behaviour by using the `--skip-executor` option. +If you have a reason to opt of this behavior, there is a `--skip-executor` +option. ```bash $ bin/rails runner --skip-executor lib/long_running_script.rb ``` -### `bin/rails destroy` +[`ActiveSupport::Executor`]: + https://api.rubyonrails.org/classes/ActiveSupport/Executor.html -Think of `destroy` as the opposite of `generate`. It'll figure out what generate did, and undo it. +### `bin/rails boot` -INFO: You can also use the alias "d" to invoke the destroy command: `bin/rails d`. +The `bin/rails boot` command is a low-level Rails command whose entire job is to +boot your Rails application. Specifically it loads `config/boot.rb` and +`config/application.rb` files so that the application environment is ready to +run. + +The `boot` command boots the application and exits — it does nothing else. It +can be useful for debugging boot problems. If your app fails to start and you +want to isolate the boot phase (without running migrations, starting the server, +etc.), `bin/rails boot` can be a simple test. + +It can also be useful for timing application initialization. You can profile how +long your application takes to boot by wrapping `bin/rails boot` in a profiler. + +Inspecting an Application +------------------------- + +### `bin/rails routes` + +The `bin/rails routes` command lists all defined routes in your application, +including the URI Pattern and HTTP verb, as well as the Controller Action it +maps to. ```bash -$ bin/rails generate model Oops - invoke active_record - create db/migrate/20120528062523_create_oops.rb - create app/models/oops.rb - invoke test_unit - create test/models/oops_test.rb - create test/fixtures/oops.yml +$ bin/rails routes + Prefix Verb URI Pattern Controller#Action + books GET /books(:format) books#index + books POST /books(:format) books#create + ... + ... ``` +This can be useful for tracking down a routing issue, or simply getting an +overview of the resources and routes that are part of a Rails application. You +can also narrow down the output of the `routes` command with options like +`--controller(-c)` or `--grep(-g)`: + ```bash -$ bin/rails destroy model Oops - invoke active_record - remove db/migrate/20120528062523_create_oops.rb - remove app/models/oops.rb - invoke test_unit - remove test/models/oops_test.rb - remove test/fixtures/oops.yml +# Only show routes where the controller name contains "users" +$ bin/rails routes --controller users + +# Show routes handled by namespace Admin::UsersController +$ bin/rails routes -c admin/users + +# Search by name, path, or controller/action with -g (or --grep) +$ bin/rails routes -g users +``` + +There is also an option, `bin/rails routes --expanded`, that displays even more +information about each route, including the line number in your +`config/routes.rb` where that route is defined: + +```bash +$ bin/rails routes --expanded +--[ Route 1 ]-------------------------------------------------------------------------------- +Prefix | +Verb | +URI | /assets +Controller#Action | Propshaft::Server +Source Location | propshaft (1.2.1) lib/propshaft/railtie.rb:49 +--[ Route 2 ]-------------------------------------------------------------------------------- +Prefix | about +Verb | GET +URI | /about(.:format) +Controller#Action | posts#about +Source Location | /Users/bhumi/Code/try_markdown/config/routes.rb:2 +--[ Route 3 ]-------------------------------------------------------------------------------- +Prefix | posts +Verb | GET +URI | /posts(.:format) +Controller#Action | posts#index +Source Location | /Users/bhumi/Code/try_markdown/config/routes.rb:4 ``` +TIP: In development mode, you can also access the same routes info by going to +`http://localhost:3000/rails/info/routes` + ### `bin/rails about` -`bin/rails about` gives information about version numbers for Ruby, RubyGems, Rails, the Rails subcomponents, your application's folder, the current Rails environment name, your app's database adapter, and schema version. It is useful when you need to ask for help, check if a security patch might affect you, or when you need some stats for an existing Rails installation. +The `bin/rails about` command displays information about your Rails application, +such as Ruby, RubyGems, and Rails versions, database adapter, schema version, +etc. It is useful when you need to ask for help or check if a security patch +might affect you. ```bash $ bin/rails about About your application's environment -Rails version 8.1.0 +Rails version 8.2.0 Ruby version 3.2.0 (x86_64-linux) RubyGems version 3.3.7 Rack version 3.0.8 JavaScript Runtime Node.js (V8) -Middleware: ActionDispatch::HostAuthorization, Rack::Sendfile, ActionDispatch::Static, ActionDispatch::Executor, ActionDispatch::ServerTiming, ActiveSupport::Cache::Strategy::LocalCache::Middleware, Rack::Runtime, Rack::MethodOverride, ActionDispatch::RequestId, ActionDispatch::RemoteIp, Sprockets::Rails::QuietAssets, Rails::Rack::Logger, ActionDispatch::ShowExceptions, WebConsole::Middleware, ActionDispatch::DebugExceptions, ActionDispatch::ActionableExceptions, ActionDispatch::Reloader, ActionDispatch::Callbacks, ActiveRecord::Migration::CheckPending, ActionDispatch::Cookies, ActionDispatch::Session::CookieStore, ActionDispatch::Flash, ActionDispatch::ContentSecurityPolicy::Middleware, ActionDispatch::PermissionsPolicy::Middleware, Rack::Head, Rack::ConditionalGet, Rack::ETag, Rack::TempfileReaper -Application root /home/foobar/my_app +Middleware: ActionDispatch::HostAuthorization, Rack::Sendfile, ... +Application root /home/code/my_app Environment development Database adapter sqlite3 -Database schema version 20180205173523 +Database schema version 20250205173523 +``` + +### `bin/rails initializers` + +The `bin/rails initializers` command prints out all defined initializers in the +order they are invoked by Rails: + +```bash +$ bin/rails initializers +ActiveSupport::Railtie.active_support.deprecator +ActionDispatch::Railtie.action_dispatch.deprecator +ActiveModel::Railtie.active_model.deprecator +... +Booknotes::Application.set_routes_reloader_hook +Booknotes::Application.set_clear_dependencies_hook +Booknotes::Application.enable_yjit +``` + +This command can be useful when initializers depend on each other and the order +in which they are run matters. Using this command, you can see what's run +before/after and discover the relationship between initializers. Rails runs +framework initializers first and then application ones, defined in +`config/initializers`. + +### `bin/rails middleware` + +The `bin/rails middleware` shows you the entire Rack middleware stack for your +Rails application, in the exact order the middlewares are run for each request. + +```bash +$ bin/rails middleware +use ActionDispatch::HostAuthorization +use Rack::Sendfile +use ActionDispatch::Static +use ActionDispatch::Executor +use ActionDispatch::ServerTiming +... ``` -### `bin/rails assets:` +This can be useful to see which middleware Rails includes and which ones are +added by gems (Warden::Manager from Devise) as well as for debugging and +profiling. + +### `bin/rails stats` + +The `bin/rails stats` command shows you things like lines of code (LOC) and the +number of classes and methods for various components in your application. + +```bash +$ bin/rails stats ++----------------------+--------+--------+---------+---------+-----+-------+ +| Name | Lines | LOC | Classes | Methods | M/C | LOC/M | ++----------------------+--------+--------+---------+---------+-----+-------+ +| Controllers | 309 | 247 | 7 | 37 | 5 | 4 | +| Helpers | 10 | 10 | 0 | 0 | 0 | 0 | +| Jobs | 7 | 2 | 1 | 0 | 0 | 0 | +| Models | 89 | 70 | 6 | 3 | 0 | 21 | +| Mailers | 10 | 10 | 2 | 1 | 0 | 8 | +| Channels | 16 | 14 | 1 | 2 | 2 | 5 | +| Views | 622 | 501 | 0 | 1 | 0 | 499 | +| Stylesheets | 584 | 495 | 0 | 0 | 0 | 0 | +| JavaScript | 81 | 62 | 0 | 0 | 0 | 0 | +| Libraries | 0 | 0 | 0 | 0 | 0 | 0 | +| Controller tests | 117 | 75 | 4 | 9 | 2 | 6 | +| Helper tests | 0 | 0 | 0 | 0 | 0 | 0 | +| Model tests | 21 | 9 | 3 | 0 | 0 | 0 | +| Mailer tests | 7 | 5 | 1 | 1 | 1 | 3 | +| Integration tests | 0 | 0 | 0 | 0 | 0 | 0 | +| System tests | 51 | 41 | 1 | 4 | 4 | 8 | ++----------------------+--------+--------+---------+---------+-----+-------+ +| Total | 1924 | 1541 | 26 | 58 | 2 | 24 | ++----------------------+--------+--------+---------+---------+-----+-------+ + Code LOC: 1411 Test LOC: 130 Code to Test Ratio: 1:0.1 +``` -You can precompile the assets in `app/assets` using `bin/rails assets:precompile`, and remove older compiled assets using `bin/rails assets:clean`. The `assets:clean` command allows for rolling deploys that may still be linking to an old asset while the new assets are being built. +### `bin/rails time:zones:all` + +The `bin/rails time:zones:all` command prints the complete list of time zones +that Active Support knows about, along with their UTC offsets followed by the +Rails timezone identifiers. + +As an example, you can use `bin/rails time:zones:local` to see your system's +timezone: + +```bash +$ bin/rails time:zones:local + +* UTC -06:00 * +Central America +Central Time (US & Canada) +Chihuahua +Guadalajara +Mexico City +Monterrey +Saskatchewan +``` + +This can be useful when setting `config.time_zone` in `config/application.rb`, +when you need an exact Rails time zone name and spelling (e.g., "Pacific Time +(US & Canada)"), to validate user input or when debugging. + +Managing Assets +--------------- + +The `bin/rails assets:*` commands allow you to manage assets in the `app/assets` +directory. + +You can get a list of all commands in the `assets:` namespace like this: + +```bash +$ bin/rails -T assets +bin/rails assets:clean[count] # Removes old files in config.assets.output_path +bin/rails assets:clobber # Remove config.assets.output_path +bin/rails assets:precompile # Compile all the assets from config.assets.paths +bin/rails assets:reveal # Print all the assets available in config.assets.paths +bin/rails assets:reveal:full # Print the full path of assets available in config.assets.paths +``` + +You can precompile the assets in `app/assets` using `bin/rails +assets:precompile`. See the [Asset Pipeline +guide](asset_pipeline.html#precompiling-assets) for more on precompiling. + +You can remove older compiled assets using `bin/rails assets:clean`. The +`assets:clean` command allows for rolling deploys that may still be linking to +an old asset while the new assets are being built. If you want to clear `public/assets` completely, you can use `bin/rails assets:clobber`. -### `bin/rails db:` +Managing the Database +--------------------- + +The commands in this section, `bin/rails db:*`, are all about setting up +databases, managing migrations, etc. + +You can get a list of all commands in the `db:` namespace like this: + +```bash +$ bin/rails -T db +bin/rails db:create # Create the database from DATABASE_URL or +bin/rails db:drop # Drop the database from DATABASE_URL or +bin/rails db:encryption:init # Generate a set of keys for configuring +bin/rails db:environment:set # Set the environment value for the database +bin/rails db:fixtures:load # Load fixtures into the current environments +bin/rails db:migrate # Migrate the database (options: VERSION=x, +bin/rails db:migrate:down # Run the "down" for a given migration VERSION +bin/rails db:migrate:redo # Roll back the database one migration and +bin/rails db:migrate:status # Display status of migrations +bin/rails db:migrate:up # Run the "up" for a given migration VERSION +bin/rails db:prepare # Run setup if database does not exist, or run +bin/rails db:reset # Drop and recreate all databases from their +bin/rails db:rollback # Roll the schema back to the previous version +bin/rails db:schema:cache:clear # Clear a db/schema_cache.yml file +bin/rails db:schema:cache:dump # Create a db/schema_cache.yml file +bin/rails db:schema:dump # Create a database schema file (either db/ +bin/rails db:schema:load # Load a database schema file (either db/ +bin/rails db:seed # Load the seed data from db/seeds.rb +bin/rails db:seed:replant # Truncate tables of each database for current +bin/rails db:setup # Create all databases, load all schemas, and +bin/rails db:version # Retrieve the current schema version number +bin/rails test:db # Reset the database and run `bin/rails test` +``` + +### Database Setup + +The `db:create` and `db:drop` commands create or delete the database for the +current environment (or all environments with the `db:create:all`, +`db:drop:all`) -The most common commands of the `db:` rails namespace are `migrate` and `create`, and it will pay off to try out all of the migration rails commands (`up`, `down`, `redo`, `reset`). `bin/rails db:version` is useful when troubleshooting, telling you the current version of the database. +The `db:seed` command loads sample data from `db/seeds.rb` and the +`db:seed:replant` command truncates tables of each database for the current +environment and then loads the seed data. -More information about migrations can be found in the [Migrations](active_record_migrations.html) guide. +The `db:setup` command creates all databases, loads all schemas, and initializes +with the seed data (it does not drop databases first, like the `db:reset` +command below). -#### Switching to a Different Database Later +The `db:reset` command drops and recreates all databases from their schema for +the current environment and loads the seed data (so it's a combination of the +above commands). -After creating a new Rails application, you have the option to switch to any -other supported database. For example, you might work with SQLite for a while and -then decide to switch to PostgreSQL. In this case, you only need to run: +NOTE: For more on seed data, see [this +section](active_record_migrations.html#migrations-and-seed-data) of the Active +Record Migrations guide. + +### Migrations + +The `db:migrate` command is one of the most frequently run commands in a Rails +application; it migrates the database by running all new (not yet run) +migrations. + +The `db:migrate:up` command runs the "up" method and the `db:migrate:down` +command runs the "down" method for the migration specified by the VERSION +argument. ```bash -$ rails db:system:change --to=postgresql +$ bin/rails db:migrate:down VERSION=20250812120000 +``` + +The `db:rollback` command rolls the schema back to the previous version (or you +can specify steps with the `STEP=n` argument). + +The `db:migrate:redo` command rolls back the database one migration and +re-migrates up. It is a combination of the above two commands. + +There is also a `db:migrate:status` command, which shows which migrations have +been run and which are still pending: + +```bash +$ bin/rails db:migrate:status +database: db/development.sqlite3 + + Status Migration ID Migration Name +-------------------------------------------------- + up 20250101010101 Create users + up 20250102020202 Add email to users + down 20250812120000 Add age to users +``` + +NOTE: Please see the [Migration Guide](active_record_migrations.html) for an +explanation of concepts related to database migrations and other migration commands. + +### Schema Management + +There are two main commands that help with managing the database schema in your +Rails application: `db:schema:dump` and `db:schema:load`. + +The `db:schema:dump` command reads your database’s current schema and writes +it out to the `db/schema.rb` file (or `db/structure.sql` if you’ve configured +the schema format to `sql`). After running migrations, Rails automatically calls +`schema:dump` so your schema file is always up to date (and doesn't need to be +modified manually). + +The schema file is a blueprint of your database and it is useful for setting up +new environments for tests or development. It’s version-controlled, so you can +see changes to the schema over time. + +The `db:schema:load` command drops and recreates the database schema from +`db/schema.rb` (or `db/structure.sql`). It does this directly, *without* +replaying each migration one at a time. + +This command is useful for quickly resetting a database to the current schema +without running years of migrations one by one. For example, running `db:setup` +also calls `db:schema:load` after creating the database and before seeding it. + +You can think of `db:schema:dump` as the one that *writes* the `schema.rb` file +and `db:schema:load` as the one that *reads* that file. + +### Other Utility Commands + +#### `bin/rails db:version` + +The `bin/rails db:version` command will show you the current version of the +database, which can be useful for troubleshooting. + +```bash +$ bin/rails db:version + +database: storage/development.sqlite3 +Current version: 20250806173936 +``` + +#### `db:fixtures:load` + +The `db:fixtures:load` command loads fixtures into the current environment's +database. To load specific fixtures, you can use `FIXTURES=x,y`. To load from a +subdirectory in `test/fixtures`, use `FIXTURES_DIR=z`. + +```bash +$ bin/rails db:fixtures:load + -> Loading fixtures from test/fixtures/users.yml + -> Loading fixtures from test/fixtures/books.yml +``` + +#### `db:system:change` + +In an existing Rails application, it's possible to switch to a different +database. The `db:system:change` command helps with that by changing the +`config/database.yml` file and your database gem to the target database. + +```bash +$ bin/rails db:system:change --to=postgresql conflict config/database.yml Overwrite config/database.yml? (enter "h" for help) [Ynaqdhm] Y force config/database.yml @@ -567,19 +1180,59 @@ Overwrite config/database.yml? (enter "h" for help) [Ynaqdhm] Y ... ``` -And then install the missing gems: +#### `db:encryption:init` + +The `db:encryption:init` command generates a set of keys for configuring Active +Record encryption in a given environment. + +Running Tests +------------- + +The `bin/rails test` command helps you run the different types of tests in your +application. The `bin/rails test --help` output has good examples of the +different options for this command: + +You can run a single test by appending a line number to a filename: + +```bash + bin/rails test test/models/user_test.rb:27 +``` + +You can run multiple tests within a line range by appending the line range to a filename: ```bash -$ bundle install -... + bin/rails test test/models/user_test.rb:10-20 +``` + +You can run multiple files and directories at the same time: +```bash + bin/rails test test/controllers test/integration/login_test.rb ``` +Rails comes with a testing framework called Minitest and there are also Minitest +options you can use with the `test` command: + +```bash +# Only run tests whose names match the regex /validation/ +$ bin/rails test -n /validation/ +``` + +INFO: Please see the [Testing Guide](testing.html) for explanations and +examples of different types of tests. + +Other Useful Commands +--------------------- + ### `bin/rails notes` -`bin/rails notes` searches through your code for comments beginning with a specific keyword. You can refer to `bin/rails notes --help` for information about usage. +The `bin/rails notes` command searches through your code for comments beginning +with a specific keyword. You can refer to `bin/rails notes --help` for +information about usage. -By default, it will search in `app`, `config`, `db`, `lib`, and `test` directories for FIXME, OPTIMIZE, and TODO annotations in files with extension `.builder`, `.rb`, `.rake`, `.yml`, `.yaml`, `.ruby`, `.css`, `.js`, and `.erb`. +By default, it will search in `app`, `config`, `db`, `lib`, and `test` +directories for FIXME, OPTIMIZE, and TODO annotations in files with extension +`.builder`, `.rb`, `.rake`, `.yml`, `.yaml`, `.ruby`, `.css`, `.js`, and `.erb`. ```bash $ bin/rails notes @@ -589,12 +1242,11 @@ app/controllers/admin/users_controller.rb: lib/school.rb: * [ 13] [OPTIMIZE] refactor this code to make it faster - * [ 17] [FIXME] ``` #### Annotations -You can pass specific annotations by using the `--annotations` argument. By default, it will search for FIXME, OPTIMIZE, and TODO. +You can pass specific annotations by using the `-a` (or `--annotations`) option. Note that annotations are case sensitive. ```bash @@ -607,9 +1259,10 @@ lib/school.rb: * [ 17] [FIXME] ``` -#### Tags +#### Add Tags -You can add more default tags to search for by using `config.annotations.register_tags`. It receives a list of tags. +You can add more default tags to search for by using +`config.annotations.register_tags`: ```ruby config.annotations.register_tags("DEPRECATEME", "TESTME") @@ -623,104 +1276,112 @@ app/controllers/admin/users_controller.rb: * [132] [DEPRECATEME] ensure this method is deprecated in next release ``` -#### Directories +#### Add Directories -You can add more default directories to search from by using `config.annotations.register_directories`. It receives a list of directory names. +You can add more default directories to search from by using +`config.annotations.register_directories`: ```ruby config.annotations.register_directories("spec", "vendor") ``` -```bash -$ bin/rails notes -app/controllers/admin/users_controller.rb: - * [ 20] [TODO] any other way to do this? - * [132] [FIXME] high priority for next deploy - -lib/school.rb: - * [ 13] [OPTIMIZE] Refactor this code to make it faster - * [ 17] [FIXME] - -spec/models/user_spec.rb: - * [122] [TODO] Verify the user that has a subscription works - -vendor/tools.rb: - * [ 56] [TODO] Get rid of this dependency -``` - -#### Extensions +#### Add File Extensions -You can add more default file extensions to search from by using `config.annotations.register_extensions`. It receives a list of extensions with its corresponding regex to match it up. +You can add more default file extensions by using +`config.annotations.register_extensions`: ```ruby config.annotations.register_extensions("scss", "sass") { |annotation| /\/\/\s*(#{annotation}):?\s*(.*)$/ } ``` -```bash -$ bin/rails notes -app/controllers/admin/users_controller.rb: - * [ 20] [TODO] any other way to do this? - * [132] [FIXME] high priority for next deploy +### `bin/rails tmp:` -app/assets/stylesheets/application.css.sass: - * [ 34] [TODO] Use pseudo element for this class +The `Rails.root/tmp` directory is, like the *nix /tmp directory, the holding +place for temporary files like process id files and cached actions. -app/assets/stylesheets/application.css.scss: - * [ 1] [TODO] Split into multiple components +The `tmp:` namespaced commands will help you clear and create the +`Rails.root/tmp` directory: -lib/school.rb: - * [ 13] [OPTIMIZE] Refactor this code to make it faster - * [ 17] [FIXME] +```bash +$ bin/rails tmp:cache:clear # clears `tmp/cache`. +$ bin/rails tmp:sockets:clear # clears `tmp/sockets`. +$ bin/rails tmp:screenshots:clear` # clears `tmp/screenshots`. +$ bin/rails tmp:clear # clears all cache, sockets, and screenshot files. +$ bin/rails tmp:create # creates tmp directories for cache, sockets, and pids. +``` + +### `bin/rails secret` -spec/models/user_spec.rb: - * [122] [TODO] Verify the user that has a subscription works +The `bin/rails secret` command generates a cryptographically secure random +string for use as a secret key in your Rails application. -vendor/tools.rb: - * [ 56] [TODO] Get rid of this dependency +```bash +$ bin/rails secret +4d39f92a661b5afea8c201b0b5d797cdd3dcf8ae41a875add6ca51489b1fbbf2852a666660d32c0a09f8df863b71073ccbf7f6534162b0a690c45fd278620a63 ``` -### `bin/rails routes` +It can be useful for setting the secret key in your application's +`config/credentials.yml.enc` file. -`bin/rails routes` will list all of your defined routes, which is useful for tracking down routing problems in your app, or giving you a good overview of the URLs in an app you're trying to get familiar with. +### `bin/rails credentials` -### `bin/rails test` +The `credentials` commands provide access to encrypted credentials, so you can +safely store access tokens, database passwords, and the like inside the app +without relying on a bunch of environment variables. -INFO: A good description of unit testing in Rails is given in [A Guide to Testing Rails Applications](testing.html) +To add values to the encrypted YML file `config/credentials.yml.enc`, you can +use the `credentials:edit` command: -Rails comes with a test framework called minitest. Rails owes its stability to the use of tests. The commands available in the `test:` namespace helps in running the different tests you will hopefully write. +```bash +$ bin/rails credentials:edit +``` -### `bin/rails tmp:` +This opens the decrypted credentials in an editor (set by `$VISUAL` or +`$EDITOR`) for editing. When saved, the content is encrypted automatically. -The `Rails.root/tmp` directory is, like the *nix /tmp directory, the holding place for temporary files like process id files and cached actions. +You can also use the `:show` command to view the decrypted credential file, +which may look something like this (This is from a sample application and not +sensitive data): -The `tmp:` namespaced commands will help you clear and create the `Rails.root/tmp` directory: +```bash +$ bin/rails credentials:show +# aws: +# access_key_id: 123 +# secret_access_key: 345 +active_record_encryption: + primary_key: 99eYu7ZO0JEwXUcpxmja5PnoRJMaazVZ + deterministic_key: lGRKzINTrMTDSuuOIr6r5kdq2sH6S6Ii + key_derivation_salt: aoOUutSgvw788fvO3z0hSgv0Bwrm76P0 + +# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies. +secret_key_base: 6013280bda2fcbdbeda1732859df557a067ac81c423855aedba057f7a9b14161442d9cadfc7e48109c79143c5948de848ab5909ee54d04c34f572153466fc589 +``` + +You can learn about credentials in the [Rails Security +Guide](security.html#custom-credentials). -* `bin/rails tmp:cache:clear` clears `tmp/cache`. -* `bin/rails tmp:sockets:clear` clears `tmp/sockets`. -* `bin/rails tmp:screenshots:clear` clears `tmp/screenshots`. -* `bin/rails tmp:clear` clears all cache, sockets, and screenshot files. -* `bin/rails tmp:create` creates tmp directories for cache, sockets, and pids. +TIP: Check out the detailed description for this command in the output of +`bin/rails credentials --help`. -### Miscellaneous +Custom Rake Tasks +----------------- -* `bin/rails initializers` prints out all defined initializers in the order they are invoked by Rails. -* `bin/rails middleware` lists Rack middleware stack enabled for your app. -* `bin/rails stats` is great for looking at statistics on your code, displaying things like KLOCs (thousands of lines of code) and your code to test ratio. -* `bin/rails secret` will give you a pseudo-random key to use for your session secret. -* `bin/rails time:zones:all` lists all the timezones Rails knows about. -* `bin/rails boot` boots the application and exits. +You may want to create custom rake tasks in your application, to delete old +records from the database for example. You can do this with the `bin/rails +generate task` command. Custom rake tasks have a `.rake` extension and are +placed in the `lib/tasks` folder in your Rails application. For example: -### Custom Rake Tasks +```bash +$ bin/rails generate task cool +create lib/tasks/cool.rake +``` -Custom rake tasks have a `.rake` extension and are placed in -`Rails.root/lib/tasks`. You can create these custom rake tasks with the -`bin/rails generate task` command. +The `cool.rake` file can contain this: ```ruby -desc "I am short, but comprehensive description for my cool task" +desc "I am short description for a cool task" task task_name: [:prerequisite_task, :another_task_we_depend_on] do - # All your magic here - # Any valid Ruby code is allowed + # Any valid Ruby code is allowed. end ``` @@ -736,26 +1397,28 @@ You can group tasks by placing them in namespaces: ```ruby namespace :db do - desc "This task does nothing" - task :nothing do - # Seriously, nothing + desc "This task has something to do with the database" + task :my_db_task do + # ... end end ``` -Invocation of the tasks will look like: +Invoking rake tasks looks like this: ```bash $ bin/rails task_name -$ bin/rails "task_name[value 1]" # entire argument string should be quoted -$ bin/rails "task_name[value 1,value2,value3]" # separate multiple args with a comma -$ bin/rails db:nothing +$ bin/rails "task_name[value1]" # entire argument string should be quoted +$ bin/rails "task_name[value1, value2]" # separate multiple args with a comma +$ bin/rails db:my_db_task ``` -If you need to interact with your application models, perform database queries, and so on, your task should depend on the `environment` task, which will load your application code. +If you need to interact with your application models, perform database queries, +and so on, your task can depend on the `environment` task, which will load your +Rails application. ```ruby task task_that_requires_app_code: [:environment] do - User.create! + puts User.count end ``` diff --git a/guides/source/configuring.md b/guides/source/configuring.md index dd3ce6f2fc579..025aea1d5a7ef 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -58,15 +58,25 @@ NOTE: If you need to apply configuration directly to a class, use a [lazy load h Below are the default values associated with each target version. In cases of conflicting values, newer versions take precedence over older versions. +#### Default Values for Target Version 8.2 + +- [`config.active_record.postgresql_adapter_decode_bytea`](#config-active-record-postgresql-adapter-decode-bytea): `true` +- [`config.active_record.postgresql_adapter_decode_money`](#config-active-record-postgresql-adapter-decode-money): `true` + #### Default Values for Target Version 8.1 +- [`config.action_controller.action_on_path_relative_redirect`](#config-action-controller-action-on-path-relative-redirect): `:raise` +- [`config.action_controller.escape_json_responses`](#config-action-controller-escape-json-responses): `false` +- [`config.action_view.remove_hidden_field_autocomplete`](#config-action-view-remove-hidden-field-autocomplete): `true` +- [`config.action_view.render_tracker`](#config-action-view-render-tracker): `:ruby` +- [`config.active_record.raise_on_missing_required_finder_order_columns`](#config-active-record-raise-on-missing-required-finder-order-columns): `true` +- [`config.active_support.escape_js_separators_in_json`](#config-active-support-escape-js-separators-in-json): `false` - [`config.yjit`](#config-yjit): `!Rails.env.local?` #### Default Values for Target Version 8.0 - [`Regexp.timeout`](#regexp-timeout): `1` - [`config.action_dispatch.strict_freshness`](#config-action-dispatch-strict-freshness): `true` -- [`config.active_support.to_time_preserves_timezone`](#config-active-support-to-time-preserves-timezone): `:zone` #### Default Values for Target Version 7.2 @@ -104,7 +114,7 @@ Below are the default values associated with each target version. In cases of co #### Default Values for Target Version 7.0 -- [`config.action_controller.raise_on_open_redirects`](#config-action-controller-raise-on-open-redirects): `true` +- [`config.action_controller.action_on_open_redirect`](#config-action-controller-action-on-open-redirect): `:raise` - [`config.action_controller.wrap_parameters_by_default`](#config-action-controller-wrap-parameters-by-default): `true` - [`config.action_dispatch.cookies_serializer`](#config-action-dispatch-cookies-serializer): `:json` - [`config.action_dispatch.default_headers`](#config-action-dispatch-default-headers): `{ "X-Frame-Options" => "SAMEORIGIN", "X-XSS-Protection" => "0", "X-Content-Type-Options" => "nosniff", "X-Download-Options" => "noopen", "X-Permitted-Cross-Domain-Policies" => "none", "Referrer-Policy" => "strict-origin-when-cross-origin" }` @@ -166,7 +176,6 @@ Below are the default values associated with each target version. In cases of co - [`config.action_controller.forgery_protection_origin_check`](#config-action-controller-forgery-protection-origin-check): `true` - [`config.action_controller.per_form_csrf_tokens`](#config-action-controller-per-form-csrf-tokens): `true` - [`config.active_record.belongs_to_required_by_default`](#config-active-record-belongs-to-required-by-default): `true` -- [`config.active_support.to_time_preserves_timezone`](#config-active-support-to-time-preserves-timezone): `:offset` - [`config.ssl_options`](#config-ssl-options): `{ hsts: { subdomains: true } }` ### Rails General Configuration @@ -369,40 +378,6 @@ Sets up the application-wide encoding. Defaults to UTF-8. Sets the exceptions application invoked by the `ShowException` middleware when an exception happens. Defaults to `ActionDispatch::PublicExceptions.new(Rails.public_path)`. -Exceptions applications need to handle `ActionDispatch::Http::MimeNegotiation::InvalidType` errors, which are raised when a client sends an invalid `Accept` or `Content-Type` header. -The default `ActionDispatch::PublicExceptions` application does this automatically, setting `Content-Type` to `text/html` and returning a `406 Not Acceptable` status. -Failure to handle this error will result in a `500 Internal Server Error`. - -Using the `Rails.application.routes` `RouteSet` as the exceptions application also requires this special handling. -It might look something like this: - -```ruby -# config/application.rb -config.exceptions_app = CustomExceptionsAppWrapper.new(exceptions_app: routes) - -# lib/custom_exceptions_app_wrapper.rb -class CustomExceptionsAppWrapper - def initialize(exceptions_app:) - @exceptions_app = exceptions_app - end - - def call(env) - request = ActionDispatch::Request.new(env) - - fallback_to_html_format_if_invalid_mime_type(request) - - @exceptions_app.call(env) - end - - private - def fallback_to_html_format_if_invalid_mime_type(request) - request.formats - rescue ActionDispatch::Http::MimeNegotiation::InvalidType - request.set_header "CONTENT_TYPE", "text/html" - end -end -``` - #### `config.file_watcher` Is the class used to detect file updates in the file system when `config.reload_classes_only_on_change` is `true`. Rails ships with `ActiveSupport::FileUpdateChecker`, the default, and `ActiveSupport::EventedFileUpdateChecker`. Custom classes must conform to the `ActiveSupport::FileUpdateChecker` API. @@ -785,11 +760,10 @@ The provided regexp will be wrapped with both anchors (`\A` and `\z`) so it must match the entire hostname. `/product.com/`, for example, once anchored, would fail to match `www.product.com`. -A special case is supported that allows you to permit all sub-domains: +A special case is supported that allows you to permit the domain and all sub-domains: ```ruby -# Allow requests from subdomains like `www.product.com` and -# `beta1.product.com`. +# Allow requests from the domain itself `product.com` and subdomains like `www.product.com` and `beta1.product.com`. Rails.application.config.hosts << ".product.com" ``` @@ -1077,6 +1051,13 @@ Lets you set an array of names of environments where destructive actions should Specifies whether Rails will look for singular or plural table names in the database. If set to `true` (the default), then the Customer class will use the `customers` table. If set to `false`, then the Customer class will use the `customer` table. +WARNING: Some Rails generators and installers (notably `active_storage:install` +and `action_text:install`) create tables with plural names regardless of this +setting. If you set `pluralize_table_names` to `false`, you will need to +manually rename those tables after installation to maintain consistency. +This limitation exists because these installers use fixed table names +in their migrations for compatibility reasons. + #### `config.active_record.default_timezone` Determines whether to use `Time.local` (if set to `:local`) or `Time.utc` (if set to `:utc`) when pulling dates and times from the database. The default is `:utc`. @@ -1198,7 +1179,7 @@ The default behavior is to report all warnings. Warnings to ignore can be specif Controls the strategy class used to perform schema statement methods in a migration. The default class delegates to the connection adapter. Custom strategies should inherit from `ActiveRecord::Migration::ExecutionStrategy`, -or may inherit from `DefaultStrategy`, which will preserve the default behaviour for methods that aren't implemented: +or may inherit from `DefaultStrategy`, which will preserve the default behavior for methods that aren't implemented: ```ruby class CustomMigrationStrategy < ActiveRecord::Migration::DefaultStrategy @@ -1210,6 +1191,23 @@ end config.active_record.migration_strategy = CustomMigrationStrategy ``` +You can also configure migration strategies on a per-adapter basis by setting the `migration_strategy` class on the adapter itself. +This is useful when you want to customize migration behavior for a specific database type. + +For example, to use a custom migration strategy for PostgreSQL: + +```ruby +class CustomPostgresStrategy < ActiveRecord::Migration::DefaultStrategy + def drop_table(*) + # Custom logic specific to PostgreSQL + end +end + +ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.migration_strategy = CustomPostgresStrategy +``` + +By assigning to the adapter class, all migrations run through connections using that adapter will use the specified strategy. + #### `config.active_record.schema_versions_formatter` Controls the formatter class used by schema dumper to format versions information. Custom class can be provided @@ -1514,6 +1512,8 @@ Define an `Array` specifying the key/value tags to be inserted in an SQL comment `[ :application, :controller, :action, :job ]`. The available tags are: `:application`, `:controller`, `:namespaced_controller`, `:action`, `:job`, and `:source_location`. +WARNING: Calculating the `:source_location` of a query can be slow, so you should consider its impact if using it in a production environment. + #### `config.active_record.query_log_tags_format` A `Symbol` specifying the formatter to use for tags. Valid values are `:sqlcommenter` and `:legacy`. @@ -1532,6 +1532,17 @@ that have a large number of queries, caching query log tags can provide a performance benefit when the context does not change during the lifetime of the request or job execution. Defaults to `false`. +#### `config.active_record.query_log_tags_prepend_comment` + +Specifies whether or not to prepend query log tags comment to the query. + +By default comments are appended at the end of the query. Certain databases, such as MySQL will +truncate the query text. This is the case for slow query logs and the results of querying +some InnoDB internal tables where the length of the query is more than 1024 bytes. +In order to not lose the log tags comments from the queries, you can prepend the comments using this option. + +Defaults to `false`. + #### `config.active_record.schema_cache_ignored_tables` Define the list of table that should be ignored when generating the schema @@ -1560,6 +1571,23 @@ The default value depends on the `config.load_defaults` target version: | (original) | `false` | | 7.1 | `true` | +#### `config.active_record.postgresql_adapter_decode_bytea` + +Specifies whether the PostgresqlAdapter should decode bytea columns. + +```ruby +ActiveRecord::Base.connection + .select_value("select '\\x48656c6c6f'::bytea").encoding #=> Encoding::BINARY +``` + + +The default value depends on the `config.load_defaults` target version: + +| Starting with version | The default value is | +| --------------------- | -------------------- | +| (original) | `false` | +| 8.2 | `true` | + #### `config.active_record.postgresql_adapter_decode_dates` Specifies whether the PostgresqlAdapter should decode date columns. @@ -1577,6 +1605,23 @@ The default value depends on the `config.load_defaults` target version: | (original) | `false` | | 7.2 | `true` | +#### `config.active_record.postgresql_adapter_decode_money` + +Specifies whether the PostgresqlAdapter should decode money columns. + +```ruby +ActiveRecord::Base.connection + .select_value("select '12.34'::money").class #=> BigDecimal +``` + + +The default value depends on the `config.load_defaults` target version: + +| Starting with version | The default value is | +| --------------------- | -------------------- | +| (original) | `false` | +| 8.2 | `true` | + #### `config.active_record.async_query_executor` @@ -1689,6 +1734,28 @@ required: config.active_record.database_cli = { postgresql: "pgcli", mysql: %w[ mycli mysql ] } ``` +#### `config.active_record.use_legacy_signed_id_verifier` + +Controls whether signed IDs are generated and verified using legacy options. Can be set to: + +* `:generate_and_verify` (default) - Generate and verify signed IDs using the following legacy options: + + ```ruby + { digest: "SHA256", serializer: JSON, url_safe: true } + ``` + +* `:verify` - Generate and verify signed IDs using options from [`Rails.application.message_verifiers`][], but fall back to verifying with the same options as `:generate_and_verify`. + +* false - Generate and verify signed IDs using options from [`Rails.application.message_verifiers`][] only. + +The purpose of this setting is to provide a smooth transition to a unified configuration for all message verifiers. Having a unified configuration makes it more straightforward to rotate secrets and upgrade signing algorithms. + +WARNING: Setting this to false may cause old signed IDs to become unreadable if `Rails.application.message_verifiers` is not properly configured. Use [`MessageVerifiers#rotate`][ActiveSupport::MessageVerifiers#rotate] or [`MessageVerifiers#prepend`][ActiveSupport::MessageVerifiers#prepend] to configure `Rails.application.message_verifiers` with the appropriate options, such as `:digest` and `:url_safe`. + +[`Rails.application.message_verifiers`]: https://api.rubyonrails.org/classes/Rails/Application.html#method-i-message_verifiers +[ActiveSupport::MessageVerifiers#rotate]: https://api.rubyonrails.org/classes/ActiveSupport/MessageVerifiers.html#method-i-rotate +[ActiveSupport::MessageVerifiers#prepend]: https://api.rubyonrails.org/classes/ActiveSupport/MessageVerifiers.html#method-i-prepend + #### `ActiveRecord::ConnectionAdapters::Mysql2Adapter.emulate_booleans` and `ActiveRecord::ConnectionAdapters::TrilogyAdapter.emulate_booleans` Controls whether the Active Record MySQL adapter will consider all `tinyint(1)` columns as booleans. Defaults to `true`. @@ -1740,11 +1807,50 @@ whether a foreign key's name should be dumped to db/schema.rb or not. By default, foreign key names starting with `fk_rails_` are not exported to the database schema dump. Defaults to `/^fk_rails_[0-9a-f]{10}$/`. +#### `config.active_record.encryption.support_unencrypted_data` + +When `true`, unencrypted data can be read normally. When `false`, it will raise errors. Default: `false`. + +#### `config.active_record.encryption.extend_queries` + +When `true`, queries referencing deterministically encrypted attributes will be modified to include additional values if needed. Those additional values will be the clean version of the value (when `config.active_record.encryption.support_unencrypted_data` is `true`) and values encrypted with previous encryption schemes, if any (as provided with the `previous:` option). Default: `false`. + +#### `config.active_record.encryption.encrypt_fixtures` + +When `true`, encryptable attributes in fixtures will be automatically encrypted when loaded. Default: `false`. + +#### `config.active_record.encryption.store_key_references` + +When `true`, a reference to the encryption key is stored in the headers of the encrypted message. This makes for faster decryption when multiple keys are in use. Default: `false`. + #### `config.active_record.encryption.add_to_filter_parameters` -Enables automatic filtering of encrypted attributes on `inspect`. +When `true`, encrypted attribute names are added automatically to [`config.filter_parameters`](#config-filter-parameters) and won't be shown in logs. Default: `true`. -The default value is `true`. +#### `config.active_record.encryption.excluded_from_filter_parameters` + +You can configure a list of params that won't be filtered out when `config.active_record.encryption.add_to_filter_parameters` is true. Default: `[]`. + +#### `config.active_record.encryption.validate_column_size` + +Adds a validation based on the column size. This is recommended to prevent storing huge values using highly compressible payloads. Default: `true`. + +#### `config.active_record.encryption.primary_key` + +The key or lists of keys used to derive root data encryption keys. The way they are used depends on the key provider configured. It's preferred to configure it via the `active_record_encryption.primary_key` credential. + +#### `config.active_record.encryption.deterministic_key` + +The key or list of keys used for deterministic encryption. It's preferred to configure it via the `active_record_encryption.deterministic_key` credential. + +#### `config.active_record.encryption.key_derivation_salt` + +The salt used when deriving keys. It's preferred to configure it via the `active_record_encryption.key_derivation_salt` credential. + +#### `config.active_record.encryption.forced_encoding_for_deterministic_encryption` + +The default encoding for attributes encrypted deterministically. You can disable +forced encoding by setting this option to `nil`. It's `Encoding::UTF_8` by default. #### `config.active_record.encryption.hash_digest_class` @@ -1759,8 +1865,9 @@ The default value depends on the `config.load_defaults` target version: #### `config.active_record.encryption.support_sha1_for_non_deterministic_encryption` -Enables support for decrypting existing data encrypted using a SHA-1 digest class. When `false`, -it will only support the digest configured in `config.active_record.encryption.hash_digest_class`. +Enables support for decrypting existing data encrypted using a SHA-1 digest +class. When `false`, it will only support the digest configured in +`config.active_record.encryption.hash_digest_class`. The default value depends on the `config.load_defaults` target version: @@ -1771,9 +1878,7 @@ The default value depends on the `config.load_defaults` target version: #### `config.active_record.encryption.compressor` -Sets the compressor used by Active Record Encryption. The default value is `Zlib`. - -You can use your own compressor by setting this to a class that responds to `deflate` and `inflate`. +The compressor used to compress encrypted payloads. The default is `Zlib`. You can use your own compressor by setting this to a class that responds to `deflate` and `inflate`. #### `config.active_record.protocol_adapters` @@ -1787,6 +1892,44 @@ config.active_record.protocol_adapters.mysql = "trilogy" If no mapping is found, the protocol is used as the adapter name. +#### `config.active_record.deprecated_associations_options` + +If present, this has to be a hash with keys `:mode` and/or `:backtrace`: + +```ruby +config.active_record.deprecated_associations_options = { mode: :notify, backtrace: true } +``` + +* In `:warn` mode, accessing the deprecated association is reported by the + Active Record logger. This is the default mode. + +* In `:raise` mode, usage raises an `ActiveRecord::DeprecatedAssociationError` + with a similar message and a clean backtrace in the exception object. + +* In `:notify` mode, a `deprecated_association.active_record` Active Support + notification is published. Please, see details about its payload in the + [Active Support Instrumentation guide](active_support_instrumentation.html). + +Backtraces are disabled by default. If `:backtrace` is true, warnings include a +clean backtrace in the message, and notifications have a `:backtrace` key in the +payload with an array of clean `Thread::Backtrace::Location` objects. Exceptions +always have a clean stack trace. + +Clean backtraces are computed using the Active Record backtrace cleaner. + +#### `config.active_record.raise_on_missing_required_finder_order_columns` + +Raises an error when order dependent finder methods (e.g. `#first`, `#second`) are called without `order` values +on the relation, and the model does not have any order columns (`implicit_order_column`, `query_constraints`, or +`primary_key`) to fall back on. + +The default value depends on the `config.load_defaults` target version: + +| Starting with version | The default value is | +| --------------------- | -------------------- | +| (original) | `false` | +| 8.1 | `true` | + ### Configuring Action Controller `config.action_controller` includes a number of configuration settings: @@ -1913,15 +2056,56 @@ with an external host is passed to [redirect_to][]. If an open redirect should be allowed, then `allow_other_host: true` can be added to the call to `redirect_to`. -The default value depends on the `config.load_defaults` target version: - | Starting with version | The default value is | | --------------------- | -------------------- | | (original) | `false` | -| 7.0 | `true` | [redirect_to]: https://api.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to +#### `config.action_controller.action_on_open_redirect` + +Controls how Rails handles open redirect attempts (redirects to external hosts). + +**Note:** This configuration replaces the deprecated [`config.action_controller.raise_on_open_redirects`](#config-action-controller-raise-on-open-redirects) +option, which will be removed in a future Rails version. The new configuration provides more +flexible control over open redirect protection. + +When set to `:log`, Rails will log a warning when an open redirect is detected. +When set to `:notify`, Rails will publish an `open_redirect.action_controller` +notification event. When set to `:raise`, Rails will raise an +`ActionController::Redirecting::UnsafeRedirectError`. + +If `raise_on_open_redirects` is set to `true`, it will take precedence +over this configuration for backward compatibility, effectively forcing `:raise` +behavior. + +The default value depends on the `config.load_defaults` target version: + +| Starting with version | The default value is | +| --------------------- | -------------------- | +| (original) | `:log` | +| 7.0 | `:raise` | + +#### `config.action_controller.action_on_path_relative_redirect` + +Controls how Rails handles paths relative URL redirects. + +When set to `:log` (default), Rails will log a warning when a path relative URL redirect +is detected. When set to `:notify`, Rails will publish an +`unsafe_redirect.action_controller` notification event. When set to `:raise`, Rails +will raise an `ActionController::Redirecting::UnsafeRedirectError`. + +This helps detect potentially unsafe redirects that could be exploited for open +redirect attacks. + +The default value depends on the `config.load_defaults` target version: + +| Starting with version | The default value is | +| --------------------- | -------------------- | +| (original) | `:log` | +| 8.1 | `:raise` | + + #### `config.action_controller.log_query_tags_around_actions` Determines whether controller context for query tags will be automatically @@ -1952,11 +2136,31 @@ The default value depends on the `config.load_defaults` target version: [params_wrapper]: https://api.rubyonrails.org/classes/ActionController/ParamsWrapper.html +#### `config.action_controller.allowed_redirect_hosts` + +Specifies a list of allowed hosts for redirects. `redirect_to` will allow redirects to them without raising an +`UnsafeRedirectError` error. + #### `ActionController::Base.wrap_parameters` Configures the [`ParamsWrapper`](https://api.rubyonrails.org/classes/ActionController/ParamsWrapper.html). This can be called at the top level, or on individual controllers. +#### `config.action_controller.escape_json_responses` + +Configures the JSON renderer to escape HTML entities and Unicode characters that are invalid in JavaScript. + +This is useful if you relied on the JSON response having those characters escaped to embed the JSON document in +\`. Read more about XSS and injection later on. * The attacker lures the victim to the infected page with the JavaScript code. By viewing the page, the victim's browser will change the session ID to the trap session ID. @@ -571,7 +580,7 @@ def legacy end ``` -This will redirect the user to the main action if they tried to access a legacy action. The intention was to preserve the URL parameters to the legacy action and pass them to the main action. However, it can be exploited by attacker if they included a host key in the URL: +This will redirect the user to the main action if they try to access a legacy action. The intention was to preserve the URL parameters to the legacy action and pass them to the main action. However, it can be exploited by an attacker if they include a host key in the URL: ``` http://www.example.com/site/legacy?param1=xy¶m2=23&host=www.attacker.com @@ -601,7 +610,7 @@ def sanitize_filename(filename) # NOTE: File.basename doesn't work right with Windows paths on Unix # get only the filename, not the whole path name.sub!(/\A.*(\\|\/)/, "") - # Finally, replace all non alphanumeric, underscore + # Finally, replace all non-alphanumeric, underscore # or periods with underscore name.gsub!(/[^\w.-]/, "_") end @@ -639,7 +648,7 @@ raise if basename != File.expand_path(File.dirname(filename)) send_file filename, disposition: "inline" ``` -Another (additional) approach is to store the file names in the database and name the files on the disk after the ids in the database. This is also a good approach to avoid possible code in an uploaded file to be executed. The `attachment_fu` plugin does this in a similar way. +Another (additional) approach is to store the file names in the database and name the files on the disk after the ids in the database. This is also a good approach to avoid possible code in an uploaded file from being executed. The `attachment_fu` plugin does this in a similar way. User Management --------------- @@ -688,7 +697,7 @@ Depending on your web application, there may be more ways to hijack the user's a ### CAPTCHAs -INFO: _A CAPTCHA is a challenge-response test to determine that the response is not generated by a computer. It is often used to protect registration forms from attackers and comment forms from automatic spam bots by asking the user to type the letters of a distorted image. This is the positive CAPTCHA, but there is also the negative CAPTCHA. The idea of a negative CAPTCHA is not for a user to prove that they are human, but reveal that a robot is a robot._ +INFO: _A CAPTCHA is a challenge-response test to determine that the response is not generated by a computer. It is often used to protect registration forms from attackers and comment forms from automatic spam bots by asking the user to type the letters of a distorted image. This is the positive CAPTCHA, but there is also the negative CAPTCHA. The idea of a negative CAPTCHA is not for a user to prove that they are human, but to reveal that a robot is a robot._ A popular positive CAPTCHA API is [reCAPTCHA](https://developers.google.com/recaptcha/) which displays two distorted images of words from old books. It also adds an angled line, rather than a distorted background and high levels of warping on the text as earlier CAPTCHAs did, because the latter were broken. As a bonus, using reCAPTCHA helps to digitize old books. [ReCAPTCHA](https://github.com/ambethia/recaptcha/) is also a Rails plug-in with the same name as the API. @@ -699,13 +708,13 @@ Most bots are really naive. They crawl the web and put their spam into every for Note that negative CAPTCHAs are only effective against naive bots and won't suffice to protect critical applications from targeted bots. Still, the negative and positive CAPTCHAs can be combined to increase the performance, e.g., if the "honeypot" field is not empty (bot detected), you won't need to verify the positive CAPTCHA, which would require an HTTPS request to Google ReCaptcha before computing the response. -Here are some ideas how to hide honeypot fields by JavaScript and/or CSS: +Here are some ideas on how to hide honeypot fields by JavaScript and/or CSS: -* position the fields off of the visible area of the page +* position the fields off the visible area of the page * make the elements very small or color them the same as the background of the page * leave the fields displayed, but tell humans to leave them blank -The most simple negative CAPTCHA is one hidden honeypot field. On the server side, you will check the value of the field: If it contains any text, it must be a bot. Then, you can either ignore the post or return a positive result, but not saving the post to the database. This way the bot will be satisfied and moves on. +The simplest negative CAPTCHA is one hidden honeypot field. On the server side, you will check the value of the field: If it contains any text, it must be a bot. Then, you can either ignore the post or return a positive result, but not save the post to the database. This way, the bot will be satisfied and move on. You can find more sophisticated negative CAPTCHAs in Ned Batchelder's [blog post](https://nedbatchelder.com/text/stopbots.html): @@ -719,7 +728,7 @@ Note that this protects you only from automatic bots, targeted tailor-made bots WARNING: _Tell Rails not to put passwords in the log files._ -By default, Rails logs all requests being made to the web application. But log files can be a huge security issue, as they may contain login credentials, credit card numbers et cetera. When designing a web application security concept, you should also think about what will happen if an attacker got (full) access to the web server. Encrypting secrets and passwords in the database will be quite useless, if the log files list them in clear text. You can _filter certain request parameters from your log files_ by appending them to [`config.filter_parameters`][] in the application configuration. These parameters will be marked [FILTERED] in the log. +By default, Rails logs all requests being made to the web application. But log files can be a huge security issue, as they may contain login credentials, credit card numbers et cetera. When designing a web application security concept, you should also think about what will happen if an attacker gets (full) access to the web server. Encrypting secrets and passwords in the database will be quite useless, if the log files list them in clear text. You can _filter certain request parameters from your log files_ by appending them to [`config.filter_parameters`][] in the application configuration. These parameters will be marked [FILTERED] in the log. ```ruby config.filter_parameters << :password @@ -821,7 +830,7 @@ INFO: _Thanks to clever methods, this is hardly a problem in most Rails applicat #### Introduction -SQL injection attacks aim at influencing database queries by manipulating web application parameters. A popular goal of SQL injection attacks is to bypass authorization. Another goal is to carry out data manipulation or reading arbitrary data. Here is an example of how not to use user input data in a query: +SQL injection attacks aim at influencing database queries by manipulating web application parameters. A popular goal of SQL injection attacks is to bypass authorization. Another goal is to carry out data manipulation or read arbitrary data. Here is an example of how not to use user input data in a query: ```ruby Project.where("name = '#{params[:name]}'") @@ -849,7 +858,7 @@ If an attacker enters `' OR '1'='1` as the name, and `' OR '2'>'1` as the passwo SELECT * FROM users WHERE login = '' OR '1'='1' AND password = '' OR '2'>'1' LIMIT 1 ``` -This will simply find the first record in the database, and grants access to this user. +This will simply find the first record in the database and grant access to this user. #### Unauthorized Reading @@ -903,7 +912,7 @@ Additionally, you can split and chain conditionals valid for your use case: Model.where(zip_code: entered_zip_code).where("quantity >= ?", entered_quantity).first ``` -Note the previous mentioned countermeasures are only available in model instances. You can +Note that the previously mentioned countermeasures are only available in model instances. You can try [`sanitize_sql`][] elsewhere. _Make it a habit to think about the security consequences when using an external string in SQL_. @@ -921,7 +930,7 @@ The most common entry points are message posts, user comments, and guest books, XSS attacks work like this: An attacker injects some code, the web application saves it and displays it on a page, later presented to a victim. Most XSS examples simply display an alert box, but it is more powerful than that. XSS can steal the cookie, hijack the session, redirect the victim to a fake website, display advertisements for the benefit of the attacker, change elements on the website to get confidential information or install malicious software through security holes in the web browser. -During the second half of 2007, there were 88 vulnerabilities reported in Mozilla browsers, 22 in Safari, 18 in IE, and 12 in Opera. The Symantec Global Internet Security threat report also documented 239 browser plug-in vulnerabilities in the last six months of 2007. [Mpack](https://www.pandasecurity.com/en/mediacenter/malware/mpack-uncovered/) is a very active and up-to-date attack framework which exploits these vulnerabilities. For criminal hackers, it is very attractive to exploit an SQL-Injection vulnerability in a web application framework and insert malicious code in every textual table column. In April 2008 more than 510,000 sites were hacked like this, among them the British government, United Nations, and many more high profile targets. +During the second half of 2007, there were 88 vulnerabilities reported in Mozilla browsers, 22 in Safari, 18 in IE, and 12 in Opera. The Symantec Global Internet Security threat report also documented 239 browser plug-in vulnerabilities in the last six months of 2007. [Mpack](https://www.pandasecurity.com/en/mediacenter/malware/mpack-uncovered/) is a very active and up-to-date attack framework which exploits these vulnerabilities. For criminal hackers, it is very attractive to exploit an SQL injection vulnerability in a web application framework and insert malicious code in every textual table column. In April 2008 more than 510,000 sites were hacked like this, among them the British government, United Nations, and many more high-profile targets. #### HTML/JavaScript Injection @@ -964,7 +973,7 @@ You can mitigate these attacks (in the obvious way) by adding the **httpOnly** f ##### Defacement -With web page defacement an attacker can do a lot of things, for example, present false information or lure the victim on the attacker's website to steal the cookie, login credentials, or other sensitive data. The most popular way is to include code from external sources by iframes: +With web page defacement, an attacker can do a lot of things, for example, present false information or lure the victim to the attacker's website to steal the cookie, login credentials, or other sensitive data. The most popular way is to include code from external sources by iframes: ```html @@ -972,9 +981,9 @@ With web page defacement an attacker can do a lot of things, for example, presen This loads arbitrary HTML and/or JavaScript from an external source and embeds it as part of the site. This `iframe` is taken from an actual attack on legitimate Italian sites using the [Mpack attack framework](https://isc.sans.edu/diary/MPack+Analysis/3015). Mpack tries to install malicious software through security holes in the web browser - very successfully, 50% of the attacks succeed. -A more specialized attack could overlap the entire website or display a login form, which looks the same as the site's original, but transmits the username and password to the attacker's site. Or it could use CSS and/or JavaScript to hide a legitimate link in the web application, and display another one at its place which redirects to a fake website. +A more specialized attack could overlap the entire website or display a login form, which looks the same as the site's original, but transmits the username and password to the attacker's site. Or it could use CSS and/or JavaScript to hide a legitimate link in the web application, and display another one in its place, which redirects to a fake website. -Reflected injection attacks are those where the payload is not stored to present it to the victim later on, but included in the URL. Especially search forms fail to escape the search string. The following link presented a page which stated that "George Bush appointed a 9 year old boy to be the chairperson...": +Reflected injection attacks are those where the payload is not stored to present it to the victim later on, but is included in the URL. Especially search forms fail to escape the search string. The following link presented a page which stated that "George Bush appointed a 9 year old boy to be the chairperson...": ``` http://www.cbsnews.com/stories/2002/02/15/weather_local/main501644.shtml?zipcode=1--> @@ -987,7 +996,7 @@ _It is very important to filter malicious input, but it is also important to esc Especially for XSS, it is important to do _permitted input filtering instead of restricted_. Permitted list filtering states the values allowed as opposed to the values not allowed. Restricted lists are never complete. -Imagine a restricted list deletes `"script"` from the user input. Now the attacker injects `""`, and after the filter, `"