diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index 17fcaa436064a..0000000000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,13 +0,0 @@ -engines: - rubocop: - enabled: true - -ratings: - paths: - - "**.rb" - -exclude_paths: - - ci/ - - guides/ - - tasks/ - - tools/ diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000000000..66f98384d2400 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,42 @@ +# 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.7" +FROM ghcr.io/rails/devcontainer/images/ruby:${VARIANT} + +RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && sudo apt-get -y install --no-install-recommends \ + mariadb-client libmariadb-dev \ + postgresql-client postgresql-contrib libpq-dev \ + ffmpeg mupdf mupdf-tools libvips-dev poppler-utils \ + 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 +# advantage due to caching of the other gems. +RUN mkdir -p /tmp/rails +COPY Gemfile Gemfile.lock RAILS_VERSION rails.gemspec package.json yarn.lock /tmp/rails/ +COPY actioncable/actioncable.gemspec /tmp/rails/actioncable/ +COPY actionmailbox/actionmailbox.gemspec /tmp/rails/actionmailbox/ +COPY actionmailer/actionmailer.gemspec /tmp/rails/actionmailer/ +COPY actionpack/actionpack.gemspec /tmp/rails/actionpack/ +COPY actiontext/actiontext.gemspec /tmp/rails/actiontext/ +COPY actionview/actionview.gemspec /tmp/rails/actionview/ +COPY activejob/activejob.gemspec /tmp/rails/activejob/ +COPY activemodel/activemodel.gemspec /tmp/rails/activemodel/ +COPY activerecord/activerecord.gemspec /tmp/rails/activerecord/ +COPY activestorage/activestorage.gemspec /tmp/rails/activestorage/ +COPY activesupport/activesupport.gemspec /tmp/rails/activesupport/ +COPY railties/railties.gemspec /tmp/rails/railties/ +COPY tools/releaser/releaser.gemspec /tmp/rails/tools/releaser/ +# Docker does not support COPY as users other than root. So we need to chown this dir so we +# 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 \ + && bash -i -c 'bundle install' \ + && rm -rf /tmp/rails diff --git a/.devcontainer/boot.sh b/.devcontainer/boot.sh new file mode 100755 index 0000000000000..ee03e3678a5e6 --- /dev/null +++ b/.devcontainer/boot.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +bundle update --bundler +bundle install + +if [ -n "${NVM_DIR}" ]; then + # shellcheck disable=SC1091 + . "${NVM_DIR}/nvm.sh" && nvm install --lts + yarn install +fi + +cd activerecord || { echo "activerecord directory doesn't exist"; exit; } + +# Create PostgreSQL databases +bundle exec rake db:postgresql:rebuild + +# Create MySQL databases +bundle exec rake db:mysql:rebuild diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml new file mode 100644 index 0000000000000..2872f87dd19a3 --- /dev/null +++ b/.devcontainer/compose.yaml @@ -0,0 +1,57 @@ +services: + rails: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + depends_on: + - postgres + - mysql + - redis + - memcached + + environment: + MYSQL_CODESPACES: "1" + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + postgres: + image: postgres:latest + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + + mysql: + image: mysql:latest + restart: unless-stopped + volumes: + - mysql-data:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: root + + redis: + image: valkey/valkey:8 + restart: unless-stopped + volumes: + - redis-data:/data + + memcached: + image: memcached:latest + restart: unless-stopped + command: ["-m", "1024"] + +volumes: + postgres-data: + mysql-data: + redis-data: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..302b4c600027d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,49 @@ +// For format details, see https://containers.dev/implementors/json_reference/. +{ + "name": "Rails project development", + "dockerComposeFile": "compose.yaml", + "service": "rails", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "latest" + }, + "ghcr.io/rails/devcontainer/features/postgres-client:1.1.3": { + "version": "18" + } + }, + + "containerEnv": { + "PGHOST": "postgres", + "PGUSER": "postgres", + "PGPASSWORD": "postgres", + "MYSQL_HOST": "mysql", + "REDIS_URL": "redis://redis/0", + "MEMCACHE_SERVERS": "memcached:11211" + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or the host. + // "forwardPorts": [3000, 5432], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": ".devcontainer/boot.sh", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "Shopify.ruby-lsp" + ] + } + } + + // Uncomment to connect as root instead. More info: https://containers.dev/implementors/json_reference/#remoteUser. + // "remoteUser": "root" +} diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000000..7fede1e72571f --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,86 @@ +# Changes that are cosmetic and do not add anything substantial +# to the stability, functionality, or testability of Rails will +# generally not be accepted. Read more about our rationale behind +# this decision: https://github.com/rails/rails/pull/13771#issuecomment-32746700 + +# normalizes indentation and whitespace across the project +80e66cc4d90bf8c15d1a5f6e3152e90147f00772 +# applies new string literal convention in * +adca8154c6ffce978a5dbc514273cceecbb15f8e +9ed740449884ba5841f756c4a5ccc0bce8f19082 +92e2d16a3c75d549fcd9422a31acd3323b74abaa +78d3f84955bccad0ab161c5f2b4c1133813161a3 +6b3719b7577ab81171bab94a0492ae383dd279fe +8b4e6bf23338e2080af537ea4f295e65a1d11388 +783763bde97bea3d0c200038453008a8cfff1e88 +69ab3eb57e8387b0dd9d672b5e8d9185395baa03 +f8477f13bfe554064bd25a57e5289b4ebaabb504 +b678eb57e93423ac8e2a0cc0b083ce556c6fb130 +b91ff557ef6f621d1b921f358fd5b8a5d9e9090e +c3e7abddfb759eecb30cd59f27103fda4c4827dc +35b3de8021e68649cac963bd82a74cc75d1f60f0 +628e51ff109334223094e30ad1365188bbd7f9c6 +4b6c68dfb810c836f87587a16353317d1a180805 +66a7cfa91045e05f134efc9ac0e226e66161e2e6 +bde6547bb6a8ddf18fb687bf20893d3dc87e0358 +93c9534c9871d4adad4bc33b5edc355672b59c61 +4c208254573c3428d82bd8744860bd86e1c78acb +18a2513729ab90b04b1c86963e7d5b9213270c81 +9617db2078e8a85c8944392c21dd748f932bbd80 +4df2b779ddfcb27761c71e00e2b241bfa06a0950 +a731125f12c5834de7eae3455fad63ea4d348034 +d66e7835bea9505f7003e5038aa19b6ea95ceea1 +e6ab70c439301d533f14b3387ee181d843a86b30 +# modernizes hash syntax in * +1607ee299d15c133b2b63dcfc840eddfba7e525b +477568ee33bee0dc5e57b9df624142296e3951a4 +5c315a8fa6296904f5e0ba8da919fc395548cf98 +d22e522179c1c90e658c3ed0e9b972ec62306209 +fa911a74e15ef34bb435812f7d9cf7324253476f +301ce2a6d11bc7a016f7ede71e3c6fd9fa0693a3 +63fff600accb41b56a3e6ac403d9b1732de3086d +5b6eb1d58b48fada298215b2cccda89f993890c3 +12a70404cd164008879e63cc320356e6afee3adc +60b67d76dc1d98e4269aac7705e9d8323eb42942 +# [Tests only] Enable Minitest/AssertPredicate rule +19f8ab2e7d60dcdfd7664d6bea3a9fae55a3618c +# Standardize nodoc comments +18707ab17fa492eb25ad2e8f9818a320dc20b823 +# Add Style/RedundantFreeze to remove redudant .freeze +aa3dcabd874a3e82e455e85a1c94a7abaac2900a +# Enable Performance/UnfreezeString cop +1b86d90136efb98c7b331a84ca163587307a49af +# Arel: rubocop -a +4c0a3d48804a363c7e9272519665a21f601b5248 +# Add more rubocop rules about whitespaces +fe1f4b2ad56f010a4e9b93d547d63a15953d9dc2 +# Add three new rubocop rules +55f9b8129a50206513264824abb44088230793c2 +# applies remaining conventions across the project +b326e82dc012d81e9698cb1f402502af1788c1e9 +# remove redundant curlies from hash arguments +411ccbdab2608c62aabdb320d52cb02d446bb39c +# Deletes trailing whitespaces (over text files only find * -type f -exec sed 's/[ \t]*$//' -i {} ;) +b95d6e84b00bd926b1118f6a820eca7a870b8c35 +b451de0d6de4df6bc66b274cec73b919f823d5ae +# Replace assert ! with assert_not +a1ac18671a90869ef81d02f2eafe8104e4eea34f +# Use respond_to test helpers +0d50cae996c51630361e8514e1f168b0c48957e1 +# Change refute to assert_not +211adb47e76b358ea15a3f756431c042ab231c23 +# Use assert_predicate and assert_not_predicate +94333a4c31bd10c1f358c538a167e6a4589bae2d +# Use assert_empty and assert_not_empty +82c39e1a0b5114e2d89a80883a41090567a83196 +# Remove extra whitespace +fda1863e1a8c120294c56482631d8254ad6125ff +# Reduce string objects by using \ instead of + or << for concatenating strings +b70fc698e157f2a768ba42efac08c08f4786b01c +# Hash Syntax to 1.9 related changes +eebb9ddf9ba559a510975c486fe59a4edc9da97d +be4a4cd38ff2c2db0f6b69bb72fb3557bd5a6e21 +d20a52930aa80d7f219465d6fc414a68b16ef2a8 +3c580182ff3c16d2247aabc85100bf8c6edb0f82 +5ad7f8ab418f0c760dbb584f7c78d94ce32e9ee3 +a2c843885470dddbad9430963190464a22167921 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000000..b5ed405b02670 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +.rubocop.yml @rafaelfranca diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000000..fdefb6a67e9d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Report an issue with Rails you've discovered +--- + +### Steps to reproduce + + + + +```ruby +# Your reproduction script goes here +``` + +### Expected behavior + + + +### Actual behavior + + + +### System configuration + +**Rails version**: + +**Ruby version**: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000..3ba13e0cec6cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/issue_template.md b/.github/issue_template.md deleted file mode 100644 index 2d071d4a716f1..0000000000000 --- a/.github/issue_template.md +++ /dev/null @@ -1,15 +0,0 @@ -### Steps to reproduce - -(Guidelines for creating a bug report are [available -here](http://guides.rubyonrails.org/contributing_to_ruby_on_rails.html#creating-a-bug-report)) - -### Expected behavior -Tell us what should happen - -### Actual behavior -Tell us what happens instead - -### System configuration -**Rails version**: - -**Ruby version**: 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/pull_request_template.md b/.github/pull_request_template.md index 214d63740c199..fd53fc45a39de 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,21 +1,45 @@ -### Summary + + +### Motivation / Background + + + +This Pull Request has been created because [REPLACE ME] + +### Detail + +This Pull Request changes [REPLACE ME] + +### Additional information + + + +### Checklist + +Before submitting the PR make sure the following are checked: + +* [ ] This Pull Request is related to one change. Unrelated changes should be opened in separate PRs. +* [ ] Commit message has a detailed description of what changed and why. If this PR fixes a related issue include it in the commit message. Ex: `[Fix #issue-number]` +* [ ] Tests are added or updated if you fix a bug or add a feature. +* [ ] CHANGELOG files are updated for the changed libraries if there is a behavior change or additional feature. Minor bug fixes and documentation changes should not be included. diff --git a/.github/security.md b/.github/security.md new file mode 100644 index 0000000000000..dc32fb3a0da2e --- /dev/null +++ b/.github/security.md @@ -0,0 +1,12 @@ +# Security Policy + +## Reporting a Vulnerability + +**Do not open up a GitHub issue if the bug is a security vulnerability in Rails**. +Instead, refer to our [security policy](https://rubyonrails.org/security). + +## Supported Versions + +Security backports are provided for some previous release series. For details +of which release series are currently receiving security backports see our +[security policy](https://rubyonrails.org/security). diff --git a/.github/workflows/devcontainer-shellcheck.yml b/.github/workflows/devcontainer-shellcheck.yml new file mode 100644 index 0000000000000..4bc4cb5faad49 --- /dev/null +++ b/.github/workflows/devcontainer-shellcheck.yml @@ -0,0 +1,24 @@ +name: Devcontainer Shellcheck + +on: + pull_request: + paths: + - ".devcontainer/**/*.sh" + push: + paths: + - ".devcontainer/**/*.sh" + +permissions: + contents: read + +jobs: + devcontainer_shellcheck: + name: Devcontainer Shellcheck + runs-on: ubuntu-latest + steps: + - name: Checkout (GitHub) + uses: actions/checkout@v6 + + - name: Lint Devcontainer Scripts + run: | + find .devcontainer/ -name '*.sh' -print0 | xargs -0 shellcheck diff --git a/.github/workflows/devcontainer-smoke-test.yml b/.github/workflows/devcontainer-smoke-test.yml new file mode 100644 index 0000000000000..dd02a7daaa29e --- /dev/null +++ b/.github/workflows/devcontainer-smoke-test.yml @@ -0,0 +1,99 @@ +name: Devcontainer smoke test + +on: push + +jobs: + build: + name: Devcontainer smoke test + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + + steps: + - name: Checkout (GitHub) + uses: actions/checkout@v6 + + - name: Login to GitHub Container Registry + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler-cache: true + + - name: Generate rails app sqlite3 + run: bundle exec railties/exe/rails new myapp_sqlite --database="sqlite3" --dev --devcontainer + + - name: Test devcontainer sqlite3 + uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.1900000417 + with: + subFolder: myapp_sqlite + imageName: ghcr.io/rails/smoke-test-devcontainer + cacheFrom: ghcr.io/rails/smoke-test-devcontainer + imageTag: sqlite3 + push: ${{ github.repository == 'rails/rails' && 'filter' || 'never' }} + refFilterForPush: refs/heads/main + runCmd: bin/rails g scaffold Post && bin/rails db:migrate && bin/rails test + + - name: Stop all containers + run: docker ps -q | xargs docker stop + + - name: Generate rails app postgresql + run: bundle exec railties/exe/rails new myapp_postgresql --database="postgresql" --dev --devcontainer + + - name: Test devcontainer postgresql + uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.1900000417 + with: + subFolder: myapp_postgresql + imageName: ghcr.io/rails/smoke-test-devcontainer + cacheFrom: ghcr.io/rails/smoke-test-devcontainer + imageTag: postgresql + push: ${{ github.repository == 'rails/rails' && 'filter' || 'never' }} + refFilterForPush: refs/heads/main + runCmd: bin/rails g scaffold Post && bin/rails db:migrate && bin/rails test && bin/rails test:system + + - name: Stop all containers + run: docker ps -q | xargs docker stop + + - name: Generate rails app mysql + run: bundle exec railties/exe/rails new myapp_mysql --database="mysql" --dev --devcontainer + + - name: Test devcontainer mysql + uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.1900000417 + with: + subFolder: myapp_mysql + imageName: ghcr.io/rails/smoke-test-devcontainer + cacheFrom: ghcr.io/rails/smoke-test-devcontainer + imageTag: mysql + push: ${{ github.repository == 'rails/rails' && 'filter' || 'never' }} + refFilterForPush: refs/heads/main + runCmd: bin/rails g scaffold Post && bin/rails db:migrate && bin/rails test + + - name: Stop all containers + run: docker ps -q | xargs docker stop + + - name: Generate rails app trilogy + run: bundle exec railties/exe/rails new myapp_trilogy --database="trilogy" --dev --devcontainer + + - name: Test devcontainer trilogy + uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 # v0.3.1900000417 + with: + subFolder: myapp_trilogy + imageName: ghcr.io/rails/smoke-test-devcontainer + cacheFrom: ghcr.io/rails/smoke-test-devcontainer + imageTag: trilogy + push: ${{ github.repository == 'rails/rails' && 'filter' || 'never' }} + refFilterForPush: refs/heads/main + runCmd: bin/rails g scaffold Post && bin/rails db:migrate && bin/rails test + + - name: Stop all containers + run: docker ps -q | xargs docker stop 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 new file mode 100644 index 0000000000000..5d0bdba3b3c65 --- /dev/null +++ b/.github/workflows/rail_inspector.yml @@ -0,0 +1,27 @@ +name: Rail Inspector + +on: + pull_request: + paths: + - "tools/rail_inspector/**" + push: + paths: + - "tools/rail_inspector/**" + +permissions: + contents: read + +jobs: + rail_inspector: + name: rail_inspector tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Remove Gemfile.lock + run: rm -f Gemfile.lock + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler-cache: true + - run: cd tools/rail_inspector && bundle exec rake diff --git a/.github/workflows/rails-new-docker.yml b/.github/workflows/rails-new-docker.yml new file mode 100644 index 0000000000000..a85765bac999b --- /dev/null +++ b/.github/workflows/rails-new-docker.yml @@ -0,0 +1,50 @@ +name: rails-new-docker + +on: [push, pull_request] + +permissions: + contents: read + +env: + APP_NAME: devrails + APP_PATH: dev/devrails + BUNDLE_WITHOUT: db:job:cable:storage + +jobs: + rails-new-docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Remove Gemfile.lock + run: rm -f Gemfile.lock + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler-cache: true + - name: Generate --dev app + run: | + bundle exec railties/exe/rails new $APP_PATH --dev + - name: Build image + run: | + podman build -t $APP_NAME \ + -v $(pwd):$(pwd) \ + -f ./$APP_PATH/Dockerfile \ + ./$APP_PATH + - 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 \ + -p 3000:3000 $APP_NAME & + - name: Test container + run: ruby -r ./.github/workflows/scripts/test-container.rb + + - uses: zzak/action-discord@4cd181470664aa174b7252e5afb2ecf896001817 # v8 + continue-on-error: true + if: failure() && github.ref_name == 'main' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + webhook: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.github/workflows/rails_releaser_tests.yml b/.github/workflows/rails_releaser_tests.yml new file mode 100644 index 0000000000000..2f291e283c657 --- /dev/null +++ b/.github/workflows/rails_releaser_tests.yml @@ -0,0 +1,29 @@ +name: Rails releaser tests + +on: + pull_request: + paths: + - "tools/releaser/**" + push: + paths: + - "tools/releaser/**" + +permissions: + contents: read + +jobs: + releaser_tests: + name: releaser tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + - name: Bundle install + run: bundle install + working-directory: tools/releaser + - run: bundle exec rake + working-directory: tools/releaser diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000..89d96fc6ca395 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +on: + release: + types: [published] + +jobs: + release: + permissions: + contents: write + id-token: write + + environment: release + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + - uses: actions/setup-node@v4 + with: + node-version: lts/* + 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 + - name: Run release rake task + run: bundle exec rake push + shell: bash + working-directory: tools/releaser + - name: Wait for release to propagate + run: gem exec rubygems-await pkg/*.gem + shell: bash diff --git a/.github/workflows/scripts/test-container.rb b/.github/workflows/scripts/test-container.rb new file mode 100644 index 0000000000000..1ebecd2aa3c21 --- /dev/null +++ b/.github/workflows/scripts/test-container.rb @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Based on Sam Ruby's system test from dockerfile-rails: +# https://github.com/rubys/dockerfile-rails/pull/21 + +require "net/http" +require "socket" + +LOCALHOST = Socket.gethostname +PORT = 3000 + +60.times do |i| + sleep 0.5 + begin + response = Net::HTTP.get_response(LOCALHOST, "/up", PORT) + + if %w(404 500).include? response.code + status = response.code.to_i + end + + puts response.body + exit status || 0 + rescue SystemCallError, IOError + puts "#{i}/60 Connection to #{LOCALHOST}:#{PORT} refused or timed out, retrying..." + end +end + +exit 999 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/.gitignore b/.gitignore index 4961ad588f1e8..91ec62d2f9a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,21 @@ -# Don't put *.swp, *.bak, etc here; those belong in a global ~/.gitignore. +# Don't put *.swp, *.bak, etc here; those belong in a global .gitignore. # Check out https://help.github.com/articles/ignoring-files for how to set that up. .Gemfile .ruby-version -.byebug_history -debug.log -pkg +/*/doc/ +/*/test/tmp/ /.bundle -/dist -/doc/rdoc -/*/doc -/*/test/tmp -/activerecord/sqlnet.log -/activemodel/test/fixtures/fixture_database.sqlite3 -/activesupport/test/fixtures/isolation_test -/railties/test/500.html -/railties/test/fixtures/tmp -/railties/test/initializer/root/log -/railties/doc -/railties/tmp -/guides/output +/dist/ +/doc/ +/guides/output/ +/preview/ +preview.tar.gz +Brewfile.lock.json +debug.log* +node_modules/ +package-lock.json +pkg/ +/tmp/ +/yarn-error.log +/test-reports/ diff --git a/.mdlrc b/.mdlrc new file mode 100644 index 0000000000000..4e92d0f58cb91 --- /dev/null +++ b/.mdlrc @@ -0,0 +1 @@ +style "#{File.dirname(__FILE__)}/.mdlrc.rb" \ No newline at end of file diff --git a/.mdlrc.rb b/.mdlrc.rb new file mode 100644 index 0000000000000..2788e6a1ffcbd --- /dev/null +++ b/.mdlrc.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +all + +exclude_rule "MD003" +exclude_rule "MD004" +exclude_rule "MD005" +exclude_rule "MD006" +exclude_rule "MD007" +exclude_rule "MD012" +exclude_rule "MD014" +exclude_rule "MD024" +exclude_rule "MD026" +exclude_rule "MD032" +exclude_rule "MD033" +exclude_rule "MD034" +exclude_rule "MD036" +exclude_rule "MD040" +exclude_rule "MD041" + +rule "MD013", line_length: 2000, ignore_code_blocks: true +# rule "MD024", allow_different_nesting: true # This did not work as intended, see action_cable_overview.md +rule "MD029", style: :ordered +# rule "MD046", style: :consistent # default (:fenced) diff --git a/.rubocop.yml b/.rubocop.yml index 0d1d0c36ce87f..a1bae07239aa7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,95 +1,197 @@ +plugins: + - rubocop-minitest + - rubocop-packaging + - rubocop-performance + - rubocop-rails + - rubocop-md + AllCops: - TargetRubyVersion: 2.2 # 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. DisabledByDefault: true + SuggestExtensions: false Exclude: + - '**/tmp/**/*' - '**/templates/**/*' - '**/vendor/**/*' - - 'actionpack/lib/action_dispatch/journey/parser.rb' + - 'actionmailbox/test/dummy/**/*' + - 'activestorage/test/dummy/**/*' + - 'actiontext/test/dummy/**/*' + - 'tools/rail_inspector/test/fixtures/*' + - guides/source/debugging_rails_applications.md + - guides/source/active_support_instrumentation.md + - '**/node_modules/**/*' + - '**/CHANGELOG.md' + - '**/2_*_release_notes.md' + - '**/3_*_release_notes.md' + - '**/4_*_release_notes.md' + - '**/5_*_release_notes.md' + - '**/6_*_release_notes.md' + + +Performance: + Exclude: + - '**/test/**/*' + +# Prefer assert_not over assert ! +Rails/AssertNot: + Include: + - '**/test/**/*' + +# Prefer assert_not_x over refute_x +Rails/RefuteMethods: + Include: + - '**/test/**/*' + +Rails/IndexBy: + Enabled: true + +Rails/IndexWith: + Enabled: true # Prefer &&/|| over and/or. Style/AndOr: Enabled: true -# Do not use braces for hash literals when they are the last argument of a -# method call. -Style/BracesAroundHashParameters: +Layout/ClosingHeredocIndentation: Enabled: true -# Align `when` with `case`. -Style/CaseIndentation: +Layout/ClosingParenthesisIndentation: Enabled: true # Align comments with method definitions. -Style/CommentIndentation: +Layout/CommentIndentation: + Enabled: true + +Layout/DefEndAlignment: + Enabled: true + +Layout/ElseAlignment: + Enabled: true + +# Align `end` with the matching keyword or starting expression except for +# assignments, where it should be aligned with the LHS. +Layout/EndAlignment: + Enabled: true + EnforcedStyleAlignWith: variable + AutoCorrect: true + +Layout/EndOfLine: Enabled: true -# No extra empty lines. -Style/EmptyLines: +Layout/EmptyLineAfterMagicComment: + Enabled: true + +Layout/EmptyLinesAroundAccessModifier: + Enabled: true + EnforcedStyle: only_before + +Layout/EmptyLinesAroundBlockBody: Enabled: true # In a regular class definition, no empty lines around the body. -Style/EmptyLinesAroundClassBody: +Layout/EmptyLinesAroundClassBody: Enabled: true # In a regular method definition, no empty lines around the body. -Style/EmptyLinesAroundMethodBody: +Layout/EmptyLinesAroundMethodBody: Enabled: true # In a regular module definition, no empty lines around the body. -Style/EmptyLinesAroundModuleBody: +Layout/EmptyLinesAroundModuleBody: Enabled: true # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. Style/HashSyntax: Enabled: true + EnforcedShorthandSyntax: either # Method definitions after `private` or `protected` isolated calls need one # extra level of indentation. -Style/IndentationConsistency: +Layout/IndentationConsistency: Enabled: true - EnforcedStyle: rails + EnforcedStyle: indented_internal_methods + Exclude: + - '**/*.md' # Two spaces, no tabs (for indentation). -Style/IndentationWidth: +Layout/IndentationWidth: Enabled: true -Style/SpaceAfterColon: +Layout/LeadingCommentSpace: Enabled: true -Style/SpaceAfterComma: +Layout/SpaceAfterColon: Enabled: true -Style/SpaceAroundEqualsInParameterDefault: +Layout/SpaceAfterComma: Enabled: true -Style/SpaceAroundKeyword: +Layout/SpaceAfterSemicolon: Enabled: true -Style/SpaceAroundOperators: +Layout/SpaceAroundEqualsInParameterDefault: Enabled: true -Style/SpaceBeforeFirstArg: - Enabled: true +Layout/SpaceAroundKeyword: + Enabled: true + +Layout/SpaceAroundOperators: + Enabled: true + +Layout/SpaceBeforeComma: + Enabled: true + +Layout/SpaceBeforeComment: + Enabled: true + +Layout/SpaceBeforeFirstArg: + Enabled: true + +Style/DefWithParentheses: + Enabled: true # Defining a method with parameters needs parentheses. Style/MethodDefParentheses: Enabled: true +Style/ExplicitBlockArgument: + Enabled: true + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + Exclude: + - 'actionview/test/**/*.builder' + - 'actionview/test/**/*.ruby' + - 'actionpack/test/**/*.builder' + - 'actionpack/test/**/*.ruby' + - 'activestorage/db/migrate/**/*.rb' + - 'activestorage/db/update_migrate/**/*.rb' + - 'actionmailbox/db/migrate/**/*.rb' + - 'actiontext/db/migrate/**/*.rb' + - '**/*.md' + +Style/MapToHash: + Enabled: true + +Style/RedundantFreeze: + Enabled: true + # Use `foo {}` not `foo{}`. -Style/SpaceBeforeBlockBraces: +Layout/SpaceBeforeBlockBraces: Enabled: true # Use `foo { bar }` not `foo {bar}`. -Style/SpaceInsideBlockBraces: +Layout/SpaceInsideBlockBraces: Enabled: true + EnforcedStyleForEmptyBraces: space # Use `{ a: 1 }` not `{a:1}`. -Style/SpaceInsideHashLiteralBraces: +Layout/SpaceInsideHashLiteralBraces: Enabled: true -Style/SpaceInsideParens: +Layout/SpaceInsideParens: Enabled: true # Check quotes usage according to lint rule below. @@ -98,27 +200,211 @@ Style/StringLiterals: EnforcedStyle: double_quotes # Detect hard tabs, no hard tabs. -Style/Tab: +Layout/IndentationStyle: Enabled: true -# Blank lines should not have any spaces. -Style/TrailingBlankLines: +# Empty lines should not have any spaces. +Layout/TrailingEmptyLines: Enabled: true # No trailing whitespace. -Style/TrailingWhitespace: +Layout/TrailingWhitespace: Enabled: true # Use quotes for string literals when they are enough. -Style/UnneededPercentQ: +Style/RedundantPercentQ: Enabled: true -# Align `end` with the matching keyword or starting expression except for -# assignments, where it should be aligned with the LHS. -Lint/EndAlignment: +Lint/NestedMethodDefinition: + Enabled: true + +Lint/AmbiguousOperator: + Enabled: true + +Lint/AmbiguousRegexpLiteral: + Enabled: true + +Lint/Debugger: + Enabled: true + DebuggerRequires: + - debug + +Lint/DuplicateRequire: + Enabled: true + +Lint/DuplicateMagicComment: + Enabled: true + +Lint/DuplicateMethods: + Enabled: true + +Lint/ErbNewArguments: + Enabled: true + +Lint/EnsureReturn: + Enabled: true + +Lint/MissingCopEnableDirective: Enabled: true - EnforcedStyleAlignWith: variable # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. Lint/RequireParentheses: Enabled: true + +Lint/RedundantCopDisableDirective: + Enabled: true + +Lint/RedundantCopEnableDirective: + Enabled: true + +Lint/RedundantRequireStatement: + Enabled: true + +Lint/RedundantStringCoercion: + Enabled: true + +Lint/RedundantSafeNavigation: + Enabled: true + +Lint/UriEscapeUnescape: + Enabled: true + +Lint/UselessAssignment: + Enabled: true + +Lint/DeprecatedClassMethods: + Enabled: true + +Lint/InterpolationCheck: + Enabled: true + Exclude: + - '**/test/**/*' + +Lint/SafeNavigationChain: + Enabled: true + +Style/EvalWithLocation: + Enabled: true + Exclude: + - '**/test/**/*' + +Style/ParenthesesAroundCondition: + Enabled: true + +Style/HashTransformKeys: + Enabled: true + +Style/HashTransformValues: + Enabled: true + +Style/RedundantBegin: + Enabled: true + +Style/RedundantReturn: + Enabled: true + AllowMultipleReturnValues: true + +Style/RedundantRegexpEscape: + Enabled: true + +Style/Semicolon: + Enabled: true + AllowAsExpressionSeparator: true + +# Prefer Foo.method over Foo::method +Style/ColonMethodCall: + Enabled: true + +Style/TrivialAccessors: + Enabled: true + +# Prefer a = b || c over a = b ? b : c +Style/RedundantCondition: + Enabled: true + +Style/RedundantDoubleSplatHashBraces: + Enabled: true + +Style/OpenStructUse: + Enabled: true + +Style/ArrayIntersect: + Enabled: true + +Style/KeywordArgumentsMerging: + Enabled: true + +Performance/BindCall: + Enabled: true + +Performance/FlatMap: + Enabled: true + +Performance/MapCompact: + Enabled: true + +Performance/SelectMap: + Enabled: true + +Performance/RedundantMerge: + Enabled: true + +Performance/StartWith: + Enabled: true + +Performance/EndWith: + Enabled: true + +Performance/RegexpMatch: + Enabled: true + +Performance/ReverseEach: + Enabled: true + +Performance/StringReplacement: + Enabled: true + +Performance/DeletePrefix: + Enabled: true + +Performance/DeleteSuffix: + Enabled: true + +Performance/InefficientHashSearch: + Enabled: true + +Performance/ConstantRegexp: + Enabled: true + +Performance/RedundantStringChars: + Enabled: true + +Performance/StringInclude: + Enabled: true + +Minitest/AssertNil: + Enabled: true + +Minitest/AssertRaisesWithRegexpArgument: + Enabled: true + +Minitest/AssertWithExpectedArgument: + Enabled: true + +Minitest/LiteralAsActualArgument: + Enabled: true + +Minitest/NonExecutableTestMethod: + Enabled: true + +Minitest/SkipEnsure: + Enabled: true + +Minitest/UnreachableAssertion: + Enabled: true + +Markdown: + # Whether to run RuboCop against non-valid snippets + WarnInvalid: true + # Whether to lint codeblocks without code attributes + Autodetect: false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index eafa06e44f14b..0000000000000 --- a/.travis.yml +++ /dev/null @@ -1,112 +0,0 @@ -language: ruby -sudo: false - -cache: - bundler: true - directories: - - /tmp/cache/unicode_conformance - - /tmp/beanstalkd-1.10 - -services: - - memcached - - redis - -addons: - postgresql: "9.4" - -bundler_args: --without test --jobs 3 --retry 3 -before_install: - - "rm ${BUNDLE_GEMFILE}.lock" - - "gem update --system" - - "gem update bundler" - - "[ -f /tmp/beanstalkd-1.10/Makefile ] || (curl -L https://github.com/kr/beanstalkd/archive/v1.10.tar.gz | tar xz -C /tmp)" - - "pushd /tmp/beanstalkd-1.10 && make && (./beanstalkd &); popd" - -before_script: - # Set Sauce Labs username and access key. Obfuscated, purposefully not encrypted. - # Decodes to e.g. `export VARIABLE=VALUE` - - $(base64 --decode <<< "ZXhwb3J0IFNBVUNFX0FDQ0VTU19LRVk9YTAzNTM0M2YtZTkyMi00MGIzLWFhM2MtMDZiM2VhNjM1YzQ4") - - $(base64 --decode <<< "ZXhwb3J0IFNBVUNFX1VTRVJOQU1FPXJ1YnlvbnJhaWxz") - -script: 'ci/travis.rb' - -env: - global: - - "JRUBY_OPTS='--dev -J-Xmx1024M'" - matrix: - - "GEM=railties" - - "GEM=ap,ac" - - "GEM=am,amo,as,av,aj" - - "GEM=as PRESERVE_TIMEZONES=1" - - "GEM=ar:mysql2" - - "GEM=ar:sqlite3" - - "GEM=ar:postgresql" - - "GEM=guides" - - "GEM=ac:integration" - -rvm: - - 2.2.6 - - 2.3.3 - - 2.4.0 - - ruby-head - -matrix: - include: - - rvm: 2.2.6 - env: "GEM=aj:integration" - services: - - memcached - - redis - - rabbitmq - - rvm: 2.3.3 - env: "GEM=aj:integration" - services: - - memcached - - redis - - rabbitmq - - rvm: 2.4.0 - env: "GEM=aj:integration" - services: - - memcached - - redis - - rabbitmq - - rvm: ruby-head - env: "GEM=aj:integration" - services: - - memcached - - redis - - rabbitmq - - rvm: 2.3.3 - env: - - "GEM=ar:mysql2 MYSQL=mariadb" - addons: - mariadb: 10.0 - - rvm: 2.4.0 - env: - - "GEM=ar:sqlite3_mem" - - rvm: jruby-9.1.7.0 - jdk: oraclejdk8 - env: - - "GEM=ap" - - rvm: jruby-9.1.7.0 - jdk: oraclejdk8 - env: - - "GEM=am,aj" - allow_failures: - - rvm: ruby-head - - rvm: jruby-9.1.7.0 - - env: "GEM=ac:integration" - fast_finish: true - -notifications: - email: false - irc: - on_success: change - on_failure: always - channels: - - "irc.freenode.org#rails-contrib" - campfire: - on_success: change - on_failure: always - rooms: - - secure: "YA1alef1ESHWGFNVwvmVGCkMe4cUy4j+UcNvMUESraceiAfVyRMAovlQBGs6\n9kBRm7DHYBUXYC2ABQoJbQRLDr/1B5JPf/M8+Qd7BKu8tcDC03U01SMHFLpO\naOs/HLXcDxtnnpL07tGVsm0zhMc5N8tq4/L3SHxK7Vi+TacwQzI=" diff --git a/.yardopts b/.yardopts index 25ec38658f55c..27130107a5773 100644 --- a/.yardopts +++ b/.yardopts @@ -1,4 +1,5 @@ --exclude /templates/ --quiet act*/lib/**/*.rb +act*/app/**/*.rb railties/lib/**/*.rb diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000000000..6be74e145d786 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,2 @@ +workspaces-experimental true +--add.prefer-offline true \ No newline at end of file diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000000000..f767e801d1f1b --- /dev/null +++ b/Brewfile @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +brew "ffmpeg" +brew "memcached" +brew "mysql" +brew "postgresql@16" +brew "libpq" +brew "redis" +brew "yarn" +cask "xquartz" +brew "mupdf" +brew "poppler" +brew "imagemagick" +brew "vips" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 078d5f121901f..d3c1555f706fa 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -4,9 +4,8 @@ The Rails team is committed to fostering a welcoming community. **Our Code of Conduct can be found here**: -http://rubyonrails.org/conduct/ +https://rubyonrails.org/conduct For a history of updates, see the page history here: -https://github.com/rails/rails.github.com/commits/master/conduct/index.html - +https://github.com/rails/website/commits/main/_pages/conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6ebef7e898f9..cd339c4e34d53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,17 +1,31 @@ +[![Build Status](https://badge.buildkite.com/ab1152b6a1f6a61d3ea4ec5b3eece8d4c2b830998459c75352.svg?branch=main)](https://buildkite.com/rails/rails) +[![Code Triage Badge](https://www.codetriage.com/rails/rails/badges/users.svg)](https://www.codetriage.com/rails/rails) +[![Version](https://img.shields.io/gem/v/rails)](https://rubygems.org/gems/rails) +[![License](https://img.shields.io/github/license/rails/rails)](https://github.com/rails/rails) + ## How to contribute to Ruby on Rails #### **Did you find a bug?** +* **Do not open up a GitHub issue if the bug is a security vulnerability + in Rails**, and instead to refer to our [security policy](https://rubyonrails.org/security). + * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/rails/rails/issues). * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/rails/rails/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. * If possible, use the relevant bug report templates to create the issue. Simply copy the content of the appropriate template into a .rb file, make the necessary changes to demonstrate the issue, and **paste the content into the issue description**: - * [**Active Record** (models, database) issues](https://github.com/rails/rails/blob/master/guides/bug_report_templates/active_record_master.rb) - * [**Action Pack** (controllers, routing) issues](https://github.com/rails/rails/blob/master/guides/bug_report_templates/action_controller_master.rb) - * [**Generic template** for other issues](https://github.com/rails/rails/blob/master/guides/bug_report_templates/generic_master.rb) - -* For more detailed information on submitting a bug report and creating an issue, visit our [reporting guidelines](http://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#reporting-an-issue). + * [**Active Record** (models, encryption, database) issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/active_record.rb) + * [**Active Record Migrations** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/active_record_migrations.rb) + * [**Action View** (views, helpers) issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/action_view.rb) + * [**Active Job** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/active_job.rb) + * [**Active Storage** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/active_storage.rb) + * [**Action Mailer** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/action_mailer.rb) + * [**Action Mailbox** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/action_mailbox.rb) + * [**Action Pack** (controllers, routing) issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/action_controller.rb) + * [**Generic template** for other issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/generic.rb) + +* For more detailed information on submitting a bug report and creating an issue, visit our [reporting guidelines](https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#reporting-an-issue). #### **Did you write a patch that fixes a bug?** @@ -19,7 +33,7 @@ * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. -* Before submitting, please read the [Contributing to Ruby on Rails](http://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html) guide to know more about coding conventions and benchmarks. +* Before submitting, please read the [Contributing to Ruby on Rails](https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html) guide to know more about coding conventions and benchmarks. #### **Did you fix whitespace, format code, or make a purely cosmetic patch?** @@ -27,20 +41,21 @@ 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://groups.google.com/forum/?fromgroups#!forum/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. +* We generally reject changes to Active Support core extensions. Those change should be proposed in the [Ruby issue tracker instead](https://bugs.ruby-lang.org/issues), as we don't want to conflict with future versions of Ruby. + #### **Do you have questions about the source code?** -* Ask any question about how to use Ruby on Rails in the [rubyonrails-talk mailing list](https://groups.google.com/forum/?fromgroups#!forum/rubyonrails-talk). +* Ask any question about how to use Ruby on Rails in the [rubyonrails-talk mailing list](https://discuss.rubyonrails.org/c/rubyonrails-talk). #### **Do you want to contribute to the Rails documentation?** -* Please read [Contributing to the Rails Documentation](http://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#contributing-to-the-rails-documentation). +* Please read [Contributing to the Rails Documentation](https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#contributing-to-the-rails-documentation). -
-Ruby on Rails is a volunteer effort. We encourage you to pitch in and [join the team](http://contributors.rubyonrails.org)! +Ruby on Rails is a volunteer effort. We encourage you to pitch in and join [the team](https://contributors.rubyonrails.org)! Thanks! :heart: :heart: :heart: diff --git a/Gemfile b/Gemfile index 9f40bae83f079..6fefb2c143557 100644 --- a/Gemfile +++ b/Gemfile @@ -1,153 +1,165 @@ -source "https://rubygems.org" - -git_source(:github) do |repo_name| - repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/") - "https://github.com/#{repo_name}.git" -end +# frozen_string_literal: true +source "https://rubygems.org" gemspec -gem "arel", github: "rails/arel" +gem "minitest" # We need a newish Rake since Active Job sets its test tasks' descriptions. -gem "rake", ">= 11.1" +gem "rake", ">= 13" -# This needs to be with require false to ensure correct loading order, as it has to -# be loaded after loading the test library. -gem "mocha", "~> 0.14", require: false +gem "releaser", path: "tools/releaser" -gem "rack-cache", "~> 1.2" -gem "jquery-rails" -gem "coffee-rails" -gem "sass-rails" -gem "turbolinks", "~> 5" +gem "sprockets-rails", ">= 2.0.0", require: false +gem "propshaft", ">= 0.1.7", "!= 1.0.1" +gem "capybara", ">= 3.39" +gem "selenium-webdriver", ">= 4.20.0" -# require: false so bcrypt is loaded only when has_secure_password is used. +gem "rack-cache", "~> 1.2" +gem "stimulus-rails" +gem "turbo-rails" +gem "jsbundling-rails" +gem "cssbundling-rails" +gem "importmap-rails", ">= 1.2.3" +gem "tailwindcss-rails" +gem "dartsass-rails" +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" +gem "kamal", ">= 2.1.0", require: false +gem "thruster", require: false +# 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. -gem "uglifier", ">= 1.3.0", require: false - -# FIXME: Remove this fork after https://github.com/nex3/rb-inotify/pull/49 is fixed. -gem "rb-inotify", github: "matthewd/rb-inotify", branch: "close-handling", require: false +gem "terser", ">= 1.1.4", require: false # Explicitly avoid 1.x that doesn't support Ruby 2.4+ -gem "json", ">= 2.0.0" +gem "json", ">= 2.0.0", "!=2.7.0" -gem "rubocop", ">= 0.47", require: false +# Workaround until all supported Ruby versions ship with uri version 0.13.1 or higher. +gem "uri", ">= 0.13.1", require: false + +gem "prism" + +group :rubocop do + gem "rubocop", "1.79.2", require: false + gem "rubocop-minitest", require: false + gem "rubocop-packaging", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-md", require: false + + # This gem is used in Railties tests so it must be a development dependency. + gem "rubocop-rails-omakase", require: false +end + +group :mdl do + gem "mdl", "!= 0.13.0", require: false +end group :doc do - gem "sdoc", "1.0.0.rc1" - gem "redcarpet", "~> 3.2.3", platforms: :ruby - gem "w3c_validators" - gem "kindlerb", "~> 1.2.0" + gem "sdoc", "~> 2.6.4" + gem "redcarpet", "~> 3.6.1", platforms: :ruby + gem "w3c_validators", "~> 1.3.6" + gem "rouge" + gem "rubyzip", "~> 2.0" end -# Active Support. -gem "dalli", ">= 2.2.1" -gem "listen", ">= 3.0.5", "< 3.2", require: false +# Active Support +gem "dalli", ">= 3.0.1" +gem "listen", "~> 3.3", require: false gem "libxml-ruby", platforms: :ruby +gem "connection_pool", require: false +gem "rexml", require: false +gem "msgpack", ">= 1.7.0", require: false + +# for railties +gem "bootsnap", ">= 1.4.4", require: false +gem "webrick", require: false +gem "jbuilder", require: false +gem "web-console", require: false + +# Action Pack and railties +rack_version = ENV.fetch("RACK", "~> 3.0") +if rack_version != "head" + gem "rack", rack_version +else + gem "rack", git: "https://github.com/rack/rack.git", branch: "main" +end -# Action View. For testing Erubis handler deprecation. -gem "erubis", "~> 2.7.0", require: false +gem "useragent", require: false -# Active Job. +# Active Job group :job do gem "resque", require: false gem "resque-scheduler", require: false gem "sidekiq", require: false - gem "sucker_punch", require: false - gem "delayed_job", require: false - gem "queue_classic", github: "QueueClassic/queue_classic", branch: "master", require: false, platforms: :ruby + gem "queue_classic", ">= 4.0.0", require: false, platforms: :ruby gem "sneakers", require: false - gem "que", require: false gem "backburner", require: false - #TODO: add qu after it support Rails 5.1 - # gem 'qu-rails', github: "bkeepers/qu", branch: "master", require: false - gem "qu-redis", require: false - gem "delayed_job_active_record", require: false - gem "sequel", require: false end # Action Cable group :cable do - gem "puma", require: false + gem "puma", ">= 5.0.3", require: false + + gem "redis", ">= 4.0.1", require: false - gem "em-hiredis", require: false - gem "hiredis", require: false - gem "redis", require: false + gem "redis-namespace" + + gem "websocket-client-simple", require: false +end - gem "websocket-client-simple", github: "matthewd/websocket-client-simple", branch: "close-race", require: false +# Active Storage +group :storage do + gem "aws-sdk-s3", require: false + gem "google-cloud-storage", "~> 1.11", require: false - gem "blade", require: false, platforms: [:ruby] - gem "blade-sauce_labs_plugin", require: false, platforms: [:ruby] + gem "image_processing", "~> 1.2" end +# Action Mailbox +gem "aws-sdk-sns", require: false +gem "webmock" + # Add your own local bundler stuff. -local_gemfile = File.dirname(__FILE__) + "/.Gemfile" +local_gemfile = File.expand_path(".Gemfile", __dir__) instance_eval File.read local_gemfile if File.exist? local_gemfile group :test do - # FIX: Our test suite isn't ready to run in random order yet. - gem "minitest", "< 5.3.4" + gem "minitest-bisect", require: false + gem "minitest-ci", require: false + gem "minitest-retry" platforms :mri do gem "stackprof" - gem "byebug" + gem "debug", ">= 1.1.0", require: false end - gem "benchmark-ips" + # Needed for Railties tests because it is included in generated apps. + gem "brakeman" + gem "bundler-audit" end -platforms :ruby, :mswin, :mswin64, :mingw, :x64_mingw do - gem "nokogiri", ">= 1.6.8" - - # Needed for compiling the ActionDispatch::Journey parser. - gem "racc", ">=1.4.6", require: false +platforms :ruby, :windows do + gem "nokogiri", ">= 1.8.1", "!= 1.11.0" # Active Record. - gem "sqlite3", "~> 1.3.6" + gem "sqlite3", ">= 2.1" group :db do - gem "pg", ">= 0.18.0" - gem "mysql2", ">= 0.4.4" + gem "pg", "~> 1.3" + gem "mysql2", "~> 0.5", "< 0.5.7" + gem "trilogy", ">= 2.7.0" end end -platforms :jruby do - if ENV["AR_JDBC"] - gem "activerecord-jdbcsqlite3-adapter", github: "jruby/activerecord-jdbc-adapter", branch: "master" - group :db do - gem "activerecord-jdbcmysql-adapter", github: "jruby/activerecord-jdbc-adapter", branch: "master" - gem "activerecord-jdbcpostgresql-adapter", github: "jruby/activerecord-jdbc-adapter", branch: "master" - end - else - gem "activerecord-jdbcsqlite3-adapter", ">= 1.3.0" - group :db do - gem "activerecord-jdbcmysql-adapter", ">= 1.3.0" - gem "activerecord-jdbcpostgresql-adapter", ">= 1.3.0" - end - end -end - -platforms :rbx do - # The rubysl-yaml gem doesn't ship with Psych by default as it needs - # libyaml that isn't always available. - gem "psych", "~> 2.0" -end - -# Gems that are necessary for Active Record tests with Oracle. -if ENV["ORACLE_ENHANCED"] - platforms :ruby do - gem "ruby-oci8", "~> 2.2" - end - gem "activerecord-oracle_enhanced-adapter", github: "rsim/oracle-enhanced", branch: "master" -end +gem "tzinfo-data", platforms: [:windows, :jruby] +gem "wdm", ">= 0.1.0", platforms: [:windows] -# A gem necessary for Active Record tests with IBM DB. -gem "ibm_db" if ENV["IBM_DB"] -gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby] -gem "wdm", ">= 0.1.0", platforms: [:mingw, :mswin, :x64_mingw, :mswin64] +gem "launchy" diff --git a/Gemfile.lock b/Gemfile.lock index c40730a33df2f..0b2bfe5987e7d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,424 +1,790 @@ -GIT - remote: https://github.com/QueueClassic/queue_classic.git - revision: 51d56ca6fa2fdf1eeffdffd702ae1cc0940b5156 - branch: master - specs: - queue_classic (3.2.0.RC1) - pg (>= 0.17, < 0.20) - -GIT - remote: https://github.com/matthewd/rb-inotify.git - revision: 90553518d1fb79aedc98a3036c59bd2b6731ac40 - branch: close-handling - specs: - rb-inotify (0.9.7) - ffi (>= 0.5.0) - -GIT - remote: https://github.com/matthewd/websocket-client-simple.git - revision: e161305f1a466b9398d86df3b1731b03362da91b - branch: close-race - specs: - websocket-client-simple (0.3.0) - event_emitter - websocket - -GIT - remote: https://github.com/rails/arel.git - revision: ab109d3bf1c773da5e78ddc93bb6b55aebbb1c2a - specs: - arel (8.0.0) - PATH remote: . specs: - actioncable (5.1.0.alpha) - actionpack (= 5.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) - actionmailer (5.1.0.alpha) - actionpack (= 5.1.0.alpha) - actionview (= 5.1.0.alpha) - activejob (= 5.1.0.alpha) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (5.1.0.alpha) - actionview (= 5.1.0.alpha) - activesupport (= 5.1.0.alpha) - rack (~> 2.0) - rack-test (~> 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.1.0.alpha) - activesupport (= 5.1.0.alpha) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + 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.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.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) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + 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.2.0.alpha) + activesupport (= 8.2.0.alpha) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.3) - activejob (5.1.0.alpha) - activesupport (= 5.1.0.alpha) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.2.0.alpha) + activesupport (= 8.2.0.alpha) globalid (>= 0.3.6) - activemodel (5.1.0.alpha) - activesupport (= 5.1.0.alpha) - activerecord (5.1.0.alpha) - activemodel (= 5.1.0.alpha) - activesupport (= 5.1.0.alpha) - arel (~> 8.0) - activesupport (5.1.0.alpha) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (~> 0.7) - minitest (~> 5.1) - tzinfo (~> 1.1) - rails (5.1.0.alpha) - actioncable (= 5.1.0.alpha) - actionmailer (= 5.1.0.alpha) - actionpack (= 5.1.0.alpha) - actionview (= 5.1.0.alpha) - activejob (= 5.1.0.alpha) - activemodel (= 5.1.0.alpha) - activerecord (= 5.1.0.alpha) - activesupport (= 5.1.0.alpha) - bundler (>= 1.3.0, < 2.0) - railties (= 5.1.0.alpha) - sprockets-rails (>= 2.0.0) - railties (5.1.0.alpha) - actionpack (= 5.1.0.alpha) - activesupport (= 5.1.0.alpha) - method_source - rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) + 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.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.2.0.alpha) + base64 + 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.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.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 + remote: tools/releaser + specs: + releaser (1.0.0) + minitest + rake (~> 13.0) GEM remote: https://rubygems.org/ specs: - addressable (2.5.0) - public_suffix (~> 2.0, >= 2.0.2) - amq-protocol (2.1.0) - ast (2.3.0) - backburner (1.3.1) + 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.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) + logger + aws-sdk-kms (1.110.0) + aws-sdk-core (~> 3, >= 3.228.0) + aws-sigv4 (~> 1.5) + 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.12.1) + aws-eventstream (~> 1, >= 1.0.2) + backburner (1.6.1) beaneater (~> 1.0) - concurrent-ruby (~> 1.0.1) + concurrent-ruby (~> 1.0, >= 1.0.1) dante (> 0.1.5) - bcrypt (3.1.11) - bcrypt (3.1.11-x64-mingw32) - bcrypt (3.1.11-x86-mingw32) - beaneater (1.0.0) - benchmark-ips (2.7.2) - blade (0.7.0) - activesupport (>= 3.0.0) - blade-qunit_adapter (~> 2.0.1) - coffee-script - coffee-script-source - curses (~> 1.0.0) - eventmachine - faye - sprockets (>= 3.0) - thin (>= 1.6.0) - thor (~> 0.19.1) - useragent (~> 0.16.7) - blade-qunit_adapter (2.0.1) - blade-sauce_labs_plugin (0.6.2) - childprocess - faraday - selenium-webdriver - builder (3.2.3) - bunny (2.6.2) - amq-protocol (>= 2.0.1) - byebug (9.0.6) - childprocess (0.5.9) - ffi (~> 1.0, >= 1.0.11) - coffee-rails (4.2.1) - coffee-script (>= 2.2.0) - railties (>= 4.0.0, < 5.2.x) - coffee-script (2.4.1) - coffee-script-source - execjs - coffee-script-source (1.12.2) - concurrent-ruby (1.0.4) - connection_pool (2.2.1) - cookiejar (0.3.3) - curses (1.0.2) - daemons (1.2.4) - dalli (2.7.6) + base64 (0.3.0) + bcrypt (3.1.20) + bcrypt_pbkdf (1.1.1) + beaneater (1.1.3) + 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.3) + bundler (>= 1.2.0) + thor (~> 1.0) + bunny (2.23.0) + amq-protocol (~> 2.3, >= 2.3.1) + sorted_set (~> 1, >= 1.0.2) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + chef-utils (18.6.2) + concurrent-ruby + childprocess (5.1.0) + logger (~> 1.5) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + crack (1.0.0) + bigdecimal + rexml + crass (1.0.6) + cssbundling-rails (1.4.1) + railties (>= 6.0.0) + dalli (3.2.8) dante (0.2.0) - delayed_job (4.1.2) - activesupport (>= 3.0, < 5.1) - delayed_job_active_record (4.1.1) - activerecord (>= 3.0, < 5.1) - delayed_job (>= 3.0, < 5) - em-hiredis (0.3.1) - eventmachine (~> 1.0) - hiredis (~> 0.6.0) - em-http-request (1.1.5) - addressable (>= 2.3.4) - cookiejar (!= 0.3.1) - em-socksify (>= 0.3) - eventmachine (>= 1.0.3) - http_parser.rb (>= 0.6.0) - em-socksify (0.3.1) - eventmachine (>= 1.0.0.beta.4) - erubi (1.4.0) - erubis (2.7.0) - event_emitter (0.2.5) - eventmachine (1.2.1) - eventmachine (1.2.1-x64-mingw32) - eventmachine (1.2.1-x86-mingw32) - execjs (2.7.0) - faraday (0.11.0) - multipart-post (>= 1.2, < 3) - faye (1.2.3) - cookiejar (>= 0.3.0) - em-http-request (>= 0.3.0) - eventmachine (>= 0.12.0) - faye-websocket (>= 0.9.1) - multi_json (>= 1.0.0) - rack (>= 1.0.0) - websocket-driver (>= 0.5.1) - faye-websocket (0.10.5) - eventmachine (>= 0.12.0) - websocket-driver (>= 0.5.1) - ffi (1.9.17) - ffi (1.9.17-x64-mingw32) - ffi (1.9.17-x86-mingw32) - globalid (0.3.7) - activesupport (>= 4.1.0) - hiredis (0.6.1) - http_parser.rb (0.6.0) - i18n (0.8.0) - jquery-rails (4.2.2) - rails-dom-testing (>= 1, < 3) - railties (>= 4.2.0) - thor (>= 0.14, < 2.0) - json (2.0.3) - kindlerb (1.2.0) - mustache - nokogiri - libxml-ruby (2.9.0) - listen (3.1.5) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - ruby_dep (~> 1.2) - loofah (2.0.3) - nokogiri (>= 1.5.9) - mail (2.6.4) - mime-types (>= 1.16, < 4) - metaclass (0.0.4) - method_source (0.8.2) - mime-types (3.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2016.0521) - mini_portile2 (2.1.0) - minitest (5.3.3) - mocha (0.14.0) - metaclass (~> 0.0.1) - mono_logger (1.1.0) - multi_json (1.12.1) - multipart-post (2.0.0) - mustache (1.0.3) - mysql2 (0.4.5) - mysql2 (0.4.5-x64-mingw32) - mysql2 (0.4.5-x86-mingw32) - nio4r (2.0.0) - nokogiri (1.7.0.1) - mini_portile2 (~> 2.1.0) - nokogiri (1.7.0.1-x64-mingw32) - mini_portile2 (~> 2.1.0) - nokogiri (1.7.0.1-x86-mingw32) - mini_portile2 (~> 2.1.0) - parser (2.4.0.0) - ast (~> 2.2) - pg (0.19.0) - pg (0.19.0-x64-mingw32) - pg (0.19.0-x86-mingw32) - powerpack (0.1.1) - psych (2.2.2) - public_suffix (2.0.5) - puma (3.7.0) - qu (0.2.0) - multi_json - qu-redis (0.2.0) - qu (= 0.2.0) - redis-namespace - simple_uuid - que (0.12.0) - racc (1.4.14) - rack (2.0.1) - rack-cache (1.6.1) - rack (>= 0.4) - rack-protection (1.5.3) + dartsass-rails (0.5.1) + railties (>= 6.0.0) + sass-embedded (~> 1.63) + date (3.4.1) + debug (1.10.0) + irb (~> 1.10) + reline (>= 0.3.8) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + dotenv (3.1.7) + drb (2.2.3) + ed25519 (1.3.0) + erb (5.1.1) + erubi (1.13.1) + et-orbi (1.2.11) + tzinfo + event_emitter (0.2.6) + execjs (2.10.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + 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) + globalid (1.2.1) + activesupport (>= 6.1) + google-apis-core (0.15.1) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.22.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.49.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.7.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.2.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.4.0) + google-cloud-storage (1.54.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (~> 0.13) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (~> 0.38) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.1.0) + 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) + google-protobuf (4.29.3-x86_64-linux) + bigdecimal + rake (>= 13) + googleauth (1.12.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + hashdiff (1.1.2) + httpclient (2.9.0) + mutex_m + i18n (1.14.7) + concurrent-ruby (~> 1.0) + 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.1) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.13.0) + actionview (>= 5.0.0) + activesupport (>= 5.0.0) + jmespath (1.6.2) + jsbundling-rails (1.3.1) + railties (>= 6.0.0) + json (2.15.2) + jwt (2.10.1) + base64 + kamal (2.4.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.2) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + kramdown (2.5.1) + rexml (>= 3.3.9) + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + language_server-protocol (3.17.0.3) + launchy (3.0.1) + addressable (~> 2.8) + childprocess (~> 5.0) + 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.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) + matrix (0.4.2) + mdl (0.12.0) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.1) + mixlib-cli (~> 2.1, >= 2.1.1) + mixlib-config (>= 2.2.1, < 4) + mixlib-shellout + mini_magick (5.3.1) + logger + mini_mime (1.1.5) + mini_portile2 (2.8.9) + minitest (5.25.5) + minitest-bisect (1.7.0) + minitest-server (~> 1.0) + path_expander (~> 1.1) + minitest-ci (3.4.0) + minitest (>= 5.0.6) + minitest-retry (0.2.3) + minitest (>= 5.0) + minitest-server (1.0.9) + drb (~> 2.0) + minitest (> 5.16) + mixlib-cli (2.1.8) + mixlib-config (3.0.27) + tomlrb + mixlib-shellout (3.3.4) + chef-utils + mono_logger (1.1.2) + msgpack (1.7.5) + multi_json (1.15.0) + multipart-post (2.4.1) + mustermann (3.0.4) + ruby2_keywords (~> 0.0.1) + mutex_m (0.3.0) + mysql2 (0.5.6) + net-imap (0.5.5) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-scp (4.0.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.0) + nio4r (2.7.4) + nokogiri (1.18.10) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + 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.9.0) + ast (~> 2.4.1) + racc + path_expander (1.1.3) + 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 - rack-test (0.6.3) - rack (>= 1.0) - rails-dom-testing (2.0.2) - activesupport (>= 4.2.0, < 6.0) - nokogiri (~> 1.6) - rails-html-sanitizer (1.0.3) - loofah (~> 2.0) - rainbow (2.2.1) - rake (12.0.0) - rb-fsevent (0.9.8) - rdoc (5.0.0) - redcarpet (3.2.3) - redis (3.3.2) - redis-namespace (1.5.2) - redis (~> 3.0, >= 3.0.4) - resque (1.27.0) - mono_logger (~> 1.0) + psych (5.2.6) + date + stringio + public_suffix (6.0.1) + puma (6.5.0) + nio4r (~> 2.0) + queue_classic (4.0.0) + pg (>= 1.1, < 2.0) + raabro (1.4.0) + racc (1.8.1) + 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.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.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + 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.3.0) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rbtree (0.4.6) + rdoc (6.15.0) + erb + psych (>= 4.0.0) + tsort + redcarpet (3.6.1) + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.25.2) + connection_pool + redis-namespace (1.11.0) + redis (>= 4) + regexp_parser (2.10.0) + reline (0.6.1) + io-console (~> 0.5) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + resque (2.7.0) + mono_logger (~> 1) multi_json (~> 1.0) - redis-namespace (~> 1.3) + redis-namespace (~> 1.6) sinatra (>= 0.9.2) - vegas (~> 0.1.2) - resque-scheduler (4.3.0) + resque-scheduler (4.11.0) mono_logger (~> 1.0) - redis (~> 3.3) - resque (~> 1.26) - rufus-scheduler (~> 3.2) - rubocop (0.47.1) - parser (>= 2.3.3.1, < 3.0) - powerpack (~> 0.1) - rainbow (>= 1.99.1, < 3.0) + redis (>= 3.3) + resque (>= 1.27) + rufus-scheduler (~> 3.2, != 3.3) + retriable (3.1.2) + rexml (3.4.0) + rouge (4.6.1) + rubocop (1.79.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) - ruby-progressbar (1.8.1) - ruby_dep (1.5.0) - rubyzip (1.2.0) - rufus-scheduler (3.3.2) - tzinfo - sass (3.4.23) - sass-rails (5.0.6) - railties (>= 4.0.0, < 6) - sass (~> 3.1) - sprockets (>= 2.8, < 4.0) - sprockets-rails (>= 2.0, < 4.0) - tilt (>= 1.1, < 3) - sdoc (1.0.0.rc1) - rdoc (= 5.0.0) - selenium-webdriver (3.0.5) - childprocess (~> 0.5) - rubyzip (~> 1.0) + unicode-display_width (>= 2.4.0, < 4.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.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) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.30.3) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails-omakase (1.0.0) + rubocop + rubocop-minitest + rubocop-performance + rubocop-rails + ruby-progressbar (1.13.0) + ruby-vips (2.2.2) + ffi (~> 1.12) + logger + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + rufus-scheduler (3.9.2) + fugit (~> 1.1, >= 1.11.1) + 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.32.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sequel (4.42.1) - serverengine (1.5.11) + serverengine (2.0.7) sigdump (~> 0.2.2) - sidekiq (4.2.9) + 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) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + sinatra (4.1.1) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.1.1) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + sneakers (2.11.0) + bunny (~> 2.12) concurrent-ruby (~> 1.0) - connection_pool (~> 2.2, >= 2.2.0) - rack-protection (>= 1.5.0) - redis (~> 3.2, >= 3.2.1) - sigdump (0.2.4) - simple_uuid (0.4.0) - sinatra (1.0) - rack (>= 1.0) - sneakers (2.4.0) - bunny (~> 2.6) - serverengine (~> 1.5.11) + rake + serverengine (~> 2.0.5) thor - thread (~> 0.1.7) - sprockets (3.7.1) + solid_cable (3.0.5) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.6) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.1.2) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (~> 1.3.1) + sorted_set (1.0.3) + rbtree + set (~> 1.0) + sprockets (4.2.1) concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (3.2.0) - actionpack (>= 4.0) - activesupport (>= 4.0) + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) - sqlite3 (1.3.13) - sqlite3 (1.3.13-x64-mingw32) - sqlite3 (1.3.13-x86-mingw32) - stackprof (0.2.10) - sucker_punch (2.0.2) - concurrent-ruby (~> 1.0.0) - thin (1.7.0) - daemons (~> 1.0, >= 1.0.9) - eventmachine (~> 1.0, >= 1.0.4) - rack (>= 1, < 3) - thor (0.19.4) - thread (0.1.7) - thread_safe (0.3.5) - tilt (2.0.5) - turbolinks (5.0.1) - turbolinks-source (~> 5) - turbolinks-source (5.0.0) - tzinfo (1.2.2) - thread_safe (~> 0.1) - tzinfo-data (1.2016.10) - tzinfo (>= 1.0.0) - uglifier (3.0.4) + 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) + base64 + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stackprof (0.2.27) + stimulus-rails (1.3.4) + railties (>= 6.0.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) - unicode-display_width (1.1.3) - useragent (0.16.8) - vegas (0.1.11) - rack (>= 1.0.0) - w3c_validators (1.3.1) - json (~> 2.0) + thor (1.3.2) + 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) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uber (0.1.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.3) + useragent (0.16.11) + w3c_validators (1.3.7) + json (>= 1.8) nokogiri (~> 1.6) - wdm (0.1.1) - websocket (1.2.3) - websocket-driver (0.6.4) + rexml (~> 3.2) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + webmock (3.25.0) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.9.1) + websocket (1.2.11) + websocket-client-simple (0.9.0) + base64 + event_emitter + mutex_m + websocket + websocket-driver (0.7.7) + base64 websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.2) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.3) PLATFORMS + aarch64-linux + arm64-darwin ruby - x64-mingw32 - x86-mingw32 + x86_64-darwin + x86_64-linux DEPENDENCIES - activerecord-jdbcmysql-adapter (>= 1.3.0) - activerecord-jdbcpostgresql-adapter (>= 1.3.0) - activerecord-jdbcsqlite3-adapter (>= 1.3.0) - arel! + argon2 (~> 2.3.2) + aws-sdk-s3 + aws-sdk-sns backburner bcrypt (~> 3.1.11) - benchmark-ips - blade - blade-sauce_labs_plugin - byebug - coffee-rails - dalli (>= 2.2.1) - delayed_job - delayed_job_active_record - em-hiredis - erubis (~> 2.7.0) - hiredis - jquery-rails - json (>= 2.0.0) - kindlerb (~> 1.2.0) + bootsnap (>= 1.4.4) + brakeman + bundler-audit + capybara (>= 3.39) + connection_pool + cssbundling-rails + dalli (>= 3.0.1) + dartsass-rails + debug (>= 1.1.0) + google-cloud-storage (~> 1.11) + image_processing (~> 1.2) + importmap-rails (>= 1.2.3) + jbuilder + jsbundling-rails + json (>= 2.0.0, != 2.7.0) + kamal (>= 2.1.0) + launchy libxml-ruby - listen (>= 3.0.5, < 3.2) - minitest (< 5.3.4) - mocha (~> 0.14) - mysql2 (>= 0.4.4) - nokogiri (>= 1.6.8) - pg (>= 0.18.0) - psych (~> 2.0) - puma - qu-redis - que - queue_classic! - racc (>= 1.4.6) + listen (~> 3.3) + mdl (!= 0.13.0) + minitest + minitest-bisect + minitest-ci + minitest-retry + msgpack (>= 1.7.0) + mysql2 (~> 0.5, < 0.5.7) + nokogiri (>= 1.8.1, != 1.11.0) + pg (~> 1.3) + prism + propshaft (>= 0.1.7, != 1.0.1) + puma (>= 5.0.3) + queue_classic (>= 4.0.0) + rack (~> 3.0) rack-cache (~> 1.2) rails! - rake (>= 11.1) - rb-inotify! - redcarpet (~> 3.2.3) - redis + rake (>= 13) + redcarpet (~> 3.6.1) + redis (>= 4.0.1) + redis-namespace + releaser! resque resque-scheduler - rubocop (>= 0.47) - sass-rails - sdoc (= 1.0.0.rc1) - sequel + rexml + rouge + rubocop (= 1.79.2) + rubocop-md + rubocop-minitest + rubocop-packaging + rubocop-performance + rubocop-rails + rubocop-rails-omakase + rubyzip (~> 2.0) + sdoc (~> 2.6.4) + selenium-webdriver (>= 4.20.0) sidekiq sneakers - sqlite3 (~> 1.3.6) + solid_cable + solid_cache + solid_queue + sprockets-rails (>= 2.0.0) + sqlite3 (>= 2.1) stackprof - sucker_punch - turbolinks (~> 5) + stimulus-rails + tailwindcss-rails + terser (>= 1.1.4) + thruster + trilogy (>= 2.7.0) + turbo-rails tzinfo-data - uglifier (>= 1.3.0) - w3c_validators + uri (>= 0.13.1) + useragent + w3c_validators (~> 1.3.6) wdm (>= 0.1.0) - websocket-client-simple! + web-console + webmock + webrick + websocket-client-simple BUNDLED WITH - 1.14.3 + 2.7.2 diff --git a/MIT-LICENSE b/MIT-LICENSE index 6b3cead1a75c8..f12cfa766c555 100644 --- a/MIT-LICENSE +++ b/MIT-LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2005-2017 David Heinemeier Hansson +Copyright (c) David Heinemeier Hansson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/RAILS_VERSION b/RAILS_VERSION index 8ea10160818e7..69640086a3d93 100644 --- a/RAILS_VERSION +++ b/RAILS_VERSION @@ -1 +1 @@ -5.1.0.alpha +8.2.0.alpha diff --git a/README.md b/README.md index a2b726ea6cee3..213345ff10a7f 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,102 @@ # Welcome to Rails -Rails is a web-application framework that includes everything needed to +## What's Rails? + +Rails is a web application framework that includes everything needed to create database-backed web applications according to the -[Model-View-Controller (MVC)](http://en.wikipedia.org/wiki/Model-view-controller) +[Model-View-Controller (MVC)](https://en.wikipedia.org/wiki/Model-view-controller) pattern. Understanding the MVC pattern is key to understanding Rails. MVC divides your -application into three layers, each with a specific responsibility. +application into three layers: Model, View, and Controller, each with a specific responsibility. + +## Model layer -The _Model layer_ represents your domain model (such as Account, Product, -Person, Post, etc.) and encapsulates the business logic that is specific to +The _**Model layer**_ represents the domain model (such as Account, Product, +Person, Post, etc.) and encapsulates the business logic specific to your application. In Rails, database-backed model classes are derived from -`ActiveRecord::Base`. Active Record allows you to present the data from +`ActiveRecord::Base`. [Active Record](activerecord/README.rdoc) allows you to present the data from database rows as objects and embellish these data objects with business logic -methods. You can read more about Active Record in its [README](activerecord/README.rdoc). +methods. Although most Rails models are backed by a database, models can also be ordinary Ruby classes, or Ruby classes that implement a set of interfaces as provided by -the Active Model module. You can read more about Active Model in its [README](activemodel/README.rdoc). +the [Active Model](activemodel/README.rdoc) module. + +## View layer + +The _**View layer**_ is composed of "templates" that are responsible for providing +appropriate representations of your application's resources. Templates can +come in a variety of formats, but most view templates are HTML with embedded +Ruby code (ERB files). Views are typically rendered to generate a controller response +or to generate the body of an email. In Rails, View generation is handled by [Action View](actionview/README.rdoc). + +## Controller layer -The _Controller layer_ is responsible for handling incoming HTTP requests and -providing a suitable response. Usually this means returning HTML, but Rails controllers +The _**Controller layer**_ is responsible for handling incoming HTTP requests and +providing a suitable response. Usually, this means returning HTML, but Rails controllers can also generate XML, JSON, PDFs, mobile-specific views, and more. Controllers load and manipulate models, and render view templates in order to generate the appropriate HTTP response. In Rails, incoming requests are routed by Action Dispatch to an appropriate controller, and controller classes are derived from `ActionController::Base`. Action Dispatch and Action Controller -are bundled together in Action Pack. You can read more about Action Pack in its -[README](actionpack/README.rdoc). +are bundled together in [Action Pack](actionpack/README.rdoc). -The _View layer_ is composed of "templates" that are responsible for providing -appropriate representations of your application's resources. Templates can -come in a variety of formats, but most view templates are HTML with embedded -Ruby code (ERB files). Views are typically rendered to generate a controller response, -or to generate the body of an email. In Rails, View generation is handled by Action View. -You can read more about Action View in its [README](actionview/README.rdoc). - -Active Record, Active Model, Action Pack, and Action View can each be used independently outside Rails. -In addition to that, Rails also comes with Action Mailer ([README](actionmailer/README.rdoc)), a library -to generate and send emails; Active Job ([README](activejob/README.md)), a -framework for declaring jobs and making them run on a variety of queueing -backends; Action Cable ([README](actioncable/README.md)), a framework to -integrate WebSockets with a Rails application; -and Active Support ([README](activesupport/README.rdoc)), a collection -of utility classes and standard library extensions that are useful for Rails, -and may also be used independently outside Rails. +## Frameworks and libraries + +[Active Record](activerecord/README.rdoc), [Active Model](activemodel/README.rdoc), [Action Pack](actionpack/README.rdoc), and [Action View](actionview/README.rdoc) can each be used independently outside Rails. + +In addition to that, Rails also comes with: + +- [Action Mailer](actionmailer/README.rdoc), a library to generate and send emails +- [Action Mailbox](actionmailbox/README.md), a library to receive emails within a Rails application +- [Active Job](activejob/README.md), a framework for declaring jobs and making them run on a variety of queuing backends +- [Action Cable](actioncable/README.md), a framework to integrate WebSockets with a Rails application +- [Active Storage](activestorage/README.md), a library to attach cloud and local files to Rails applications +- [Action Text](actiontext/README.md), a library to handle rich text content +- [Active Support](activesupport/README.rdoc), a collection of utility classes and standard library extensions that are useful for Rails, and may also be used independently outside Rails ## Getting Started 1. Install Rails at the command prompt if you haven't yet: - $ gem install rails + ```bash + $ gem install rails + ``` 2. At the command prompt, create a new Rails application: - $ rails new myapp + ```bash + $ rails new myapp + ``` where "myapp" is the application name. 3. Change directory to `myapp` and start the web server: - $ cd myapp - $ rails server - + ```bash + $ cd myapp + $ bin/rails server + ``` Run with `--help` or `-h` for options. -4. Using a browser, go to `http://localhost:3000` and you'll see: -"Yay! You’re on Rails!" +4. Go to `http://localhost:3000` and you'll see the Rails bootscreen with your Rails and Ruby versions. 5. Follow the guidelines to start developing your application. You may find the following resources handy: - * [Getting Started with Rails](http://guides.rubyonrails.org/getting_started.html) - * [Ruby on Rails Guides](http://guides.rubyonrails.org) - * [The API Documentation](http://api.rubyonrails.org) - * [Ruby on Rails Tutorial](http://www.railstutorial.org/book) + * [Getting Started with Rails](https://guides.rubyonrails.org/getting_started.html) + * [Ruby on Rails Guides](https://guides.rubyonrails.org) + * [The API Documentation](https://api.rubyonrails.org) ## Contributing We encourage you to contribute to Ruby on Rails! Please check out the -[Contributing to Ruby on Rails guide](http://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html) for guidelines about how to proceed. [Join us!](http://contributors.rubyonrails.org) - -Everyone interacting in Rails and its sub-projects' codebases, issue trackers, chat rooms, and mailing lists is expected to follow the Rails [code of conduct](http://rubyonrails.org/conduct/). +[Contributing to Ruby on Rails guide](https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html) for guidelines about how to proceed. [Join us!](https://contributors.rubyonrails.org) -## Code Status +Trying to report a possible security vulnerability in Rails? Please +check out our [security policy](https://rubyonrails.org/security) for +guidelines about how to proceed. -[![Build Status](https://travis-ci.org/rails/rails.svg?branch=master)](https://travis-ci.org/rails/rails) +Everyone interacting in Rails and its sub-projects' codebases, issue trackers, chat rooms, and mailing lists is expected to follow the Rails [code of conduct](https://rubyonrails.org/conduct). ## License -Ruby on Rails is released under the [MIT License](http://www.opensource.org/licenses/MIT). +Ruby on Rails is released under the [MIT License](https://opensource.org/licenses/MIT). diff --git a/RELEASING_RAILS.md b/RELEASING_RAILS.md index 10a8bca3b3c11..3d05c0409a984 100644 --- a/RELEASING_RAILS.md +++ b/RELEASING_RAILS.md @@ -1,88 +1,36 @@ # Releasing Rails -In this document, we'll cover the steps necessary to release Rails. Each -section contains steps to take during that time before the release. The times -suggested in each header are just that: suggestions. However, they should +In this document, we'll cover the steps necessary to release Rails. Each +section contains steps to take during that time before the release. The times +suggested in each header are just that: suggestions. However, they should really be considered as minimums. ## 10 Days before release -Today is mostly coordination tasks. Here are the things you must do today: +Today is mostly coordination tasks. Here are the things you must do today: -### Is the CI green? If not, make it green. (See "Fixing the CI") +### Is the CI green? If not, make it green. (See "Fixing the CI") -Do not release with a Red CI. You can find the CI status here: +Do not release with a Red CI. You can find the CI status here: ``` -http://travis-ci.org/rails/rails +https://buildkite.com/rails/rails ``` -### Is Sam Ruby happy? If not, make him happy. - -Sam Ruby keeps a [test suite](https://github.com/rubys/awdwr) that makes -sure the code samples in his book -([Agile Web Development with Rails](https://pragprog.com/titles/rails5/agile-web-development-with-rails-5th-edition)) -all work. These are valuable system tests -for Rails. You can check the status of these tests here: - -[http://intertwingly.net/projects/dashboard.html](http://intertwingly.net/projects/dashboard.html) - -Do not release with Red AWDwR tests. - -### Do we have any Git dependencies? If so, contact those authors. +### Do we have any Git dependencies? If so, contact those authors. Having Git dependencies indicates that we depend on unreleased code. Obviously Rails cannot be released when it depends on unreleased code. Contact the authors of those particular gems and work out a release date that suits them. -### Contact the security team (either tenderlove or rafaelfranca) - -Let them know of your plans to release. There may be security issues to be -addressed, and that can impact your release date. - -### Notify implementors. - -Ruby implementors have high stakes in making sure Rails works. Be kind and -give them a heads up that Rails will be released soonish. - -This is only required for major and minor releases, bugfix releases aren't a -big enough deal, and are supposed to be backward compatible. - -Send an email just giving a heads up about the upcoming release to these -lists: - -* team@jruby.org -* community@rubini.us -* rubyonrails-core@googlegroups.com - -Implementors will love you and help you. +### Announce your plans to the rest of the team on Basecamp -### 3 Days before release - -This is when you should release the release candidate. Here are your tasks -for today: - -### Is the CI green? If not, make it green. - -### Is Sam Ruby happy? If not, make him happy. - -### Contact the security team. CVE emails must be sent on this day. - -### Create a release branch. - -From the stable branch, create a release branch. For example, if you're -releasing Rails 3.0.10, do this: - -``` -[aaron@higgins rails (3-0-stable)]$ git checkout -b 3-0-10 -Switched to a new branch '3-0-10' -[aaron@higgins rails (3-0-10)]$ -``` +Let them know of your plans to release. ### Update each CHANGELOG. -Many times commits are made without the CHANGELOG being updated. You should +Many times commits are made without the CHANGELOG being updated. You should review the commits since the last release, and fill in any missing information for each CHANGELOG. @@ -93,103 +41,106 @@ You can review the commits for the 3.0.10 release like this: ``` If you're doing a stable branch release, you should also ensure that all of -the CHANGELOG entries in the stable branch are also synced to the master +the CHANGELOG entries in the stable branch are also synced to the main branch. -### Update the RAILS_VERSION file to include the RC. +## Day of release + +If making multiple releases. Publish them in order from oldest to newest, to +ensure that the "greatest" version also shows up in npm and GitHub Releases as +"latest". -### Build and test the gem. +### Put the new version in the RAILS_VERSION file. + +Include an RC number if appropriate, e.g. `6.0.0.rc1`. -Run `rake install` to generate the gems and install them locally. Then try -generating a new app and ensure that nothing explodes. +### Build and test the gem. -Verify that Action Cable and Action View's package.json files are updated with -the RC version. +Run `rake install` to generate the gems and install them locally. You can now +use the version installed locally to generate a new app and check if everything +is working as expected. This will stop you from looking silly when you push an RC to rubygems.org and then realize it is broken. -### Release to RubyGems and NPM. +### Check credentials for GitHub -IMPORTANT: The Action Cable client and Action View's UJS adapter are released -as NPM packages, so you must have Node.js installed, have an NPM account -(npmjs.com), and be a package owner for `actioncable` and `rails-ujs` (you can -check this via `npm owner ls actioncable` and `npm owner ls rails-ujs`) in -order to do a full release. Do not release until you're set up with NPM! +For GitHub run `gh auth status` to check that you are logged in (run `gh login` if not). -Run `rake release`. This will populate the gemspecs and NPM package.json with -the current RAILS_VERSION, commit the changes, tag it, and push the gems to -rubygems.org. +The release task will sign the release tag. If you haven't got commit signing +set up, use https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work as a +guide. You can generate keys with the GPG suite from here: https://gpgtools.org. -Here are the commands that `rake release` uses so you can understand what to do -in case anything goes wrong: +Run `rake prep_release` to prepare the release. This will populate the gemspecs and +npm package.json with the current RAILS_VERSION, add the header to the CHANGELOGs, +build the gems, and check if bundler can resolve the dependencies. -``` -$ rake all:build -$ git commit -am'updating RAILS_VERSION' -$ git tag -m 'v3.0.10.rc1 release' v3.0.10.rc1 -$ git push -$ git push --tags -$ for i in $(ls pkg); do gem push $i; npm publish; done -``` +You can now inspect the results in the diff and see if you are happy with the +changes. + +To release, Run `rake release`. This will commit the changes, tag it, and create a GitHub +release with the proper release notes in draft mode. + +Open the corresponding GitHub release draft and check that the release notes +are correct. If everything is fine, publish the release. + +### Publish the gems + +To publish the gems approve the [Release workflow in GitHub Actions](https://github.com/rails/rails/actions/workflows/release.yml), +that was created after the release was published. ### Send Rails release announcements Write a release announcement that includes the version, changes, and links to -GitHub where people can find the specific commit list. Here are the mailing +GitHub where people can find the specific commit list. Here are the mailing lists where you should announce: -* rubyonrails-core@googlegroups.com -* rubyonrails-talk@googlegroups.com -* ruby-talk@ruby-lang.org +* [rubyonrails-core](https://discuss.rubyonrails.org/c/rubyonrails-core) +* [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 rubyonrails-core forum. -Use Markdown format for your announcement. Remember to ask people to report -issues with the release candidate to the rails-core mailing list. +NOTE: For patch releases, there's a `rake announce` task to generate the release +post. It supports multiple patch releases too: + +``` +VERSIONS="5.0.5.rc1,5.1.3.rc1" rake announce +``` IMPORTANT: If any users experience regressions when using the release -candidate, you *must* postpone the release. Bugfix releases *should not* +candidate, you *must* postpone the release. Bugfix releases *should not* break existing applications. ### Post the announcement to the Rails blog. -If you used Markdown format for your email, you can just paste it into the -blog. - -* http://weblog.rubyonrails.org - -### Post the announcement to the Rails Twitter account. +The blog at https://rubyonrails.org/blog is built from +https://github.com/rails/website. -## Time between release candidate and actual release +Create a file named like +`_posts/$(date +'%F')-Rails--have-been-released.markdown` -Check the rails-core mailing list and the GitHub issue list for regressions in -the RC. - -If any regressions are found, fix the regressions and repeat the release -candidate process. We will not release the final until 72 hours after the -last release candidate has been pushed. This means that if users find -regressions, the scheduled release date must be postponed. - -When you fix the regressions, do not create a new branch. Fix them on the -stable branch, then cherry pick the commit to your release branch. No other -commits should be added to the release branch besides regression fixing commits. +Add YAML frontmatter +``` +--- +layout: post +title: 'Rails have been released!' +categories: releases +author: +published: true +date: +--- +``` -## Day of release +Use the markdown generated by `rake announce` earlier as a base for the post. +Add some context for users as to the purpose of this release (bugfix/security). -Many of these steps are the same as for the release candidate, so if you need -more explanation on a particular step, see the RC steps. +If this is a part of the latest release series, update `_data/version.yml` so +that the homepage points to the latest version. -Today, do this stuff in this order: +### Post the announcement to the Rails X account. -* Apply security patches to the release branch -* Update CHANGELOG with security fixes -* Update RAILS_VERSION to remove the rc -* Build and test the gem -* Release the gems -* If releasing a new stable version: - - Trigger stable docs generation (see below) - - Update the version in the home page -* Email security lists -* Email general announcement lists +## Security releases ### Emailing the Rails security announce list @@ -203,11 +154,11 @@ Email the security reports to: * oss-security@lists.openwall.com Be sure to note the security fixes in your announcement along with CVE numbers -and links to each patch. Some people may not be able to upgrade right away, +and links to each patch. Some people may not be able to upgrade right away, so we need to give them the security fixes in patch form. * Blog announcements -* Twitter announcements +* X announcements * Merge the release branch to the stable branch * Drink beer (or other cocktail) diff --git a/Rakefile b/Rakefile index 202eb5e6fc8e3..8c58b65d8c467 100644 --- a/Rakefile +++ b/Rakefile @@ -1,42 +1,191 @@ +# frozen_string_literal: true + require "net/http" -$:.unshift File.expand_path("..", __FILE__) +$:.unshift __dir__ require "tasks/release" require "railties/lib/rails/api/task" - -desc "Build gem files for all projects" -task build: "all:build" - -desc "Prepare the release" -task prep_release: "all:prep_release" - -desc "Release all gems to rubygems and create a tag" -task release: "all:release" +require "tools/preview_docs" desc "Run all tests by default" task default: %w(test test:isolated) -%w(test test:isolated package gem).each do |task_name| +%w(test test:isolated).each do |task_name| desc "Run #{task_name} task for all projects" task task_name do errors = [] - FRAMEWORKS.each do |project| + Releaser::FRAMEWORKS.each do |project| system(%(cd #{project} && #{$0} #{task_name} --trace)) || errors << project end fail("Errors in #{errors.join(', ')}") unless errors.empty? end end -desc "Smoke-test all projects" -task :smoke do - (FRAMEWORKS - %w(activerecord)).each do |project| - system %(cd #{project} && #{$0} test:isolated --trace) +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 - system %(cd activerecord && #{$0} sqlite3:isolated_test --trace) end -desc "Install gems for all projects." -task install: "all:install" +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 + # The arguments are positional, and users may want to specify only the isolated flag.. so we allow 'all' as a default for the first argument: + if frameworks.include?("all") + frameworks = Releaser::FRAMEWORKS + end + + isolated = args[:isolated].nil? || args[:isolated] == "true" + test_task = isolated ? "test:isolated" : "test" + + (frameworks - ["activerecord"]).each do |project| + system %(cd #{project} && #{$0} #{test_task} --trace) + end + + if frameworks.include? "activerecord" + test_task = isolated ? "sqlite3:isolated_test" : "sqlite3:test" + system %(cd activerecord && #{$0} #{test_task} --trace) + end +end desc "Generate documentation for the Rails framework" if ENV["EDGE"] @@ -45,8 +194,20 @@ else Rails::API::StableTask.new("rdoc") end -desc "Bump all versions to match RAILS_VERSION" -task update_versions: "all:update_versions" +desc "Generate documentation for previewing" +task :preview_docs do + FileUtils.mkdir_p("preview") + PreviewDocs.new.render("preview") + + system(%(cd guides && #{$0} guides:generate --trace)) + + Rake::Task[:rdoc].invoke + + FileUtils.mv("doc/rdoc", "preview/api") + FileUtils.mv("guides/output", "preview/guides") + + system("tar -czf preview.tar.gz -C preview .") +end # We have a webhook configured in GitHub that gets invoked after pushes. # This hook triggers the following tasks: diff --git a/actioncable/.babelrc b/actioncable/.babelrc new file mode 100644 index 0000000000000..4f0c469c60e98 --- /dev/null +++ b/actioncable/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": [ + ["env", { "modules": false, "loose": true } ] + ], + "plugins": [ + "external-helpers" + ] +} diff --git a/actioncable/.gitignore b/actioncable/.gitignore index 0a04b297867d1..93070addd7e69 100644 --- a/actioncable/.gitignore +++ b/actioncable/.gitignore @@ -1,2 +1,3 @@ -/lib/assets/compiled -/tmp +/src +/test/javascript/compiled/ +/tmp/ diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index 7657a05077948..ca72310999d75 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,43 +1,2 @@ -* Redis subscription adapters now support `channel_prefix` option in `cable.yml` - Avoids channel name collisions when multiple apps use the same Redis server. - - *Chad Ingram* - -* Permit same-origin connections by default. - - Added new option `config.action_cable.allow_same_origin_as_host = false` - to disable this behaviour. - - *Dávid Halász*, *Matthew Draper* - -* Prevent race where the client could receive and act upon a - subscription confirmation before the channel's `subscribed` method - completed. - - Fixes #25381. - - *Vladimir Dementyev* - -* Buffer now writes to WebSocket connections, to avoid blocking threads - that could be doing more useful things. - - *Matthew Draper*, *Tinco Andringa* - -* Protect against concurrent writes to a WebSocket connection from - multiple threads; the underlying OS write is not always threadsafe. - - *Tinco Andringa* - -* Add `ActiveSupport::Notifications` hook to `Broadcaster#broadcast`. - - *Matthew Wear* - -* Close hijacked socket when connection is shut down. - - Fixes #25613. - - *Tinco Andringa* - - -Please check [5-0-stable](https://github.com/rails/rails/blob/5-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/MIT-LICENSE b/actioncable/MIT-LICENSE index 1a0e653b69938..be1075927c6b6 100644 --- a/actioncable/MIT-LICENSE +++ b/actioncable/MIT-LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015-2017 Basecamp, LLC +Copyright (c) 37signals LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/actioncable/README.md b/actioncable/README.md index c55b7dc57bacf..0ae89cb41e5a2 100644 --- a/actioncable/README.md +++ b/actioncable/README.md @@ -1,567 +1,24 @@ -# Action Cable – Integrated WebSockets for Rails +# Action Cable – Integrated WebSockets for \Rails -Action Cable seamlessly integrates WebSockets with the rest of your Rails application. +Action Cable seamlessly integrates WebSockets with the rest of your \Rails application. It allows for real-time features to be written in Ruby in the same style -and form as the rest of your Rails application, while still being performant +and form as the rest of your \Rails application, while still being performant and scalable. It's a full-stack offering that provides both a client-side JavaScript framework and a server-side Ruby framework. You have access to your full domain model written with Active Record or your ORM of choice. -## Terminology - -A single Action Cable server can handle multiple connection instances. It has one -connection instance per WebSocket connection. A single user may have multiple -WebSockets open to your application if they use multiple browser tabs or devices. -The client of a WebSocket connection is called the consumer. - -Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates -a logical unit of work, similar to what a controller does in a regular MVC setup. For example, -you could have a `ChatChannel` and an `AppearancesChannel`, and a consumer could be subscribed to either -or to both of these channels. At the very least, a consumer should be subscribed to one channel. - -When the consumer is subscribed to a channel, they act as a subscriber. The connection between -the subscriber and the channel is, surprise-surprise, called a subscription. A consumer -can act as a subscriber to a given channel any number of times. For example, a consumer -could subscribe to multiple chat rooms at the same time. (And remember that a physical user may -have multiple consumers, one per tab/device open to your connection). - -Each channel can then again be streaming zero or more broadcastings. A broadcasting is a -pubsub link where anything transmitted by the broadcaster is sent directly to the channel -subscribers who are streaming that named broadcasting. - -As you can see, this is a fairly deep architectural stack. There's a lot of new terminology -to identify the new pieces, and on top of that, you're dealing with both client and server side -reflections of each unit. - -## Examples - -### A full-stack example - -The first thing you must do is define your `ApplicationCable::Connection` class in Ruby. This -is the place where you authorize the incoming connection, and proceed to establish it, -if all is well. Here's the simplest example starting with the server-side connection class: - -```ruby -# app/channels/application_cable/connection.rb -module ApplicationCable - class Connection < ActionCable::Connection::Base - identified_by :current_user - - def connect - self.current_user = find_verified_user - end - - private - def find_verified_user - if current_user = User.find_by(id: cookies.signed[:user_id]) - current_user - else - reject_unauthorized_connection - end - end - end -end -``` -Here `identified_by` is a connection identifier that can be used to find the specific connection again or later. -Note that anything marked as an identifier will automatically create a delegate by the same name on any channel instances created off the connection. - -This relies on the fact that you will already have handled authentication of the user, and -that a successful authentication sets a signed cookie with the `user_id`. This cookie is then -automatically sent to the connection instance when a new connection is attempted, and you -use that to set the `current_user`. By identifying the connection by this same current_user, -you're also ensuring that you can later retrieve all open connections by a given user (and -potentially disconnect them all if the user is deleted or deauthorized). - -Next, you should define your `ApplicationCable::Channel` class in Ruby. This is the place where you put -shared logic between your channels. - -```ruby -# app/channels/application_cable/channel.rb -module ApplicationCable - class Channel < ActionCable::Channel::Base - end -end -``` - -The client-side needs to setup a consumer instance of this connection. That's done like so: - -```js -// app/assets/javascripts/cable.js -//= require action_cable -//= require_self -//= require_tree ./channels - -(function() { - this.App || (this.App = {}); - - App.cable = ActionCable.createConsumer("ws://cable.example.com"); -}).call(this); -``` - -The `ws://cable.example.com` address must point to your Action Cable server(s), and it -must share a cookie namespace with the rest of the application (which may live under http://example.com). -This ensures that the signed cookie will be correctly sent. - -That's all you need to establish the connection! But of course, this isn't very useful in -itself. This just gives you the plumbing. To make stuff happen, you need content. That content -is defined by declaring channels on the server and allowing the consumer to subscribe to them. - - -### Channel example 1: User appearances - -Here's a simple example of a channel that tracks whether a user is online or not, and also what page they are currently on. -(This is useful for creating presence features like showing a green dot next to a user's name if they're online). - -First you declare the server-side channel: - -```ruby -# app/channels/appearance_channel.rb -class AppearanceChannel < ApplicationCable::Channel - def subscribed - current_user.appear - end - - def unsubscribed - current_user.disappear - end - - def appear(data) - current_user.appear on: data['appearing_on'] - end - - def away - current_user.away - end -end -``` - -The `#subscribed` callback is invoked when, as we'll show below, a client-side subscription is initiated. In this case, -we take that opportunity to say "the current user has indeed appeared". That appear/disappear API could be backed by -Redis or a database or whatever else. Here's what the client-side of that looks like: - -```coffeescript -# app/assets/javascripts/cable/subscriptions/appearance.coffee -App.cable.subscriptions.create "AppearanceChannel", - # Called when the subscription is ready for use on the server - connected: -> - @install() - @appear() - - # Called when the WebSocket connection is closed - disconnected: -> - @uninstall() - - # Called when the subscription is rejected by the server - rejected: -> - @uninstall() - - appear: -> - # Calls `AppearanceChannel#appear(data)` on the server - @perform("appear", appearing_on: $("main").data("appearing-on")) - - away: -> - # Calls `AppearanceChannel#away` on the server - @perform("away") - - - buttonSelector = "[data-behavior~=appear_away]" - - install: -> - $(document).on "turbolinks:load.appearance", => - @appear() - - $(document).on "click.appearance", buttonSelector, => - @away() - false - - $(buttonSelector).show() - - uninstall: -> - $(document).off(".appearance") - $(buttonSelector).hide() -``` - -Simply calling `App.cable.subscriptions.create` will setup the subscription, which will call `AppearanceChannel#subscribed`, -which in turn is linked to the original `App.cable` -> `ApplicationCable::Connection` instances. - -Next, we link the client-side `appear` method to `AppearanceChannel#appear(data)`. This is possible because the server-side -channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these -can be reached as remote procedure calls via a subscription's `perform` method. - -### Channel example 2: Receiving new web notifications - -The appearance example was all about exposing server functionality to client-side invocation over the WebSocket connection. -But the great thing about WebSockets is that it's a two-way street. So now let's show an example where the server invokes -an action on the client. - -This is a web notification channel that allows you to trigger client-side web notifications when you broadcast to the right -streams: - -```ruby -# app/channels/web_notifications_channel.rb -class WebNotificationsChannel < ApplicationCable::Channel - def subscribed - stream_from "web_notifications_#{current_user.id}" - end -end -``` - -```coffeescript -# Client-side, 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"] -``` - -```ruby -# Somewhere in your app this is called, perhaps from a NewCommentJob -ActionCable.server.broadcast \ - "web_notifications_#{current_user.id}", { title: 'New things!', body: 'All the news that is fit to print' } -``` - -The `ActionCable.server.broadcast` call places a message in the Action Cable pubsub queue under a separate broadcasting name for each user. For a user with an ID of 1, the broadcasting name would be `web_notifications_1`. -The channel has been instructed to stream everything that arrives at `web_notifications_1` directly to the client by invoking the -`#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip -across the wire, and unpacked for the data argument arriving to `#received`. - - -### Passing Parameters to Channel - -You can pass parameters from the client side to the server side when creating a subscription. For example: - -```ruby -# app/channels/chat_channel.rb -class ChatChannel < ApplicationCable::Channel - def subscribed - stream_from "chat_#{params[:room]}" - end -end -``` - -If you pass an object as the first argument to `subscriptions.create`, that object will become the params hash in your cable channel. The keyword `channel` is required. - -```coffeescript -# Client-side, which assumes you've already requested the right to send web notifications -App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, - received: (data) -> - @appendLine(data) - - appendLine: (data) -> - html = @createLine(data) - $("[data-chat-room='Best Room']").append(html) - - createLine: (data) -> - """ -
- #{data["sent_by"]} - #{data["body"]} -
- """ -``` - -```ruby -# Somewhere in your app this is called, perhaps from a NewCommentJob -ActionCable.server.broadcast \ - "chat_#{room}", { sent_by: 'Paul', body: 'This is a cool chat app.' } -``` - - -### Rebroadcasting message - -A common use case is to rebroadcast a message sent by one client to any other connected clients. - -```ruby -# app/channels/chat_channel.rb -class ChatChannel < ApplicationCable::Channel - def subscribed - stream_from "chat_#{params[:room]}" - end - - def receive(data) - ActionCable.server.broadcast "chat_#{params[:room]}", data - end -end -``` - -```coffeescript -# Client-side, which assumes you've already requested the right to send web notifications -App.chatChannel = App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, - received: (data) -> - # data => { sent_by: "Paul", body: "This is a cool chat app." } - -App.chatChannel.send({ sent_by: "Paul", body: "This is a cool chat app." }) -``` - -The rebroadcast will be received by all connected clients, _including_ the client that sent the message. Note that params are the same as they were when you subscribed to the channel. - - -### More complete examples - -See the [rails/actioncable-examples](https://github.com/rails/actioncable-examples) repository for a full example of how to setup Action Cable in a Rails app, and how to add channels. - -## Configuration - -Action Cable has three required configurations: a subscription adapter, allowed request origins, and the cable server URL (which can optionally be set on the client side). - -### Redis - -By default, `ActionCable::Server::Base` will look for a configuration file in `Rails.root.join('config/cable.yml')`. -This file must specify an adapter and a URL for each Rails environment. It may use the following format: - -```yaml -production: &production - adapter: redis - url: redis://10.10.3.153:6381 -development: &development - adapter: redis - url: redis://localhost:6379 -test: *development -``` - -You can also change the location of the Action Cable config file in a Rails initializer with something like: - -```ruby -Rails.application.paths.add "config/cable", with: "somewhere/else/cable.yml" -``` - -### Allowed Request Origins - -Action Cable will only accept requests from specific origins. - -By default, only an origin matching the cable server itself will be permitted. -Additional origins can be specified using strings or regular expressions, provided in an array. - -```ruby -Rails.application.config.action_cable.allowed_request_origins = ['http://rubyonrails.com', /http:\/\/ruby.*/] -``` - -When running in the development environment, this defaults to "http://localhost:3000". - -To disable protection and allow requests from any origin: - -```ruby -Rails.application.config.action_cable.disable_request_forgery_protection = true -``` - -To disable automatic access for same-origin requests, and strictly allow -only the configured origins: - -```ruby -Rails.application.config.action_cable.allow_same_origin_as_host = false -``` - -### Consumer Configuration - -Once you have decided how to run your cable server (see below), you must provide the server URL (or path) to your client-side setup. -There are two ways you can do this. - -The first is to simply pass it in when creating your consumer. For a standalone server, -this would be something like: `App.cable = ActionCable.createConsumer("ws://example.com:28080")`, and for an in-app server, -something like: `App.cable = ActionCable.createConsumer("/cable")`. - -The second option is to pass the server URL through the `action_cable_meta_tag` in your layout. -This uses a URL or path typically set via `config.action_cable.url` in the environment configuration files, or defaults to "/cable". - -This method is especially useful if your WebSocket URL might change between environments. If you host your production server via https, you will need to use the wss scheme -for your Action Cable server, but development might remain http and use the ws scheme. You might use localhost in development and your -domain in production. - -In any case, to vary the WebSocket URL between environments, add the following configuration to each environment: - -```ruby -config.action_cable.url = "ws://example.com:28080" -``` - -Then add the following line to your layout before your JavaScript tag: - -```erb -<%= action_cable_meta_tag %> -``` - -And finally, create your consumer like so: - -```coffeescript -App.cable = ActionCable.createConsumer() -``` - -### Other Configurations - -The other common option to configure is the log tags applied to the per-connection logger. Here's an example that uses the user account id if available, else "no-account" while tagging: - -```ruby -config.action_cable.log_tags = [ - -> request { request.env['user_account_id'] || "no-account" }, - :action_cable, - -> request { request.uuid } -] -``` - -For a full list of all configuration options, see the `ActionCable::Server::Configuration` class. - -Also note that your server must provide at least the same number of database connections as you have workers. The default worker pool is set to 4, so that means you have to make at least that available. You can change that in `config/database.yml` through the `pool` attribute. - - -## Running the cable server - -### Standalone -The cable server(s) is separated from your normal application server. It's still a Rack application, but it is its own Rack -application. The recommended basic setup is as follows: - -```ruby -# cable/config.ru -require ::File.expand_path('../../config/environment', __FILE__) -Rails.application.eager_load! - -run ActionCable.server -``` - -Then you start the server using a binstub in bin/cable ala: -```sh -#!/bin/bash -bundle exec puma -p 28080 cable/config.ru -``` - -The above will start a cable server on port 28080. - -### In app - -If you are using a server that supports the [Rack socket hijacking API](http://www.rubydoc.info/github/rack/rack/file/SPEC#Hijacking), Action Cable can run alongside your Rails application. For example, to listen for WebSocket requests on `/websocket`, specify that path to `config.action_cable.mount_path`: - -```ruby -# config/application.rb -class Application < Rails::Application - config.action_cable.mount_path = '/websocket' -end -``` - -For every instance of your server you create and for every worker your server spawns, you will also have a new instance of Action Cable, but the use of Redis keeps messages synced across connections. - -### Notes - -Beware that currently, the cable server will _not_ auto-reload any changes in the framework. As we've discussed, long-running cable connections mean long-running objects. We don't yet have a way of reloading the classes of those objects in a safe manner. So when you change your channels, or the model your channels use, you must restart the cable server. - -We'll get all this abstracted properly when the framework is integrated into Rails. - -The WebSocket server doesn't have access to the session, but it has access to the cookies. This can be used when you need to handle authentication. You can see one way of doing that with Devise in this [article](http://www.rubytutorial.io/actioncable-devise-authentication). - -## Dependencies - -Action Cable provides a subscription adapter interface to process its pubsub internals. By default, asynchronous, inline, PostgreSQL, evented Redis, and non-evented Redis adapters are included. The default adapter in new Rails applications is the asynchronous (`async`) adapter. To create your own adapter, you can look at `ActionCable::SubscriptionAdapter::Base` for all methods that must be implemented, and any of the adapters included within Action Cable as example implementations. - -The Ruby side of things is built on top of [websocket-driver](https://github.com/faye/websocket-driver-ruby), [nio4r](https://github.com/celluloid/nio4r), and [concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby). - - -## Deployment - -Action Cable is powered by a combination of WebSockets and threads. All of the -connection management is handled internally by utilizing Ruby’s native thread -support, which means you can use all your regular Rails models with no problems -as long as you haven’t committed any thread-safety sins. - -The Action Cable server does _not_ need to be a multi-threaded application server. -This is because Action Cable uses the [Rack socket hijacking API](http://www.rubydoc.info/github/rack/rack/file/SPEC#Hijacking) -to take over control of connections from the application server. Action Cable -then manages connections internally, in a multithreaded manner, regardless of -whether the application server is multi-threaded or not. So Action Cable works -with all the popular application servers -- Unicorn, Puma and Passenger. - -Action Cable does not work with WEBrick, because WEBrick does not support the -Rack socket hijacking API. - -## Frontend assets - -Action Cable's frontend assets are distributed through two channels: the -official gem and npm package, both titled `actioncable`. - -### Gem usage - -Through the `actioncable` gem, Action Cable's frontend assets are -available through the Rails Asset Pipeline. Create a `cable.js` or -`cable.coffee` file (this is automatically done for you with Rails -generators), and then simply require the assets: - -In JavaScript... - -```javascript -//= require action_cable -``` - -... and in CoffeeScript: - -```coffeescript -#= require action_cable -``` - -### npm usage - -In addition to being available through the `actioncable` gem, Action Cable's -frontend JS assets are also bundled in an officially supported npm module, -intended for usage in standalone frontend applications that communicate with a -Rails application. A common use case for this could be if you have a decoupled -frontend application written in React, Ember.js, etc. and want to add real-time -WebSocket functionality. - -### Installation - -``` -npm install actioncable --save -``` - -### Usage - -The `ActionCable` constant is available as a `require`-able module, so -you only have to require the package to gain access to the API that is -provided. - -In JavaScript... - -```javascript -ActionCable = require('actioncable') - -var cable = ActionCable.createConsumer('wss://RAILS-API-PATH.com/cable') - -cable.subscriptions.create('AppearanceChannel', { - // normal channel code goes here... -}); -``` - -and in CoffeeScript... - -```coffeescript -ActionCable = require('actioncable') - -cable = ActionCable.createConsumer('wss://RAILS-API-PATH.com/cable') - -cable.subscriptions.create 'AppearanceChannel', - # normal channel code goes here... -``` - -## Download and Installation - -The latest version of Action Cable can be installed with [RubyGems](#gem-usage), -or with [npm](#npm-usage). - -Source code can be downloaded as part of the Rails project on GitHub - -* https://github.com/rails/rails/tree/master/actioncable - -## License - -Action Cable is released under the MIT license: - -* http://www.opensource.org/licenses/MIT - +You can read more about Action Cable in the [Action Cable Overview](https://guides.rubyonrails.org/action_cable_overview.html) guide. ## Support API documentation is at: -* http://api.rubyonrails.org +* https://api.rubyonrails.org -Bug reports can be filed for the Ruby on Rails project here: +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://groups.google.com/forum/?fromgroups#!forum/rubyonrails-core +* https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/actioncable/Rakefile b/actioncable/Rakefile index 87d443919c3ac..f4ac5d83d49a8 100644 --- a/actioncable/Rakefile +++ b/actioncable/Rakefile @@ -1,42 +1,51 @@ +# frozen_string_literal: true + +require "base64" require "rake/testtask" require "pathname" +require "open3" require "action_cable" -dir = File.dirname(__FILE__) - task default: :test -task package: "assets:compile" +ENV["RAILS_MINITEST_PLUGIN"] = "true" Rake::TestTask.new do |t| t.libs << "test" - t.test_files = Dir.glob("#{dir}/test/**/*_test.rb") + t.test_files = FileList["#{__dir__}/test/**/*_test.rb"] t.warning = true t.verbose = true + t.options = "--profile" if ENV["CI"] t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) 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") end task :integration do - require "blade" - if ENV["CI"] - Blade.start(interface: :ci) - else - Blade.start(interface: :runner) - end + 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 - desc "Compile Action Cable assets" - task :compile do - require "blade" - Blade.build + desc "Generate ActionCable::INTERNAL JS module" + task :codegen do + require "json" + require "action_cable" + + File.open(File.join(__dir__, "app/javascript/action_cable/internal.js").to_s, "w+") do |file| + file.write("export default #{JSON.pretty_generate(ActionCable::INTERNAL)}\n") + end end end diff --git a/actioncable/actioncable.gemspec b/actioncable/actioncable.gemspec index 6d95f022face0..132362da296eb 100644 --- a/actioncable/actioncable.gemspec +++ b/actioncable/actioncable.gemspec @@ -1,4 +1,6 @@ -version = File.read(File.expand_path("../../RAILS_VERSION", __FILE__)).strip +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY @@ -7,19 +9,33 @@ Gem::Specification.new do |s| s.summary = "WebSocket framework for Rails." s.description = "Structure many real-time application concerns into channels over a single WebSocket connection." - s.required_ruby_version = ">= 2.2.2" + s.required_ruby_version = ">= 3.2.0" s.license = "MIT" s.author = ["Pratik Naik", "David Heinemeier Hansson"] s.email = ["pratiknaik@gmail.com", "david@loudthinking.com"] - s.homepage = "http://rubyonrails.org" + s.homepage = "https://rubyonrails.org" - s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.md", "lib/**/*"] + s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.md", "lib/**/*", "app/assets/javascripts/*.js"] s.require_path = "lib" + s.metadata = { + "bug_tracker_uri" => "https://github.com/rails/rails/issues", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actioncable/CHANGELOG.md", + "documentation_uri" => "https://api.rubyonrails.org/v#{version}/", + "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actioncable", + "rubygems_mfa_required" => "true", + } + + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + + s.add_dependency "activesupport", version s.add_dependency "actionpack", version s.add_dependency "nio4r", "~> 2.0" - s.add_dependency "websocket-driver", "~> 0.6.1" + s.add_dependency "websocket-driver", ">= 0.6.1" + s.add_dependency "zeitwerk", "~> 2.6" end diff --git a/actioncable/app/assets/javascripts/.gitattributes b/actioncable/app/assets/javascripts/.gitattributes new file mode 100644 index 0000000000000..7051e4979a08d --- /dev/null +++ b/actioncable/app/assets/javascripts/.gitattributes @@ -0,0 +1,3 @@ +actioncable.js linguist-generated +actioncable.esm.js linguist-generated +action_cable.js linguist-generated diff --git a/actioncable/app/assets/javascripts/action_cable.coffee.erb b/actioncable/app/assets/javascripts/action_cable.coffee.erb deleted file mode 100644 index e0758dae72620..0000000000000 --- a/actioncable/app/assets/javascripts/action_cable.coffee.erb +++ /dev/null @@ -1,38 +0,0 @@ -#= export ActionCable -#= require_self -#= require ./action_cable/consumer - -@ActionCable = - INTERNAL: <%= ActionCable::INTERNAL.to_json %> - WebSocket: window.WebSocket - logger: window.console - - createConsumer: (url) -> - url ?= @getConfig("url") ? @INTERNAL.default_mount_path - new ActionCable.Consumer @createWebSocketURL(url) - - getConfig: (name) -> - element = document.head.querySelector("meta[name='action-cable-#{name}']") - element?.getAttribute("content") - - createWebSocketURL: (url) -> - if url and not /^wss?:/i.test(url) - a = document.createElement("a") - a.href = url - # Fix populating Location properties in IE. Otherwise, protocol will be blank. - a.href = a.href - a.protocol = a.protocol.replace("http", "ws") - a.href - else - url - - startDebugging: -> - @debugging = true - - stopDebugging: -> - @debugging = null - - log: (messages...) -> - if @debugging - messages.push(Date.now()) - @logger.log("[ActionCable]", messages...) diff --git a/actioncable/app/assets/javascripts/action_cable.js b/actioncable/app/assets/javascripts/action_cable.js new file mode 100644 index 0000000000000..a8a2a22bd9def --- /dev/null +++ b/actioncable/app/assets/javascripts/action_cable.js @@ -0,0 +1,511 @@ +(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.ActionCable = {})); +})(this, (function(exports) { + "use strict"; + var adapters = { + logger: typeof console !== "undefined" ? console : undefined, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined + }; + var logger = { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()); + adapters.logger.log("[ActionCable]", ...messages); + } + } + }; + const now = () => (new Date).getTime(); + const secondsSince = time => (now() - time) / 1e3; + class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; + } + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener("visibilitychange", this.visibilityDidChange); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); + } + } + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener("visibilitychange", this.visibilityDidChange); + logger.log("ConnectionMonitor stopped"); + } + } + isRunning() { + return this.startedAt && !this.stoppedAt; + } + recordMessage() { + this.pingedAt = now(); + } + recordConnect() { + this.reconnectAttempts = 0; + delete this.disconnectedAt; + logger.log("ConnectionMonitor recorded connect"); + } + recordDisconnect() { + this.disconnectedAt = now(); + logger.log("ConnectionMonitor recorded disconnect"); + } + startPolling() { + this.stopPolling(); + this.poll(); + } + stopPolling() { + clearTimeout(this.pollTimeout); + } + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1e3 * backoff * (1 + jitter); + } + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); + this.reconnectAttempts++; + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); + } else { + logger.log("ConnectionMonitor reopening"); + this.connection.reopen(); + } + } + } + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + disconnectedRecently() { + return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; + } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); + } + }), 200); + } + } + } + ConnectionMonitor.staleThreshold = 6; + ConnectionMonitor.reconnectionBackoffRate = .15; + var INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" + }, + default_mount_path: "/cable", + protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] + }; + const {message_types: message_types, protocols: protocols} = INTERNAL; + const supportedProtocols = protocols.slice(0, protocols.length - 1); + const indexOf = [].indexOf; + class Connection { + constructor(consumer) { + this.open = this.open.bind(this); + this.consumer = consumer; + this.subscriptions = this.consumer.subscriptions; + this.monitor = new ConnectionMonitor(this); + this.disconnected = true; + } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)); + return true; + } else { + return false; + } + } + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); + return false; + } else { + const socketProtocols = [ ...protocols, ...this.consumer.subprotocols || [] ]; + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`); + if (this.webSocket) { + this.uninstallEventHandlers(); + } + this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols); + this.installEventHandlers(); + this.monitor.start(); + return true; + } + } + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { + if (!allowReconnect) { + this.monitor.stop(); + } + if (this.isOpen()) { + return this.webSocket.close(); + } + } + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); + if (this.isActive()) { + try { + return this.close(); + } catch (error) { + logger.log("Failed to reopen WebSocket", error); + } finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); + setTimeout(this.open, this.constructor.reopenDelay); + } + } else { + return this.open(); + } + } + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol; + } + } + isOpen() { + return this.isState("open"); + } + isActive() { + return this.isState("open", "connecting"); + } + triedToReconnect() { + return this.monitor.reconnectAttempts > 0; + } + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; + } + isState(...states) { + return indexOf.call(states, this.getState()) >= 0; + } + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase(); + } + } + } + return null; + } + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; + } + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; + } + } + } + Connection.reopenDelay = 500; + Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { + return; + } + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); + this.monitor.recordMessage(); + switch (type) { + case message_types.welcome: + if (this.triedToReconnect()) { + this.reconnectAttempted = true; + } + this.monitor.recordConnect(); + return this.subscriptions.reload(); + + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`); + return this.close({ + allowReconnect: reconnect + }); + + case message_types.ping: + return null; + + case message_types.confirmation: + this.subscriptions.confirmSubscription(identifier); + if (this.reconnectAttempted) { + this.reconnectAttempted = false; + return this.subscriptions.notify(identifier, "connected", { + reconnected: true + }); + } else { + return this.subscriptions.notify(identifier, "connected", { + reconnected: false + }); + } + + case message_types.rejection: + return this.subscriptions.reject(identifier); + + default: + return this.subscriptions.notify(identifier, "received", message); + } + }, + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); + this.disconnected = false; + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); + return this.close({ + allowReconnect: false + }); + } + }, + close(event) { + logger.log("WebSocket onclose event"); + if (this.disconnected) { + return; + } + this.disconnected = true; + this.monitor.recordDisconnect(); + return this.subscriptions.notifyAll("disconnected", { + willAttemptReconnect: this.monitor.isRunning() + }); + }, + error() { + logger.log("WebSocket onerror event"); + } + }; + const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key]; + object[key] = value; + } + } + return object; + }; + class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer; + this.identifier = JSON.stringify(params); + extend(this, mixin); + } + perform(action, data = {}) { + data.action = action; + return this.send(data); + } + send(data) { + return this.consumer.send({ + command: "message", + identifier: this.identifier, + data: JSON.stringify(data) + }); + } + unsubscribe() { + return this.consumer.subscriptions.remove(this); + } + } + class SubscriptionGuarantor { + constructor(subscriptions) { + this.subscriptions = subscriptions; + this.pendingSubscriptions = []; + } + guarantee(subscription) { + if (this.pendingSubscriptions.indexOf(subscription) == -1) { + logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`); + this.pendingSubscriptions.push(subscription); + } else { + logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`); + } + this.startGuaranteeing(); + } + forget(subscription) { + logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`); + this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription)); + } + startGuaranteeing() { + this.stopGuaranteeing(); + this.retrySubscribing(); + } + stopGuaranteeing() { + clearTimeout(this.retryTimeout); + } + retrySubscribing() { + this.retryTimeout = setTimeout((() => { + if (this.subscriptions && typeof this.subscriptions.subscribe === "function") { + this.pendingSubscriptions.map((subscription => { + logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`); + this.subscriptions.subscribe(subscription); + })); + } + }), 500); + } + } + class Subscriptions { + constructor(consumer) { + this.consumer = consumer; + this.guarantor = new SubscriptionGuarantor(this); + this.subscriptions = []; + } + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { + channel: channel + }; + const subscription = new Subscription(this.consumer, params, mixin); + return this.add(subscription); + } + add(subscription) { + this.subscriptions.push(subscription); + this.consumer.ensureActiveConnection(); + this.notify(subscription, "initialized"); + this.subscribe(subscription); + return subscription; + } + remove(subscription) { + this.forget(subscription); + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe"); + } + return subscription; + } + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); + return subscription; + })); + } + forget(subscription) { + this.guarantor.forget(subscription); + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); + return subscription; + } + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); + } + reload() { + return this.subscriptions.map((subscription => this.subscribe(subscription))); + } + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); + } + notify(subscription, callbackName, ...args) { + let subscriptions; + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription); + } else { + subscriptions = [ subscription ]; + } + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)); + } + subscribe(subscription) { + if (this.sendCommand(subscription, "subscribe")) { + this.guarantor.guarantee(subscription); + } + } + confirmSubscription(identifier) { + logger.log(`Subscription confirmed ${identifier}`); + this.findAll(identifier).map((subscription => this.guarantor.forget(subscription))); + } + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; + return this.consumer.send({ + command: command, + identifier: identifier + }); + } + } + class Consumer { + constructor(url) { + this._url = url; + this.subscriptions = new Subscriptions(this); + this.connection = new Connection(this); + this.subprotocols = []; + } + get url() { + return createWebSocketURL(this._url); + } + send(data) { + return this.connection.send(data); + } + connect() { + return this.connection.open(); + } + disconnect() { + return this.connection.close({ + allowReconnect: false + }); + } + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open(); + } + } + addSubProtocol(subprotocol) { + this.subprotocols = [ ...this.subprotocols, subprotocol ]; + } + } + function createWebSocketURL(url) { + if (typeof url === "function") { + url = url(); + } + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a"); + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace("http", "ws"); + return a.href; + } else { + return url; + } + } + function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url); + } + function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); + if (element) { + return element.getAttribute("content"); + } + } + console.log("DEPRECATION: action_cable.js has been renamed to actioncable.js – please update your reference before Rails 8"); + exports.Connection = Connection; + exports.ConnectionMonitor = ConnectionMonitor; + exports.Consumer = Consumer; + exports.INTERNAL = INTERNAL; + exports.Subscription = Subscription; + exports.SubscriptionGuarantor = SubscriptionGuarantor; + exports.Subscriptions = Subscriptions; + exports.adapters = adapters; + exports.createConsumer = createConsumer; + exports.createWebSocketURL = createWebSocketURL; + exports.getConfig = getConfig; + exports.logger = logger; + Object.defineProperty(exports, "__esModule", { + value: true + }); +})); diff --git a/actioncable/app/assets/javascripts/action_cable/connection.coffee b/actioncable/app/assets/javascripts/action_cable/connection.coffee deleted file mode 100644 index 7fd68cad2ff0b..0000000000000 --- a/actioncable/app/assets/javascripts/action_cable/connection.coffee +++ /dev/null @@ -1,116 +0,0 @@ -#= require ./connection_monitor - -# Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. - -{message_types, protocols} = ActionCable.INTERNAL -[supportedProtocols..., unsupportedProtocol] = protocols - -class ActionCable.Connection - @reopenDelay: 500 - - constructor: (@consumer) -> - {@subscriptions} = @consumer - @monitor = new ActionCable.ConnectionMonitor this - @disconnected = true - - send: (data) -> - if @isOpen() - @webSocket.send(JSON.stringify(data)) - true - else - false - - open: => - if @isActive() - ActionCable.log("Attempted to open WebSocket, but existing socket is #{@getState()}") - false - else - ActionCable.log("Opening WebSocket, current state is #{@getState()}, subprotocols: #{protocols}") - @uninstallEventHandlers() if @webSocket? - @webSocket = new ActionCable.WebSocket(@consumer.url, protocols) - @installEventHandlers() - @monitor.start() - true - - close: ({allowReconnect} = {allowReconnect: true}) -> - @monitor.stop() unless allowReconnect - @webSocket?.close() if @isActive() - - reopen: -> - ActionCable.log("Reopening WebSocket, current state is #{@getState()}") - if @isActive() - try - @close() - catch error - ActionCable.log("Failed to reopen WebSocket", error) - finally - ActionCable.log("Reopening WebSocket in #{@constructor.reopenDelay}ms") - setTimeout(@open, @constructor.reopenDelay) - else - @open() - - getProtocol: -> - @webSocket?.protocol - - isOpen: -> - @isState("open") - - isActive: -> - @isState("open", "connecting") - - # Private - - isProtocolSupported: -> - @getProtocol() in supportedProtocols - - isState: (states...) -> - @getState() in states - - getState: -> - return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState - null - - installEventHandlers: -> - for eventName of @events - handler = @events[eventName].bind(this) - @webSocket["on#{eventName}"] = handler - return - - uninstallEventHandlers: -> - for eventName of @events - @webSocket["on#{eventName}"] = -> - return - - events: - message: (event) -> - return unless @isProtocolSupported() - {identifier, message, type} = JSON.parse(event.data) - switch type - when message_types.welcome - @monitor.recordConnect() - @subscriptions.reload() - when message_types.ping - @monitor.recordPing() - when message_types.confirmation - @subscriptions.notify(identifier, "connected") - when message_types.rejection - @subscriptions.reject(identifier) - else - @subscriptions.notify(identifier, "received", message) - - open: -> - ActionCable.log("WebSocket onopen event, using '#{@getProtocol()}' subprotocol") - @disconnected = false - if not @isProtocolSupported() - ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting.") - @close(allowReconnect: false) - - close: (event) -> - ActionCable.log("WebSocket onclose event") - return if @disconnected - @disconnected = true - @monitor.recordDisconnect() - @subscriptions.notifyAll("disconnected", {willAttemptReconnect: @monitor.isRunning()}) - - error: -> - ActionCable.log("WebSocket onerror event") diff --git a/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee b/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee deleted file mode 100644 index 0cc675fa94c6e..0000000000000 --- a/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee +++ /dev/null @@ -1,95 +0,0 @@ -# Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting -# revival reconnections if things go astray. Internal class, not intended for direct user manipulation. -class ActionCable.ConnectionMonitor - @pollInterval: - min: 3 - max: 30 - - @staleThreshold: 6 # Server::Connections::BEAT_INTERVAL * 2 (missed two pings) - - constructor: (@connection) -> - @reconnectAttempts = 0 - - start: -> - unless @isRunning() - @startedAt = now() - delete @stoppedAt - @startPolling() - document.addEventListener("visibilitychange", @visibilityDidChange) - ActionCable.log("ConnectionMonitor started. pollInterval = #{@getPollInterval()} ms") - - stop: -> - if @isRunning() - @stoppedAt = now() - @stopPolling() - document.removeEventListener("visibilitychange", @visibilityDidChange) - ActionCable.log("ConnectionMonitor stopped") - - isRunning: -> - @startedAt? and not @stoppedAt? - - recordPing: -> - @pingedAt = now() - - recordConnect: -> - @reconnectAttempts = 0 - @recordPing() - delete @disconnectedAt - ActionCable.log("ConnectionMonitor recorded connect") - - recordDisconnect: -> - @disconnectedAt = now() - ActionCable.log("ConnectionMonitor recorded disconnect") - - # Private - - startPolling: -> - @stopPolling() - @poll() - - stopPolling: -> - clearTimeout(@pollTimeout) - - poll: -> - @pollTimeout = setTimeout => - @reconnectIfStale() - @poll() - , @getPollInterval() - - getPollInterval: -> - {min, max} = @constructor.pollInterval - interval = 5 * Math.log(@reconnectAttempts + 1) - Math.round(clamp(interval, min, max) * 1000) - - reconnectIfStale: -> - if @connectionIsStale() - ActionCable.log("ConnectionMonitor detected stale connection. reconnectAttempts = #{@reconnectAttempts}, pollInterval = #{@getPollInterval()} ms, time disconnected = #{secondsSince(@disconnectedAt)} s, stale threshold = #{@constructor.staleThreshold} s") - @reconnectAttempts++ - if @disconnectedRecently() - ActionCable.log("ConnectionMonitor skipping reopening recent disconnect") - else - ActionCable.log("ConnectionMonitor reopening") - @connection.reopen() - - connectionIsStale: -> - secondsSince(@pingedAt ? @startedAt) > @constructor.staleThreshold - - disconnectedRecently: -> - @disconnectedAt and secondsSince(@disconnectedAt) < @constructor.staleThreshold - - visibilityDidChange: => - if document.visibilityState is "visible" - setTimeout => - if @connectionIsStale() or not @connection.isOpen() - ActionCable.log("ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = #{document.visibilityState}") - @connection.reopen() - , 200 - - now = -> - new Date().getTime() - - secondsSince = (time) -> - (now() - time) / 1000 - - clamp = (number, min, max) -> - Math.max(min, Math.min(max, number)) diff --git a/actioncable/app/assets/javascripts/action_cable/consumer.coffee b/actioncable/app/assets/javascripts/action_cable/consumer.coffee deleted file mode 100644 index 3298be717f6b5..0000000000000 --- a/actioncable/app/assets/javascripts/action_cable/consumer.coffee +++ /dev/null @@ -1,46 +0,0 @@ -#= require ./connection -#= require ./subscriptions -#= require ./subscription - -# The ActionCable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, -# the ActionCable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. -# The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription -# method. -# -# The following example shows how this can be setup: -# -# @App = {} -# App.cable = ActionCable.createConsumer "ws://example.com/accounts/1" -# App.appearance = App.cable.subscriptions.create "AppearanceChannel" -# -# For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. -# -# When a consumer is created, it automatically connects with the server. -# -# To disconnect from the server, call -# -# App.cable.disconnect() -# -# and to restart the connection: -# -# App.cable.connect() -# -# Any channel subscriptions which existed prior to disconnecting will -# automatically resubscribe. -class ActionCable.Consumer - constructor: (@url) -> - @subscriptions = new ActionCable.Subscriptions this - @connection = new ActionCable.Connection this - - send: (data) -> - @connection.send(data) - - connect: -> - @connection.open() - - disconnect: -> - @connection.close(allowReconnect: false) - - ensureActiveConnection: -> - unless @connection.isActive() - @connection.open() diff --git a/actioncable/app/assets/javascripts/action_cable/subscription.coffee b/actioncable/app/assets/javascripts/action_cable/subscription.coffee deleted file mode 100644 index 8e0805a174eae..0000000000000 --- a/actioncable/app/assets/javascripts/action_cable/subscription.coffee +++ /dev/null @@ -1,72 +0,0 @@ -# A new subscription is created through the ActionCable.Subscriptions instance available on the consumer. -# It provides a number of callbacks and a method for calling remote procedure calls on the corresponding -# Channel instance on the server side. -# -# An example demonstrates the basic functionality: -# -# App.appearance = App.cable.subscriptions.create "AppearanceChannel", -# connected: -> -# # Called once the subscription has been successfully completed -# -# disconnected: ({ willAttemptReconnect: boolean }) -> -# # Called when the client has disconnected with the server. -# # The object will have an `willAttemptReconnect` property which -# # says whether the client has the intention of attempting -# # to reconnect. -# -# appear: -> -# @perform 'appear', appearing_on: @appearingOn() -# -# away: -> -# @perform 'away' -# -# appearingOn: -> -# $('main').data 'appearing-on' -# -# The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server -# by calling the `@perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). -# The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. -# -# This is how the server component would look: -# -# class AppearanceChannel < ApplicationActionCable::Channel -# def subscribed -# current_user.appear -# end -# -# def unsubscribed -# current_user.disappear -# end -# -# def appear(data) -# current_user.appear on: data['appearing_on'] -# end -# -# def away -# current_user.away -# end -# end -# -# The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name. -# The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the @perform method. -class ActionCable.Subscription - constructor: (@consumer, params = {}, mixin) -> - @identifier = JSON.stringify(params) - extend(this, mixin) - - # Perform a channel action with the optional data passed as an attribute - perform: (action, data = {}) -> - data.action = action - @send(data) - - send: (data) -> - @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) - - unsubscribe: -> - @consumer.subscriptions.remove(this) - - extend = (object, properties) -> - if properties? - for key, value of properties - object[key] = value - object diff --git a/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee b/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee deleted file mode 100644 index aa052bf5d8408..0000000000000 --- a/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee +++ /dev/null @@ -1,66 +0,0 @@ -# Collection class for creating (and internally managing) channel subscriptions. The only method intended to be triggered by the user -# us ActionCable.Subscriptions#create, and it should be called through the consumer like so: -# -# @App = {} -# App.cable = ActionCable.createConsumer "ws://example.com/accounts/1" -# App.appearance = App.cable.subscriptions.create "AppearanceChannel" -# -# For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. -class ActionCable.Subscriptions - constructor: (@consumer) -> - @subscriptions = [] - - create: (channelName, mixin) -> - channel = channelName - params = if typeof channel is "object" then channel else {channel} - subscription = new ActionCable.Subscription @consumer, params, mixin - @add(subscription) - - # Private - - add: (subscription) -> - @subscriptions.push(subscription) - @consumer.ensureActiveConnection() - @notify(subscription, "initialized") - @sendCommand(subscription, "subscribe") - subscription - - remove: (subscription) -> - @forget(subscription) - unless @findAll(subscription.identifier).length - @sendCommand(subscription, "unsubscribe") - subscription - - reject: (identifier) -> - for subscription in @findAll(identifier) - @forget(subscription) - @notify(subscription, "rejected") - subscription - - forget: (subscription) -> - @subscriptions = (s for s in @subscriptions when s isnt subscription) - subscription - - findAll: (identifier) -> - s for s in @subscriptions when s.identifier is identifier - - reload: -> - for subscription in @subscriptions - @sendCommand(subscription, "subscribe") - - notifyAll: (callbackName, args...) -> - for subscription in @subscriptions - @notify(subscription, callbackName, args...) - - notify: (subscription, callbackName, args...) -> - if typeof subscription is "string" - subscriptions = @findAll(subscription) - else - subscriptions = [subscription] - - for subscription in subscriptions - subscription[callbackName]?(args...) - - sendCommand: (subscription, command) -> - {identifier} = subscription - @consumer.send({command, identifier}) diff --git a/actioncable/app/assets/javascripts/actioncable.esm.js b/actioncable/app/assets/javascripts/actioncable.esm.js new file mode 100644 index 0000000000000..18320091e55e2 --- /dev/null +++ b/actioncable/app/assets/javascripts/actioncable.esm.js @@ -0,0 +1,512 @@ +var adapters = { + logger: typeof console !== "undefined" ? console : undefined, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined +}; + +var logger = { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()); + adapters.logger.log("[ActionCable]", ...messages); + } + } +}; + +const now = () => (new Date).getTime(); + +const secondsSince = time => (now() - time) / 1e3; + +class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; + } + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener("visibilitychange", this.visibilityDidChange); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); + } + } + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener("visibilitychange", this.visibilityDidChange); + logger.log("ConnectionMonitor stopped"); + } + } + isRunning() { + return this.startedAt && !this.stoppedAt; + } + recordMessage() { + this.pingedAt = now(); + } + recordConnect() { + this.reconnectAttempts = 0; + delete this.disconnectedAt; + logger.log("ConnectionMonitor recorded connect"); + } + recordDisconnect() { + this.disconnectedAt = now(); + logger.log("ConnectionMonitor recorded disconnect"); + } + startPolling() { + this.stopPolling(); + this.poll(); + } + stopPolling() { + clearTimeout(this.pollTimeout); + } + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1e3 * backoff * (1 + jitter); + } + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); + this.reconnectAttempts++; + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); + } else { + logger.log("ConnectionMonitor reopening"); + this.connection.reopen(); + } + } + } + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + disconnectedRecently() { + return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; + } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); + } + }), 200); + } + } +} + +ConnectionMonitor.staleThreshold = 6; + +ConnectionMonitor.reconnectionBackoffRate = .15; + +var INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" + }, + default_mount_path: "/cable", + protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] +}; + +const {message_types: message_types, protocols: protocols} = INTERNAL; + +const supportedProtocols = protocols.slice(0, protocols.length - 1); + +const indexOf = [].indexOf; + +class Connection { + constructor(consumer) { + this.open = this.open.bind(this); + this.consumer = consumer; + this.subscriptions = this.consumer.subscriptions; + this.monitor = new ConnectionMonitor(this); + this.disconnected = true; + } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)); + return true; + } else { + return false; + } + } + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); + return false; + } else { + const socketProtocols = [ ...protocols, ...this.consumer.subprotocols || [] ]; + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`); + if (this.webSocket) { + this.uninstallEventHandlers(); + } + this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols); + this.installEventHandlers(); + this.monitor.start(); + return true; + } + } + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { + if (!allowReconnect) { + this.monitor.stop(); + } + if (this.isOpen()) { + return this.webSocket.close(); + } + } + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); + if (this.isActive()) { + try { + return this.close(); + } catch (error) { + logger.log("Failed to reopen WebSocket", error); + } finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); + setTimeout(this.open, this.constructor.reopenDelay); + } + } else { + return this.open(); + } + } + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol; + } + } + isOpen() { + return this.isState("open"); + } + isActive() { + return this.isState("open", "connecting"); + } + triedToReconnect() { + return this.monitor.reconnectAttempts > 0; + } + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; + } + isState(...states) { + return indexOf.call(states, this.getState()) >= 0; + } + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase(); + } + } + } + return null; + } + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; + } + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; + } + } +} + +Connection.reopenDelay = 500; + +Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { + return; + } + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); + this.monitor.recordMessage(); + switch (type) { + case message_types.welcome: + if (this.triedToReconnect()) { + this.reconnectAttempted = true; + } + this.monitor.recordConnect(); + return this.subscriptions.reload(); + + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`); + return this.close({ + allowReconnect: reconnect + }); + + case message_types.ping: + return null; + + case message_types.confirmation: + this.subscriptions.confirmSubscription(identifier); + if (this.reconnectAttempted) { + this.reconnectAttempted = false; + return this.subscriptions.notify(identifier, "connected", { + reconnected: true + }); + } else { + return this.subscriptions.notify(identifier, "connected", { + reconnected: false + }); + } + + case message_types.rejection: + return this.subscriptions.reject(identifier); + + default: + return this.subscriptions.notify(identifier, "received", message); + } + }, + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); + this.disconnected = false; + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); + return this.close({ + allowReconnect: false + }); + } + }, + close(event) { + logger.log("WebSocket onclose event"); + if (this.disconnected) { + return; + } + this.disconnected = true; + this.monitor.recordDisconnect(); + return this.subscriptions.notifyAll("disconnected", { + willAttemptReconnect: this.monitor.isRunning() + }); + }, + error() { + logger.log("WebSocket onerror event"); + } +}; + +const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key]; + object[key] = value; + } + } + return object; +}; + +class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer; + this.identifier = JSON.stringify(params); + extend(this, mixin); + } + perform(action, data = {}) { + data.action = action; + return this.send(data); + } + send(data) { + return this.consumer.send({ + command: "message", + identifier: this.identifier, + data: JSON.stringify(data) + }); + } + unsubscribe() { + return this.consumer.subscriptions.remove(this); + } +} + +class SubscriptionGuarantor { + constructor(subscriptions) { + this.subscriptions = subscriptions; + this.pendingSubscriptions = []; + } + guarantee(subscription) { + if (this.pendingSubscriptions.indexOf(subscription) == -1) { + logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`); + this.pendingSubscriptions.push(subscription); + } else { + logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`); + } + this.startGuaranteeing(); + } + forget(subscription) { + logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`); + this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription)); + } + startGuaranteeing() { + this.stopGuaranteeing(); + this.retrySubscribing(); + } + stopGuaranteeing() { + clearTimeout(this.retryTimeout); + } + retrySubscribing() { + this.retryTimeout = setTimeout((() => { + if (this.subscriptions && typeof this.subscriptions.subscribe === "function") { + this.pendingSubscriptions.map((subscription => { + logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`); + this.subscriptions.subscribe(subscription); + })); + } + }), 500); + } +} + +class Subscriptions { + constructor(consumer) { + this.consumer = consumer; + this.guarantor = new SubscriptionGuarantor(this); + this.subscriptions = []; + } + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { + channel: channel + }; + const subscription = new Subscription(this.consumer, params, mixin); + return this.add(subscription); + } + add(subscription) { + this.subscriptions.push(subscription); + this.consumer.ensureActiveConnection(); + this.notify(subscription, "initialized"); + this.subscribe(subscription); + return subscription; + } + remove(subscription) { + this.forget(subscription); + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe"); + } + return subscription; + } + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); + return subscription; + })); + } + forget(subscription) { + this.guarantor.forget(subscription); + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); + return subscription; + } + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); + } + reload() { + return this.subscriptions.map((subscription => this.subscribe(subscription))); + } + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); + } + notify(subscription, callbackName, ...args) { + let subscriptions; + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription); + } else { + subscriptions = [ subscription ]; + } + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)); + } + subscribe(subscription) { + if (this.sendCommand(subscription, "subscribe")) { + this.guarantor.guarantee(subscription); + } + } + confirmSubscription(identifier) { + logger.log(`Subscription confirmed ${identifier}`); + this.findAll(identifier).map((subscription => this.guarantor.forget(subscription))); + } + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; + return this.consumer.send({ + command: command, + identifier: identifier + }); + } +} + +class Consumer { + constructor(url) { + this._url = url; + this.subscriptions = new Subscriptions(this); + this.connection = new Connection(this); + this.subprotocols = []; + } + get url() { + return createWebSocketURL(this._url); + } + send(data) { + return this.connection.send(data); + } + connect() { + return this.connection.open(); + } + disconnect() { + return this.connection.close({ + allowReconnect: false + }); + } + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open(); + } + } + addSubProtocol(subprotocol) { + this.subprotocols = [ ...this.subprotocols, subprotocol ]; + } +} + +function createWebSocketURL(url) { + if (typeof url === "function") { + url = url(); + } + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a"); + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace("http", "ws"); + return a.href; + } else { + return url; + } +} + +function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url); +} + +function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); + if (element) { + return element.getAttribute("content"); + } +} + +export { Connection, ConnectionMonitor, Consumer, INTERNAL, Subscription, SubscriptionGuarantor, Subscriptions, adapters, createConsumer, createWebSocketURL, getConfig, logger }; diff --git a/actioncable/app/assets/javascripts/actioncable.js b/actioncable/app/assets/javascripts/actioncable.js new file mode 100644 index 0000000000000..5fc994339fb36 --- /dev/null +++ b/actioncable/app/assets/javascripts/actioncable.js @@ -0,0 +1,510 @@ +(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.ActionCable = {})); +})(this, (function(exports) { + "use strict"; + var adapters = { + logger: typeof console !== "undefined" ? console : undefined, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined + }; + var logger = { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()); + adapters.logger.log("[ActionCable]", ...messages); + } + } + }; + const now = () => (new Date).getTime(); + const secondsSince = time => (now() - time) / 1e3; + class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; + } + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener("visibilitychange", this.visibilityDidChange); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); + } + } + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener("visibilitychange", this.visibilityDidChange); + logger.log("ConnectionMonitor stopped"); + } + } + isRunning() { + return this.startedAt && !this.stoppedAt; + } + recordMessage() { + this.pingedAt = now(); + } + recordConnect() { + this.reconnectAttempts = 0; + delete this.disconnectedAt; + logger.log("ConnectionMonitor recorded connect"); + } + recordDisconnect() { + this.disconnectedAt = now(); + logger.log("ConnectionMonitor recorded disconnect"); + } + startPolling() { + this.stopPolling(); + this.poll(); + } + stopPolling() { + clearTimeout(this.pollTimeout); + } + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1e3 * backoff * (1 + jitter); + } + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); + this.reconnectAttempts++; + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); + } else { + logger.log("ConnectionMonitor reopening"); + this.connection.reopen(); + } + } + } + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + disconnectedRecently() { + return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; + } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); + } + }), 200); + } + } + } + ConnectionMonitor.staleThreshold = 6; + ConnectionMonitor.reconnectionBackoffRate = .15; + var INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" + }, + default_mount_path: "/cable", + protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] + }; + const {message_types: message_types, protocols: protocols} = INTERNAL; + const supportedProtocols = protocols.slice(0, protocols.length - 1); + const indexOf = [].indexOf; + class Connection { + constructor(consumer) { + this.open = this.open.bind(this); + this.consumer = consumer; + this.subscriptions = this.consumer.subscriptions; + this.monitor = new ConnectionMonitor(this); + this.disconnected = true; + } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)); + return true; + } else { + return false; + } + } + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); + return false; + } else { + const socketProtocols = [ ...protocols, ...this.consumer.subprotocols || [] ]; + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`); + if (this.webSocket) { + this.uninstallEventHandlers(); + } + this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols); + this.installEventHandlers(); + this.monitor.start(); + return true; + } + } + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { + if (!allowReconnect) { + this.monitor.stop(); + } + if (this.isOpen()) { + return this.webSocket.close(); + } + } + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); + if (this.isActive()) { + try { + return this.close(); + } catch (error) { + logger.log("Failed to reopen WebSocket", error); + } finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); + setTimeout(this.open, this.constructor.reopenDelay); + } + } else { + return this.open(); + } + } + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol; + } + } + isOpen() { + return this.isState("open"); + } + isActive() { + return this.isState("open", "connecting"); + } + triedToReconnect() { + return this.monitor.reconnectAttempts > 0; + } + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; + } + isState(...states) { + return indexOf.call(states, this.getState()) >= 0; + } + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase(); + } + } + } + return null; + } + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; + } + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; + } + } + } + Connection.reopenDelay = 500; + Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { + return; + } + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); + this.monitor.recordMessage(); + switch (type) { + case message_types.welcome: + if (this.triedToReconnect()) { + this.reconnectAttempted = true; + } + this.monitor.recordConnect(); + return this.subscriptions.reload(); + + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`); + return this.close({ + allowReconnect: reconnect + }); + + case message_types.ping: + return null; + + case message_types.confirmation: + this.subscriptions.confirmSubscription(identifier); + if (this.reconnectAttempted) { + this.reconnectAttempted = false; + return this.subscriptions.notify(identifier, "connected", { + reconnected: true + }); + } else { + return this.subscriptions.notify(identifier, "connected", { + reconnected: false + }); + } + + case message_types.rejection: + return this.subscriptions.reject(identifier); + + default: + return this.subscriptions.notify(identifier, "received", message); + } + }, + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); + this.disconnected = false; + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); + return this.close({ + allowReconnect: false + }); + } + }, + close(event) { + logger.log("WebSocket onclose event"); + if (this.disconnected) { + return; + } + this.disconnected = true; + this.monitor.recordDisconnect(); + return this.subscriptions.notifyAll("disconnected", { + willAttemptReconnect: this.monitor.isRunning() + }); + }, + error() { + logger.log("WebSocket onerror event"); + } + }; + const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key]; + object[key] = value; + } + } + return object; + }; + class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer; + this.identifier = JSON.stringify(params); + extend(this, mixin); + } + perform(action, data = {}) { + data.action = action; + return this.send(data); + } + send(data) { + return this.consumer.send({ + command: "message", + identifier: this.identifier, + data: JSON.stringify(data) + }); + } + unsubscribe() { + return this.consumer.subscriptions.remove(this); + } + } + class SubscriptionGuarantor { + constructor(subscriptions) { + this.subscriptions = subscriptions; + this.pendingSubscriptions = []; + } + guarantee(subscription) { + if (this.pendingSubscriptions.indexOf(subscription) == -1) { + logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`); + this.pendingSubscriptions.push(subscription); + } else { + logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`); + } + this.startGuaranteeing(); + } + forget(subscription) { + logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`); + this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription)); + } + startGuaranteeing() { + this.stopGuaranteeing(); + this.retrySubscribing(); + } + stopGuaranteeing() { + clearTimeout(this.retryTimeout); + } + retrySubscribing() { + this.retryTimeout = setTimeout((() => { + if (this.subscriptions && typeof this.subscriptions.subscribe === "function") { + this.pendingSubscriptions.map((subscription => { + logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`); + this.subscriptions.subscribe(subscription); + })); + } + }), 500); + } + } + class Subscriptions { + constructor(consumer) { + this.consumer = consumer; + this.guarantor = new SubscriptionGuarantor(this); + this.subscriptions = []; + } + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { + channel: channel + }; + const subscription = new Subscription(this.consumer, params, mixin); + return this.add(subscription); + } + add(subscription) { + this.subscriptions.push(subscription); + this.consumer.ensureActiveConnection(); + this.notify(subscription, "initialized"); + this.subscribe(subscription); + return subscription; + } + remove(subscription) { + this.forget(subscription); + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe"); + } + return subscription; + } + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); + return subscription; + })); + } + forget(subscription) { + this.guarantor.forget(subscription); + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); + return subscription; + } + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); + } + reload() { + return this.subscriptions.map((subscription => this.subscribe(subscription))); + } + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); + } + notify(subscription, callbackName, ...args) { + let subscriptions; + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription); + } else { + subscriptions = [ subscription ]; + } + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)); + } + subscribe(subscription) { + if (this.sendCommand(subscription, "subscribe")) { + this.guarantor.guarantee(subscription); + } + } + confirmSubscription(identifier) { + logger.log(`Subscription confirmed ${identifier}`); + this.findAll(identifier).map((subscription => this.guarantor.forget(subscription))); + } + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; + return this.consumer.send({ + command: command, + identifier: identifier + }); + } + } + class Consumer { + constructor(url) { + this._url = url; + this.subscriptions = new Subscriptions(this); + this.connection = new Connection(this); + this.subprotocols = []; + } + get url() { + return createWebSocketURL(this._url); + } + send(data) { + return this.connection.send(data); + } + connect() { + return this.connection.open(); + } + disconnect() { + return this.connection.close({ + allowReconnect: false + }); + } + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open(); + } + } + addSubProtocol(subprotocol) { + this.subprotocols = [ ...this.subprotocols, subprotocol ]; + } + } + function createWebSocketURL(url) { + if (typeof url === "function") { + url = url(); + } + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a"); + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace("http", "ws"); + return a.href; + } else { + return url; + } + } + function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url); + } + function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); + if (element) { + return element.getAttribute("content"); + } + } + exports.Connection = Connection; + exports.ConnectionMonitor = ConnectionMonitor; + exports.Consumer = Consumer; + exports.INTERNAL = INTERNAL; + exports.Subscription = Subscription; + exports.SubscriptionGuarantor = SubscriptionGuarantor; + exports.Subscriptions = Subscriptions; + exports.adapters = adapters; + exports.createConsumer = createConsumer; + exports.createWebSocketURL = createWebSocketURL; + exports.getConfig = getConfig; + exports.logger = logger; + Object.defineProperty(exports, "__esModule", { + value: true + }); +})); diff --git a/actioncable/app/javascript/action_cable/adapters.js b/actioncable/app/javascript/action_cable/adapters.js new file mode 100644 index 0000000000000..f9759de9a05dd --- /dev/null +++ b/actioncable/app/javascript/action_cable/adapters.js @@ -0,0 +1,4 @@ +export default { + logger: typeof console !== "undefined" ? console : undefined, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined, +} diff --git a/actioncable/app/javascript/action_cable/connection.js b/actioncable/app/javascript/action_cable/connection.js new file mode 100644 index 0000000000000..fa32cfd5a675f --- /dev/null +++ b/actioncable/app/javascript/action_cable/connection.js @@ -0,0 +1,181 @@ +import adapters from "./adapters" +import ConnectionMonitor from "./connection_monitor" +import INTERNAL from "./internal" +import logger from "./logger" + +// Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. + +const {message_types, protocols} = INTERNAL +const supportedProtocols = protocols.slice(0, protocols.length - 1) + +const indexOf = [].indexOf + +class Connection { + constructor(consumer) { + this.open = this.open.bind(this) + this.consumer = consumer + this.subscriptions = this.consumer.subscriptions + this.monitor = new ConnectionMonitor(this) + this.disconnected = true + } + + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)) + return true + } else { + return false + } + } + + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`) + return false + } else { + const socketProtocols = [...protocols, ...this.consumer.subprotocols || []] + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`) + if (this.webSocket) { this.uninstallEventHandlers() } + this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols) + this.installEventHandlers() + this.monitor.start() + return true + } + } + + close({allowReconnect} = {allowReconnect: true}) { + if (!allowReconnect) { this.monitor.stop() } + // Avoid closing websockets in a "connecting" state due to Safari 15.1+ bug. See: https://github.com/rails/rails/issues/43835#issuecomment-1002288478 + if (this.isOpen()) { + return this.webSocket.close() + } + } + + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`) + if (this.isActive()) { + try { + return this.close() + } catch (error) { + logger.log("Failed to reopen WebSocket", error) + } + finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`) + setTimeout(this.open, this.constructor.reopenDelay) + } + } else { + return this.open() + } + } + + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol + } + } + + isOpen() { + return this.isState("open") + } + + isActive() { + return this.isState("open", "connecting") + } + + triedToReconnect() { + return this.monitor.reconnectAttempts > 0 + } + + // Private + + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0 + } + + isState(...states) { + return indexOf.call(states, this.getState()) >= 0 + } + + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase() + } + } + } + return null + } + + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this) + this.webSocket[`on${eventName}`] = handler + } + } + + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {} + } + } + +} + +Connection.reopenDelay = 500 + +Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { return } + const {identifier, message, reason, reconnect, type} = JSON.parse(event.data) + this.monitor.recordMessage() + switch (type) { + case message_types.welcome: + if (this.triedToReconnect()) { + this.reconnectAttempted = true + } + this.monitor.recordConnect() + return this.subscriptions.reload() + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`) + return this.close({allowReconnect: reconnect}) + case message_types.ping: + return null + case message_types.confirmation: + this.subscriptions.confirmSubscription(identifier) + if (this.reconnectAttempted) { + this.reconnectAttempted = false + return this.subscriptions.notify(identifier, "connected", {reconnected: true}) + } else { + return this.subscriptions.notify(identifier, "connected", {reconnected: false}) + } + case message_types.rejection: + return this.subscriptions.reject(identifier) + default: + return this.subscriptions.notify(identifier, "received", message) + } + }, + + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`) + this.disconnected = false + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting.") + return this.close({allowReconnect: false}) + } + }, + + close(event) { + logger.log("WebSocket onclose event") + if (this.disconnected) { return } + this.disconnected = true + this.monitor.recordDisconnect() + return this.subscriptions.notifyAll("disconnected", {willAttemptReconnect: this.monitor.isRunning()}) + }, + + error() { + logger.log("WebSocket onerror event") + } +} + +export default Connection diff --git a/actioncable/app/javascript/action_cable/connection_monitor.js b/actioncable/app/javascript/action_cable/connection_monitor.js new file mode 100644 index 0000000000000..986d1408e0ba2 --- /dev/null +++ b/actioncable/app/javascript/action_cable/connection_monitor.js @@ -0,0 +1,124 @@ +import logger from "./logger" + +// Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting +// revival reconnections if things go astray. Internal class, not intended for direct user manipulation. + +const now = () => new Date().getTime() + +const secondsSince = time => (now() - time) / 1000 + +class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this) + this.connection = connection + this.reconnectAttempts = 0 + } + + start() { + if (!this.isRunning()) { + this.startedAt = now() + delete this.stoppedAt + this.startPolling() + addEventListener("visibilitychange", this.visibilityDidChange) + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`) + } + } + + stop() { + if (this.isRunning()) { + this.stoppedAt = now() + this.stopPolling() + removeEventListener("visibilitychange", this.visibilityDidChange) + logger.log("ConnectionMonitor stopped") + } + } + + isRunning() { + return this.startedAt && !this.stoppedAt + } + + recordMessage() { + this.pingedAt = now() + } + + recordConnect() { + this.reconnectAttempts = 0 + delete this.disconnectedAt + logger.log("ConnectionMonitor recorded connect") + } + + recordDisconnect() { + this.disconnectedAt = now() + logger.log("ConnectionMonitor recorded disconnect") + } + + // Private + + startPolling() { + this.stopPolling() + this.poll() + } + + stopPolling() { + clearTimeout(this.pollTimeout) + } + + poll() { + this.pollTimeout = setTimeout(() => { + this.reconnectIfStale() + this.poll() + } + , this.getPollInterval()) + } + + getPollInterval() { + const { staleThreshold, reconnectionBackoffRate } = this.constructor + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)) + const jitterMax = this.reconnectAttempts === 0 ? 1.0 : reconnectionBackoffRate + const jitter = jitterMax * Math.random() + return staleThreshold * 1000 * backoff * (1 + jitter) + } + + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`) + this.reconnectAttempts++ + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`) + } else { + logger.log("ConnectionMonitor reopening") + this.connection.reopen() + } + } + } + + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt + } + + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold + } + + disconnectedRecently() { + return this.disconnectedAt && (secondsSince(this.disconnectedAt) < this.constructor.staleThreshold) + } + + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout(() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`) + this.connection.reopen() + } + } + , 200) + } + } + +} + +ConnectionMonitor.staleThreshold = 6 // Server::Connections::BEAT_INTERVAL * 2 (missed two pings) +ConnectionMonitor.reconnectionBackoffRate = 0.15 + +export default ConnectionMonitor diff --git a/actioncable/app/javascript/action_cable/consumer.js b/actioncable/app/javascript/action_cable/consumer.js new file mode 100644 index 0000000000000..71ef125a45537 --- /dev/null +++ b/actioncable/app/javascript/action_cable/consumer.js @@ -0,0 +1,80 @@ +import Connection from "./connection" +import Subscriptions from "./subscriptions" + +// The ActionCable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, +// the ActionCable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. +// The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription +// method. +// +// The following example shows how this can be set up: +// +// App = {} +// App.cable = ActionCable.createConsumer("ws://example.com/accounts/1") +// App.appearance = App.cable.subscriptions.create("AppearanceChannel") +// +// For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. +// +// When a consumer is created, it automatically connects with the server. +// +// To disconnect from the server, call +// +// App.cable.disconnect() +// +// and to restart the connection: +// +// App.cable.connect() +// +// Any channel subscriptions which existed prior to disconnecting will +// automatically resubscribe. + +export default class Consumer { + constructor(url) { + this._url = url + this.subscriptions = new Subscriptions(this) + this.connection = new Connection(this) + this.subprotocols = [] + } + + get url() { + return createWebSocketURL(this._url) + } + + send(data) { + return this.connection.send(data) + } + + connect() { + return this.connection.open() + } + + disconnect() { + return this.connection.close({allowReconnect: false}) + } + + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open() + } + } + + addSubProtocol(subprotocol) { + this.subprotocols = [...this.subprotocols, subprotocol] + } +} + +export function createWebSocketURL(url) { + if (typeof url === "function") { + url = url() + } + + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a") + a.href = url + // Fix populating Location properties in IE. Otherwise, protocol will be blank. + a.href = a.href // eslint-disable-line + a.protocol = a.protocol.replace("http", "ws") + return a.href + } else { + return url + } +} diff --git a/actioncable/app/javascript/action_cable/index.js b/actioncable/app/javascript/action_cable/index.js new file mode 100644 index 0000000000000..62b904f5f7ef3 --- /dev/null +++ b/actioncable/app/javascript/action_cable/index.js @@ -0,0 +1,33 @@ +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 SubscriptionGuarantor from "./subscription_guarantor" +import Subscriptions from "./subscriptions" + +export { + Connection, + ConnectionMonitor, + Consumer, + INTERNAL, + Subscription, + Subscriptions, + SubscriptionGuarantor, + adapters, + createWebSocketURL, + logger, +} + +export function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url) +} + +export function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`) + if (element) { + return element.getAttribute("content") + } +} diff --git a/actioncable/app/javascript/action_cable/index_with_name_deprecation.js b/actioncable/app/javascript/action_cable/index_with_name_deprecation.js new file mode 100644 index 0000000000000..272ba7bec2221 --- /dev/null +++ b/actioncable/app/javascript/action_cable/index_with_name_deprecation.js @@ -0,0 +1,2 @@ +export * from "./index" +console.log("DEPRECATION: action_cable.js has been renamed to actioncable.js – please update your reference before Rails 8") diff --git a/actioncable/app/javascript/action_cable/internal.js b/actioncable/app/javascript/action_cable/internal.js new file mode 100644 index 0000000000000..a007d6f471f7a --- /dev/null +++ b/actioncable/app/javascript/action_cable/internal.js @@ -0,0 +1,20 @@ +export default { + "message_types": { + "welcome": "welcome", + "disconnect": "disconnect", + "ping": "ping", + "confirmation": "confirm_subscription", + "rejection": "reject_subscription" + }, + "disconnect_reasons": { + "unauthorized": "unauthorized", + "invalid_request": "invalid_request", + "server_restart": "server_restart", + "remote": "remote" + }, + "default_mount_path": "/cable", + "protocols": [ + "actioncable-v1-json", + "actioncable-unsupported" + ] +} diff --git a/actioncable/app/javascript/action_cable/logger.js b/actioncable/app/javascript/action_cable/logger.js new file mode 100644 index 0000000000000..c73f5bd542fc5 --- /dev/null +++ b/actioncable/app/javascript/action_cable/logger.js @@ -0,0 +1,22 @@ +import adapters from "./adapters" + +// The logger is disabled by default. You can enable it with: +// +// ActionCable.logger.enabled = true +// +// Example: +// +// import * as ActionCable from '@rails/actioncable' +// +// ActionCable.logger.enabled = true +// ActionCable.logger.log('Connection Established.') +// + +export default { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()) + adapters.logger.log("[ActionCable]", ...messages) + } + }, +} diff --git a/actioncable/app/javascript/action_cable/subscription.js b/actioncable/app/javascript/action_cable/subscription.js new file mode 100644 index 0000000000000..7de08f93b3de5 --- /dev/null +++ b/actioncable/app/javascript/action_cable/subscription.js @@ -0,0 +1,89 @@ +// A new subscription is created through the ActionCable.Subscriptions instance available on the consumer. +// It provides a number of callbacks and a method for calling remote procedure calls on the corresponding +// Channel instance on the server side. +// +// An example demonstrates the basic functionality: +// +// App.appearance = App.cable.subscriptions.create("AppearanceChannel", { +// connected() { +// // Called once the subscription has been successfully completed +// }, +// +// disconnected({ willAttemptReconnect: boolean }) { +// // Called when the client has disconnected with the server. +// // The object will have an `willAttemptReconnect` property which +// // says whether the client has the intention of attempting +// // to reconnect. +// }, +// +// appear() { +// this.perform('appear', {appearing_on: this.appearingOn()}) +// }, +// +// away() { +// this.perform('away') +// }, +// +// appearingOn() { +// $('main').data('appearing-on') +// } +// }) +// +// The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server +// by calling the `perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). +// The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. +// +// This is how the server component would look: +// +// class AppearanceChannel < ApplicationActionCable::Channel +// def subscribed +// current_user.appear +// end +// +// def unsubscribed +// current_user.disappear +// end +// +// def appear(data) +// current_user.appear on: data['appearing_on'] +// end +// +// def away +// current_user.away +// end +// end +// +// The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name. +// The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the perform method. + +const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key] + object[key] = value + } + } + return object +} + +export default class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer + this.identifier = JSON.stringify(params) + extend(this, mixin) + } + + // Perform a channel action with the optional data passed as an attribute + perform(action, data = {}) { + data.action = action + return this.send(data) + } + + send(data) { + return this.consumer.send({command: "message", identifier: this.identifier, data: JSON.stringify(data)}) + } + + unsubscribe() { + return this.consumer.subscriptions.remove(this) + } +} diff --git a/actioncable/app/javascript/action_cable/subscription_guarantor.js b/actioncable/app/javascript/action_cable/subscription_guarantor.js new file mode 100644 index 0000000000000..7d6ad98fa3904 --- /dev/null +++ b/actioncable/app/javascript/action_cable/subscription_guarantor.js @@ -0,0 +1,50 @@ +import logger from "./logger" + +// Responsible for ensuring channel subscribe command is confirmed, retrying until confirmation is received. +// Internal class, not intended for direct user manipulation. + +class SubscriptionGuarantor { + constructor(subscriptions) { + this.subscriptions = subscriptions + this.pendingSubscriptions = [] + } + + guarantee(subscription) { + if(this.pendingSubscriptions.indexOf(subscription) == -1){ + logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`) + this.pendingSubscriptions.push(subscription) + } + else { + logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`) + } + this.startGuaranteeing() + } + + forget(subscription) { + logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`) + this.pendingSubscriptions = (this.pendingSubscriptions.filter((s) => s !== subscription)) + } + + startGuaranteeing() { + this.stopGuaranteeing() + this.retrySubscribing() + } + + stopGuaranteeing() { + clearTimeout(this.retryTimeout) + } + + retrySubscribing() { + this.retryTimeout = setTimeout(() => { + if (this.subscriptions && typeof(this.subscriptions.subscribe) === "function") { + this.pendingSubscriptions.map((subscription) => { + logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`) + this.subscriptions.subscribe(subscription) + }) + } + } + , 500) + } +} + +export default SubscriptionGuarantor \ No newline at end of file diff --git a/actioncable/app/javascript/action_cable/subscriptions.js b/actioncable/app/javascript/action_cable/subscriptions.js new file mode 100644 index 0000000000000..0f166057ad6ea --- /dev/null +++ b/actioncable/app/javascript/action_cable/subscriptions.js @@ -0,0 +1,103 @@ +import logger from "./logger" +import Subscription from "./subscription" +import SubscriptionGuarantor from "./subscription_guarantor" + +// Collection class for creating (and internally managing) channel subscriptions. +// The only method intended to be triggered by the user is ActionCable.Subscriptions#create, +// and it should be called through the consumer like so: +// +// App = {} +// App.cable = ActionCable.createConsumer("ws://example.com/accounts/1") +// App.appearance = App.cable.subscriptions.create("AppearanceChannel") +// +// For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. + +export default class Subscriptions { + constructor(consumer) { + this.consumer = consumer + this.guarantor = new SubscriptionGuarantor(this) + this.subscriptions = [] + } + + create(channelName, mixin) { + const channel = channelName + const params = typeof channel === "object" ? channel : {channel} + const subscription = new Subscription(this.consumer, params, mixin) + return this.add(subscription) + } + + // Private + + add(subscription) { + this.subscriptions.push(subscription) + this.consumer.ensureActiveConnection() + this.notify(subscription, "initialized") + this.subscribe(subscription) + return subscription + } + + remove(subscription) { + this.forget(subscription) + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe") + } + return subscription + } + + reject(identifier) { + return this.findAll(identifier).map((subscription) => { + this.forget(subscription) + this.notify(subscription, "rejected") + return subscription + }) + } + + forget(subscription) { + this.guarantor.forget(subscription) + this.subscriptions = (this.subscriptions.filter((s) => s !== subscription)) + return subscription + } + + findAll(identifier) { + return this.subscriptions.filter((s) => s.identifier === identifier) + } + + reload() { + return this.subscriptions.map((subscription) => + this.subscribe(subscription)) + } + + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription) => + this.notify(subscription, callbackName, ...args)) + } + + notify(subscription, callbackName, ...args) { + let subscriptions + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription) + } else { + subscriptions = [subscription] + } + + return subscriptions.map((subscription) => + (typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)) + } + + subscribe(subscription) { + if (this.sendCommand(subscription, "subscribe")) { + this.guarantor.guarantee(subscription) + } + } + + confirmSubscription(identifier) { + logger.log(`Subscription confirmed ${identifier}`) + this.findAll(identifier).map((subscription) => + this.guarantor.forget(subscription)) + } + + sendCommand(subscription, command) { + const {identifier} = subscription + return this.consumer.send({command, identifier}) + } +} diff --git a/actioncable/bin/test b/actioncable/bin/test index a7beb14b271a3..c53377cc970f4 100755 --- a/actioncable/bin/test +++ b/actioncable/bin/test @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true COMPONENT_ROOT = File.expand_path("..", __dir__) -require File.expand_path("../tools/test", COMPONENT_ROOT) +require_relative "../../tools/test" diff --git a/actioncable/blade.yml b/actioncable/blade.yml deleted file mode 100644 index e38e9b2f1d348..0000000000000 --- a/actioncable/blade.yml +++ /dev/null @@ -1,34 +0,0 @@ -load_paths: - - app/assets/javascripts - - test/javascript/src - - test/javascript/vendor - -logical_paths: - - test.js - -build: - logical_paths: - - action_cable.js - path: lib/assets/compiled - clean: true - -plugins: - sauce_labs: - browsers: - Google Chrome: - os: Mac, Windows - version: -1 - Firefox: - os: Mac, Windows - version: -1 - Safari: - platform: Mac - version: -1 - Microsoft Edge: - version: -1 - Internet Explorer: - version: 11 - iPhone: - version: -1 - Motorola Droid 4 Emulator: - version: -1 diff --git a/actioncable/karma.conf.js b/actioncable/karma.conf.js new file mode 100644 index 0000000000000..a6e03f8023760 --- /dev/null +++ b/actioncable/karma.conf.js @@ -0,0 +1,63 @@ +const config = { + browsers: ["ChromeHeadless"], + frameworks: ["qunit"], + files: [ + "test/javascript/compiled/test.js", + ], + + client: { + clearContext: false, + qunit: { + showUI: true + } + }, + + singleRun: true, + autoWatch: false, + + captureTimeout: 180000, + browserDisconnectTimeout: 180000, + browserDisconnectTolerance: 3, + browserNoActivityTimeout: 300000, +} + +if (process.env.CI) { + config.customLaunchers = { + sl_chrome: sauce("chrome", 70), + sl_ff: sauce("firefox", 63), + sl_safari: sauce("safari", "16", "macOS 13"), + sl_edge: sauce("microsoftedge", "latest", "Windows 11"), + } + + config.browsers = Object.keys(config.customLaunchers) + config.reporters = ["dots", "saucelabs"] + + config.sauceLabs = { + testName: "ActionCable JS Client", + retryLimit: 3, + build: buildId(), + } + + function sauce(browserName, version, platform) { + const options = { + base: "SauceLabs", + browserName: browserName.toString(), + version: version.toString(), + } + if (platform) { + options.platform = platform.toString() + } + return options + } + + function buildId() { + const { BUILDKITE_JOB_ID } = process.env + return BUILDKITE_JOB_ID + ? `Buildkite ${BUILDKITE_JOB_ID}` + : "" + } +} + +module.exports = function(karmaConfig) { + karmaConfig.set(config) +} diff --git a/actioncable/lib/action_cable.rb b/actioncable/lib/action_cable.rb index c2d3550acbd24..043e1395b1236 100644 --- a/actioncable/lib/action_cable.rb +++ b/actioncable/lib/action_cable.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + #-- -# Copyright (c) 2015-2017 Basecamp, LLC +# Copyright (c) 37signals LLC # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -23,30 +25,56 @@ require "active_support" require "active_support/rails" -require "action_cable/version" +require "zeitwerk" + +# We compute lib this way instead of using __dir__ because __dir__ gives a real +# path, while __FILE__ honors symlinks. If the gem is stored under a symlinked +# directory, this matters. +lib = File.dirname(__FILE__) + +Zeitwerk::Loader.for_gem.tap do |loader| + loader.ignore( + "#{lib}/rails", # Contains generators, templates, docs, etc. + "#{lib}/action_cable/gem_version.rb", + "#{lib}/action_cable/version.rb", + "#{lib}/action_cable/deprecator.rb", + ) + + loader.do_not_eager_load( + "#{lib}/action_cable/subscription_adapter", # Adapters are required and loaded on demand. + "#{lib}/action_cable/test_helper.rb", + Dir["#{lib}/action_cable/**/test_case.rb"] + ) + loader.inflector.inflect("postgresql" => "PostgreSQL") +end.setup + +# :markup: markdown +# :include: ../README.md module ActionCable - extend ActiveSupport::Autoload + require_relative "action_cable/version" + require_relative "action_cable/deprecator" INTERNAL = { message_types: { - welcome: "welcome".freeze, - ping: "ping".freeze, - confirmation: "confirm_subscription".freeze, - rejection: "reject_subscription".freeze + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" }, - default_mount_path: "/cable".freeze, - protocols: ["actioncable-v1-json".freeze, "actioncable-unsupported".freeze].freeze + default_mount_path: "/cable", + protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze } # Singleton instance of the server module_function def server @server ||= ActionCable::Server::Base.new end - - autoload :Server - autoload :Connection - autoload :Channel - autoload :RemoteConnections - autoload :SubscriptionAdapter end diff --git a/actioncable/lib/action_cable/channel.rb b/actioncable/lib/action_cable/channel.rb deleted file mode 100644 index 7ae262ce5f47a..0000000000000 --- a/actioncable/lib/action_cable/channel.rb +++ /dev/null @@ -1,14 +0,0 @@ -module ActionCable - module Channel - extend ActiveSupport::Autoload - - eager_autoload do - autoload :Base - autoload :Broadcasting - autoload :Callbacks - autoload :Naming - autoload :PeriodicTimers - autoload :Streams - end - end -end diff --git a/actioncable/lib/action_cable/channel/base.rb b/actioncable/lib/action_cable/channel/base.rb index 718f630f58cf0..de2040769416c 100644 --- a/actioncable/lib/action_cable/channel/base.rb +++ b/actioncable/lib/action_cable/channel/base.rb @@ -1,115 +1,130 @@ -require "set" +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/rescuable" +require "active_support/parameter_filter" module ActionCable module Channel - # The channel provides the basic structure of grouping behavior into logical units when communicating over the WebSocket connection. - # You can think of a channel like a form of controller, but one that's capable of pushing content to the subscriber in addition to simply - # responding to the subscriber's direct requests. + # # Action Cable Channel Base # - # Channel instances are long-lived. A channel object will be instantiated when the cable consumer becomes a subscriber, and then - # lives until the consumer disconnects. This may be seconds, minutes, hours, or even days. That means you have to take special care - # not to do anything silly in a channel that would balloon its memory footprint or whatever. The references are forever, so they won't be released - # as is normally the case with a controller instance that gets thrown away after every request. + # The channel provides the basic structure of grouping behavior into logical + # units when communicating over the WebSocket connection. You can think of a + # channel like a form of controller, but one that's capable of pushing content + # to the subscriber in addition to simply responding to the subscriber's direct + # requests. # - # Long-lived channels (and connections) also mean you're responsible for ensuring that the data is fresh. If you hold a reference to a user - # record, but the name is changed while that reference is held, you may be sending stale data if you don't take precautions to avoid it. + # Channel instances are long-lived. A channel object will be instantiated when + # the cable consumer becomes a subscriber, and then lives until the consumer + # disconnects. This may be seconds, minutes, hours, or even days. That means you + # have to take special care not to do anything silly in a channel that would + # balloon its memory footprint or whatever. The references are forever, so they + # won't be released as is normally the case with a controller instance that gets + # thrown away after every request. # - # The upside of long-lived channel instances is that you can use instance variables to keep reference to objects that future subscriber requests - # can interact with. Here's a quick example: + # Long-lived channels (and connections) also mean you're responsible for + # ensuring that the data is fresh. If you hold a reference to a user record, but + # the name is changed while that reference is held, you may be sending stale + # data if you don't take precautions to avoid it. # - # class ChatChannel < ApplicationCable::Channel - # def subscribed - # @room = Chat::Room[params[:room_number]] - # end + # The upside of long-lived channel instances is that you can use instance + # variables to keep reference to objects that future subscriber requests can + # interact with. Here's a quick example: # - # def speak(data) - # @room.speak data, user: current_user + # class ChatChannel < ApplicationCable::Channel + # def subscribed + # @room = Chat::Room[params[:room_number]] + # end + # + # def speak(data) + # @room.speak data, user: current_user + # end # end - # end # - # The #speak action simply uses the Chat::Room object that was created when the channel was first subscribed to by the consumer when that - # subscriber wants to say something in the room. + # The #speak action simply uses the Chat::Room object that was created when the + # channel was first subscribed to by the consumer when that subscriber wants to + # say something in the room. # - # == Action processing + # ## Action processing # # Unlike subclasses of ActionController::Base, channels do not follow a RESTful # constraint form for their actions. Instead, Action Cable operates through a - # remote-procedure call model. You can declare any public method on the - # channel (optionally taking a data argument), and this method is - # automatically exposed as callable to the client. + # remote-procedure call model. You can declare any public method on the channel + # (optionally taking a `data` argument), and this method is automatically + # exposed as callable to the client. # # Example: # - # class AppearanceChannel < ApplicationCable::Channel - # def subscribed - # @connection_token = generate_connection_token - # end - # - # def unsubscribed - # current_user.disappear @connection_token - # end + # class AppearanceChannel < ApplicationCable::Channel + # def subscribed + # @connection_token = generate_connection_token + # end # - # def appear(data) - # current_user.appear @connection_token, on: data['appearing_on'] - # end + # def unsubscribed + # current_user.disappear @connection_token + # end # - # def away - # current_user.away @connection_token - # end + # def appear(data) + # current_user.appear @connection_token, on: data['appearing_on'] + # end # - # private - # def generate_connection_token - # SecureRandom.hex(36) + # def away + # current_user.away @connection_token # end - # end # - # In this example, the subscribed and unsubscribed methods are not callable methods, as they - # were already declared in ActionCable::Channel::Base, but #appear - # and #away are. #generate_connection_token is also not - # callable, since it's a private method. You'll see that appear accepts a data - # parameter, which it then uses as part of its model call. #away - # does not, since it's simply a trigger action. + # private + # def generate_connection_token + # SecureRandom.hex(36) + # end + # end + # + # In this example, the subscribed and unsubscribed methods are not callable + # methods, as they were already declared in ActionCable::Channel::Base, but + # `#appear` and `#away` are. `#generate_connection_token` is also not callable, + # since it's a private method. You'll see that appear accepts a data parameter, + # which it then uses as part of its model call. `#away` does not, since it's + # simply a trigger action. # - # Also note that in this example, current_user is available because - # it was marked as an identifying attribute on the connection. All such - # identifiers will automatically create a delegation method of the same name - # on the channel instance. + # Also note that in this example, `current_user` is available because it was + # marked as an identifying attribute on the connection. All such identifiers + # will automatically create a delegation method of the same name on the channel + # instance. # - # == Rejecting subscription requests + # ## Rejecting subscription requests # # A channel can reject a subscription request in the #subscribed callback by # invoking the #reject method: # - # class ChatChannel < ApplicationCable::Channel - # def subscribed - # @room = Chat::Room[params[:room_number]] - # reject unless current_user.can_access?(@room) + # class ChatChannel < ApplicationCable::Channel + # def subscribed + # @room = Chat::Room[params[:room_number]] + # reject unless current_user.can_access?(@room) + # end # end - # end # - # In this example, the subscription will be rejected if the - # current_user does not have access to the chat room. On the - # client-side, the Channel#rejected callback will get invoked when - # the server rejects the subscription request. + # In this example, the subscription will be rejected if the `current_user` does + # not have access to the chat room. On the client-side, the `Channel#rejected` + # callback will get invoked when the server rejects the subscription request. class Base include Callbacks include PeriodicTimers include Streams include Naming include Broadcasting + include ActiveSupport::Rescuable attr_reader :params, :connection, :identifier delegate :logger, to: :connection class << self - # A list of method names that should be considered actions. This - # includes all public instance methods on a channel, less - # any internal methods (defined on Base), adding back in - # any methods that are internal, but still exist on the class - # itself. + # A list of method names that should be considered actions. This includes all + # public instance methods on a channel, less any internal methods (defined on + # Base), 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. + # #### 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 @@ -117,15 +132,19 @@ 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 private - # action_methods are cached and there is sometimes need to refresh - # them. ::clear_action_methods! allows you to do that, so next time - # you run action_methods, they will be recalculated. + # action_methods are cached and there is sometimes need to refresh them. + # ::clear_action_methods! allows you to do that, so next time you run + # action_methods, they will be recalculated. def clear_action_methods! # :doc: @action_methods = nil end @@ -135,6 +154,10 @@ def method_added(name) # :doc: super clear_action_methods! end + + def internal_methods + super + end end def initialize(connection, identifier, params = {}) @@ -150,13 +173,14 @@ def initialize(connection, identifier, params = {}) @reject_subscription = nil @subscription_confirmation_sent = nil + @unsubscribed = false delegate_connection_identifiers end - # Extract the action name from the passed data and process it via the channel. The process will ensure - # that the action requested is a public method on the channel declared by the user (so not one of the callbacks - # like #subscribed). + # Extract the action name from the passed data and process it via the channel. + # The process will ensure that the action requested is a public method on the + # channel declared by the user (so not one of the callbacks like #subscribed). def perform_action(data) action = extract_action(data) @@ -170,8 +194,8 @@ def perform_action(data) end end - # This method is called after subscription has been added to the connection - # and confirms or rejects the subscription. + # This method is called after subscription has been added to the connection and + # confirms or rejects the subscription. def subscribe_to_channel run_callbacks :subscribe do subscribed @@ -181,31 +205,43 @@ def subscribe_to_channel ensure_confirmation_sent end - # Called by the cable connection when it's cut, so the channel has a chance to cleanup with callbacks. - # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback. + # Called by the cable connection when it's cut, so the channel has a chance to + # 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 setup any streams - # you want this channel to be sending to the subscriber. + # 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 + # subscriber. def subscribed # :doc: # Override in subclasses end - # Called once a consumer has cut its cable connection. Can be used for cleaning up connections or marking - # users as offline or the like. + # Called once a consumer has cut its cable connection. Can be used for cleaning + # up connections or marking users as offline or the like. def unsubscribed # :doc: # Override in subclasses end - # Transmit a hash of data to the subscriber. The hash will automatically be wrapped in a JSON envelope with - # the proper channel identifier marked as the recipient. + # Transmit a hash of data to the subscriber. The hash will automatically be + # wrapped in a JSON envelope with the proper channel identifier marked as the + # recipient. def transmit(data, via: nil) # :doc: - logger.debug "#{self.class.name} transmitting #{data.inspect.truncate(300)}".tap { |m| m << " (via #{via})" if via } + logger.debug do + status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}" + status += " (via #{via})" if via + status + end payload = { channel_class: self.class.name, data: data, via: via } ActiveSupport::Notifications.instrument("transmit.action_cable", payload) do @@ -256,28 +292,37 @@ def processable_action?(action) end def dispatch_action(action, data) - logger.info action_signature(action, data) + logger.debug action_signature(action, data) if method(action).arity == 1 public_send action, data else public_send action end + rescue Exception => exception + rescue_with_handler(exception) || raise end def action_signature(action, data) - "#{self.class.name}##{action}".tap do |signature| - if (arguments = data.except("action")).any? + (+"#{self.class.name}##{action}").tap do |signature| + arguments = data.except("action") + + if arguments.any? + arguments = parameter_filter.filter(arguments) signature << "(#{arguments.inspect})" end end end + def parameter_filter + @parameter_filter ||= ActiveSupport::ParameterFilter.new(connection.config.filter_parameters) + end + def transmit_subscription_confirmation unless subscription_confirmation_sent? - logger.info "#{self.class.name} is transmitting the subscription confirmation" + logger.debug "#{self.class.name} is transmitting the subscription confirmation" - ActiveSupport::Notifications.instrument("transmit_subscription_confirmation.action_cable", channel_class: self.class.name) do + ActiveSupport::Notifications.instrument("transmit_subscription_confirmation.action_cable", channel_class: self.class.name, identifier: @identifier) do connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation] @subscription_confirmation_sent = true end @@ -290,12 +335,14 @@ def reject_subscription end def transmit_subscription_rejection - logger.info "#{self.class.name} is transmitting the subscription rejection" + logger.debug "#{self.class.name} is transmitting the subscription rejection" - ActiveSupport::Notifications.instrument("transmit_subscription_rejection.action_cable", channel_class: self.class.name) do + ActiveSupport::Notifications.instrument("transmit_subscription_rejection.action_cable", channel_class: self.class.name, identifier: @identifier) do connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection] end end end end end + +ActiveSupport.run_load_hooks(:action_cable_channel, ActionCable::Channel::Base) diff --git a/actioncable/lib/action_cable/channel/broadcasting.rb b/actioncable/lib/action_cable/channel/broadcasting.rb index 23ed4ec943459..4717f914a4fbf 100644 --- a/actioncable/lib/action_cable/channel/broadcasting.rb +++ b/actioncable/lib/action_cable/channel/broadcasting.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/object/to_param" module ActionCable @@ -5,24 +9,41 @@ module Channel module Broadcasting extend ActiveSupport::Concern - delegate :broadcasting_for, to: :class + module ClassMethods + # 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 - class_methods do - # Broadcast a hash to a unique broadcasting for this model in this channel. - def broadcast_to(model, message) - ActionCable.server.broadcast(broadcasting_for([ channel_name, model ]), message) + # Returns a unique broadcasting identifier for this `model` in this channel: + # + # CommentsChannel.broadcasting_for("all") # => "comments:all" + # + # 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(broadcastables) + serialize_broadcasting([ channel_name ] + Array(broadcastables)) end - def broadcasting_for(model) #:nodoc: - case - when model.is_a?(Array) - model.map { |m| broadcasting_for(m) }.join(":") - when model.respond_to?(:to_gid_param) - model.to_gid_param - else - model.to_param + private + def serialize_broadcasting(object) # :nodoc: + case + when object.is_a?(Array) + object.map { |m| serialize_broadcasting(m) }.join(":") + when object.respond_to?(:to_gid_param) + object.to_gid_param + else + object.to_param + end end - end + end + + def broadcasting_for(model) + self.class.broadcasting_for(model) + end + + def broadcast_to(model, message) + self.class.broadcast_to(model, message) end end end diff --git a/actioncable/lib/action_cable/channel/callbacks.rb b/actioncable/lib/action_cable/channel/callbacks.rb index c740132c944ae..8412a5421e260 100644 --- a/actioncable/lib/action_cable/channel/callbacks.rb +++ b/actioncable/lib/action_cable/channel/callbacks.rb @@ -1,21 +1,64 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/callbacks" module ActionCable module Channel + # # Action Cable Channel Callbacks + # + # Action Cable Channel provides callback hooks that are invoked during the life + # cycle of a channel: + # + # * [before_subscribe](rdoc-ref:ClassMethods#before_subscribe) + # * [after_subscribe](rdoc-ref:ClassMethods#after_subscribe) (aliased as + # [on_subscribe](rdoc-ref:ClassMethods#on_subscribe)) + # * [before_unsubscribe](rdoc-ref:ClassMethods#before_unsubscribe) + # * [after_unsubscribe](rdoc-ref:ClassMethods#after_unsubscribe) (aliased as + # [on_unsubscribe](rdoc-ref:ClassMethods#on_unsubscribe)) + # + # + # #### Example + # + # class ChatChannel < ApplicationCable::Channel + # after_subscribe :send_welcome_message, unless: :subscription_rejected? + # after_subscribe :track_subscription + # + # private + # def send_welcome_message + # broadcast_to(...) + # end + # + # def track_subscription + # # ... + # end + # end + # 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 end - class_methods do + module ClassMethods def before_subscribe(*methods, &block) set_callback(:subscribe, :before, *methods, &block) end + # This callback will be triggered after the Base#subscribed method is called, + # even if the subscription was rejected with the Base#reject method. + # + # To trigger the callback only on successful subscriptions, use the + # Base#subscription_rejected? method: + # + # after_subscribe :my_method, unless: :subscription_rejected? + # def after_subscribe(*methods, &block) set_callback(:subscribe, :after, *methods, &block) end @@ -29,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/naming.rb b/actioncable/lib/action_cable/channel/naming.rb index b565cb3cac9b6..9a17fc514bd2a 100644 --- a/actioncable/lib/action_cable/channel/naming.rb +++ b/actioncable/lib/action_cable/channel/naming.rb @@ -1,23 +1,28 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Channel module Naming extend ActiveSupport::Concern - class_methods do - # Returns the name of the channel, underscored, without the Channel ending. - # If the channel is in a namespace, then the namespaces are represented by single + module ClassMethods + # Returns the name of the channel, underscored, without the `Channel` ending. If + # the channel is in a namespace, then the namespaces are represented by single # colon separators in the channel name. # - # ChatChannel.channel_name # => 'chat' - # Chats::AppearancesChannel.channel_name # => 'chats:appearances' - # FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances' + # ChatChannel.channel_name # => 'chat' + # Chats::AppearancesChannel.channel_name # => 'chats:appearances' + # FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances' def channel_name - @channel_name ||= name.sub(/Channel$/, "").gsub("::", ":").underscore + @channel_name ||= name.delete_suffix("Channel").gsub("::", ":").underscore end end - # Delegates to the class' channel_name - delegate :channel_name, to: :class + def channel_name + self.class.channel_name + end end end end diff --git a/actioncable/lib/action_cable/channel/periodic_timers.rb b/actioncable/lib/action_cable/channel/periodic_timers.rb index c9daa0bcd327a..2c5a574626547 100644 --- a/actioncable/lib/action_cable/channel/periodic_timers.rb +++ b/actioncable/lib/action_cable/channel/periodic_timers.rb @@ -1,25 +1,26 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Channel module PeriodicTimers extend ActiveSupport::Concern included do - class_attribute :periodic_timers, instance_reader: false - self.periodic_timers = [] + class_attribute :periodic_timers, instance_reader: false, default: [] after_subscribe :start_periodic_timers after_unsubscribe :stop_periodic_timers end module ClassMethods - # Periodically performs a task on the channel, like updating an online - # user counter, polling a backend for new status messages, sending - # regular "heartbeat" messages, or doing some internal work and giving - # progress updates. + # Periodically performs a task on the channel, like updating an online user + # counter, polling a backend for new status messages, sending regular + # "heartbeat" messages, or doing some internal work and giving progress updates. # - # Pass a method name or lambda argument or provide a block to call. - # Specify the calling period in seconds using the every: - # keyword argument. + # Pass a method name or lambda argument or provide a block to call. Specify the + # calling period in seconds using the `every:` keyword argument. # # periodically :transmit_progress, every: 5.seconds # diff --git a/actioncable/lib/action_cable/channel/streams.rb b/actioncable/lib/action_cable/channel/streams.rb index dbba333353520..6f517b6d0adb8 100644 --- a/actioncable/lib/action_cable/channel/streams.rb +++ b/actioncable/lib/action_cable/channel/streams.rb @@ -1,63 +1,77 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Channel - # Streams allow channels to route broadcastings to the subscriber. A broadcasting is, as discussed elsewhere, a pubsub queue where any data - # placed into it is automatically sent to the clients that are connected at that time. It's purely an online queue, though. If you're not - # streaming a broadcasting at the very moment it sends out an update, you will not get that update, even if you connect after it has been sent. - # - # Most commonly, the streamed broadcast is sent straight to the subscriber on the client-side. The channel just acts as a connector between - # the two parties (the broadcaster and the channel subscriber). Here's an example of a channel that allows subscribers to get all new - # comments on a given page: - # - # class CommentsChannel < ApplicationCable::Channel - # def follow(data) - # stream_from "comments_for_#{data['recording_id']}" - # end + # # Action Cable Channel Streams + # + # Streams allow channels to route broadcastings to the subscriber. A + # broadcasting is, as discussed elsewhere, a pubsub queue where any data placed + # into it is automatically sent to the clients that are connected at that time. + # It's purely an online queue, though. If you're not streaming a broadcasting at + # the very moment it sends out an update, you will not get that update, even if + # you connect after it has been sent. + # + # Most commonly, the streamed broadcast is sent straight to the subscriber on + # the client-side. The channel just acts as a connector between the two parties + # (the broadcaster and the channel subscriber). Here's an example of a channel + # that allows subscribers to get all new comments on a given page: + # + # class CommentsChannel < ApplicationCable::Channel + # def follow(data) + # stream_from "comments_for_#{data['recording_id']}" + # end # - # def unfollow - # stop_all_streams + # def unfollow + # stop_all_streams + # end # end - # end # - # Based on the above example, the subscribers of this channel will get whatever data is put into the, - # let's say, comments_for_45 broadcasting as soon as it's put there. + # Based on the above example, the subscribers of this channel will get whatever + # data is put into the, let's say, `comments_for_45` broadcasting as soon as + # it's put there. # # An example broadcasting for this channel looks like so: # - # ActionCable.server.broadcast "comments_for_45", author: 'DHH', content: 'Rails is just swell' + # ActionCable.server.broadcast "comments_for_45", { author: 'DHH', content: 'Rails is just swell' } # - # If you have a stream that is related to a model, then the broadcasting used can be generated from the model and channel. - # The following example would subscribe to a broadcasting like comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE. + # If you have a stream that is related to a model, then the broadcasting used + # can be generated from the model and channel. The following example would + # subscribe to a broadcasting like `comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE`. # - # class CommentsChannel < ApplicationCable::Channel - # def subscribed - # post = Post.find(params[:id]) - # stream_for post + # class CommentsChannel < ApplicationCable::Channel + # def subscribed + # post = Post.find(params[:id]) + # stream_for post + # end # end - # end # # You can then broadcast to this channel using: # - # CommentsChannel.broadcast_to(@post, @comment) + # CommentsChannel.broadcast_to(@post, @comment) # - # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can also supply a callback that lets you alter what is sent out. - # The below example shows how you can use this to provide performance introspection in the process: + # If you don't just want to parlay the broadcast unfiltered to the subscriber, + # you can also supply a callback that lets you alter what is sent out. The below + # example shows how you can use this to provide performance introspection in the + # process: # - # class ChatChannel < ApplicationCable::Channel - # def subscribed - # @room = Chat::Room[params[:room_number]] + # class ChatChannel < ApplicationCable::Channel + # def subscribed + # @room = Chat::Room[params[:room_number]] # - # stream_for @room, coder: ActiveSupport::JSON do |message| - # if message['originated_at'].present? - # elapsed_time = (Time.now.to_f - message['originated_at']).round(2) + # stream_for @room, coder: ActiveSupport::JSON do |message| + # if message['originated_at'].present? + # elapsed_time = (Time.now.to_f - message['originated_at']).round(2) # - # ActiveSupport::Notifications.instrument :performance, measurement: 'Chat.message_delay', value: elapsed_time, action: :timing - # logger.info "Message took #{elapsed_time}s to arrive" - # end + # ActiveSupport::Notifications.instrument :performance, measurement: 'Chat.message_delay', value: elapsed_time, action: :timing + # logger.info "Message took #{elapsed_time}s to arrive" + # end # - # transmit message + # transmit message + # end # end # end - # end # # You can stop streaming from all broadcasts by calling #stop_all_streams. module Streams @@ -67,20 +81,24 @@ module Streams on_unsubscribe :stop_all_streams end - # Start streaming from the named broadcasting pubsub queue. 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. + # Start streaming from the named `broadcasting` pubsub queue. 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_from(broadcasting, callback = nil, coder: nil, &block) + return if unsubscribed? + broadcasting = String(broadcasting) # Don't send the confirmation until pubsub#subscribe is successful defer_subscription_confirmation! - # Build a stream handler by wrapping the user-provided callback with - # a decoder or defaulting to a JSON-decoding retransmitter. + # Build a stream handler by wrapping the user-provided callback with a decoder + # or defaulting to a JSON-decoding retransmitter. handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder) - streams << [ broadcasting, handler ] + streams[broadcasting] = handler connection.server.event_loop.post do pubsub.subscribe(broadcasting, handler, lambda do @@ -90,14 +108,29 @@ def stream_from(broadcasting, callback = nil, coder: nil, &block) end end - # Start streaming the pubsub queue for the model 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. + # 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([ channel_name, model ]), callback || block, coder: coder) + # 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(broadcastables, callback = nil, coder: nil, &block) + stream_from(broadcasting_for(broadcastables), callback || block, coder: coder) + end + + # Unsubscribes streams from the named `broadcasting`. + def stop_stream_from(broadcasting) + callback = streams.delete(broadcasting) + if callback + pubsub.unsubscribe(broadcasting, callback) + logger.info "#{self.class.name} stopped streaming from #{broadcasting}" + end + end + + # Unsubscribes streams for the `model`. + def stop_stream_for(model) + stop_stream_from(broadcasting_for(model)) end # Unsubscribes all streams associated with this channel from the pubsub queue. @@ -108,15 +141,25 @@ def stop_all_streams end.clear end + # Calls stream_for with the given `model` if it's present to start streaming, + # otherwise rejects the subscription. + def stream_or_reject_for(model) + if model + stream_for model + else + reject + end + end + private delegate :pubsub, to: :connection def streams - @_streams ||= [] + @_streams ||= {} end - # Always wrap the outermost handler to invoke the user handler on the - # worker pool rather than blocking the event loop. + # Always wrap the outermost handler to invoke the user handler on the worker + # pool rather than blocking the event loop. def worker_pool_stream_handler(broadcasting, user_handler, coder: nil) handler = stream_handler(broadcasting, user_handler, coder: coder) @@ -125,8 +168,8 @@ def worker_pool_stream_handler(broadcasting, user_handler, coder: nil) end end - # May be overridden to add instrumentation, logging, specialized error - # handling, or other forms of handler decoration. + # May be overridden to add instrumentation, logging, specialized error handling, + # or other forms of handler decoration. # # TODO: Tests demonstrating this. def stream_handler(broadcasting, user_handler, coder: nil) @@ -137,14 +180,14 @@ def stream_handler(broadcasting, user_handler, coder: nil) end end - # May be overridden to change the default stream handling behavior - # which decodes JSON and transmits to the client. + # May be overridden to change the default stream handling behavior which decodes + # JSON and transmits to the client. # # TODO: Tests demonstrating this. # - # TODO: Room for optimization. Update transmit API to be coder-aware - # so we can no-op when pubsub and connection are both JSON-encoded. - # Then we can skip decode+encode if we're just proxying messages. + # TODO: Room for optimization. Update transmit API to be coder-aware so we can + # no-op when pubsub and connection are both JSON-encoded. Then we can skip + # decode+encode if we're just proxying messages. def default_stream_handler(broadcasting, coder:) coder ||= ActiveSupport::JSON stream_transmitter stream_decoder(coder: coder), broadcasting: broadcasting diff --git a/actioncable/lib/action_cable/channel/test_case.rb b/actioncable/lib/action_cable/channel/test_case.rb new file mode 100644 index 0000000000000..d3b38ca5151bb --- /dev/null +++ b/actioncable/lib/action_cable/channel/test_case.rb @@ -0,0 +1,356 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support" +require "active_support/test_case" +require "active_support/core_ext/hash/indifferent_access" +require "json" + +module ActionCable + module Channel + class NonInferrableChannelError < ::StandardError + def initialize(name) + super "Unable to determine the channel to test from #{name}. " + + "You'll need to specify it using `tests YourChannel` in your " + + "test case definition." + end + end + + # # Action Cable Channel Stub + # + # Stub `stream_from` to track streams for the channel. Add public aliases for + # `subscription_confirmation_sent?` and `subscription_rejected?`. + module ChannelStub + def confirmed? + subscription_confirmation_sent? + end + + def rejected? + subscription_rejected? + end + + def stream_from(broadcasting, *) + streams << broadcasting + end + + def stop_all_streams + @_streams = [] + end + + def streams + @_streams ||= [] + end + + # Make periodic timers no-op + def start_periodic_timers; end + alias stop_periodic_timers start_periodic_timers + end + + class ConnectionStub + attr_reader :server, :transmissions, :identifiers, :subscriptions, :logger + + delegate :pubsub, :config, to: :server + + def initialize(identifiers = {}) + @server = ActionCable.server + @transmissions = [] + + identifiers.each do |identifier, val| + define_singleton_method(identifier) { val } + end + + @subscriptions = ActionCable::Connection::Subscriptions.new(self) + @identifiers = identifiers.keys + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + end + + def transmit(cable_message) + transmissions << cable_message.with_indifferent_access + end + + def connection_identifier + @connection_identifier ||= connection_gid(identifiers.filter_map { |id| send(id.to_sym) if id }) + end + + private + def connection_gid(ids) + ids.map do |o| + if o.respond_to?(:to_gid_param) + o.to_gid_param + else + o.to_s + end + end.sort.join(":") + end + end + + # Superclass for Action Cable channel functional tests. + # + # ## Basic example + # + # Functional tests are written as follows: + # 1. First, one uses the `subscribe` method to simulate subscription creation. + # 2. Then, one asserts whether the current state is as expected. "State" can be + # anything: transmitted messages, subscribed streams, etc. + # + # + # For example: + # + # class ChatChannelTest < ActionCable::Channel::TestCase + # def test_subscribed_with_room_number + # # Simulate a subscription creation + # subscribe room_number: 1 + # + # # Asserts that the subscription was successfully created + # assert subscription.confirmed? + # + # # Asserts that the channel subscribes connection to a stream + # assert_has_stream "chat_1" + # + # # Asserts that the channel subscribes connection to a specific + # # stream created for a model + # assert_has_stream_for Room.find(1) + # end + # + # def test_does_not_stream_with_incorrect_room_number + # subscribe room_number: -1 + # + # # Asserts that not streams was started + # assert_no_streams + # end + # + # def test_does_not_subscribe_without_room_number + # subscribe + # + # # Asserts that the subscription was rejected + # assert subscription.rejected? + # end + # end + # + # You can also perform actions: + # def test_perform_speak + # subscribe room_number: 1 + # + # perform :speak, message: "Hello, Rails!" + # + # assert_equal "Hello, Rails!", transmissions.last["text"] + # end + # + # ## Special methods + # + # ActionCable::Channel::TestCase will also automatically provide the following + # instance methods for use in the tests: + # + # connection + # : An ActionCable::Channel::ConnectionStub, representing the current HTTP + # connection. + # + # subscription + # : An instance of the current channel, created when you call `subscribe`. + # + # transmissions + # : A list of all messages that have been transmitted into the channel. + # + # + # ## Channel is automatically inferred + # + # ActionCable::Channel::TestCase will automatically infer the channel under test + # from the test class name. If the channel cannot be inferred from the test + # class name, you can explicitly set it with `tests`. + # + # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase + # tests SpecialChannel + # end + # + # ## Specifying connection identifiers + # + # You need to set up your connection manually to provide values for the + # identifiers. To do this just use: + # + # stub_connection(user: users(:john)) + # + # ## Testing broadcasting + # + # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions + # (e.g. `assert_broadcasts`) to handle broadcasting to models: + # + # # in your channel + # def speak(data) + # broadcast_to room, text: data["message"] + # end + # + # def test_speak + # subscribe room_id: rooms(:chat).id + # + # assert_broadcast_on(rooms(:chat), text: "Hello, Rails!") do + # perform :speak, message: "Hello, Rails!" + # end + # end + class TestCase < ActiveSupport::TestCase + module Behavior + extend ActiveSupport::Concern + + include ActiveSupport::Testing::ConstantLookup + include ActionCable::TestHelper + + CHANNEL_IDENTIFIER = "test_stub" + + included do + class_attribute :_channel_class + + attr_reader :connection, :subscription + + ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self) + end + + module ClassMethods + def tests(channel) + case channel + when String, Symbol + self._channel_class = channel.to_s.camelize.constantize + when Module + self._channel_class = channel + else + raise NonInferrableChannelError.new(channel) + end + end + + def channel_class + if channel = self._channel_class + channel + else + tests determine_default_channel(name) + end + end + + def determine_default_channel(name) + channel = determine_constant_from_test_name(name) do |constant| + Class === constant && constant < ActionCable::Channel::Base + end + raise NonInferrableChannelError.new(name) if channel.nil? + channel + end + end + + # Set up test connection with the specified identifiers: + # + # class ApplicationCable < ActionCable::Connection::Base + # identified_by :user, :token + # end + # + # stub_connection(user: users[:john], token: 'my-secret-token') + def stub_connection(identifiers = {}) + @connection = ConnectionStub.new(identifiers) + end + + # Subscribe to the channel under test. Optionally pass subscription parameters + # as a Hash. + def subscribe(params = {}) + @connection ||= stub_connection + @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access) + @subscription.singleton_class.include(ChannelStub) + @subscription.subscribe_to_channel + @subscription + end + + # Unsubscribe the subscription under test. + def unsubscribe + check_subscribed! + subscription.unsubscribe_from_channel + end + + # Perform action on a channel. + # + # NOTE: Must be subscribed. + def perform(action, data = {}) + check_subscribed! + subscription.perform_action(data.stringify_keys.merge("action" => action.to_s)) + end + + # Returns messages transmitted into channel + def transmissions + # Return only directly sent message (via #transmit) + connection.transmissions.filter_map { |data| data["message"] } + end + + # Enhance TestHelper assertions to handle non-String broadcastings + def assert_broadcasts(stream_or_object, *args) + super(broadcasting_for(stream_or_object), *args) + end + + def assert_broadcast_on(stream_or_object, *args) + super(broadcasting_for(stream_or_object), *args) + end + + # Asserts that no streams have been started. + # + # def test_assert_no_started_stream + # subscribe + # assert_no_streams + # end + # + def assert_no_streams + assert subscription.streams.empty?, "No streams started was expected, but #{subscription.streams.count} found" + end + + # Asserts that the specified stream has been started. + # + # def test_assert_started_stream + # subscribe + # assert_has_stream 'messages' + # end + # + def assert_has_stream(stream) + assert subscription.streams.include?(stream), "Stream #{stream} has not been started" + end + + # Asserts that the specified stream for a model has started. + # + # def test_assert_started_stream_for + # subscribe id: 42 + # assert_has_stream_for User.find(42) + # end + # + def assert_has_stream_for(object) + assert_has_stream(broadcasting_for(object)) + end + + # Asserts that the specified stream has not been started. + # + # def test_assert_no_started_stream + # subscribe + # assert_has_no_stream 'messages' + # end + # + def assert_has_no_stream(stream) + assert subscription.streams.exclude?(stream), "Stream #{stream} has been started" + end + + # Asserts that the specified stream for a model has not started. + # + # def test_assert_no_started_stream_for + # subscribe id: 41 + # assert_has_no_stream_for User.find(42) + # end + # + def assert_has_no_stream_for(object) + assert_has_no_stream(broadcasting_for(object)) + end + + private + def check_subscribed! + raise "Must be subscribed!" if subscription.nil? || subscription.rejected? + end + + def broadcasting_for(stream_or_object) + return stream_or_object if stream_or_object.is_a?(String) + + self.class.channel_class.broadcasting_for(stream_or_object) + end + end + + include Behavior + end + end +end diff --git a/actioncable/lib/action_cable/connection.rb b/actioncable/lib/action_cable/connection.rb deleted file mode 100644 index 902efb07e2baa..0000000000000 --- a/actioncable/lib/action_cable/connection.rb +++ /dev/null @@ -1,19 +0,0 @@ -module ActionCable - module Connection - extend ActiveSupport::Autoload - - eager_autoload do - autoload :Authorization - autoload :Base - autoload :ClientSocket - autoload :Identification - autoload :InternalChannel - autoload :MessageBuffer - autoload :Stream - autoload :StreamEventLoop - autoload :Subscriptions - autoload :TaggedLoggerProxy - autoload :WebSocket - end - end -end diff --git a/actioncable/lib/action_cable/connection/authorization.rb b/actioncable/lib/action_cable/connection/authorization.rb index 85df206445c5b..de996e30517db 100644 --- a/actioncable/lib/action_cable/connection/authorization.rb +++ b/actioncable/lib/action_cable/connection/authorization.rb @@ -1,13 +1,18 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Connection module Authorization class UnauthorizedError < StandardError; end - private - def reject_unauthorized_connection - logger.error "An unauthorized connection attempt was rejected" - raise UnauthorizedError - end + # Closes the WebSocket connection if it is open and returns an "unauthorized" + # reason. + def reject_unauthorized_connection + logger.error "An unauthorized connection attempt was rejected" + raise UnauthorizedError + end end end end diff --git a/actioncable/lib/action_cable/connection/base.rb b/actioncable/lib/action_cable/connection/base.rb index 0a517a532d147..5ac3bc646b18f 100644 --- a/actioncable/lib/action_cable/connection/base.rb +++ b/actioncable/lib/action_cable/connection/base.rb @@ -1,52 +1,68 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch" +require "active_support/rescuable" module ActionCable module Connection - # For every WebSocket connection the Action Cable server accepts, a Connection object will be instantiated. This instance becomes the parent - # of all of the channel subscriptions that are created from there on. Incoming messages are then routed to these channel subscriptions - # based on an identifier sent by the Action Cable consumer. The Connection itself does not deal with any specific application logic beyond - # authentication and authorization. + # # Action Cable Connection Base # - # Here's a basic example: + # For every WebSocket connection the Action Cable server accepts, a Connection + # object will be instantiated. This instance becomes the parent of all of the + # channel subscriptions that are created from there on. Incoming messages are + # then routed to these channel subscriptions based on an identifier sent by the + # Action Cable consumer. The Connection itself does not deal with any specific + # application logic beyond authentication and authorization. # - # module ApplicationCable - # class Connection < ActionCable::Connection::Base - # identified_by :current_user + # Here's a basic example: # - # def connect - # self.current_user = find_verified_user - # logger.add_tags current_user.name - # end + # module ApplicationCable + # class Connection < ActionCable::Connection::Base + # identified_by :current_user # - # def disconnect - # # Any cleanup work needed when the cable connection is cut. - # end + # def connect + # self.current_user = find_verified_user + # logger.add_tags current_user.name + # end # - # private - # def find_verified_user - # User.find_by_identity(cookies.signed[:identity_id]) || - # reject_unauthorized_connection + # def disconnect + # # Any cleanup work needed when the cable connection is cut. # end + # + # private + # def find_verified_user + # User.find_by_identity(cookies.encrypted[:identity_id]) || + # reject_unauthorized_connection + # end + # end # end - # end # - # First, we declare that this connection can be identified by its current_user. This allows us to later be able to find all connections - # established for that current_user (and potentially disconnect them). You can declare as many - # identification indexes as you like. Declaring an identification means that an attr_accessor is automatically set for that key. + # First, we declare that this connection can be identified by its current_user. + # This allows us to later be able to find all connections established for that + # current_user (and potentially disconnect them). You can declare as many + # identification indexes as you like. Declaring an identification means that an + # attr_accessor is automatically set for that key. # - # Second, we rely on the fact that the WebSocket connection is established with the cookies from the domain being sent along. This makes - # it easy to use signed cookies that were set when logging in via a web interface to authorize the WebSocket connection. + # Second, we rely on the fact that the WebSocket connection is established with + # the cookies from the domain being sent along. This makes it easy to use signed + # cookies that were set when logging in via a web interface to authorize the + # WebSocket connection. # - # Finally, we add a tag to the connection-specific logger with the name of the current user to easily distinguish their messages in the log. + # Finally, we add a tag to the connection-specific logger with the name of the + # current user to easily distinguish their messages in the log. # # Pretty simple, eh? class Base include Identification include InternalChannel include Authorization + include Callbacks + include ActiveSupport::Rescuable attr_reader :server, :env, :subscriptions, :logger, :worker_pool, :protocol - delegate :event_loop, :pubsub, to: :server + delegate :event_loop, :pubsub, :config, to: :server def initialize(server, env, coder: ActiveSupport::JSON) @server, @env, @coder = server, env, coder @@ -62,9 +78,11 @@ def initialize(server, env, coder: ActiveSupport::JSON) @started_at = Time.now end - # Called by the server when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user. - # This method should not be called directly -- instead rely upon on the #connect (and #disconnect) callbacks. - def process #:nodoc: + # Called by the server when a new WebSocket connection is established. This + # configures the callbacks intended for overwriting by the user. This method + # should not be called directly -- instead rely upon on the #connect (and + # #disconnect) callbacks. + def process # :nodoc: logger.info started_request_message if websocket.possible? && allow_request_origin? @@ -76,34 +94,47 @@ def process #:nodoc: # Decodes WebSocket messages and dispatches them to subscribed channels. # WebSocket message transfer encoding is always JSON. - def receive(websocket_message) #:nodoc: + def receive(websocket_message) # :nodoc: send_async :dispatch_websocket_message, websocket_message end - def dispatch_websocket_message(websocket_message) #:nodoc: + def dispatch_websocket_message(websocket_message) # :nodoc: if websocket.alive? - subscriptions.execute_command decode(websocket_message) + handle_channel_command decode(websocket_message) else logger.error "Ignoring message processed after the WebSocket was closed: #{websocket_message.inspect})" end end + def handle_channel_command(payload) + run_callbacks :command do + subscriptions.execute_command payload + end + end + def transmit(cable_message) # :nodoc: websocket.transmit encode(cable_message) end # Close the WebSocket connection. - def close + def close(reason: nil, reconnect: true) + transmit( + type: ActionCable::INTERNAL[:message_types][:disconnect], + reason: reason, + reconnect: reconnect + ) websocket.close end - # Invoke a method on the connection asynchronously through the pool of thread workers. + # Invoke a method on the connection asynchronously through the pool of thread + # workers. def send_async(method, *arguments) worker_pool.async_invoke(self, method, *arguments) end - # Return a basic hash of statistics for the connection keyed with identifier, started_at, subscriptions, and request_id. - # This can be returned by a health check against the connection. + # Return a basic hash of statistics for the connection keyed with `identifier`, + # `started_at`, `subscriptions`, and `request_id`. This can be returned by a + # health check against the connection. def statistics { identifier: connection_identifier, @@ -126,21 +157,24 @@ def on_message(message) # :nodoc: end def on_error(message) # :nodoc: - # ignore + # log errors to make diagnosing socket errors easier + logger.error "WebSocket error occurred: #{message}" end def on_close(reason, code) # :nodoc: send_async :handle_close end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected + def inspect # :nodoc: + "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" + end + + private attr_reader :websocket attr_reader :message_buffer - private - # The request that initiated the WebSocket connection is available here. This gives access to the environment, cookies, etc. + # The request that initiated the WebSocket connection is available here. This + # gives access to the environment, cookies, etc. def request # :doc: @request ||= begin environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application @@ -148,7 +182,8 @@ def request # :doc: end end - # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks. + # The cookies of the request that initiated the WebSocket connection. Useful for + # performing authorization checks. def cookies # :doc: request.cookie_jar end @@ -170,7 +205,7 @@ def handle_open message_buffer.process! server.add_connection(self) rescue ActionCable::Connection::Authorization::UnauthorizedError - respond_to_invalid_request + close(reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized], reconnect: false) if websocket.alive? end def handle_close @@ -185,9 +220,8 @@ def handle_close end def send_welcome_message - # Send welcome message to the internal connection monitor channel. - # This ensures the connection monitor state is reset after a successful - # websocket connection. + # Send welcome message to the internal connection monitor channel. This ensures + # the connection monitor state is reset after a successful websocket connection. transmit type: ActionCable::INTERNAL[:message_types][:welcome] end @@ -211,14 +245,15 @@ def respond_to_successful_request end def respond_to_invalid_request - close if websocket.alive? + close(reason: ActionCable::INTERNAL[:disconnect_reasons][:invalid_request]) if websocket.alive? logger.error invalid_request_message logger.info finished_request_message - [ 404, { "Content-Type" => "text/plain" }, [ "Page not found" ] ] + [ 404, { Rack::CONTENT_TYPE => "text/plain; charset=utf-8" }, [ "Page not found" ] ] end - # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags. + # Tags are declared in the server but computed in the connection. This allows us + # per-connection tailored tags. def new_tagged_logger TaggedLoggerProxy.new server.logger, tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } @@ -255,3 +290,5 @@ def successful_request_message end end end + +ActiveSupport.run_load_hooks(:action_cable_connection, ActionCable::Connection::Base) diff --git a/actioncable/lib/action_cable/connection/callbacks.rb b/actioncable/lib/action_cable/connection/callbacks.rb new file mode 100644 index 0000000000000..85a27c6f9f5f6 --- /dev/null +++ b/actioncable/lib/action_cable/connection/callbacks.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/callbacks" + +module ActionCable + module Connection + # # Action Cable Connection Callbacks + # + # The [before_command](rdoc-ref:ClassMethods#before_command), + # [after_command](rdoc-ref:ClassMethods#after_command), and + # [around_command](rdoc-ref:ClassMethods#around_command) callbacks are invoked + # when sending commands to the client, such as when subscribing, unsubscribing, + # or performing an action. + # + # #### Example + # + # module ApplicationCable + # class Connection < ActionCable::Connection::Base + # identified_by :user + # + # around_command :set_current_account + # + # private + # + # def set_current_account + # # Now all channels could use Current.account + # Current.set(account: user.account) { yield } + # end + # end + # end + # + module Callbacks + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + included do + define_callbacks :command + end + + module ClassMethods + def before_command(*methods, &block) + set_callback(:command, :before, *methods, &block) + end + + def after_command(*methods, &block) + set_callback(:command, :after, *methods, &block) + end + + def around_command(*methods, &block) + set_callback(:command, :around, *methods, &block) + end + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/client_socket.rb b/actioncable/lib/action_cable/connection/client_socket.rb index c7e30e78c8999..9d8be5e92fe06 100644 --- a/actioncable/lib/action_cable/connection/client_socket.rb +++ b/actioncable/lib/action_cable/connection/client_socket.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "websocket/driver" module ActionCable @@ -19,7 +23,7 @@ def self.secure_request?(env) return true if env["HTTP_X_FORWARDED_PROTO"] == "https" return true if env["rack.url_scheme"] == "https" - return false + false end CONNECTING = 0 @@ -41,7 +45,7 @@ def initialize(env, event_target, event_loop, protocols) @ready_state = CONNECTING - # The driver calls +env+, +url+, and +write+ + # The driver calls `env`, `url`, and `write` @driver = ::WebSocket::Driver.rack(self, protocols: protocols) @driver.on(:open) { |e| open } @@ -81,7 +85,7 @@ def transmit(message) when Numeric then @driver.text(message.to_s) when String then @driver.text(message) when Array then @driver.binary(message) - else false + else false end end diff --git a/actioncable/lib/action_cable/connection/identification.rb b/actioncable/lib/action_cable/connection/identification.rb index c91a1d1fd782e..663fba60ac24e 100644 --- a/actioncable/lib/action_cable/connection/identification.rb +++ b/actioncable/lib/action_cable/connection/identification.rb @@ -1,4 +1,6 @@ -require "set" +# frozen_string_literal: true + +# :markup: markdown module ActionCable module Connection @@ -6,26 +8,27 @@ module Identification extend ActiveSupport::Concern included do - class_attribute :identifiers - self.identifiers = Set.new + class_attribute :identifiers, default: Set.new end - class_methods do - # Mark a key as being a connection identifier index that can then be used to find the specific connection again later. - # Common identifiers are current_user and current_account, but could be anything, really. + module ClassMethods + # Mark a key as being a connection identifier index that can then be used to + # find the specific connection again later. Common identifiers are current_user + # and current_account, but could be anything, really. # - # Note that anything marked as an identifier will automatically create a delegate by the same name on any - # channel instances created off the connection. + # Note that anything marked as an identifier will automatically create a + # delegate by the same name on any channel instances created off the connection. def identified_by(*identifiers) Array(identifiers).each { |identifier| attr_accessor identifier } self.identifiers += identifiers end end - # Return a single connection identifier that combines the value of all the registered identifiers into a single gid. + # Return a single connection identifier that combines the value of all the + # registered identifiers into a single gid. def connection_identifier unless defined? @connection_identifier - @connection_identifier = connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact + @connection_identifier = connection_gid identifiers.filter_map { |id| instance_variable_get("@#{id}") } end @connection_identifier diff --git a/actioncable/lib/action_cable/connection/internal_channel.rb b/actioncable/lib/action_cable/connection/internal_channel.rb index 8f0ec766c34a7..23933e8660bea 100644 --- a/actioncable/lib/action_cable/connection/internal_channel.rb +++ b/actioncable/lib/action_cable/connection/internal_channel.rb @@ -1,6 +1,13 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Connection - # Makes it possible for the RemoteConnection to disconnect a specific connection. + # # Action Cable InternalChannel + # + # Makes it possible for the RemoteConnection to disconnect a specific + # connection. module InternalChannel extend ActiveSupport::Concern @@ -30,7 +37,7 @@ def process_internal_message(message) case message["type"] when "disconnect" logger.info "Removing connection (#{connection_identifier})" - websocket.close + close(reason: ActionCable::INTERNAL[:disconnect_reasons][:remote], reconnect: message.fetch("reconnect", true)) end rescue Exception => e logger.error "There was an exception - #{e.class}(#{e.message})" diff --git a/actioncable/lib/action_cable/connection/message_buffer.rb b/actioncable/lib/action_cable/connection/message_buffer.rb index 4ccd322644ed0..35813930244a4 100644 --- a/actioncable/lib/action_cable/connection/message_buffer.rb +++ b/actioncable/lib/action_cable/connection/message_buffer.rb @@ -1,6 +1,11 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Connection - # Allows us to buffer messages received from the WebSocket before the Connection has been fully initialized, and is ready to receive them. + # Allows us to buffer messages received from the WebSocket before the Connection + # has been fully initialized, and is ready to receive them. class MessageBuffer # :nodoc: def initialize(connection) @connection = connection @@ -28,13 +33,10 @@ def process! receive_buffered_messages end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected + private attr_reader :connection attr_reader :buffered_messages - private def valid?(message) message.is_a?(String) end diff --git a/actioncable/lib/action_cable/connection/stream.rb b/actioncable/lib/action_cable/connection/stream.rb index e620b9384559d..01c2d114ec199 100644 --- a/actioncable/lib/action_cable/connection/stream.rb +++ b/actioncable/lib/action_cable/connection/stream.rb @@ -1,4 +1,6 @@ -require "thread" +# frozen_string_literal: true + +# :markup: markdown module ActionCable module Connection @@ -96,8 +98,10 @@ def receive(data) def hijack_rack_socket return unless @socket_object.env["rack.hijack"] - @socket_object.env["rack.hijack"].call - @rack_hijack_io = @socket_object.env["rack.hijack_io"] + # This should return the underlying io according to the SPEC: + @rack_hijack_io = @socket_object.env["rack.hijack"].call + # Retain existing behavior if required: + @rack_hijack_io ||= @socket_object.env["rack.hijack_io"] @event_loop.attach(@rack_hijack_io, self) end diff --git a/actioncable/lib/action_cable/connection/stream_event_loop.rb b/actioncable/lib/action_cable/connection/stream_event_loop.rb index 2d1af0ff9f02e..38e9823b7e06b 100644 --- a/actioncable/lib/action_cable/connection/stream_event_loop.rb +++ b/actioncable/lib/action_cable/connection/stream_event_loop.rb @@ -1,5 +1,8 @@ +# frozen_string_literal: true + +# :markup: markdown + require "nio" -require "thread" module ActionCable module Connection @@ -65,6 +68,7 @@ def spawn @nio ||= NIO::Selector.new @executor ||= Concurrent::ThreadPoolExecutor.new( + name: "ActionCable-streamer", min_threads: 1, max_threads: 10, max_queue: 0, @@ -115,9 +119,8 @@ def run stream.receive incoming end rescue - # We expect one of EOFError or Errno::ECONNRESET in - # normal operation (when the client goes away). But if - # anything else goes wrong, this is still the best way + # We expect one of EOFError or Errno::ECONNRESET in normal operation (when the + # client goes away). But if anything else goes wrong, this is still the best way # to handle it. begin stream.close diff --git a/actioncable/lib/action_cable/connection/subscriptions.rb b/actioncable/lib/action_cable/connection/subscriptions.rb index 44bce1e195d5f..a9b1bca7cbc7a 100644 --- a/actioncable/lib/action_cable/connection/subscriptions.rb +++ b/actioncable/lib/action_cable/connection/subscriptions.rb @@ -1,9 +1,16 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/hash/indifferent_access" module ActionCable module Connection - # Collection class for all the channel subscriptions established on a given connection. Responsible for routing incoming commands that arrive on - # the connection to the proper channel. + # # Action Cable Connection Subscriptions + # + # Collection class for all the channel subscriptions established on a given + # connection. Responsible for routing incoming commands that arrive on the + # connection to the proper channel. class Subscriptions # :nodoc: def initialize(connection) @connection = connection @@ -19,6 +26,7 @@ def execute_command(data) logger.error "Received unrecognized command in #{data.inspect}" end rescue Exception => e + @connection.rescue_with_handler(e) logger.error "Could not execute command from (#{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}" end @@ -30,7 +38,7 @@ def add(data) subscription_klass = id_options[:channel].safe_constantize - if subscription_klass && ActionCable::Channel::Base >= subscription_klass + if subscription_klass && ActionCable::Channel::Base > subscription_klass subscription = subscription_klass.new(connection, id_key, id_options) subscriptions[id_key] = subscription subscription.subscribe_to_channel @@ -41,7 +49,7 @@ def add(data) def remove(data) logger.info "Unsubscribing from channel: #{data['identifier']}" - remove_subscription subscriptions[data["identifier"]] + remove_subscription find(data) end def remove_subscription(subscription) @@ -61,12 +69,8 @@ def unsubscribe_from_all subscriptions.each { |id, channel| remove_subscription(channel) } end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - attr_reader :connection, :subscriptions - private + attr_reader :connection, :subscriptions delegate :logger, to: :connection def find(data) diff --git a/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb index aef549aa86ce4..b7d97afb09eee 100644 --- a/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb +++ b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb @@ -1,8 +1,15 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Connection - # Allows the use of per-connection tags against the server logger. This wouldn't work using the traditional - # ActiveSupport::TaggedLogging enhanced Rails.logger, as that logger will reset the tags between requests. - # The connection is long-lived, so it needs its own set of tags for its independent duration. + # # Action Cable Connection TaggedLoggerProxy + # + # Allows the use of per-connection tags against the server logger. This wouldn't + # work using the traditional ActiveSupport::TaggedLogging enhanced Rails.logger, + # as that logger will reset the tags between requests. The connection is + # long-lived, so it needs its own set of tags for its independent duration. class TaggedLoggerProxy attr_reader :tags @@ -16,24 +23,24 @@ def add_tags(*tags) @tags = @tags.uniq end - def tag(logger) + def tag(logger, &block) if logger.respond_to?(:tagged) current_tags = tags - logger.formatter.current_tags - logger.tagged(*current_tags) { yield } + logger.tagged(*current_tags, &block) else yield end end %i( debug info warn error fatal unknown ).each do |severity| - define_method(severity) do |message| - log severity, message + define_method(severity) do |message = nil, &block| + log severity, message, &block end end private - def log(type, message) # :doc: - tag(@logger) { @logger.send type, message } + def log(type, message, &block) # :doc: + tag(@logger) { @logger.send type, message, &block } end end end diff --git a/actioncable/lib/action_cable/connection/test_case.rb b/actioncable/lib/action_cable/connection/test_case.rb new file mode 100644 index 0000000000000..5eeb0775fd7b3 --- /dev/null +++ b/actioncable/lib/action_cable/connection/test_case.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support" +require "active_support/test_case" +require "active_support/core_ext/hash/indifferent_access" +require "action_dispatch" +require "action_dispatch/http/headers" +require "action_dispatch/testing/test_request" + +module ActionCable + module Connection + class NonInferrableConnectionError < ::StandardError + def initialize(name) + super "Unable to determine the connection to test from #{name}. " + + "You'll need to specify it using `tests YourConnection` in your " + + "test case definition." + end + end + + module Assertions + # Asserts that the connection is rejected (via + # `reject_unauthorized_connection`). + # + # # Asserts that connection without user_id fails + # assert_reject_connection { connect params: { user_id: '' } } + def assert_reject_connection(&block) + assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block) + end + end + + class TestCookies < ActiveSupport::HashWithIndifferentAccess # :nodoc: + def []=(name, options) + value = options.is_a?(Hash) ? options.symbolize_keys[:value] : options + super(name, value) + end + end + + # We don't want to use the whole "encryption stack" for connection unit-tests, + # but we want to make sure that users test against the correct types of cookies + # (i.e. signed or encrypted or plain) + class TestCookieJar < TestCookies + def signed + @signed ||= TestCookies.new + end + + def encrypted + @encrypted ||= TestCookies.new + end + end + + class TestRequest < ActionDispatch::TestRequest + attr_accessor :session, :cookie_jar + end + + module TestConnection + attr_reader :logger, :request + + def initialize(request) + inner_logger = ActiveSupport::Logger.new(StringIO.new) + tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger) + @logger = ActionCable::Connection::TaggedLoggerProxy.new(tagged_logging, tags: []) + @request = request + @env = request.env + end + end + + # # Action Cable Connection TestCase + # + # Unit test Action Cable connections. + # + # Useful to check whether a connection's `identified_by` gets assigned properly + # and that any improper connection requests are rejected. + # + # ## Basic example + # + # Unit tests are written by first simulating a connection attempt by calling + # `connect` and then asserting state, e.g. identifiers, have been assigned. + # + # class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase + # def test_connects_with_proper_cookie + # # Simulate the connection request with a cookie. + # cookies["user_id"] = users(:john).id + # + # connect + # + # # Assert the connection identifier matches the fixture. + # assert_equal users(:john).id, connection.user.id + # end + # + # def test_rejects_connection_without_proper_cookie + # assert_reject_connection { connect } + # end + # end + # + # `connect` accepts additional information about the HTTP request with the + # `params`, `headers`, `session`, and Rack `env` options. + # + # def test_connect_with_headers_and_query_string + # connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" } + # + # assert_equal "1", connection.user.id + # assert_equal "secret-my", connection.token + # end + # + # def test_connect_with_params + # connect params: { user_id: 1 } + # + # assert_equal "1", connection.user.id + # end + # + # You can also set up the correct cookies before the connection request: + # + # def test_connect_with_cookies + # # Plain cookies: + # cookies["user_id"] = 1 + # + # # Or signed/encrypted: + # # cookies.signed["user_id"] = 1 + # # cookies.encrypted["user_id"] = 1 + # + # connect + # + # assert_equal "1", connection.user_id + # end + # + # ## Connection is automatically inferred + # + # ActionCable::Connection::TestCase will automatically infer the connection + # under test from the test class name. If the channel cannot be inferred from + # the test class name, you can explicitly set it with `tests`. + # + # class ConnectionTest < ActionCable::Connection::TestCase + # tests ApplicationCable::Connection + # end + # + class TestCase < ActiveSupport::TestCase + module Behavior + extend ActiveSupport::Concern + + DEFAULT_PATH = "/cable" + + include ActiveSupport::Testing::ConstantLookup + include Assertions + + included do + class_attribute :_connection_class + + attr_reader :connection + + ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self) + end + + module ClassMethods + def tests(connection) + case connection + when String, Symbol + self._connection_class = connection.to_s.camelize.constantize + when Module + self._connection_class = connection + else + raise NonInferrableConnectionError.new(connection) + end + end + + def connection_class + if connection = self._connection_class + connection + else + tests determine_default_connection(name) + end + end + + def determine_default_connection(name) + connection = determine_constant_from_test_name(name) do |constant| + Class === constant && constant < ActionCable::Connection::Base + end + raise NonInferrableConnectionError.new(name) if connection.nil? + connection + end + end + + # Performs connection attempt to exert #connect on the connection under test. + # + # Accepts request path as the first argument and the following request options: + # + # * params – URL parameters (Hash) + # * headers – request headers (Hash) + # * session – session data (Hash) + # * env – additional Rack env configuration (Hash) + def connect(path = ActionCable.server.config.mount_path, **request_params) + path ||= DEFAULT_PATH + + connection = self.class.connection_class.allocate + connection.singleton_class.include(TestConnection) + connection.send(:initialize, build_test_request(path, **request_params)) + connection.connect if connection.respond_to?(:connect) + + # Only set instance variable if connected successfully + @connection = connection + end + + # Exert #disconnect on the connection under test. + def disconnect + raise "Must be connected!" if connection.nil? + + connection.disconnect if connection.respond_to?(:disconnect) + @connection = nil + end + + def cookies + @cookie_jar ||= TestCookieJar.new + end + + private + def build_test_request(path, params: nil, headers: {}, session: {}, env: {}) + wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers) + + uri = URI.parse(path) + + query_string = params.nil? ? uri.query : params.to_query + + request_env = { + "QUERY_STRING" => query_string, + "PATH_INFO" => uri.path + }.merge(env) + + if wrapped_headers.present? + ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers) + end + + TestRequest.create(request_env).tap do |request| + request.session = session.with_indifferent_access + request.cookie_jar = cookies + end + end + end + + include Behavior + end + end +end diff --git a/actioncable/lib/action_cable/connection/web_socket.rb b/actioncable/lib/action_cable/connection/web_socket.rb index 03eb6e2ea8e44..662f5fbb159f6 100644 --- a/actioncable/lib/action_cable/connection/web_socket.rb +++ b/actioncable/lib/action_cable/connection/web_socket.rb @@ -1,9 +1,15 @@ +# frozen_string_literal: true + +# :markup: markdown + require "websocket/driver" module ActionCable module Connection + # # Action Cable Connection WebSocket + # # Wrap the real socket to minimize the externally-presented API - class WebSocket + class WebSocket # :nodoc: def initialize(env, event_target, event_loop, protocols: ActionCable::INTERNAL[:protocols]) @websocket = ::WebSocket::Driver.websocket?(env) ? ClientSocket.new(env, event_target, event_loop, protocols) : nil end @@ -13,28 +19,26 @@ def possible? end def alive? - websocket && websocket.alive? + websocket&.alive? end - def transmit(data) - websocket.transmit data + def transmit(...) + websocket&.transmit(...) end - def close - websocket.close + def close(...) + websocket&.close(...) end def protocol - websocket.protocol + websocket&.protocol end def rack_response - websocket.rack_response + websocket&.rack_response end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected + private attr_reader :websocket end end diff --git a/actioncable/lib/action_cable/deprecator.rb b/actioncable/lib/action_cable/deprecator.rb new file mode 100644 index 0000000000000..b2e74e8ee8e83 --- /dev/null +++ b/actioncable/lib/action_cable/deprecator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end diff --git a/actioncable/lib/action_cable/engine.rb b/actioncable/lib/action_cable/engine.rb index 63a26636a0c6c..d8a92d28b650f 100644 --- a/actioncable/lib/action_cable/engine.rb +++ b/actioncable/lib/action_cable/engine.rb @@ -1,14 +1,20 @@ +# frozen_string_literal: true + +# :markup: markdown + require "rails" require "action_cable" -require "action_cable/helpers/action_cable_helper" require "active_support/core_ext/hash/indifferent_access" module ActionCable class Engine < Rails::Engine # :nodoc: config.action_cable = ActiveSupport::OrderedOptions.new config.action_cable.mount_path = ActionCable::INTERNAL[:default_mount_path] + config.action_cable.precompile_assets = true - config.eager_load_namespaces << ActionCable + initializer "action_cable.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_cable] = ActionCable.deprecator + end initializer "action_cable.helpers" do ActiveSupport.on_load(:action_view) do @@ -20,6 +26,20 @@ class Engine < Rails::Engine # :nodoc: ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } end + initializer "action_cable.health_check_application" do + ActiveSupport.on_load(:action_cable) { + self.health_check_application = ->(env) { Rails::HealthController.action(:show).call(env) } + } + end + + initializer "action_cable.asset" do + config.after_initialize do |app| + if app.config.respond_to?(:assets) && app.config.action_cable.precompile_assets + app.config.assets.precompile += %w( actioncable.js actioncable.esm.js ) + end + end + end + initializer "action_cable.set_configs" do |app| options = app.config.action_cable options.allowed_request_origins ||= /https?:\/\/localhost:\d+/ if ::Rails.env.development? @@ -28,11 +48,12 @@ class Engine < Rails::Engine # :nodoc: ActiveSupport.on_load(:action_cable) do if (config_path = Pathname.new(app.config.paths["config/cable"].first)).exist? - self.cable = Rails.application.config_for(config_path).with_indifferent_access + self.cable = app.config_for(config_path).to_h.with_indifferent_access end previous_connection_class = connection_class self.connection_class = -> { "ApplicationCable::Connection".safe_constantize || previous_connection_class.call } + self.filter_parameters += app.config.filter_parameters options.each { |k, v| send("#{k}=", v) } end @@ -43,7 +64,7 @@ class Engine < Rails::Engine # :nodoc: config = app.config unless config.action_cable.mount_path.nil? app.routes.prepend do - mount ActionCable.server => config.action_cable.mount_path, internal: true + mount ActionCable.server => config.action_cable.mount_path, internal: true, anchor: true end end end @@ -52,10 +73,10 @@ class Engine < Rails::Engine # :nodoc: initializer "action_cable.set_work_hooks" do |app| ActiveSupport.on_load(:action_cable) do ActionCable::Server::Worker.set_callback :work, :around, prepend: true do |_, inner| - app.executor.wrap do - # If we took a while to get the lock, we may have been halted - # in the meantime. As we haven't started doing any real work - # yet, we should pretend that we never made it off the queue. + app.executor.wrap(source: "application.action_cable") do + # If we took a while to get the lock, we may have been halted in the meantime. + # As we haven't started doing any real work yet, we should pretend that we never + # made it off the queue. unless stopping? inner.call end @@ -63,7 +84,7 @@ class Engine < Rails::Engine # :nodoc: end wrap = lambda do |_, inner| - app.executor.wrap(&inner) + app.executor.wrap(source: "application.action_cable", &inner) end ActionCable::Channel::Base.set_callback :subscribe, :around, prepend: true, &wrap ActionCable::Channel::Base.set_callback :unsubscribe, :around, prepend: true, &wrap diff --git a/actioncable/lib/action_cable/gem_version.rb b/actioncable/lib/action_cable/gem_version.rb index 8ba0230d478be..0ad4fd88bbeaf 100644 --- a/actioncable/lib/action_cable/gem_version.rb +++ b/actioncable/lib/action_cable/gem_version.rb @@ -1,12 +1,16 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable - # Returns the version of the currently loaded Action Cable as a Gem::Version. + # Returns the currently loaded version of Action Cable as a `Gem::Version`. def self.gem_version Gem::Version.new VERSION::STRING end module VERSION - MAJOR = 5 - MINOR = 1 + MAJOR = 8 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/actioncable/lib/action_cable/helpers/action_cable_helper.rb b/actioncable/lib/action_cable/helpers/action_cable_helper.rb index f53be0bc31e4d..93d21a983cef8 100644 --- a/actioncable/lib/action_cable/helpers/action_cable_helper.rb +++ b/actioncable/lib/action_cable/helpers/action_cable_helper.rb @@ -1,32 +1,37 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Helpers module ActionCableHelper - # Returns an "action-cable-url" meta tag with the value of the URL specified in your - # configuration. Ensure this is above your JavaScript tag: + # Returns an "action-cable-url" meta tag with the value of the URL specified in + # your configuration. Ensure this is above your JavaScript tag: # - # - # <%= action_cable_meta_tag %> - # <%= javascript_include_tag 'application', 'data-turbolinks-track' => 'reload' %> - # + # + # <%= action_cable_meta_tag %> + # <%= javascript_include_tag 'application', 'data-turbo-track' => 'reload' %> + # # - # This is then used by Action Cable to determine the URL of your WebSocket server. - # Your CoffeeScript can then connect to the server without needing to specify the - # URL directly: + # This is then used by Action Cable to determine the URL of your WebSocket + # server. Your JavaScript can then connect to the server without needing to + # specify the URL directly: # - # #= require cable - # @App = {} - # App.cable = Cable.createConsumer() + # import Cable from "@rails/actioncable" + # window.Cable = Cable + # window.App = {} + # App.cable = Cable.createConsumer() # # Make sure to specify the correct server location in each of your environment # config files: # - # config.action_cable.mount_path = "/cable123" - # <%= action_cable_meta_tag %> would render: - # => + # config.action_cable.mount_path = "/cable123" + # <%= action_cable_meta_tag %> would render: + # => # - # config.action_cable.url = "ws://actioncable.com" - # <%= action_cable_meta_tag %> would render: - # => + # config.action_cable.url = "ws://actioncable.com" + # <%= action_cable_meta_tag %> would render: + # => # def action_cable_meta_tag tag "meta", name: "action-cable-url", content: ( diff --git a/actioncable/lib/action_cable/remote_connections.rb b/actioncable/lib/action_cable/remote_connections.rb index d2856bc6ae483..e167a1c5521c8 100644 --- a/actioncable/lib/action_cable/remote_connections.rb +++ b/actioncable/lib/action_cable/remote_connections.rb @@ -1,20 +1,33 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/module/redefine_method" + module ActionCable + # # Action Cable Remote Connections + # # If you need to disconnect a given connection, you can go through the # RemoteConnections. You can find the connections you're looking for by # searching for the identifier declared on the connection. For example: # - # module ApplicationCable - # class Connection < ActionCable::Connection::Base - # identified_by :current_user - # .... + # module ApplicationCable + # class Connection < ActionCable::Connection::Base + # identified_by :current_user + # .... + # end # end - # end # - # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect + # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect # - # This will disconnect all the connections established for - # User.find(1), across all servers running on all machines, because - # it uses the internal channel that all of these servers are subscribed to. + # This will disconnect all the connections established for `User.find(1)`, + # across all servers running on all machines, because it uses the internal + # channel that all of these servers are subscribed to. + # + # By default, server sends a "disconnect" message with "reconnect" flag set to + # true. You can override it by specifying the `reconnect` option: + # + # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect(reconnect: false) class RemoteConnections attr_reader :server @@ -26,41 +39,44 @@ def where(identifier) RemoteConnection.new(server, identifier) end - private - # Represents a single remote connection found via ActionCable.server.remote_connections.where(*). - # Exists solely for the purpose of calling #disconnect on that connection. - class RemoteConnection - class InvalidIdentifiersError < StandardError; end + # # Action Cable Remote Connection + # + # Represents a single remote connection found via + # `ActionCable.server.remote_connections.where(*)`. Exists solely for the + # purpose of calling #disconnect on that connection. + class RemoteConnection + class InvalidIdentifiersError < StandardError; end - include Connection::Identification, Connection::InternalChannel + include Connection::Identification, Connection::InternalChannel - def initialize(server, ids) - @server = server - set_identifier_instance_vars(ids) - end + def initialize(server, ids) + @server = server + set_identifier_instance_vars(ids) + end - # Uses the internal channel to disconnect the connection. - def disconnect - server.broadcast internal_channel, type: "disconnect" - end + # Uses the internal channel to disconnect the connection. + def disconnect(reconnect: true) + server.broadcast internal_channel, { type: "disconnect", reconnect: reconnect } + end - # Returns all the identifiers that were applied to this connection. - def identifiers - server.connection_identifiers - end + # Returns all the identifiers that were applied to this connection. + redefine_method :identifiers do + server.connection_identifiers + end - private - attr_reader :server + protected + attr_reader :server - def set_identifier_instance_vars(ids) - raise InvalidIdentifiersError unless valid_identifiers?(ids) - ids.each { |k, v| instance_variable_set("@#{k}", v) } - end + private + def set_identifier_instance_vars(ids) + raise InvalidIdentifiersError unless valid_identifiers?(ids) + ids.each { |k, v| instance_variable_set("@#{k}", v) } + end - def valid_identifiers?(ids) - keys = ids.keys - identifiers.all? { |id| keys.include?(id) } - end - end + def valid_identifiers?(ids) + keys = ids.keys + identifiers.all? { |id| keys.include?(id) } + end + end end end diff --git a/actioncable/lib/action_cable/server.rb b/actioncable/lib/action_cable/server.rb deleted file mode 100644 index 22f93538254ed..0000000000000 --- a/actioncable/lib/action_cable/server.rb +++ /dev/null @@ -1,15 +0,0 @@ -module ActionCable - module Server - extend ActiveSupport::Autoload - - eager_autoload do - autoload :Base - autoload :Broadcasting - autoload :Connections - autoload :Configuration - - autoload :Worker - autoload :ActiveRecordConnectionManagement, "action_cable/server/worker/active_record_connection_management" - end - end -end diff --git a/actioncable/lib/action_cable/server/base.rb b/actioncable/lib/action_cable/server/base.rb index 419eccd73c8ba..cd150700e2329 100644 --- a/actioncable/lib/action_cable/server/base.rb +++ b/actioncable/lib/action_cable/server/base.rb @@ -1,40 +1,56 @@ +# frozen_string_literal: true + +# :markup: markdown + require "monitor" module ActionCable module Server - # A singleton ActionCable::Server instance is available via ActionCable.server. It's used by the Rack process that starts the Action Cable server, but - # is also used by the user to reach the RemoteConnections object, which is used for finding and disconnecting connections across all servers. + # # Action Cable Server Base + # + # A singleton ActionCable::Server instance is available via ActionCable.server. + # It's used by the Rack process that starts the Action Cable server, but is also + # used by the user to reach the RemoteConnections object, which is used for + # finding and disconnecting connections across all servers. # - # Also, this is the server instance used for broadcasting. See Broadcasting for more information. + # Also, this is the server instance used for broadcasting. See Broadcasting for + # more information. class Base include ActionCable::Server::Broadcasting include ActionCable::Server::Connections - cattr_accessor(:config, instance_accessor: true) { ActionCable::Server::Configuration.new } + cattr_accessor :config, instance_accessor: false, default: ActionCable::Server::Configuration.new + + attr_reader :config def self.logger; config.logger; end delegate :logger, to: :config attr_reader :mutex - def initialize + def initialize(config: self.class.config) + @config = config @mutex = Monitor.new @remote_connections = @event_loop = @worker_pool = @pubsub = nil end - # Called by Rack to setup the server. + # Called by Rack to set up the server. def call(env) + return config.health_check_application.call(env) if env["PATH_INFO"] == config.health_check_path setup_heartbeat_timer config.connection_class.call.new(self, env).process end - # Disconnect all the connections identified by `identifiers` on this server or any others via RemoteConnections. + # Disconnect all the connections identified by `identifiers` on this server or + # any others via RemoteConnections. def disconnect(identifiers) remote_connections.where(identifiers).disconnect end def restart - connections.each(&:close) + connections.each do |connection| + connection.close(reason: ActionCable::INTERNAL[:disconnect_reasons][:server_restart]) + end @mutex.synchronize do # Shutdown the worker pool @@ -56,17 +72,22 @@ def event_loop @event_loop || @mutex.synchronize { @event_loop ||= ActionCable::Connection::StreamEventLoop.new } end - # The worker pool is where we run connection callbacks and channel actions. We do as little as possible on the server's main thread. - # The worker pool is an executor service that's backed by a pool of threads working from a task queue. The thread pool size maxes out - # at 4 worker threads by default. Tune the size yourself with config.action_cable.worker_pool_size. + # The worker pool is where we run connection callbacks and channel actions. We + # do as little as possible on the server's main thread. The worker pool is an + # executor service that's backed by a pool of threads working from a task queue. + # The thread pool size maxes out at 4 worker threads by default. Tune the size + # yourself with `config.action_cable.worker_pool_size`. # - # Using Active Record, Redis, etc within your channel actions means you'll get a separate connection from each thread in the worker pool. - # Plan your deployment accordingly: 5 servers each running 5 Puma workers each running an 8-thread worker pool means at least 200 database - # connections. + # Using Active Record, Redis, etc within your channel actions means you'll get a + # separate connection from each thread in the worker pool. Plan your deployment + # accordingly: 5 servers each running 5 Puma workers each running an 8-thread + # worker pool means at least 200 database connections. # - # Also, ensure that your database connection pool size is as least as large as your worker pool size. Otherwise, workers may oversubscribe - # the database connection pool and block while they wait for other workers to release their connections. Use a smaller worker pool or a larger - # database connection pool instead. + # Also, ensure that your database connection pool size is as least as large as + # your worker pool size. Otherwise, workers may oversubscribe the database + # connection pool and block while they wait for other workers to release their + # connections. Use a smaller worker pool or a larger database connection pool + # instead. def worker_pool @worker_pool || @mutex.synchronize { @worker_pool ||= ActionCable::Server::Worker.new(max_size: config.worker_pool_size) } end @@ -76,7 +97,8 @@ def pubsub @pubsub || @mutex.synchronize { @pubsub ||= config.pubsub_adapter.new(self) } end - # All of the identifiers applied to the connection class associated with this server. + # All of the identifiers applied to the connection class associated with this + # server. def connection_identifiers config.connection_class.call.identifiers end diff --git a/actioncable/lib/action_cable/server/broadcasting.rb b/actioncable/lib/action_cable/server/broadcasting.rb index 7fcd6c6587d7b..cedd0fb58fb0b 100644 --- a/actioncable/lib/action_cable/server/broadcasting.rb +++ b/actioncable/lib/action_cable/server/broadcasting.rb @@ -1,30 +1,42 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Server - # Broadcasting is how other parts of your application can send messages to a channel's subscribers. As explained in Channel, most of the time, these - # broadcastings are streamed directly to the clients subscribed to the named broadcasting. Let's explain with a full-stack example: + # # Action Cable Server Broadcasting + # + # Broadcasting is how other parts of your application can send messages to a + # channel's subscribers. As explained in Channel, most of the time, these + # broadcastings are streamed directly to the clients subscribed to the named + # broadcasting. Let's explain with a full-stack example: # - # class WebNotificationsChannel < ApplicationCable::Channel - # def subscribed - # stream_from "web_notifications_#{current_user.id}" + # class WebNotificationsChannel < ApplicationCable::Channel + # def subscribed + # stream_from "web_notifications_#{current_user.id}" + # end # end - # end # - # # Somewhere in your app this is called, perhaps from a NewCommentJob: - # ActionCable.server.broadcast \ - # "web_notifications_1", { title: "New things!", body: "All that's fit for print" } + # # Somewhere in your app this is called, perhaps from a NewCommentJob: + # 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. + # Broadcast a hash directly to a named `broadcasting`. This will later be JSON + # encoded. def broadcast(broadcasting, message, coder: ActiveSupport::JSON) broadcaster_for(broadcasting, coder: coder).broadcast(message) end - # Returns a broadcaster for a named broadcasting that can be reused. Useful when you have an object that - # may need multiple spots to transmit to a specific broadcasting over and over. + # Returns a broadcaster for a named `broadcasting` that can be reused. Useful + # when you have an object that may need multiple spots to transmit to a specific + # broadcasting over and over. def broadcaster_for(broadcasting, coder: ActiveSupport::JSON) Broadcaster.new(self, String(broadcasting), coder: coder) end @@ -38,7 +50,7 @@ def initialize(server, broadcasting, coder:) end def broadcast(message) - server.logger.debug "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect}" + server.logger.debug { "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect.truncate(300)}" } payload = { broadcasting: broadcasting, message: message, coder: coder } ActiveSupport::Notifications.instrument("broadcast.action_cable", payload) do diff --git a/actioncable/lib/action_cable/server/configuration.rb b/actioncable/lib/action_cable/server/configuration.rb index 17e0dee064ef0..6210bfcf47b9d 100644 --- a/actioncable/lib/action_cable/server/configuration.rb +++ b/actioncable/lib/action_cable/server/configuration.rb @@ -1,12 +1,23 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "rack" + module ActionCable module Server - # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak Action Cable configuration - # in a Rails config initializer. + # # Action Cable Server Configuration + # + # An instance of this configuration object is available via + # ActionCable.server.config, which allows you to tweak Action Cable + # configuration in a Rails config initializer. class Configuration attr_accessor :logger, :log_tags attr_accessor :connection_class, :worker_pool_size - attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host + attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host, :filter_parameters attr_accessor :cable, :url, :mount_path + attr_accessor :precompile_assets + attr_accessor :health_check_path, :health_check_application def initialize @log_tags = [] @@ -16,20 +27,38 @@ def initialize @disable_request_forgery_protection = false @allow_same_origin_as_host = true + @filter_parameters = [] + + @health_check_application = ->(env) { + [200, { Rack::CONTENT_TYPE => "text/html", "date" => Time.now.httpdate }, []] + } end - # Returns constant of subscription adapter specified in config/cable.yml. - # If the adapter cannot be found, this will default to the Redis adapter. - # Also makes sure proper dependencies are required. + # Returns constant of subscription adapter specified in config/cable.yml. If the + # adapter cannot be found, this will default to the Redis adapter. Also makes + # sure proper dependencies are required. def pubsub_adapter adapter = (cable.fetch("adapter") { "redis" }) + + # Require the adapter itself and give useful feedback about + # 1. Missing adapter gems and + # 2. Adapter gems' missing dependencies. path_to_adapter = "action_cable/subscription_adapter/#{adapter}" begin require path_to_adapter - rescue Gem::LoadError => e - raise Gem::LoadError, "Specified '#{adapter}' for Action Cable pubsub adapter, but the gem is not loaded. Add `gem '#{e.name}'` to your Gemfile (and ensure its version is at the minimum required by Action Cable)." rescue LoadError => e - raise LoadError, "Could not load '#{path_to_adapter}'. Make sure that the adapter in config/cable.yml is valid. If you use an adapter other than 'postgresql' or 'redis' add the necessary adapter gem to the Gemfile.", e.backtrace + # We couldn't require the adapter itself. Raise an exception that points out + # config typos and missing gems. + if e.path == path_to_adapter + # We can assume that a non-builtin adapter was specified, so it's either + # misspelled or missing from Gemfile. + raise e.class, "Could not load the '#{adapter}' Action Cable pubsub adapter. Ensure that the adapter is spelled correctly in config/cable.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace + + # Bubbled up from the adapter require. Prefix the exception message with some + # guidance about how to address it and reraise. + else + raise e.class, "Error loading the '#{adapter}' Action Cable pubsub adapter. Missing a gem it depends on? #{e.message}", e.backtrace + end end adapter = adapter.camelize diff --git a/actioncable/lib/action_cable/server/connections.rb b/actioncable/lib/action_cable/server/connections.rb index 5e61b4e335b14..e51933b177410 100644 --- a/actioncable/lib/action_cable/server/connections.rb +++ b/actioncable/lib/action_cable/server/connections.rb @@ -1,7 +1,15 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Server - # Collection class for all the connections that have been established on this specific server. Remember, usually you'll run many Action Cable servers, so - # you can't use this collection as a full list of all of the connections established against your application. Instead, use RemoteConnections for that. + # # Action Cable Server Connections + # + # Collection class for all the connections that have been established on this + # specific server. Remember, usually you'll run many Action Cable servers, so + # you can't use this collection as a full list of all of the connections + # established against your application. Instead, use RemoteConnections for that. module Connections # :nodoc: BEAT_INTERVAL = 3 @@ -17,12 +25,14 @@ def remove_connection(connection) connections.delete connection end - # WebSocket connection implementations differ on when they'll mark a connection as stale. We basically never want a connection to go stale, as you - # then can't rely on being able to communicate with the connection. To solve this, a 3 second heartbeat runs on all connections. If the beat fails, we automatically + # WebSocket connection implementations differ on when they'll mark a connection + # as stale. We basically never want a connection to go stale, as you then can't + # rely on being able to communicate with the connection. To solve this, a 3 + # second heartbeat runs on all connections. If the beat fails, we automatically # disconnect. def setup_heartbeat_timer @heartbeat_timer ||= event_loop.timer(BEAT_INTERVAL) do - event_loop.post { connections.map(&:beat) } + event_loop.post { connections.each(&:beat) } end end diff --git a/actioncable/lib/action_cable/server/worker.rb b/actioncable/lib/action_cable/server/worker.rb index 43639c27afdcc..535849a1515e7 100644 --- a/actioncable/lib/action_cable/server/worker.rb +++ b/actioncable/lib/action_cable/server/worker.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/callbacks" require "active_support/core_ext/module/attribute_accessors_per_thread" require "concurrent" @@ -16,14 +20,15 @@ class Worker # :nodoc: def initialize(max_size: 5) @executor = Concurrent::ThreadPoolExecutor.new( + name: "ActionCable-server", min_threads: 1, max_threads: max_size, max_queue: 0, ) end - # Stop processing work: any work that has not already started - # running will be discarded from the queue + # Stop processing work: any work that has not already started running will be + # discarded from the queue def halt @executor.shutdown end @@ -32,12 +37,10 @@ def stopping? @executor.shuttingdown? end - def work(connection) + def work(connection, &block) self.connection = connection - run_callbacks :work do - yield - end + run_callbacks :work, &block ensure self.connection = nil end @@ -54,19 +57,16 @@ def async_invoke(receiver, method, *args, connection: receiver, &block) def invoke(receiver, method, *args, connection:, &block) work(connection) do - begin - receiver.send method, *args, &block - rescue Exception => e - logger.error "There was an exception - #{e.class}(#{e.message})" - logger.error e.backtrace.join("\n") + receiver.send method, *args, &block + rescue Exception => e + logger.error "There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") - receiver.handle_exception if receiver.respond_to?(:handle_exception) - end + receiver.handle_exception if receiver.respond_to?(:handle_exception) end end private - def logger ActionCable.server.logger end diff --git a/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb index c1e4aa8103565..2512c500f4265 100644 --- a/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb +++ b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module Server class Worker @@ -10,8 +14,8 @@ module ActiveRecordConnectionManagement end end - def with_database_connections - connection.logger.tag(ActiveRecord::Base.logger) { yield } + def with_database_connections(&block) + connection.logger.tag(ActiveRecord::Base.logger, &block) end end end diff --git a/actioncable/lib/action_cable/subscription_adapter.rb b/actioncable/lib/action_cable/subscription_adapter.rb deleted file mode 100644 index 596269ab9bf31..0000000000000 --- a/actioncable/lib/action_cable/subscription_adapter.rb +++ /dev/null @@ -1,9 +0,0 @@ -module ActionCable - module SubscriptionAdapter - extend ActiveSupport::Autoload - - autoload :Base - autoload :SubscriberMap - autoload :ChannelPrefix - end -end diff --git a/actioncable/lib/action_cable/subscription_adapter/async.rb b/actioncable/lib/action_cable/subscription_adapter/async.rb index 46819dbfec17a..f78edb9ae7c35 100644 --- a/actioncable/lib/action_cable/subscription_adapter/async.rb +++ b/actioncable/lib/action_cable/subscription_adapter/async.rb @@ -1,4 +1,6 @@ -require "action_cable/subscription_adapter/inline" +# frozen_string_literal: true + +# :markup: markdown module ActionCable module SubscriptionAdapter diff --git a/actioncable/lib/action_cable/subscription_adapter/base.rb b/actioncable/lib/action_cable/subscription_adapter/base.rb index 796db5ffa354e..2df2667b2336c 100644 --- a/actioncable/lib/action_cable/subscription_adapter/base.rb +++ b/actioncable/lib/action_cable/subscription_adapter/base.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module SubscriptionAdapter class Base @@ -23,6 +27,11 @@ def unsubscribe(channel, message_callback) def shutdown raise NotImplementedError end + + def identifier + @server.config.cable[:id] = "ActionCable-PID-#{$$}" unless @server.config.cable.key?(:id) + @server.config.cable[:id] + end end end end diff --git a/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb b/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb index 8b293cc7853ab..2c3e88f307ed2 100644 --- a/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb +++ b/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module SubscriptionAdapter module ChannelPrefix # :nodoc: diff --git a/actioncable/lib/action_cable/subscription_adapter/evented_redis.rb b/actioncable/lib/action_cable/subscription_adapter/evented_redis.rb deleted file mode 100644 index 56b068976bf09..0000000000000 --- a/actioncable/lib/action_cable/subscription_adapter/evented_redis.rb +++ /dev/null @@ -1,81 +0,0 @@ -require "thread" - -gem "em-hiredis", "~> 0.3.0" -gem "redis", "~> 3.0" -require "em-hiredis" -require "redis" - -EventMachine.epoll if EventMachine.epoll? -EventMachine.kqueue if EventMachine.kqueue? - -module ActionCable - module SubscriptionAdapter - class EventedRedis < Base # :nodoc: - prepend ChannelPrefix - - @@mutex = Mutex.new - - # Overwrite this factory method for EventMachine Redis connections if you want to use a different Redis connection library than EM::Hiredis. - # This is needed, for example, when using Makara proxies for distributed Redis. - cattr_accessor(:em_redis_connector) { ->(config) { EM::Hiredis.connect(config[:url]) } } - - # Overwrite this factory method for Redis connections if you want to use a different Redis connection library than Redis. - # This is needed, for example, when using Makara proxies for distributed Redis. - cattr_accessor(:redis_connector) { ->(config) { ::Redis.new(url: config[:url]) } } - - def initialize(*) - super - @redis_connection_for_broadcasts = @redis_connection_for_subscriptions = nil - end - - def broadcast(channel, payload) - redis_connection_for_broadcasts.publish(channel, payload) - end - - def subscribe(channel, message_callback, success_callback = nil) - redis_connection_for_subscriptions.pubsub.subscribe(channel, &message_callback).tap do |result| - result.callback { |reply| success_callback.call } if success_callback - end - end - - def unsubscribe(channel, message_callback) - redis_connection_for_subscriptions.pubsub.unsubscribe_proc(channel, message_callback) - end - - def shutdown - redis_connection_for_subscriptions.pubsub.close_connection - @redis_connection_for_subscriptions = nil - end - - private - def redis_connection_for_subscriptions - ensure_reactor_running - @redis_connection_for_subscriptions || @server.mutex.synchronize do - @redis_connection_for_subscriptions ||= self.class.em_redis_connector.call(@server.config.cable).tap do |redis| - redis.on(:reconnect_failed) do - @logger.error "[ActionCable] Redis reconnect failed." - end - - redis.on(:failed) do - @logger.error "[ActionCable] Redis connection has failed." - end - end - end - end - - def redis_connection_for_broadcasts - @redis_connection_for_broadcasts || @server.mutex.synchronize do - @redis_connection_for_broadcasts ||= self.class.redis_connector.call(@server.config.cable) - end - end - - def ensure_reactor_running - return if EventMachine.reactor_running? && EventMachine.reactor_thread - @@mutex.synchronize do - Thread.new { EventMachine.run } unless EventMachine.reactor_running? - Thread.pass until EventMachine.reactor_running? && EventMachine.reactor_thread - end - end - end - end -end diff --git a/actioncable/lib/action_cable/subscription_adapter/inline.rb b/actioncable/lib/action_cable/subscription_adapter/inline.rb index 81357faead2c0..88559fb39c64e 100644 --- a/actioncable/lib/action_cable/subscription_adapter/inline.rb +++ b/actioncable/lib/action_cable/subscription_adapter/inline.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module SubscriptionAdapter class Inline < Base # :nodoc: diff --git a/actioncable/lib/action_cable/subscription_adapter/postgresql.rb b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb index bdab5205ec91c..2ac063a2e63b1 100644 --- a/actioncable/lib/action_cable/subscription_adapter/postgresql.rb +++ b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb @@ -1,50 +1,76 @@ -gem "pg", "~> 0.18" +# frozen_string_literal: true + +# :markup: markdown + +gem "pg", "~> 1.1" require "pg" -require "thread" +require "openssl" module ActionCable module SubscriptionAdapter class PostgreSQL < Base # :nodoc: + prepend ChannelPrefix + def initialize(*) super @listener = nil end def broadcast(channel, payload) - with_connection do |pg_conn| - pg_conn.exec("NOTIFY #{pg_conn.escape_identifier(channel)}, '#{pg_conn.escape_string(payload)}'") + with_broadcast_connection do |pg_conn| + pg_conn.exec("NOTIFY #{pg_conn.escape_identifier(channel_identifier(channel))}, '#{pg_conn.escape_string(payload)}'") end end def subscribe(channel, callback, success_callback = nil) - listener.add_subscriber(channel, callback, success_callback) + listener.add_subscriber(channel_identifier(channel), callback, success_callback) end def unsubscribe(channel, callback) - listener.remove_subscriber(channel, callback) + listener.remove_subscriber(channel_identifier(channel), callback) end def shutdown listener.shutdown end - def with_connection(&block) # :nodoc: + def with_subscriptions_connection(&block) # :nodoc: + # Action Cable is taking ownership over this database connection, and will + # perform the necessary cleanup tasks. + # 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 + + verify!(pg_conn) + pg_conn.exec("SET application_name = #{pg_conn.escape_identifier(identifier)}") + yield pg_conn + ensure + ar_conn&.disconnect! + end + + def with_broadcast_connection(&block) # :nodoc: ActiveRecord::Base.connection_pool.with_connection do |ar_conn| pg_conn = ar_conn.raw_connection - - unless pg_conn.is_a?(PG::Connection) - raise "The Active Record database must be PostgreSQL in order to use the PostgreSQL Action Cable storage adapter" - end - + verify!(pg_conn) yield pg_conn end end private + def channel_identifier(channel) + channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel + end + def listener @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) } end + def verify!(pg_conn) + unless pg_conn.is_a?(PG::Connection) + raise "The Active Record database must be PostgreSQL in order to use the PostgreSQL Action Cable storage adapter" + end + end + class Listener < SubscriberMap def initialize(adapter, event_loop) super() @@ -60,7 +86,7 @@ def initialize(adapter, event_loop) end def listen - @adapter.with_connection do |pg_conn| + @adapter.with_subscriptions_connection do |pg_conn| catch :shutdown do loop do until @queue.empty? diff --git a/actioncable/lib/action_cable/subscription_adapter/redis.rb b/actioncable/lib/action_cable/subscription_adapter/redis.rb index 41a6e55822509..784a7ee0911da 100644 --- a/actioncable/lib/action_cable/subscription_adapter/redis.rb +++ b/actioncable/lib/action_cable/subscription_adapter/redis.rb @@ -1,16 +1,23 @@ -require "thread" +# frozen_string_literal: true -gem "redis", "~> 3.0" +# :markup: markdown + +gem "redis", ">= 4", "< 6" require "redis" +require "active_support/core_ext/hash/except" + module ActionCable module SubscriptionAdapter class Redis < Base # :nodoc: prepend ChannelPrefix - # Overwrite this factory method for redis connections if you want to use a different Redis library than Redis. - # This is needed, for example, when using Makara proxies for distributed Redis. - cattr_accessor(:redis_connector) { ->(config) { ::Redis.new(url: config[:url]) } } + # Overwrite this factory method for Redis connections if you want to use a + # different Redis library than the redis gem. This is needed, for example, when + # using Makara proxies for distributed Redis. + cattr_accessor :redis_connector, default: ->(config) do + ::Redis.new(config.except(:adapter, :channel_prefix)) + end def initialize(*) super @@ -40,7 +47,7 @@ def redis_connection_for_subscriptions private def listener - @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) } + @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, config_options, @server.event_loop) } end def redis_connection_for_broadcasts @@ -50,11 +57,15 @@ def redis_connection_for_broadcasts end def redis_connection - self.class.redis_connector.call(@server.config.cable) + self.class.redis_connector.call(config_options) + end + + def config_options + @config_options ||= @server.config.cable.deep_symbolize_keys.merge(id: identifier) end class Listener < SubscriberMap - def initialize(adapter, event_loop) + def initialize(adapter, config_options, event_loop) super() @adapter = adapter @@ -63,7 +74,12 @@ def initialize(adapter, event_loop) @subscribe_callbacks = Hash.new { |h, k| h[k] = [] } @subscription_lock = Mutex.new - @raw_client = nil + @reconnect_attempt = 0 + # Use the same config as used by Redis conn + @reconnect_attempts = config_options.fetch(:reconnect_attempts, 1) + @reconnect_attempts = Array.new(@reconnect_attempts, 0) if @reconnect_attempts.is_a?(Integer) + + @subscribed_client = nil @when_connected = [] @@ -72,13 +88,14 @@ def initialize(adapter, event_loop) def listen(conn) conn.without_reconnect do - original_client = conn.client + original_client = extract_subscribed_client(conn) conn.subscribe("_action_cable_internal") do |on| on.subscribe do |chan, count| @subscription_lock.synchronize do if count == 1 - @raw_client = original_client + @reconnect_attempt = 0 + @subscribed_client = original_client until @when_connected.empty? @when_connected.shift.call @@ -100,7 +117,7 @@ def listen(conn) on.unsubscribe do |chan, count| if count == 0 @subscription_lock.synchronize do - @raw_client = nil + @subscribed_client = nil end end end @@ -113,8 +130,8 @@ def shutdown return if @thread.nil? when_connected do - send_command("unsubscribe") - @raw_client = nil + @subscribed_client.unsubscribe + @subscribed_client = nil end end @@ -125,13 +142,13 @@ def add_channel(channel, on_success) @subscription_lock.synchronize do ensure_listener_running @subscribe_callbacks[channel] << on_success - when_connected { send_command("subscribe", channel) } + when_connected { @subscribed_client.subscribe(channel) } end end def remove_channel(channel) @subscription_lock.synchronize do - when_connected { send_command("unsubscribe", channel) } + when_connected { @subscribed_client.unsubscribe(channel) } end end @@ -144,28 +161,98 @@ def ensure_listener_running @thread ||= Thread.new do Thread.current.abort_on_exception = true - conn = @adapter.redis_connection_for_subscriptions - listen conn + begin + conn = @adapter.redis_connection_for_subscriptions + listen conn + rescue *CONNECTION_ERRORS + reset + if retry_connecting? + when_connected { resubscribe } + retry + end + end end end def when_connected(&block) - if @raw_client + if @subscribed_client block.call else @when_connected << block end end - def send_command(*command) - @raw_client.write(command) + def retry_connecting? + @reconnect_attempt += 1 + + return false if @reconnect_attempt > @reconnect_attempts.size + + sleep_t = @reconnect_attempts[@reconnect_attempt - 1] + + sleep(sleep_t) if sleep_t > 0 + + true + end + + def resubscribe + channels = @sync.synchronize do + @subscribers.keys + end + @subscribed_client.subscribe(*channels) unless channels.empty? + end + + def reset + @subscription_lock.synchronize do + @subscribed_client = nil + @subscribe_callbacks.clear + @when_connected.clear + end + end + + if ::Redis::VERSION < "5" + CONNECTION_ERRORS = [::Redis::BaseConnectionError].freeze + + class SubscribedClient + def initialize(raw_client) + @raw_client = raw_client + end + + def subscribe(*channel) + send_command("subscribe", *channel) + end + + def unsubscribe(*channel) + send_command("unsubscribe", *channel) + end + + private + def send_command(*command) + @raw_client.write(command) + + very_raw_connection = + @raw_client.connection.instance_variable_defined?(:@connection) && + @raw_client.connection.instance_variable_get(:@connection) + + if very_raw_connection && very_raw_connection.respond_to?(:flush) + very_raw_connection.flush + end + nil + end + end + + def extract_subscribed_client(conn) + SubscribedClient.new(conn._client) + end + else + CONNECTION_ERRORS = [ + ::Redis::BaseConnectionError, - very_raw_connection = - @raw_client.connection.instance_variable_defined?(:@connection) && - @raw_client.connection.instance_variable_get(:@connection) + # Some older versions of redis-rb sometime leak underlying exceptions + RedisClient::ConnectionError, + ].freeze - if very_raw_connection && very_raw_connection.respond_to?(:flush) - very_raw_connection.flush + def extract_subscribed_client(conn) + conn end end end diff --git a/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb b/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb index 4cce86dccad78..d541681d3d473 100644 --- a/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb +++ b/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionCable module SubscriptionAdapter class SubscriberMap diff --git a/actioncable/lib/action_cable/subscription_adapter/test.rb b/actioncable/lib/action_cable/subscription_adapter/test.rb new file mode 100644 index 0000000000000..d09018aabbab5 --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + # ## Test adapter for Action Cable + # + # The test adapter should be used only in testing. Along with + # ActionCable::TestHelper it makes a great tool to test your Rails application. + # + # To use the test adapter set `adapter` value to `test` in your + # `config/cable.yml` file. + # + # NOTE: `Test` adapter extends the `ActionCable::SubscriptionAdapter::Async` + # adapter, so it could be used in system tests too. + class Test < Async + def broadcast(channel, payload) + broadcasts(channel) << payload + super + end + + def broadcasts(channel) + channels_data[channel] ||= [] + end + + def clear_messages(channel) + channels_data[channel] = [] + end + + def clear + @channels_data = nil + end + + private + def channels_data + @channels_data ||= {} + end + end + end +end diff --git a/actioncable/lib/action_cable/test_case.rb b/actioncable/lib/action_cable/test_case.rb new file mode 100644 index 0000000000000..b56f2ead6c306 --- /dev/null +++ b/actioncable/lib/action_cable/test_case.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/test_case" + +module ActionCable + class TestCase < ActiveSupport::TestCase + include ActionCable::TestHelper + + ActiveSupport.run_load_hooks(:action_cable_test_case, self) + end +end diff --git a/actioncable/lib/action_cable/test_helper.rb b/actioncable/lib/action_cable/test_helper.rb new file mode 100644 index 0000000000000..682d8fc039843 --- /dev/null +++ b/actioncable/lib/action_cable/test_helper.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + # Provides helper methods for testing Action Cable broadcasting + module TestHelper + def before_setup # :nodoc: + server = ActionCable.server + test_adapter = ActionCable::SubscriptionAdapter::Test.new(server) + + @old_pubsub_adapter = server.pubsub + + server.instance_variable_set(:@pubsub, test_adapter) + super + end + + def after_teardown # :nodoc: + super + ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter) + end + + # Asserts that the number of broadcasted messages to the stream matches the + # given number. + # + # def test_broadcasts + # assert_broadcasts 'messages', 0 + # ActionCable.server.broadcast 'messages', { text: 'hello' } + # assert_broadcasts 'messages', 1 + # ActionCable.server.broadcast 'messages', { text: 'world' } + # assert_broadcasts 'messages', 2 + # end + # + # If a block is passed, that block should cause the specified number of messages + # to be broadcasted. + # + # def test_broadcasts_again + # assert_broadcasts('messages', 1) do + # ActionCable.server.broadcast 'messages', { text: 'hello' } + # end + # + # assert_broadcasts('messages', 2) do + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # ActionCable.server.broadcast 'messages', { text: 'how are you?' } + # end + # end + # + def assert_broadcasts(stream, number, &block) + if block_given? + new_messages = new_broadcasts_from(broadcasts(stream), stream, "assert_broadcasts", &block) + + actual_count = new_messages.size + assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent" + else + actual_count = broadcasts(stream).size + assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent" + end + end + + # Asserts that no messages have been sent to the stream. + # + # def test_no_broadcasts + # assert_no_broadcasts 'messages' + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # assert_broadcasts 'messages', 1 + # end + # + # If a block is passed, that block should not cause any message to be sent. + # + # def test_broadcasts_again + # assert_no_broadcasts 'messages' do + # # No job messages should be sent from this block + # end + # end + # + # Note: This assertion is simply a shortcut for: + # + # assert_broadcasts 'messages', 0, &block + # + def assert_no_broadcasts(stream, &block) + assert_broadcasts stream, 0, &block + end + + # Returns the messages that are broadcasted in the block. + # + # def test_broadcasts + # messages = capture_broadcasts('messages') do + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # ActionCable.server.broadcast 'messages', { text: 'how are you?' } + # end + # assert_equal 2, messages.length + # assert_equal({ text: 'hi' }, messages.first) + # assert_equal({ text: 'how are you?' }, messages.last) + # end + # + def capture_broadcasts(stream, &block) + new_broadcasts_from(broadcasts(stream), stream, "capture_broadcasts", &block).map { |m| ActiveSupport::JSON.decode(m) } + end + + # Asserts that the specified message has been sent to the stream. + # + # def test_assert_transmitted_message + # ActionCable.server.broadcast 'messages', text: 'hello' + # assert_broadcast_on('messages', text: 'hello') + # end + # + # If a block is passed, that block should cause a message with the specified + # data to be sent. + # + # def test_assert_broadcast_on_again + # assert_broadcast_on('messages', text: 'hello') do + # ActionCable.server.broadcast 'messages', text: 'hello' + # end + # end + # + def assert_broadcast_on(stream, data, &block) + # Encode to JSON and back–we want to use this value to compare with decoded + # JSON. Comparing JSON strings doesn't work due to the order if the keys. + serialized_msg = + ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(data)) + + new_messages = broadcasts(stream) + if block_given? + new_messages = new_broadcasts_from(new_messages, stream, "assert_broadcast_on", &block) + end + + message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg } + + error_message = "No messages sent with #{data} to #{stream}" + + if new_messages.any? + error_message = new_messages.inject("#{error_message}\nMessage(s) found:\n") do |error_message, new_message| + error_message + "#{ActiveSupport::JSON.decode(new_message)}\n" + end + else + error_message = "#{error_message}\nNo message found for #{stream}" + end + + assert message, error_message + end + + def pubsub_adapter # :nodoc: + ActionCable.server.pubsub + end + + delegate :broadcasts, :clear_messages, to: :pubsub_adapter + + private + def new_broadcasts_from(current_messages, stream, assertion, &block) + old_messages = current_messages + clear_messages(stream) + + _assert_nothing_raised_or_warn(assertion, &block) + new_messages = broadcasts(stream) + clear_messages(stream) + + # Restore all sent messages + (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) } + + new_messages + end + end +end diff --git a/actioncable/lib/action_cable/version.rb b/actioncable/lib/action_cable/version.rb index d6081409f0e72..14423f9a6a1b6 100644 --- a/actioncable/lib/action_cable/version.rb +++ b/actioncable/lib/action_cable/version.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + +# :markup: markdown + require_relative "gem_version" module ActionCable - # Returns the version of the currently loaded Action Cable as a Gem::Version + # Returns the currently loaded version of Action Cable as a `Gem::Version`. def self.version gem_version end diff --git a/actioncable/lib/rails/generators/channel/USAGE b/actioncable/lib/rails/generators/channel/USAGE index dd109fda80b8b..13190920227e3 100644 --- a/actioncable/lib/rails/generators/channel/USAGE +++ b/actioncable/lib/rails/generators/channel/USAGE @@ -1,14 +1,19 @@ Description: -============ - Stubs out a new cable channel for the server (in Ruby) and client (in CoffeeScript). + Generates a new cable channel for the server (in Ruby) and client (in JavaScript). Pass the channel name, either CamelCased or under_scored, and an optional list of channel actions as arguments. - Note: Turn on the cable connection in app/assets/javascripts/cable.js after generating any channels. +Examples: + `bin/rails generate channel notification` -Example: -======== - rails generate channel Chat speak + creates a notification channel class, test and JavaScript asset: + Channel: app/channels/notification_channel.rb + Test: test/channels/notification_channel_test.rb + Assets: $JAVASCRIPT_PATH/channels/notification_channel.js - creates a Chat channel class and CoffeeScript asset: - Channel: app/channels/chat_channel.rb - Assets: app/assets/javascripts/channels/chat.coffee + `bin/rails generate channel chat speak` + + creates a chat channel with a speak action. + + `bin/rails generate channel comments --no-assets` + + creates a comments channel without JavaScript assets. diff --git a/actioncable/lib/rails/generators/channel/channel_generator.rb b/actioncable/lib/rails/generators/channel/channel_generator.rb index 984b78bc9c7c9..72a6a3821ebe8 100644 --- a/actioncable/lib/rails/generators/channel/channel_generator.rb +++ b/actioncable/lib/rails/generators/channel/channel_generator.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + +# :markup: markdown + module Rails module Generators class ChannelGenerator < NamedBase - source_root File.expand_path("../templates", __FILE__) + source_root File.expand_path("templates", __dir__) argument :actions, type: :array, default: [], banner: "method method" @@ -9,39 +13,115 @@ class ChannelGenerator < NamedBase check_class_collision suffix: "Channel" - def create_channel_file - template "channel.rb", File.join("app/channels", class_path, "#{file_name}_channel.rb") + hook_for :test_framework + + def create_channel_files + create_shared_channel_files + create_channel_file - if options[:assets] - if behavior == :invoke - template "assets/cable.js", "app/assets/javascripts/cable.js" + if using_javascript? + if first_setup_required? + create_shared_channel_javascript_files + import_channels_in_javascript_entrypoint + + if using_importmap? + pin_javascript_dependencies + elsif using_js_runtime? + install_javascript_dependencies + end end - js_template "assets/channel", File.join("app/assets/javascripts/channels", class_path, "#{file_name}") + create_channel_javascript_file + import_channel_in_javascript_entrypoint end - - generate_application_cable_files end private - def file_name - @_file_name ||= super.gsub(/_channel/i, "") + def create_shared_channel_files + return if behavior != :invoke + + copy_file "#{__dir__}/templates/application_cable/channel.rb", + "app/channels/application_cable/channel.rb" + copy_file "#{__dir__}/templates/application_cable/connection.rb", + "app/channels/application_cable/connection.rb" end - # FIXME: Change these files to symlinks once RubyGems 2.5.0 is required. - def generate_application_cable_files - return if behavior != :invoke + def create_channel_file + template "channel.rb", + File.join("app/channels", class_path, "#{file_name}_channel.rb") + end - files = [ - "application_cable/channel.rb", - "application_cable/connection.rb" - ] + def create_shared_channel_javascript_files + template "javascript/index.js", "app/javascript/channels/index.js" + template "javascript/consumer.js", "app/javascript/channels/consumer.js" + end - files.each do |name| - path = File.join("app/channels/", name) - template(name, path) if !File.exist?(path) + def create_channel_javascript_file + channel_js_path = File.join("app/javascript/channels", class_path, "#{file_name}_channel") + js_template "javascript/channel", channel_js_path + gsub_file "#{channel_js_path}.js", /\.\/consumer/, "channels/consumer" if using_importmap? + end + + def import_channels_in_javascript_entrypoint + append_to_file "app/javascript/application.js", + using_importmap? ? %(import "channels"\n) : %(import "./channels"\n) + end + + def import_channel_in_javascript_entrypoint + append_to_file "app/javascript/channels/index.js", + using_importmap? ? %(import "channels/#{file_name}_channel"\n) : %(import "./#{file_name}_channel"\n) + end + + def install_javascript_dependencies + say "Installing JavaScript dependencies", :green + if using_bun? + run "bun add @rails/actioncable" + elsif using_node? + run "yarn add @rails/actioncable" end end + + def pin_javascript_dependencies + append_to_file "config/importmap.rb", <<-RUBY +pin "@rails/actioncable", to: "actioncable.esm.js" +pin_all_from "app/javascript/channels", under: "channels" + RUBY + end + + def file_name + @_file_name ||= super.sub(/_channel\z/i, "") + end + + def first_setup_required? + !root.join("app/javascript/channels/index.js").exist? + end + + def using_javascript? + @using_javascript ||= options[:assets] && root.join("app/javascript").exist? + end + + def using_js_runtime? + @using_js_runtime ||= root.join("package.json").exist? + end + + def using_bun? + # Cannot assume Bun lockfile has been generated yet so we look for a file known to + # be generated by the jsbundling-rails gem + @using_bun ||= using_js_runtime? && root.join("bun.config.js").exist? + end + + def using_node? + # Bun is the only runtime that _isn't_ node. + @using_node ||= using_js_runtime? && !root.join("bun.config.js").exist? + end + + def using_importmap? + @using_importmap ||= root.join("config/importmap.rb").exist? + end + + def root + @root ||= Pathname(destination_root) + end end end end diff --git a/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb b/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt similarity index 100% rename from actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb rename to actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt diff --git a/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb b/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt similarity index 100% rename from actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb rename to actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt diff --git a/actioncable/lib/rails/generators/channel/templates/assets/cable.js b/actioncable/lib/rails/generators/channel/templates/assets/cable.js deleted file mode 100644 index 739aa5f022071..0000000000000 --- a/actioncable/lib/rails/generators/channel/templates/assets/cable.js +++ /dev/null @@ -1,13 +0,0 @@ -// Action Cable provides the framework to deal with WebSockets in Rails. -// You can generate new channels where WebSocket features live using the `rails generate channel` command. -// -//= require action_cable -//= require_self -//= require_tree ./channels - -(function() { - this.App || (this.App = {}); - - App.cable = ActionCable.createConsumer(); - -}).call(this); diff --git a/actioncable/lib/rails/generators/channel/templates/assets/channel.coffee b/actioncable/lib/rails/generators/channel/templates/assets/channel.coffee deleted file mode 100644 index 5467811aba574..0000000000000 --- a/actioncable/lib/rails/generators/channel/templates/assets/channel.coffee +++ /dev/null @@ -1,14 +0,0 @@ -App.<%= class_name.underscore %> = App.cable.subscriptions.create "<%= class_name %>Channel", - connected: -> - # Called when the subscription is ready for use on the server - - disconnected: -> - # Called when the subscription has been terminated by the server - - received: (data) -> - # Called when there's incoming data on the websocket for this channel -<% actions.each do |action| -%> - - <%= action %>: -> - @perform '<%= action %>' -<% end -%> diff --git a/actioncable/lib/rails/generators/channel/templates/assets/channel.js b/actioncable/lib/rails/generators/channel/templates/assets/channel.js deleted file mode 100644 index ab0e68b11aaf0..0000000000000 --- a/actioncable/lib/rails/generators/channel/templates/assets/channel.js +++ /dev/null @@ -1,18 +0,0 @@ -App.<%= class_name.underscore %> = App.cable.subscriptions.create("<%= class_name %>Channel", { - connected: function() { - // Called when the subscription is ready for use on the server - }, - - disconnected: function() { - // Called when the subscription has been terminated by the server - }, - - received: function(data) { - // Called when there's incoming data on the websocket for this channel - }<%= actions.any? ? ",\n" : '' %> -<% actions.each do |action| -%> - <%=action %>: function() { - return this.perform('<%= action %>'); - }<%= action == actions[-1] ? '' : ",\n" %> -<% end -%> -}); diff --git a/actioncable/lib/rails/generators/channel/templates/channel.rb b/actioncable/lib/rails/generators/channel/templates/channel.rb.tt similarity index 100% rename from actioncable/lib/rails/generators/channel/templates/channel.rb rename to actioncable/lib/rails/generators/channel/templates/channel.rb.tt diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt new file mode 100644 index 0000000000000..ddf6b2d79ba0a --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt @@ -0,0 +1,20 @@ +import consumer from "./consumer" + +consumer.subscriptions.create("<%= class_name %>Channel", { + connected() { + // Called when the subscription is ready for use on the server + }, + + disconnected() { + // Called when the subscription has been terminated by the server + }, + + received(data) { + // Called when there's incoming data on the websocket for this channel + }<%= actions.any? ? ",\n" : '' %> +<% actions.each do |action| -%> + <%=action %>: function() { + return this.perform('<%= action %>'); + }<%= action == actions[-1] ? '' : ",\n" %> +<% end -%> +}); diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt new file mode 100644 index 0000000000000..8ec3aad3ae962 --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt @@ -0,0 +1,6 @@ +// Action Cable provides the framework to deal with WebSockets in Rails. +// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. + +import { createConsumer } from "@rails/actioncable" + +export default createConsumer() diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt new file mode 100644 index 0000000000000..08dc8af2a03bd --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt @@ -0,0 +1 @@ +// Import all the channels to be used by Action Cable diff --git a/actioncable/lib/rails/generators/test_unit/channel_generator.rb b/actioncable/lib/rails/generators/test_unit/channel_generator.rb new file mode 100644 index 0000000000000..6054a3be77aff --- /dev/null +++ b/actioncable/lib/rails/generators/test_unit/channel_generator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# :markup: markdown + +module TestUnit + module Generators + class ChannelGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + check_class_collision suffix: "ChannelTest" + + def create_test_files + template "channel_test.rb", File.join("test/channels", class_path, "#{file_name}_channel_test.rb") + end + + private + def file_name # :doc: + @_file_name ||= super.sub(/_channel\z/i, "") + end + end + end +end diff --git a/actioncable/lib/rails/generators/test_unit/templates/channel_test.rb.tt b/actioncable/lib/rails/generators/test_unit/templates/channel_test.rb.tt new file mode 100644 index 0000000000000..7307654611402 --- /dev/null +++ b/actioncable/lib/rails/generators/test_unit/templates/channel_test.rb.tt @@ -0,0 +1,8 @@ +require "test_helper" + +class <%= class_name %>ChannelTest < ActionCable::Channel::TestCase + # test "subscribes" do + # subscribe + # assert subscription.confirmed? + # end +end diff --git a/actioncable/package.json b/actioncable/package.json index 37f82fa1ea2e7..1f1535a5bbdc1 100644 --- a/actioncable/package.json +++ b/actioncable/package.json @@ -1,10 +1,12 @@ { - "name": "actioncable", - "version": "5.0.0-rc1", + "name": "@rails/actioncable", + "version": "8.2.0-alpha", "description": "WebSocket framework for Ruby on Rails.", - "main": "lib/assets/compiled/action_cable.js", + "module": "app/assets/javascripts/actioncable.esm.js", + "main": "app/assets/javascripts/actioncable.js", "files": [ - "lib/assets/compiled/*.js" + "app/assets/javascripts/*.js", + "src/*.js" ], "repository": { "type": "git", @@ -20,5 +22,29 @@ "bugs": { "url": "https://github.com/rails/rails/issues" }, - "homepage": "http://rubyonrails.org/" + "homepage": "https://rubyonrails.org/", + "devDependencies": { + "@eslint/js": "^9.24.0", + "@rollup/plugin-commonjs": "^19.0.1", + "@rollup/plugin-node-resolve": "^11.0.1", + "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", + "karma-sauce-launcher": "^1.2.0", + "mock-socket": "^2.0.0", + "qunit": "^2.8.0", + "rollup": "^2.35.1", + "rollup-plugin-terser": "^7.0.2" + }, + "scripts": { + "prebuild": "yarn lint && bundle exec rake assets:codegen", + "build": "rollup --config rollup.config.js", + "lint": "eslint app/javascript", + "prepublishOnly": "rm -rf src && cp -R app/javascript/action_cable src", + "pretest": "bundle exec rake assets:codegen && rollup --config rollup.config.test.js", + "test": "karma start" + } } diff --git a/actioncable/rollup.config.js b/actioncable/rollup.config.js new file mode 100644 index 0000000000000..0ae1181be17dd --- /dev/null +++ b/actioncable/rollup.config.js @@ -0,0 +1,46 @@ +import resolve from "@rollup/plugin-node-resolve" +import { terser } from "rollup-plugin-terser" + +const terserOptions = { + mangle: false, + compress: false, + format: { + beautify: true, + indent_level: 2 + } +} + +export default [ + { + input: "app/javascript/action_cable/index.js", + output: [ + { + file: "app/assets/javascripts/actioncable.js", + format: "umd", + name: "ActionCable" + }, + + { + file: "app/assets/javascripts/actioncable.esm.js", + format: "es" + } + ], + plugins: [ + resolve(), + terser(terserOptions) + ] + }, + + { + input: "app/javascript/action_cable/index_with_name_deprecation.js", + output: { + file: "app/assets/javascripts/action_cable.js", + format: "umd", + name: "ActionCable" + }, + plugins: [ + resolve(), + terser(terserOptions) + ] + }, +] diff --git a/actioncable/rollup.config.test.js b/actioncable/rollup.config.test.js new file mode 100644 index 0000000000000..06ddc8871ce52 --- /dev/null +++ b/actioncable/rollup.config.test.js @@ -0,0 +1,16 @@ +import commonjs from "@rollup/plugin-commonjs" +import resolve from "@rollup/plugin-node-resolve" + +export default { + input: "test/javascript/src/test.js", + + output: { + file: "test/javascript/compiled/test.js", + format: "iife" + }, + + plugins: [ + resolve(), + commonjs() + ] +} diff --git a/actioncable/test/channel/base_test.rb b/actioncable/test/channel/base_test.rb index 9a3a3581e675f..62e6958fcd8d0 100644 --- a/actioncable/test/channel/base_test.rb +++ b/actioncable/test/channel/base_test.rb @@ -1,8 +1,11 @@ +# frozen_string_literal: true + require "test_helper" +require "minitest/mock" require "stubs/test_connection" require "stubs/room" -class ActionCable::Channel::BaseTest < ActiveSupport::TestCase +class ActionCable::Channel::BaseTest < ActionCable::TestCase class ActionCable::Channel::Base def kick @last_action = [ :kick ] @@ -23,6 +26,9 @@ class ChatChannel < BasicChannel after_subscribe :toggle_subscribed after_unsubscribe :toggle_subscribed + class SomeCustomError < StandardError; end + rescue_from SomeCustomError, with: :error_handler + def initialize(*) @subscribed = false super @@ -58,17 +64,25 @@ def subscribed? end def get_latest - transmit data: "latest" + transmit({ data: "latest" }) end def receive @last_action = [ :receive ] end + def error_action + raise SomeCustomError + end + private def rm_rf @last_action = [ :rm_rf ] end + + def error_handler + @last_action = [ :error_action ] + end end setup do @@ -91,16 +105,35 @@ def rm_rf assert_equal({ id: 1 }, @channel.params) end + test "does not log filtered parameters" do + @connection.server.config.filter_parameters << :password + data = { password: "password", foo: "foo" } + + assert_logged({ password: "[FILTERED]" }.inspect[1..-2]) do + @channel.perform_action data + end + end + test "unsubscribing from a channel" do @channel.subscribe_to_channel assert @channel.room - assert @channel.subscribed? + assert_predicate @channel, :subscribed? @channel.unsubscribe_from_channel - assert ! @channel.room - assert ! @channel.subscribed? + assert_not @channel.room + 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 @@ -165,7 +198,7 @@ def rm_rf end test "actions available on Channel" do - available_actions = %w(room last_action subscribed unsubscribed toggle_subscribed leave speak subscribed? get_latest receive chatters topic).to_set + available_actions = %w(room last_action subscribed unsubscribed toggle_subscribed leave speak subscribed? get_latest receive chatters topic error_action).to_set assert_equal available_actions, ChatChannel.action_methods end @@ -176,82 +209,56 @@ def rm_rf end test "notification for perform_action" do - begin - events = [] - ActiveSupport::Notifications.subscribe "perform_action.action_cable" do |*args| - events << ActiveSupport::Notifications::Event.new(*args) - end - - data = { "action" => :speak, "content" => "hello" } - @channel.perform_action data + data = { "action" => :speak, "content" => "hello" } + expected_payload = { channel_class: "ActionCable::Channel::BaseTest::ChatChannel", action: :speak, data: } - assert_equal 1, events.length - assert_equal "perform_action.action_cable", events[0].name - assert_equal "ActionCable::Channel::BaseTest::ChatChannel", events[0].payload[:channel_class] - assert_equal :speak, events[0].payload[:action] - assert_equal data, events[0].payload[:data] - ensure - ActiveSupport::Notifications.unsubscribe "perform_action.action_cable" + assert_notifications_count("perform_action.action_cable", 1) do + assert_notification("perform_action.action_cable", expected_payload) do + @channel.perform_action data + end end end test "notification for transmit" do - begin - events = [] - ActiveSupport::Notifications.subscribe "transmit.action_cable" do |*args| - events << ActiveSupport::Notifications::Event.new(*args) - end - - @channel.perform_action "action" => :get_latest - expected_data = { data: "latest" } + data = { data: "latest" } + expected_payload = { channel_class: "ActionCable::Channel::BaseTest::ChatChannel", data:, via: nil } - assert_equal 1, events.length - assert_equal "transmit.action_cable", events[0].name - assert_equal "ActionCable::Channel::BaseTest::ChatChannel", events[0].payload[:channel_class] - assert_equal expected_data, events[0].payload[:data] - assert_nil events[0].payload[:via] - ensure - ActiveSupport::Notifications.unsubscribe "transmit.action_cable" + assert_notifications_count("transmit.action_cable", 1) do + assert_notification("transmit.action_cable", expected_payload) do + @channel.perform_action "action" => :get_latest + end end end test "notification for transmit_subscription_confirmation" do - begin - @channel.subscribe_to_channel - - events = [] - ActiveSupport::Notifications.subscribe "transmit_subscription_confirmation.action_cable" do |*args| - events << ActiveSupport::Notifications::Event.new(*args) - end + expected_payload = { channel_class: "ActionCable::Channel::BaseTest::ChatChannel", identifier: "{id: 1}" } - @channel.stubs(:subscription_confirmation_sent?).returns(false) - @channel.send(:transmit_subscription_confirmation) + @channel.subscribe_to_channel - assert_equal 1, events.length - assert_equal "transmit_subscription_confirmation.action_cable", events[0].name - assert_equal "ActionCable::Channel::BaseTest::ChatChannel", events[0].payload[:channel_class] - ensure - ActiveSupport::Notifications.unsubscribe "transmit_subscription_confirmation.action_cable" + assert_notifications_count("transmit_subscription_confirmation.action_cable", 1) do + assert_notification("transmit_subscription_confirmation.action_cable", expected_payload) do + @channel.stub(:subscription_confirmation_sent?, false) do + @channel.send(:transmit_subscription_confirmation) + end + end end end test "notification for transmit_subscription_rejection" do - begin - events = [] - ActiveSupport::Notifications.subscribe "transmit_subscription_rejection.action_cable" do |*args| - events << ActiveSupport::Notifications::Event.new(*args) - end + expected_payload = { channel_class: "ActionCable::Channel::BaseTest::ChatChannel", identifier: "{id: 1}" } - @channel.send(:transmit_subscription_rejection) - - assert_equal 1, events.length - assert_equal "transmit_subscription_rejection.action_cable", events[0].name - assert_equal "ActionCable::Channel::BaseTest::ChatChannel", events[0].payload[:channel_class] - ensure - ActiveSupport::Notifications.unsubscribe "transmit_subscription_rejection.action_cable" + assert_notifications_count("transmit_subscription_rejection.action_cable", 1) do + assert_notification("transmit_subscription_rejection.action_cable", expected_payload) do + @channel.send(:transmit_subscription_rejection) + end end end + test "behaves like rescuable" do + @channel.perform_action "action" => :error_action + assert_equal [ :error_action ], @channel.last_action + end + private def assert_logged(message) old_logger = @connection.logger diff --git a/actioncable/test/channel/broadcasting_test.rb b/actioncable/test/channel/broadcasting_test.rb index 3476c1db31fe5..a241ec6a20977 100644 --- a/actioncable/test/channel/broadcasting_test.rb +++ b/actioncable/test/channel/broadcasting_test.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_connection" require "stubs/room" -class ActionCable::Channel::BroadcastingTest < ActiveSupport::TestCase +class ActionCable::Channel::BroadcastingTest < ActionCable::TestCase class ChatChannel < ActionCable::Channel::Base end @@ -10,20 +12,63 @@ class ChatChannel < ActionCable::Channel::Base @connection = TestConnection.new end - test "broadcasts_to" do - ActionCable.stubs(:server).returns mock().tap { |m| m.expects(:broadcast).with("action_cable:channel:broadcasting_test:chat:Room#1-Campfire", "Hello World") } - ChatChannel.broadcast_to(Room.new(1), "Hello World") + test "broadcasts_to with an object" do + assert_called_with( + ActionCable.server, + :broadcast, + [ + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire", + "Hello World" + ] + ) do + ChatChannel.broadcast_to(Room.new(1), "Hello World") + 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 "Room#1-Campfire", ChatChannel.broadcasting_for(Room.new(1)) + assert_equal( + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire", + ChatChannel.broadcasting_for(Room.new(1)) + ) end test "broadcasting_for with an array" do - assert_equal "Room#1-Campfire:Room#2-Campfire", ChatChannel.broadcasting_for([ Room.new(1), Room.new(2) ]) + assert_equal( + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire:Room#2-Campfire", + ChatChannel.broadcasting_for([ Room.new(1), Room.new(2) ]) + ) end test "broadcasting_for with a string" do - assert_equal "hello", ChatChannel.broadcasting_for("hello") + assert_equal( + "action_cable:channel:broadcasting_test:chat:hello", + ChatChannel.broadcasting_for("hello") + ) end end diff --git a/actioncable/test/channel/naming_test.rb b/actioncable/test/channel/naming_test.rb index 08f0e7be4829d..45652d9cc9db1 100644 --- a/actioncable/test/channel/naming_test.rb +++ b/actioncable/test/channel/naming_test.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require "test_helper" -class ActionCable::Channel::NamingTest < ActiveSupport::TestCase +class ActionCable::Channel::NamingTest < ActionCable::TestCase class ChatChannel < ActionCable::Channel::Base end diff --git a/actioncable/test/channel/periodic_timers_test.rb b/actioncable/test/channel/periodic_timers_test.rb index 17a8e45a35d7a..0c979f4c7c63c 100644 --- a/actioncable/test/channel/periodic_timers_test.rb +++ b/actioncable/test/channel/periodic_timers_test.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_connection" require "stubs/room" require "active_support/time" -class ActionCable::Channel::PeriodicTimersTest < ActiveSupport::TestCase +class ActionCable::Channel::PeriodicTimersTest < ActionCable::TestCase class ChatChannel < ActionCable::Channel::Base # Method name arg periodically :send_updates, every: 1 @@ -62,11 +64,22 @@ def ping end test "timer start and stop" do - @connection.server.event_loop.expects(:timer).times(3).returns(stub(shutdown: nil)) - channel = ChatChannel.new @connection, "{id: 1}", id: 1 + mock = Minitest::Mock.new + 3.times { mock.expect(:shutdown, nil) } + + assert_called( + @connection.server.event_loop, + :timer, + times: 3, + returns: mock + ) do + channel = ChatChannel.new @connection, "{id: 1}", id: 1 + + channel.subscribe_to_channel + channel.unsubscribe_from_channel + assert_equal [], channel.send(:active_periodic_timers) + end - channel.subscribe_to_channel - channel.unsubscribe_from_channel - assert_equal [], channel.send(:active_periodic_timers) + assert mock.verify end end diff --git a/actioncable/test/channel/rejection_test.rb b/actioncable/test/channel/rejection_test.rb index 99c4a7603abea..683eafcac02c7 100644 --- a/actioncable/test/channel/rejection_test.rb +++ b/actioncable/test/channel/rejection_test.rb @@ -1,8 +1,11 @@ +# frozen_string_literal: true + require "test_helper" +require "minitest/mock" require "stubs/test_connection" require "stubs/room" -class ActionCable::Channel::RejectionTest < ActiveSupport::TestCase +class ActionCable::Channel::RejectionTest < ActionCable::TestCase class SecretChannel < ActionCable::Channel::Base def subscribed reject if params[:id] > 0 @@ -18,24 +21,36 @@ def secret_action end test "subscription rejection" do - @connection.expects(:subscriptions).returns mock().tap { |m| m.expects(:remove_subscription).with instance_of(SecretChannel) } - @channel = SecretChannel.new @connection, "{id: 1}", id: 1 - @channel.subscribe_to_channel + subscriptions = Minitest::Mock.new + subscriptions.expect(:remove_subscription, SecretChannel, [SecretChannel]) + + @connection.stub(:subscriptions, subscriptions) do + @channel = SecretChannel.new @connection, "{id: 1}", id: 1 + @channel.subscribe_to_channel + + expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } + assert_equal expected, @connection.last_transmission + end - expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } - assert_equal expected, @connection.last_transmission + assert subscriptions.verify end test "does not execute action if subscription is rejected" do - @connection.expects(:subscriptions).returns mock().tap { |m| m.expects(:remove_subscription).with instance_of(SecretChannel) } - @channel = SecretChannel.new @connection, "{id: 1}", id: 1 - @channel.subscribe_to_channel + subscriptions = Minitest::Mock.new + subscriptions.expect(:remove_subscription, SecretChannel, [SecretChannel]) - expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } - assert_equal expected, @connection.last_transmission - assert_equal 1, @connection.transmissions.size + @connection.stub(:subscriptions, subscriptions) do + @channel = SecretChannel.new @connection, "{id: 1}", id: 1 + @channel.subscribe_to_channel + + expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } + assert_equal expected, @connection.last_transmission + assert_equal 1, @connection.transmissions.size + + @channel.perform_action("action" => :secret_action) + assert_equal 1, @connection.transmissions.size + end - @channel.perform_action("action" => :secret_action) - assert_equal 1, @connection.transmissions.size + assert subscriptions.verify end end diff --git a/actioncable/test/channel/stream_test.rb b/actioncable/test/channel/stream_test.rb index 31dcde2e2933a..3aa42297d68ff 100644 --- a/actioncable/test/channel/stream_test.rb +++ b/actioncable/test/channel/stream_test.rb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + require "test_helper" +require "minitest/mock" require "stubs/test_connection" require "stubs/room" +require "concurrent/atomic/cyclic_barrier" module ActionCable::StreamTests class Connection < ActionCable::Connection::Base @@ -23,16 +27,17 @@ def send_confirmation transmit_subscription_confirmation end - private def pick_coder(coder) - case coder - when nil, "json" - ActiveSupport::JSON - when "custom" - DummyEncoder - when "none" - nil + private + def pick_coder(coder) + case coder + when nil, "json" + ActiveSupport::JSON + when "custom" + DummyEncoder + when "none" + nil + end end - end end module DummyEncoder @@ -51,35 +56,91 @@ class StreamTest < ActionCable::TestCase test "streaming start and stop" do run_in_eventmachine do connection = TestConnection.new - connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1", kind_of(Proc), kind_of(Proc)).returns stub_everything(:pubsub) } - channel = ChatChannel.new connection, "{id: 1}", id: 1 - channel.subscribe_to_channel + pubsub = Minitest::Mock.new connection.pubsub + + pubsub.expect(:subscribe, nil, ["test_room_1", Proc, Proc]) + pubsub.expect(:unsubscribe, nil, ["test_room_1", Proc]) + + connection.stub(:pubsub, pubsub) do + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel - connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe) } - channel.unsubscribe_from_channel + wait_for_async + channel.unsubscribe_from_channel + end + + assert pubsub.verify end end test "stream from non-string channel" do run_in_eventmachine do connection = TestConnection.new - connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("channel", kind_of(Proc), kind_of(Proc)).returns stub_everything(:pubsub) } - channel = SymbolChannel.new connection, "" - channel.subscribe_to_channel + pubsub = Minitest::Mock.new connection.pubsub + + pubsub.expect(:subscribe, nil, ["channel", Proc, Proc]) + pubsub.expect(:unsubscribe, nil, ["channel", Proc]) + + connection.stub(:pubsub, pubsub) do + channel = SymbolChannel.new connection, "" + channel.subscribe_to_channel + + wait_for_async - connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe) } - channel.unsubscribe_from_channel + channel.unsubscribe_from_channel + end + + assert pubsub.verify end end test "stream_for" do run_in_eventmachine do connection = TestConnection.new - connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:stream_tests:chat:Room#1-Campfire", kind_of(Proc), kind_of(Proc)).returns stub_everything(:pubsub) } channel = ChatChannel.new connection, "" channel.subscribe_to_channel channel.stream_for Room.new(1) + wait_for_async + + pubsub_call = channel.pubsub.class.class_variable_get "@@subscribe_called" + + assert_equal "action_cable:stream_tests:chat:Room#1-Campfire", pubsub_call[:channel] + assert_instance_of Proc, pubsub_call[:callback] + assert_instance_of Proc, pubsub_call[:success_callback] + end + end + + test "stream_or_reject_for" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "" + channel.subscribe_to_channel + channel.stream_or_reject_for Room.new(1) + wait_for_async + + pubsub_call = channel.pubsub.class.class_variable_get "@@subscribe_called" + + assert_equal "action_cable:stream_tests:chat:Room#1-Campfire", pubsub_call[:channel] + assert_instance_of Proc, pubsub_call[:callback] + assert_instance_of Proc, pubsub_call[:success_callback] + end + end + + test "reject subscription when nil is passed to stream_or_reject_for" do + run_in_eventmachine do + connection = TestConnection.new + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel + channel.stream_or_reject_for nil + assert_nil connection.last_transmission + + wait_for_async + + rejection = { "identifier" => "{id: 1}", "type" => "reject_subscription" } + connection.transmit(rejection) + assert_equal rejection, connection.last_transmission end end @@ -117,9 +178,173 @@ class StreamTest < ActionCable::TestCase assert_equal 1, connection.transmissions.size end end - end - require "action_cable/subscription_adapter/async" + test "stop_all_streams" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "{id: 3}" + channel.subscribe_to_channel + + assert_equal 0, subscribers_of(connection).size + + channel.stream_from "room_one" + channel.stream_from "room_two" + + wait_for_async + assert_equal 2, subscribers_of(connection).size + + channel2 = ChatChannel.new connection, "{id: 3}" + channel2.subscribe_to_channel + + channel2.stream_from "room_one" + wait_for_async + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 2, subscribers["room_one"].size + assert_equal 1, subscribers["room_two"].size + + channel.stop_all_streams + + subscribers = subscribers_of(connection) + assert_equal 1, subscribers.size + assert_equal 1, subscribers["room_one"].size + end + end + + test "stop_stream_from" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "{id: 3}" + channel.subscribe_to_channel + + channel.stream_from "room_one" + channel.stream_from "room_two" + + channel2 = ChatChannel.new connection, "{id: 3}" + channel2.subscribe_to_channel + + channel2.stream_from "room_one" + + subscribers = subscribers_of(connection) + + wait_for_async + + assert_equal 2, subscribers.size + assert_equal 2, subscribers["room_one"].size + assert_equal 1, subscribers["room_two"].size + + channel.stop_stream_from "room_one" + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 1, subscribers["room_one"].size + assert_equal 1, subscribers["room_two"].size + end + end + + test "stop_stream_for" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "{id: 3}" + channel.subscribe_to_channel + + channel.stream_for Room.new(1) + channel.stream_for Room.new(2) + + channel2 = ChatChannel.new connection, "{id: 3}" + channel2.subscribe_to_channel + + channel2.stream_for Room.new(1) + + subscribers = subscribers_of(connection) + + wait_for_async + + assert_equal 2, subscribers.size + + assert_equal 2, subscribers[ChatChannel.broadcasting_for(Room.new(1))].size + assert_equal 1, subscribers[ChatChannel.broadcasting_for(Room.new(2))].size + + channel.stop_stream_for Room.new(1) + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 1, subscribers[ChatChannel.broadcasting_for(Room.new(1))].size + assert_equal 1, subscribers[ChatChannel.broadcasting_for(Room.new(2))].size + 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 + .pubsub + .subscriber_map + end + end class UserCallbackChannel < ActionCable::Channel::Base def subscribed @@ -147,10 +372,11 @@ class StreamFromTest < ActionCable::TestCase connection = open_connection subscribe_to connection, identifiers: { id: 1 } - connection.websocket.expects(:transmit) - @server.broadcast "test_room_1", { foo: "bar" }, coder: DummyEncoder - wait_for_async - wait_for_executor connection.server.worker_pool.executor + assert_called(connection.websocket, :transmit) do + @server.broadcast "test_room_1", { foo: "bar" }, coder: DummyEncoder + wait_for_async + wait_for_executor connection.server.worker_pool.executor + end end end @@ -161,18 +387,18 @@ class StreamFromTest < ActionCable::TestCase @server.broadcast "channel", {} wait_for_async - refute Thread.current[:ran_callback], "User callback was not run through the worker pool" + assert_not Thread.current[:ran_callback], "User callback was not run through the worker pool" end end - test "subscription confirmation should only be sent out once with muptiple stream_from" do + test "subscription confirmation should only be sent out once with multiple stream_from" do run_in_eventmachine do connection = open_connection expected = { "identifier" => { "channel" => MultiChatChannel.name }.to_json, "type" => "confirm_subscription" } - connection.websocket.expects(:transmit).with(expected.to_json) - receive(connection, command: "subscribe", channel: MultiChatChannel.name, identifiers: {}) - - wait_for_async + assert_called_with(connection.websocket, :transmit, [expected.to_json]) do + receive(connection, command: "subscribe", channel: MultiChatChannel.name, identifiers: {}) + wait_for_async + end end end @@ -186,15 +412,15 @@ def open_connection Connection.new(@server, env).tap do |connection| connection.process - assert connection.websocket.possible? + assert_predicate connection.websocket, :possible? wait_for_async - assert connection.websocket.alive? + assert_predicate connection.websocket, :alive? end end def receive(connection, command:, identifiers:, channel: "ActionCable::StreamTests::ChatChannel") - identifier = JSON.generate(channel: channel, **identifiers) + identifier = JSON.generate(identifiers.merge(channel: channel)) connection.dispatch_websocket_message JSON.generate(command: command, identifier: identifier) wait_for_async end diff --git a/actioncable/test/channel/test_case_test.rb b/actioncable/test/channel/test_case_test.rb new file mode 100644 index 0000000000000..48ef86998bf54 --- /dev/null +++ b/actioncable/test/channel/test_case_test.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestTestChannel < ActionCable::Channel::Base +end + +class NonInferrableExplicitClassChannelTest < ActionCable::Channel::TestCase + tests TestTestChannel + + def test_set_channel_class_manual + assert_equal TestTestChannel, self.class.channel_class + end +end + +class NonInferrableSymbolNameChannelTest < ActionCable::Channel::TestCase + tests :test_test_channel + + def test_set_channel_class_manual_using_symbol + assert_equal TestTestChannel, self.class.channel_class + end +end + +class NonInferrableStringNameChannelTest < ActionCable::Channel::TestCase + tests "test_test_channel" + + def test_set_channel_class_manual_using_string + assert_equal TestTestChannel, self.class.channel_class + end +end + +class SubscriptionsTestChannel < ActionCable::Channel::Base +end + +class SubscriptionsTestChannelTest < ActionCable::Channel::TestCase + def setup + stub_connection + end + + def test_no_subscribe + assert_nil subscription + end + + def test_subscribe + subscribe + + assert_predicate subscription, :confirmed? + assert_not subscription.rejected? + assert_equal 1, connection.transmissions.size + assert_equal ActionCable::INTERNAL[:message_types][:confirmation], + connection.transmissions.last["type"] + end +end + +class StubConnectionTest < ActionCable::Channel::TestCase + tests SubscriptionsTestChannel + + def test_connection_identifiers + stub_connection username: "John", admin: true + + subscribe + + assert_equal "John", subscription.username + assert subscription.admin + assert_equal "John:true", connection.connection_identifier + end +end + +class RejectionTestChannel < ActionCable::Channel::Base + def subscribed + reject + end +end + +class RejectionTestChannelTest < ActionCable::Channel::TestCase + def test_rejection + subscribe + + assert_not subscription.confirmed? + assert_predicate subscription, :rejected? + assert_equal 1, connection.transmissions.size + assert_equal ActionCable::INTERNAL[:message_types][:rejection], + connection.transmissions.last["type"] + end +end + +class StreamsTestChannel < ActionCable::Channel::Base + def subscribed + stream_from "test_#{params[:id] || 0}" + end + + def unsubscribed + stop_stream_from "test_#{params[:id] || 0}" + end +end + +class StreamsTestChannelTest < ActionCable::Channel::TestCase + def test_stream_without_params + subscribe + + assert_has_stream "test_0" + end + + def test_stream_with_params + subscribe id: 42 + + assert_has_stream "test_42" + end + + def test_not_stream_without_params + subscribe + unsubscribe + + assert_has_no_stream "test_0" + end + + def test_not_stream_with_params + subscribe id: 42 + perform :unsubscribed, id: 42 + + assert_has_no_stream "test_42" + end + + def test_unsubscribe_from_stream + subscribe + unsubscribe + + assert_no_streams + end +end + +class StreamsForTestChannel < ActionCable::Channel::Base + def subscribed + stream_for User.new(params[:id]) + end + + def unsubscribed + stop_stream_for User.new(params[:id]) + end +end + +class StreamsForTestChannelTest < ActionCable::Channel::TestCase + def test_stream_with_params + subscribe id: 42 + + assert_has_stream_for User.new(42) + end + + def test_not_stream_with_params + subscribe id: 42 + perform :unsubscribed, id: 42 + + assert_has_no_stream_for User.new(42) + end +end + +class NoStreamsTestChannel < ActionCable::Channel::Base + def subscribed; end # no-op +end + +class NoStreamsTestChannelTest < ActionCable::Channel::TestCase + def test_stream_with_params + subscribe + + assert_no_streams + end +end + +class PerformTestChannel < ActionCable::Channel::Base + def echo(data) + data.delete("action") + transmit data + end + + def ping + transmit({ type: "pong" }) + end +end + +class PerformTestChannelTest < ActionCable::Channel::TestCase + def setup + stub_connection user_id: 2016 + subscribe id: 5 + end + + def test_perform_with_params + perform :echo, text: "You are man!" + + assert_equal({ "text" => "You are man!" }, transmissions.last) + end + + def test_perform_and_transmit + perform :ping + + assert_equal "pong", transmissions.last["type"] + end +end + +class PerformUnsubscribedTestChannelTest < ActionCable::Channel::TestCase + tests PerformTestChannel + + def test_perform_when_unsubscribed + assert_raises do + perform :echo + end + end +end + +class BroadcastsTestChannel < ActionCable::Channel::Base + def broadcast(data) + ActionCable.server.broadcast( + "broadcast_#{params[:id]}", + { text: data["message"], user_id: user_id } + ) + end + + def broadcast_to_user(data) + user = User.new user_id + + broadcast_to user, text: data["message"] + end +end + +class BroadcastsTestChannelTest < ActionCable::Channel::TestCase + def setup + stub_connection user_id: 2017 + subscribe id: 5 + end + + def test_broadcast_matchers_included + assert_broadcast_on("broadcast_5", user_id: 2017, text: "SOS") do + perform :broadcast, message: "SOS" + end + end + + def test_broadcast_to_object + user = User.new(2017) + + assert_broadcasts(user, 1) do + perform :broadcast_to_user, text: "SOS" + end + end + + def test_broadcast_to_object_with_data + user = User.new(2017) + + assert_broadcast_on(user, text: "SOS") do + perform :broadcast_to_user, message: "SOS" + end + end +end diff --git a/actioncable/test/client_test.rb b/actioncable/test/client_test.rb index 98a114a5f47f5..9069510c89311 100644 --- a/actioncable/test/client_test.rb +++ b/actioncable/test/client_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "concurrent" @@ -6,28 +8,18 @@ require "active_support/hash_with_indifferent_access" -#### -# 😷 Warning suppression 😷 -WebSocket::Frame::Handler::Handler03.prepend Module.new { - def initialize(*) - @application_data_buffer = nil - super - end -} - -WebSocket::Frame::Data.prepend Module.new { - def initialize(*) - @masking_key = nil - super - end -} -# -#### - class ClientTest < ActionCable::TestCase WAIT_WHEN_EXPECTING_EVENT = 2 WAIT_WHEN_NOT_EXPECTING_EVENT = 0.5 + class Connection < ActionCable::Connection::Base + identified_by :id + + def connect + self.id = request.params["id"] || SecureRandom.hex(4) + end + end + class EchoChannel < ActionCable::Channel::Base def subscribed stream_from "global" @@ -38,16 +30,16 @@ def unsubscribed end def ding(data) - transmit(dong: data["message"]) + transmit({ dong: data["message"] }) end def delay(data) sleep 1 - transmit(dong: data["message"]) + transmit({ dong: data["message"] }) end def bulk(data) - ActionCable.server.broadcast "global", wide: data["message"] + ActionCable.server.broadcast "global", { wide: data["message"] } end end @@ -57,29 +49,56 @@ def setup server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } server.config.cable = ActiveSupport::HashWithIndifferentAccess.new(adapter: "async") + server.config.connection_class = -> { ClientTest::Connection } # and now the "real" setup for our test: server.config.disable_request_forgery_protection = true end def with_puma_server(rack_app = ActionCable.server, port = 3099) - server = ::Puma::Server.new(rack_app, ::Puma::Events.strings) + opts = { min_threads: 1, max_threads: 4 } + server = if Puma::Const::PUMA_VERSION >= "6" + opts[:log_writer] = ::Puma::LogWriter.strings + ::Puma::Server.new(rack_app, nil, opts) + else + # Puma >= 5.0.3 + ::Puma::Server.new(rack_app, ::Puma::Events.strings, opts) + end server.add_tcp_listener "127.0.0.1", port - server.min_threads = 1 - server.max_threads = 4 - t = Thread.new { server.run.join } - yield port + thread = server.run + + begin + yield port - ensure - server.stop(true) if server - t.join if t + ensure + server.stop + + begin + thread.join + + rescue IOError + # Work around https://bugs.ruby-lang.org/issues/13405 + # + # Puma's sometimes raising while shutting down, when it closes + # its internal pipe. We can safely ignore that, but we do need + # to do the step skipped by the exception: + server.binder.close + + rescue RuntimeError => ex + # Work around https://bugs.ruby-lang.org/issues/13239 + raise unless ex.message.match?(/can't modify frozen IOError/) + + # Handle this as if it were the IOError: do the same as above. + server.binder.close + end + end end class SyncClient attr_reader :pings - def initialize(port) + def initialize(port, path = "/") messages = @messages = Queue.new closed = @closed = Concurrent::Event.new has_messages = @has_messages = Concurrent::Semaphore.new(0) @@ -87,7 +106,7 @@ def initialize(port) open = Concurrent::Promise.new - @ws = WebSocket::Client::Simple.connect("ws://127.0.0.1:#{port}/") do |ws| + @ws = WebSocket::Client::Simple.connect("ws://127.0.0.1:#{port}#{path}") do |ws| ws.on(:error) do |event| event = RuntimeError.new(event.message) unless event.is_a?(Exception) @@ -117,7 +136,7 @@ def initialize(port) end end - ws.on(:close) do |event| + ws.on(:close) do |_| closed.set end end @@ -173,12 +192,12 @@ def closed? end end - def websocket_client(port) - SyncClient.new(port) + def websocket_client(*args) + SyncClient.new(*args) end def concurrently(enum) - enum.map { |*x| Concurrent::Future.execute { yield(*x) } }.map(&:value!) + enum.map { |*x| Concurrent::Promises.future { yield(*x) } }.map(&:value!) end def test_single_client @@ -261,20 +280,54 @@ def test_unsubscribe_client c.send_message command: "subscribe", identifier: identifier assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) assert_equal(1, app.connections.count) - assert(app.remote_connections.where(identifier: identifier)) subscriptions = app.connections.first.subscriptions.send(:subscriptions) assert_not_equal 0, subscriptions.size, "Missing EchoChannel subscription" channel = subscriptions.first[1] - channel.expects(:unsubscribed) - c.close - sleep 0.1 # Data takes a moment to process + assert_called(channel, :unsubscribed) do + c.close + sleep 0.1 # Data takes a moment to process + end # All data is removed: No more connection or subscription information! assert_equal(0, app.connections.count) end end + def test_remote_disconnect_client + with_puma_server do |port| + app = ActionCable.server + + c = websocket_client(port, "/?id=1") + assert_equal({ "type" => "welcome" }, c.read_message) + + sleep 0.1 # make sure connections is registered + app.remote_connections.where(id: "1").disconnect + + assert_equal({ "type" => "disconnect", "reason" => "remote", "reconnect" => true }, c.read_message) + + c.wait_for_close + assert_predicate(c, :closed?) + end + end + + def test_remote_disconnect_client_with_reconnect + with_puma_server do |port| + app = ActionCable.server + + c = websocket_client(port, "/?id=2") + assert_equal({ "type" => "welcome" }, c.read_message) + + sleep 0.1 # make sure connections is registered + app.remote_connections.where(id: "2").disconnect(reconnect: false) + + assert_equal({ "type" => "disconnect", "reason" => "remote", "reconnect" => false }, c.read_message) + + c.wait_for_close + assert_predicate(c, :closed?) + end + end + def test_server_restart with_puma_server do |port| c = websocket_client(port) @@ -284,7 +337,7 @@ def test_server_restart ActionCable.server.restart c.wait_for_close - assert c.closed? + assert_predicate c, :closed? end end end diff --git a/actioncable/test/connection/authorization_test.rb b/actioncable/test/connection/authorization_test.rb index dcdbe9c1d1705..ac5c128135cda 100644 --- a/actioncable/test/connection/authorization_test.rb +++ b/actioncable/test/connection/authorization_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" @@ -23,9 +25,12 @@ def send_async(method, *args) "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com" connection = Connection.new(server, env) - connection.websocket.expects(:close) - connection.process + assert_called_with(connection.websocket, :transmit, [{ type: "disconnect", reason: "unauthorized", reconnect: false }.to_json]) do + assert_called(connection.websocket, :close) do + connection.process + end + end end end end diff --git a/actioncable/test/connection/base_test.rb b/actioncable/test/connection/base_test.rb index 9bcd0700cf968..af0e88aae1c19 100644 --- a/actioncable/test/connection/base_test.rb +++ b/actioncable/test/connection/base_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" require "active_support/core_ext/object/json" @@ -37,10 +39,10 @@ def send_async(method, *args) connection = open_connection connection.process - assert connection.websocket.possible? + assert_predicate connection.websocket, :possible? wait_for_async - assert connection.websocket.alive? + assert_predicate connection.websocket, :alive? end end @@ -57,11 +59,12 @@ def send_async(method, *args) run_in_eventmachine do connection = open_connection - connection.websocket.expects(:transmit).with({ type: "welcome" }.to_json) - connection.message_buffer.expects(:process!) - - connection.process - wait_for_async + assert_called_with(connection.websocket, :transmit, [{ type: "welcome" }.to_json]) do + assert_called(connection.message_buffer, :process!) do + connection.process + wait_for_async + end + end assert_equal [ connection ], @server.connections assert connection.connected @@ -73,15 +76,15 @@ def send_async(method, *args) connection = open_connection connection.process - # Setup the connection - connection.server.stubs(:timer).returns(true) + # Set up the connection connection.send :handle_open assert connection.connected - connection.subscriptions.expects(:unsubscribe_from_all) - connection.send :handle_close + assert_called(connection.subscriptions, :unsubscribe_from_all) do + connection.send :handle_close + end - assert ! connection.connected + assert_not connection.connected assert_equal [], @server.connections end end @@ -93,7 +96,7 @@ def send_async(method, *args) statistics = connection.statistics - assert statistics[:identifier].blank? + assert_predicate statistics[:identifier], :blank? assert_kind_of Time, statistics[:started_at] assert_equal [], statistics[:subscriptions] end @@ -104,8 +107,9 @@ def send_async(method, *args) connection = open_connection connection.process - connection.websocket.expects(:close) - connection.close + assert_called(connection.websocket, :close) do + connection.close(reason: "testing") + end end end diff --git a/actioncable/test/connection/callbacks_test.rb b/actioncable/test/connection/callbacks_test.rb new file mode 100644 index 0000000000000..0552a9ebf1d3e --- /dev/null +++ b/actioncable/test/connection/callbacks_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Connection::CallbacksTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + identified_by :context + + attr_reader :commands_counter + + before_command do + throw :abort unless context.nil? + end + + around_command :set_current_context + after_command :increment_commands_counter + + def initialize(*) + super + @commands_counter = 0 + end + + private + def set_current_context + self.context = request.params["context"] + yield + ensure + self.context = nil + end + + def increment_commands_counter + @commands_counter += 1 + end + end + + class ChatChannel < ActionCable::Channel::Base + class << self + attr_accessor :words_spoken, :subscribed_count + end + + self.words_spoken = [] + self.subscribed_count = 0 + + def subscribed + self.class.subscribed_count += 1 + end + + def speak(data) + self.class.words_spoken << { data: data, context: context } + end + end + + setup do + @server = TestServer.new + @env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + @connection = Connection.new(@server, @env) + @identifier = { channel: "ActionCable::Connection::CallbacksTest::ChatChannel" }.to_json + end + + attr_reader :server, :env, :connection, :identifier + + test "before and after callbacks" do + result = assert_difference -> { ChatChannel.subscribed_count }, +1 do + assert_difference -> { connection.commands_counter }, +1 do + connection.handle_channel_command({ "identifier" => identifier, "command" => "subscribe" }) + end + end + assert result + end + + test "before callback halts" do + connection.context = "non_null" + result = assert_no_difference -> { ChatChannel.subscribed_count } do + connection.handle_channel_command({ "identifier" => identifier, "command" => "subscribe" }) + end + assert_not result + end + + test "around_command callback" do + env["QUERY_STRING"] = "context=test" + connection = Connection.new(server, env) + + assert_difference -> { ChatChannel.words_spoken.size }, +1 do + # We need to add subscriptions first + connection.handle_channel_command({ + "identifier" => identifier, + "command" => "subscribe" + }) + connection.handle_channel_command({ + "identifier" => identifier, + "command" => "message", + "data" => { "action" => "speak", "message" => "hello" }.to_json + }) + end + + message = ChatChannel.words_spoken.last + assert_equal({ data: { "action" => "speak", "message" => "hello" }, context: "test" }, message) + end +end diff --git a/actioncable/test/connection/client_socket_test.rb b/actioncable/test/connection/client_socket_test.rb index bc3ff6a3d79ad..1ab3c3b71d88f 100644 --- a/actioncable/test/connection/client_socket_test.rb +++ b/actioncable/test/connection/client_socket_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" @@ -38,10 +40,11 @@ def on_error(message) # Internal hax = :( client = connection.websocket.send(:websocket) - client.instance_variable_get("@stream").expects(:write).raises("foo") - client.expects(:client_gone).never - - client.write("boo") + client.instance_variable_get("@stream").stub(:write, proc { raise "foo" }) do + assert_not_called(client, :client_gone) do + client.write("boo") + end + end assert_equal %w[ foo ], connection.errors end end @@ -55,7 +58,7 @@ def on_error(message) client.instance_variable_get("@stream") .instance_variable_get("@rack_hijack_io") .define_singleton_method(:close) { event.set } - connection.close + connection.close(reason: "testing") event.wait end end @@ -65,9 +68,9 @@ def open_connection env = Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com" - io = \ + io, client_io = \ begin - Socket.pair(Socket::AF_UNIX, Socket::SOCK_STREAM, 0).first + Socket.pair(Socket::AF_UNIX, Socket::SOCK_STREAM, 0) rescue StringIO.new end @@ -75,6 +78,14 @@ def open_connection Connection.new(@server, env).tap do |connection| connection.process + if client_io + # Make sure server returns handshake response + Timeout.timeout(1) do + loop do + break if client_io.readline == "\r\n" + end + end + end connection.send :handle_open assert connection.connected end diff --git a/actioncable/test/connection/cross_site_forgery_test.rb b/actioncable/test/connection/cross_site_forgery_test.rb index 37bedfd7346a3..3e21138ffc2b7 100644 --- a/actioncable/test/connection/cross_site_forgery_test.rb +++ b/actioncable/test/connection/cross_site_forgery_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" diff --git a/actioncable/test/connection/identifier_test.rb b/actioncable/test/connection/identifier_test.rb index f3d3bc0603ed0..707f4bab72e83 100644 --- a/actioncable/test/connection/identifier_test.rb +++ b/actioncable/test/connection/identifier_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" require "stubs/user" @@ -16,52 +18,52 @@ def connect test "connection identifier" do run_in_eventmachine do - open_connection_with_stubbed_pubsub + open_connection assert_equal "User#lifo", @connection.connection_identifier end end test "should subscribe to internal channel on open and unsubscribe on close" do run_in_eventmachine do - pubsub = mock("pubsub_adapter") - pubsub.expects(:subscribe).with("action_cable/User#lifo", kind_of(Proc)) - pubsub.expects(:unsubscribe).with("action_cable/User#lifo", kind_of(Proc)) - server = TestServer.new - server.stubs(:pubsub).returns(pubsub) - open_connection server: server + open_connection(server) close_connection + wait_for_async + + %w[subscribe unsubscribe].each do |method| + pubsub_call = server.pubsub.class.class_variable_get "@@#{method}_called" + + assert_equal "action_cable/User#lifo", pubsub_call[:channel] + assert_instance_of Proc, pubsub_call[:callback] + end end end test "processing disconnect message" do run_in_eventmachine do - open_connection_with_stubbed_pubsub + open_connection - @connection.websocket.expects(:close) - @connection.process_internal_message "type" => "disconnect" + assert_called(@connection.websocket, :close) do + @connection.process_internal_message "type" => "disconnect" + end end end test "processing invalid message" do run_in_eventmachine do - open_connection_with_stubbed_pubsub + open_connection - @connection.websocket.expects(:close).never - @connection.process_internal_message "type" => "unknown" + assert_not_called(@connection.websocket, :close) do + @connection.process_internal_message "type" => "unknown" + end end end private - def open_connection_with_stubbed_pubsub - server = TestServer.new - server.stubs(:adapter).returns(stub_everything("adapter")) - - open_connection server: server - end + def open_connection(server = nil) + server ||= TestServer.new - def open_connection(server:) env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" @connection = Connection.new(server, env) diff --git a/actioncable/test/connection/multiple_identifiers_test.rb b/actioncable/test/connection/multiple_identifiers_test.rb index ca1a08f4d63bc..51716410b2dc6 100644 --- a/actioncable/test/connection/multiple_identifiers_test.rb +++ b/actioncable/test/connection/multiple_identifiers_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" require "stubs/user" @@ -14,28 +16,19 @@ def connect test "multiple connection identifiers" do run_in_eventmachine do - open_connection_with_stubbed_pubsub + open_connection + assert_equal "Room#my-room:User#lifo", @connection.connection_identifier end end private - def open_connection_with_stubbed_pubsub + def open_connection server = TestServer.new - server.stubs(:pubsub).returns(stub_everything("pubsub")) - - open_connection server: server - end - - def open_connection(server:) env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" @connection = Connection.new(server, env) @connection.process @connection.send :handle_open end - - def close_connection - @connection.send :handle_close - end end diff --git a/actioncable/test/connection/stream_test.rb b/actioncable/test/connection/stream_test.rb index 36e1d3c095ce9..c579f1cd683c7 100644 --- a/actioncable/test/connection/stream_test.rb +++ b/actioncable/test/connection/stream_test.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "test_helper" +require "minitest/mock" require "stubs/test_server" class ActionCable::Connection::StreamTest < ActionCable::TestCase @@ -35,25 +38,27 @@ def on_error(message) [ EOFError, Errno::ECONNRESET ].each do |closed_exception| test "closes socket on #{closed_exception}" do run_in_eventmachine do - connection = open_connection + rack_hijack_io = File.open(File::NULL, "w") + connection = open_connection(rack_hijack_io) # Internal hax = :( client = connection.websocket.send(:websocket) - client.instance_variable_get("@stream").instance_variable_get("@rack_hijack_io").expects(:write).raises(closed_exception, "foo") - client.expects(:client_gone) - - client.write("boo") + rack_hijack_io.stub(:write_nonblock, proc { raise(closed_exception, "foo") }) do + assert_called(client, :client_gone) do + client.write("boo") + end + end assert_equal [], connection.errors end end end private - def open_connection + def open_connection(io) env = Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com" - env["rack.hijack"] = -> { env["rack.hijack_io"] = StringIO.new } + env["rack.hijack"] = -> { env["rack.hijack_io"] = io } Connection.new(@server, env).tap do |connection| connection.process diff --git a/actioncable/test/connection/string_identifier_test.rb b/actioncable/test/connection/string_identifier_test.rb index 6d53e249cb929..f7019b926aef5 100644 --- a/actioncable/test/connection/string_identifier_test.rb +++ b/actioncable/test/connection/string_identifier_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" @@ -16,28 +18,19 @@ def send_async(method, *args) test "connection identifier" do run_in_eventmachine do - open_connection_with_stubbed_pubsub + open_connection + assert_equal "random-string", @connection.connection_identifier end end private - def open_connection_with_stubbed_pubsub - @server = TestServer.new - @server.stubs(:pubsub).returns(stub_everything("pubsub")) - - open_connection - end - def open_connection + server = TestServer.new env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" - @connection = Connection.new(@server, env) + @connection = Connection.new(server, env) @connection.process @connection.send :on_open end - - def close_connection - @connection.send :on_close - end end diff --git a/actioncable/test/connection/subscriptions_test.rb b/actioncable/test/connection/subscriptions_test.rb index a1c8a4613c4d5..0af3d1be2f9f7 100644 --- a/actioncable/test/connection/subscriptions_test.rb +++ b/actioncable/test/connection/subscriptions_test.rb @@ -1,12 +1,27 @@ +# frozen_string_literal: true + require "test_helper" class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase + class ChatChannelError < Exception; end + class Connection < ActionCable::Connection::Base - attr_reader :websocket + attr_reader :websocket, :exceptions + + rescue_from ChatChannelError, with: :error_handler + + def initialize(*) + super + @exceptions = [] + end def send_async(method, *args) send method, *args end + + def error_handler(e) + @exceptions << e + end end class ChatChannel < ActionCable::Channel::Base @@ -20,6 +35,10 @@ def subscribed def speak(data) @lines << data end + + def throw_exception(_data) + raise ChatChannelError.new("Uh Oh") + end end setup do @@ -43,7 +62,18 @@ def speak(data) setup_connection @subscriptions.execute_command "command" => "subscribe" - assert @subscriptions.identifiers.empty? + assert_empty @subscriptions.identifiers + end + end + + test "subscribe command with Base channel" do + run_in_eventmachine do + setup_connection + + identifier = ActiveSupport::JSON.encode(id: 1, channel: "ActionCable::Channel::Base") + @subscriptions.execute_command "command" => "subscribe", "identifier" => identifier + + assert_empty @subscriptions.identifiers end end @@ -53,10 +83,12 @@ def speak(data) subscribe_to_chat_channel channel = subscribe_to_chat_channel - channel.expects(:unsubscribe_from_channel) - @subscriptions.execute_command "command" => "unsubscribe", "identifier" => @chat_identifier - assert @subscriptions.identifiers.empty? + assert_called(channel, :unsubscribe_from_channel) do + @subscriptions.execute_command "command" => "unsubscribe", "identifier" => @chat_identifier + end + + assert_empty @subscriptions.identifiers end end @@ -65,7 +97,7 @@ def speak(data) setup_connection @subscriptions.execute_command "command" => "unsubscribe" - assert @subscriptions.identifiers.empty? + assert_empty @subscriptions.identifiers end end @@ -81,6 +113,19 @@ def speak(data) end end + test "accessing exceptions thrown during command execution" do + run_in_eventmachine do + setup_connection + subscribe_to_chat_channel + + data = { "content" => "Hello World!", "action" => "throw_exception" } + @subscriptions.execute_command "command" => "message", "identifier" => @chat_identifier, "data" => ActiveSupport::JSON.encode(data) + + exception = @connection.exceptions.first + assert_kind_of ChatChannelError, exception + end + end + test "unsubscribe from all" do run_in_eventmachine do setup_connection @@ -90,10 +135,11 @@ def speak(data) channel2_id = ActiveSupport::JSON.encode(id: 2, channel: "ActionCable::Connection::SubscriptionsTest::ChatChannel") channel2 = subscribe_to_chat_channel(channel2_id) - channel1.expects(:unsubscribe_from_channel) - channel2.expects(:unsubscribe_from_channel) - - @subscriptions.unsubscribe_from_all + assert_called(channel1, :unsubscribe_from_channel) do + assert_called(channel2, :unsubscribe_from_channel) do + @subscriptions.unsubscribe_from_all + end + end end end diff --git a/actioncable/test/connection/test_case_test.rb b/actioncable/test/connection/test_case_test.rb new file mode 100644 index 0000000000000..7852fdeca7d01 --- /dev/null +++ b/actioncable/test/connection/test_case_test.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "test_helper" + +class SimpleConnection < ActionCable::Connection::Base + identified_by :user_id + + class << self + attr_accessor :disconnected_user_id + end + + def connect + self.user_id = request.params[:user_id] || cookies[:user_id] + end + + def disconnect + self.class.disconnected_user_id = user_id + end +end + +class ConnectionSimpleTest < ActionCable::Connection::TestCase + tests SimpleConnection + + def test_connected + connect + + assert_nil connection.user_id + end + + def test_url_params + connect "/cable?user_id=323" + + assert_equal "323", connection.user_id + end + + def test_params + connect params: { user_id: 323 } + + assert_equal "323", connection.user_id + end + + def test_plain_cookie + cookies["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + end + + def test_plain_cookie_with_explicit_value_and_string_key + cookies["user_id"] = { "value" => "456" } + + connect + + assert_equal "456", connection.user_id + end + + def test_disconnect + cookies["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + + disconnect + + assert_equal "456", SimpleConnection.disconnected_user_id + end +end + +class Connection < ActionCable::Connection::Base + identified_by :current_user_id + identified_by :token + + class << self + attr_accessor :disconnected_user_id + end + + def connect + self.current_user_id = verify_user + self.token = request.headers["X-API-TOKEN"] + logger.add_tags("ActionCable") + end + + private + def verify_user + cookies.signed[:user_id].presence || reject_unauthorized_connection + end +end + +class ConnectionTest < ActionCable::Connection::TestCase + def test_connected_with_signed_cookies_and_headers + cookies.signed["user_id"] = "456" + + connect headers: { "X-API-TOKEN" => "abc" } + + assert_equal "abc", connection.token + assert_equal "456", connection.current_user_id + end + + def test_connected_when_no_signed_cookies_set + cookies["user_id"] = "456" + + assert_reject_connection { connect } + end + + def test_connection_rejected + assert_reject_connection { connect } + end + + def test_connection_rejected_assertion_message + error = assert_raises Minitest::Assertion do + assert_reject_connection { "Intentionally doesn't connect." } + end + + assert_match(/Expected to reject connection/, error.message) + end +end + +class EncryptedCookiesConnection < ActionCable::Connection::Base + identified_by :user_id + + def connect + self.user_id = verify_user + end + + private + def verify_user + cookies.encrypted[:user_id].presence || reject_unauthorized_connection + end +end + +class EncryptedCookiesConnectionTest < ActionCable::Connection::TestCase + tests EncryptedCookiesConnection + + def test_connected_with_encrypted_cookies + cookies.encrypted["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + end + + def test_connected_with_encrypted_cookies_with_explicit_value_and_symbol_key + cookies.encrypted["user_id"] = { value: "456" } + + connect + + assert_equal "456", connection.user_id + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end + +class SessionConnection < ActionCable::Connection::Base + identified_by :user_id + + def connect + self.user_id = verify_user + end + + private + def verify_user + request.session[:user_id].presence || reject_unauthorized_connection + end +end + +class SessionConnectionTest < ActionCable::Connection::TestCase + tests SessionConnection + + def test_connected_with_encrypted_cookies + connect session: { user_id: "789" } + assert_equal "789", connection.user_id + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end + +class EnvConnection < ActionCable::Connection::Base + identified_by :user + + def connect + self.user = verify_user + end + + private + def verify_user + # Warden-like authentication + env["authenticator"]&.user || reject_unauthorized_connection + end +end + +class EnvConnectionTest < ActionCable::Connection::TestCase + tests EnvConnection + + def test_connected_with_env + authenticator = Class.new do + def user; "David"; end + end + + connect env: { "authenticator" => authenticator.new } + + assert_equal "David", connection.user + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end diff --git a/actioncable/test/javascript/src/test.coffee b/actioncable/test/javascript/src/test.coffee deleted file mode 100644 index eb95fb2604efb..0000000000000 --- a/actioncable/test/javascript/src/test.coffee +++ /dev/null @@ -1,3 +0,0 @@ -#= require action_cable -#= require ./test_helpers -#= require_tree ./unit diff --git a/actioncable/test/javascript/src/test.js b/actioncable/test/javascript/src/test.js new file mode 100644 index 0000000000000..938f71a2fa3a2 --- /dev/null +++ b/actioncable/test/javascript/src/test.js @@ -0,0 +1,8 @@ +import "./test_helpers/index" +import "./unit/action_cable_test" +import "./unit/connection_test" +import "./unit/connection_monitor_test" +import "./unit/consumer_test" +import "./unit/subscription_test" +import "./unit/subscriptions_test" +import "./unit/subscription_guarantor_test" diff --git a/actioncable/test/javascript/src/test_helpers/consumer_test_helper.coffee b/actioncable/test/javascript/src/test_helpers/consumer_test_helper.coffee deleted file mode 100644 index a9e95c37f0d4f..0000000000000 --- a/actioncable/test/javascript/src/test_helpers/consumer_test_helper.coffee +++ /dev/null @@ -1,47 +0,0 @@ -#= require mock-socket - -{TestHelpers} = ActionCable - -TestHelpers.consumerTest = (name, options = {}, callback) -> - unless callback? - callback = options - options = {} - - options.url ?= TestHelpers.testURL - - QUnit.test name, (assert) -> - doneAsync = assert.async() - - ActionCable.WebSocket = MockWebSocket - server = new MockServer options.url - consumer = ActionCable.createConsumer(options.url) - - server.on "connection", -> - clients = server.clients() - assert.equal clients.length, 1 - assert.equal clients[0].readyState, WebSocket.OPEN - - server.broadcastTo = (subscription, data = {}, callback) -> - data.identifier = subscription.identifier - - if data.message_type - data.type = ActionCable.INTERNAL.message_types[data.message_type] - delete data.message_type - - server.send(JSON.stringify(data)) - TestHelpers.defer(callback) - - done = -> - consumer.disconnect() - server.close() - doneAsync() - - testData = {assert, consumer, server, done} - - if options.connect is false - callback(testData) - else - server.on "connection", -> - testData.client = server.clients()[0] - callback(testData) - consumer.connect() diff --git a/actioncable/test/javascript/src/test_helpers/consumer_test_helper.js b/actioncable/test/javascript/src/test_helpers/consumer_test_helper.js new file mode 100644 index 0000000000000..30e8c277bc999 --- /dev/null +++ b/actioncable/test/javascript/src/test_helpers/consumer_test_helper.js @@ -0,0 +1,62 @@ +import { WebSocket as MockWebSocket, Server as MockServer } from "mock-socket" +import * as ActionCable from "../../../../app/javascript/action_cable/index" +import {defer, testURL} from "./index" + +export default function(name, options, callback) { + if (options == null) { options = {} } + if (callback == null) { + callback = options + options = {} + } + + if (options.url == null) { options.url = testURL } + + return QUnit.test(name, function(assert) { + const doneAsync = assert.async() + + ActionCable.adapters.WebSocket = MockWebSocket + const server = new MockServer(options.url) + const consumer = ActionCable.createConsumer(options.url) + const connection = consumer.connection + const monitor = connection.monitor + + if ("subprotocols" in options) consumer.addSubProtocol(options.subprotocols) + + server.on("connection", function() { + const clients = server.clients() + assert.equal(clients.length, 1) + assert.equal(clients[0].readyState, WebSocket.OPEN) + }) + + server.broadcastTo = function(subscription, data, callback) { + if (data == null) { data = {} } + data.identifier = subscription.identifier + + if (data.message_type) { + data.type = ActionCable.INTERNAL.message_types[data.message_type] + delete data.message_type + } + + server.send(JSON.stringify(data)) + defer(callback) + } + + const done = function() { + consumer.disconnect() + server.close() + doneAsync() + } + + const testData = {assert, consumer, connection, monitor, server, done} + + if (options.connect === false) { + callback(testData) + } else { + server.on("connection", function() { + testData.client = server.clients()[0] + callback(testData) + }) + consumer.connect() + } + }) +} diff --git a/actioncable/test/javascript/src/test_helpers/index.coffee b/actioncable/test/javascript/src/test_helpers/index.coffee deleted file mode 100644 index c84cbbcb2ca3f..0000000000000 --- a/actioncable/test/javascript/src/test_helpers/index.coffee +++ /dev/null @@ -1,11 +0,0 @@ -#= require_self -#= require_tree . - -ActionCable.TestHelpers = - testURL: "ws://cable.example.com/" - - defer: (callback) -> - setTimeout(callback, 1) - -originalWebSocket = ActionCable.WebSocket -QUnit.testDone -> ActionCable.WebSocket = originalWebSocket diff --git a/actioncable/test/javascript/src/test_helpers/index.js b/actioncable/test/javascript/src/test_helpers/index.js new file mode 100644 index 0000000000000..0cd4e260b31e8 --- /dev/null +++ b/actioncable/test/javascript/src/test_helpers/index.js @@ -0,0 +1,10 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" + +export const testURL = "ws://cable.example.com/" + +export function defer(callback) { + setTimeout(callback, 1) +} + +const originalWebSocket = ActionCable.adapters.WebSocket +QUnit.testDone(() => ActionCable.adapters.WebSocket = originalWebSocket) diff --git a/actioncable/test/javascript/src/unit/action_cable_test.coffee b/actioncable/test/javascript/src/unit/action_cable_test.coffee deleted file mode 100644 index 3944f3a7f6622..0000000000000 --- a/actioncable/test/javascript/src/unit/action_cable_test.coffee +++ /dev/null @@ -1,41 +0,0 @@ -{module, test} = QUnit -{testURL} = ActionCable.TestHelpers - -module "ActionCable", -> - module "Adapters", -> - module "WebSocket", -> - test "default is window.WebSocket", (assert) -> - assert.equal ActionCable.WebSocket, window.WebSocket - - test "configurable", (assert) -> - ActionCable.WebSocket = "" - assert.equal ActionCable.WebSocket, "" - - module "logger", -> - test "default is window.console", (assert) -> - assert.equal ActionCable.logger, window.console - - test "configurable", (assert) -> - ActionCable.logger = "" - assert.equal ActionCable.logger, "" - - module "#createConsumer", -> - test "uses specified URL", (assert) -> - consumer = ActionCable.createConsumer(testURL) - assert.equal consumer.url, testURL - - test "uses default URL", (assert) -> - pattern = ///#{ActionCable.INTERNAL.default_mount_path}$/// - consumer = ActionCable.createConsumer() - assert.ok pattern.test(consumer.url), "Expected #{consumer.url} to match #{pattern}" - - test "uses URL from meta tag", (assert) -> - element = document.createElement("meta") - element.setAttribute("name", "action-cable-url") - element.setAttribute("content", testURL) - - document.head.appendChild(element) - consumer = ActionCable.createConsumer() - document.head.removeChild(element) - - assert.equal consumer.url, testURL diff --git a/actioncable/test/javascript/src/unit/action_cable_test.js b/actioncable/test/javascript/src/unit/action_cable_test.js new file mode 100644 index 0000000000000..017959ab8ca80 --- /dev/null +++ b/actioncable/test/javascript/src/unit/action_cable_test.js @@ -0,0 +1,57 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" +import {testURL} from "../test_helpers/index" + +const {module, test} = QUnit + +module("ActionCable", () => { + module("Adapters", () => { + module("WebSocket", () => { + test("default is WebSocket", assert => { + assert.equal(ActionCable.adapters.WebSocket, self.WebSocket) + }) + }) + + module("logger", () => { + test("default is console", assert => { + assert.equal(ActionCable.adapters.logger, self.console) + }) + }) + }) + + module("#createConsumer", () => { + test("uses specified URL", assert => { + const consumer = ActionCable.createConsumer(testURL) + assert.equal(consumer.url, testURL) + }) + + test("uses default URL", assert => { + const pattern = new RegExp(`${ActionCable.INTERNAL.default_mount_path}$`) + const consumer = ActionCable.createConsumer() + assert.ok(pattern.test(consumer.url), `Expected ${consumer.url} to match ${pattern}`) + }) + + test("uses URL from meta tag", assert => { + const element = document.createElement("meta") + element.setAttribute("name", "action-cable-url") + element.setAttribute("content", testURL) + + document.head.appendChild(element) + const consumer = ActionCable.createConsumer() + document.head.removeChild(element) + + assert.equal(consumer.url, testURL) + }) + + test("dynamically computes URL from function", assert => { + let dynamicURL = testURL + const generateURL = () => { + return dynamicURL + } + const consumer = ActionCable.createConsumer(generateURL) + assert.equal(consumer.url, testURL) + + dynamicURL = `${testURL}foo` + assert.equal(consumer.url, `${testURL}foo`) + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/connection_monitor_test.js b/actioncable/test/javascript/src/unit/connection_monitor_test.js new file mode 100644 index 0000000000000..ac5d92494ec99 --- /dev/null +++ b/actioncable/test/javascript/src/unit/connection_monitor_test.js @@ -0,0 +1,68 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" + +const {module, test} = QUnit + +module("ActionCable.ConnectionMonitor", hooks => { + let monitor + hooks.beforeEach(() => monitor = new ActionCable.ConnectionMonitor({})) + + module("#getPollInterval", hooks => { + hooks.beforeEach(() => Math._random = Math.random) + hooks.afterEach(() => Math.random = Math._random) + + const { staleThreshold, reconnectionBackoffRate } = ActionCable.ConnectionMonitor + const backoffFactor = 1 + reconnectionBackoffRate + const ms = 1000 + + test("uses exponential backoff", assert => { + Math.random = () => 0 + + monitor.reconnectAttempts = 0 + assert.equal(monitor.getPollInterval(), staleThreshold * ms) + + monitor.reconnectAttempts = 1 + assert.equal(monitor.getPollInterval(), staleThreshold * backoffFactor * ms) + + monitor.reconnectAttempts = 2 + assert.equal(monitor.getPollInterval(), staleThreshold * backoffFactor * backoffFactor * ms) + }) + + test("caps exponential backoff after some number of reconnection attempts", assert => { + Math.random = () => 0 + monitor.reconnectAttempts = 42 + const cappedPollInterval = monitor.getPollInterval() + + monitor.reconnectAttempts = 9001 + assert.equal(monitor.getPollInterval(), cappedPollInterval) + }) + + test("uses 100% jitter when 0 reconnection attempts", assert => { + Math.random = () => 0 + assert.equal(monitor.getPollInterval(), staleThreshold * ms) + + Math.random = () => 0.5 + assert.equal(monitor.getPollInterval(), staleThreshold * 1.5 * ms) + }) + + test("uses reconnectionBackoffRate for jitter when >0 reconnection attempts", assert => { + monitor.reconnectAttempts = 1 + + Math.random = () => 0.25 + assert.equal(monitor.getPollInterval(), staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.25) * ms) + + Math.random = () => 0.5 + assert.equal(monitor.getPollInterval(), staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.5) * ms) + }) + + test("applies jitter after capped exponential backoff", assert => { + monitor.reconnectAttempts = 9001 + + Math.random = () => 0 + const withoutJitter = monitor.getPollInterval() + Math.random = () => 0.5 + const withJitter = monitor.getPollInterval() + + assert.ok(withJitter > withoutJitter) + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/connection_test.js b/actioncable/test/javascript/src/unit/connection_test.js new file mode 100644 index 0000000000000..9b1a975bfb63e --- /dev/null +++ b/actioncable/test/javascript/src/unit/connection_test.js @@ -0,0 +1,28 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" + +const {module, test} = QUnit + +module("ActionCable.Connection", () => { + module("#getState", () => { + test("uses the configured WebSocket adapter", assert => { + ActionCable.adapters.WebSocket = { foo: 1, BAR: "42" } + const connection = new ActionCable.Connection({}) + connection.webSocket = {} + connection.webSocket.readyState = 1 + assert.equal(connection.getState(), "foo") + connection.webSocket.readyState = "42" + assert.equal(connection.getState(), "bar") + }) + }) + + module("#open", () => { + test("uses the configured WebSocket adapter", assert => { + const FakeWebSocket = function() {} + ActionCable.adapters.WebSocket = FakeWebSocket + const connection = new ActionCable.Connection({}) + connection.monitor = { start() {} } + connection.open() + assert.equal(connection.webSocket instanceof FakeWebSocket, true) + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/consumer_test.coffee b/actioncable/test/javascript/src/unit/consumer_test.coffee deleted file mode 100644 index 41445274eba86..0000000000000 --- a/actioncable/test/javascript/src/unit/consumer_test.coffee +++ /dev/null @@ -1,14 +0,0 @@ -{module, test} = QUnit -{consumerTest} = ActionCable.TestHelpers - -module "ActionCable.Consumer", -> - consumerTest "#connect", connect: false, ({consumer, server, assert, done}) -> - server.on "connection", -> - assert.equal consumer.connect(), false - done() - - consumer.connect() - - consumerTest "#disconnect", ({consumer, client, done}) -> - client.addEventListener("close", done) - consumer.disconnect() diff --git a/actioncable/test/javascript/src/unit/consumer_test.js b/actioncable/test/javascript/src/unit/consumer_test.js new file mode 100644 index 0000000000000..1eba5bbb4471f --- /dev/null +++ b/actioncable/test/javascript/src/unit/consumer_test.js @@ -0,0 +1,27 @@ +import consumerTest from "../test_helpers/consumer_test_helper" + +const {module} = QUnit + +module("ActionCable.Consumer", () => { + consumerTest("#connect", {connect: false}, ({consumer, server, assert, done}) => { + server.on("connection", () => { + assert.equal(consumer.connect(), false) + done() + }) + + consumer.connect() + }) + + consumerTest("#disconnect", ({consumer, client, done}) => { + client.addEventListener("close", done) + consumer.disconnect() + }) + + consumerTest("#addSubProtocol", {subprotocols: "some subprotocol"}, ({consumer, server, assert, done}) => { + server.on("connection", () => { + assert.equal(consumer.subprotocols.length, 1) + assert.equal(consumer.subprotocols[0], "some subprotocol") + done() + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/subscription_guarantor_test.js b/actioncable/test/javascript/src/unit/subscription_guarantor_test.js new file mode 100644 index 0000000000000..83665344f5d67 --- /dev/null +++ b/actioncable/test/javascript/src/unit/subscription_guarantor_test.js @@ -0,0 +1,32 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" + +const {module, test} = QUnit + +module("ActionCable.SubscriptionGuarantor", hooks => { + let guarantor + hooks.beforeEach(() => guarantor = new ActionCable.SubscriptionGuarantor({})) + + module("#guarantee", () => { + test("guarantees subscription only once", assert => { + const sub = {} + + assert.equal(guarantor.pendingSubscriptions.length, 0) + guarantor.guarantee(sub) + assert.equal(guarantor.pendingSubscriptions.length, 1) + guarantor.guarantee(sub) + assert.equal(guarantor.pendingSubscriptions.length, 1) + }) + }), + + module("#forget", () => { + test("removes subscription", assert => { + const sub = {} + + assert.equal(guarantor.pendingSubscriptions.length, 0) + guarantor.guarantee(sub) + assert.equal(guarantor.pendingSubscriptions.length, 1) + guarantor.forget(sub) + assert.equal(guarantor.pendingSubscriptions.length, 0) + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/subscription_test.coffee b/actioncable/test/javascript/src/unit/subscription_test.coffee deleted file mode 100644 index 07027ed17069d..0000000000000 --- a/actioncable/test/javascript/src/unit/subscription_test.coffee +++ /dev/null @@ -1,40 +0,0 @@ -{module, test} = QUnit -{consumerTest} = ActionCable.TestHelpers - -module "ActionCable.Subscription", -> - consumerTest "#initialized callback", ({server, consumer, assert, done}) -> - consumer.subscriptions.create "chat", - initialized: -> - assert.ok true - done() - - consumerTest "#connected callback", ({server, consumer, assert, done}) -> - subscription = consumer.subscriptions.create "chat", - connected: -> - assert.ok true - done() - - server.broadcastTo(subscription, message_type: "confirmation") - - consumerTest "#disconnected callback", ({server, consumer, assert, done}) -> - subscription = consumer.subscriptions.create "chat", - disconnected: -> - assert.ok true - done() - - server.broadcastTo subscription, message_type: "confirmation", -> - server.close() - - consumerTest "#perform", ({consumer, server, assert, done}) -> - subscription = consumer.subscriptions.create "chat", - connected: -> - @perform(publish: "hi") - - server.on "message", (message) -> - data = JSON.parse(message) - assert.equal data.identifier, subscription.identifier - assert.equal data.command, "message" - assert.deepEqual data.data, JSON.stringify(action: { publish: "hi" }) - done() - - server.broadcastTo(subscription, message_type: "confirmation") diff --git a/actioncable/test/javascript/src/unit/subscription_test.js b/actioncable/test/javascript/src/unit/subscription_test.js new file mode 100644 index 0000000000000..f1c2efabad33e --- /dev/null +++ b/actioncable/test/javascript/src/unit/subscription_test.js @@ -0,0 +1,68 @@ +import consumerTest from "../test_helpers/consumer_test_helper" + +const {module} = QUnit + +module("ActionCable.Subscription", () => { + consumerTest("#initialized callback", ({server, consumer, assert, done}) => + consumer.subscriptions.create("chat", { + initialized() { + assert.ok(true) + done() + } + }) + ) + + consumerTest("#connected callback", ({server, consumer, assert, done}) => { + const subscription = consumer.subscriptions.create("chat", { + connected({reconnected}) { + assert.ok(true) + assert.notOk(reconnected) + done() + } + }) + + server.broadcastTo(subscription, {message_type: "confirmation"}) + }) + + consumerTest("#connected callback (handling reconnects)", ({server, consumer, connection, monitor, assert, done}) => { + const subscription = consumer.subscriptions.create("chat", { + connected({reconnected}) { + assert.ok(reconnected) + done() + } + }) + + monitor.reconnectAttempts = 1 + server.broadcastTo(subscription, {message_type: "welcome"}) + server.broadcastTo(subscription, {message_type: "confirmation"}) + }) + + consumerTest("#disconnected callback", ({server, consumer, assert, done}) => { + const subscription = consumer.subscriptions.create("chat", { + disconnected() { + assert.ok(true) + done() + } + }) + + server.broadcastTo(subscription, {message_type: "confirmation"}, () => server.close()) + }) + + consumerTest("#perform", ({consumer, server, assert, done}) => { + const subscription = consumer.subscriptions.create("chat", { + connected() { + this.perform({publish: "hi"}) + } + }) + + server.on("message", (message) => { + const data = JSON.parse(message) + assert.equal(data.identifier, subscription.identifier) + assert.equal(data.command, "message") + assert.deepEqual(data.data, JSON.stringify({action: { publish: "hi" }})) + done() + }) + + server.broadcastTo(subscription, {message_type: "confirmation"}) + }) +}) diff --git a/actioncable/test/javascript/src/unit/subscriptions_test.coffee b/actioncable/test/javascript/src/unit/subscriptions_test.coffee deleted file mode 100644 index 170b370e4a036..0000000000000 --- a/actioncable/test/javascript/src/unit/subscriptions_test.coffee +++ /dev/null @@ -1,25 +0,0 @@ -{module, test} = QUnit -{consumerTest} = ActionCable.TestHelpers - -module "ActionCable.Subscriptions", -> - consumerTest "create subscription with channel string", ({consumer, server, assert, done}) -> - channel = "chat" - - server.on "message", (message) -> - data = JSON.parse(message) - assert.equal data.command, "subscribe" - assert.equal data.identifier, JSON.stringify({channel}) - done() - - consumer.subscriptions.create(channel) - - consumerTest "create subscription with channel object", ({consumer, server, assert, done}) -> - channel = channel: "chat", room: "action" - - server.on "message", (message) -> - data = JSON.parse(message) - assert.equal data.command, "subscribe" - assert.equal data.identifier, JSON.stringify(channel) - done() - - consumer.subscriptions.create(channel) diff --git a/actioncable/test/javascript/src/unit/subscriptions_test.js b/actioncable/test/javascript/src/unit/subscriptions_test.js new file mode 100644 index 0000000000000..33af5d4d824bf --- /dev/null +++ b/actioncable/test/javascript/src/unit/subscriptions_test.js @@ -0,0 +1,31 @@ +import consumerTest from "../test_helpers/consumer_test_helper" + +const {module} = QUnit + +module("ActionCable.Subscriptions", () => { + consumerTest("create subscription with channel string", ({consumer, server, assert, done}) => { + const channel = "chat" + + server.on("message", (message) => { + const data = JSON.parse(message) + assert.equal(data.command, "subscribe") + assert.equal(data.identifier, JSON.stringify({channel})) + done() + }) + + consumer.subscriptions.create(channel) + }) + + consumerTest("create subscription with channel object", ({consumer, server, assert, done}) => { + const channel = {channel: "chat", room: "action"} + + server.on("message", (message) => { + const data = JSON.parse(message) + assert.equal(data.command, "subscribe") + assert.equal(data.identifier, JSON.stringify(channel)) + done() + }) + + consumer.subscriptions.create(channel) + }) +}) diff --git a/actioncable/test/javascript/vendor/mock-socket.js b/actioncable/test/javascript/vendor/mock-socket.js deleted file mode 100644 index b465c8b53fa68..0000000000000 --- a/actioncable/test/javascript/vendor/mock-socket.js +++ /dev/null @@ -1,4533 +0,0 @@ -(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 1) { - _segments.splice(0,1); - } else { - break; - } - } - - segments[i] = _segments.join(''); - } - - // find longest sequence of zeroes and coalesce them into one segment - var best = -1; - var _best = 0; - var _current = 0; - var current = -1; - var inzeroes = false; - // i; already declared - - for (i = 0; i < total; i++) { - if (inzeroes) { - if (segments[i] === '0') { - _current += 1; - } else { - inzeroes = false; - if (_current > _best) { - best = current; - _best = _current; - } - } - } else { - if (segments[i] === '0') { - inzeroes = true; - current = i; - _current = 1; - } - } - } - - if (_current > _best) { - best = current; - _best = _current; - } - - if (_best > 1) { - segments.splice(best, _best, ''); - } - - length = segments.length; - - // assemble remaining segments - var result = ''; - if (segments[0] === '') { - result = ':'; - } - - for (i = 0; i < length; i++) { - result += segments[i]; - if (i === length - 1) { - break; - } - - result += ':'; - } - - if (segments[length - 1] === '') { - result += ':'; - } - - return result; - } - - function noConflict() { - /*jshint validthis: true */ - if (root.IPv6 === this) { - root.IPv6 = _IPv6; - } - - return this; - } - - return { - best: bestPresentation, - noConflict: noConflict - }; -})); - -},{}],2:[function(require,module,exports){ -/*! - * URI.js - Mutating URLs - * Second Level Domain (SLD) Support - * - * Version: 1.17.0 - * - * Author: Rodney Rehm - * Web: http://medialize.github.io/URI.js/ - * - * Licensed under - * MIT License http://www.opensource.org/licenses/mit-license - * GPL v3 http://opensource.org/licenses/GPL-3.0 - * - */ - -(function (root, factory) { - 'use strict'; - // https://github.com/umdjs/umd/blob/master/returnExports.js - if (typeof exports === 'object') { - // Node - module.exports = factory(); - } else if (typeof define === 'function' && define.amd) { - // AMD. Register as an anonymous module. - define(factory); - } else { - // Browser globals (root is window) - root.SecondLevelDomains = factory(root); - } -}(this, function (root) { - 'use strict'; - - // save current SecondLevelDomains variable, if any - var _SecondLevelDomains = root && root.SecondLevelDomains; - - var SLD = { - // list of known Second Level Domains - // converted list of SLDs from https://github.com/gavingmiller/second-level-domains - // ---- - // publicsuffix.org is more current and actually used by a couple of browsers internally. - // downside is it also contains domains like "dyndns.org" - which is fine for the security - // issues browser have to deal with (SOP for cookies, etc) - but is way overboard for URI.js - // ---- - list: { - 'ac':' com gov mil net org ', - 'ae':' ac co gov mil name net org pro sch ', - 'af':' com edu gov net org ', - 'al':' com edu gov mil net org ', - 'ao':' co ed gv it og pb ', - 'ar':' com edu gob gov int mil net org tur ', - 'at':' ac co gv or ', - 'au':' asn com csiro edu gov id net org ', - 'ba':' co com edu gov mil net org rs unbi unmo unsa untz unze ', - 'bb':' biz co com edu gov info net org store tv ', - 'bh':' biz cc com edu gov info net org ', - 'bn':' com edu gov net org ', - 'bo':' com edu gob gov int mil net org tv ', - 'br':' adm adv agr am arq art ato b bio blog bmd cim cng cnt com coop ecn edu eng esp etc eti far flog fm fnd fot fst g12 ggf gov imb ind inf jor jus lel mat med mil mus net nom not ntr odo org ppg pro psc psi qsl rec slg srv tmp trd tur tv vet vlog wiki zlg ', - 'bs':' com edu gov net org ', - 'bz':' du et om ov rg ', - 'ca':' ab bc mb nb nf nl ns nt nu on pe qc sk yk ', - 'ck':' biz co edu gen gov info net org ', - 'cn':' ac ah bj com cq edu fj gd gov gs gx gz ha hb he hi hl hn jl js jx ln mil net nm nx org qh sc sd sh sn sx tj tw xj xz yn zj ', - 'co':' com edu gov mil net nom org ', - 'cr':' ac c co ed fi go or sa ', - 'cy':' ac biz com ekloges gov ltd name net org parliament press pro tm ', - 'do':' art com edu gob gov mil net org sld web ', - 'dz':' art asso com edu gov net org pol ', - 'ec':' com edu fin gov info med mil net org pro ', - 'eg':' com edu eun gov mil name net org sci ', - 'er':' com edu gov ind mil net org rochest w ', - 'es':' com edu gob nom org ', - 'et':' biz com edu gov info name net org ', - 'fj':' ac biz com info mil name net org pro ', - 'fk':' ac co gov net nom org ', - 'fr':' asso com f gouv nom prd presse tm ', - 'gg':' co net org ', - 'gh':' com edu gov mil org ', - 'gn':' ac com gov net org ', - 'gr':' com edu gov mil net org ', - 'gt':' com edu gob ind mil net org ', - 'gu':' com edu gov net org ', - 'hk':' com edu gov idv net org ', - 'hu':' 2000 agrar bolt casino city co erotica erotika film forum games hotel info ingatlan jogasz konyvelo lakas media news org priv reklam sex shop sport suli szex tm tozsde utazas video ', - 'id':' ac co go mil net or sch web ', - 'il':' ac co gov idf k12 muni net org ', - 'in':' ac co edu ernet firm gen gov i ind mil net nic org res ', - 'iq':' com edu gov i mil net org ', - 'ir':' ac co dnssec gov i id net org sch ', - 'it':' edu gov ', - 'je':' co net org ', - 'jo':' com edu gov mil name net org sch ', - 'jp':' ac ad co ed go gr lg ne or ', - 'ke':' ac co go info me mobi ne or sc ', - 'kh':' com edu gov mil net org per ', - 'ki':' biz com de edu gov info mob net org tel ', - 'km':' asso com coop edu gouv k medecin mil nom notaires pharmaciens presse tm veterinaire ', - 'kn':' edu gov net org ', - 'kr':' ac busan chungbuk chungnam co daegu daejeon es gangwon go gwangju gyeongbuk gyeonggi gyeongnam hs incheon jeju jeonbuk jeonnam k kg mil ms ne or pe re sc seoul ulsan ', - 'kw':' com edu gov net org ', - 'ky':' com edu gov net org ', - 'kz':' com edu gov mil net org ', - 'lb':' com edu gov net org ', - 'lk':' assn com edu gov grp hotel int ltd net ngo org sch soc web ', - 'lr':' com edu gov net org ', - 'lv':' asn com conf edu gov id mil net org ', - 'ly':' com edu gov id med net org plc sch ', - 'ma':' ac co gov m net org press ', - 'mc':' asso tm ', - 'me':' ac co edu gov its net org priv ', - 'mg':' com edu gov mil nom org prd tm ', - 'mk':' com edu gov inf name net org pro ', - 'ml':' com edu gov net org presse ', - 'mn':' edu gov org ', - 'mo':' com edu gov net org ', - 'mt':' com edu gov net org ', - 'mv':' aero biz com coop edu gov info int mil museum name net org pro ', - 'mw':' ac co com coop edu gov int museum net org ', - 'mx':' com edu gob net org ', - 'my':' com edu gov mil name net org sch ', - 'nf':' arts com firm info net other per rec store web ', - 'ng':' biz com edu gov mil mobi name net org sch ', - 'ni':' ac co com edu gob mil net nom org ', - 'np':' com edu gov mil net org ', - 'nr':' biz com edu gov info net org ', - 'om':' ac biz co com edu gov med mil museum net org pro sch ', - 'pe':' com edu gob mil net nom org sld ', - 'ph':' com edu gov i mil net ngo org ', - 'pk':' biz com edu fam gob gok gon gop gos gov net org web ', - 'pl':' art bialystok biz com edu gda gdansk gorzow gov info katowice krakow lodz lublin mil net ngo olsztyn org poznan pwr radom slupsk szczecin torun warszawa waw wroc wroclaw zgora ', - 'pr':' ac biz com edu est gov info isla name net org pro prof ', - 'ps':' com edu gov net org plo sec ', - 'pw':' belau co ed go ne or ', - 'ro':' arts com firm info nom nt org rec store tm www ', - 'rs':' ac co edu gov in org ', - 'sb':' com edu gov net org ', - 'sc':' com edu gov net org ', - 'sh':' co com edu gov net nom org ', - 'sl':' com edu gov net org ', - 'st':' co com consulado edu embaixada gov mil net org principe saotome store ', - 'sv':' com edu gob org red ', - 'sz':' ac co org ', - 'tr':' av bbs bel biz com dr edu gen gov info k12 name net org pol tel tsk tv web ', - 'tt':' aero biz cat co com coop edu gov info int jobs mil mobi museum name net org pro tel travel ', - 'tw':' club com ebiz edu game gov idv mil net org ', - 'mu':' ac co com gov net or org ', - 'mz':' ac co edu gov org ', - 'na':' co com ', - 'nz':' ac co cri geek gen govt health iwi maori mil net org parliament school ', - 'pa':' abo ac com edu gob ing med net nom org sld ', - 'pt':' com edu gov int net nome org publ ', - 'py':' com edu gov mil net org ', - 'qa':' com edu gov mil net org ', - 're':' asso com nom ', - 'ru':' ac adygeya altai amur arkhangelsk astrakhan bashkiria belgorod bir bryansk buryatia cbg chel chelyabinsk chita chukotka chuvashia com dagestan e-burg edu gov grozny int irkutsk ivanovo izhevsk jar joshkar-ola kalmykia kaluga kamchatka karelia kazan kchr kemerovo khabarovsk khakassia khv kirov koenig komi kostroma kranoyarsk kuban kurgan kursk lipetsk magadan mari mari-el marine mil mordovia mosreg msk murmansk nalchik net nnov nov novosibirsk nsk omsk orenburg org oryol penza perm pp pskov ptz rnd ryazan sakhalin samara saratov simbirsk smolensk spb stavropol stv surgut tambov tatarstan tom tomsk tsaritsyn tsk tula tuva tver tyumen udm udmurtia ulan-ude vladikavkaz vladimir vladivostok volgograd vologda voronezh vrn vyatka yakutia yamal yekaterinburg yuzhno-sakhalinsk ', - 'rw':' ac co com edu gouv gov int mil net ', - 'sa':' com edu gov med net org pub sch ', - 'sd':' com edu gov info med net org tv ', - 'se':' a ac b bd c d e f g h i k l m n o org p parti pp press r s t tm u w x y z ', - 'sg':' com edu gov idn net org per ', - 'sn':' art com edu gouv org perso univ ', - 'sy':' com edu gov mil net news org ', - 'th':' ac co go in mi net or ', - 'tj':' ac biz co com edu go gov info int mil name net nic org test web ', - 'tn':' agrinet com defense edunet ens fin gov ind info intl mincom nat net org perso rnrt rns rnu tourism ', - 'tz':' ac co go ne or ', - 'ua':' biz cherkassy chernigov chernovtsy ck cn co com crimea cv dn dnepropetrovsk donetsk dp edu gov if in ivano-frankivsk kh kharkov kherson khmelnitskiy kiev kirovograd km kr ks kv lg lugansk lutsk lviv me mk net nikolaev od odessa org pl poltava pp rovno rv sebastopol sumy te ternopil uzhgorod vinnica vn zaporizhzhe zhitomir zp zt ', - 'ug':' ac co go ne or org sc ', - 'uk':' ac bl british-library co cym gov govt icnet jet lea ltd me mil mod national-library-scotland nel net nhs nic nls org orgn parliament plc police sch scot soc ', - 'us':' dni fed isa kids nsn ', - 'uy':' com edu gub mil net org ', - 've':' co com edu gob info mil net org web ', - 'vi':' co com k12 net org ', - 'vn':' ac biz com edu gov health info int name net org pro ', - 'ye':' co com gov ltd me net org plc ', - 'yu':' ac co edu gov org ', - 'za':' ac agric alt bourse city co cybernet db edu gov grondar iaccess imt inca landesign law mil net ngo nis nom olivetti org pix school tm web ', - 'zm':' ac co com edu gov net org sch ' - }, - // gorhill 2013-10-25: Using indexOf() instead Regexp(). Significant boost - // in both performance and memory footprint. No initialization required. - // http://jsperf.com/uri-js-sld-regex-vs-binary-search/4 - // Following methods use lastIndexOf() rather than array.split() in order - // to avoid any memory allocations. - has: function(domain) { - var tldOffset = domain.lastIndexOf('.'); - if (tldOffset <= 0 || tldOffset >= (domain.length-1)) { - return false; - } - var sldOffset = domain.lastIndexOf('.', tldOffset-1); - if (sldOffset <= 0 || sldOffset >= (tldOffset-1)) { - return false; - } - var sldList = SLD.list[domain.slice(tldOffset+1)]; - if (!sldList) { - return false; - } - return sldList.indexOf(' ' + domain.slice(sldOffset+1, tldOffset) + ' ') >= 0; - }, - is: function(domain) { - var tldOffset = domain.lastIndexOf('.'); - if (tldOffset <= 0 || tldOffset >= (domain.length-1)) { - return false; - } - var sldOffset = domain.lastIndexOf('.', tldOffset-1); - if (sldOffset >= 0) { - return false; - } - var sldList = SLD.list[domain.slice(tldOffset+1)]; - if (!sldList) { - return false; - } - return sldList.indexOf(' ' + domain.slice(0, tldOffset) + ' ') >= 0; - }, - get: function(domain) { - var tldOffset = domain.lastIndexOf('.'); - if (tldOffset <= 0 || tldOffset >= (domain.length-1)) { - return null; - } - var sldOffset = domain.lastIndexOf('.', tldOffset-1); - if (sldOffset <= 0 || sldOffset >= (tldOffset-1)) { - return null; - } - var sldList = SLD.list[domain.slice(tldOffset+1)]; - if (!sldList) { - return null; - } - if (sldList.indexOf(' ' + domain.slice(sldOffset+1, tldOffset) + ' ') < 0) { - return null; - } - return domain.slice(sldOffset+1); - }, - noConflict: function(){ - if (root.SecondLevelDomains === this) { - root.SecondLevelDomains = _SecondLevelDomains; - } - return this; - } - }; - - return SLD; -})); - -},{}],3:[function(require,module,exports){ -/*! - * URI.js - Mutating URLs - * - * Version: 1.17.0 - * - * Author: Rodney Rehm - * Web: http://medialize.github.io/URI.js/ - * - * Licensed under - * MIT License http://www.opensource.org/licenses/mit-license - * GPL v3 http://opensource.org/licenses/GPL-3.0 - * - */ -(function (root, factory) { - 'use strict'; - // https://github.com/umdjs/umd/blob/master/returnExports.js - if (typeof exports === 'object') { - // Node - module.exports = factory(require('./punycode'), require('./IPv6'), require('./SecondLevelDomains')); - } else if (typeof define === 'function' && define.amd) { - // AMD. Register as an anonymous module. - define(['./punycode', './IPv6', './SecondLevelDomains'], factory); - } else { - // Browser globals (root is window) - root.URI = factory(root.punycode, root.IPv6, root.SecondLevelDomains, root); - } -}(this, function (punycode, IPv6, SLD, root) { - 'use strict'; - /*global location, escape, unescape */ - // FIXME: v2.0.0 renamce non-camelCase properties to uppercase - /*jshint camelcase: false */ - - // save current URI variable, if any - var _URI = root && root.URI; - - function URI(url, base) { - var _urlSupplied = arguments.length >= 1; - var _baseSupplied = arguments.length >= 2; - - // Allow instantiation without the 'new' keyword - if (!(this instanceof URI)) { - if (_urlSupplied) { - if (_baseSupplied) { - return new URI(url, base); - } - - return new URI(url); - } - - return new URI(); - } - - if (url === undefined) { - if (_urlSupplied) { - throw new TypeError('undefined is not a valid argument for URI'); - } - - if (typeof location !== 'undefined') { - url = location.href + ''; - } else { - url = ''; - } - } - - this.href(url); - - // resolve to base according to http://dvcs.w3.org/hg/url/raw-file/tip/Overview.html#constructor - if (base !== undefined) { - return this.absoluteTo(base); - } - - return this; - } - - URI.version = '1.17.0'; - - var p = URI.prototype; - var hasOwn = Object.prototype.hasOwnProperty; - - function escapeRegEx(string) { - // https://github.com/medialize/URI.js/commit/85ac21783c11f8ccab06106dba9735a31a86924d#commitcomment-821963 - return string.replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1'); - } - - function getType(value) { - // IE8 doesn't return [Object Undefined] but [Object Object] for undefined value - if (value === undefined) { - return 'Undefined'; - } - - return String(Object.prototype.toString.call(value)).slice(8, -1); - } - - function isArray(obj) { - return getType(obj) === 'Array'; - } - - function filterArrayValues(data, value) { - var lookup = {}; - var i, length; - - if (getType(value) === 'RegExp') { - lookup = null; - } else if (isArray(value)) { - for (i = 0, length = value.length; i < length; i++) { - lookup[value[i]] = true; - } - } else { - lookup[value] = true; - } - - for (i = 0, length = data.length; i < length; i++) { - /*jshint laxbreak: true */ - var _match = lookup && lookup[data[i]] !== undefined - || !lookup && value.test(data[i]); - /*jshint laxbreak: false */ - if (_match) { - data.splice(i, 1); - length--; - i--; - } - } - - return data; - } - - function arrayContains(list, value) { - var i, length; - - // value may be string, number, array, regexp - if (isArray(value)) { - // Note: this can be optimized to O(n) (instead of current O(m * n)) - for (i = 0, length = value.length; i < length; i++) { - if (!arrayContains(list, value[i])) { - return false; - } - } - - return true; - } - - var _type = getType(value); - for (i = 0, length = list.length; i < length; i++) { - if (_type === 'RegExp') { - if (typeof list[i] === 'string' && list[i].match(value)) { - return true; - } - } else if (list[i] === value) { - return true; - } - } - - return false; - } - - function arraysEqual(one, two) { - if (!isArray(one) || !isArray(two)) { - return false; - } - - // arrays can't be equal if they have different amount of content - if (one.length !== two.length) { - return false; - } - - one.sort(); - two.sort(); - - for (var i = 0, l = one.length; i < l; i++) { - if (one[i] !== two[i]) { - return false; - } - } - - return true; - } - - function trimSlashes(text) { - var trim_expression = /^\/+|\/+$/g; - return text.replace(trim_expression, ''); - } - - URI._parts = function() { - return { - protocol: null, - username: null, - password: null, - hostname: null, - urn: null, - port: null, - path: null, - query: null, - fragment: null, - // state - duplicateQueryParameters: URI.duplicateQueryParameters, - escapeQuerySpace: URI.escapeQuerySpace - }; - }; - // state: allow duplicate query parameters (a=1&a=1) - URI.duplicateQueryParameters = false; - // state: replaces + with %20 (space in query strings) - URI.escapeQuerySpace = true; - // static properties - URI.protocol_expression = /^[a-z][a-z0-9.+-]*$/i; - URI.idn_expression = /[^a-z0-9\.-]/i; - URI.punycode_expression = /(xn--)/i; - // well, 333.444.555.666 matches, but it sure ain't no IPv4 - do we care? - URI.ip4_expression = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; - // credits to Rich Brown - // source: http://forums.intermapper.com/viewtopic.php?p=1096#1096 - // specification: http://www.ietf.org/rfc/rfc4291.txt - URI.ip6_expression = /^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/; - // expression used is "gruber revised" (@gruber v2) determined to be the - // best solution in a regex-golf we did a couple of ages ago at - // * http://mathiasbynens.be/demo/url-regex - // * http://rodneyrehm.de/t/url-regex.html - URI.find_uri_expression = /\b((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“â€â€˜â€™]))/ig; - URI.findUri = { - // valid "scheme://" or "www." - start: /\b(?:([a-z][a-z0-9.+-]*:\/\/)|www\.)/gi, - // everything up to the next whitespace - end: /[\s\r\n]|$/, - // trim trailing punctuation captured by end RegExp - trim: /[`!()\[\]{};:'".,<>?«»“â€â€žâ€˜â€™]+$/ - }; - // http://www.iana.org/assignments/uri-schemes.html - // http://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers#Well-known_ports - URI.defaultPorts = { - http: '80', - https: '443', - ftp: '21', - gopher: '70', - ws: '80', - wss: '443' - }; - // allowed hostname characters according to RFC 3986 - // ALPHA DIGIT "-" "." "_" "~" "!" "$" "&" "'" "(" ")" "*" "+" "," ";" "=" %encoded - // I've never seen a (non-IDN) hostname other than: ALPHA DIGIT . - - URI.invalid_hostname_characters = /[^a-zA-Z0-9\.-]/; - // map DOM Elements to their URI attribute - URI.domAttributes = { - 'a': 'href', - 'blockquote': 'cite', - 'link': 'href', - 'base': 'href', - 'script': 'src', - 'form': 'action', - 'img': 'src', - 'area': 'href', - 'iframe': 'src', - 'embed': 'src', - 'source': 'src', - 'track': 'src', - 'input': 'src', // but only if type="image" - 'audio': 'src', - 'video': 'src' - }; - URI.getDomAttribute = function(node) { - if (!node || !node.nodeName) { - return undefined; - } - - var nodeName = node.nodeName.toLowerCase(); - // should only expose src for type="image" - if (nodeName === 'input' && node.type !== 'image') { - return undefined; - } - - return URI.domAttributes[nodeName]; - }; - - function escapeForDumbFirefox36(value) { - // https://github.com/medialize/URI.js/issues/91 - return escape(value); - } - - // encoding / decoding according to RFC3986 - function strictEncodeURIComponent(string) { - // see https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/encodeURIComponent - return encodeURIComponent(string) - .replace(/[!'()*]/g, escapeForDumbFirefox36) - .replace(/\*/g, '%2A'); - } - URI.encode = strictEncodeURIComponent; - URI.decode = decodeURIComponent; - URI.iso8859 = function() { - URI.encode = escape; - URI.decode = unescape; - }; - URI.unicode = function() { - URI.encode = strictEncodeURIComponent; - URI.decode = decodeURIComponent; - }; - URI.characters = { - pathname: { - encode: { - // RFC3986 2.1: For consistency, URI producers and normalizers should - // use uppercase hexadecimal digits for all percent-encodings. - expression: /%(24|26|2B|2C|3B|3D|3A|40)/ig, - map: { - // -._~!'()* - '%24': '$', - '%26': '&', - '%2B': '+', - '%2C': ',', - '%3B': ';', - '%3D': '=', - '%3A': ':', - '%40': '@' - } - }, - decode: { - expression: /[\/\?#]/g, - map: { - '/': '%2F', - '?': '%3F', - '#': '%23' - } - } - }, - reserved: { - encode: { - // RFC3986 2.1: For consistency, URI producers and normalizers should - // use uppercase hexadecimal digits for all percent-encodings. - expression: /%(21|23|24|26|27|28|29|2A|2B|2C|2F|3A|3B|3D|3F|40|5B|5D)/ig, - map: { - // gen-delims - '%3A': ':', - '%2F': '/', - '%3F': '?', - '%23': '#', - '%5B': '[', - '%5D': ']', - '%40': '@', - // sub-delims - '%21': '!', - '%24': '$', - '%26': '&', - '%27': '\'', - '%28': '(', - '%29': ')', - '%2A': '*', - '%2B': '+', - '%2C': ',', - '%3B': ';', - '%3D': '=' - } - } - }, - urnpath: { - // The characters under `encode` are the characters called out by RFC 2141 as being acceptable - // for usage in a URN. RFC2141 also calls out "-", ".", and "_" as acceptable characters, but - // these aren't encoded by encodeURIComponent, so we don't have to call them out here. Also - // note that the colon character is not featured in the encoding map; this is because URI.js - // gives the colons in URNs semantic meaning as the delimiters of path segements, and so it - // should not appear unencoded in a segment itself. - // See also the note above about RFC3986 and capitalalized hex digits. - encode: { - expression: /%(21|24|27|28|29|2A|2B|2C|3B|3D|40)/ig, - map: { - '%21': '!', - '%24': '$', - '%27': '\'', - '%28': '(', - '%29': ')', - '%2A': '*', - '%2B': '+', - '%2C': ',', - '%3B': ';', - '%3D': '=', - '%40': '@' - } - }, - // These characters are the characters called out by RFC2141 as "reserved" characters that - // should never appear in a URN, plus the colon character (see note above). - decode: { - expression: /[\/\?#:]/g, - map: { - '/': '%2F', - '?': '%3F', - '#': '%23', - ':': '%3A' - } - } - } - }; - URI.encodeQuery = function(string, escapeQuerySpace) { - var escaped = URI.encode(string + ''); - if (escapeQuerySpace === undefined) { - escapeQuerySpace = URI.escapeQuerySpace; - } - - return escapeQuerySpace ? escaped.replace(/%20/g, '+') : escaped; - }; - URI.decodeQuery = function(string, escapeQuerySpace) { - string += ''; - if (escapeQuerySpace === undefined) { - escapeQuerySpace = URI.escapeQuerySpace; - } - - try { - return URI.decode(escapeQuerySpace ? string.replace(/\+/g, '%20') : string); - } catch(e) { - // we're not going to mess with weird encodings, - // give up and return the undecoded original string - // see https://github.com/medialize/URI.js/issues/87 - // see https://github.com/medialize/URI.js/issues/92 - return string; - } - }; - // generate encode/decode path functions - var _parts = {'encode':'encode', 'decode':'decode'}; - var _part; - var generateAccessor = function(_group, _part) { - return function(string) { - try { - return URI[_part](string + '').replace(URI.characters[_group][_part].expression, function(c) { - return URI.characters[_group][_part].map[c]; - }); - } catch (e) { - // we're not going to mess with weird encodings, - // give up and return the undecoded original string - // see https://github.com/medialize/URI.js/issues/87 - // see https://github.com/medialize/URI.js/issues/92 - return string; - } - }; - }; - - for (_part in _parts) { - URI[_part + 'PathSegment'] = generateAccessor('pathname', _parts[_part]); - URI[_part + 'UrnPathSegment'] = generateAccessor('urnpath', _parts[_part]); - } - - var generateSegmentedPathFunction = function(_sep, _codingFuncName, _innerCodingFuncName) { - return function(string) { - // Why pass in names of functions, rather than the function objects themselves? The - // definitions of some functions (but in particular, URI.decode) will occasionally change due - // to URI.js having ISO8859 and Unicode modes. Passing in the name and getting it will ensure - // that the functions we use here are "fresh". - var actualCodingFunc; - if (!_innerCodingFuncName) { - actualCodingFunc = URI[_codingFuncName]; - } else { - actualCodingFunc = function(string) { - return URI[_codingFuncName](URI[_innerCodingFuncName](string)); - }; - } - - var segments = (string + '').split(_sep); - - for (var i = 0, length = segments.length; i < length; i++) { - segments[i] = actualCodingFunc(segments[i]); - } - - return segments.join(_sep); - }; - }; - - // This takes place outside the above loop because we don't want, e.g., encodeUrnPath functions. - URI.decodePath = generateSegmentedPathFunction('/', 'decodePathSegment'); - URI.decodeUrnPath = generateSegmentedPathFunction(':', 'decodeUrnPathSegment'); - URI.recodePath = generateSegmentedPathFunction('/', 'encodePathSegment', 'decode'); - URI.recodeUrnPath = generateSegmentedPathFunction(':', 'encodeUrnPathSegment', 'decode'); - - URI.encodeReserved = generateAccessor('reserved', 'encode'); - - URI.parse = function(string, parts) { - var pos; - if (!parts) { - parts = {}; - } - // [protocol"://"[username[":"password]"@"]hostname[":"port]"/"?][path]["?"querystring]["#"fragment] - - // extract fragment - pos = string.indexOf('#'); - if (pos > -1) { - // escaping? - parts.fragment = string.substring(pos + 1) || null; - string = string.substring(0, pos); - } - - // extract query - pos = string.indexOf('?'); - if (pos > -1) { - // escaping? - parts.query = string.substring(pos + 1) || null; - string = string.substring(0, pos); - } - - // extract protocol - if (string.substring(0, 2) === '//') { - // relative-scheme - parts.protocol = null; - string = string.substring(2); - // extract "user:pass@host:port" - string = URI.parseAuthority(string, parts); - } else { - pos = string.indexOf(':'); - if (pos > -1) { - parts.protocol = string.substring(0, pos) || null; - if (parts.protocol && !parts.protocol.match(URI.protocol_expression)) { - // : may be within the path - parts.protocol = undefined; - } else if (string.substring(pos + 1, pos + 3) === '//') { - string = string.substring(pos + 3); - - // extract "user:pass@host:port" - string = URI.parseAuthority(string, parts); - } else { - string = string.substring(pos + 1); - parts.urn = true; - } - } - } - - // what's left must be the path - parts.path = string; - - // and we're done - return parts; - }; - URI.parseHost = function(string, parts) { - // Copy chrome, IE, opera backslash-handling behavior. - // Back slashes before the query string get converted to forward slashes - // See: https://github.com/joyent/node/blob/386fd24f49b0e9d1a8a076592a404168faeecc34/lib/url.js#L115-L124 - // See: https://code.google.com/p/chromium/issues/detail?id=25916 - // https://github.com/medialize/URI.js/pull/233 - string = string.replace(/\\/g, '/'); - - // extract host:port - var pos = string.indexOf('/'); - var bracketPos; - var t; - - if (pos === -1) { - pos = string.length; - } - - if (string.charAt(0) === '[') { - // IPv6 host - http://tools.ietf.org/html/draft-ietf-6man-text-addr-representation-04#section-6 - // I claim most client software breaks on IPv6 anyways. To simplify things, URI only accepts - // IPv6+port in the format [2001:db8::1]:80 (for the time being) - bracketPos = string.indexOf(']'); - parts.hostname = string.substring(1, bracketPos) || null; - parts.port = string.substring(bracketPos + 2, pos) || null; - if (parts.port === '/') { - parts.port = null; - } - } else { - var firstColon = string.indexOf(':'); - var firstSlash = string.indexOf('/'); - var nextColon = string.indexOf(':', firstColon + 1); - if (nextColon !== -1 && (firstSlash === -1 || nextColon < firstSlash)) { - // IPv6 host contains multiple colons - but no port - // this notation is actually not allowed by RFC 3986, but we're a liberal parser - parts.hostname = string.substring(0, pos) || null; - parts.port = null; - } else { - t = string.substring(0, pos).split(':'); - parts.hostname = t[0] || null; - parts.port = t[1] || null; - } - } - - if (parts.hostname && string.substring(pos).charAt(0) !== '/') { - pos++; - string = '/' + string; - } - - return string.substring(pos) || '/'; - }; - URI.parseAuthority = function(string, parts) { - string = URI.parseUserinfo(string, parts); - return URI.parseHost(string, parts); - }; - URI.parseUserinfo = function(string, parts) { - // extract username:password - var firstSlash = string.indexOf('/'); - var pos = string.lastIndexOf('@', firstSlash > -1 ? firstSlash : string.length - 1); - var t; - - // authority@ must come before /path - if (pos > -1 && (firstSlash === -1 || pos < firstSlash)) { - t = string.substring(0, pos).split(':'); - parts.username = t[0] ? URI.decode(t[0]) : null; - t.shift(); - parts.password = t[0] ? URI.decode(t.join(':')) : null; - string = string.substring(pos + 1); - } else { - parts.username = null; - parts.password = null; - } - - return string; - }; - URI.parseQuery = function(string, escapeQuerySpace) { - if (!string) { - return {}; - } - - // throw out the funky business - "?"[name"="value"&"]+ - string = string.replace(/&+/g, '&').replace(/^\?*&*|&+$/g, ''); - - if (!string) { - return {}; - } - - var items = {}; - var splits = string.split('&'); - var length = splits.length; - var v, name, value; - - for (var i = 0; i < length; i++) { - v = splits[i].split('='); - name = URI.decodeQuery(v.shift(), escapeQuerySpace); - // no "=" is null according to http://dvcs.w3.org/hg/url/raw-file/tip/Overview.html#collect-url-parameters - value = v.length ? URI.decodeQuery(v.join('='), escapeQuerySpace) : null; - - if (hasOwn.call(items, name)) { - if (typeof items[name] === 'string' || items[name] === null) { - items[name] = [items[name]]; - } - - items[name].push(value); - } else { - items[name] = value; - } - } - - return items; - }; - - URI.build = function(parts) { - var t = ''; - - if (parts.protocol) { - t += parts.protocol + ':'; - } - - if (!parts.urn && (t || parts.hostname)) { - t += '//'; - } - - t += (URI.buildAuthority(parts) || ''); - - if (typeof parts.path === 'string') { - if (parts.path.charAt(0) !== '/' && typeof parts.hostname === 'string') { - t += '/'; - } - - t += parts.path; - } - - if (typeof parts.query === 'string' && parts.query) { - t += '?' + parts.query; - } - - if (typeof parts.fragment === 'string' && parts.fragment) { - t += '#' + parts.fragment; - } - return t; - }; - URI.buildHost = function(parts) { - var t = ''; - - if (!parts.hostname) { - return ''; - } else if (URI.ip6_expression.test(parts.hostname)) { - t += '[' + parts.hostname + ']'; - } else { - t += parts.hostname; - } - - if (parts.port) { - t += ':' + parts.port; - } - - return t; - }; - URI.buildAuthority = function(parts) { - return URI.buildUserinfo(parts) + URI.buildHost(parts); - }; - URI.buildUserinfo = function(parts) { - var t = ''; - - if (parts.username) { - t += URI.encode(parts.username); - - if (parts.password) { - t += ':' + URI.encode(parts.password); - } - - t += '@'; - } - - return t; - }; - URI.buildQuery = function(data, duplicateQueryParameters, escapeQuerySpace) { - // according to http://tools.ietf.org/html/rfc3986 or http://labs.apache.org/webarch/uri/rfc/rfc3986.html - // being »-._~!$&'()*+,;=:@/?« %HEX and alnum are allowed - // the RFC explicitly states ?/foo being a valid use case, no mention of parameter syntax! - // URI.js treats the query string as being application/x-www-form-urlencoded - // see http://www.w3.org/TR/REC-html40/interact/forms.html#form-content-type - - var t = ''; - var unique, key, i, length; - for (key in data) { - if (hasOwn.call(data, key) && key) { - if (isArray(data[key])) { - unique = {}; - for (i = 0, length = data[key].length; i < length; i++) { - if (data[key][i] !== undefined && unique[data[key][i] + ''] === undefined) { - t += '&' + URI.buildQueryParameter(key, data[key][i], escapeQuerySpace); - if (duplicateQueryParameters !== true) { - unique[data[key][i] + ''] = true; - } - } - } - } else if (data[key] !== undefined) { - t += '&' + URI.buildQueryParameter(key, data[key], escapeQuerySpace); - } - } - } - - return t.substring(1); - }; - URI.buildQueryParameter = function(name, value, escapeQuerySpace) { - // http://www.w3.org/TR/REC-html40/interact/forms.html#form-content-type -- application/x-www-form-urlencoded - // don't append "=" for null values, according to http://dvcs.w3.org/hg/url/raw-file/tip/Overview.html#url-parameter-serialization - return URI.encodeQuery(name, escapeQuerySpace) + (value !== null ? '=' + URI.encodeQuery(value, escapeQuerySpace) : ''); - }; - - URI.addQuery = function(data, name, value) { - if (typeof name === 'object') { - for (var key in name) { - if (hasOwn.call(name, key)) { - URI.addQuery(data, key, name[key]); - } - } - } else if (typeof name === 'string') { - if (data[name] === undefined) { - data[name] = value; - return; - } else if (typeof data[name] === 'string') { - data[name] = [data[name]]; - } - - if (!isArray(value)) { - value = [value]; - } - - data[name] = (data[name] || []).concat(value); - } else { - throw new TypeError('URI.addQuery() accepts an object, string as the name parameter'); - } - }; - URI.removeQuery = function(data, name, value) { - var i, length, key; - - if (isArray(name)) { - for (i = 0, length = name.length; i < length; i++) { - data[name[i]] = undefined; - } - } else if (getType(name) === 'RegExp') { - for (key in data) { - if (name.test(key)) { - data[key] = undefined; - } - } - } else if (typeof name === 'object') { - for (key in name) { - if (hasOwn.call(name, key)) { - URI.removeQuery(data, key, name[key]); - } - } - } else if (typeof name === 'string') { - if (value !== undefined) { - if (getType(value) === 'RegExp') { - if (!isArray(data[name]) && value.test(data[name])) { - data[name] = undefined; - } else { - data[name] = filterArrayValues(data[name], value); - } - } else if (data[name] === String(value) && (!isArray(value) || value.length === 1)) { - data[name] = undefined; - } else if (isArray(data[name])) { - data[name] = filterArrayValues(data[name], value); - } - } else { - data[name] = undefined; - } - } else { - throw new TypeError('URI.removeQuery() accepts an object, string, RegExp as the first parameter'); - } - }; - URI.hasQuery = function(data, name, value, withinArray) { - if (typeof name === 'object') { - for (var key in name) { - if (hasOwn.call(name, key)) { - if (!URI.hasQuery(data, key, name[key])) { - return false; - } - } - } - - return true; - } else if (typeof name !== 'string') { - throw new TypeError('URI.hasQuery() accepts an object, string as the name parameter'); - } - - switch (getType(value)) { - case 'Undefined': - // true if exists (but may be empty) - return name in data; // data[name] !== undefined; - - case 'Boolean': - // true if exists and non-empty - var _booly = Boolean(isArray(data[name]) ? data[name].length : data[name]); - return value === _booly; - - case 'Function': - // allow complex comparison - return !!value(data[name], name, data); - - case 'Array': - if (!isArray(data[name])) { - return false; - } - - var op = withinArray ? arrayContains : arraysEqual; - return op(data[name], value); - - case 'RegExp': - if (!isArray(data[name])) { - return Boolean(data[name] && data[name].match(value)); - } - - if (!withinArray) { - return false; - } - - return arrayContains(data[name], value); - - case 'Number': - value = String(value); - /* falls through */ - case 'String': - if (!isArray(data[name])) { - return data[name] === value; - } - - if (!withinArray) { - return false; - } - - return arrayContains(data[name], value); - - default: - throw new TypeError('URI.hasQuery() accepts undefined, boolean, string, number, RegExp, Function as the value parameter'); - } - }; - - - URI.commonPath = function(one, two) { - var length = Math.min(one.length, two.length); - var pos; - - // find first non-matching character - for (pos = 0; pos < length; pos++) { - if (one.charAt(pos) !== two.charAt(pos)) { - pos--; - break; - } - } - - if (pos < 1) { - return one.charAt(0) === two.charAt(0) && one.charAt(0) === '/' ? '/' : ''; - } - - // revert to last / - if (one.charAt(pos) !== '/' || two.charAt(pos) !== '/') { - pos = one.substring(0, pos).lastIndexOf('/'); - } - - return one.substring(0, pos + 1); - }; - - URI.withinString = function(string, callback, options) { - options || (options = {}); - var _start = options.start || URI.findUri.start; - var _end = options.end || URI.findUri.end; - var _trim = options.trim || URI.findUri.trim; - var _attributeOpen = /[a-z0-9-]=["']?$/i; - - _start.lastIndex = 0; - while (true) { - var match = _start.exec(string); - if (!match) { - break; - } - - var start = match.index; - if (options.ignoreHtml) { - // attribut(e=["']?$) - var attributeOpen = string.slice(Math.max(start - 3, 0), start); - if (attributeOpen && _attributeOpen.test(attributeOpen)) { - continue; - } - } - - var end = start + string.slice(start).search(_end); - var slice = string.slice(start, end).replace(_trim, ''); - if (options.ignore && options.ignore.test(slice)) { - continue; - } - - end = start + slice.length; - var result = callback(slice, start, end, string); - string = string.slice(0, start) + result + string.slice(end); - _start.lastIndex = start + result.length; - } - - _start.lastIndex = 0; - return string; - }; - - URI.ensureValidHostname = function(v) { - // Theoretically URIs allow percent-encoding in Hostnames (according to RFC 3986) - // they are not part of DNS and therefore ignored by URI.js - - if (v.match(URI.invalid_hostname_characters)) { - // test punycode - if (!punycode) { - throw new TypeError('Hostname "' + v + '" contains characters other than [A-Z0-9.-] and Punycode.js is not available'); - } - - if (punycode.toASCII(v).match(URI.invalid_hostname_characters)) { - throw new TypeError('Hostname "' + v + '" contains characters other than [A-Z0-9.-]'); - } - } - }; - - // noConflict - URI.noConflict = function(removeAll) { - if (removeAll) { - var unconflicted = { - URI: this.noConflict() - }; - - if (root.URITemplate && typeof root.URITemplate.noConflict === 'function') { - unconflicted.URITemplate = root.URITemplate.noConflict(); - } - - if (root.IPv6 && typeof root.IPv6.noConflict === 'function') { - unconflicted.IPv6 = root.IPv6.noConflict(); - } - - if (root.SecondLevelDomains && typeof root.SecondLevelDomains.noConflict === 'function') { - unconflicted.SecondLevelDomains = root.SecondLevelDomains.noConflict(); - } - - return unconflicted; - } else if (root.URI === this) { - root.URI = _URI; - } - - return this; - }; - - p.build = function(deferBuild) { - if (deferBuild === true) { - this._deferred_build = true; - } else if (deferBuild === undefined || this._deferred_build) { - this._string = URI.build(this._parts); - this._deferred_build = false; - } - - return this; - }; - - p.clone = function() { - return new URI(this); - }; - - p.valueOf = p.toString = function() { - return this.build(false)._string; - }; - - - function generateSimpleAccessor(_part){ - return function(v, build) { - if (v === undefined) { - return this._parts[_part] || ''; - } else { - this._parts[_part] = v || null; - this.build(!build); - return this; - } - }; - } - - function generatePrefixAccessor(_part, _key){ - return function(v, build) { - if (v === undefined) { - return this._parts[_part] || ''; - } else { - if (v !== null) { - v = v + ''; - if (v.charAt(0) === _key) { - v = v.substring(1); - } - } - - this._parts[_part] = v; - this.build(!build); - return this; - } - }; - } - - p.protocol = generateSimpleAccessor('protocol'); - p.username = generateSimpleAccessor('username'); - p.password = generateSimpleAccessor('password'); - p.hostname = generateSimpleAccessor('hostname'); - p.port = generateSimpleAccessor('port'); - p.query = generatePrefixAccessor('query', '?'); - p.fragment = generatePrefixAccessor('fragment', '#'); - - p.search = function(v, build) { - var t = this.query(v, build); - return typeof t === 'string' && t.length ? ('?' + t) : t; - }; - p.hash = function(v, build) { - var t = this.fragment(v, build); - return typeof t === 'string' && t.length ? ('#' + t) : t; - }; - - p.pathname = function(v, build) { - if (v === undefined || v === true) { - var res = this._parts.path || (this._parts.hostname ? '/' : ''); - return v ? (this._parts.urn ? URI.decodeUrnPath : URI.decodePath)(res) : res; - } else { - if (this._parts.urn) { - this._parts.path = v ? URI.recodeUrnPath(v) : ''; - } else { - this._parts.path = v ? URI.recodePath(v) : '/'; - } - this.build(!build); - return this; - } - }; - p.path = p.pathname; - p.href = function(href, build) { - var key; - - if (href === undefined) { - return this.toString(); - } - - this._string = ''; - this._parts = URI._parts(); - - var _URI = href instanceof URI; - var _object = typeof href === 'object' && (href.hostname || href.path || href.pathname); - if (href.nodeName) { - var attribute = URI.getDomAttribute(href); - href = href[attribute] || ''; - _object = false; - } - - // window.location is reported to be an object, but it's not the sort - // of object we're looking for: - // * location.protocol ends with a colon - // * location.query != object.search - // * location.hash != object.fragment - // simply serializing the unknown object should do the trick - // (for location, not for everything...) - if (!_URI && _object && href.pathname !== undefined) { - href = href.toString(); - } - - if (typeof href === 'string' || href instanceof String) { - this._parts = URI.parse(String(href), this._parts); - } else if (_URI || _object) { - var src = _URI ? href._parts : href; - for (key in src) { - if (hasOwn.call(this._parts, key)) { - this._parts[key] = src[key]; - } - } - } else { - throw new TypeError('invalid input'); - } - - this.build(!build); - return this; - }; - - // identification accessors - p.is = function(what) { - var ip = false; - var ip4 = false; - var ip6 = false; - var name = false; - var sld = false; - var idn = false; - var punycode = false; - var relative = !this._parts.urn; - - if (this._parts.hostname) { - relative = false; - ip4 = URI.ip4_expression.test(this._parts.hostname); - ip6 = URI.ip6_expression.test(this._parts.hostname); - ip = ip4 || ip6; - name = !ip; - sld = name && SLD && SLD.has(this._parts.hostname); - idn = name && URI.idn_expression.test(this._parts.hostname); - punycode = name && URI.punycode_expression.test(this._parts.hostname); - } - - switch (what.toLowerCase()) { - case 'relative': - return relative; - - case 'absolute': - return !relative; - - // hostname identification - case 'domain': - case 'name': - return name; - - case 'sld': - return sld; - - case 'ip': - return ip; - - case 'ip4': - case 'ipv4': - case 'inet4': - return ip4; - - case 'ip6': - case 'ipv6': - case 'inet6': - return ip6; - - case 'idn': - return idn; - - case 'url': - return !this._parts.urn; - - case 'urn': - return !!this._parts.urn; - - case 'punycode': - return punycode; - } - - return null; - }; - - // component specific input validation - var _protocol = p.protocol; - var _port = p.port; - var _hostname = p.hostname; - - p.protocol = function(v, build) { - if (v !== undefined) { - if (v) { - // accept trailing :// - v = v.replace(/:(\/\/)?$/, ''); - - if (!v.match(URI.protocol_expression)) { - throw new TypeError('Protocol "' + v + '" contains characters other than [A-Z0-9.+-] or doesn\'t start with [A-Z]'); - } - } - } - return _protocol.call(this, v, build); - }; - p.scheme = p.protocol; - p.port = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (v !== undefined) { - if (v === 0) { - v = null; - } - - if (v) { - v += ''; - if (v.charAt(0) === ':') { - v = v.substring(1); - } - - if (v.match(/[^0-9]/)) { - throw new TypeError('Port "' + v + '" contains characters other than [0-9]'); - } - } - } - return _port.call(this, v, build); - }; - p.hostname = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (v !== undefined) { - var x = {}; - var res = URI.parseHost(v, x); - if (res !== '/') { - throw new TypeError('Hostname "' + v + '" contains characters other than [A-Z0-9.-]'); - } - - v = x.hostname; - } - return _hostname.call(this, v, build); - }; - - // compound accessors - p.origin = function(v, build) { - var parts; - - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (v === undefined) { - var protocol = this.protocol(); - var authority = this.authority(); - if (!authority) return ''; - return (protocol ? protocol + '://' : '') + this.authority(); - } else { - var origin = URI(v); - this - .protocol(origin.protocol()) - .authority(origin.authority()) - .build(!build); - return this; - } - }; - p.host = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (v === undefined) { - return this._parts.hostname ? URI.buildHost(this._parts) : ''; - } else { - var res = URI.parseHost(v, this._parts); - if (res !== '/') { - throw new TypeError('Hostname "' + v + '" contains characters other than [A-Z0-9.-]'); - } - - this.build(!build); - return this; - } - }; - p.authority = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (v === undefined) { - return this._parts.hostname ? URI.buildAuthority(this._parts) : ''; - } else { - var res = URI.parseAuthority(v, this._parts); - if (res !== '/') { - throw new TypeError('Hostname "' + v + '" contains characters other than [A-Z0-9.-]'); - } - - this.build(!build); - return this; - } - }; - p.userinfo = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (v === undefined) { - if (!this._parts.username) { - return ''; - } - - var t = URI.buildUserinfo(this._parts); - return t.substring(0, t.length -1); - } else { - if (v[v.length-1] !== '@') { - v += '@'; - } - - URI.parseUserinfo(v, this._parts); - this.build(!build); - return this; - } - }; - p.resource = function(v, build) { - var parts; - - if (v === undefined) { - return this.path() + this.search() + this.hash(); - } - - parts = URI.parse(v); - this._parts.path = parts.path; - this._parts.query = parts.query; - this._parts.fragment = parts.fragment; - this.build(!build); - return this; - }; - - // fraction accessors - p.subdomain = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - // convenience, return "www" from "www.example.org" - if (v === undefined) { - if (!this._parts.hostname || this.is('IP')) { - return ''; - } - - // grab domain and add another segment - var end = this._parts.hostname.length - this.domain().length - 1; - return this._parts.hostname.substring(0, end) || ''; - } else { - var e = this._parts.hostname.length - this.domain().length; - var sub = this._parts.hostname.substring(0, e); - var replace = new RegExp('^' + escapeRegEx(sub)); - - if (v && v.charAt(v.length - 1) !== '.') { - v += '.'; - } - - if (v) { - URI.ensureValidHostname(v); - } - - this._parts.hostname = this._parts.hostname.replace(replace, v); - this.build(!build); - return this; - } - }; - p.domain = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (typeof v === 'boolean') { - build = v; - v = undefined; - } - - // convenience, return "example.org" from "www.example.org" - if (v === undefined) { - if (!this._parts.hostname || this.is('IP')) { - return ''; - } - - // if hostname consists of 1 or 2 segments, it must be the domain - var t = this._parts.hostname.match(/\./g); - if (t && t.length < 2) { - return this._parts.hostname; - } - - // grab tld and add another segment - var end = this._parts.hostname.length - this.tld(build).length - 1; - end = this._parts.hostname.lastIndexOf('.', end -1) + 1; - return this._parts.hostname.substring(end) || ''; - } else { - if (!v) { - throw new TypeError('cannot set domain empty'); - } - - URI.ensureValidHostname(v); - - if (!this._parts.hostname || this.is('IP')) { - this._parts.hostname = v; - } else { - var replace = new RegExp(escapeRegEx(this.domain()) + '$'); - this._parts.hostname = this._parts.hostname.replace(replace, v); - } - - this.build(!build); - return this; - } - }; - p.tld = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (typeof v === 'boolean') { - build = v; - v = undefined; - } - - // return "org" from "www.example.org" - if (v === undefined) { - if (!this._parts.hostname || this.is('IP')) { - return ''; - } - - var pos = this._parts.hostname.lastIndexOf('.'); - var tld = this._parts.hostname.substring(pos + 1); - - if (build !== true && SLD && SLD.list[tld.toLowerCase()]) { - return SLD.get(this._parts.hostname) || tld; - } - - return tld; - } else { - var replace; - - if (!v) { - throw new TypeError('cannot set TLD empty'); - } else if (v.match(/[^a-zA-Z0-9-]/)) { - if (SLD && SLD.is(v)) { - replace = new RegExp(escapeRegEx(this.tld()) + '$'); - this._parts.hostname = this._parts.hostname.replace(replace, v); - } else { - throw new TypeError('TLD "' + v + '" contains characters other than [A-Z0-9]'); - } - } else if (!this._parts.hostname || this.is('IP')) { - throw new ReferenceError('cannot set TLD on non-domain host'); - } else { - replace = new RegExp(escapeRegEx(this.tld()) + '$'); - this._parts.hostname = this._parts.hostname.replace(replace, v); - } - - this.build(!build); - return this; - } - }; - p.directory = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (v === undefined || v === true) { - if (!this._parts.path && !this._parts.hostname) { - return ''; - } - - if (this._parts.path === '/') { - return '/'; - } - - var end = this._parts.path.length - this.filename().length - 1; - var res = this._parts.path.substring(0, end) || (this._parts.hostname ? '/' : ''); - - return v ? URI.decodePath(res) : res; - - } else { - var e = this._parts.path.length - this.filename().length; - var directory = this._parts.path.substring(0, e); - var replace = new RegExp('^' + escapeRegEx(directory)); - - // fully qualifier directories begin with a slash - if (!this.is('relative')) { - if (!v) { - v = '/'; - } - - if (v.charAt(0) !== '/') { - v = '/' + v; - } - } - - // directories always end with a slash - if (v && v.charAt(v.length - 1) !== '/') { - v += '/'; - } - - v = URI.recodePath(v); - this._parts.path = this._parts.path.replace(replace, v); - this.build(!build); - return this; - } - }; - p.filename = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (v === undefined || v === true) { - if (!this._parts.path || this._parts.path === '/') { - return ''; - } - - var pos = this._parts.path.lastIndexOf('/'); - var res = this._parts.path.substring(pos+1); - - return v ? URI.decodePathSegment(res) : res; - } else { - var mutatedDirectory = false; - - if (v.charAt(0) === '/') { - v = v.substring(1); - } - - if (v.match(/\.?\//)) { - mutatedDirectory = true; - } - - var replace = new RegExp(escapeRegEx(this.filename()) + '$'); - v = URI.recodePath(v); - this._parts.path = this._parts.path.replace(replace, v); - - if (mutatedDirectory) { - this.normalizePath(build); - } else { - this.build(!build); - } - - return this; - } - }; - p.suffix = function(v, build) { - if (this._parts.urn) { - return v === undefined ? '' : this; - } - - if (v === undefined || v === true) { - if (!this._parts.path || this._parts.path === '/') { - return ''; - } - - var filename = this.filename(); - var pos = filename.lastIndexOf('.'); - var s, res; - - if (pos === -1) { - return ''; - } - - // suffix may only contain alnum characters (yup, I made this up.) - s = filename.substring(pos+1); - res = (/^[a-z0-9%]+$/i).test(s) ? s : ''; - return v ? URI.decodePathSegment(res) : res; - } else { - if (v.charAt(0) === '.') { - v = v.substring(1); - } - - var suffix = this.suffix(); - var replace; - - if (!suffix) { - if (!v) { - return this; - } - - this._parts.path += '.' + URI.recodePath(v); - } else if (!v) { - replace = new RegExp(escapeRegEx('.' + suffix) + '$'); - } else { - replace = new RegExp(escapeRegEx(suffix) + '$'); - } - - if (replace) { - v = URI.recodePath(v); - this._parts.path = this._parts.path.replace(replace, v); - } - - this.build(!build); - return this; - } - }; - p.segment = function(segment, v, build) { - var separator = this._parts.urn ? ':' : '/'; - var path = this.path(); - var absolute = path.substring(0, 1) === '/'; - var segments = path.split(separator); - - if (segment !== undefined && typeof segment !== 'number') { - build = v; - v = segment; - segment = undefined; - } - - if (segment !== undefined && typeof segment !== 'number') { - throw new Error('Bad segment "' + segment + '", must be 0-based integer'); - } - - if (absolute) { - segments.shift(); - } - - if (segment < 0) { - // allow negative indexes to address from the end - segment = Math.max(segments.length + segment, 0); - } - - if (v === undefined) { - /*jshint laxbreak: true */ - return segment === undefined - ? segments - : segments[segment]; - /*jshint laxbreak: false */ - } else if (segment === null || segments[segment] === undefined) { - if (isArray(v)) { - segments = []; - // collapse empty elements within array - for (var i=0, l=v.length; i < l; i++) { - if (!v[i].length && (!segments.length || !segments[segments.length -1].length)) { - continue; - } - - if (segments.length && !segments[segments.length -1].length) { - segments.pop(); - } - - segments.push(trimSlashes(v[i])); - } - } else if (v || typeof v === 'string') { - v = trimSlashes(v); - if (segments[segments.length -1] === '') { - // empty trailing elements have to be overwritten - // to prevent results such as /foo//bar - segments[segments.length -1] = v; - } else { - segments.push(v); - } - } - } else { - if (v) { - segments[segment] = trimSlashes(v); - } else { - segments.splice(segment, 1); - } - } - - if (absolute) { - segments.unshift(''); - } - - return this.path(segments.join(separator), build); - }; - p.segmentCoded = function(segment, v, build) { - var segments, i, l; - - if (typeof segment !== 'number') { - build = v; - v = segment; - segment = undefined; - } - - if (v === undefined) { - segments = this.segment(segment, v, build); - if (!isArray(segments)) { - segments = segments !== undefined ? URI.decode(segments) : undefined; - } else { - for (i = 0, l = segments.length; i < l; i++) { - segments[i] = URI.decode(segments[i]); - } - } - - return segments; - } - - if (!isArray(v)) { - v = (typeof v === 'string' || v instanceof String) ? URI.encode(v) : v; - } else { - for (i = 0, l = v.length; i < l; i++) { - v[i] = URI.encode(v[i]); - } - } - - return this.segment(segment, v, build); - }; - - // mutating query string - var q = p.query; - p.query = function(v, build) { - if (v === true) { - return URI.parseQuery(this._parts.query, this._parts.escapeQuerySpace); - } else if (typeof v === 'function') { - var data = URI.parseQuery(this._parts.query, this._parts.escapeQuerySpace); - var result = v.call(this, data); - this._parts.query = URI.buildQuery(result || data, this._parts.duplicateQueryParameters, this._parts.escapeQuerySpace); - this.build(!build); - return this; - } else if (v !== undefined && typeof v !== 'string') { - this._parts.query = URI.buildQuery(v, this._parts.duplicateQueryParameters, this._parts.escapeQuerySpace); - this.build(!build); - return this; - } else { - return q.call(this, v, build); - } - }; - p.setQuery = function(name, value, build) { - var data = URI.parseQuery(this._parts.query, this._parts.escapeQuerySpace); - - if (typeof name === 'string' || name instanceof String) { - data[name] = value !== undefined ? value : null; - } else if (typeof name === 'object') { - for (var key in name) { - if (hasOwn.call(name, key)) { - data[key] = name[key]; - } - } - } else { - throw new TypeError('URI.addQuery() accepts an object, string as the name parameter'); - } - - this._parts.query = URI.buildQuery(data, this._parts.duplicateQueryParameters, this._parts.escapeQuerySpace); - if (typeof name !== 'string') { - build = value; - } - - this.build(!build); - return this; - }; - p.addQuery = function(name, value, build) { - var data = URI.parseQuery(this._parts.query, this._parts.escapeQuerySpace); - URI.addQuery(data, name, value === undefined ? null : value); - this._parts.query = URI.buildQuery(data, this._parts.duplicateQueryParameters, this._parts.escapeQuerySpace); - if (typeof name !== 'string') { - build = value; - } - - this.build(!build); - return this; - }; - p.removeQuery = function(name, value, build) { - var data = URI.parseQuery(this._parts.query, this._parts.escapeQuerySpace); - URI.removeQuery(data, name, value); - this._parts.query = URI.buildQuery(data, this._parts.duplicateQueryParameters, this._parts.escapeQuerySpace); - if (typeof name !== 'string') { - build = value; - } - - this.build(!build); - return this; - }; - p.hasQuery = function(name, value, withinArray) { - var data = URI.parseQuery(this._parts.query, this._parts.escapeQuerySpace); - return URI.hasQuery(data, name, value, withinArray); - }; - p.setSearch = p.setQuery; - p.addSearch = p.addQuery; - p.removeSearch = p.removeQuery; - p.hasSearch = p.hasQuery; - - // sanitizing URLs - p.normalize = function() { - if (this._parts.urn) { - return this - .normalizeProtocol(false) - .normalizePath(false) - .normalizeQuery(false) - .normalizeFragment(false) - .build(); - } - - return this - .normalizeProtocol(false) - .normalizeHostname(false) - .normalizePort(false) - .normalizePath(false) - .normalizeQuery(false) - .normalizeFragment(false) - .build(); - }; - p.normalizeProtocol = function(build) { - if (typeof this._parts.protocol === 'string') { - this._parts.protocol = this._parts.protocol.toLowerCase(); - this.build(!build); - } - - return this; - }; - p.normalizeHostname = function(build) { - if (this._parts.hostname) { - if (this.is('IDN') && punycode) { - this._parts.hostname = punycode.toASCII(this._parts.hostname); - } else if (this.is('IPv6') && IPv6) { - this._parts.hostname = IPv6.best(this._parts.hostname); - } - - this._parts.hostname = this._parts.hostname.toLowerCase(); - this.build(!build); - } - - return this; - }; - p.normalizePort = function(build) { - // remove port of it's the protocol's default - if (typeof this._parts.protocol === 'string' && this._parts.port === URI.defaultPorts[this._parts.protocol]) { - this._parts.port = null; - this.build(!build); - } - - return this; - }; - p.normalizePath = function(build) { - var _path = this._parts.path; - if (!_path) { - return this; - } - - if (this._parts.urn) { - this._parts.path = URI.recodeUrnPath(this._parts.path); - this.build(!build); - return this; - } - - if (this._parts.path === '/') { - return this; - } - - var _was_relative; - var _leadingParents = ''; - var _parent, _pos; - - // handle relative paths - if (_path.charAt(0) !== '/') { - _was_relative = true; - _path = '/' + _path; - } - - // handle relative files (as opposed to directories) - if (_path.slice(-3) === '/..' || _path.slice(-2) === '/.') { - _path += '/'; - } - - // resolve simples - _path = _path - .replace(/(\/(\.\/)+)|(\/\.$)/g, '/') - .replace(/\/{2,}/g, '/'); - - // remember leading parents - if (_was_relative) { - _leadingParents = _path.substring(1).match(/^(\.\.\/)+/) || ''; - if (_leadingParents) { - _leadingParents = _leadingParents[0]; - } - } - - // resolve parents - while (true) { - _parent = _path.indexOf('/..'); - if (_parent === -1) { - // no more ../ to resolve - break; - } else if (_parent === 0) { - // top level cannot be relative, skip it - _path = _path.substring(3); - continue; - } - - _pos = _path.substring(0, _parent).lastIndexOf('/'); - if (_pos === -1) { - _pos = _parent; - } - _path = _path.substring(0, _pos) + _path.substring(_parent + 3); - } - - // revert to relative - if (_was_relative && this.is('relative')) { - _path = _leadingParents + _path.substring(1); - } - - _path = URI.recodePath(_path); - this._parts.path = _path; - this.build(!build); - return this; - }; - p.normalizePathname = p.normalizePath; - p.normalizeQuery = function(build) { - if (typeof this._parts.query === 'string') { - if (!this._parts.query.length) { - this._parts.query = null; - } else { - this.query(URI.parseQuery(this._parts.query, this._parts.escapeQuerySpace)); - } - - this.build(!build); - } - - return this; - }; - p.normalizeFragment = function(build) { - if (!this._parts.fragment) { - this._parts.fragment = null; - this.build(!build); - } - - return this; - }; - p.normalizeSearch = p.normalizeQuery; - p.normalizeHash = p.normalizeFragment; - - p.iso8859 = function() { - // expect unicode input, iso8859 output - var e = URI.encode; - var d = URI.decode; - - URI.encode = escape; - URI.decode = decodeURIComponent; - try { - this.normalize(); - } finally { - URI.encode = e; - URI.decode = d; - } - return this; - }; - - p.unicode = function() { - // expect iso8859 input, unicode output - var e = URI.encode; - var d = URI.decode; - - URI.encode = strictEncodeURIComponent; - URI.decode = unescape; - try { - this.normalize(); - } finally { - URI.encode = e; - URI.decode = d; - } - return this; - }; - - p.readable = function() { - var uri = this.clone(); - // removing username, password, because they shouldn't be displayed according to RFC 3986 - uri.username('').password('').normalize(); - var t = ''; - if (uri._parts.protocol) { - t += uri._parts.protocol + '://'; - } - - if (uri._parts.hostname) { - if (uri.is('punycode') && punycode) { - t += punycode.toUnicode(uri._parts.hostname); - if (uri._parts.port) { - t += ':' + uri._parts.port; - } - } else { - t += uri.host(); - } - } - - if (uri._parts.hostname && uri._parts.path && uri._parts.path.charAt(0) !== '/') { - t += '/'; - } - - t += uri.path(true); - if (uri._parts.query) { - var q = ''; - for (var i = 0, qp = uri._parts.query.split('&'), l = qp.length; i < l; i++) { - var kv = (qp[i] || '').split('='); - q += '&' + URI.decodeQuery(kv[0], this._parts.escapeQuerySpace) - .replace(/&/g, '%26'); - - if (kv[1] !== undefined) { - q += '=' + URI.decodeQuery(kv[1], this._parts.escapeQuerySpace) - .replace(/&/g, '%26'); - } - } - t += '?' + q.substring(1); - } - - t += URI.decodeQuery(uri.hash(), true); - return t; - }; - - // resolving relative and absolute URLs - p.absoluteTo = function(base) { - var resolved = this.clone(); - var properties = ['protocol', 'username', 'password', 'hostname', 'port']; - var basedir, i, p; - - if (this._parts.urn) { - throw new Error('URNs do not have any generally defined hierarchical components'); - } - - if (!(base instanceof URI)) { - base = new URI(base); - } - - if (!resolved._parts.protocol) { - resolved._parts.protocol = base._parts.protocol; - } - - if (this._parts.hostname) { - return resolved; - } - - for (i = 0; (p = properties[i]); i++) { - resolved._parts[p] = base._parts[p]; - } - - if (!resolved._parts.path) { - resolved._parts.path = base._parts.path; - if (!resolved._parts.query) { - resolved._parts.query = base._parts.query; - } - } else if (resolved._parts.path.substring(-2) === '..') { - resolved._parts.path += '/'; - } - - if (resolved.path().charAt(0) !== '/') { - basedir = base.directory(); - basedir = basedir ? basedir : base.path().indexOf('/') === 0 ? '/' : ''; - resolved._parts.path = (basedir ? (basedir + '/') : '') + resolved._parts.path; - resolved.normalizePath(); - } - - resolved.build(); - return resolved; - }; - p.relativeTo = function(base) { - var relative = this.clone().normalize(); - var relativeParts, baseParts, common, relativePath, basePath; - - if (relative._parts.urn) { - throw new Error('URNs do not have any generally defined hierarchical components'); - } - - base = new URI(base).normalize(); - relativeParts = relative._parts; - baseParts = base._parts; - relativePath = relative.path(); - basePath = base.path(); - - if (relativePath.charAt(0) !== '/') { - throw new Error('URI is already relative'); - } - - if (basePath.charAt(0) !== '/') { - throw new Error('Cannot calculate a URI relative to another relative URI'); - } - - if (relativeParts.protocol === baseParts.protocol) { - relativeParts.protocol = null; - } - - if (relativeParts.username !== baseParts.username || relativeParts.password !== baseParts.password) { - return relative.build(); - } - - if (relativeParts.protocol !== null || relativeParts.username !== null || relativeParts.password !== null) { - return relative.build(); - } - - if (relativeParts.hostname === baseParts.hostname && relativeParts.port === baseParts.port) { - relativeParts.hostname = null; - relativeParts.port = null; - } else { - return relative.build(); - } - - if (relativePath === basePath) { - relativeParts.path = ''; - return relative.build(); - } - - // determine common sub path - common = URI.commonPath(relativePath, basePath); - - // If the paths have nothing in common, return a relative URL with the absolute path. - if (!common) { - return relative.build(); - } - - var parents = baseParts.path - .substring(common.length) - .replace(/[^\/]*$/, '') - .replace(/.*?\//g, '../'); - - relativeParts.path = (parents + relativeParts.path.substring(common.length)) || './'; - - return relative.build(); - }; - - // comparing URIs - p.equals = function(uri) { - var one = this.clone(); - var two = new URI(uri); - var one_map = {}; - var two_map = {}; - var checked = {}; - var one_query, two_query, key; - - one.normalize(); - two.normalize(); - - // exact match - if (one.toString() === two.toString()) { - return true; - } - - // extract query string - one_query = one.query(); - two_query = two.query(); - one.query(''); - two.query(''); - - // definitely not equal if not even non-query parts match - if (one.toString() !== two.toString()) { - return false; - } - - // query parameters have the same length, even if they're permuted - if (one_query.length !== two_query.length) { - return false; - } - - one_map = URI.parseQuery(one_query, this._parts.escapeQuerySpace); - two_map = URI.parseQuery(two_query, this._parts.escapeQuerySpace); - - for (key in one_map) { - if (hasOwn.call(one_map, key)) { - if (!isArray(one_map[key])) { - if (one_map[key] !== two_map[key]) { - return false; - } - } else if (!arraysEqual(one_map[key], two_map[key])) { - return false; - } - - checked[key] = true; - } - } - - for (key in two_map) { - if (hasOwn.call(two_map, key)) { - if (!checked[key]) { - // two contains a parameter not present in one - return false; - } - } - } - - return true; - }; - - // state - p.duplicateQueryParameters = function(v) { - this._parts.duplicateQueryParameters = !!v; - return this; - }; - - p.escapeQuerySpace = function(v) { - this._parts.escapeQuerySpace = !!v; - return this; - }; - - return URI; -})); - -},{"./IPv6":1,"./SecondLevelDomains":2,"./punycode":4}],4:[function(require,module,exports){ -(function (global){ -/*! http://mths.be/punycode v1.2.3 by @mathias */ -;(function(root) { - - /** Detect free variables */ - var freeExports = typeof exports == 'object' && exports; - var freeModule = typeof module == 'object' && module && - module.exports == freeExports && module; - var freeGlobal = typeof global == 'object' && global; - if (freeGlobal.global === freeGlobal || freeGlobal.window === freeGlobal) { - root = freeGlobal; - } - - /** - * The `punycode` object. - * @name punycode - * @type Object - */ - var punycode, - - /** Highest positive signed 32-bit float value */ - maxInt = 2147483647, // aka. 0x7FFFFFFF or 2^31-1 - - /** Bootstring parameters */ - base = 36, - tMin = 1, - tMax = 26, - skew = 38, - damp = 700, - initialBias = 72, - initialN = 128, // 0x80 - delimiter = '-', // '\x2D' - - /** Regular expressions */ - regexPunycode = /^xn--/, - regexNonASCII = /[^ -~]/, // unprintable ASCII chars + non-ASCII chars - regexSeparators = /\x2E|\u3002|\uFF0E|\uFF61/g, // RFC 3490 separators - - /** Error messages */ - errors = { - 'overflow': 'Overflow: input needs wider integers to process', - 'not-basic': 'Illegal input >= 0x80 (not a basic code point)', - 'invalid-input': 'Invalid input' - }, - - /** Convenience shortcuts */ - baseMinusTMin = base - tMin, - floor = Math.floor, - stringFromCharCode = String.fromCharCode, - - /** Temporary variable */ - key; - - /*--------------------------------------------------------------------------*/ - - /** - * A generic error utility function. - * @private - * @param {String} type The error type. - * @returns {Error} Throws a `RangeError` with the applicable error message. - */ - function error(type) { - throw RangeError(errors[type]); - } - - /** - * A generic `Array#map` utility function. - * @private - * @param {Array} array The array to iterate over. - * @param {Function} callback The function that gets called for every array - * item. - * @returns {Array} A new array of values returned by the callback function. - */ - function map(array, fn) { - var length = array.length; - while (length--) { - array[length] = fn(array[length]); - } - return array; - } - - /** - * A simple `Array#map`-like wrapper to work with domain name strings. - * @private - * @param {String} domain The domain name. - * @param {Function} callback The function that gets called for every - * character. - * @returns {Array} A new string of characters returned by the callback - * function. - */ - function mapDomain(string, fn) { - return map(string.split(regexSeparators), fn).join('.'); - } - - /** - * 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. - * @see `punycode.ucs2.encode` - * @see - * @memberOf punycode.ucs2 - * @name decode - * @param {String} string The Unicode input string (UCS-2). - * @returns {Array} The new array of code points. - */ - function ucs2decode(string) { - var output = [], - counter = 0, - length = string.length, - value, - extra; - while (counter < length) { - value = string.charCodeAt(counter++); - if (value >= 0xD800 && value <= 0xDBFF && counter < length) { - // high surrogate, and there is a next character - extra = string.charCodeAt(counter++); - if ((extra & 0xFC00) == 0xDC00) { // low surrogate - output.push(((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 - output.push(value); - counter--; - } - } else { - output.push(value); - } - } - return output; - } - - /** - * Creates a string based on an array of numeric code points. - * @see `punycode.ucs2.decode` - * @memberOf punycode.ucs2 - * @name encode - * @param {Array} codePoints The array of numeric code points. - * @returns {String} The new Unicode string (UCS-2). - */ - function ucs2encode(array) { - return map(array, function(value) { - var output = ''; - if (value > 0xFFFF) { - value -= 0x10000; - output += stringFromCharCode(value >>> 10 & 0x3FF | 0xD800); - value = 0xDC00 | value & 0x3FF; - } - output += stringFromCharCode(value); - return output; - }).join(''); - } - - /** - * Converts a basic code point into a digit/integer. - * @see `digitToBasic()` - * @private - * @param {Number} codePoint The basic numeric code point value. - * @returns {Number} The numeric value of a basic code point (for use in - * representing integers) in the range `0` to `base - 1`, or `base` if - * the code point does not represent a value. - */ - function basicToDigit(codePoint) { - if (codePoint - 48 < 10) { - return codePoint - 22; - } - if (codePoint - 65 < 26) { - return codePoint - 65; - } - if (codePoint - 97 < 26) { - return codePoint - 97; - } - return base; - } - - /** - * Converts a digit/integer into a basic code point. - * @see `basicToDigit()` - * @private - * @param {Number} digit The numeric value of a basic code point. - * @returns {Number} The basic code point whose value (when used for - * representing integers) is `digit`, which needs to be in the range - * `0` to `base - 1`. If `flag` is non-zero, the uppercase form is - * used; else, the lowercase form is used. The behavior is undefined - * if `flag` is non-zero and `digit` has no uppercase form. - */ - function digitToBasic(digit, flag) { - // 0..25 map to ASCII a..z or A..Z - // 26..35 map to ASCII 0..9 - return digit + 22 + 75 * (digit < 26) - ((flag != 0) << 5); - } - - /** - * Bias adaptation function as per section 3.4 of RFC 3492. - * http://tools.ietf.org/html/rfc3492#section-3.4 - * @private - */ - function adapt(delta, numPoints, firstTime) { - var k = 0; - delta = firstTime ? floor(delta / damp) : delta >> 1; - delta += floor(delta / numPoints); - for (/* no initialization */; delta > baseMinusTMin * tMax >> 1; k += base) { - delta = floor(delta / baseMinusTMin); - } - return floor(k + (baseMinusTMin + 1) * delta / (delta + skew)); - } - - /** - * Converts a Punycode string of ASCII-only symbols to a string of Unicode - * symbols. - * @memberOf punycode - * @param {String} input The Punycode string of ASCII-only symbols. - * @returns {String} The resulting string of Unicode symbols. - */ - function decode(input) { - // Don't use UCS-2 - var output = [], - inputLength = input.length, - out, - i = 0, - n = initialN, - bias = initialBias, - basic, - j, - index, - oldi, - w, - k, - digit, - t, - length, - /** Cached calculation results */ - baseMinusT; - - // Handle the basic code points: let `basic` be the number of input code - // points before the last delimiter, or `0` if there is none, then copy - // the first basic code points to the output. - - basic = input.lastIndexOf(delimiter); - if (basic < 0) { - basic = 0; - } - - for (j = 0; j < basic; ++j) { - // if it's not a basic code point - if (input.charCodeAt(j) >= 0x80) { - error('not-basic'); - } - output.push(input.charCodeAt(j)); - } - - // Main decoding loop: start just after the last delimiter if any basic code - // points were copied; start at the beginning otherwise. - - for (index = basic > 0 ? basic + 1 : 0; index < inputLength; /* no final expression */) { - - // `index` is the index of the next character to be consumed. - // Decode a generalized variable-length integer into `delta`, - // which gets added to `i`. The overflow checking is easier - // if we increase `i` as we go, then subtract off its starting - // value at the end to obtain `delta`. - for (oldi = i, w = 1, k = base; /* no condition */; k += base) { - - if (index >= inputLength) { - error('invalid-input'); - } - - digit = basicToDigit(input.charCodeAt(index++)); - - if (digit >= base || digit > floor((maxInt - i) / w)) { - error('overflow'); - } - - i += digit * w; - t = k <= bias ? tMin : (k >= bias + tMax ? tMax : k - bias); - - if (digit < t) { - break; - } - - baseMinusT = base - t; - if (w > floor(maxInt / baseMinusT)) { - error('overflow'); - } - - w *= baseMinusT; - - } - - out = output.length + 1; - bias = adapt(i - oldi, out, oldi == 0); - - // `i` was supposed to wrap around from `out` to `0`, - // incrementing `n` each time, so we'll fix that now: - if (floor(i / out) > maxInt - n) { - error('overflow'); - } - - n += floor(i / out); - i %= out; - - // Insert `n` at position `i` of the output - output.splice(i++, 0, n); - - } - - return ucs2encode(output); - } - - /** - * Converts a string of Unicode symbols to a Punycode string of ASCII-only - * symbols. - * @memberOf punycode - * @param {String} input The string of Unicode symbols. - * @returns {String} The resulting Punycode string of ASCII-only symbols. - */ - function encode(input) { - var n, - delta, - handledCPCount, - basicLength, - bias, - j, - m, - q, - k, - t, - currentValue, - output = [], - /** `inputLength` will hold the number of code points in `input`. */ - inputLength, - /** Cached calculation results */ - handledCPCountPlusOne, - baseMinusT, - qMinusT; - - // Convert the input in UCS-2 to Unicode - input = ucs2decode(input); - - // Cache the length - inputLength = input.length; - - // Initialize the state - n = initialN; - delta = 0; - bias = initialBias; - - // Handle the basic code points - for (j = 0; j < inputLength; ++j) { - currentValue = input[j]; - if (currentValue < 0x80) { - output.push(stringFromCharCode(currentValue)); - } - } - - handledCPCount = basicLength = output.length; - - // `handledCPCount` is the number of code points that have been handled; - // `basicLength` is the number of basic code points. - - // Finish the basic string - if it is not empty - with a delimiter - if (basicLength) { - output.push(delimiter); - } - - // Main encoding loop: - while (handledCPCount < inputLength) { - - // All non-basic code points < n have been handled already. Find the next - // larger one: - for (m = maxInt, j = 0; j < inputLength; ++j) { - currentValue = input[j]; - if (currentValue >= n && currentValue < m) { - m = currentValue; - } - } - - // Increase `delta` enough to advance the decoder's state to , - // but guard against overflow - handledCPCountPlusOne = handledCPCount + 1; - if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) { - error('overflow'); - } - - delta += (m - n) * handledCPCountPlusOne; - n = m; - - for (j = 0; j < inputLength; ++j) { - currentValue = input[j]; - - if (currentValue < n && ++delta > maxInt) { - error('overflow'); - } - - if (currentValue == n) { - // Represent delta as a generalized variable-length integer - for (q = delta, k = base; /* no condition */; k += base) { - t = k <= bias ? tMin : (k >= bias + tMax ? tMax : k - bias); - if (q < t) { - break; - } - qMinusT = q - t; - baseMinusT = base - t; - output.push( - stringFromCharCode(digitToBasic(t + qMinusT % baseMinusT, 0)) - ); - q = floor(qMinusT / baseMinusT); - } - - output.push(stringFromCharCode(digitToBasic(q, 0))); - bias = adapt(delta, handledCPCountPlusOne, handledCPCount == basicLength); - delta = 0; - ++handledCPCount; - } - } - - ++delta; - ++n; - - } - return output.join(''); - } - - /** - * Converts a Punycode string representing a domain name to Unicode. Only the - * Punycoded parts of the domain name will be converted, i.e. it doesn't - * matter if you call it on a string that has already been converted to - * Unicode. - * @memberOf punycode - * @param {String} domain The Punycode domain name to convert to Unicode. - * @returns {String} The Unicode representation of the given Punycode - * string. - */ - function toUnicode(domain) { - return mapDomain(domain, function(string) { - return regexPunycode.test(string) - ? decode(string.slice(4).toLowerCase()) - : string; - }); - } - - /** - * Converts a Unicode string representing a domain name to Punycode. Only the - * non-ASCII parts of the domain name will be converted, i.e. it doesn't - * matter if you call it with a domain that's already in ASCII. - * @memberOf punycode - * @param {String} domain The domain name to convert, as a Unicode string. - * @returns {String} The Punycode representation of the given domain name. - */ - function toASCII(domain) { - return mapDomain(domain, function(string) { - return regexNonASCII.test(string) - ? 'xn--' + encode(string) - : string; - }); - } - - /*--------------------------------------------------------------------------*/ - - /** Define the public API */ - punycode = { - /** - * A string representing the current Punycode.js version number. - * @memberOf punycode - * @type String - */ - 'version': '1.2.3', - /** - * An object of methods to convert from JavaScript's internal character - * representation (UCS-2) to Unicode code points, and back. - * @see - * @memberOf punycode - * @type Object - */ - 'ucs2': { - 'decode': ucs2decode, - 'encode': ucs2encode - }, - 'decode': decode, - 'encode': encode, - 'toASCII': toASCII, - 'toUnicode': toUnicode - }; - - /** Expose `punycode` */ - // Some AMD build optimizers, like r.js, check for specific condition patterns - // like the following: - if ( - typeof define == 'function' && - typeof define.amd == 'object' && - define.amd - ) { - define(function() { - return punycode; - }); - } else if (freeExports && !freeExports.nodeType) { - if (freeModule) { // in Node.js or RingoJS v0.8.0+ - freeModule.exports = punycode; - } else { // in Narwhal or RingoJS v0.7.0- - for (key in punycode) { - punycode.hasOwnProperty(key) && (freeExports[key] = punycode[key]); - } - } - } else { // in Rhino or a web browser - root.punycode = punycode; - } - -}(this)); - -}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) - -},{}],5:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -var _helpersEvent = require('./helpers/event'); - -var _helpersEvent2 = _interopRequireDefault(_helpersEvent); - -var _helpersMessageEvent = require('./helpers/message-event'); - -var _helpersMessageEvent2 = _interopRequireDefault(_helpersMessageEvent); - -var _helpersCloseEvent = require('./helpers/close-event'); - -var _helpersCloseEvent2 = _interopRequireDefault(_helpersCloseEvent); - -/* -* Creates an Event object and extends it to allow full modification of -* its properties. -* -* @param {object} config - within config you will need to pass type and optionally target -*/ -function createEvent(config) { - var type = config.type; - var target = config.target; - - var eventObject = new _helpersEvent2['default'](type); - - if (target) { - eventObject.target = target; - eventObject.srcElement = target; - eventObject.currentTarget = target; - } - - return eventObject; -} - -/* -* Creates a MessageEvent object and extends it to allow full modification of -* its properties. -* -* @param {object} config - within config you will need to pass type, origin, data and optionally target -*/ -function createMessageEvent(config) { - var type = config.type; - var origin = config.origin; - var data = config.data; - var target = config.target; - - var messageEvent = new _helpersMessageEvent2['default'](type, { - data: data, - origin: origin - }); - - if (target) { - messageEvent.target = target; - messageEvent.srcElement = target; - messageEvent.currentTarget = target; - } - - return messageEvent; -} - -/* -* Creates a CloseEvent object and extends it to allow full modification of -* its properties. -* -* @param {object} config - within config you will need to pass type and optionally target, code, and reason -*/ -function createCloseEvent(config) { - var code = config.code; - var reason = config.reason; - var type = config.type; - var target = config.target; - var wasClean = config.wasClean; - - if (!wasClean) { - wasClean = code === 1000; - } - - var closeEvent = new _helpersCloseEvent2['default'](type, { - code: code, - reason: reason, - wasClean: wasClean - }); - - if (target) { - closeEvent.target = target; - closeEvent.srcElement = target; - closeEvent.currentTarget = target; - } - - return closeEvent; -} - -exports.createEvent = createEvent; -exports.createMessageEvent = createMessageEvent; -exports.createCloseEvent = createCloseEvent; -},{"./helpers/close-event":9,"./helpers/event":12,"./helpers/message-event":13}],6:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _createClass = (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, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -var _helpersArrayHelpers = require('./helpers/array-helpers'); - -/* -* EventTarget is an interface implemented by objects that can -* receive events and may have listeners for them. -* -* https://developer.mozilla.org/en-US/docs/Web/API/EventTarget -*/ - -var EventTarget = (function () { - function EventTarget() { - _classCallCheck(this, EventTarget); - - this.listeners = {}; - } - - /* - * Ties a listener function to a event type which can later be invoked via the - * dispatchEvent method. - * - * @param {string} type - the type of event (ie: 'open', 'message', etc.) - * @param {function} listener - the callback function to invoke whenever a event is dispatched matching the given type - * @param {boolean} useCapture - N/A TODO: implement useCapture functionality - */ - - _createClass(EventTarget, [{ - key: 'addEventListener', - value: function addEventListener(type, listener /* , useCapture */) { - if (typeof listener === 'function') { - if (!Array.isArray(this.listeners[type])) { - this.listeners[type] = []; - } - - // Only add the same function once - if ((0, _helpersArrayHelpers.filter)(this.listeners[type], function (item) { - return item === listener; - }).length === 0) { - this.listeners[type].push(listener); - } - } - } - - /* - * Removes the listener so it will no longer be invoked via the dispatchEvent method. - * - * @param {string} type - the type of event (ie: 'open', 'message', etc.) - * @param {function} listener - the callback function to invoke whenever a event is dispatched matching the given type - * @param {boolean} useCapture - N/A TODO: implement useCapture functionality - */ - }, { - key: 'removeEventListener', - value: function removeEventListener(type, removingListener /* , useCapture */) { - var arrayOfListeners = this.listeners[type]; - this.listeners[type] = (0, _helpersArrayHelpers.reject)(arrayOfListeners, function (listener) { - return listener === removingListener; - }); - } - - /* - * Invokes all listener functions that are listening to the given event.type property. Each - * listener will be passed the event as the first argument. - * - * @param {object} event - event object which will be passed to all listeners of the event.type property - */ - }, { - key: 'dispatchEvent', - value: function dispatchEvent(event) { - var _this = this; - - for (var _len = arguments.length, customArguments = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { - customArguments[_key - 1] = arguments[_key]; - } - - var eventName = event.type; - var listeners = this.listeners[eventName]; - - if (!Array.isArray(listeners)) { - return false; - } - - listeners.forEach(function (listener) { - if (customArguments.length > 0) { - listener.apply(_this, customArguments); - } else { - listener.call(_this, event); - } - }); - } - }]); - - return EventTarget; -})(); - -exports['default'] = EventTarget; -module.exports = exports['default']; -},{"./helpers/array-helpers":7}],7:[function(require,module,exports){ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.reject = reject; -exports.filter = filter; - -function reject(array, callback) { - var results = []; - array.forEach(function (itemInArray) { - if (!callback(itemInArray)) { - results.push(itemInArray); - } - }); - - return results; -} - -function filter(array, callback) { - var results = []; - array.forEach(function (itemInArray) { - if (callback(itemInArray)) { - results.push(itemInArray); - } - }); - - return results; -} -},{}],8:[function(require,module,exports){ -/* -* https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent -*/ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -var codes = { - CLOSE_NORMAL: 1000, - CLOSE_GOING_AWAY: 1001, - CLOSE_PROTOCOL_ERROR: 1002, - CLOSE_UNSUPPORTED: 1003, - CLOSE_NO_STATUS: 1005, - CLOSE_ABNORMAL: 1006, - CLOSE_TOO_LARGE: 1009 -}; - -exports["default"] = codes; -module.exports = exports["default"]; -},{}],9:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _get = function get(_x2, _x3, _x4) { var _again = true; _function: while (_again) { var object = _x2, property = _x3, receiver = _x4; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x2 = parent; _x3 = property; _x4 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } }; - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } - -var _eventPrototype = require('./event-prototype'); - -var _eventPrototype2 = _interopRequireDefault(_eventPrototype); - -var CloseEvent = (function (_EventPrototype) { - _inherits(CloseEvent, _EventPrototype); - - function CloseEvent(type) { - var eventInitConfig = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; - - _classCallCheck(this, CloseEvent); - - _get(Object.getPrototypeOf(CloseEvent.prototype), 'constructor', this).call(this); - - if (!type) { - throw new TypeError('Failed to construct \'CloseEvent\': 1 argument required, but only 0 present.'); - } - - if (typeof eventInitConfig !== 'object') { - throw new TypeError('Failed to construct \'CloseEvent\': parameter 2 (\'eventInitDict\') is not an object'); - } - - var bubbles = eventInitConfig.bubbles; - var cancelable = eventInitConfig.cancelable; - var code = eventInitConfig.code; - var reason = eventInitConfig.reason; - var wasClean = eventInitConfig.wasClean; - - this.type = String(type); - this.timeStamp = Date.now(); - this.target = null; - this.srcElement = null; - this.returnValue = true; - this.isTrusted = false; - this.eventPhase = 0; - this.defaultPrevented = false; - this.currentTarget = null; - this.cancelable = cancelable ? Boolean(cancelable) : false; - this.canncelBubble = false; - this.bubbles = bubbles ? Boolean(bubbles) : false; - this.code = typeof code === 'number' ? Number(code) : 0; - this.reason = reason ? String(reason) : ''; - this.wasClean = wasClean ? Boolean(wasClean) : false; - } - - return CloseEvent; -})(_eventPrototype2['default']); - -exports['default'] = CloseEvent; -module.exports = exports['default']; -},{"./event-prototype":11}],10:[function(require,module,exports){ -/* -* This delay allows the thread to finish assigning its on* methods -* before invoking the delay callback. This is purely a timing hack. -* http://geekabyte.blogspot.com/2014/01/javascript-effect-of-setting-settimeout.html -* -* @param {callback: function} the callback which will be invoked after the timeout -* @parma {context: object} the context in which to invoke the function -*/ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -function delay(callback, context) { - setTimeout(function timeout(timeoutContext) { - callback.call(timeoutContext); - }, 4, context); -} - -exports["default"] = delay; -module.exports = exports["default"]; -},{}],11:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _createClass = (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, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -var EventPrototype = (function () { - function EventPrototype() { - _classCallCheck(this, EventPrototype); - } - - _createClass(EventPrototype, [{ - key: 'stopPropagation', - - // Noops - value: function stopPropagation() {} - }, { - key: 'stopImmediatePropagation', - value: function stopImmediatePropagation() {} - - // if no arguments are passed then the type is set to "undefined" on - // chrome and safari. - }, { - key: 'initEvent', - value: function initEvent() { - var type = arguments.length <= 0 || arguments[0] === undefined ? 'undefined' : arguments[0]; - var bubbles = arguments.length <= 1 || arguments[1] === undefined ? false : arguments[1]; - var cancelable = arguments.length <= 2 || arguments[2] === undefined ? false : arguments[2]; - - Object.assign(this, { - type: String(type), - bubbles: Boolean(bubbles), - cancelable: Boolean(cancelable) - }); - } - }]); - - return EventPrototype; -})(); - -exports['default'] = EventPrototype; -module.exports = exports['default']; -},{}],12:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _get = function get(_x2, _x3, _x4) { var _again = true; _function: while (_again) { var object = _x2, property = _x3, receiver = _x4; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x2 = parent; _x3 = property; _x4 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } }; - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } - -var _eventPrototype = require('./event-prototype'); - -var _eventPrototype2 = _interopRequireDefault(_eventPrototype); - -var Event = (function (_EventPrototype) { - _inherits(Event, _EventPrototype); - - function Event(type) { - var eventInitConfig = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; - - _classCallCheck(this, Event); - - _get(Object.getPrototypeOf(Event.prototype), 'constructor', this).call(this); - - if (!type) { - throw new TypeError('Failed to construct \'Event\': 1 argument required, but only 0 present.'); - } - - if (typeof eventInitConfig !== 'object') { - throw new TypeError('Failed to construct \'Event\': parameter 2 (\'eventInitDict\') is not an object'); - } - - var bubbles = eventInitConfig.bubbles; - var cancelable = eventInitConfig.cancelable; - - this.type = String(type); - this.timeStamp = Date.now(); - this.target = null; - this.srcElement = null; - this.returnValue = true; - this.isTrusted = false; - this.eventPhase = 0; - this.defaultPrevented = false; - this.currentTarget = null; - this.cancelable = cancelable ? Boolean(cancelable) : false; - this.canncelBubble = false; - this.bubbles = bubbles ? Boolean(bubbles) : false; - } - - return Event; -})(_eventPrototype2['default']); - -exports['default'] = Event; -module.exports = exports['default']; -},{"./event-prototype":11}],13:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _get = function get(_x2, _x3, _x4) { var _again = true; _function: while (_again) { var object = _x2, property = _x3, receiver = _x4; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x2 = parent; _x3 = property; _x4 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } }; - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } - -var _eventPrototype = require('./event-prototype'); - -var _eventPrototype2 = _interopRequireDefault(_eventPrototype); - -var MessageEvent = (function (_EventPrototype) { - _inherits(MessageEvent, _EventPrototype); - - function MessageEvent(type) { - var eventInitConfig = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; - - _classCallCheck(this, MessageEvent); - - _get(Object.getPrototypeOf(MessageEvent.prototype), 'constructor', this).call(this); - - if (!type) { - throw new TypeError('Failed to construct \'MessageEvent\': 1 argument required, but only 0 present.'); - } - - if (typeof eventInitConfig !== 'object') { - throw new TypeError('Failed to construct \'MessageEvent\': parameter 2 (\'eventInitDict\') is not an object'); - } - - var bubbles = eventInitConfig.bubbles; - var cancelable = eventInitConfig.cancelable; - var data = eventInitConfig.data; - var origin = eventInitConfig.origin; - var lastEventId = eventInitConfig.lastEventId; - var ports = eventInitConfig.ports; - - this.type = String(type); - this.timeStamp = Date.now(); - this.target = null; - this.srcElement = null; - this.returnValue = true; - this.isTrusted = false; - this.eventPhase = 0; - this.defaultPrevented = false; - this.currentTarget = null; - this.cancelable = cancelable ? Boolean(cancelable) : false; - this.canncelBubble = false; - this.bubbles = bubbles ? Boolean(bubbles) : false; - this.origin = origin ? String(origin) : ''; - this.ports = typeof ports === 'undefined' ? null : ports; - this.data = typeof data === 'undefined' ? null : data; - this.lastEventId = lastEventId ? String(lastEventId) : ''; - } - - return MessageEvent; -})(_eventPrototype2['default']); - -exports['default'] = MessageEvent; -module.exports = exports['default']; -},{"./event-prototype":11}],14:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -var _server = require('./server'); - -var _server2 = _interopRequireDefault(_server); - -var _socketIo = require('./socket-io'); - -var _socketIo2 = _interopRequireDefault(_socketIo); - -var _websocket = require('./websocket'); - -var _websocket2 = _interopRequireDefault(_websocket); - -if (typeof window !== 'undefined') { - window.MockServer = _server2['default']; - window.MockWebSocket = _websocket2['default']; - window.MockSocketIO = _socketIo2['default']; -} - -var Server = _server2['default']; -exports.Server = Server; -var WebSocket = _websocket2['default']; -exports.WebSocket = WebSocket; -var SocketIO = _socketIo2['default']; -exports.SocketIO = SocketIO; -},{"./server":16,"./socket-io":17,"./websocket":18}],15:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _createClass = (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, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -var _helpersArrayHelpers = require('./helpers/array-helpers'); - -/* -* The network bridge is a way for the mock websocket object to 'communicate' with -* all avalible servers. This is a singleton object so it is important that you -* clean up urlMap whenever you are finished. -*/ - -var NetworkBridge = (function () { - function NetworkBridge() { - _classCallCheck(this, NetworkBridge); - - this.urlMap = {}; - } - - /* - * Attaches a websocket object to the urlMap hash so that it can find the server - * it is connected to and the server in turn can find it. - * - * @param {object} websocket - websocket object to add to the urlMap hash - * @param {string} url - */ - - _createClass(NetworkBridge, [{ - key: 'attachWebSocket', - value: function attachWebSocket(websocket, url) { - var connectionLookup = this.urlMap[url]; - - if (connectionLookup && connectionLookup.server && connectionLookup.websockets.indexOf(websocket) === -1) { - connectionLookup.websockets.push(websocket); - return connectionLookup.server; - } - } - - /* - * Attaches a websocket to a room - */ - }, { - key: 'addMembershipToRoom', - value: function addMembershipToRoom(websocket, room) { - var connectionLookup = this.urlMap[websocket.url]; - - if (connectionLookup && connectionLookup.server && connectionLookup.websockets.indexOf(websocket) !== -1) { - if (!connectionLookup.roomMemberships[room]) { - connectionLookup.roomMemberships[room] = []; - } - - connectionLookup.roomMemberships[room].push(websocket); - } - } - - /* - * Attaches a server object to the urlMap hash so that it can find a websockets - * which are connected to it and so that websockets can in turn can find it. - * - * @param {object} server - server object to add to the urlMap hash - * @param {string} url - */ - }, { - key: 'attachServer', - value: function attachServer(server, url) { - var connectionLookup = this.urlMap[url]; - - if (!connectionLookup) { - this.urlMap[url] = { - server: server, - websockets: [], - roomMemberships: {} - }; - - return server; - } - } - - /* - * Finds the server which is 'running' on the given url. - * - * @param {string} url - the url to use to find which server is running on it - */ - }, { - key: 'serverLookup', - value: function serverLookup(url) { - var connectionLookup = this.urlMap[url]; - - if (connectionLookup) { - return connectionLookup.server; - } - } - - /* - * Finds all websockets which is 'listening' on the given url. - * - * @param {string} url - the url to use to find all websockets which are associated with it - * @param {string} room - if a room is provided, will only return sockets in this room - */ - }, { - key: 'websocketsLookup', - value: function websocketsLookup(url, room) { - var connectionLookup = this.urlMap[url]; - - if (!connectionLookup) { - return []; - } - - if (room) { - var members = connectionLookup.roomMemberships[room]; - return members ? members : []; - } - - return connectionLookup.websockets; - } - - /* - * Removes the entry associated with the url. - * - * @param {string} url - */ - }, { - key: 'removeServer', - value: function removeServer(url) { - delete this.urlMap[url]; - } - - /* - * Removes the individual websocket from the map of associated websockets. - * - * @param {object} websocket - websocket object to remove from the url map - * @param {string} url - */ - }, { - key: 'removeWebSocket', - value: function removeWebSocket(websocket, url) { - var connectionLookup = this.urlMap[url]; - - if (connectionLookup) { - connectionLookup.websockets = (0, _helpersArrayHelpers.reject)(connectionLookup.websockets, function (socket) { - return socket === websocket; - }); - } - } - - /* - * Removes a websocket from a room - */ - }, { - key: 'removeMembershipFromRoom', - value: function removeMembershipFromRoom(websocket, room) { - var connectionLookup = this.urlMap[websocket.url]; - var memberships = connectionLookup.roomMemberships[room]; - - if (connectionLookup && memberships !== null) { - connectionLookup.roomMemberships[room] = (0, _helpersArrayHelpers.reject)(memberships, function (socket) { - return socket === websocket; - }); - } - } - }]); - - return NetworkBridge; -})(); - -exports['default'] = new NetworkBridge(); -// Note: this is a singleton -module.exports = exports['default']; -},{"./helpers/array-helpers":7}],16:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _createClass = (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, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); - -var _get = function get(_x4, _x5, _x6) { var _again = true; _function: while (_again) { var object = _x4, property = _x5, receiver = _x6; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x4 = parent; _x5 = property; _x6 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } }; - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } - -var _urijs = require('urijs'); - -var _urijs2 = _interopRequireDefault(_urijs); - -var _websocket = require('./websocket'); - -var _websocket2 = _interopRequireDefault(_websocket); - -var _eventTarget = require('./event-target'); - -var _eventTarget2 = _interopRequireDefault(_eventTarget); - -var _networkBridge = require('./network-bridge'); - -var _networkBridge2 = _interopRequireDefault(_networkBridge); - -var _helpersCloseCodes = require('./helpers/close-codes'); - -var _helpersCloseCodes2 = _interopRequireDefault(_helpersCloseCodes); - -var _eventFactory = require('./event-factory'); - -/* -* https://github.com/websockets/ws#server-example -*/ - -var Server = (function (_EventTarget) { - _inherits(Server, _EventTarget); - - /* - * @param {string} url - */ - - function Server(url) { - _classCallCheck(this, Server); - - _get(Object.getPrototypeOf(Server.prototype), 'constructor', this).call(this); - this.url = (0, _urijs2['default'])(url).toString(); - var server = _networkBridge2['default'].attachServer(this, this.url); - - if (!server) { - this.dispatchEvent((0, _eventFactory.createEvent)({ type: 'error' })); - throw new Error('A mock server is already listening on this url'); - } - } - - /* - * Alternative constructor to support namespaces in socket.io - * - * http://socket.io/docs/rooms-and-namespaces/#custom-namespaces - */ - - /* - * This is the main function for the mock server to subscribe to the on events. - * - * ie: mockServer.on('connection', function() { console.log('a mock client connected'); }); - * - * @param {string} type - The event key to subscribe to. Valid keys are: connection, message, and close. - * @param {function} callback - The callback which should be called when a certain event is fired. - */ - - _createClass(Server, [{ - key: 'on', - value: function on(type, callback) { - this.addEventListener(type, callback); - } - - /* - * This send function will notify all mock clients via their onmessage callbacks that the server - * has a message for them. - * - * @param {*} data - Any javascript object which will be crafted into a MessageObject. - */ - }, { - key: 'send', - value: function send(data) { - var options = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; - - this.emit('message', data, options); - } - - /* - * Sends a generic message event to all mock clients. - */ - }, { - key: 'emit', - value: function emit(event, data) { - var _this2 = this; - - var options = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2]; - var websockets = options.websockets; - - if (!websockets) { - websockets = _networkBridge2['default'].websocketsLookup(this.url); - } - - websockets.forEach(function (socket) { - socket.dispatchEvent((0, _eventFactory.createMessageEvent)({ - type: event, - data: data, - origin: _this2.url, - target: socket - })); - }); - } - - /* - * Closes the connection and triggers the onclose method of all listening - * websockets. After that it removes itself from the urlMap so another server - * could add itself to the url. - * - * @param {object} options - */ - }, { - key: 'close', - value: function close() { - var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; - var code = options.code; - var reason = options.reason; - var wasClean = options.wasClean; - - var listeners = _networkBridge2['default'].websocketsLookup(this.url); - - listeners.forEach(function (socket) { - socket.readyState = _websocket2['default'].CLOSE; - socket.dispatchEvent((0, _eventFactory.createCloseEvent)({ - type: 'close', - target: socket, - code: code || _helpersCloseCodes2['default'].CLOSE_NORMAL, - reason: reason || '', - wasClean: wasClean - })); - }); - - this.dispatchEvent((0, _eventFactory.createCloseEvent)({ type: 'close' }), this); - _networkBridge2['default'].removeServer(this.url); - } - - /* - * Returns an array of websockets which are listening to this server - */ - }, { - key: 'clients', - value: function clients() { - return _networkBridge2['default'].websocketsLookup(this.url); - } - - /* - * Prepares a method to submit an event to members of the room - * - * e.g. server.to('my-room').emit('hi!'); - */ - }, { - key: 'to', - value: function to(room) { - var _this = this; - var websockets = _networkBridge2['default'].websocketsLookup(this.url, room); - return { - emit: function emit(event, data) { - _this.emit(event, data, { websockets: websockets }); - } - }; - } - }]); - - return Server; -})(_eventTarget2['default']); - -Server.of = function of(url) { - return new Server(url); -}; - -exports['default'] = Server; -module.exports = exports['default']; -},{"./event-factory":5,"./event-target":6,"./helpers/close-codes":8,"./network-bridge":15,"./websocket":18,"urijs":3}],17:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _createClass = (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, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); - -var _get = function get(_x3, _x4, _x5) { var _again = true; _function: while (_again) { var object = _x3, property = _x4, receiver = _x5; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x3 = parent; _x4 = property; _x5 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } }; - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } - -var _urijs = require('urijs'); - -var _urijs2 = _interopRequireDefault(_urijs); - -var _helpersDelay = require('./helpers/delay'); - -var _helpersDelay2 = _interopRequireDefault(_helpersDelay); - -var _eventTarget = require('./event-target'); - -var _eventTarget2 = _interopRequireDefault(_eventTarget); - -var _networkBridge = require('./network-bridge'); - -var _networkBridge2 = _interopRequireDefault(_networkBridge); - -var _helpersCloseCodes = require('./helpers/close-codes'); - -var _helpersCloseCodes2 = _interopRequireDefault(_helpersCloseCodes); - -var _eventFactory = require('./event-factory'); - -/* -* The socket-io class is designed to mimick the real API as closely as possible. -* -* http://socket.io/docs/ -*/ - -var SocketIO = (function (_EventTarget) { - _inherits(SocketIO, _EventTarget); - - /* - * @param {string} url - */ - - function SocketIO() { - var _this = this; - - var url = arguments.length <= 0 || arguments[0] === undefined ? 'socket.io' : arguments[0]; - var protocol = arguments.length <= 1 || arguments[1] === undefined ? '' : arguments[1]; - - _classCallCheck(this, SocketIO); - - _get(Object.getPrototypeOf(SocketIO.prototype), 'constructor', this).call(this); - - this.binaryType = 'blob'; - this.url = (0, _urijs2['default'])(url).toString(); - this.readyState = SocketIO.CONNECTING; - this.protocol = ''; - - if (typeof protocol === 'string') { - this.protocol = protocol; - } else if (Array.isArray(protocol) && protocol.length > 0) { - this.protocol = protocol[0]; - } - - var server = _networkBridge2['default'].attachWebSocket(this, this.url); - - /* - * Delay triggering the connection events so they can be defined in time. - */ - (0, _helpersDelay2['default'])(function delayCallback() { - if (server) { - this.readyState = SocketIO.OPEN; - server.dispatchEvent((0, _eventFactory.createEvent)({ type: 'connection' }), server, this); - server.dispatchEvent((0, _eventFactory.createEvent)({ type: 'connect' }), server, this); // alias - this.dispatchEvent((0, _eventFactory.createEvent)({ type: 'connect', target: this })); - } else { - this.readyState = SocketIO.CLOSED; - this.dispatchEvent((0, _eventFactory.createEvent)({ type: 'error', target: this })); - this.dispatchEvent((0, _eventFactory.createCloseEvent)({ - type: 'close', - target: this, - code: _helpersCloseCodes2['default'].CLOSE_NORMAL - })); - - console.error('Socket.io connection to \'' + this.url + '\' failed'); - } - }, this); - - /** - Add an aliased event listener for close / disconnect - */ - this.addEventListener('close', function (event) { - _this.dispatchEvent((0, _eventFactory.createCloseEvent)({ - type: 'disconnect', - target: event.target, - code: event.code - })); - }); - } - - /* - * Closes the SocketIO connection or connection attempt, if any. - * If the connection is already CLOSED, this method does nothing. - */ - - _createClass(SocketIO, [{ - key: 'close', - value: function close() { - if (this.readyState !== SocketIO.OPEN) { - return undefined; - } - - var server = _networkBridge2['default'].serverLookup(this.url); - _networkBridge2['default'].removeWebSocket(this, this.url); - - this.readyState = SocketIO.CLOSED; - this.dispatchEvent((0, _eventFactory.createCloseEvent)({ - type: 'close', - target: this, - code: _helpersCloseCodes2['default'].CLOSE_NORMAL - })); - - if (server) { - server.dispatchEvent((0, _eventFactory.createCloseEvent)({ - type: 'disconnect', - target: this, - code: _helpersCloseCodes2['default'].CLOSE_NORMAL - }), server); - } - } - - /* - * Alias for Socket#close - * - * https://github.com/socketio/socket.io-client/blob/master/lib/socket.js#L383 - */ - }, { - key: 'disconnect', - value: function disconnect() { - this.close(); - } - - /* - * Submits an event to the server with a payload - */ - }, { - key: 'emit', - value: function emit(event, data) { - if (this.readyState !== SocketIO.OPEN) { - throw new Error('SocketIO is already in CLOSING or CLOSED state'); - } - - var messageEvent = (0, _eventFactory.createMessageEvent)({ - type: event, - origin: this.url, - data: data - }); - - var server = _networkBridge2['default'].serverLookup(this.url); - - if (server) { - server.dispatchEvent(messageEvent, data); - } - } - - /* - * Submits a 'message' event to the server. - * - * Should behave exactly like WebSocket#send - * - * https://github.com/socketio/socket.io-client/blob/master/lib/socket.js#L113 - */ - }, { - key: 'send', - value: function send(data) { - this.emit('message', data); - } - - /* - * For registering events to be received from the server - */ - }, { - key: 'on', - value: function on(type, callback) { - this.addEventListener(type, callback); - } - - /* - * Join a room on a server - * - * http://socket.io/docs/rooms-and-namespaces/#joining-and-leaving - */ - }, { - key: 'join', - value: function join(room) { - _networkBridge2['default'].addMembershipToRoom(this, room); - } - - /* - * Get the websocket to leave the room - * - * http://socket.io/docs/rooms-and-namespaces/#joining-and-leaving - */ - }, { - key: 'leave', - value: function leave(room) { - _networkBridge2['default'].removeMembershipFromRoom(this, room); - } - - /* - * Invokes all listener functions that are listening to the given event.type property. Each - * listener will be passed the event as the first argument. - * - * @param {object} event - event object which will be passed to all listeners of the event.type property - */ - }, { - key: 'dispatchEvent', - value: function dispatchEvent(event) { - var _this2 = this; - - for (var _len = arguments.length, customArguments = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { - customArguments[_key - 1] = arguments[_key]; - } - - var eventName = event.type; - var listeners = this.listeners[eventName]; - - if (!Array.isArray(listeners)) { - return false; - } - - listeners.forEach(function (listener) { - if (customArguments.length > 0) { - listener.apply(_this2, customArguments); - } else { - // Regular WebSockets expect a MessageEvent but Socketio.io just wants raw data - // payload instanceof MessageEvent works, but you can't isntance of NodeEvent - // for now we detect if the output has data defined on it - listener.call(_this2, event.data ? event.data : event); - } - }); - } - }]); - - return SocketIO; -})(_eventTarget2['default']); - -SocketIO.CONNECTING = 0; -SocketIO.OPEN = 1; -SocketIO.CLOSING = 2; -SocketIO.CLOSED = 3; - -/* -* Static constructor methods for the IO Socket -*/ -var IO = function ioConstructor(url) { - return new SocketIO(url); -}; - -/* -* Alias the raw IO() constructor -*/ -IO.connect = function ioConnect(url) { - /* eslint-disable new-cap */ - return IO(url); - /* eslint-enable new-cap */ -}; - -exports['default'] = IO; -module.exports = exports['default']; -},{"./event-factory":5,"./event-target":6,"./helpers/close-codes":8,"./helpers/delay":10,"./network-bridge":15,"urijs":3}],18:[function(require,module,exports){ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _createClass = (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, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); - -var _get = function get(_x2, _x3, _x4) { var _again = true; _function: while (_again) { var object = _x2, property = _x3, receiver = _x4; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x2 = parent; _x3 = property; _x4 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } }; - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } - -var _urijs = require('urijs'); - -var _urijs2 = _interopRequireDefault(_urijs); - -var _helpersDelay = require('./helpers/delay'); - -var _helpersDelay2 = _interopRequireDefault(_helpersDelay); - -var _eventTarget = require('./event-target'); - -var _eventTarget2 = _interopRequireDefault(_eventTarget); - -var _networkBridge = require('./network-bridge'); - -var _networkBridge2 = _interopRequireDefault(_networkBridge); - -var _helpersCloseCodes = require('./helpers/close-codes'); - -var _helpersCloseCodes2 = _interopRequireDefault(_helpersCloseCodes); - -var _eventFactory = require('./event-factory'); - -/* -* The main websocket class which is designed to mimick the native WebSocket class as close -* as possible. -* -* https://developer.mozilla.org/en-US/docs/Web/API/WebSocket -*/ - -var WebSocket = (function (_EventTarget) { - _inherits(WebSocket, _EventTarget); - - /* - * @param {string} url - */ - - function WebSocket(url) { - var protocol = arguments.length <= 1 || arguments[1] === undefined ? '' : arguments[1]; - - _classCallCheck(this, WebSocket); - - _get(Object.getPrototypeOf(WebSocket.prototype), 'constructor', this).call(this); - - if (!url) { - throw new TypeError('Failed to construct \'WebSocket\': 1 argument required, but only 0 present.'); - } - - this.binaryType = 'blob'; - this.url = (0, _urijs2['default'])(url).toString(); - this.readyState = WebSocket.CONNECTING; - this.protocol = ''; - - if (typeof protocol === 'string') { - this.protocol = protocol; - } else if (Array.isArray(protocol) && protocol.length > 0) { - this.protocol = protocol[0]; - } - - /* - * In order to capture the callback function we need to define custom setters. - * To illustrate: - * mySocket.onopen = function() { alert(true) }; - * - * The only way to capture that function and hold onto it for later is with the - * below code: - */ - Object.defineProperties(this, { - onopen: { - configurable: true, - enumerable: true, - get: function get() { - return this.listeners.open; - }, - set: function set(listener) { - this.addEventListener('open', listener); - } - }, - onmessage: { - configurable: true, - enumerable: true, - get: function get() { - return this.listeners.message; - }, - set: function set(listener) { - this.addEventListener('message', listener); - } - }, - onclose: { - configurable: true, - enumerable: true, - get: function get() { - return this.listeners.close; - }, - set: function set(listener) { - this.addEventListener('close', listener); - } - }, - onerror: { - configurable: true, - enumerable: true, - get: function get() { - return this.listeners.error; - }, - set: function set(listener) { - this.addEventListener('error', listener); - } - } - }); - - var server = _networkBridge2['default'].attachWebSocket(this, this.url); - - /* - * This delay is needed so that we dont trigger an event before the callbacks have been - * setup. For example: - * - * var socket = new WebSocket('ws://localhost'); - * - * // If we dont have the delay then the event would be triggered right here and this is - * // before the onopen had a chance to register itself. - * - * socket.onopen = () => { // this would never be called }; - * - * // and with the delay the event gets triggered here after all of the callbacks have been - * // registered :-) - */ - (0, _helpersDelay2['default'])(function delayCallback() { - if (server) { - this.readyState = WebSocket.OPEN; - server.dispatchEvent((0, _eventFactory.createEvent)({ type: 'connection' }), server, this); - this.dispatchEvent((0, _eventFactory.createEvent)({ type: 'open', target: this })); - } else { - this.readyState = WebSocket.CLOSED; - this.dispatchEvent((0, _eventFactory.createEvent)({ type: 'error', target: this })); - this.dispatchEvent((0, _eventFactory.createCloseEvent)({ type: 'close', target: this, code: _helpersCloseCodes2['default'].CLOSE_NORMAL })); - - console.error('WebSocket connection to \'' + this.url + '\' failed'); - } - }, this); - } - - /* - * Transmits data to the server over the WebSocket connection. - * - * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket#send() - */ - - _createClass(WebSocket, [{ - key: 'send', - value: function send(data) { - if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) { - throw new Error('WebSocket is already in CLOSING or CLOSED state'); - } - - var messageEvent = (0, _eventFactory.createMessageEvent)({ - type: 'message', - origin: this.url, - data: data - }); - - var server = _networkBridge2['default'].serverLookup(this.url); - - if (server) { - server.dispatchEvent(messageEvent, data); - } - } - - /* - * Closes the WebSocket connection or connection attempt, if any. - * If the connection is already CLOSED, this method does nothing. - * - * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket#close() - */ - }, { - key: 'close', - value: function close() { - if (this.readyState !== WebSocket.OPEN) { - return undefined; - } - - var server = _networkBridge2['default'].serverLookup(this.url); - var closeEvent = (0, _eventFactory.createCloseEvent)({ - type: 'close', - target: this, - code: _helpersCloseCodes2['default'].CLOSE_NORMAL - }); - - _networkBridge2['default'].removeWebSocket(this, this.url); - - this.readyState = WebSocket.CLOSED; - this.dispatchEvent(closeEvent); - - if (server) { - server.dispatchEvent(closeEvent, server); - } - } - }]); - - return WebSocket; -})(_eventTarget2['default']); - -WebSocket.CONNECTING = 0; -WebSocket.OPEN = 1; -WebSocket.CLOSING = 2; -WebSocket.CLOSED = 3; - -exports['default'] = WebSocket; -module.exports = exports['default']; -},{"./event-factory":5,"./event-target":6,"./helpers/close-codes":8,"./helpers/delay":10,"./network-bridge":15,"urijs":3}]},{},[14]) -//# sourceMappingURL=data:application/json;charset:utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uL25vZGVfbW9kdWxlcy9icm93c2VyLXBhY2svX3ByZWx1ZGUuanMiLCIuLi8uLi9ub2RlX21vZHVsZXMvdXJpanMvc3JjL0lQdjYuanMiLCIuLi8uLi9ub2RlX21vZHVsZXMvdXJpanMvc3JjL1NlY29uZExldmVsRG9tYWlucy5qcyIsIi4uLy4uL25vZGVfbW9kdWxlcy91cmlqcy9zcmMvVVJJLmpzIiwiLi4vLi4vbm9kZV9tb2R1bGVzL3VyaWpzL3NyYy9wdW55Y29kZS5qcyIsImV2ZW50LWZhY3RvcnkuanMiLCJldmVudC10YXJnZXQuanMiLCJoZWxwZXJzL2FycmF5LWhlbHBlcnMuanMiLCJoZWxwZXJzL2Nsb3NlLWNvZGVzLmpzIiwiaGVscGVycy9jbG9zZS1ldmVudC5qcyIsImhlbHBlcnMvZGVsYXkuanMiLCJoZWxwZXJzL2V2ZW50LXByb3RvdHlwZS5qcyIsImhlbHBlcnMvZXZlbnQuanMiLCJoZWxwZXJzL21lc3NhZ2UtZXZlbnQuanMiLCJtYWluLmpzIiwibmV0d29yay1icmlkZ2UuanMiLCJzZXJ2ZXIuanMiLCJzb2NrZXQtaW8uanMiLCJ3ZWJzb2NrZXQuanMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7QUNBQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FDNUxBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FDalBBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7OztBQ2puRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7OztBQzVmQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FDckdBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUN4R0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUM1QkE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUNuQkE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FDL0RBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUNwQkE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FDN0NBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOztBQ3pEQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FDakVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FDL0JBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUM3S0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FDN0xBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FDclJBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBIiwiZmlsZSI6ImdlbmVyYXRlZC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzQ29udGVudCI6WyIoZnVuY3Rpb24gZSh0LG4scil7ZnVuY3Rpb24gcyhvLHUpe2lmKCFuW29dKXtpZighdFtvXSl7dmFyIGE9dHlwZW9mIHJlcXVpcmU9PVwiZnVuY3Rpb25cIiYmcmVxdWlyZTtpZighdSYmYSlyZXR1cm4gYShvLCEwKTtpZihpKXJldHVybiBpKG8sITApO3ZhciBmPW5ldyBFcnJvcihcIkNhbm5vdCBmaW5kIG1vZHVsZSAnXCIrbytcIidcIik7dGhyb3cgZi5jb2RlPVwiTU9EVUxFX05PVF9GT1VORFwiLGZ9dmFyIGw9bltvXT17ZXhwb3J0czp7fX07dFtvXVswXS5jYWxsKGwuZXhwb3J0cyxmdW5jdGlvbihlKXt2YXIgbj10W29dWzFdW2VdO3JldHVybiBzKG4/bjplKX0sbCxsLmV4cG9ydHMsZSx0LG4scil9cmV0dXJuIG5bb10uZXhwb3J0c312YXIgaT10eXBlb2YgcmVxdWlyZT09XCJmdW5jdGlvblwiJiZyZXF1aXJlO2Zvcih2YXIgbz0wO288ci5sZW5ndGg7bysrKXMocltvXSk7cmV0dXJuIHN9KSIsIi8qIVxuICogVVJJLmpzIC0gTXV0YXRpbmcgVVJMc1xuICogSVB2NiBTdXBwb3J0XG4gKlxuICogVmVyc2lvbjogMS4xNy4wXG4gKlxuICogQXV0aG9yOiBSb2RuZXkgUmVobVxuICogV2ViOiBodHRwOi8vbWVkaWFsaXplLmdpdGh1Yi5pby9VUkkuanMvXG4gKlxuICogTGljZW5zZWQgdW5kZXJcbiAqICAgTUlUIExpY2Vuc2UgaHR0cDovL3d3dy5vcGVuc291cmNlLm9yZy9saWNlbnNlcy9taXQtbGljZW5zZVxuICogICBHUEwgdjMgaHR0cDovL29wZW5zb3VyY2Uub3JnL2xpY2Vuc2VzL0dQTC0zLjBcbiAqXG4gKi9cblxuKGZ1bmN0aW9uIChyb290LCBmYWN0b3J5KSB7XG4gICd1c2Ugc3RyaWN0JztcbiAgLy8gaHR0cHM6Ly9naXRodWIuY29tL3VtZGpzL3VtZC9ibG9iL21hc3Rlci9yZXR1cm5FeHBvcnRzLmpzXG4gIGlmICh0eXBlb2YgZXhwb3J0cyA9PT0gJ29iamVjdCcpIHtcbiAgICAvLyBOb2RlXG4gICAgbW9kdWxlLmV4cG9ydHMgPSBmYWN0b3J5KCk7XG4gIH0gZWxzZSBpZiAodHlwZW9mIGRlZmluZSA9PT0gJ2Z1bmN0aW9uJyAmJiBkZWZpbmUuYW1kKSB7XG4gICAgLy8gQU1ELiBSZWdpc3RlciBhcyBhbiBhbm9ueW1vdXMgbW9kdWxlLlxuICAgIGRlZmluZShmYWN0b3J5KTtcbiAgfSBlbHNlIHtcbiAgICAvLyBCcm93c2VyIGdsb2JhbHMgKHJvb3QgaXMgd2luZG93KVxuICAgIHJvb3QuSVB2NiA9IGZhY3Rvcnkocm9vdCk7XG4gIH1cbn0odGhpcywgZnVuY3Rpb24gKHJvb3QpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIC8qXG4gIHZhciBfaW4gPSBcImZlODA6MDAwMDowMDAwOjAwMDA6MDIwNDo2MWZmOmZlOWQ6ZjE1NlwiO1xuICB2YXIgX291dCA9IElQdjYuYmVzdChfaW4pO1xuICB2YXIgX2V4cGVjdGVkID0gXCJmZTgwOjoyMDQ6NjFmZjpmZTlkOmYxNTZcIjtcblxuICBjb25zb2xlLmxvZyhfaW4sIF9vdXQsIF9leHBlY3RlZCwgX291dCA9PT0gX2V4cGVjdGVkKTtcbiAgKi9cblxuICAvLyBzYXZlIGN1cnJlbnQgSVB2NiB2YXJpYWJsZSwgaWYgYW55XG4gIHZhciBfSVB2NiA9IHJvb3QgJiYgcm9vdC5JUHY2O1xuXG4gIGZ1bmN0aW9uIGJlc3RQcmVzZW50YXRpb24oYWRkcmVzcykge1xuICAgIC8vIGJhc2VkIG9uOlxuICAgIC8vIEphdmFzY3JpcHQgdG8gdGVzdCBhbiBJUHY2IGFkZHJlc3MgZm9yIHByb3BlciBmb3JtYXQsIGFuZCB0b1xuICAgIC8vIHByZXNlbnQgdGhlIFwiYmVzdCB0ZXh0IHJlcHJlc2VudGF0aW9uXCIgYWNjb3JkaW5nIHRvIElFVEYgRHJhZnQgUkZDIGF0XG4gICAgLy8gaHR0cDovL3Rvb2xzLmlldGYub3JnL2h0bWwvZHJhZnQtaWV0Zi02bWFuLXRleHQtYWRkci1yZXByZXNlbnRhdGlvbi0wNFxuICAgIC8vIDggRmViIDIwMTAgUmljaCBCcm93biwgRGFydHdhcmUsIExMQ1xuICAgIC8vIFBsZWFzZSBmZWVsIGZyZWUgdG8gdXNlIHRoaXMgY29kZSBhcyBsb25nIGFzIHlvdSBwcm92aWRlIGEgbGluayB0b1xuICAgIC8vIGh0dHA6Ly93d3cuaW50ZXJtYXBwZXIuY29tXG4gICAgLy8gaHR0cDovL2ludGVybWFwcGVyLmNvbS9zdXBwb3J0L3Rvb2xzL0lQVjYtVmFsaWRhdG9yLmFzcHhcbiAgICAvLyBodHRwOi8vZG93bmxvYWQuZGFydHdhcmUuY29tL3RoaXJkcGFydHkvaXB2NnZhbGlkYXRvci5qc1xuXG4gICAgdmFyIF9hZGRyZXNzID0gYWRkcmVzcy50b0xvd2VyQ2FzZSgpO1xuICAgIHZhciBzZWdtZW50cyA9IF9hZGRyZXNzLnNwbGl0KCc6Jyk7XG4gICAgdmFyIGxlbmd0aCA9IHNlZ21lbnRzLmxlbmd0aDtcbiAgICB2YXIgdG90YWwgPSA4O1xuXG4gICAgLy8gdHJpbSBjb2xvbnMgKDo6IG9yIDo6YTpiOmPigKYgb3Ig4oCmYTpiOmM6OilcbiAgICBpZiAoc2VnbWVudHNbMF0gPT09ICcnICYmIHNlZ21lbnRzWzFdID09PSAnJyAmJiBzZWdtZW50c1syXSA9PT0gJycpIHtcbiAgICAgIC8vIG11c3QgaGF2ZSBiZWVuIDo6XG4gICAgICAvLyByZW1vdmUgZmlyc3QgdHdvIGl0ZW1zXG4gICAgICBzZWdtZW50cy5zaGlmdCgpO1xuICAgICAgc2VnbWVudHMuc2hpZnQoKTtcbiAgICB9IGVsc2UgaWYgKHNlZ21lbnRzWzBdID09PSAnJyAmJiBzZWdtZW50c1sxXSA9PT0gJycpIHtcbiAgICAgIC8vIG11c3QgaGF2ZSBiZWVuIDo6eHh4eFxuICAgICAgLy8gcmVtb3ZlIHRoZSBmaXJzdCBpdGVtXG4gICAgICBzZWdtZW50cy5zaGlmdCgpO1xuICAgIH0gZWxzZSBpZiAoc2VnbWVudHNbbGVuZ3RoIC0gMV0gPT09ICcnICYmIHNlZ21lbnRzW2xlbmd0aCAtIDJdID09PSAnJykge1xuICAgICAgLy8gbXVzdCBoYXZlIGJlZW4geHh4eDo6XG4gICAgICBzZWdtZW50cy5wb3AoKTtcbiAgICB9XG5cbiAgICBsZW5ndGggPSBzZWdtZW50cy5sZW5ndGg7XG5cbiAgICAvLyBhZGp1c3QgdG90YWwgc2VnbWVudHMgZm9yIElQdjQgdHJhaWxlclxuICAgIGlmIChzZWdtZW50c1tsZW5ndGggLSAxXS5pbmRleE9mKCcuJykgIT09IC0xKSB7XG4gICAgICAvLyBmb3VuZCBhIFwiLlwiIHdoaWNoIG1lYW5zIElQdjRcbiAgICAgIHRvdGFsID0gNztcbiAgICB9XG5cbiAgICAvLyBmaWxsIGVtcHR5IHNlZ21lbnRzIHRoZW0gd2l0aCBcIjAwMDBcIlxuICAgIHZhciBwb3M7XG4gICAgZm9yIChwb3MgPSAwOyBwb3MgPCBsZW5ndGg7IHBvcysrKSB7XG4gICAgICBpZiAoc2VnbWVudHNbcG9zXSA9PT0gJycpIHtcbiAgICAgICAgYnJlYWs7XG4gICAgICB9XG4gICAgfVxuXG4gICAgaWYgKHBvcyA8IHRvdGFsKSB7XG4gICAgICBzZWdtZW50cy5zcGxpY2UocG9zLCAxLCAnMDAwMCcpO1xuICAgICAgd2hpbGUgKHNlZ21lbnRzLmxlbmd0aCA8IHRvdGFsKSB7XG4gICAgICAgIHNlZ21lbnRzLnNwbGljZShwb3MsIDAsICcwMDAwJyk7XG4gICAgICB9XG5cbiAgICAgIGxlbmd0aCA9IHNlZ21lbnRzLmxlbmd0aDtcbiAgICB9XG5cbiAgICAvLyBzdHJpcCBsZWFkaW5nIHplcm9zXG4gICAgdmFyIF9zZWdtZW50cztcbiAgICBmb3IgKHZhciBpID0gMDsgaSA8IHRvdGFsOyBpKyspIHtcbiAgICAgIF9zZWdtZW50cyA9IHNlZ21lbnRzW2ldLnNwbGl0KCcnKTtcbiAgICAgIGZvciAodmFyIGogPSAwOyBqIDwgMyA7IGorKykge1xuICAgICAgICBpZiAoX3NlZ21lbnRzWzBdID09PSAnMCcgJiYgX3NlZ21lbnRzLmxlbmd0aCA+IDEpIHtcbiAgICAgICAgICBfc2VnbWVudHMuc3BsaWNlKDAsMSk7XG4gICAgICAgIH0gZWxzZSB7XG4gICAgICAgICAgYnJlYWs7XG4gICAgICAgIH1cbiAgICAgIH1cblxuICAgICAgc2VnbWVudHNbaV0gPSBfc2VnbWVudHMuam9pbignJyk7XG4gICAgfVxuXG4gICAgLy8gZmluZCBsb25nZXN0IHNlcXVlbmNlIG9mIHplcm9lcyBhbmQgY29hbGVzY2UgdGhlbSBpbnRvIG9uZSBzZWdtZW50XG4gICAgdmFyIGJlc3QgPSAtMTtcbiAgICB2YXIgX2Jlc3QgPSAwO1xuICAgIHZhciBfY3VycmVudCA9IDA7XG4gICAgdmFyIGN1cnJlbnQgPSAtMTtcbiAgICB2YXIgaW56ZXJvZXMgPSBmYWxzZTtcbiAgICAvLyBpOyBhbHJlYWR5IGRlY2xhcmVkXG5cbiAgICBmb3IgKGkgPSAwOyBpIDwgdG90YWw7IGkrKykge1xuICAgICAgaWYgKGluemVyb2VzKSB7XG4gICAgICAgIGlmIChzZWdtZW50c1tpXSA9PT0gJzAnKSB7XG4gICAgICAgICAgX2N1cnJlbnQgKz0gMTtcbiAgICAgICAgfSBlbHNlIHtcbiAgICAgICAgICBpbnplcm9lcyA9IGZhbHNlO1xuICAgICAgICAgIGlmIChfY3VycmVudCA+IF9iZXN0KSB7XG4gICAgICAgICAgICBiZXN0ID0gY3VycmVudDtcbiAgICAgICAgICAgIF9iZXN0ID0gX2N1cnJlbnQ7XG4gICAgICAgICAgfVxuICAgICAgICB9XG4gICAgICB9IGVsc2Uge1xuICAgICAgICBpZiAoc2VnbWVudHNbaV0gPT09ICcwJykge1xuICAgICAgICAgIGluemVyb2VzID0gdHJ1ZTtcbiAgICAgICAgICBjdXJyZW50ID0gaTtcbiAgICAgICAgICBfY3VycmVudCA9IDE7XG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9XG5cbiAgICBpZiAoX2N1cnJlbnQgPiBfYmVzdCkge1xuICAgICAgYmVzdCA9IGN1cnJlbnQ7XG4gICAgICBfYmVzdCA9IF9jdXJyZW50O1xuICAgIH1cblxuICAgIGlmIChfYmVzdCA+IDEpIHtcbiAgICAgIHNlZ21lbnRzLnNwbGljZShiZXN0LCBfYmVzdCwgJycpO1xuICAgIH1cblxuICAgIGxlbmd0aCA9IHNlZ21lbnRzLmxlbmd0aDtcblxuICAgIC8vIGFzc2VtYmxlIHJlbWFpbmluZyBzZWdtZW50c1xuICAgIHZhciByZXN1bHQgPSAnJztcbiAgICBpZiAoc2VnbWVudHNbMF0gPT09ICcnKSAge1xuICAgICAgcmVzdWx0ID0gJzonO1xuICAgIH1cblxuICAgIGZvciAoaSA9IDA7IGkgPCBsZW5ndGg7IGkrKykge1xuICAgICAgcmVzdWx0ICs9IHNlZ21lbnRzW2ldO1xuICAgICAgaWYgKGkgPT09IGxlbmd0aCAtIDEpIHtcbiAgICAgICAgYnJlYWs7XG4gICAgICB9XG5cbiAgICAgIHJlc3VsdCArPSAnOic7XG4gICAgfVxuXG4gICAgaWYgKHNlZ21lbnRzW2xlbmd0aCAtIDFdID09PSAnJykge1xuICAgICAgcmVzdWx0ICs9ICc6JztcbiAgICB9XG5cbiAgICByZXR1cm4gcmVzdWx0O1xuICB9XG5cbiAgZnVuY3Rpb24gbm9Db25mbGljdCgpIHtcbiAgICAvKmpzaGludCB2YWxpZHRoaXM6IHRydWUgKi9cbiAgICBpZiAocm9vdC5JUHY2ID09PSB0aGlzKSB7XG4gICAgICByb290LklQdjYgPSBfSVB2NjtcbiAgICB9XG4gIFxuICAgIHJldHVybiB0aGlzO1xuICB9XG5cbiAgcmV0dXJuIHtcbiAgICBiZXN0OiBiZXN0UHJlc2VudGF0aW9uLFxuICAgIG5vQ29uZmxpY3Q6IG5vQ29uZmxpY3RcbiAgfTtcbn0pKTtcbiIsIi8qIVxuICogVVJJLmpzIC0gTXV0YXRpbmcgVVJMc1xuICogU2Vjb25kIExldmVsIERvbWFpbiAoU0xEKSBTdXBwb3J0XG4gKlxuICogVmVyc2lvbjogMS4xNy4wXG4gKlxuICogQXV0aG9yOiBSb2RuZXkgUmVobVxuICogV2ViOiBodHRwOi8vbWVkaWFsaXplLmdpdGh1Yi5pby9VUkkuanMvXG4gKlxuICogTGljZW5zZWQgdW5kZXJcbiAqICAgTUlUIExpY2Vuc2UgaHR0cDovL3d3dy5vcGVuc291cmNlLm9yZy9saWNlbnNlcy9taXQtbGljZW5zZVxuICogICBHUEwgdjMgaHR0cDovL29wZW5zb3VyY2Uub3JnL2xpY2Vuc2VzL0dQTC0zLjBcbiAqXG4gKi9cblxuKGZ1bmN0aW9uIChyb290LCBmYWN0b3J5KSB7XG4gICd1c2Ugc3RyaWN0JztcbiAgLy8gaHR0cHM6Ly9naXRodWIuY29tL3VtZGpzL3VtZC9ibG9iL21hc3Rlci9yZXR1cm5FeHBvcnRzLmpzXG4gIGlmICh0eXBlb2YgZXhwb3J0cyA9PT0gJ29iamVjdCcpIHtcbiAgICAvLyBOb2RlXG4gICAgbW9kdWxlLmV4cG9ydHMgPSBmYWN0b3J5KCk7XG4gIH0gZWxzZSBpZiAodHlwZW9mIGRlZmluZSA9PT0gJ2Z1bmN0aW9uJyAmJiBkZWZpbmUuYW1kKSB7XG4gICAgLy8gQU1ELiBSZWdpc3RlciBhcyBhbiBhbm9ueW1vdXMgbW9kdWxlLlxuICAgIGRlZmluZShmYWN0b3J5KTtcbiAgfSBlbHNlIHtcbiAgICAvLyBCcm93c2VyIGdsb2JhbHMgKHJvb3QgaXMgd2luZG93KVxuICAgIHJvb3QuU2Vjb25kTGV2ZWxEb21haW5zID0gZmFjdG9yeShyb290KTtcbiAgfVxufSh0aGlzLCBmdW5jdGlvbiAocm9vdCkge1xuICAndXNlIHN0cmljdCc7XG5cbiAgLy8gc2F2ZSBjdXJyZW50IFNlY29uZExldmVsRG9tYWlucyB2YXJpYWJsZSwgaWYgYW55XG4gIHZhciBfU2Vjb25kTGV2ZWxEb21haW5zID0gcm9vdCAmJiByb290LlNlY29uZExldmVsRG9tYWlucztcblxuICB2YXIgU0xEID0ge1xuICAgIC8vIGxpc3Qgb2Yga25vd24gU2Vjb25kIExldmVsIERvbWFpbnNcbiAgICAvLyBjb252ZXJ0ZWQgbGlzdCBvZiBTTERzIGZyb20gaHR0cHM6Ly9naXRodWIuY29tL2dhdmluZ21pbGxlci9zZWNvbmQtbGV2ZWwtZG9tYWluc1xuICAgIC8vIC0tLS1cbiAgICAvLyBwdWJsaWNzdWZmaXgub3JnIGlzIG1vcmUgY3VycmVudCBhbmQgYWN0dWFsbHkgdXNlZCBieSBhIGNvdXBsZSBvZiBicm93c2VycyBpbnRlcm5hbGx5LlxuICAgIC8vIGRvd25zaWRlIGlzIGl0IGFsc28gY29udGFpbnMgZG9tYWlucyBsaWtlIFwiZHluZG5zLm9yZ1wiIC0gd2hpY2ggaXMgZmluZSBmb3IgdGhlIHNlY3VyaXR5XG4gICAgLy8gaXNzdWVzIGJyb3dzZXIgaGF2ZSB0byBkZWFsIHdpdGggKFNPUCBmb3IgY29va2llcywgZXRjKSAtIGJ1dCBpcyB3YXkgb3ZlcmJvYXJkIGZvciBVUkkuanNcbiAgICAvLyAtLS0tXG4gICAgbGlzdDoge1xuICAgICAgJ2FjJzonIGNvbSBnb3YgbWlsIG5ldCBvcmcgJyxcbiAgICAgICdhZSc6JyBhYyBjbyBnb3YgbWlsIG5hbWUgbmV0IG9yZyBwcm8gc2NoICcsXG4gICAgICAnYWYnOicgY29tIGVkdSBnb3YgbmV0IG9yZyAnLFxuICAgICAgJ2FsJzonIGNvbSBlZHUgZ292IG1pbCBuZXQgb3JnICcsXG4gICAgICAnYW8nOicgY28gZWQgZ3YgaXQgb2cgcGIgJyxcbiAgICAgICdhcic6JyBjb20gZWR1IGdvYiBnb3YgaW50IG1pbCBuZXQgb3JnIHR1ciAnLFxuICAgICAgJ2F0JzonIGFjIGNvIGd2IG9yICcsXG4gICAgICAnYXUnOicgYXNuIGNvbSBjc2lybyBlZHUgZ292IGlkIG5ldCBvcmcgJyxcbiAgICAgICdiYSc6JyBjbyBjb20gZWR1IGdvdiBtaWwgbmV0IG9yZyBycyB1bmJpIHVubW8gdW5zYSB1bnR6IHVuemUgJyxcbiAgICAgICdiYic6JyBiaXogY28gY29tIGVkdSBnb3YgaW5mbyBuZXQgb3JnIHN0b3JlIHR2ICcsXG4gICAgICAnYmgnOicgYml6IGNjIGNvbSBlZHUgZ292IGluZm8gbmV0IG9yZyAnLFxuICAgICAgJ2JuJzonIGNvbSBlZHUgZ292IG5ldCBvcmcgJyxcbiAgICAgICdibyc6JyBjb20gZWR1IGdvYiBnb3YgaW50IG1pbCBuZXQgb3JnIHR2ICcsXG4gICAgICAnYnInOicgYWRtIGFkdiBhZ3IgYW0gYXJxIGFydCBhdG8gYiBiaW8gYmxvZyBibWQgY2ltIGNuZyBjbnQgY29tIGNvb3AgZWNuIGVkdSBlbmcgZXNwIGV0YyBldGkgZmFyIGZsb2cgZm0gZm5kIGZvdCBmc3QgZzEyIGdnZiBnb3YgaW1iIGluZCBpbmYgam9yIGp1cyBsZWwgbWF0IG1lZCBtaWwgbXVzIG5ldCBub20gbm90IG50ciBvZG8gb3JnIHBwZyBwcm8gcHNjIHBzaSBxc2wgcmVjIHNsZyBzcnYgdG1wIHRyZCB0dXIgdHYgdmV0IHZsb2cgd2lraSB6bGcgJyxcbiAgICAgICdicyc6JyBjb20gZWR1IGdvdiBuZXQgb3JnICcsXG4gICAgICAnYnonOicgZHUgZXQgb20gb3YgcmcgJyxcbiAgICAgICdjYSc6JyBhYiBiYyBtYiBuYiBuZiBubCBucyBudCBudSBvbiBwZSBxYyBzayB5ayAnLFxuICAgICAgJ2NrJzonIGJpeiBjbyBlZHUgZ2VuIGdvdiBpbmZvIG5ldCBvcmcgJyxcbiAgICAgICdjbic6JyBhYyBhaCBiaiBjb20gY3EgZWR1IGZqIGdkIGdvdiBncyBneCBneiBoYSBoYiBoZSBoaSBobCBobiBqbCBqcyBqeCBsbiBtaWwgbmV0IG5tIG54IG9yZyBxaCBzYyBzZCBzaCBzbiBzeCB0aiB0dyB4aiB4eiB5biB6aiAnLFxuICAgICAgJ2NvJzonIGNvbSBlZHUgZ292IG1pbCBuZXQgbm9tIG9yZyAnLFxuICAgICAgJ2NyJzonIGFjIGMgY28gZWQgZmkgZ28gb3Igc2EgJyxcbiAgICAgICdjeSc6JyBhYyBiaXogY29tIGVrbG9nZXMgZ292IGx0ZCBuYW1lIG5ldCBvcmcgcGFybGlhbWVudCBwcmVzcyBwcm8gdG0gJyxcbiAgICAgICdkbyc6JyBhcnQgY29tIGVkdSBnb2IgZ292IG1pbCBuZXQgb3JnIHNsZCB3ZWIgJyxcbiAgICAgICdkeic6JyBhcnQgYXNzbyBjb20gZWR1IGdvdiBuZXQgb3JnIHBvbCAnLFxuICAgICAgJ2VjJzonIGNvbSBlZHUgZmluIGdvdiBpbmZvIG1lZCBtaWwgbmV0IG9yZyBwcm8gJyxcbiAgICAgICdlZyc6JyBjb20gZWR1IGV1biBnb3YgbWlsIG5hbWUgbmV0IG9yZyBzY2kgJyxcbiAgICAgICdlcic6JyBjb20gZWR1IGdvdiBpbmQgbWlsIG5ldCBvcmcgcm9jaGVzdCB3ICcsXG4gICAgICAnZXMnOicgY29tIGVkdSBnb2Igbm9tIG9yZyAnLFxuICAgICAgJ2V0JzonIGJpeiBjb20gZWR1IGdvdiBpbmZvIG5hbWUgbmV0IG9yZyAnLFxuICAgICAgJ2ZqJzonIGFjIGJpeiBjb20gaW5mbyBtaWwgbmFtZSBuZXQgb3JnIHBybyAnLFxuICAgICAgJ2ZrJzonIGFjIGNvIGdvdiBuZXQgbm9tIG9yZyAnLFxuICAgICAgJ2ZyJzonIGFzc28gY29tIGYgZ291diBub20gcHJkIHByZXNzZSB0bSAnLFxuICAgICAgJ2dnJzonIGNvIG5ldCBvcmcgJyxcbiAgICAgICdnaCc6JyBjb20gZWR1IGdvdiBtaWwgb3JnICcsXG4gICAgICAnZ24nOicgYWMgY29tIGdvdiBuZXQgb3JnICcsXG4gICAgICAnZ3InOicgY29tIGVkdSBnb3YgbWlsIG5ldCBvcmcgJyxcbiAgICAgICdndCc6JyBjb20gZWR1IGdvYiBpbmQgbWlsIG5ldCBvcmcgJyxcbiAgICAgICdndSc6JyBjb20gZWR1IGdvdiBuZXQgb3JnICcsXG4gICAgICAnaGsnOicgY29tIGVkdSBnb3YgaWR2IG5ldCBvcmcgJyxcbiAgICAgICdodSc6JyAyMDAwIGFncmFyIGJvbHQgY2FzaW5vIGNpdHkgY28gZXJvdGljYSBlcm90aWthIGZpbG0gZm9ydW0gZ2FtZXMgaG90ZWwgaW5mbyBpbmdhdGxhbiBqb2dhc3oga29ueXZlbG8gbGFrYXMgbWVkaWEgbmV3cyBvcmcgcHJpdiByZWtsYW0gc2V4IHNob3Agc3BvcnQgc3VsaSBzemV4IHRtIHRvenNkZSB1dGF6YXMgdmlkZW8gJyxcbiAgICAgICdpZCc6JyBhYyBjbyBnbyBtaWwgbmV0IG9yIHNjaCB3ZWIgJyxcbiAgICAgICdpbCc6JyBhYyBjbyBnb3YgaWRmIGsxMiBtdW5pIG5ldCBvcmcgJyxcbiAgICAgICdpbic6JyBhYyBjbyBlZHUgZXJuZXQgZmlybSBnZW4gZ292IGkgaW5kIG1pbCBuZXQgbmljIG9yZyByZXMgJyxcbiAgICAgICdpcSc6JyBjb20gZWR1IGdvdiBpIG1pbCBuZXQgb3JnICcsXG4gICAgICAnaXInOicgYWMgY28gZG5zc2VjIGdvdiBpIGlkIG5ldCBvcmcgc2NoICcsXG4gICAgICAnaXQnOicgZWR1IGdvdiAnLFxuICAgICAgJ2plJzonIGNvIG5ldCBvcmcgJyxcbiAgICAgICdqbyc6JyBjb20gZWR1IGdvdiBtaWwgbmFtZSBuZXQgb3JnIHNjaCAnLFxuICAgICAgJ2pwJzonIGFjIGFkIGNvIGVkIGdvIGdyIGxnIG5lIG9yICcsXG4gICAgICAna2UnOicgYWMgY28gZ28gaW5mbyBtZSBtb2JpIG5lIG9yIHNjICcsXG4gICAgICAna2gnOicgY29tIGVkdSBnb3YgbWlsIG5ldCBvcmcgcGVyICcsXG4gICAgICAna2knOicgYml6IGNvbSBkZSBlZHUgZ292IGluZm8gbW9iIG5ldCBvcmcgdGVsICcsXG4gICAgICAna20nOicgYXNzbyBjb20gY29vcCBlZHUgZ291diBrIG1lZGVjaW4gbWlsIG5vbSBub3RhaXJlcyBwaGFybWFjaWVucyBwcmVzc2UgdG0gdmV0ZXJpbmFpcmUgJyxcbiAgICAgICdrbic6JyBlZHUgZ292IG5ldCBvcmcgJyxcbiAgICAgICdrcic6JyBhYyBidXNhbiBjaHVuZ2J1ayBjaHVuZ25hbSBjbyBkYWVndSBkYWVqZW9uIGVzIGdhbmd3b24gZ28gZ3dhbmdqdSBneWVvbmdidWsgZ3llb25nZ2kgZ3llb25nbmFtIGhzIGluY2hlb24gamVqdSBqZW9uYnVrIGplb25uYW0gayBrZyBtaWwgbXMgbmUgb3IgcGUgcmUgc2Mgc2VvdWwgdWxzYW4gJyxcbiAgICAgICdrdyc6JyBjb20gZWR1IGdvdiBuZXQgb3JnICcsXG4gICAgICAna3knOicgY29tIGVkdSBnb3YgbmV0IG9yZyAnLFxuICAgICAgJ2t6JzonIGNvbSBlZHUgZ292IG1pbCBuZXQgb3JnICcsXG4gICAgICAnbGInOicgY29tIGVkdSBnb3YgbmV0IG9yZyAnLFxuICAgICAgJ2xrJzonIGFzc24gY29tIGVkdSBnb3YgZ3JwIGhvdGVsIGludCBsdGQgbmV0IG5nbyBvcmcgc2NoIHNvYyB3ZWIgJyxcbiAgICAgICdscic6JyBjb20gZWR1IGdvdiBuZXQgb3JnICcsXG4gICAgICAnbHYnOicgYXNuIGNvbSBjb25mIGVkdSBnb3YgaWQgbWlsIG5ldCBvcmcgJyxcbiAgICAgICdseSc6JyBjb20gZWR1IGdvdiBpZCBtZWQgbmV0IG9yZyBwbGMgc2NoICcsXG4gICAgICAnbWEnOicgYWMgY28gZ292IG0gbmV0IG9yZyBwcmVzcyAnLFxuICAgICAgJ21jJzonIGFzc28gdG0gJyxcbiAgICAgICdtZSc6JyBhYyBjbyBlZHUgZ292IGl0cyBuZXQgb3JnIHByaXYgJyxcbiAgICAgICdtZyc6JyBjb20gZWR1IGdvdiBtaWwgbm9tIG9yZyBwcmQgdG0gJyxcbiAgICAgICdtayc6JyBjb20gZWR1IGdvdiBpbmYgbmFtZSBuZXQgb3JnIHBybyAnLFxuICAgICAgJ21sJzonIGNvbSBlZHUgZ292IG5ldCBvcmcgcHJlc3NlICcsXG4gICAgICAnbW4nOicgZWR1IGdvdiBvcmcgJyxcbiAgICAgICdtbyc6JyBjb20gZWR1IGdvdiBuZXQgb3JnICcsXG4gICAgICAnbXQnOicgY29tIGVkdSBnb3YgbmV0IG9yZyAnLFxuICAgICAgJ212JzonIGFlcm8gYml6IGNvbSBjb29wIGVkdSBnb3YgaW5mbyBpbnQgbWlsIG11c2V1bSBuYW1lIG5ldCBvcmcgcHJvICcsXG4gICAgICAnbXcnOicgYWMgY28gY29tIGNvb3AgZWR1IGdvdiBpbnQgbXVzZXVtIG5ldCBvcmcgJyxcbiAgICAgICdteCc6JyBjb20gZWR1IGdvYiBuZXQgb3JnICcsXG4gICAgICAnbXknOicgY29tIGVkdSBnb3YgbWlsIG5hbWUgbmV0IG9yZyBzY2ggJyxcbiAgICAgICduZic6JyBhcnRzIGNvbSBmaXJtIGluZm8gbmV0IG90aGVyIHBlciByZWMgc3RvcmUgd2ViICcsXG4gICAgICAnbmcnOicgYml6IGNvbSBlZHUgZ292IG1pbCBtb2JpIG5hbWUgbmV0IG9yZyBzY2ggJyxcbiAgICAgICduaSc6JyBhYyBjbyBjb20gZWR1IGdvYiBtaWwgbmV0IG5vbSBvcmcgJyxcbiAgICAgICducCc6JyBjb20gZWR1IGdvdiBtaWwgbmV0IG9yZyAnLFxuICAgICAgJ25yJzonIGJpeiBjb20gZWR1IGdvdiBpbmZvIG5ldCBvcmcgJyxcbiAgICAgICdvbSc6JyBhYyBiaXogY28gY29tIGVkdSBnb3YgbWVkIG1pbCBtdXNldW0gbmV0IG9yZyBwcm8gc2NoICcsXG4gICAgICAncGUnOicgY29tIGVkdSBnb2IgbWlsIG5ldCBub20gb3JnIHNsZCAnLFxuICAgICAgJ3BoJzonIGNvbSBlZHUgZ292IGkgbWlsIG5ldCBuZ28gb3JnICcsXG4gICAgICAncGsnOicgYml6IGNvbSBlZHUgZmFtIGdvYiBnb2sgZ29uIGdvcCBnb3MgZ292IG5ldCBvcmcgd2ViICcsXG4gICAgICAncGwnOicgYXJ0IGJpYWx5c3RvayBiaXogY29tIGVkdSBnZGEgZ2RhbnNrIGdvcnpvdyBnb3YgaW5mbyBrYXRvd2ljZSBrcmFrb3cgbG9keiBsdWJsaW4gbWlsIG5ldCBuZ28gb2xzenR5biBvcmcgcG96bmFuIHB3ciByYWRvbSBzbHVwc2sgc3pjemVjaW4gdG9ydW4gd2Fyc3phd2Egd2F3IHdyb2Mgd3JvY2xhdyB6Z29yYSAnLFxuICAgICAgJ3ByJzonIGFjIGJpeiBjb20gZWR1IGVzdCBnb3YgaW5mbyBpc2xhIG5hbWUgbmV0IG9yZyBwcm8gcHJvZiAnLFxuICAgICAgJ3BzJzonIGNvbSBlZHUgZ292IG5ldCBvcmcgcGxvIHNlYyAnLFxuICAgICAgJ3B3JzonIGJlbGF1IGNvIGVkIGdvIG5lIG9yICcsXG4gICAgICAncm8nOicgYXJ0cyBjb20gZmlybSBpbmZvIG5vbSBudCBvcmcgcmVjIHN0b3JlIHRtIHd3dyAnLFxuICAgICAgJ3JzJzonIGFjIGNvIGVkdSBnb3YgaW4gb3JnICcsXG4gICAgICAnc2InOicgY29tIGVkdSBnb3YgbmV0IG9yZyAnLFxuICAgICAgJ3NjJzonIGNvbSBlZHUgZ292IG5ldCBvcmcgJyxcbiAgICAgICdzaCc6JyBjbyBjb20gZWR1IGdvdiBuZXQgbm9tIG9yZyAnLFxuICAgICAgJ3NsJzonIGNvbSBlZHUgZ292IG5ldCBvcmcgJyxcbiAgICAgICdzdCc6JyBjbyBjb20gY29uc3VsYWRvIGVkdSBlbWJhaXhhZGEgZ292IG1pbCBuZXQgb3JnIHByaW5jaXBlIHNhb3RvbWUgc3RvcmUgJyxcbiAgICAgICdzdic6JyBjb20gZWR1IGdvYiBvcmcgcmVkICcsXG4gICAgICAnc3onOicgYWMgY28gb3JnICcsXG4gICAgICAndHInOicgYXYgYmJzIGJlbCBiaXogY29tIGRyIGVkdSBnZW4gZ292IGluZm8gazEyIG5hbWUgbmV0IG9yZyBwb2wgdGVsIHRzayB0diB3ZWIgJyxcbiAgICAgICd0dCc6JyBhZXJvIGJpeiBjYXQgY28gY29tIGNvb3AgZWR1IGdvdiBpbmZvIGludCBqb2JzIG1pbCBtb2JpIG11c2V1bSBuYW1lIG5ldCBvcmcgcHJvIHRlbCB0cmF2ZWwgJyxcbiAgICAgICd0dyc6JyBjbHViIGNvbSBlYml6IGVkdSBnYW1lIGdvdiBpZHYgbWlsIG5ldCBvcmcgJyxcbiAgICAgICdtdSc6JyBhYyBjbyBjb20gZ292IG5ldCBvciBvcmcgJyxcbiAgICAgICdteic6JyBhYyBjbyBlZHUgZ292IG9yZyAnLFxuICAgICAgJ25hJzonIGNvIGNvbSAnLFxuICAgICAgJ256JzonIGFjIGNvIGNyaSBnZWVrIGdlbiBnb3Z0IGhlYWx0aCBpd2kgbWFvcmkgbWlsIG5ldCBvcmcgcGFybGlhbWVudCBzY2hvb2wgJyxcbiAgICAgICdwYSc6JyBhYm8gYWMgY29tIGVkdSBnb2IgaW5nIG1lZCBuZXQgbm9tIG9yZyBzbGQgJyxcbiAgICAgICdwdCc6JyBjb20gZWR1IGdvdiBpbnQgbmV0IG5vbWUgb3JnIHB1YmwgJyxcbiAgICAgICdweSc6JyBjb20gZWR1IGdvdiBtaWwgbmV0IG9yZyAnLFxuICAgICAgJ3FhJzonIGNvbSBlZHUgZ292IG1pbCBuZXQgb3JnICcsXG4gICAgICAncmUnOicgYXNzbyBjb20gbm9tICcsXG4gICAgICAncnUnOicgYWMgYWR5Z2V5YSBhbHRhaSBhbXVyIGFya2hhbmdlbHNrIGFzdHJha2hhbiBiYXNoa2lyaWEgYmVsZ29yb2QgYmlyIGJyeWFuc2sgYnVyeWF0aWEgY2JnIGNoZWwgY2hlbHlhYmluc2sgY2hpdGEgY2h1a290a2EgY2h1dmFzaGlhIGNvbSBkYWdlc3RhbiBlLWJ1cmcgZWR1IGdvdiBncm96bnkgaW50IGlya3V0c2sgaXZhbm92byBpemhldnNrIGphciBqb3Noa2FyLW9sYSBrYWxteWtpYSBrYWx1Z2Ega2FtY2hhdGthIGthcmVsaWEga2F6YW4ga2NociBrZW1lcm92byBraGFiYXJvdnNrIGtoYWthc3NpYSBraHYga2lyb3Yga29lbmlnIGtvbWkga29zdHJvbWEga3Jhbm95YXJzayBrdWJhbiBrdXJnYW4ga3Vyc2sgbGlwZXRzayBtYWdhZGFuIG1hcmkgbWFyaS1lbCBtYXJpbmUgbWlsIG1vcmRvdmlhIG1vc3JlZyBtc2sgbXVybWFuc2sgbmFsY2hpayBuZXQgbm5vdiBub3Ygbm92b3NpYmlyc2sgbnNrIG9tc2sgb3JlbmJ1cmcgb3JnIG9yeW9sIHBlbnphIHBlcm0gcHAgcHNrb3YgcHR6IHJuZCByeWF6YW4gc2FraGFsaW4gc2FtYXJhIHNhcmF0b3Ygc2ltYmlyc2sgc21vbGVuc2sgc3BiIHN0YXZyb3BvbCBzdHYgc3VyZ3V0IHRhbWJvdiB0YXRhcnN0YW4gdG9tIHRvbXNrIHRzYXJpdHN5biB0c2sgdHVsYSB0dXZhIHR2ZXIgdHl1bWVuIHVkbSB1ZG11cnRpYSB1bGFuLXVkZSB2bGFkaWthdmtheiB2bGFkaW1pciB2bGFkaXZvc3RvayB2b2xnb2dyYWQgdm9sb2dkYSB2b3JvbmV6aCB2cm4gdnlhdGthIHlha3V0aWEgeWFtYWwgeWVrYXRlcmluYnVyZyB5dXpobm8tc2FraGFsaW5zayAnLFxuICAgICAgJ3J3JzonIGFjIGNvIGNvbSBlZHUgZ291diBnb3YgaW50IG1pbCBuZXQgJyxcbiAgICAgICdzYSc6JyBjb20gZWR1IGdvdiBtZWQgbmV0IG9yZyBwdWIgc2NoICcsXG4gICAgICAnc2QnOicgY29tIGVkdSBnb3YgaW5mbyBtZWQgbmV0IG9yZyB0diAnLFxuICAgICAgJ3NlJzonIGEgYWMgYiBiZCBjIGQgZSBmIGcgaCBpIGsgbCBtIG4gbyBvcmcgcCBwYXJ0aSBwcCBwcmVzcyByIHMgdCB0bSB1IHcgeCB5IHogJyxcbiAgICAgICdzZyc6JyBjb20gZWR1IGdvdiBpZG4gbmV0IG9yZyBwZXIgJyxcbiAgICAgICdzbic6JyBhcnQgY29tIGVkdSBnb3V2IG9yZyBwZXJzbyB1bml2ICcsXG4gICAgICAnc3knOicgY29tIGVkdSBnb3YgbWlsIG5ldCBuZXdzIG9yZyAnLFxuICAgICAgJ3RoJzonIGFjIGNvIGdvIGluIG1pIG5ldCBvciAnLFxuICAgICAgJ3RqJzonIGFjIGJpeiBjbyBjb20gZWR1IGdvIGdvdiBpbmZvIGludCBtaWwgbmFtZSBuZXQgbmljIG9yZyB0ZXN0IHdlYiAnLFxuICAgICAgJ3RuJzonIGFncmluZXQgY29tIGRlZmVuc2UgZWR1bmV0IGVucyBmaW4gZ292IGluZCBpbmZvIGludGwgbWluY29tIG5hdCBuZXQgb3JnIHBlcnNvIHJucnQgcm5zIHJudSB0b3VyaXNtICcsXG4gICAgICAndHonOicgYWMgY28gZ28gbmUgb3IgJyxcbiAgICAgICd1YSc6JyBiaXogY2hlcmthc3N5IGNoZXJuaWdvdiBjaGVybm92dHN5IGNrIGNuIGNvIGNvbSBjcmltZWEgY3YgZG4gZG5lcHJvcGV0cm92c2sgZG9uZXRzayBkcCBlZHUgZ292IGlmIGluIGl2YW5vLWZyYW5raXZzayBraCBraGFya292IGtoZXJzb24ga2htZWxuaXRza2l5IGtpZXYga2lyb3ZvZ3JhZCBrbSBrciBrcyBrdiBsZyBsdWdhbnNrIGx1dHNrIGx2aXYgbWUgbWsgbmV0IG5pa29sYWV2IG9kIG9kZXNzYSBvcmcgcGwgcG9sdGF2YSBwcCByb3ZubyBydiBzZWJhc3RvcG9sIHN1bXkgdGUgdGVybm9waWwgdXpoZ29yb2QgdmlubmljYSB2biB6YXBvcml6aHpoZSB6aGl0b21pciB6cCB6dCAnLFxuICAgICAgJ3VnJzonIGFjIGNvIGdvIG5lIG9yIG9yZyBzYyAnLFxuICAgICAgJ3VrJzonIGFjIGJsIGJyaXRpc2gtbGlicmFyeSBjbyBjeW0gZ292IGdvdnQgaWNuZXQgamV0IGxlYSBsdGQgbWUgbWlsIG1vZCBuYXRpb25hbC1saWJyYXJ5LXNjb3RsYW5kIG5lbCBuZXQgbmhzIG5pYyBubHMgb3JnIG9yZ24gcGFybGlhbWVudCBwbGMgcG9saWNlIHNjaCBzY290IHNvYyAnLFxuICAgICAgJ3VzJzonIGRuaSBmZWQgaXNhIGtpZHMgbnNuICcsXG4gICAgICAndXknOicgY29tIGVkdSBndWIgbWlsIG5ldCBvcmcgJyxcbiAgICAgICd2ZSc6JyBjbyBjb20gZWR1IGdvYiBpbmZvIG1pbCBuZXQgb3JnIHdlYiAnLFxuICAgICAgJ3ZpJzonIGNvIGNvbSBrMTIgbmV0IG9yZyAnLFxuICAgICAgJ3ZuJzonIGFjIGJpeiBjb20gZWR1IGdvdiBoZWFsdGggaW5mbyBpbnQgbmFtZSBuZXQgb3JnIHBybyAnLFxuICAgICAgJ3llJzonIGNvIGNvbSBnb3YgbHRkIG1lIG5ldCBvcmcgcGxjICcsXG4gICAgICAneXUnOicgYWMgY28gZWR1IGdvdiBvcmcgJyxcbiAgICAgICd6YSc6JyBhYyBhZ3JpYyBhbHQgYm91cnNlIGNpdHkgY28gY3liZXJuZXQgZGIgZWR1IGdvdiBncm9uZGFyIGlhY2Nlc3MgaW10IGluY2EgbGFuZGVzaWduIGxhdyBtaWwgbmV0IG5nbyBuaXMgbm9tIG9saXZldHRpIG9yZyBwaXggc2Nob29sIHRtIHdlYiAnLFxuICAgICAgJ3ptJzonIGFjIGNvIGNvbSBlZHUgZ292IG5ldCBvcmcgc2NoICdcbiAgICB9LFxuICAgIC8vIGdvcmhpbGwgMjAxMy0xMC0yNTogVXNpbmcgaW5kZXhPZigpIGluc3RlYWQgUmVnZXhwKCkuIFNpZ25pZmljYW50IGJvb3N0XG4gICAgLy8gaW4gYm90aCBwZXJmb3JtYW5jZSBhbmQgbWVtb3J5IGZvb3RwcmludC4gTm8gaW5pdGlhbGl6YXRpb24gcmVxdWlyZWQuXG4gICAgLy8gaHR0cDovL2pzcGVyZi5jb20vdXJpLWpzLXNsZC1yZWdleC12cy1iaW5hcnktc2VhcmNoLzRcbiAgICAvLyBGb2xsb3dpbmcgbWV0aG9kcyB1c2UgbGFzdEluZGV4T2YoKSByYXRoZXIgdGhhbiBhcnJheS5zcGxpdCgpIGluIG9yZGVyXG4gICAgLy8gdG8gYXZvaWQgYW55IG1lbW9yeSBhbGxvY2F0aW9ucy5cbiAgICBoYXM6IGZ1bmN0aW9uKGRvbWFpbikge1xuICAgICAgdmFyIHRsZE9mZnNldCA9IGRvbWFpbi5sYXN0SW5kZXhPZignLicpO1xuICAgICAgaWYgKHRsZE9mZnNldCA8PSAwIHx8IHRsZE9mZnNldCA+PSAoZG9tYWluLmxlbmd0aC0xKSkge1xuICAgICAgICByZXR1cm4gZmFsc2U7XG4gICAgICB9XG4gICAgICB2YXIgc2xkT2Zmc2V0ID0gZG9tYWluLmxhc3RJbmRleE9mKCcuJywgdGxkT2Zmc2V0LTEpO1xuICAgICAgaWYgKHNsZE9mZnNldCA8PSAwIHx8IHNsZE9mZnNldCA+PSAodGxkT2Zmc2V0LTEpKSB7XG4gICAgICAgIHJldHVybiBmYWxzZTtcbiAgICAgIH1cbiAgICAgIHZhciBzbGRMaXN0ID0gU0xELmxpc3RbZG9tYWluLnNsaWNlKHRsZE9mZnNldCsxKV07XG4gICAgICBpZiAoIXNsZExpc3QpIHtcbiAgICAgICAgcmV0dXJuIGZhbHNlO1xuICAgICAgfVxuICAgICAgcmV0dXJuIHNsZExpc3QuaW5kZXhPZignICcgKyBkb21haW4uc2xpY2Uoc2xkT2Zmc2V0KzEsIHRsZE9mZnNldCkgKyAnICcpID49IDA7XG4gICAgfSxcbiAgICBpczogZnVuY3Rpb24oZG9tYWluKSB7XG4gICAgICB2YXIgdGxkT2Zmc2V0ID0gZG9tYWluLmxhc3RJbmRleE9mKCcuJyk7XG4gICAgICBpZiAodGxkT2Zmc2V0IDw9IDAgfHwgdGxkT2Zmc2V0ID49IChkb21haW4ubGVuZ3RoLTEpKSB7XG4gICAgICAgIHJldHVybiBmYWxzZTtcbiAgICAgIH1cbiAgICAgIHZhciBzbGRPZmZzZXQgPSBkb21haW4ubGFzdEluZGV4T2YoJy4nLCB0bGRPZmZzZXQtMSk7XG4gICAgICBpZiAoc2xkT2Zmc2V0ID49IDApIHtcbiAgICAgICAgcmV0dXJuIGZhbHNlO1xuICAgICAgfVxuICAgICAgdmFyIHNsZExpc3QgPSBTTEQubGlzdFtkb21haW4uc2xpY2UodGxkT2Zmc2V0KzEpXTtcbiAgICAgIGlmICghc2xkTGlzdCkge1xuICAgICAgICByZXR1cm4gZmFsc2U7XG4gICAgICB9XG4gICAgICByZXR1cm4gc2xkTGlzdC5pbmRleE9mKCcgJyArIGRvbWFpbi5zbGljZSgwLCB0bGRPZmZzZXQpICsgJyAnKSA+PSAwO1xuICAgIH0sXG4gICAgZ2V0OiBmdW5jdGlvbihkb21haW4pIHtcbiAgICAgIHZhciB0bGRPZmZzZXQgPSBkb21haW4ubGFzdEluZGV4T2YoJy4nKTtcbiAgICAgIGlmICh0bGRPZmZzZXQgPD0gMCB8fCB0bGRPZmZzZXQgPj0gKGRvbWFpbi5sZW5ndGgtMSkpIHtcbiAgICAgICAgcmV0dXJuIG51bGw7XG4gICAgICB9XG4gICAgICB2YXIgc2xkT2Zmc2V0ID0gZG9tYWluLmxhc3RJbmRleE9mKCcuJywgdGxkT2Zmc2V0LTEpO1xuICAgICAgaWYgKHNsZE9mZnNldCA8PSAwIHx8IHNsZE9mZnNldCA+PSAodGxkT2Zmc2V0LTEpKSB7XG4gICAgICAgIHJldHVybiBudWxsO1xuICAgICAgfVxuICAgICAgdmFyIHNsZExpc3QgPSBTTEQubGlzdFtkb21haW4uc2xpY2UodGxkT2Zmc2V0KzEpXTtcbiAgICAgIGlmICghc2xkTGlzdCkge1xuICAgICAgICByZXR1cm4gbnVsbDtcbiAgICAgIH1cbiAgICAgIGlmIChzbGRMaXN0LmluZGV4T2YoJyAnICsgZG9tYWluLnNsaWNlKHNsZE9mZnNldCsxLCB0bGRPZmZzZXQpICsgJyAnKSA8IDApIHtcbiAgICAgICAgcmV0dXJuIG51bGw7XG4gICAgICB9XG4gICAgICByZXR1cm4gZG9tYWluLnNsaWNlKHNsZE9mZnNldCsxKTtcbiAgICB9LFxuICAgIG5vQ29uZmxpY3Q6IGZ1bmN0aW9uKCl7XG4gICAgICBpZiAocm9vdC5TZWNvbmRMZXZlbERvbWFpbnMgPT09IHRoaXMpIHtcbiAgICAgICAgcm9vdC5TZWNvbmRMZXZlbERvbWFpbnMgPSBfU2Vjb25kTGV2ZWxEb21haW5zO1xuICAgICAgfVxuICAgICAgcmV0dXJuIHRoaXM7XG4gICAgfVxuICB9O1xuXG4gIHJldHVybiBTTEQ7XG59KSk7XG4iLCIvKiFcbiAqIFVSSS5qcyAtIE11dGF0aW5nIFVSTHNcbiAqXG4gKiBWZXJzaW9uOiAxLjE3LjBcbiAqXG4gKiBBdXRob3I6IFJvZG5leSBSZWhtXG4gKiBXZWI6IGh0dHA6Ly9tZWRpYWxpemUuZ2l0aHViLmlvL1VSSS5qcy9cbiAqXG4gKiBMaWNlbnNlZCB1bmRlclxuICogICBNSVQgTGljZW5zZSBodHRwOi8vd3d3Lm9wZW5zb3VyY2Uub3JnL2xpY2Vuc2VzL21pdC1saWNlbnNlXG4gKiAgIEdQTCB2MyBodHRwOi8vb3BlbnNvdXJjZS5vcmcvbGljZW5zZXMvR1BMLTMuMFxuICpcbiAqL1xuKGZ1bmN0aW9uIChyb290LCBmYWN0b3J5KSB7XG4gICd1c2Ugc3RyaWN0JztcbiAgLy8gaHR0cHM6Ly9naXRodWIuY29tL3VtZGpzL3VtZC9ibG9iL21hc3Rlci9yZXR1cm5FeHBvcnRzLmpzXG4gIGlmICh0eXBlb2YgZXhwb3J0cyA9PT0gJ29iamVjdCcpIHtcbiAgICAvLyBOb2RlXG4gICAgbW9kdWxlLmV4cG9ydHMgPSBmYWN0b3J5KHJlcXVpcmUoJy4vcHVueWNvZGUnKSwgcmVxdWlyZSgnLi9JUHY2JyksIHJlcXVpcmUoJy4vU2Vjb25kTGV2ZWxEb21haW5zJykpO1xuICB9IGVsc2UgaWYgKHR5cGVvZiBkZWZpbmUgPT09ICdmdW5jdGlvbicgJiYgZGVmaW5lLmFtZCkge1xuICAgIC8vIEFNRC4gUmVnaXN0ZXIgYXMgYW4gYW5vbnltb3VzIG1vZHVsZS5cbiAgICBkZWZpbmUoWycuL3B1bnljb2RlJywgJy4vSVB2NicsICcuL1NlY29uZExldmVsRG9tYWlucyddLCBmYWN0b3J5KTtcbiAgfSBlbHNlIHtcbiAgICAvLyBCcm93c2VyIGdsb2JhbHMgKHJvb3QgaXMgd2luZG93KVxuICAgIHJvb3QuVVJJID0gZmFjdG9yeShyb290LnB1bnljb2RlLCByb290LklQdjYsIHJvb3QuU2Vjb25kTGV2ZWxEb21haW5zLCByb290KTtcbiAgfVxufSh0aGlzLCBmdW5jdGlvbiAocHVueWNvZGUsIElQdjYsIFNMRCwgcm9vdCkge1xuICAndXNlIHN0cmljdCc7XG4gIC8qZ2xvYmFsIGxvY2F0aW9uLCBlc2NhcGUsIHVuZXNjYXBlICovXG4gIC8vIEZJWE1FOiB2Mi4wLjAgcmVuYW1jZSBub24tY2FtZWxDYXNlIHByb3BlcnRpZXMgdG8gdXBwZXJjYXNlXG4gIC8qanNoaW50IGNhbWVsY2FzZTogZmFsc2UgKi9cblxuICAvLyBzYXZlIGN1cnJlbnQgVVJJIHZhcmlhYmxlLCBpZiBhbnlcbiAgdmFyIF9VUkkgPSByb290ICYmIHJvb3QuVVJJO1xuXG4gIGZ1bmN0aW9uIFVSSSh1cmwsIGJhc2UpIHtcbiAgICB2YXIgX3VybFN1cHBsaWVkID0gYXJndW1lbnRzLmxlbmd0aCA+PSAxO1xuICAgIHZhciBfYmFzZVN1cHBsaWVkID0gYXJndW1lbnRzLmxlbmd0aCA+PSAyO1xuXG4gICAgLy8gQWxsb3cgaW5zdGFudGlhdGlvbiB3aXRob3V0IHRoZSAnbmV3JyBrZXl3b3JkXG4gICAgaWYgKCEodGhpcyBpbnN0YW5jZW9mIFVSSSkpIHtcbiAgICAgIGlmIChfdXJsU3VwcGxpZWQpIHtcbiAgICAgICAgaWYgKF9iYXNlU3VwcGxpZWQpIHtcbiAgICAgICAgICByZXR1cm4gbmV3IFVSSSh1cmwsIGJhc2UpO1xuICAgICAgICB9XG5cbiAgICAgICAgcmV0dXJuIG5ldyBVUkkodXJsKTtcbiAgICAgIH1cblxuICAgICAgcmV0dXJuIG5ldyBVUkkoKTtcbiAgICB9XG5cbiAgICBpZiAodXJsID09PSB1bmRlZmluZWQpIHtcbiAgICAgIGlmIChfdXJsU3VwcGxpZWQpIHtcbiAgICAgICAgdGhyb3cgbmV3IFR5cGVFcnJvcigndW5kZWZpbmVkIGlzIG5vdCBhIHZhbGlkIGFyZ3VtZW50IGZvciBVUkknKTtcbiAgICAgIH1cblxuICAgICAgaWYgKHR5cGVvZiBsb2NhdGlvbiAhPT0gJ3VuZGVmaW5lZCcpIHtcbiAgICAgICAgdXJsID0gbG9jYXRpb24uaHJlZiArICcnO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgdXJsID0gJyc7XG4gICAgICB9XG4gICAgfVxuXG4gICAgdGhpcy5ocmVmKHVybCk7XG5cbiAgICAvLyByZXNvbHZlIHRvIGJhc2UgYWNjb3JkaW5nIHRvIGh0dHA6Ly9kdmNzLnczLm9yZy9oZy91cmwvcmF3LWZpbGUvdGlwL092ZXJ2aWV3Lmh0bWwjY29uc3RydWN0b3JcbiAgICBpZiAoYmFzZSAhPT0gdW5kZWZpbmVkKSB7XG4gICAgICByZXR1cm4gdGhpcy5hYnNvbHV0ZVRvKGJhc2UpO1xuICAgIH1cblxuICAgIHJldHVybiB0aGlzO1xuICB9XG5cbiAgVVJJLnZlcnNpb24gPSAnMS4xNy4wJztcblxuICB2YXIgcCA9IFVSSS5wcm90b3R5cGU7XG4gIHZhciBoYXNPd24gPSBPYmplY3QucHJvdG90eXBlLmhhc093blByb3BlcnR5O1xuXG4gIGZ1bmN0aW9uIGVzY2FwZVJlZ0V4KHN0cmluZykge1xuICAgIC8vIGh0dHBzOi8vZ2l0aHViLmNvbS9tZWRpYWxpemUvVVJJLmpzL2NvbW1pdC84NWFjMjE3ODNjMTFmOGNjYWIwNjEwNmRiYTk3MzVhMzFhODY5MjRkI2NvbW1pdGNvbW1lbnQtODIxOTYzXG4gICAgcmV0dXJuIHN0cmluZy5yZXBsYWNlKC8oWy4qKz9ePSE6JHt9KCl8W1xcXVxcL1xcXFxdKS9nLCAnXFxcXCQxJyk7XG4gIH1cblxuICBmdW5jdGlvbiBnZXRUeXBlKHZhbHVlKSB7XG4gICAgLy8gSUU4IGRvZXNuJ3QgcmV0dXJuIFtPYmplY3QgVW5kZWZpbmVkXSBidXQgW09iamVjdCBPYmplY3RdIGZvciB1bmRlZmluZWQgdmFsdWVcbiAgICBpZiAodmFsdWUgPT09IHVuZGVmaW5lZCkge1xuICAgICAgcmV0dXJuICdVbmRlZmluZWQnO1xuICAgIH1cblxuICAgIHJldHVybiBTdHJpbmcoT2JqZWN0LnByb3RvdHlwZS50b1N0cmluZy5jYWxsKHZhbHVlKSkuc2xpY2UoOCwgLTEpO1xuICB9XG5cbiAgZnVuY3Rpb24gaXNBcnJheShvYmopIHtcbiAgICByZXR1cm4gZ2V0VHlwZShvYmopID09PSAnQXJyYXknO1xuICB9XG5cbiAgZnVuY3Rpb24gZmlsdGVyQXJyYXlWYWx1ZXMoZGF0YSwgdmFsdWUpIHtcbiAgICB2YXIgbG9va3VwID0ge307XG4gICAgdmFyIGksIGxlbmd0aDtcblxuICAgIGlmIChnZXRUeXBlKHZhbHVlKSA9PT0gJ1JlZ0V4cCcpIHtcbiAgICAgIGxvb2t1cCA9IG51bGw7XG4gICAgfSBlbHNlIGlmIChpc0FycmF5KHZhbHVlKSkge1xuICAgICAgZm9yIChpID0gMCwgbGVuZ3RoID0gdmFsdWUubGVuZ3RoOyBpIDwgbGVuZ3RoOyBpKyspIHtcbiAgICAgICAgbG9va3VwW3ZhbHVlW2ldXSA9IHRydWU7XG4gICAgICB9XG4gICAgfSBlbHNlIHtcbiAgICAgIGxvb2t1cFt2YWx1ZV0gPSB0cnVlO1xuICAgIH1cblxuICAgIGZvciAoaSA9IDAsIGxlbmd0aCA9IGRhdGEubGVuZ3RoOyBpIDwgbGVuZ3RoOyBpKyspIHtcbiAgICAgIC8qanNoaW50IGxheGJyZWFrOiB0cnVlICovXG4gICAgICB2YXIgX21hdGNoID0gbG9va3VwICYmIGxvb2t1cFtkYXRhW2ldXSAhPT0gdW5kZWZpbmVkXG4gICAgICAgIHx8ICFsb29rdXAgJiYgdmFsdWUudGVzdChkYXRhW2ldKTtcbiAgICAgIC8qanNoaW50IGxheGJyZWFrOiBmYWxzZSAqL1xuICAgICAgaWYgKF9tYXRjaCkge1xuICAgICAgICBkYXRhLnNwbGljZShpLCAxKTtcbiAgICAgICAgbGVuZ3RoLS07XG4gICAgICAgIGktLTtcbiAgICAgIH1cbiAgICB9XG5cbiAgICByZXR1cm4gZGF0YTtcbiAgfVxuXG4gIGZ1bmN0aW9uIGFycmF5Q29udGFpbnMobGlzdCwgdmFsdWUpIHtcbiAgICB2YXIgaSwgbGVuZ3RoO1xuXG4gICAgLy8gdmFsdWUgbWF5IGJlIHN0cmluZywgbnVtYmVyLCBhcnJheSwgcmVnZXhwXG4gICAgaWYgKGlzQXJyYXkodmFsdWUpKSB7XG4gICAgICAvLyBOb3RlOiB0aGlzIGNhbiBiZSBvcHRpbWl6ZWQgdG8gTyhuKSAoaW5zdGVhZCBvZiBjdXJyZW50IE8obSAqIG4pKVxuICAgICAgZm9yIChpID0gMCwgbGVuZ3RoID0gdmFsdWUubGVuZ3RoOyBpIDwgbGVuZ3RoOyBpKyspIHtcbiAgICAgICAgaWYgKCFhcnJheUNvbnRhaW5zKGxpc3QsIHZhbHVlW2ldKSkge1xuICAgICAgICAgIHJldHVybiBmYWxzZTtcbiAgICAgICAgfVxuICAgICAgfVxuXG4gICAgICByZXR1cm4gdHJ1ZTtcbiAgICB9XG5cbiAgICB2YXIgX3R5cGUgPSBnZXRUeXBlKHZhbHVlKTtcbiAgICBmb3IgKGkgPSAwLCBsZW5ndGggPSBsaXN0Lmxlbmd0aDsgaSA8IGxlbmd0aDsgaSsrKSB7XG4gICAgICBpZiAoX3R5cGUgPT09ICdSZWdFeHAnKSB7XG4gICAgICAgIGlmICh0eXBlb2YgbGlzdFtpXSA9PT0gJ3N0cmluZycgJiYgbGlzdFtpXS5tYXRjaCh2YWx1ZSkpIHtcbiAgICAgICAgICByZXR1cm4gdHJ1ZTtcbiAgICAgICAgfVxuICAgICAgfSBlbHNlIGlmIChsaXN0W2ldID09PSB2YWx1ZSkge1xuICAgICAgICByZXR1cm4gdHJ1ZTtcbiAgICAgIH1cbiAgICB9XG5cbiAgICByZXR1cm4gZmFsc2U7XG4gIH1cblxuICBmdW5jdGlvbiBhcnJheXNFcXVhbChvbmUsIHR3bykge1xuICAgIGlmICghaXNBcnJheShvbmUpIHx8ICFpc0FycmF5KHR3bykpIHtcbiAgICAgIHJldHVybiBmYWxzZTtcbiAgICB9XG5cbiAgICAvLyBhcnJheXMgY2FuJ3QgYmUgZXF1YWwgaWYgdGhleSBoYXZlIGRpZmZlcmVudCBhbW91bnQgb2YgY29udGVudFxuICAgIGlmIChvbmUubGVuZ3RoICE9PSB0d28ubGVuZ3RoKSB7XG4gICAgICByZXR1cm4gZmFsc2U7XG4gICAgfVxuXG4gICAgb25lLnNvcnQoKTtcbiAgICB0d28uc29ydCgpO1xuXG4gICAgZm9yICh2YXIgaSA9IDAsIGwgPSBvbmUubGVuZ3RoOyBpIDwgbDsgaSsrKSB7XG4gICAgICBpZiAob25lW2ldICE9PSB0d29baV0pIHtcbiAgICAgICAgcmV0dXJuIGZhbHNlO1xuICAgICAgfVxuICAgIH1cblxuICAgIHJldHVybiB0cnVlO1xuICB9XG5cbiAgZnVuY3Rpb24gdHJpbVNsYXNoZXModGV4dCkge1xuICAgIHZhciB0cmltX2V4cHJlc3Npb24gPSAvXlxcLyt8XFwvKyQvZztcbiAgICByZXR1cm4gdGV4dC5yZXBsYWNlKHRyaW1fZXhwcmVzc2lvbiwgJycpO1xuICB9XG5cbiAgVVJJLl9wYXJ0cyA9IGZ1bmN0aW9uKCkge1xuICAgIHJldHVybiB7XG4gICAgICBwcm90b2NvbDogbnVsbCxcbiAgICAgIHVzZXJuYW1lOiBudWxsLFxuICAgICAgcGFzc3dvcmQ6IG51bGwsXG4gICAgICBob3N0bmFtZTogbnVsbCxcbiAgICAgIHVybjogbnVsbCxcbiAgICAgIHBvcnQ6IG51bGwsXG4gICAgICBwYXRoOiBudWxsLFxuICAgICAgcXVlcnk6IG51bGwsXG4gICAgICBmcmFnbWVudDogbnVsbCxcbiAgICAgIC8vIHN0YXRlXG4gICAgICBkdXBsaWNhdGVRdWVyeVBhcmFtZXRlcnM6IFVSSS5kdXBsaWNhdGVRdWVyeVBhcmFtZXRlcnMsXG4gICAgICBlc2NhcGVRdWVyeVNwYWNlOiBVUkkuZXNjYXBlUXVlcnlTcGFjZVxuICAgIH07XG4gIH07XG4gIC8vIHN0YXRlOiBhbGxvdyBkdXBsaWNhdGUgcXVlcnkgcGFyYW1ldGVycyAoYT0xJmE9MSlcbiAgVVJJLmR1cGxpY2F0ZVF1ZXJ5UGFyYW1ldGVycyA9IGZhbHNlO1xuICAvLyBzdGF0ZTogcmVwbGFjZXMgKyB3aXRoICUyMCAoc3BhY2UgaW4gcXVlcnkgc3RyaW5ncylcbiAgVVJJLmVzY2FwZVF1ZXJ5U3BhY2UgPSB0cnVlO1xuICAvLyBzdGF0aWMgcHJvcGVydGllc1xuICBVUkkucHJvdG9jb2xfZXhwcmVzc2lvbiA9IC9eW2Etel1bYS16MC05ListXSokL2k7XG4gIFVSSS5pZG5fZXhwcmVzc2lvbiA9IC9bXmEtejAtOVxcLi1dL2k7XG4gIFVSSS5wdW55Y29kZV9leHByZXNzaW9uID0gLyh4bi0tKS9pO1xuICAvLyB3ZWxsLCAzMzMuNDQ0LjU1NS42NjYgbWF0Y2hlcywgYnV0IGl0IHN1cmUgYWluJ3Qgbm8gSVB2NCAtIGRvIHdlIGNhcmU/XG4gIFVSSS5pcDRfZXhwcmVzc2lvbiA9IC9eXFxkezEsM31cXC5cXGR7MSwzfVxcLlxcZHsxLDN9XFwuXFxkezEsM30kLztcbiAgLy8gY3JlZGl0cyB0byBSaWNoIEJyb3duXG4gIC8vIHNvdXJjZTogaHR0cDovL2ZvcnVtcy5pbnRlcm1hcHBlci5jb20vdmlld3RvcGljLnBocD9wPTEwOTYjMTA5NlxuICAvLyBzcGVjaWZpY2F0aW9uOiBodHRwOi8vd3d3LmlldGYub3JnL3JmYy9yZmM0MjkxLnR4dFxuICBVUkkuaXA2X2V4cHJlc3Npb24gPSAvXlxccyooKChbMC05QS1GYS1mXXsxLDR9Oil7N30oWzAtOUEtRmEtZl17MSw0fXw6KSl8KChbMC05QS1GYS1mXXsxLDR9Oil7Nn0oOlswLTlBLUZhLWZdezEsNH18KCgyNVswLTVdfDJbMC00XVxcZHwxXFxkXFxkfFsxLTldP1xcZCkoXFwuKDI1WzAtNV18MlswLTRdXFxkfDFcXGRcXGR8WzEtOV0/XFxkKSl7M30pfDopKXwoKFswLTlBLUZhLWZdezEsNH06KXs1fSgoKDpbMC05QS1GYS1mXXsxLDR9KXsxLDJ9KXw6KCgyNVswLTVdfDJbMC00XVxcZHwxXFxkXFxkfFsxLTldP1xcZCkoXFwuKDI1WzAtNV18MlswLTRdXFxkfDFcXGRcXGR8WzEtOV0/XFxkKSl7M30pfDopKXwoKFswLTlBLUZhLWZdezEsNH06KXs0fSgoKDpbMC05QS1GYS1mXXsxLDR9KXsxLDN9KXwoKDpbMC05QS1GYS1mXXsxLDR9KT86KCgyNVswLTVdfDJbMC00XVxcZHwxXFxkXFxkfFsxLTldP1xcZCkoXFwuKDI1WzAtNV18MlswLTRdXFxkfDFcXGRcXGR8WzEtOV0/XFxkKSl7M30pKXw6KSl8KChbMC05QS1GYS1mXXsxLDR9Oil7M30oKCg6WzAtOUEtRmEtZl17MSw0fSl7MSw0fSl8KCg6WzAtOUEtRmEtZl17MSw0fSl7MCwyfTooKDI1WzAtNV18MlswLTRdXFxkfDFcXGRcXGR8WzEtOV0/XFxkKShcXC4oMjVbMC01XXwyWzAtNF1cXGR8MVxcZFxcZHxbMS05XT9cXGQpKXszfSkpfDopKXwoKFswLTlBLUZhLWZdezEsNH06KXsyfSgoKDpbMC05QS1GYS1mXXsxLDR9KXsxLDV9KXwoKDpbMC05QS1GYS1mXXsxLDR9KXswLDN9OigoMjVbMC01XXwyWzAtNF1cXGR8MVxcZFxcZHxbMS05XT9cXGQpKFxcLigyNVswLTVdfDJbMC00XVxcZHwxXFxkXFxkfFsxLTldP1xcZCkpezN9KSl8OikpfCgoWzAtOUEtRmEtZl17MSw0fTopezF9KCgoOlswLTlBLUZhLWZdezEsNH0pezEsNn0pfCgoOlswLTlBLUZhLWZdezEsNH0pezAsNH06KCgyNVswLTVdfDJbMC00XVxcZHwxXFxkXFxkfFsxLTldP1xcZCkoXFwuKDI1WzAtNV18MlswLTRdXFxkfDFcXGRcXGR8WzEtOV0/XFxkKSl7M30pKXw6KSl8KDooKCg6WzAtOUEtRmEtZl17MSw0fSl7MSw3fSl8KCg6WzAtOUEtRmEtZl17MSw0fSl7MCw1fTooKDI1WzAtNV18MlswLTRdXFxkfDFcXGRcXGR8WzEtOV0/XFxkKShcXC4oMjVbMC01XXwyWzAtNF1cXGR8MVxcZFxcZHxbMS05XT9cXGQpKXszfSkpfDopKSkoJS4rKT9cXHMqJC87XG4gIC8vIGV4cHJlc3Npb24gdXNlZCBpcyBcImdydWJlciByZXZpc2VkXCIgKEBncnViZXIgdjIpIGRldGVybWluZWQgdG8gYmUgdGhlXG4gIC8vIGJlc3Qgc29sdXRpb24gaW4gYSByZWdleC1nb2xmIHdlIGRpZCBhIGNvdXBsZSBvZiBhZ2VzIGFnbyBhdFxuICAvLyAqIGh0dHA6Ly9tYXRoaWFzYnluZW5zLmJlL2RlbW8vdXJsLXJlZ2V4XG4gIC8vICogaHR0cDovL3JvZG5leXJlaG0uZGUvdC91cmwtcmVnZXguaHRtbFxuICBVUkkuZmluZF91cmlfZXhwcmVzc2lvbiA9IC9cXGIoKD86W2Etel1bXFx3LV0rOig/OlxcL3sxLDN9fFthLXowLTklXSl8d3d3XFxkezAsM31bLl18W2EtejAtOS5cXC1dK1suXVthLXpdezIsNH1cXC8pKD86W15cXHMoKTw+XSt8XFwoKFteXFxzKCk8Pl0rfChcXChbXlxccygpPD5dK1xcKSkpKlxcKSkrKD86XFwoKFteXFxzKCk8Pl0rfChcXChbXlxccygpPD5dK1xcKSkpKlxcKXxbXlxcc2AhKClcXFtcXF17fTs6J1wiLiw8Pj/Cq8K74oCc4oCd4oCY4oCZXSkpL2lnO1xuICBVUkkuZmluZFVyaSA9IHtcbiAgICAvLyB2YWxpZCBcInNjaGVtZTovL1wiIG9yIFwid3d3LlwiXG4gICAgc3RhcnQ6IC9cXGIoPzooW2Etel1bYS16MC05ListXSo6XFwvXFwvKXx3d3dcXC4pL2dpLFxuICAgIC8vIGV2ZXJ5dGhpbmcgdXAgdG8gdGhlIG5leHQgd2hpdGVzcGFjZVxuICAgIGVuZDogL1tcXHNcXHJcXG5dfCQvLFxuICAgIC8vIHRyaW0gdHJhaWxpbmcgcHVuY3R1YXRpb24gY2FwdHVyZWQgYnkgZW5kIFJlZ0V4cFxuICAgIHRyaW06IC9bYCEoKVxcW1xcXXt9OzonXCIuLDw+P8KrwrvigJzigJ3igJ7igJjigJldKyQvXG4gIH07XG4gIC8vIGh0dHA6Ly93d3cuaWFuYS5vcmcvYXNzaWdubWVudHMvdXJpLXNjaGVtZXMuaHRtbFxuICAvLyBodHRwOi8vZW4ud2lraXBlZGlhLm9yZy93aWtpL0xpc3Rfb2ZfVENQX2FuZF9VRFBfcG9ydF9udW1iZXJzI1dlbGwta25vd25fcG9ydHNcbiAgVVJJLmRlZmF1bHRQb3J0cyA9IHtcbiAgICBodHRwOiAnODAnLFxuICAgIGh0dHBzOiAnNDQzJyxcbiAgICBmdHA6ICcyMScsXG4gICAgZ29waGVyOiAnNzAnLFxuICAgIHdzOiAnODAnLFxuICAgIHdzczogJzQ0MydcbiAgfTtcbiAgLy8gYWxsb3dlZCBob3N0bmFtZSBjaGFyYWN0ZXJzIGFjY29yZGluZyB0byBSRkMgMzk4NlxuICAvLyBBTFBIQSBESUdJVCBcIi1cIiBcIi5cIiBcIl9cIiBcIn5cIiBcIiFcIiBcIiRcIiBcIiZcIiBcIidcIiBcIihcIiBcIilcIiBcIipcIiBcIitcIiBcIixcIiBcIjtcIiBcIj1cIiAlZW5jb2RlZFxuICAvLyBJJ3ZlIG5ldmVyIHNlZW4gYSAobm9uLUlETikgaG9zdG5hbWUgb3RoZXIgdGhhbjogQUxQSEEgRElHSVQgLiAtXG4gIFVSSS5pbnZhbGlkX2hvc3RuYW1lX2NoYXJhY3RlcnMgPSAvW15hLXpBLVowLTlcXC4tXS87XG4gIC8vIG1hcCBET00gRWxlbWVudHMgdG8gdGhlaXIgVVJJIGF0dHJpYnV0ZVxuICBVUkkuZG9tQXR0cmlidXRlcyA9IHtcbiAgICAnYSc6ICdocmVmJyxcbiAgICAnYmxvY2txdW90ZSc6ICdjaXRlJyxcbiAgICAnbGluayc6ICdocmVmJyxcbiAgICAnYmFzZSc6ICdocmVmJyxcbiAgICAnc2NyaXB0JzogJ3NyYycsXG4gICAgJ2Zvcm0nOiAnYWN0aW9uJyxcbiAgICAnaW1nJzogJ3NyYycsXG4gICAgJ2FyZWEnOiAnaHJlZicsXG4gICAgJ2lmcmFtZSc6ICdzcmMnLFxuICAgICdlbWJlZCc6ICdzcmMnLFxuICAgICdzb3VyY2UnOiAnc3JjJyxcbiAgICAndHJhY2snOiAnc3JjJyxcbiAgICAnaW5wdXQnOiAnc3JjJywgLy8gYnV0IG9ubHkgaWYgdHlwZT1cImltYWdlXCJcbiAgICAnYXVkaW8nOiAnc3JjJyxcbiAgICAndmlkZW8nOiAnc3JjJ1xuICB9O1xuICBVUkkuZ2V0RG9tQXR0cmlidXRlID0gZnVuY3Rpb24obm9kZSkge1xuICAgIGlmICghbm9kZSB8fCAhbm9kZS5ub2RlTmFtZSkge1xuICAgICAgcmV0dXJuIHVuZGVmaW5lZDtcbiAgICB9XG5cbiAgICB2YXIgbm9kZU5hbWUgPSBub2RlLm5vZGVOYW1lLnRvTG93ZXJDYXNlKCk7XG4gICAgLy8gPGlucHV0PiBzaG91bGQgb25seSBleHBvc2Ugc3JjIGZvciB0eXBlPVwiaW1hZ2VcIlxuICAgIGlmIChub2RlTmFtZSA9PT0gJ2lucHV0JyAmJiBub2RlLnR5cGUgIT09ICdpbWFnZScpIHtcbiAgICAgIHJldHVybiB1bmRlZmluZWQ7XG4gICAgfVxuXG4gICAgcmV0dXJuIFVSSS5kb21BdHRyaWJ1dGVzW25vZGVOYW1lXTtcbiAgfTtcblxuICBmdW5jdGlvbiBlc2NhcGVGb3JEdW1iRmlyZWZveDM2KHZhbHVlKSB7XG4gICAgLy8gaHR0cHM6Ly9naXRodWIuY29tL21lZGlhbGl6ZS9VUkkuanMvaXNzdWVzLzkxXG4gICAgcmV0dXJuIGVzY2FwZSh2YWx1ZSk7XG4gIH1cblxuICAvLyBlbmNvZGluZyAvIGRlY29kaW5nIGFjY29yZGluZyB0byBSRkMzOTg2XG4gIGZ1bmN0aW9uIHN0cmljdEVuY29kZVVSSUNvbXBvbmVudChzdHJpbmcpIHtcbiAgICAvLyBzZWUgaHR0cHM6Ly9kZXZlbG9wZXIubW96aWxsYS5vcmcvZW4tVVMvZG9jcy9KYXZhU2NyaXB0L1JlZmVyZW5jZS9HbG9iYWxfT2JqZWN0cy9lbmNvZGVVUklDb21wb25lbnRcbiAgICByZXR1cm4gZW5jb2RlVVJJQ29tcG9uZW50KHN0cmluZylcbiAgICAgIC5yZXBsYWNlKC9bIScoKSpdL2csIGVzY2FwZUZvckR1bWJGaXJlZm94MzYpXG4gICAgICAucmVwbGFjZSgvXFwqL2csICclMkEnKTtcbiAgfVxuICBVUkkuZW5jb2RlID0gc3RyaWN0RW5jb2RlVVJJQ29tcG9uZW50O1xuICBVUkkuZGVjb2RlID0gZGVjb2RlVVJJQ29tcG9uZW50O1xuICBVUkkuaXNvODg1OSA9IGZ1bmN0aW9uKCkge1xuICAgIFVSSS5lbmNvZGUgPSBlc2NhcGU7XG4gICAgVVJJLmRlY29kZSA9IHVuZXNjYXBlO1xuICB9O1xuICBVUkkudW5pY29kZSA9IGZ1bmN0aW9uKCkge1xuICAgIFVSSS5lbmNvZGUgPSBzdHJpY3RFbmNvZGVVUklDb21wb25lbnQ7XG4gICAgVVJJLmRlY29kZSA9IGRlY29kZVVSSUNvbXBvbmVudDtcbiAgfTtcbiAgVVJJLmNoYXJhY3RlcnMgPSB7XG4gICAgcGF0aG5hbWU6IHtcbiAgICAgIGVuY29kZToge1xuICAgICAgICAvLyBSRkMzOTg2IDIuMTogRm9yIGNvbnNpc3RlbmN5LCBVUkkgcHJvZHVjZXJzIGFuZCBub3JtYWxpemVycyBzaG91bGRcbiAgICAgICAgLy8gdXNlIHVwcGVyY2FzZSBoZXhhZGVjaW1hbCBkaWdpdHMgZm9yIGFsbCBwZXJjZW50LWVuY29kaW5ncy5cbiAgICAgICAgZXhwcmVzc2lvbjogLyUoMjR8MjZ8MkJ8MkN8M0J8M0R8M0F8NDApL2lnLFxuICAgICAgICBtYXA6IHtcbiAgICAgICAgICAvLyAtLl9+IScoKSpcbiAgICAgICAgICAnJTI0JzogJyQnLFxuICAgICAgICAgICclMjYnOiAnJicsXG4gICAgICAgICAgJyUyQic6ICcrJyxcbiAgICAgICAgICAnJTJDJzogJywnLFxuICAgICAgICAgICclM0InOiAnOycsXG4gICAgICAgICAgJyUzRCc6ICc9JyxcbiAgICAgICAgICAnJTNBJzogJzonLFxuICAgICAgICAgICclNDAnOiAnQCdcbiAgICAgICAgfVxuICAgICAgfSxcbiAgICAgIGRlY29kZToge1xuICAgICAgICBleHByZXNzaW9uOiAvW1xcL1xcPyNdL2csXG4gICAgICAgIG1hcDoge1xuICAgICAgICAgICcvJzogJyUyRicsXG4gICAgICAgICAgJz8nOiAnJTNGJyxcbiAgICAgICAgICAnIyc6ICclMjMnXG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9LFxuICAgIHJlc2VydmVkOiB7XG4gICAgICBlbmNvZGU6IHtcbiAgICAgICAgLy8gUkZDMzk4NiAyLjE6IEZvciBjb25zaXN0ZW5jeSwgVVJJIHByb2R1Y2VycyBhbmQgbm9ybWFsaXplcnMgc2hvdWxkXG4gICAgICAgIC8vIHVzZSB1cHBlcmNhc2UgaGV4YWRlY2ltYWwgZGlnaXRzIGZvciBhbGwgcGVyY2VudC1lbmNvZGluZ3MuXG4gICAgICAgIGV4cHJlc3Npb246IC8lKDIxfDIzfDI0fDI2fDI3fDI4fDI5fDJBfDJCfDJDfDJGfDNBfDNCfDNEfDNGfDQwfDVCfDVEKS9pZyxcbiAgICAgICAgbWFwOiB7XG4gICAgICAgICAgLy8gZ2VuLWRlbGltc1xuICAgICAgICAgICclM0EnOiAnOicsXG4gICAgICAgICAgJyUyRic6ICcvJyxcbiAgICAgICAgICAnJTNGJzogJz8nLFxuICAgICAgICAgICclMjMnOiAnIycsXG4gICAgICAgICAgJyU1Qic6ICdbJyxcbiAgICAgICAgICAnJTVEJzogJ10nLFxuICAgICAgICAgICclNDAnOiAnQCcsXG4gICAgICAgICAgLy8gc3ViLWRlbGltc1xuICAgICAgICAgICclMjEnOiAnIScsXG4gICAgICAgICAgJyUyNCc6ICckJyxcbiAgICAgICAgICAnJTI2JzogJyYnLFxuICAgICAgICAgICclMjcnOiAnXFwnJyxcbiAgICAgICAgICAnJTI4JzogJygnLFxuICAgICAgICAgICclMjknOiAnKScsXG4gICAgICAgICAgJyUyQSc6ICcqJyxcbiAgICAgICAgICAnJTJCJzogJysnLFxuICAgICAgICAgICclMkMnOiAnLCcsXG4gICAgICAgICAgJyUzQic6ICc7JyxcbiAgICAgICAgICAnJTNEJzogJz0nXG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9LFxuICAgIHVybnBhdGg6IHtcbiAgICAgIC8vIFRoZSBjaGFyYWN0ZXJzIHVuZGVyIGBlbmNvZGVgIGFyZSB0aGUgY2hhcmFjdGVycyBjYWxsZWQgb3V0IGJ5IFJGQyAyMTQxIGFzIGJlaW5nIGFjY2VwdGFibGVcbiAgICAgIC8vIGZvciB1c2FnZSBpbiBhIFVSTi4gUkZDMjE0MSBhbHNvIGNhbGxzIG91dCBcIi1cIiwgXCIuXCIsIGFuZCBcIl9cIiBhcyBhY2NlcHRhYmxlIGNoYXJhY3RlcnMsIGJ1dFxuICAgICAgLy8gdGhlc2UgYXJlbid0IGVuY29kZWQgYnkgZW5jb2RlVVJJQ29tcG9uZW50LCBzbyB3ZSBkb24ndCBoYXZlIHRvIGNhbGwgdGhlbSBvdXQgaGVyZS4gQWxzb1xuICAgICAgLy8gbm90ZSB0aGF0IHRoZSBjb2xvbiBjaGFyYWN0ZXIgaXMgbm90IGZlYXR1cmVkIGluIHRoZSBlbmNvZGluZyBtYXA7IHRoaXMgaXMgYmVjYXVzZSBVUkkuanNcbiAgICAgIC8vIGdpdmVzIHRoZSBjb2xvbnMgaW4gVVJOcyBzZW1hbnRpYyBtZWFuaW5nIGFzIHRoZSBkZWxpbWl0ZXJzIG9mIHBhdGggc2VnZW1lbnRzLCBhbmQgc28gaXRcbiAgICAgIC8vIHNob3VsZCBub3QgYXBwZWFyIHVuZW5jb2RlZCBpbiBhIHNlZ21lbnQgaXRzZWxmLlxuICAgICAgLy8gU2VlIGFsc28gdGhlIG5vdGUgYWJvdmUgYWJvdXQgUkZDMzk4NiBhbmQgY2FwaXRhbGFsaXplZCBoZXggZGlnaXRzLlxuICAgICAgZW5jb2RlOiB7XG4gICAgICAgIGV4cHJlc3Npb246IC8lKDIxfDI0fDI3fDI4fDI5fDJBfDJCfDJDfDNCfDNEfDQwKS9pZyxcbiAgICAgICAgbWFwOiB7XG4gICAgICAgICAgJyUyMSc6ICchJyxcbiAgICAgICAgICAnJTI0JzogJyQnLFxuICAgICAgICAgICclMjcnOiAnXFwnJyxcbiAgICAgICAgICAnJTI4JzogJygnLFxuICAgICAgICAgICclMjknOiAnKScsXG4gICAgICAgICAgJyUyQSc6ICcqJyxcbiAgICAgICAgICAnJTJCJzogJysnLFxuICAgICAgICAgICclMkMnOiAnLCcsXG4gICAgICAgICAgJyUzQic6ICc7JyxcbiAgICAgICAgICAnJTNEJzogJz0nLFxuICAgICAgICAgICclNDAnOiAnQCdcbiAgICAgICAgfVxuICAgICAgfSxcbiAgICAgIC8vIFRoZXNlIGNoYXJhY3RlcnMgYXJlIHRoZSBjaGFyYWN0ZXJzIGNhbGxlZCBvdXQgYnkgUkZDMjE0MSBhcyBcInJlc2VydmVkXCIgY2hhcmFjdGVycyB0aGF0XG4gICAgICAvLyBzaG91bGQgbmV2ZXIgYXBwZWFyIGluIGEgVVJOLCBwbHVzIHRoZSBjb2xvbiBjaGFyYWN0ZXIgKHNlZSBub3RlIGFib3ZlKS5cbiAgICAgIGRlY29kZToge1xuICAgICAgICBleHByZXNzaW9uOiAvW1xcL1xcPyM6XS9nLFxuICAgICAgICBtYXA6IHtcbiAgICAgICAgICAnLyc6ICclMkYnLFxuICAgICAgICAgICc/JzogJyUzRicsXG4gICAgICAgICAgJyMnOiAnJTIzJyxcbiAgICAgICAgICAnOic6ICclM0EnXG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9XG4gIH07XG4gIFVSSS5lbmNvZGVRdWVyeSA9IGZ1bmN0aW9uKHN0cmluZywgZXNjYXBlUXVlcnlTcGFjZSkge1xuICAgIHZhciBlc2NhcGVkID0gVVJJLmVuY29kZShzdHJpbmcgKyAnJyk7XG4gICAgaWYgKGVzY2FwZVF1ZXJ5U3BhY2UgPT09IHVuZGVmaW5lZCkge1xuICAgICAgZXNjYXBlUXVlcnlTcGFjZSA9IFVSSS5lc2NhcGVRdWVyeVNwYWNlO1xuICAgIH1cblxuICAgIHJldHVybiBlc2NhcGVRdWVyeVNwYWNlID8gZXNjYXBlZC5yZXBsYWNlKC8lMjAvZywgJysnKSA6IGVzY2FwZWQ7XG4gIH07XG4gIFVSSS5kZWNvZGVRdWVyeSA9IGZ1bmN0aW9uKHN0cmluZywgZXNjYXBlUXVlcnlTcGFjZSkge1xuICAgIHN0cmluZyArPSAnJztcbiAgICBpZiAoZXNjYXBlUXVlcnlTcGFjZSA9PT0gdW5kZWZpbmVkKSB7XG4gICAgICBlc2NhcGVRdWVyeVNwYWNlID0gVVJJLmVzY2FwZVF1ZXJ5U3BhY2U7XG4gICAgfVxuXG4gICAgdHJ5IHtcbiAgICAgIHJldHVybiBVUkkuZGVjb2RlKGVzY2FwZVF1ZXJ5U3BhY2UgPyBzdHJpbmcucmVwbGFjZSgvXFwrL2csICclMjAnKSA6IHN0cmluZyk7XG4gICAgfSBjYXRjaChlKSB7XG4gICAgICAvLyB3ZSdyZSBub3QgZ29pbmcgdG8gbWVzcyB3aXRoIHdlaXJkIGVuY29kaW5ncyxcbiAgICAgIC8vIGdpdmUgdXAgYW5kIHJldHVybiB0aGUgdW5kZWNvZGVkIG9yaWdpbmFsIHN0cmluZ1xuICAgICAgLy8gc2VlIGh0dHBzOi8vZ2l0aHViLmNvbS9tZWRpYWxpemUvVVJJLmpzL2lzc3Vlcy84N1xuICAgICAgLy8gc2VlIGh0dHBzOi8vZ2l0aHViLmNvbS9tZWRpYWxpemUvVVJJLmpzL2lzc3Vlcy85MlxuICAgICAgcmV0dXJuIHN0cmluZztcbiAgICB9XG4gIH07XG4gIC8vIGdlbmVyYXRlIGVuY29kZS9kZWNvZGUgcGF0aCBmdW5jdGlvbnNcbiAgdmFyIF9wYXJ0cyA9IHsnZW5jb2RlJzonZW5jb2RlJywgJ2RlY29kZSc6J2RlY29kZSd9O1xuICB2YXIgX3BhcnQ7XG4gIHZhciBnZW5lcmF0ZUFjY2Vzc29yID0gZnVuY3Rpb24oX2dyb3VwLCBfcGFydCkge1xuICAgIHJldHVybiBmdW5jdGlvbihzdHJpbmcpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIHJldHVybiBVUklbX3BhcnRdKHN0cmluZyArICcnKS5yZXBsYWNlKFVSSS5jaGFyYWN0ZXJzW19ncm91cF1bX3BhcnRdLmV4cHJlc3Npb24sIGZ1bmN0aW9uKGMpIHtcbiAgICAgICAgICByZXR1cm4gVVJJLmNoYXJhY3RlcnNbX2dyb3VwXVtfcGFydF0ubWFwW2NdO1xuICAgICAgICB9KTtcbiAgICAgIH0gY2F0Y2ggKGUpIHtcbiAgICAgICAgLy8gd2UncmUgbm90IGdvaW5nIHRvIG1lc3Mgd2l0aCB3ZWlyZCBlbmNvZGluZ3MsXG4gICAgICAgIC8vIGdpdmUgdXAgYW5kIHJldHVybiB0aGUgdW5kZWNvZGVkIG9yaWdpbmFsIHN0cmluZ1xuICAgICAgICAvLyBzZWUgaHR0cHM6Ly9naXRodWIuY29tL21lZGlhbGl6ZS9VUkkuanMvaXNzdWVzLzg3XG4gICAgICAgIC8vIHNlZSBodHRwczovL2dpdGh1Yi5jb20vbWVkaWFsaXplL1VSSS5qcy9pc3N1ZXMvOTJcbiAgICAgICAgcmV0dXJuIHN0cmluZztcbiAgICAgIH1cbiAgICB9O1xuICB9O1xuXG4gIGZvciAoX3BhcnQgaW4gX3BhcnRzKSB7XG4gICAgVVJJW19wYXJ0ICsgJ1BhdGhTZWdtZW50J10gPSBnZW5lcmF0ZUFjY2Vzc29yKCdwYXRobmFtZScsIF9wYXJ0c1tfcGFydF0pO1xuICAgIFVSSVtfcGFydCArICdVcm5QYXRoU2VnbWVudCddID0gZ2VuZXJhdGVBY2Nlc3NvcigndXJucGF0aCcsIF9wYXJ0c1tfcGFydF0pO1xuICB9XG5cbiAgdmFyIGdlbmVyYXRlU2VnbWVudGVkUGF0aEZ1bmN0aW9uID0gZnVuY3Rpb24oX3NlcCwgX2NvZGluZ0Z1bmNOYW1lLCBfaW5uZXJDb2RpbmdGdW5jTmFtZSkge1xuICAgIHJldHVybiBmdW5jdGlvbihzdHJpbmcpIHtcbiAgICAgIC8vIFdoeSBwYXNzIGluIG5hbWVzIG9mIGZ1bmN0aW9ucywgcmF0aGVyIHRoYW4gdGhlIGZ1bmN0aW9uIG9iamVjdHMgdGhlbXNlbHZlcz8gVGhlXG4gICAgICAvLyBkZWZpbml0aW9ucyBvZiBzb21lIGZ1bmN0aW9ucyAoYnV0IGluIHBhcnRpY3VsYXIsIFVSSS5kZWNvZGUpIHdpbGwgb2NjYXNpb25hbGx5IGNoYW5nZSBkdWVcbiAgICAgIC8vIHRvIFVSSS5qcyBoYXZpbmcgSVNPODg1OSBhbmQgVW5pY29kZSBtb2Rlcy4gUGFzc2luZyBpbiB0aGUgbmFtZSBhbmQgZ2V0dGluZyBpdCB3aWxsIGVuc3VyZVxuICAgICAgLy8gdGhhdCB0aGUgZnVuY3Rpb25zIHdlIHVzZSBoZXJlIGFyZSBcImZyZXNoXCIuXG4gICAgICB2YXIgYWN0dWFsQ29kaW5nRnVuYztcbiAgICAgIGlmICghX2lubmVyQ29kaW5nRnVuY05hbWUpIHtcbiAgICAgICAgYWN0dWFsQ29kaW5nRnVuYyA9IFVSSVtfY29kaW5nRnVuY05hbWVdO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgYWN0dWFsQ29kaW5nRnVuYyA9IGZ1bmN0aW9uKHN0cmluZykge1xuICAgICAgICAgIHJldHVybiBVUklbX2NvZGluZ0Z1bmNOYW1lXShVUklbX2lubmVyQ29kaW5nRnVuY05hbWVdKHN0cmluZykpO1xuICAgICAgICB9O1xuICAgICAgfVxuXG4gICAgICB2YXIgc2VnbWVudHMgPSAoc3RyaW5nICsgJycpLnNwbGl0KF9zZXApO1xuXG4gICAgICBmb3IgKHZhciBpID0gMCwgbGVuZ3RoID0gc2VnbWVudHMubGVuZ3RoOyBpIDwgbGVuZ3RoOyBpKyspIHtcbiAgICAgICAgc2VnbWVudHNbaV0gPSBhY3R1YWxDb2RpbmdGdW5jKHNlZ21lbnRzW2ldKTtcbiAgICAgIH1cblxuICAgICAgcmV0dXJuIHNlZ21lbnRzLmpvaW4oX3NlcCk7XG4gICAgfTtcbiAgfTtcblxuICAvLyBUaGlzIHRha2VzIHBsYWNlIG91dHNpZGUgdGhlIGFib3ZlIGxvb3AgYmVjYXVzZSB3ZSBkb24ndCB3YW50LCBlLmcuLCBlbmNvZGVVcm5QYXRoIGZ1bmN0aW9ucy5cbiAgVVJJLmRlY29kZVBhdGggPSBnZW5lcmF0ZVNlZ21lbnRlZFBhdGhGdW5jdGlvbignLycsICdkZWNvZGVQYXRoU2VnbWVudCcpO1xuICBVUkkuZGVjb2RlVXJuUGF0aCA9IGdlbmVyYXRlU2VnbWVudGVkUGF0aEZ1bmN0aW9uKCc6JywgJ2RlY29kZVVyblBhdGhTZWdtZW50Jyk7XG4gIFVSSS5yZWNvZGVQYXRoID0gZ2VuZXJhdGVTZWdtZW50ZWRQYXRoRnVuY3Rpb24oJy8nLCAnZW5jb2RlUGF0aFNlZ21lbnQnLCAnZGVjb2RlJyk7XG4gIFVSSS5yZWNvZGVVcm5QYXRoID0gZ2VuZXJhdGVTZWdtZW50ZWRQYXRoRnVuY3Rpb24oJzonLCAnZW5jb2RlVXJuUGF0aFNlZ21lbnQnLCAnZGVjb2RlJyk7XG5cbiAgVVJJLmVuY29kZVJlc2VydmVkID0gZ2VuZXJhdGVBY2Nlc3NvcigncmVzZXJ2ZWQnLCAnZW5jb2RlJyk7XG5cbiAgVVJJLnBhcnNlID0gZnVuY3Rpb24oc3RyaW5nLCBwYXJ0cykge1xuICAgIHZhciBwb3M7XG4gICAgaWYgKCFwYXJ0cykge1xuICAgICAgcGFydHMgPSB7fTtcbiAgICB9XG4gICAgLy8gW3Byb3RvY29sXCI6Ly9cIlt1c2VybmFtZVtcIjpcInBhc3N3b3JkXVwiQFwiXWhvc3RuYW1lW1wiOlwicG9ydF1cIi9cIj9dW3BhdGhdW1wiP1wicXVlcnlzdHJpbmddW1wiI1wiZnJhZ21lbnRdXG5cbiAgICAvLyBleHRyYWN0IGZyYWdtZW50XG4gICAgcG9zID0gc3RyaW5nLmluZGV4T2YoJyMnKTtcbiAgICBpZiAocG9zID4gLTEpIHtcbiAgICAgIC8vIGVzY2FwaW5nP1xuICAgICAgcGFydHMuZnJhZ21lbnQgPSBzdHJpbmcuc3Vic3RyaW5nKHBvcyArIDEpIHx8IG51bGw7XG4gICAgICBzdHJpbmcgPSBzdHJpbmcuc3Vic3RyaW5nKDAsIHBvcyk7XG4gICAgfVxuXG4gICAgLy8gZXh0cmFjdCBxdWVyeVxuICAgIHBvcyA9IHN0cmluZy5pbmRleE9mKCc/Jyk7XG4gICAgaWYgKHBvcyA+IC0xKSB7XG4gICAgICAvLyBlc2NhcGluZz9cbiAgICAgIHBhcnRzLnF1ZXJ5ID0gc3RyaW5nLnN1YnN0cmluZyhwb3MgKyAxKSB8fCBudWxsO1xuICAgICAgc3RyaW5nID0gc3RyaW5nLnN1YnN0cmluZygwLCBwb3MpO1xuICAgIH1cblxuICAgIC8vIGV4dHJhY3QgcHJvdG9jb2xcbiAgICBpZiAoc3RyaW5nLnN1YnN0cmluZygwLCAyKSA9PT0gJy8vJykge1xuICAgICAgLy8gcmVsYXRpdmUtc2NoZW1lXG4gICAgICBwYXJ0cy5wcm90b2NvbCA9IG51bGw7XG4gICAgICBzdHJpbmcgPSBzdHJpbmcuc3Vic3RyaW5nKDIpO1xuICAgICAgLy8gZXh0cmFjdCBcInVzZXI6cGFzc0Bob3N0OnBvcnRcIlxuICAgICAgc3RyaW5nID0gVVJJLnBhcnNlQXV0aG9yaXR5KHN0cmluZywgcGFydHMpO1xuICAgIH0gZWxzZSB7XG4gICAgICBwb3MgPSBzdHJpbmcuaW5kZXhPZignOicpO1xuICAgICAgaWYgKHBvcyA+IC0xKSB7XG4gICAgICAgIHBhcnRzLnByb3RvY29sID0gc3RyaW5nLnN1YnN0cmluZygwLCBwb3MpIHx8IG51bGw7XG4gICAgICAgIGlmIChwYXJ0cy5wcm90b2NvbCAmJiAhcGFydHMucHJvdG9jb2wubWF0Y2goVVJJLnByb3RvY29sX2V4cHJlc3Npb24pKSB7XG4gICAgICAgICAgLy8gOiBtYXkgYmUgd2l0aGluIHRoZSBwYXRoXG4gICAgICAgICAgcGFydHMucHJvdG9jb2wgPSB1bmRlZmluZWQ7XG4gICAgICAgIH0gZWxzZSBpZiAoc3RyaW5nLnN1YnN0cmluZyhwb3MgKyAxLCBwb3MgKyAzKSA9PT0gJy8vJykge1xuICAgICAgICAgIHN0cmluZyA9IHN0cmluZy5zdWJzdHJpbmcocG9zICsgMyk7XG5cbiAgICAgICAgICAvLyBleHRyYWN0IFwidXNlcjpwYXNzQGhvc3Q6cG9ydFwiXG4gICAgICAgICAgc3RyaW5nID0gVVJJLnBhcnNlQXV0aG9yaXR5KHN0cmluZywgcGFydHMpO1xuICAgICAgICB9IGVsc2Uge1xuICAgICAgICAgIHN0cmluZyA9IHN0cmluZy5zdWJzdHJpbmcocG9zICsgMSk7XG4gICAgICAgICAgcGFydHMudXJuID0gdHJ1ZTtcbiAgICAgICAgfVxuICAgICAgfVxuICAgIH1cblxuICAgIC8vIHdoYXQncyBsZWZ0IG11c3QgYmUgdGhlIHBhdGhcbiAgICBwYXJ0cy5wYXRoID0gc3RyaW5nO1xuXG4gICAgLy8gYW5kIHdlJ3JlIGRvbmVcbiAgICByZXR1cm4gcGFydHM7XG4gIH07XG4gIFVSSS5wYXJzZUhvc3QgPSBmdW5jdGlvbihzdHJpbmcsIHBhcnRzKSB7XG4gICAgLy8gQ29weSBjaHJvbWUsIElFLCBvcGVyYSBiYWNrc2xhc2gtaGFuZGxpbmcgYmVoYXZpb3IuXG4gICAgLy8gQmFjayBzbGFzaGVzIGJlZm9yZSB0aGUgcXVlcnkgc3RyaW5nIGdldCBjb252ZXJ0ZWQgdG8gZm9yd2FyZCBzbGFzaGVzXG4gICAgLy8gU2VlOiBodHRwczovL2dpdGh1Yi5jb20vam95ZW50L25vZGUvYmxvYi8zODZmZDI0ZjQ5YjBlOWQxYThhMDc2NTkyYTQwNDE2OGZhZWVjYzM0L2xpYi91cmwuanMjTDExNS1MMTI0XG4gICAgLy8gU2VlOiBodHRwczovL2NvZGUuZ29vZ2xlLmNvbS9wL2Nocm9taXVtL2lzc3Vlcy9kZXRhaWw/aWQ9MjU5MTZcbiAgICAvLyBodHRwczovL2dpdGh1Yi5jb20vbWVkaWFsaXplL1VSSS5qcy9wdWxsLzIzM1xuICAgIHN0cmluZyA9IHN0cmluZy5yZXBsYWNlKC9cXFxcL2csICcvJyk7XG5cbiAgICAvLyBleHRyYWN0IGhvc3Q6cG9ydFxuICAgIHZhciBwb3MgPSBzdHJpbmcuaW5kZXhPZignLycpO1xuICAgIHZhciBicmFja2V0UG9zO1xuICAgIHZhciB0O1xuXG4gICAgaWYgKHBvcyA9PT0gLTEpIHtcbiAgICAgIHBvcyA9IHN0cmluZy5sZW5ndGg7XG4gICAgfVxuXG4gICAgaWYgKHN0cmluZy5jaGFyQXQoMCkgPT09ICdbJykge1xuICAgICAgLy8gSVB2NiBob3N0IC0gaHR0cDovL3Rvb2xzLmlldGYub3JnL2h0bWwvZHJhZnQtaWV0Zi02bWFuLXRleHQtYWRkci1yZXByZXNlbnRhdGlvbi0wNCNzZWN0aW9uLTZcbiAgICAgIC8vIEkgY2xhaW0gbW9zdCBjbGllbnQgc29mdHdhcmUgYnJlYWtzIG9uIElQdjYgYW55d2F5cy4gVG8gc2ltcGxpZnkgdGhpbmdzLCBVUkkgb25seSBhY2NlcHRzXG4gICAgICAvLyBJUHY2K3BvcnQgaW4gdGhlIGZvcm1hdCBbMjAwMTpkYjg6OjFdOjgwIChmb3IgdGhlIHRpbWUgYmVpbmcpXG4gICAgICBicmFja2V0UG9zID0gc3RyaW5nLmluZGV4T2YoJ10nKTtcbiAgICAgIHBhcnRzLmhvc3RuYW1lID0gc3RyaW5nLnN1YnN0cmluZygxLCBicmFja2V0UG9zKSB8fCBudWxsO1xuICAgICAgcGFydHMucG9ydCA9IHN0cmluZy5zdWJzdHJpbmcoYnJhY2tldFBvcyArIDIsIHBvcykgfHwgbnVsbDtcbiAgICAgIGlmIChwYXJ0cy5wb3J0ID09PSAnLycpIHtcbiAgICAgICAgcGFydHMucG9ydCA9IG51bGw7XG4gICAgICB9XG4gICAgfSBlbHNlIHtcbiAgICAgIHZhciBmaXJzdENvbG9uID0gc3RyaW5nLmluZGV4T2YoJzonKTtcbiAgICAgIHZhciBmaXJzdFNsYXNoID0gc3RyaW5nLmluZGV4T2YoJy8nKTtcbiAgICAgIHZhciBuZXh0Q29sb24gPSBzdHJpbmcuaW5kZXhPZignOicsIGZpcnN0Q29sb24gKyAxKTtcbiAgICAgIGlmIChuZXh0Q29sb24gIT09IC0xICYmIChmaXJzdFNsYXNoID09PSAtMSB8fCBuZXh0Q29sb24gPCBmaXJzdFNsYXNoKSkge1xuICAgICAgICAvLyBJUHY2IGhvc3QgY29udGFpbnMgbXVsdGlwbGUgY29sb25zIC0gYnV0IG5vIHBvcnRcbiAgICAgICAgLy8gdGhpcyBub3RhdGlvbiBpcyBhY3R1YWxseSBub3QgYWxsb3dlZCBieSBSRkMgMzk4NiwgYnV0IHdlJ3JlIGEgbGliZXJhbCBwYXJzZXJcbiAgICAgICAgcGFydHMuaG9zdG5hbWUgPSBzdHJpbmcuc3Vic3RyaW5nKDAsIHBvcykgfHwgbnVsbDtcbiAgICAgICAgcGFydHMucG9ydCA9IG51bGw7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICB0ID0gc3RyaW5nLnN1YnN0cmluZygwLCBwb3MpLnNwbGl0KCc6Jyk7XG4gICAgICAgIHBhcnRzLmhvc3RuYW1lID0gdFswXSB8fCBudWxsO1xuICAgICAgICBwYXJ0cy5wb3J0ID0gdFsxXSB8fCBudWxsO1xuICAgICAgfVxuICAgIH1cblxuICAgIGlmIChwYXJ0cy5ob3N0bmFtZSAmJiBzdHJpbmcuc3Vic3RyaW5nKHBvcykuY2hhckF0KDApICE9PSAnLycpIHtcbiAgICAgIHBvcysrO1xuICAgICAgc3RyaW5nID0gJy8nICsgc3RyaW5nO1xuICAgIH1cblxuICAgIHJldHVybiBzdHJpbmcuc3Vic3RyaW5nKHBvcykgfHwgJy8nO1xuICB9O1xuICBVUkkucGFyc2VBdXRob3JpdHkgPSBmdW5jdGlvbihzdHJpbmcsIHBhcnRzKSB7XG4gICAgc3RyaW5nID0gVVJJLnBhcnNlVXNlcmluZm8oc3RyaW5nLCBwYXJ0cyk7XG4gICAgcmV0dXJuIFVSSS5wYXJzZUhvc3Qoc3RyaW5nLCBwYXJ0cyk7XG4gIH07XG4gIFVSSS5wYXJzZVVzZXJpbmZvID0gZnVuY3Rpb24oc3RyaW5nLCBwYXJ0cykge1xuICAgIC8vIGV4dHJhY3QgdXNlcm5hbWU6cGFzc3dvcmRcbiAgICB2YXIgZmlyc3RTbGFzaCA9IHN0cmluZy5pbmRleE9mKCcvJyk7XG4gICAgdmFyIHBvcyA9IHN0cmluZy5sYXN0SW5kZXhPZignQCcsIGZpcnN0U2xhc2ggPiAtMSA/IGZpcnN0U2xhc2ggOiBzdHJpbmcubGVuZ3RoIC0gMSk7XG4gICAgdmFyIHQ7XG5cbiAgICAvLyBhdXRob3JpdHlAIG11c3QgY29tZSBiZWZvcmUgL3BhdGhcbiAgICBpZiAocG9zID4gLTEgJiYgKGZpcnN0U2xhc2ggPT09IC0xIHx8IHBvcyA8IGZpcnN0U2xhc2gpKSB7XG4gICAgICB0ID0gc3RyaW5nLnN1YnN0cmluZygwLCBwb3MpLnNwbGl0KCc6Jyk7XG4gICAgICBwYXJ0cy51c2VybmFtZSA9IHRbMF0gPyBVUkkuZGVjb2RlKHRbMF0pIDogbnVsbDtcbiAgICAgIHQuc2hpZnQoKTtcbiAgICAgIHBhcnRzLnBhc3N3b3JkID0gdFswXSA/IFVSSS5kZWNvZGUodC5qb2luKCc6JykpIDogbnVsbDtcbiAgICAgIHN0cmluZyA9IHN0cmluZy5zdWJzdHJpbmcocG9zICsgMSk7XG4gICAgfSBlbHNlIHtcbiAgICAgIHBhcnRzLnVzZXJuYW1lID0gbnVsbDtcbiAgICAgIHBhcnRzLnBhc3N3b3JkID0gbnVsbDtcbiAgICB9XG5cbiAgICByZXR1cm4gc3RyaW5nO1xuICB9O1xuICBVUkkucGFyc2VRdWVyeSA9IGZ1bmN0aW9uKHN0cmluZywgZXNjYXBlUXVlcnlTcGFjZSkge1xuICAgIGlmICghc3RyaW5nKSB7XG4gICAgICByZXR1cm4ge307XG4gICAgfVxuXG4gICAgLy8gdGhyb3cgb3V0IHRoZSBmdW5reSBidXNpbmVzcyAtIFwiP1wiW25hbWVcIj1cInZhbHVlXCImXCJdK1xuICAgIHN0cmluZyA9IHN0cmluZy5yZXBsYWNlKC8mKy9nLCAnJicpLnJlcGxhY2UoL15cXD8qJip8JiskL2csICcnKTtcblxuICAgIGlmICghc3RyaW5nKSB7XG4gICAgICByZXR1cm4ge307XG4gICAgfVxuXG4gICAgdmFyIGl0ZW1zID0ge307XG4gICAgdmFyIHNwbGl0cyA9IHN0cmluZy5zcGxpdCgnJicpO1xuICAgIHZhciBsZW5ndGggPSBzcGxpdHMubGVuZ3RoO1xuICAgIHZhciB2LCBuYW1lLCB2YWx1ZTtcblxuICAgIGZvciAodmFyIGkgPSAwOyBpIDwgbGVuZ3RoOyBpKyspIHtcbiAgICAgIHYgPSBzcGxpdHNbaV0uc3BsaXQoJz0nKTtcbiAgICAgIG5hbWUgPSBVUkkuZGVjb2RlUXVlcnkodi5zaGlmdCgpLCBlc2NhcGVRdWVyeVNwYWNlKTtcbiAgICAgIC8vIG5vIFwiPVwiIGlzIG51bGwgYWNjb3JkaW5nIHRvIGh0dHA6Ly9kdmNzLnczLm9yZy9oZy91cmwvcmF3LWZpbGUvdGlwL092ZXJ2aWV3Lmh0bWwjY29sbGVjdC11cmwtcGFyYW1ldGVyc1xuICAgICAgdmFsdWUgPSB2Lmxlbmd0aCA/IFVSSS5kZWNvZGVRdWVyeSh2LmpvaW4oJz0nKSwgZXNjYXBlUXVlcnlTcGFjZSkgOiBudWxsO1xuXG4gICAgICBpZiAoaGFzT3duLmNhbGwoaXRlbXMsIG5hbWUpKSB7XG4gICAgICAgIGlmICh0eXBlb2YgaXRlbXNbbmFtZV0gPT09ICdzdHJpbmcnIHx8IGl0ZW1zW25hbWVdID09PSBudWxsKSB7XG4gICAgICAgICAgaXRlbXNbbmFtZV0gPSBbaXRlbXNbbmFtZV1dO1xuICAgICAgICB9XG5cbiAgICAgICAgaXRlbXNbbmFtZV0ucHVzaCh2YWx1ZSk7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICBpdGVtc1tuYW1lXSA9IHZhbHVlO1xuICAgICAgfVxuICAgIH1cblxuICAgIHJldHVybiBpdGVtcztcbiAgfTtcblxuICBVUkkuYnVpbGQgPSBmdW5jdGlvbihwYXJ0cykge1xuICAgIHZhciB0ID0gJyc7XG5cbiAgICBpZiAocGFydHMucHJvdG9jb2wpIHtcbiAgICAgIHQgKz0gcGFydHMucHJvdG9jb2wgKyAnOic7XG4gICAgfVxuXG4gICAgaWYgKCFwYXJ0cy51cm4gJiYgKHQgfHwgcGFydHMuaG9zdG5hbWUpKSB7XG4gICAgICB0ICs9ICcvLyc7XG4gICAgfVxuXG4gICAgdCArPSAoVVJJLmJ1aWxkQXV0aG9yaXR5KHBhcnRzKSB8fCAnJyk7XG5cbiAgICBpZiAodHlwZW9mIHBhcnRzLnBhdGggPT09ICdzdHJpbmcnKSB7XG4gICAgICBpZiAocGFydHMucGF0aC5jaGFyQXQoMCkgIT09ICcvJyAmJiB0eXBlb2YgcGFydHMuaG9zdG5hbWUgPT09ICdzdHJpbmcnKSB7XG4gICAgICAgIHQgKz0gJy8nO1xuICAgICAgfVxuXG4gICAgICB0ICs9IHBhcnRzLnBhdGg7XG4gICAgfVxuXG4gICAgaWYgKHR5cGVvZiBwYXJ0cy5xdWVyeSA9PT0gJ3N0cmluZycgJiYgcGFydHMucXVlcnkpIHtcbiAgICAgIHQgKz0gJz8nICsgcGFydHMucXVlcnk7XG4gICAgfVxuXG4gICAgaWYgKHR5cGVvZiBwYXJ0cy5mcmFnbWVudCA9PT0gJ3N0cmluZycgJiYgcGFydHMuZnJhZ21lbnQpIHtcbiAgICAgIHQgKz0gJyMnICsgcGFydHMuZnJhZ21lbnQ7XG4gICAgfVxuICAgIHJldHVybiB0O1xuICB9O1xuICBVUkkuYnVpbGRIb3N0ID0gZnVuY3Rpb24ocGFydHMpIHtcbiAgICB2YXIgdCA9ICcnO1xuXG4gICAgaWYgKCFwYXJ0cy5ob3N0bmFtZSkge1xuICAgICAgcmV0dXJuICcnO1xuICAgIH0gZWxzZSBpZiAoVVJJLmlwNl9leHByZXNzaW9uLnRlc3QocGFydHMuaG9zdG5hbWUpKSB7XG4gICAgICB0ICs9ICdbJyArIHBhcnRzLmhvc3RuYW1lICsgJ10nO1xuICAgIH0gZWxzZSB7XG4gICAgICB0ICs9IHBhcnRzLmhvc3RuYW1lO1xuICAgIH1cblxuICAgIGlmIChwYXJ0cy5wb3J0KSB7XG4gICAgICB0ICs9ICc6JyArIHBhcnRzLnBvcnQ7XG4gICAgfVxuXG4gICAgcmV0dXJuIHQ7XG4gIH07XG4gIFVSSS5idWlsZEF1dGhvcml0eSA9IGZ1bmN0aW9uKHBhcnRzKSB7XG4gICAgcmV0dXJuIFVSSS5idWlsZFVzZXJpbmZvKHBhcnRzKSArIFVSSS5idWlsZEhvc3QocGFydHMpO1xuICB9O1xuICBVUkkuYnVpbGRVc2VyaW5mbyA9IGZ1bmN0aW9uKHBhcnRzKSB7XG4gICAgdmFyIHQgPSAnJztcblxuICAgIGlmIChwYXJ0cy51c2VybmFtZSkge1xuICAgICAgdCArPSBVUkkuZW5jb2RlKHBhcnRzLnVzZXJuYW1lKTtcblxuICAgICAgaWYgKHBhcnRzLnBhc3N3b3JkKSB7XG4gICAgICAgIHQgKz0gJzonICsgVVJJLmVuY29kZShwYXJ0cy5wYXNzd29yZCk7XG4gICAgICB9XG5cbiAgICAgIHQgKz0gJ0AnO1xuICAgIH1cblxuICAgIHJldHVybiB0O1xuICB9O1xuICBVUkkuYnVpbGRRdWVyeSA9IGZ1bmN0aW9uKGRhdGEsIGR1cGxpY2F0ZVF1ZXJ5UGFyYW1ldGVycywgZXNjYXBlUXVlcnlTcGFjZSkge1xuICAgIC8vIGFjY29yZGluZyB0byBodHRwOi8vdG9vbHMuaWV0Zi5vcmcvaHRtbC9yZmMzOTg2IG9yIGh0dHA6Ly9sYWJzLmFwYWNoZS5vcmcvd2ViYXJjaC91cmkvcmZjL3JmYzM5ODYuaHRtbFxuICAgIC8vIGJlaW5nIMK7LS5ffiEkJicoKSorLDs9OkAvP8KrICVIRVggYW5kIGFsbnVtIGFyZSBhbGxvd2VkXG4gICAgLy8gdGhlIFJGQyBleHBsaWNpdGx5IHN0YXRlcyA/L2ZvbyBiZWluZyBhIHZhbGlkIHVzZSBjYXNlLCBubyBtZW50aW9uIG9mIHBhcmFtZXRlciBzeW50YXghXG4gICAgLy8gVVJJLmpzIHRyZWF0cyB0aGUgcXVlcnkgc3RyaW5nIGFzIGJlaW5nIGFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZFxuICAgIC8vIHNlZSBodHRwOi8vd3d3LnczLm9yZy9UUi9SRUMtaHRtbDQwL2ludGVyYWN0L2Zvcm1zLmh0bWwjZm9ybS1jb250ZW50LXR5cGVcblxuICAgIHZhciB0ID0gJyc7XG4gICAgdmFyIHVuaXF1ZSwga2V5LCBpLCBsZW5ndGg7XG4gICAgZm9yIChrZXkgaW4gZGF0YSkge1xuICAgICAgaWYgKGhhc093bi5jYWxsKGRhdGEsIGtleSkgJiYga2V5KSB7XG4gICAgICAgIGlmIChpc0FycmF5KGRhdGFba2V5XSkpIHtcbiAgICAgICAgICB1bmlxdWUgPSB7fTtcbiAgICAgICAgICBmb3IgKGkgPSAwLCBsZW5ndGggPSBkYXRhW2tleV0ubGVuZ3RoOyBpIDwgbGVuZ3RoOyBpKyspIHtcbiAgICAgICAgICAgIGlmIChkYXRhW2tleV1baV0gIT09IHVuZGVmaW5lZCAmJiB1bmlxdWVbZGF0YVtrZXldW2ldICsgJyddID09PSB1bmRlZmluZWQpIHtcbiAgICAgICAgICAgICAgdCArPSAnJicgKyBVUkkuYnVpbGRRdWVyeVBhcmFtZXRlcihrZXksIGRhdGFba2V5XVtpXSwgZXNjYXBlUXVlcnlTcGFjZSk7XG4gICAgICAgICAgICAgIGlmIChkdXBsaWNhdGVRdWVyeVBhcmFtZXRlcnMgIT09IHRydWUpIHtcbiAgICAgICAgICAgICAgICB1bmlxdWVbZGF0YVtrZXldW2ldICsgJyddID0gdHJ1ZTtcbiAgICAgICAgICAgICAgfVxuICAgICAgICAgICAgfVxuICAgICAgICAgIH1cbiAgICAgICAgfSBlbHNlIGlmIChkYXRhW2tleV0gIT09IHVuZGVmaW5lZCkge1xuICAgICAgICAgIHQgKz0gJyYnICsgVVJJLmJ1aWxkUXVlcnlQYXJhbWV0ZXIoa2V5LCBkYXRhW2tleV0sIGVzY2FwZVF1ZXJ5U3BhY2UpO1xuICAgICAgICB9XG4gICAgICB9XG4gICAgfVxuXG4gICAgcmV0dXJuIHQuc3Vic3RyaW5nKDEpO1xuICB9O1xuICBVUkkuYnVpbGRRdWVyeVBhcmFtZXRlciA9IGZ1bmN0aW9uKG5hbWUsIHZhbHVlLCBlc2NhcGVRdWVyeVNwYWNlKSB7XG4gICAgLy8gaHR0cDovL3d3dy53My5vcmcvVFIvUkVDLWh0bWw0MC9pbnRlcmFjdC9mb3Jtcy5odG1sI2Zvcm0tY29udGVudC10eXBlIC0tIGFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZFxuICAgIC8vIGRvbid0IGFwcGVuZCBcIj1cIiBmb3IgbnVsbCB2YWx1ZXMsIGFjY29yZGluZyB0byBodHRwOi8vZHZjcy53My5vcmcvaGcvdXJsL3Jhdy1maWxlL3RpcC9PdmVydmlldy5odG1sI3VybC1wYXJhbWV0ZXItc2VyaWFsaXphdGlvblxuICAgIHJldHVybiBVUkkuZW5jb2RlUXVlcnkobmFtZSwgZXNjYXBlUXVlcnlTcGFjZSkgKyAodmFsdWUgIT09IG51bGwgPyAnPScgKyBVUkkuZW5jb2RlUXVlcnkodmFsdWUsIGVzY2FwZVF1ZXJ5U3BhY2UpIDogJycpO1xuICB9O1xuXG4gIFVSSS5hZGRRdWVyeSA9IGZ1bmN0aW9uKGRhdGEsIG5hbWUsIHZhbHVlKSB7XG4gICAgaWYgKHR5cGVvZiBuYW1lID09PSAnb2JqZWN0Jykge1xuICAgICAgZm9yICh2YXIga2V5IGluIG5hbWUpIHtcbiAgICAgICAgaWYgKGhhc093bi5jYWxsKG5hbWUsIGtleSkpIHtcbiAgICAgICAgICBVUkkuYWRkUXVlcnkoZGF0YSwga2V5LCBuYW1lW2tleV0pO1xuICAgICAgICB9XG4gICAgICB9XG4gICAgfSBlbHNlIGlmICh0eXBlb2YgbmFtZSA9PT0gJ3N0cmluZycpIHtcbiAgICAgIGlmIChkYXRhW25hbWVdID09PSB1bmRlZmluZWQpIHtcbiAgICAgICAgZGF0YVtuYW1lXSA9IHZhbHVlO1xuICAgICAgICByZXR1cm47XG4gICAgICB9IGVsc2UgaWYgKHR5cGVvZiBkYXRhW25hbWVdID09PSAnc3RyaW5nJykge1xuICAgICAgICBkYXRhW25hbWVdID0gW2RhdGFbbmFtZV1dO1xuICAgICAgfVxuXG4gICAgICBpZiAoIWlzQXJyYXkodmFsdWUpKSB7XG4gICAgICAgIHZhbHVlID0gW3ZhbHVlXTtcbiAgICAgIH1cblxuICAgICAgZGF0YVtuYW1lXSA9IChkYXRhW25hbWVdIHx8IFtdKS5jb25jYXQodmFsdWUpO1xuICAgIH0gZWxzZSB7XG4gICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKCdVUkkuYWRkUXVlcnkoKSBhY2NlcHRzIGFuIG9iamVjdCwgc3RyaW5nIGFzIHRoZSBuYW1lIHBhcmFtZXRlcicpO1xuICAgIH1cbiAgfTtcbiAgVVJJLnJlbW92ZVF1ZXJ5ID0gZnVuY3Rpb24oZGF0YSwgbmFtZSwgdmFsdWUpIHtcbiAgICB2YXIgaSwgbGVuZ3RoLCBrZXk7XG5cbiAgICBpZiAoaXNBcnJheShuYW1lKSkge1xuICAgICAgZm9yIChpID0gMCwgbGVuZ3RoID0gbmFtZS5sZW5ndGg7IGkgPCBsZW5ndGg7IGkrKykge1xuICAgICAgICBkYXRhW25hbWVbaV1dID0gdW5kZWZpbmVkO1xuICAgICAgfVxuICAgIH0gZWxzZSBpZiAoZ2V0VHlwZShuYW1lKSA9PT0gJ1JlZ0V4cCcpIHtcbiAgICAgIGZvciAoa2V5IGluIGRhdGEpIHtcbiAgICAgICAgaWYgKG5hbWUudGVzdChrZXkpKSB7XG4gICAgICAgICAgZGF0YVtrZXldID0gdW5kZWZpbmVkO1xuICAgICAgICB9XG4gICAgICB9XG4gICAgfSBlbHNlIGlmICh0eXBlb2YgbmFtZSA9PT0gJ29iamVjdCcpIHtcbiAgICAgIGZvciAoa2V5IGluIG5hbWUpIHtcbiAgICAgICAgaWYgKGhhc093bi5jYWxsKG5hbWUsIGtleSkpIHtcbiAgICAgICAgICBVUkkucmVtb3ZlUXVlcnkoZGF0YSwga2V5LCBuYW1lW2tleV0pO1xuICAgICAgICB9XG4gICAgICB9XG4gICAgfSBlbHNlIGlmICh0eXBlb2YgbmFtZSA9PT0gJ3N0cmluZycpIHtcbiAgICAgIGlmICh2YWx1ZSAhPT0gdW5kZWZpbmVkKSB7XG4gICAgICAgIGlmIChnZXRUeXBlKHZhbHVlKSA9PT0gJ1JlZ0V4cCcpIHtcbiAgICAgICAgICBpZiAoIWlzQXJyYXkoZGF0YVtuYW1lXSkgJiYgdmFsdWUudGVzdChkYXRhW25hbWVdKSkge1xuICAgICAgICAgICAgZGF0YVtuYW1lXSA9IHVuZGVmaW5lZDtcbiAgICAgICAgICB9IGVsc2Uge1xuICAgICAgICAgICAgZGF0YVtuYW1lXSA9IGZpbHRlckFycmF5VmFsdWVzKGRhdGFbbmFtZV0sIHZhbHVlKTtcbiAgICAgICAgICB9XG4gICAgICAgIH0gZWxzZSBpZiAoZGF0YVtuYW1lXSA9PT0gU3RyaW5nKHZhbHVlKSAmJiAoIWlzQXJyYXkodmFsdWUpIHx8IHZhbHVlLmxlbmd0aCA9PT0gMSkpIHtcbiAgICAgICAgICBkYXRhW25hbWVdID0gdW5kZWZpbmVkO1xuICAgICAgICB9IGVsc2UgaWYgKGlzQXJyYXkoZGF0YVtuYW1lXSkpIHtcbiAgICAgICAgICBkYXRhW25hbWVdID0gZmlsdGVyQXJyYXlWYWx1ZXMoZGF0YVtuYW1lXSwgdmFsdWUpO1xuICAgICAgICB9XG4gICAgICB9IGVsc2Uge1xuICAgICAgICBkYXRhW25hbWVdID0gdW5kZWZpbmVkO1xuICAgICAgfVxuICAgIH0gZWxzZSB7XG4gICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKCdVUkkucmVtb3ZlUXVlcnkoKSBhY2NlcHRzIGFuIG9iamVjdCwgc3RyaW5nLCBSZWdFeHAgYXMgdGhlIGZpcnN0IHBhcmFtZXRlcicpO1xuICAgIH1cbiAgfTtcbiAgVVJJLmhhc1F1ZXJ5ID0gZnVuY3Rpb24oZGF0YSwgbmFtZSwgdmFsdWUsIHdpdGhpbkFycmF5KSB7XG4gICAgaWYgKHR5cGVvZiBuYW1lID09PSAnb2JqZWN0Jykge1xuICAgICAgZm9yICh2YXIga2V5IGluIG5hbWUpIHtcbiAgICAgICAgaWYgKGhhc093bi5jYWxsKG5hbWUsIGtleSkpIHtcbiAgICAgICAgICBpZiAoIVVSSS5oYXNRdWVyeShkYXRhLCBrZXksIG5hbWVba2V5XSkpIHtcbiAgICAgICAgICAgIHJldHVybiBmYWxzZTtcbiAgICAgICAgICB9XG4gICAgICAgIH1cbiAgICAgIH1cblxuICAgICAgcmV0dXJuIHRydWU7XG4gICAgfSBlbHNlIGlmICh0eXBlb2YgbmFtZSAhPT0gJ3N0cmluZycpIHtcbiAgICAgIHRocm93IG5ldyBUeXBlRXJyb3IoJ1VSSS5oYXNRdWVyeSgpIGFjY2VwdHMgYW4gb2JqZWN0LCBzdHJpbmcgYXMgdGhlIG5hbWUgcGFyYW1ldGVyJyk7XG4gICAgfVxuXG4gICAgc3dpdGNoIChnZXRUeXBlKHZhbHVlKSkge1xuICAgICAgY2FzZSAnVW5kZWZpbmVkJzpcbiAgICAgICAgLy8gdHJ1ZSBpZiBleGlzdHMgKGJ1dCBtYXkgYmUgZW1wdHkpXG4gICAgICAgIHJldHVybiBuYW1lIGluIGRhdGE7IC8vIGRhdGFbbmFtZV0gIT09IHVuZGVmaW5lZDtcblxuICAgICAgY2FzZSAnQm9vbGVhbic6XG4gICAgICAgIC8vIHRydWUgaWYgZXhpc3RzIGFuZCBub24tZW1wdHlcbiAgICAgICAgdmFyIF9ib29seSA9IEJvb2xlYW4oaXNBcnJheShkYXRhW25hbWVdKSA/IGRhdGFbbmFtZV0ubGVuZ3RoIDogZGF0YVtuYW1lXSk7XG4gICAgICAgIHJldHVybiB2YWx1ZSA9PT0gX2Jvb2x5O1xuXG4gICAgICBjYXNlICdGdW5jdGlvbic6XG4gICAgICAgIC8vIGFsbG93IGNvbXBsZXggY29tcGFyaXNvblxuICAgICAgICByZXR1cm4gISF2YWx1ZShkYXRhW25hbWVdLCBuYW1lLCBkYXRhKTtcblxuICAgICAgY2FzZSAnQXJyYXknOlxuICAgICAgICBpZiAoIWlzQXJyYXkoZGF0YVtuYW1lXSkpIHtcbiAgICAgICAgICByZXR1cm4gZmFsc2U7XG4gICAgICAgIH1cblxuICAgICAgICB2YXIgb3AgPSB3aXRoaW5BcnJheSA/IGFycmF5Q29udGFpbnMgOiBhcnJheXNFcXVhbDtcbiAgICAgICAgcmV0dXJuIG9wKGRhdGFbbmFtZV0sIHZhbHVlKTtcblxuICAgICAgY2FzZSAnUmVnRXhwJzpcbiAgICAgICAgaWYgKCFpc0FycmF5KGRhdGFbbmFtZV0pKSB7XG4gICAgICAgICAgcmV0dXJuIEJvb2xlYW4oZGF0YVtuYW1lXSAmJiBkYXRhW25hbWVdLm1hdGNoKHZhbHVlKSk7XG4gICAgICAgIH1cblxuICAgICAgICBpZiAoIXdpdGhpbkFycmF5KSB7XG4gICAgICAgICAgcmV0dXJuIGZhbHNlO1xuICAgICAgICB9XG5cbiAgICAgICAgcmV0dXJuIGFycmF5Q29udGFpbnMoZGF0YVtuYW1lXSwgdmFsdWUpO1xuXG4gICAgICBjYXNlICdOdW1iZXInOlxuICAgICAgICB2YWx1ZSA9IFN0cmluZyh2YWx1ZSk7XG4gICAgICAgIC8qIGZhbGxzIHRocm91Z2ggKi9cbiAgICAgIGNhc2UgJ1N0cmluZyc6XG4gICAgICAgIGlmICghaXNBcnJheShkYXRhW25hbWVdKSkge1xuICAgICAgICAgIHJldHVybiBkYXRhW25hbWVdID09PSB2YWx1ZTtcbiAgICAgICAgfVxuXG4gICAgICAgIGlmICghd2l0aGluQXJyYXkpIHtcbiAgICAgICAgICByZXR1cm4gZmFsc2U7XG4gICAgICAgIH1cblxuICAgICAgICByZXR1cm4gYXJyYXlDb250YWlucyhkYXRhW25hbWVdLCB2YWx1ZSk7XG5cbiAgICAgIGRlZmF1bHQ6XG4gICAgICAgIHRocm93IG5ldyBUeXBlRXJyb3IoJ1VSSS5oYXNRdWVyeSgpIGFjY2VwdHMgdW5kZWZpbmVkLCBib29sZWFuLCBzdHJpbmcsIG51bWJlciwgUmVnRXhwLCBGdW5jdGlvbiBhcyB0aGUgdmFsdWUgcGFyYW1ldGVyJyk7XG4gICAgfVxuICB9O1xuXG5cbiAgVVJJLmNvbW1vblBhdGggPSBmdW5jdGlvbihvbmUsIHR3bykge1xuICAgIHZhciBsZW5ndGggPSBNYXRoLm1pbihvbmUubGVuZ3RoLCB0d28ubGVuZ3RoKTtcbiAgICB2YXIgcG9zO1xuXG4gICAgLy8gZmluZCBmaXJzdCBub24tbWF0Y2hpbmcgY2hhcmFjdGVyXG4gICAgZm9yIChwb3MgPSAwOyBwb3MgPCBsZW5ndGg7IHBvcysrKSB7XG4gICAgICBpZiAob25lLmNoYXJBdChwb3MpICE9PSB0d28uY2hhckF0KHBvcykpIHtcbiAgICAgICAgcG9zLS07XG4gICAgICAgIGJyZWFrO1xuICAgICAgfVxuICAgIH1cblxuICAgIGlmIChwb3MgPCAxKSB7XG4gICAgICByZXR1cm4gb25lLmNoYXJBdCgwKSA9PT0gdHdvLmNoYXJBdCgwKSAmJiBvbmUuY2hhckF0KDApID09PSAnLycgPyAnLycgOiAnJztcbiAgICB9XG5cbiAgICAvLyByZXZlcnQgdG8gbGFzdCAvXG4gICAgaWYgKG9uZS5jaGFyQXQocG9zKSAhPT0gJy8nIHx8IHR3by5jaGFyQXQocG9zKSAhPT0gJy8nKSB7XG4gICAgICBwb3MgPSBvbmUuc3Vic3RyaW5nKDAsIHBvcykubGFzdEluZGV4T2YoJy8nKTtcbiAgICB9XG5cbiAgICByZXR1cm4gb25lLnN1YnN0cmluZygwLCBwb3MgKyAxKTtcbiAgfTtcblxuICBVUkkud2l0aGluU3RyaW5nID0gZnVuY3Rpb24oc3RyaW5nLCBjYWxsYmFjaywgb3B0aW9ucykge1xuICAgIG9wdGlvbnMgfHwgKG9wdGlvbnMgPSB7fSk7XG4gICAgdmFyIF9zdGFydCA9IG9wdGlvbnMuc3RhcnQgfHwgVVJJLmZpbmRVcmkuc3RhcnQ7XG4gICAgdmFyIF9lbmQgPSBvcHRpb25zLmVuZCB8fCBVUkkuZmluZFVyaS5lbmQ7XG4gICAgdmFyIF90cmltID0gb3B0aW9ucy50cmltIHx8IFVSSS5maW5kVXJpLnRyaW07XG4gICAgdmFyIF9hdHRyaWJ1dGVPcGVuID0gL1thLXowLTktXT1bXCInXT8kL2k7XG5cbiAgICBfc3RhcnQubGFzdEluZGV4ID0gMDtcbiAgICB3aGlsZSAodHJ1ZSkge1xuICAgICAgdmFyIG1hdGNoID0gX3N0YXJ0LmV4ZWMoc3RyaW5nKTtcbiAgICAgIGlmICghbWF0Y2gpIHtcbiAgICAgICAgYnJlYWs7XG4gICAgICB9XG5cbiAgICAgIHZhciBzdGFydCA9IG1hdGNoLmluZGV4O1xuICAgICAgaWYgKG9wdGlvbnMuaWdub3JlSHRtbCkge1xuICAgICAgICAvLyBhdHRyaWJ1dChlPVtcIiddPyQpXG4gICAgICAgIHZhciBhdHRyaWJ1dGVPcGVuID0gc3RyaW5nLnNsaWNlKE1hdGgubWF4KHN0YXJ0IC0gMywgMCksIHN0YXJ0KTtcbiAgICAgICAgaWYgKGF0dHJpYnV0ZU9wZW4gJiYgX2F0dHJpYnV0ZU9wZW4udGVzdChhdHRyaWJ1dGVPcGVuKSkge1xuICAgICAgICAgIGNvbnRpbnVlO1xuICAgICAgICB9XG4gICAgICB9XG5cbiAgICAgIHZhciBlbmQgPSBzdGFydCArIHN0cmluZy5zbGljZShzdGFydCkuc2VhcmNoKF9lbmQpO1xuICAgICAgdmFyIHNsaWNlID0gc3RyaW5nLnNsaWNlKHN0YXJ0LCBlbmQpLnJlcGxhY2UoX3RyaW0sICcnKTtcbiAgICAgIGlmIChvcHRpb25zLmlnbm9yZSAmJiBvcHRpb25zLmlnbm9yZS50ZXN0KHNsaWNlKSkge1xuICAgICAgICBjb250aW51ZTtcbiAgICAgIH1cblxuICAgICAgZW5kID0gc3RhcnQgKyBzbGljZS5sZW5ndGg7XG4gICAgICB2YXIgcmVzdWx0ID0gY2FsbGJhY2soc2xpY2UsIHN0YXJ0LCBlbmQsIHN0cmluZyk7XG4gICAgICBzdHJpbmcgPSBzdHJpbmcuc2xpY2UoMCwgc3RhcnQpICsgcmVzdWx0ICsgc3RyaW5nLnNsaWNlKGVuZCk7XG4gICAgICBfc3RhcnQubGFzdEluZGV4ID0gc3RhcnQgKyByZXN1bHQubGVuZ3RoO1xuICAgIH1cblxuICAgIF9zdGFydC5sYXN0SW5kZXggPSAwO1xuICAgIHJldHVybiBzdHJpbmc7XG4gIH07XG5cbiAgVVJJLmVuc3VyZVZhbGlkSG9zdG5hbWUgPSBmdW5jdGlvbih2KSB7XG4gICAgLy8gVGhlb3JldGljYWxseSBVUklzIGFsbG93IHBlcmNlbnQtZW5jb2RpbmcgaW4gSG9zdG5hbWVzIChhY2NvcmRpbmcgdG8gUkZDIDM5ODYpXG4gICAgLy8gdGhleSBhcmUgbm90IHBhcnQgb2YgRE5TIGFuZCB0aGVyZWZvcmUgaWdub3JlZCBieSBVUkkuanNcblxuICAgIGlmICh2Lm1hdGNoKFVSSS5pbnZhbGlkX2hvc3RuYW1lX2NoYXJhY3RlcnMpKSB7XG4gICAgICAvLyB0ZXN0IHB1bnljb2RlXG4gICAgICBpZiAoIXB1bnljb2RlKSB7XG4gICAgICAgIHRocm93IG5ldyBUeXBlRXJyb3IoJ0hvc3RuYW1lIFwiJyArIHYgKyAnXCIgY29udGFpbnMgY2hhcmFjdGVycyBvdGhlciB0aGFuIFtBLVowLTkuLV0gYW5kIFB1bnljb2RlLmpzIGlzIG5vdCBhdmFpbGFibGUnKTtcbiAgICAgIH1cblxuICAgICAgaWYgKHB1bnljb2RlLnRvQVNDSUkodikubWF0Y2goVVJJLmludmFsaWRfaG9zdG5hbWVfY2hhcmFjdGVycykpIHtcbiAgICAgICAgdGhyb3cgbmV3IFR5cGVFcnJvcignSG9zdG5hbWUgXCInICsgdiArICdcIiBjb250YWlucyBjaGFyYWN0ZXJzIG90aGVyIHRoYW4gW0EtWjAtOS4tXScpO1xuICAgICAgfVxuICAgIH1cbiAgfTtcblxuICAvLyBub0NvbmZsaWN0XG4gIFVSSS5ub0NvbmZsaWN0ID0gZnVuY3Rpb24ocmVtb3ZlQWxsKSB7XG4gICAgaWYgKHJlbW92ZUFsbCkge1xuICAgICAgdmFyIHVuY29uZmxpY3RlZCA9IHtcbiAgICAgICAgVVJJOiB0aGlzLm5vQ29uZmxpY3QoKVxuICAgICAgfTtcblxuICAgICAgaWYgKHJvb3QuVVJJVGVtcGxhdGUgJiYgdHlwZW9mIHJvb3QuVVJJVGVtcGxhdGUubm9Db25mbGljdCA9PT0gJ2Z1bmN0aW9uJykge1xuICAgICAgICB1bmNvbmZsaWN0ZWQuVVJJVGVtcGxhdGUgPSByb290LlVSSVRlbXBsYXRlLm5vQ29uZmxpY3QoKTtcbiAgICAgIH1cblxuICAgICAgaWYgKHJvb3QuSVB2NiAmJiB0eXBlb2Ygcm9vdC5JUHY2Lm5vQ29uZmxpY3QgPT09ICdmdW5jdGlvbicpIHtcbiAgICAgICAgdW5jb25mbGljdGVkLklQdjYgPSByb290LklQdjYubm9Db25mbGljdCgpO1xuICAgICAgfVxuXG4gICAgICBpZiAocm9vdC5TZWNvbmRMZXZlbERvbWFpbnMgJiYgdHlwZW9mIHJvb3QuU2Vjb25kTGV2ZWxEb21haW5zLm5vQ29uZmxpY3QgPT09ICdmdW5jdGlvbicpIHtcbiAgICAgICAgdW5jb25mbGljdGVkLlNlY29uZExldmVsRG9tYWlucyA9IHJvb3QuU2Vjb25kTGV2ZWxEb21haW5zLm5vQ29uZmxpY3QoKTtcbiAgICAgIH1cblxuICAgICAgcmV0dXJuIHVuY29uZmxpY3RlZDtcbiAgICB9IGVsc2UgaWYgKHJvb3QuVVJJID09PSB0aGlzKSB7XG4gICAgICByb290LlVSSSA9IF9VUkk7XG4gICAgfVxuXG4gICAgcmV0dXJuIHRoaXM7XG4gIH07XG5cbiAgcC5idWlsZCA9IGZ1bmN0aW9uKGRlZmVyQnVpbGQpIHtcbiAgICBpZiAoZGVmZXJCdWlsZCA9PT0gdHJ1ZSkge1xuICAgICAgdGhpcy5fZGVmZXJyZWRfYnVpbGQgPSB0cnVlO1xuICAgIH0gZWxzZSBpZiAoZGVmZXJCdWlsZCA9PT0gdW5kZWZpbmVkIHx8IHRoaXMuX2RlZmVycmVkX2J1aWxkKSB7XG4gICAgICB0aGlzLl9zdHJpbmcgPSBVUkkuYnVpbGQodGhpcy5fcGFydHMpO1xuICAgICAgdGhpcy5fZGVmZXJyZWRfYnVpbGQgPSBmYWxzZTtcbiAgICB9XG5cbiAgICByZXR1cm4gdGhpcztcbiAgfTtcblxuICBwLmNsb25lID0gZnVuY3Rpb24oKSB7XG4gICAgcmV0dXJuIG5ldyBVUkkodGhpcyk7XG4gIH07XG5cbiAgcC52YWx1ZU9mID0gcC50b1N0cmluZyA9IGZ1bmN0aW9uKCkge1xuICAgIHJldHVybiB0aGlzLmJ1aWxkKGZhbHNlKS5fc3RyaW5nO1xuICB9O1xuXG5cbiAgZnVuY3Rpb24gZ2VuZXJhdGVTaW1wbGVBY2Nlc3NvcihfcGFydCl7XG4gICAgcmV0dXJuIGZ1bmN0aW9uKHYsIGJ1aWxkKSB7XG4gICAgICBpZiAodiA9PT0gdW5kZWZpbmVkKSB7XG4gICAgICAgIHJldHVybiB0aGlzLl9wYXJ0c1tfcGFydF0gfHwgJyc7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICB0aGlzLl9wYXJ0c1tfcGFydF0gPSB2IHx8IG51bGw7XG4gICAgICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICAgICAgcmV0dXJuIHRoaXM7XG4gICAgICB9XG4gICAgfTtcbiAgfVxuXG4gIGZ1bmN0aW9uIGdlbmVyYXRlUHJlZml4QWNjZXNzb3IoX3BhcnQsIF9rZXkpe1xuICAgIHJldHVybiBmdW5jdGlvbih2LCBidWlsZCkge1xuICAgICAgaWYgKHYgPT09IHVuZGVmaW5lZCkge1xuICAgICAgICByZXR1cm4gdGhpcy5fcGFydHNbX3BhcnRdIHx8ICcnO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgaWYgKHYgIT09IG51bGwpIHtcbiAgICAgICAgICB2ID0gdiArICcnO1xuICAgICAgICAgIGlmICh2LmNoYXJBdCgwKSA9PT0gX2tleSkge1xuICAgICAgICAgICAgdiA9IHYuc3Vic3RyaW5nKDEpO1xuICAgICAgICAgIH1cbiAgICAgICAgfVxuXG4gICAgICAgIHRoaXMuX3BhcnRzW19wYXJ0XSA9IHY7XG4gICAgICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICAgICAgcmV0dXJuIHRoaXM7XG4gICAgICB9XG4gICAgfTtcbiAgfVxuXG4gIHAucHJvdG9jb2wgPSBnZW5lcmF0ZVNpbXBsZUFjY2Vzc29yKCdwcm90b2NvbCcpO1xuICBwLnVzZXJuYW1lID0gZ2VuZXJhdGVTaW1wbGVBY2Nlc3NvcigndXNlcm5hbWUnKTtcbiAgcC5wYXNzd29yZCA9IGdlbmVyYXRlU2ltcGxlQWNjZXNzb3IoJ3Bhc3N3b3JkJyk7XG4gIHAuaG9zdG5hbWUgPSBnZW5lcmF0ZVNpbXBsZUFjY2Vzc29yKCdob3N0bmFtZScpO1xuICBwLnBvcnQgPSBnZW5lcmF0ZVNpbXBsZUFjY2Vzc29yKCdwb3J0Jyk7XG4gIHAucXVlcnkgPSBnZW5lcmF0ZVByZWZpeEFjY2Vzc29yKCdxdWVyeScsICc/Jyk7XG4gIHAuZnJhZ21lbnQgPSBnZW5lcmF0ZVByZWZpeEFjY2Vzc29yKCdmcmFnbWVudCcsICcjJyk7XG5cbiAgcC5zZWFyY2ggPSBmdW5jdGlvbih2LCBidWlsZCkge1xuICAgIHZhciB0ID0gdGhpcy5xdWVyeSh2LCBidWlsZCk7XG4gICAgcmV0dXJuIHR5cGVvZiB0ID09PSAnc3RyaW5nJyAmJiB0Lmxlbmd0aCA/ICgnPycgKyB0KSA6IHQ7XG4gIH07XG4gIHAuaGFzaCA9IGZ1bmN0aW9uKHYsIGJ1aWxkKSB7XG4gICAgdmFyIHQgPSB0aGlzLmZyYWdtZW50KHYsIGJ1aWxkKTtcbiAgICByZXR1cm4gdHlwZW9mIHQgPT09ICdzdHJpbmcnICYmIHQubGVuZ3RoID8gKCcjJyArIHQpIDogdDtcbiAgfTtcblxuICBwLnBhdGhuYW1lID0gZnVuY3Rpb24odiwgYnVpbGQpIHtcbiAgICBpZiAodiA9PT0gdW5kZWZpbmVkIHx8IHYgPT09IHRydWUpIHtcbiAgICAgIHZhciByZXMgPSB0aGlzLl9wYXJ0cy5wYXRoIHx8ICh0aGlzLl9wYXJ0cy5ob3N0bmFtZSA/ICcvJyA6ICcnKTtcbiAgICAgIHJldHVybiB2ID8gKHRoaXMuX3BhcnRzLnVybiA/IFVSSS5kZWNvZGVVcm5QYXRoIDogVVJJLmRlY29kZVBhdGgpKHJlcykgOiByZXM7XG4gICAgfSBlbHNlIHtcbiAgICAgIGlmICh0aGlzLl9wYXJ0cy51cm4pIHtcbiAgICAgICAgdGhpcy5fcGFydHMucGF0aCA9IHYgPyBVUkkucmVjb2RlVXJuUGF0aCh2KSA6ICcnO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgdGhpcy5fcGFydHMucGF0aCA9IHYgPyBVUkkucmVjb2RlUGF0aCh2KSA6ICcvJztcbiAgICAgIH1cbiAgICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICAgIHJldHVybiB0aGlzO1xuICAgIH1cbiAgfTtcbiAgcC5wYXRoID0gcC5wYXRobmFtZTtcbiAgcC5ocmVmID0gZnVuY3Rpb24oaHJlZiwgYnVpbGQpIHtcbiAgICB2YXIga2V5O1xuXG4gICAgaWYgKGhyZWYgPT09IHVuZGVmaW5lZCkge1xuICAgICAgcmV0dXJuIHRoaXMudG9TdHJpbmcoKTtcbiAgICB9XG5cbiAgICB0aGlzLl9zdHJpbmcgPSAnJztcbiAgICB0aGlzLl9wYXJ0cyA9IFVSSS5fcGFydHMoKTtcblxuICAgIHZhciBfVVJJID0gaHJlZiBpbnN0YW5jZW9mIFVSSTtcbiAgICB2YXIgX29iamVjdCA9IHR5cGVvZiBocmVmID09PSAnb2JqZWN0JyAmJiAoaHJlZi5ob3N0bmFtZSB8fCBocmVmLnBhdGggfHwgaHJlZi5wYXRobmFtZSk7XG4gICAgaWYgKGhyZWYubm9kZU5hbWUpIHtcbiAgICAgIHZhciBhdHRyaWJ1dGUgPSBVUkkuZ2V0RG9tQXR0cmlidXRlKGhyZWYpO1xuICAgICAgaHJlZiA9IGhyZWZbYXR0cmlidXRlXSB8fCAnJztcbiAgICAgIF9vYmplY3QgPSBmYWxzZTtcbiAgICB9XG5cbiAgICAvLyB3aW5kb3cubG9jYXRpb24gaXMgcmVwb3J0ZWQgdG8gYmUgYW4gb2JqZWN0LCBidXQgaXQncyBub3QgdGhlIHNvcnRcbiAgICAvLyBvZiBvYmplY3Qgd2UncmUgbG9va2luZyBmb3I6XG4gICAgLy8gKiBsb2NhdGlvbi5wcm90b2NvbCBlbmRzIHdpdGggYSBjb2xvblxuICAgIC8vICogbG9jYXRpb24ucXVlcnkgIT0gb2JqZWN0LnNlYXJjaFxuICAgIC8vICogbG9jYXRpb24uaGFzaCAhPSBvYmplY3QuZnJhZ21lbnRcbiAgICAvLyBzaW1wbHkgc2VyaWFsaXppbmcgdGhlIHVua25vd24gb2JqZWN0IHNob3VsZCBkbyB0aGUgdHJpY2tcbiAgICAvLyAoZm9yIGxvY2F0aW9uLCBub3QgZm9yIGV2ZXJ5dGhpbmcuLi4pXG4gICAgaWYgKCFfVVJJICYmIF9vYmplY3QgJiYgaHJlZi5wYXRobmFtZSAhPT0gdW5kZWZpbmVkKSB7XG4gICAgICBocmVmID0gaHJlZi50b1N0cmluZygpO1xuICAgIH1cblxuICAgIGlmICh0eXBlb2YgaHJlZiA9PT0gJ3N0cmluZycgfHwgaHJlZiBpbnN0YW5jZW9mIFN0cmluZykge1xuICAgICAgdGhpcy5fcGFydHMgPSBVUkkucGFyc2UoU3RyaW5nKGhyZWYpLCB0aGlzLl9wYXJ0cyk7XG4gICAgfSBlbHNlIGlmIChfVVJJIHx8IF9vYmplY3QpIHtcbiAgICAgIHZhciBzcmMgPSBfVVJJID8gaHJlZi5fcGFydHMgOiBocmVmO1xuICAgICAgZm9yIChrZXkgaW4gc3JjKSB7XG4gICAgICAgIGlmIChoYXNPd24uY2FsbCh0aGlzLl9wYXJ0cywga2V5KSkge1xuICAgICAgICAgIHRoaXMuX3BhcnRzW2tleV0gPSBzcmNba2V5XTtcbiAgICAgICAgfVxuICAgICAgfVxuICAgIH0gZWxzZSB7XG4gICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKCdpbnZhbGlkIGlucHV0Jyk7XG4gICAgfVxuXG4gICAgdGhpcy5idWlsZCghYnVpbGQpO1xuICAgIHJldHVybiB0aGlzO1xuICB9O1xuXG4gIC8vIGlkZW50aWZpY2F0aW9uIGFjY2Vzc29yc1xuICBwLmlzID0gZnVuY3Rpb24od2hhdCkge1xuICAgIHZhciBpcCA9IGZhbHNlO1xuICAgIHZhciBpcDQgPSBmYWxzZTtcbiAgICB2YXIgaXA2ID0gZmFsc2U7XG4gICAgdmFyIG5hbWUgPSBmYWxzZTtcbiAgICB2YXIgc2xkID0gZmFsc2U7XG4gICAgdmFyIGlkbiA9IGZhbHNlO1xuICAgIHZhciBwdW55Y29kZSA9IGZhbHNlO1xuICAgIHZhciByZWxhdGl2ZSA9ICF0aGlzLl9wYXJ0cy51cm47XG5cbiAgICBpZiAodGhpcy5fcGFydHMuaG9zdG5hbWUpIHtcbiAgICAgIHJlbGF0aXZlID0gZmFsc2U7XG4gICAgICBpcDQgPSBVUkkuaXA0X2V4cHJlc3Npb24udGVzdCh0aGlzLl9wYXJ0cy5ob3N0bmFtZSk7XG4gICAgICBpcDYgPSBVUkkuaXA2X2V4cHJlc3Npb24udGVzdCh0aGlzLl9wYXJ0cy5ob3N0bmFtZSk7XG4gICAgICBpcCA9IGlwNCB8fCBpcDY7XG4gICAgICBuYW1lID0gIWlwO1xuICAgICAgc2xkID0gbmFtZSAmJiBTTEQgJiYgU0xELmhhcyh0aGlzLl9wYXJ0cy5ob3N0bmFtZSk7XG4gICAgICBpZG4gPSBuYW1lICYmIFVSSS5pZG5fZXhwcmVzc2lvbi50ZXN0KHRoaXMuX3BhcnRzLmhvc3RuYW1lKTtcbiAgICAgIHB1bnljb2RlID0gbmFtZSAmJiBVUkkucHVueWNvZGVfZXhwcmVzc2lvbi50ZXN0KHRoaXMuX3BhcnRzLmhvc3RuYW1lKTtcbiAgICB9XG5cbiAgICBzd2l0Y2ggKHdoYXQudG9Mb3dlckNhc2UoKSkge1xuICAgICAgY2FzZSAncmVsYXRpdmUnOlxuICAgICAgICByZXR1cm4gcmVsYXRpdmU7XG5cbiAgICAgIGNhc2UgJ2Fic29sdXRlJzpcbiAgICAgICAgcmV0dXJuICFyZWxhdGl2ZTtcblxuICAgICAgLy8gaG9zdG5hbWUgaWRlbnRpZmljYXRpb25cbiAgICAgIGNhc2UgJ2RvbWFpbic6XG4gICAgICBjYXNlICduYW1lJzpcbiAgICAgICAgcmV0dXJuIG5hbWU7XG5cbiAgICAgIGNhc2UgJ3NsZCc6XG4gICAgICAgIHJldHVybiBzbGQ7XG5cbiAgICAgIGNhc2UgJ2lwJzpcbiAgICAgICAgcmV0dXJuIGlwO1xuXG4gICAgICBjYXNlICdpcDQnOlxuICAgICAgY2FzZSAnaXB2NCc6XG4gICAgICBjYXNlICdpbmV0NCc6XG4gICAgICAgIHJldHVybiBpcDQ7XG5cbiAgICAgIGNhc2UgJ2lwNic6XG4gICAgICBjYXNlICdpcHY2JzpcbiAgICAgIGNhc2UgJ2luZXQ2JzpcbiAgICAgICAgcmV0dXJuIGlwNjtcblxuICAgICAgY2FzZSAnaWRuJzpcbiAgICAgICAgcmV0dXJuIGlkbjtcblxuICAgICAgY2FzZSAndXJsJzpcbiAgICAgICAgcmV0dXJuICF0aGlzLl9wYXJ0cy51cm47XG5cbiAgICAgIGNhc2UgJ3Vybic6XG4gICAgICAgIHJldHVybiAhIXRoaXMuX3BhcnRzLnVybjtcblxuICAgICAgY2FzZSAncHVueWNvZGUnOlxuICAgICAgICByZXR1cm4gcHVueWNvZGU7XG4gICAgfVxuXG4gICAgcmV0dXJuIG51bGw7XG4gIH07XG5cbiAgLy8gY29tcG9uZW50IHNwZWNpZmljIGlucHV0IHZhbGlkYXRpb25cbiAgdmFyIF9wcm90b2NvbCA9IHAucHJvdG9jb2w7XG4gIHZhciBfcG9ydCA9IHAucG9ydDtcbiAgdmFyIF9ob3N0bmFtZSA9IHAuaG9zdG5hbWU7XG5cbiAgcC5wcm90b2NvbCA9IGZ1bmN0aW9uKHYsIGJ1aWxkKSB7XG4gICAgaWYgKHYgIT09IHVuZGVmaW5lZCkge1xuICAgICAgaWYgKHYpIHtcbiAgICAgICAgLy8gYWNjZXB0IHRyYWlsaW5nIDovL1xuICAgICAgICB2ID0gdi5yZXBsYWNlKC86KFxcL1xcLyk/JC8sICcnKTtcblxuICAgICAgICBpZiAoIXYubWF0Y2goVVJJLnByb3RvY29sX2V4cHJlc3Npb24pKSB7XG4gICAgICAgICAgdGhyb3cgbmV3IFR5cGVFcnJvcignUHJvdG9jb2wgXCInICsgdiArICdcIiBjb250YWlucyBjaGFyYWN0ZXJzIG90aGVyIHRoYW4gW0EtWjAtOS4rLV0gb3IgZG9lc25cXCd0IHN0YXJ0IHdpdGggW0EtWl0nKTtcbiAgICAgICAgfVxuICAgICAgfVxuICAgIH1cbiAgICByZXR1cm4gX3Byb3RvY29sLmNhbGwodGhpcywgdiwgYnVpbGQpO1xuICB9O1xuICBwLnNjaGVtZSA9IHAucHJvdG9jb2w7XG4gIHAucG9ydCA9IGZ1bmN0aW9uKHYsIGJ1aWxkKSB7XG4gICAgaWYgKHRoaXMuX3BhcnRzLnVybikge1xuICAgICAgcmV0dXJuIHYgPT09IHVuZGVmaW5lZCA/ICcnIDogdGhpcztcbiAgICB9XG5cbiAgICBpZiAodiAhPT0gdW5kZWZpbmVkKSB7XG4gICAgICBpZiAodiA9PT0gMCkge1xuICAgICAgICB2ID0gbnVsbDtcbiAgICAgIH1cblxuICAgICAgaWYgKHYpIHtcbiAgICAgICAgdiArPSAnJztcbiAgICAgICAgaWYgKHYuY2hhckF0KDApID09PSAnOicpIHtcbiAgICAgICAgICB2ID0gdi5zdWJzdHJpbmcoMSk7XG4gICAgICAgIH1cblxuICAgICAgICBpZiAodi5tYXRjaCgvW14wLTldLykpIHtcbiAgICAgICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKCdQb3J0IFwiJyArIHYgKyAnXCIgY29udGFpbnMgY2hhcmFjdGVycyBvdGhlciB0aGFuIFswLTldJyk7XG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9XG4gICAgcmV0dXJuIF9wb3J0LmNhbGwodGhpcywgdiwgYnVpbGQpO1xuICB9O1xuICBwLmhvc3RuYW1lID0gZnVuY3Rpb24odiwgYnVpbGQpIHtcbiAgICBpZiAodGhpcy5fcGFydHMudXJuKSB7XG4gICAgICByZXR1cm4gdiA9PT0gdW5kZWZpbmVkID8gJycgOiB0aGlzO1xuICAgIH1cblxuICAgIGlmICh2ICE9PSB1bmRlZmluZWQpIHtcbiAgICAgIHZhciB4ID0ge307XG4gICAgICB2YXIgcmVzID0gVVJJLnBhcnNlSG9zdCh2LCB4KTtcbiAgICAgIGlmIChyZXMgIT09ICcvJykge1xuICAgICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKCdIb3N0bmFtZSBcIicgKyB2ICsgJ1wiIGNvbnRhaW5zIGNoYXJhY3RlcnMgb3RoZXIgdGhhbiBbQS1aMC05Li1dJyk7XG4gICAgICB9XG5cbiAgICAgIHYgPSB4Lmhvc3RuYW1lO1xuICAgIH1cbiAgICByZXR1cm4gX2hvc3RuYW1lLmNhbGwodGhpcywgdiwgYnVpbGQpO1xuICB9O1xuXG4gIC8vIGNvbXBvdW5kIGFjY2Vzc29yc1xuICBwLm9yaWdpbiA9IGZ1bmN0aW9uKHYsIGJ1aWxkKSB7XG4gICAgdmFyIHBhcnRzO1xuXG4gICAgaWYgKHRoaXMuX3BhcnRzLnVybikge1xuICAgICAgcmV0dXJuIHYgPT09IHVuZGVmaW5lZCA/ICcnIDogdGhpcztcbiAgICB9XG5cbiAgICBpZiAodiA9PT0gdW5kZWZpbmVkKSB7XG4gICAgICB2YXIgcHJvdG9jb2wgPSB0aGlzLnByb3RvY29sKCk7XG4gICAgICB2YXIgYXV0aG9yaXR5ID0gdGhpcy5hdXRob3JpdHkoKTtcbiAgICAgIGlmICghYXV0aG9yaXR5KSByZXR1cm4gJyc7XG4gICAgICByZXR1cm4gKHByb3RvY29sID8gcHJvdG9jb2wgKyAnOi8vJyA6ICcnKSArIHRoaXMuYXV0aG9yaXR5KCk7XG4gICAgfSBlbHNlIHtcbiAgICAgIHZhciBvcmlnaW4gPSBVUkkodik7XG4gICAgICB0aGlzXG4gICAgICAgIC5wcm90b2NvbChvcmlnaW4ucHJvdG9jb2woKSlcbiAgICAgICAgLmF1dGhvcml0eShvcmlnaW4uYXV0aG9yaXR5KCkpXG4gICAgICAgIC5idWlsZCghYnVpbGQpO1xuICAgICAgcmV0dXJuIHRoaXM7XG4gICAgfVxuICB9O1xuICBwLmhvc3QgPSBmdW5jdGlvbih2LCBidWlsZCkge1xuICAgIGlmICh0aGlzLl9wYXJ0cy51cm4pIHtcbiAgICAgIHJldHVybiB2ID09PSB1bmRlZmluZWQgPyAnJyA6IHRoaXM7XG4gICAgfVxuXG4gICAgaWYgKHYgPT09IHVuZGVmaW5lZCkge1xuICAgICAgcmV0dXJuIHRoaXMuX3BhcnRzLmhvc3RuYW1lID8gVVJJLmJ1aWxkSG9zdCh0aGlzLl9wYXJ0cykgOiAnJztcbiAgICB9IGVsc2Uge1xuICAgICAgdmFyIHJlcyA9IFVSSS5wYXJzZUhvc3QodiwgdGhpcy5fcGFydHMpO1xuICAgICAgaWYgKHJlcyAhPT0gJy8nKSB7XG4gICAgICAgIHRocm93IG5ldyBUeXBlRXJyb3IoJ0hvc3RuYW1lIFwiJyArIHYgKyAnXCIgY29udGFpbnMgY2hhcmFjdGVycyBvdGhlciB0aGFuIFtBLVowLTkuLV0nKTtcbiAgICAgIH1cblxuICAgICAgdGhpcy5idWlsZCghYnVpbGQpO1xuICAgICAgcmV0dXJuIHRoaXM7XG4gICAgfVxuICB9O1xuICBwLmF1dGhvcml0eSA9IGZ1bmN0aW9uKHYsIGJ1aWxkKSB7XG4gICAgaWYgKHRoaXMuX3BhcnRzLnVybikge1xuICAgICAgcmV0dXJuIHYgPT09IHVuZGVmaW5lZCA/ICcnIDogdGhpcztcbiAgICB9XG5cbiAgICBpZiAodiA9PT0gdW5kZWZpbmVkKSB7XG4gICAgICByZXR1cm4gdGhpcy5fcGFydHMuaG9zdG5hbWUgPyBVUkkuYnVpbGRBdXRob3JpdHkodGhpcy5fcGFydHMpIDogJyc7XG4gICAgfSBlbHNlIHtcbiAgICAgIHZhciByZXMgPSBVUkkucGFyc2VBdXRob3JpdHkodiwgdGhpcy5fcGFydHMpO1xuICAgICAgaWYgKHJlcyAhPT0gJy8nKSB7XG4gICAgICAgIHRocm93IG5ldyBUeXBlRXJyb3IoJ0hvc3RuYW1lIFwiJyArIHYgKyAnXCIgY29udGFpbnMgY2hhcmFjdGVycyBvdGhlciB0aGFuIFtBLVowLTkuLV0nKTtcbiAgICAgIH1cblxuICAgICAgdGhpcy5idWlsZCghYnVpbGQpO1xuICAgICAgcmV0dXJuIHRoaXM7XG4gICAgfVxuICB9O1xuICBwLnVzZXJpbmZvID0gZnVuY3Rpb24odiwgYnVpbGQpIHtcbiAgICBpZiAodGhpcy5fcGFydHMudXJuKSB7XG4gICAgICByZXR1cm4gdiA9PT0gdW5kZWZpbmVkID8gJycgOiB0aGlzO1xuICAgIH1cblxuICAgIGlmICh2ID09PSB1bmRlZmluZWQpIHtcbiAgICAgIGlmICghdGhpcy5fcGFydHMudXNlcm5hbWUpIHtcbiAgICAgICAgcmV0dXJuICcnO1xuICAgICAgfVxuXG4gICAgICB2YXIgdCA9IFVSSS5idWlsZFVzZXJpbmZvKHRoaXMuX3BhcnRzKTtcbiAgICAgIHJldHVybiB0LnN1YnN0cmluZygwLCB0Lmxlbmd0aCAtMSk7XG4gICAgfSBlbHNlIHtcbiAgICAgIGlmICh2W3YubGVuZ3RoLTFdICE9PSAnQCcpIHtcbiAgICAgICAgdiArPSAnQCc7XG4gICAgICB9XG5cbiAgICAgIFVSSS5wYXJzZVVzZXJpbmZvKHYsIHRoaXMuX3BhcnRzKTtcbiAgICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICAgIHJldHVybiB0aGlzO1xuICAgIH1cbiAgfTtcbiAgcC5yZXNvdXJjZSA9IGZ1bmN0aW9uKHYsIGJ1aWxkKSB7XG4gICAgdmFyIHBhcnRzO1xuXG4gICAgaWYgKHYgPT09IHVuZGVmaW5lZCkge1xuICAgICAgcmV0dXJuIHRoaXMucGF0aCgpICsgdGhpcy5zZWFyY2goKSArIHRoaXMuaGFzaCgpO1xuICAgIH1cblxuICAgIHBhcnRzID0gVVJJLnBhcnNlKHYpO1xuICAgIHRoaXMuX3BhcnRzLnBhdGggPSBwYXJ0cy5wYXRoO1xuICAgIHRoaXMuX3BhcnRzLnF1ZXJ5ID0gcGFydHMucXVlcnk7XG4gICAgdGhpcy5fcGFydHMuZnJhZ21lbnQgPSBwYXJ0cy5mcmFnbWVudDtcbiAgICB0aGlzLmJ1aWxkKCFidWlsZCk7XG4gICAgcmV0dXJuIHRoaXM7XG4gIH07XG5cbiAgLy8gZnJhY3Rpb24gYWNjZXNzb3JzXG4gIHAuc3ViZG9tYWluID0gZnVuY3Rpb24odiwgYnVpbGQpIHtcbiAgICBpZiAodGhpcy5fcGFydHMudXJuKSB7XG4gICAgICByZXR1cm4gdiA9PT0gdW5kZWZpbmVkID8gJycgOiB0aGlzO1xuICAgIH1cblxuICAgIC8vIGNvbnZlbmllbmNlLCByZXR1cm4gXCJ3d3dcIiBmcm9tIFwid3d3LmV4YW1wbGUub3JnXCJcbiAgICBpZiAodiA9PT0gdW5kZWZpbmVkKSB7XG4gICAgICBpZiAoIXRoaXMuX3BhcnRzLmhvc3RuYW1lIHx8IHRoaXMuaXMoJ0lQJykpIHtcbiAgICAgICAgcmV0dXJuICcnO1xuICAgICAgfVxuXG4gICAgICAvLyBncmFiIGRvbWFpbiBhbmQgYWRkIGFub3RoZXIgc2VnbWVudFxuICAgICAgdmFyIGVuZCA9IHRoaXMuX3BhcnRzLmhvc3RuYW1lLmxlbmd0aCAtIHRoaXMuZG9tYWluKCkubGVuZ3RoIC0gMTtcbiAgICAgIHJldHVybiB0aGlzLl9wYXJ0cy5ob3N0bmFtZS5zdWJzdHJpbmcoMCwgZW5kKSB8fCAnJztcbiAgICB9IGVsc2Uge1xuICAgICAgdmFyIGUgPSB0aGlzLl9wYXJ0cy5ob3N0bmFtZS5sZW5ndGggLSB0aGlzLmRvbWFpbigpLmxlbmd0aDtcbiAgICAgIHZhciBzdWIgPSB0aGlzLl9wYXJ0cy5ob3N0bmFtZS5zdWJzdHJpbmcoMCwgZSk7XG4gICAgICB2YXIgcmVwbGFjZSA9IG5ldyBSZWdFeHAoJ14nICsgZXNjYXBlUmVnRXgoc3ViKSk7XG5cbiAgICAgIGlmICh2ICYmIHYuY2hhckF0KHYubGVuZ3RoIC0gMSkgIT09ICcuJykge1xuICAgICAgICB2ICs9ICcuJztcbiAgICAgIH1cblxuICAgICAgaWYgKHYpIHtcbiAgICAgICAgVVJJLmVuc3VyZVZhbGlkSG9zdG5hbWUodik7XG4gICAgICB9XG5cbiAgICAgIHRoaXMuX3BhcnRzLmhvc3RuYW1lID0gdGhpcy5fcGFydHMuaG9zdG5hbWUucmVwbGFjZShyZXBsYWNlLCB2KTtcbiAgICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICAgIHJldHVybiB0aGlzO1xuICAgIH1cbiAgfTtcbiAgcC5kb21haW4gPSBmdW5jdGlvbih2LCBidWlsZCkge1xuICAgIGlmICh0aGlzLl9wYXJ0cy51cm4pIHtcbiAgICAgIHJldHVybiB2ID09PSB1bmRlZmluZWQgPyAnJyA6IHRoaXM7XG4gICAgfVxuXG4gICAgaWYgKHR5cGVvZiB2ID09PSAnYm9vbGVhbicpIHtcbiAgICAgIGJ1aWxkID0gdjtcbiAgICAgIHYgPSB1bmRlZmluZWQ7XG4gICAgfVxuXG4gICAgLy8gY29udmVuaWVuY2UsIHJldHVybiBcImV4YW1wbGUub3JnXCIgZnJvbSBcInd3dy5leGFtcGxlLm9yZ1wiXG4gICAgaWYgKHYgPT09IHVuZGVmaW5lZCkge1xuICAgICAgaWYgKCF0aGlzLl9wYXJ0cy5ob3N0bmFtZSB8fCB0aGlzLmlzKCdJUCcpKSB7XG4gICAgICAgIHJldHVybiAnJztcbiAgICAgIH1cblxuICAgICAgLy8gaWYgaG9zdG5hbWUgY29uc2lzdHMgb2YgMSBvciAyIHNlZ21lbnRzLCBpdCBtdXN0IGJlIHRoZSBkb21haW5cbiAgICAgIHZhciB0ID0gdGhpcy5fcGFydHMuaG9zdG5hbWUubWF0Y2goL1xcLi9nKTtcbiAgICAgIGlmICh0ICYmIHQubGVuZ3RoIDwgMikge1xuICAgICAgICByZXR1cm4gdGhpcy5fcGFydHMuaG9zdG5hbWU7XG4gICAgICB9XG5cbiAgICAgIC8vIGdyYWIgdGxkIGFuZCBhZGQgYW5vdGhlciBzZWdtZW50XG4gICAgICB2YXIgZW5kID0gdGhpcy5fcGFydHMuaG9zdG5hbWUubGVuZ3RoIC0gdGhpcy50bGQoYnVpbGQpLmxlbmd0aCAtIDE7XG4gICAgICBlbmQgPSB0aGlzLl9wYXJ0cy5ob3N0bmFtZS5sYXN0SW5kZXhPZignLicsIGVuZCAtMSkgKyAxO1xuICAgICAgcmV0dXJuIHRoaXMuX3BhcnRzLmhvc3RuYW1lLnN1YnN0cmluZyhlbmQpIHx8ICcnO1xuICAgIH0gZWxzZSB7XG4gICAgICBpZiAoIXYpIHtcbiAgICAgICAgdGhyb3cgbmV3IFR5cGVFcnJvcignY2Fubm90IHNldCBkb21haW4gZW1wdHknKTtcbiAgICAgIH1cblxuICAgICAgVVJJLmVuc3VyZVZhbGlkSG9zdG5hbWUodik7XG5cbiAgICAgIGlmICghdGhpcy5fcGFydHMuaG9zdG5hbWUgfHwgdGhpcy5pcygnSVAnKSkge1xuICAgICAgICB0aGlzLl9wYXJ0cy5ob3N0bmFtZSA9IHY7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICB2YXIgcmVwbGFjZSA9IG5ldyBSZWdFeHAoZXNjYXBlUmVnRXgodGhpcy5kb21haW4oKSkgKyAnJCcpO1xuICAgICAgICB0aGlzLl9wYXJ0cy5ob3N0bmFtZSA9IHRoaXMuX3BhcnRzLmhvc3RuYW1lLnJlcGxhY2UocmVwbGFjZSwgdik7XG4gICAgICB9XG5cbiAgICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICAgIHJldHVybiB0aGlzO1xuICAgIH1cbiAgfTtcbiAgcC50bGQgPSBmdW5jdGlvbih2LCBidWlsZCkge1xuICAgIGlmICh0aGlzLl9wYXJ0cy51cm4pIHtcbiAgICAgIHJldHVybiB2ID09PSB1bmRlZmluZWQgPyAnJyA6IHRoaXM7XG4gICAgfVxuXG4gICAgaWYgKHR5cGVvZiB2ID09PSAnYm9vbGVhbicpIHtcbiAgICAgIGJ1aWxkID0gdjtcbiAgICAgIHYgPSB1bmRlZmluZWQ7XG4gICAgfVxuXG4gICAgLy8gcmV0dXJuIFwib3JnXCIgZnJvbSBcInd3dy5leGFtcGxlLm9yZ1wiXG4gICAgaWYgKHYgPT09IHVuZGVmaW5lZCkge1xuICAgICAgaWYgKCF0aGlzLl9wYXJ0cy5ob3N0bmFtZSB8fCB0aGlzLmlzKCdJUCcpKSB7XG4gICAgICAgIHJldHVybiAnJztcbiAgICAgIH1cblxuICAgICAgdmFyIHBvcyA9IHRoaXMuX3BhcnRzLmhvc3RuYW1lLmxhc3RJbmRleE9mKCcuJyk7XG4gICAgICB2YXIgdGxkID0gdGhpcy5fcGFydHMuaG9zdG5hbWUuc3Vic3RyaW5nKHBvcyArIDEpO1xuXG4gICAgICBpZiAoYnVpbGQgIT09IHRydWUgJiYgU0xEICYmIFNMRC5saXN0W3RsZC50b0xvd2VyQ2FzZSgpXSkge1xuICAgICAgICByZXR1cm4gU0xELmdldCh0aGlzLl9wYXJ0cy5ob3N0bmFtZSkgfHwgdGxkO1xuICAgICAgfVxuXG4gICAgICByZXR1cm4gdGxkO1xuICAgIH0gZWxzZSB7XG4gICAgICB2YXIgcmVwbGFjZTtcblxuICAgICAgaWYgKCF2KSB7XG4gICAgICAgIHRocm93IG5ldyBUeXBlRXJyb3IoJ2Nhbm5vdCBzZXQgVExEIGVtcHR5Jyk7XG4gICAgICB9IGVsc2UgaWYgKHYubWF0Y2goL1teYS16QS1aMC05LV0vKSkge1xuICAgICAgICBpZiAoU0xEICYmIFNMRC5pcyh2KSkge1xuICAgICAgICAgIHJlcGxhY2UgPSBuZXcgUmVnRXhwKGVzY2FwZVJlZ0V4KHRoaXMudGxkKCkpICsgJyQnKTtcbiAgICAgICAgICB0aGlzLl9wYXJ0cy5ob3N0bmFtZSA9IHRoaXMuX3BhcnRzLmhvc3RuYW1lLnJlcGxhY2UocmVwbGFjZSwgdik7XG4gICAgICAgIH0gZWxzZSB7XG4gICAgICAgICAgdGhyb3cgbmV3IFR5cGVFcnJvcignVExEIFwiJyArIHYgKyAnXCIgY29udGFpbnMgY2hhcmFjdGVycyBvdGhlciB0aGFuIFtBLVowLTldJyk7XG4gICAgICAgIH1cbiAgICAgIH0gZWxzZSBpZiAoIXRoaXMuX3BhcnRzLmhvc3RuYW1lIHx8IHRoaXMuaXMoJ0lQJykpIHtcbiAgICAgICAgdGhyb3cgbmV3IFJlZmVyZW5jZUVycm9yKCdjYW5ub3Qgc2V0IFRMRCBvbiBub24tZG9tYWluIGhvc3QnKTtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIHJlcGxhY2UgPSBuZXcgUmVnRXhwKGVzY2FwZVJlZ0V4KHRoaXMudGxkKCkpICsgJyQnKTtcbiAgICAgICAgdGhpcy5fcGFydHMuaG9zdG5hbWUgPSB0aGlzLl9wYXJ0cy5ob3N0bmFtZS5yZXBsYWNlKHJlcGxhY2UsIHYpO1xuICAgICAgfVxuXG4gICAgICB0aGlzLmJ1aWxkKCFidWlsZCk7XG4gICAgICByZXR1cm4gdGhpcztcbiAgICB9XG4gIH07XG4gIHAuZGlyZWN0b3J5ID0gZnVuY3Rpb24odiwgYnVpbGQpIHtcbiAgICBpZiAodGhpcy5fcGFydHMudXJuKSB7XG4gICAgICByZXR1cm4gdiA9PT0gdW5kZWZpbmVkID8gJycgOiB0aGlzO1xuICAgIH1cblxuICAgIGlmICh2ID09PSB1bmRlZmluZWQgfHwgdiA9PT0gdHJ1ZSkge1xuICAgICAgaWYgKCF0aGlzLl9wYXJ0cy5wYXRoICYmICF0aGlzLl9wYXJ0cy5ob3N0bmFtZSkge1xuICAgICAgICByZXR1cm4gJyc7XG4gICAgICB9XG5cbiAgICAgIGlmICh0aGlzLl9wYXJ0cy5wYXRoID09PSAnLycpIHtcbiAgICAgICAgcmV0dXJuICcvJztcbiAgICAgIH1cblxuICAgICAgdmFyIGVuZCA9IHRoaXMuX3BhcnRzLnBhdGgubGVuZ3RoIC0gdGhpcy5maWxlbmFtZSgpLmxlbmd0aCAtIDE7XG4gICAgICB2YXIgcmVzID0gdGhpcy5fcGFydHMucGF0aC5zdWJzdHJpbmcoMCwgZW5kKSB8fCAodGhpcy5fcGFydHMuaG9zdG5hbWUgPyAnLycgOiAnJyk7XG5cbiAgICAgIHJldHVybiB2ID8gVVJJLmRlY29kZVBhdGgocmVzKSA6IHJlcztcblxuICAgIH0gZWxzZSB7XG4gICAgICB2YXIgZSA9IHRoaXMuX3BhcnRzLnBhdGgubGVuZ3RoIC0gdGhpcy5maWxlbmFtZSgpLmxlbmd0aDtcbiAgICAgIHZhciBkaXJlY3RvcnkgPSB0aGlzLl9wYXJ0cy5wYXRoLnN1YnN0cmluZygwLCBlKTtcbiAgICAgIHZhciByZXBsYWNlID0gbmV3IFJlZ0V4cCgnXicgKyBlc2NhcGVSZWdFeChkaXJlY3RvcnkpKTtcblxuICAgICAgLy8gZnVsbHkgcXVhbGlmaWVyIGRpcmVjdG9yaWVzIGJlZ2luIHdpdGggYSBzbGFzaFxuICAgICAgaWYgKCF0aGlzLmlzKCdyZWxhdGl2ZScpKSB7XG4gICAgICAgIGlmICghdikge1xuICAgICAgICAgIHYgPSAnLyc7XG4gICAgICAgIH1cblxuICAgICAgICBpZiAodi5jaGFyQXQoMCkgIT09ICcvJykge1xuICAgICAgICAgIHYgPSAnLycgKyB2O1xuICAgICAgICB9XG4gICAgICB9XG5cbiAgICAgIC8vIGRpcmVjdG9yaWVzIGFsd2F5cyBlbmQgd2l0aCBhIHNsYXNoXG4gICAgICBpZiAodiAmJiB2LmNoYXJBdCh2Lmxlbmd0aCAtIDEpICE9PSAnLycpIHtcbiAgICAgICAgdiArPSAnLyc7XG4gICAgICB9XG5cbiAgICAgIHYgPSBVUkkucmVjb2RlUGF0aCh2KTtcbiAgICAgIHRoaXMuX3BhcnRzLnBhdGggPSB0aGlzLl9wYXJ0cy5wYXRoLnJlcGxhY2UocmVwbGFjZSwgdik7XG4gICAgICB0aGlzLmJ1aWxkKCFidWlsZCk7XG4gICAgICByZXR1cm4gdGhpcztcbiAgICB9XG4gIH07XG4gIHAuZmlsZW5hbWUgPSBmdW5jdGlvbih2LCBidWlsZCkge1xuICAgIGlmICh0aGlzLl9wYXJ0cy51cm4pIHtcbiAgICAgIHJldHVybiB2ID09PSB1bmRlZmluZWQgPyAnJyA6IHRoaXM7XG4gICAgfVxuXG4gICAgaWYgKHYgPT09IHVuZGVmaW5lZCB8fCB2ID09PSB0cnVlKSB7XG4gICAgICBpZiAoIXRoaXMuX3BhcnRzLnBhdGggfHwgdGhpcy5fcGFydHMucGF0aCA9PT0gJy8nKSB7XG4gICAgICAgIHJldHVybiAnJztcbiAgICAgIH1cblxuICAgICAgdmFyIHBvcyA9IHRoaXMuX3BhcnRzLnBhdGgubGFzdEluZGV4T2YoJy8nKTtcbiAgICAgIHZhciByZXMgPSB0aGlzLl9wYXJ0cy5wYXRoLnN1YnN0cmluZyhwb3MrMSk7XG5cbiAgICAgIHJldHVybiB2ID8gVVJJLmRlY29kZVBhdGhTZWdtZW50KHJlcykgOiByZXM7XG4gICAgfSBlbHNlIHtcbiAgICAgIHZhciBtdXRhdGVkRGlyZWN0b3J5ID0gZmFsc2U7XG5cbiAgICAgIGlmICh2LmNoYXJBdCgwKSA9PT0gJy8nKSB7XG4gICAgICAgIHYgPSB2LnN1YnN0cmluZygxKTtcbiAgICAgIH1cblxuICAgICAgaWYgKHYubWF0Y2goL1xcLj9cXC8vKSkge1xuICAgICAgICBtdXRhdGVkRGlyZWN0b3J5ID0gdHJ1ZTtcbiAgICAgIH1cblxuICAgICAgdmFyIHJlcGxhY2UgPSBuZXcgUmVnRXhwKGVzY2FwZVJlZ0V4KHRoaXMuZmlsZW5hbWUoKSkgKyAnJCcpO1xuICAgICAgdiA9IFVSSS5yZWNvZGVQYXRoKHYpO1xuICAgICAgdGhpcy5fcGFydHMucGF0aCA9IHRoaXMuX3BhcnRzLnBhdGgucmVwbGFjZShyZXBsYWNlLCB2KTtcblxuICAgICAgaWYgKG11dGF0ZWREaXJlY3RvcnkpIHtcbiAgICAgICAgdGhpcy5ub3JtYWxpemVQYXRoKGJ1aWxkKTtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICAgIH1cblxuICAgICAgcmV0dXJuIHRoaXM7XG4gICAgfVxuICB9O1xuICBwLnN1ZmZpeCA9IGZ1bmN0aW9uKHYsIGJ1aWxkKSB7XG4gICAgaWYgKHRoaXMuX3BhcnRzLnVybikge1xuICAgICAgcmV0dXJuIHYgPT09IHVuZGVmaW5lZCA/ICcnIDogdGhpcztcbiAgICB9XG5cbiAgICBpZiAodiA9PT0gdW5kZWZpbmVkIHx8IHYgPT09IHRydWUpIHtcbiAgICAgIGlmICghdGhpcy5fcGFydHMucGF0aCB8fCB0aGlzLl9wYXJ0cy5wYXRoID09PSAnLycpIHtcbiAgICAgICAgcmV0dXJuICcnO1xuICAgICAgfVxuXG4gICAgICB2YXIgZmlsZW5hbWUgPSB0aGlzLmZpbGVuYW1lKCk7XG4gICAgICB2YXIgcG9zID0gZmlsZW5hbWUubGFzdEluZGV4T2YoJy4nKTtcbiAgICAgIHZhciBzLCByZXM7XG5cbiAgICAgIGlmIChwb3MgPT09IC0xKSB7XG4gICAgICAgIHJldHVybiAnJztcbiAgICAgIH1cblxuICAgICAgLy8gc3VmZml4IG1heSBvbmx5IGNvbnRhaW4gYWxudW0gY2hhcmFjdGVycyAoeXVwLCBJIG1hZGUgdGhpcyB1cC4pXG4gICAgICBzID0gZmlsZW5hbWUuc3Vic3RyaW5nKHBvcysxKTtcbiAgICAgIHJlcyA9ICgvXlthLXowLTklXSskL2kpLnRlc3QocykgPyBzIDogJyc7XG4gICAgICByZXR1cm4gdiA/IFVSSS5kZWNvZGVQYXRoU2VnbWVudChyZXMpIDogcmVzO1xuICAgIH0gZWxzZSB7XG4gICAgICBpZiAodi5jaGFyQXQoMCkgPT09ICcuJykge1xuICAgICAgICB2ID0gdi5zdWJzdHJpbmcoMSk7XG4gICAgICB9XG5cbiAgICAgIHZhciBzdWZmaXggPSB0aGlzLnN1ZmZpeCgpO1xuICAgICAgdmFyIHJlcGxhY2U7XG5cbiAgICAgIGlmICghc3VmZml4KSB7XG4gICAgICAgIGlmICghdikge1xuICAgICAgICAgIHJldHVybiB0aGlzO1xuICAgICAgICB9XG5cbiAgICAgICAgdGhpcy5fcGFydHMucGF0aCArPSAnLicgKyBVUkkucmVjb2RlUGF0aCh2KTtcbiAgICAgIH0gZWxzZSBpZiAoIXYpIHtcbiAgICAgICAgcmVwbGFjZSA9IG5ldyBSZWdFeHAoZXNjYXBlUmVnRXgoJy4nICsgc3VmZml4KSArICckJyk7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICByZXBsYWNlID0gbmV3IFJlZ0V4cChlc2NhcGVSZWdFeChzdWZmaXgpICsgJyQnKTtcbiAgICAgIH1cblxuICAgICAgaWYgKHJlcGxhY2UpIHtcbiAgICAgICAgdiA9IFVSSS5yZWNvZGVQYXRoKHYpO1xuICAgICAgICB0aGlzLl9wYXJ0cy5wYXRoID0gdGhpcy5fcGFydHMucGF0aC5yZXBsYWNlKHJlcGxhY2UsIHYpO1xuICAgICAgfVxuXG4gICAgICB0aGlzLmJ1aWxkKCFidWlsZCk7XG4gICAgICByZXR1cm4gdGhpcztcbiAgICB9XG4gIH07XG4gIHAuc2VnbWVudCA9IGZ1bmN0aW9uKHNlZ21lbnQsIHYsIGJ1aWxkKSB7XG4gICAgdmFyIHNlcGFyYXRvciA9IHRoaXMuX3BhcnRzLnVybiA/ICc6JyA6ICcvJztcbiAgICB2YXIgcGF0aCA9IHRoaXMucGF0aCgpO1xuICAgIHZhciBhYnNvbHV0ZSA9IHBhdGguc3Vic3RyaW5nKDAsIDEpID09PSAnLyc7XG4gICAgdmFyIHNlZ21lbnRzID0gcGF0aC5zcGxpdChzZXBhcmF0b3IpO1xuXG4gICAgaWYgKHNlZ21lbnQgIT09IHVuZGVmaW5lZCAmJiB0eXBlb2Ygc2VnbWVudCAhPT0gJ251bWJlcicpIHtcbiAgICAgIGJ1aWxkID0gdjtcbiAgICAgIHYgPSBzZWdtZW50O1xuICAgICAgc2VnbWVudCA9IHVuZGVmaW5lZDtcbiAgICB9XG5cbiAgICBpZiAoc2VnbWVudCAhPT0gdW5kZWZpbmVkICYmIHR5cGVvZiBzZWdtZW50ICE9PSAnbnVtYmVyJykge1xuICAgICAgdGhyb3cgbmV3IEVycm9yKCdCYWQgc2VnbWVudCBcIicgKyBzZWdtZW50ICsgJ1wiLCBtdXN0IGJlIDAtYmFzZWQgaW50ZWdlcicpO1xuICAgIH1cblxuICAgIGlmIChhYnNvbHV0ZSkge1xuICAgICAgc2VnbWVudHMuc2hpZnQoKTtcbiAgICB9XG5cbiAgICBpZiAoc2VnbWVudCA8IDApIHtcbiAgICAgIC8vIGFsbG93IG5lZ2F0aXZlIGluZGV4ZXMgdG8gYWRkcmVzcyBmcm9tIHRoZSBlbmRcbiAgICAgIHNlZ21lbnQgPSBNYXRoLm1heChzZWdtZW50cy5sZW5ndGggKyBzZWdtZW50LCAwKTtcbiAgICB9XG5cbiAgICBpZiAodiA9PT0gdW5kZWZpbmVkKSB7XG4gICAgICAvKmpzaGludCBsYXhicmVhazogdHJ1ZSAqL1xuICAgICAgcmV0dXJuIHNlZ21lbnQgPT09IHVuZGVmaW5lZFxuICAgICAgICA/IHNlZ21lbnRzXG4gICAgICAgIDogc2VnbWVudHNbc2VnbWVudF07XG4gICAgICAvKmpzaGludCBsYXhicmVhazogZmFsc2UgKi9cbiAgICB9IGVsc2UgaWYgKHNlZ21lbnQgPT09IG51bGwgfHwgc2VnbWVudHNbc2VnbWVudF0gPT09IHVuZGVmaW5lZCkge1xuICAgICAgaWYgKGlzQXJyYXkodikpIHtcbiAgICAgICAgc2VnbWVudHMgPSBbXTtcbiAgICAgICAgLy8gY29sbGFwc2UgZW1wdHkgZWxlbWVudHMgd2l0aGluIGFycmF5XG4gICAgICAgIGZvciAodmFyIGk9MCwgbD12Lmxlbmd0aDsgaSA8IGw7IGkrKykge1xuICAgICAgICAgIGlmICghdltpXS5sZW5ndGggJiYgKCFzZWdtZW50cy5sZW5ndGggfHwgIXNlZ21lbnRzW3NlZ21lbnRzLmxlbmd0aCAtMV0ubGVuZ3RoKSkge1xuICAgICAgICAgICAgY29udGludWU7XG4gICAgICAgICAgfVxuXG4gICAgICAgICAgaWYgKHNlZ21lbnRzLmxlbmd0aCAmJiAhc2VnbWVudHNbc2VnbWVudHMubGVuZ3RoIC0xXS5sZW5ndGgpIHtcbiAgICAgICAgICAgIHNlZ21lbnRzLnBvcCgpO1xuICAgICAgICAgIH1cblxuICAgICAgICAgIHNlZ21lbnRzLnB1c2godHJpbVNsYXNoZXModltpXSkpO1xuICAgICAgICB9XG4gICAgICB9IGVsc2UgaWYgKHYgfHwgdHlwZW9mIHYgPT09ICdzdHJpbmcnKSB7XG4gICAgICAgIHYgPSB0cmltU2xhc2hlcyh2KTtcbiAgICAgICAgaWYgKHNlZ21lbnRzW3NlZ21lbnRzLmxlbmd0aCAtMV0gPT09ICcnKSB7XG4gICAgICAgICAgLy8gZW1wdHkgdHJhaWxpbmcgZWxlbWVudHMgaGF2ZSB0byBiZSBvdmVyd3JpdHRlblxuICAgICAgICAgIC8vIHRvIHByZXZlbnQgcmVzdWx0cyBzdWNoIGFzIC9mb28vL2JhclxuICAgICAgICAgIHNlZ21lbnRzW3NlZ21lbnRzLmxlbmd0aCAtMV0gPSB2O1xuICAgICAgICB9IGVsc2Uge1xuICAgICAgICAgIHNlZ21lbnRzLnB1c2godik7XG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9IGVsc2Uge1xuICAgICAgaWYgKHYpIHtcbiAgICAgICAgc2VnbWVudHNbc2VnbWVudF0gPSB0cmltU2xhc2hlcyh2KTtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIHNlZ21lbnRzLnNwbGljZShzZWdtZW50LCAxKTtcbiAgICAgIH1cbiAgICB9XG5cbiAgICBpZiAoYWJzb2x1dGUpIHtcbiAgICAgIHNlZ21lbnRzLnVuc2hpZnQoJycpO1xuICAgIH1cblxuICAgIHJldHVybiB0aGlzLnBhdGgoc2VnbWVudHMuam9pbihzZXBhcmF0b3IpLCBidWlsZCk7XG4gIH07XG4gIHAuc2VnbWVudENvZGVkID0gZnVuY3Rpb24oc2VnbWVudCwgdiwgYnVpbGQpIHtcbiAgICB2YXIgc2VnbWVudHMsIGksIGw7XG5cbiAgICBpZiAodHlwZW9mIHNlZ21lbnQgIT09ICdudW1iZXInKSB7XG4gICAgICBidWlsZCA9IHY7XG4gICAgICB2ID0gc2VnbWVudDtcbiAgICAgIHNlZ21lbnQgPSB1bmRlZmluZWQ7XG4gICAgfVxuXG4gICAgaWYgKHYgPT09IHVuZGVmaW5lZCkge1xuICAgICAgc2VnbWVudHMgPSB0aGlzLnNlZ21lbnQoc2VnbWVudCwgdiwgYnVpbGQpO1xuICAgICAgaWYgKCFpc0FycmF5KHNlZ21lbnRzKSkge1xuICAgICAgICBzZWdtZW50cyA9IHNlZ21lbnRzICE9PSB1bmRlZmluZWQgPyBVUkkuZGVjb2RlKHNlZ21lbnRzKSA6IHVuZGVmaW5lZDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGZvciAoaSA9IDAsIGwgPSBzZWdtZW50cy5sZW5ndGg7IGkgPCBsOyBpKyspIHtcbiAgICAgICAgICBzZWdtZW50c1tpXSA9IFVSSS5kZWNvZGUoc2VnbWVudHNbaV0pO1xuICAgICAgICB9XG4gICAgICB9XG5cbiAgICAgIHJldHVybiBzZWdtZW50cztcbiAgICB9XG5cbiAgICBpZiAoIWlzQXJyYXkodikpIHtcbiAgICAgIHYgPSAodHlwZW9mIHYgPT09ICdzdHJpbmcnIHx8IHYgaW5zdGFuY2VvZiBTdHJpbmcpID8gVVJJLmVuY29kZSh2KSA6IHY7XG4gICAgfSBlbHNlIHtcbiAgICAgIGZvciAoaSA9IDAsIGwgPSB2Lmxlbmd0aDsgaSA8IGw7IGkrKykge1xuICAgICAgICB2W2ldID0gVVJJLmVuY29kZSh2W2ldKTtcbiAgICAgIH1cbiAgICB9XG5cbiAgICByZXR1cm4gdGhpcy5zZWdtZW50KHNlZ21lbnQsIHYsIGJ1aWxkKTtcbiAgfTtcblxuICAvLyBtdXRhdGluZyBxdWVyeSBzdHJpbmdcbiAgdmFyIHEgPSBwLnF1ZXJ5O1xuICBwLnF1ZXJ5ID0gZnVuY3Rpb24odiwgYnVpbGQpIHtcbiAgICBpZiAodiA9PT0gdHJ1ZSkge1xuICAgICAgcmV0dXJuIFVSSS5wYXJzZVF1ZXJ5KHRoaXMuX3BhcnRzLnF1ZXJ5LCB0aGlzLl9wYXJ0cy5lc2NhcGVRdWVyeVNwYWNlKTtcbiAgICB9IGVsc2UgaWYgKHR5cGVvZiB2ID09PSAnZnVuY3Rpb24nKSB7XG4gICAgICB2YXIgZGF0YSA9IFVSSS5wYXJzZVF1ZXJ5KHRoaXMuX3BhcnRzLnF1ZXJ5LCB0aGlzLl9wYXJ0cy5lc2NhcGVRdWVyeVNwYWNlKTtcbiAgICAgIHZhciByZXN1bHQgPSB2LmNhbGwodGhpcywgZGF0YSk7XG4gICAgICB0aGlzLl9wYXJ0cy5xdWVyeSA9IFVSSS5idWlsZFF1ZXJ5KHJlc3VsdCB8fCBkYXRhLCB0aGlzLl9wYXJ0cy5kdXBsaWNhdGVRdWVyeVBhcmFtZXRlcnMsIHRoaXMuX3BhcnRzLmVzY2FwZVF1ZXJ5U3BhY2UpO1xuICAgICAgdGhpcy5idWlsZCghYnVpbGQpO1xuICAgICAgcmV0dXJuIHRoaXM7XG4gICAgfSBlbHNlIGlmICh2ICE9PSB1bmRlZmluZWQgJiYgdHlwZW9mIHYgIT09ICdzdHJpbmcnKSB7XG4gICAgICB0aGlzLl9wYXJ0cy5xdWVyeSA9IFVSSS5idWlsZFF1ZXJ5KHYsIHRoaXMuX3BhcnRzLmR1cGxpY2F0ZVF1ZXJ5UGFyYW1ldGVycywgdGhpcy5fcGFydHMuZXNjYXBlUXVlcnlTcGFjZSk7XG4gICAgICB0aGlzLmJ1aWxkKCFidWlsZCk7XG4gICAgICByZXR1cm4gdGhpcztcbiAgICB9IGVsc2Uge1xuICAgICAgcmV0dXJuIHEuY2FsbCh0aGlzLCB2LCBidWlsZCk7XG4gICAgfVxuICB9O1xuICBwLnNldFF1ZXJ5ID0gZnVuY3Rpb24obmFtZSwgdmFsdWUsIGJ1aWxkKSB7XG4gICAgdmFyIGRhdGEgPSBVUkkucGFyc2VRdWVyeSh0aGlzLl9wYXJ0cy5xdWVyeSwgdGhpcy5fcGFydHMuZXNjYXBlUXVlcnlTcGFjZSk7XG5cbiAgICBpZiAodHlwZW9mIG5hbWUgPT09ICdzdHJpbmcnIHx8IG5hbWUgaW5zdGFuY2VvZiBTdHJpbmcpIHtcbiAgICAgIGRhdGFbbmFtZV0gPSB2YWx1ZSAhPT0gdW5kZWZpbmVkID8gdmFsdWUgOiBudWxsO1xuICAgIH0gZWxzZSBpZiAodHlwZW9mIG5hbWUgPT09ICdvYmplY3QnKSB7XG4gICAgICBmb3IgKHZhciBrZXkgaW4gbmFtZSkge1xuICAgICAgICBpZiAoaGFzT3duLmNhbGwobmFtZSwga2V5KSkge1xuICAgICAgICAgIGRhdGFba2V5XSA9IG5hbWVba2V5XTtcbiAgICAgICAgfVxuICAgICAgfVxuICAgIH0gZWxzZSB7XG4gICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKCdVUkkuYWRkUXVlcnkoKSBhY2NlcHRzIGFuIG9iamVjdCwgc3RyaW5nIGFzIHRoZSBuYW1lIHBhcmFtZXRlcicpO1xuICAgIH1cblxuICAgIHRoaXMuX3BhcnRzLnF1ZXJ5ID0gVVJJLmJ1aWxkUXVlcnkoZGF0YSwgdGhpcy5fcGFydHMuZHVwbGljYXRlUXVlcnlQYXJhbWV0ZXJzLCB0aGlzLl9wYXJ0cy5lc2NhcGVRdWVyeVNwYWNlKTtcbiAgICBpZiAodHlwZW9mIG5hbWUgIT09ICdzdHJpbmcnKSB7XG4gICAgICBidWlsZCA9IHZhbHVlO1xuICAgIH1cblxuICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICByZXR1cm4gdGhpcztcbiAgfTtcbiAgcC5hZGRRdWVyeSA9IGZ1bmN0aW9uKG5hbWUsIHZhbHVlLCBidWlsZCkge1xuICAgIHZhciBkYXRhID0gVVJJLnBhcnNlUXVlcnkodGhpcy5fcGFydHMucXVlcnksIHRoaXMuX3BhcnRzLmVzY2FwZVF1ZXJ5U3BhY2UpO1xuICAgIFVSSS5hZGRRdWVyeShkYXRhLCBuYW1lLCB2YWx1ZSA9PT0gdW5kZWZpbmVkID8gbnVsbCA6IHZhbHVlKTtcbiAgICB0aGlzLl9wYXJ0cy5xdWVyeSA9IFVSSS5idWlsZFF1ZXJ5KGRhdGEsIHRoaXMuX3BhcnRzLmR1cGxpY2F0ZVF1ZXJ5UGFyYW1ldGVycywgdGhpcy5fcGFydHMuZXNjYXBlUXVlcnlTcGFjZSk7XG4gICAgaWYgKHR5cGVvZiBuYW1lICE9PSAnc3RyaW5nJykge1xuICAgICAgYnVpbGQgPSB2YWx1ZTtcbiAgICB9XG5cbiAgICB0aGlzLmJ1aWxkKCFidWlsZCk7XG4gICAgcmV0dXJuIHRoaXM7XG4gIH07XG4gIHAucmVtb3ZlUXVlcnkgPSBmdW5jdGlvbihuYW1lLCB2YWx1ZSwgYnVpbGQpIHtcbiAgICB2YXIgZGF0YSA9IFVSSS5wYXJzZVF1ZXJ5KHRoaXMuX3BhcnRzLnF1ZXJ5LCB0aGlzLl9wYXJ0cy5lc2NhcGVRdWVyeVNwYWNlKTtcbiAgICBVUkkucmVtb3ZlUXVlcnkoZGF0YSwgbmFtZSwgdmFsdWUpO1xuICAgIHRoaXMuX3BhcnRzLnF1ZXJ5ID0gVVJJLmJ1aWxkUXVlcnkoZGF0YSwgdGhpcy5fcGFydHMuZHVwbGljYXRlUXVlcnlQYXJhbWV0ZXJzLCB0aGlzLl9wYXJ0cy5lc2NhcGVRdWVyeVNwYWNlKTtcbiAgICBpZiAodHlwZW9mIG5hbWUgIT09ICdzdHJpbmcnKSB7XG4gICAgICBidWlsZCA9IHZhbHVlO1xuICAgIH1cblxuICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICByZXR1cm4gdGhpcztcbiAgfTtcbiAgcC5oYXNRdWVyeSA9IGZ1bmN0aW9uKG5hbWUsIHZhbHVlLCB3aXRoaW5BcnJheSkge1xuICAgIHZhciBkYXRhID0gVVJJLnBhcnNlUXVlcnkodGhpcy5fcGFydHMucXVlcnksIHRoaXMuX3BhcnRzLmVzY2FwZVF1ZXJ5U3BhY2UpO1xuICAgIHJldHVybiBVUkkuaGFzUXVlcnkoZGF0YSwgbmFtZSwgdmFsdWUsIHdpdGhpbkFycmF5KTtcbiAgfTtcbiAgcC5zZXRTZWFyY2ggPSBwLnNldFF1ZXJ5O1xuICBwLmFkZFNlYXJjaCA9IHAuYWRkUXVlcnk7XG4gIHAucmVtb3ZlU2VhcmNoID0gcC5yZW1vdmVRdWVyeTtcbiAgcC5oYXNTZWFyY2ggPSBwLmhhc1F1ZXJ5O1xuXG4gIC8vIHNhbml0aXppbmcgVVJMc1xuICBwLm5vcm1hbGl6ZSA9IGZ1bmN0aW9uKCkge1xuICAgIGlmICh0aGlzLl9wYXJ0cy51cm4pIHtcbiAgICAgIHJldHVybiB0aGlzXG4gICAgICAgIC5ub3JtYWxpemVQcm90b2NvbChmYWxzZSlcbiAgICAgICAgLm5vcm1hbGl6ZVBhdGgoZmFsc2UpXG4gICAgICAgIC5ub3JtYWxpemVRdWVyeShmYWxzZSlcbiAgICAgICAgLm5vcm1hbGl6ZUZyYWdtZW50KGZhbHNlKVxuICAgICAgICAuYnVpbGQoKTtcbiAgICB9XG5cbiAgICByZXR1cm4gdGhpc1xuICAgICAgLm5vcm1hbGl6ZVByb3RvY29sKGZhbHNlKVxuICAgICAgLm5vcm1hbGl6ZUhvc3RuYW1lKGZhbHNlKVxuICAgICAgLm5vcm1hbGl6ZVBvcnQoZmFsc2UpXG4gICAgICAubm9ybWFsaXplUGF0aChmYWxzZSlcbiAgICAgIC5ub3JtYWxpemVRdWVyeShmYWxzZSlcbiAgICAgIC5ub3JtYWxpemVGcmFnbWVudChmYWxzZSlcbiAgICAgIC5idWlsZCgpO1xuICB9O1xuICBwLm5vcm1hbGl6ZVByb3RvY29sID0gZnVuY3Rpb24oYnVpbGQpIHtcbiAgICBpZiAodHlwZW9mIHRoaXMuX3BhcnRzLnByb3RvY29sID09PSAnc3RyaW5nJykge1xuICAgICAgdGhpcy5fcGFydHMucHJvdG9jb2wgPSB0aGlzLl9wYXJ0cy5wcm90b2NvbC50b0xvd2VyQ2FzZSgpO1xuICAgICAgdGhpcy5idWlsZCghYnVpbGQpO1xuICAgIH1cblxuICAgIHJldHVybiB0aGlzO1xuICB9O1xuICBwLm5vcm1hbGl6ZUhvc3RuYW1lID0gZnVuY3Rpb24oYnVpbGQpIHtcbiAgICBpZiAodGhpcy5fcGFydHMuaG9zdG5hbWUpIHtcbiAgICAgIGlmICh0aGlzLmlzKCdJRE4nKSAmJiBwdW55Y29kZSkge1xuICAgICAgICB0aGlzLl9wYXJ0cy5ob3N0bmFtZSA9IHB1bnljb2RlLnRvQVNDSUkodGhpcy5fcGFydHMuaG9zdG5hbWUpO1xuICAgICAgfSBlbHNlIGlmICh0aGlzLmlzKCdJUHY2JykgJiYgSVB2Nikge1xuICAgICAgICB0aGlzLl9wYXJ0cy5ob3N0bmFtZSA9IElQdjYuYmVzdCh0aGlzLl9wYXJ0cy5ob3N0bmFtZSk7XG4gICAgICB9XG5cbiAgICAgIHRoaXMuX3BhcnRzLmhvc3RuYW1lID0gdGhpcy5fcGFydHMuaG9zdG5hbWUudG9Mb3dlckNhc2UoKTtcbiAgICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICB9XG5cbiAgICByZXR1cm4gdGhpcztcbiAgfTtcbiAgcC5ub3JtYWxpemVQb3J0ID0gZnVuY3Rpb24oYnVpbGQpIHtcbiAgICAvLyByZW1vdmUgcG9ydCBvZiBpdCdzIHRoZSBwcm90b2NvbCdzIGRlZmF1bHRcbiAgICBpZiAodHlwZW9mIHRoaXMuX3BhcnRzLnByb3RvY29sID09PSAnc3RyaW5nJyAmJiB0aGlzLl9wYXJ0cy5wb3J0ID09PSBVUkkuZGVmYXVsdFBvcnRzW3RoaXMuX3BhcnRzLnByb3RvY29sXSkge1xuICAgICAgdGhpcy5fcGFydHMucG9ydCA9IG51bGw7XG4gICAgICB0aGlzLmJ1aWxkKCFidWlsZCk7XG4gICAgfVxuXG4gICAgcmV0dXJuIHRoaXM7XG4gIH07XG4gIHAubm9ybWFsaXplUGF0aCA9IGZ1bmN0aW9uKGJ1aWxkKSB7XG4gICAgdmFyIF9wYXRoID0gdGhpcy5fcGFydHMucGF0aDtcbiAgICBpZiAoIV9wYXRoKSB7XG4gICAgICByZXR1cm4gdGhpcztcbiAgICB9XG5cbiAgICBpZiAodGhpcy5fcGFydHMudXJuKSB7XG4gICAgICB0aGlzLl9wYXJ0cy5wYXRoID0gVVJJLnJlY29kZVVyblBhdGgodGhpcy5fcGFydHMucGF0aCk7XG4gICAgICB0aGlzLmJ1aWxkKCFidWlsZCk7XG4gICAgICByZXR1cm4gdGhpcztcbiAgICB9XG5cbiAgICBpZiAodGhpcy5fcGFydHMucGF0aCA9PT0gJy8nKSB7XG4gICAgICByZXR1cm4gdGhpcztcbiAgICB9XG5cbiAgICB2YXIgX3dhc19yZWxhdGl2ZTtcbiAgICB2YXIgX2xlYWRpbmdQYXJlbnRzID0gJyc7XG4gICAgdmFyIF9wYXJlbnQsIF9wb3M7XG5cbiAgICAvLyBoYW5kbGUgcmVsYXRpdmUgcGF0aHNcbiAgICBpZiAoX3BhdGguY2hhckF0KDApICE9PSAnLycpIHtcbiAgICAgIF93YXNfcmVsYXRpdmUgPSB0cnVlO1xuICAgICAgX3BhdGggPSAnLycgKyBfcGF0aDtcbiAgICB9XG5cbiAgICAvLyBoYW5kbGUgcmVsYXRpdmUgZmlsZXMgKGFzIG9wcG9zZWQgdG8gZGlyZWN0b3JpZXMpXG4gICAgaWYgKF9wYXRoLnNsaWNlKC0zKSA9PT0gJy8uLicgfHwgX3BhdGguc2xpY2UoLTIpID09PSAnLy4nKSB7XG4gICAgICBfcGF0aCArPSAnLyc7XG4gICAgfVxuXG4gICAgLy8gcmVzb2x2ZSBzaW1wbGVzXG4gICAgX3BhdGggPSBfcGF0aFxuICAgICAgLnJlcGxhY2UoLyhcXC8oXFwuXFwvKSspfChcXC9cXC4kKS9nLCAnLycpXG4gICAgICAucmVwbGFjZSgvXFwvezIsfS9nLCAnLycpO1xuXG4gICAgLy8gcmVtZW1iZXIgbGVhZGluZyBwYXJlbnRzXG4gICAgaWYgKF93YXNfcmVsYXRpdmUpIHtcbiAgICAgIF9sZWFkaW5nUGFyZW50cyA9IF9wYXRoLnN1YnN0cmluZygxKS5tYXRjaCgvXihcXC5cXC5cXC8pKy8pIHx8ICcnO1xuICAgICAgaWYgKF9sZWFkaW5nUGFyZW50cykge1xuICAgICAgICBfbGVhZGluZ1BhcmVudHMgPSBfbGVhZGluZ1BhcmVudHNbMF07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLy8gcmVzb2x2ZSBwYXJlbnRzXG4gICAgd2hpbGUgKHRydWUpIHtcbiAgICAgIF9wYXJlbnQgPSBfcGF0aC5pbmRleE9mKCcvLi4nKTtcbiAgICAgIGlmIChfcGFyZW50ID09PSAtMSkge1xuICAgICAgICAvLyBubyBtb3JlIC4uLyB0byByZXNvbHZlXG4gICAgICAgIGJyZWFrO1xuICAgICAgfSBlbHNlIGlmIChfcGFyZW50ID09PSAwKSB7XG4gICAgICAgIC8vIHRvcCBsZXZlbCBjYW5ub3QgYmUgcmVsYXRpdmUsIHNraXAgaXRcbiAgICAgICAgX3BhdGggPSBfcGF0aC5zdWJzdHJpbmcoMyk7XG4gICAgICAgIGNvbnRpbnVlO1xuICAgICAgfVxuXG4gICAgICBfcG9zID0gX3BhdGguc3Vic3RyaW5nKDAsIF9wYXJlbnQpLmxhc3RJbmRleE9mKCcvJyk7XG4gICAgICBpZiAoX3BvcyA9PT0gLTEpIHtcbiAgICAgICAgX3BvcyA9IF9wYXJlbnQ7XG4gICAgICB9XG4gICAgICBfcGF0aCA9IF9wYXRoLnN1YnN0cmluZygwLCBfcG9zKSArIF9wYXRoLnN1YnN0cmluZyhfcGFyZW50ICsgMyk7XG4gICAgfVxuXG4gICAgLy8gcmV2ZXJ0IHRvIHJlbGF0aXZlXG4gICAgaWYgKF93YXNfcmVsYXRpdmUgJiYgdGhpcy5pcygncmVsYXRpdmUnKSkge1xuICAgICAgX3BhdGggPSBfbGVhZGluZ1BhcmVudHMgKyBfcGF0aC5zdWJzdHJpbmcoMSk7XG4gICAgfVxuXG4gICAgX3BhdGggPSBVUkkucmVjb2RlUGF0aChfcGF0aCk7XG4gICAgdGhpcy5fcGFydHMucGF0aCA9IF9wYXRoO1xuICAgIHRoaXMuYnVpbGQoIWJ1aWxkKTtcbiAgICByZXR1cm4gdGhpcztcbiAgfTtcbiAgcC5ub3JtYWxpemVQYXRobmFtZSA9IHAubm9ybWFsaXplUGF0aDtcbiAgcC5ub3JtYWxpemVRdWVyeSA9IGZ1bmN0aW9uKGJ1aWxkKSB7XG4gICAgaWYgKHR5cGVvZiB0aGlzLl9wYXJ0cy5xdWVyeSA9PT0gJ3N0cmluZycpIHtcbiAgICAgIGlmICghdGhpcy5fcGFydHMucXVlcnkubGVuZ3RoKSB7XG4gICAgICAgIHRoaXMuX3BhcnRzLnF1ZXJ5ID0gbnVsbDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIHRoaXMucXVlcnkoVVJJLnBhcnNlUXVlcnkodGhpcy5fcGFydHMucXVlcnksIHRoaXMuX3BhcnRzLmVzY2FwZVF1ZXJ5U3BhY2UpKTtcbiAgICAgIH1cblxuICAgICAgdGhpcy5idWlsZCghYnVpbGQpO1xuICAgIH1cblxuICAgIHJldHVybiB0aGlzO1xuICB9O1xuICBwLm5vcm1hbGl6ZUZyYWdtZW50ID0gZnVuY3Rpb24oYnVpbGQpIHtcbiAgICBpZiAoIXRoaXMuX3BhcnRzLmZyYWdtZW50KSB7XG4gICAgICB0aGlzLl9wYXJ0cy5mcmFnbWVudCA9IG51bGw7XG4gICAgICB0aGlzLmJ1aWxkKCFidWlsZCk7XG4gICAgfVxuXG4gICAgcmV0dXJuIHRoaXM7XG4gIH07XG4gIHAubm9ybWFsaXplU2VhcmNoID0gcC5ub3JtYWxpemVRdWVyeTtcbiAgcC5ub3JtYWxpemVIYXNoID0gcC5ub3JtYWxpemVGcmFnbWVudDtcblxuICBwLmlzbzg4NTkgPSBmdW5jdGlvbigpIHtcbiAgICAvLyBleHBlY3QgdW5pY29kZSBpbnB1dCwgaXNvODg1OSBvdXRwdXRcbiAgICB2YXIgZSA9IFVSSS5lbmNvZGU7XG4gICAgdmFyIGQgPSBVUkkuZGVjb2RlO1xuXG4gICAgVVJJLmVuY29kZSA9IGVzY2FwZTtcbiAgICBVUkkuZGVjb2RlID0gZGVjb2RlVVJJQ29tcG9uZW50O1xuICAgIHRyeSB7XG4gICAgICB0aGlzLm5vcm1hbGl6ZSgpO1xuICAgIH0gZmluYWxseSB7XG4gICAgICBVUkkuZW5jb2RlID0gZTtcbiAgICAgIFVSSS5kZWNvZGUgPSBkO1xuICAgIH1cbiAgICByZXR1cm4gdGhpcztcbiAgfTtcblxuICBwLnVuaWNvZGUgPSBmdW5jdGlvbigpIHtcbiAgICAvLyBleHBlY3QgaXNvODg1OSBpbnB1dCwgdW5pY29kZSBvdXRwdXRcbiAgICB2YXIgZSA9IFVSSS5lbmNvZGU7XG4gICAgdmFyIGQgPSBVUkkuZGVjb2RlO1xuXG4gICAgVVJJLmVuY29kZSA9IHN0cmljdEVuY29kZVVSSUNvbXBvbmVudDtcbiAgICBVUkkuZGVjb2RlID0gdW5lc2NhcGU7XG4gICAgdHJ5IHtcbiAgICAgIHRoaXMubm9ybWFsaXplKCk7XG4gICAgfSBmaW5hbGx5IHtcbiAgICAgIFVSSS5lbmNvZGUgPSBlO1xuICAgICAgVVJJLmRlY29kZSA9IGQ7XG4gICAgfVxuICAgIHJldHVybiB0aGlzO1xuICB9O1xuXG4gIHAucmVhZGFibGUgPSBmdW5jdGlvbigpIHtcbiAgICB2YXIgdXJpID0gdGhpcy5jbG9uZSgpO1xuICAgIC8vIHJlbW92aW5nIHVzZXJuYW1lLCBwYXNzd29yZCwgYmVjYXVzZSB0aGV5IHNob3VsZG4ndCBiZSBkaXNwbGF5ZWQgYWNjb3JkaW5nIHRvIFJGQyAzOTg2XG4gICAgdXJpLnVzZXJuYW1lKCcnKS5wYXNzd29yZCgnJykubm9ybWFsaXplKCk7XG4gICAgdmFyIHQgPSAnJztcbiAgICBpZiAodXJpLl9wYXJ0cy5wcm90b2NvbCkge1xuICAgICAgdCArPSB1cmkuX3BhcnRzLnByb3RvY29sICsgJzovLyc7XG4gICAgfVxuXG4gICAgaWYgKHVyaS5fcGFydHMuaG9zdG5hbWUpIHtcbiAgICAgIGlmICh1cmkuaXMoJ3B1bnljb2RlJykgJiYgcHVueWNvZGUpIHtcbiAgICAgICAgdCArPSBwdW55Y29kZS50b1VuaWNvZGUodXJpLl9wYXJ0cy5ob3N0bmFtZSk7XG4gICAgICAgIGlmICh1cmkuX3BhcnRzLnBvcnQpIHtcbiAgICAgICAgICB0ICs9ICc6JyArIHVyaS5fcGFydHMucG9ydDtcbiAgICAgICAgfVxuICAgICAgfSBlbHNlIHtcbiAgICAgICAgdCArPSB1cmkuaG9zdCgpO1xuICAgICAgfVxuICAgIH1cblxuICAgIGlmICh1cmkuX3BhcnRzLmhvc3RuYW1lICYmIHVyaS5fcGFydHMucGF0aCAmJiB1cmkuX3BhcnRzLnBhdGguY2hhckF0KDApICE9PSAnLycpIHtcbiAgICAgIHQgKz0gJy8nO1xuICAgIH1cblxuICAgIHQgKz0gdXJpLnBhdGgodHJ1ZSk7XG4gICAgaWYgKHVyaS5fcGFydHMucXVlcnkpIHtcbiAgICAgIHZhciBxID0gJyc7XG4gICAgICBmb3IgKHZhciBpID0gMCwgcXAgPSB1cmkuX3BhcnRzLnF1ZXJ5LnNwbGl0KCcmJyksIGwgPSBxcC5sZW5ndGg7IGkgPCBsOyBpKyspIHtcbiAgICAgICAgdmFyIGt2ID0gKHFwW2ldIHx8ICcnKS5zcGxpdCgnPScpO1xuICAgICAgICBxICs9ICcmJyArIFVSSS5kZWNvZGVRdWVyeShrdlswXSwgdGhpcy5fcGFydHMuZXNjYXBlUXVlcnlTcGFjZSlcbiAgICAgICAgICAucmVwbGFjZSgvJi9nLCAnJTI2Jyk7XG5cbiAgICAgICAgaWYgKGt2WzFdICE9PSB1bmRlZmluZWQpIHtcbiAgICAgICAgICBxICs9ICc9JyArIFVSSS5kZWNvZGVRdWVyeShrdlsxXSwgdGhpcy5fcGFydHMuZXNjYXBlUXVlcnlTcGFjZSlcbiAgICAgICAgICAgIC5yZXBsYWNlKC8mL2csICclMjYnKTtcbiAgICAgICAgfVxuICAgICAgfVxuICAgICAgdCArPSAnPycgKyBxLnN1YnN0cmluZygxKTtcbiAgICB9XG5cbiAgICB0ICs9IFVSSS5kZWNvZGVRdWVyeSh1cmkuaGFzaCgpLCB0cnVlKTtcbiAgICByZXR1cm4gdDtcbiAgfTtcblxuICAvLyByZXNvbHZpbmcgcmVsYXRpdmUgYW5kIGFic29sdXRlIFVSTHNcbiAgcC5hYnNvbHV0ZVRvID0gZnVuY3Rpb24oYmFzZSkge1xuICAgIHZhciByZXNvbHZlZCA9IHRoaXMuY2xvbmUoKTtcbiAgICB2YXIgcHJvcGVydGllcyA9IFsncHJvdG9jb2wnLCAndXNlcm5hbWUnLCAncGFzc3dvcmQnLCAnaG9zdG5hbWUnLCAncG9ydCddO1xuICAgIHZhciBiYXNlZGlyLCBpLCBwO1xuXG4gICAgaWYgKHRoaXMuX3BhcnRzLnVybikge1xuICAgICAgdGhyb3cgbmV3IEVycm9yKCdVUk5zIGRvIG5vdCBoYXZlIGFueSBnZW5lcmFsbHkgZGVmaW5lZCBoaWVyYXJjaGljYWwgY29tcG9uZW50cycpO1xuICAgIH1cblxuICAgIGlmICghKGJhc2UgaW5zdGFuY2VvZiBVUkkpKSB7XG4gICAgICBiYXNlID0gbmV3IFVSSShiYXNlKTtcbiAgICB9XG5cbiAgICBpZiAoIXJlc29sdmVkLl9wYXJ0cy5wcm90b2NvbCkge1xuICAgICAgcmVzb2x2ZWQuX3BhcnRzLnByb3RvY29sID0gYmFzZS5fcGFydHMucHJvdG9jb2w7XG4gICAgfVxuXG4gICAgaWYgKHRoaXMuX3BhcnRzLmhvc3RuYW1lKSB7XG4gICAgICByZXR1cm4gcmVzb2x2ZWQ7XG4gICAgfVxuXG4gICAgZm9yIChpID0gMDsgKHAgPSBwcm9wZXJ0aWVzW2ldKTsgaSsrKSB7XG4gICAgICByZXNvbHZlZC5fcGFydHNbcF0gPSBiYXNlLl9wYXJ0c1twXTtcbiAgICB9XG5cbiAgICBpZiAoIXJlc29sdmVkLl9wYXJ0cy5wYXRoKSB7XG4gICAgICByZXNvbHZlZC5fcGFydHMucGF0aCA9IGJhc2UuX3BhcnRzLnBhdGg7XG4gICAgICBpZiAoIXJlc29sdmVkLl9wYXJ0cy5xdWVyeSkge1xuICAgICAgICByZXNvbHZlZC5fcGFydHMucXVlcnkgPSBiYXNlLl9wYXJ0cy5xdWVyeTtcbiAgICAgIH1cbiAgICB9IGVsc2UgaWYgKHJlc29sdmVkLl9wYXJ0cy5wYXRoLnN1YnN0cmluZygtMikgPT09ICcuLicpIHtcbiAgICAgIHJlc29sdmVkLl9wYXJ0cy5wYXRoICs9ICcvJztcbiAgICB9XG5cbiAgICBpZiAocmVzb2x2ZWQucGF0aCgpLmNoYXJBdCgwKSAhPT0gJy8nKSB7XG4gICAgICBiYXNlZGlyID0gYmFzZS5kaXJlY3RvcnkoKTtcbiAgICAgIGJhc2VkaXIgPSBiYXNlZGlyID8gYmFzZWRpciA6IGJhc2UucGF0aCgpLmluZGV4T2YoJy8nKSA9PT0gMCA/ICcvJyA6ICcnO1xuICAgICAgcmVzb2x2ZWQuX3BhcnRzLnBhdGggPSAoYmFzZWRpciA/IChiYXNlZGlyICsgJy8nKSA6ICcnKSArIHJlc29sdmVkLl9wYXJ0cy5wYXRoO1xuICAgICAgcmVzb2x2ZWQubm9ybWFsaXplUGF0aCgpO1xuICAgIH1cblxuICAgIHJlc29sdmVkLmJ1aWxkKCk7XG4gICAgcmV0dXJuIHJlc29sdmVkO1xuICB9O1xuICBwLnJlbGF0aXZlVG8gPSBmdW5jdGlvbihiYXNlKSB7XG4gICAgdmFyIHJlbGF0aXZlID0gdGhpcy5jbG9uZSgpLm5vcm1hbGl6ZSgpO1xuICAgIHZhciByZWxhdGl2ZVBhcnRzLCBiYXNlUGFydHMsIGNvbW1vbiwgcmVsYXRpdmVQYXRoLCBiYXNlUGF0aDtcblxuICAgIGlmIChyZWxhdGl2ZS5fcGFydHMudXJuKSB7XG4gICAgICB0aHJvdyBuZXcgRXJyb3IoJ1VSTnMgZG8gbm90IGhhdmUgYW55IGdlbmVyYWxseSBkZWZpbmVkIGhpZXJhcmNoaWNhbCBjb21wb25lbnRzJyk7XG4gICAgfVxuXG4gICAgYmFzZSA9IG5ldyBVUkkoYmFzZSkubm9ybWFsaXplKCk7XG4gICAgcmVsYXRpdmVQYXJ0cyA9IHJlbGF0aXZlLl9wYXJ0cztcbiAgICBiYXNlUGFydHMgPSBiYXNlLl9wYXJ0cztcbiAgICByZWxhdGl2ZVBhdGggPSByZWxhdGl2ZS5wYXRoKCk7XG4gICAgYmFzZVBhdGggPSBiYXNlLnBhdGgoKTtcblxuICAgIGlmIChyZWxhdGl2ZVBhdGguY2hhckF0KDApICE9PSAnLycpIHtcbiAgICAgIHRocm93IG5ldyBFcnJvcignVVJJIGlzIGFscmVhZHkgcmVsYXRpdmUnKTtcbiAgICB9XG5cbiAgICBpZiAoYmFzZVBhdGguY2hhckF0KDApICE9PSAnLycpIHtcbiAgICAgIHRocm93IG5ldyBFcnJvcignQ2Fubm90IGNhbGN1bGF0ZSBhIFVSSSByZWxhdGl2ZSB0byBhbm90aGVyIHJlbGF0aXZlIFVSSScpO1xuICAgIH1cblxuICAgIGlmIChyZWxhdGl2ZVBhcnRzLnByb3RvY29sID09PSBiYXNlUGFydHMucHJvdG9jb2wpIHtcbiAgICAgIHJlbGF0aXZlUGFydHMucHJvdG9jb2wgPSBudWxsO1xuICAgIH1cblxuICAgIGlmIChyZWxhdGl2ZVBhcnRzLnVzZXJuYW1lICE9PSBiYXNlUGFydHMudXNlcm5hbWUgfHwgcmVsYXRpdmVQYXJ0cy5wYXNzd29yZCAhPT0gYmFzZVBhcnRzLnBhc3N3b3JkKSB7XG4gICAgICByZXR1cm4gcmVsYXRpdmUuYnVpbGQoKTtcbiAgICB9XG5cbiAgICBpZiAocmVsYXRpdmVQYXJ0cy5wcm90b2NvbCAhPT0gbnVsbCB8fCByZWxhdGl2ZVBhcnRzLnVzZXJuYW1lICE9PSBudWxsIHx8IHJlbGF0aXZlUGFydHMucGFzc3dvcmQgIT09IG51bGwpIHtcbiAgICAgIHJldHVybiByZWxhdGl2ZS5idWlsZCgpO1xuICAgIH1cblxuICAgIGlmIChyZWxhdGl2ZVBhcnRzLmhvc3RuYW1lID09PSBiYXNlUGFydHMuaG9zdG5hbWUgJiYgcmVsYXRpdmVQYXJ0cy5wb3J0ID09PSBiYXNlUGFydHMucG9ydCkge1xuICAgICAgcmVsYXRpdmVQYXJ0cy5ob3N0bmFtZSA9IG51bGw7XG4gICAgICByZWxhdGl2ZVBhcnRzLnBvcnQgPSBudWxsO1xuICAgIH0gZWxzZSB7XG4gICAgICByZXR1cm4gcmVsYXRpdmUuYnVpbGQoKTtcbiAgICB9XG5cbiAgICBpZiAocmVsYXRpdmVQYXRoID09PSBiYXNlUGF0aCkge1xuICAgICAgcmVsYXRpdmVQYXJ0cy5wYXRoID0gJyc7XG4gICAgICByZXR1cm4gcmVsYXRpdmUuYnVpbGQoKTtcbiAgICB9XG5cbiAgICAvLyBkZXRlcm1pbmUgY29tbW9uIHN1YiBwYXRoXG4gICAgY29tbW9uID0gVVJJLmNvbW1vblBhdGgocmVsYXRpdmVQYXRoLCBiYXNlUGF0aCk7XG5cbiAgICAvLyBJZiB0aGUgcGF0aHMgaGF2ZSBub3RoaW5nIGluIGNvbW1vbiwgcmV0dXJuIGEgcmVsYXRpdmUgVVJMIHdpdGggdGhlIGFic29sdXRlIHBhdGguXG4gICAgaWYgKCFjb21tb24pIHtcbiAgICAgIHJldHVybiByZWxhdGl2ZS5idWlsZCgpO1xuICAgIH1cblxuICAgIHZhciBwYXJlbnRzID0gYmFzZVBhcnRzLnBhdGhcbiAgICAgIC5zdWJzdHJpbmcoY29tbW9uLmxlbmd0aClcbiAgICAgIC5yZXBsYWNlKC9bXlxcL10qJC8sICcnKVxuICAgICAgLnJlcGxhY2UoLy4qP1xcLy9nLCAnLi4vJyk7XG5cbiAgICByZWxhdGl2ZVBhcnRzLnBhdGggPSAocGFyZW50cyArIHJlbGF0aXZlUGFydHMucGF0aC5zdWJzdHJpbmcoY29tbW9uLmxlbmd0aCkpIHx8ICcuLyc7XG5cbiAgICByZXR1cm4gcmVsYXRpdmUuYnVpbGQoKTtcbiAgfTtcblxuICAvLyBjb21wYXJpbmcgVVJJc1xuICBwLmVxdWFscyA9IGZ1bmN0aW9uKHVyaSkge1xuICAgIHZhciBvbmUgPSB0aGlzLmNsb25lKCk7XG4gICAgdmFyIHR3byA9IG5ldyBVUkkodXJpKTtcbiAgICB2YXIgb25lX21hcCA9IHt9O1xuICAgIHZhciB0d29fbWFwID0ge307XG4gICAgdmFyIGNoZWNrZWQgPSB7fTtcbiAgICB2YXIgb25lX3F1ZXJ5LCB0d29fcXVlcnksIGtleTtcblxuICAgIG9uZS5ub3JtYWxpemUoKTtcbiAgICB0d28ubm9ybWFsaXplKCk7XG5cbiAgICAvLyBleGFjdCBtYXRjaFxuICAgIGlmIChvbmUudG9TdHJpbmcoKSA9PT0gdHdvLnRvU3RyaW5nKCkpIHtcbiAgICAgIHJldHVybiB0cnVlO1xuICAgIH1cblxuICAgIC8vIGV4dHJhY3QgcXVlcnkgc3RyaW5nXG4gICAgb25lX3F1ZXJ5ID0gb25lLnF1ZXJ5KCk7XG4gICAgdHdvX3F1ZXJ5ID0gdHdvLnF1ZXJ5KCk7XG4gICAgb25lLnF1ZXJ5KCcnKTtcbiAgICB0d28ucXVlcnkoJycpO1xuXG4gICAgLy8gZGVmaW5pdGVseSBub3QgZXF1YWwgaWYgbm90IGV2ZW4gbm9uLXF1ZXJ5IHBhcnRzIG1hdGNoXG4gICAgaWYgKG9uZS50b1N0cmluZygpICE9PSB0d28udG9TdHJpbmcoKSkge1xuICAgICAgcmV0dXJuIGZhbHNlO1xuICAgIH1cblxuICAgIC8vIHF1ZXJ5IHBhcmFtZXRlcnMgaGF2ZSB0aGUgc2FtZSBsZW5ndGgsIGV2ZW4gaWYgdGhleSdyZSBwZXJtdXRlZFxuICAgIGlmIChvbmVfcXVlcnkubGVuZ3RoICE9PSB0d29fcXVlcnkubGVuZ3RoKSB7XG4gICAgICByZXR1cm4gZmFsc2U7XG4gICAgfVxuXG4gICAgb25lX21hcCA9IFVSSS5wYXJzZVF1ZXJ5KG9uZV9xdWVyeSwgdGhpcy5fcGFydHMuZXNjYXBlUXVlcnlTcGFjZSk7XG4gICAgdHdvX21hcCA9IFVSSS5wYXJzZVF1ZXJ5KHR3b19xdWVyeSwgdGhpcy5fcGFydHMuZXNjYXBlUXVlcnlTcGFjZSk7XG5cbiAgICBmb3IgKGtleSBpbiBvbmVfbWFwKSB7XG4gICAgICBpZiAoaGFzT3duLmNhbGwob25lX21hcCwga2V5KSkge1xuICAgICAgICBpZiAoIWlzQXJyYXkob25lX21hcFtrZXldKSkge1xuICAgICAgICAgIGlmIChvbmVfbWFwW2tleV0gIT09IHR3b19tYXBba2V5XSkge1xuICAgICAgICAgICAgcmV0dXJuIGZhbHNlO1xuICAgICAgICAgIH1cbiAgICAgICAgfSBlbHNlIGlmICghYXJyYXlzRXF1YWwob25lX21hcFtrZXldLCB0d29fbWFwW2tleV0pKSB7XG4gICAgICAgICAgcmV0dXJuIGZhbHNlO1xuICAgICAgICB9XG5cbiAgICAgICAgY2hlY2tlZFtrZXldID0gdHJ1ZTtcbiAgICAgIH1cbiAgICB9XG5cbiAgICBmb3IgKGtleSBpbiB0d29fbWFwKSB7XG4gICAgICBpZiAoaGFzT3duLmNhbGwodHdvX21hcCwga2V5KSkge1xuICAgICAgICBpZiAoIWNoZWNrZWRba2V5XSkge1xuICAgICAgICAgIC8vIHR3byBjb250YWlucyBhIHBhcmFtZXRlciBub3QgcHJlc2VudCBpbiBvbmVcbiAgICAgICAgICByZXR1cm4gZmFsc2U7XG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9XG5cbiAgICByZXR1cm4gdHJ1ZTtcbiAgfTtcblxuICAvLyBzdGF0ZVxuICBwLmR1cGxpY2F0ZVF1ZXJ5UGFyYW1ldGVycyA9IGZ1bmN0aW9uKHYpIHtcbiAgICB0aGlzLl9wYXJ0cy5kdXBsaWNhdGVRdWVyeVBhcmFtZXRlcnMgPSAhIXY7XG4gICAgcmV0dXJuIHRoaXM7XG4gIH07XG5cbiAgcC5lc2NhcGVRdWVyeVNwYWNlID0gZnVuY3Rpb24odikge1xuICAgIHRoaXMuX3BhcnRzLmVzY2FwZVF1ZXJ5U3BhY2UgPSAhIXY7XG4gICAgcmV0dXJuIHRoaXM7XG4gIH07XG5cbiAgcmV0dXJuIFVSSTtcbn0pKTtcbiIsIi8qISBodHRwOi8vbXRocy5iZS9wdW55Y29kZSB2MS4yLjMgYnkgQG1hdGhpYXMgKi9cbjsoZnVuY3Rpb24ocm9vdCkge1xuXG5cdC8qKiBEZXRlY3QgZnJlZSB2YXJpYWJsZXMgKi9cblx0dmFyIGZyZWVFeHBvcnRzID0gdHlwZW9mIGV4cG9ydHMgPT0gJ29iamVjdCcgJiYgZXhwb3J0cztcblx0dmFyIGZyZWVNb2R1bGUgPSB0eXBlb2YgbW9kdWxlID09ICdvYmplY3QnICYmIG1vZHVsZSAmJlxuXHRcdG1vZHVsZS5leHBvcnRzID09IGZyZWVFeHBvcnRzICYmIG1vZHVsZTtcblx0dmFyIGZyZWVHbG9iYWwgPSB0eXBlb2YgZ2xvYmFsID09ICdvYmplY3QnICYmIGdsb2JhbDtcblx0aWYgKGZyZWVHbG9iYWwuZ2xvYmFsID09PSBmcmVlR2xvYmFsIHx8IGZyZWVHbG9iYWwud2luZG93ID09PSBmcmVlR2xvYmFsKSB7XG5cdFx0cm9vdCA9IGZyZWVHbG9iYWw7XG5cdH1cblxuXHQvKipcblx0ICogVGhlIGBwdW55Y29kZWAgb2JqZWN0LlxuXHQgKiBAbmFtZSBwdW55Y29kZVxuXHQgKiBAdHlwZSBPYmplY3Rcblx0ICovXG5cdHZhciBwdW55Y29kZSxcblxuXHQvKiogSGlnaGVzdCBwb3NpdGl2ZSBzaWduZWQgMzItYml0IGZsb2F0IHZhbHVlICovXG5cdG1heEludCA9IDIxNDc0ODM2NDcsIC8vIGFrYS4gMHg3RkZGRkZGRiBvciAyXjMxLTFcblxuXHQvKiogQm9vdHN0cmluZyBwYXJhbWV0ZXJzICovXG5cdGJhc2UgPSAzNixcblx0dE1pbiA9IDEsXG5cdHRNYXggPSAyNixcblx0c2tldyA9IDM4LFxuXHRkYW1wID0gNzAwLFxuXHRpbml0aWFsQmlhcyA9IDcyLFxuXHRpbml0aWFsTiA9IDEyOCwgLy8gMHg4MFxuXHRkZWxpbWl0ZXIgPSAnLScsIC8vICdcXHgyRCdcblxuXHQvKiogUmVndWxhciBleHByZXNzaW9ucyAqL1xuXHRyZWdleFB1bnljb2RlID0gL154bi0tLyxcblx0cmVnZXhOb25BU0NJSSA9IC9bXiAtfl0vLCAvLyB1bnByaW50YWJsZSBBU0NJSSBjaGFycyArIG5vbi1BU0NJSSBjaGFyc1xuXHRyZWdleFNlcGFyYXRvcnMgPSAvXFx4MkV8XFx1MzAwMnxcXHVGRjBFfFxcdUZGNjEvZywgLy8gUkZDIDM0OTAgc2VwYXJhdG9yc1xuXG5cdC8qKiBFcnJvciBtZXNzYWdlcyAqL1xuXHRlcnJvcnMgPSB7XG5cdFx0J292ZXJmbG93JzogJ092ZXJmbG93OiBpbnB1dCBuZWVkcyB3aWRlciBpbnRlZ2VycyB0byBwcm9jZXNzJyxcblx0XHQnbm90LWJhc2ljJzogJ0lsbGVnYWwgaW5wdXQgPj0gMHg4MCAobm90IGEgYmFzaWMgY29kZSBwb2ludCknLFxuXHRcdCdpbnZhbGlkLWlucHV0JzogJ0ludmFsaWQgaW5wdXQnXG5cdH0sXG5cblx0LyoqIENvbnZlbmllbmNlIHNob3J0Y3V0cyAqL1xuXHRiYXNlTWludXNUTWluID0gYmFzZSAtIHRNaW4sXG5cdGZsb29yID0gTWF0aC5mbG9vcixcblx0c3RyaW5nRnJvbUNoYXJDb2RlID0gU3RyaW5nLmZyb21DaGFyQ29kZSxcblxuXHQvKiogVGVtcG9yYXJ5IHZhcmlhYmxlICovXG5cdGtleTtcblxuXHQvKi0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tKi9cblxuXHQvKipcblx0ICogQSBnZW5lcmljIGVycm9yIHV0aWxpdHkgZnVuY3Rpb24uXG5cdCAqIEBwcml2YXRlXG5cdCAqIEBwYXJhbSB7U3RyaW5nfSB0eXBlIFRoZSBlcnJvciB0eXBlLlxuXHQgKiBAcmV0dXJucyB7RXJyb3J9IFRocm93cyBhIGBSYW5nZUVycm9yYCB3aXRoIHRoZSBhcHBsaWNhYmxlIGVycm9yIG1lc3NhZ2UuXG5cdCAqL1xuXHRmdW5jdGlvbiBlcnJvcih0eXBlKSB7XG5cdFx0dGhyb3cgUmFuZ2VFcnJvcihlcnJvcnNbdHlwZV0pO1xuXHR9XG5cblx0LyoqXG5cdCAqIEEgZ2VuZXJpYyBgQXJyYXkjbWFwYCB1dGlsaXR5IGZ1bmN0aW9uLlxuXHQgKiBAcHJpdmF0ZVxuXHQgKiBAcGFyYW0ge0FycmF5fSBhcnJheSBUaGUgYXJyYXkgdG8gaXRlcmF0ZSBvdmVyLlxuXHQgKiBAcGFyYW0ge0Z1bmN0aW9ufSBjYWxsYmFjayBUaGUgZnVuY3Rpb24gdGhhdCBnZXRzIGNhbGxlZCBmb3IgZXZlcnkgYXJyYXlcblx0ICogaXRlbS5cblx0ICogQHJldHVybnMge0FycmF5fSBBIG5ldyBhcnJheSBvZiB2YWx1ZXMgcmV0dXJuZWQgYnkgdGhlIGNhbGxiYWNrIGZ1bmN0aW9uLlxuXHQgKi9cblx0ZnVuY3Rpb24gbWFwKGFycmF5LCBmbikge1xuXHRcdHZhciBsZW5ndGggPSBhcnJheS5sZW5ndGg7XG5cdFx0d2hpbGUgKGxlbmd0aC0tKSB7XG5cdFx0XHRhcnJheVtsZW5ndGhdID0gZm4oYXJyYXlbbGVuZ3RoXSk7XG5cdFx0fVxuXHRcdHJldHVybiBhcnJheTtcblx0fVxuXG5cdC8qKlxuXHQgKiBBIHNpbXBsZSBgQXJyYXkjbWFwYC1saWtlIHdyYXBwZXIgdG8gd29yayB3aXRoIGRvbWFpbiBuYW1lIHN0cmluZ3MuXG5cdCAqIEBwcml2YXRlXG5cdCAqIEBwYXJhbSB7U3RyaW5nfSBkb21haW4gVGhlIGRvbWFpbiBuYW1lLlxuXHQgKiBAcGFyYW0ge0Z1bmN0aW9ufSBjYWxsYmFjayBUaGUgZnVuY3Rpb24gdGhhdCBnZXRzIGNhbGxlZCBmb3IgZXZlcnlcblx0ICogY2hhcmFjdGVyLlxuXHQgKiBAcmV0dXJucyB7QXJyYXl9IEEgbmV3IHN0cmluZyBvZiBjaGFyYWN0ZXJzIHJldHVybmVkIGJ5IHRoZSBjYWxsYmFja1xuXHQgKiBmdW5jdGlvbi5cblx0ICovXG5cdGZ1bmN0aW9uIG1hcERvbWFpbihzdHJpbmcsIGZuKSB7XG5cdFx0cmV0dXJuIG1hcChzdHJpbmcuc3BsaXQocmVnZXhTZXBhcmF0b3JzKSwgZm4pLmpvaW4oJy4nKTtcblx0fVxuXG5cdC8qKlxuXHQgKiBDcmVhdGVzIGFuIGFycmF5IGNvbnRhaW5pbmcgdGhlIG51bWVyaWMgY29kZSBwb2ludHMgb2YgZWFjaCBVbmljb2RlXG5cdCAqIGNoYXJhY3RlciBpbiB0aGUgc3RyaW5nLiBXaGlsZSBKYXZhU2NyaXB0IHVzZXMgVUNTLTIgaW50ZXJuYWxseSxcblx0ICogdGhpcyBmdW5jdGlvbiB3aWxsIGNvbnZlcnQgYSBwYWlyIG9mIHN1cnJvZ2F0ZSBoYWx2ZXMgKGVhY2ggb2Ygd2hpY2hcblx0ICogVUNTLTIgZXhwb3NlcyBhcyBzZXBhcmF0ZSBjaGFyYWN0ZXJzKSBpbnRvIGEgc2luZ2xlIGNvZGUgcG9pbnQsXG5cdCAqIG1hdGNoaW5nIFVURi0xNi5cblx0ICogQHNlZSBgcHVueWNvZGUudWNzMi5lbmNvZGVgXG5cdCAqIEBzZWUgPGh0dHA6Ly9tYXRoaWFzYnluZW5zLmJlL25vdGVzL2phdmFzY3JpcHQtZW5jb2Rpbmc+XG5cdCAqIEBtZW1iZXJPZiBwdW55Y29kZS51Y3MyXG5cdCAqIEBuYW1lIGRlY29kZVxuXHQgKiBAcGFyYW0ge1N0cmluZ30gc3RyaW5nIFRoZSBVbmljb2RlIGlucHV0IHN0cmluZyAoVUNTLTIpLlxuXHQgKiBAcmV0dXJucyB7QXJyYXl9IFRoZSBuZXcgYXJyYXkgb2YgY29kZSBwb2ludHMuXG5cdCAqL1xuXHRmdW5jdGlvbiB1Y3MyZGVjb2RlKHN0cmluZykge1xuXHRcdHZhciBvdXRwdXQgPSBbXSxcblx0XHQgICAgY291bnRlciA9IDAsXG5cdFx0ICAgIGxlbmd0aCA9IHN0cmluZy5sZW5ndGgsXG5cdFx0ICAgIHZhbHVlLFxuXHRcdCAgICBleHRyYTtcblx0XHR3aGlsZSAoY291bnRlciA8IGxlbmd0aCkge1xuXHRcdFx0dmFsdWUgPSBzdHJpbmcuY2hhckNvZGVBdChjb3VudGVyKyspO1xuXHRcdFx0aWYgKHZhbHVlID49IDB4RDgwMCAmJiB2YWx1ZSA8PSAweERCRkYgJiYgY291bnRlciA8IGxlbmd0aCkge1xuXHRcdFx0XHQvLyBoaWdoIHN1cnJvZ2F0ZSwgYW5kIHRoZXJlIGlzIGEgbmV4dCBjaGFyYWN0ZXJcblx0XHRcdFx0ZXh0cmEgPSBzdHJpbmcuY2hhckNvZGVBdChjb3VudGVyKyspO1xuXHRcdFx0XHRpZiAoKGV4dHJhICYgMHhGQzAwKSA9PSAweERDMDApIHsgLy8gbG93IHN1cnJvZ2F0ZVxuXHRcdFx0XHRcdG91dHB1dC5wdXNoKCgodmFsdWUgJiAweDNGRikgPDwgMTApICsgKGV4dHJhICYgMHgzRkYpICsgMHgxMDAwMCk7XG5cdFx0XHRcdH0gZWxzZSB7XG5cdFx0XHRcdFx0Ly8gdW5tYXRjaGVkIHN1cnJvZ2F0ZTsgb25seSBhcHBlbmQgdGhpcyBjb2RlIHVuaXQsIGluIGNhc2UgdGhlIG5leHRcblx0XHRcdFx0XHQvLyBjb2RlIHVuaXQgaXMgdGhlIGhpZ2ggc3Vycm9nYXRlIG9mIGEgc3Vycm9nYXRlIHBhaXJcblx0XHRcdFx0XHRvdXRwdXQucHVzaCh2YWx1ZSk7XG5cdFx0XHRcdFx0Y291bnRlci0tO1xuXHRcdFx0XHR9XG5cdFx0XHR9IGVsc2Uge1xuXHRcdFx0XHRvdXRwdXQucHVzaCh2YWx1ZSk7XG5cdFx0XHR9XG5cdFx0fVxuXHRcdHJldHVybiBvdXRwdXQ7XG5cdH1cblxuXHQvKipcblx0ICogQ3JlYXRlcyBhIHN0cmluZyBiYXNlZCBvbiBhbiBhcnJheSBvZiBudW1lcmljIGNvZGUgcG9pbnRzLlxuXHQgKiBAc2VlIGBwdW55Y29kZS51Y3MyLmRlY29kZWBcblx0ICogQG1lbWJlck9mIHB1bnljb2RlLnVjczJcblx0ICogQG5hbWUgZW5jb2RlXG5cdCAqIEBwYXJhbSB7QXJyYXl9IGNvZGVQb2ludHMgVGhlIGFycmF5IG9mIG51bWVyaWMgY29kZSBwb2ludHMuXG5cdCAqIEByZXR1cm5zIHtTdHJpbmd9IFRoZSBuZXcgVW5pY29kZSBzdHJpbmcgKFVDUy0yKS5cblx0ICovXG5cdGZ1bmN0aW9uIHVjczJlbmNvZGUoYXJyYXkpIHtcblx0XHRyZXR1cm4gbWFwKGFycmF5LCBmdW5jdGlvbih2YWx1ZSkge1xuXHRcdFx0dmFyIG91dHB1dCA9ICcnO1xuXHRcdFx0aWYgKHZhbHVlID4gMHhGRkZGKSB7XG5cdFx0XHRcdHZhbHVlIC09IDB4MTAwMDA7XG5cdFx0XHRcdG91dHB1dCArPSBzdHJpbmdGcm9tQ2hhckNvZGUodmFsdWUgPj4+IDEwICYgMHgzRkYgfCAweEQ4MDApO1xuXHRcdFx0XHR2YWx1ZSA9IDB4REMwMCB8IHZhbHVlICYgMHgzRkY7XG5cdFx0XHR9XG5cdFx0XHRvdXRwdXQgKz0gc3RyaW5nRnJvbUNoYXJDb2RlKHZhbHVlKTtcblx0XHRcdHJldHVybiBvdXRwdXQ7XG5cdFx0fSkuam9pbignJyk7XG5cdH1cblxuXHQvKipcblx0ICogQ29udmVydHMgYSBiYXNpYyBjb2RlIHBvaW50IGludG8gYSBkaWdpdC9pbnRlZ2VyLlxuXHQgKiBAc2VlIGBkaWdpdFRvQmFzaWMoKWBcblx0ICogQHByaXZhdGVcblx0ICogQHBhcmFtIHtOdW1iZXJ9IGNvZGVQb2ludCBUaGUgYmFzaWMgbnVtZXJpYyBjb2RlIHBvaW50IHZhbHVlLlxuXHQgKiBAcmV0dXJucyB7TnVtYmVyfSBUaGUgbnVtZXJpYyB2YWx1ZSBvZiBhIGJhc2ljIGNvZGUgcG9pbnQgKGZvciB1c2UgaW5cblx0ICogcmVwcmVzZW50aW5nIGludGVnZXJzKSBpbiB0aGUgcmFuZ2UgYDBgIHRvIGBiYXNlIC0gMWAsIG9yIGBiYXNlYCBpZlxuXHQgKiB0aGUgY29kZSBwb2ludCBkb2VzIG5vdCByZXByZXNlbnQgYSB2YWx1ZS5cblx0ICovXG5cdGZ1bmN0aW9uIGJhc2ljVG9EaWdpdChjb2RlUG9pbnQpIHtcblx0XHRpZiAoY29kZVBvaW50IC0gNDggPCAxMCkge1xuXHRcdFx0cmV0dXJuIGNvZGVQb2ludCAtIDIyO1xuXHRcdH1cblx0XHRpZiAoY29kZVBvaW50IC0gNjUgPCAyNikge1xuXHRcdFx0cmV0dXJuIGNvZGVQb2ludCAtIDY1O1xuXHRcdH1cblx0XHRpZiAoY29kZVBvaW50IC0gOTcgPCAyNikge1xuXHRcdFx0cmV0dXJuIGNvZGVQb2ludCAtIDk3O1xuXHRcdH1cblx0XHRyZXR1cm4gYmFzZTtcblx0fVxuXG5cdC8qKlxuXHQgKiBDb252ZXJ0cyBhIGRpZ2l0L2ludGVnZXIgaW50byBhIGJhc2ljIGNvZGUgcG9pbnQuXG5cdCAqIEBzZWUgYGJhc2ljVG9EaWdpdCgpYFxuXHQgKiBAcHJpdmF0ZVxuXHQgKiBAcGFyYW0ge051bWJlcn0gZGlnaXQgVGhlIG51bWVyaWMgdmFsdWUgb2YgYSBiYXNpYyBjb2RlIHBvaW50LlxuXHQgKiBAcmV0dXJucyB7TnVtYmVyfSBUaGUgYmFzaWMgY29kZSBwb2ludCB3aG9zZSB2YWx1ZSAod2hlbiB1c2VkIGZvclxuXHQgKiByZXByZXNlbnRpbmcgaW50ZWdlcnMpIGlzIGBkaWdpdGAsIHdoaWNoIG5lZWRzIHRvIGJlIGluIHRoZSByYW5nZVxuXHQgKiBgMGAgdG8gYGJhc2UgLSAxYC4gSWYgYGZsYWdgIGlzIG5vbi16ZXJvLCB0aGUgdXBwZXJjYXNlIGZvcm0gaXNcblx0ICogdXNlZDsgZWxzZSwgdGhlIGxvd2VyY2FzZSBmb3JtIGlzIHVzZWQuIFRoZSBiZWhhdmlvciBpcyB1bmRlZmluZWRcblx0ICogaWYgYGZsYWdgIGlzIG5vbi16ZXJvIGFuZCBgZGlnaXRgIGhhcyBubyB1cHBlcmNhc2UgZm9ybS5cblx0ICovXG5cdGZ1bmN0aW9uIGRpZ2l0VG9CYXNpYyhkaWdpdCwgZmxhZykge1xuXHRcdC8vICAwLi4yNSBtYXAgdG8gQVNDSUkgYS4ueiBvciBBLi5aXG5cdFx0Ly8gMjYuLjM1IG1hcCB0byBBU0NJSSAwLi45XG5cdFx0cmV0dXJuIGRpZ2l0ICsgMjIgKyA3NSAqIChkaWdpdCA8IDI2KSAtICgoZmxhZyAhPSAwKSA8PCA1KTtcblx0fVxuXG5cdC8qKlxuXHQgKiBCaWFzIGFkYXB0YXRpb24gZnVuY3Rpb24gYXMgcGVyIHNlY3Rpb24gMy40IG9mIFJGQyAzNDkyLlxuXHQgKiBodHRwOi8vdG9vbHMuaWV0Zi5vcmcvaHRtbC9yZmMzNDkyI3NlY3Rpb24tMy40XG5cdCAqIEBwcml2YXRlXG5cdCAqL1xuXHRmdW5jdGlvbiBhZGFwdChkZWx0YSwgbnVtUG9pbnRzLCBmaXJzdFRpbWUpIHtcblx0XHR2YXIgayA9IDA7XG5cdFx0ZGVsdGEgPSBmaXJzdFRpbWUgPyBmbG9vcihkZWx0YSAvIGRhbXApIDogZGVsdGEgPj4gMTtcblx0XHRkZWx0YSArPSBmbG9vcihkZWx0YSAvIG51bVBvaW50cyk7XG5cdFx0Zm9yICgvKiBubyBpbml0aWFsaXphdGlvbiAqLzsgZGVsdGEgPiBiYXNlTWludXNUTWluICogdE1heCA+PiAxOyBrICs9IGJhc2UpIHtcblx0XHRcdGRlbHRhID0gZmxvb3IoZGVsdGEgLyBiYXNlTWludXNUTWluKTtcblx0XHR9XG5cdFx0cmV0dXJuIGZsb29yKGsgKyAoYmFzZU1pbnVzVE1pbiArIDEpICogZGVsdGEgLyAoZGVsdGEgKyBza2V3KSk7XG5cdH1cblxuXHQvKipcblx0ICogQ29udmVydHMgYSBQdW55Y29kZSBzdHJpbmcgb2YgQVNDSUktb25seSBzeW1ib2xzIHRvIGEgc3RyaW5nIG9mIFVuaWNvZGVcblx0ICogc3ltYm9scy5cblx0ICogQG1lbWJlck9mIHB1bnljb2RlXG5cdCAqIEBwYXJhbSB7U3RyaW5nfSBpbnB1dCBUaGUgUHVueWNvZGUgc3RyaW5nIG9mIEFTQ0lJLW9ubHkgc3ltYm9scy5cblx0ICogQHJldHVybnMge1N0cmluZ30gVGhlIHJlc3VsdGluZyBzdHJpbmcgb2YgVW5pY29kZSBzeW1ib2xzLlxuXHQgKi9cblx0ZnVuY3Rpb24gZGVjb2RlKGlucHV0KSB7XG5cdFx0Ly8gRG9uJ3QgdXNlIFVDUy0yXG5cdFx0dmFyIG91dHB1dCA9IFtdLFxuXHRcdCAgICBpbnB1dExlbmd0aCA9IGlucHV0Lmxlbmd0aCxcblx0XHQgICAgb3V0LFxuXHRcdCAgICBpID0gMCxcblx0XHQgICAgbiA9IGluaXRpYWxOLFxuXHRcdCAgICBiaWFzID0gaW5pdGlhbEJpYXMsXG5cdFx0ICAgIGJhc2ljLFxuXHRcdCAgICBqLFxuXHRcdCAgICBpbmRleCxcblx0XHQgICAgb2xkaSxcblx0XHQgICAgdyxcblx0XHQgICAgayxcblx0XHQgICAgZGlnaXQsXG5cdFx0ICAgIHQsXG5cdFx0ICAgIGxlbmd0aCxcblx0XHQgICAgLyoqIENhY2hlZCBjYWxjdWxhdGlvbiByZXN1bHRzICovXG5cdFx0ICAgIGJhc2VNaW51c1Q7XG5cblx0XHQvLyBIYW5kbGUgdGhlIGJhc2ljIGNvZGUgcG9pbnRzOiBsZXQgYGJhc2ljYCBiZSB0aGUgbnVtYmVyIG9mIGlucHV0IGNvZGVcblx0XHQvLyBwb2ludHMgYmVmb3JlIHRoZSBsYXN0IGRlbGltaXRlciwgb3IgYDBgIGlmIHRoZXJlIGlzIG5vbmUsIHRoZW4gY29weVxuXHRcdC8vIHRoZSBmaXJzdCBiYXNpYyBjb2RlIHBvaW50cyB0byB0aGUgb3V0cHV0LlxuXG5cdFx0YmFzaWMgPSBpbnB1dC5sYXN0SW5kZXhPZihkZWxpbWl0ZXIpO1xuXHRcdGlmIChiYXNpYyA8IDApIHtcblx0XHRcdGJhc2ljID0gMDtcblx0XHR9XG5cblx0XHRmb3IgKGogPSAwOyBqIDwgYmFzaWM7ICsraikge1xuXHRcdFx0Ly8gaWYgaXQncyBub3QgYSBiYXNpYyBjb2RlIHBvaW50XG5cdFx0XHRpZiAoaW5wdXQuY2hhckNvZGVBdChqKSA+PSAweDgwKSB7XG5cdFx0XHRcdGVycm9yKCdub3QtYmFzaWMnKTtcblx0XHRcdH1cblx0XHRcdG91dHB1dC5wdXNoKGlucHV0LmNoYXJDb2RlQXQoaikpO1xuXHRcdH1cblxuXHRcdC8vIE1haW4gZGVjb2RpbmcgbG9vcDogc3RhcnQganVzdCBhZnRlciB0aGUgbGFzdCBkZWxpbWl0ZXIgaWYgYW55IGJhc2ljIGNvZGVcblx0XHQvLyBwb2ludHMgd2VyZSBjb3BpZWQ7IHN0YXJ0IGF0IHRoZSBiZWdpbm5pbmcgb3RoZXJ3aXNlLlxuXG5cdFx0Zm9yIChpbmRleCA9IGJhc2ljID4gMCA/IGJhc2ljICsgMSA6IDA7IGluZGV4IDwgaW5wdXRMZW5ndGg7IC8qIG5vIGZpbmFsIGV4cHJlc3Npb24gKi8pIHtcblxuXHRcdFx0Ly8gYGluZGV4YCBpcyB0aGUgaW5kZXggb2YgdGhlIG5leHQgY2hhcmFjdGVyIHRvIGJlIGNvbnN1bWVkLlxuXHRcdFx0Ly8gRGVjb2RlIGEgZ2VuZXJhbGl6ZWQgdmFyaWFibGUtbGVuZ3RoIGludGVnZXIgaW50byBgZGVsdGFgLFxuXHRcdFx0Ly8gd2hpY2ggZ2V0cyBhZGRlZCB0byBgaWAuIFRoZSBvdmVyZmxvdyBjaGVja2luZyBpcyBlYXNpZXJcblx0XHRcdC8vIGlmIHdlIGluY3JlYXNlIGBpYCBhcyB3ZSBnbywgdGhlbiBzdWJ0cmFjdCBvZmYgaXRzIHN0YXJ0aW5nXG5cdFx0XHQvLyB2YWx1ZSBhdCB0aGUgZW5kIHRvIG9idGFpbiBgZGVsdGFgLlxuXHRcdFx0Zm9yIChvbGRpID0gaSwgdyA9IDEsIGsgPSBiYXNlOyAvKiBubyBjb25kaXRpb24gKi87IGsgKz0gYmFzZSkge1xuXG5cdFx0XHRcdGlmIChpbmRleCA+PSBpbnB1dExlbmd0aCkge1xuXHRcdFx0XHRcdGVycm9yKCdpbnZhbGlkLWlucHV0Jyk7XG5cdFx0XHRcdH1cblxuXHRcdFx0XHRkaWdpdCA9IGJhc2ljVG9EaWdpdChpbnB1dC5jaGFyQ29kZUF0KGluZGV4KyspKTtcblxuXHRcdFx0XHRpZiAoZGlnaXQgPj0gYmFzZSB8fCBkaWdpdCA+IGZsb29yKChtYXhJbnQgLSBpKSAvIHcpKSB7XG5cdFx0XHRcdFx0ZXJyb3IoJ292ZXJmbG93Jyk7XG5cdFx0XHRcdH1cblxuXHRcdFx0XHRpICs9IGRpZ2l0ICogdztcblx0XHRcdFx0dCA9IGsgPD0gYmlhcyA/IHRNaW4gOiAoayA+PSBiaWFzICsgdE1heCA/IHRNYXggOiBrIC0gYmlhcyk7XG5cblx0XHRcdFx0aWYgKGRpZ2l0IDwgdCkge1xuXHRcdFx0XHRcdGJyZWFrO1xuXHRcdFx0XHR9XG5cblx0XHRcdFx0YmFzZU1pbnVzVCA9IGJhc2UgLSB0O1xuXHRcdFx0XHRpZiAodyA+IGZsb29yKG1heEludCAvIGJhc2VNaW51c1QpKSB7XG5cdFx0XHRcdFx0ZXJyb3IoJ292ZXJmbG93Jyk7XG5cdFx0XHRcdH1cblxuXHRcdFx0XHR3ICo9IGJhc2VNaW51c1Q7XG5cblx0XHRcdH1cblxuXHRcdFx0b3V0ID0gb3V0cHV0Lmxlbmd0aCArIDE7XG5cdFx0XHRiaWFzID0gYWRhcHQoaSAtIG9sZGksIG91dCwgb2xkaSA9PSAwKTtcblxuXHRcdFx0Ly8gYGlgIHdhcyBzdXBwb3NlZCB0byB3cmFwIGFyb3VuZCBmcm9tIGBvdXRgIHRvIGAwYCxcblx0XHRcdC8vIGluY3JlbWVudGluZyBgbmAgZWFjaCB0aW1lLCBzbyB3ZSdsbCBmaXggdGhhdCBub3c6XG5cdFx0XHRpZiAoZmxvb3IoaSAvIG91dCkgPiBtYXhJbnQgLSBuKSB7XG5cdFx0XHRcdGVycm9yKCdvdmVyZmxvdycpO1xuXHRcdFx0fVxuXG5cdFx0XHRuICs9IGZsb29yKGkgLyBvdXQpO1xuXHRcdFx0aSAlPSBvdXQ7XG5cblx0XHRcdC8vIEluc2VydCBgbmAgYXQgcG9zaXRpb24gYGlgIG9mIHRoZSBvdXRwdXRcblx0XHRcdG91dHB1dC5zcGxpY2UoaSsrLCAwLCBuKTtcblxuXHRcdH1cblxuXHRcdHJldHVybiB1Y3MyZW5jb2RlKG91dHB1dCk7XG5cdH1cblxuXHQvKipcblx0ICogQ29udmVydHMgYSBzdHJpbmcgb2YgVW5pY29kZSBzeW1ib2xzIHRvIGEgUHVueWNvZGUgc3RyaW5nIG9mIEFTQ0lJLW9ubHlcblx0ICogc3ltYm9scy5cblx0ICogQG1lbWJlck9mIHB1bnljb2RlXG5cdCAqIEBwYXJhbSB7U3RyaW5nfSBpbnB1dCBUaGUgc3RyaW5nIG9mIFVuaWNvZGUgc3ltYm9scy5cblx0ICogQHJldHVybnMge1N0cmluZ30gVGhlIHJlc3VsdGluZyBQdW55Y29kZSBzdHJpbmcgb2YgQVNDSUktb25seSBzeW1ib2xzLlxuXHQgKi9cblx0ZnVuY3Rpb24gZW5jb2RlKGlucHV0KSB7XG5cdFx0dmFyIG4sXG5cdFx0ICAgIGRlbHRhLFxuXHRcdCAgICBoYW5kbGVkQ1BDb3VudCxcblx0XHQgICAgYmFzaWNMZW5ndGgsXG5cdFx0ICAgIGJpYXMsXG5cdFx0ICAgIGosXG5cdFx0ICAgIG0sXG5cdFx0ICAgIHEsXG5cdFx0ICAgIGssXG5cdFx0ICAgIHQsXG5cdFx0ICAgIGN1cnJlbnRWYWx1ZSxcblx0XHQgICAgb3V0cHV0ID0gW10sXG5cdFx0ICAgIC8qKiBgaW5wdXRMZW5ndGhgIHdpbGwgaG9sZCB0aGUgbnVtYmVyIG9mIGNvZGUgcG9pbnRzIGluIGBpbnB1dGAuICovXG5cdFx0ICAgIGlucHV0TGVuZ3RoLFxuXHRcdCAgICAvKiogQ2FjaGVkIGNhbGN1bGF0aW9uIHJlc3VsdHMgKi9cblx0XHQgICAgaGFuZGxlZENQQ291bnRQbHVzT25lLFxuXHRcdCAgICBiYXNlTWludXNULFxuXHRcdCAgICBxTWludXNUO1xuXG5cdFx0Ly8gQ29udmVydCB0aGUgaW5wdXQgaW4gVUNTLTIgdG8gVW5pY29kZVxuXHRcdGlucHV0ID0gdWNzMmRlY29kZShpbnB1dCk7XG5cblx0XHQvLyBDYWNoZSB0aGUgbGVuZ3RoXG5cdFx0aW5wdXRMZW5ndGggPSBpbnB1dC5sZW5ndGg7XG5cblx0XHQvLyBJbml0aWFsaXplIHRoZSBzdGF0ZVxuXHRcdG4gPSBpbml0aWFsTjtcblx0XHRkZWx0YSA9IDA7XG5cdFx0YmlhcyA9IGluaXRpYWxCaWFzO1xuXG5cdFx0Ly8gSGFuZGxlIHRoZSBiYXNpYyBjb2RlIHBvaW50c1xuXHRcdGZvciAoaiA9IDA7IGogPCBpbnB1dExlbmd0aDsgKytqKSB7XG5cdFx0XHRjdXJyZW50VmFsdWUgPSBpbnB1dFtqXTtcblx0XHRcdGlmIChjdXJyZW50VmFsdWUgPCAweDgwKSB7XG5cdFx0XHRcdG91dHB1dC5wdXNoKHN0cmluZ0Zyb21DaGFyQ29kZShjdXJyZW50VmFsdWUpKTtcblx0XHRcdH1cblx0XHR9XG5cblx0XHRoYW5kbGVkQ1BDb3VudCA9IGJhc2ljTGVuZ3RoID0gb3V0cHV0Lmxlbmd0aDtcblxuXHRcdC8vIGBoYW5kbGVkQ1BDb3VudGAgaXMgdGhlIG51bWJlciBvZiBjb2RlIHBvaW50cyB0aGF0IGhhdmUgYmVlbiBoYW5kbGVkO1xuXHRcdC8vIGBiYXNpY0xlbmd0aGAgaXMgdGhlIG51bWJlciBvZiBiYXNpYyBjb2RlIHBvaW50cy5cblxuXHRcdC8vIEZpbmlzaCB0aGUgYmFzaWMgc3RyaW5nIC0gaWYgaXQgaXMgbm90IGVtcHR5IC0gd2l0aCBhIGRlbGltaXRlclxuXHRcdGlmIChiYXNpY0xlbmd0aCkge1xuXHRcdFx0b3V0cHV0LnB1c2goZGVsaW1pdGVyKTtcblx0XHR9XG5cblx0XHQvLyBNYWluIGVuY29kaW5nIGxvb3A6XG5cdFx0d2hpbGUgKGhhbmRsZWRDUENvdW50IDwgaW5wdXRMZW5ndGgpIHtcblxuXHRcdFx0Ly8gQWxsIG5vbi1iYXNpYyBjb2RlIHBvaW50cyA8IG4gaGF2ZSBiZWVuIGhhbmRsZWQgYWxyZWFkeS4gRmluZCB0aGUgbmV4dFxuXHRcdFx0Ly8gbGFyZ2VyIG9uZTpcblx0XHRcdGZvciAobSA9IG1heEludCwgaiA9IDA7IGogPCBpbnB1dExlbmd0aDsgKytqKSB7XG5cdFx0XHRcdGN1cnJlbnRWYWx1ZSA9IGlucHV0W2pdO1xuXHRcdFx0XHRpZiAoY3VycmVudFZhbHVlID49IG4gJiYgY3VycmVudFZhbHVlIDwgbSkge1xuXHRcdFx0XHRcdG0gPSBjdXJyZW50VmFsdWU7XG5cdFx0XHRcdH1cblx0XHRcdH1cblxuXHRcdFx0Ly8gSW5jcmVhc2UgYGRlbHRhYCBlbm91Z2ggdG8gYWR2YW5jZSB0aGUgZGVjb2RlcidzIDxuLGk+IHN0YXRlIHRvIDxtLDA+LFxuXHRcdFx0Ly8gYnV0IGd1YXJkIGFnYWluc3Qgb3ZlcmZsb3dcblx0XHRcdGhhbmRsZWRDUENvdW50UGx1c09uZSA9IGhhbmRsZWRDUENvdW50ICsgMTtcblx0XHRcdGlmIChtIC0gbiA+IGZsb29yKChtYXhJbnQgLSBkZWx0YSkgLyBoYW5kbGVkQ1BDb3VudFBsdXNPbmUpKSB7XG5cdFx0XHRcdGVycm9yKCdvdmVyZmxvdycpO1xuXHRcdFx0fVxuXG5cdFx0XHRkZWx0YSArPSAobSAtIG4pICogaGFuZGxlZENQQ291bnRQbHVzT25lO1xuXHRcdFx0biA9IG07XG5cblx0XHRcdGZvciAoaiA9IDA7IGogPCBpbnB1dExlbmd0aDsgKytqKSB7XG5cdFx0XHRcdGN1cnJlbnRWYWx1ZSA9IGlucHV0W2pdO1xuXG5cdFx0XHRcdGlmIChjdXJyZW50VmFsdWUgPCBuICYmICsrZGVsdGEgPiBtYXhJbnQpIHtcblx0XHRcdFx0XHRlcnJvcignb3ZlcmZsb3cnKTtcblx0XHRcdFx0fVxuXG5cdFx0XHRcdGlmIChjdXJyZW50VmFsdWUgPT0gbikge1xuXHRcdFx0XHRcdC8vIFJlcHJlc2VudCBkZWx0YSBhcyBhIGdlbmVyYWxpemVkIHZhcmlhYmxlLWxlbmd0aCBpbnRlZ2VyXG5cdFx0XHRcdFx0Zm9yIChxID0gZGVsdGEsIGsgPSBiYXNlOyAvKiBubyBjb25kaXRpb24gKi87IGsgKz0gYmFzZSkge1xuXHRcdFx0XHRcdFx0dCA9IGsgPD0gYmlhcyA/IHRNaW4gOiAoayA+PSBiaWFzICsgdE1heCA/IHRNYXggOiBrIC0gYmlhcyk7XG5cdFx0XHRcdFx0XHRpZiAocSA8IHQpIHtcblx0XHRcdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdFx0XHR9XG5cdFx0XHRcdFx0XHRxTWludXNUID0gcSAtIHQ7XG5cdFx0XHRcdFx0XHRiYXNlTWludXNUID0gYmFzZSAtIHQ7XG5cdFx0XHRcdFx0XHRvdXRwdXQucHVzaChcblx0XHRcdFx0XHRcdFx0c3RyaW5nRnJvbUNoYXJDb2RlKGRpZ2l0VG9CYXNpYyh0ICsgcU1pbnVzVCAlIGJhc2VNaW51c1QsIDApKVxuXHRcdFx0XHRcdFx0KTtcblx0XHRcdFx0XHRcdHEgPSBmbG9vcihxTWludXNUIC8gYmFzZU1pbnVzVCk7XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0b3V0cHV0LnB1c2goc3RyaW5nRnJvbUNoYXJDb2RlKGRpZ2l0VG9CYXNpYyhxLCAwKSkpO1xuXHRcdFx0XHRcdGJpYXMgPSBhZGFwdChkZWx0YSwgaGFuZGxlZENQQ291bnRQbHVzT25lLCBoYW5kbGVkQ1BDb3VudCA9PSBiYXNpY0xlbmd0aCk7XG5cdFx0XHRcdFx0ZGVsdGEgPSAwO1xuXHRcdFx0XHRcdCsraGFuZGxlZENQQ291bnQ7XG5cdFx0XHRcdH1cblx0XHRcdH1cblxuXHRcdFx0KytkZWx0YTtcblx0XHRcdCsrbjtcblxuXHRcdH1cblx0XHRyZXR1cm4gb3V0cHV0LmpvaW4oJycpO1xuXHR9XG5cblx0LyoqXG5cdCAqIENvbnZlcnRzIGEgUHVueWNvZGUgc3RyaW5nIHJlcHJlc2VudGluZyBhIGRvbWFpbiBuYW1lIHRvIFVuaWNvZGUuIE9ubHkgdGhlXG5cdCAqIFB1bnljb2RlZCBwYXJ0cyBvZiB0aGUgZG9tYWluIG5hbWUgd2lsbCBiZSBjb252ZXJ0ZWQsIGkuZS4gaXQgZG9lc24ndFxuXHQgKiBtYXR0ZXIgaWYgeW91IGNhbGwgaXQgb24gYSBzdHJpbmcgdGhhdCBoYXMgYWxyZWFkeSBiZWVuIGNvbnZlcnRlZCB0b1xuXHQgKiBVbmljb2RlLlxuXHQgKiBAbWVtYmVyT2YgcHVueWNvZGVcblx0ICogQHBhcmFtIHtTdHJpbmd9IGRvbWFpbiBUaGUgUHVueWNvZGUgZG9tYWluIG5hbWUgdG8gY29udmVydCB0byBVbmljb2RlLlxuXHQgKiBAcmV0dXJucyB7U3RyaW5nfSBUaGUgVW5pY29kZSByZXByZXNlbnRhdGlvbiBvZiB0aGUgZ2l2ZW4gUHVueWNvZGVcblx0ICogc3RyaW5nLlxuXHQgKi9cblx0ZnVuY3Rpb24gdG9Vbmljb2RlKGRvbWFpbikge1xuXHRcdHJldHVybiBtYXBEb21haW4oZG9tYWluLCBmdW5jdGlvbihzdHJpbmcpIHtcblx0XHRcdHJldHVybiByZWdleFB1bnljb2RlLnRlc3Qoc3RyaW5nKVxuXHRcdFx0XHQ/IGRlY29kZShzdHJpbmcuc2xpY2UoNCkudG9Mb3dlckNhc2UoKSlcblx0XHRcdFx0OiBzdHJpbmc7XG5cdFx0fSk7XG5cdH1cblxuXHQvKipcblx0ICogQ29udmVydHMgYSBVbmljb2RlIHN0cmluZyByZXByZXNlbnRpbmcgYSBkb21haW4gbmFtZSB0byBQdW55Y29kZS4gT25seSB0aGVcblx0ICogbm9uLUFTQ0lJIHBhcnRzIG9mIHRoZSBkb21haW4gbmFtZSB3aWxsIGJlIGNvbnZlcnRlZCwgaS5lLiBpdCBkb2Vzbid0XG5cdCAqIG1hdHRlciBpZiB5b3UgY2FsbCBpdCB3aXRoIGEgZG9tYWluIHRoYXQncyBhbHJlYWR5IGluIEFTQ0lJLlxuXHQgKiBAbWVtYmVyT2YgcHVueWNvZGVcblx0ICogQHBhcmFtIHtTdHJpbmd9IGRvbWFpbiBUaGUgZG9tYWluIG5hbWUgdG8gY29udmVydCwgYXMgYSBVbmljb2RlIHN0cmluZy5cblx0ICogQHJldHVybnMge1N0cmluZ30gVGhlIFB1bnljb2RlIHJlcHJlc2VudGF0aW9uIG9mIHRoZSBnaXZlbiBkb21haW4gbmFtZS5cblx0ICovXG5cdGZ1bmN0aW9uIHRvQVNDSUkoZG9tYWluKSB7XG5cdFx0cmV0dXJuIG1hcERvbWFpbihkb21haW4sIGZ1bmN0aW9uKHN0cmluZykge1xuXHRcdFx0cmV0dXJuIHJlZ2V4Tm9uQVNDSUkudGVzdChzdHJpbmcpXG5cdFx0XHRcdD8gJ3huLS0nICsgZW5jb2RlKHN0cmluZylcblx0XHRcdFx0OiBzdHJpbmc7XG5cdFx0fSk7XG5cdH1cblxuXHQvKi0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tKi9cblxuXHQvKiogRGVmaW5lIHRoZSBwdWJsaWMgQVBJICovXG5cdHB1bnljb2RlID0ge1xuXHRcdC8qKlxuXHRcdCAqIEEgc3RyaW5nIHJlcHJlc2VudGluZyB0aGUgY3VycmVudCBQdW55Y29kZS5qcyB2ZXJzaW9uIG51bWJlci5cblx0XHQgKiBAbWVtYmVyT2YgcHVueWNvZGVcblx0XHQgKiBAdHlwZSBTdHJpbmdcblx0XHQgKi9cblx0XHQndmVyc2lvbic6ICcxLjIuMycsXG5cdFx0LyoqXG5cdFx0ICogQW4gb2JqZWN0IG9mIG1ldGhvZHMgdG8gY29udmVydCBmcm9tIEphdmFTY3JpcHQncyBpbnRlcm5hbCBjaGFyYWN0ZXJcblx0XHQgKiByZXByZXNlbnRhdGlvbiAoVUNTLTIpIHRvIFVuaWNvZGUgY29kZSBwb2ludHMsIGFuZCBiYWNrLlxuXHRcdCAqIEBzZWUgPGh0dHA6Ly9tYXRoaWFzYnluZW5zLmJlL25vdGVzL2phdmFzY3JpcHQtZW5jb2Rpbmc+XG5cdFx0ICogQG1lbWJlck9mIHB1bnljb2RlXG5cdFx0ICogQHR5cGUgT2JqZWN0XG5cdFx0ICovXG5cdFx0J3VjczInOiB7XG5cdFx0XHQnZGVjb2RlJzogdWNzMmRlY29kZSxcblx0XHRcdCdlbmNvZGUnOiB1Y3MyZW5jb2RlXG5cdFx0fSxcblx0XHQnZGVjb2RlJzogZGVjb2RlLFxuXHRcdCdlbmNvZGUnOiBlbmNvZGUsXG5cdFx0J3RvQVNDSUknOiB0b0FTQ0lJLFxuXHRcdCd0b1VuaWNvZGUnOiB0b1VuaWNvZGVcblx0fTtcblxuXHQvKiogRXhwb3NlIGBwdW55Y29kZWAgKi9cblx0Ly8gU29tZSBBTUQgYnVpbGQgb3B0aW1pemVycywgbGlrZSByLmpzLCBjaGVjayBmb3Igc3BlY2lmaWMgY29uZGl0aW9uIHBhdHRlcm5zXG5cdC8vIGxpa2UgdGhlIGZvbGxvd2luZzpcblx0aWYgKFxuXHRcdHR5cGVvZiBkZWZpbmUgPT0gJ2Z1bmN0aW9uJyAmJlxuXHRcdHR5cGVvZiBkZWZpbmUuYW1kID09ICdvYmplY3QnICYmXG5cdFx0ZGVmaW5lLmFtZFxuXHQpIHtcblx0XHRkZWZpbmUoZnVuY3Rpb24oKSB7XG5cdFx0XHRyZXR1cm4gcHVueWNvZGU7XG5cdFx0fSk7XG5cdH1cdGVsc2UgaWYgKGZyZWVFeHBvcnRzICYmICFmcmVlRXhwb3J0cy5ub2RlVHlwZSkge1xuXHRcdGlmIChmcmVlTW9kdWxlKSB7IC8vIGluIE5vZGUuanMgb3IgUmluZ29KUyB2MC44LjArXG5cdFx0XHRmcmVlTW9kdWxlLmV4cG9ydHMgPSBwdW55Y29kZTtcblx0XHR9IGVsc2UgeyAvLyBpbiBOYXJ3aGFsIG9yIFJpbmdvSlMgdjAuNy4wLVxuXHRcdFx0Zm9yIChrZXkgaW4gcHVueWNvZGUpIHtcblx0XHRcdFx0cHVueWNvZGUuaGFzT3duUHJvcGVydHkoa2V5KSAmJiAoZnJlZUV4cG9ydHNba2V5XSA9IHB1bnljb2RlW2tleV0pO1xuXHRcdFx0fVxuXHRcdH1cblx0fSBlbHNlIHsgLy8gaW4gUmhpbm8gb3IgYSB3ZWIgYnJvd3NlclxuXHRcdHJvb3QucHVueWNvZGUgPSBwdW55Y29kZTtcblx0fVxuXG59KHRoaXMpKTtcbiIsIid1c2Ugc3RyaWN0JztcblxuT2JqZWN0LmRlZmluZVByb3BlcnR5KGV4cG9ydHMsICdfX2VzTW9kdWxlJywge1xuICB2YWx1ZTogdHJ1ZVxufSk7XG5cbmZ1bmN0aW9uIF9pbnRlcm9wUmVxdWlyZURlZmF1bHQob2JqKSB7IHJldHVybiBvYmogJiYgb2JqLl9fZXNNb2R1bGUgPyBvYmogOiB7ICdkZWZhdWx0Jzogb2JqIH07IH1cblxudmFyIF9oZWxwZXJzRXZlbnQgPSByZXF1aXJlKCcuL2hlbHBlcnMvZXZlbnQnKTtcblxudmFyIF9oZWxwZXJzRXZlbnQyID0gX2ludGVyb3BSZXF1aXJlRGVmYXVsdChfaGVscGVyc0V2ZW50KTtcblxudmFyIF9oZWxwZXJzTWVzc2FnZUV2ZW50ID0gcmVxdWlyZSgnLi9oZWxwZXJzL21lc3NhZ2UtZXZlbnQnKTtcblxudmFyIF9oZWxwZXJzTWVzc2FnZUV2ZW50MiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX2hlbHBlcnNNZXNzYWdlRXZlbnQpO1xuXG52YXIgX2hlbHBlcnNDbG9zZUV2ZW50ID0gcmVxdWlyZSgnLi9oZWxwZXJzL2Nsb3NlLWV2ZW50Jyk7XG5cbnZhciBfaGVscGVyc0Nsb3NlRXZlbnQyID0gX2ludGVyb3BSZXF1aXJlRGVmYXVsdChfaGVscGVyc0Nsb3NlRXZlbnQpO1xuXG4vKlxuKiBDcmVhdGVzIGFuIEV2ZW50IG9iamVjdCBhbmQgZXh0ZW5kcyBpdCB0byBhbGxvdyBmdWxsIG1vZGlmaWNhdGlvbiBvZlxuKiBpdHMgcHJvcGVydGllcy5cbipcbiogQHBhcmFtIHtvYmplY3R9IGNvbmZpZyAtIHdpdGhpbiBjb25maWcgeW91IHdpbGwgbmVlZCB0byBwYXNzIHR5cGUgYW5kIG9wdGlvbmFsbHkgdGFyZ2V0XG4qL1xuZnVuY3Rpb24gY3JlYXRlRXZlbnQoY29uZmlnKSB7XG4gIHZhciB0eXBlID0gY29uZmlnLnR5cGU7XG4gIHZhciB0YXJnZXQgPSBjb25maWcudGFyZ2V0O1xuXG4gIHZhciBldmVudE9iamVjdCA9IG5ldyBfaGVscGVyc0V2ZW50MlsnZGVmYXVsdCddKHR5cGUpO1xuXG4gIGlmICh0YXJnZXQpIHtcbiAgICBldmVudE9iamVjdC50YXJnZXQgPSB0YXJnZXQ7XG4gICAgZXZlbnRPYmplY3Quc3JjRWxlbWVudCA9IHRhcmdldDtcbiAgICBldmVudE9iamVjdC5jdXJyZW50VGFyZ2V0ID0gdGFyZ2V0O1xuICB9XG5cbiAgcmV0dXJuIGV2ZW50T2JqZWN0O1xufVxuXG4vKlxuKiBDcmVhdGVzIGEgTWVzc2FnZUV2ZW50IG9iamVjdCBhbmQgZXh0ZW5kcyBpdCB0byBhbGxvdyBmdWxsIG1vZGlmaWNhdGlvbiBvZlxuKiBpdHMgcHJvcGVydGllcy5cbipcbiogQHBhcmFtIHtvYmplY3R9IGNvbmZpZyAtIHdpdGhpbiBjb25maWcgeW91IHdpbGwgbmVlZCB0byBwYXNzIHR5cGUsIG9yaWdpbiwgZGF0YSBhbmQgb3B0aW9uYWxseSB0YXJnZXRcbiovXG5mdW5jdGlvbiBjcmVhdGVNZXNzYWdlRXZlbnQoY29uZmlnKSB7XG4gIHZhciB0eXBlID0gY29uZmlnLnR5cGU7XG4gIHZhciBvcmlnaW4gPSBjb25maWcub3JpZ2luO1xuICB2YXIgZGF0YSA9IGNvbmZpZy5kYXRhO1xuICB2YXIgdGFyZ2V0ID0gY29uZmlnLnRhcmdldDtcblxuICB2YXIgbWVzc2FnZUV2ZW50ID0gbmV3IF9oZWxwZXJzTWVzc2FnZUV2ZW50MlsnZGVmYXVsdCddKHR5cGUsIHtcbiAgICBkYXRhOiBkYXRhLFxuICAgIG9yaWdpbjogb3JpZ2luXG4gIH0pO1xuXG4gIGlmICh0YXJnZXQpIHtcbiAgICBtZXNzYWdlRXZlbnQudGFyZ2V0ID0gdGFyZ2V0O1xuICAgIG1lc3NhZ2VFdmVudC5zcmNFbGVtZW50ID0gdGFyZ2V0O1xuICAgIG1lc3NhZ2VFdmVudC5jdXJyZW50VGFyZ2V0ID0gdGFyZ2V0O1xuICB9XG5cbiAgcmV0dXJuIG1lc3NhZ2VFdmVudDtcbn1cblxuLypcbiogQ3JlYXRlcyBhIENsb3NlRXZlbnQgb2JqZWN0IGFuZCBleHRlbmRzIGl0IHRvIGFsbG93IGZ1bGwgbW9kaWZpY2F0aW9uIG9mXG4qIGl0cyBwcm9wZXJ0aWVzLlxuKlxuKiBAcGFyYW0ge29iamVjdH0gY29uZmlnIC0gd2l0aGluIGNvbmZpZyB5b3Ugd2lsbCBuZWVkIHRvIHBhc3MgdHlwZSBhbmQgb3B0aW9uYWxseSB0YXJnZXQsIGNvZGUsIGFuZCByZWFzb25cbiovXG5mdW5jdGlvbiBjcmVhdGVDbG9zZUV2ZW50KGNvbmZpZykge1xuICB2YXIgY29kZSA9IGNvbmZpZy5jb2RlO1xuICB2YXIgcmVhc29uID0gY29uZmlnLnJlYXNvbjtcbiAgdmFyIHR5cGUgPSBjb25maWcudHlwZTtcbiAgdmFyIHRhcmdldCA9IGNvbmZpZy50YXJnZXQ7XG4gIHZhciB3YXNDbGVhbiA9IGNvbmZpZy53YXNDbGVhbjtcblxuICBpZiAoIXdhc0NsZWFuKSB7XG4gICAgd2FzQ2xlYW4gPSBjb2RlID09PSAxMDAwO1xuICB9XG5cbiAgdmFyIGNsb3NlRXZlbnQgPSBuZXcgX2hlbHBlcnNDbG9zZUV2ZW50MlsnZGVmYXVsdCddKHR5cGUsIHtcbiAgICBjb2RlOiBjb2RlLFxuICAgIHJlYXNvbjogcmVhc29uLFxuICAgIHdhc0NsZWFuOiB3YXNDbGVhblxuICB9KTtcblxuICBpZiAodGFyZ2V0KSB7XG4gICAgY2xvc2VFdmVudC50YXJnZXQgPSB0YXJnZXQ7XG4gICAgY2xvc2VFdmVudC5zcmNFbGVtZW50ID0gdGFyZ2V0O1xuICAgIGNsb3NlRXZlbnQuY3VycmVudFRhcmdldCA9IHRhcmdldDtcbiAgfVxuXG4gIHJldHVybiBjbG9zZUV2ZW50O1xufVxuXG5leHBvcnRzLmNyZWF0ZUV2ZW50ID0gY3JlYXRlRXZlbnQ7XG5leHBvcnRzLmNyZWF0ZU1lc3NhZ2VFdmVudCA9IGNyZWF0ZU1lc3NhZ2VFdmVudDtcbmV4cG9ydHMuY3JlYXRlQ2xvc2VFdmVudCA9IGNyZWF0ZUNsb3NlRXZlbnQ7IiwiJ3VzZSBzdHJpY3QnO1xuXG5PYmplY3QuZGVmaW5lUHJvcGVydHkoZXhwb3J0cywgJ19fZXNNb2R1bGUnLCB7XG4gIHZhbHVlOiB0cnVlXG59KTtcblxudmFyIF9jcmVhdGVDbGFzcyA9IChmdW5jdGlvbiAoKSB7IGZ1bmN0aW9uIGRlZmluZVByb3BlcnRpZXModGFyZ2V0LCBwcm9wcykgeyBmb3IgKHZhciBpID0gMDsgaSA8IHByb3BzLmxlbmd0aDsgaSsrKSB7IHZhciBkZXNjcmlwdG9yID0gcHJvcHNbaV07IGRlc2NyaXB0b3IuZW51bWVyYWJsZSA9IGRlc2NyaXB0b3IuZW51bWVyYWJsZSB8fCBmYWxzZTsgZGVzY3JpcHRvci5jb25maWd1cmFibGUgPSB0cnVlOyBpZiAoJ3ZhbHVlJyBpbiBkZXNjcmlwdG9yKSBkZXNjcmlwdG9yLndyaXRhYmxlID0gdHJ1ZTsgT2JqZWN0LmRlZmluZVByb3BlcnR5KHRhcmdldCwgZGVzY3JpcHRvci5rZXksIGRlc2NyaXB0b3IpOyB9IH0gcmV0dXJuIGZ1bmN0aW9uIChDb25zdHJ1Y3RvciwgcHJvdG9Qcm9wcywgc3RhdGljUHJvcHMpIHsgaWYgKHByb3RvUHJvcHMpIGRlZmluZVByb3BlcnRpZXMoQ29uc3RydWN0b3IucHJvdG90eXBlLCBwcm90b1Byb3BzKTsgaWYgKHN0YXRpY1Byb3BzKSBkZWZpbmVQcm9wZXJ0aWVzKENvbnN0cnVjdG9yLCBzdGF0aWNQcm9wcyk7IHJldHVybiBDb25zdHJ1Y3RvcjsgfTsgfSkoKTtcblxuZnVuY3Rpb24gX2NsYXNzQ2FsbENoZWNrKGluc3RhbmNlLCBDb25zdHJ1Y3RvcikgeyBpZiAoIShpbnN0YW5jZSBpbnN0YW5jZW9mIENvbnN0cnVjdG9yKSkgeyB0aHJvdyBuZXcgVHlwZUVycm9yKCdDYW5ub3QgY2FsbCBhIGNsYXNzIGFzIGEgZnVuY3Rpb24nKTsgfSB9XG5cbnZhciBfaGVscGVyc0FycmF5SGVscGVycyA9IHJlcXVpcmUoJy4vaGVscGVycy9hcnJheS1oZWxwZXJzJyk7XG5cbi8qXG4qIEV2ZW50VGFyZ2V0IGlzIGFuIGludGVyZmFjZSBpbXBsZW1lbnRlZCBieSBvYmplY3RzIHRoYXQgY2FuXG4qIHJlY2VpdmUgZXZlbnRzIGFuZCBtYXkgaGF2ZSBsaXN0ZW5lcnMgZm9yIHRoZW0uXG4qXG4qIGh0dHBzOi8vZGV2ZWxvcGVyLm1vemlsbGEub3JnL2VuLVVTL2RvY3MvV2ViL0FQSS9FdmVudFRhcmdldFxuKi9cblxudmFyIEV2ZW50VGFyZ2V0ID0gKGZ1bmN0aW9uICgpIHtcbiAgZnVuY3Rpb24gRXZlbnRUYXJnZXQoKSB7XG4gICAgX2NsYXNzQ2FsbENoZWNrKHRoaXMsIEV2ZW50VGFyZ2V0KTtcblxuICAgIHRoaXMubGlzdGVuZXJzID0ge307XG4gIH1cblxuICAvKlxuICAqIFRpZXMgYSBsaXN0ZW5lciBmdW5jdGlvbiB0byBhIGV2ZW50IHR5cGUgd2hpY2ggY2FuIGxhdGVyIGJlIGludm9rZWQgdmlhIHRoZVxuICAqIGRpc3BhdGNoRXZlbnQgbWV0aG9kLlxuICAqXG4gICogQHBhcmFtIHtzdHJpbmd9IHR5cGUgLSB0aGUgdHlwZSBvZiBldmVudCAoaWU6ICdvcGVuJywgJ21lc3NhZ2UnLCBldGMuKVxuICAqIEBwYXJhbSB7ZnVuY3Rpb259IGxpc3RlbmVyIC0gdGhlIGNhbGxiYWNrIGZ1bmN0aW9uIHRvIGludm9rZSB3aGVuZXZlciBhIGV2ZW50IGlzIGRpc3BhdGNoZWQgbWF0Y2hpbmcgdGhlIGdpdmVuIHR5cGVcbiAgKiBAcGFyYW0ge2Jvb2xlYW59IHVzZUNhcHR1cmUgLSBOL0EgVE9ETzogaW1wbGVtZW50IHVzZUNhcHR1cmUgZnVuY3Rpb25hbGl0eVxuICAqL1xuXG4gIF9jcmVhdGVDbGFzcyhFdmVudFRhcmdldCwgW3tcbiAgICBrZXk6ICdhZGRFdmVudExpc3RlbmVyJyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gYWRkRXZlbnRMaXN0ZW5lcih0eXBlLCBsaXN0ZW5lciAvKiAsIHVzZUNhcHR1cmUgKi8pIHtcbiAgICAgIGlmICh0eXBlb2YgbGlzdGVuZXIgPT09ICdmdW5jdGlvbicpIHtcbiAgICAgICAgaWYgKCFBcnJheS5pc0FycmF5KHRoaXMubGlzdGVuZXJzW3R5cGVdKSkge1xuICAgICAgICAgIHRoaXMubGlzdGVuZXJzW3R5cGVdID0gW107XG4gICAgICAgIH1cblxuICAgICAgICAvLyBPbmx5IGFkZCB0aGUgc2FtZSBmdW5jdGlvbiBvbmNlXG4gICAgICAgIGlmICgoMCwgX2hlbHBlcnNBcnJheUhlbHBlcnMuZmlsdGVyKSh0aGlzLmxpc3RlbmVyc1t0eXBlXSwgZnVuY3Rpb24gKGl0ZW0pIHtcbiAgICAgICAgICByZXR1cm4gaXRlbSA9PT0gbGlzdGVuZXI7XG4gICAgICAgIH0pLmxlbmd0aCA9PT0gMCkge1xuICAgICAgICAgIHRoaXMubGlzdGVuZXJzW3R5cGVdLnB1c2gobGlzdGVuZXIpO1xuICAgICAgICB9XG4gICAgICB9XG4gICAgfVxuXG4gICAgLypcbiAgICAqIFJlbW92ZXMgdGhlIGxpc3RlbmVyIHNvIGl0IHdpbGwgbm8gbG9uZ2VyIGJlIGludm9rZWQgdmlhIHRoZSBkaXNwYXRjaEV2ZW50IG1ldGhvZC5cbiAgICAqXG4gICAgKiBAcGFyYW0ge3N0cmluZ30gdHlwZSAtIHRoZSB0eXBlIG9mIGV2ZW50IChpZTogJ29wZW4nLCAnbWVzc2FnZScsIGV0Yy4pXG4gICAgKiBAcGFyYW0ge2Z1bmN0aW9ufSBsaXN0ZW5lciAtIHRoZSBjYWxsYmFjayBmdW5jdGlvbiB0byBpbnZva2Ugd2hlbmV2ZXIgYSBldmVudCBpcyBkaXNwYXRjaGVkIG1hdGNoaW5nIHRoZSBnaXZlbiB0eXBlXG4gICAgKiBAcGFyYW0ge2Jvb2xlYW59IHVzZUNhcHR1cmUgLSBOL0EgVE9ETzogaW1wbGVtZW50IHVzZUNhcHR1cmUgZnVuY3Rpb25hbGl0eVxuICAgICovXG4gIH0sIHtcbiAgICBrZXk6ICdyZW1vdmVFdmVudExpc3RlbmVyJyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gcmVtb3ZlRXZlbnRMaXN0ZW5lcih0eXBlLCByZW1vdmluZ0xpc3RlbmVyIC8qICwgdXNlQ2FwdHVyZSAqLykge1xuICAgICAgdmFyIGFycmF5T2ZMaXN0ZW5lcnMgPSB0aGlzLmxpc3RlbmVyc1t0eXBlXTtcbiAgICAgIHRoaXMubGlzdGVuZXJzW3R5cGVdID0gKDAsIF9oZWxwZXJzQXJyYXlIZWxwZXJzLnJlamVjdCkoYXJyYXlPZkxpc3RlbmVycywgZnVuY3Rpb24gKGxpc3RlbmVyKSB7XG4gICAgICAgIHJldHVybiBsaXN0ZW5lciA9PT0gcmVtb3ZpbmdMaXN0ZW5lcjtcbiAgICAgIH0pO1xuICAgIH1cblxuICAgIC8qXG4gICAgKiBJbnZva2VzIGFsbCBsaXN0ZW5lciBmdW5jdGlvbnMgdGhhdCBhcmUgbGlzdGVuaW5nIHRvIHRoZSBnaXZlbiBldmVudC50eXBlIHByb3BlcnR5LiBFYWNoXG4gICAgKiBsaXN0ZW5lciB3aWxsIGJlIHBhc3NlZCB0aGUgZXZlbnQgYXMgdGhlIGZpcnN0IGFyZ3VtZW50LlxuICAgICpcbiAgICAqIEBwYXJhbSB7b2JqZWN0fSBldmVudCAtIGV2ZW50IG9iamVjdCB3aGljaCB3aWxsIGJlIHBhc3NlZCB0byBhbGwgbGlzdGVuZXJzIG9mIHRoZSBldmVudC50eXBlIHByb3BlcnR5XG4gICAgKi9cbiAgfSwge1xuICAgIGtleTogJ2Rpc3BhdGNoRXZlbnQnLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBkaXNwYXRjaEV2ZW50KGV2ZW50KSB7XG4gICAgICB2YXIgX3RoaXMgPSB0aGlzO1xuXG4gICAgICBmb3IgKHZhciBfbGVuID0gYXJndW1lbnRzLmxlbmd0aCwgY3VzdG9tQXJndW1lbnRzID0gQXJyYXkoX2xlbiA+IDEgPyBfbGVuIC0gMSA6IDApLCBfa2V5ID0gMTsgX2tleSA8IF9sZW47IF9rZXkrKykge1xuICAgICAgICBjdXN0b21Bcmd1bWVudHNbX2tleSAtIDFdID0gYXJndW1lbnRzW19rZXldO1xuICAgICAgfVxuXG4gICAgICB2YXIgZXZlbnROYW1lID0gZXZlbnQudHlwZTtcbiAgICAgIHZhciBsaXN0ZW5lcnMgPSB0aGlzLmxpc3RlbmVyc1tldmVudE5hbWVdO1xuXG4gICAgICBpZiAoIUFycmF5LmlzQXJyYXkobGlzdGVuZXJzKSkge1xuICAgICAgICByZXR1cm4gZmFsc2U7XG4gICAgICB9XG5cbiAgICAgIGxpc3RlbmVycy5mb3JFYWNoKGZ1bmN0aW9uIChsaXN0ZW5lcikge1xuICAgICAgICBpZiAoY3VzdG9tQXJndW1lbnRzLmxlbmd0aCA+IDApIHtcbiAgICAgICAgICBsaXN0ZW5lci5hcHBseShfdGhpcywgY3VzdG9tQXJndW1lbnRzKTtcbiAgICAgICAgfSBlbHNlIHtcbiAgICAgICAgICBsaXN0ZW5lci5jYWxsKF90aGlzLCBldmVudCk7XG4gICAgICAgIH1cbiAgICAgIH0pO1xuICAgIH1cbiAgfV0pO1xuXG4gIHJldHVybiBFdmVudFRhcmdldDtcbn0pKCk7XG5cbmV4cG9ydHNbJ2RlZmF1bHQnXSA9IEV2ZW50VGFyZ2V0O1xubW9kdWxlLmV4cG9ydHMgPSBleHBvcnRzWydkZWZhdWx0J107IiwiXCJ1c2Ugc3RyaWN0XCI7XG5cbk9iamVjdC5kZWZpbmVQcm9wZXJ0eShleHBvcnRzLCBcIl9fZXNNb2R1bGVcIiwge1xuICB2YWx1ZTogdHJ1ZVxufSk7XG5leHBvcnRzLnJlamVjdCA9IHJlamVjdDtcbmV4cG9ydHMuZmlsdGVyID0gZmlsdGVyO1xuXG5mdW5jdGlvbiByZWplY3QoYXJyYXksIGNhbGxiYWNrKSB7XG4gIHZhciByZXN1bHRzID0gW107XG4gIGFycmF5LmZvckVhY2goZnVuY3Rpb24gKGl0ZW1JbkFycmF5KSB7XG4gICAgaWYgKCFjYWxsYmFjayhpdGVtSW5BcnJheSkpIHtcbiAgICAgIHJlc3VsdHMucHVzaChpdGVtSW5BcnJheSk7XG4gICAgfVxuICB9KTtcblxuICByZXR1cm4gcmVzdWx0cztcbn1cblxuZnVuY3Rpb24gZmlsdGVyKGFycmF5LCBjYWxsYmFjaykge1xuICB2YXIgcmVzdWx0cyA9IFtdO1xuICBhcnJheS5mb3JFYWNoKGZ1bmN0aW9uIChpdGVtSW5BcnJheSkge1xuICAgIGlmIChjYWxsYmFjayhpdGVtSW5BcnJheSkpIHtcbiAgICAgIHJlc3VsdHMucHVzaChpdGVtSW5BcnJheSk7XG4gICAgfVxuICB9KTtcblxuICByZXR1cm4gcmVzdWx0cztcbn0iLCIvKlxuKiBodHRwczovL2RldmVsb3Blci5tb3ppbGxhLm9yZy9lbi1VUy9kb2NzL1dlYi9BUEkvQ2xvc2VFdmVudFxuKi9cblwidXNlIHN0cmljdFwiO1xuXG5PYmplY3QuZGVmaW5lUHJvcGVydHkoZXhwb3J0cywgXCJfX2VzTW9kdWxlXCIsIHtcbiAgdmFsdWU6IHRydWVcbn0pO1xudmFyIGNvZGVzID0ge1xuICBDTE9TRV9OT1JNQUw6IDEwMDAsXG4gIENMT1NFX0dPSU5HX0FXQVk6IDEwMDEsXG4gIENMT1NFX1BST1RPQ09MX0VSUk9SOiAxMDAyLFxuICBDTE9TRV9VTlNVUFBPUlRFRDogMTAwMyxcbiAgQ0xPU0VfTk9fU1RBVFVTOiAxMDA1LFxuICBDTE9TRV9BQk5PUk1BTDogMTAwNixcbiAgQ0xPU0VfVE9PX0xBUkdFOiAxMDA5XG59O1xuXG5leHBvcnRzW1wiZGVmYXVsdFwiXSA9IGNvZGVzO1xubW9kdWxlLmV4cG9ydHMgPSBleHBvcnRzW1wiZGVmYXVsdFwiXTsiLCIndXNlIHN0cmljdCc7XG5cbk9iamVjdC5kZWZpbmVQcm9wZXJ0eShleHBvcnRzLCAnX19lc01vZHVsZScsIHtcbiAgdmFsdWU6IHRydWVcbn0pO1xuXG52YXIgX2dldCA9IGZ1bmN0aW9uIGdldChfeDIsIF94MywgX3g0KSB7IHZhciBfYWdhaW4gPSB0cnVlOyBfZnVuY3Rpb246IHdoaWxlIChfYWdhaW4pIHsgdmFyIG9iamVjdCA9IF94MiwgcHJvcGVydHkgPSBfeDMsIHJlY2VpdmVyID0gX3g0OyBfYWdhaW4gPSBmYWxzZTsgaWYgKG9iamVjdCA9PT0gbnVsbCkgb2JqZWN0ID0gRnVuY3Rpb24ucHJvdG90eXBlOyB2YXIgZGVzYyA9IE9iamVjdC5nZXRPd25Qcm9wZXJ0eURlc2NyaXB0b3Iob2JqZWN0LCBwcm9wZXJ0eSk7IGlmIChkZXNjID09PSB1bmRlZmluZWQpIHsgdmFyIHBhcmVudCA9IE9iamVjdC5nZXRQcm90b3R5cGVPZihvYmplY3QpOyBpZiAocGFyZW50ID09PSBudWxsKSB7IHJldHVybiB1bmRlZmluZWQ7IH0gZWxzZSB7IF94MiA9IHBhcmVudDsgX3gzID0gcHJvcGVydHk7IF94NCA9IHJlY2VpdmVyOyBfYWdhaW4gPSB0cnVlOyBkZXNjID0gcGFyZW50ID0gdW5kZWZpbmVkOyBjb250aW51ZSBfZnVuY3Rpb247IH0gfSBlbHNlIGlmICgndmFsdWUnIGluIGRlc2MpIHsgcmV0dXJuIGRlc2MudmFsdWU7IH0gZWxzZSB7IHZhciBnZXR0ZXIgPSBkZXNjLmdldDsgaWYgKGdldHRlciA9PT0gdW5kZWZpbmVkKSB7IHJldHVybiB1bmRlZmluZWQ7IH0gcmV0dXJuIGdldHRlci5jYWxsKHJlY2VpdmVyKTsgfSB9IH07XG5cbmZ1bmN0aW9uIF9pbnRlcm9wUmVxdWlyZURlZmF1bHQob2JqKSB7IHJldHVybiBvYmogJiYgb2JqLl9fZXNNb2R1bGUgPyBvYmogOiB7ICdkZWZhdWx0Jzogb2JqIH07IH1cblxuZnVuY3Rpb24gX2NsYXNzQ2FsbENoZWNrKGluc3RhbmNlLCBDb25zdHJ1Y3RvcikgeyBpZiAoIShpbnN0YW5jZSBpbnN0YW5jZW9mIENvbnN0cnVjdG9yKSkgeyB0aHJvdyBuZXcgVHlwZUVycm9yKCdDYW5ub3QgY2FsbCBhIGNsYXNzIGFzIGEgZnVuY3Rpb24nKTsgfSB9XG5cbmZ1bmN0aW9uIF9pbmhlcml0cyhzdWJDbGFzcywgc3VwZXJDbGFzcykgeyBpZiAodHlwZW9mIHN1cGVyQ2xhc3MgIT09ICdmdW5jdGlvbicgJiYgc3VwZXJDbGFzcyAhPT0gbnVsbCkgeyB0aHJvdyBuZXcgVHlwZUVycm9yKCdTdXBlciBleHByZXNzaW9uIG11c3QgZWl0aGVyIGJlIG51bGwgb3IgYSBmdW5jdGlvbiwgbm90ICcgKyB0eXBlb2Ygc3VwZXJDbGFzcyk7IH0gc3ViQ2xhc3MucHJvdG90eXBlID0gT2JqZWN0LmNyZWF0ZShzdXBlckNsYXNzICYmIHN1cGVyQ2xhc3MucHJvdG90eXBlLCB7IGNvbnN0cnVjdG9yOiB7IHZhbHVlOiBzdWJDbGFzcywgZW51bWVyYWJsZTogZmFsc2UsIHdyaXRhYmxlOiB0cnVlLCBjb25maWd1cmFibGU6IHRydWUgfSB9KTsgaWYgKHN1cGVyQ2xhc3MpIE9iamVjdC5zZXRQcm90b3R5cGVPZiA/IE9iamVjdC5zZXRQcm90b3R5cGVPZihzdWJDbGFzcywgc3VwZXJDbGFzcykgOiBzdWJDbGFzcy5fX3Byb3RvX18gPSBzdXBlckNsYXNzOyB9XG5cbnZhciBfZXZlbnRQcm90b3R5cGUgPSByZXF1aXJlKCcuL2V2ZW50LXByb3RvdHlwZScpO1xuXG52YXIgX2V2ZW50UHJvdG90eXBlMiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX2V2ZW50UHJvdG90eXBlKTtcblxudmFyIENsb3NlRXZlbnQgPSAoZnVuY3Rpb24gKF9FdmVudFByb3RvdHlwZSkge1xuICBfaW5oZXJpdHMoQ2xvc2VFdmVudCwgX0V2ZW50UHJvdG90eXBlKTtcblxuICBmdW5jdGlvbiBDbG9zZUV2ZW50KHR5cGUpIHtcbiAgICB2YXIgZXZlbnRJbml0Q29uZmlnID0gYXJndW1lbnRzLmxlbmd0aCA8PSAxIHx8IGFyZ3VtZW50c1sxXSA9PT0gdW5kZWZpbmVkID8ge30gOiBhcmd1bWVudHNbMV07XG5cbiAgICBfY2xhc3NDYWxsQ2hlY2sodGhpcywgQ2xvc2VFdmVudCk7XG5cbiAgICBfZ2V0KE9iamVjdC5nZXRQcm90b3R5cGVPZihDbG9zZUV2ZW50LnByb3RvdHlwZSksICdjb25zdHJ1Y3RvcicsIHRoaXMpLmNhbGwodGhpcyk7XG5cbiAgICBpZiAoIXR5cGUpIHtcbiAgICAgIHRocm93IG5ldyBUeXBlRXJyb3IoJ0ZhaWxlZCB0byBjb25zdHJ1Y3QgXFwnQ2xvc2VFdmVudFxcJzogMSBhcmd1bWVudCByZXF1aXJlZCwgYnV0IG9ubHkgMCBwcmVzZW50LicpO1xuICAgIH1cblxuICAgIGlmICh0eXBlb2YgZXZlbnRJbml0Q29uZmlnICE9PSAnb2JqZWN0Jykge1xuICAgICAgdGhyb3cgbmV3IFR5cGVFcnJvcignRmFpbGVkIHRvIGNvbnN0cnVjdCBcXCdDbG9zZUV2ZW50XFwnOiBwYXJhbWV0ZXIgMiAoXFwnZXZlbnRJbml0RGljdFxcJykgaXMgbm90IGFuIG9iamVjdCcpO1xuICAgIH1cblxuICAgIHZhciBidWJibGVzID0gZXZlbnRJbml0Q29uZmlnLmJ1YmJsZXM7XG4gICAgdmFyIGNhbmNlbGFibGUgPSBldmVudEluaXRDb25maWcuY2FuY2VsYWJsZTtcbiAgICB2YXIgY29kZSA9IGV2ZW50SW5pdENvbmZpZy5jb2RlO1xuICAgIHZhciByZWFzb24gPSBldmVudEluaXRDb25maWcucmVhc29uO1xuICAgIHZhciB3YXNDbGVhbiA9IGV2ZW50SW5pdENvbmZpZy53YXNDbGVhbjtcblxuICAgIHRoaXMudHlwZSA9IFN0cmluZyh0eXBlKTtcbiAgICB0aGlzLnRpbWVTdGFtcCA9IERhdGUubm93KCk7XG4gICAgdGhpcy50YXJnZXQgPSBudWxsO1xuICAgIHRoaXMuc3JjRWxlbWVudCA9IG51bGw7XG4gICAgdGhpcy5yZXR1cm5WYWx1ZSA9IHRydWU7XG4gICAgdGhpcy5pc1RydXN0ZWQgPSBmYWxzZTtcbiAgICB0aGlzLmV2ZW50UGhhc2UgPSAwO1xuICAgIHRoaXMuZGVmYXVsdFByZXZlbnRlZCA9IGZhbHNlO1xuICAgIHRoaXMuY3VycmVudFRhcmdldCA9IG51bGw7XG4gICAgdGhpcy5jYW5jZWxhYmxlID0gY2FuY2VsYWJsZSA/IEJvb2xlYW4oY2FuY2VsYWJsZSkgOiBmYWxzZTtcbiAgICB0aGlzLmNhbm5jZWxCdWJibGUgPSBmYWxzZTtcbiAgICB0aGlzLmJ1YmJsZXMgPSBidWJibGVzID8gQm9vbGVhbihidWJibGVzKSA6IGZhbHNlO1xuICAgIHRoaXMuY29kZSA9IHR5cGVvZiBjb2RlID09PSAnbnVtYmVyJyA/IE51bWJlcihjb2RlKSA6IDA7XG4gICAgdGhpcy5yZWFzb24gPSByZWFzb24gPyBTdHJpbmcocmVhc29uKSA6ICcnO1xuICAgIHRoaXMud2FzQ2xlYW4gPSB3YXNDbGVhbiA/IEJvb2xlYW4od2FzQ2xlYW4pIDogZmFsc2U7XG4gIH1cblxuICByZXR1cm4gQ2xvc2VFdmVudDtcbn0pKF9ldmVudFByb3RvdHlwZTJbJ2RlZmF1bHQnXSk7XG5cbmV4cG9ydHNbJ2RlZmF1bHQnXSA9IENsb3NlRXZlbnQ7XG5tb2R1bGUuZXhwb3J0cyA9IGV4cG9ydHNbJ2RlZmF1bHQnXTsiLCIvKlxuKiBUaGlzIGRlbGF5IGFsbG93cyB0aGUgdGhyZWFkIHRvIGZpbmlzaCBhc3NpZ25pbmcgaXRzIG9uKiBtZXRob2RzXG4qIGJlZm9yZSBpbnZva2luZyB0aGUgZGVsYXkgY2FsbGJhY2suIFRoaXMgaXMgcHVyZWx5IGEgdGltaW5nIGhhY2suXG4qIGh0dHA6Ly9nZWVrYWJ5dGUuYmxvZ3Nwb3QuY29tLzIwMTQvMDEvamF2YXNjcmlwdC1lZmZlY3Qtb2Ytc2V0dGluZy1zZXR0aW1lb3V0Lmh0bWxcbipcbiogQHBhcmFtIHtjYWxsYmFjazogZnVuY3Rpb259IHRoZSBjYWxsYmFjayB3aGljaCB3aWxsIGJlIGludm9rZWQgYWZ0ZXIgdGhlIHRpbWVvdXRcbiogQHBhcm1hIHtjb250ZXh0OiBvYmplY3R9IHRoZSBjb250ZXh0IGluIHdoaWNoIHRvIGludm9rZSB0aGUgZnVuY3Rpb25cbiovXG5cInVzZSBzdHJpY3RcIjtcblxuT2JqZWN0LmRlZmluZVByb3BlcnR5KGV4cG9ydHMsIFwiX19lc01vZHVsZVwiLCB7XG4gIHZhbHVlOiB0cnVlXG59KTtcbmZ1bmN0aW9uIGRlbGF5KGNhbGxiYWNrLCBjb250ZXh0KSB7XG4gIHNldFRpbWVvdXQoZnVuY3Rpb24gdGltZW91dCh0aW1lb3V0Q29udGV4dCkge1xuICAgIGNhbGxiYWNrLmNhbGwodGltZW91dENvbnRleHQpO1xuICB9LCA0LCBjb250ZXh0KTtcbn1cblxuZXhwb3J0c1tcImRlZmF1bHRcIl0gPSBkZWxheTtcbm1vZHVsZS5leHBvcnRzID0gZXhwb3J0c1tcImRlZmF1bHRcIl07IiwiJ3VzZSBzdHJpY3QnO1xuXG5PYmplY3QuZGVmaW5lUHJvcGVydHkoZXhwb3J0cywgJ19fZXNNb2R1bGUnLCB7XG4gIHZhbHVlOiB0cnVlXG59KTtcblxudmFyIF9jcmVhdGVDbGFzcyA9IChmdW5jdGlvbiAoKSB7IGZ1bmN0aW9uIGRlZmluZVByb3BlcnRpZXModGFyZ2V0LCBwcm9wcykgeyBmb3IgKHZhciBpID0gMDsgaSA8IHByb3BzLmxlbmd0aDsgaSsrKSB7IHZhciBkZXNjcmlwdG9yID0gcHJvcHNbaV07IGRlc2NyaXB0b3IuZW51bWVyYWJsZSA9IGRlc2NyaXB0b3IuZW51bWVyYWJsZSB8fCBmYWxzZTsgZGVzY3JpcHRvci5jb25maWd1cmFibGUgPSB0cnVlOyBpZiAoJ3ZhbHVlJyBpbiBkZXNjcmlwdG9yKSBkZXNjcmlwdG9yLndyaXRhYmxlID0gdHJ1ZTsgT2JqZWN0LmRlZmluZVByb3BlcnR5KHRhcmdldCwgZGVzY3JpcHRvci5rZXksIGRlc2NyaXB0b3IpOyB9IH0gcmV0dXJuIGZ1bmN0aW9uIChDb25zdHJ1Y3RvciwgcHJvdG9Qcm9wcywgc3RhdGljUHJvcHMpIHsgaWYgKHByb3RvUHJvcHMpIGRlZmluZVByb3BlcnRpZXMoQ29uc3RydWN0b3IucHJvdG90eXBlLCBwcm90b1Byb3BzKTsgaWYgKHN0YXRpY1Byb3BzKSBkZWZpbmVQcm9wZXJ0aWVzKENvbnN0cnVjdG9yLCBzdGF0aWNQcm9wcyk7IHJldHVybiBDb25zdHJ1Y3RvcjsgfTsgfSkoKTtcblxuZnVuY3Rpb24gX2NsYXNzQ2FsbENoZWNrKGluc3RhbmNlLCBDb25zdHJ1Y3RvcikgeyBpZiAoIShpbnN0YW5jZSBpbnN0YW5jZW9mIENvbnN0cnVjdG9yKSkgeyB0aHJvdyBuZXcgVHlwZUVycm9yKCdDYW5ub3QgY2FsbCBhIGNsYXNzIGFzIGEgZnVuY3Rpb24nKTsgfSB9XG5cbnZhciBFdmVudFByb3RvdHlwZSA9IChmdW5jdGlvbiAoKSB7XG4gIGZ1bmN0aW9uIEV2ZW50UHJvdG90eXBlKCkge1xuICAgIF9jbGFzc0NhbGxDaGVjayh0aGlzLCBFdmVudFByb3RvdHlwZSk7XG4gIH1cblxuICBfY3JlYXRlQ2xhc3MoRXZlbnRQcm90b3R5cGUsIFt7XG4gICAga2V5OiAnc3RvcFByb3BhZ2F0aW9uJyxcblxuICAgIC8vIE5vb3BzXG4gICAgdmFsdWU6IGZ1bmN0aW9uIHN0b3BQcm9wYWdhdGlvbigpIHt9XG4gIH0sIHtcbiAgICBrZXk6ICdzdG9wSW1tZWRpYXRlUHJvcGFnYXRpb24nLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBzdG9wSW1tZWRpYXRlUHJvcGFnYXRpb24oKSB7fVxuXG4gICAgLy8gaWYgbm8gYXJndW1lbnRzIGFyZSBwYXNzZWQgdGhlbiB0aGUgdHlwZSBpcyBzZXQgdG8gXCJ1bmRlZmluZWRcIiBvblxuICAgIC8vIGNocm9tZSBhbmQgc2FmYXJpLlxuICB9LCB7XG4gICAga2V5OiAnaW5pdEV2ZW50JyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gaW5pdEV2ZW50KCkge1xuICAgICAgdmFyIHR5cGUgPSBhcmd1bWVudHMubGVuZ3RoIDw9IDAgfHwgYXJndW1lbnRzWzBdID09PSB1bmRlZmluZWQgPyAndW5kZWZpbmVkJyA6IGFyZ3VtZW50c1swXTtcbiAgICAgIHZhciBidWJibGVzID0gYXJndW1lbnRzLmxlbmd0aCA8PSAxIHx8IGFyZ3VtZW50c1sxXSA9PT0gdW5kZWZpbmVkID8gZmFsc2UgOiBhcmd1bWVudHNbMV07XG4gICAgICB2YXIgY2FuY2VsYWJsZSA9IGFyZ3VtZW50cy5sZW5ndGggPD0gMiB8fCBhcmd1bWVudHNbMl0gPT09IHVuZGVmaW5lZCA/IGZhbHNlIDogYXJndW1lbnRzWzJdO1xuXG4gICAgICBPYmplY3QuYXNzaWduKHRoaXMsIHtcbiAgICAgICAgdHlwZTogU3RyaW5nKHR5cGUpLFxuICAgICAgICBidWJibGVzOiBCb29sZWFuKGJ1YmJsZXMpLFxuICAgICAgICBjYW5jZWxhYmxlOiBCb29sZWFuKGNhbmNlbGFibGUpXG4gICAgICB9KTtcbiAgICB9XG4gIH1dKTtcblxuICByZXR1cm4gRXZlbnRQcm90b3R5cGU7XG59KSgpO1xuXG5leHBvcnRzWydkZWZhdWx0J10gPSBFdmVudFByb3RvdHlwZTtcbm1vZHVsZS5leHBvcnRzID0gZXhwb3J0c1snZGVmYXVsdCddOyIsIid1c2Ugc3RyaWN0JztcblxuT2JqZWN0LmRlZmluZVByb3BlcnR5KGV4cG9ydHMsICdfX2VzTW9kdWxlJywge1xuICB2YWx1ZTogdHJ1ZVxufSk7XG5cbnZhciBfZ2V0ID0gZnVuY3Rpb24gZ2V0KF94MiwgX3gzLCBfeDQpIHsgdmFyIF9hZ2FpbiA9IHRydWU7IF9mdW5jdGlvbjogd2hpbGUgKF9hZ2FpbikgeyB2YXIgb2JqZWN0ID0gX3gyLCBwcm9wZXJ0eSA9IF94MywgcmVjZWl2ZXIgPSBfeDQ7IF9hZ2FpbiA9IGZhbHNlOyBpZiAob2JqZWN0ID09PSBudWxsKSBvYmplY3QgPSBGdW5jdGlvbi5wcm90b3R5cGU7IHZhciBkZXNjID0gT2JqZWN0LmdldE93blByb3BlcnR5RGVzY3JpcHRvcihvYmplY3QsIHByb3BlcnR5KTsgaWYgKGRlc2MgPT09IHVuZGVmaW5lZCkgeyB2YXIgcGFyZW50ID0gT2JqZWN0LmdldFByb3RvdHlwZU9mKG9iamVjdCk7IGlmIChwYXJlbnQgPT09IG51bGwpIHsgcmV0dXJuIHVuZGVmaW5lZDsgfSBlbHNlIHsgX3gyID0gcGFyZW50OyBfeDMgPSBwcm9wZXJ0eTsgX3g0ID0gcmVjZWl2ZXI7IF9hZ2FpbiA9IHRydWU7IGRlc2MgPSBwYXJlbnQgPSB1bmRlZmluZWQ7IGNvbnRpbnVlIF9mdW5jdGlvbjsgfSB9IGVsc2UgaWYgKCd2YWx1ZScgaW4gZGVzYykgeyByZXR1cm4gZGVzYy52YWx1ZTsgfSBlbHNlIHsgdmFyIGdldHRlciA9IGRlc2MuZ2V0OyBpZiAoZ2V0dGVyID09PSB1bmRlZmluZWQpIHsgcmV0dXJuIHVuZGVmaW5lZDsgfSByZXR1cm4gZ2V0dGVyLmNhbGwocmVjZWl2ZXIpOyB9IH0gfTtcblxuZnVuY3Rpb24gX2ludGVyb3BSZXF1aXJlRGVmYXVsdChvYmopIHsgcmV0dXJuIG9iaiAmJiBvYmouX19lc01vZHVsZSA/IG9iaiA6IHsgJ2RlZmF1bHQnOiBvYmogfTsgfVxuXG5mdW5jdGlvbiBfY2xhc3NDYWxsQ2hlY2soaW5zdGFuY2UsIENvbnN0cnVjdG9yKSB7IGlmICghKGluc3RhbmNlIGluc3RhbmNlb2YgQ29uc3RydWN0b3IpKSB7IHRocm93IG5ldyBUeXBlRXJyb3IoJ0Nhbm5vdCBjYWxsIGEgY2xhc3MgYXMgYSBmdW5jdGlvbicpOyB9IH1cblxuZnVuY3Rpb24gX2luaGVyaXRzKHN1YkNsYXNzLCBzdXBlckNsYXNzKSB7IGlmICh0eXBlb2Ygc3VwZXJDbGFzcyAhPT0gJ2Z1bmN0aW9uJyAmJiBzdXBlckNsYXNzICE9PSBudWxsKSB7IHRocm93IG5ldyBUeXBlRXJyb3IoJ1N1cGVyIGV4cHJlc3Npb24gbXVzdCBlaXRoZXIgYmUgbnVsbCBvciBhIGZ1bmN0aW9uLCBub3QgJyArIHR5cGVvZiBzdXBlckNsYXNzKTsgfSBzdWJDbGFzcy5wcm90b3R5cGUgPSBPYmplY3QuY3JlYXRlKHN1cGVyQ2xhc3MgJiYgc3VwZXJDbGFzcy5wcm90b3R5cGUsIHsgY29uc3RydWN0b3I6IHsgdmFsdWU6IHN1YkNsYXNzLCBlbnVtZXJhYmxlOiBmYWxzZSwgd3JpdGFibGU6IHRydWUsIGNvbmZpZ3VyYWJsZTogdHJ1ZSB9IH0pOyBpZiAoc3VwZXJDbGFzcykgT2JqZWN0LnNldFByb3RvdHlwZU9mID8gT2JqZWN0LnNldFByb3RvdHlwZU9mKHN1YkNsYXNzLCBzdXBlckNsYXNzKSA6IHN1YkNsYXNzLl9fcHJvdG9fXyA9IHN1cGVyQ2xhc3M7IH1cblxudmFyIF9ldmVudFByb3RvdHlwZSA9IHJlcXVpcmUoJy4vZXZlbnQtcHJvdG90eXBlJyk7XG5cbnZhciBfZXZlbnRQcm90b3R5cGUyID0gX2ludGVyb3BSZXF1aXJlRGVmYXVsdChfZXZlbnRQcm90b3R5cGUpO1xuXG52YXIgRXZlbnQgPSAoZnVuY3Rpb24gKF9FdmVudFByb3RvdHlwZSkge1xuICBfaW5oZXJpdHMoRXZlbnQsIF9FdmVudFByb3RvdHlwZSk7XG5cbiAgZnVuY3Rpb24gRXZlbnQodHlwZSkge1xuICAgIHZhciBldmVudEluaXRDb25maWcgPSBhcmd1bWVudHMubGVuZ3RoIDw9IDEgfHwgYXJndW1lbnRzWzFdID09PSB1bmRlZmluZWQgPyB7fSA6IGFyZ3VtZW50c1sxXTtcblxuICAgIF9jbGFzc0NhbGxDaGVjayh0aGlzLCBFdmVudCk7XG5cbiAgICBfZ2V0KE9iamVjdC5nZXRQcm90b3R5cGVPZihFdmVudC5wcm90b3R5cGUpLCAnY29uc3RydWN0b3InLCB0aGlzKS5jYWxsKHRoaXMpO1xuXG4gICAgaWYgKCF0eXBlKSB7XG4gICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKCdGYWlsZWQgdG8gY29uc3RydWN0IFxcJ0V2ZW50XFwnOiAxIGFyZ3VtZW50IHJlcXVpcmVkLCBidXQgb25seSAwIHByZXNlbnQuJyk7XG4gICAgfVxuXG4gICAgaWYgKHR5cGVvZiBldmVudEluaXRDb25maWcgIT09ICdvYmplY3QnKSB7XG4gICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKCdGYWlsZWQgdG8gY29uc3RydWN0IFxcJ0V2ZW50XFwnOiBwYXJhbWV0ZXIgMiAoXFwnZXZlbnRJbml0RGljdFxcJykgaXMgbm90IGFuIG9iamVjdCcpO1xuICAgIH1cblxuICAgIHZhciBidWJibGVzID0gZXZlbnRJbml0Q29uZmlnLmJ1YmJsZXM7XG4gICAgdmFyIGNhbmNlbGFibGUgPSBldmVudEluaXRDb25maWcuY2FuY2VsYWJsZTtcblxuICAgIHRoaXMudHlwZSA9IFN0cmluZyh0eXBlKTtcbiAgICB0aGlzLnRpbWVTdGFtcCA9IERhdGUubm93KCk7XG4gICAgdGhpcy50YXJnZXQgPSBudWxsO1xuICAgIHRoaXMuc3JjRWxlbWVudCA9IG51bGw7XG4gICAgdGhpcy5yZXR1cm5WYWx1ZSA9IHRydWU7XG4gICAgdGhpcy5pc1RydXN0ZWQgPSBmYWxzZTtcbiAgICB0aGlzLmV2ZW50UGhhc2UgPSAwO1xuICAgIHRoaXMuZGVmYXVsdFByZXZlbnRlZCA9IGZhbHNlO1xuICAgIHRoaXMuY3VycmVudFRhcmdldCA9IG51bGw7XG4gICAgdGhpcy5jYW5jZWxhYmxlID0gY2FuY2VsYWJsZSA/IEJvb2xlYW4oY2FuY2VsYWJsZSkgOiBmYWxzZTtcbiAgICB0aGlzLmNhbm5jZWxCdWJibGUgPSBmYWxzZTtcbiAgICB0aGlzLmJ1YmJsZXMgPSBidWJibGVzID8gQm9vbGVhbihidWJibGVzKSA6IGZhbHNlO1xuICB9XG5cbiAgcmV0dXJuIEV2ZW50O1xufSkoX2V2ZW50UHJvdG90eXBlMlsnZGVmYXVsdCddKTtcblxuZXhwb3J0c1snZGVmYXVsdCddID0gRXZlbnQ7XG5tb2R1bGUuZXhwb3J0cyA9IGV4cG9ydHNbJ2RlZmF1bHQnXTsiLCIndXNlIHN0cmljdCc7XG5cbk9iamVjdC5kZWZpbmVQcm9wZXJ0eShleHBvcnRzLCAnX19lc01vZHVsZScsIHtcbiAgdmFsdWU6IHRydWVcbn0pO1xuXG52YXIgX2dldCA9IGZ1bmN0aW9uIGdldChfeDIsIF94MywgX3g0KSB7IHZhciBfYWdhaW4gPSB0cnVlOyBfZnVuY3Rpb246IHdoaWxlIChfYWdhaW4pIHsgdmFyIG9iamVjdCA9IF94MiwgcHJvcGVydHkgPSBfeDMsIHJlY2VpdmVyID0gX3g0OyBfYWdhaW4gPSBmYWxzZTsgaWYgKG9iamVjdCA9PT0gbnVsbCkgb2JqZWN0ID0gRnVuY3Rpb24ucHJvdG90eXBlOyB2YXIgZGVzYyA9IE9iamVjdC5nZXRPd25Qcm9wZXJ0eURlc2NyaXB0b3Iob2JqZWN0LCBwcm9wZXJ0eSk7IGlmIChkZXNjID09PSB1bmRlZmluZWQpIHsgdmFyIHBhcmVudCA9IE9iamVjdC5nZXRQcm90b3R5cGVPZihvYmplY3QpOyBpZiAocGFyZW50ID09PSBudWxsKSB7IHJldHVybiB1bmRlZmluZWQ7IH0gZWxzZSB7IF94MiA9IHBhcmVudDsgX3gzID0gcHJvcGVydHk7IF94NCA9IHJlY2VpdmVyOyBfYWdhaW4gPSB0cnVlOyBkZXNjID0gcGFyZW50ID0gdW5kZWZpbmVkOyBjb250aW51ZSBfZnVuY3Rpb247IH0gfSBlbHNlIGlmICgndmFsdWUnIGluIGRlc2MpIHsgcmV0dXJuIGRlc2MudmFsdWU7IH0gZWxzZSB7IHZhciBnZXR0ZXIgPSBkZXNjLmdldDsgaWYgKGdldHRlciA9PT0gdW5kZWZpbmVkKSB7IHJldHVybiB1bmRlZmluZWQ7IH0gcmV0dXJuIGdldHRlci5jYWxsKHJlY2VpdmVyKTsgfSB9IH07XG5cbmZ1bmN0aW9uIF9pbnRlcm9wUmVxdWlyZURlZmF1bHQob2JqKSB7IHJldHVybiBvYmogJiYgb2JqLl9fZXNNb2R1bGUgPyBvYmogOiB7ICdkZWZhdWx0Jzogb2JqIH07IH1cblxuZnVuY3Rpb24gX2NsYXNzQ2FsbENoZWNrKGluc3RhbmNlLCBDb25zdHJ1Y3RvcikgeyBpZiAoIShpbnN0YW5jZSBpbnN0YW5jZW9mIENvbnN0cnVjdG9yKSkgeyB0aHJvdyBuZXcgVHlwZUVycm9yKCdDYW5ub3QgY2FsbCBhIGNsYXNzIGFzIGEgZnVuY3Rpb24nKTsgfSB9XG5cbmZ1bmN0aW9uIF9pbmhlcml0cyhzdWJDbGFzcywgc3VwZXJDbGFzcykgeyBpZiAodHlwZW9mIHN1cGVyQ2xhc3MgIT09ICdmdW5jdGlvbicgJiYgc3VwZXJDbGFzcyAhPT0gbnVsbCkgeyB0aHJvdyBuZXcgVHlwZUVycm9yKCdTdXBlciBleHByZXNzaW9uIG11c3QgZWl0aGVyIGJlIG51bGwgb3IgYSBmdW5jdGlvbiwgbm90ICcgKyB0eXBlb2Ygc3VwZXJDbGFzcyk7IH0gc3ViQ2xhc3MucHJvdG90eXBlID0gT2JqZWN0LmNyZWF0ZShzdXBlckNsYXNzICYmIHN1cGVyQ2xhc3MucHJvdG90eXBlLCB7IGNvbnN0cnVjdG9yOiB7IHZhbHVlOiBzdWJDbGFzcywgZW51bWVyYWJsZTogZmFsc2UsIHdyaXRhYmxlOiB0cnVlLCBjb25maWd1cmFibGU6IHRydWUgfSB9KTsgaWYgKHN1cGVyQ2xhc3MpIE9iamVjdC5zZXRQcm90b3R5cGVPZiA/IE9iamVjdC5zZXRQcm90b3R5cGVPZihzdWJDbGFzcywgc3VwZXJDbGFzcykgOiBzdWJDbGFzcy5fX3Byb3RvX18gPSBzdXBlckNsYXNzOyB9XG5cbnZhciBfZXZlbnRQcm90b3R5cGUgPSByZXF1aXJlKCcuL2V2ZW50LXByb3RvdHlwZScpO1xuXG52YXIgX2V2ZW50UHJvdG90eXBlMiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX2V2ZW50UHJvdG90eXBlKTtcblxudmFyIE1lc3NhZ2VFdmVudCA9IChmdW5jdGlvbiAoX0V2ZW50UHJvdG90eXBlKSB7XG4gIF9pbmhlcml0cyhNZXNzYWdlRXZlbnQsIF9FdmVudFByb3RvdHlwZSk7XG5cbiAgZnVuY3Rpb24gTWVzc2FnZUV2ZW50KHR5cGUpIHtcbiAgICB2YXIgZXZlbnRJbml0Q29uZmlnID0gYXJndW1lbnRzLmxlbmd0aCA8PSAxIHx8IGFyZ3VtZW50c1sxXSA9PT0gdW5kZWZpbmVkID8ge30gOiBhcmd1bWVudHNbMV07XG5cbiAgICBfY2xhc3NDYWxsQ2hlY2sodGhpcywgTWVzc2FnZUV2ZW50KTtcblxuICAgIF9nZXQoT2JqZWN0LmdldFByb3RvdHlwZU9mKE1lc3NhZ2VFdmVudC5wcm90b3R5cGUpLCAnY29uc3RydWN0b3InLCB0aGlzKS5jYWxsKHRoaXMpO1xuXG4gICAgaWYgKCF0eXBlKSB7XG4gICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKCdGYWlsZWQgdG8gY29uc3RydWN0IFxcJ01lc3NhZ2VFdmVudFxcJzogMSBhcmd1bWVudCByZXF1aXJlZCwgYnV0IG9ubHkgMCBwcmVzZW50LicpO1xuICAgIH1cblxuICAgIGlmICh0eXBlb2YgZXZlbnRJbml0Q29uZmlnICE9PSAnb2JqZWN0Jykge1xuICAgICAgdGhyb3cgbmV3IFR5cGVFcnJvcignRmFpbGVkIHRvIGNvbnN0cnVjdCBcXCdNZXNzYWdlRXZlbnRcXCc6IHBhcmFtZXRlciAyIChcXCdldmVudEluaXREaWN0XFwnKSBpcyBub3QgYW4gb2JqZWN0Jyk7XG4gICAgfVxuXG4gICAgdmFyIGJ1YmJsZXMgPSBldmVudEluaXRDb25maWcuYnViYmxlcztcbiAgICB2YXIgY2FuY2VsYWJsZSA9IGV2ZW50SW5pdENvbmZpZy5jYW5jZWxhYmxlO1xuICAgIHZhciBkYXRhID0gZXZlbnRJbml0Q29uZmlnLmRhdGE7XG4gICAgdmFyIG9yaWdpbiA9IGV2ZW50SW5pdENvbmZpZy5vcmlnaW47XG4gICAgdmFyIGxhc3RFdmVudElkID0gZXZlbnRJbml0Q29uZmlnLmxhc3RFdmVudElkO1xuICAgIHZhciBwb3J0cyA9IGV2ZW50SW5pdENvbmZpZy5wb3J0cztcblxuICAgIHRoaXMudHlwZSA9IFN0cmluZyh0eXBlKTtcbiAgICB0aGlzLnRpbWVTdGFtcCA9IERhdGUubm93KCk7XG4gICAgdGhpcy50YXJnZXQgPSBudWxsO1xuICAgIHRoaXMuc3JjRWxlbWVudCA9IG51bGw7XG4gICAgdGhpcy5yZXR1cm5WYWx1ZSA9IHRydWU7XG4gICAgdGhpcy5pc1RydXN0ZWQgPSBmYWxzZTtcbiAgICB0aGlzLmV2ZW50UGhhc2UgPSAwO1xuICAgIHRoaXMuZGVmYXVsdFByZXZlbnRlZCA9IGZhbHNlO1xuICAgIHRoaXMuY3VycmVudFRhcmdldCA9IG51bGw7XG4gICAgdGhpcy5jYW5jZWxhYmxlID0gY2FuY2VsYWJsZSA/IEJvb2xlYW4oY2FuY2VsYWJsZSkgOiBmYWxzZTtcbiAgICB0aGlzLmNhbm5jZWxCdWJibGUgPSBmYWxzZTtcbiAgICB0aGlzLmJ1YmJsZXMgPSBidWJibGVzID8gQm9vbGVhbihidWJibGVzKSA6IGZhbHNlO1xuICAgIHRoaXMub3JpZ2luID0gb3JpZ2luID8gU3RyaW5nKG9yaWdpbikgOiAnJztcbiAgICB0aGlzLnBvcnRzID0gdHlwZW9mIHBvcnRzID09PSAndW5kZWZpbmVkJyA/IG51bGwgOiBwb3J0cztcbiAgICB0aGlzLmRhdGEgPSB0eXBlb2YgZGF0YSA9PT0gJ3VuZGVmaW5lZCcgPyBudWxsIDogZGF0YTtcbiAgICB0aGlzLmxhc3RFdmVudElkID0gbGFzdEV2ZW50SWQgPyBTdHJpbmcobGFzdEV2ZW50SWQpIDogJyc7XG4gIH1cblxuICByZXR1cm4gTWVzc2FnZUV2ZW50O1xufSkoX2V2ZW50UHJvdG90eXBlMlsnZGVmYXVsdCddKTtcblxuZXhwb3J0c1snZGVmYXVsdCddID0gTWVzc2FnZUV2ZW50O1xubW9kdWxlLmV4cG9ydHMgPSBleHBvcnRzWydkZWZhdWx0J107IiwiJ3VzZSBzdHJpY3QnO1xuXG5PYmplY3QuZGVmaW5lUHJvcGVydHkoZXhwb3J0cywgJ19fZXNNb2R1bGUnLCB7XG4gIHZhbHVlOiB0cnVlXG59KTtcblxuZnVuY3Rpb24gX2ludGVyb3BSZXF1aXJlRGVmYXVsdChvYmopIHsgcmV0dXJuIG9iaiAmJiBvYmouX19lc01vZHVsZSA/IG9iaiA6IHsgJ2RlZmF1bHQnOiBvYmogfTsgfVxuXG52YXIgX3NlcnZlciA9IHJlcXVpcmUoJy4vc2VydmVyJyk7XG5cbnZhciBfc2VydmVyMiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX3NlcnZlcik7XG5cbnZhciBfc29ja2V0SW8gPSByZXF1aXJlKCcuL3NvY2tldC1pbycpO1xuXG52YXIgX3NvY2tldElvMiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX3NvY2tldElvKTtcblxudmFyIF93ZWJzb2NrZXQgPSByZXF1aXJlKCcuL3dlYnNvY2tldCcpO1xuXG52YXIgX3dlYnNvY2tldDIgPSBfaW50ZXJvcFJlcXVpcmVEZWZhdWx0KF93ZWJzb2NrZXQpO1xuXG5pZiAodHlwZW9mIHdpbmRvdyAhPT0gJ3VuZGVmaW5lZCcpIHtcbiAgd2luZG93Lk1vY2tTZXJ2ZXIgPSBfc2VydmVyMlsnZGVmYXVsdCddO1xuICB3aW5kb3cuTW9ja1dlYlNvY2tldCA9IF93ZWJzb2NrZXQyWydkZWZhdWx0J107XG4gIHdpbmRvdy5Nb2NrU29ja2V0SU8gPSBfc29ja2V0SW8yWydkZWZhdWx0J107XG59XG5cbnZhciBTZXJ2ZXIgPSBfc2VydmVyMlsnZGVmYXVsdCddO1xuZXhwb3J0cy5TZXJ2ZXIgPSBTZXJ2ZXI7XG52YXIgV2ViU29ja2V0ID0gX3dlYnNvY2tldDJbJ2RlZmF1bHQnXTtcbmV4cG9ydHMuV2ViU29ja2V0ID0gV2ViU29ja2V0O1xudmFyIFNvY2tldElPID0gX3NvY2tldElvMlsnZGVmYXVsdCddO1xuZXhwb3J0cy5Tb2NrZXRJTyA9IFNvY2tldElPOyIsIid1c2Ugc3RyaWN0JztcblxuT2JqZWN0LmRlZmluZVByb3BlcnR5KGV4cG9ydHMsICdfX2VzTW9kdWxlJywge1xuICB2YWx1ZTogdHJ1ZVxufSk7XG5cbnZhciBfY3JlYXRlQ2xhc3MgPSAoZnVuY3Rpb24gKCkgeyBmdW5jdGlvbiBkZWZpbmVQcm9wZXJ0aWVzKHRhcmdldCwgcHJvcHMpIHsgZm9yICh2YXIgaSA9IDA7IGkgPCBwcm9wcy5sZW5ndGg7IGkrKykgeyB2YXIgZGVzY3JpcHRvciA9IHByb3BzW2ldOyBkZXNjcmlwdG9yLmVudW1lcmFibGUgPSBkZXNjcmlwdG9yLmVudW1lcmFibGUgfHwgZmFsc2U7IGRlc2NyaXB0b3IuY29uZmlndXJhYmxlID0gdHJ1ZTsgaWYgKCd2YWx1ZScgaW4gZGVzY3JpcHRvcikgZGVzY3JpcHRvci53cml0YWJsZSA9IHRydWU7IE9iamVjdC5kZWZpbmVQcm9wZXJ0eSh0YXJnZXQsIGRlc2NyaXB0b3Iua2V5LCBkZXNjcmlwdG9yKTsgfSB9IHJldHVybiBmdW5jdGlvbiAoQ29uc3RydWN0b3IsIHByb3RvUHJvcHMsIHN0YXRpY1Byb3BzKSB7IGlmIChwcm90b1Byb3BzKSBkZWZpbmVQcm9wZXJ0aWVzKENvbnN0cnVjdG9yLnByb3RvdHlwZSwgcHJvdG9Qcm9wcyk7IGlmIChzdGF0aWNQcm9wcykgZGVmaW5lUHJvcGVydGllcyhDb25zdHJ1Y3Rvciwgc3RhdGljUHJvcHMpOyByZXR1cm4gQ29uc3RydWN0b3I7IH07IH0pKCk7XG5cbmZ1bmN0aW9uIF9jbGFzc0NhbGxDaGVjayhpbnN0YW5jZSwgQ29uc3RydWN0b3IpIHsgaWYgKCEoaW5zdGFuY2UgaW5zdGFuY2VvZiBDb25zdHJ1Y3RvcikpIHsgdGhyb3cgbmV3IFR5cGVFcnJvcignQ2Fubm90IGNhbGwgYSBjbGFzcyBhcyBhIGZ1bmN0aW9uJyk7IH0gfVxuXG52YXIgX2hlbHBlcnNBcnJheUhlbHBlcnMgPSByZXF1aXJlKCcuL2hlbHBlcnMvYXJyYXktaGVscGVycycpO1xuXG4vKlxuKiBUaGUgbmV0d29yayBicmlkZ2UgaXMgYSB3YXkgZm9yIHRoZSBtb2NrIHdlYnNvY2tldCBvYmplY3QgdG8gJ2NvbW11bmljYXRlJyB3aXRoXG4qIGFsbCBhdmFsaWJsZSBzZXJ2ZXJzLiBUaGlzIGlzIGEgc2luZ2xldG9uIG9iamVjdCBzbyBpdCBpcyBpbXBvcnRhbnQgdGhhdCB5b3VcbiogY2xlYW4gdXAgdXJsTWFwIHdoZW5ldmVyIHlvdSBhcmUgZmluaXNoZWQuXG4qL1xuXG52YXIgTmV0d29ya0JyaWRnZSA9IChmdW5jdGlvbiAoKSB7XG4gIGZ1bmN0aW9uIE5ldHdvcmtCcmlkZ2UoKSB7XG4gICAgX2NsYXNzQ2FsbENoZWNrKHRoaXMsIE5ldHdvcmtCcmlkZ2UpO1xuXG4gICAgdGhpcy51cmxNYXAgPSB7fTtcbiAgfVxuXG4gIC8qXG4gICogQXR0YWNoZXMgYSB3ZWJzb2NrZXQgb2JqZWN0IHRvIHRoZSB1cmxNYXAgaGFzaCBzbyB0aGF0IGl0IGNhbiBmaW5kIHRoZSBzZXJ2ZXJcbiAgKiBpdCBpcyBjb25uZWN0ZWQgdG8gYW5kIHRoZSBzZXJ2ZXIgaW4gdHVybiBjYW4gZmluZCBpdC5cbiAgKlxuICAqIEBwYXJhbSB7b2JqZWN0fSB3ZWJzb2NrZXQgLSB3ZWJzb2NrZXQgb2JqZWN0IHRvIGFkZCB0byB0aGUgdXJsTWFwIGhhc2hcbiAgKiBAcGFyYW0ge3N0cmluZ30gdXJsXG4gICovXG5cbiAgX2NyZWF0ZUNsYXNzKE5ldHdvcmtCcmlkZ2UsIFt7XG4gICAga2V5OiAnYXR0YWNoV2ViU29ja2V0JyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gYXR0YWNoV2ViU29ja2V0KHdlYnNvY2tldCwgdXJsKSB7XG4gICAgICB2YXIgY29ubmVjdGlvbkxvb2t1cCA9IHRoaXMudXJsTWFwW3VybF07XG5cbiAgICAgIGlmIChjb25uZWN0aW9uTG9va3VwICYmIGNvbm5lY3Rpb25Mb29rdXAuc2VydmVyICYmIGNvbm5lY3Rpb25Mb29rdXAud2Vic29ja2V0cy5pbmRleE9mKHdlYnNvY2tldCkgPT09IC0xKSB7XG4gICAgICAgIGNvbm5lY3Rpb25Mb29rdXAud2Vic29ja2V0cy5wdXNoKHdlYnNvY2tldCk7XG4gICAgICAgIHJldHVybiBjb25uZWN0aW9uTG9va3VwLnNlcnZlcjtcbiAgICAgIH1cbiAgICB9XG5cbiAgICAvKlxuICAgICogQXR0YWNoZXMgYSB3ZWJzb2NrZXQgdG8gYSByb29tXG4gICAgKi9cbiAgfSwge1xuICAgIGtleTogJ2FkZE1lbWJlcnNoaXBUb1Jvb20nLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBhZGRNZW1iZXJzaGlwVG9Sb29tKHdlYnNvY2tldCwgcm9vbSkge1xuICAgICAgdmFyIGNvbm5lY3Rpb25Mb29rdXAgPSB0aGlzLnVybE1hcFt3ZWJzb2NrZXQudXJsXTtcblxuICAgICAgaWYgKGNvbm5lY3Rpb25Mb29rdXAgJiYgY29ubmVjdGlvbkxvb2t1cC5zZXJ2ZXIgJiYgY29ubmVjdGlvbkxvb2t1cC53ZWJzb2NrZXRzLmluZGV4T2Yod2Vic29ja2V0KSAhPT0gLTEpIHtcbiAgICAgICAgaWYgKCFjb25uZWN0aW9uTG9va3VwLnJvb21NZW1iZXJzaGlwc1tyb29tXSkge1xuICAgICAgICAgIGNvbm5lY3Rpb25Mb29rdXAucm9vbU1lbWJlcnNoaXBzW3Jvb21dID0gW107XG4gICAgICAgIH1cblxuICAgICAgICBjb25uZWN0aW9uTG9va3VwLnJvb21NZW1iZXJzaGlwc1tyb29tXS5wdXNoKHdlYnNvY2tldCk7XG4gICAgICB9XG4gICAgfVxuXG4gICAgLypcbiAgICAqIEF0dGFjaGVzIGEgc2VydmVyIG9iamVjdCB0byB0aGUgdXJsTWFwIGhhc2ggc28gdGhhdCBpdCBjYW4gZmluZCBhIHdlYnNvY2tldHNcbiAgICAqIHdoaWNoIGFyZSBjb25uZWN0ZWQgdG8gaXQgYW5kIHNvIHRoYXQgd2Vic29ja2V0cyBjYW4gaW4gdHVybiBjYW4gZmluZCBpdC5cbiAgICAqXG4gICAgKiBAcGFyYW0ge29iamVjdH0gc2VydmVyIC0gc2VydmVyIG9iamVjdCB0byBhZGQgdG8gdGhlIHVybE1hcCBoYXNoXG4gICAgKiBAcGFyYW0ge3N0cmluZ30gdXJsXG4gICAgKi9cbiAgfSwge1xuICAgIGtleTogJ2F0dGFjaFNlcnZlcicsXG4gICAgdmFsdWU6IGZ1bmN0aW9uIGF0dGFjaFNlcnZlcihzZXJ2ZXIsIHVybCkge1xuICAgICAgdmFyIGNvbm5lY3Rpb25Mb29rdXAgPSB0aGlzLnVybE1hcFt1cmxdO1xuXG4gICAgICBpZiAoIWNvbm5lY3Rpb25Mb29rdXApIHtcbiAgICAgICAgdGhpcy51cmxNYXBbdXJsXSA9IHtcbiAgICAgICAgICBzZXJ2ZXI6IHNlcnZlcixcbiAgICAgICAgICB3ZWJzb2NrZXRzOiBbXSxcbiAgICAgICAgICByb29tTWVtYmVyc2hpcHM6IHt9XG4gICAgICAgIH07XG5cbiAgICAgICAgcmV0dXJuIHNlcnZlcjtcbiAgICAgIH1cbiAgICB9XG5cbiAgICAvKlxuICAgICogRmluZHMgdGhlIHNlcnZlciB3aGljaCBpcyAncnVubmluZycgb24gdGhlIGdpdmVuIHVybC5cbiAgICAqXG4gICAgKiBAcGFyYW0ge3N0cmluZ30gdXJsIC0gdGhlIHVybCB0byB1c2UgdG8gZmluZCB3aGljaCBzZXJ2ZXIgaXMgcnVubmluZyBvbiBpdFxuICAgICovXG4gIH0sIHtcbiAgICBrZXk6ICdzZXJ2ZXJMb29rdXAnLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBzZXJ2ZXJMb29rdXAodXJsKSB7XG4gICAgICB2YXIgY29ubmVjdGlvbkxvb2t1cCA9IHRoaXMudXJsTWFwW3VybF07XG5cbiAgICAgIGlmIChjb25uZWN0aW9uTG9va3VwKSB7XG4gICAgICAgIHJldHVybiBjb25uZWN0aW9uTG9va3VwLnNlcnZlcjtcbiAgICAgIH1cbiAgICB9XG5cbiAgICAvKlxuICAgICogRmluZHMgYWxsIHdlYnNvY2tldHMgd2hpY2ggaXMgJ2xpc3RlbmluZycgb24gdGhlIGdpdmVuIHVybC5cbiAgICAqXG4gICAgKiBAcGFyYW0ge3N0cmluZ30gdXJsIC0gdGhlIHVybCB0byB1c2UgdG8gZmluZCBhbGwgd2Vic29ja2V0cyB3aGljaCBhcmUgYXNzb2NpYXRlZCB3aXRoIGl0XG4gICAgKiBAcGFyYW0ge3N0cmluZ30gcm9vbSAtIGlmIGEgcm9vbSBpcyBwcm92aWRlZCwgd2lsbCBvbmx5IHJldHVybiBzb2NrZXRzIGluIHRoaXMgcm9vbVxuICAgICovXG4gIH0sIHtcbiAgICBrZXk6ICd3ZWJzb2NrZXRzTG9va3VwJyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gd2Vic29ja2V0c0xvb2t1cCh1cmwsIHJvb20pIHtcbiAgICAgIHZhciBjb25uZWN0aW9uTG9va3VwID0gdGhpcy51cmxNYXBbdXJsXTtcblxuICAgICAgaWYgKCFjb25uZWN0aW9uTG9va3VwKSB7XG4gICAgICAgIHJldHVybiBbXTtcbiAgICAgIH1cblxuICAgICAgaWYgKHJvb20pIHtcbiAgICAgICAgdmFyIG1lbWJlcnMgPSBjb25uZWN0aW9uTG9va3VwLnJvb21NZW1iZXJzaGlwc1tyb29tXTtcbiAgICAgICAgcmV0dXJuIG1lbWJlcnMgPyBtZW1iZXJzIDogW107XG4gICAgICB9XG5cbiAgICAgIHJldHVybiBjb25uZWN0aW9uTG9va3VwLndlYnNvY2tldHM7XG4gICAgfVxuXG4gICAgLypcbiAgICAqIFJlbW92ZXMgdGhlIGVudHJ5IGFzc29jaWF0ZWQgd2l0aCB0aGUgdXJsLlxuICAgICpcbiAgICAqIEBwYXJhbSB7c3RyaW5nfSB1cmxcbiAgICAqL1xuICB9LCB7XG4gICAga2V5OiAncmVtb3ZlU2VydmVyJyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gcmVtb3ZlU2VydmVyKHVybCkge1xuICAgICAgZGVsZXRlIHRoaXMudXJsTWFwW3VybF07XG4gICAgfVxuXG4gICAgLypcbiAgICAqIFJlbW92ZXMgdGhlIGluZGl2aWR1YWwgd2Vic29ja2V0IGZyb20gdGhlIG1hcCBvZiBhc3NvY2lhdGVkIHdlYnNvY2tldHMuXG4gICAgKlxuICAgICogQHBhcmFtIHtvYmplY3R9IHdlYnNvY2tldCAtIHdlYnNvY2tldCBvYmplY3QgdG8gcmVtb3ZlIGZyb20gdGhlIHVybCBtYXBcbiAgICAqIEBwYXJhbSB7c3RyaW5nfSB1cmxcbiAgICAqL1xuICB9LCB7XG4gICAga2V5OiAncmVtb3ZlV2ViU29ja2V0JyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gcmVtb3ZlV2ViU29ja2V0KHdlYnNvY2tldCwgdXJsKSB7XG4gICAgICB2YXIgY29ubmVjdGlvbkxvb2t1cCA9IHRoaXMudXJsTWFwW3VybF07XG5cbiAgICAgIGlmIChjb25uZWN0aW9uTG9va3VwKSB7XG4gICAgICAgIGNvbm5lY3Rpb25Mb29rdXAud2Vic29ja2V0cyA9ICgwLCBfaGVscGVyc0FycmF5SGVscGVycy5yZWplY3QpKGNvbm5lY3Rpb25Mb29rdXAud2Vic29ja2V0cywgZnVuY3Rpb24gKHNvY2tldCkge1xuICAgICAgICAgIHJldHVybiBzb2NrZXQgPT09IHdlYnNvY2tldDtcbiAgICAgICAgfSk7XG4gICAgICB9XG4gICAgfVxuXG4gICAgLypcbiAgICAqIFJlbW92ZXMgYSB3ZWJzb2NrZXQgZnJvbSBhIHJvb21cbiAgICAqL1xuICB9LCB7XG4gICAga2V5OiAncmVtb3ZlTWVtYmVyc2hpcEZyb21Sb29tJyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gcmVtb3ZlTWVtYmVyc2hpcEZyb21Sb29tKHdlYnNvY2tldCwgcm9vbSkge1xuICAgICAgdmFyIGNvbm5lY3Rpb25Mb29rdXAgPSB0aGlzLnVybE1hcFt3ZWJzb2NrZXQudXJsXTtcbiAgICAgIHZhciBtZW1iZXJzaGlwcyA9IGNvbm5lY3Rpb25Mb29rdXAucm9vbU1lbWJlcnNoaXBzW3Jvb21dO1xuXG4gICAgICBpZiAoY29ubmVjdGlvbkxvb2t1cCAmJiBtZW1iZXJzaGlwcyAhPT0gbnVsbCkge1xuICAgICAgICBjb25uZWN0aW9uTG9va3VwLnJvb21NZW1iZXJzaGlwc1tyb29tXSA9ICgwLCBfaGVscGVyc0FycmF5SGVscGVycy5yZWplY3QpKG1lbWJlcnNoaXBzLCBmdW5jdGlvbiAoc29ja2V0KSB7XG4gICAgICAgICAgcmV0dXJuIHNvY2tldCA9PT0gd2Vic29ja2V0O1xuICAgICAgICB9KTtcbiAgICAgIH1cbiAgICB9XG4gIH1dKTtcblxuICByZXR1cm4gTmV0d29ya0JyaWRnZTtcbn0pKCk7XG5cbmV4cG9ydHNbJ2RlZmF1bHQnXSA9IG5ldyBOZXR3b3JrQnJpZGdlKCk7XG4vLyBOb3RlOiB0aGlzIGlzIGEgc2luZ2xldG9uXG5tb2R1bGUuZXhwb3J0cyA9IGV4cG9ydHNbJ2RlZmF1bHQnXTsiLCIndXNlIHN0cmljdCc7XG5cbk9iamVjdC5kZWZpbmVQcm9wZXJ0eShleHBvcnRzLCAnX19lc01vZHVsZScsIHtcbiAgdmFsdWU6IHRydWVcbn0pO1xuXG52YXIgX2NyZWF0ZUNsYXNzID0gKGZ1bmN0aW9uICgpIHsgZnVuY3Rpb24gZGVmaW5lUHJvcGVydGllcyh0YXJnZXQsIHByb3BzKSB7IGZvciAodmFyIGkgPSAwOyBpIDwgcHJvcHMubGVuZ3RoOyBpKyspIHsgdmFyIGRlc2NyaXB0b3IgPSBwcm9wc1tpXTsgZGVzY3JpcHRvci5lbnVtZXJhYmxlID0gZGVzY3JpcHRvci5lbnVtZXJhYmxlIHx8IGZhbHNlOyBkZXNjcmlwdG9yLmNvbmZpZ3VyYWJsZSA9IHRydWU7IGlmICgndmFsdWUnIGluIGRlc2NyaXB0b3IpIGRlc2NyaXB0b3Iud3JpdGFibGUgPSB0cnVlOyBPYmplY3QuZGVmaW5lUHJvcGVydHkodGFyZ2V0LCBkZXNjcmlwdG9yLmtleSwgZGVzY3JpcHRvcik7IH0gfSByZXR1cm4gZnVuY3Rpb24gKENvbnN0cnVjdG9yLCBwcm90b1Byb3BzLCBzdGF0aWNQcm9wcykgeyBpZiAocHJvdG9Qcm9wcykgZGVmaW5lUHJvcGVydGllcyhDb25zdHJ1Y3Rvci5wcm90b3R5cGUsIHByb3RvUHJvcHMpOyBpZiAoc3RhdGljUHJvcHMpIGRlZmluZVByb3BlcnRpZXMoQ29uc3RydWN0b3IsIHN0YXRpY1Byb3BzKTsgcmV0dXJuIENvbnN0cnVjdG9yOyB9OyB9KSgpO1xuXG52YXIgX2dldCA9IGZ1bmN0aW9uIGdldChfeDQsIF94NSwgX3g2KSB7IHZhciBfYWdhaW4gPSB0cnVlOyBfZnVuY3Rpb246IHdoaWxlIChfYWdhaW4pIHsgdmFyIG9iamVjdCA9IF94NCwgcHJvcGVydHkgPSBfeDUsIHJlY2VpdmVyID0gX3g2OyBfYWdhaW4gPSBmYWxzZTsgaWYgKG9iamVjdCA9PT0gbnVsbCkgb2JqZWN0ID0gRnVuY3Rpb24ucHJvdG90eXBlOyB2YXIgZGVzYyA9IE9iamVjdC5nZXRPd25Qcm9wZXJ0eURlc2NyaXB0b3Iob2JqZWN0LCBwcm9wZXJ0eSk7IGlmIChkZXNjID09PSB1bmRlZmluZWQpIHsgdmFyIHBhcmVudCA9IE9iamVjdC5nZXRQcm90b3R5cGVPZihvYmplY3QpOyBpZiAocGFyZW50ID09PSBudWxsKSB7IHJldHVybiB1bmRlZmluZWQ7IH0gZWxzZSB7IF94NCA9IHBhcmVudDsgX3g1ID0gcHJvcGVydHk7IF94NiA9IHJlY2VpdmVyOyBfYWdhaW4gPSB0cnVlOyBkZXNjID0gcGFyZW50ID0gdW5kZWZpbmVkOyBjb250aW51ZSBfZnVuY3Rpb247IH0gfSBlbHNlIGlmICgndmFsdWUnIGluIGRlc2MpIHsgcmV0dXJuIGRlc2MudmFsdWU7IH0gZWxzZSB7IHZhciBnZXR0ZXIgPSBkZXNjLmdldDsgaWYgKGdldHRlciA9PT0gdW5kZWZpbmVkKSB7IHJldHVybiB1bmRlZmluZWQ7IH0gcmV0dXJuIGdldHRlci5jYWxsKHJlY2VpdmVyKTsgfSB9IH07XG5cbmZ1bmN0aW9uIF9pbnRlcm9wUmVxdWlyZURlZmF1bHQob2JqKSB7IHJldHVybiBvYmogJiYgb2JqLl9fZXNNb2R1bGUgPyBvYmogOiB7ICdkZWZhdWx0Jzogb2JqIH07IH1cblxuZnVuY3Rpb24gX2NsYXNzQ2FsbENoZWNrKGluc3RhbmNlLCBDb25zdHJ1Y3RvcikgeyBpZiAoIShpbnN0YW5jZSBpbnN0YW5jZW9mIENvbnN0cnVjdG9yKSkgeyB0aHJvdyBuZXcgVHlwZUVycm9yKCdDYW5ub3QgY2FsbCBhIGNsYXNzIGFzIGEgZnVuY3Rpb24nKTsgfSB9XG5cbmZ1bmN0aW9uIF9pbmhlcml0cyhzdWJDbGFzcywgc3VwZXJDbGFzcykgeyBpZiAodHlwZW9mIHN1cGVyQ2xhc3MgIT09ICdmdW5jdGlvbicgJiYgc3VwZXJDbGFzcyAhPT0gbnVsbCkgeyB0aHJvdyBuZXcgVHlwZUVycm9yKCdTdXBlciBleHByZXNzaW9uIG11c3QgZWl0aGVyIGJlIG51bGwgb3IgYSBmdW5jdGlvbiwgbm90ICcgKyB0eXBlb2Ygc3VwZXJDbGFzcyk7IH0gc3ViQ2xhc3MucHJvdG90eXBlID0gT2JqZWN0LmNyZWF0ZShzdXBlckNsYXNzICYmIHN1cGVyQ2xhc3MucHJvdG90eXBlLCB7IGNvbnN0cnVjdG9yOiB7IHZhbHVlOiBzdWJDbGFzcywgZW51bWVyYWJsZTogZmFsc2UsIHdyaXRhYmxlOiB0cnVlLCBjb25maWd1cmFibGU6IHRydWUgfSB9KTsgaWYgKHN1cGVyQ2xhc3MpIE9iamVjdC5zZXRQcm90b3R5cGVPZiA/IE9iamVjdC5zZXRQcm90b3R5cGVPZihzdWJDbGFzcywgc3VwZXJDbGFzcykgOiBzdWJDbGFzcy5fX3Byb3RvX18gPSBzdXBlckNsYXNzOyB9XG5cbnZhciBfdXJpanMgPSByZXF1aXJlKCd1cmlqcycpO1xuXG52YXIgX3VyaWpzMiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX3VyaWpzKTtcblxudmFyIF93ZWJzb2NrZXQgPSByZXF1aXJlKCcuL3dlYnNvY2tldCcpO1xuXG52YXIgX3dlYnNvY2tldDIgPSBfaW50ZXJvcFJlcXVpcmVEZWZhdWx0KF93ZWJzb2NrZXQpO1xuXG52YXIgX2V2ZW50VGFyZ2V0ID0gcmVxdWlyZSgnLi9ldmVudC10YXJnZXQnKTtcblxudmFyIF9ldmVudFRhcmdldDIgPSBfaW50ZXJvcFJlcXVpcmVEZWZhdWx0KF9ldmVudFRhcmdldCk7XG5cbnZhciBfbmV0d29ya0JyaWRnZSA9IHJlcXVpcmUoJy4vbmV0d29yay1icmlkZ2UnKTtcblxudmFyIF9uZXR3b3JrQnJpZGdlMiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX25ldHdvcmtCcmlkZ2UpO1xuXG52YXIgX2hlbHBlcnNDbG9zZUNvZGVzID0gcmVxdWlyZSgnLi9oZWxwZXJzL2Nsb3NlLWNvZGVzJyk7XG5cbnZhciBfaGVscGVyc0Nsb3NlQ29kZXMyID0gX2ludGVyb3BSZXF1aXJlRGVmYXVsdChfaGVscGVyc0Nsb3NlQ29kZXMpO1xuXG52YXIgX2V2ZW50RmFjdG9yeSA9IHJlcXVpcmUoJy4vZXZlbnQtZmFjdG9yeScpO1xuXG4vKlxuKiBodHRwczovL2dpdGh1Yi5jb20vd2Vic29ja2V0cy93cyNzZXJ2ZXItZXhhbXBsZVxuKi9cblxudmFyIFNlcnZlciA9IChmdW5jdGlvbiAoX0V2ZW50VGFyZ2V0KSB7XG4gIF9pbmhlcml0cyhTZXJ2ZXIsIF9FdmVudFRhcmdldCk7XG5cbiAgLypcbiAgKiBAcGFyYW0ge3N0cmluZ30gdXJsXG4gICovXG5cbiAgZnVuY3Rpb24gU2VydmVyKHVybCkge1xuICAgIF9jbGFzc0NhbGxDaGVjayh0aGlzLCBTZXJ2ZXIpO1xuXG4gICAgX2dldChPYmplY3QuZ2V0UHJvdG90eXBlT2YoU2VydmVyLnByb3RvdHlwZSksICdjb25zdHJ1Y3RvcicsIHRoaXMpLmNhbGwodGhpcyk7XG4gICAgdGhpcy51cmwgPSAoMCwgX3VyaWpzMlsnZGVmYXVsdCddKSh1cmwpLnRvU3RyaW5nKCk7XG4gICAgdmFyIHNlcnZlciA9IF9uZXR3b3JrQnJpZGdlMlsnZGVmYXVsdCddLmF0dGFjaFNlcnZlcih0aGlzLCB0aGlzLnVybCk7XG5cbiAgICBpZiAoIXNlcnZlcikge1xuICAgICAgdGhpcy5kaXNwYXRjaEV2ZW50KCgwLCBfZXZlbnRGYWN0b3J5LmNyZWF0ZUV2ZW50KSh7IHR5cGU6ICdlcnJvcicgfSkpO1xuICAgICAgdGhyb3cgbmV3IEVycm9yKCdBIG1vY2sgc2VydmVyIGlzIGFscmVhZHkgbGlzdGVuaW5nIG9uIHRoaXMgdXJsJyk7XG4gICAgfVxuICB9XG5cbiAgLypcbiAgICogQWx0ZXJuYXRpdmUgY29uc3RydWN0b3IgdG8gc3VwcG9ydCBuYW1lc3BhY2VzIGluIHNvY2tldC5pb1xuICAgKlxuICAgKiBodHRwOi8vc29ja2V0LmlvL2RvY3Mvcm9vbXMtYW5kLW5hbWVzcGFjZXMvI2N1c3RvbS1uYW1lc3BhY2VzXG4gICAqL1xuXG4gIC8qXG4gICogVGhpcyBpcyB0aGUgbWFpbiBmdW5jdGlvbiBmb3IgdGhlIG1vY2sgc2VydmVyIHRvIHN1YnNjcmliZSB0byB0aGUgb24gZXZlbnRzLlxuICAqXG4gICogaWU6IG1vY2tTZXJ2ZXIub24oJ2Nvbm5lY3Rpb24nLCBmdW5jdGlvbigpIHsgY29uc29sZS5sb2coJ2EgbW9jayBjbGllbnQgY29ubmVjdGVkJyk7IH0pO1xuICAqXG4gICogQHBhcmFtIHtzdHJpbmd9IHR5cGUgLSBUaGUgZXZlbnQga2V5IHRvIHN1YnNjcmliZSB0by4gVmFsaWQga2V5cyBhcmU6IGNvbm5lY3Rpb24sIG1lc3NhZ2UsIGFuZCBjbG9zZS5cbiAgKiBAcGFyYW0ge2Z1bmN0aW9ufSBjYWxsYmFjayAtIFRoZSBjYWxsYmFjayB3aGljaCBzaG91bGQgYmUgY2FsbGVkIHdoZW4gYSBjZXJ0YWluIGV2ZW50IGlzIGZpcmVkLlxuICAqL1xuXG4gIF9jcmVhdGVDbGFzcyhTZXJ2ZXIsIFt7XG4gICAga2V5OiAnb24nLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBvbih0eXBlLCBjYWxsYmFjaykge1xuICAgICAgdGhpcy5hZGRFdmVudExpc3RlbmVyKHR5cGUsIGNhbGxiYWNrKTtcbiAgICB9XG5cbiAgICAvKlxuICAgICogVGhpcyBzZW5kIGZ1bmN0aW9uIHdpbGwgbm90aWZ5IGFsbCBtb2NrIGNsaWVudHMgdmlhIHRoZWlyIG9ubWVzc2FnZSBjYWxsYmFja3MgdGhhdCB0aGUgc2VydmVyXG4gICAgKiBoYXMgYSBtZXNzYWdlIGZvciB0aGVtLlxuICAgICpcbiAgICAqIEBwYXJhbSB7Kn0gZGF0YSAtIEFueSBqYXZhc2NyaXB0IG9iamVjdCB3aGljaCB3aWxsIGJlIGNyYWZ0ZWQgaW50byBhIE1lc3NhZ2VPYmplY3QuXG4gICAgKi9cbiAgfSwge1xuICAgIGtleTogJ3NlbmQnLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBzZW5kKGRhdGEpIHtcbiAgICAgIHZhciBvcHRpb25zID0gYXJndW1lbnRzLmxlbmd0aCA8PSAxIHx8IGFyZ3VtZW50c1sxXSA9PT0gdW5kZWZpbmVkID8ge30gOiBhcmd1bWVudHNbMV07XG5cbiAgICAgIHRoaXMuZW1pdCgnbWVzc2FnZScsIGRhdGEsIG9wdGlvbnMpO1xuICAgIH1cblxuICAgIC8qXG4gICAgKiBTZW5kcyBhIGdlbmVyaWMgbWVzc2FnZSBldmVudCB0byBhbGwgbW9jayBjbGllbnRzLlxuICAgICovXG4gIH0sIHtcbiAgICBrZXk6ICdlbWl0JyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gZW1pdChldmVudCwgZGF0YSkge1xuICAgICAgdmFyIF90aGlzMiA9IHRoaXM7XG5cbiAgICAgIHZhciBvcHRpb25zID0gYXJndW1lbnRzLmxlbmd0aCA8PSAyIHx8IGFyZ3VtZW50c1syXSA9PT0gdW5kZWZpbmVkID8ge30gOiBhcmd1bWVudHNbMl07XG4gICAgICB2YXIgd2Vic29ja2V0cyA9IG9wdGlvbnMud2Vic29ja2V0cztcblxuICAgICAgaWYgKCF3ZWJzb2NrZXRzKSB7XG4gICAgICAgIHdlYnNvY2tldHMgPSBfbmV0d29ya0JyaWRnZTJbJ2RlZmF1bHQnXS53ZWJzb2NrZXRzTG9va3VwKHRoaXMudXJsKTtcbiAgICAgIH1cblxuICAgICAgd2Vic29ja2V0cy5mb3JFYWNoKGZ1bmN0aW9uIChzb2NrZXQpIHtcbiAgICAgICAgc29ja2V0LmRpc3BhdGNoRXZlbnQoKDAsIF9ldmVudEZhY3RvcnkuY3JlYXRlTWVzc2FnZUV2ZW50KSh7XG4gICAgICAgICAgdHlwZTogZXZlbnQsXG4gICAgICAgICAgZGF0YTogZGF0YSxcbiAgICAgICAgICBvcmlnaW46IF90aGlzMi51cmwsXG4gICAgICAgICAgdGFyZ2V0OiBzb2NrZXRcbiAgICAgICAgfSkpO1xuICAgICAgfSk7XG4gICAgfVxuXG4gICAgLypcbiAgICAqIENsb3NlcyB0aGUgY29ubmVjdGlvbiBhbmQgdHJpZ2dlcnMgdGhlIG9uY2xvc2UgbWV0aG9kIG9mIGFsbCBsaXN0ZW5pbmdcbiAgICAqIHdlYnNvY2tldHMuIEFmdGVyIHRoYXQgaXQgcmVtb3ZlcyBpdHNlbGYgZnJvbSB0aGUgdXJsTWFwIHNvIGFub3RoZXIgc2VydmVyXG4gICAgKiBjb3VsZCBhZGQgaXRzZWxmIHRvIHRoZSB1cmwuXG4gICAgKlxuICAgICogQHBhcmFtIHtvYmplY3R9IG9wdGlvbnNcbiAgICAqL1xuICB9LCB7XG4gICAga2V5OiAnY2xvc2UnLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBjbG9zZSgpIHtcbiAgICAgIHZhciBvcHRpb25zID0gYXJndW1lbnRzLmxlbmd0aCA8PSAwIHx8IGFyZ3VtZW50c1swXSA9PT0gdW5kZWZpbmVkID8ge30gOiBhcmd1bWVudHNbMF07XG4gICAgICB2YXIgY29kZSA9IG9wdGlvbnMuY29kZTtcbiAgICAgIHZhciByZWFzb24gPSBvcHRpb25zLnJlYXNvbjtcbiAgICAgIHZhciB3YXNDbGVhbiA9IG9wdGlvbnMud2FzQ2xlYW47XG5cbiAgICAgIHZhciBsaXN0ZW5lcnMgPSBfbmV0d29ya0JyaWRnZTJbJ2RlZmF1bHQnXS53ZWJzb2NrZXRzTG9va3VwKHRoaXMudXJsKTtcblxuICAgICAgbGlzdGVuZXJzLmZvckVhY2goZnVuY3Rpb24gKHNvY2tldCkge1xuICAgICAgICBzb2NrZXQucmVhZHlTdGF0ZSA9IF93ZWJzb2NrZXQyWydkZWZhdWx0J10uQ0xPU0U7XG4gICAgICAgIHNvY2tldC5kaXNwYXRjaEV2ZW50KCgwLCBfZXZlbnRGYWN0b3J5LmNyZWF0ZUNsb3NlRXZlbnQpKHtcbiAgICAgICAgICB0eXBlOiAnY2xvc2UnLFxuICAgICAgICAgIHRhcmdldDogc29ja2V0LFxuICAgICAgICAgIGNvZGU6IGNvZGUgfHwgX2hlbHBlcnNDbG9zZUNvZGVzMlsnZGVmYXVsdCddLkNMT1NFX05PUk1BTCxcbiAgICAgICAgICByZWFzb246IHJlYXNvbiB8fCAnJyxcbiAgICAgICAgICB3YXNDbGVhbjogd2FzQ2xlYW5cbiAgICAgICAgfSkpO1xuICAgICAgfSk7XG5cbiAgICAgIHRoaXMuZGlzcGF0Y2hFdmVudCgoMCwgX2V2ZW50RmFjdG9yeS5jcmVhdGVDbG9zZUV2ZW50KSh7IHR5cGU6ICdjbG9zZScgfSksIHRoaXMpO1xuICAgICAgX25ldHdvcmtCcmlkZ2UyWydkZWZhdWx0J10ucmVtb3ZlU2VydmVyKHRoaXMudXJsKTtcbiAgICB9XG5cbiAgICAvKlxuICAgICogUmV0dXJucyBhbiBhcnJheSBvZiB3ZWJzb2NrZXRzIHdoaWNoIGFyZSBsaXN0ZW5pbmcgdG8gdGhpcyBzZXJ2ZXJcbiAgICAqL1xuICB9LCB7XG4gICAga2V5OiAnY2xpZW50cycsXG4gICAgdmFsdWU6IGZ1bmN0aW9uIGNsaWVudHMoKSB7XG4gICAgICByZXR1cm4gX25ldHdvcmtCcmlkZ2UyWydkZWZhdWx0J10ud2Vic29ja2V0c0xvb2t1cCh0aGlzLnVybCk7XG4gICAgfVxuXG4gICAgLypcbiAgICAqIFByZXBhcmVzIGEgbWV0aG9kIHRvIHN1Ym1pdCBhbiBldmVudCB0byBtZW1iZXJzIG9mIHRoZSByb29tXG4gICAgKlxuICAgICogZS5nLiBzZXJ2ZXIudG8oJ215LXJvb20nKS5lbWl0KCdoaSEnKTtcbiAgICAqL1xuICB9LCB7XG4gICAga2V5OiAndG8nLFxuICAgIHZhbHVlOiBmdW5jdGlvbiB0byhyb29tKSB7XG4gICAgICB2YXIgX3RoaXMgPSB0aGlzO1xuICAgICAgdmFyIHdlYnNvY2tldHMgPSBfbmV0d29ya0JyaWRnZTJbJ2RlZmF1bHQnXS53ZWJzb2NrZXRzTG9va3VwKHRoaXMudXJsLCByb29tKTtcbiAgICAgIHJldHVybiB7XG4gICAgICAgIGVtaXQ6IGZ1bmN0aW9uIGVtaXQoZXZlbnQsIGRhdGEpIHtcbiAgICAgICAgICBfdGhpcy5lbWl0KGV2ZW50LCBkYXRhLCB7IHdlYnNvY2tldHM6IHdlYnNvY2tldHMgfSk7XG4gICAgICAgIH1cbiAgICAgIH07XG4gICAgfVxuICB9XSk7XG5cbiAgcmV0dXJuIFNlcnZlcjtcbn0pKF9ldmVudFRhcmdldDJbJ2RlZmF1bHQnXSk7XG5cblNlcnZlci5vZiA9IGZ1bmN0aW9uIG9mKHVybCkge1xuICByZXR1cm4gbmV3IFNlcnZlcih1cmwpO1xufTtcblxuZXhwb3J0c1snZGVmYXVsdCddID0gU2VydmVyO1xubW9kdWxlLmV4cG9ydHMgPSBleHBvcnRzWydkZWZhdWx0J107IiwiJ3VzZSBzdHJpY3QnO1xuXG5PYmplY3QuZGVmaW5lUHJvcGVydHkoZXhwb3J0cywgJ19fZXNNb2R1bGUnLCB7XG4gIHZhbHVlOiB0cnVlXG59KTtcblxudmFyIF9jcmVhdGVDbGFzcyA9IChmdW5jdGlvbiAoKSB7IGZ1bmN0aW9uIGRlZmluZVByb3BlcnRpZXModGFyZ2V0LCBwcm9wcykgeyBmb3IgKHZhciBpID0gMDsgaSA8IHByb3BzLmxlbmd0aDsgaSsrKSB7IHZhciBkZXNjcmlwdG9yID0gcHJvcHNbaV07IGRlc2NyaXB0b3IuZW51bWVyYWJsZSA9IGRlc2NyaXB0b3IuZW51bWVyYWJsZSB8fCBmYWxzZTsgZGVzY3JpcHRvci5jb25maWd1cmFibGUgPSB0cnVlOyBpZiAoJ3ZhbHVlJyBpbiBkZXNjcmlwdG9yKSBkZXNjcmlwdG9yLndyaXRhYmxlID0gdHJ1ZTsgT2JqZWN0LmRlZmluZVByb3BlcnR5KHRhcmdldCwgZGVzY3JpcHRvci5rZXksIGRlc2NyaXB0b3IpOyB9IH0gcmV0dXJuIGZ1bmN0aW9uIChDb25zdHJ1Y3RvciwgcHJvdG9Qcm9wcywgc3RhdGljUHJvcHMpIHsgaWYgKHByb3RvUHJvcHMpIGRlZmluZVByb3BlcnRpZXMoQ29uc3RydWN0b3IucHJvdG90eXBlLCBwcm90b1Byb3BzKTsgaWYgKHN0YXRpY1Byb3BzKSBkZWZpbmVQcm9wZXJ0aWVzKENvbnN0cnVjdG9yLCBzdGF0aWNQcm9wcyk7IHJldHVybiBDb25zdHJ1Y3RvcjsgfTsgfSkoKTtcblxudmFyIF9nZXQgPSBmdW5jdGlvbiBnZXQoX3gzLCBfeDQsIF94NSkgeyB2YXIgX2FnYWluID0gdHJ1ZTsgX2Z1bmN0aW9uOiB3aGlsZSAoX2FnYWluKSB7IHZhciBvYmplY3QgPSBfeDMsIHByb3BlcnR5ID0gX3g0LCByZWNlaXZlciA9IF94NTsgX2FnYWluID0gZmFsc2U7IGlmIChvYmplY3QgPT09IG51bGwpIG9iamVjdCA9IEZ1bmN0aW9uLnByb3RvdHlwZTsgdmFyIGRlc2MgPSBPYmplY3QuZ2V0T3duUHJvcGVydHlEZXNjcmlwdG9yKG9iamVjdCwgcHJvcGVydHkpOyBpZiAoZGVzYyA9PT0gdW5kZWZpbmVkKSB7IHZhciBwYXJlbnQgPSBPYmplY3QuZ2V0UHJvdG90eXBlT2Yob2JqZWN0KTsgaWYgKHBhcmVudCA9PT0gbnVsbCkgeyByZXR1cm4gdW5kZWZpbmVkOyB9IGVsc2UgeyBfeDMgPSBwYXJlbnQ7IF94NCA9IHByb3BlcnR5OyBfeDUgPSByZWNlaXZlcjsgX2FnYWluID0gdHJ1ZTsgZGVzYyA9IHBhcmVudCA9IHVuZGVmaW5lZDsgY29udGludWUgX2Z1bmN0aW9uOyB9IH0gZWxzZSBpZiAoJ3ZhbHVlJyBpbiBkZXNjKSB7IHJldHVybiBkZXNjLnZhbHVlOyB9IGVsc2UgeyB2YXIgZ2V0dGVyID0gZGVzYy5nZXQ7IGlmIChnZXR0ZXIgPT09IHVuZGVmaW5lZCkgeyByZXR1cm4gdW5kZWZpbmVkOyB9IHJldHVybiBnZXR0ZXIuY2FsbChyZWNlaXZlcik7IH0gfSB9O1xuXG5mdW5jdGlvbiBfaW50ZXJvcFJlcXVpcmVEZWZhdWx0KG9iaikgeyByZXR1cm4gb2JqICYmIG9iai5fX2VzTW9kdWxlID8gb2JqIDogeyAnZGVmYXVsdCc6IG9iaiB9OyB9XG5cbmZ1bmN0aW9uIF9jbGFzc0NhbGxDaGVjayhpbnN0YW5jZSwgQ29uc3RydWN0b3IpIHsgaWYgKCEoaW5zdGFuY2UgaW5zdGFuY2VvZiBDb25zdHJ1Y3RvcikpIHsgdGhyb3cgbmV3IFR5cGVFcnJvcignQ2Fubm90IGNhbGwgYSBjbGFzcyBhcyBhIGZ1bmN0aW9uJyk7IH0gfVxuXG5mdW5jdGlvbiBfaW5oZXJpdHMoc3ViQ2xhc3MsIHN1cGVyQ2xhc3MpIHsgaWYgKHR5cGVvZiBzdXBlckNsYXNzICE9PSAnZnVuY3Rpb24nICYmIHN1cGVyQ2xhc3MgIT09IG51bGwpIHsgdGhyb3cgbmV3IFR5cGVFcnJvcignU3VwZXIgZXhwcmVzc2lvbiBtdXN0IGVpdGhlciBiZSBudWxsIG9yIGEgZnVuY3Rpb24sIG5vdCAnICsgdHlwZW9mIHN1cGVyQ2xhc3MpOyB9IHN1YkNsYXNzLnByb3RvdHlwZSA9IE9iamVjdC5jcmVhdGUoc3VwZXJDbGFzcyAmJiBzdXBlckNsYXNzLnByb3RvdHlwZSwgeyBjb25zdHJ1Y3RvcjogeyB2YWx1ZTogc3ViQ2xhc3MsIGVudW1lcmFibGU6IGZhbHNlLCB3cml0YWJsZTogdHJ1ZSwgY29uZmlndXJhYmxlOiB0cnVlIH0gfSk7IGlmIChzdXBlckNsYXNzKSBPYmplY3Quc2V0UHJvdG90eXBlT2YgPyBPYmplY3Quc2V0UHJvdG90eXBlT2Yoc3ViQ2xhc3MsIHN1cGVyQ2xhc3MpIDogc3ViQ2xhc3MuX19wcm90b19fID0gc3VwZXJDbGFzczsgfVxuXG52YXIgX3VyaWpzID0gcmVxdWlyZSgndXJpanMnKTtcblxudmFyIF91cmlqczIgPSBfaW50ZXJvcFJlcXVpcmVEZWZhdWx0KF91cmlqcyk7XG5cbnZhciBfaGVscGVyc0RlbGF5ID0gcmVxdWlyZSgnLi9oZWxwZXJzL2RlbGF5Jyk7XG5cbnZhciBfaGVscGVyc0RlbGF5MiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX2hlbHBlcnNEZWxheSk7XG5cbnZhciBfZXZlbnRUYXJnZXQgPSByZXF1aXJlKCcuL2V2ZW50LXRhcmdldCcpO1xuXG52YXIgX2V2ZW50VGFyZ2V0MiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX2V2ZW50VGFyZ2V0KTtcblxudmFyIF9uZXR3b3JrQnJpZGdlID0gcmVxdWlyZSgnLi9uZXR3b3JrLWJyaWRnZScpO1xuXG52YXIgX25ldHdvcmtCcmlkZ2UyID0gX2ludGVyb3BSZXF1aXJlRGVmYXVsdChfbmV0d29ya0JyaWRnZSk7XG5cbnZhciBfaGVscGVyc0Nsb3NlQ29kZXMgPSByZXF1aXJlKCcuL2hlbHBlcnMvY2xvc2UtY29kZXMnKTtcblxudmFyIF9oZWxwZXJzQ2xvc2VDb2RlczIgPSBfaW50ZXJvcFJlcXVpcmVEZWZhdWx0KF9oZWxwZXJzQ2xvc2VDb2Rlcyk7XG5cbnZhciBfZXZlbnRGYWN0b3J5ID0gcmVxdWlyZSgnLi9ldmVudC1mYWN0b3J5Jyk7XG5cbi8qXG4qIFRoZSBzb2NrZXQtaW8gY2xhc3MgaXMgZGVzaWduZWQgdG8gbWltaWNrIHRoZSByZWFsIEFQSSBhcyBjbG9zZWx5IGFzIHBvc3NpYmxlLlxuKlxuKiBodHRwOi8vc29ja2V0LmlvL2RvY3MvXG4qL1xuXG52YXIgU29ja2V0SU8gPSAoZnVuY3Rpb24gKF9FdmVudFRhcmdldCkge1xuICBfaW5oZXJpdHMoU29ja2V0SU8sIF9FdmVudFRhcmdldCk7XG5cbiAgLypcbiAgKiBAcGFyYW0ge3N0cmluZ30gdXJsXG4gICovXG5cbiAgZnVuY3Rpb24gU29ja2V0SU8oKSB7XG4gICAgdmFyIF90aGlzID0gdGhpcztcblxuICAgIHZhciB1cmwgPSBhcmd1bWVudHMubGVuZ3RoIDw9IDAgfHwgYXJndW1lbnRzWzBdID09PSB1bmRlZmluZWQgPyAnc29ja2V0LmlvJyA6IGFyZ3VtZW50c1swXTtcbiAgICB2YXIgcHJvdG9jb2wgPSBhcmd1bWVudHMubGVuZ3RoIDw9IDEgfHwgYXJndW1lbnRzWzFdID09PSB1bmRlZmluZWQgPyAnJyA6IGFyZ3VtZW50c1sxXTtcblxuICAgIF9jbGFzc0NhbGxDaGVjayh0aGlzLCBTb2NrZXRJTyk7XG5cbiAgICBfZ2V0KE9iamVjdC5nZXRQcm90b3R5cGVPZihTb2NrZXRJTy5wcm90b3R5cGUpLCAnY29uc3RydWN0b3InLCB0aGlzKS5jYWxsKHRoaXMpO1xuXG4gICAgdGhpcy5iaW5hcnlUeXBlID0gJ2Jsb2InO1xuICAgIHRoaXMudXJsID0gKDAsIF91cmlqczJbJ2RlZmF1bHQnXSkodXJsKS50b1N0cmluZygpO1xuICAgIHRoaXMucmVhZHlTdGF0ZSA9IFNvY2tldElPLkNPTk5FQ1RJTkc7XG4gICAgdGhpcy5wcm90b2NvbCA9ICcnO1xuXG4gICAgaWYgKHR5cGVvZiBwcm90b2NvbCA9PT0gJ3N0cmluZycpIHtcbiAgICAgIHRoaXMucHJvdG9jb2wgPSBwcm90b2NvbDtcbiAgICB9IGVsc2UgaWYgKEFycmF5LmlzQXJyYXkocHJvdG9jb2wpICYmIHByb3RvY29sLmxlbmd0aCA+IDApIHtcbiAgICAgIHRoaXMucHJvdG9jb2wgPSBwcm90b2NvbFswXTtcbiAgICB9XG5cbiAgICB2YXIgc2VydmVyID0gX25ldHdvcmtCcmlkZ2UyWydkZWZhdWx0J10uYXR0YWNoV2ViU29ja2V0KHRoaXMsIHRoaXMudXJsKTtcblxuICAgIC8qXG4gICAgKiBEZWxheSB0cmlnZ2VyaW5nIHRoZSBjb25uZWN0aW9uIGV2ZW50cyBzbyB0aGV5IGNhbiBiZSBkZWZpbmVkIGluIHRpbWUuXG4gICAgKi9cbiAgICAoMCwgX2hlbHBlcnNEZWxheTJbJ2RlZmF1bHQnXSkoZnVuY3Rpb24gZGVsYXlDYWxsYmFjaygpIHtcbiAgICAgIGlmIChzZXJ2ZXIpIHtcbiAgICAgICAgdGhpcy5yZWFkeVN0YXRlID0gU29ja2V0SU8uT1BFTjtcbiAgICAgICAgc2VydmVyLmRpc3BhdGNoRXZlbnQoKDAsIF9ldmVudEZhY3RvcnkuY3JlYXRlRXZlbnQpKHsgdHlwZTogJ2Nvbm5lY3Rpb24nIH0pLCBzZXJ2ZXIsIHRoaXMpO1xuICAgICAgICBzZXJ2ZXIuZGlzcGF0Y2hFdmVudCgoMCwgX2V2ZW50RmFjdG9yeS5jcmVhdGVFdmVudCkoeyB0eXBlOiAnY29ubmVjdCcgfSksIHNlcnZlciwgdGhpcyk7IC8vIGFsaWFzXG4gICAgICAgIHRoaXMuZGlzcGF0Y2hFdmVudCgoMCwgX2V2ZW50RmFjdG9yeS5jcmVhdGVFdmVudCkoeyB0eXBlOiAnY29ubmVjdCcsIHRhcmdldDogdGhpcyB9KSk7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICB0aGlzLnJlYWR5U3RhdGUgPSBTb2NrZXRJTy5DTE9TRUQ7XG4gICAgICAgIHRoaXMuZGlzcGF0Y2hFdmVudCgoMCwgX2V2ZW50RmFjdG9yeS5jcmVhdGVFdmVudCkoeyB0eXBlOiAnZXJyb3InLCB0YXJnZXQ6IHRoaXMgfSkpO1xuICAgICAgICB0aGlzLmRpc3BhdGNoRXZlbnQoKDAsIF9ldmVudEZhY3RvcnkuY3JlYXRlQ2xvc2VFdmVudCkoe1xuICAgICAgICAgIHR5cGU6ICdjbG9zZScsXG4gICAgICAgICAgdGFyZ2V0OiB0aGlzLFxuICAgICAgICAgIGNvZGU6IF9oZWxwZXJzQ2xvc2VDb2RlczJbJ2RlZmF1bHQnXS5DTE9TRV9OT1JNQUxcbiAgICAgICAgfSkpO1xuXG4gICAgICAgIGNvbnNvbGUuZXJyb3IoJ1NvY2tldC5pbyBjb25uZWN0aW9uIHRvIFxcJycgKyB0aGlzLnVybCArICdcXCcgZmFpbGVkJyk7XG4gICAgICB9XG4gICAgfSwgdGhpcyk7XG5cbiAgICAvKipcbiAgICAgIEFkZCBhbiBhbGlhc2VkIGV2ZW50IGxpc3RlbmVyIGZvciBjbG9zZSAvIGRpc2Nvbm5lY3RcbiAgICAgKi9cbiAgICB0aGlzLmFkZEV2ZW50TGlzdGVuZXIoJ2Nsb3NlJywgZnVuY3Rpb24gKGV2ZW50KSB7XG4gICAgICBfdGhpcy5kaXNwYXRjaEV2ZW50KCgwLCBfZXZlbnRGYWN0b3J5LmNyZWF0ZUNsb3NlRXZlbnQpKHtcbiAgICAgICAgdHlwZTogJ2Rpc2Nvbm5lY3QnLFxuICAgICAgICB0YXJnZXQ6IGV2ZW50LnRhcmdldCxcbiAgICAgICAgY29kZTogZXZlbnQuY29kZVxuICAgICAgfSkpO1xuICAgIH0pO1xuICB9XG5cbiAgLypcbiAgKiBDbG9zZXMgdGhlIFNvY2tldElPIGNvbm5lY3Rpb24gb3IgY29ubmVjdGlvbiBhdHRlbXB0LCBpZiBhbnkuXG4gICogSWYgdGhlIGNvbm5lY3Rpb24gaXMgYWxyZWFkeSBDTE9TRUQsIHRoaXMgbWV0aG9kIGRvZXMgbm90aGluZy5cbiAgKi9cblxuICBfY3JlYXRlQ2xhc3MoU29ja2V0SU8sIFt7XG4gICAga2V5OiAnY2xvc2UnLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBjbG9zZSgpIHtcbiAgICAgIGlmICh0aGlzLnJlYWR5U3RhdGUgIT09IFNvY2tldElPLk9QRU4pIHtcbiAgICAgICAgcmV0dXJuIHVuZGVmaW5lZDtcbiAgICAgIH1cblxuICAgICAgdmFyIHNlcnZlciA9IF9uZXR3b3JrQnJpZGdlMlsnZGVmYXVsdCddLnNlcnZlckxvb2t1cCh0aGlzLnVybCk7XG4gICAgICBfbmV0d29ya0JyaWRnZTJbJ2RlZmF1bHQnXS5yZW1vdmVXZWJTb2NrZXQodGhpcywgdGhpcy51cmwpO1xuXG4gICAgICB0aGlzLnJlYWR5U3RhdGUgPSBTb2NrZXRJTy5DTE9TRUQ7XG4gICAgICB0aGlzLmRpc3BhdGNoRXZlbnQoKDAsIF9ldmVudEZhY3RvcnkuY3JlYXRlQ2xvc2VFdmVudCkoe1xuICAgICAgICB0eXBlOiAnY2xvc2UnLFxuICAgICAgICB0YXJnZXQ6IHRoaXMsXG4gICAgICAgIGNvZGU6IF9oZWxwZXJzQ2xvc2VDb2RlczJbJ2RlZmF1bHQnXS5DTE9TRV9OT1JNQUxcbiAgICAgIH0pKTtcblxuICAgICAgaWYgKHNlcnZlcikge1xuICAgICAgICBzZXJ2ZXIuZGlzcGF0Y2hFdmVudCgoMCwgX2V2ZW50RmFjdG9yeS5jcmVhdGVDbG9zZUV2ZW50KSh7XG4gICAgICAgICAgdHlwZTogJ2Rpc2Nvbm5lY3QnLFxuICAgICAgICAgIHRhcmdldDogdGhpcyxcbiAgICAgICAgICBjb2RlOiBfaGVscGVyc0Nsb3NlQ29kZXMyWydkZWZhdWx0J10uQ0xPU0VfTk9STUFMXG4gICAgICAgIH0pLCBzZXJ2ZXIpO1xuICAgICAgfVxuICAgIH1cblxuICAgIC8qXG4gICAgKiBBbGlhcyBmb3IgU29ja2V0I2Nsb3NlXG4gICAgKlxuICAgICogaHR0cHM6Ly9naXRodWIuY29tL3NvY2tldGlvL3NvY2tldC5pby1jbGllbnQvYmxvYi9tYXN0ZXIvbGliL3NvY2tldC5qcyNMMzgzXG4gICAgKi9cbiAgfSwge1xuICAgIGtleTogJ2Rpc2Nvbm5lY3QnLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBkaXNjb25uZWN0KCkge1xuICAgICAgdGhpcy5jbG9zZSgpO1xuICAgIH1cblxuICAgIC8qXG4gICAgKiBTdWJtaXRzIGFuIGV2ZW50IHRvIHRoZSBzZXJ2ZXIgd2l0aCBhIHBheWxvYWRcbiAgICAqL1xuICB9LCB7XG4gICAga2V5OiAnZW1pdCcsXG4gICAgdmFsdWU6IGZ1bmN0aW9uIGVtaXQoZXZlbnQsIGRhdGEpIHtcbiAgICAgIGlmICh0aGlzLnJlYWR5U3RhdGUgIT09IFNvY2tldElPLk9QRU4pIHtcbiAgICAgICAgdGhyb3cgbmV3IEVycm9yKCdTb2NrZXRJTyBpcyBhbHJlYWR5IGluIENMT1NJTkcgb3IgQ0xPU0VEIHN0YXRlJyk7XG4gICAgICB9XG5cbiAgICAgIHZhciBtZXNzYWdlRXZlbnQgPSAoMCwgX2V2ZW50RmFjdG9yeS5jcmVhdGVNZXNzYWdlRXZlbnQpKHtcbiAgICAgICAgdHlwZTogZXZlbnQsXG4gICAgICAgIG9yaWdpbjogdGhpcy51cmwsXG4gICAgICAgIGRhdGE6IGRhdGFcbiAgICAgIH0pO1xuXG4gICAgICB2YXIgc2VydmVyID0gX25ldHdvcmtCcmlkZ2UyWydkZWZhdWx0J10uc2VydmVyTG9va3VwKHRoaXMudXJsKTtcblxuICAgICAgaWYgKHNlcnZlcikge1xuICAgICAgICBzZXJ2ZXIuZGlzcGF0Y2hFdmVudChtZXNzYWdlRXZlbnQsIGRhdGEpO1xuICAgICAgfVxuICAgIH1cblxuICAgIC8qXG4gICAgKiBTdWJtaXRzIGEgJ21lc3NhZ2UnIGV2ZW50IHRvIHRoZSBzZXJ2ZXIuXG4gICAgKlxuICAgICogU2hvdWxkIGJlaGF2ZSBleGFjdGx5IGxpa2UgV2ViU29ja2V0I3NlbmRcbiAgICAqXG4gICAgKiBodHRwczovL2dpdGh1Yi5jb20vc29ja2V0aW8vc29ja2V0LmlvLWNsaWVudC9ibG9iL21hc3Rlci9saWIvc29ja2V0LmpzI0wxMTNcbiAgICAqL1xuICB9LCB7XG4gICAga2V5OiAnc2VuZCcsXG4gICAgdmFsdWU6IGZ1bmN0aW9uIHNlbmQoZGF0YSkge1xuICAgICAgdGhpcy5lbWl0KCdtZXNzYWdlJywgZGF0YSk7XG4gICAgfVxuXG4gICAgLypcbiAgICAqIEZvciByZWdpc3RlcmluZyBldmVudHMgdG8gYmUgcmVjZWl2ZWQgZnJvbSB0aGUgc2VydmVyXG4gICAgKi9cbiAgfSwge1xuICAgIGtleTogJ29uJyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gb24odHlwZSwgY2FsbGJhY2spIHtcbiAgICAgIHRoaXMuYWRkRXZlbnRMaXN0ZW5lcih0eXBlLCBjYWxsYmFjayk7XG4gICAgfVxuXG4gICAgLypcbiAgICAgKiBKb2luIGEgcm9vbSBvbiBhIHNlcnZlclxuICAgICAqXG4gICAgICogaHR0cDovL3NvY2tldC5pby9kb2NzL3Jvb21zLWFuZC1uYW1lc3BhY2VzLyNqb2luaW5nLWFuZC1sZWF2aW5nXG4gICAgICovXG4gIH0sIHtcbiAgICBrZXk6ICdqb2luJyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gam9pbihyb29tKSB7XG4gICAgICBfbmV0d29ya0JyaWRnZTJbJ2RlZmF1bHQnXS5hZGRNZW1iZXJzaGlwVG9Sb29tKHRoaXMsIHJvb20pO1xuICAgIH1cblxuICAgIC8qXG4gICAgICogR2V0IHRoZSB3ZWJzb2NrZXQgdG8gbGVhdmUgdGhlIHJvb21cbiAgICAgKlxuICAgICAqIGh0dHA6Ly9zb2NrZXQuaW8vZG9jcy9yb29tcy1hbmQtbmFtZXNwYWNlcy8jam9pbmluZy1hbmQtbGVhdmluZ1xuICAgICAqL1xuICB9LCB7XG4gICAga2V5OiAnbGVhdmUnLFxuICAgIHZhbHVlOiBmdW5jdGlvbiBsZWF2ZShyb29tKSB7XG4gICAgICBfbmV0d29ya0JyaWRnZTJbJ2RlZmF1bHQnXS5yZW1vdmVNZW1iZXJzaGlwRnJvbVJvb20odGhpcywgcm9vbSk7XG4gICAgfVxuXG4gICAgLypcbiAgICAgKiBJbnZva2VzIGFsbCBsaXN0ZW5lciBmdW5jdGlvbnMgdGhhdCBhcmUgbGlzdGVuaW5nIHRvIHRoZSBnaXZlbiBldmVudC50eXBlIHByb3BlcnR5LiBFYWNoXG4gICAgICogbGlzdGVuZXIgd2lsbCBiZSBwYXNzZWQgdGhlIGV2ZW50IGFzIHRoZSBmaXJzdCBhcmd1bWVudC5cbiAgICAgKlxuICAgICAqIEBwYXJhbSB7b2JqZWN0fSBldmVudCAtIGV2ZW50IG9iamVjdCB3aGljaCB3aWxsIGJlIHBhc3NlZCB0byBhbGwgbGlzdGVuZXJzIG9mIHRoZSBldmVudC50eXBlIHByb3BlcnR5XG4gICAgICovXG4gIH0sIHtcbiAgICBrZXk6ICdkaXNwYXRjaEV2ZW50JyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gZGlzcGF0Y2hFdmVudChldmVudCkge1xuICAgICAgdmFyIF90aGlzMiA9IHRoaXM7XG5cbiAgICAgIGZvciAodmFyIF9sZW4gPSBhcmd1bWVudHMubGVuZ3RoLCBjdXN0b21Bcmd1bWVudHMgPSBBcnJheShfbGVuID4gMSA/IF9sZW4gLSAxIDogMCksIF9rZXkgPSAxOyBfa2V5IDwgX2xlbjsgX2tleSsrKSB7XG4gICAgICAgIGN1c3RvbUFyZ3VtZW50c1tfa2V5IC0gMV0gPSBhcmd1bWVudHNbX2tleV07XG4gICAgICB9XG5cbiAgICAgIHZhciBldmVudE5hbWUgPSBldmVudC50eXBlO1xuICAgICAgdmFyIGxpc3RlbmVycyA9IHRoaXMubGlzdGVuZXJzW2V2ZW50TmFtZV07XG5cbiAgICAgIGlmICghQXJyYXkuaXNBcnJheShsaXN0ZW5lcnMpKSB7XG4gICAgICAgIHJldHVybiBmYWxzZTtcbiAgICAgIH1cblxuICAgICAgbGlzdGVuZXJzLmZvckVhY2goZnVuY3Rpb24gKGxpc3RlbmVyKSB7XG4gICAgICAgIGlmIChjdXN0b21Bcmd1bWVudHMubGVuZ3RoID4gMCkge1xuICAgICAgICAgIGxpc3RlbmVyLmFwcGx5KF90aGlzMiwgY3VzdG9tQXJndW1lbnRzKTtcbiAgICAgICAgfSBlbHNlIHtcbiAgICAgICAgICAvLyBSZWd1bGFyIFdlYlNvY2tldHMgZXhwZWN0IGEgTWVzc2FnZUV2ZW50IGJ1dCBTb2NrZXRpby5pbyBqdXN0IHdhbnRzIHJhdyBkYXRhXG4gICAgICAgICAgLy8gIHBheWxvYWQgaW5zdGFuY2VvZiBNZXNzYWdlRXZlbnQgd29ya3MsIGJ1dCB5b3UgY2FuJ3QgaXNudGFuY2Ugb2YgTm9kZUV2ZW50XG4gICAgICAgICAgLy8gIGZvciBub3cgd2UgZGV0ZWN0IGlmIHRoZSBvdXRwdXQgaGFzIGRhdGEgZGVmaW5lZCBvbiBpdFxuICAgICAgICAgIGxpc3RlbmVyLmNhbGwoX3RoaXMyLCBldmVudC5kYXRhID8gZXZlbnQuZGF0YSA6IGV2ZW50KTtcbiAgICAgICAgfVxuICAgICAgfSk7XG4gICAgfVxuICB9XSk7XG5cbiAgcmV0dXJuIFNvY2tldElPO1xufSkoX2V2ZW50VGFyZ2V0MlsnZGVmYXVsdCddKTtcblxuU29ja2V0SU8uQ09OTkVDVElORyA9IDA7XG5Tb2NrZXRJTy5PUEVOID0gMTtcblNvY2tldElPLkNMT1NJTkcgPSAyO1xuU29ja2V0SU8uQ0xPU0VEID0gMztcblxuLypcbiogU3RhdGljIGNvbnN0cnVjdG9yIG1ldGhvZHMgZm9yIHRoZSBJTyBTb2NrZXRcbiovXG52YXIgSU8gPSBmdW5jdGlvbiBpb0NvbnN0cnVjdG9yKHVybCkge1xuICByZXR1cm4gbmV3IFNvY2tldElPKHVybCk7XG59O1xuXG4vKlxuKiBBbGlhcyB0aGUgcmF3IElPKCkgY29uc3RydWN0b3JcbiovXG5JTy5jb25uZWN0ID0gZnVuY3Rpb24gaW9Db25uZWN0KHVybCkge1xuICAvKiBlc2xpbnQtZGlzYWJsZSBuZXctY2FwICovXG4gIHJldHVybiBJTyh1cmwpO1xuICAvKiBlc2xpbnQtZW5hYmxlIG5ldy1jYXAgKi9cbn07XG5cbmV4cG9ydHNbJ2RlZmF1bHQnXSA9IElPO1xubW9kdWxlLmV4cG9ydHMgPSBleHBvcnRzWydkZWZhdWx0J107IiwiJ3VzZSBzdHJpY3QnO1xuXG5PYmplY3QuZGVmaW5lUHJvcGVydHkoZXhwb3J0cywgJ19fZXNNb2R1bGUnLCB7XG4gIHZhbHVlOiB0cnVlXG59KTtcblxudmFyIF9jcmVhdGVDbGFzcyA9IChmdW5jdGlvbiAoKSB7IGZ1bmN0aW9uIGRlZmluZVByb3BlcnRpZXModGFyZ2V0LCBwcm9wcykgeyBmb3IgKHZhciBpID0gMDsgaSA8IHByb3BzLmxlbmd0aDsgaSsrKSB7IHZhciBkZXNjcmlwdG9yID0gcHJvcHNbaV07IGRlc2NyaXB0b3IuZW51bWVyYWJsZSA9IGRlc2NyaXB0b3IuZW51bWVyYWJsZSB8fCBmYWxzZTsgZGVzY3JpcHRvci5jb25maWd1cmFibGUgPSB0cnVlOyBpZiAoJ3ZhbHVlJyBpbiBkZXNjcmlwdG9yKSBkZXNjcmlwdG9yLndyaXRhYmxlID0gdHJ1ZTsgT2JqZWN0LmRlZmluZVByb3BlcnR5KHRhcmdldCwgZGVzY3JpcHRvci5rZXksIGRlc2NyaXB0b3IpOyB9IH0gcmV0dXJuIGZ1bmN0aW9uIChDb25zdHJ1Y3RvciwgcHJvdG9Qcm9wcywgc3RhdGljUHJvcHMpIHsgaWYgKHByb3RvUHJvcHMpIGRlZmluZVByb3BlcnRpZXMoQ29uc3RydWN0b3IucHJvdG90eXBlLCBwcm90b1Byb3BzKTsgaWYgKHN0YXRpY1Byb3BzKSBkZWZpbmVQcm9wZXJ0aWVzKENvbnN0cnVjdG9yLCBzdGF0aWNQcm9wcyk7IHJldHVybiBDb25zdHJ1Y3RvcjsgfTsgfSkoKTtcblxudmFyIF9nZXQgPSBmdW5jdGlvbiBnZXQoX3gyLCBfeDMsIF94NCkgeyB2YXIgX2FnYWluID0gdHJ1ZTsgX2Z1bmN0aW9uOiB3aGlsZSAoX2FnYWluKSB7IHZhciBvYmplY3QgPSBfeDIsIHByb3BlcnR5ID0gX3gzLCByZWNlaXZlciA9IF94NDsgX2FnYWluID0gZmFsc2U7IGlmIChvYmplY3QgPT09IG51bGwpIG9iamVjdCA9IEZ1bmN0aW9uLnByb3RvdHlwZTsgdmFyIGRlc2MgPSBPYmplY3QuZ2V0T3duUHJvcGVydHlEZXNjcmlwdG9yKG9iamVjdCwgcHJvcGVydHkpOyBpZiAoZGVzYyA9PT0gdW5kZWZpbmVkKSB7IHZhciBwYXJlbnQgPSBPYmplY3QuZ2V0UHJvdG90eXBlT2Yob2JqZWN0KTsgaWYgKHBhcmVudCA9PT0gbnVsbCkgeyByZXR1cm4gdW5kZWZpbmVkOyB9IGVsc2UgeyBfeDIgPSBwYXJlbnQ7IF94MyA9IHByb3BlcnR5OyBfeDQgPSByZWNlaXZlcjsgX2FnYWluID0gdHJ1ZTsgZGVzYyA9IHBhcmVudCA9IHVuZGVmaW5lZDsgY29udGludWUgX2Z1bmN0aW9uOyB9IH0gZWxzZSBpZiAoJ3ZhbHVlJyBpbiBkZXNjKSB7IHJldHVybiBkZXNjLnZhbHVlOyB9IGVsc2UgeyB2YXIgZ2V0dGVyID0gZGVzYy5nZXQ7IGlmIChnZXR0ZXIgPT09IHVuZGVmaW5lZCkgeyByZXR1cm4gdW5kZWZpbmVkOyB9IHJldHVybiBnZXR0ZXIuY2FsbChyZWNlaXZlcik7IH0gfSB9O1xuXG5mdW5jdGlvbiBfaW50ZXJvcFJlcXVpcmVEZWZhdWx0KG9iaikgeyByZXR1cm4gb2JqICYmIG9iai5fX2VzTW9kdWxlID8gb2JqIDogeyAnZGVmYXVsdCc6IG9iaiB9OyB9XG5cbmZ1bmN0aW9uIF9jbGFzc0NhbGxDaGVjayhpbnN0YW5jZSwgQ29uc3RydWN0b3IpIHsgaWYgKCEoaW5zdGFuY2UgaW5zdGFuY2VvZiBDb25zdHJ1Y3RvcikpIHsgdGhyb3cgbmV3IFR5cGVFcnJvcignQ2Fubm90IGNhbGwgYSBjbGFzcyBhcyBhIGZ1bmN0aW9uJyk7IH0gfVxuXG5mdW5jdGlvbiBfaW5oZXJpdHMoc3ViQ2xhc3MsIHN1cGVyQ2xhc3MpIHsgaWYgKHR5cGVvZiBzdXBlckNsYXNzICE9PSAnZnVuY3Rpb24nICYmIHN1cGVyQ2xhc3MgIT09IG51bGwpIHsgdGhyb3cgbmV3IFR5cGVFcnJvcignU3VwZXIgZXhwcmVzc2lvbiBtdXN0IGVpdGhlciBiZSBudWxsIG9yIGEgZnVuY3Rpb24sIG5vdCAnICsgdHlwZW9mIHN1cGVyQ2xhc3MpOyB9IHN1YkNsYXNzLnByb3RvdHlwZSA9IE9iamVjdC5jcmVhdGUoc3VwZXJDbGFzcyAmJiBzdXBlckNsYXNzLnByb3RvdHlwZSwgeyBjb25zdHJ1Y3RvcjogeyB2YWx1ZTogc3ViQ2xhc3MsIGVudW1lcmFibGU6IGZhbHNlLCB3cml0YWJsZTogdHJ1ZSwgY29uZmlndXJhYmxlOiB0cnVlIH0gfSk7IGlmIChzdXBlckNsYXNzKSBPYmplY3Quc2V0UHJvdG90eXBlT2YgPyBPYmplY3Quc2V0UHJvdG90eXBlT2Yoc3ViQ2xhc3MsIHN1cGVyQ2xhc3MpIDogc3ViQ2xhc3MuX19wcm90b19fID0gc3VwZXJDbGFzczsgfVxuXG52YXIgX3VyaWpzID0gcmVxdWlyZSgndXJpanMnKTtcblxudmFyIF91cmlqczIgPSBfaW50ZXJvcFJlcXVpcmVEZWZhdWx0KF91cmlqcyk7XG5cbnZhciBfaGVscGVyc0RlbGF5ID0gcmVxdWlyZSgnLi9oZWxwZXJzL2RlbGF5Jyk7XG5cbnZhciBfaGVscGVyc0RlbGF5MiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX2hlbHBlcnNEZWxheSk7XG5cbnZhciBfZXZlbnRUYXJnZXQgPSByZXF1aXJlKCcuL2V2ZW50LXRhcmdldCcpO1xuXG52YXIgX2V2ZW50VGFyZ2V0MiA9IF9pbnRlcm9wUmVxdWlyZURlZmF1bHQoX2V2ZW50VGFyZ2V0KTtcblxudmFyIF9uZXR3b3JrQnJpZGdlID0gcmVxdWlyZSgnLi9uZXR3b3JrLWJyaWRnZScpO1xuXG52YXIgX25ldHdvcmtCcmlkZ2UyID0gX2ludGVyb3BSZXF1aXJlRGVmYXVsdChfbmV0d29ya0JyaWRnZSk7XG5cbnZhciBfaGVscGVyc0Nsb3NlQ29kZXMgPSByZXF1aXJlKCcuL2hlbHBlcnMvY2xvc2UtY29kZXMnKTtcblxudmFyIF9oZWxwZXJzQ2xvc2VDb2RlczIgPSBfaW50ZXJvcFJlcXVpcmVEZWZhdWx0KF9oZWxwZXJzQ2xvc2VDb2Rlcyk7XG5cbnZhciBfZXZlbnRGYWN0b3J5ID0gcmVxdWlyZSgnLi9ldmVudC1mYWN0b3J5Jyk7XG5cbi8qXG4qIFRoZSBtYWluIHdlYnNvY2tldCBjbGFzcyB3aGljaCBpcyBkZXNpZ25lZCB0byBtaW1pY2sgdGhlIG5hdGl2ZSBXZWJTb2NrZXQgY2xhc3MgYXMgY2xvc2VcbiogYXMgcG9zc2libGUuXG4qXG4qIGh0dHBzOi8vZGV2ZWxvcGVyLm1vemlsbGEub3JnL2VuLVVTL2RvY3MvV2ViL0FQSS9XZWJTb2NrZXRcbiovXG5cbnZhciBXZWJTb2NrZXQgPSAoZnVuY3Rpb24gKF9FdmVudFRhcmdldCkge1xuICBfaW5oZXJpdHMoV2ViU29ja2V0LCBfRXZlbnRUYXJnZXQpO1xuXG4gIC8qXG4gICogQHBhcmFtIHtzdHJpbmd9IHVybFxuICAqL1xuXG4gIGZ1bmN0aW9uIFdlYlNvY2tldCh1cmwpIHtcbiAgICB2YXIgcHJvdG9jb2wgPSBhcmd1bWVudHMubGVuZ3RoIDw9IDEgfHwgYXJndW1lbnRzWzFdID09PSB1bmRlZmluZWQgPyAnJyA6IGFyZ3VtZW50c1sxXTtcblxuICAgIF9jbGFzc0NhbGxDaGVjayh0aGlzLCBXZWJTb2NrZXQpO1xuXG4gICAgX2dldChPYmplY3QuZ2V0UHJvdG90eXBlT2YoV2ViU29ja2V0LnByb3RvdHlwZSksICdjb25zdHJ1Y3RvcicsIHRoaXMpLmNhbGwodGhpcyk7XG5cbiAgICBpZiAoIXVybCkge1xuICAgICAgdGhyb3cgbmV3IFR5cGVFcnJvcignRmFpbGVkIHRvIGNvbnN0cnVjdCBcXCdXZWJTb2NrZXRcXCc6IDEgYXJndW1lbnQgcmVxdWlyZWQsIGJ1dCBvbmx5IDAgcHJlc2VudC4nKTtcbiAgICB9XG5cbiAgICB0aGlzLmJpbmFyeVR5cGUgPSAnYmxvYic7XG4gICAgdGhpcy51cmwgPSAoMCwgX3VyaWpzMlsnZGVmYXVsdCddKSh1cmwpLnRvU3RyaW5nKCk7XG4gICAgdGhpcy5yZWFkeVN0YXRlID0gV2ViU29ja2V0LkNPTk5FQ1RJTkc7XG4gICAgdGhpcy5wcm90b2NvbCA9ICcnO1xuXG4gICAgaWYgKHR5cGVvZiBwcm90b2NvbCA9PT0gJ3N0cmluZycpIHtcbiAgICAgIHRoaXMucHJvdG9jb2wgPSBwcm90b2NvbDtcbiAgICB9IGVsc2UgaWYgKEFycmF5LmlzQXJyYXkocHJvdG9jb2wpICYmIHByb3RvY29sLmxlbmd0aCA+IDApIHtcbiAgICAgIHRoaXMucHJvdG9jb2wgPSBwcm90b2NvbFswXTtcbiAgICB9XG5cbiAgICAvKlxuICAgICogSW4gb3JkZXIgdG8gY2FwdHVyZSB0aGUgY2FsbGJhY2sgZnVuY3Rpb24gd2UgbmVlZCB0byBkZWZpbmUgY3VzdG9tIHNldHRlcnMuXG4gICAgKiBUbyBpbGx1c3RyYXRlOlxuICAgICogICBteVNvY2tldC5vbm9wZW4gPSBmdW5jdGlvbigpIHsgYWxlcnQodHJ1ZSkgfTtcbiAgICAqXG4gICAgKiBUaGUgb25seSB3YXkgdG8gY2FwdHVyZSB0aGF0IGZ1bmN0aW9uIGFuZCBob2xkIG9udG8gaXQgZm9yIGxhdGVyIGlzIHdpdGggdGhlXG4gICAgKiBiZWxvdyBjb2RlOlxuICAgICovXG4gICAgT2JqZWN0LmRlZmluZVByb3BlcnRpZXModGhpcywge1xuICAgICAgb25vcGVuOiB7XG4gICAgICAgIGNvbmZpZ3VyYWJsZTogdHJ1ZSxcbiAgICAgICAgZW51bWVyYWJsZTogdHJ1ZSxcbiAgICAgICAgZ2V0OiBmdW5jdGlvbiBnZXQoKSB7XG4gICAgICAgICAgcmV0dXJuIHRoaXMubGlzdGVuZXJzLm9wZW47XG4gICAgICAgIH0sXG4gICAgICAgIHNldDogZnVuY3Rpb24gc2V0KGxpc3RlbmVyKSB7XG4gICAgICAgICAgdGhpcy5hZGRFdmVudExpc3RlbmVyKCdvcGVuJywgbGlzdGVuZXIpO1xuICAgICAgICB9XG4gICAgICB9LFxuICAgICAgb25tZXNzYWdlOiB7XG4gICAgICAgIGNvbmZpZ3VyYWJsZTogdHJ1ZSxcbiAgICAgICAgZW51bWVyYWJsZTogdHJ1ZSxcbiAgICAgICAgZ2V0OiBmdW5jdGlvbiBnZXQoKSB7XG4gICAgICAgICAgcmV0dXJuIHRoaXMubGlzdGVuZXJzLm1lc3NhZ2U7XG4gICAgICAgIH0sXG4gICAgICAgIHNldDogZnVuY3Rpb24gc2V0KGxpc3RlbmVyKSB7XG4gICAgICAgICAgdGhpcy5hZGRFdmVudExpc3RlbmVyKCdtZXNzYWdlJywgbGlzdGVuZXIpO1xuICAgICAgICB9XG4gICAgICB9LFxuICAgICAgb25jbG9zZToge1xuICAgICAgICBjb25maWd1cmFibGU6IHRydWUsXG4gICAgICAgIGVudW1lcmFibGU6IHRydWUsXG4gICAgICAgIGdldDogZnVuY3Rpb24gZ2V0KCkge1xuICAgICAgICAgIHJldHVybiB0aGlzLmxpc3RlbmVycy5jbG9zZTtcbiAgICAgICAgfSxcbiAgICAgICAgc2V0OiBmdW5jdGlvbiBzZXQobGlzdGVuZXIpIHtcbiAgICAgICAgICB0aGlzLmFkZEV2ZW50TGlzdGVuZXIoJ2Nsb3NlJywgbGlzdGVuZXIpO1xuICAgICAgICB9XG4gICAgICB9LFxuICAgICAgb25lcnJvcjoge1xuICAgICAgICBjb25maWd1cmFibGU6IHRydWUsXG4gICAgICAgIGVudW1lcmFibGU6IHRydWUsXG4gICAgICAgIGdldDogZnVuY3Rpb24gZ2V0KCkge1xuICAgICAgICAgIHJldHVybiB0aGlzLmxpc3RlbmVycy5lcnJvcjtcbiAgICAgICAgfSxcbiAgICAgICAgc2V0OiBmdW5jdGlvbiBzZXQobGlzdGVuZXIpIHtcbiAgICAgICAgICB0aGlzLmFkZEV2ZW50TGlzdGVuZXIoJ2Vycm9yJywgbGlzdGVuZXIpO1xuICAgICAgICB9XG4gICAgICB9XG4gICAgfSk7XG5cbiAgICB2YXIgc2VydmVyID0gX25ldHdvcmtCcmlkZ2UyWydkZWZhdWx0J10uYXR0YWNoV2ViU29ja2V0KHRoaXMsIHRoaXMudXJsKTtcblxuICAgIC8qXG4gICAgKiBUaGlzIGRlbGF5IGlzIG5lZWRlZCBzbyB0aGF0IHdlIGRvbnQgdHJpZ2dlciBhbiBldmVudCBiZWZvcmUgdGhlIGNhbGxiYWNrcyBoYXZlIGJlZW5cbiAgICAqIHNldHVwLiBGb3IgZXhhbXBsZTpcbiAgICAqXG4gICAgKiB2YXIgc29ja2V0ID0gbmV3IFdlYlNvY2tldCgnd3M6Ly9sb2NhbGhvc3QnKTtcbiAgICAqXG4gICAgKiAvLyBJZiB3ZSBkb250IGhhdmUgdGhlIGRlbGF5IHRoZW4gdGhlIGV2ZW50IHdvdWxkIGJlIHRyaWdnZXJlZCByaWdodCBoZXJlIGFuZCB0aGlzIGlzXG4gICAgKiAvLyBiZWZvcmUgdGhlIG9ub3BlbiBoYWQgYSBjaGFuY2UgdG8gcmVnaXN0ZXIgaXRzZWxmLlxuICAgICpcbiAgICAqIHNvY2tldC5vbm9wZW4gPSAoKSA9PiB7IC8vIHRoaXMgd291bGQgbmV2ZXIgYmUgY2FsbGVkIH07XG4gICAgKlxuICAgICogLy8gYW5kIHdpdGggdGhlIGRlbGF5IHRoZSBldmVudCBnZXRzIHRyaWdnZXJlZCBoZXJlIGFmdGVyIGFsbCBvZiB0aGUgY2FsbGJhY2tzIGhhdmUgYmVlblxuICAgICogLy8gcmVnaXN0ZXJlZCA6LSlcbiAgICAqL1xuICAgICgwLCBfaGVscGVyc0RlbGF5MlsnZGVmYXVsdCddKShmdW5jdGlvbiBkZWxheUNhbGxiYWNrKCkge1xuICAgICAgaWYgKHNlcnZlcikge1xuICAgICAgICB0aGlzLnJlYWR5U3RhdGUgPSBXZWJTb2NrZXQuT1BFTjtcbiAgICAgICAgc2VydmVyLmRpc3BhdGNoRXZlbnQoKDAsIF9ldmVudEZhY3RvcnkuY3JlYXRlRXZlbnQpKHsgdHlwZTogJ2Nvbm5lY3Rpb24nIH0pLCBzZXJ2ZXIsIHRoaXMpO1xuICAgICAgICB0aGlzLmRpc3BhdGNoRXZlbnQoKDAsIF9ldmVudEZhY3RvcnkuY3JlYXRlRXZlbnQpKHsgdHlwZTogJ29wZW4nLCB0YXJnZXQ6IHRoaXMgfSkpO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgdGhpcy5yZWFkeVN0YXRlID0gV2ViU29ja2V0LkNMT1NFRDtcbiAgICAgICAgdGhpcy5kaXNwYXRjaEV2ZW50KCgwLCBfZXZlbnRGYWN0b3J5LmNyZWF0ZUV2ZW50KSh7IHR5cGU6ICdlcnJvcicsIHRhcmdldDogdGhpcyB9KSk7XG4gICAgICAgIHRoaXMuZGlzcGF0Y2hFdmVudCgoMCwgX2V2ZW50RmFjdG9yeS5jcmVhdGVDbG9zZUV2ZW50KSh7IHR5cGU6ICdjbG9zZScsIHRhcmdldDogdGhpcywgY29kZTogX2hlbHBlcnNDbG9zZUNvZGVzMlsnZGVmYXVsdCddLkNMT1NFX05PUk1BTCB9KSk7XG5cbiAgICAgICAgY29uc29sZS5lcnJvcignV2ViU29ja2V0IGNvbm5lY3Rpb24gdG8gXFwnJyArIHRoaXMudXJsICsgJ1xcJyBmYWlsZWQnKTtcbiAgICAgIH1cbiAgICB9LCB0aGlzKTtcbiAgfVxuXG4gIC8qXG4gICogVHJhbnNtaXRzIGRhdGEgdG8gdGhlIHNlcnZlciBvdmVyIHRoZSBXZWJTb2NrZXQgY29ubmVjdGlvbi5cbiAgKlxuICAqIGh0dHBzOi8vZGV2ZWxvcGVyLm1vemlsbGEub3JnL2VuLVVTL2RvY3MvV2ViL0FQSS9XZWJTb2NrZXQjc2VuZCgpXG4gICovXG5cbiAgX2NyZWF0ZUNsYXNzKFdlYlNvY2tldCwgW3tcbiAgICBrZXk6ICdzZW5kJyxcbiAgICB2YWx1ZTogZnVuY3Rpb24gc2VuZChkYXRhKSB7XG4gICAgICBpZiAodGhpcy5yZWFkeVN0YXRlID09PSBXZWJTb2NrZXQuQ0xPU0lORyB8fCB0aGlzLnJlYWR5U3RhdGUgPT09IFdlYlNvY2tldC5DTE9TRUQpIHtcbiAgICAgICAgdGhyb3cgbmV3IEVycm9yKCdXZWJTb2NrZXQgaXMgYWxyZWFkeSBpbiBDTE9TSU5HIG9yIENMT1NFRCBzdGF0ZScpO1xuICAgICAgfVxuXG4gICAgICB2YXIgbWVzc2FnZUV2ZW50ID0gKDAsIF9ldmVudEZhY3RvcnkuY3JlYXRlTWVzc2FnZUV2ZW50KSh7XG4gICAgICAgIHR5cGU6ICdtZXNzYWdlJyxcbiAgICAgICAgb3JpZ2luOiB0aGlzLnVybCxcbiAgICAgICAgZGF0YTogZGF0YVxuICAgICAgfSk7XG5cbiAgICAgIHZhciBzZXJ2ZXIgPSBfbmV0d29ya0JyaWRnZTJbJ2RlZmF1bHQnXS5zZXJ2ZXJMb29rdXAodGhpcy51cmwpO1xuXG4gICAgICBpZiAoc2VydmVyKSB7XG4gICAgICAgIHNlcnZlci5kaXNwYXRjaEV2ZW50KG1lc3NhZ2VFdmVudCwgZGF0YSk7XG4gICAgICB9XG4gICAgfVxuXG4gICAgLypcbiAgICAqIENsb3NlcyB0aGUgV2ViU29ja2V0IGNvbm5lY3Rpb24gb3IgY29ubmVjdGlvbiBhdHRlbXB0LCBpZiBhbnkuXG4gICAgKiBJZiB0aGUgY29ubmVjdGlvbiBpcyBhbHJlYWR5IENMT1NFRCwgdGhpcyBtZXRob2QgZG9lcyBub3RoaW5nLlxuICAgICpcbiAgICAqIGh0dHBzOi8vZGV2ZWxvcGVyLm1vemlsbGEub3JnL2VuLVVTL2RvY3MvV2ViL0FQSS9XZWJTb2NrZXQjY2xvc2UoKVxuICAgICovXG4gIH0sIHtcbiAgICBrZXk6ICdjbG9zZScsXG4gICAgdmFsdWU6IGZ1bmN0aW9uIGNsb3NlKCkge1xuICAgICAgaWYgKHRoaXMucmVhZHlTdGF0ZSAhPT0gV2ViU29ja2V0Lk9QRU4pIHtcbiAgICAgICAgcmV0dXJuIHVuZGVmaW5lZDtcbiAgICAgIH1cblxuICAgICAgdmFyIHNlcnZlciA9IF9uZXR3b3JrQnJpZGdlMlsnZGVmYXVsdCddLnNlcnZlckxvb2t1cCh0aGlzLnVybCk7XG4gICAgICB2YXIgY2xvc2VFdmVudCA9ICgwLCBfZXZlbnRGYWN0b3J5LmNyZWF0ZUNsb3NlRXZlbnQpKHtcbiAgICAgICAgdHlwZTogJ2Nsb3NlJyxcbiAgICAgICAgdGFyZ2V0OiB0aGlzLFxuICAgICAgICBjb2RlOiBfaGVscGVyc0Nsb3NlQ29kZXMyWydkZWZhdWx0J10uQ0xPU0VfTk9STUFMXG4gICAgICB9KTtcblxuICAgICAgX25ldHdvcmtCcmlkZ2UyWydkZWZhdWx0J10ucmVtb3ZlV2ViU29ja2V0KHRoaXMsIHRoaXMudXJsKTtcblxuICAgICAgdGhpcy5yZWFkeVN0YXRlID0gV2ViU29ja2V0LkNMT1NFRDtcbiAgICAgIHRoaXMuZGlzcGF0Y2hFdmVudChjbG9zZUV2ZW50KTtcblxuICAgICAgaWYgKHNlcnZlcikge1xuICAgICAgICBzZXJ2ZXIuZGlzcGF0Y2hFdmVudChjbG9zZUV2ZW50LCBzZXJ2ZXIpO1xuICAgICAgfVxuICAgIH1cbiAgfV0pO1xuXG4gIHJldHVybiBXZWJTb2NrZXQ7XG59KShfZXZlbnRUYXJnZXQyWydkZWZhdWx0J10pO1xuXG5XZWJTb2NrZXQuQ09OTkVDVElORyA9IDA7XG5XZWJTb2NrZXQuT1BFTiA9IDE7XG5XZWJTb2NrZXQuQ0xPU0lORyA9IDI7XG5XZWJTb2NrZXQuQ0xPU0VEID0gMztcblxuZXhwb3J0c1snZGVmYXVsdCddID0gV2ViU29ja2V0O1xubW9kdWxlLmV4cG9ydHMgPSBleHBvcnRzWydkZWZhdWx0J107Il19 diff --git a/actioncable/test/javascript_package_test.rb b/actioncable/test/javascript_package_test.rb new file mode 100644 index 0000000000000..948f98e68698f --- /dev/null +++ b/actioncable/test/javascript_package_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" + +class JavascriptPackageTest < ActiveSupport::TestCase + def test_compiled_code_is_in_sync_with_source_code + compiled_files = %w[ + app/assets/javascripts/actioncable.js + app/assets/javascripts/actioncable.esm.js + app/assets/javascripts/action_cable.js + ].map do |file| + Pathname(file).expand_path("#{__dir__}/..") + end + + assert_no_changes -> { compiled_files.map(&:read) } do + system "yarn build", exception: true + end + end +end diff --git a/actioncable/test/server/base_test.rb b/actioncable/test/server/base_test.rb index f0a51c5a7db81..d46debea45a8d 100644 --- a/actioncable/test/server/base_test.rb +++ b/actioncable/test/server/base_test.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" require "active_support/core_ext/hash/indifferent_access" -class BaseTest < ActiveSupport::TestCase +class BaseTest < ActionCable::TestCase def setup @server = ActionCable::Server::Base.new @server.config.cable = { adapter: "async" }.with_indifferent_access @@ -17,17 +19,20 @@ def close conn = FakeConnection.new @server.add_connection(conn) - conn.expects(:close) - @server.restart + assert_called(conn, :close) do + @server.restart + end end test "#restart shuts down worker pool" do - @server.worker_pool.expects(:halt) - @server.restart + assert_called(@server.worker_pool, :halt) do + @server.restart + end end test "#restart shuts down pub/sub adapter" do - @server.pubsub.expects(:shutdown) - @server.restart + assert_called(@server.pubsub, :shutdown) do + @server.restart + end end end diff --git a/actioncable/test/server/broadcasting_test.rb b/actioncable/test/server/broadcasting_test.rb index ed377b7d5dafa..9caa9db477dd5 100644 --- a/actioncable/test/server/broadcasting_test.rb +++ b/actioncable/test/server/broadcasting_test.rb @@ -1,60 +1,40 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" -class BroadcastingTest < ActiveSupport::TestCase - test "fetching a broadcaster converts the broadcasting queue to a string" do - broadcasting = :test_queue - server = TestServer.new - broadcaster = server.broadcaster_for(broadcasting) +class BroadcastingTest < ActionCable::TestCase + setup do + @server = TestServer.new + @broadcasting = "test_queue" + @broadcaster = server.broadcaster_for(@broadcasting) + end + attr_reader :server, :broadcasting, :broadcaster + + test "fetching a broadcaster converts the broadcasting queue to a string" do assert_equal "test_queue", broadcaster.broadcasting end test "broadcast generates notification" do - begin - server = TestServer.new + message = { body: "test message" } + expected_payload = { broadcasting:, message:, coder: ActiveSupport::JSON } - events = [] - ActiveSupport::Notifications.subscribe "broadcast.action_cable" do |*args| - events << ActiveSupport::Notifications::Event.new(*args) + assert_notifications_count("broadcast.action_cable", 1) do + assert_notification("broadcast.action_cable", expected_payload) do + server.broadcast(broadcasting, message) end - - broadcasting = "test_queue" - message = { body: "test message" } - server.broadcast(broadcasting, message) - - assert_equal 1, events.length - assert_equal "broadcast.action_cable", events[0].name - assert_equal broadcasting, events[0].payload[:broadcasting] - assert_equal message, events[0].payload[:message] - assert_equal ActiveSupport::JSON, events[0].payload[:coder] - ensure - ActiveSupport::Notifications.unsubscribe "broadcast.action_cable" end end test "broadcaster from broadcaster_for generates notification" do - begin - server = TestServer.new + message = { body: "test message" } + expected_payload = { broadcasting:, message:, coder: ActiveSupport::JSON } - events = [] - ActiveSupport::Notifications.subscribe "broadcast.action_cable" do |*args| - events << ActiveSupport::Notifications::Event.new(*args) + assert_notifications_count("broadcast.action_cable", 1) do + assert_notification("broadcast.action_cable", expected_payload) do + broadcaster.broadcast(message) end - - broadcasting = "test_queue" - message = { body: "test message" } - - broadcaster = server.broadcaster_for(broadcasting) - broadcaster.broadcast(message) - - assert_equal 1, events.length - assert_equal "broadcast.action_cable", events[0].name - assert_equal broadcasting, events[0].payload[:broadcasting] - assert_equal message, events[0].payload[:message] - assert_equal ActiveSupport::JSON, events[0].payload[:coder] - ensure - ActiveSupport::Notifications.unsubscribe "broadcast.action_cable" end end end diff --git a/actioncable/test/server/health_check_test.rb b/actioncable/test/server/health_check_test.rb new file mode 100644 index 0000000000000..4fa3a108fb192 --- /dev/null +++ b/actioncable/test/server/health_check_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_support/core_ext/hash/indifferent_access" + +class HealthCheckTest < ActionCable::TestCase + def setup + @config = ActionCable::Server::Configuration.new + @config.logger = Logger.new(nil) + @server = ActionCable::Server::Base.new config: @config + @server.config.cable = { adapter: "async" }.with_indifferent_access + + @app = Rack::Lint.new(@server) + end + + + test "no health check app are mounted by default" do + get "/up" + assert_equal 404, response.first + end + + test "setting health_check_path mount the configured health check application" do + @server.config.health_check_path = "/up" + get "/up" + + assert_equal 200, response.first + assert_equal [], response.last.enum_for.to_a + end + + test "health_check_application_can_be_customized" do + @server.config.health_check_path = "/up" + @server.config.health_check_application = health_check_application + get "/up" + + assert_equal 200, response.first + assert_equal ["Hello world!"], response.last.enum_for.to_a + end + + + private + def get(path) + env = Rack::MockRequest.env_for "/up", "HTTP_HOST" => "localhost" + @response = @app.call env + end + + attr_reader :response + + def health_check_application + ->(env) { + [ + 200, + { Rack::CONTENT_TYPE => "text/html" }, + ["Hello world!"], + ] + } + end +end diff --git a/actioncable/test/stubs/global_id.rb b/actioncable/test/stubs/global_id.rb index 334f0d03e8f86..15fab6b8a7237 100644 --- a/actioncable/test/stubs/global_id.rb +++ b/actioncable/test/stubs/global_id.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GlobalID attr_reader :uri delegate :to_param, :to_s, to: :uri diff --git a/actioncable/test/stubs/room.rb b/actioncable/test/stubs/room.rb index 1664b07d128c8..df7236f408909 100644 --- a/actioncable/test/stubs/room.rb +++ b/actioncable/test/stubs/room.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Room attr_reader :id, :name diff --git a/actioncable/test/stubs/test_adapter.rb b/actioncable/test/stubs/test_adapter.rb index bbd142b287a25..1ae5976fa0011 100644 --- a/actioncable/test/stubs/test_adapter.rb +++ b/actioncable/test/stubs/test_adapter.rb @@ -1,10 +1,29 @@ +# 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 def subscribe(channel, callback, success_callback = nil) + subscriber_map[channel] << callback + @@subscribe_called = { channel: channel, callback: callback, success_callback: success_callback } 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 } + end + + def subscriber_map + @subscribers ||= Hash.new { |h, k| h[k] = [] } end end diff --git a/actioncable/test/stubs/test_connection.rb b/actioncable/test/stubs/test_connection.rb index cd2e219d88572..ca026516bb9f3 100644 --- a/actioncable/test/stubs/test_connection.rb +++ b/actioncable/test/stubs/test_connection.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + require "stubs/user" class TestConnection - attr_reader :identifiers, :logger, :current_user, :server, :transmissions + attr_reader :identifiers, :logger, :current_user, :server, :subscriptions, :transmissions - delegate :pubsub, to: :server + delegate :pubsub, :config, to: :server def initialize(user = User.new("lifo"), coder: ActiveSupport::JSON, subscription_adapter: SuccessAdapter) @coder = coder diff --git a/actioncable/test/stubs/test_server.rb b/actioncable/test/stubs/test_server.rb index 5bf2a151dc6c5..2bdf0a28bed91 100644 --- a/actioncable/test/stubs/test_server.rb +++ b/actioncable/test/stubs/test_server.rb @@ -1,4 +1,4 @@ -require "ostruct" +# frozen_string_literal: true class TestServer include ActionCable::Server::Connections @@ -6,11 +6,23 @@ class TestServer attr_reader :logger, :config, :mutex - def initialize(subscription_adapter: SuccessAdapter) - @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + class FakeConfiguration < ActionCable::Server::Configuration + attr_accessor :subscription_adapter, :log_tags, :filter_parameters + + def initialize(subscription_adapter:) + @log_tags = [] + @filter_parameters = [] + @subscription_adapter = subscription_adapter + end - @config = OpenStruct.new(log_tags: [], subscription_adapter: subscription_adapter) + def pubsub_adapter + @subscription_adapter + end + end + def initialize(subscription_adapter: SuccessAdapter) + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + @config = FakeConfiguration.new(subscription_adapter: subscription_adapter) @mutex = Monitor.new end diff --git a/actioncable/test/stubs/user.rb b/actioncable/test/stubs/user.rb index a66b4f87d5592..7894d1d9ae566 100644 --- a/actioncable/test/stubs/user.rb +++ b/actioncable/test/stubs/user.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class User attr_reader :name diff --git a/actioncable/test/subscription_adapter/async_test.rb b/actioncable/test/subscription_adapter/async_test.rb index 7bc2e55d40dc6..6e038259b55ce 100644 --- a/actioncable/test/subscription_adapter/async_test.rb +++ b/actioncable/test/subscription_adapter/async_test.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + require "test_helper" -require_relative "./common" +require_relative "common" class AsyncAdapterTest < ActionCable::TestCase include CommonSubscriptionAdapterTest diff --git a/actioncable/test/subscription_adapter/base_test.rb b/actioncable/test/subscription_adapter/base_test.rb index 212ea49d2fa3b..999dc0cba1069 100644 --- a/actioncable/test/subscription_adapter/base_test.rb +++ b/actioncable/test/subscription_adapter/base_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "stubs/test_server" @@ -39,35 +41,25 @@ class BrokenAdapter < ActionCable::SubscriptionAdapter::Base # TEST METHODS THAT ARE REQUIRED OF THE ADAPTER'S BACKEND STORAGE OBJECT test "#broadcast is implemented" do - broadcast = SuccessAdapter.new(@server).broadcast("channel", "payload") - - assert_respond_to(SuccessAdapter.new(@server), :broadcast) - assert_nothing_raised do - broadcast + SuccessAdapter.new(@server).broadcast("channel", "payload") end end test "#subscribe is implemented" do callback = lambda { puts "callback" } success_callback = lambda { puts "success" } - subscribe = SuccessAdapter.new(@server).subscribe("channel", callback, success_callback) - - assert_respond_to(SuccessAdapter.new(@server), :subscribe) assert_nothing_raised do - subscribe + SuccessAdapter.new(@server).subscribe("channel", callback, success_callback) end end test "#unsubscribe is implemented" do callback = lambda { puts "callback" } - unsubscribe = SuccessAdapter.new(@server).unsubscribe("channel", callback) - - assert_respond_to(SuccessAdapter.new(@server), :unsubscribe) assert_nothing_raised do - unsubscribe + SuccessAdapter.new(@server).unsubscribe("channel", callback) end end end diff --git a/actioncable/test/subscription_adapter/channel_prefix.rb b/actioncable/test/subscription_adapter/channel_prefix.rb index 9ad659912eb96..16baa4e2d6c3a 100644 --- a/actioncable/test/subscription_adapter/channel_prefix.rb +++ b/actioncable/test/subscription_adapter/channel_prefix.rb @@ -1,17 +1,11 @@ -require "test_helper" +# frozen_string_literal: true -class ActionCable::Server::WithIndependentConfig < ActionCable::Server::Base - # ActionCable::Server::Base defines config as a class variable. - # Need config to be an instance variable here as we're testing 2 separate configs - def config - @config ||= ActionCable::Server::Configuration.new - end -end +require "test_helper" module ChannelPrefixTest def test_channel_prefix - server2 = ActionCable::Server::WithIndependentConfig.new - server2.config.cable = alt_cable_config + server2 = ActionCable::Server::Base.new(config: ActionCable::Server::Configuration.new) + server2.config.cable = alt_cable_config.with_indifferent_access server2.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } adapter_klass = server2.config.pubsub_adapter diff --git a/actioncable/test/subscription_adapter/common.rb b/actioncable/test/subscription_adapter/common.rb index 3aa88c2caa1b0..b3e9ae9d5ceca 100644 --- a/actioncable/test/subscription_adapter/common.rb +++ b/actioncable/test/subscription_adapter/common.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" require "concurrent" @@ -30,7 +32,7 @@ def subscribe_as_queue(channel, adapter = @rx_adapter) subscribed = Concurrent::Event.new adapter.subscribe(channel, callback, Proc.new { subscribed.set }) subscribed.wait(WAIT_WHEN_EXPECTING_EVENT) - assert subscribed.set? + assert_predicate subscribed, :set? yield queue @@ -112,4 +114,18 @@ def test_channel_filtered_broadcast assert_equal "two", queue.pop end end + + def test_long_identifiers + channel_1 = "a" * 100 + "1" + channel_2 = "a" * 100 + "2" + subscribe_as_queue(channel_1) do |queue| + subscribe_as_queue(channel_2) do |queue_2| + @tx_adapter.broadcast(channel_1, "apples") + @tx_adapter.broadcast(channel_2, "oranges") + + assert_equal "apples", queue.pop + assert_equal "oranges", queue_2.pop + end + end + end end diff --git a/actioncable/test/subscription_adapter/evented_redis_test.rb b/actioncable/test/subscription_adapter/evented_redis_test.rb deleted file mode 100644 index c55d35848e1f0..0000000000000 --- a/actioncable/test/subscription_adapter/evented_redis_test.rb +++ /dev/null @@ -1,57 +0,0 @@ -require "test_helper" -require_relative "./common" -require_relative "./channel_prefix" - -class EventedRedisAdapterTest < ActionCable::TestCase - include CommonSubscriptionAdapterTest - include ChannelPrefixTest - - def setup - super - - # em-hiredis is warning-rich - @previous_verbose, $VERBOSE = $VERBOSE, nil - end - - def teardown - super - - # Ensure EM is shut down before we re-enable warnings - EventMachine.reactor_thread.tap do |thread| - EventMachine.stop - thread.join - end - - $VERBOSE = @previous_verbose - end - - def test_slow_eventmachine - require "eventmachine" - require "thread" - - lock = Mutex.new - - EventMachine.singleton_class.class_eval do - alias_method :delayed_initialize_event_machine, :initialize_event_machine - define_method(:initialize_event_machine) do - lock.synchronize do - sleep 0.5 - delayed_initialize_event_machine - end - end - end - - test_basic_broadcast - ensure - lock.synchronize do - EventMachine.singleton_class.class_eval do - alias_method :initialize_event_machine, :delayed_initialize_event_machine - remove_method :delayed_initialize_event_machine - end - end - end - - def cable_config - { adapter: "evented_redis", url: "redis://127.0.0.1:6379/12" } - end -end diff --git a/actioncable/test/subscription_adapter/inline_test.rb b/actioncable/test/subscription_adapter/inline_test.rb index 52bfa00abae57..6305626b2bb39 100644 --- a/actioncable/test/subscription_adapter/inline_test.rb +++ b/actioncable/test/subscription_adapter/inline_test.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + require "test_helper" -require_relative "./common" +require_relative "common" class InlineAdapterTest < ActionCable::TestCase include CommonSubscriptionAdapterTest diff --git a/actioncable/test/subscription_adapter/postgresql_test.rb b/actioncable/test/subscription_adapter/postgresql_test.rb index beb6efab285ab..5dd091621e818 100644 --- a/actioncable/test/subscription_adapter/postgresql_test.rb +++ b/actioncable/test/subscription_adapter/postgresql_test.rb @@ -1,10 +1,14 @@ +# frozen_string_literal: true + require "test_helper" -require_relative "./common" +require_relative "common" +require_relative "channel_prefix" require "active_record" class PostgresqlAdapterTest < ActionCable::TestCase include CommonSubscriptionAdapterTest + include ChannelPrefixTest def setup database_config = { "adapter" => "postgresql", "database" => "activerecord_unittest" } @@ -12,14 +16,14 @@ def setup if Dir.exist?(ar_tests) require File.join(ar_tests, "config") require File.join(ar_tests, "support/config") - local_config = ARTest.config["arunit"] + local_config = ARTest.config["connections"]["postgresql"]["arunit"] database_config.update local_config if local_config end ActiveRecord::Base.establish_connection database_config begin - ActiveRecord::Base.connection + ActiveRecord::Base.lease_connection.connect! rescue @rx_adapter = @tx_adapter = nil skip "Couldn't connect to PostgreSQL: #{database_config.inspect}" @@ -31,10 +35,53 @@ def setup def teardown super - ActiveRecord::Base.clear_all_connections! + ActiveRecord::Base.connection_handler.clear_all_connections! end def cable_config { adapter: "postgresql" } end + + def test_clear_active_record_connections_adapter_still_works + server = ActionCable::Server::Base.new + server.config.cable = cable_config.with_indifferent_access + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + adapter_klass = Class.new(server.config.pubsub_adapter) do + def active? + !@listener.nil? + end + end + + adapter = adapter_klass.new(server) + + subscribe_as_queue("channel", adapter) do |queue| + adapter.broadcast("channel", "hello world") + assert_equal "hello world", queue.pop + end + + ActiveRecord::Base.connection_handler.clear_reloadable_connections! + + assert_predicate adapter, :active? + end + + def test_default_subscription_connection_identifier + subscribe_as_queue("channel") { } + + identifiers = ActiveRecord::Base.lease_connection.exec_query("SELECT application_name FROM pg_stat_activity").rows + assert_includes identifiers, ["ActionCable-PID-#{$$}"] + end + + def test_custom_subscription_connection_identifier + server = ActionCable::Server::Base.new + server.config.cable = cable_config.merge(id: "hello-world-42").with_indifferent_access + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + adapter = server.config.pubsub_adapter.new(server) + + subscribe_as_queue("channel", adapter) { } + + identifiers = ActiveRecord::Base.lease_connection.exec_query("SELECT application_name FROM pg_stat_activity").rows + assert_includes identifiers, ["hello-world-42"] + end end diff --git a/actioncable/test/subscription_adapter/redis_test.rb b/actioncable/test/subscription_adapter/redis_test.rb index 4df5e0cbcd038..e90e4129ce89e 100644 --- a/actioncable/test/subscription_adapter/redis_test.rb +++ b/actioncable/test/subscription_adapter/redis_test.rb @@ -1,18 +1,162 @@ +# frozen_string_literal: true + require "test_helper" -require_relative "./common" -require_relative "./channel_prefix" +require_relative "common" +require_relative "channel_prefix" class RedisAdapterTest < ActionCable::TestCase include CommonSubscriptionAdapterTest include ChannelPrefixTest def cable_config - { adapter: "redis", driver: "ruby", url: "redis://127.0.0.1:6379/12" } + { adapter: "redis", driver: "ruby" }.tap do |x| + if host = ENV["REDIS_URL"] + x[:url] = host + end + end + end + + def test_reconnections + subscribe_as_queue("channel") do |queue| + subscribe_as_queue("other channel") do |queue_2| + @tx_adapter.broadcast("channel", "hello world") + + assert_equal "hello world", queue.pop + + drop_pubsub_connections + wait_pubsub_connection(redis_conn, "channel") + + @tx_adapter.broadcast("channel", "hallo welt") + + assert_equal "hallo welt", queue.pop + + drop_pubsub_connections + wait_pubsub_connection(redis_conn, "channel") + wait_pubsub_connection(redis_conn, "other channel") + + @tx_adapter.broadcast("channel", "hola mundo") + @tx_adapter.broadcast("other channel", "other message") + + assert_equal "hola mundo", queue.pop + assert_equal "other message", queue_2.pop + end + end + end + + private + def redis_conn + @redis_conn ||= ::Redis.new(cable_config.except(:adapter)) + end + + def drop_pubsub_connections + # Emulate connection failure by dropping all connections + redis_conn.client("kill", "type", "pubsub") + end + + def wait_pubsub_connection(redis_conn, channel, timeout: 5) + wait = timeout + loop do + break if redis_conn.pubsub("numsub", channel).last > 0 + + sleep 0.1 + wait -= 0.1 + + raise "Timed out to subscribe to #{channel}" if wait <= 0 + end + end +end + +class RedisAdapterTest::AlternateConfiguration < RedisAdapterTest + def cable_config + alt_cable_config = super.dup + alt_cable_config.delete(:url) + url = URI(ENV["REDIS_URL"] || "") + alt_cable_config.merge(host: url.hostname || "127.0.0.1", port: url.port || 6379, db: 12) end end -class RedisAdapterTest::Hiredis < RedisAdapterTest +class RedisAdapterTest::ConnectorDefaultID < ActionCable::TestCase + def setup + server = ActionCable::Server::Base.new + server.config.cable = cable_config.merge(adapter: "redis").with_indifferent_access + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + @adapter = server.config.pubsub_adapter.new(server) + end + def cable_config - super.merge(driver: "hiredis") + { url: 1, host: 2, port: 3, db: 4, password: 5 } + end + + def connection_id + "ActionCable-PID-#{$$}" + end + + def expected_connection + cable_config.merge(id: connection_id) + end + + test "sets connection id for connection" do + assert_called_with ::Redis, :new, [ expected_connection.symbolize_keys ] do + @adapter.send(:redis_connection) + end + end +end + +class RedisAdapterTest::ConnectorCustomID < RedisAdapterTest::ConnectorDefaultID + def cable_config + super.merge(id: connection_id) + end + + def connection_id + "Some custom 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") + end + + def expected_connection + super.except(:adapter, :channel_prefix) + end +end + +class RedisAdapterTest::SentinelConfigAsHash < ActionCable::TestCase + def setup + server = ActionCable::Server::Base.new + server.config.cable = cable_config.merge(adapter: "redis").with_indifferent_access + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + @adapter = server.config.pubsub_adapter.new(server) + end + + def cable_config + { url: "redis://test", sentinels: [{ "host" => "localhost", "port" => 26379 }] } + end + + def expected_connection + { url: "redis://test", sentinels: [{ host: "localhost", port: 26379 }], id: connection_id } + end + + def connection_id + "ActionCable-PID-#{$$}" + end + + test "sets sentinels as array of hashes with keyword arguments" do + assert_called_with ::Redis, :new, [ expected_connection ] do + @adapter.send(:redis_connection) + end end end diff --git a/actioncable/test/subscription_adapter/subscriber_map_test.rb b/actioncable/test/subscription_adapter/subscriber_map_test.rb index 76b984c8495a2..ed81099cbc6e7 100644 --- a/actioncable/test/subscription_adapter/subscriber_map_test.rb +++ b/actioncable/test/subscription_adapter/subscriber_map_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" class SubscriberMapTest < ActionCable::TestCase diff --git a/actioncable/test/subscription_adapter/test_adapter_test.rb b/actioncable/test/subscription_adapter/test_adapter_test.rb new file mode 100644 index 0000000000000..3fe07adb4ae35 --- /dev/null +++ b/actioncable/test/subscription_adapter/test_adapter_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" + +class ActionCable::SubscriptionAdapter::TestTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + + def setup + super + + @tx_adapter.shutdown + @tx_adapter = @rx_adapter + end + + def cable_config + { adapter: "test" } + end + + test "#broadcast stores messages for streams" do + @tx_adapter.broadcast("channel", "payload") + @tx_adapter.broadcast("channel2", "payload2") + + assert_equal ["payload"], @tx_adapter.broadcasts("channel") + assert_equal ["payload2"], @tx_adapter.broadcasts("channel2") + end + + test "#clear_messages deletes recorded broadcasts for the channel" do + @tx_adapter.broadcast("channel", "payload") + @tx_adapter.broadcast("channel2", "payload2") + + @tx_adapter.clear_messages("channel") + + assert_equal [], @tx_adapter.broadcasts("channel") + assert_equal ["payload2"], @tx_adapter.broadcasts("channel2") + end + + test "#clear deletes all recorded broadcasts" do + @tx_adapter.broadcast("channel", "payload") + @tx_adapter.broadcast("channel2", "payload2") + + @tx_adapter.clear + + assert_equal [], @tx_adapter.broadcasts("channel") + assert_equal [], @tx_adapter.broadcasts("channel2") + end +end diff --git a/actioncable/test/test_helper.rb b/actioncable/test/test_helper.rb index a47032753beaa..d6305dcc766cf 100644 --- a/actioncable/test/test_helper.rb +++ b/actioncable/test/test_helper.rb @@ -1,19 +1,23 @@ +# frozen_string_literal: true + +require_relative "../../tools/strict_warnings" require "action_cable" require "active_support/testing/autorun" +require "active_support/testing/method_call_assertions" require "puma" -require "mocha/setup" require "rack/mock" -begin - require "byebug" -rescue LoadError -end - # Require all the stubs and models -Dir[File.dirname(__FILE__) + "/stubs/*.rb"].each { |file| require file } +Dir[File.expand_path("stubs/*.rb", __dir__)].each { |file| require file } + +# Set test adapter and logger +ActionCable.server.config.cable = { "adapter" => "test" } +ActionCable.server.config.logger = Logger.new(nil) class ActionCable::TestCase < ActiveSupport::TestCase + include ActiveSupport::Testing::MethodCallAssertions + def wait_for_async wait_for_executor Concurrent.global_io_executor end @@ -33,3 +37,5 @@ def wait_for_executor(executor) end end end + +require_relative "../../tools/test_common" diff --git a/actioncable/test/test_helper_test.rb b/actioncable/test/test_helper_test.rb new file mode 100644 index 0000000000000..ef5615f4b0b84 --- /dev/null +++ b/actioncable/test/test_helper_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "test_helper" + +class BroadcastChannel < ActionCable::Channel::Base +end + +class TransmissionsTest < ActionCable::TestCase + def test_assert_broadcasts + assert_nothing_raised do + assert_broadcasts("test", 1) do + ActionCable.server.broadcast "test", "message" + end + end + end + + def test_capture_broadcasts + messages = capture_broadcasts("test") do + ActionCable.server.broadcast "test", "message" + end + assert_equal "message", messages.first + + messages = capture_broadcasts("test") do + ActionCable.server.broadcast "test", { message: "one" } + ActionCable.server.broadcast "test", { message: "two" } + end + assert_equal 2, messages.length + assert_equal({ "message" => "one" }, messages.first) + assert_equal({ "message" => "two" }, messages.last) + end + + def test_assert_broadcasts_with_no_block + assert_nothing_raised do + ActionCable.server.broadcast "test", "message" + assert_broadcasts "test", 1 + end + + assert_nothing_raised do + ActionCable.server.broadcast "test", "message 2" + ActionCable.server.broadcast "test", "message 3" + assert_broadcasts "test", 3 + end + end + + def test_assert_no_broadcasts_with_no_block + assert_nothing_raised do + assert_no_broadcasts "test" + end + end + + def test_assert_no_broadcasts + assert_nothing_raised do + assert_no_broadcasts("test") do + ActionCable.server.broadcast "test2", "message" + end + end + end + + def test_assert_broadcasts_message_too_few_sent + ActionCable.server.broadcast "test", "hello" + error = assert_raises Minitest::Assertion do + assert_broadcasts("test", 2) do + ActionCable.server.broadcast "test", "world" + end + end + + assert_match(/2 .* but 1/, error.message) + end + + def test_assert_broadcasts_message_too_many_sent + error = assert_raises Minitest::Assertion do + assert_broadcasts("test", 1) do + ActionCable.server.broadcast "test", "hello" + ActionCable.server.broadcast "test", "world" + end + end + + assert_match(/1 .* but 2/, error.message) + end + + def test_assert_no_broadcasts_failure + error = assert_raises Minitest::Assertion do + assert_no_broadcasts "test" do + ActionCable.server.broadcast "test", "hello" + end + end + + assert_match(/0 .* but 1/, error.message) + end +end + +class TransmittedDataTest < ActionCable::TestCase + include ActionCable::TestHelper + + def test_assert_broadcast_on + assert_nothing_raised do + assert_broadcast_on("test", "message") do + ActionCable.server.broadcast "test", "message" + end + end + end + + def test_assert_broadcast_on_with_hash + assert_nothing_raised do + assert_broadcast_on("test", text: "hello") do + ActionCable.server.broadcast "test", { text: "hello" } + end + end + end + + def test_assert_broadcast_on_with_no_block + assert_nothing_raised do + ActionCable.server.broadcast "test", "hello" + assert_broadcast_on "test", "hello" + end + + assert_nothing_raised do + ActionCable.server.broadcast "test", "world" + assert_broadcast_on "test", "world" + end + end + + def test_assert_broadcast_on_message + ActionCable.server.broadcast "test", "hello" + error = assert_raises Minitest::Assertion do + assert_broadcast_on("test", "world") + end + + assert_match(/No messages sent/, error.message) + assert_match(/Message\(s\) found:\nhello/, error.message) + end + + def test_assert_broadcast_on_message_with_empty_channel + error = assert_raises Minitest::Assertion do + assert_broadcast_on("test", "world") + end + + assert_match(/No messages sent/, error.message) + assert_match(/No message found for test/, error.message) + end +end diff --git a/actioncable/test/worker_test.rb b/actioncable/test/worker_test.rb index 3385593f745ea..f7dc428441ab6 100644 --- a/actioncable/test/worker_test.rb +++ b/actioncable/test/worker_test.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require "test_helper" -class WorkerTest < ActiveSupport::TestCase +class WorkerTest < ActionCable::TestCase class Receiver attr_accessor :last_action diff --git a/actionmailbox/.gitignore b/actionmailbox/.gitignore new file mode 100644 index 0000000000000..d84c713a08039 --- /dev/null +++ b/actionmailbox/.gitignore @@ -0,0 +1,7 @@ +/test/dummy/storage/*.sqlite3 +/test/dummy/storage/*.sqlite3-* +/test/dummy/db/*.sqlite3 +/test/dummy/db/*.sqlite3-* +/test/dummy/log/*.log +/test/dummy/tmp/ +/tmp/ diff --git a/actionmailbox/CHANGELOG.md b/actionmailbox/CHANGELOG.md new file mode 100644 index 0000000000000..a4d7342c05681 --- /dev/null +++ b/actionmailbox/CHANGELOG.md @@ -0,0 +1,2 @@ + +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionmailbox/CHANGELOG.md) for previous changes. diff --git a/actionmailbox/MIT-LICENSE b/actionmailbox/MIT-LICENSE new file mode 100644 index 0000000000000..18b341857b9f9 --- /dev/null +++ b/actionmailbox/MIT-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 37signals LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/actionmailbox/README.md b/actionmailbox/README.md new file mode 100644 index 0000000000000..47fcbc0113fe1 --- /dev/null +++ b/actionmailbox/README.md @@ -0,0 +1,13 @@ +# Action Mailbox + +Action Mailbox routes incoming emails to controller-like mailboxes for processing in \Rails. It ships with ingresses for Mailgun, Mandrill, Postmark, and SendGrid. You can also handle inbound mails directly via the built-in Exim, Postfix, and Qmail ingresses. + +The inbound emails are turned into `InboundEmail` records using Active Record and feature lifecycle tracking, storage of the original email on cloud storage via Active Storage, and responsible data handling with on-by-default incineration. + +These inbound emails are routed asynchronously using Active Job to one or several dedicated mailboxes, which are capable of interacting directly with the rest of your domain model. + +You can read more about Action Mailbox in the [Action Mailbox Basics](https://guides.rubyonrails.org/action_mailbox_basics.html) guide. + +## License + +Action Mailbox is released under the [MIT License](https://opensource.org/licenses/MIT). diff --git a/actionmailbox/Rakefile b/actionmailbox/Rakefile new file mode 100644 index 0000000000000..4590a03128eac --- /dev/null +++ b/actionmailbox/Rakefile @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "bundler/gem_tasks" +require "rake/testtask" + +ENV["RAILS_MINITEST_PLUGIN"] = "true" + +Rake::TestTask.new do |t| + t.libs << "test" + t.pattern = "test/**/*_test.rb" + t.verbose = true + t.options = "--profile" if ENV["CI"] +end + +namespace :test 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/actionmailbox.gemspec b/actionmailbox/actionmailbox.gemspec new file mode 100644 index 0000000000000..7496467f95f73 --- /dev/null +++ b/actionmailbox/actionmailbox.gemspec @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip + +Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = "actionmailbox" + s.version = version + s.summary = "Inbound email handling framework." + s.description = "Receive and process incoming emails in Rails applications." + + s.required_ruby_version = ">= 3.2.0" + + s.license = "MIT" + + s.authors = ["David Heinemeier Hansson", "George Claghorn"] + s.email = ["david@loudthinking.com", "george@basecamp.com"] + s.homepage = "https://rubyonrails.org" + + s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.md", "lib/**/*", "app/**/*", "config/**/*", "db/**/*"] + s.require_path = "lib" + + s.metadata = { + "bug_tracker_uri" => "https://github.com/rails/rails/issues", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actionmailbox/CHANGELOG.md", + "documentation_uri" => "https://api.rubyonrails.org/v#{version}/", + "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actionmailbox", + "rubygems_mfa_required" => "true", + } + + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + + s.add_dependency "activesupport", version + s.add_dependency "activerecord", version + s.add_dependency "activestorage", version + s.add_dependency "activejob", version + s.add_dependency "actionpack", version + + s.add_dependency "mail", ">= 2.8.0" +end diff --git a/actionmailbox/app/controllers/action_mailbox/base_controller.rb b/actionmailbox/app/controllers/action_mailbox/base_controller.rb new file mode 100644 index 0000000000000..fdd3b5e735aa8 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/base_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ActionMailbox + # The base class for all Action Mailbox ingress controllers. + class BaseController < ActionController::Base + skip_forgery_protection + + before_action :ensure_configured + + private + def ensure_configured + unless ActionMailbox.ingress == ingress_name + head :not_found + end + end + + def ingress_name + self.class.name.remove(/\AActionMailbox::Ingresses::/, /::InboundEmailsController\z/).underscore.to_sym + end + + + def authenticate_by_password + if password.present? + http_basic_authenticate_or_request_with name: "actionmailbox", password: password, realm: "Action Mailbox" + else + raise ArgumentError, "Missing required ingress credentials" + end + end + + def password + Rails.application.credentials.dig(:action_mailbox, :ingress_password) || ENV["RAILS_INBOUND_EMAIL_PASSWORD"] + end + end +end diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb new file mode 100644 index 0000000000000..c14e5334755e8 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails from Mailgun. Requires the following parameters: + # + # - +body-mime+: The full RFC 822 message + # - +timestamp+: The current time according to Mailgun as the number of seconds passed since the UNIX epoch + # - +token+: A randomly-generated, 50-character string + # - +signature+: A hexadecimal HMAC-SHA256 of the timestamp concatenated with the token, generated using the Mailgun Signing key + # + # Authenticates requests by validating their signatures. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request's signature could not be validated, or if its timestamp is more than 2 minutes old + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails from Mailgun + # - 422 Unprocessable Entity if the request is missing required parameters + # - 500 Server Error if the Mailgun Signing key is missing, or one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + # + # == Usage + # + # 1. Give Action Mailbox your Mailgun Signing key (which you can find under Settings -> Security & Users -> API security in Mailgun) + # so it can authenticate requests to the Mailgun ingress. + # + # Use bin/rails credentials:edit to add your Signing key to your application's encrypted credentials under + # +action_mailbox.mailgun_signing_key+, where Action Mailbox will automatically find it: + # + # action_mailbox: + # mailgun_signing_key: ... + # + # Alternatively, provide your Signing key in the +MAILGUN_INGRESS_SIGNING_KEY+ environment variable. + # + # 2. Tell Action Mailbox to accept emails from Mailgun: + # + # # config/environments/production.rb + # config.action_mailbox.ingress = :mailgun + # + # 3. {Configure Mailgun}[https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages] + # to forward inbound emails to +/rails/action_mailbox/mailgun/inbound_emails/mime+. + # + # If your application lived at https://example.com, you would specify the fully-qualified URL + # https://example.com/rails/action_mailbox/mailgun/inbound_emails/mime. + class Ingresses::Mailgun::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate + param_encoding :create, "body-mime", Encoding::ASCII_8BIT + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! mail + end + + private + def mail + params.require("body-mime").tap do |raw_email| + raw_email.prepend("X-Original-To: ", params.require(:recipient), "\n") if params.key?(:recipient) + end + end + + def authenticate + head :unauthorized unless authenticated? + end + + def authenticated? + if key.present? + Authenticator.new( + key: key, + timestamp: params.require(:timestamp), + token: params.require(:token), + signature: params.require(:signature) + ).authenticated? + else + raise ArgumentError, <<~MESSAGE.squish + Missing required Mailgun Signing key. Set action_mailbox.mailgun_signing_key in your application's + encrypted credentials or provide the MAILGUN_INGRESS_SIGNING_KEY environment variable. + MESSAGE + end + end + + def key + Rails.application.credentials.dig(:action_mailbox, :mailgun_signing_key) || ENV["MAILGUN_INGRESS_SIGNING_KEY"] + end + + class Authenticator + attr_reader :key, :timestamp, :token, :signature + + def initialize(key:, timestamp:, token:, signature:) + @key, @timestamp, @token, @signature = key, Integer(timestamp), token, signature + end + + def authenticated? + signed? && recent? + end + + private + def signed? + ActiveSupport::SecurityUtils.secure_compare signature, expected_signature + end + + # Allow for 2 minutes of drift between Mailgun time and local server time. + def recent? + Time.at(timestamp) >= 2.minutes.ago + end + + def expected_signature + OpenSSL::HMAC.hexdigest OpenSSL::Digest::SHA256.new, key, "#{timestamp}#{token}" + end + end + end +end 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 new file mode 100644 index 0000000000000..15249c3803007 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails from Mandrill. + # + # Requires a +mandrill_events+ parameter containing a JSON array of Mandrill inbound email event objects. + # Each event is expected to have a +msg+ object containing a full RFC 822 message in its +raw_msg+ property. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request's signature could not be validated + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails from Mandrill + # - 422 Unprocessable Entity if the request is missing required parameters + # - 500 Server Error if the Mandrill API key is missing, or one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + class Ingresses::Mandrill::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate, except: :health_check + + def create + raw_emails.each { |raw_email| ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email } + head :ok + rescue JSON::ParserError => error + logger.error error.message + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT + end + + def health_check + head :ok + end + + private + def raw_emails + events.select { |event| event["event"] == "inbound" }.collect { |event| event.dig("msg", "raw_msg") } + end + + def events + JSON.parse params.require(:mandrill_events) + end + + + def authenticate + head :unauthorized unless authenticated? + end + + def authenticated? + if key.present? + Authenticator.new(request, key).authenticated? + else + raise ArgumentError, <<~MESSAGE.squish + Missing required Mandrill API key. Set action_mailbox.mandrill_api_key in your application's + encrypted credentials or provide the MANDRILL_INGRESS_API_KEY environment variable. + MESSAGE + end + end + + def key + Rails.application.credentials.dig(:action_mailbox, :mandrill_api_key) || ENV["MANDRILL_INGRESS_API_KEY"] + end + + class Authenticator + attr_reader :request, :key + + def initialize(request, key) + @request, @key = request, key + end + + def authenticated? + ActiveSupport::SecurityUtils.secure_compare given_signature, expected_signature + end + + private + def given_signature + request.headers["X-Mandrill-Signature"] + end + + def expected_signature + Base64.strict_encode64 OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, key, message) + end + + def message + request.url + request.POST.sort.flatten.join + end + end + end +end 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 new file mode 100644 index 0000000000000..cb32a58767cf7 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails from Postmark. Requires a +RawEmail+ parameter containing a full RFC 822 message. + # + # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the + # password is read from the application's encrypted credentials or an environment variable. See the Usage section below. + # + # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to + # the Postmark ingress can learn its password. You should only use the Postmark ingress over HTTPS. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request's signature could not be validated + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails from Postmark + # - 422 Unprocessable Entity if the request is missing the required +RawEmail+ parameter + # - 500 Server Error if the ingress password is not configured, or if one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + # + # == Usage + # + # 1. Tell Action Mailbox to accept emails from Postmark: + # + # # config/environments/production.rb + # config.action_mailbox.ingress = :postmark + # + # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the Postmark ingress. + # + # Use bin/rails credentials:edit to add the password to your application's encrypted credentials under + # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it: + # + # action_mailbox: + # ingress_password: ... + # + # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable. + # + # 3. {Configure Postmark}[https://postmarkapp.com/manual#configure-your-inbound-webhook-url] to forward inbound emails + # to +/rails/action_mailbox/postmark/inbound_emails+ with the username +actionmailbox+ and the password you + # previously generated. If your application lived at https://example.com, you would configure your + # Postmark inbound webhook with the following fully-qualified URL: + # + # https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/postmark/inbound_emails + # + # *NOTE:* When configuring your Postmark inbound webhook, be sure to check the box labeled *"Include raw email + # content in JSON payload"*. Action Mailbox needs the raw email content to work. + class Ingresses::Postmark::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate_by_password + param_encoding :create, "RawEmail", Encoding::ASCII_8BIT + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! mail + rescue ActionController::ParameterMissing => error + logger.error <<~MESSAGE + #{error.message} + + When configuring your Postmark inbound webhook, be sure to check the box + labeled "Include raw email content in JSON payload". + MESSAGE + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT + end + + private + def mail + params.require("RawEmail").tap do |raw_email| + raw_email.prepend("X-Original-To: ", params.require("OriginalRecipient"), "\n") if params.key?("OriginalRecipient") + end + end + end +end 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 new file mode 100644 index 0000000000000..2cf5ceece8f51 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails relayed from an SMTP server. + # + # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the + # password is read from the application's encrypted credentials or an environment variable. See the Usage section below. + # + # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to + # the ingress can learn its password. You should only use this ingress over HTTPS. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request could not be authenticated + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails relayed from an SMTP server + # - 415 Unsupported Media Type if the request does not contain an RFC 822 message + # - 500 Server Error if the ingress password is not configured, or if one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + # + # == Usage + # + # 1. Tell Action Mailbox to accept emails from an SMTP relay: + # + # # config/environments/production.rb + # config.action_mailbox.ingress = :relay + # + # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the ingress. + # + # Use bin/rails credentials:edit to add the password to your application's encrypted credentials under + # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it: + # + # action_mailbox: + # ingress_password: ... + # + # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable. + # + # 3. Configure your SMTP server to pipe inbound emails to the appropriate ingress command, providing the +URL+ of the + # relay ingress and the +INGRESS_PASSWORD+ you previously generated. + # + # If your application lives at https://example.com, you would configure the Postfix SMTP server to pipe + # inbound emails to the following command: + # + # $ bin/rails action_mailbox:ingress:postfix URL=https://example.com/rails/action_mailbox/postfix/inbound_emails INGRESS_PASSWORD=... + # + # Built-in ingress commands are available for these popular SMTP servers: + # + # - Exim (bin/rails action_mailbox:ingress:exim) + # - Postfix (bin/rails action_mailbox:ingress:postfix) + # - Qmail (bin/rails action_mailbox:ingress:qmail) + class Ingresses::Relay::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate_by_password, :require_valid_rfc822_message + + def create + if request.body + ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read + else + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT + end + end + + private + def require_valid_rfc822_message + unless request.media_type == "message/rfc822" + head :unsupported_media_type + end + end + 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 new file mode 100644 index 0000000000000..19904c1aae634 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails from SendGrid. Requires an +email+ parameter containing a full RFC 822 message. + # + # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the + # password is read from the application's encrypted credentials or an environment variable. See the Usage section below. + # + # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to + # the SendGrid ingress can learn its password. You should only use the SendGrid ingress over HTTPS. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request's signature could not be validated + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails from SendGrid + # - 422 Unprocessable Entity if the request is missing the required +email+ parameter + # - 500 Server Error if the ingress password is not configured, or if one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + # + # == Usage + # + # 1. Tell Action Mailbox to accept emails from SendGrid: + # + # # config/environments/production.rb + # config.action_mailbox.ingress = :sendgrid + # + # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the SendGrid ingress. + # + # Use bin/rails credentials:edit to add the password to your application's encrypted credentials under + # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it: + # + # action_mailbox: + # ingress_password: ... + # + # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable. + # + # 3. {Configure SendGrid Inbound Parse}[https://sendgrid.com/docs/for-developers/parsing-email/setting-up-the-inbound-parse-webhook/] + # to forward inbound emails to +/rails/action_mailbox/sendgrid/inbound_emails+ with the username +actionmailbox+ and + # the password you previously generated. If your application lived at https://example.com, you would + # configure SendGrid with the following fully-qualified URL: + # + # https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/sendgrid/inbound_emails + # + # *NOTE:* When configuring your SendGrid Inbound Parse webhook, be sure to check the box labeled *"Post the raw, + # full MIME message."* Action Mailbox needs the raw MIME message to work. + class Ingresses::Sendgrid::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate_by_password + param_encoding :create, :email, Encoding::ASCII_8BIT + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! mail + rescue JSON::ParserError => error + logger.error error.message + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT + end + + private + def mail + params.require(:email).tap do |raw_email| + envelope["to"].each { |to| raw_email.prepend("X-Original-To: ", to, "\n") } if params.key?(:envelope) + end + end + + def envelope + JSON.parse(params.require(:envelope)) + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails/sources_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails/sources_controller.rb new file mode 100644 index 0000000000000..dfc382c7dcdeb --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails/sources_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# :enddoc: + +module Rails + class Conductor::ActionMailbox::InboundEmails::SourcesController < Rails::Conductor::BaseController # :nodoc: + def new + end + + def create + inbound_email = ActionMailbox::InboundEmail.create_and_extract_message_id! params[:source] + redirect_to main_app.rails_conductor_inbound_email_url(inbound_email) + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb new file mode 100644 index 0000000000000..1146082b4b2b7 --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# :enddoc: + +module Rails + class Conductor::ActionMailbox::InboundEmailsController < Rails::Conductor::BaseController + def index + @inbound_emails = ActionMailbox::InboundEmail.order(created_at: :desc) + end + + def new + end + + def show + @inbound_email = ActionMailbox::InboundEmail.find(params[:id]) + end + + def create + inbound_email = create_inbound_email(new_mail) + redirect_to main_app.rails_conductor_inbound_email_url(inbound_email) + end + + private + def new_mail + Mail.new(mail_params.except(:attachments).to_h).tap do |mail| + mail[:bcc]&.include_in_headers = true + mail_params[:attachments]&.select(&:present?)&.each do |attachment| + mail.add_file(filename: attachment.original_filename, content: attachment.read) + end + end + end + + def mail_params + params.expect(mail: [:from, :to, :cc, :bcc, :x_original_to, :in_reply_to, :subject, :body, attachments: []]) + end + + def create_inbound_email(mail) + ActionMailbox::InboundEmail.create_and_extract_message_id!(mail.to_s) + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb new file mode 100644 index 0000000000000..2466eda8eac63 --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# :enddoc: + +module Rails + # Incinerating will destroy an email that is due and has already been processed. + class Conductor::ActionMailbox::IncineratesController < Rails::Conductor::BaseController + def create + ActionMailbox::InboundEmail.find(params[:inbound_email_id]).incinerate + + redirect_to main_app.rails_conductor_inbound_emails_url + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb new file mode 100644 index 0000000000000..a1f266d00b88d --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# :enddoc: + +module Rails + # Rerouting will run routing and processing on an email that has already been, or attempted to be, processed. + class Conductor::ActionMailbox::ReroutesController < Rails::Conductor::BaseController + def create + inbound_email = ActionMailbox::InboundEmail.find(params[:inbound_email_id]) + reroute inbound_email + + redirect_to main_app.rails_conductor_inbound_email_url(inbound_email) + end + + private + def reroute(inbound_email) + inbound_email.pending! + inbound_email.route_later + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/base_controller.rb b/actionmailbox/app/controllers/rails/conductor/base_controller.rb new file mode 100644 index 0000000000000..93c4e2a66e7a6 --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/base_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Rails + # TODO: Move this to Rails::Conductor gem + class Conductor::BaseController < ActionController::Base + layout "rails/conductor" + before_action :ensure_development_env + + private + def ensure_development_env + head :forbidden unless Rails.env.development? + end + end +end diff --git a/actionmailbox/app/jobs/action_mailbox/incineration_job.rb b/actionmailbox/app/jobs/action_mailbox/incineration_job.rb new file mode 100644 index 0000000000000..351bd67c693f9 --- /dev/null +++ b/actionmailbox/app/jobs/action_mailbox/incineration_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ActionMailbox + # You can configure when this +IncinerationJob+ will be run as a time-after-processing using the + # +config.action_mailbox.incinerate_after+ or +ActionMailbox.incinerate_after+ setting. + # + # Since this incineration is set for the future, it'll automatically ignore any InboundEmails + # that have already been deleted and discard itself if so. + # + # You can disable incinerating processed emails by setting +config.action_mailbox.incinerate+ or + # +ActionMailbox.incinerate+ to +false+. + class IncinerationJob < ActiveJob::Base + queue_as { ActionMailbox.queues[:incineration] } + + discard_on ActiveRecord::RecordNotFound + + def self.schedule(inbound_email) + set(wait: ActionMailbox.incinerate_after).perform_later(inbound_email) + end + + def perform(inbound_email) + inbound_email.incinerate + end + end +end diff --git a/actionmailbox/app/jobs/action_mailbox/routing_job.rb b/actionmailbox/app/jobs/action_mailbox/routing_job.rb new file mode 100644 index 0000000000000..4ddf6e4231015 --- /dev/null +++ b/actionmailbox/app/jobs/action_mailbox/routing_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ActionMailbox + # Routing a new InboundEmail is an asynchronous operation, which allows the ingress controllers to quickly + # accept new incoming emails without being burdened to hang while they're actually being processed. + class RoutingJob < ActiveJob::Base + queue_as { ActionMailbox.queues[:routing] } + + def perform(inbound_email) + inbound_email.route + end + end +end diff --git a/actionmailbox/app/models/action_mailbox/inbound_email.rb b/actionmailbox/app/models/action_mailbox/inbound_email.rb new file mode 100644 index 0000000000000..76af7caf922f1 --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "mail" + +module ActionMailbox + # The +InboundEmail+ is an Active Record that keeps a reference to the raw email stored in Active Storage + # and tracks the status of processing. By default, incoming emails will go through the following lifecycle: + # + # * Pending: Just received by one of the ingress controllers and scheduled for routing. + # * Processing: During active processing, while a specific mailbox is running its #process method. + # * Delivered: Successfully processed by the specific mailbox. + # * Failed: An exception was raised during the specific mailbox's execution of the +#process+ method. + # * Bounced: Rejected processing by the specific mailbox and bounced to sender. + # + # Once the +InboundEmail+ has reached the status of being either +delivered+, +failed+, or +bounced+, + # it'll count as having been +#processed?+. Once processed, the +InboundEmail+ will be scheduled for + # automatic incineration at a later point. + # + # When working with an +InboundEmail+, you'll usually interact with the parsed version of the source, + # which is available as a +Mail+ object from +#mail+. But you can also access the raw source directly + # using the +#source+ method. + # + # Examples: + # + # inbound_email.mail.from # => 'david@loudthinking.com' + # inbound_email.source # Returns the full rfc822 source of the email as text + class InboundEmail < Record + include Incineratable, MessageId, Routable + + has_one_attached :raw_email, service: ActionMailbox.storage_service + enum :status, %i[ pending processing delivered failed bounced ] + + def mail + @mail ||= Mail.from_source(source) + end + + def source + @source ||= raw_email.download + end + + def processed? + delivered? || failed? || bounced? + end + + def instrumentation_payload # :nodoc: + { + id: id, + message_id: message_id, + status: status + } + end + end +end + +ActiveSupport.run_load_hooks :action_mailbox_inbound_email, ActionMailbox::InboundEmail diff --git a/actionmailbox/app/models/action_mailbox/inbound_email/incineratable.rb b/actionmailbox/app/models/action_mailbox/inbound_email/incineratable.rb new file mode 100644 index 0000000000000..e7c8782f3394d --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email/incineratable.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Ensure that the +InboundEmail+ is automatically scheduled for later incineration if the status has been +# changed to +processed+. The later incineration will be invoked at the time specified by the +# +ActionMailbox.incinerate_after+ time using the +IncinerationJob+. +module ActionMailbox::InboundEmail::Incineratable + extend ActiveSupport::Concern + + included do + after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? } + end + + def incinerate_later + ActionMailbox::IncinerationJob.schedule self + end + + def incinerate + Incineration.new(self).run + end +end diff --git a/actionmailbox/app/models/action_mailbox/inbound_email/incineratable/incineration.rb b/actionmailbox/app/models/action_mailbox/inbound_email/incineratable/incineration.rb new file mode 100644 index 0000000000000..dabc83fae671d --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email/incineratable/incineration.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActionMailbox + # Command class for carrying out the actual incineration of the +InboundMail+ that's been scheduled + # for removal. Before the incineration – which really is just a call to +#destroy!+ – is run, we verify + # that it's both eligible (by virtue of having already been processed) and time to do so (that is, + # the +InboundEmail+ was processed after the +incinerate_after+ time). + class InboundEmail::Incineratable::Incineration + def initialize(inbound_email) + @inbound_email = inbound_email + end + + def run + @inbound_email.destroy! if due? && processed? + end + + private + def due? + @inbound_email.updated_at < ActionMailbox.incinerate_after.ago.end_of_day + end + + def processed? + @inbound_email.processed? + end + end +end diff --git a/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb b/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb new file mode 100644 index 0000000000000..78392f02ee772 --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# The +Message-ID+ as specified by rfc822 is supposed to be a unique identifier for that individual email. +# That makes it an ideal tracking token for debugging and forensics, just like +X-Request-Id+ does for +# web request. +# +# If an inbound email does not, against the rfc822 mandate, specify a Message-ID, one will be generated +# using the approach from +Mail::MessageIdField+. +module ActionMailbox::InboundEmail::MessageId + extend ActiveSupport::Concern + + class_methods do + # Create a new +InboundEmail+ from the raw +source+ of the email, which is uploaded as an Active Storage + # attachment called +raw_email+. Before the upload, extract the Message-ID from the +source+ and set + # it as an attribute on the new +InboundEmail+. + def create_and_extract_message_id!(source, **options) + message_checksum = OpenSSL::Digest::SHA1.hexdigest(source) + message_id = extract_message_id(source) || generate_missing_message_id(message_checksum) + + create! raw_email: create_and_upload_raw_email!(source), + message_id: message_id, message_checksum: message_checksum, **options + rescue ActiveRecord::RecordNotUnique + nil + end + + private + def extract_message_id(source) + Mail.from_source(source).message_id rescue nil + end + + def generate_missing_message_id(message_checksum) + Mail::MessageIdField.new("<#{message_checksum}@#{::Socket.gethostname}.mail>").message_id.tap do |message_id| + logger.warn "Message-ID couldn't be parsed or is missing. Generated a new Message-ID: #{message_id}" + end + end + + def create_and_upload_raw_email!(source) + ActiveStorage::Blob.create_and_upload! io: StringIO.new(source), filename: "message.eml", content_type: "message/rfc822", + service_name: ActionMailbox.storage_service + end + end +end diff --git a/actionmailbox/app/models/action_mailbox/inbound_email/routable.rb b/actionmailbox/app/models/action_mailbox/inbound_email/routable.rb new file mode 100644 index 0000000000000..39565df16636b --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email/routable.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# A newly received +InboundEmail+ will not be routed synchronously as part of ingress controller's receival. +# Instead, the routing will be done asynchronously, using a +RoutingJob+, to ensure maximum parallel capacity. +# +# By default, all newly created +InboundEmail+ records that have the status of +pending+, which is the default, +# will be scheduled for automatic, deferred routing. +module ActionMailbox::InboundEmail::Routable + extend ActiveSupport::Concern + + included do + after_create_commit :route_later, if: :pending? + end + + # Enqueue a +RoutingJob+ for this +InboundEmail+. + def route_later + ActionMailbox::RoutingJob.perform_later self + end + + # Route this +InboundEmail+ using the routing rules declared on the +ApplicationMailbox+. + def route + ApplicationMailbox.route self + end +end diff --git a/actionmailbox/app/models/action_mailbox/record.rb b/actionmailbox/app/models/action_mailbox/record.rb new file mode 100644 index 0000000000000..77b3a99e8978f --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/record.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ActionMailbox + class Record < ActiveRecord::Base # :nodoc: + self.abstract_class = true + end +end + +ActiveSupport.run_load_hooks :action_mailbox_record, ActionMailbox::Record diff --git a/actionmailbox/app/views/layouts/rails/conductor.html.erb b/actionmailbox/app/views/layouts/rails/conductor.html.erb new file mode 100644 index 0000000000000..1cad6560c4b01 --- /dev/null +++ b/actionmailbox/app/views/layouts/rails/conductor.html.erb @@ -0,0 +1,8 @@ + + + Rails Conductor: <%= yield :title %> + + +<%= yield %> + + diff --git a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/index.html.erb b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/index.html.erb new file mode 100644 index 0000000000000..65f64bf56d7a9 --- /dev/null +++ b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/index.html.erb @@ -0,0 +1,16 @@ +<% provide :title, "Deliver new inbound email" %> + +

All inbound emails

+ +<%= link_to "New inbound email by form", main_app.new_rails_conductor_inbound_email_path %> | +<%= link_to "New inbound email by source", main_app.new_rails_conductor_inbound_email_source_path %> + + + + <% @inbound_emails.each do |inbound_email| %> + + + + + <% end %> +
Message IDStatus
<%= link_to inbound_email.message_id, main_app.rails_conductor_inbound_email_path(inbound_email) %><%= inbound_email.status %>
diff --git a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/new.html.erb b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/new.html.erb new file mode 100644 index 0000000000000..5b0e639509a6d --- /dev/null +++ b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/new.html.erb @@ -0,0 +1,52 @@ +<% provide :title, "Deliver new inbound email" %> + +

Deliver new inbound email

+ +<%= form_with(url: main_app.rails_conductor_inbound_emails_path, scope: :mail, local: true) do |form| %> +
+ <%= form.label :from, "From" %>
+ <%= form.text_field :from, value: params[:from] %> +
+ +
+ <%= form.label :to, "To" %>
+ <%= form.text_field :to, value: params[:to] %> +
+ +
+ <%= form.label :cc, "CC" %>
+ <%= form.text_field :cc, value: params[:cc] %> +
+ +
+ <%= form.label :bcc, "BCC" %>
+ <%= form.text_field :bcc, value: params[:bcc] %> +
+ +
+ <%= form.label :x_original_to, "X-Original-To" %>
+ <%= form.text_field :x_original_to, value: params[:x_original_to] %> +
+ +
+ <%= form.label :in_reply_to, "In-Reply-To" %>
+ <%= form.text_field :in_reply_to, value: params[:in_reply_to] %> +
+ +
+ <%= form.label :subject, "Subject" %>
+ <%= form.text_field :subject, value: params[:subject] %> +
+ +
+ <%= form.label :body, "Body" %>
+ <%= form.textarea :body, size: "40x20", value: params[:body] %> +
+ +
+ <%= form.label :attachments, "Attachments" %>
+ <%= form.file_field :attachments, multiple: true %> +
+ + <%= form.submit "Deliver inbound email" %> +<% end %> diff --git a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb new file mode 100644 index 0000000000000..48c08cd040ad3 --- /dev/null +++ b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb @@ -0,0 +1,15 @@ +<% provide :title, @inbound_email.message_id %> + +

<%= @inbound_email.message_id %>: <%= @inbound_email.status %>

+ +
    +
  • <%= button_to "Route again", main_app.rails_conductor_inbound_email_reroute_path(@inbound_email), method: :post %>
  • +
  • <%= button_to "Incinerate", main_app.rails_conductor_inbound_email_incinerate_path(@inbound_email), method: :post %>
  • +
+ +
+ Full email source +
<%= @inbound_email.source %>
+
+ +<%= link_to "Back to all inbound emails", main_app.rails_conductor_inbound_emails_path %> \ No newline at end of file diff --git a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/sources/new.html.erb b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/sources/new.html.erb new file mode 100644 index 0000000000000..82b6ea8dd1bb9 --- /dev/null +++ b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/sources/new.html.erb @@ -0,0 +1,12 @@ +<% provide :title, "Deliver new inbound email by source" %> + +

Deliver new inbound email by source

+ +<%= form_with(url: main_app.rails_conductor_inbound_email_sources_path, local: true) do |form| %> +
+ <%= form.label :source, "Source" %>
+ <%= form.textarea :source, size: "80x60" %> +
+ + <%= form.submit "Deliver inbound email" %> +<% end %> diff --git a/actionmailbox/bin/test b/actionmailbox/bin/test new file mode 100755 index 0000000000000..c53377cc970f4 --- /dev/null +++ b/actionmailbox/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/actionmailbox/config/routes.rb b/actionmailbox/config/routes.rb new file mode 100644 index 0000000000000..e3d5737a631f3 --- /dev/null +++ b/actionmailbox/config/routes.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do + post "/postmark/inbound_emails" => "postmark/inbound_emails#create", as: :rails_postmark_inbound_emails + post "/relay/inbound_emails" => "relay/inbound_emails#create", as: :rails_relay_inbound_emails + post "/sendgrid/inbound_emails" => "sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails + + # Mandrill checks for the existence of a URL with a HEAD request before it will create the webhook. + get "/mandrill/inbound_emails" => "mandrill/inbound_emails#health_check", as: :rails_mandrill_inbound_health_check + post "/mandrill/inbound_emails" => "mandrill/inbound_emails#create", as: :rails_mandrill_inbound_emails + + # Mailgun requires that a webhook's URL end in 'mime' for it to receive the raw contents of emails. + post "/mailgun/inbound_emails/mime" => "mailgun/inbound_emails#create", as: :rails_mailgun_inbound_emails + end + + # TODO: Should these be mounted within the engine only? + scope "rails/conductor/action_mailbox/", module: "rails/conductor/action_mailbox" do + resources :inbound_emails, as: :rails_conductor_inbound_emails, only: %i[index new show create] + get "inbound_emails/sources/new", to: "inbound_emails/sources#new", as: :new_rails_conductor_inbound_email_source + post "inbound_emails/sources", to: "inbound_emails/sources#create", as: :rails_conductor_inbound_email_sources + + post ":inbound_email_id/reroute" => "reroutes#create", as: :rails_conductor_inbound_email_reroute + post ":inbound_email_id/incinerate" => "incinerates#create", as: :rails_conductor_inbound_email_incinerate + end +end diff --git a/actionmailbox/db/migrate/20180917164000_create_action_mailbox_tables.rb b/actionmailbox/db/migrate/20180917164000_create_action_mailbox_tables.rb new file mode 100644 index 0000000000000..2a1e2f7a8e2e1 --- /dev/null +++ b/actionmailbox/db/migrate/20180917164000_create_action_mailbox_tables.rb @@ -0,0 +1,19 @@ +class CreateActionMailboxTables < ActiveRecord::Migration[6.0] + def change + create_table :action_mailbox_inbound_emails, id: primary_key_type do |t| + t.integer :status, default: 0, null: false + t.string :message_id, null: false + t.string :message_checksum, null: false + + t.timestamps + + t.index [ :message_id, :message_checksum ], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true + end + end + + private + def primary_key_type + config = Rails.configuration.generators + config.options[config.orm][:primary_key_type] || :primary_key + end +end diff --git a/actionmailbox/lib/action_mailbox.rb b/actionmailbox/lib/action_mailbox.rb new file mode 100644 index 0000000000000..d0e6b1600d840 --- /dev/null +++ b/actionmailbox/lib/action_mailbox.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/rails" +require "active_support/core_ext/numeric/time" + +require "action_mailbox/version" +require "action_mailbox/deprecator" +require "action_mailbox/mail_ext" + +# :markup: markdown +# :include: ../README.md +module ActionMailbox + extend ActiveSupport::Autoload + + autoload :Base + autoload :Router + autoload :TestCase + + mattr_accessor :ingress + mattr_accessor :logger + mattr_accessor :incinerate, default: true + mattr_accessor :incinerate_after, default: 30.days + mattr_accessor :queues, default: {} + mattr_accessor :storage_service +end diff --git a/actionmailbox/lib/action_mailbox/base.rb b/actionmailbox/lib/action_mailbox/base.rb new file mode 100644 index 0000000000000..d6ff9caf404bb --- /dev/null +++ b/actionmailbox/lib/action_mailbox/base.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "active_support/rescuable" + +require "action_mailbox/callbacks" +require "action_mailbox/routing" + +module ActionMailbox + # = Action Mailbox \Base + # + # The base class for all application mailboxes. Not intended to be inherited from directly. Inherit from + # +ApplicationMailbox+ instead, as that's where the app-specific routing is configured. This routing + # is specified in the following ways: + # + # class ApplicationMailbox < ActionMailbox::Base + # # Any of the recipients of the mail (whether to, cc, bcc) are matched against the regexp. + # routing /^replies@/i => :replies + # + # # Any of the recipients of the mail (whether to, cc, bcc) needs to be an exact match for the string. + # routing "help@example.com" => :help + # + # # Any callable (proc, lambda, etc) object is passed the inbound_email record and is a match if true. + # routing ->(inbound_email) { inbound_email.mail.to.size > 2 } => :multiple_recipients + # + # # Any object responding to #match? is called with the inbound_email record as an argument. Match if true. + # routing CustomAddress.new => :custom + # + # # Any inbound_email that has not been already matched will be sent to the BackstopMailbox. + # routing :all => :backstop + # end + # + # Application mailboxes need to override the #process method, which is invoked by the framework after + # callbacks have been run. The callbacks available are: +before_processing+, +after_processing+, and + # +around_processing+. The primary use case is to ensure that certain preconditions to processing are fulfilled + # using +before_processing+ callbacks. + # + # If a precondition fails to be met, you can halt the processing using the +#bounced!+ method, + # which will silently prevent any further processing, but not actually send out any bounce notice. You + # can also pair this behavior with the invocation of an Action Mailer class responsible for sending out + # an actual bounce email. This is done using the #bounce_with method, which takes the mail object returned + # by an Action Mailer method, like so: + # + # class ForwardsMailbox < ApplicationMailbox + # before_processing :ensure_sender_is_a_user + # + # private + # def ensure_sender_is_a_user + # unless User.exist?(email_address: mail.from) + # bounce_with UserRequiredMailer.missing(inbound_email) + # end + # end + # end + # + # During the processing of the inbound email, the status will be tracked. Before processing begins, + # the email will normally have the +pending+ status. Once processing begins, just before callbacks + # and the #process method is called, the status is changed to +processing+. If processing is allowed to + # complete, the status is changed to +delivered+. If a bounce is triggered, then +bounced+. If an unhandled + # exception is bubbled up, then +failed+. + # + # Exceptions can be handled at the class level using the familiar + # ActiveSupport::Rescuable approach: + # + # class ForwardsMailbox < ApplicationMailbox + # rescue_from(ApplicationSpecificVerificationError) { bounced! } + # end + class Base + include ActiveSupport::Rescuable + include ActionMailbox::Callbacks, ActionMailbox::Routing + + attr_reader :inbound_email + delegate :mail, :delivered!, :bounced!, to: :inbound_email + + delegate :logger, to: ActionMailbox + + def self.receive(inbound_email) + new(inbound_email).perform_processing + end + + def initialize(inbound_email) + @inbound_email = inbound_email + end + + def perform_processing # :nodoc: + ActiveSupport::Notifications.instrument "process.action_mailbox", instrumentation_payload do + track_status_of_inbound_email do + run_callbacks :process do + process + end + end + rescue => exception + # TODO: Include a reference to the inbound_email in the exception raised so error handling becomes easier + rescue_with_handler(exception) || raise + end + end + + def process + # Override in subclasses + end + + def finished_processing? # :nodoc: + inbound_email.delivered? || inbound_email.bounced? + end + + # Enqueues the given +message+ for delivery and changes the inbound email's status to +:bounced+. + def bounce_with(message) + inbound_email.bounced! + message.deliver_later + end + + # Immediately sends the given +message+ and changes the inbound email's status to +:bounced+. + def bounce_now_with(message) + inbound_email.bounced! + message.deliver_now + end + + private + def instrumentation_payload + { + mailbox: self, + inbound_email: inbound_email.instrumentation_payload + } + end + + def track_status_of_inbound_email + inbound_email.processing! + yield + inbound_email.delivered! unless inbound_email.bounced? + rescue + inbound_email.failed! + raise + end + end +end + +ActiveSupport.run_load_hooks :action_mailbox, ActionMailbox::Base diff --git a/actionmailbox/lib/action_mailbox/callbacks.rb b/actionmailbox/lib/action_mailbox/callbacks.rb new file mode 100644 index 0000000000000..cc801083dd091 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/callbacks.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "active_support/callbacks" + +module ActionMailbox + # = Action Mailbox \Callbacks + # + # Defines the callbacks related to processing. + module Callbacks + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + TERMINATOR = ->(mailbox, chain) do + chain.call + mailbox.finished_processing? + end + + included do + define_callbacks :process, terminator: TERMINATOR, skip_after_callbacks_if_terminated: true + end + + class_methods do + def before_processing(*methods, &block) + set_callback(:process, :before, *methods, &block) + end + + def after_processing(*methods, &block) + set_callback(:process, :after, *methods, &block) + end + + def around_processing(*methods, &block) + set_callback(:process, :around, *methods, &block) + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/deprecator.rb b/actionmailbox/lib/action_mailbox/deprecator.rb new file mode 100644 index 0000000000000..675becfc42921 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/deprecator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActionMailbox + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end diff --git a/actionmailbox/lib/action_mailbox/engine.rb b/actionmailbox/lib/action_mailbox/engine.rb new file mode 100644 index 0000000000000..4f745acd815f3 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/engine.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails" +require "action_controller/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" + +require "action_mailbox" + +module ActionMailbox + class Engine < Rails::Engine + isolate_namespace ActionMailbox + config.eager_load_namespaces << ActionMailbox + + config.action_mailbox = ActiveSupport::OrderedOptions.new + config.action_mailbox.incinerate = true + config.action_mailbox.incinerate_after = 30.days + + config.action_mailbox.queues = ActiveSupport::InheritableOptions.new \ + incineration: :action_mailbox_incineration, routing: :action_mailbox_routing + + config.action_mailbox.storage_service = nil + + initializer "action_mailbox.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_mailbox] = ActionMailbox.deprecator + end + + 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? || 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 + ActionMailbox.storage_service = app.config.action_mailbox.storage_service + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/gem_version.rb b/actionmailbox/lib/action_mailbox/gem_version.rb new file mode 100644 index 0000000000000..d6e0f3264a7f6 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/gem_version.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ActionMailbox + # Returns the currently loaded version of Action Mailbox as a +Gem::Version+. + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 8 + MINOR = 2 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext.rb b/actionmailbox/lib/action_mailbox/mail_ext.rb new file mode 100644 index 0000000000000..c4d277a1f9d7a --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "mail" + +# The hope is to upstream most of these basic additions to the Mail gem's Mail object. But until then, here they lay! +Dir["#{File.expand_path(File.dirname(__FILE__))}/mail_ext/*"].each { |path| require "action_mailbox/mail_ext/#{File.basename(path)}" } diff --git a/actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb b/actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb new file mode 100644 index 0000000000000..39a43b3468601 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Mail + class Address + def ==(other_address) + other_address.is_a?(Mail::Address) && to_s == other_address.to_s + end + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb b/actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb new file mode 100644 index 0000000000000..19eb624c1c0b0 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Mail + class Address + def self.wrap(address) + address.is_a?(Mail::Address) ? address : Mail::Address.new(address) + end + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb b/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb new file mode 100644 index 0000000000000..5961088c781c0 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Mail + class Message + def from_address + address_list(header[:from])&.addresses&.first + end + + def reply_to_address + address_list(header[:reply_to])&.addresses&.first + end + + def recipients_addresses + to_addresses + cc_addresses + bcc_addresses + x_original_to_addresses + x_forwarded_to_addresses + end + + def to_addresses + Array(address_list(header[:to])&.addresses) + end + + def cc_addresses + Array(address_list(header[:cc])&.addresses) + end + + def bcc_addresses + Array(address_list(header[:bcc])&.addresses) + end + + def x_original_to_addresses + Array(header[:x_original_to]).collect { |header| Mail::Address.new header.to_s } + end + + def x_forwarded_to_addresses + Array(header[:x_forwarded_to]).collect { |header| Mail::Address.new header.to_s } + end + + private + def address_list(obj) + if obj.respond_to?(:element) + # Mail 2.8+ + obj.element + else + # Mail <= 2.7.x + obj&.address_list + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext/from_source.rb b/actionmailbox/lib/action_mailbox/mail_ext/from_source.rb new file mode 100644 index 0000000000000..17b7fc80ad536 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/from_source.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Mail + def self.from_source(source) + Mail.new Mail::Utilities.binary_unsafe_to_crlf(source.to_s) + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext/recipients.rb b/actionmailbox/lib/action_mailbox/mail_ext/recipients.rb new file mode 100644 index 0000000000000..102ec83debe4b --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/recipients.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Mail + class Message + def recipients + Array(to) + Array(cc) + Array(bcc) + Array(header[:x_original_to]).map(&:to_s) + + Array(header[:x_forwarded_to]).map(&:to_s) + end + end +end diff --git a/actionmailbox/lib/action_mailbox/relayer.rb b/actionmailbox/lib/action_mailbox/relayer.rb new file mode 100644 index 0000000000000..e2890acb608ab --- /dev/null +++ b/actionmailbox/lib/action_mailbox/relayer.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "action_mailbox/version" +require "net/http" +require "uri" + +module ActionMailbox + class Relayer + class Result < Struct.new(:status_code, :message) + def success? + !failure? + end + + def failure? + transient_failure? || permanent_failure? + end + + def transient_failure? + status_code.start_with?("4.") + end + + def permanent_failure? + status_code.start_with?("5.") + end + end + + CONTENT_TYPE = "message/rfc822" + USER_AGENT = "Action Mailbox relayer v#{ActionMailbox.version}" + + attr_reader :uri, :username, :password + + def initialize(url:, username: "actionmailbox", password:) + @uri, @username, @password = URI(url), username, password + end + + def relay(source) + case response = post(source) + when Net::HTTPSuccess + Result.new "2.0.0", "Successfully relayed message to ingress" + when Net::HTTPUnauthorized + Result.new "4.7.0", "Invalid credentials for ingress" + else + Result.new "4.0.0", "HTTP #{response.code}" + end + rescue IOError, SocketError, SystemCallError => error + Result.new "4.4.2", "Network error relaying to ingress: #{error.message}" + rescue Timeout::Error + Result.new "4.4.2", "Timed out relaying to ingress" + rescue => error + Result.new "4.0.0", "Error relaying to ingress: #{error.message}" + end + + private + def post(source) + client.post uri, source, + "Content-Type" => CONTENT_TYPE, + "User-Agent" => USER_AGENT, + "Authorization" => "Basic #{Base64.strict_encode64(username + ":" + password)}" + end + + def client + @client ||= Net::HTTP.new(uri.host, uri.port).tap do |connection| + if uri.scheme == "https" + require "openssl" + + connection.use_ssl = true + connection.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + + connection.open_timeout = 1 + connection.read_timeout = 10 + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/router.rb b/actionmailbox/lib/action_mailbox/router.rb new file mode 100644 index 0000000000000..0b67266af5c83 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/router.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ActionMailbox + # = Action Mailbox \Router + # + # Encapsulates the routes that live on the ApplicationMailbox and performs the actual routing when + # an inbound_email is received. + class Router + class RoutingError < StandardError; end + + def initialize + @routes = [] + end + + def add_routes(routes) + routes.each do |(address, mailbox_name)| + add_route address, to: mailbox_name + end + end + + def add_route(address, to:) + routes.append Route.new(address, to: to) + end + + def route(inbound_email) + if mailbox = mailbox_for(inbound_email) + mailbox.receive(inbound_email) + else + inbound_email.bounced! + + raise RoutingError + end + end + + def mailbox_for(inbound_email) + routes.detect { |route| route.match?(inbound_email) }&.mailbox_class + end + + private + attr_reader :routes + end +end + +require "action_mailbox/router/route" diff --git a/actionmailbox/lib/action_mailbox/router/route.rb b/actionmailbox/lib/action_mailbox/router/route.rb new file mode 100644 index 0000000000000..7c67c775900f7 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/router/route.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module ActionMailbox + # Encapsulates a route, which can then be matched against an inbound_email and provide a lookup of the matching + # mailbox class. See examples for the different route addresses and how to use them in the ActionMailbox::Base + # documentation. + class Router::Route + attr_reader :address, :mailbox_name + + def initialize(address, to:) + @address, @mailbox_name = address, to + + ensure_valid_address + end + + def match?(inbound_email) + case address + when :all + true + when String + inbound_email.mail.recipients.any? { |recipient| address.casecmp?(recipient) } + when Regexp + inbound_email.mail.recipients.any? { |recipient| address.match?(recipient) } + when Proc + address.call(inbound_email) + else + address.match?(inbound_email) + end + end + + def mailbox_class + "#{mailbox_name.to_s.camelize}Mailbox".constantize + end + + private + def ensure_valid_address + unless [ Symbol, String, Regexp, Proc ].any? { |klass| address.is_a?(klass) } || address.respond_to?(:match?) + raise ArgumentError, "Expected a Symbol, String, Regexp, Proc, or matchable, got #{address.inspect}" + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/routing.rb b/actionmailbox/lib/action_mailbox/routing.rb new file mode 100644 index 0000000000000..4e98d4ee0b29f --- /dev/null +++ b/actionmailbox/lib/action_mailbox/routing.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActionMailbox + # See ActionMailbox::Base for how to specify routing. + module Routing + extend ActiveSupport::Concern + + included do + cattr_accessor :router, default: ActionMailbox::Router.new + end + + class_methods do + def routing(routes) + router.add_routes(routes) + end + + def route(inbound_email) + router.route(inbound_email) + end + + def mailbox_for(inbound_email) + router.mailbox_for(inbound_email) + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/test_case.rb b/actionmailbox/lib/action_mailbox/test_case.rb new file mode 100644 index 0000000000000..5e78e428d3309 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/test_case.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "action_mailbox/test_helper" +require "active_support/test_case" + +module ActionMailbox + class TestCase < ActiveSupport::TestCase + include ActionMailbox::TestHelper + end +end + +ActiveSupport.run_load_hooks :action_mailbox_test_case, ActionMailbox::TestCase diff --git a/actionmailbox/lib/action_mailbox/test_helper.rb b/actionmailbox/lib/action_mailbox/test_helper.rb new file mode 100644 index 0000000000000..ea50afd7f0a61 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/test_helper.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "mail" + +module ActionMailbox + module TestHelper + # Create an InboundEmail record using an eml fixture in the format of message/rfc822 + # referenced with +fixture_name+ located in +test/fixtures/files/fixture_name+. + def create_inbound_email_from_fixture(fixture_name, status: :processing) + create_inbound_email_from_source file_fixture(fixture_name).read, status: status + end + + # Creates an InboundEmail by specifying through options or a block. + # + # ==== Options + # + # * :status - The +status+ to set for the created InboundEmail. + # For possible statuses, see its documentation. + # + # ==== Creating a simple email + # + # When you only need to set basic fields like +from+, +to+, +subject+, and + # +body+, you can pass them directly as options. + # + # create_inbound_email_from_mail(from: "david@loudthinking.com", subject: "Hello!") + # + # ==== Creating a multi-part email + # + # When you need to create a more intricate email, like a multi-part email + # that contains both a plaintext version and an HTML version, you can pass a + # block. + # + # create_inbound_email_from_mail do + # to "David Heinemeier Hansson " + # from "Bilbo Baggins " + # subject "Come down to the Shire!" + # + # text_part do + # body "Please join us for a party at Bag End" + # end + # + # html_part do + # body "

Please join us for a party at Bag End

" + # end + # end + # + # As with +Mail.new+, you can also use a block parameter to define the parts + # of the message: + # + # create_inbound_email_from_mail do |mail| + # mail.to "David Heinemeier Hansson " + # mail.from "Bilbo Baggins " + # mail.subject "Come down to the Shire!" + # + # mail.text_part do |part| + # part.body "Please join us for a party at Bag End" + # end + # + # mail.html_part do |part| + # part.body "

Please join us for a party at Bag End

" + # end + # end + def create_inbound_email_from_mail(status: :processing, **mail_options, &block) + mail = Mail.new(mail_options, &block) + # Bcc header is not encoded by default + mail[:bcc].include_in_headers = true if mail[:bcc] + + create_inbound_email_from_source mail.to_s, status: status + end + + # Create an InboundEmail using the raw rfc822 +source+ as text. + def create_inbound_email_from_source(source, status: :processing) + ActionMailbox::InboundEmail.create_and_extract_message_id! source, status: status + end + + + # Create an InboundEmail from fixture using the same arguments as create_inbound_email_from_fixture + # and immediately route it to processing. + def receive_inbound_email_from_fixture(*args) + create_inbound_email_from_fixture(*args).tap(&:route) + end + + # Create an InboundEmail using the same options or block as + # create_inbound_email_from_mail, then immediately route it for processing. + def receive_inbound_email_from_mail(**kwargs, &block) + create_inbound_email_from_mail(**kwargs, &block).tap(&:route) + end + + # Create an InboundEmail using the same arguments as create_inbound_email_from_source and immediately route it + # to processing. + def receive_inbound_email_from_source(*args) + create_inbound_email_from_source(*args).tap(&:route) + end + end +end diff --git a/actionmailbox/lib/action_mailbox/version.rb b/actionmailbox/lib/action_mailbox/version.rb new file mode 100644 index 0000000000000..10c4cee6adcf7 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "gem_version" + +module ActionMailbox + # Returns the currently loaded version of Action Mailbox as a +Gem::Version+. + def self.version + gem_version + end +end diff --git a/actionmailbox/lib/generators/action_mailbox/install/install_generator.rb b/actionmailbox/lib/generators/action_mailbox/install/install_generator.rb new file mode 100644 index 0000000000000..05066d6071d2c --- /dev/null +++ b/actionmailbox/lib/generators/action_mailbox/install/install_generator.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails/generators/mailbox/mailbox_generator" + +module ActionMailbox + module Generators + class InstallGenerator < ::Rails::Generators::Base + source_root Rails::Generators::MailboxGenerator.source_root + + def create_action_mailbox_files + say "Copying application_mailbox.rb to app/mailboxes", :green + template "application_mailbox.rb", "app/mailboxes/application_mailbox.rb" + end + + def add_action_mailbox_production_environment_config + environment <<~end_of_config, env: "production" + # Prepare the ingress controller used to receive mail + # config.action_mailbox.ingress = :relay + + end_of_config + end + + def create_migrations + rails_command "railties:install:migrations FROM=active_storage,action_mailbox", inline: true + end + end + end +end diff --git a/actionmailbox/lib/rails/generators/mailbox/USAGE b/actionmailbox/lib/rails/generators/mailbox/USAGE new file mode 100644 index 0000000000000..371fc2af69119 --- /dev/null +++ b/actionmailbox/lib/rails/generators/mailbox/USAGE @@ -0,0 +1,10 @@ +Description: + Generates a new mailbox class in app/mailboxes and invokes your template + engine and test framework generators. + +Example: + `bin/rails generate mailbox inbox` + + creates an InboxMailbox class and test: + Mailbox: app/mailboxes/inbox_mailbox.rb + Test: test/mailboxes/inbox_mailbox_test.rb diff --git a/actionmailbox/lib/rails/generators/mailbox/mailbox_generator.rb b/actionmailbox/lib/rails/generators/mailbox/mailbox_generator.rb new file mode 100644 index 0000000000000..c2c403b8f6e94 --- /dev/null +++ b/actionmailbox/lib/rails/generators/mailbox/mailbox_generator.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Rails + module Generators + class MailboxGenerator < NamedBase + source_root File.expand_path("templates", __dir__) + + check_class_collision suffix: "Mailbox" + + def create_mailbox_file + template "mailbox.rb", File.join("app/mailboxes", class_path, "#{file_name}_mailbox.rb") + + in_root do + if behavior == :invoke && !File.exist?(application_mailbox_file_name) + template "application_mailbox.rb", application_mailbox_file_name + end + end + end + + hook_for :test_framework + + private + def file_name # :doc: + @_file_name ||= super.sub(/_mailbox\z/i, "") + end + + def application_mailbox_file_name + "app/mailboxes/application_mailbox.rb" + end + end + end +end diff --git a/actionmailbox/lib/rails/generators/mailbox/templates/application_mailbox.rb.tt b/actionmailbox/lib/rails/generators/mailbox/templates/application_mailbox.rb.tt new file mode 100644 index 0000000000000..ac22d03cd29ac --- /dev/null +++ b/actionmailbox/lib/rails/generators/mailbox/templates/application_mailbox.rb.tt @@ -0,0 +1,3 @@ +class ApplicationMailbox < ActionMailbox::Base + # routing /something/i => :somewhere +end diff --git a/actionmailbox/lib/rails/generators/mailbox/templates/mailbox.rb.tt b/actionmailbox/lib/rails/generators/mailbox/templates/mailbox.rb.tt new file mode 100644 index 0000000000000..110b3b9d7e17d --- /dev/null +++ b/actionmailbox/lib/rails/generators/mailbox/templates/mailbox.rb.tt @@ -0,0 +1,4 @@ +class <%= class_name %>Mailbox < ApplicationMailbox + def process + end +end diff --git a/actionmailbox/lib/rails/generators/test_unit/mailbox_generator.rb b/actionmailbox/lib/rails/generators/test_unit/mailbox_generator.rb new file mode 100644 index 0000000000000..2ec7d11a2fa07 --- /dev/null +++ b/actionmailbox/lib/rails/generators/test_unit/mailbox_generator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module TestUnit + module Generators + class MailboxGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + check_class_collision suffix: "MailboxTest" + + def create_test_files + template "mailbox_test.rb", File.join("test/mailboxes", class_path, "#{file_name}_mailbox_test.rb") + end + + private + def file_name # :doc: + @_file_name ||= super.sub(/_mailbox\z/i, "") + end + end + end +end diff --git a/actionmailbox/lib/rails/generators/test_unit/templates/mailbox_test.rb.tt b/actionmailbox/lib/rails/generators/test_unit/templates/mailbox_test.rb.tt new file mode 100644 index 0000000000000..3e215b4d0bed7 --- /dev/null +++ b/actionmailbox/lib/rails/generators/test_unit/templates/mailbox_test.rb.tt @@ -0,0 +1,11 @@ +require "test_helper" + +class <%= class_name %>MailboxTest < ActionMailbox::TestCase + # test "receive mail" do + # receive_inbound_email_from_mail \ + # to: '"someone" ', + # from: '"else" ', + # subject: "Hello world!", + # body: "Hello?" + # end +end diff --git a/actionmailbox/lib/tasks/ingress.rake b/actionmailbox/lib/tasks/ingress.rake new file mode 100644 index 0000000000000..43b613ea12d7d --- /dev/null +++ b/actionmailbox/lib/tasks/ingress.rake @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +namespace :action_mailbox do + namespace :ingress do + task :environment do + require "active_support" + require "active_support/core_ext/object/blank" + require "action_mailbox/relayer" + end + + desc "Relay an inbound email from Exim to Action Mailbox (URL and INGRESS_PASSWORD required)" + task exim: "action_mailbox:ingress:environment" do + url, password = ENV.values_at("URL", "INGRESS_PASSWORD") + + if url.blank? || password.blank? + print "URL and INGRESS_PASSWORD are required" + exit 64 # EX_USAGE + end + + ActionMailbox::Relayer.new(url: url, password: password).relay(STDIN.read).tap do |result| + print result.message + + case + when result.success? + exit 0 + when result.transient_failure? + exit 75 # EX_TEMPFAIL + else + exit 69 # EX_UNAVAILABLE + end + end + end + + desc "Relay an inbound email from Postfix to Action Mailbox (URL and INGRESS_PASSWORD required)" + task postfix: "action_mailbox:ingress:environment" do + url, password = ENV.values_at("URL", "INGRESS_PASSWORD") + + if url.blank? || password.blank? + print "4.3.5 URL and INGRESS_PASSWORD are required" + exit 1 + end + + ActionMailbox::Relayer.new(url: url, password: password).relay(STDIN.read).tap do |result| + print "#{result.status_code} #{result.message}" + exit result.success? + end + end + + desc "Relay an inbound email from Qmail to Action Mailbox (URL and INGRESS_PASSWORD required)" + task qmail: "action_mailbox:ingress:environment" do + url, password = ENV.values_at("URL", "INGRESS_PASSWORD") + + if url.blank? || password.blank? + print "URL and INGRESS_PASSWORD are required" + exit 111 + end + + ActionMailbox::Relayer.new(url: url, password: password).relay(STDIN.read).tap do |result| + print result.message + + case + when result.success? + exit 0 + when result.transient_failure? + exit 111 + else + exit 100 + end + end + end + end +end diff --git a/actionmailbox/lib/tasks/install.rake b/actionmailbox/lib/tasks/install.rake new file mode 100644 index 0000000000000..cffa6c9c0f431 --- /dev/null +++ b/actionmailbox/lib/tasks/install.rake @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +desc "Install Action Mailbox and its dependencies" +task "action_mailbox:install" do + Rails::Command.invoke :generate, ["action_mailbox:install"] +end diff --git a/actionmailbox/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..dfed9fa63c465 --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "test_helper" + +ENV["MAILGUN_INGRESS_SIGNING_KEY"] = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL" + +class ActionMailbox::Ingresses::Mailgun::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :mailgun } + + test "receiving an inbound email from Mailgun" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "receiving an inbound email from Mailgun with non UTF-8 characters" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/invalid_utf.eml").read + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/invalid_utf.eml").binread, inbound_email.raw_email.download + assert_equal "05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com", inbound_email.message_id + end + + test "add X-Original-To to email from Mailgun" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read, + recipient: "replies@example.com" + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + mail = Mail.from_source(inbound_email.raw_email.download) + assert_equal "replies@example.com", mail.header["X-Original-To"].decoded + end + + test "rejecting a delayed inbound email from Mailgun" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + travel_to "2018-10-09 15:26:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + + assert_response :unauthorized + end + + test "rejecting a forged inbound email from Mailgun" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "Zx8mJBiGmiiyyfWnho3zKyjCg2pxLARoCuBM7X9AKCioShGiMX", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + + assert_response :unauthorized + end + + test "raising when the configured Mailgun Signing key is nil" do + switch_key_to nil do + assert_raises ArgumentError do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + end + end + + test "raising when the configured Mailgun Signing key is blank" do + switch_key_to "" do + assert_raises ArgumentError do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + end + end + + private + def switch_key_to(new_key) + previous_key, ENV["MAILGUN_INGRESS_SIGNING_KEY"] = ENV["MAILGUN_INGRESS_SIGNING_KEY"], new_key + yield + ensure + ENV["MAILGUN_INGRESS_SIGNING_KEY"] = previous_key + end +end diff --git a/actionmailbox/test/controllers/ingresses/mandrill/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/mandrill/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..7dc900aa68c46 --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/mandrill/inbound_emails_controller_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" + +ENV["MANDRILL_INGRESS_API_KEY"] = "1l9Qf7lutEf7h73VXfBwhw" + +class ActionMailbox::Ingresses::Mandrill::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup do + ActionMailbox.ingress = :mandrill + @events = JSON.generate([{ event: "inbound", msg: { raw_msg: file_fixture("../files/welcome.eml").read } }]) + end + + test "verifying existence of Mandrill inbound route" do + # Mandrill uses a HEAD request to verify if a URL exists before creating the ingress webhook + head rails_mandrill_inbound_health_check_url + assert_response :ok + end + + test "receiving an inbound email from Mandrill" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "1bNbyqkMFL4VYIT5+RQCrPs/mRY=" }, params: { mandrill_events: @events } + end + + assert_response :ok + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "rejecting a forged inbound email from Mandrill" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "forged" }, params: { mandrill_events: @events } + end + + assert_response :unauthorized + end + + test "raising when Mandrill API key is nil" do + switch_key_to nil do + assert_raises ArgumentError do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events } + end + end + end + + test "raising when Mandrill API key is blank" do + switch_key_to "" do + assert_raises ArgumentError do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events } + end + end + end + + private + def switch_key_to(new_key) + previous_key, ENV["MANDRILL_INGRESS_API_KEY"] = ENV["MANDRILL_INGRESS_API_KEY"], new_key + yield + ensure + ENV["MANDRILL_INGRESS_API_KEY"] = previous_key + end +end diff --git a/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..11982f74e8319 --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::Ingresses::Postmark::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :postmark } + + test "receiving an inbound email from Postmark" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { RawEmail: file_fixture("../files/welcome.eml").read } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "receiving an inbound email from Postmark with non UTF-8 characters" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { RawEmail: file_fixture("../files/invalid_utf.eml").read } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/invalid_utf.eml").binread, inbound_email.raw_email.download + assert_equal "05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com", inbound_email.message_id + end + + test "add X-Original-To to email from Postmark" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { + RawEmail: file_fixture("../files/welcome.eml").read, + OriginalRecipient: "thisguy@domain.abcd", + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + mail = Mail.from_source(inbound_email.raw_email.download) + assert_equal "thisguy@domain.abcd", mail.header["X-Original-To"].decoded + end + + test "rejecting when RawEmail param is missing" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { From: "someone@example.com" } + end + + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT + end + + test "rejecting an unauthorized inbound email from Postmark" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_postmark_inbound_emails_url, params: { RawEmail: file_fixture("../files/welcome.eml").read } + end + + assert_response :unauthorized + end + + test "raising when the configured password is nil" do + switch_password_to nil do + assert_raises ArgumentError do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { RawEmail: file_fixture("../files/welcome.eml").read } + end + end + end + + test "raising when the configured password is blank" do + switch_password_to "" do + assert_raises ArgumentError do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { RawEmail: file_fixture("../files/welcome.eml").read } + end + end + end +end diff --git a/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..a0b121bb4bf1c --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::Ingresses::Relay::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :relay } + + test "receiving an inbound email relayed from an SMTP server" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "receiving an inbound email relayed from an SMTP server with non UTF-8 characters" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/invalid_utf.eml").read + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/invalid_utf.eml").binread, inbound_email.raw_email.download + assert_equal "05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com", inbound_email.message_id + end + + test "rejecting a request with no body" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + env: { "rack.input" => nil } + end + + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT + end + + test "rejecting an unauthorized inbound email" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_relay_inbound_emails_url, headers: { "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + + assert_response :unauthorized + end + + test "rejecting an inbound email of an unsupported media type" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "text/plain" }, + params: file_fixture("../files/welcome.eml").read + end + + assert_response :unsupported_media_type + end + + test "raising when the configured password is nil" do + switch_password_to nil do + assert_raises ArgumentError do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + end + end + + test "raising when the configured password is blank" do + switch_password_to "" do + assert_raises ArgumentError do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + end + end +end diff --git a/actionmailbox/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..3454585088e91 --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::Ingresses::Sendgrid::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :sendgrid } + + test "receiving an inbound email from Sendgrid" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "receiving an inbound email from Sendgrid with non UTF-8 characters" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/invalid_utf.eml").read } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/invalid_utf.eml").binread, inbound_email.raw_email.download + assert_equal "05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com", inbound_email.message_id + end + + test "add X-Original-To to email from Sendgrid" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { + email: file_fixture("../files/welcome.eml").read, + envelope: "{\"to\":[\"replies@example.com\"],\"from\":\"jason@37signals.com\"}", + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + mail = Mail.from_source(inbound_email.raw_email.download) + assert_equal "replies@example.com", mail.header["X-Original-To"].decoded + end + + test "rejecting an unauthorized inbound email from Sendgrid" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_sendgrid_inbound_emails_url, params: { email: file_fixture("../files/welcome.eml").read } + end + + assert_response :unauthorized + end + + test "raising when the configured password is nil" do + switch_password_to nil do + assert_raises ArgumentError do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read } + end + end + end + + test "raising when the configured password is blank" do + switch_password_to "" do + assert_raises ArgumentError do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read } + end + end + end +end diff --git a/actionmailbox/test/controllers/rails/action_mailbox/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/rails/action_mailbox/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..83c9e90dd5671 --- /dev/null +++ b/actionmailbox/test/controllers/rails/action_mailbox/inbound_emails_controller_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "test_helper" + +class Rails::Conductor::ActionMailbox::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + test "create inbound email" do + with_rails_env("development") do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_conductor_inbound_emails_path, params: { + mail: { + from: "Jason Fried ", + to: "Replies ", + cc: "CC ", + bcc: "Bcc ", + in_reply_to: "<4e6e35f5a38b4_479f13bb90078178@small-app-01.mail>", + subject: "Hey there", + body: "How's it going?" + } + } + end + + mail = ActionMailbox::InboundEmail.last.mail + assert_equal %w[ jason@37signals.com ], mail.from + assert_equal %w[ replies@example.com ], mail.to + assert_equal %w[ cc@example.com ], mail.cc + assert_equal %w[ bcc@example.com ], mail.bcc + assert_equal "4e6e35f5a38b4_479f13bb90078178@small-app-01.mail", mail.in_reply_to + assert_equal "Hey there", mail.subject + assert_equal "How's it going?", mail.body.decoded + end + end + + test "create inbound email with bcc" do + with_rails_env("development") do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_conductor_inbound_emails_path, params: { + mail: { + from: "Jason Fried ", + bcc: "Replies ", + subject: "Hey there", + body: "How's it going?" + } + } + end + + mail = ActionMailbox::InboundEmail.last.mail + assert_equal %w[ jason@37signals.com ], mail.from + assert_equal %w[ replies@example.com ], mail.bcc + assert_equal "Hey there", mail.subject + assert_equal "How's it going?", mail.body.decoded + end + end + + test "create inbound email with attachments" do + with_rails_env("development") do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_conductor_inbound_emails_path, params: { + mail: { + from: "Jason Fried ", + to: "Replies ", + subject: "Let's debate some attachments", + body: "Let's talk about these images:", + attachments: [ fixture_file_upload("avatar1.jpeg"), fixture_file_upload("avatar2.jpeg") ] + } + } + end + + mail = ActionMailbox::InboundEmail.last.mail + assert_equal "Let's talk about these images:", mail.text_part.decoded + assert_equal 2, mail.attachments.count + assert_equal %w[ avatar1.jpeg avatar2.jpeg ], mail.attachments.collect(&:filename) + end + end + + test "create inbound email with empty attachment" do + with_rails_env("development") do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_conductor_inbound_emails_path, params: { + mail: { + from: "", + to: "", + cc: "", + bcc: "", + x_original_to: "", + subject: "", + in_reply_to: "", + body: "", + attachments: [ "" ], + } + } + end + + mail = ActionMailbox::InboundEmail.last.mail + assert_equal 0, mail.attachments.count + end + end + + private + def with_rails_env(env) + old_rails_env = Rails.env + Rails.env = env + yield + ensure + Rails.env = old_rails_env + end +end diff --git a/actionmailbox/test/dummy/Rakefile b/actionmailbox/test/dummy/Rakefile new file mode 100644 index 0000000000000..9a5ea7383aa83 --- /dev/null +++ b/actionmailbox/test/dummy/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/actionmailbox/test/dummy/app/assets/config/manifest.js b/actionmailbox/test/dummy/app/assets/config/manifest.js new file mode 100644 index 0000000000000..591819335f0b2 --- /dev/null +++ b/actionmailbox/test/dummy/app/assets/config/manifest.js @@ -0,0 +1,2 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css diff --git a/actionpack/test/tmp/.gitignore b/actionmailbox/test/dummy/app/assets/images/.keep similarity index 100% rename from actionpack/test/tmp/.gitignore rename to actionmailbox/test/dummy/app/assets/images/.keep diff --git a/actionmailbox/test/dummy/app/assets/stylesheets/application.css b/actionmailbox/test/dummy/app/assets/stylesheets/application.css new file mode 100644 index 0000000000000..0ebd7fe8299eb --- /dev/null +++ b/actionmailbox/test/dummy/app/assets/stylesheets/application.css @@ -0,0 +1,15 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ diff --git a/railties/lib/rails/generators/rails/scaffold/templates/scaffold.css b/actionmailbox/test/dummy/app/assets/stylesheets/scaffold.css similarity index 100% rename from railties/lib/rails/generators/rails/scaffold/templates/scaffold.css rename to actionmailbox/test/dummy/app/assets/stylesheets/scaffold.css diff --git a/railties/lib/rails/generators/rails/app/templates/app/channels/application_cable/channel.rb b/actionmailbox/test/dummy/app/channels/application_cable/channel.rb similarity index 100% rename from railties/lib/rails/generators/rails/app/templates/app/channels/application_cable/channel.rb rename to actionmailbox/test/dummy/app/channels/application_cable/channel.rb diff --git a/railties/lib/rails/generators/rails/app/templates/app/channels/application_cable/connection.rb b/actionmailbox/test/dummy/app/channels/application_cable/connection.rb similarity index 100% rename from railties/lib/rails/generators/rails/app/templates/app/channels/application_cable/connection.rb rename to actionmailbox/test/dummy/app/channels/application_cable/connection.rb diff --git a/actionmailbox/test/dummy/app/controllers/application_controller.rb b/actionmailbox/test/dummy/app/controllers/application_controller.rb new file mode 100644 index 0000000000000..09705d12ab4df --- /dev/null +++ b/actionmailbox/test/dummy/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::Base +end diff --git a/actionview/test/tmp/.gitkeep b/actionmailbox/test/dummy/app/controllers/concerns/.keep similarity index 100% rename from actionview/test/tmp/.gitkeep rename to actionmailbox/test/dummy/app/controllers/concerns/.keep diff --git a/railties/lib/rails/generators/rails/app/templates/app/helpers/application_helper.rb b/actionmailbox/test/dummy/app/helpers/application_helper.rb similarity index 100% rename from railties/lib/rails/generators/rails/app/templates/app/helpers/application_helper.rb rename to actionmailbox/test/dummy/app/helpers/application_helper.rb diff --git a/activerecord/test/migrations/empty/.gitkeep b/actionmailbox/test/dummy/app/javascript/packs/application.js similarity index 100% rename from activerecord/test/migrations/empty/.gitkeep rename to actionmailbox/test/dummy/app/javascript/packs/application.js diff --git a/actionmailbox/test/dummy/app/jobs/application_job.rb b/actionmailbox/test/dummy/app/jobs/application_job.rb new file mode 100644 index 0000000000000..d394c3d106230 --- /dev/null +++ b/actionmailbox/test/dummy/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/actionmailbox/test/dummy/app/mailboxes/application_mailbox.rb b/actionmailbox/test/dummy/app/mailboxes/application_mailbox.rb new file mode 100644 index 0000000000000..be51eb363999d --- /dev/null +++ b/actionmailbox/test/dummy/app/mailboxes/application_mailbox.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationMailbox < ActionMailbox::Base + # routing /something/i => :somewhere +end diff --git a/actionmailbox/test/dummy/app/mailboxes/messages_mailbox.rb b/actionmailbox/test/dummy/app/mailboxes/messages_mailbox.rb new file mode 100644 index 0000000000000..1a046ee13d959 --- /dev/null +++ b/actionmailbox/test/dummy/app/mailboxes/messages_mailbox.rb @@ -0,0 +1,4 @@ +class MessagesMailbox < ApplicationMailbox + def process + end +end diff --git a/actionmailbox/test/dummy/app/mailers/application_mailer.rb b/actionmailbox/test/dummy/app/mailers/application_mailer.rb new file mode 100644 index 0000000000000..3c34c8148f105 --- /dev/null +++ b/actionmailbox/test/dummy/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/actionmailbox/test/dummy/app/models/application_record.rb b/actionmailbox/test/dummy/app/models/application_record.rb new file mode 100644 index 0000000000000..b63caeb8a5c4a --- /dev/null +++ b/actionmailbox/test/dummy/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/railties/lib/rails/generators/rails/app/templates/public/apple-touch-icon-precomposed.png b/actionmailbox/test/dummy/app/models/concerns/.keep similarity index 100% rename from railties/lib/rails/generators/rails/app/templates/public/apple-touch-icon-precomposed.png rename to actionmailbox/test/dummy/app/models/concerns/.keep diff --git a/actionmailbox/test/dummy/app/views/layouts/application.html.erb b/actionmailbox/test/dummy/app/views/layouts/application.html.erb new file mode 100644 index 0000000000000..f72b4ef0e7316 --- /dev/null +++ b/actionmailbox/test/dummy/app/views/layouts/application.html.erb @@ -0,0 +1,15 @@ + + + + Dummy + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "application" %> + + + + <%= yield %> + + diff --git a/actionmailbox/test/dummy/app/views/layouts/mailer.html.erb b/actionmailbox/test/dummy/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000000000..3aac9002edca7 --- /dev/null +++ b/actionmailbox/test/dummy/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/actionmailbox/test/dummy/app/views/layouts/mailer.text.erb b/actionmailbox/test/dummy/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000000000..37f0bddbd746b --- /dev/null +++ b/actionmailbox/test/dummy/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/actionmailbox/test/dummy/bin/rails b/actionmailbox/test/dummy/bin/rails new file mode 100755 index 0000000000000..efc0377492f7e --- /dev/null +++ b/actionmailbox/test/dummy/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/actionmailbox/test/dummy/bin/rake b/actionmailbox/test/dummy/bin/rake new file mode 100755 index 0000000000000..4fbf10b960ef7 --- /dev/null +++ b/actionmailbox/test/dummy/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/actionmailbox/test/dummy/bin/setup b/actionmailbox/test/dummy/bin/setup new file mode 100755 index 0000000000000..3cd5a9d7801ca --- /dev/null +++ b/actionmailbox/test/dummy/bin/setup @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +require "fileutils" + +# path to your application root. +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + puts "\n== Restarting application server ==" + system! "bin/rails restart" +end diff --git a/actionmailbox/test/dummy/config.ru b/actionmailbox/test/dummy/config.ru new file mode 100644 index 0000000000000..4a3c09a6889a9 --- /dev/null +++ b/actionmailbox/test/dummy/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/actionmailbox/test/dummy/config/application.rb b/actionmailbox/test/dummy/config/application.rb new file mode 100644 index 0000000000000..8154f557052d9 --- /dev/null +++ b/actionmailbox/test/dummy/config/application.rb @@ -0,0 +1,27 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Dummy + class Application < Rails::Application + config.load_defaults Rails::VERSION::STRING.to_f + + # For compatibility with applications that use this config + config.action_controller.include_all_helpers = false + + config.active_record.table_name_prefix = 'prefix_' + config.active_record.table_name_suffix = '_suffix' + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/actionmailbox/test/dummy/config/boot.rb b/actionmailbox/test/dummy/config/boot.rb new file mode 100644 index 0000000000000..d5e0f0fdc80b8 --- /dev/null +++ b/actionmailbox/test/dummy/config/boot.rb @@ -0,0 +1,5 @@ +# Set up gems listed in the Gemfile. +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../../Gemfile", __dir__) + +require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) +$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) diff --git a/actionmailbox/test/dummy/config/cable.yml b/actionmailbox/test/dummy/config/cable.yml new file mode 100644 index 0000000000000..98367f8954247 --- /dev/null +++ b/actionmailbox/test/dummy/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: dummy_production diff --git a/actionmailbox/test/dummy/config/database.yml b/actionmailbox/test/dummy/config/database.yml new file mode 100644 index 0000000000000..796466ba23eed --- /dev/null +++ b/actionmailbox/test/dummy/config/database.yml @@ -0,0 +1,25 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + +production: + <<: *default + database: storage/production.sqlite3 diff --git a/actionmailbox/test/dummy/config/environment.rb b/actionmailbox/test/dummy/config/environment.rb new file mode 100644 index 0000000000000..cac5315775258 --- /dev/null +++ b/actionmailbox/test/dummy/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/actionmailbox/test/dummy/config/environments/development.rb b/actionmailbox/test/dummy/config/environments/development.rb new file mode 100644 index 0000000000000..4d134bb530348 --- /dev/null +++ b/actionmailbox/test/dummy/config/environments/development.rb @@ -0,0 +1,73 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing + config.server_timing = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/actionmailbox/test/dummy/config/environments/production.rb b/actionmailbox/test/dummy/config/environments/production.rb new file mode 100644 index 0000000000000..999e332a7e2be --- /dev/null +++ b/actionmailbox/test/dummy/config/environments/production.rb @@ -0,0 +1,89 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] + # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. + # config.public_file_server.enabled = false + + # Compress CSS using a preprocessor. + # config.assets.css_compressor = :sass + + # Do not fall back to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain. + # config.action_cable.mount_path = nil + # config.action_cable.url = "wss://example.com/cable" + # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Log to STDOUT by default + config.logger = ActiveSupport::Logger.new(STDOUT) + .tap { |logger| logger.formatter = ::Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + + # Prepend all log lines with the following tags. + config.log_tags = [ :request_id ] + + # "info" includes generic and useful information about system operation, but avoids logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). Use "debug" + # for everything. + config.log_level = ENV.fetch("RAILS_LOG_LEVEL") { "info" } + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment). + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "dummy_production" + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false +end diff --git a/actionmailbox/test/dummy/config/environments/test.rb b/actionmailbox/test/dummy/config/environments/test.rb new file mode 100644 index 0000000000000..5b1b89421f004 --- /dev/null +++ b/actionmailbox/test/dummy/config/environments/test.rb @@ -0,0 +1,63 @@ +require "active_support/core_ext/integer/time" + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Raise exceptions instead of rendering exception templates. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/actionmailbox/test/dummy/config/initializers/assets.rb b/actionmailbox/test/dummy/config/initializers/assets.rb new file mode 100644 index 0000000000000..2eeef966fe872 --- /dev/null +++ b/actionmailbox/test/dummy/config/initializers/assets.rb @@ -0,0 +1,12 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in the app/assets +# folder are already added. +# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/actionmailbox/test/dummy/config/initializers/content_security_policy.rb b/actionmailbox/test/dummy/config/initializers/content_security_policy.rb new file mode 100644 index 0000000000000..b3076b38fe143 --- /dev/null +++ b/actionmailbox/test/dummy/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/actionmailbox/test/dummy/config/initializers/filter_parameter_logging.rb b/actionmailbox/test/dummy/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000000000..adc6568ce8372 --- /dev/null +++ b/actionmailbox/test/dummy/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be filtered from the log file. Use this to limit dissemination of +# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported +# notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +] diff --git a/actionmailbox/test/dummy/config/initializers/inflections.rb b/actionmailbox/test/dummy/config/initializers/inflections.rb new file mode 100644 index 0000000000000..3860f659ead02 --- /dev/null +++ b/actionmailbox/test/dummy/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/actionmailbox/test/dummy/config/locales/en.yml b/actionmailbox/test/dummy/config/locales/en.yml new file mode 100644 index 0000000000000..6c349ae5e3743 --- /dev/null +++ b/actionmailbox/test/dummy/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/actionmailbox/test/dummy/config/puma.rb b/actionmailbox/test/dummy/config/puma.rb new file mode 100644 index 0000000000000..09a5c4b7865e7 --- /dev/null +++ b/actionmailbox/test/dummy/config/puma.rb @@ -0,0 +1,38 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. + +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies that the worker count should equal the number of processors in production. +if ENV["RAILS_ENV"] == "production" + worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) + workers worker_count if worker_count > 1 +end + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT") { 3000 } + +# Specifies the `environment` that Puma will run in. +environment ENV.fetch("RAILS_ENV") { "development" } + +# Specifies the `pidfile` that Puma will use. +if ENV["PIDFILE"] + pidfile ENV["PIDFILE"] +else + pidfile "tmp/pids/server.pid" if ENV.fetch("RAILS_ENV", "development") == "development" +end + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart diff --git a/actionmailbox/test/dummy/config/routes.rb b/actionmailbox/test/dummy/config/routes.rb new file mode 100644 index 0000000000000..1daf9a4121a8b --- /dev/null +++ b/actionmailbox/test/dummy/config/routes.rb @@ -0,0 +1,2 @@ +Rails.application.routes.draw do +end diff --git a/actionmailbox/test/dummy/config/storage.yml b/actionmailbox/test/dummy/config/storage.yml new file mode 100644 index 0000000000000..b8a59e9a53a4d --- /dev/null +++ b/actionmailbox/test/dummy/config/storage.yml @@ -0,0 +1,31 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +test_email: + service: Disk + root: <%= Rails.root.join("tmp/storage_email") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/actionmailbox/test/dummy/db/migrate/20180208205311_create_action_mailbox_tables.rb b/actionmailbox/test/dummy/db/migrate/20180208205311_create_action_mailbox_tables.rb new file mode 100644 index 0000000000000..2a1e2f7a8e2e1 --- /dev/null +++ b/actionmailbox/test/dummy/db/migrate/20180208205311_create_action_mailbox_tables.rb @@ -0,0 +1,19 @@ +class CreateActionMailboxTables < ActiveRecord::Migration[6.0] + def change + create_table :action_mailbox_inbound_emails, id: primary_key_type do |t| + t.integer :status, default: 0, null: false + t.string :message_id, null: false + t.string :message_checksum, null: false + + t.timestamps + + t.index [ :message_id, :message_checksum ], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true + end + end + + private + def primary_key_type + config = Rails.configuration.generators + config.options[config.orm][:primary_key_type] || :primary_key + end +end diff --git a/actionmailbox/test/dummy/db/migrate/20180212164506_create_active_storage_tables.active_storage.rb b/actionmailbox/test/dummy/db/migrate/20180212164506_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000000000..87798267b4764 --- /dev/null +++ b/actionmailbox/test/dummy/db/migrate/20180212164506_create_active_storage_tables.active_storage.rb @@ -0,0 +1,36 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[5.2] + def change + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + t.datetime :created_at, null: false + + t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records do |t| + t.belongs_to :blob, null: false, index: false + t.string :variation_digest, null: false + + t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/actionmailbox/test/dummy/db/schema.rb b/actionmailbox/test/dummy/db/schema.rb new file mode 100644 index 0000000000000..4981e05a0b94c --- /dev/null +++ b/actionmailbox/test/dummy/db/schema.rb @@ -0,0 +1,53 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +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 + t.string "message_checksum", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["message_id", "message_checksum"], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true + end + + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.integer "record_id", null: false + t.integer "blob_id", null: false + t.datetime "created_at", precision: nil, null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", precision: nil, null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.integer "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" +end diff --git a/railties/lib/rails/generators/rails/app/templates/public/apple-touch-icon.png b/actionmailbox/test/dummy/lib/assets/.keep similarity index 100% rename from railties/lib/rails/generators/rails/app/templates/public/apple-touch-icon.png rename to actionmailbox/test/dummy/lib/assets/.keep diff --git a/railties/lib/rails/generators/rails/app/templates/public/favicon.ico b/actionmailbox/test/dummy/log/.keep similarity index 100% rename from railties/lib/rails/generators/rails/app/templates/public/favicon.ico rename to actionmailbox/test/dummy/log/.keep diff --git a/actionmailbox/test/dummy/public/400.html b/actionmailbox/test/dummy/public/400.html new file mode 100644 index 0000000000000..f59c79ab82f05 --- /dev/null +++ b/actionmailbox/test/dummy/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actionmailbox/test/dummy/public/404.html b/actionmailbox/test/dummy/public/404.html new file mode 100644 index 0000000000000..26d16027c6a4c --- /dev/null +++ b/actionmailbox/test/dummy/public/404.html @@ -0,0 +1,114 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actionmailbox/test/dummy/public/422.html b/actionmailbox/test/dummy/public/422.html new file mode 100644 index 0000000000000..ed5a5805d0e5f --- /dev/null +++ b/actionmailbox/test/dummy/public/422.html @@ -0,0 +1,114 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actionmailbox/test/dummy/public/500.html b/actionmailbox/test/dummy/public/500.html new file mode 100644 index 0000000000000..318723853a010 --- /dev/null +++ b/actionmailbox/test/dummy/public/500.html @@ -0,0 +1,114 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actionmailbox/test/dummy/public/apple-touch-icon-precomposed.png b/actionmailbox/test/dummy/public/apple-touch-icon-precomposed.png new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actionmailbox/test/dummy/public/apple-touch-icon.png b/actionmailbox/test/dummy/public/apple-touch-icon.png new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actionmailbox/test/dummy/public/favicon.ico b/actionmailbox/test/dummy/public/favicon.ico new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actionmailbox/test/dummy/storage/.keep b/actionmailbox/test/dummy/storage/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actionmailbox/test/fixtures/files/avatar1.jpeg b/actionmailbox/test/fixtures/files/avatar1.jpeg new file mode 100644 index 0000000000000..31111c3bc9cf2 Binary files /dev/null and b/actionmailbox/test/fixtures/files/avatar1.jpeg differ diff --git a/actionmailbox/test/fixtures/files/avatar2.jpeg b/actionmailbox/test/fixtures/files/avatar2.jpeg new file mode 100644 index 0000000000000..e844e7f3c61d6 Binary files /dev/null and b/actionmailbox/test/fixtures/files/avatar2.jpeg differ diff --git a/actionmailbox/test/fixtures/files/invalid_utf.eml b/actionmailbox/test/fixtures/files/invalid_utf.eml new file mode 100644 index 0000000000000..c5c03d572b643 --- /dev/null +++ b/actionmailbox/test/fixtures/files/invalid_utf.eml @@ -0,0 +1,39 @@ +thread-index: Adjkg/rniynGRZvvRu2Ftd4zu7/YrA== +Thread-Topic: =?iso-8859-2?Q?Informace_o_skladov=FDch_z=E1sob=E1ch_Copmany?= +From: +To: , +Message-ID: <05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com> +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_NextPart_000_168F_01D8E494.BE7019A0" +Content-Class: urn:content-classes:message +Importance: normal +Priority: normal +X-MimeOLE: Produced By Microsoft MimeOLE V6.3.9600.20564 +X-EOPAttributedMessage: 0 +X-Spam-IndexStatus: 0 + +This is a multi-part message in MIME format. + +------=_NextPart_000_168F_01D8E494.BE7019A0 +Content-Type: multipart/alternative; + boundary="----=_NextPart_001_1690_01D8E494.BE7019A0" + +------=_NextPart_001_1690_01D8E494.BE7019A0 +Content-Type: text/plain; + charset="iso-8859-2" +Content-Transfer-Encoding: quoted-printable + +V=E1=BEen=FD z=E1kazn=EDku, + +v p=F8=EDloze zas=EDl=E1me aktu=E1ln=ED informace o skladov=FDch = +z=E1sob=E1ch. + +------=_NextPart_001_1690_01D8E494.BE7019A0 +Content-Type: text/html; + charset="iso-8859-2" +Content-Transfer-Encoding: 8bit + +Vá¾ený zákazníku,

v pøíloze zasíláme aktuální informace o skladových zásobách. + +------=_NextPart_000_168F_01D8E494.BE7019A0-- diff --git a/actionmailbox/test/fixtures/files/welcome.eml b/actionmailbox/test/fixtures/files/welcome.eml new file mode 100644 index 0000000000000..27fd51c58a586 --- /dev/null +++ b/actionmailbox/test/fixtures/files/welcome.eml @@ -0,0 +1,631 @@ +From: Jason Fried +Mime-Version: 1.0 (Apple Message framework v1244.3) +Content-Type: multipart/alternative; boundary="Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74" +Subject: Discussion: Let's debate these attachments +Date: Tue, 13 Sep 2011 15:19:37 -0400 +In-Reply-To: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +To: "Replies" +References: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +Message-Id: <0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com> +X-Mailer: Apple Mail (2.1244.3) + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; + charset=utf-8 + +Let's talk about these images: + + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Type: multipart/related; + type="text/html"; + boundary="Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1" + + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar1.jpeg +Content-Type: image/jpeg; + name="avatar1.jpeg" +Content-Id: <7AAEB353-2341-4D46-A054-5CA5CB2363B7> + +/9j/4AAQSkZJRgABAQAAAQABAAD/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdC +IFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAA +AADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFj +cHJ0AAABUAAAADNkZXNjAAABhAAAAGx3dHB0AAAB8AAAABRia3B0AAACBAAAABRyWFlaAAACGAAA +ABRnWFlaAAACLAAAABRiWFlaAAACQAAAABRkbW5kAAACVAAAAHBkbWRkAAACxAAAAIh2dWVkAAAD +TAAAAIZ2aWV3AAAD1AAAACRsdW1pAAAD+AAAABRtZWFzAAAEDAAAACR0ZWNoAAAEMAAAAAxyVFJD +AAAEPAAACAxnVFJDAAAEPAAACAxiVFJDAAAEPAAACAx0ZXh0AAAAAENvcHlyaWdodCAoYykgMTk5 +OCBIZXdsZXR0LVBhY2thcmQgQ29tcGFueQAAZGVzYwAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEA +AAAAAAAAAAAAABJzUkdCIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAWFlaIAAAAAAAAPNRAAEAAAABFsxYWVogAAAAAAAAAAAAAAAA +AAAAAFhZWiAAAAAAAABvogAAOPUAAAOQWFlaIAAAAAAAAGKZAAC3hQAAGNpYWVogAAAAAAAAJKAA +AA+EAAC2z2Rlc2MAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAFklFQyBo +dHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAABkZXNjAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAsUmVmZXJlbmNlIFZpZXdpbmcg +Q29uZGl0aW9uIGluIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENv +bmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZpZXcAAAAA +ABOk/gAUXy4AEM8UAAPtzAAEEwsAA1yeAAAAAVhZWiAAAAAAAEwJVgBQAAAAVx/nbWVhcwAAAAAA +AAABAAAAAAAAAAAAAAAAAAAAAAAAAo8AAAACc2lnIAAAAABDUlQgY3VydgAAAAAAAAQAAAAABQAK +AA8AFAAZAB4AIwAoAC0AMgA3ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUA +mgCfAKQAqQCuALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEy +ATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMC +DAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMh +Ay0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA+AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4E +jASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3 +BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB/gICwgfCDII +RghaCG4IggiWCKoIvgjSCOcI+wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ+woRCicKPQpUCmoKgQqY +Cq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUAN +Wg10DY4NqQ3DDd4N+A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg+zD88P7BAJECYQQxBh +EH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT +5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReu +F9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9oc +AhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR+UH78f6iAVIEEgbCCY +IMQg8CEcIUghdSGhIc4h+yInIlUigiKvIt0jCiM4I2YjlCPCI/AkHyRNJHwkqyTaJQklOCVoJZcl +xyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg/KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2 +K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi+RL8cv/jA1MGwwpDDbMRIx +SjGCMbox8jIqMmMymzLUMw0zRjN/M7gz8TQrNGU0njTYNRM1TTWHNcI1/TY3NnI2rjbpNyQ3YDec +N9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA+ +oD7gPyE/YT+iP+JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXe +RiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS+JMKkxyTLpNAk1KTZNN +3E4lTm5Ot08AT0lPk0/dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYP +VlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW+VcNVyGXNZdJ114XcleGl5sXr1f +D19hX7NgBWBXYKpg/GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg/ +aJZo7GlDaZpp8WpIap9q92tPa6dr/2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfBy +S3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyB +fOF9QX2hfgF+Yn7CfyN/hH/lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuH +n4gEiGmIzokziZmJ/opkisqLMIuWi/yMY4zKjTGNmI3/jmaOzo82j56QBpBukNaRP5GokhGSepLj +k02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ/JpomtWbQpuvnByciZz3nWSd0p5Anq6f +HZ+Ln/qgaaDYoUehtqImopajBqN2o+akVqTHpTilqaYapoum/adup+CoUqjEqTepqaocqo+rAqt1 +q+msXKzQrUStuK4trqGvFq+LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm4 +0blKucK6O7q1uy67p7whvJu9Fb2Pvgq+hL7/v3q/9cBwwOzBZ8Hjwl/C28NYw9TEUcTOxUvFyMZG +xsPHQce/yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI/0sHTRNPG1EnU +y9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba+9uA3AXcit0Q3ZbeHN6i3ynfr+A24L3hROHM4lPi2+Nj +4+vkc+T85YTmDeaW5x/nqegy6LzpRunQ6lvq5etw6/vshu0R7ZzuKO6070DvzPBY8OXxcvH/8ozz +GfOn9DT0wvVQ9d72bfb794r4Gfio+Tj5x/pX+uf7d/wH/Jj9Kf26/kv+3P9t////2wBDAAICAgIC +AQICAgICAgIDAwYEAwMDAwcFBQQGCAcICAgHCAgJCg0LCQkMCggICw8LDA0ODg4OCQsQEQ8OEQ0O +Dg7/2wBDAQICAgMDAwYEBAYOCQgJDg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4O +Dg4ODg4ODg4ODg4ODg7/wAARCADwAPADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAEC +AwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0Kx +wRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1 +dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ +2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QA +tREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk +NOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaH +iImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq +8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9v1Wob5cWEh5q4v3qhvlzp0gz2oA+XvEwiTXbtWwTuJ59 +6/Mn4tCGP9p+OabLR5UEKeB81fo345uPK8Y3lvnkj86/M341XaW3xuSfjYeWz3IPFAHv+r6mINN0 +LdLt3na+Bnj6nmvtn4ISiT4eaeN2VVSAfXrX583Eiah4L8PrCgeVmGZT2yucV90fAZnTwLbiQ/vQ +SKAPrjTseWMVsL0rhdU8UaF4R8E3/iLxJqUGkaLYw+Zd3UxwqD8OSScDA55r4n8Yftla1r0l/bfC +rwxcQaWG2w6zrVs0YkyR8wQ4IVgTtJGTQB+iEl1awRk3FxFEoGSXcKB78kV5n4m+MHwu0W5TStY8 +c+GbS/mDlbd75C20dWO0kjFfiD8Y/ib8V9e1Kzmu/Ef9owLcx3U9pZ6o8TSIzlGgAyAQvXH+1XkX +iP4ca0uq3ev6fJHo2nXOnTCeVSX8iKVlQfvDncVYqrjrhgegoA+xfi9J+zR4ul8RfEZ/iO/2G5u2 +imtYLDfdCYHaDEpwQrY9ORXhej/DT4A61q2oahonxIkdbexa7vYdV0do4lj3KkgV93Ubs4+pr4ck +8F+LLmaz068WKwuWl2hJnIFwFTd8vcDaAQehwa5PWfE15HomnaQt1NpMls6xsshDCRxv2yAqM4wS +Dj1oA/c/9mz4O2Pwt+OGpeJ/D9/4e17wvqaobHU9OvUm2wkE4bPKnPav1XsJ1n0xWVw+ByQfbP8A +Wv4/tB+N/jHwHbKPDGuXaRXOyS5tIZCsSspPzIeBj2PNfU3w2/4KK+NfC3jjT73UL3UbnThax2dw +s0rSb23sTcFehbaQuP8AYoA/psHRfxplwhe1IHPB4r81Pgf+394d8ZR6g3ia7tJ7CC4X/TreJ4sR +FQNxRhuyGznAxzX6SaNq+l6/osGpaVeQX1lOgaOWI8MD0oA+SfjX4S8Rz3yX9pcB9PLbJY8cqCOu +fxr4J8ZeAr6y1YbJ237+cnOa/YfxtZRT+E7oMoI8tsHNfnX8Q7i2ttfjMwjVdvJPrmgDrf2UPhdb +vfap4q1PM96lwILTP8AwCx/Wv0UtoPs8CoDkAcV8q/sx6jb3nw4uli+8l64fjHOBX1sBx1BoAjC8 +Uu3PHpUoXj/69L0oAwNZ02C50e4WVN4dcN7jHSvgPxR4b03S/FGrQrbeUBOzqM4wCOBjvX6H3p/0 +F8gkbT0FfnH8d9e/sLx1fGSC5hjkAw7IQrHDd6ALPw9ttNj+K2jzMka4uNqZ+9nFfonZL/oEfPav +x7+Gev8AiDxP8YtLXw9ps+pXVvcrLNGH2oiZxuJ+nbrX6+6OZjo0AnULJs+bFAGnTgMU6igA7V81 +fHG8srDRLWa5kiTbNxu6/dNfSh5U151488D6R4t8OT22p2a3MRXj1U460AX1B80U26QnTpR3xVlV +4x39ajuQTZyYGaAPjT4nosXjGeXG1sYQj1r8u/jq+34rQzNgAHDhugz6V+qXxYg/4qhwOAw4NfmB +8e7Zk8dWqlR8/V2XIHpQB6b4Uxd/DPSIRMWLMHDKPbpX1T4U+JPg74VfCsav441eDRY2ceTE0bGS +VicAIO5JwK+LLDxloPgfwHo+i6xLNJ4mmKyWljaL5hmQ92C9Fr6j+DX7OF/8afHlp8TPiLHfw+Hh +KH0zSrp9yKi9BtI+XnmgDfu/D/jT9pXWLHUNVguLLwjHcpcaVpkTsINqnlrg/wAbnghegr2vw9+z +TpHhnTpILxm1ppJzcKbvDorduP7o6AV9g6Zoum6Lo8NjplnDa2sSbEjjQKBirMkAkYbwuPpmgD8a +/jx+zvf6ppUy3dxp+kWVjKj6febREFk3Z2lgOFI6+4r5q0eP4p+HPi3caBDp9t4u0a5sJ7q50CZU +P2YpGFMgJ/5ZnCsMZzX9A2t+GNK1rSJrK/topoJVZGBQelfF/j/9mG1l+IGn+LtKudSi1exiaG2k +trgoAmDjev8AEP4SO4JoA/Jn4m22kReCfDWtabpuqaNf6jDHca4uopi12qCQ0DDn5gSMDtmvhXUd +Klt/FkkxZ4nW28+1Mluf3SqcBVz13Zzk1+wvxq/Z08QWvgm0t9Age0tjeiSexsZz5NsrqRIsCvzs +blmX+E4xxXx/rHwB8ceIYtXWSwvnksdtnYwYO+FCOPlxkDHPNAH58apJfzeIDbugaNQ3mb1wVz06 +VNo+hXd9enT0i8+Vx9yEZI9zX3p4J/Y18Za7dPealGYAB5ayyRk9OpAxzn+lfaHw6/Yw8K+GjDdX +0dxM+P3m9fnYkc8HpQB+afhT4KeO9Z8E2S6Ok1rLMrRho8KyRHHJwfUV9X/D74+fEv8AZ/8AEtho +vju/8V6Jp9nai2XU4ZfOjuGByMoflBxxn0r9JtC+HGl6Do8dtaW8duqR+UmE/h+vrWb41+E/hXxx +4PudI1zSYL61mG12ZRvPGMhuxFAHtXgj9pbwN8bPg8jeFNds5tYe1HmxHLFHK4G8fwkn8K/O34ze +IvEGh/GG80HW5Qt7AqsHAIRgScYzx1r558cfDH4n/ssfE+08ffC+8vb7w+rGG5tjllkhbOYpFB7A +5DV2Wl/ETSP2lfhdL9tk0jS/ijo48i3+1XbRrLGTkELn5iP6UAfr5+zJ8OLvwr8MotVvdQuL251S +NLqRWOFjyoIAr61UYQD0FeQ/BN5z8APDEV3JHJdQ6bDFMydCyoAT+lexYB96AGUYNPwKWgCNkDQl +W5Br5T/ah8I6Zf8A7Out3clsslzbAXETAYYEH1/GvrCvD/j9bNdfs2+KkQAuLByM98YOKAPiL9iq +K3HxT8cW5jVnUQvGx6gZI4NfqTGirEFUYGK/KX9ja6Fv+0p4rtndf3umo+M9w4/xr9XEIMS4I6UA +OoozSblHVh+dAC1VuhnT3HXipvMT+8KqXdwiWL8joe9AGAoPPFOkX/RXzTgCDTn/ANQwoA+R/izF +/wAVKGPKlDgetfmh+0E+nLrFn54dL5lH2TYMtI5ONuO4r9Jfj/qFrotmdTuXRI44mLEngcGvz9+H +XhO6+Inx2/4Tnxf5ptYbh10TTwMpGRwHP6cUAdp+zF+zJP4z+IFv4g8a28xWGOKYL5eCADuC8/qK +/aDStMs9J0W206whjt7aGMJHGiYAArifhp4dj0XwHAxiQXUyhpXUY5x0+mK9LVcUAMKcVWlyGGOl +Xz05qpcDEZNAFB3A61k3Gx3J6n0x1q5cPgEd6xJJMTHNAFC/0rT7tClzbQSIPVBmvMLn4e6Rb67c +XdlZwq07hpiRncR06V6dNOPM64+tQF8HcckY7c0AcFb+FNNhgYC3i8wdWCbRUdzpdlGuFjHHcCuq +1CYpFlQ2Se9c1PORuXkkigDmL62jWJAqfxelYEtuU4UZ56V092ZPJL/dweprJkQtuc79w6YoA4vV +dJtNS0u6s721guEkRhtdQRyMd6/Ff9qf4KS/DL4uDxZoEVxaaPeEOPs+Va1kGfm46jJz+dfuVNGs +8GSxLA8jvXzp+0B4Eg8afA/VrN4/KuooWaGYLlhwTQBf/YR/aLl8QaBp3grxRqNtc3gsYza3sT5F +zhcEsD0bjkV+pIuo/LBDhhjrmv4+PAfjrVvhN+1FpHm3t1pWmremGa6tXKzW7HjfjoRX9J3wU+L9 +j8Qfhdavp2rx6rqdhGq3gA2uRtGHI/2uDn3oA+uDexA4J5+tMN/ED3x9a88S7upow2GTIyQDmmlr +lpMGQ+2TQB3ralEGPP615D8ZdTiPwM8RrlfnsnABPtXSfZrhxuJevOfifo0138LdVjILZtmwPXjN +AH5xfs++Jf8AhF/2v4HYOUvYWgfnjqCP5V+v9tr0b6bGyvu+TjFfiz8PbQXX7WXh2EKoR7g/pX7H +abpoGiwnoAlAGu+vjPy5P0qBtclz0asXUL3T9NtmeRkXb97ccYrjJPiT4Ztrry5NUsVc/wAJmWgD +0Y6pdO3y7h+FVLy8v205znsccVFouvaXqtukkE0UqseGVwRXVfY4pdPZlwwPSgBPtCD+IVFJeIIH +5HtXnD6+4J5HHvULavcSRcNgepoA8J+P+iSeMrvTPD6TbYJnMt4yx5PlqckZ9T0qD4VeA9Ni8TaW +tvFi1tmJIcY3MeRx9Bmuj8W6xHpxS5uZYxc3Eqwwr3bJ5/CvSvh5aK0cV2xiUMRsjVcHPTJoA+g7 +RFjsokUAKB0FW6rW/wDqUHtVmgBrMAue9U5zuXOasP8AKMkZ9qpyAtGTnA9KAMm7GMk9MVzUzYbk +nJPFdNcgDJOTx6Vz158iltvXrx0oAzWAOfm/IVI6qUXLP+Aqtna2BuJJx0rXS3DRZPpjA9aAOS1A +b3cIWYD1rl5jiZvvbSOSD0rvbmyzIQCVbGc/zrlru2xFKwQhc/dFAHPSAtjIOzoCeap3EZcGJsq2 +3IYKeaufP5qD5goPAxSF90rHf83TINAHNtbqB8uVnJwc1zfi23YeDbxRF5p8k7sLmu/8tTMemeu4 +jNYniC3WbRXH3d0TKcHAORxQB/O/+0X4TGnfHDV7kwAWtwxLxhcEdwR719n/APBN/wCM1hpHxE1H +wVr4cXs8I+zXTHLSRDpG/rjqPbFeVftRaOV8a3lvL+7uoyT5eM7k/vZrwf8AZ28Rx+A/2vdC1yec +WtlHGxedVDHGRxg98n8qAP6MvFHxg8MeHb2W2uNVtRMibvKjILYxkfoRXlQ/aT0l74Rw+Y6g5zuA +r5TtfgP8VPjF+2r4k1SDV4dF+G8kUElvejLyT70VnCqOnJIHtivraz/Yl8FWunfLrPiFrwIMyNKv +X1xigD0nwd8dvC+tahDaz3y2t052rHK+M/TtXr/iXUNMvPAN5+8WRXtmIIYYIINfnR8S/wBnbxf4 +BsH1jw/e3Wv6VCSWiCDz4sd8jtXI+Hfin4rm8ItoralLJFHF5WyWP94oHGDnnigDiPCur2ui/tfe +H724Kx20OqOoI7gsRX6x6z8QtE0P4cyatdXESQRR5GJBuNfj9eaHv8epeyNIiRyiQtjGD611PjLx +l4n8QX2keCdDmu7/AFC72xwxLJuGT/EfQD3oA6b4k/H3xZ418bvo3h23vnDyYgsbI73YerEVgwfB +74/arp66uPDDxqfuwz3KiQ/m1foN8Av2evD3w58K2t9c2seoeKriPdfahIoLFz1VM9AK+pU022SM +KI0X6DkUAfiRo/jz4k/DLxoIdRg1nQLuFvmtrk5hkHoCTg56cGv0n+BvxusfiT4ZuYZ1S01i1Rft +NvnoOPm/Wuw+Lfwp8PeOfAF/a32n273Rib7PPj54mAOCD9a/LPwh4lvvhH8eZrmTcFj821vR03gE +YPv0oA+wdT+JOmWCmeXVokY9FZhWt4O+KmieINdOnw6lZXE687N3NfCei/sifHfWvD0V14m8e26X +fa2SIkD8a4bWvhr8WPgd4ph8QXEyX9tZzh/tUbZ3A8EP/k0Aff3j3UDd/GvT9Osx5zgRFEYZX5jy +R9BX1Z4LiA0a0d1EcxGWGMdsV8QfDjVLnXviPc6zf4897G2KW5XhGZclgTX3/wCErMy6fFKuPLRQ +oHtjP86APQbc4jUe1WjytQDCqPYU+WaOGAvIdqjqaAGzEbTyOlUTINmM8is+41yyDOvmqFH8R6Hm +sefXrMT7FuIM9wW6f0oA1rmQFWAZRxXJ6nchoGUOcj0ps3iHTPtBj+22zT4zsVwTzWNdXkM29VYE +hucUAWbb57sFi5Xp1rrbMp5Wzg/hXHxPHGZGEinH88ZqzpmpCe9aLzASvJxQBq6tIsKk8Z71wt7d +K1wI/wC8Ogre1qVpZv3WXyOOeK4p3mMiO6qnXnPH60AMmh+csMHH+1VWe1PlZBNOa4T5klnVJeoD +EAH8azn1yzRGhN5ayFW+crKp2/rQBYVGR8E/LjFEkMc0Dps8xAhDJ7mnFormz3wzblOfmXnBHak8 +mS3tFcgNuOD3P40Afkb+11p0ln8QEfyE+0NG2GI5MeT8v86+FdOt7P8A4S/Ss23mWksiM5Xg7DuW +T/vng/hX7J/tYfDNvEXw+/tvTYQ8tsv71UHzNX463dmdE1WeHMqtFd9BztznP/6qAP6Mf2MbhtW/ +Ye8KXd5GXvbMS2byt/y2WORljf3+QLX2AqgghcY+lfAX/BPnxRbah+wRplq0redaatcwN5h5ILBx ++jCv0Et9phDD5uOwxQBmXmkQ31q0UsaFXUhsjOR6V+f/AMd/gMnhjVpfHvhmN47czF9SskXK8nJd +cD3zX6Odq5/xLpMGteDNT064UFLi2ePpnqp7UAfij491a2ttGa4twhDruyD1Fe+/sYfDo65far8T +NZt/MneU22m704SMdWGe5r5R+KOkXeia5rGhXqTI9lcPCMjhlDcEfhiv13/Z68NQeG/2b/C1jArA +LZISSO5GSaAPdoohDbqiAcdKmzmkZgqkk8Cub1bxLY6VCXupo4VHdjigC7rlxFBocrSHACE5r8TP +ifa/8JZ+0JqGnaQHmlv9UkjhCdTzzX3P8bvj5YWHhe/0zRLuOS6ljKtcI3CDHOD0zivGv2VfhjqP +iv4nT/EvxDaulhb7otLjljIMjnhpORyOeDQB+gMccSLsVVAHQcV5r8SPC2m+IPAGo2d5AJUmi+Yd +jg969IxtbJzXHeONUt9M8FXs88ipEsRLMegHqaAPjn4b6dLZ/F7UQd8sjgMGEoIjijyAAv04r9KP +DlukPhi3YIwZ1DYPB6V+cHgvxRpD+O7S+02CNreOUW81ymPl3PkCTngZ716n8VP+ChX7MPwN8S3P +hHxh4t1S+8UafDG13p2jaa9w8bOoIXcSq5wc9aAPuaV/LhLBCzcgDsa8u8R+IL5JTH87IeCqDBHN +fOPgv9qH4sfG/wCHY8S/BT9m/wAQR+GLpd2m674+1uDR7W9TODJHHH5spXjqVGe1a2sP+1rLpLTz +j9mnwyQMiSS+1O8KDv1gUUAcl8TPH/jDw8zvY+Gtf1lQ/wA6WKDESdiMn5j6ivknxP8AHT4k6pLP +YaZ4A8QafbSykW9xJK6SSN0OVwdvT8a3PiR8Xvjf4X1aTTda+L/wLudRmQywWml+E7uYEDqCxcYP +1r5ktv2j/jPca1DDc3XwpmBnKLnTrm3Zz6/KzYoA9Eh+IPxIu9YjkvfDWoadqUISIXKxMzEbuWJ4 +6fSvpnRfix4h0uG0tdVmlluDKUQgE+cOCD+Wa8Y8M/FD4rX2mC8b4c+EPGzRDDJoPiTZdfMM8xXM +anHXvXRaR+0B8Kz4ks9A+JPhTxd8INWL7EfxXpgjtAxz9y6jLR4PqSBxQB92eHfET6ppqzApIzxA +8HjJrQh1uPR9f3zMiIRhgetWPB3g6K68GWWp6K1td6dLGr29xayCVJlPRlZcgqRz1rn/AIheGb59 +MutsUlvc+WRHIVOM/hQB5F8Wf2l/C3geymhkik1O6jO5bZDjdnjqK/Orx9+2T8QNR1NhoerT6RZw +zOu6KNeehA+YHpz+dYnxzbR9C8feX418U2sd+rsfs8MvmSYHT5Rz36V5nZ3GhT6TDcaT8KvG2r2d +wQYrq9gjtYpj3I8xt2D64oA6Wx/bB+KGoavDa39zc6lbBSZHiiAd/TOAK7VfiF4x1uddRsbTxLbS +mPDfZ43ZexYkY54xXEWPxO1zwzrzR6Z8EfCNo1uVMi6nqgLIp6E7UP6E19D+B/2lviRf+Ko9Mt/B +nwb0uW5IEEOo63NbRMMfMRIIiOlAHT+BPF/xOQrNFd3UIkkU2ofiM9jvBPfHpX234E8bXXiOxey1 +zR7rRNVjj+ZiQ6SEfxKQeh9K8X0bWPjfq+lpqy/Ab4WaxpUsfE2g+OUcyYJyQssKjP41har8edR+ +HYWbxz8APjd4aslc4vNM0uDVYkA65+zyFto65K9KAPqnXtNg1TwvPaXG0FlwrEZGcelfz/8Axn0t +9G/aB8T6e0oSVbyRQDHtzzhSB9K/XXw9+2T+zZ4xttkHxU0bSL1JhG9nrkT2UyOMcMrjj0r84f2r +H0iT9rqDWdOvbG/0TV7Tzbee0ZZI5M9JEYcHoR7HigDa/Z0+PHin4YfD3S7fT72M2E2tTxypOpEb +nZGcA9mr9d/g3+1Jpvi3xrp3h3WLM6beXqYt3WTekjDGRwetfN/7CHwe8LePP2ANXufE+g2Wrwze +Mbs2b3MKkgJHGmVI6DKnv1zX2r4U/Zw+HnhTxZHq+kaDDb3cZzE+4nyz3Kg9KAPpKORZIwynIIzm +ormZILOWWQgIqk5J6cVSghktbVETJOcc9Kp+IrG41DwxdW0TbXeMqCvagD80P2jdD0298WanrkKW +y+bOAz7hg4719Ofs5fE7TPFngCHTUljXUdOjSK4iB6DHB+lfD/7QX9taJruoaBqAkXyJfMR8fK6c +4P1r339iv4e3dj4Nv/G9/Jum1kr5Ean/AFcanA/WgD7/ALzzG09vL5Jr4B+NHgL41eIfiIp0OCHU +tFf5IlFwYxCfVh3r9DQuItpwaryWkUsgZ1DEHJJHWgD4D8AfsmtLfWeqfEW9Or3Snc2nRDFshz39 +a+6tD0Kz0bSYLSygitreFdkUUabQorZSKONNqIq+mBVjjbz1oA+XvFfxF0nw7EzXN4iEKTtB5OK+ +BfjF8Y/G/wAUdI13wX8IfC+veJ9R2hb+ewt2dbVSQAXIGAD/ACr6O+MHwBuviFOHh1rUdJlAIL2s +hGc19E/s+fCjw/8ABX9nqx8O2Amnury4kuNR1CfBmuJGOAWYegGAO1AH5p+D/BPj/wAMyQ2nikRa +JDPpyz61ehCymBEIkG0DJcdABzmvCP2tf2O7Dx18B/Ev7Tnh+bxfoWvJDHe3vhvXIVEcunxhEyvA +aJ/LBkwxJGce9fu14x0XRPL0uQ6fay313qEUcbsD8gzuYj/vmrvxK8Gad46/Z38a+D9St47iy1rQ +rqyljK8kSwlev4/WgDA8B3+g6P8ABnwh4e0wC107TdCs7e0j9I0gQL9eMc9818c/tQ/GDVbLTV0n +wxBc3l3NP5dvaxNtkuJMckgZJQf0r6I+CenQeLf2BPg7qkgY6u3gzTrbUW6EXUFskNwrZ/iWWN1P +uprc0v4baZ4d1afU2tYLnU3yPtdzCsjop6qpPQfSgD8qvjP8IbL4cfsC3HxK8V+E9R8Y/EnxD5Vs +ki3726aCZlyJSF4cL0wcZJHNflh8Ix4k8U/H5tNsYtdvvJMkxiRAjEA4AmJBABweByQeor+pTxjo +un+I/BOpeHdat7PVNGu4TFNa3AwpHb6Edj2r5MtPgr8N/h3Ndy6FZXuixTNulSDVZZBIPT5jmgD5 +C1Pw/p3w2+KulaUuqXdrBeW6SQNE7+ZYyPjfGxA5jB7E19C+G7G713Vrzw547tNM1bwRJYsNSS7U +SW7W7Kd8nIOAF+YntRd+AND8R+N47tNEdLBAwmuJ3O+XJ7nOTXR/Gn4d+JdP/YWtbzwpd22n6jHf +2VlpOlSs63GtXEsywWlkrKwxvmdC+cr5YcngUAfGf7IH7JHjz4taz8X9Z0j4+/Ev4W/AbRvF19ov +hy38O6i6XGpmCU/OvmZVIkRkXhTuJI42nPo/x1/YZ+IHhb4banrPw/8A2qvjLqGpRRM4tfEV+Xiu +DtJ274ymzOMZINfqv+zt8J7H4Tfsd+Dfh5a3BvU0OzaK4vCpH2+9aRpLy6YEk5kneVgMnAIAJxmu +u8X6dZXPh+5tbmKOWCRSrKy/ez26GgD+YT9mzwx/anhjU9cv0XxH8So9ceC9+3sZ57dUO0Ehs/KT +u59q+1Y4NQ8R/Fa18Ixam8d+I9mpXgcFIU7xRA8AkZGe2OnFeJf8Kq8W/Cj/AILBvovhTWrHT7TV +rifUIxODtvbVsG4hAx80mzayDsd1fTllpPh7SfGP2m20bzNQhuGaaOclnQMxzznnrnNAHwF+07p4 +8O/FLxDp9l/bemT6TqAt7Kz8t3gS2aNT5rOGyzMT9PpX1Z+yP4A8PfHD4a6x4Z1HRr+WXT9Jjnm1 +LVblW+z3TMw/dKACkZXB2kk5zzX1BrXwM8EfFSW31G8SW11Z4ET7RDc+UxxgAOCGzwK9o+HnwCm8 +A+Fp9E8LS2WmWFzk3twDumuTggFnAHAAHGKAPjT4aa/47+Dvx1l8Im4v49Fiumt4lnB+y3MYPRc8 +KT/eFfpA3jjTpvBUOoGb7HOQP3DOQ68cjj/JryM/Ae61W626tqF1qQChYy5ztGc4Bz2z1r1Pwl8J +I9Itxb6jqEt3GhwiyYYgdsk0AfhV+3l8OIPHn7efhq58A+HILK+1vQpJdUaO08uOV4JGDXDgDrtI +BOOcCvcvhH8GvDPxQ/4JBaN4Og1nTrP49/DbxkbeWOdtsws7+72qjK5BeB1lVlYZwy4r9FW8B+Hr +z/gpBq2rf2dFLHoHw+t7BUl5Ec95dzyv14OY44/w+ted/HvQ/C/hn4t/C/xbqejaV/Yt3fLo+pq8 +KgbWYywPlRkMkq5U9iaAPrL9jLwU/wAPv+Cavw10i8Ahubq1l1K5DJ5e1riV5QCD0IVlFfU8TB4l +ZSjIehVsiviXWNNubvSDd+ONa1PXtB0uAtZ6X5ax20MajCqIYwokOAOZNx54wMCu4+B/j8ap4hGi +xWk+naTNAWs7WVt5jIG4Ef3QRn5e1AH1VwR2NI5+RvoaUfdFUdRuUtdJmmdtoVGP5DNAH5k/tXQr +qnxIv44UDLFbAOQM8n1r139izxXHqv7PEekOcXWk3L20qkYOM5XNcN9mj+IXxJ8bM4+0Ib1xGSM7 +VAwB+lcx8FHl+E37buq+Db92gsNfgWa0APymRf64oA/UCiobV1ms1ZWyCM5qzx7UAJt5p1GR60ZH +rQB5oFBYE5OPXnNdLIobQ7FAdqMyjA+tYIGOxroLMCa1tQ/PlzDP06igCj4jgjl8UeF45BuAvmeP +nphD/jXYj/V+uBXBa1cl/Heil8lYZ2x6DIxXcr93k9s8UAfLOnaT8Zfgv418VW3hrwpa/Fv4Vajq +k+p6NpthqEFlrOhSXEjz3UJ89liuIGmeSRNriRd+3aQAak1f9prwZpemLN408EfGTwLCx2tPq/gm +7ECt3HmorKfqODX1HkZPSqUzSeWyoxXjggkYoA+Gte/aL+BWoWgvI/iXaWMDtjbcWU8b4z3UpkV5 +PqXxz/ZyFw0svxH0zU5hu2KqTysfYKEr9D9R077W379VmbGCWUHP6Uy28OWULCUWkEZA/gjC/wAh +QB+f/h/46fDjVr54fCnhT4p/EK4i+ePTvDng27k8zjJUyOioPxPevpTwP4V8feOPHWj/ABC+K/hq +x8D6RoTyy+CPA63S3U1pLLGYvt+oOvyNciJpEjjQlYllkBJYgr9BxSNEwjMj+X0xuPNacWLm8RcH +YvJPqaAL9lapa6XFbxLtRVwAa8+8a7k06QxqTg9MV6TI6RozOcCvP/E9wJbCYLjBFAH53fHb4Y3X +i7WdJ8TeE7m30H4i6O63PhvW5Uylrdx8eXIO8UsZaNvQEHtivID8WvC8lzbWHxc8M658GPiKF23M +txpslxpV2y8GSC6iVl8ts5AbBGelffWoWMFxJcQTACOTBBB5B9RWVFa+TcfY50MJPPnFidwH6ZoA ++cfC/wAVvg7dygJ8SPB2+JwhY6ksbDA7bsV9A6f8Tvh9DYxsPil4OWA9PM1eEDHv81WJ/B2kaoJJ +L3SNC1IN90XenRSH8SVyafB8LfALMDL4D8Fyucbn/seHI/8AHaAHTftCfATQrQR6l8Zfhxbyj+/r +MZP5AmsHVP2sfguNKkg8LazqHxH1mVCtvpfhXSZr2e5bB+VcKFGf7zEAcEmvSLLwd4S01Qtj4U8M +WpXo0WkwKfzC5roBI0dm0EREUWMBIwFAH0FAHiPwo0fxRHpnijxr4901tD8VeMNXGoy6ObgXD6Ta +JCkNraO44LpGm5tvAZyMnGa8Z/bVvIbb9ky1iZGMp1eAxvHj5DuJLc9OlfYshYq2CDkc1+d37eGq +xxfBXTtOLMBLqiRsVHAUAnn60AfdOif8Tn4U2+p+YJ0utLjdeRtY+WDn8a4r4KpJP8Z9LCblEM0m +Qp4AAP515d+yt4qutb+DGlaZdXD3It7IQhT/AAqFwB+VfSnwQ0dLG+u9SlUAxvOIiepBkIH6UAfV +W84POB2rxv4yeJx4f+EuqT+ZtkaMxx4P8TcYr0s3a+QSzEfjXx9+0JrP9oTaPoolyjTGSVVPp0zQ +ByfwJsp5tV1ed1LISvmHH8Z61yf7UGj3mg634W+IOmq63uj3auTGMEr3BPpXuHwKt47XwnOzgB55 +y+K9I+Inhmy8VeBL/TbiFJ4ZIiNuAeaAOw+Gviu28VfCrRtXgkDR3Nsj4J6ZHI/OvQ/MFfn1+zn4 +rk8F6trnwy1u5ZJrC8drIynG6JjkAfSvtM65B5akODlc/e4oA7EzKD1FMNwgNcI/iK3BO6WPHfD9 +KzpvFtlGfnuY1696ANlQW2kdDWtprkNNGccgEfhWJZyCWwjcNkHpitmy+XVYm/2se3IoA5LxvdjT +tSgmLFVDeYTnHCjcf5V6Jp9yt1o8E6n/AFkSkV5x8XdP+0fCm/1CGMNewrtjBOOvek+FviWLXfBA +XcBLEem7PH/6xQB6pTTtbgioy3ynHWkDYGc80AQmKMyHIxj0rPupdsfyk4qzNMVjcnGa5y9vMAAD +B5oAhmuQsuWLYBya7PTgselRytw0nNeVNPLcXgjHzMeta/jKXUJvhrJb6bczWN3JaNHHPFwY2IwG +HoQeaAOm8QaqkFiMH5jxgHp6V5zrOqwiwhikLnzCdxHavnX9nLRv2lbfwr4z8M/G7V7bxLo1hOh8 +Ka5cbFvrhDu3JKVADAALg4B46nNdPrN1qi6w9lcKsUsTfL5zbRj19xQB18dul9NIYgvykAkZ/X0r +Onl2aqLCeGMSIMlycg185eGNK/aGt/2ztf8AE/i7xRFB8K7a3aHRdEsI4xbzqwAEr4yxb6n8K+gd +RlF9NBLHuVo4/vEfeP8AkUAdHbqrwrIg6HoOlasKDzBgsuRzzXOWk5eNNhZcD5gPWuhiYpGrj5z3 +BNAF/agTaS2fWqUx2nJOFPGR1zQb2KRiELZBw2V6VBIwklGG3KOgI70ANLBQS21UXhjux9TX5P8A +7cXiO0vfEXh3w6/mSs9z9okRTw2GAHP0Jr9RNcv/ALF4fubjjcqMB7nFfhd8e/FNv4r/AGx3Essl +5b2t9FCkZ5AU7QcY/GgD9Fv2U4WHh/xZPaxmLyVZbJTIPkXbx296+m7/AOJvh34domjXd5bRXgiW +SZTJlskZ5A9+awPgj4J0Twj8MrS30aORjqMQOZCGZQcEknHoa+R/jJ4P8S+KP2i/F2pR3E0NibwQ +xiMZIVVAGP1oA+2bX46aHeaC0ttcmX5SQea8fv8AUJPFfjVdRuI3eLqpXpjtXiOheDda0vw3bx3F +2zKflGVwSp9R619BeGNOMFgo+YYTbgd6AJ9M8cJ4PYwSLweVABB/nXuXw88bw+MtDklAKujlXUnu +a8C8QeCP7cu43MbqB1bPNegfC/w/J4W8RzRR/NbTDOD60AeD/tEaTfeCvjVpfjTTA1ss8YhmdO5F +VbP43a9eaPAIJnZlXDlVzX1P8e/CSeJvgnfCOISXMEZmiJ5ww/pXw78ItPi1fU47W6jVQsjRyAdd +woA6+b4oeK2DYeVgx6KORWRceOvFl4JCDcljxjJr6Tg+H+lrhvs6cDnK9ad/wg2moxbyUAPt0oA9 +y+G3iODxL8MdF1a1ZZLa7tEuImBzlWXNekxkpMjDqrAivz//AGK/F1wfhXqHgPWZWGs+E9Rl02dT +/cVyEI9sAV+gEeSgYtxQAeNbBdW+FWrRkneLRpUwcbioJx+lfKPwQ10WPxIvLFbiGO0m5WEtyAcn ++dfTuvambHwfdrI3yPC6IxGQuQea+DPBkk/h79oi0t7qS3kzuRWCcuoCuG+g3GgD9IlcNHu7VBI+ +AeaxdD1L7Tp5Z+jn5SfTtitWcjqDxQBk3cxAZielcndtJI4+fac4HvXQXAaW48teSetT6do6Taj9 +ouMCGL7qnuaAH6LobQwiecAu3PPpXVNaWz2QjmjSRAehqGW7VQVyqjGMVk3eqxxxgLJlu/vQBmeJ +ruPT/D83kLsiij3bQeuBXxd4l8d2mqeNZo9sCXUWQBKeCB24r3vx9rc03gu/SOOV3MT7lA5AFfCW +g28moa1qs9zAIriK93QkOcquOQSetAHsGk+Lnkmis53jks5hveSInauD9017NpTWE1sjRliNgbbt +xg88c18369p9jpi6bcyLkTtiMJIQu4jjOPerXh/xRqekSzTXRnnRo95CNvLkHG0enWgD6aitxHdN +Mny55welaMDliWOGUjGK8dt/iEspjR4HgZQBJuHKk9M12uk66l/cI0MgRGcocj7rYGDzQB10jhYy +Q7AdCoFQmQmLaoGCOAPX1oM6l5AQpOOF/rWNc6tb2FrJcSsmVUgJ6nFAHjXxz8XL4c+H+qwRTJDc +R2jujHkbsHrX4W6Pcz6h+0D9v1BozcSXSSJK0m1Qd4AJz25Nffn7S/xVs1tfEVib5BeMoEdu5wGA +5P5Zr82fB13Pe/F6zinRpruW4iW0VCCOWAXPbGaAP6aPh9FDY/C6xYTGeOG2AEqsCDhQDgjsSK42 +40OKe7mufKV5biVpHyOpJqD4YyX0XwVsLC/uYLi/EYS4+zsWSIqACBjg/wCNeg21t8hkYEKoxz7U +AeJeLLWOzkihVBkEZA7VveGZIWC26bCwAPJyayPEkjT+MPJTEmSQSQTiul8I6LLFqr3Mm1lzgcUA +d/Dbj7OuEUH1x1q0irHdo4UKc54qZuE2jjFNwSBu7UAd3IsWq+E5IpFBV4yGB7ivzbFtJ8Pf2vNR +0xgYbCe6MkBx1zX6K6BdKY/IbpjGDXy/+0x4JIgs/F1jC32qylzI6ngr3oA9osZvtWkW8y/xLkgf +Snyjgj73qK86+F3iFdc8A2ZLbpljwxByOK9MnA8rjg45oA+Fy8/we/4KzQOSYvDfji32njCrdRk8 +H3Oa/UXTLkXOlRvjhlzwa/PX9t3wrej4RWfjrRoHfWPDGpRajblRyFU5cce2a+t/gr41tPG/wX8P +eILOZJIL6xSYAHJBI5B9waAO88Ypu8C3eACwU4z06GvhdZ7OHxJC8kdvHcacSkcu7LsG4fd+lffH +iGAXPg+8hHDGM8+nBr84fEyz23jzUYtPhDPGyuzY+8GfBzQB9l+GfFcK6NGzXILsqeVG2OV2jkV6 +/balFcWO1yiOQAvzdTX5/wDh3xVFe6jLYsXihkt1jgBPzxOh5Of7uK+ltE8R+dYCNJDJNakLPnsw +H9aAPahDh5JASMDr6GrWo3/9nWixrt2hNzMe3HJrM06+GoaArk4fHIrjvHurvFo6x20ck88oUKB6 +5xigDattQm1GFpVJdNxwcEB/oaR3s4pB9puVY5+4vJX2+teQ6f4c+Md9qdoLOXw9b+GXB+1wSXTp +dKexTClcevOa9CufA3jRrURabrOg6MeMO8T3EmcDOTgdeaAK+r31mbCSEaZcSrKfndmCkj6V59Jb ++FNKL3EumD7RKxMpYIM+nTis/wAU/Df41zazHNB4u8G6nZRkma2ms5YHPPADKT/KvNfGnhn4z3cN +vp9l4X8LPCp3yzR60wIOOmCm40AbvifxFotxLHbLo7XtuMDEJH7r0OK81nvPD5tZbYSjT33kKr/u +y34j0/rXlV38LvjRfeK7q/uvFsPhywcKBYaYokIx1ZmfBq2fhNr8unSwan8QNTvA0ZCbbdGYMeOO +M0Ad+1tdRadcPDi8jbDYS4Jyv+93ru/BkE1rpdws3meaAssblicgHGK8e8G/BnX/AA14Unkl8e+I +NQnL/uYbjAjiXPQjFe/ac/m6fFAAGukdYiVXaCCRk0Ad54p1ZNC8H3GpMIUl8jKZPGcV8E+Lvi3d +Jpup6tJeCODHlRqkhI8w54x+Br6F/aN1y7svhbJa2ilpSmxUH8bHI/TGa/LzXry7bwtdWOozNBZW +YeZWjGfMcDnr6ZoA+fvHPiLVfGXiy+v9TmWVoGkCK6YEkZruv2cfA8HjL4sq1/Oum2VraNcI6AF3 +bdhVGa8t1xoF1O0061DS7o/MXcPu5Hc19ffsq6ZE3imGeeGNLgwsjxbcAgdCPwoA/XrwVYpYfD7T +LK3thBHFEFwrZz6k+pPWu+v/APRfDjSDG4qc5rnPCke7RLIKP3eOlWvG18tp4VfB7dB9KAPKrErd +eJrlmJyrZznPfpXrekWogsQVHykZryTwjBJI/nMpyzZJPWvdbWHbp8SgfMR+lAFZlO4EHGac+DFx +96pMAscimBeelAFywlMGqJzgN39K1vGehWfiX4b3unzIksc0JU7RXOKxznHI613mkSi60jY3U9c0 +AfBvweu5PDXj/VvCt3K8Ztrlkj3emTivrJk327OGBGBj3r5W+K2nS+C/2pbLXBGwstQfbIU4APAr +6U0O/j1DwxbXCKwXyxgE8mgDpfid4Zg8SfDfVtNnQSR3Nq8ThhkEMMV8V/sS+KJdA1Xxf8IdVd4r +3w3qMi20Uh+Y2zsSp96/RbUoBcWUkbKXyMH6V+X3jK1l+D//AAVU8GeM4kNtoviqJtM1F+ieaAfL +J9ycUAfqpcos2jzgdWjOPyr84viPfQ6D8VtWhNu11cXtwLdsEI0S/MwYDoRkV+iOk3X2zw/HMGD5 +HGPpX5i/tXRzaL8evD9yqv8A2ddBmu+dpkCg4QHqCT6UASaVdWcmvPLDbra2N4gSBwdxDA4c+xr3 +LQdWKeMriDzpHiuYVdiOnHAb68V8Tx+JNUvtVsNTsrYQW1w/krHPJt278geWPUGvqDRIJbSW0S1k +23SRmOWRyZBhTkqSeh68YoA+xPBmpi4t5rfuhZSexA71uPp6ahrMUMm8LG2c+o9K8k+Huos/iWFY +GMttIm4+gPfivfLWPbfh8+tAHR2sMNvbBEUKo6cVBO8Y5LHHSmtOFBBOfrWDqV0PIfnbQBzHinxT +p2kWDtdyTfdJ3BfSvnfXvi3oUa/ahcTrHK4QOy/catj4ntez6NPFDPHCJU+QnqOa+NvEOmz/ANlx +6fJfzqEuPNmcKQHHYZoA+hh4v0HU5PObzpYnyBKHxkg9h+NdLBLpk1ruto4Wdjxg5Ixivm/wt4fh +1CSFXkn8xQfLKyZGD65719AaJottp/lli0kkYG07uOev8qAOyhjQ2o/d8bTuB5oiiX+1o59mY/L5 +Zudp+lRK7iUgSgemBx+NWlDfaFLs+4rhNnAxQB8sftEazGusWcbySSQrEwKK2CWPRv6V+YfjySWG +Yx+aQiR5njL/AHC3O0+ua+9v2ntRT+1biAxOzW6eYHj6luyfng1+YviJdV1DUHnurhpzfDz7nDgi +NVzt/KgDkbaaDU/FEUlxujkW62xgNzIu3v7V99fsytZXPjVbR5Zlmh3Kh2fJJ7cfXFfnxClt/aUl +ufOEKSrslXh2YnkfSv0a/ZC04z/Gv7OZAbGCAyMBgruYDCj6UAfrpoFr9m020jCgGJACo6HgZrjP +iBOJoorZCWZ5MBRXo1viNlYHgj9MV5jqpS88bIOGQMePSgC54asDawwxSZJUDJr0xF2RAljkdvSu +a0eDcWwOAcDPtXUffkfKHOKAKLYy2PWndQMdutSeWAOBn2NI20oduAe+KAISACQOAeprd0O4MN0s +TO23HTNYgXMb5GafBKsF5G43HHWgDhf2hPCA134Q3V9bRhr2zAmhYL8wxycflXnPwV8TNqnguG2n +fM0Y2sS3PBxjFfWt/bRav4JmhZVKvGVOR2Ir8+vDjS+Af2kNY8PzqyRSzGW2LHqCelAH6SEFgcV8 +EftreErq+/ZzvfEOnRsdU0G6h1O2KDDAxyKWwRz0zX33GteY/E3w9Br3w01XT54/NhngeNwehzxQ +Bgfs/eOLXx3+zp4b161mE32uwRmcHkOFwwP418kftz6Y39k+GdYgVfOsNSDDPAIIzj6VS/Yl1u68 +H+OPiP8ABzUXKz6DqrT2Ubn/AJd5ckY9gRXoP7Z1otx8GrK52sdl6h+T64oA/NDwf4iiuvHOiWd/ +dCRv7UX552YeWGYnCc4HNforZGax1f8AtC1aE6XbttmgY7hIzcBwevU81+feg+EtQ8VfErw/aaHo +V9rlzBdrNc2ttFvZQrY3SEcR/U1+5Or/AAg0O+8DxQ6bbJYzTQx70HbjJ6dT2oA8U+Ft/bx6rZwi +Q+fkh0ycqSx9e3NfWNugDbuvH9a+WbH4ceKvC/xMfUGC3OjtKJC4OGjKjA+ua+mtMvI7iyjlJA7M +O+aANOaMueCRWNeWEsqsvJBrpUZWRivrVaZwEOaAPLdY8F2F/EZL6JnEa7cZ9a+b/iH8PNEh065N +v9raAYMgSZsDHP8A9avsye6jaFy2CFGCT2rwD4kavbwWrxR4lkZxny03GgD5d0fQpNOliubZJljL +gMj/AMJPTp2xXrdm8n2eMNPbICcEEHJPtXCazr0UUgtonEUk0m3KgqQ3b6mtjRmuDJFJczuwiOJN +2NzH2xQB6bbDYrb9wRVznGQa0lkDEfO3loQSwHQVjfaolsdyA4bAwWxj61C+oQxRsjMAmM/I2c+3 +1oA+Ff2ndatNK1y8mm8uNnm+Qq3zOW+X9OtfnLqek6tqF2iaetxcTXbm3SCIbyEH9T1r9M/jD8C9 +b+Lv7Ruj3iT21r4RjieTUYizedIwxsAA4r6p+FP7P3gvwZLBeR6RZCcbSH27juA9TQB+O+s/syeN +/Bvwo0bxprVheQW9/M0sVqkDSuAo4LYGQD6V7r+xhL/Z/wAZLuzuo3gl80MPtEbRkg/w/N3r6w/b +2+JfjD4d+FvhpB4U8RXHhu0u7qZL8wTKrOileNpHIr4F8EftvfFrTPGeq2Gm3ui+INOswsrw6vYx +zNKc4wGxuUe+RQB+5Wr3X9naA15kLtTOPbFeXeGp11HX7i68wMHJC5PSvMvDf7Q/hb4n/seat42u +3svBN3oUiw+I7S9uh5Npv4WZWPWNjx04OR2qT4X+KfC3iK2kXwr4q0bXZly5SyvVkYepCg5xQB9X +aVGiwFxgnNbBAy3OOK5bSzNb6fHmQSnp93pWtDdgHZN8o9fU0ASgHBNRhFEJZvu9zVtWUythuoqF +bm0kumtkuLSSZOWjDgt+QoAqsdm3ByT37UhVgDtKlvSrLR7pNzfIPSm8hj/Cv8NAHVaDdebZ+RJ0 +Awa+Nf2oNBfw/r+h+OLJTHIk3lzY/iBHQ19WaVcNBqAz909Ky/jJ4Uh8X/AnXNO2Bp2tW8okdHA4 +/WgD1xO9ZfiKXTbPwxdXWq39pplise6a4up0jjjHqzNivz4/bP8A26R+z9q9r8PPhxpVh4o+KN2m +6f7XL/o2lK33GlA5LEdE7V+Efxk/aa+LnxU8Xxad8QPFmr+OS9xldONx5GnxyMeEEKYVgP8AbzQB ++sPxP+NPws+GX7dmifEbwb4x0Pxg01tJZa5pXh66S6nlXIKE7SVHPrxXG/Ev9rHxf8fvGfh/4R+A +PB9no2oa3qMcFp9snF3dn5gXkZEysYQfNz6V+a2lL/whngWSbybG31K6XNy1rGIyncJkdcV9f/8A +BMeyh8W/8FPPEHiS9jDtoHg+4uLIHpFJLJHDn67WNAH6K/E+20j9nv8AZLuvD/hy6FrfR2IfWdeE +Q8+6cj523dgWzxX6UeFbpdQ+Ffhq/SQyLcaVbyBv7waNTn61+O3/AAUhu7iw/ZambzWhtLm8SC5I +HUHmv1i+D0zXX7J3w1ncfM/hmyPPb9yv/wBagDq763WSJwy7lPauFlhk02+M0Ct5ROWA616XMmSR +7Vz9xaB1IYAj36UAUbTVIZYMq6pxnHenXeoRrACDnIznOK4fWbC9sbuSa0L4IyQvSvNtZ8X3sFg6 +ywtvXjaxwGoA7TXPFFrb3HlzMQRGSFEn3iOcV4f441q3vLCK4sZYhIcb1Zs5JHSvO/EfiW6k1Z7s +28ylFyux84Pt+Ga87k8S3F5fBLuXZZQtuQLF3PYmgDtY4yZDd3M1u7AkeQ65Ue496ty6sselxiAM +v7z95KVwVXuBXDNr+nC1inDl3CEhc8nnjisr+2tR1TUUt7YM8W87jjqcDigD1r/hIrh4ooUAK9v7 +23sasRanPPNJHCTPgde34VwukaJqsl0skjh18vGxh69q9k0DQswmNrIKFQYB9fWgDpfDGlCGJp5s +jIBI7Zr1iyEvmKCsSQBflb3NcvZW7R2cMTDyyi9u/tXV2bEWwfGMHpQB+YX/AAU/8H3Wt/DD4W6x +BftbLb3N1azhT8hUhXBIr8gvh/oaWPjnVz9oaYG3QOSdoZs9a/eP9v8A0aPxD+wlqSrcfZrvTrtL +yJgOW4KsoP0Nfhh8JUc3OoXs9uDc2swt5A5yGwaAPur4C29pqg8d+CNSMR0fxN4M1Gx1FZB5kJxA +0sRbjgq6Aj61+Tnw68f+Lvhr8SdO8ReEtavdK1eykBBgnKI+1uUYdwcV+lFj4kg8EfCrxbqYlRLm +fTbiC3SEYyXUgkn6HH4V+UEEYS4ZZGIDMSxznJzQB/SP4A/4KE/DPVfgR4d1TxXpus2viGa12X6W +UO6MzqBu2+2c0/X/APgot8J4tah0vwz4Z8T+JNVliB+zIqIVOcbee/rX4h/BrS7nxrp+raDY6tY2 +eqWIF9apeviGTJ2spPbjmsvW/FOn+Cp9T0nwrfLqHiu53RaprafdhGSPKtvT0Ld6AP04/aQ/4KMa +xYeGo/Bfw605dD8RTRg6tqYdZVsgRzEuOsg6H0Ir44+HP7WHxH8KfEi18SjUrjU9QglEs26VsXce +fmDZPWviv7TKnmlndwc+ZHIc5J/iPqau6XevbzQE/vEWTK/U9aAP67vAvj218f8Awa8K+NdMGLHW +bBLtR/cLDJU16AsjeWn8dfIv7G82m3n/AATX+GT6Xem/ijs5EnJ/5YyCRtyfhX1lZfPbbd/3aAJ0 +fbcBuhU7jXokSpfeG5YmAcMnOfpXnz4EeW78HFdl4buM2rwvywPP07UAfyzfHrXr3xH+3d4v8Ta2 +7SXc3im7juNy8hElaKNefRVFfLuiaeH/AGsxBPHGoiunlRW4XoSDX1l8fLS21r45+NtZ0dQ+n3mv +3dxZSKeiNMzKSRXzBr1tdad4z0jxorK8BYQagy9YHPGT7GgD1T4jqf8AhDHuol3yfOrBex7mvuH/ +AIJBukn7QPxjuCokaDQrWFJG7K07HH0+Svh/XbpdY8IRfZwPKIw0q/MrDA55r6n/AOCX3i618Dft +++L/AAdqUyQDxXonlWRyADNC29V57ld2KAP0u/by+HV18QP2CfG1jpVnLc6xYW6ahZFOSWiYMRjv +xmv0D+DOsafr37JPw31fS2RrG48OWnlbTwAIlUj8wa8x120ttY8P3CXiK1u9u0c6OMkgggivIf2L +vEl54RtfF/7PXieaUan4WvnuPD0sp+W70yZi8ZUnrsyVIHSgD7ycbhis6eMZJrUPSs646Hgn6UAY +09usyuGAYY6E15H4v8KQX9nMPKBOMjBr2GRhlsgjisHVIhJZvgA/LQB8W614LaO9mjKOseNoIPIA +7+/WuCuPAdw2IbeZpYi2XA44/wAa+pvENnt1AOMAbeSa4janm/u0VcNyaAPG4fhpDHcCT7wABWRm +yy+oNdXBodjbTwrHbRKoH3k+UMR613JVSXLjCE4wPWgWyO4XylZO/qtAFG202FEaOVQUIGwBs4zX +Z6RAIoEXDu2OAwxVCGFjGoUg7Tg/LW/apsjVmZcZ5KtmgDai+VSAqJKRkMTwKd9ujtUd5SUXaOpI +rFu9S+yxSMyL5XZ8818b/tCftFWPgTw/cQWd2kmqyLtgj80ZGcjO3+VAHgH7dnxmcs/gC0d5EaNZ +LlY29WIVcevtXw/4Z0X+yvC6WYEUdzK/n3DgfxZztP4cVBql3qnifxbc+LfFDyXl9M7GCOT7yqef +MI/QCpbjXodP0J3uZPKIjyVbG4fWgDlPit4vjtvAE9ujqpkiaCMFcDOMn/Cvie3VmkWOEMXkPyv2 +X3NezfEPXE127t7O2aN7VGLu7OBgkV5za28EaNEkn7z+GQj7w/pQBt2WqXOheGr7SdFjSG4vlVdS +1DPzumeY0/uj1rn4U3bdxL5JABU/KetWktSS6EED72d2SfpS7UKOY3Ykp8qHhlIODmgCoqSbvMy7 +v/GMDFRu5S4RQNzHk7R0rdVYvMCLtQKP3jA5zWRfaVd395tsSXK9Qo60Afsr/wAEtfjZAw8T/AzX +Ls/a55W1bQGcj958oE0S/QLux7V+y0DtFcbQm2P1Nfy8/sk+FviP4d/b1+Eviyx0PUXsLbxFDFez +oQUjt5PkkDeg2sc59K/qLuUQyyrGxaMv8hHT86ANLKlBtxir+iXBg1rBcAP2FcebiaJtpJbFP0q8 +mk8VWW5WRGJGAOaAP//Z + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar2.jpg +Content-Type: image/jpeg; + x-unix-mode=0700; + name="avatar2.jpg" +Content-Id: <4594E827-6E69-4329-8691-6BC35E3E73A0> + +/9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAZAAA/+4ADkFkb2JlAGTAAAAAAf/b +AIQAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQICAgICAgICAgIC +AwMDAwMDAwMDAwEBAQEBAQECAQECAgIBAgIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD +AwMDAwMDAwMDAwMDAwMDAwMD/8AAEQgAwwDDAwERAAIRAQMRAf/EAJ8AAQABBAMBAQEAAAAAAAAA +AAAKBAUJCwYHCAMBAgEBAAAAAAAAAAAAAAAAAAAAABAAAAUDAgMDBgcIDQkDDQAAAQIEBQYAAwcR +CCESCTEVCkFRIhMUFvBhcYGhJRfRMiMkNEQ1RZGxweFCUpIzZGV1JhhyslRVNkZWZhliooXSQ3SE +tJWltbYnN0c4EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCZBQKBQKBQKBQKBQKBQKBQ +KBQKBQKBQKBQKBQKBQKBQKBQKBQKBQWx5emeOta98f3VuZGVrS3lrk7O61M3NrejTkNdvqlq5Xcs +pkqezbKJjHOYpSgGojQYANyviVenfgaW3oTEHKb7gHhArXoXlxxc0JvdZsUodbY27cikSppSvZb6 +gOUl1AVTYEAE3rNNOYMEm4TxW26yTqpg2bdMRYvx3GHATJonJZOgdJXPmVPy8ntt21edrcRurrg+ +kBbqG/btiOmhu2g8CSnxE3VNmDYytxM6oowtaAL65dFoZF2pY8GAnKJnQxGw1u+U33wgBShrQdPM +vW+6qLGtfVybeFkxZdflHtN609Xmt8RN1wAAoEY0DsgWImVPoH80mt27Yjx5deNB3rFvEh9WCOub +WsX5yi8sQN9m2nUMsgxLjT2N0t2xKInXLGqMtjv7ScpdBuW1JDDqIjxoM1u1HxW7M+SWMRbd7g5F +D2NcYqF9yhixwXuqdpumJpad1sJcvWrrqAL2nryJFNy6Qgie3bOIBbEJRe27ebtc3dxoZXtyzXCc +otdq4FlZYZXC4kfG2+Jeb1DtGXiw3SJru6dgX0tvXThrQenKBQKBQKBQKBQKBQKBQKBQKBQYSeph +1ytsXT0FTA0xbmac93UN28mxxE3JIRujl09u57JencgAb5Ga3cuFD8XtW7yoxe0pO2gg0b9+rZvP +6i6qylyO5+7GLmFVfVt2OscJHZsiSQ94BtlUPd4VKpS9LCWB5fWKrpihx5Sl10oMSIqR1HUR1146 +9uvl19HtoK781Q/2n+5QUH5z8PPQV3/tnw/71BXJvxT6fi8/Z2UHJ4tFJZkSTs8Lg0ed5XK5AttN +zKwMSG+4ujktvmALSZGkTW7l67cMPmCg9o5B2cdQzY9djuRJlhzPWCbqv1LixS1K1SNi5DEL623c +7ybAKWwchR15bhg7eIUEifpY+JVkTE+x/BHUDXGd4/fBIzR/PlhKPfTSpADWbBMhI7YFBajOYCBc +Xk1ukH0jEENaCbFFpVG5vHmiWRF7bZHG35Cncmd6aFVpa3uCFVaLesKE6i0YxTFPbOHAdDFHgIAI +CFBf6BQKBQKBQKBQKBQKBQKCMv11OtNY2gs7ptZ24OFh23JylmEsjfkVwiqzixmcieqLqWyJ+aVr +rVz8DZH0rIDzCADpoGPboreHMkW9Qj5u96liTIzRAZUrtOcAx+rdlDJN8sqV4FWLZpLHS7656aIo +BTltprRQsqlxhMYp7dq2UboTacK9NvY9t+xPewpjHbfjVqx8sSqEjo2OjEnka55tqiGIoO7vT8Dg +7OF64U2nMe8IgHZppQQQfEJ+H6S7L7DtvK2nWHl329v8l5cjY6BECxVhlwergijcm5SjtgZRAlq8 +RslNdIUyC7ct2zmOU5TAESX8z+HnoKJL2j8v7g0H5w/JPj+P4a0BUq7ePw+nz0E9jwj/AE2Udxsm +fUVyrFrV4664rx7t6tvCIpwt2kl4PfOeN1tQQQAwqLZG5IoL2CRTyj5aCdEub0DolvIXNCjcUSm1 +csKEa5NZVpb9m6USXbN5OoJctXbVwg6GKYBAQ4DQRr+rr4dDbZvVgshyTtfhUK2/bqmlOpdWVfGW +61GoBkdXbIa8ZjmDA02SNTcvcTl5bTmmTkulum1vhcLxKEYDpCdRrOnTO3XOWwPeXdeI5i1NNluP +npvl9+6f7IJYF8bCBzQq7pzFtRZUpOT1glEbHs90t4no+kAT+kaxI4pEy9AqTrUK2xaVI1iS9bUJ +lSa+QtyyoT37RjW71m7bMBimKIgIDqFBU0CgUCgUCgUCgUCgUGKzq79Q9i6eO1aQTpCpRKswTYiu +KYejd2+T2pZIr6fS++GSAB791BHbN4t+5oXQTCUuvGgjLeHG6cl/qPbu8m75N2CZVkPHWGpOkfzp +5OQ65Bk/OUgv3XZsSuYKRPacGKHI7IrlScdSGunR2TlNaOctBsj7Fiyms2k6azaTp7FolmxYsWyW +rNmzaKBLVq1atgUlu1bIUAKUAAAANAoPrQWeQR5hljI6xmUMzXIo6+oFLW9MT2gTObQ7Nqy0awrQ +OLestXkqxIpsnEp7dwpimAdBCghudSfwl2NcuSCUZd2Fzdpw9InY6h6XYKmdpXexyudLXOptJYZI +khVLjEbKm9qS2lUWVKW2JgALlq2GgBHxhPhiOpE/7as/Zwk0eYsbTnFze9OuOtvjqrTOmSM0I4+7 +LrMqWoAQqLzbErZGtEYGg1s4g/Dy+QxRMGFHG+2LPGYXlHHscYPyVM3pfbC8ibmaGP70pv2DGKUL +5CIUN7WwJhD0/vfjoJaPTU8JZknIl6N5R6g0hT46xw4NKd3SYThjlfPlRdfUjbuWEcwcrjeLNEk/ +sxhNct2rqtYU/oHt2h1Ggnu4exFjvAmL4LhrE0ZQQ7HOOI22xSIxxtJyJm1na7BbFggnHW4pVXhA +bl+9cE12/eOa4cROYREOyaBQa+XxgGxhDCMr4i34wtuOmTZbtWsW5XOmKIWPfOKNhbkQfLnIGltS +7RhMZKc3DmFvKP3wjqF48Md1K5PMweNjebphfdL7SzLJHgd1kLlcV3lSBtU2SOsGa3NUIivsJm+9 +7Witc4mLas3ilDQtBMqoFAoFAoFAoFAoFB8VCiwksXlSq/ZTJk9s96+oUXCWbFizbKJ7l29duGLb +t27ZQETGMIAABxoNX/1p98jjvl3mzt7aXE9/E2LVazHmLURD2DprjWzq7tpzkIDZExDqH9eUTibm +H8Fbth5KDYR+HYwy14e6SO1m4lb7KN6ykySDLUpv27RCXXF1l0lde71ag5fSunLGkKG2UREdCkAA +4aUGbugCADwENQ8w8aBQfO7cC1auXRLcOFsh7gktWzXbpgIUTCW3bIBj3DmANAKACIjwCgpCAS/e +IIDp6kx7ptPKY2gAA/JQfidpa0hiHStremNaKYtsydGnsmtlOYTnKQbdsokKc5hEQDtEdaC4UCgU +Cgj5+J3xEqyp0kM0OSBDaXK8US7HGTDgYgGvWG5FI7MZdFCc2giQydJJxunEP/NkNrQau7EGQ5Ji +nJkVn8QdlrFJog+tr+yu7ddMnWIVzcqtqLF+xdIICUwDb0EOwQHQeA0G2w2gbi4dus254tzhCXhK +8t8vjLed1OmuWznb5OjTWk0jaVZLf8wqQuhLgCQQAeUSm00EKD0rQKBQKBQKBQKBQYlut1ubXbWO +nTnCZMTiLZLJkhR4tiiq2cbaiw6zk9xsNfTXwOT2W/aRet5Lo6gS4YvDUQoNW46PrQivpk61ztGW +rroXVgCcTnIa+fUx1BilMW2c4mEePy0G622Iw+O4/wBku0OFRK0isxuM7aMHtLOVuuWryE6NNjaN +lKoTX7BSWb9tUYRu+sKABcE/MAcaD1dQKBQBDUBAfLw830hxoLQf1SC6UeNmxasHualtmPZspLHK +a9bEpREbYgI6lEA8/wAlBdSmKcpTFEDFMAGKIdggYAEB+cBoP4vXSWLZrlwwFKXzjpqI9gB8YjQW +2y4GG4BLhP54/wCD5jgQxfSIF21y6CTnT+tKUQ5uY5y3ADiUAMF3oFB4Z6m8KbMh9O/exEni0N9v +cdsmY1N4gEC4cDssId3xNdtkEBAbthU2kOT/ALRQoNL4nD2NYIeYdO3X0QEQ7aDYIeFmyndlW0TM ++MxMa8kxpldsdm9SJtS+zz6NlOdKUv8AB9Qoi57g+f11BKBoFAoFAoFAoFAoIpfi152WMbI8KR0i +w1hVKc33r9pKUwgC20xxFyC6BygIc9uxdd7RhDjoIgPkoNcua4e5cG4cTHOY/MYREREwiP7NBvBe +l0uXOXTd2IrHJEsbV9zaXgQipA4Wrllakup8ax1ONhTZugW5bu2wtAAgIBpQe76AIgACIjoAcREe +AAAdoiNB8Qv2xONvX0y66lABHQOYQKOoAIemAagHaNB9dQ05tQ5dNddQ008+vZpQWNUrTXb3qzcp +xMnulKU9/wBnT3Ut/kC8e5d04h+D4AHk184UHztPyG1rbOJilAR5TAUTfMIAGutBa3dxBbb5bHMU +SCPIUeHMP8YQ837lB/aVVoOqvUBDyhpw8w0HJUd8bpClObU/IFwoiUSH9UcTerLeIIiJVFsoAFzy +c3Zp2AFbQeVN9hil2R7wzHHlKG13PomNygflKGKpXqPIPA+geTy0GlBWffD8o/51BNM8JS9ONtZv +Djw3rFtoVo8av1lvIP4a2tTukyRmv3g17RtLhL2UE0WgUCgUCgUCgUCgh/8AjCGlMp2p7Wng6stt +W2ZolSROiHTnVWnOKoTqb5fLokFvIA+T8JQQcNnWIVGfd2G2/CiYnObKObcaQq9+CG+W2ifpc1IX +G+eyACNy2nQXbhzBpxKUaDelxliQReOMEaa09lI2x9mbGVAmT2rdiwnRtiOyjT2bNm0Utu1bt2rI +ABSgAAHZQVaxWFg4FtiQ14SGONsboENyWiX7xdAMUwaXj2BII9oBqIdlBVCYR7f2KC3UFOqt3QKJ +AuH5f4uo6fHwAaC2+y+16ir7POH08KCt7rS+cP2A+7QPZE3mH6fuUH77P/Qfh/JoKyg/U14SXA5j +Dym4DqIjp5vpoOJ5ax/H8uYryTiiUnvkjWUIHLsdSAyQRBWVmmzEvi7mKbTiCgErocCeY+lBpZd2 +m3SZbUdy2cds02Xd9SHBeUJjClr4Hpd7g0iItEv11/3gYKCVR4R9vtlf95DlYDntCz4vT+u/j21S +6Sr0oecdCGPQTXaBQKBQKBQKBQKCLb4rKIR6QbNMHurtaLfcmrOV9sarImEDntP8JfAXmt6CHpWr +jYnNrx0oInPRCxoz3erHsAI1K7qhzJuIgkgMRSNs9oGZg1dH0pScgBqJPRAR1EAoNxGN843Qt8AD +yiAcR+5QR1Ooz4jzZhsNnMrwcwMUv3K5+h7TpJofj9zZWSC49lP8GLZJys73rgRyQX7YahbbrT3d +IA6HKU4CABHpk3jHd6QKwVx3aztNjbWIAX2GSzLKTtr8Yuhfcoga/EAUFc2+Mf3frA0DaXtNeOPa +15IyeI/94pgoMqnTj8UhjfdHlCC4J3UYSLt0nWTJaywqE5CiEyPK8SvElebxGyMM0q942Zhfcf3X +125gtc4GDm5dRAOYaCWpQUiVL8PN+/QQ3OoH1jOo9uN3DZS2X9IzA2ZFkh27zyXQTN+VIdjxld3P +vVnezNjQDPKZ3rAYBH9CmMIupROIecAAKDBpmRq8QgjUShFuN3vTrESOLNQOMp+0bffhSJtLQDqB +XMAeAxfNRLHwAA/RDpp+1QYiWzN+6FVOu8nffbJ2SQoBB0993Leq8urZ3v8A1QeBzKZn0+IWvT4q +DKTsK8R1vk2kZPi7TmfODvu/2/23Vojk0hE1d7kqyCMYKYmrphSXL7sfkJn7Ti1kdtQMJQAQ0HUA +m+QPxCfSAnDWmWWN5kVjKhU294Wm/IePcowJ3brQhprdK9QaxbtmAB4gBjfL5KCAP4gPJ22rNfU5 +y7mnajlOMZpxrleB4hkT7MIZcF5izbPysbxE3NquDykL6wxWJrMbQA9Iwhx0AaDNN4S1M8e3b0FC +VOnCKWLWJk15UY/40L5dPMBR2LVvTUycECS+Y4j96blAO0aCZ5QKBQKBQKBQKBQYM/EO4xJPem5P +pARImvrcZS2GS21fvCBbyZKpd7UeUilEe25cUO6fmAOIlKPmoINnTK3UxjZJvuwFuum2OpTkyE4Z +c5iL3F4OBe9yu+QoS9RaIGZuYpyiIP8A5wEKCQ7uS8X5uElTXKGDbltAjWGUrzGnlCyTbNUvenOU +NBjFO1HdmdmjAM7H3+xHuCYCmOblEA0HQKDExs26DnUV3vxlFml3RxnB2Pp45XZmhylufeXwMhT8 +XYAuuuQmqJtZTzx8HW+U/enATAIiHlEAzhYu8LvDMZKyyM3UIycabg26vr1C9t+MAaGrQdRKP2mm +KYAHTyCA0F5y34bN/kCZY7RLf02SjITO26RVFnzaviyVxN0DlE3MaXQUzyLAAiGnDUdRDyaiAYM8 +oYFyj0otyeL5Vvw6dm3XPuHXuSs7jB5diPvnHsAljtFHwrmBsdZExmDQVhyOQR1BolRTdnENNaDK +Blfxg+7G/OnxZhHadt4iGNwM19yseXnibSzIAl9EXQXV3gL2xx0TDqIaAQvKABxHUdA63HxW/Unl +qGQSpJi3Z5F4/ieLhkWVxVwieVQNkZnGaw6BGibPec34t23dD31B0AS+lp5Q5QoOhJh1A+rX1386 +W9rGHHuNbfIRJYy7yLJGOsKvr3j7EzTEBFgJKcsbh8i3CGkE+j4mAQ7qETcwiHDt5gza4A6B3Tn2 +0MXvbuDWOG7KQRdqNJJRlLca7PjRiaHtEVZjGdgHHZjCxmjhdeAu4mNoABrwoPW2y/ed0ud0ORXv +CWzxZhsJXAGszj7jt2BwxQ1S2Ksxu6TyvEoOeoz2PFHiJR0EAEB7BARD31kba/t9yvGFsUyvh3Dm +QIo8/Vy1km2N2R2Hhpr8dBCt6znSwwl0+ZPineNgfHLXNNs8kyc1QbKe1rI7w+OsAiUodWIHNnJE +5e1iL8aNT0veoB/qF8HTsAAAI++e5/D8jZJnT1ivCLbtZxVkV2iLrjzCceenx4izXFoqyvbaY55i +7CMhyCY0gHi7jxHWgl5eEmWorsa3jWb7mnB7vLsNqSs6e4AEO3Eb5oN1yJa11OFpUrJaEwcC84AP +bQTIKBQKBQKBQKBQKCPZ4jPcPCsebPl2AHO69qJtmpU33GFmYjmtOQoYxIGle4qVRSiIHTequksc +nZ68dfIFBBh2vwfI0/3Asm3LHolheeMzzvEeN8WSuTOs0x/KsRZta8nsznE3geYBf8fyMH7s8oDx +DQaDPb0pYvuT6j3VaSX+pI+SjLci6ceL3ZtW4yy0I92s+WMTTU8DaIhLCRnWNv0jYJ4dzdXh2MU/ +fpifWg6agITplStUrVe1q13tqseK5d8PIOlBgx8QPA92k32Crmnaf7+LUiKeM7ln6LYl78+0KW4o +Bk/VHuu9C/yCOd//AKXaKDzx4dSD7x4ZtVyGl3HtmS4phN5nTQ2bWoTlYH4ssi7Ya4c+RXpqZnYw +zqOY7K/HAjQDi5kIBSunDXURDNNvK2kId4exbcxiFZF41JLE2xbMEETROds4OLXm6KAcuO8gM98x +PUsV2APxRN3mBuflDXTloI6fhK4Dt9zHC90kTybtWw/KMxYancPkqLN8ziLLPpd3TkFmMIY7A85Z +3oWD3AFhAPqsSB6Qh5tQkwdUeMQzGfTr3gzKL7b8Y5YdYdg+YSRBjBbDYKaLu7mRsOhNKXW3dh9w +twIFZdjyU33hh5DCAlHQShHa8LDi9hadjOfshNK7+8OXNxgRt9XdndETx8ycGj/43QSBdxuCEm4P +A+Y9uKxc5oUm4HF0wxMK9t7Gd2lgatP66ZuHf7L81BHe6ZHh+M17EN48WzfuXzHA3oMSRl7DFcVx +uZ5M7OztKmUYAMseBcw/2eIwvLqAtPn8gaUEqBL3okV+1pVzWALWvu1chcmgHZp7p83yUGHjr5R9 +ieOlFveUqW9sSJkbZjaaMKAoCLU1O8Vm7KDQDOUA9IwmenTh5aCHV1KWmxHdg/QbhC1LzP6TZ5uO +kysugfWTRkLcFbcojb119LU5TG4/xvMFBx7p17jcydOp4xbvMxzOMQzmBZOl0txHljDzbKl16bRd +AjXWHYbmTIkoaSPbNdlFhLaVMbmhJdsGOTQ5DFEQoNlHivKLbleIMM2jxrfccla2x0QqSmE4GbHp +suX+cdQDQwAfT5qDtSgUCgUCgUCgUETPxDcXfse7jNj+5ppXNa3uWe43bEKGSfWzS0SyJ5RhcoaO +9v8AlygjZ573hbp5tv1w7lzdchbmXeDti3EQ6P5Sm7bEGSAyt5+z3JzG5RVryC0NJWMDSKBmJ3WG +gCPcGgajprQSfMJRMuyfxRW7HEKoXEsP6guK5dluDLiCUbLs65B0ycJ7RiiJDWTZbLKWwpg4CXQa +CT5QUaVVp5/xIfN+/wAaAr/G/wAr/HVfk4UFvkGdf8PuG8pziQssZVQnHkXyTlqUrnF4eOEUijG9 +Sd2NoZjHUQBlHTycezsoMLPhNNvnuVsGnO5h2bzJJDu/zJLZShAAKBWzH+OxNBIeQphEdBLctuY6 +/J5xoJQMij7FL2B7ikhRNrvHpK3Okek7IvKHdjq1OjQLW7tPEDehctjoYO3lEeIDxoIa3RwiA7AN +5HUU6W+QFzgilLPlQu4/AhrjV6tpyHiY7G9ldOQvKbnOeDPZXICiJf0F26gBRCSeKoEqpC7Jfypl +dGdyQh5wag00HXgOtBV5NnJslCyilhp2l1ZnQoA8md7YgRpddSnEvJbIb0x4jrrx7NOyg4y1pXT8 +7XfDs17aDAx4g7Ib/LdueEenzicBes8dQDO0Qx4yxZsIU7qGPYm+A4yt3MU4lAxDSQxSj26APYPZ +QYZPFRY5YsK7gOnxgloAyOI4J6fzXG0S5uDTkaGWde6PfHZwKJmQP2aBlLpCRjaJ4f1futywxlR7 +t8zZOwRkhWdxKbvfE+KHV91iWJWsOTlKHu+Yrq66mExtRDQALxCWZ0q/alfT72rq1n5WtxhD/wD5 +Hp5aDIZQKBQKBQKBQKCL14ophVq9r+K5Ck/U07efYfi/uS9a0EZPrfZFxTlffxK8m4VXtL28TTBW +3SS5hfGsNGj/ABAGxkzDKXaI+XmJa0B48mtBN73lbC5b1I9suwrfFgmWtUY3z7ZIdCMhY/kYWrdt +jmjw3JmxzmmJ5RbIIFtxpLPWZRbACh9+U/EObloPXm1LdXjvdjDn52iqxpZMrwt1LG8+YQcTC15D +xLlYSlGXNRmgSF79jwHEQaHYBHgADrx0APTir8U8vk83k+bWg/fzVc6+w/VKP8vfHL6paWj/AMX+ +UaCNv1I92D71DsksXRz6c8rLkCZZalLa2b3dw8F0v4v287fGF4tnmMQGSWjA1Pr8b1gFdTaemIGa +LfMZzMABLBwfhuD7ccJYtwTjdEDPAMSwaKY4iiINeDNFGfuu2A83pCY5SgJhH+FrpQdsJvJ8P41B +gz6wnTbypukJivdxsukjfj/f/tMdTyHELm6EZgi2Vo0S6VydcR5FtvJu47idQBvQM4hyl5R1HlNq +UPCeFOuBt5azDjPf/GZ506dzzO5dwZAx9mqGzUMUXJMICYrziXIIGfikjwELqIOhjAHaBjF0MIZF +Eu/rZGri77LEu9LaWtjzK1+8b4ubcwMjt3Q0/tUHhHcZ1uNpcIi/dO3CVf4tdx8zax+x3B+JWd6l +nvdLHbQIi0O/uv31Qc66WHS63GuO4OTdVPqj3W163sTNqux/C+IG7u0IBtYx4RoBsZSILbWpOity +K3HjgQtv9TEMfm5nQR5Qjz+LmB0V7+MXNuhraJm2kwFGhMJSgCh/c5llq6a3buCHMPs9sSmOUo6f +hCCID6IgHpjrR9SC5vF6cnSowLAUDiimu8xtxvnrIMXazEud2M+PALBWuLFOXQOSRZfHlAB/ia8d +QoJSu0zF/wBiO2nCGJ/y33LgTPG/bv7JZBoPR9AoFAoFAoFAoMOvXMw39rGwXIyRIhBarhbozzVA +P9k/pfj8TBQa62eJVUTS+1yFja1qTJzV/cicOXfbT3O7RN7/AL3C0eaR6/pag2anh3Mypsx9LXEF +62495OGN5ZkrH0qvCcTnM7BML2QiWzBza2/ZYvPENkA/iWwHy0HPN43R7xtuDzIi3XbfcqSvZjvX +ZGwW8mfMTs4OrZLWs+pjNWWcdg+MdvIFsSgUoauZD6E1ETcKDyFHtiXXwg6ebMEX6i+05+b5MYDM +02mOCZrbl0UtgICcrLFzEfrdoxihwEXMwAPHQQDSg4M2+H73R7glKr/qNdXTc1uBijwcRf8AF2F2 +pmwjFXkumoC7uTWF23eAohwL3SXXyjQZttm2w/arsDxiGLNrmH43jOK8omkrwQDuUolbm29jrMZW +5mF7kdz0dNTjoAhwAvHUPWXtSpWq/onZ5h+AUFd+SUFD7Uk9q9l0/wDUfJ+z2UHBMo4bxLm2PjE8 +xYrgmWY7x+pMiw5klzUHER5iklBDgIjrx4caDwE49EjpMOrkDou2CbdRXAGpu7Yd3UA8R491tTtb +IbT4gD5KD1pgfZ/tX2rpXNLt027YcweR8H2F5W4pxvH4k6ufLobldXdqt235+AptBATGMOvHXhQe +kknsn5pr8+v33x6+Wg14HiKMUZW3j9YiFbb9vURXZAzI9xHHUNjsUI+WmollRaht2bLpA9PGhAj0 +dZmY6ozsJjcvIQBAQ5h1DovpMbQVmY+pt7v+/Jct4n6frX9nEWnBQegiTs7Y+EYv3vEQcQAAjcgf +w710oJ/qVKlSJUKRJr+JfRQf1QKBQKBQKBQKDic8i7XNobKom7IfbWmTtbxG1yH+1vmANaCGht+w +Oyv8c6pHRrzBFYo4T52juYNyWyZ2dmcXV0bc/wAUgx3N3LjsSctz3kf2TldR5TlHl10HXSg9U+Df +3NN7rBd3W0tZfIkfrMoie6WItvrhMdQzZBisfx3kO1bsj/NhFFsRiwnEOBu+Sa6CHEJwPs4fF9H/ +AJNBQ+zAl0EBAddfPpQflB1JnCKTycYkyhEcUTdBjPJcjgkvjeOcid0keCwGfO7DcTxKXXmo2oHL +H3w/rBLrzCIB2jwoMT3To2z9YzblPTx3eDvOwTuxwCtI5OIuLnH5wXNrQ6uQga2ETl4gQl6NW9Ox +zMJigI8ogI60GUjcUz5vkGEpy1bcJ1BcfZsXNJhx3NskRI8uikTdzCTleHWKtgiZ8KQAN6Iej6VB +h56c2xfqt7eNwE2yrvi6kJd0uMZLFXhrQ4SaQmYM5JU5PRQtSlqLKGhlDH5I6Ajyg0l4ajxDhQZ4 +Ff5KOn5X5PP2cPh5qDivtSpIPzfP+6HkoKH2lV/pv+dQcqa1X417Jp9zt+Sg12uaN+cIxR1yOpzv +QTPI2JRt4wtm3Fe3VtsN9u9ZmO5M7dCttcJLaMUD2jJ2YJY4vbsbTmuWkYDqACUaDM54c/bSrw5s +tXZMkKD+9mdZR7ye3f1T+qOFBIWoFAoFAoFAoFAoFBg86r3Txyfm5+xzuw2nvjnC90uF3RncmNdG +/ql1d+6v0R3R3pr/AHjoIJER3X7sOkf1DU+VouwXMZZvhzurWziAPaNtSwuYQ7IjgeVSeASdgZrV +n2mPTNoUtdwTW1FtQiUprSlOa2otWrpA2nPTb6lO3/qc7a2XPOD3MELml9mZMq4rdVqe/MsRTsUv +r1cZkVqyFoVbcrAh77S627RErqjD1hAt3SKE9gMgntQ+Yfo+5QWSg/j2oPMH0/coHeof6CP0UFEq +fvZPzHTt+L46Cu70/Ffa/L5+Oumnm81BQqn5KrS+bT4aUHFFXYHyfuhQf1/ROPtn7vw+agwfdb3r +UYw6V2KbUWgp2LIW9DIzABsZYzu3QWIYPZCwRMiyjkpKmuFvJo+hV3Td3N5jW770rtchNLBL922E +SDoi9MJJ1EJBLs95hlrveh8QyevvPOtklh2yu7Op1zyZx7xt3rpbR1p043Dl5h0OYeI0GwTjEXYY +QwsUTiSHuWPMrV3axoW39UNLT+3QX6gUCgUCgUCgUCgUCgggeLB2cEQS3H27KNtyowuyW7FJOqTN +g2U4g3ktODStv3S8SlOkV3LIa66GTaeegjZ7BOoTuk6duXEud9rs7CJShU1Gjstj7y323+CZBjJr +9tRdjs4i6k9pO7toqbRbtm7bOnXIrwesSqLFz06DYRdJvxQGIN/uScdbX874cfcH7msgX77PFXKF +HUTPC08fG9pXvCuykUKDFmOPVitG3XTWEi+26oyFtiB3TnEhBCUd7UHmD6fuUFf7N8Xw/lUD2f8A +oPw/k0D2b4vh/KoOPt8qhrs5rmhqlMZeXVIURXMra8szk6l0Lz6GaymA9vgHboFB9FTCl0D5P3v2 +qDrOYzGJ48iclnk8kjHDoTDWJ1k8tlkmc0bLHo1HGNFecXh9e3dwvWELY1NiBPcvX7945Ldq2QTG +EACgiM7/ALxbO3fF9p3g2wyBr9w88spVaFNmGdJHaD4WYHG4nEidxZ48vSoshZHM3rAEt1PdsR1F +c0LcsLVFsdBCBBmvNWV9y2WpvmjNczfMk5ZyW9LX2RyZ7VjddXd9XiFhtto7VoLdhGgbFF5PbSIk +xLaZKjsBZtWyWyAUA2cnQy2vG2sdP/E7WuQmRSWesFibP6A46mC48lAxwMPZqAjpQZlaBQKBQKBQ +KBQKBQKBQYsespt8btxHT6zzHFDf3gvYYkeYM63s1c4sUXIfP56DVAL2w8eenRgumC4REoOZFdEB +AL6K5qe0YupQEwkKPKbT+EAhQZeOgffBN1hNhtwbg2+bMd+wBg14+1QqWJQt8A1/Cje5R8mg8aDb +v8PyT4/j+GtBi46n217qN7oo1jCP7Cd4LbtLTNTs7DmVwFtfWyUytvLbIDQdolUbtnfLWlspw5CC +TUTAbmERMUQwoj4YveRkFT78Zu6w2eXrLIOQuSB6a2iauzU1iGoho7yqbA/aj2cRAKDlBfDK7qZw +l93s89YjcZkHH3YvYu6Xp2M6tenMBTe802ewA4APkoL6/eEc2wtLWiV4T3abn8Y5MZTd4sU4cRhL +w1d6ejoJmhsZWLQPOACPD46DN507tr+47Zlt1JiHcJuklO7ycDOHmQo8nzkXj1jRFHUS9yxExpFe +f3seQebXiOgmHQQ8ocU6tS49vpj7/hUGKU5toWf7IGNygGqjGcis6cTaamC7oHl1Gg04lB696emH +Q3Bb4du+LbgWRbXeftl13u3ya2EaBuLec1Ks5/4iRMlPdMGnH1Q8aDb6MLWljzCxx9o/EkbM1s7a +h1/qny+Sgv8AQW/89+HmoLhQKBQKBQKBQKC30FwoOjd0DD7w7c84NOv5bi/JH0sj15vjoNU5k3aJ +Pp1tryZvAgdkr/HdvOV4nizNLClIAusEj8/tOqXGE+dA1KYI5K5AwrWk94Q5bS6xbAeBxEA7j6EC +4P8Aq6bBFVsto2ueWe2e2oAwlKFxme7N4pigICFwhDmEvEQ5g7PJQbe50Sqkirj9zjx/yaCva3T8 +04/D4fJQeC99rV1K3VPClHT2k+CWVQhK7BkSLZt74aXB3KHMVoc2SVkhs0EvqgLx1KGuocR0HQMS +/wBiHidcnKwapDuZ2lYXj638tXRx3+tw1D9Ti17fOHCgzzbOcXZ3wlt9hWPtx+cTbjctMguwSnKg +s/uuDwVze/WDpbA4gAMRTCUR0Awh8gUHc6qUflyTT5fpHtGgxndXQ43elz1AFZC857e1DMxFZQNy +cpr8Mc05T66Dwtjd10/haacNaDTzakJbu3ro8tmwUDHADlJcumMPLZT2NSXBMoU3TAUgAQ/LxOYO +QpjAGbjoqYpkkT6jG0lmnDK5w97m7uyTi2nemS9bc7sOl8WI5wJ8aS3RG4ZskLW5kV2DDxPbtgOn +Gg2i1AoLH+c/Dz0F8oFAoFAoFAoFAoFB1vlpL7Xi/IyVV+SLYHMGzs/1syfPQRaPDWQ6HzfMvVo2 +25AijdNcZTeNw5slcKkIFFolTQWZ5RjfdVzm1AbZ2F3AnDjqYNNO0AxIbn+n9IugH1WtsW5NZFJr +kXZW3bho3PMXy5uS2zya3HbLtzu+G38pQ5XDL8KZLt0ycDjyyZNbtqbYAJ74gGyjwxmfGO43FOP8 +u4ombZPMbZIjDTIobNmIxitjw2udpPoa2Y1rmZnzQ4lM2jxKcvKIagIFDsNKl7pVf+m9v7vH56Dk +FAoOByhzVpPxRIuHT975NKCxtbCrVqtfzT976KDGn11Z3FcX9I7fCofndG0kkuGHTH7SdUptIlb3 +Mp8rRxlrj6BKe8Fxe8uV9yC6CK2BjWEFu9d0ApDCAQEuhP0dXrqL5oTZJyzHVSbZ1geStTlld4vn +IlsZWlgJiObVhGN63LalcpfbdwUkhUCNv3bRhzfg1CgxRDMVkVqSKvFErStCBqQtEWc8DRhiQtjM +DQ0M7Q0bXcLgDOz/APLYUEy+gUFv9l/Gu3h9H7etBcKBQKBQKBQKBQKBQcGyh/sHOPa/+F3j5P0J ++xQRlvC/6O+9LqaStJqtSf3PbES7savrbKOTnPUB4Dw7loJYm6va9hLehhqbbdNwULb5njCftpkT +83gTV3bHQSn7plUTdTEPdYJDHTH5gdS6GAwAHZqUQhPMkh3o+GO3RLIVMGnIm5vppZslZHNvfm+7 +daBZ3RfbuJrcpx/a0MwwTcKyWiAWUNBvQnRQAQ0ECmKEx3b3uuwzu6xgxZp2+ZTa8sQB5KXleo2G +jlE3YSmHubIEU5SvsDkhfVjqDpoYoBqIAAgIh3MlfnT/AE7234fL8dB+9+qvZfy7h5/39aCg70+V +YHw+fQKDxPv86tG07pr48XSDPE2aHDJXdxl8MwDDXdmc825CcfVczTyRAxDhB4+c5ih3s6CUoAI8 +dS8pgiewXDfUP8TZmtiz1uDcpZtm6c+LnYjlB2WPkdjtN0AtnLcaMUA6Ax28v5FPw74yA5k9WyAY +e6gAAABCZriLDWJ9vOI4NgfCmO2qA4rxi1BGoVGGsnIDUbiI8roJji/SGQDq6urqJjCAeUR40ETb +cSdFjbxMKF4fkhWhnmZsFSFkFwMBwdmtywvDYpcddQEQ5iXISICHkEBCgl7JPyX9j/NoP6oFAoFA +oFAoFAoFAoLC/PzDE2BdIZC+NjK0srWLkuXOf6KaGnXWgih9XLrwRdpYXzA2zmVNcndlrW8Nk3yo +2fW0TaGnT/dH/iCR8aDIn4VrbE54n2MS7cJNWRwZ5buzyh76My50OUHN1xRFCg3Y8drg8RAX57fH +QwgIBqA/PQSQ350VpHT2v804fPrxoOKT2LYlz1A3rGWWIvGZ/CZM1d2vsVkbODs0PID/AABAQEPn +4DQRoc0+HPyFhLIS3O/Sn3TzfbJOu8gcUUJty98aGY/okAzULmYX1inZhuCbQkpbbgAUOJhoPLjp +uq8UVtPfl0eybtJgm6pp/wCKW3D3vZ3v5vrfDc0839V0HE3XrHeIHWJQSR/o8NqFWHYucdrO4qVB +2eXlFk7dPLQcWM+eLE3kMKtqaWBq2rwiSD3e9OBmfF230QbHcpiCYoyiZZNy8QRIYQ+qmvXQR40H +vPYd4XXDcKfkOd+oZlVw3l5sXCDk5RxxdHp8xaDoJbnrAlMqlRiTrLpyjyCQ7qDOGuoDbEONBKwY +o+xRppQsEcZ29lZmRtam5nZG5rI2NLO0tfFqbGtqRDpbJaEB0AvAPNpwoPkqa0ventfsP0cOzhxo +I9XXg6X8o3SwKK7mtuCF0/xS7cNO42KNh9b5cxP3370O+Omnj/tHH38O9Gmg6r6afVyxhm3HSHGW +4+VNeMs8wv8Au2+e+31S0y52aP8A6fkf+t2igzgNbqldkqF2aVwrUi38hXNvyfTQXCgUCgUCgUCg +UHW+UMtY5w5F104ybOIvC48i1/HpI8A0j5P3KCMtvc8SxA8eql0T2nwgZo7fmE4m31TE+9v7ID6/ +1oIqW6rqg7vt3ar/AO7OYZQtj3/A8b/unE//AHQ10HafRdw5gHdH1MdtOEt0DN74YsyCSZlVQkXV +9bWaWSyKsBpNjxpeiEuWz3I0Pc3EoCAmARDUKDbVsLC0xlpRR6PIm5naGduaW5mZm5pBpamdqaQH +upra2rUOQpNNOH3vAAANAAAuipKlV8OPk+X5x40FjVMLX5PJ9z7oUFclSpUg/ivy60Fd7T8fw/k0 +D8W+GlB+eyJvMP0/coPrQfL2RN5h+n7lB9aC2pfaUn9NS/F8n7VBhV6kPRe26b2SPOWIosvbc9yO +neAZhhTIV2bZgctsxjfaRj0nLYnoiJBDXUr1zcAHiFBFVxb1JNxfSM3ZZg2eZhfGzM8dxPJix6Uo +G92uW2sAEAOzy3HhJYBTkMAD+hxABDy0Er7aD1DttO8aLd7YyyM1+8I/l0GcnfumWcP6oCg94UCg +UCgUCgsEl9r7qXd3d5e2/wDLHc3evze9X1DQQfeun72e8yHvD/qA+26//tb7Ffs67P8AdD3Z+oaC +I7KO9u8lv+3f336z9y+zh837v0UHFU/evtHD3k1+L3N1+fm9HWg9pdPb3g/xx7SO7vte9u/xN459 +g+zn7M/tD9Z37w91vej+73vlp2d5fgvPxoNz83e1d12vaO9Pbe7Q5u9+5u9P5sf073X9Qa/5Hk1o +L7QU9AoKigp6BQKC2pfa9f1n83cvm8nxUD8b9r/Wfb/UnN2UFyoFBqTOvl3p/wBW/ePr78a+/bRp +3f7na69yk/S3L6PNprprw018ulB0lsj94vtjg/8A/Umnegf/AIk+y37Qu3/dHy0Gyl2Xe932ZJ/e +r/Fxp3Y0d2/4tvsR95f/AAH7Gvwmn9rUHsOgUCg//9k= + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1-- + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74-- diff --git a/actionmailbox/test/generators/mailbox_generator_test.rb b/actionmailbox/test/generators/mailbox_generator_test.rb new file mode 100644 index 0000000000000..f9677ba591270 --- /dev/null +++ b/actionmailbox/test/generators/mailbox_generator_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "test_helper" +require "rails/generators/mailbox/mailbox_generator" + +class MailboxGeneratorTest < Rails::Generators::TestCase + destination File.expand_path("../../tmp", __dir__) + setup :prepare_destination + tests Rails::Generators::MailboxGenerator + + arguments ["inbox"] + + def test_mailbox_skeleton_is_created + run_generator + + assert_file "app/mailboxes/inbox_mailbox.rb" do |mailbox| + assert_match(/class InboxMailbox < ApplicationMailbox/, mailbox) + assert_match(/def process/, mailbox) + assert_no_match(%r{# routing /something/i => :somewhere}, mailbox) + end + + assert_file "app/mailboxes/application_mailbox.rb" do |mailbox| + assert_match(/class ApplicationMailbox < ActionMailbox::Base/, mailbox) + assert_match(%r{# routing /something/i => :somewhere}, mailbox) + assert_no_match(/def process/, mailbox) + end + end + + def test_mailbox_skeleton_is_created_with_namespace + run_generator %w(inceptions/inbox -t=test_unit) + + assert_file "app/mailboxes/inceptions/inbox_mailbox.rb" do |mailbox| + assert_match(/class Inceptions::InboxMailbox < ApplicationMailbox/, mailbox) + assert_match(/def process/, mailbox) + assert_no_match(%r{# routing /something/i => :somewhere}, mailbox) + end + + assert_file "test/mailboxes/inceptions/inbox_mailbox_test.rb" do |mailbox| + assert_match(/class Inceptions::InboxMailboxTest < ActionMailbox::TestCase/, mailbox) + assert_match(/# test "receive mail" do/, mailbox) + assert_match(/# to: '"someone" ',/, mailbox) + end + + assert_file "app/mailboxes/application_mailbox.rb" do |mailbox| + assert_match(/class ApplicationMailbox < ActionMailbox::Base/, mailbox) + assert_match(%r{# routing /something/i => :somewhere}, mailbox) + assert_no_match(/def process/, mailbox) + end + end + + def test_check_class_collision + Object.const_set :InboxMailbox, Class.new + content = capture(:stderr) { run_generator } + assert_match(/The name 'InboxMailbox' is either already used in your application or reserved/, content) + ensure + Object.send :remove_const, :InboxMailbox + end + + def test_invokes_default_test_framework + run_generator %w(inbox -t=test_unit) + + assert_file "test/mailboxes/inbox_mailbox_test.rb" do |test| + assert_match(/class InboxMailboxTest < ActionMailbox::TestCase/, test) + assert_match(/# test "receive mail" do/, test) + assert_match(/# to: '"someone" ',/, test) + end + end + + def test_mailbox_on_revoke + run_generator + run_generator ["inbox"], behavior: :revoke + + assert_no_file "app/mailboxes/inbox_mailbox.rb" + end + + def test_mailbox_suffix_is_not_duplicated + run_generator %w(inbox_mailbox -t=test_unit) + + assert_no_file "app/mailboxes/inbox_mailbox_mailbox.rb" + assert_file "app/mailboxes/inbox_mailbox.rb" + + assert_no_file "test/mailboxes/inbox_mailbox_mailbox_test.rb" + assert_file "test/mailboxes/inbox_mailbox_test.rb" + end +end diff --git a/actionmailbox/test/jobs/incineration_job_test.rb b/actionmailbox/test/jobs/incineration_job_test.rb new file mode 100644 index 0000000000000..03948a32bf93f --- /dev/null +++ b/actionmailbox/test/jobs/incineration_job_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::IncinerationJobTest < ActiveJob::TestCase + setup { @inbound_email = create_inbound_email_from_fixture("welcome.eml") } + + test "ignoring a missing inbound email" do + @inbound_email.destroy! + + perform_enqueued_jobs do + assert_nothing_raised do + ActionMailbox::IncinerationJob.perform_later @inbound_email + end + end + end +end diff --git a/actionmailbox/test/migrations_test.rb b/actionmailbox/test/migrations_test.rb new file mode 100644 index 0000000000000..eb18781e03081 --- /dev/null +++ b/actionmailbox/test/migrations_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "test_helper" +require ActionMailbox::Engine.root.join("db/migrate/20180917164000_create_action_mailbox_tables.rb").to_s + +class ActionMailbox::MigrationsTest < ActiveSupport::TestCase + setup do + @original_verbose = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + + @connection = ActiveRecord::Base.lease_connection + @original_options = Rails.configuration.generators.options.deep_dup + end + + teardown do + Rails.configuration.generators.options = @original_options + rerun_migration + ActiveRecord::Migration.verbose = @original_verbose + end + + test "migration creates tables with default primary key type" do + action_mailbox_tables.each do |table| + assert_equal :integer, primary_key(table).type + end + end + + test "migration creates tables with configured primary key type" do + Rails.configuration.generators do |g| + g.orm :active_record, primary_key_type: :string + end + + rerun_migration + + action_mailbox_tables.each do |table| + assert_equal :string, primary_key(table).type + end + end + + private + def rerun_migration + CreateActionMailboxTables.migrate(:down) + CreateActionMailboxTables.migrate(:up) + end + + def action_mailbox_tables + @action_mailbox_tables ||= ActionMailbox::Record.descendants.map { |klass| klass.table_name.to_sym } + end + + def primary_key(table) + @connection.columns(table).find { |c| c.name == "id" } + end +end diff --git a/actionmailbox/test/models/table_name_test.rb b/actionmailbox/test/models/table_name_test.rb new file mode 100644 index 0000000000000..0b082d1bd4867 --- /dev/null +++ b/actionmailbox/test/models/table_name_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::TableNameTest < ActiveSupport::TestCase + setup do + @old_prefix = ActiveRecord::Base.table_name_prefix + @old_suffix = ActiveRecord::Base.table_name_suffix + + ActiveRecord::Base.table_name_prefix = @prefix = "abc_" + ActiveRecord::Base.table_name_suffix = @suffix = "_xyz" + + @models = [ActionMailbox::InboundEmail] + @models.map(&:reset_table_name) + end + + teardown do + ActiveRecord::Base.table_name_prefix = @old_prefix + ActiveRecord::Base.table_name_suffix = @old_suffix + + @models.map(&:reset_table_name) + end + + test "prefix and suffix are added to the Action Mailbox tables' name" do + assert_equal( + "#{@prefix}action_mailbox_inbound_emails#{@suffix}", + ActionMailbox::InboundEmail.table_name + ) + end +end diff --git a/actionmailbox/test/test_helper.rb b/actionmailbox/test/test_helper.rb new file mode 100644 index 0000000000000..2f4edb8282d49 --- /dev/null +++ b/actionmailbox/test/test_helper.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "../../tools/strict_warnings" + +ENV["RAILS_ENV"] = "test" +ENV["RAILS_INBOUND_EMAIL_PASSWORD"] = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL" + +require_relative "../test/dummy/config/environment" +ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] +require "rails/test_help" + +require "webmock/minitest" + +require "rails/test_unit/reporter" +Rails::TestUnitReporter.executable = "bin/test" + +if ActiveSupport::TestCase.respond_to?(:fixture_paths=) + ActiveSupport::TestCase.fixture_paths = [File.expand_path("fixtures", __dir__)] + ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths + ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files" + ActiveSupport::TestCase.fixtures :all +end + +require "action_mailbox/test_helper" + +class ActiveSupport::TestCase + include ActionMailbox::TestHelper, ActiveJob::TestHelper +end + +class ActionDispatch::IntegrationTest + private + def credentials + ActionController::HttpAuthentication::Basic.encode_credentials "actionmailbox", ENV["RAILS_INBOUND_EMAIL_PASSWORD"] + end + + def switch_password_to(new_password) + previous_password, ENV["RAILS_INBOUND_EMAIL_PASSWORD"] = ENV["RAILS_INBOUND_EMAIL_PASSWORD"], new_password + yield + ensure + ENV["RAILS_INBOUND_EMAIL_PASSWORD"] = previous_password + end +end + +if ARGV.include?("-v") + ActiveRecord::Base.logger = Logger.new(STDOUT) + ActiveJob::Base.logger = Logger.new(STDOUT) +end + +class BounceMailer < ActionMailer::Base + def bounce(to:) + mail from: "receiver@example.com", to: to, subject: "Your email was not delivered" do |format| + format.html { render plain: "Sorry!" } + end + end +end + +require_relative "../../tools/test_common" diff --git a/actionmailbox/test/unit/inbound_email/incineration_test.rb b/actionmailbox/test/unit/inbound_email/incineration_test.rb new file mode 100644 index 0000000000000..54488349fda0a --- /dev/null +++ b/actionmailbox/test/unit/inbound_email/incineration_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class ActionMailbox::InboundEmail::IncinerationTest < ActiveSupport::TestCase + test "incinerating 30 days after delivery" do + freeze_time + + assert_enqueued_with job: ActionMailbox::IncinerationJob, at: 30.days.from_now do + create_inbound_email_from_fixture("welcome.eml").delivered! + end + + travel 30.days + + assert_difference -> { ActionMailbox::InboundEmail.count }, -1 do + perform_enqueued_jobs only: ActionMailbox::IncinerationJob + end + end + + test "incinerating 30 days after bounce" do + freeze_time + + assert_enqueued_with job: ActionMailbox::IncinerationJob, at: 30.days.from_now do + create_inbound_email_from_fixture("welcome.eml").bounced! + end + + travel 30.days + + assert_difference -> { ActionMailbox::InboundEmail.count }, -1 do + perform_enqueued_jobs only: ActionMailbox::IncinerationJob + end + end + + test "incinerating 30 days after failure" do + freeze_time + + assert_enqueued_with job: ActionMailbox::IncinerationJob, at: 30.days.from_now do + create_inbound_email_from_fixture("welcome.eml").failed! + end + + travel 30.days + + assert_difference -> { ActionMailbox::InboundEmail.count }, -1 do + perform_enqueued_jobs only: ActionMailbox::IncinerationJob + end + end + + test "skipping incineration" do + original, ActionMailbox.incinerate = ActionMailbox.incinerate, false + + assert_no_enqueued_jobs only: ActionMailbox::IncinerationJob do + create_inbound_email_from_fixture("welcome.eml").delivered! + end + ensure + ActionMailbox.incinerate = original + end +end diff --git a/actionmailbox/test/unit/inbound_email/message_id_test.rb b/actionmailbox/test/unit/inbound_email/message_id_test.rb new file mode 100644 index 0000000000000..af467a8d45466 --- /dev/null +++ b/actionmailbox/test/unit/inbound_email/message_id_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class ActionMailbox::InboundEmail::MessageIdTest < ActiveSupport::TestCase + test "message id is extracted from raw email" do + inbound_email = create_inbound_email_from_fixture("welcome.eml") + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "message id is generated if its missing" do + inbound_email = create_inbound_email_from_source "Date: Fri, 28 Sep 2018 11:08:55 -0700\r\nTo: a@example.com\r\nMime-Version: 1.0\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello!" + assert_not_nil inbound_email.message_id + end +end diff --git a/actionmailbox/test/unit/inbound_email_test.rb b/actionmailbox/test/unit/inbound_email_test.rb new file mode 100644 index 0000000000000..264209c118192 --- /dev/null +++ b/actionmailbox/test/unit/inbound_email_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require "minitest/mock" + +module ActionMailbox + class InboundEmailTest < ActiveSupport::TestCase + test "mail provides the parsed source" do + assert_equal "Discussion: Let's debate these attachments", create_inbound_email_from_fixture("welcome.eml").mail.subject + end + + test "source returns the contents of the raw email" do + assert_equal file_fixture("welcome.eml").read, create_inbound_email_from_fixture("welcome.eml").source + end + + test "email with message id is processed only once when received multiple times" do + mail = Mail.from_source(file_fixture("welcome.eml").read) + assert mail.message_id + + inbound_email_1 = create_inbound_email_from_source(mail.to_s) + assert inbound_email_1 + + inbound_email_2 = create_inbound_email_from_source(mail.to_s) + assert_nil inbound_email_2 + end + + test "email with missing message id is processed only once when received multiple times" do + mail = Mail.from_source("Date: Fri, 28 Sep 2018 11:08:55 -0700\r\nTo: a@example.com\r\nMime-Version: 1.0\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello!") + assert_nil mail.message_id + + inbound_email_1 = create_inbound_email_from_source(mail.to_s) + assert inbound_email_1 + + inbound_email_2 = create_inbound_email_from_source(mail.to_s) + assert_nil inbound_email_2 + end + + test "error on upload doesn't leave behind a pending inbound email" do + ActiveStorage::Blob.service.stub(:upload, -> { raise "Boom!" }) do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + assert_raises do + create_inbound_email_from_fixture "welcome.eml" + end + end + end + end + + test "email gets saved to the configured storage service" do + ActionMailbox.storage_service = :test_email + + assert_equal(:test_email, ActionMailbox.storage_service) + + email = create_inbound_email_from_fixture("welcome.eml") + + storage_service = ActiveStorage::Blob.services.fetch(ActionMailbox.storage_service) + raw = email.raw_email_blob + + # Not present in the main storage + assert_not(ActiveStorage::Blob.service.exist?(raw.key)) + # Present in the email storage + assert(storage_service.exist?(raw.key)) + ensure + ActionMailbox.storage_service = nil + end + + test "email gets saved to the default storage service, even if it gets changed" do + default_service = ActiveStorage::Blob.service + ActiveStorage::Blob.service = ActiveStorage::Blob.services.fetch(:test_email) + + # Doesn't change ActionMailbox.storage_service + assert_nil(ActionMailbox.storage_service) + + email = create_inbound_email_from_fixture("welcome.eml") + raw = email.raw_email_blob + + # Not present in the (previously) default storage + assert_not(default_service.exist?(raw.key)) + # Present in the current default storage (email) + assert(ActiveStorage::Blob.service.exist?(raw.key)) + ensure + ActiveStorage::Blob.service = default_service + end + end +end diff --git a/actionmailbox/test/unit/mail_ext/address_equality_test.rb b/actionmailbox/test/unit/mail_ext/address_equality_test.rb new file mode 100644 index 0000000000000..e4426aeae9594 --- /dev/null +++ b/actionmailbox/test/unit/mail_ext/address_equality_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module MailExt + class AddressEqualityTest < ActiveSupport::TestCase + test "two addresses with the same address are equal" do + assert_equal Mail::Address.new("david@basecamp.com"), Mail::Address.new("david@basecamp.com") + end + end +end diff --git a/actionmailbox/test/unit/mail_ext/address_wrapping_test.rb b/actionmailbox/test/unit/mail_ext/address_wrapping_test.rb new file mode 100644 index 0000000000000..c4eb1328efa69 --- /dev/null +++ b/actionmailbox/test/unit/mail_ext/address_wrapping_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module MailExt + class AddressWrappingTest < ActiveSupport::TestCase + test "wrap" do + needing_wrapping = Mail::Address.wrap("david@basecamp.com") + wrapping_not_needed = Mail::Address.wrap(Mail::Address.new("david@basecamp.com")) + assert_equal needing_wrapping.address, wrapping_not_needed.address + end + end +end diff --git a/actionmailbox/test/unit/mail_ext/addresses_test.rb b/actionmailbox/test/unit/mail_ext/addresses_test.rb new file mode 100644 index 0000000000000..92ea75a071ccf --- /dev/null +++ b/actionmailbox/test/unit/mail_ext/addresses_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module MailExt + class AddressesTest < ActiveSupport::TestCase + setup do + @mail = Mail.new \ + from: "sally@example.com", + reply_to: "sarah@example.com", + to: "david@basecamp.com", + cc: "jason@basecamp.com", + bcc: "andrea@basecamp.com", + x_original_to: "ryan@basecamp.com", + x_forwarded_to: "jane@example.com" + end + + test "from address uses address object" do + assert_equal "example.com", @mail.from_address.domain + end + + test "reply to address uses address object" do + assert_equal "example.com", @mail.reply_to_address.domain + end + + test "recipients include everyone from to, cc, bcc, x-original-to, and x-forwarded-to" do + assert_equal %w[ david@basecamp.com jason@basecamp.com andrea@basecamp.com ryan@basecamp.com jane@example.com ], @mail.recipients + end + + test "recipients addresses use address objects" do + assert_equal "basecamp.com", @mail.recipients_addresses.first.domain + end + + test "to addresses use address objects" do + assert_equal "basecamp.com", @mail.to_addresses.first.domain + end + + test "cc addresses use address objects" do + assert_equal "basecamp.com", @mail.cc_addresses.first.domain + end + + test "bcc addresses use address objects" do + assert_equal "basecamp.com", @mail.bcc_addresses.first.domain + end + + test "x_original_to addresses use address objects" do + assert_equal "basecamp.com", @mail.x_original_to_addresses.first.domain + end + + test "x_forwarded_to addresses use address objects" do + assert_equal "example.com", @mail.x_forwarded_to_addresses.first.domain + end + end +end diff --git a/actionmailbox/test/unit/mailbox/bouncing_test.rb b/actionmailbox/test/unit/mailbox/bouncing_test.rb new file mode 100644 index 0000000000000..667ec62c04c31 --- /dev/null +++ b/actionmailbox/test/unit/mailbox/bouncing_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class BouncingWithReplyMailbox < ActionMailbox::Base + def process + bounce_with BounceMailer.bounce(to: mail.from) + end +end + +class BouncingWithImmediateReplyMailbox < ActionMailbox::Base + def process + bounce_now_with BounceMailer.bounce(to: mail.from) + end +end + +class ActionMailbox::Base::BouncingTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + + setup do + @inbound_email = create_inbound_email_from_mail \ + from: "sender@example.com", to: "replies@example.com", subject: "Bounce me" + end + + teardown do + ActionMailer::Base.deliveries.clear + end + + test "bouncing with a reply" do + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + BouncingWithReplyMailbox.receive @inbound_email + end + + assert_predicate @inbound_email, :bounced? + assert_emails 1 + + mail = ActionMailer::Base.deliveries.last + assert_equal %w[ sender@example.com ], mail.to + assert_equal "Your email was not delivered", mail.subject + end + + test "bouncing now with a reply" do + assert_no_enqueued_emails do + BouncingWithImmediateReplyMailbox.receive @inbound_email + end + + assert_predicate @inbound_email, :bounced? + assert_emails 1 + + mail = ActionMailer::Base.deliveries.last + assert_equal %w[ sender@example.com ], mail.to + assert_equal "Your email was not delivered", mail.subject + end +end diff --git a/actionmailbox/test/unit/mailbox/callbacks_test.rb b/actionmailbox/test/unit/mailbox/callbacks_test.rb new file mode 100644 index 0000000000000..1917aa3250da0 --- /dev/null +++ b/actionmailbox/test/unit/mailbox/callbacks_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class CallbackMailbox < ActionMailbox::Base + before_processing { $before_processing = "Ran that!" } + after_processing { $after_processing = "Ran that too!" } + around_processing ->(r, block) { block.call; $around_processing = "Ran that as well!" } + + def process + $processed = mail.subject + end +end + +class BouncingCallbackMailbox < ActionMailbox::Base + before_processing { $before_processing = [ "Pre-bounce" ] } + + before_processing do + bounce_with BounceMailer.bounce(to: mail.from) + $before_processing << "Bounce" + end + + before_processing { $before_processing << "Post-bounce" } + + after_processing { $after_processing = true } + + def process + $processed = true + end +end + +class DiscardingCallbackMailbox < ActionMailbox::Base + before_processing { $before_processing = [ "Pre-discard" ] } + + before_processing do + delivered! + $before_processing << "Discard" + end + + before_processing { $before_processing << "Post-discard" } + + after_processing { $after_processing = true } + + def process + $processed = true + end +end + +class ActionMailbox::Base::CallbacksTest < ActiveSupport::TestCase + setup do + $before_processing = $after_processing = $around_processing = $processed = false + @inbound_email = create_inbound_email_from_fixture("welcome.eml") + end + + test "all callback types" do + CallbackMailbox.receive @inbound_email + assert_equal "Ran that!", $before_processing + assert_equal "Ran that too!", $after_processing + assert_equal "Ran that as well!", $around_processing + end + + test "bouncing in a callback terminates processing" do + BouncingCallbackMailbox.receive @inbound_email + assert_predicate @inbound_email, :bounced? + assert_equal [ "Pre-bounce", "Bounce" ], $before_processing + assert_not $processed + assert_not $after_processing + end + + test "marking the inbound email as delivered in a callback terminates processing" do + DiscardingCallbackMailbox.receive @inbound_email + assert_predicate @inbound_email, :delivered? + assert_equal [ "Pre-discard", "Discard" ], $before_processing + assert_not $processed + assert_not $after_processing + end +end diff --git a/actionmailbox/test/unit/mailbox/notifications_test.rb b/actionmailbox/test/unit/mailbox/notifications_test.rb new file mode 100644 index 0000000000000..655ed4560258f --- /dev/null +++ b/actionmailbox/test/unit/mailbox/notifications_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class RepliesMailbox < ActionMailbox::Base +end + +class ActionMailbox::Base::NotificationsTest < ActiveSupport::TestCase + test "instruments processing" do + mailbox = RepliesMailbox.new(create_inbound_email_from_fixture("welcome.eml")) + expected_payload = { + mailbox:, + inbound_email: { + id: 1, + message_id: "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", + status: "processing" + } + } + + assert_notifications_count("process.action_mailbox", 1) do + assert_notification("process.action_mailbox", expected_payload) do + mailbox.perform_processing + end + end + end +end diff --git a/actionmailbox/test/unit/mailbox/routing_test.rb b/actionmailbox/test/unit/mailbox/routing_test.rb new file mode 100644 index 0000000000000..8302b1d5ccce6 --- /dev/null +++ b/actionmailbox/test/unit/mailbox/routing_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class ApplicationMailbox < ActionMailbox::Base + routing "replies@example.com" => :replies +end + +class RepliesMailbox < ActionMailbox::Base + def process + $processed = mail.subject + end +end + +class ActionMailbox::Base::RoutingTest < ActiveSupport::TestCase + setup do + $processed = false + end + + test "string routing" do + ApplicationMailbox.route create_inbound_email_from_fixture("welcome.eml") + assert_equal "Discussion: Let's debate these attachments", $processed + end + + test "delayed routing" do + perform_enqueued_jobs only: ActionMailbox::RoutingJob do + create_inbound_email_from_fixture "welcome.eml", status: :pending + assert_equal "Discussion: Let's debate these attachments", $processed + end + end + + test "mailbox_for" do + inbound_email = create_inbound_email_from_fixture "welcome.eml", status: :pending + assert_equal RepliesMailbox, ApplicationMailbox.mailbox_for(inbound_email) + end +end diff --git a/actionmailbox/test/unit/mailbox/state_test.rb b/actionmailbox/test/unit/mailbox/state_test.rb new file mode 100644 index 0000000000000..cf1fd5441ea72 --- /dev/null +++ b/actionmailbox/test/unit/mailbox/state_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class SuccessfulMailbox < ActionMailbox::Base + def process + $processed = mail.subject + end +end + +class UnsuccessfulMailbox < ActionMailbox::Base + rescue_from(RuntimeError) { $processed = :failure } + + def process + raise "No way!" + end +end + +class BouncingMailbox < ActionMailbox::Base + def process + $processed = :bounced + bounced! + end +end + + +class ActionMailbox::Base::StateTest < ActiveSupport::TestCase + setup do + $processed = false + @inbound_email = create_inbound_email_from_mail \ + to: "replies@example.com", subject: "I was processed" + end + + test "successful mailbox processing leaves inbound email in delivered state" do + SuccessfulMailbox.receive @inbound_email + assert_predicate @inbound_email, :delivered? + assert_equal "I was processed", $processed + end + + test "unsuccessful mailbox processing leaves inbound email in failed state" do + UnsuccessfulMailbox.receive @inbound_email + assert_predicate @inbound_email, :failed? + assert_equal :failure, $processed + end + + test "bounced inbound emails are not delivered" do + BouncingMailbox.receive @inbound_email + assert_predicate @inbound_email, :bounced? + assert_equal :bounced, $processed + end +end diff --git a/actionmailbox/test/unit/relayer_test.rb b/actionmailbox/test/unit/relayer_test.rb new file mode 100644 index 0000000000000..89701d0baa111 --- /dev/null +++ b/actionmailbox/test/unit/relayer_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +require "action_mailbox/relayer" + +module ActionMailbox + class RelayerTest < ActiveSupport::TestCase + URL = "https://example.com/rails/action_mailbox/relay/inbound_emails" + INGRESS_PASSWORD = "secret" + + setup do + @relayer = ActionMailbox::Relayer.new(url: URL, password: INGRESS_PASSWORD) + end + + test "successfully relaying an email" do + stub_request(:post, URL).to_return status: 204 + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "2.0.0", result.status_code + assert_equal "Successfully relayed message to ingress", result.message + assert_predicate result, :success? + assert_not result.failure? + + assert_requested :post, URL, body: file_fixture("welcome.eml").read, + basic_auth: [ "actionmailbox", INGRESS_PASSWORD ], + headers: { "Content-Type" => "message/rfc822", "User-Agent" => /\AAction Mailbox relayer v\d+\./ } + end + + test "unsuccessfully relaying with invalid credentials" do + stub_request(:post, URL).to_return status: 401 + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.7.0", result.status_code + assert_equal "Invalid credentials for ingress", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to an unspecified server error" do + stub_request(:post, URL).to_return status: 500 + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.0.0", result.status_code + assert_equal "HTTP 500", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to a gateway timeout" do + stub_request(:post, URL).to_return status: 504 + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.0.0", result.status_code + assert_equal "HTTP 504", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to ECONNRESET" do + stub_request(:post, URL).to_raise Errno::ECONNRESET.new + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.4.2", result.status_code + assert_equal "Network error relaying to ingress: Connection reset by peer", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to connection failure" do + stub_request(:post, URL).to_raise SocketError.new("Failed to open TCP connection to example.com:443") + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.4.2", result.status_code + assert_equal "Network error relaying to ingress: Failed to open TCP connection to example.com:443", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to client-side timeout" do + stub_request(:post, URL).to_timeout + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.4.2", result.status_code + assert_equal "Timed out relaying to ingress", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to an unhandled exception" do + stub_request(:post, URL).to_raise StandardError.new("Something went wrong") + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.0.0", result.status_code + assert_equal "Error relaying to ingress: Something went wrong", result.message + assert_not result.success? + assert_predicate result, :failure? + end + end +end diff --git a/actionmailbox/test/unit/router_test.rb b/actionmailbox/test/unit/router_test.rb new file mode 100644 index 0000000000000..d1f46c18c8bdd --- /dev/null +++ b/actionmailbox/test/unit/router_test.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class RootMailbox < ActionMailbox::Base + def process + $processed_by = self.class.to_s + $processed_mail = mail + end +end + +class FirstMailbox < RootMailbox +end + +class SecondMailbox < RootMailbox +end + +module Nested + class FirstMailbox < RootMailbox + end +end + +class FirstMailboxAddress + def match?(inbound_email) + inbound_email.mail.to.include?("replies-class@example.com") + end +end + +module ActionMailbox + class RouterTest < ActiveSupport::TestCase + setup do + @router = ActionMailbox::Router.new + $processed_by = $processed_mail = nil + end + + test "single string route" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "single string routing on cc" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "someone@example.com", cc: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "single string routing on bcc" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "someone@example.com", bcc: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "single string routing case-insensitively" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "FIRST@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "multiple string routes" do + @router.add_routes("first@example.com" => :first, "second@example.com" => :second) + + inbound_email = create_inbound_email_from_mail(to: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + + inbound_email = create_inbound_email_from_mail(to: "second@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "SecondMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "single regexp route" do + @router.add_routes(/replies-\w+@example.com/ => :first, "replies-nowhere@example.com" => :second) + + inbound_email = create_inbound_email_from_mail(to: "replies-okay@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + end + + test "single proc route" do + @router.add_route \ + ->(inbound_email) { inbound_email.mail.to.include?("replies-proc@example.com") }, + to: :second + + @router.route create_inbound_email_from_mail(to: "replies-proc@example.com", subject: "This is a reply") + assert_equal "SecondMailbox", $processed_by + end + + test "address class route" do + @router.add_route FirstMailboxAddress.new, to: :first + @router.route create_inbound_email_from_mail(to: "replies-class@example.com", subject: "This is a reply") + assert_equal "FirstMailbox", $processed_by + end + + test "string route to nested mailbox" do + @router.add_route "first@example.com", to: "nested/first" + + inbound_email = create_inbound_email_from_mail(to: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "Nested::FirstMailbox", $processed_by + end + + test "all as the only route" do + @router.add_route :all, to: :first + @router.route create_inbound_email_from_mail(to: "replies-class@example.com", subject: "This is a reply") + assert_equal "FirstMailbox", $processed_by + end + + test "all as the second route" do + @router.add_route FirstMailboxAddress.new, to: :first + @router.add_route :all, to: :second + + @router.route create_inbound_email_from_mail(to: "replies-class@example.com", subject: "This is a reply") + assert_equal "FirstMailbox", $processed_by + + @router.route create_inbound_email_from_mail(to: "elsewhere@example.com", subject: "This is a reply") + assert_equal "SecondMailbox", $processed_by + end + + test "missing route" do + inbound_email = create_inbound_email_from_mail(to: "going-nowhere@example.com", subject: "This is a reply") + assert_raises(ActionMailbox::Router::RoutingError) do + @router.route inbound_email + end + assert_predicate inbound_email, :bounced? + end + + test "invalid address" do + error = assert_raises(ArgumentError) do + @router.add_route Array.new, to: :first + end + assert_equal "Expected a Symbol, String, Regexp, Proc, or matchable, got []", error.message + end + + test "single string mailbox_for" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "first@example.com", subject: "This is a reply") + assert_equal FirstMailbox, @router.mailbox_for(inbound_email) + end + + test "mailbox_for with no matches" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "second@example.com", subject: "This is a reply") + assert_nil @router.mailbox_for(inbound_email) + end + end +end diff --git a/actionmailbox/test/unit/test_helper_test.rb b/actionmailbox/test/unit/test_helper_test.rb new file mode 100644 index 0000000000000..d392c80133789 --- /dev/null +++ b/actionmailbox/test/unit/test_helper_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module ActionMailbox + class TestHelperTest < ActiveSupport::TestCase + test "multi-part mail can be built in tests using a block" do + inbound_email = create_inbound_email_from_mail do + to "test@example.com" + from "hello@example.com" + + text_part do + body "Hello, world" + end + + html_part do + body "

Hello, world

" + end + end + + mail = inbound_email.mail + + expected_mail_text_part = <<~TEXT.chomp + Content-Type: text/plain;\r + charset=UTF-8\r + Content-Transfer-Encoding: 7bit\r + \r + Hello, world + TEXT + + expected_mail_html_part = <<~HTML.chomp + Content-Type: text/html;\r + charset=UTF-8\r + Content-Transfer-Encoding: 7bit\r + \r +

Hello, world

+ HTML + + assert_equal 2, mail.parts.count + assert_equal expected_mail_text_part, mail.text_part.to_s + assert_equal expected_mail_html_part, mail.html_part.to_s + end + end +end diff --git a/actionmailer/CHANGELOG.md b/actionmailer/CHANGELOG.md index 6ec10c7a704cf..9163901e39662 100644 --- a/actionmailer/CHANGELOG.md +++ b/actionmailer/CHANGELOG.md @@ -1,31 +1,18 @@ -* Add `:args` to `process.action_mailer` event. +* Add `assert_part` and `assert_no_part` to `ActionMailer::TestCase` - *Yuji Yaginuma* + ```ruby + test "assert MyMailer.welcome HTML and text parts" do + mail = MyMailer.welcome("Hello, world") -* Add parameterized invocation of mailers as a way to share before filters and defaults between actions. - See ActionMailer::Parameterized for a full example of the benefit. + 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 + ``` - *DHH* + *Sean Doyle* -* Allow lambdas to be used as lazy defaults in addition to procs. - - *DHH* - -* Mime type: allow to custom content type when setting body in headers - and attachments. - - Example: - - def test_emails - attachments["invoice.pdf"] = "This is test File content" - mail(body: "Hello there", content_type: "text/html") - end - - *Minh Quy* - -* Exception handling: use `rescue_from` to handle exceptions raised by - mailer actions, by message delivery, and by deferred delivery jobs. - - *Jeremy Daer* - -Please check [5-0-stable](https://github.com/rails/rails/blob/5-0-stable/actionmailer/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionmailer/CHANGELOG.md) for previous changes. diff --git a/actionmailer/MIT-LICENSE b/actionmailer/MIT-LICENSE index ac810e86d0210..7be9ac633faf0 100644 --- a/actionmailer/MIT-LICENSE +++ b/actionmailer/MIT-LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2004-2017 David Heinemeier Hansson +Copyright (c) David Heinemeier Hansson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/actionmailer/README.rdoc b/actionmailer/README.rdoc index 397ebe42017d4..5259b952e13d7 100644 --- a/actionmailer/README.rdoc +++ b/actionmailer/README.rdoc @@ -13,6 +13,8 @@ Additionally, an Action Mailer class can be used to process incoming email, such as allowing a blog to accept new posts from an email (which could even have been sent from a phone). +You can read more about Action Mailer in the {Action Mailer Basics}[https://guides.rubyonrails.org/action_mailer_basics.html] guide. + == Sending emails The framework works by initializing any instance variables you want to be @@ -76,7 +78,7 @@ Or you can just chain the methods together like: It is possible to set default values that will be used in every method in your Action Mailer class. To implement this functionality, you just call the public -class method +default+ which you get for free from ActionMailer::Base. +class method +default+ which you get for free from ActionMailer::Base. This method accepts a Hash as the parameter. You can use any of the headers, email messages have, like +:from+ as the key. You can also pass in a string as the key, like "Content-Type", but Action Mailer does this out of the box for you, @@ -93,42 +95,6 @@ Example: ..... end -== Receiving emails - -To receive emails, you need to implement a public instance method called -+receive+ that takes an email object as its single parameter. The Action Mailer -framework has a corresponding class method, which is also called +receive+, that -accepts a raw, unprocessed email as a string, which it then turns into the email -object and calls the receive instance method. - -Example: - - class Mailman < ActionMailer::Base - def receive(email) - page = Page.find_by(address: email.to.first) - page.emails.create( - subject: email.subject, body: email.body - ) - - if email.has_attachments? - email.attachments.each do |attachment| - page.attachments.create({ - file: attachment, description: email.subject - }) - end - end - end - end - -This Mailman can be the target for Postfix or other MTAs. In Rails, you would use -the runner in the trivial case like this: - - rails runner 'Mailman.receive(STDIN.read)' - -However, invoking Rails in the runner for each mail to be received is very -resource intensive. A single instance of Rails should be run within a daemon, if -it is going to process more than just a limited amount of email. - == Configuration The Base class has the full list of configuration options. Here's an example: @@ -148,28 +114,28 @@ The latest version of Action Mailer can be installed with RubyGems: $ gem install actionmailer -Source code can be downloaded as part of the Rails project on GitHub +Source code can be downloaded as part of the \Rails project on GitHub: -* https://github.com/rails/rails/tree/master/actionmailer +* https://github.com/rails/rails/tree/main/actionmailer == License Action Mailer is released under the MIT license: -* http://www.opensource.org/licenses/MIT +* https://opensource.org/licenses/MIT == Support API documentation is at -* http://api.rubyonrails.org +* https://api.rubyonrails.org -Bug reports can be filed for the Ruby on Rails project here: +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://groups.google.com/forum/?fromgroups#!forum/rubyonrails-core +* https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/actionmailer/Rakefile b/actionmailer/Rakefile index 6f05d236d9131..3b55b05c19f0b 100644 --- a/actionmailer/Rakefile +++ b/actionmailer/Rakefile @@ -1,9 +1,11 @@ +# frozen_string_literal: true + require "rake/testtask" desc "Default Task" task default: [ :test ] -task :package +ENV["RAILS_MINITEST_PLUGIN"] = "true" # Run the unit tests Rake::TestTask.new { |t| @@ -11,13 +13,20 @@ Rake::TestTask.new { |t| t.pattern = "test/**/*_test.rb" t.warning = true t.verbose = true + t.options = "--profile" if ENV["CI"] t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) } 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/actionmailer.gemspec b/actionmailer/actionmailer.gemspec index e75dae6cf930b..88a9be08c1cf7 100644 --- a/actionmailer/actionmailer.gemspec +++ b/actionmailer/actionmailer.gemspec @@ -1,28 +1,43 @@ -version = File.read(File.expand_path("../../RAILS_VERSION", __FILE__)).strip +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.name = "actionmailer" s.version = version - s.summary = "Email composition, delivery, and receiving framework (part of Rails)." - s.description = "Email on Rails. Compose, deliver, receive, and test emails using the familiar controller/view pattern. First-class support for multipart email and attachments." + s.summary = "Email composition and delivery framework (part of Rails)." + s.description = "Email on Rails. Compose, deliver, and test emails using the familiar controller/view pattern. First-class support for multipart email and attachments." - s.required_ruby_version = ">= 2.2.2" + s.required_ruby_version = ">= 3.2.0" s.license = "MIT" s.author = "David Heinemeier Hansson" s.email = "david@loudthinking.com" - s.homepage = "http://rubyonrails.org" + s.homepage = "https://rubyonrails.org" s.files = Dir["CHANGELOG.md", "README.rdoc", "MIT-LICENSE", "lib/**/*"] s.require_path = "lib" s.requirements << "none" + s.metadata = { + "bug_tracker_uri" => "https://github.com/rails/rails/issues", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actionmailer/CHANGELOG.md", + "documentation_uri" => "https://api.rubyonrails.org/v#{version}/", + "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actionmailer", + "rubygems_mfa_required" => "true", + } + + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + + s.add_dependency "activesupport", version s.add_dependency "actionpack", version s.add_dependency "actionview", version s.add_dependency "activejob", version - s.add_dependency "mail", ["~> 2.5", ">= 2.5.4"] - s.add_dependency "rails-dom-testing", "~> 2.0" + s.add_dependency "mail", ">= 2.8.0" + s.add_dependency "rails-dom-testing", "~> 2.2" end diff --git a/actionmailer/bin/test b/actionmailer/bin/test index a7beb14b271a3..c53377cc970f4 100755 --- a/actionmailer/bin/test +++ b/actionmailer/bin/test @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true COMPONENT_ROOT = File.expand_path("..", __dir__) -require File.expand_path("../tools/test", COMPONENT_ROOT) +require_relative "../../tools/test" diff --git a/actionmailer/lib/action_mailer.rb b/actionmailer/lib/action_mailer.rb index 211190560a3c5..f58a858094143 100644 --- a/actionmailer/lib/action_mailer.rb +++ b/actionmailer/lib/action_mailer.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + #-- -# Copyright (c) 2004-2017 David Heinemeier Hansson +# Copyright (c) David Heinemeier Hansson # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -23,14 +25,17 @@ require "abstract_controller" require "action_mailer/version" +require "action_mailer/deprecator" # Common Active Support usage in Action Mailer +require "active_support" require "active_support/rails" require "active_support/core_ext/class" require "active_support/core_ext/module/attr_internal" require "active_support/core_ext/string/inflections" require "active_support/lazy_load_hooks" +# :include: ../README.rdoc module ActionMailer extend ::ActiveSupport::Autoload @@ -39,6 +44,7 @@ module ActionMailer end autoload :Base + autoload :Callbacks autoload :DeliveryMethods autoload :InlinePreviewInterceptor autoload :MailHelper @@ -48,12 +54,26 @@ module ActionMailer autoload :TestCase autoload :TestHelper autoload :MessageDelivery - autoload :DeliveryJob + autoload :MailDeliveryJob + autoload :QueuedDelivery + autoload :FormBuilder + + def self.eager_load! + super + + require "mail" + Mail.eager_autoload! + + Base.descendants.each do |mailer| + mailer.eager_load! unless mailer.abstract? + end + end end autoload :Mime, "action_dispatch/http/mime_type" ActiveSupport.on_load(:action_view) do ActionView::Base.default_formats ||= Mime::SET.symbols - ActionView::Template::Types.delegate_to Mime + ActionView::Template.mime_types_implementation = Mime + ActionView::LookupContext::DetailsKey.clear end diff --git a/actionmailer/lib/action_mailer/base.rb b/actionmailer/lib/action_mailer/base.rb index 9b5d39faeab31..4ad5145995a96 100644 --- a/actionmailer/lib/action_mailer/base.rb +++ b/actionmailer/lib/action_mailer/base.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "mail" require "action_mailer/collector" require "active_support/core_ext/string/inflections" @@ -5,20 +7,23 @@ require "active_support/core_ext/module/anonymous" require "action_mailer/log_subscriber" +require "action_mailer/structured_event_subscriber" require "action_mailer/rescuable" module ActionMailer + # = Action Mailer \Base + # # Action Mailer allows you to send email from your application using a mailer model and views. # - # = Mailer Models + # == Mailer Models # # To use Action Mailer, you need to create a mailer model. # - # $ rails generate mailer Notifier + # $ bin/rails generate mailer Notifier # # The generated model inherits from ApplicationMailer which in turn - # inherits from ActionMailer::Base. A mailer model defines methods - # used to generate an email message. In these methods, you can setup variables to be used in + # inherits from +ActionMailer::Base+. A mailer model defines methods + # used to generate an email message. In these methods, you can set up variables to be used in # the mailer views, options on the mail itself such as the :from address, and attachments. # # class ApplicationMailer < ActionMailer::Base @@ -54,10 +59,10 @@ module ActionMailer # # * mail - Allows you to specify email to be sent. # - # The hash passed to the mail method allows you to specify any header that a Mail::Message + # The hash passed to the mail method allows you to specify any header that a +Mail::Message+ # will accept (any valid email header including optional fields). # - # The mail method, if not passed a block, will inspect your views and send all the views with + # The +mail+ method, if not passed a block, will inspect your views and send all the views with # the same name as the method, so the above action would send the +welcome.text.erb+ view # file as well as the +welcome.html.erb+ view file in a +multipart/alternative+ email. # @@ -82,7 +87,7 @@ module ActionMailer # format.html { render "some_other_template" } # end # - # = Mailer views + # == Mailer views # # Like Action Controller, each mailer class has a corresponding view directory in which each # method of the class looks for a template with its name. @@ -104,13 +109,13 @@ module ActionMailer # You got a new note! # <%= truncate(@note.body, length: 25) %> # - # If you need to access the subject, from or the recipients in the view, you can do that through message object: + # If you need to access the subject, from, or the recipients in the view, you can do that through message object: # # You got a new note from <%= message.from %>! # <%= truncate(@note.body, length: 25) %> # # - # = Generating URLs + # == Generating URLs # # URLs can be generated in mailer views using url_for or named routes. Unlike controllers from # Action Pack, the mailer instance doesn't have any context about the incoming request, so you'll need @@ -133,9 +138,12 @@ module ActionMailer # # config.action_mailer.default_url_options = { host: "example.com" } # - # By default when config.force_ssl is true, URLs generated for hosts will use the HTTPS protocol. + # You can also define a default_url_options method on individual mailers to override these + # default settings per-mailer. + # + # By default when config.force_ssl is +true+, URLs generated for hosts will use the HTTPS protocol. # - # = Sending mail + # == Sending mail # # Once a mailer action and template are defined, you can deliver your message or defer its creation and # delivery for later: @@ -144,9 +152,9 @@ module ActionMailer # mail = NotifierMailer.welcome(User.first) # => an ActionMailer::MessageDelivery object # mail.deliver_now # generates and sends the email now # - # The ActionMailer::MessageDelivery class is a wrapper around a delegate that will call - # your method to generate the mail. If you want direct access to the delegator, or Mail::Message, - # you can call the message method on the ActionMailer::MessageDelivery object. + # The ActionMailer::MessageDelivery class is a wrapper around a delegate that will call + # your method to generate the mail. If you want direct access to the delegator, or +Mail::Message+, + # you can call the message method on the ActionMailer::MessageDelivery object. # # NotifierMailer.welcome(User.first).message # => a Mail::Message object # @@ -160,7 +168,7 @@ module ActionMailer # You never instantiate your mailer class. Rather, you just call the method you defined on the class itself. # All instance methods are expected to return a message object to be sent. # - # = Multipart Emails + # == Multipart Emails # # Multipart messages can also be used implicitly because Action Mailer will automatically detect and use # multipart templates, where each template is named after the name of the action, followed by the content @@ -181,7 +189,7 @@ module ActionMailer # This means that you'll have to manually add each part to the email and set the content type of the email # to multipart/alternative. # - # = Attachments + # == Attachments # # Sending attachment in emails is easy: # @@ -208,7 +216,7 @@ module ActionMailer # end # end # - # You can also send attachments with html template, in this case you need to add body, attachments, + # You can also send attachments with HTML template, in this case you need to add body, attachments, # and custom content type like this: # # class NotifierMailer < ApplicationMailer @@ -221,7 +229,7 @@ module ActionMailer # end # end # - # = Inline Attachments + # == Inline Attachments # # You can also specify that a file should be displayed inline with other HTML. This is useful # if you want to display a corporate logo or a photo. @@ -247,7 +255,7 @@ module ActionMailer # # <%= image_tag attachments['photo.png'].url, alt: 'Our Photo', class: 'photo' -%> # - # = Observing and Intercepting Mails + # == Observing and Intercepting Mails # # Action Mailer provides hooks into the Mail observer and interceptor methods. These allow you to # register classes that are called during the mail delivery life cycle. @@ -258,9 +266,9 @@ module ActionMailer # An interceptor class must implement the :delivering_email(message) method which will be # called before the email is sent, allowing you to make modifications to the email before it hits # the delivery agents. Your class should make any needed modifications directly to the passed - # in Mail::Message instance. + # in +Mail::Message+ instance. # - # = Default Hash + # == Default \Hash # # Action Mailer provides some intelligent defaults for your emails, these are usually specified in a # default method inside the class definition: @@ -269,15 +277,15 @@ module ActionMailer # default sender: 'system@example.com' # end # - # You can pass in any header value that a Mail::Message accepts. Out of the box, - # ActionMailer::Base sets the following: + # You can pass in any header value that a +Mail::Message+ accepts. Out of the box, + # +ActionMailer::Base+ sets the following: # # * mime_version: "1.0" # * charset: "UTF-8" # * content_type: "text/plain" # * parts_order: [ "text/plain", "text/enriched", "text/html" ] # - # parts_order and charset are not actually valid Mail::Message header fields, + # parts_order and charset are not actually valid +Mail::Message+ header fields, # but Action Mailer translates them appropriately and sets the correct values. # # As you can pass in any header, you need to either quote the header as a string, or pass it in as @@ -309,14 +317,16 @@ module ActionMailer # # config.action_mailer.default_options = { from: "no-reply@example.org" } # - # = Callbacks + # == \Callbacks # - # You can specify callbacks using before_action and after_action for configuring your messages. - # This may be useful, for example, when you want to add default inline attachments for all - # messages sent out by a certain mailer class: + # You can specify callbacks using before_action and after_action for configuring your messages, + # and using before_deliver and after_deliver for wrapping the delivery process. + # For example, when you want to add default inline attachments and log delivery for all messages + # sent out by a certain mailer class: # # class NotifierMailer < ApplicationMailer # before_action :add_inline_attachment! + # after_deliver :log_delivery # # def welcome # mail @@ -326,21 +336,48 @@ module ActionMailer # def add_inline_attachment! # attachments.inline["footer.jpg"] = File.read('/path/to/filename.jpg') # end + # + # def log_delivery + # Rails.logger.info "Sent email with message id '#{message.message_id}' at #{Time.current}." + # end # end # - # Callbacks in Action Mailer are implemented using - # AbstractController::Callbacks, so you can define and configure + # Action callbacks in Action Mailer are implemented using + # AbstractController::Callbacks, so you can define and configure # callbacks in the same manner that you would use callbacks in classes that - # inherit from ActionController::Base. + # inherit from ActionController::Base. # # Note that unless you have a specific reason to do so, you should prefer # using before_action rather than after_action in your # Action Mailer classes so that headers are parsed properly. # - # = Previewing emails + # == Rescuing Errors + # + # +rescue+ blocks inside of a mailer method cannot rescue errors that occur + # outside of rendering -- for example, record deserialization errors in a + # background job, or errors from a third-party mail delivery service. + # + # To rescue errors that occur during any part of the mailing process, use + # {rescue_from}[rdoc-ref:ActiveSupport::Rescuable::ClassMethods#rescue_from]: + # + # class NotifierMailer < ApplicationMailer + # rescue_from ActiveJob::DeserializationError do + # # ... + # end + # + # rescue_from "SomeThirdPartyService::ApiError" do + # # ... + # end + # + # def notify(recipient) + # mail(to: recipient, subject: "Notification") + # end + # end + # + # == Previewing emails # # You can preview your email templates visually by adding a mailer preview file to the - # ActionMailer::Base.preview_path. Since most emails do something interesting + # ActionMailer::Base.preview_paths. Since most emails do something interesting # with database data, you'll need to write some scenarios to load messages with fake data: # # class NotifierMailerPreview < ActionMailer::Preview @@ -349,12 +386,12 @@ module ActionMailer # end # end # - # Methods must return a Mail::Message object which can be generated by calling the mailer + # Methods must return a +Mail::Message+ object which can be generated by calling the mailer # method without the additional deliver_now / deliver_later. The location of the - # mailer previews directory can be configured using the preview_path option which has a default + # mailer preview directories can be configured using the preview_paths option which has a default # of test/mailers/previews: # - # config.action_mailer.preview_path = "#{Rails.root}/lib/mailer_previews" + # config.action_mailer.preview_paths << "#{Rails.root}/lib/mailer_previews" # # An overview of all previews is accessible at http://localhost:3000/rails/mailers # on a running development server instance. @@ -374,7 +411,7 @@ module ActionMailer # and register_preview_interceptor if they should operate on both sending and # previewing emails. # - # = Configuration options + # == Configuration options # # These options are specified on the class level, like # ActionMailer::Base.raise_delivery_errors = true @@ -397,17 +434,21 @@ module ActionMailer # This is a symbol and one of :plain (will send the password Base64 encoded), :login (will # send the password Base64 encoded) or :cram_md5 (combines a Challenge/Response mechanism to exchange # information and a cryptographic Message Digest 5 algorithm to hash important information) + # * :enable_starttls - Use STARTTLS when connecting to your SMTP server and fail if unsupported. Defaults + # to false. Requires at least version 2.7 of the Mail gem. # * :enable_starttls_auto - Detects if STARTTLS is enabled in your SMTP server and starts # to use it. Defaults to true. # * :openssl_verify_mode - When using TLS, you can set how OpenSSL checks the certificate. This is # really useful if you need to validate a self-signed and/or a wildcard certificate. You can use the name # of an OpenSSL verify constant ('none' or 'peer') or directly the constant - # (OpenSSL::SSL::VERIFY_NONE or OpenSSL::SSL::VERIFY_PEER). - # :ssl/:tls Enables the SMTP connection to use SMTP/TLS (SMTPS: SMTP over direct TLS connection) + # (+OpenSSL::SSL::VERIFY_NONE+ or +OpenSSL::SSL::VERIFY_PEER+). + # * :ssl/:tls Enables the SMTP connection to use SMTP/TLS (SMTPS: SMTP over direct TLS connection) + # * :open_timeout Number of seconds to wait while attempting to open a connection. + # * :read_timeout Number of seconds to wait until timing-out a read(2) call. # # * sendmail_settings - Allows you to override options for the :sendmail delivery method. # * :location - The location of the sendmail executable. Defaults to /usr/sbin/sendmail. - # * :arguments - The command line arguments. Defaults to -i with -f sender@address + # * :arguments - The command line arguments. Defaults to %w[ -i ] with -f sender@address # added automatically before the message is sent. # # * file_settings - Allows you to override options for the :file delivery method. @@ -428,12 +469,19 @@ module ActionMailer # * deliveries - Keeps an array of all the emails sent out through the Action Mailer with # delivery_method :test. Most useful for unit and functional testing. # - # * deliver_later_queue_name - The name of the queue used with deliver_later. Defaults to +mailers+. + # * delivery_job - The job class used with deliver_later. Mailers can set this to use a + # custom delivery job. Defaults to +ActionMailer::MailDeliveryJob+. + # + # * deliver_later_queue_name - The queue name used by deliver_later with the default + # delivery_job. Mailers can set this to use a custom queue name. class Base < AbstractController::Base + include Callbacks include DeliveryMethods + include QueuedDelivery include Rescuable include Parameterized include Previews + include FormBuilder abstract! @@ -450,14 +498,9 @@ class Base < AbstractController::Base PROTECTED_IVARS = AbstractController::Rendering::DEFAULT_PROTECTED_INSTANCE_VARIABLES + [:@_action_has_layout] - def _protected_ivars # :nodoc: - PROTECTED_IVARS - end - helper ActionMailer::MailHelper - class_attribute :default_params - self.default_params = { + class_attribute :default_params, default: { mime_version: "1.0", charset: "UTF-8", content_type: "text/plain", @@ -470,25 +513,49 @@ def register_observers(*observers) observers.flatten.compact.each { |observer| register_observer(observer) } end + # Unregister one or more previously registered Observers. + def unregister_observers(*observers) + observers.flatten.compact.each { |observer| unregister_observer(observer) } + end + # Register one or more Interceptors which will be called before mail is sent. def register_interceptors(*interceptors) interceptors.flatten.compact.each { |interceptor| register_interceptor(interceptor) } end + # Unregister one or more previously registered Interceptors. + def unregister_interceptors(*interceptors) + interceptors.flatten.compact.each { |interceptor| unregister_interceptor(interceptor) } + end + # Register an Observer which will be notified when mail is delivered. - # Either a class, string or symbol can be passed in as the Observer. + # Either a class, string, or symbol can be passed in as the Observer. # If a string or symbol is passed in it will be camelized and constantized. def register_observer(observer) Mail.register_observer(observer_class_for(observer)) end + # Unregister a previously registered Observer. + # Either a class, string, or symbol can be passed in as the Observer. + # If a string or symbol is passed in it will be camelized and constantized. + def unregister_observer(observer) + Mail.unregister_observer(observer_class_for(observer)) + end + # Register an Interceptor which will be called before mail is sent. - # Either a class, string or symbol can be passed in as the Interceptor. + # Either a class, string, or symbol can be passed in as the Interceptor. # If a string or symbol is passed in it will be camelized and constantized. def register_interceptor(interceptor) Mail.register_interceptor(observer_class_for(interceptor)) end + # Unregister a previously registered Interceptor. + # Either a class, string, or symbol can be passed in as the Interceptor. + # If a string or symbol is passed in it will be camelized and constantized. + def unregister_interceptor(interceptor) + Mail.unregister_interceptor(observer_class_for(interceptor)) + end + def observer_class_for(value) # :nodoc: case value when String, Symbol @@ -508,94 +575,74 @@ def mailer_name attr_writer :mailer_name alias :controller_path :mailer_name - # Sets the defaults through app configuration: - # - # config.action_mailer.default(from: "no-reply@example.org") + # Allows to set defaults through app configuration: # - # Aliased by ::default_options= + # config.action_mailer.default_options = { from: "no-reply@example.org" } def default(value = nil) self.default_params = default_params.merge(value).freeze if value default_params end - # Allows to set defaults through app configuration: - # - # config.action_mailer.default_options = { from: "no-reply@example.org" } alias :default_options= :default - # Receives a raw email, parses it into an email object, decodes it, - # instantiates a new mailer, and passes the email object to the mailer - # object's +receive+ method. - # - # If you want your mailer to be able to process incoming messages, you'll - # need to implement a +receive+ method that accepts the raw email string - # as a parameter: + # Wraps an email delivery inside of ActiveSupport::Notifications instrumentation. # - # class MyMailer < ActionMailer::Base - # def receive(mail) - # # ... - # end - # end - def receive(raw_mail) - ActiveSupport::Notifications.instrument("receive.action_mailer") do |payload| - mail = Mail.new(raw_mail) - set_payload_for_mail(payload, mail) - new.receive(mail) - end - end - - # Wraps an email delivery inside of ActiveSupport::Notifications instrumentation. - # - # This method is actually called by the Mail::Message object itself - # through a callback when you call :deliver on the Mail::Message, - # calling +deliver_mail+ directly and passing a Mail::Message will do + # This method is actually called by the +Mail::Message+ object itself + # through a callback when you call :deliver on the +Mail::Message+, + # calling +deliver_mail+ directly and passing a +Mail::Message+ will do # nothing except tell the logger you sent the email. - def deliver_mail(mail) #:nodoc: + def deliver_mail(mail) # :nodoc: ActiveSupport::Notifications.instrument("deliver.action_mailer") do |payload| set_payload_for_mail(payload, mail) yield # Let Mail do the delivery actions end end - private + # Returns an email in the format "Name ". + # + # If the name is a blank string, it returns just the address. + def email_address_with_name(address, name) + Mail::Address.new.tap do |builder| + builder.address = address + builder.display_name = name.presence + end.to_s + end + private def set_payload_for_mail(payload, mail) - payload[:mailer] = name - payload[:message_id] = mail.message_id - payload[:subject] = mail.subject - payload[:to] = mail.to - payload[:from] = mail.from - payload[:bcc] = mail.bcc if mail.bcc.present? - payload[:cc] = mail.cc if mail.cc.present? - payload[:date] = mail.date - payload[:mail] = mail.encoded - end - - def method_missing(method_name, *args) - if action_methods.include?(method_name.to_s) - MessageDelivery.new(self, method_name, *args) + payload[:mail] = mail.encoded + payload[:mailer] = name + payload[:message_id] = mail.message_id + payload[:subject] = mail.subject + payload[:to] = mail.to + payload[:from] = mail.from + payload[:bcc] = mail.bcc if mail.bcc.present? + payload[:cc] = mail.cc if mail.cc.present? + payload[:date] = mail.date + payload[:perform_deliveries] = mail.perform_deliveries + end + + def method_missing(method_name, ...) + if action_methods.include?(method_name.name) + MessageDelivery.new(self, method_name, ...) else super end end def respond_to_missing?(method, include_all = false) - action_methods.include?(method.to_s) || super + action_methods.include?(method.name) || super end end attr_internal :message - # Instantiate a new mailer object. If +method_name+ is not +nil+, the mailer - # will be initialized according to the named method. If not, the mailer will - # remain uninitialized (useful when you only need to invoke the "receive" - # method, for instance). def initialize super() @_mail_was_called = false @_message = Mail.new end - def process(method_name, *args) #:nodoc: + def process(method_name, *args) # :nodoc: payload = { mailer: self.class.name, action: method_name, @@ -607,8 +654,9 @@ def process(method_name, *args) #:nodoc: @_message = NullMail.new unless @_mail_was_called end end + ruby2_keywords(:process) - class NullMail #:nodoc: + class NullMail # :nodoc: def body; "" end def header; {} end @@ -616,7 +664,7 @@ def respond_to?(string, include_all = false) true end - def method_missing(*args) + def method_missing(...) nil end end @@ -626,18 +674,25 @@ def mailer_name self.class.mailer_name end - # Allows you to pass random and unusual headers to the new Mail::Message + # Returns an email in the format "Name ". + # + # If the name is a blank string, it returns just the address. + def email_address_with_name(address, name) + self.class.email_address_with_name(address, name) + end + + # Allows you to pass random and unusual headers to the new +Mail::Message+ # object which will add them to itself. # # headers['X-Special-Domain-Specific-Header'] = "SecretValue" # # You can also pass a hash into headers of header field names and values, - # which will then be set on the Mail::Message object: + # which will then be set on the +Mail::Message+ object: # # headers 'X-Special-Domain-Specific-Header' => "SecretValue", # 'In-Reply-To' => incoming.message_id # - # The resulting Mail::Message will have the following in its header: + # The resulting +Mail::Message+ will have the following in its header: # # X-Special-Domain-Specific-Header: SecretValue # @@ -673,7 +728,7 @@ def headers(args = nil) # mail.attachments['filename.jpg'] = File.read('/path/to/filename.jpg') # # If you do this, then Mail will take the file name and work out the mime type. - # It will also set the Content-Type, Content-Disposition, Content-Transfer-Encoding + # It will also set the +Content-Type+, +Content-Disposition+, and +Content-Transfer-Encoding+, # and encode the contents of the attachment in Base64. # # You can also specify overrides if you want by passing a hash instead of a string: @@ -707,7 +762,7 @@ def attachments end class LateAttachmentsProxy < SimpleDelegator - def inline; _raise_error end + def inline; self end def []=(_name, _content); _raise_error end private @@ -724,7 +779,7 @@ def _raise_error # the most used headers in an email message, these are: # # * +:subject+ - The subject of the message, if this is omitted, Action Mailer will - # ask the Rails I18n class for a translated +:subject+ in the scope of + # ask the \Rails I18n class for a translated +:subject+ in the scope of # [mailer_scope, action_name] or if this is missing, will translate the # humanized version of the +action_name+ # * +:to+ - Who the message is destined for, can be a string of addresses, or an array @@ -734,7 +789,7 @@ def _raise_error # or an array of addresses. # * +:bcc+ - Who you would like to Blind-Carbon-Copy on this email, can be a string of # addresses, or an array of addresses. - # * +:reply_to+ - Who to set the Reply-To header of the email to. + # * +:reply_to+ - Who to set the +Reply-To+ header of the email to. # * +:date+ - The date to say the email was sent on. # # You can set default values for any of the above headers (except +:date+) @@ -761,7 +816,7 @@ def _raise_error # templates in the view paths using by default the mailer name and the # method name that it is being called from, it will then create parts for # each of these templates intelligently, making educated guesses on correct - # content type and sequence, and return a fully prepared Mail::Message + # content type and sequence, and return a fully prepared +Mail::Message+ # ready to call :deliver on to send. # # For example: @@ -828,8 +883,9 @@ def mail(headers = {}, &block) @_mail_was_called = true create_parts_from_responses(message, responses) + wrap_inline_attachments(message) - # Setup content type, reapply charset and handle parts order + # Set up content type, reapply charset and handle parts order message.content_type = set_content_type(message, content_type, headers[:content_type]) message.charset = charset @@ -842,7 +898,6 @@ def mail(headers = {}, &block) end private - # Used by #mail to set the content type of the message. # # It will use the given +user_content_type+, or multipart if the mail @@ -858,7 +913,7 @@ def set_content_type(m, user_content_type, class_default) # :doc: when user_content_type.present? user_content_type when m.has_attachments? - if m.attachments.detect(&:inline?) + if m.attachments.all?(&:inline?) ["multipart", "related", params] else ["multipart", "mixed", params] @@ -870,13 +925,13 @@ def set_content_type(m, user_content_type, class_default) # :doc: end end - # Translates the +subject+ using Rails I18n class under [mailer_scope, action_name] scope. + # Translates the +subject+ using \Rails I18n class under [mailer_scope, action_name] scope. # If it does not find a translation for the +subject+ under the specified scope it will default to a # humanized version of the action_name. # If the subject has interpolations, you can pass them through the +interpolations+ parameter. def default_i18n_subject(interpolations = {}) # :doc: mailer_scope = self.class.mailer_name.tr("/", ".") - I18n.t(:subject, interpolations.merge(scope: [mailer_scope, action_name], default: action_name.humanize)) + I18n.t(:subject, **interpolations, scope: [mailer_scope, action_name], default: action_name.humanize) end # Emails do not support relative path links. @@ -885,29 +940,34 @@ def self.supports_path? # :doc: end def apply_defaults(headers) - default_values = self.class.default.map do |key, value| - [ - key, - value.is_a?(Proc) ? instance_exec(&value) : value - ] - end.to_h + default_values = self.class.default.except(*headers.keys).transform_values do |value| + compute_default(value) + end headers_with_defaults = headers.reverse_merge(default_values) headers_with_defaults[:subject] ||= default_i18n_subject headers_with_defaults end + def compute_default(value) + return value unless value.is_a?(Proc) + + if value.arity == 1 + instance_exec(self, &value) + else + instance_exec(&value) + end + end + def assign_headers_to_message(message, headers) assignable = headers.except(:parts_order, :content_type, :body, :template_name, :template_path, :delivery_method, :delivery_method_options) assignable.each { |k, v| message[k] = v } end - def collect_responses(headers) + def collect_responses(headers, &block) if block_given? - collector = ActionMailer::Collector.new(lookup_context) { render(action_name) } - yield(collector) - collector.responses + collect_responses_from_block(headers, &block) elsif headers[:body] collect_responses_from_text(headers) else @@ -915,6 +975,13 @@ def collect_responses(headers) end end + def collect_responses_from_block(headers) + templates_name = headers[:template_name] || action_name + collector = ActionMailer::Collector.new(lookup_context) { render(templates_name) } + yield(collector) + collector.responses + end + def collect_responses_from_text(headers) [{ body: headers.delete(:body), @@ -927,10 +994,10 @@ def collect_responses_from_templates(headers) templates_name = headers[:template_name] || action_name each_template(Array(templates_path), templates_name).map do |template| - self.formats = template.formats + format = template.format || self.formats.first { - body: render(template: template), - content_type: template.type.to_s + body: render(template: template, formats: [format]), + content_type: Mime[format].to_s } end end @@ -940,7 +1007,28 @@ def each_template(paths, name, &block) if templates.empty? raise ActionView::MissingTemplate.new(paths, name, paths, false, "mailer") else - templates.uniq(&:formats).each(&block) + templates.uniq(&:format).each(&block) + end + end + + def wrap_inline_attachments(message) + # If we have both types of attachment, wrap all the inline attachments + # in multipart/related, but not the actual attachments + if message.attachments.detect(&:inline?) && message.attachments.detect { |a| !a.inline? } + related = Mail::Part.new + related.content_type = "multipart/related" + mixed = [ related ] + + message.parts.each do |p| + if p.attachment? && !p.inline? + mixed << p + else + related.add_part(p) + end + end + + message.parts.clear + mixed.each { |c| message.add_part(c) } end end @@ -972,7 +1060,11 @@ def instrument_payload(key) end def instrument_name - "action_mailer".freeze + "action_mailer" + end + + def _protected_ivars + PROTECTED_IVARS end ActiveSupport.run_load_hooks(:action_mailer, self) diff --git a/actionmailer/lib/action_mailer/callbacks.rb b/actionmailer/lib/action_mailer/callbacks.rb new file mode 100644 index 0000000000000..b92842b442460 --- /dev/null +++ b/actionmailer/lib/action_mailer/callbacks.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +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 + end + + module ClassMethods + # Defines a callback that will get called right before the + # message is sent to the delivery method. + def before_deliver(*filters, &blk) + set_callback(:deliver, :before, *filters, &blk) + end + + # Defines a callback that will get called right after the + # message's delivery method is finished. + def after_deliver(*filters, &blk) + set_callback(:deliver, :after, *filters, &blk) + end + + # Defines a callback that will get called around the message's deliver method. + 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/collector.rb b/actionmailer/lib/action_mailer/collector.rb index d97a73d65a34d..888410fa755ca 100644 --- a/actionmailer/lib/action_mailer/collector.rb +++ b/actionmailer/lib/action_mailer/collector.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_controller/collector" require "active_support/core_ext/hash/reverse_merge" require "active_support/core_ext/array/extract_options" diff --git a/actionmailer/lib/action_mailer/delivery_job.rb b/actionmailer/lib/action_mailer/delivery_job.rb deleted file mode 100644 index a617daa87e8e4..0000000000000 --- a/actionmailer/lib/action_mailer/delivery_job.rb +++ /dev/null @@ -1,34 +0,0 @@ -require "active_job" - -module ActionMailer - # The ActionMailer::DeliveryJob class is used when you - # want to send emails outside of the request-response cycle. - # - # Exceptions are rescued and handled by the mailer class. - class DeliveryJob < ActiveJob::Base # :nodoc: - queue_as { ActionMailer::Base.deliver_later_queue_name } - - rescue_from StandardError, with: :handle_exception_with_mailer_class - - def perform(mailer, mail_method, delivery_method, *args) #:nodoc: - mailer.constantize.public_send(mail_method, *args).send(delivery_method) - end - - private - # "Deserialize" the mailer class name by hand in case another argument - # (like a Global ID reference) raised DeserializationError. - def mailer_class - if mailer = Array(@serialized_arguments).first || Array(arguments).first - mailer.constantize - end - end - - def handle_exception_with_mailer_class(exception) - if klass = mailer_class - klass.handle_exception exception - else - raise exception - end - end - end -end diff --git a/actionmailer/lib/action_mailer/delivery_methods.rb b/actionmailer/lib/action_mailer/delivery_methods.rb index bcc4ef03cf86e..df6e90db7adcc 100644 --- a/actionmailer/lib/action_mailer/delivery_methods.rb +++ b/actionmailer/lib/action_mailer/delivery_methods.rb @@ -1,26 +1,22 @@ +# frozen_string_literal: true + require "tmpdir" module ActionMailer + # = Action Mailer \DeliveryMethods + # # This module handles everything related to mail delivery, from registering # new delivery methods to configuring the mail object to be sent. module DeliveryMethods extend ActiveSupport::Concern included do - class_attribute :delivery_methods, :delivery_method - # Do not make this inheritable, because we always want it to propagate - cattr_accessor :raise_delivery_errors - self.raise_delivery_errors = true - - cattr_accessor :perform_deliveries - self.perform_deliveries = true - - cattr_accessor :deliver_later_queue_name - self.deliver_later_queue_name = :mailers + cattr_accessor :raise_delivery_errors, default: true + cattr_accessor :perform_deliveries, default: true - self.delivery_methods = {}.freeze - self.delivery_method = :smtp + class_attribute :delivery_methods, default: {}.freeze + class_attribute :delivery_method, default: :smtp add_delivery_method :smtp, Mail::SMTP, address: "localhost", @@ -36,7 +32,7 @@ module DeliveryMethods add_delivery_method :sendmail, Mail::Sendmail, location: "/usr/sbin/sendmail", - arguments: "-i" + arguments: %w[-i] add_delivery_method :test, Mail::TestMailer end @@ -51,10 +47,10 @@ module ClassMethods # # add_delivery_method :sendmail, Mail::Sendmail, # location: '/usr/sbin/sendmail', - # arguments: '-i' + # arguments: %w[ -i ] def add_delivery_method(symbol, klass, default_options = {}) class_attribute(:"#{symbol}_settings") unless respond_to?(:"#{symbol}_settings") - send(:"#{symbol}_settings=", default_options) + public_send(:"#{symbol}_settings=", default_options) self.delivery_methods = delivery_methods.merge(symbol.to_sym => klass).freeze end diff --git a/actionmailer/lib/action_mailer/deprecator.rb b/actionmailer/lib/action_mailer/deprecator.rb new file mode 100644 index 0000000000000..26fde3a857ece --- /dev/null +++ b/actionmailer/lib/action_mailer/deprecator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActionMailer + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end diff --git a/actionmailer/lib/action_mailer/form_builder.rb b/actionmailer/lib/action_mailer/form_builder.rb new file mode 100644 index 0000000000000..048ea337822fc --- /dev/null +++ b/actionmailer/lib/action_mailer/form_builder.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module ActionMailer + # = Action Mailer Form Builder + # + # Override the default form builder for all views rendered by this + # mailer and any of its descendants. Accepts a subclass of + # ActionView::Helpers::FormBuilder. + # + # While emails typically will not include forms, this can be used + # by views that are shared between controllers and mailers. + # + # For more information, see +ActionController::FormBuilder+. + module FormBuilder + extend ActiveSupport::Concern + + included do + class_attribute :_default_form_builder, instance_accessor: false + end + + module ClassMethods + # Set the form builder to be used as the default for all forms + # in the views rendered by this mailer and its subclasses. + # + # ==== Parameters + # * builder - Default form builder. Accepts a subclass of ActionView::Helpers::FormBuilder + def default_form_builder(builder) + self._default_form_builder = builder + end + end + + # Default form builder for the mailer + def default_form_builder + self.class._default_form_builder + end + end +end diff --git a/actionmailer/lib/action_mailer/gem_version.rb b/actionmailer/lib/action_mailer/gem_version.rb index 7dafceef2bf27..0a366740204f0 100644 --- a/actionmailer/lib/action_mailer/gem_version.rb +++ b/actionmailer/lib/action_mailer/gem_version.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + module ActionMailer - # Returns the version of the currently loaded Action Mailer as a Gem::Version. + # Returns the currently loaded version of Action Mailer as a +Gem::Version+. def self.gem_version Gem::Version.new VERSION::STRING end module VERSION - MAJOR = 5 - MINOR = 1 + MAJOR = 8 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/actionmailer/lib/action_mailer/inline_preview_interceptor.rb b/actionmailer/lib/action_mailer/inline_preview_interceptor.rb index 980415afe0902..6b47f1b961137 100644 --- a/actionmailer/lib/action_mailer/inline_preview_interceptor.rb +++ b/actionmailer/lib/action_mailer/inline_preview_interceptor.rb @@ -1,8 +1,12 @@ +# frozen_string_literal: true + require "base64" module ActionMailer + # = Action Mailer \InlinePreviewInterceptor + # # Implements a mailer preview interceptor that converts image tag src attributes - # that use inline cid: style urls to data: style urls so that they are visible + # that use inline +cid:+ style URLs to +data:+ style URLs so that they are visible # when previewing an HTML email in a web browser. # # This interceptor is enabled by default. To disable it, delete it from the @@ -15,15 +19,15 @@ class InlinePreviewInterceptor include Base64 - def self.previewing_email(message) #:nodoc: + def self.previewing_email(message) # :nodoc: new(message).transform! end - def initialize(message) #:nodoc: + def initialize(message) # :nodoc: @message = message end - def transform! #:nodoc: + def transform! # :nodoc: return message if html_part.blank? html_part.body = html_part.decoded.gsub(PATTERN) do |match| @@ -38,9 +42,7 @@ def transform! #:nodoc: end private - def message - @message - end + attr_reader :message def html_part @html_part ||= message.html_part diff --git a/actionmailer/lib/action_mailer/log_subscriber.rb b/actionmailer/lib/action_mailer/log_subscriber.rb index 2c058ccf660fa..bc521a4bc43af 100644 --- a/actionmailer/lib/action_mailer/log_subscriber.rb +++ b/actionmailer/lib/action_mailer/log_subscriber.rb @@ -1,39 +1,44 @@ +# frozen_string_literal: true + require "active_support/log_subscriber" module ActionMailer - # 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 - recipients = Array(event.payload[:to]).join(", ") - "Sent mail to #{recipients} (#{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 #{payload[:message_id]} as `perform_deliveries` is false" + end end - debug { event.payload[:mail] } - end - - # An email was received. - def receive(event) - info { "Received mail (#{event.duration.round(1)}ms)" } - debug { event.payload[:mail] } + debug { payload[:mail] } end + 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 + 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/mail_delivery_job.rb b/actionmailer/lib/action_mailer/mail_delivery_job.rb new file mode 100644 index 0000000000000..d76a7cf1155c8 --- /dev/null +++ b/actionmailer/lib/action_mailer/mail_delivery_job.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "active_job" + +module ActionMailer + # = Action Mailer \MailDeliveryJob + # + # The +ActionMailer::MailDeliveryJob+ class is used when you + # want to send emails outside of the request-response cycle. It supports + # sending either parameterized or normal mail. + # + # Exceptions are rescued and handled by the mailer class. + class MailDeliveryJob < ActiveJob::Base # :nodoc: + queue_as do + mailer_class = arguments.first.constantize + mailer_class.deliver_later_queue_name + end + + rescue_from StandardError, with: :handle_exception_with_mailer_class + + def perform(mailer, mail_method, delivery_method, args:, kwargs: nil, params: nil) + mailer_class = params ? mailer.constantize.with(params) : mailer.constantize + message = if kwargs + mailer_class.public_send(mail_method, *args, **kwargs) + else + mailer_class.public_send(mail_method, *args) + end + message.send(delivery_method) + end + + private + # "Deserialize" the mailer class name by hand in case another argument + # (like a Global ID reference) raised DeserializationError. + def mailer_class + if mailer = Array(@serialized_arguments).first || Array(arguments).first + mailer.constantize + end + end + + def handle_exception_with_mailer_class(exception) + if klass = mailer_class + klass.handle_exception exception + else + raise exception + end + end + end +end diff --git a/actionmailer/lib/action_mailer/mail_helper.rb b/actionmailer/lib/action_mailer/mail_helper.rb index e04fc08866aa2..13c8571dc45f7 100644 --- a/actionmailer/lib/action_mailer/mail_helper.rb +++ b/actionmailer/lib/action_mailer/mail_helper.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + module ActionMailer + # = Action Mailer \MailHelper + # # Provides helper methods for ActionMailer::Base that can be used for easily # formatting messages, accessing mailer or message instances, and the # attachments list. @@ -21,10 +25,18 @@ def block_format(text) }.join("\n\n") # Make list points stand on their own line - formatted.gsub!(/[ ]*([*]+) ([^*]*)/) { " #{$1} #{$2.strip}\n" } - formatted.gsub!(/[ ]*([#]+) ([^#]*)/) { " #{$1} #{$2.strip}\n" } + output = +"" + splits = formatted.split(/(\*+|\#+)/) + while line = splits.shift + if line.start_with?("*", "#") && splits.first&.start_with?(" ") + output.chomp!(" ") while output.end_with?(" ") + output << " #{line} #{splits.shift.strip}\n" + else + output << line + end + end - formatted + output end # Access the mailer instance. diff --git a/actionmailer/lib/action_mailer/message_delivery.rb b/actionmailer/lib/action_mailer/message_delivery.rb index cf7c57e6bf042..4020807182086 100644 --- a/actionmailer/lib/action_mailer/message_delivery.rb +++ b/actionmailer/lib/action_mailer/message_delivery.rb @@ -1,11 +1,47 @@ +# frozen_string_literal: true + require "delegate" module ActionMailer - # The ActionMailer::MessageDelivery class is used by - # ActionMailer::Base when creating a new mailer. + 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 + # ActionMailer::Base when creating a new mailer. # MessageDelivery is a wrapper (+Delegator+ subclass) around a lazy - # created Mail::Message. You can get direct access to the - # Mail::Message, deliver the email or schedule the email to be sent + # created +Mail::Message+. You can get direct access to the + # +Mail::Message+, deliver the email or schedule the email to be sent # through Active Job. # # Notifier.welcome(User.first) # an ActionMailer::MessageDelivery object @@ -13,7 +49,9 @@ 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 - def initialize(mailer_class, action, *args) #:nodoc: + attr_reader :mailer_class, :action, :params, :args # :nodoc: + + def initialize(mailer_class, action, *args) # :nodoc: @mailer_class, @action, @args = mailer_class, action, args # The mail is only processed if we try to call any methods on it. @@ -21,14 +59,15 @@ def initialize(mailer_class, action, *args) #:nodoc: @processed_mailer = nil @mail_message = nil end + ruby2_keywords(:initialize) # Method calls are delegated to the Mail::Message that's ready to deliver. - def __getobj__ #:nodoc: + def __getobj__ # :nodoc: @mail_message ||= processed_mailer.message end - # Unused except for delegator internals (dup, marshaling). - def __setobj__(mail_message) #:nodoc: + # Unused except for delegator internals (dup, marshalling). + def __setobj__(mail_message) # :nodoc: @mail_message = mail_message end @@ -50,12 +89,23 @@ def processed? # Notifier.welcome(User.first).deliver_later! # Notifier.welcome(User.first).deliver_later!(wait: 1.hour) # Notifier.welcome(User.first).deliver_later!(wait_until: 10.hours.from_now) + # Notifier.welcome(User.first).deliver_later!(priority: 10) # # Options: # # * :wait - Enqueue the email to be delivered with a delay # * :wait_until - Enqueue the email to be delivered at (after) a specific date / time # * :queue - Enqueue the email on the specified queue + # * :priority - Enqueues the email with the specified priority + # + # By default, the email will be enqueued using ActionMailer::MailDeliveryJob on + # the default queue. Mailer classes can customize the queue name used for the default + # job by assigning a +deliver_later_queue_name+ class variable, or provide a custom job + # by assigning a +delivery_job+. When a custom job is used, it controls the queue name. + # + # class AccountRegistrationMailer < ApplicationMailer + # self.delivery_job = RegistrationDeliveryJob + # end def deliver_later!(options = {}) enqueue_delivery :deliver_now!, options end @@ -66,12 +116,23 @@ def deliver_later!(options = {}) # Notifier.welcome(User.first).deliver_later # Notifier.welcome(User.first).deliver_later(wait: 1.hour) # Notifier.welcome(User.first).deliver_later(wait_until: 10.hours.from_now) + # Notifier.welcome(User.first).deliver_later(priority: 10) # # Options: # # * :wait - Enqueue the email to be delivered with a delay. # * :wait_until - Enqueue the email to be delivered at (after) a specific date / time. # * :queue - Enqueue the email on the specified queue. + # * :priority - Enqueues the email with the specified priority + # + # By default, the email will be enqueued using ActionMailer::MailDeliveryJob on + # the default queue. Mailer classes can customize the queue name used for the default + # job by assigning a +deliver_later_queue_name+ class variable, or provide a custom job + # by assigning a +delivery_job+. When a custom job is used, it controls the queue name. + # + # class AccountRegistrationMailer < ApplicationMailer + # self.delivery_job = RegistrationDeliveryJob + # end def deliver_later(options = {}) enqueue_delivery :deliver_now, options end @@ -83,7 +144,9 @@ def deliver_later(options = {}) # def deliver_now! processed_mailer.handle_exceptions do - message.deliver! + processed_mailer.run_callbacks(:deliver) do + message.deliver! + end end end @@ -93,13 +156,15 @@ def deliver_now! # def deliver_now processed_mailer.handle_exceptions do - message.deliver + processed_mailer.run_callbacks(:deliver) do + message.deliver + end end end private # Returns the processed Mailer instance. We keep this instance - # on hand so we can delegate exception handling to it. + # on hand so we can run callbacks and delegate exception handling to it. def processed_mailer @processed_mailer ||= @mailer_class.new.tap do |mailer| mailer.process @action, *@args @@ -117,8 +182,8 @@ def enqueue_delivery(delivery_method, options = {}) "#deliver_later, 2. only touch the message *within your mailer " \ "method*, or 3. use a custom Active Job instead of #deliver_later." else - args = @mailer_class.name, @action.to_s, delivery_method.to_s, *@args - ::ActionMailer::DeliveryJob.set(options).perform_later(*args) + @mailer_class.delivery_job.set(options).perform_later( + @mailer_class.name, @action.to_s, delivery_method.to_s, args: @args) end end end diff --git a/actionmailer/lib/action_mailer/parameterized.rb b/actionmailer/lib/action_mailer/parameterized.rb index 3acacc1f14396..cbfba5f1deedc 100644 --- a/actionmailer/lib/action_mailer/parameterized.rb +++ b/actionmailer/lib/action_mailer/parameterized.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + module ActionMailer + # = Action Mailer \Parameterized + # # Provides the option to parameterize mailers in order to share instance variable # setup, processing, and common headers. # @@ -86,7 +90,11 @@ module Parameterized extend ActiveSupport::Concern included do - attr_accessor :params + attr_writer :params + + def params + @params ||= {} + end end module ClassMethods @@ -106,9 +114,9 @@ def initialize(mailer, params) end private - def method_missing(method_name, *args) - if @mailer.action_methods.include?(method_name.to_s) - ActionMailer::Parameterized::MessageDelivery.new(@mailer, method_name, @params, *args) + def method_missing(method_name, ...) + if @mailer.action_methods.include?(method_name.name) + ActionMailer::Parameterized::MessageDelivery.new(@mailer, method_name, @params, ...) else super end @@ -120,8 +128,8 @@ def respond_to_missing?(method, include_all = false) end class MessageDelivery < ActionMailer::MessageDelivery # :nodoc: - def initialize(mailer_class, action, params, *args) - super(mailer_class, action, *args) + def initialize(mailer_class, action, params, ...) + super(mailer_class, action, ...) @params = params end @@ -137,16 +145,10 @@ def enqueue_delivery(delivery_method, options = {}) if processed? super else - args = @mailer_class.name, @action.to_s, delivery_method.to_s, @params, *@args - ActionMailer::Parameterized::DeliveryJob.set(options).perform_later(*args) + @mailer_class.delivery_job.set(options).perform_later( + @mailer_class.name, @action.to_s, delivery_method.to_s, params: @params, args: @args) end end end - - class DeliveryJob < ActionMailer::DeliveryJob # :nodoc: - def perform(mailer, mail_method, delivery_method, params, *args) - mailer.constantize.with(params).public_send(mail_method, *args).send(delivery_method) - end - end end end diff --git a/actionmailer/lib/action_mailer/preview.rb b/actionmailer/lib/action_mailer/preview.rb index b0152aff0377e..814f256cef1d2 100644 --- a/actionmailer/lib/action_mailer/preview.rb +++ b/actionmailer/lib/action_mailer/preview.rb @@ -1,27 +1,28 @@ +# frozen_string_literal: true + require "active_support/descendants_tracker" module ActionMailer - module Previews #:nodoc: + module Previews # :nodoc: extend ActiveSupport::Concern included do - # Set the location of mailer previews through app configuration: + # Add the location of mailer previews through app configuration: # - # config.action_mailer.preview_path = "#{Rails.root}/lib/mailer_previews" + # config.action_mailer.preview_paths << "#{Rails.root}/lib/mailer_previews" # - mattr_accessor :preview_path, instance_writer: false + mattr_accessor :preview_paths, instance_writer: false, default: [] # Enable or disable mailer previews through app configuration: # # config.action_mailer.show_previews = true # - # Defaults to true for development environment + # Defaults to +true+ for development environment # mattr_accessor :show_previews, instance_writer: false # :nodoc: - mattr_accessor :preview_interceptors, instance_writer: false - self.preview_interceptors = [ActionMailer::InlinePreviewInterceptor] + mattr_accessor :preview_interceptors, instance_writer: false, default: [ActionMailer::InlinePreviewInterceptor] end module ClassMethods @@ -30,40 +31,62 @@ def register_preview_interceptors(*interceptors) interceptors.flatten.compact.each { |interceptor| register_preview_interceptor(interceptor) } end + # Unregister one or more previously registered Interceptors. + def unregister_preview_interceptors(*interceptors) + interceptors.flatten.compact.each { |interceptor| unregister_preview_interceptor(interceptor) } + end + # Register an Interceptor which will be called before mail is previewed. # Either a class or a string can be passed in as the Interceptor. If a - # string is passed in it will be constantized. + # string is passed in it will be constantized. def register_preview_interceptor(interceptor) - preview_interceptor = \ + preview_interceptor = interceptor_class_for(interceptor) + + unless preview_interceptors.include?(preview_interceptor) + preview_interceptors << preview_interceptor + end + end + + # Unregister a previously registered Interceptor. + # Either a class or a string can be passed in as the Interceptor. If a + # string is passed in it will be constantized. + def unregister_preview_interceptor(interceptor) + preview_interceptors.delete(interceptor_class_for(interceptor)) + end + + private + def interceptor_class_for(interceptor) case interceptor when String, Symbol interceptor.to_s.camelize.constantize else interceptor end - - unless preview_interceptors.include?(preview_interceptor) - preview_interceptors << preview_interceptor end - end end end class Preview extend ActiveSupport::DescendantsTracker + attr_reader :params + + def initialize(params = {}) + @params = params + end + class << self # Returns all mailer preview classes. def all load_previews if descendants.empty? - descendants + descendants.sort_by { |mailer| mailer.name.titleize } end # Returns the mail object for the given email name. The registered preview # interceptors will be informed so that they can transform the message # as they would if the mail was actually being delivered. - def call(email) - preview = new + def call(email, params = {}) + preview = new(params) message = preview.public_send(email) inform_preview_interceptors(message) message @@ -74,12 +97,12 @@ def emails public_instance_methods(false).map(&:to_s).sort end - # Returns true if the email exists. + # Returns +true+ if the email exists. def email_exists?(email) emails.include?(email) end - # Returns true if the preview exists. + # Returns +true+ if the preview exists. def exists?(preview) all.any? { |p| p.preview_name == preview } end @@ -91,18 +114,18 @@ def find(preview) # Returns the underscored name of the mailer preview without the suffix. def preview_name - name.sub(/Preview$/, "").underscore + name.delete_suffix("Preview").underscore end private def load_previews - if preview_path - Dir["#{preview_path}/**/*_preview.rb"].each { |file| require_dependency file } + preview_paths.each do |preview_path| + Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require file } end end - def preview_path - Base.preview_path + def preview_paths + Base.preview_paths end def show_previews diff --git a/actionmailer/lib/action_mailer/queued_delivery.rb b/actionmailer/lib/action_mailer/queued_delivery.rb new file mode 100644 index 0000000000000..1624b62aa373d --- /dev/null +++ b/actionmailer/lib/action_mailer/queued_delivery.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ActionMailer + module QueuedDelivery + extend ActiveSupport::Concern + + included do + class_attribute :delivery_job, default: ::ActionMailer::MailDeliveryJob + class_attribute :deliver_later_queue_name, default: :mailers + end + end +end diff --git a/actionmailer/lib/action_mailer/railtie.rb b/actionmailer/lib/action_mailer/railtie.rb index 913df8cf939cf..3c8d638b34c80 100644 --- a/actionmailer/lib/action_mailer/railtie.rb +++ b/actionmailer/lib/action_mailer/railtie.rb @@ -1,13 +1,20 @@ +# frozen_string_literal: true + +require "rails" require "active_job/railtie" require "action_mailer" -require "rails" require "abstract_controller/railties/routes_helpers" module ActionMailer class Railtie < Rails::Railtie # :nodoc: config.action_mailer = ActiveSupport::OrderedOptions.new + config.action_mailer.preview_paths = [] config.eager_load_namespaces << ActionMailer + initializer "action_mailer.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_mailer] = ActionMailer.deprecator + end + initializer "action_mailer.logger" do ActiveSupport.on_load(:action_mailer) { self.logger ||= Rails.logger } end @@ -16,20 +23,12 @@ class Railtie < Rails::Railtie # :nodoc: paths = app.config.paths options = app.config.action_mailer - if app.config.force_ssl - options.default_url_options ||= {} - options.default_url_options[:protocol] ||= "https" - end - options.assets_dir ||= paths["public"].first options.javascripts_dir ||= paths["public/javascripts"].first options.stylesheets_dir ||= paths["public/stylesheets"].first options.show_previews = Rails.env.development? if options.show_previews.nil? options.cache_store ||= Rails.cache - - if options.show_previews - options.preview_path ||= defined?(Rails.root) ? "#{Rails.root}/test/mailers/previews" : nil - end + options.preview_paths |= ["#{Rails.root}/test/mailers/previews"] # make sure readers methods get compiled options.asset_host ||= app.config.asset_host @@ -43,17 +42,35 @@ class Railtie < Rails::Railtie # :nodoc: register_interceptors(options.delete(:interceptors)) register_preview_interceptors(options.delete(:preview_interceptors)) register_observers(options.delete(:observers)) + self.preview_paths |= options[:preview_paths] + + if delivery_job = options.delete(:delivery_job) + self.delivery_job = delivery_job.constantize + end + + if options.smtp_settings + self.smtp_settings = options.smtp_settings + end + + smtp_timeout = options.delete(:smtp_timeout) + + if self.smtp_settings && smtp_timeout + self.smtp_settings[:open_timeout] ||= smtp_timeout + self.smtp_settings[:read_timeout] ||= smtp_timeout + end options.each { |k, v| send("#{k}=", v) } end - ActiveSupport.on_load(:action_dispatch_integration_test) { include ActionMailer::TestCase::ClearTestDeliveries } + ActiveSupport.on_load(:action_dispatch_integration_test) do + include ActionMailer::TestHelper + include ActionMailer::TestCase::ClearTestDeliveries + end end - initializer "action_mailer.compile_config_methods" do - ActiveSupport.on_load(:action_mailer) do - config.compile_methods! if config.respond_to?(:compile_methods!) - end + initializer "action_mailer.set_autoload_paths", before: :set_autoload_paths do |app| + options = app.config.action_mailer + app.config.paths["test/mailers/previews"].concat(options.preview_paths) end config.after_initialize do |app| @@ -61,12 +78,9 @@ class Railtie < Rails::Railtie # :nodoc: if options.show_previews app.routes.prepend do - get "/rails/mailers" => "rails/mailers#index", internal: true - get "/rails/mailers/*path" => "rails/mailers#preview", internal: true - end - - if options.preview_path - ActiveSupport::Dependencies.autoload_paths << options.preview_path + get "/rails/mailers" => "rails/mailers#index", internal: true + get "/rails/mailers/download/*path" => "rails/mailers#download", internal: true + get "/rails/mailers/*path" => "rails/mailers#preview", internal: true end end end diff --git a/actionmailer/lib/action_mailer/rescuable.rb b/actionmailer/lib/action_mailer/rescuable.rb index f2eabfa0574f8..2c881505ae27b 100644 --- a/actionmailer/lib/action_mailer/rescuable.rb +++ b/actionmailer/lib/action_mailer/rescuable.rb @@ -1,24 +1,30 @@ -module ActionMailer #:nodoc: - # Provides `rescue_from` for mailers. Wraps mailer action processing, - # mail job processing, and mail delivery. +# frozen_string_literal: true + +module ActionMailer # :nodoc: + # = Action Mailer \Rescuable + # + # Provides + # {rescue_from}[rdoc-ref:ActiveSupport::Rescuable::ClassMethods#rescue_from] + # for mailers. Wraps mailer action processing, mail job processing, and mail + # delivery to handle configured errors. module Rescuable extend ActiveSupport::Concern include ActiveSupport::Rescuable class_methods do - def handle_exception(exception) #:nodoc: + def handle_exception(exception) # :nodoc: rescue_with_handler(exception) || raise(exception) end end - def handle_exceptions #:nodoc: + def handle_exceptions # :nodoc: yield rescue => exception rescue_with_handler(exception) || raise end private - def process(*) + def process(...) handle_exceptions do super end 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 9ead03a40cc29..8ec38edef00a0 100644 --- a/actionmailer/lib/action_mailer/test_case.rb +++ b/actionmailer/lib/action_mailer/test_case.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/test_case" require "rails-dom-testing" @@ -20,7 +22,6 @@ module ClearTestDeliveries end private - def clear_test_deliveries if ActionMailer::Base.delivery_method == :test ActionMailer::Base.deliveries.clear @@ -37,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 @@ -73,8 +77,63 @@ def determine_default_mailer(name) end end - private + # Reads the fixture file for the given mailer. + # + # This is useful when testing mailers by being able to write the body of + # an email inside a fixture. See the testing guide for a concrete example: + # https://guides.rubyonrails.org/testing.html#revenge-of-the-fixtures + 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 @old_perform_deliveries = ActionMailer::Base.perform_deliveries @@ -111,8 +170,14 @@ def encode(subject) Mail::Encodings.q_value_encode(subject, charset) end - def read_fixture(action) - IO.readlines(File.join(Rails.root, "test", "fixtures", self.class.mailer_class.name.underscore, action)) + 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 diff --git a/actionmailer/lib/action_mailer/test_helper.rb b/actionmailer/lib/action_mailer/test_helper.rb index c30fb1fc18fb5..32fcb66873834 100644 --- a/actionmailer/lib/action_mailer/test_helper.rb +++ b/actionmailer/lib/action_mailer/test_helper.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +require "active_support/core_ext/array/extract_options" require "active_job" module ActionMailer @@ -26,15 +29,13 @@ module TestHelper # # assert_emails 2 do # ContactMailer.welcome.deliver_now - # ContactMailer.welcome.deliver_now + # ContactMailer.welcome.deliver_later # end # end - def assert_emails(number) + def assert_emails(number, &block) if block_given? - original_count = ActionMailer::Base.deliveries.size - yield - new_count = ActionMailer::Base.deliveries.size - assert_equal number, new_count - original_count, "#{number} emails expected, but #{new_count - original_count} were sent" + diff = capture_emails(&block).length + assert_equal number, diff, "#{number} emails expected, but #{diff} were sent" else assert_equal number, ActionMailer::Base.deliveries.size end @@ -58,7 +59,7 @@ def assert_emails(number) # # Note: This assertion is simply a shortcut for: # - # assert_emails 0 + # assert_emails 0, &block def assert_no_emails(&block) assert_emails 0, &block end @@ -88,7 +89,88 @@ def assert_no_emails(&block) # end # end def assert_enqueued_emails(number, &block) - assert_enqueued_jobs number, only: [ ActionMailer::DeliveryJob, ActionMailer::Parameterized::DeliveryJob ], &block + assert_enqueued_jobs(number, only: ->(job) { delivery_job_filter(job) }, &block) + end + + # Asserts that a specific email has been enqueued, optionally + # matching arguments and/or params. + # + # def test_email + # ContactMailer.welcome.deliver_later + # assert_enqueued_email_with ContactMailer, :welcome + # end + # + # def test_email_with_parameters + # ContactMailer.with(greeting: "Hello").welcome.deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, args: { greeting: "Hello" } + # end + # + # def test_email_with_arguments + # ContactMailer.welcome("Hello", "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, args: ["Hello", "Goodbye"] + # end + # + # def test_email_with_named_arguments + # ContactMailer.welcome(greeting: "Hello", farewell: "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, args: [{ greeting: "Hello", farewell: "Goodbye" }] + # end + # + # def test_email_with_parameters_and_arguments + # ContactMailer.with(greeting: "Hello").welcome("Cheers", "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, params: { greeting: "Hello" }, args: ["Cheers", "Goodbye"] + # end + # + # def test_email_with_parameters_and_named_arguments + # ContactMailer.with(greeting: "Hello").welcome(farewell: "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, params: { greeting: "Hello" }, args: [{farewell: "Goodbye"}] + # end + # + # def test_email_with_parameterized_mailer + # ContactMailer.with(greeting: "Hello").welcome.deliver_later + # assert_enqueued_email_with ContactMailer.with(greeting: "Hello"), :welcome + # end + # + # def test_email_with_matchers + # ContactMailer.with(greeting: "Hello").welcome("Cheers", "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, + # params: ->(params) { /hello/i.match?(params[:greeting]) }, + # args: ->(args) { /cheers/i.match?(args[0]) } + # end + # + # If a block is passed, that block should cause the specified email + # to be enqueued. + # + # def test_email_in_block + # assert_enqueued_email_with ContactMailer, :welcome do + # ContactMailer.welcome.deliver_later + # end + # end + # + # If +args+ is provided as a Hash, a parameterized email is matched. + # + # def test_parameterized_email + # assert_enqueued_email_with ContactMailer, :welcome, + # args: {email: 'user@example.com'} do + # ContactMailer.with(email: 'user@example.com').welcome.deliver_later + # end + # end + def assert_enqueued_email_with(mailer, method, params: nil, args: nil, queue: nil, &block) + if mailer.is_a? ActionMailer::Parameterized::Mailer + params = mailer.instance_variable_get(:@params) + mailer = mailer.instance_variable_get(:@mailer) + end + + args = Array(args) unless args.is_a?(Proc) + queue ||= mailer.deliver_later_queue_name || ActiveJob::Base.default_queue_name + + expected = ->(job_args) do + job_kwargs = job_args.extract_options! + + [mailer.to_s, method.to_s, "deliver_now"] == job_args && + params === job_kwargs[:params] && args === job_kwargs[:args] + end + + assert_enqueued_with(job: mailer.delivery_job, args: expected, queue: queue.to_s, &block) end # Asserts that no emails are enqueued for later delivery. @@ -107,7 +189,76 @@ def assert_enqueued_emails(number, &block) # end # end def assert_no_enqueued_emails(&block) - assert_no_enqueued_jobs only: [ ActionMailer::DeliveryJob, ActionMailer::Parameterized::DeliveryJob ], &block + assert_enqueued_emails 0, &block + end + + # Delivers all enqueued emails. If a block is given, delivers all of the emails + # that were enqueued throughout the duration of the block. If a block is + # not given, delivers all the enqueued emails up to this point in the test. + # + # def test_deliver_enqueued_emails + # deliver_enqueued_emails do + # ContactMailer.welcome.deliver_later + # end + # + # assert_emails 1 + # end + # + # def test_deliver_enqueued_emails_without_block + # ContactMailer.welcome.deliver_later + # + # deliver_enqueued_emails + # + # assert_emails 1 + # end + # + # If the +:queue+ option is specified, + # then only the emails(s) enqueued to a specific queue will be performed. + # + # def test_deliver_enqueued_emails_with_queue + # deliver_enqueued_emails queue: :external_mailers do + # CustomerMailer.deliver_later_queue_name = :external_mailers + # CustomerMailer.welcome.deliver_later # will be performed + # EmployeeMailer.deliver_later_queue_name = :internal_mailers + # EmployeeMailer.welcome.deliver_later # will not be performed + # end + # + # assert_emails 1 + # end + # + # If the +:at+ option is specified, then only delivers emails enqueued to deliver + # immediately or before the given time. + def deliver_enqueued_emails(queue: nil, at: nil, &block) + perform_enqueued_jobs(only: ->(job) { delivery_job_filter(job) }, queue: queue, at: at, &block) + end + + # Returns any emails that are sent in the block. + # + # def test_emails + # emails = capture_emails do + # ContactMailer.welcome.deliver_now + # end + # assert_equal "Hi there", emails.first.subject + # + # emails = capture_emails do + # ContactMailer.welcome.deliver_now + # ContactMailer.welcome.deliver_later + # end + # assert_equal "Hi there", emails.first.subject + # end + def capture_emails(&block) + original_count = ActionMailer::Base.deliveries.size + deliver_enqueued_emails(&block) + new_count = ActionMailer::Base.deliveries.size + diff = new_count - original_count + ActionMailer::Base.deliveries.last(diff) end + + private + def delivery_job_filter(job) + job_class = job.is_a?(Hash) ? job.fetch(:job) : job.class + + Base.descendants.map(&:delivery_job).include?(job_class) + end end end diff --git a/actionmailer/lib/action_mailer/version.rb b/actionmailer/lib/action_mailer/version.rb index 8452d6370e5b4..9be6e66e07bb0 100644 --- a/actionmailer/lib/action_mailer/version.rb +++ b/actionmailer/lib/action_mailer/version.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require_relative "gem_version" module ActionMailer - # Returns the version of the currently loaded Action Mailer as a - # Gem::Version. + # Returns the currently loaded version of Action Mailer as a + # +Gem::Version+. def self.version gem_version end diff --git a/actionmailer/lib/rails/generators/mailer/USAGE b/actionmailer/lib/rails/generators/mailer/USAGE index 2b0a078109e95..6768e5c2a2ac2 100644 --- a/actionmailer/lib/rails/generators/mailer/USAGE +++ b/actionmailer/lib/rails/generators/mailer/USAGE @@ -1,17 +1,20 @@ Description: -============ - Stubs out a new mailer and its views. Passes the mailer name, either + Generates a new mailer and its views. Passes the mailer name, either CamelCased or under_scored, and an optional list of emails as arguments. This generates a mailer class in app/mailers and invokes your template engine and test framework generators. -Example: -======== - rails generate mailer Notifications signup forgot_password invoice +Examples: + `bin/rails generate mailer sign_up` + + creates a sign up mailer class, views, and test: + Mailer: app/mailers/sign_up_mailer.rb + Views: app/views/sign_up_mailer/signup.text.erb [...] + Test: test/mailers/sign_up_mailer_test.rb + + `bin/rails generate mailer notifications sign_up forgot_password invoice` + + creates a notifications mailer with sign_up, forgot_password, and invoice actions. - creates a Notifications mailer class, views, and test: - Mailer: app/mailers/notifications_mailer.rb - Views: app/views/notifications_mailer/signup.text.erb [...] - Test: test/mailers/notifications_mailer_test.rb diff --git a/actionmailer/lib/rails/generators/mailer/mailer_generator.rb b/actionmailer/lib/rails/generators/mailer/mailer_generator.rb index 99fe4544f180b..c37a74c76232a 100644 --- a/actionmailer/lib/rails/generators/mailer/mailer_generator.rb +++ b/actionmailer/lib/rails/generators/mailer/mailer_generator.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + module Rails module Generators class MailerGenerator < NamedBase - source_root File.expand_path("../templates", __FILE__) + source_root File.expand_path("templates", __dir__) argument :actions, type: :array, default: [], banner: "method method" @@ -21,7 +23,7 @@ def create_mailer_file private def file_name # :doc: - @_file_name ||= super.gsub(/_mailer/i, "") + @_file_name ||= super.sub(/_mailer\z/i, "") end def application_mailer_file_name diff --git a/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb b/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb.tt similarity index 100% rename from actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb rename to actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb.tt diff --git a/actionmailer/lib/rails/generators/mailer/templates/mailer.rb b/actionmailer/lib/rails/generators/mailer/templates/mailer.rb deleted file mode 100644 index 348d3147586be..0000000000000 --- a/actionmailer/lib/rails/generators/mailer/templates/mailer.rb +++ /dev/null @@ -1,17 +0,0 @@ -<% module_namespacing do -%> -class <%= class_name %>Mailer < ApplicationMailer -<% actions.each do |action| -%> - - # Subject can be set in your I18n file at config/locales/en.yml - # with the following lookup: - # - # en.<%= file_path.tr("/",".") %>_mailer.<%= action %>.subject - # - def <%= action %> - @greeting = "Hi" - - mail to: "to@example.org" - end -<% end -%> -end -<% end -%> diff --git a/actionmailer/lib/rails/generators/mailer/templates/mailer.rb.tt b/actionmailer/lib/rails/generators/mailer/templates/mailer.rb.tt new file mode 100644 index 0000000000000..333a9f59b8144 --- /dev/null +++ b/actionmailer/lib/rails/generators/mailer/templates/mailer.rb.tt @@ -0,0 +1,19 @@ +<% module_namespacing do -%> +class <%= class_name %>Mailer < ApplicationMailer +<% actions.each_with_index do |action, index| -%> +<% if index != 0 -%> + +<% end -%> + # Subject can be set in your I18n file at config/locales/en.yml + # with the following lookup: + # + # en.<%= file_path.tr("/",".") %>_mailer.<%= action %>.subject + # + def <%= action %> + @greeting = "Hi" + + mail to: "to@example.org" + end +<% end -%> +end +<% end -%> diff --git a/actionmailer/test/abstract_unit.rb b/actionmailer/test/abstract_unit.rb index a646cbd581c08..d795e466c3003 100644 --- a/actionmailer/test/abstract_unit.rb +++ b/actionmailer/test/abstract_unit.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +require_relative "../../tools/strict_warnings" require "active_support/core_ext/kernel/reporting" # These are the normal settings that will be set up by Railties @@ -9,7 +12,7 @@ module Rails def self.root - File.expand_path("../", File.dirname(__FILE__)) + File.expand_path("..", __dir__) end end @@ -23,23 +26,18 @@ def self.root ActionMailer::Base.include(ActionView::Layouts) # Show backtraces for deprecated behavior for quicker cleanup. -ActiveSupport::Deprecation.debug = true +ActionMailer.deprecator.debug = true # Disable available locale checks to avoid warnings running the test suite. I18n.enforce_available_locales = false -FIXTURE_LOAD_PATH = File.expand_path("fixtures", File.dirname(__FILE__)) +FIXTURE_LOAD_PATH = File.expand_path("fixtures", __dir__) ActionMailer::Base.view_paths = FIXTURE_LOAD_PATH +ActionMailer::Base.delivery_job = ActionMailer::MailDeliveryJob + class ActiveSupport::TestCase include ActiveSupport::Testing::MethodCallAssertions - - # Skips the current run on Rubinius using Minitest::Assertions#skip - private def rubinius_skip(message = "") - skip message if RUBY_ENGINE == "rbx" - end - # Skips the current run on JRuby using Minitest::Assertions#skip - private def jruby_skip(message = "") - skip message if defined?(JRUBY_VERSION) - end end + +require_relative "../../tools/test_common" diff --git a/actionmailer/test/assert_select_email_test.rb b/actionmailer/test/assert_select_email_test.rb index bf14fe0853034..5052f64d13016 100644 --- a/actionmailer/test/assert_select_email_test.rb +++ b/actionmailer/test/assert_select_email_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class AssertSelectEmailTest < ActionMailer::TestCase @@ -8,25 +10,75 @@ 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 # def test_assert_select_email assert_raise ActiveSupport::TestCase::Assertion do - 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" @@ -44,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/asset_host_test.rb b/actionmailer/test/asset_host_test.rb index 812df01a34dba..7e0d7e7cc599b 100644 --- a/actionmailer/test/asset_host_test.rb +++ b/actionmailer/test/asset_host_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller" @@ -22,16 +24,16 @@ def teardown def test_asset_host_as_string mail = AssetHostMailer.email_with_asset - assert_dom_equal 'Somelogo', mail.body.to_s.strip + assert_dom_equal '', mail.body.to_s.strip end def test_asset_host_as_one_argument_proc AssetHostMailer.config.asset_host = Proc.new { |source| - if source.starts_with?("/images") + if source.start_with?("/images") "http://images.example.com" end } mail = AssetHostMailer.email_with_asset - assert_dom_equal 'Somelogo', mail.body.to_s.strip + assert_dom_equal '', mail.body.to_s.strip end end diff --git a/actionmailer/test/base_test.rb b/actionmailer/test/base_test.rb index 61960d411ddad..74e33709ff608 100644 --- a/actionmailer/test/base_test.rb +++ b/actionmailer/test/base_test.rb @@ -1,5 +1,6 @@ +# frozen_string_literal: true + require "abstract_unit" -require "set" require "action_dispatch" require "active_support/time" @@ -34,6 +35,7 @@ class BaseTest < ActiveSupport::TestCase email = BaseMailer.welcome assert_equal(["system@test.lindsaar.net"], email.to) assert_equal(["jose@test.plataformatec.com"], email.from) + assert_equal(["mikel@test.lindsaar.net"], email.reply_to) assert_equal("The first email on new API!", email.subject) end @@ -67,6 +69,13 @@ class BaseTest < ActiveSupport::TestCase assert_equal("Welcome", email.body.encoded) end + test "mail() doesn't set the mailer as a controller in the execution context" do + ActiveSupport::ExecutionContext.clear + assert_nil ActiveSupport::ExecutionContext.to_h[:controller] + BaseMailer.welcome(from: "someone@example.com", to: "another@example.org").to + assert_nil ActiveSupport::ExecutionContext.to_h[:controller] + end + test "can pass in :body to the mail method hash" do email = BaseMailer.welcome(body: "Hello there") assert_equal("text/plain", email.mime_type) @@ -80,6 +89,17 @@ class BaseTest < ActiveSupport::TestCase assert_equal("text/plain", mail.mime_type) end + test "mail() using email_address_with_name" do + email = BaseMailer.with_name + assert_equal("Sunny ", email["To"].value) + assert_equal("Mikel ", email["Reply-To"].value) + end + + test "mail() using email_address_with_name with blank string as name" do + email = BaseMailer.with_blank_name + assert_equal("sunny@example.com", email["To"].value) + end + # Custom headers test "custom headers" do email = BaseMailer.welcome @@ -88,18 +108,18 @@ class BaseTest < ActiveSupport::TestCase test "can pass random headers in as a hash to mail" do hash = { "X-Special-Domain-Specific-Header" => "SecretValue", - "In-Reply-To" => "1234@mikel.me.com" } + "In-Reply-To" => "<1234@mikel.me.com>" } mail = BaseMailer.welcome(hash) assert_equal("SecretValue", mail["X-Special-Domain-Specific-Header"].decoded) - assert_equal("1234@mikel.me.com", mail["In-Reply-To"].decoded) + assert_equal("<1234@mikel.me.com>", mail["In-Reply-To"].decoded) end test "can pass random headers in as a hash to headers" do hash = { "X-Special-Domain-Specific-Header" => "SecretValue", - "In-Reply-To" => "1234@mikel.me.com" } + "In-Reply-To" => "<1234@mikel.me.com>" } mail = BaseMailer.welcome_with_headers(hash) assert_equal("SecretValue", mail["X-Special-Domain-Specific-Header"].decoded) - assert_equal("1234@mikel.me.com", mail["In-Reply-To"].decoded) + assert_equal("<1234@mikel.me.com>", mail["In-Reply-To"].decoded) end # Attachments @@ -120,7 +140,7 @@ class BaseTest < ActiveSupport::TestCase email = BaseMailer.attachment_with_hash assert_equal(1, email.attachments.length) assert_equal("invoice.jpg", email.attachments[0].filename) - expected = "\312\213\254\232)b" + expected = +"\312\213\254\232)b" expected.force_encoding(Encoding::BINARY) assert_equal expected, email.attachments["invoice.jpg"].decoded end @@ -129,7 +149,7 @@ class BaseTest < ActiveSupport::TestCase email = BaseMailer.attachment_with_hash_default_encoding assert_equal(1, email.attachments.length) assert_equal("invoice.jpg", email.attachments[0].filename) - expected = "\312\213\254\232)b" + expected = +"\312\213\254\232)b" expected.force_encoding(Encoding::BINARY) assert_equal expected, email.attachments["invoice.jpg"].decoded end @@ -150,9 +170,9 @@ class BaseTest < ActiveSupport::TestCase assert_equal(2, email.parts.length) assert_equal("multipart/mixed", email.mime_type) assert_equal("text/html", email.parts[0].mime_type) - assert_equal("Attachment with content", email.parts[0].body.encoded) + assert_equal("Attachment with content", email.parts[0].decoded) assert_equal("application/pdf", email.parts[1].mime_type) - assert_equal("VGhpcyBpcyB0ZXN0IEZpbGUgY29udGVudA==\r\n", email.parts[1].body.encoded) + assert_equal("This is test File content", email.parts[1].decoded) end test "adds the given :body as part" do @@ -160,9 +180,9 @@ class BaseTest < ActiveSupport::TestCase assert_equal(2, email.parts.length) assert_equal("multipart/mixed", email.mime_type) assert_equal("text/plain", email.parts[0].mime_type) - assert_equal("I'm the eggman", email.parts[0].body.encoded) + assert_equal("I'm the eggman", email.parts[0].decoded) assert_equal("application/pdf", email.parts[1].mime_type) - assert_equal("VGhpcyBpcyB0ZXN0IEZpbGUgY29udGVudA==\r\n", email.parts[1].body.encoded) + assert_equal("This is test File content", email.parts[1].decoded) end test "can embed an inline attachment" do @@ -177,7 +197,21 @@ class BaseTest < ActiveSupport::TestCase assert_equal("logo.png", email.parts[1].filename) end - # Defaults values + test "can embed an inline attachment and other attachments" do + email = BaseMailer.inline_and_other_attachments + # Need to call #encoded to force the JIT sort on parts + email.encoded + assert_equal(2, email.parts.length) + assert_equal("multipart/mixed", email.mime_type) + assert_equal("multipart/related", email.parts[0].mime_type) + assert_equal("multipart/alternative", email.parts[0].parts[0].mime_type) + assert_equal("text/plain", email.parts[0].parts[0].parts[0].mime_type) + assert_equal("text/html", email.parts[0].parts[0].parts[1].mime_type) + assert_equal("logo.png", email.parts[0].parts[1].filename) + assert_equal("certificate.pdf", email.parts[1].filename) + end + + # Default values test "uses default charset from class" do with_default BaseMailer, charset: "US-ASCII" do email = BaseMailer.welcome @@ -265,6 +299,17 @@ def welcome assert_match(/Can't add attachments after `mail` was called./, e.message) end + test "accessing inline attachments after mail was called works" do + class LateInlineAttachmentAccessorMailer < ActionMailer::Base + def welcome + mail body: "yay", from: "welcome@example.com", to: "to@example.com" + attachments.inline["invoice.pdf"] + end + end + + assert_nothing_raised { LateInlineAttachmentAccessorMailer.welcome.message } + end + test "adding inline attachments while rendering mail works" do class LateInlineAttachmentMailer < ActionMailer::Base def on_render @@ -286,7 +331,7 @@ def welcome mail body: "yay", from: "welcome@example.com", to: "to@example.com" unless attachments.map(&:filename) == ["invoice.pdf"] - raise Minitest::Assertion, "Should allow access to attachments" + flunk("Should allow access to attachments") end end end @@ -305,6 +350,16 @@ def welcome assert_equal("HTML Implicit Multipart", email.parts[1].body.encoded) end + test "implicit multipart formats" do + email = BaseMailer.implicit_multipart_formats + assert_equal(2, email.parts.size) + assert_equal("multipart/alternative", email.mime_type) + assert_equal("text/plain", email.parts[0].mime_type) + assert_equal("Implicit Multipart [:text]", email.parts[0].body.encoded) + assert_equal("text/html", email.parts[1].mime_type) + assert_equal("Implicit Multipart [:html]", email.parts[1].body.encoded) + end + test "implicit multipart with sort order" do order = ["text/html", "text/plain"] with_default BaseMailer, parts_order: order do @@ -320,22 +375,21 @@ def welcome test "implicit multipart with attachments creates nested parts" do email = BaseMailer.implicit_multipart(attachments: true) - assert_equal("application/pdf", email.parts[0].mime_type) - assert_equal("multipart/alternative", email.parts[1].mime_type) - assert_equal("text/plain", email.parts[1].parts[0].mime_type) - assert_equal("TEXT Implicit Multipart", email.parts[1].parts[0].body.encoded) - assert_equal("text/html", email.parts[1].parts[1].mime_type) - assert_equal("HTML Implicit Multipart", email.parts[1].parts[1].body.encoded) + assert_equal(%w[ application/pdf multipart/alternative ], email.parts.map(&:mime_type).sort) + multipart = email.parts.detect { |p| p.mime_type == "multipart/alternative" } + assert_equal("text/plain", multipart.parts[0].mime_type) + assert_equal("TEXT Implicit Multipart", multipart.parts[0].body.encoded) + assert_equal("text/html", multipart.parts[1].mime_type) + assert_equal("HTML Implicit Multipart", multipart.parts[1].body.encoded) end test "implicit multipart with attachments and sort order" do order = ["text/html", "text/plain"] with_default BaseMailer, parts_order: order do email = BaseMailer.implicit_multipart(attachments: true) - assert_equal("application/pdf", email.parts[0].mime_type) - assert_equal("multipart/alternative", email.parts[1].mime_type) - assert_equal("text/plain", email.parts[1].parts[1].mime_type) - assert_equal("text/html", email.parts[1].parts[0].mime_type) + assert_equal(%w[ application/pdf multipart/alternative ], email.parts.map(&:mime_type).sort) + multipart = email.parts.detect { |p| p.mime_type == "multipart/alternative" } + assert_equal(%w[ text/html text/plain ], multipart.parts.map(&:mime_type).sort) end end @@ -425,12 +479,12 @@ def welcome test "explicit multipart with attachments creates nested parts" do email = BaseMailer.explicit_multipart(attachments: true) - assert_equal("application/pdf", email.parts[0].mime_type) - assert_equal("multipart/alternative", email.parts[1].mime_type) - assert_equal("text/plain", email.parts[1].parts[0].mime_type) - assert_equal("TEXT Explicit Multipart", email.parts[1].parts[0].body.encoded) - assert_equal("text/html", email.parts[1].parts[1].mime_type) - assert_equal("HTML Explicit Multipart", email.parts[1].parts[1].body.encoded) + assert_equal(%w[ application/pdf multipart/alternative ], email.parts.map(&:mime_type).sort) + multipart = email.parts.detect { |p| p.mime_type == "multipart/alternative" } + assert_equal("text/plain", multipart.parts[0].mime_type) + assert_equal("TEXT Explicit Multipart", multipart.parts[0].body.encoded) + assert_equal("text/html", multipart.parts[1].mime_type) + assert_equal("HTML Explicit Multipart", multipart.parts[1].body.encoded) end test "explicit multipart with templates" do @@ -505,8 +559,8 @@ def welcome test "should respond to action methods" do assert_respond_to BaseMailer, :welcome assert_respond_to BaseMailer, :implicit_multipart - assert !BaseMailer.respond_to?(:mail) - assert !BaseMailer.respond_to?(:headers) + assert_not_respond_to BaseMailer, :mail + assert_not_respond_to BaseMailer, :headers end test "calling just the action should return the generated mail object" do @@ -543,6 +597,12 @@ def welcome assert_equal("TEXT Implicit Multipart", mail.text_part.body.decoded) end + test "you can specify a different template for multipart render" do + mail = BaseMailer.implicit_different_template_with_block("explicit_multipart_templates").deliver + assert_equal("HTML Explicit Multipart Templates", mail.html_part.body.decoded) + assert_equal("TEXT Explicit Multipart Templates", mail.text_part.body.decoded) + end + test "should raise if missing template in implicit render" do assert_raises ActionView::MissingTemplate do BaseMailer.implicit_different_template("missing_template").deliver_now @@ -576,7 +636,7 @@ def welcome mail = AssetMailer.welcome - assert_dom_equal(%{Dummy}, mail.body.to_s.strip) + assert_dom_equal(%{}, mail.body.to_s.strip) end test "assets tags should use a Mailer's asset_host settings when available" do @@ -590,7 +650,7 @@ def welcome mail = TempAssetMailer.welcome - assert_dom_equal(%{Dummy}, mail.body.to_s.strip) + assert_dom_equal(%{}, mail.body.to_s.strip) end test "the view is not rendered when mail was never called" do @@ -617,37 +677,52 @@ def self.delivered_email(mail) end end - test "you can register an observer to the mail object that gets informed on email delivery" do + test "you can register and unregister an observer to the mail object that gets informed on email delivery" do mail_side_effects do ActionMailer::Base.register_observer(MyObserver) mail = BaseMailer.welcome assert_called_with(MyObserver, :delivered_email, [mail]) do mail.deliver_now end + + ActionMailer::Base.unregister_observer(MyObserver) + assert_not_called(MyObserver, :delivered_email, returns: mail) do + mail.deliver_now + end end end - test "you can register an observer using its stringified name to the mail object that gets informed on email delivery" do + test "you can register and unregister an observer using its stringified name to the mail object that gets informed on email delivery" do mail_side_effects do ActionMailer::Base.register_observer("BaseTest::MyObserver") mail = BaseMailer.welcome assert_called_with(MyObserver, :delivered_email, [mail]) do mail.deliver_now end + + ActionMailer::Base.unregister_observer("BaseTest::MyObserver") + assert_not_called(MyObserver, :delivered_email, returns: mail) do + mail.deliver_now + end end end - test "you can register an observer using its symbolized underscored name to the mail object that gets informed on email delivery" do + test "you can register and unregister an observer using its symbolized underscored name to the mail object that gets informed on email delivery" do mail_side_effects do ActionMailer::Base.register_observer(:"base_test/my_observer") mail = BaseMailer.welcome assert_called_with(MyObserver, :delivered_email, [mail]) do mail.deliver_now end + + ActionMailer::Base.unregister_observer(:"base_test/my_observer") + assert_not_called(MyObserver, :delivered_email, returns: mail) do + mail.deliver_now + end end end - test "you can register multiple observers to the mail object that both get informed on email delivery" do + test "you can register and unregister multiple observers to the mail object that both get informed on email delivery" do mail_side_effects do ActionMailer::Base.register_observers("BaseTest::MyObserver", MySecondObserver) mail = BaseMailer.welcome @@ -656,6 +731,14 @@ def self.delivered_email(mail) mail.deliver_now end end + + ActionMailer::Base.unregister_observers("BaseTest::MyObserver", MySecondObserver) + assert_not_called(MyObserver, :delivered_email, returns: mail) do + mail.deliver_now + end + assert_not_called(MySecondObserver, :delivered_email, returns: mail) do + mail.deliver_now + end end end @@ -669,37 +752,52 @@ def self.delivering_email(mail); end def self.previewing_email(mail); end end - test "you can register an interceptor to the mail object that gets passed the mail object before delivery" do + test "you can register and unregister an interceptor to the mail object that gets passed the mail object before delivery" do mail_side_effects do ActionMailer::Base.register_interceptor(MyInterceptor) mail = BaseMailer.welcome assert_called_with(MyInterceptor, :delivering_email, [mail]) do mail.deliver_now end + + ActionMailer::Base.unregister_interceptor(MyInterceptor) + assert_not_called(MyInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end end end - test "you can register an interceptor using its stringified name to the mail object that gets passed the mail object before delivery" do + test "you can register and unregister an interceptor using its stringified name to the mail object that gets passed the mail object before delivery" do mail_side_effects do ActionMailer::Base.register_interceptor("BaseTest::MyInterceptor") mail = BaseMailer.welcome assert_called_with(MyInterceptor, :delivering_email, [mail]) do mail.deliver_now end + + ActionMailer::Base.unregister_interceptor("BaseTest::MyInterceptor") + assert_not_called(MyInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end end end - test "you can register an interceptor using its symbolized underscored name to the mail object that gets passed the mail object before delivery" do + test "you can register and unregister an interceptor using its symbolized underscored name to the mail object that gets passed the mail object before delivery" do mail_side_effects do ActionMailer::Base.register_interceptor(:"base_test/my_interceptor") mail = BaseMailer.welcome assert_called_with(MyInterceptor, :delivering_email, [mail]) do mail.deliver_now end + + ActionMailer::Base.unregister_interceptor(:"base_test/my_interceptor") + assert_not_called(MyInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end end end - test "you can register multiple interceptors to the mail object that both get passed the mail object before delivery" do + test "you can register and unregister multiple interceptors to the mail object that both get passed the mail object before delivery" do mail_side_effects do ActionMailer::Base.register_interceptors("BaseTest::MyInterceptor", MySecondInterceptor) mail = BaseMailer.welcome @@ -708,6 +806,14 @@ def self.previewing_email(mail); end mail.deliver_now end end + + ActionMailer::Base.unregister_interceptors("BaseTest::MyInterceptor", MySecondInterceptor) + assert_not_called(MyInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end + assert_not_called(MySecondInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end end end @@ -724,11 +830,28 @@ def self.previewing_email(mail); end assert(ProcMailer.welcome["x-has-to-proc"].to_s == "symbol") end + test "proc default values can have arity of 1 where arg is a mailer instance" do + assert_equal("complex_value", ProcMailer.welcome["X-Lambda-Arity-1-arg"].to_s) + assert_equal("complex_value", ProcMailer.welcome["X-Lambda-Arity-1-self"].to_s) + end + + test "proc default values with fixed arity of 0 can be called" do + assert_equal("0", ProcMailer.welcome["X-Lambda-Arity-0"].to_s) + end + test "we can call other defined methods on the class as needed" do mail = ProcMailer.welcome assert_equal("Thanks for signing up this afternoon", mail.subject) end + test "proc default values are not evaluated when overridden" do + with_default BaseMailer, from: -> { flunk }, to: -> { flunk } do + email = BaseMailer.welcome(from: "overridden-from@example.com", to: "overridden-to@example.com") + assert_equal ["overridden-from@example.com"], email.from + assert_equal ["overridden-to@example.com"], email.to + end + end + test "modifying the mail message with a before_action" do class BeforeActionMailer < ActionMailer::Base before_action :add_special_header! @@ -780,6 +903,8 @@ class FooMailer < ActionMailer::Base # This triggers action_methods. respond_to?(:foo) + after_deliver :foo + def notify end end @@ -800,6 +925,11 @@ def welcome assert_equal "Anonymous mailer body", mailer.welcome.body.encoded.strip end + test "email_address_with_name escapes" do + address = BaseMailer.email_address_with_name("test@example.org", 'I "<3" email') + assert_equal '"I \"<3\" email" ', address + end + test "default_from can be set" do class DefaultFromMailer < ActionMailer::Base default to: "system@test.lindsaar.net" @@ -835,38 +965,38 @@ def a_callback end test "notification for process" do - begin - events = [] - ActiveSupport::Notifications.subscribe("process.action_mailer") do |*args| - events << ActiveSupport::Notifications::Event.new(*args) + expected_payload = { mailer: "BaseMailer", action: :welcome, args: [{ body: "Hello there" }] } + + assert_notifications_count("process.action_mailer", 1) do + assert_notification("process.action_mailer", expected_payload) do + BaseMailer.welcome(body: "Hello there").deliver_now end + end + end - BaseMailer.welcome(body: "Hello there").deliver_now + test "notification for deliver" do + assert_notifications_count("deliver.action_mailer", 1) do + notification = assert_notification("deliver.action_mailer") do + BaseMailer.welcome(body: "Hello there").deliver_now + end - assert_equal 1, events.length - assert_equal "process.action_mailer", events[0].name - assert_equal "BaseMailer", events[0].payload[:mailer] - assert_equal :welcome, events[0].payload[:action] - assert_equal [{ body: "Hello there" }], events[0].payload[:args] - ensure - ActiveSupport::Notifications.unsubscribe "process.action_mailer" + assert_not_nil notification.payload[:message_id] end end private - # Execute the block setting the given values and restoring old values after # the block is executed. def swap(klass, new_values) old_values = {} new_values.each do |key, value| - old_values[key] = klass.send key - klass.send :"#{key}=", value + old_values[key] = klass.public_send key + klass.public_send :"#{key}=", value end yield ensure old_values.each do |key, value| - klass.send :"#{key}=", value + klass.public_send :"#{key}=", value end end @@ -878,8 +1008,6 @@ def with_default(klass, new_values) klass.default_params = old end - # A simple hack to restore the observers and interceptors for Mail, as it - # does not have an unregister API yet. def mail_side_effects old_observers = Mail.class_variable_get(:@@delivery_notification_observers) old_delivery_interceptors = Mail.class_variable_get(:@@delivery_interceptors) @@ -918,7 +1046,7 @@ def self.delivering_email(mail); end def self.previewing_email(mail); end end - test "you can register a preview interceptor to the mail object that gets passed the mail object before previewing" do + test "you can register and unregister a preview interceptor to the mail object that gets passed the mail object before previewing" do ActionMailer::Base.register_preview_interceptor(MyInterceptor) mail = BaseMailer.welcome stub_any_instance(BaseMailerPreview) do |instance| @@ -928,9 +1056,14 @@ def self.previewing_email(mail); end end end end + + ActionMailer::Base.unregister_preview_interceptor(MyInterceptor) + assert_not_called(MyInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end end - test "you can register a preview interceptor using its stringified name to the mail object that gets passed the mail object before previewing" do + test "you can register and unregister a preview interceptor using its stringified name to the mail object that gets passed the mail object before previewing" do ActionMailer::Base.register_preview_interceptor("BasePreviewInterceptorsTest::MyInterceptor") mail = BaseMailer.welcome stub_any_instance(BaseMailerPreview) do |instance| @@ -940,9 +1073,14 @@ def self.previewing_email(mail); end end end end + + ActionMailer::Base.unregister_preview_interceptor("BasePreviewInterceptorsTest::MyInterceptor") + assert_not_called(MyInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end end - test "you can register an interceptor using its symbolized underscored name to the mail object that gets passed the mail object before previewing" do + test "you can register and unregister a preview interceptor using its symbolized underscored name to the mail object that gets passed the mail object before previewing" do ActionMailer::Base.register_preview_interceptor(:"base_preview_interceptors_test/my_interceptor") mail = BaseMailer.welcome stub_any_instance(BaseMailerPreview) do |instance| @@ -952,9 +1090,14 @@ def self.previewing_email(mail); end end end end + + ActionMailer::Base.unregister_preview_interceptor(:"base_preview_interceptors_test/my_interceptor") + assert_not_called(MyInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end end - test "you can register multiple preview interceptors to the mail object that both get passed the mail object before previewing" do + test "you can register and unregister multiple preview interceptors to the mail object that both get passed the mail object before previewing" do ActionMailer::Base.register_preview_interceptors("BasePreviewInterceptorsTest::MyInterceptor", MySecondInterceptor) mail = BaseMailer.welcome stub_any_instance(BaseMailerPreview) do |instance| @@ -966,5 +1109,46 @@ def self.previewing_email(mail); end end end end + + ActionMailer::Base.unregister_preview_interceptors("BasePreviewInterceptorsTest::MyInterceptor", MySecondInterceptor) + assert_not_called(MyInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end + assert_not_called(MySecondInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end + end +end + +class PreviewTest < ActiveSupport::TestCase + class A < ActionMailer::Preview; end + + module B + class A < ActionMailer::Preview; end + class C < ActionMailer::Preview; end + end + + class C < ActionMailer::Preview; end + + test "all() returns mailers in alphabetical order" do + ActionMailer::Preview.stub(:descendants, [C, A, B::C, B::A]) do + mailers = ActionMailer::Preview.all + assert_equal [A, B::A, B::C, C], mailers + end + end +end + +class BasePreviewTest < ActiveSupport::TestCase + class BaseMailerPreview < ActionMailer::Preview + def welcome + BaseMailer.welcome(params) + end + end + + test "has access to params" do + params = { name: "World" } + + message = BaseMailerPreview.call(:welcome, params) + assert_equal "World", message["name"].decoded end end diff --git a/actionmailer/test/caching_test.rb b/actionmailer/test/caching_test.rb index cff49c889405d..1a495159b2c45 100644 --- a/actionmailer/test/caching_test.rb +++ b/actionmailer/test/caching_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "fileutils" require "abstract_unit" require "mailers/base_mailer" @@ -5,7 +7,7 @@ CACHE_DIR = "test_cache" # Don't change '/../temp/' cavalierly or you might hose something you don't want hosed -FILE_STORE_PATH = File.join(File.dirname(__FILE__), "/../temp/", CACHE_DIR) +FILE_STORE_PATH = File.join(__dir__, "/../temp/", CACHE_DIR) class FragmentCachingMailer < ActionMailer::Base abstract! @@ -21,10 +23,6 @@ def setup @mailer.perform_caching = true @mailer.cache_store = @store end - - def test_fragment_cache_key - assert_equal "views/what a key", @mailer.fragment_cache_key("what a key") - end end class FragmentCachingTest < BaseCachingTest @@ -42,14 +40,14 @@ def test_read_fragment_with_caching_disabled def test_fragment_exist_with_caching_enabled @store.write("views/name", "value") assert @mailer.fragment_exist?("name") - assert !@mailer.fragment_exist?("other_name") + assert_not @mailer.fragment_exist?("other_name") end def test_fragment_exist_with_caching_disabled @mailer.perform_caching = false @store.write("views/name", "value") - assert !@mailer.fragment_exist?("name") - assert !@mailer.fragment_exist?("other_name") + assert_not @mailer.fragment_exist?("name") + assert_not @mailer.fragment_exist?("other_name") end def test_write_fragment_with_caching_enabled @@ -92,7 +90,7 @@ def test_fragment_for buffer = "generated till now -> ".html_safe buffer << view_context.send(:fragment_for, "expensive") { fragment_computed = true } - assert !fragment_computed + assert_not fragment_computed assert_equal "generated till now -> fragment content", buffer end @@ -107,7 +105,7 @@ def test_html_safety html_safe = @mailer.read_fragment("name") assert_equal content, html_safe - assert html_safe.html_safe? + assert_predicate html_safe, :html_safe? end end @@ -126,7 +124,7 @@ def test_fragment_caching assert_match expected_body, email.body.encoded assert_match expected_body, - @store.read("views/caching/#{template_digest("caching_mailer/fragment_cache")}") + @store.read("views/caching_mailer/fragment_cache:#{template_digest("caching_mailer/fragment_cache", "html")}/caching") end def test_fragment_caching_in_partials @@ -135,7 +133,7 @@ def test_fragment_caching_in_partials assert_match(expected_body, email.body.encoded) assert_match(expected_body, - @store.read("views/caching/#{template_digest("caching_mailer/_partial")}")) + @store.read("views/caching_mailer/_partial:#{template_digest("caching_mailer/_partial", "html")}/caching")) end def test_skip_fragment_cache_digesting @@ -173,34 +171,29 @@ def test_multipart_fragment_caching def test_fragment_cache_instrumentation @mailer.enable_fragment_cache_logging = true - payload = nil - subscriber = proc do |*args| - event = ActiveSupport::Notifications::Event.new(*args) - payload = event.payload - end + expected_payload = { + mailer: "caching_mailer", + key: [:views, "caching_mailer/fragment_cache:#{template_digest("caching_mailer/fragment_cache", "html")}", :caching] + } - ActiveSupport::Notifications.subscribed(subscriber, "read_fragment.action_mailer") do + assert_notification("read_fragment.action_mailer", expected_payload) do @mailer.fragment_cache end - - assert_equal "caching_mailer", payload[:mailer] - assert_equal "views/caching/#{template_digest("caching_mailer/fragment_cache")}", payload[:key] ensure @mailer.enable_fragment_cache_logging = true end private - - def template_digest(name) - ActionView::Digestor.digest(name: name, finder: @mailer.lookup_context) + def template_digest(name, format) + ActionView::Digestor.digest(name: name, format: format, finder: @mailer.lookup_context) end end class CacheHelperOutputBufferTest < BaseCachingTest class MockController def read_fragment(name, options) - return false + false end def write_fragment(name, fragment, options) @@ -216,39 +209,16 @@ def test_output_buffer output_buffer = ActionView::OutputBuffer.new controller = MockController.new cache_helper = Class.new do - def self.controller; end; - def self.output_buffer; end; - def self.output_buffer=; end; - end - cache_helper.extend(ActionView::Helpers::CacheHelper) - - cache_helper.stub :controller, controller do - cache_helper.stub :output_buffer, output_buffer do - assert_called_with cache_helper, :output_buffer=, [output_buffer.class.new(output_buffer)] do - assert_nothing_raised do - cache_helper.send :fragment_for, "Test fragment name", "Test fragment", &Proc.new { nil } - end - end - end - end - end - - def test_safe_buffer - output_buffer = ActiveSupport::SafeBuffer.new - controller = MockController.new - cache_helper = Class.new do - def self.controller; end; - def self.output_buffer; end; - def self.output_buffer=; end; + def self.controller; end + def self.output_buffer; end + def self.output_buffer=; end end cache_helper.extend(ActionView::Helpers::CacheHelper) cache_helper.stub :controller, controller do cache_helper.stub :output_buffer, output_buffer do - assert_called_with cache_helper, :output_buffer=, [output_buffer.class.new(output_buffer)] do - assert_nothing_raised do - cache_helper.send :fragment_for, "Test fragment name", "Test fragment", &Proc.new { nil } - end + assert_nothing_raised do + cache_helper.send :fragment_for, "Test fragment name", "Test fragment", &Proc.new { nil } end end end @@ -264,7 +234,7 @@ class HasDependenciesMailer < ActionMailer::Base end def test_view_cache_dependencies_are_empty_by_default - assert NoDependenciesMailer.new.view_cache_dependencies.empty? + assert_empty NoDependenciesMailer.new.view_cache_dependencies end def test_view_cache_dependencies_are_listed_in_declaration_order diff --git a/actionmailer/test/callbacks_test.rb b/actionmailer/test/callbacks_test.rb new file mode 100644 index 0000000000000..3a2cf8f31822c --- /dev/null +++ b/actionmailer/test/callbacks_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "mailers/callback_mailer" +require "active_support/testing/stream" + +class ActionMailerCallbacksTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + include ActiveSupport::Testing::Stream + + setup do + @previous_delivery_method = ActionMailer::Base.delivery_method + ActionMailer::Base.delivery_method = :test + CallbackMailer.rescue_from_error = nil + CallbackMailer.after_deliver_instance = nil + CallbackMailer.around_deliver_instance = nil + CallbackMailer.abort_before_deliver = nil + CallbackMailer.around_handles_error = nil + end + + teardown do + ActionMailer::Base.deliveries.clear + ActionMailer::Base.delivery_method = @previous_delivery_method + CallbackMailer.rescue_from_error = nil + CallbackMailer.after_deliver_instance = nil + CallbackMailer.around_deliver_instance = nil + CallbackMailer.abort_before_deliver = nil + CallbackMailer.around_handles_error = nil + end + + test "deliver_now should call after_deliver callback and can access sent message" do + mail_delivery = CallbackMailer.test_message + mail_delivery.deliver_now + + assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance + assert_not_empty CallbackMailer.after_deliver_instance.message.message_id + assert_equal mail_delivery.message_id, CallbackMailer.after_deliver_instance.message.message_id + assert_equal "test-receiver@test.com", CallbackMailer.after_deliver_instance.message.to.first + end + + test "deliver_now! should call after_deliver callback" do + CallbackMailer.test_message.deliver_now! + + assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance + end + + test "before_deliver can abort the delivery and not run after_deliver callbacks" do + CallbackMailer.abort_before_deliver = true + + mail_delivery = CallbackMailer.test_message + mail_delivery.deliver_now + + assert_nil mail_delivery.message_id + assert_nil CallbackMailer.after_deliver_instance + end + + test "deliver_later should call after_deliver callback and can access sent message" do + perform_enqueued_jobs do + silence_stream($stdout) do + CallbackMailer.test_message.deliver_later + end + end + assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance + assert_not_empty CallbackMailer.after_deliver_instance.message.message_id + end + + test "around_deliver is called after rescue_from on action processing exceptions" do + CallbackMailer.around_handles_error = true + + CallbackMailer.test_raise_action.deliver_now + assert CallbackMailer.rescue_from_error + end + + test "around_deliver is called before rescue_from on deliver! exceptions" do + CallbackMailer.around_handles_error = true + + stub_any_instance(Mail::TestMailer, instance: Mail::TestMailer.new({})) do |instance| + instance.stub(:deliver!, proc { raise "boom deliver exception" }) do + CallbackMailer.test_message.deliver_now + end + end + + assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance + assert_nil CallbackMailer.rescue_from_error + end +end diff --git a/actionmailer/test/delivery_methods_test.rb b/actionmailer/test/delivery_methods_test.rb index f64a69019f9c5..a39eb37eaa907 100644 --- a/actionmailer/test/delivery_methods_test.rb +++ b/actionmailer/test/delivery_methods_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class MyCustomDelivery @@ -39,7 +41,7 @@ class DefaultsDeliveryMethodsTest < ActiveSupport::TestCase test "default sendmail settings" do settings = { location: "/usr/sbin/sendmail", - arguments: "-i" + arguments: %w[ -i ] } assert_equal settings, ActionMailer::Base.sendmail_settings end diff --git a/actionmailer/test/fixtures/attachments/foo.jpg b/actionmailer/test/fixtures/attachments/foo.jpg deleted file mode 100644 index b976fe5e002bf..0000000000000 Binary files a/actionmailer/test/fixtures/attachments/foo.jpg and /dev/null differ diff --git a/actionmailer/test/fixtures/attachments/test.jpg b/actionmailer/test/fixtures/attachments/test.jpg deleted file mode 100644 index b976fe5e002bf..0000000000000 Binary files a/actionmailer/test/fixtures/attachments/test.jpg and /dev/null differ diff --git a/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.html.erb b/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.html.erb new file mode 100644 index 0000000000000..0179b070b8a65 --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.html.erb @@ -0,0 +1 @@ +Implicit Multipart <%= formats.inspect %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.text.erb b/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.text.erb new file mode 100644 index 0000000000000..0179b070b8a65 --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.text.erb @@ -0,0 +1 @@ +Implicit Multipart <%= formats.inspect %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.html.erb b/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.html.erb new file mode 100644 index 0000000000000..e20087812748f --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.html.erb @@ -0,0 +1,5 @@ +

Inline Image

+ +<%= image_tag attachments['logo.png'].url %> + +

This is an image that is inline

\ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.text.erb b/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.text.erb new file mode 100644 index 0000000000000..e161d244d27eb --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.text.erb @@ -0,0 +1,4 @@ +Inline Image + +No image for you + diff --git a/actionmailer/test/fixtures/form_builder_mailer/welcome.html.erb b/actionmailer/test/fixtures/form_builder_mailer/welcome.html.erb new file mode 100644 index 0000000000000..180a827089c00 --- /dev/null +++ b/actionmailer/test/fixtures/form_builder_mailer/welcome.html.erb @@ -0,0 +1,3 @@ +<%= form_with(url: "/") do |f| %> + <%= f.message %> +<% end %> diff --git a/actionmailer/test/fixtures/raw_email b/actionmailer/test/fixtures/raw_email deleted file mode 100644 index 43f7a59cee03f..0000000000000 --- a/actionmailer/test/fixtures/raw_email +++ /dev/null @@ -1,14 +0,0 @@ -From jamis_buck@byu.edu Mon May 2 16:07:05 2005 -Mime-Version: 1.0 (Apple Message framework v622) -Content-Transfer-Encoding: base64 -Message-Id: -Content-Type: text/plain; - charset=EUC-KR; - format=flowed -To: willard15georgina@jamis.backpackit.com -From: Jamis Buck -Subject: =?EUC-KR?Q?NOTE:_=C7=D1=B1=B9=B8=BB=B7=CE_=C7=CF=B4=C2_=B0=CD?= -Date: Mon, 2 May 2005 16:07:05 -0600 - -tOu6zrrQwMcguLbC+bChwfa3ziwgv+y4rrTCIMfPs6q01MC7ILnPvcC0z7TZLg0KDQrBpiDAzLin -wLogSmFtaXPA1LTPtNku diff --git a/actionmailer/test/form_builder_test.rb b/actionmailer/test/form_builder_test.rb new file mode 100644 index 0000000000000..460acd04be822 --- /dev/null +++ b/actionmailer/test/form_builder_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "mailers/form_builder_mailer" + +class MailerFormBuilderTest < ActiveSupport::TestCase + def test_default_form_builder_assigned + email = FormBuilderMailer.welcome + assert_includes(email.body.encoded, "hi from SpecializedFormBuilder") + end +end diff --git a/actionmailer/test/i18n_with_controller_test.rb b/actionmailer/test/i18n_with_controller_test.rb index 4f09339800cbb..5e5be4ff2b2a3 100644 --- a/actionmailer/test/i18n_with_controller_test.rb +++ b/actionmailer/test/i18n_with_controller_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_view" require "action_controller" @@ -25,7 +27,7 @@ def send_mail class ActionMailerI18nWithControllerTest < ActionDispatch::IntegrationTest Routes = ActionDispatch::Routing::RouteSet.new Routes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller(/:action(/:id))" end end @@ -65,7 +67,6 @@ def test_send_mail end private - def with_translation(locale, data) I18n.backend.store_translations(locale, data) yield diff --git a/actionmailer/test/log_subscriber_test.rb b/actionmailer/test/log_subscriber_test.rb index d864c3acca230..89f87a470ca34 100644 --- a/actionmailer/test/log_subscriber_test.rb +++ b/actionmailer/test/log_subscriber_test.rb @@ -1,32 +1,45 @@ +# frozen_string_literal: true + require "abstract_unit" 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 + + setup do + @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + @old_logger = ActionMailer::LogSubscriber.logger + ActionMailer::LogSubscriber.logger = @logger + end - def setup - super - ActionMailer::LogSubscriber.attach_to :action_mailer + teardown do + ActionMailer::LogSubscriber.logger = @old_logger end - class TestMailer < ActionMailer::Base - def receive(mail) - # Do nothing + def run(*) + with_debug_event_reporting do + super end end - def set_logger(logger) - ActionMailer::Base.logger = logger + class BogusDelivery + def initialize(*) + end + + def deliver!(mail) + raise "failed" + end end def test_deliver_is_notified - BaseMailer.welcome.deliver_now - wait + BaseMailer.welcome(message_id: "123@abc").deliver_now assert_equal(1, @logger.logged(:info).size) - assert_match(/Sent mail to system@test.lindsaar.net/, @logger.logged(:info).first) + assert_match(/Delivered mail 123@abc/, @logger.logged(:info).first) assert_equal(2, @logger.logged(:debug).size) assert_match(/BaseMailer#welcome: processed outbound mail in [\d.]+ms/, @logger.logged(:debug).first) @@ -35,13 +48,28 @@ def test_deliver_is_notified BaseMailer.deliveries.clear end - def test_receive_is_notified - fixture = File.read(File.dirname(__FILE__) + "/fixtures/raw_email") - TestMailer.receive(fixture) - wait + def test_deliver_message_when_perform_deliveries_is_false + BaseMailer.welcome_without_deliveries(message_id: "123@abc").deliver_now + assert_equal(1, @logger.logged(:info).size) - assert_match(/Received mail/, @logger.logged(:info).first) - assert_equal(1, @logger.logged(:debug).size) - assert_match(/Jamis/, @logger.logged(:debug).first) + assert_match("Skipped delivery of mail 123@abc as `perform_deliveries` is false", @logger.logged(:info).first) + + assert_equal(2, @logger.logged(:debug).size) + assert_match(/BaseMailer#welcome_without_deliveries: processed outbound mail in [\d.]+ms/, @logger.logged(:debug).first) + assert_match("Welcome", @logger.logged(:debug).second) + ensure + BaseMailer.deliveries.clear + end + + def test_deliver_message_when_exception_happened + previous_delivery_method = BaseMailer.delivery_method + BaseMailer.delivery_method = BogusDelivery + + assert_raises(RuntimeError) { BaseMailer.welcome(message_id: "123@abc").deliver_now } + + 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) + ensure + BaseMailer.delivery_method = previous_delivery_method end end diff --git a/actionmailer/test/mail_helper_test.rb b/actionmailer/test/mail_helper_test.rb index 6042548aef33b..b0c74c80561ce 100644 --- a/actionmailer/test/mail_helper_test.rb +++ b/actionmailer/test/mail_helper_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class HelperMailer < ActionMailer::Base @@ -65,8 +67,13 @@ def use_cache end end - private + 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", subject: "using helpers", &block) @@ -120,4 +127,29 @@ def test_use_cache assert_equal "Greetings from a cache helper block", mail.body.encoded 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, %( Proc.new { Time.now.to_i.to_s }, subject: Proc.new { give_a_greeting }, - "x-has-to-proc" => :symbol + "x-has-to-proc" => :symbol, + "X-Lambda-Arity-0" => ->() { "0" }, + "X-Lambda-Arity-1-arg" => ->(arg) { arg.computed_value }, + "X-Lambda-Arity-1-self" => ->(_) { self.computed_value } def welcome mail end - private + def computed_value + "complex_value" + end + private def give_a_greeting "Thanks for signing up this afternoon" end diff --git a/actionmailer/test/message_delivery_test.rb b/actionmailer/test/message_delivery_test.rb index a79d77e1e5af4..b011bba1a8670 100644 --- a/actionmailer/test/message_delivery_test.rb +++ b/actionmailer/test/message_delivery_test.rb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_job" +require "mailers/base_mailer" require "mailers/delayed_mailer" +require "mailers/params_mailer" class MessageDeliveryTest < ActiveSupport::TestCase include ActiveJob::TestHelper @@ -8,11 +12,10 @@ class MessageDeliveryTest < ActiveSupport::TestCase setup do @previous_logger = ActiveJob::Base.logger @previous_delivery_method = ActionMailer::Base.delivery_method - @previous_deliver_later_queue_name = ActionMailer::Base.deliver_later_queue_name - ActionMailer::Base.deliver_later_queue_name = :test_queue - ActionMailer::Base.delivery_method = :test + ActiveJob::Base.logger = Logger.new(nil) - ActionMailer::Base.deliveries.clear + ActionMailer::Base.delivery_method = :test + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true @@ -23,9 +26,10 @@ class MessageDeliveryTest < ActiveSupport::TestCase end teardown do + ActionMailer::Base.deliveries.clear + ActiveJob::Base.logger = @previous_logger ActionMailer::Base.delivery_method = @previous_delivery_method - ActionMailer::Base.deliver_later_queue_name = @previous_deliver_later_queue_name DelayedMailer.last_error = nil DelayedMailer.last_rescue_from_instance = nil @@ -36,7 +40,7 @@ class MessageDeliveryTest < ActiveSupport::TestCase end test "its message should be a Mail::Message" do - assert_equal Mail::Message , @mail.message.class + assert_equal Mail::Message, @mail.message.class end test "should respond to .deliver_later" do @@ -55,52 +59,77 @@ class MessageDeliveryTest < ActiveSupport::TestCase assert_respond_to @mail, :deliver_now! end - def test_should_enqueue_and_run_correctly_in_activejob - @mail.deliver_later! - assert_equal 1, ActionMailer::Base.deliveries.size - ensure - ActionMailer::Base.deliveries.clear - end - test "should enqueue the email with :deliver_now delivery method" do - assert_performed_with(job: ActionMailer::DeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", 1, 2, 3]) do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]]) do @mail.deliver_later end end test "should enqueue the email with :deliver_now! delivery method" do - assert_performed_with(job: ActionMailer::DeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now!", 1, 2, 3]) do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now!", args: [1, 2, 3]]) do @mail.deliver_later! end end - test "should enqueue a delivery with a delay" do - travel_to Time.new(2004, 11, 24, 01, 04, 44) do - assert_performed_with(job: ActionMailer::DeliveryJob, at: Time.current.to_f + 600.seconds, args: ["DelayedMailer", "test_message", "deliver_now", 1, 2, 3]) do - @mail.deliver_later wait: 600.seconds + test "should enqueue delivery with a delay" do + travel_to Time.new(2004, 11, 24, 1, 4, 44) do + assert_performed_with(job: ActionMailer::MailDeliveryJob, at: Time.current + 10.minutes, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]]) do + @mail.deliver_later wait: 10.minutes end end end - test "should enqueue a delivery at a specific time" do - later_time = Time.now.to_f + 3600 - assert_performed_with(job: ActionMailer::DeliveryJob, at: later_time, args: ["DelayedMailer", "test_message", "deliver_now", 1, 2, 3]) do + test "should enqueue delivery with a priority" do + job = @mail.deliver_later priority: 10 + assert_equal 10, job.priority + end + + test "should enqueue delivery at a specific time" do + later_time = Time.current + 1.hour + assert_performed_with(job: ActionMailer::MailDeliveryJob, at: later_time, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]]) do @mail.deliver_later wait_until: later_time end end - test "should enqueue the job on the correct queue" do - assert_performed_with(job: ActionMailer::DeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", 1, 2, 3], queue: "test_queue") do + test "should enqueue delivery on the correct queue" do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]], queue: "delayed_mailers") do + @mail.deliver_later + end + end + + test "should enqueue delivery with the correct job" do + old_delivery_job = DelayedMailer.delivery_job + DelayedMailer.delivery_job = DummyJob + + assert_performed_with(job: DummyJob, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]]) do @mail.deliver_later end + + DelayedMailer.delivery_job = old_delivery_job end - test "can override the queue when enqueuing mail" do - assert_performed_with(job: ActionMailer::DeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", 1, 2, 3], queue: "another_queue") do + class DummyJob < ActionMailer::MailDeliveryJob; end + + test "delivery queue can be overridden when enqueuing mail" do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]], queue: "another_queue") do @mail.deliver_later(queue: :another_queue) end end + test "delivery queue can be overridden in subclasses" do + previous_queue_name = DelayedMailer.deliver_later_queue_name + DelayedMailer.deliver_later_queue_name = :throttled_mailers + + assert_equal :throttled_mailers, DelayedMailer.deliver_later_queue_name + assert_equal :mailers, ActionMailer::Base.deliver_later_queue_name + + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", args: []], queue: "throttled_mailers") do + DelayedMailer.test_message.deliver_later + end + ensure + DelayedMailer.deliver_later_queue_name = previous_queue_name + end + test "deliver_later after accessing the message is disallowed" do @mail.message # Load the message, which calls the mailer method. @@ -109,6 +138,85 @@ def test_should_enqueue_and_run_correctly_in_activejob end end + test "deliver_all_later enqueues multiple deliveries" do + mail1 = DelayedMailer.test_message(1) + mail2 = DelayedMailer.test_kwargs(argument: 1) + + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", params: nil, args: [1]]) do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_kwargs", "deliver_now", params: nil, args: [argument: 1]]) do + ActionMailer.deliver_all_later(mail1, mail2) + end + end + end + + test "deliver_all_later! enqueues multiple deliveries" do + mail1 = DelayedMailer.test_message(1) + mail2 = DelayedMailer.test_kwargs(argument: 1) + + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now!", params: nil, args: [1]]) do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_kwargs", "deliver_now!", params: nil, args: [argument: 1]]) do + ActionMailer.deliver_all_later!(mail1, mail2) + end + end + end + + test "deliver_all_later does not inline process the mailers" do + mail1 = DelayedMailer.test_message(1) + mail2 = DelayedMailer.test_message(2) + + ActionMailer.deliver_all_later(mail1, mail2) + + assert_not mail1.processed? + assert_not mail2.processed? + + mail1 = DelayedMailer.test_message(1) + mail2 = DelayedMailer.test_message(2) + + ActionMailer.deliver_all_later([mail1, mail2]) + + assert_not mail1.processed? + assert_not mail2.processed? + end + + test "deliver_all_later enqueues multiple deliveries with correct jobs" do + old_delivery_job = BaseMailer.delivery_job + BaseMailer.delivery_job = DummyJob + + mail1 = DelayedMailer.test_message + mail2 = BaseMailer.welcome + + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", params: nil, args: []]) do + assert_performed_with(job: DummyJob, args: ["BaseMailer", "welcome", "deliver_now", params: nil, args: []]) do + ActionMailer.deliver_all_later(mail1, mail2) + end + end + ensure + BaseMailer.delivery_job = old_delivery_job + end + + test "deliver_all_later enqueues multiple deliveries with custom options" do + mail1 = DelayedMailer.test_message(1) + mail2 = DelayedMailer.test_message(2) + + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", params: nil, args: [1]], queue: "another_queue") do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", params: nil, args: [2]], queue: "another_queue") do + ActionMailer.deliver_all_later(mail1, mail2, queue: :another_queue) + end + end + end + + test "deliver_all_later enqueues parameterized emails" do + mail1 = DelayedMailer.test_message(1) + mail2 = ParamsMailer.with(inviter: "david@basecamp.com", invitee: "jason@basecamp.com").invitation + + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", params: nil, args: [1]]) do + assert_performed_with(job: ActionMailer::MailDeliveryJob, + args: ["ParamsMailer", "invitation", "deliver_now", args: [], params: { inviter: "david@basecamp.com", invitee: "jason@basecamp.com" }]) do + ActionMailer.deliver_all_later(mail1, mail2) + end + end + end + test "job delegates error handling to mailer" do # Superclass not rescued by mailer's rescue_from RuntimeError message = DelayedMailer.test_raise("StandardError") @@ -150,4 +258,11 @@ def to_global_id(options = {}) assert_equal DelayedMailer, DelayedMailer.last_rescue_from_instance assert_equal "Error while trying to deserialize arguments: boom, missing find", DelayedMailer.last_error.message end + + test "allows for keyword arguments" do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_kwargs", "deliver_now", args: [argument: 1]]) do + message = DelayedMailer.test_kwargs(argument: 1) + message.deliver_later + end + end end diff --git a/actionmailer/test/parameterized_test.rb b/actionmailer/test/parameterized_test.rb index 914ed12312195..366327ee76be1 100644 --- a/actionmailer/test/parameterized_test.rb +++ b/actionmailer/test/parameterized_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_job" require "mailers/params_mailer" @@ -5,6 +7,9 @@ class ParameterizedTest < ActiveSupport::TestCase include ActiveJob::TestHelper + class DummyDeliveryJob < ActionMailer::MailDeliveryJob + end + setup do @previous_logger = ActiveJob::Base.logger ActiveJob::Base.logger = Logger.new(nil) @@ -12,19 +17,13 @@ class ParameterizedTest < ActiveSupport::TestCase @previous_delivery_method = ActionMailer::Base.delivery_method ActionMailer::Base.delivery_method = :test - @previous_deliver_later_queue_name = ActionMailer::Base.deliver_later_queue_name - ActionMailer::Base.deliver_later_queue_name = :test_queue - ActionMailer::Base.delivery_method = :test - @mail = ParamsMailer.with(inviter: "david@basecamp.com", invitee: "jason@basecamp.com").invitation end teardown do ActiveJob::Base.logger = @previous_logger ParamsMailer.deliveries.clear - ActionMailer::Base.delivery_method = @previous_delivery_method - ActionMailer::Base.deliver_later_queue_name = @previous_deliver_later_queue_name end test "parameterized headers" do @@ -33,8 +32,22 @@ class ParameterizedTest < ActiveSupport::TestCase assert_equal("So says david@basecamp.com", @mail.body.encoded) end + test "degrade gracefully when .with is not called" do + @mail = ParamsMailer.invitation + + assert_nil(@mail.to) + assert_nil(@mail.from) + end + test "enqueue the email with params" do - assert_performed_with(job: ActionMailer::Parameterized::DeliveryJob, args: ["ParamsMailer", "invitation", "deliver_now", { inviter: "david@basecamp.com", invitee: "jason@basecamp.com" } ]) do + args = [ + "ParamsMailer", + "invitation", + "deliver_now", + params: { inviter: "david@basecamp.com", invitee: "jason@basecamp.com" }, + args: [], + ] + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: args) do @mail.deliver_later end end @@ -52,4 +65,29 @@ class ParameterizedTest < ActiveSupport::TestCase invitation = mailer.method(:anything) end end + + test "should enqueue a parameterized request with the correct delivery job" do + args = [ + "ParamsMailer", + "invitation", + "deliver_now", + params: { inviter: "david@basecamp.com", invitee: "jason@basecamp.com" }, + args: [], + ] + + with_delivery_job DummyDeliveryJob do + assert_performed_with(job: DummyDeliveryJob, args: args) do + @mail.deliver_later + end + end + end + + private + def with_delivery_job(job) + old_delivery_job = ParamsMailer.delivery_job + ParamsMailer.delivery_job = job + yield + ensure + ParamsMailer.delivery_job = old_delivery_job + end end diff --git a/actionmailer/test/structured_event_subscriber_test.rb b/actionmailer/test/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..d1cd1f5540e2f --- /dev/null +++ b/actionmailer/test/structured_event_subscriber_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/testing/event_reporter_assertions" +require "mailers/base_mailer" +require "action_mailer/structured_event_subscriber" + +module ActionMailer + class StructuredEventSubscriberTest < ActionMailer::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + class BogusDelivery + def initialize(*) + end + + def deliver!(mail) + raise "failed" + end + end + + def run(*) + with_debug_event_reporting do + super + end + end + + def test_deliver_is_notified + event = assert_event_reported("action_mailer.delivered", payload: { message_id: "123@abc", mail: /.*/, perform_deliveries: true }) do + BaseMailer.welcome(message_id: "123@abc").deliver_now + end + + assert event[:payload][:duration_ms] > 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/actionmailer/test/test_case_test.rb b/actionmailer/test/test_case_test.rb index 193d107b0a869..9897f3891bb1e 100644 --- a/actionmailer/test/test_case_test.rb +++ b/actionmailer/test/test_case_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class TestTestMailer < ActionMailer::Base @@ -41,7 +43,7 @@ def test_deliveries_are_cleared_on_setup_and_teardown end end -class CrazyNameMailerTest < ActionMailer::TestCase +class ManuallySetNameMailerTest < ActionMailer::TestCase tests TestTestMailer def test_set_mailer_class_manual @@ -49,7 +51,7 @@ def test_set_mailer_class_manual end end -class CrazySymbolNameMailerTest < ActionMailer::TestCase +class ManuallySetSymbolNameMailerTest < ActionMailer::TestCase tests :test_test_mailer def test_set_mailer_class_manual_using_symbol @@ -57,7 +59,7 @@ def test_set_mailer_class_manual_using_symbol end end -class CrazyStringNameMailerTest < ActionMailer::TestCase +class ManuallySetStringNameMailerTest < ActionMailer::TestCase tests "test_test_mailer" def test_set_mailer_class_manual_using_string diff --git a/actionmailer/test/test_helper_test.rb b/actionmailer/test/test_helper_test.rb index 876e9b0634cd7..de1c3861a96fe 100644 --- a/actionmailer/test/test_helper_test.rb +++ b/actionmailer/test/test_helper_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_support/testing/stream" @@ -5,14 +7,52 @@ class TestHelperMailer < ActionMailer::Base def test @world = "Earth" mail body: render(inline: "Hello, <%= @world %>"), + subject: "Hi!", + to: "test@example.com", + from: "tester@example.com" + end + + def test_args(recipient, name) + mail body: render(inline: "Hello, #{name}"), + to: recipient, + from: "tester@example.com" + end + + def test_named_args(recipient:, name:) + mail body: render(inline: "Hello, #{name}"), + to: recipient, + from: "tester@example.com" + end + + def test_parameter_args + mail body: render(inline: "All is #{params[:all]}"), to: "test@example.com", from: "tester@example.com" end end +class CustomDeliveryJob < ActionMailer::MailDeliveryJob +end + +class CustomDeliveryMailer < TestHelperMailer + self.delivery_job = CustomDeliveryJob +end + +class CustomQueueMailer < TestHelperMailer + self.deliver_later_queue_name = :custom_queue +end + class TestHelperMailerTest < ActionMailer::TestCase include ActiveSupport::Testing::Stream + setup do + @previous_deliver_later_queue_name = ActionMailer::Base.deliver_later_queue_name + end + + teardown do + ActionMailer::Base.deliver_later_queue_name = @previous_deliver_later_queue_name + end + def test_setup_sets_right_action_mailer_options assert_equal :test, ActionMailer::Base.delivery_method assert ActionMailer::Base.perform_deliveries @@ -40,7 +80,7 @@ def test_charset_is_utf_8 end def test_encode - assert_equal "=?UTF-8?Q?This_is_=E3=81=82_string?=", encode("This is ã‚ string") + assert_equal "This is ã‚ string", Mail::Encodings.q_value_decode(encode("This is ã‚ string")) end def test_read_fixture @@ -55,6 +95,56 @@ def test_assert_emails end end + def test_capture_emails + assert_nothing_raised do + emails = capture_emails do + TestHelperMailer.test.deliver_now + end + email = emails.first + assert_instance_of Mail::Message, email + assert_equal "Hello, Earth", email.body.to_s + assert_equal "Hi!", email.subject + + emails = capture_emails do + TestHelperMailer.test.deliver_now + TestHelperMailer.test.deliver_now + end + assert_instance_of Array, emails + assert_instance_of Mail::Message, emails.first + assert_instance_of Mail::Message, emails.second + end + end + + def test_assert_emails_with_custom_delivery_job + assert_nothing_raised do + assert_emails(1) do + silence_stream($stdout) do + CustomDeliveryMailer.test.deliver_later + end + end + end + end + + def test_assert_emails_with_custom_parameterized_delivery_job + assert_nothing_raised do + assert_emails(1) do + silence_stream($stdout) do + CustomDeliveryMailer.with(foo: "bar").test_parameter_args.deliver_later + end + end + end + end + + def test_assert_emails_with_enqueued_emails + assert_nothing_raised do + assert_emails 1 do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + def test_repeated_assert_emails_calls assert_nothing_raised do assert_emails 1 do @@ -91,6 +181,18 @@ def test_assert_no_emails end end + def test_assert_no_emails_with_enqueued_emails + error = assert_raise ActiveSupport::TestCase::Assertion do + assert_no_emails do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + + assert_match(/0 .* but 1/, error.message) + end + def test_assert_emails_too_few_sent error = assert_raise ActiveSupport::TestCase::Assertion do assert_emails 2 do @@ -165,6 +267,16 @@ def test_assert_enqueued_emails_too_few_sent assert_match(/2 .* but 1/, error.message) end + def test_assert_enqueued_emails_with_custom_delivery_job + assert_nothing_raised do + assert_enqueued_emails(1) do + silence_stream($stdout) do + CustomDeliveryMailer.test.deliver_later + end + end + end + end + def test_assert_enqueued_emails_too_many_sent error = assert_raise ActiveSupport::TestCase::Assertion do assert_enqueued_emails 1 do @@ -205,6 +317,295 @@ def test_assert_no_enqueued_emails_failure assert_match(/0 .* but 1/, error.message) end + + def test_assert_enqueued_email_with + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_deliver_later_queue_name_is_nil + ActionMailer::Base.deliver_later_queue_name = nil + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_deliver_later_queue_name_with_non_default_name + ActionMailer::Base.deliver_later_queue_name = "sample_mailer_queue_name" + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_deliver_later_queue_name_is_symbol + ActionMailer::Base.deliver_later_queue_name = :mailers + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_queue_arg_is_symbol + ActionMailer::Base.deliver_later_queue_name = "mailers" + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test, queue: :mailers do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_mailer_has_custom_deliver_later_queue + assert_nothing_raised do + assert_enqueued_email_with CustomQueueMailer, :test do + silence_stream($stdout) do + CustomQueueMailer.test.deliver_later + end + end + + assert_enqueued_email_with CustomQueueMailer, :test, queue: :custom_queue do + silence_stream($stdout) do + CustomQueueMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_mailer_has_custom_delivery_job + assert_nothing_raised do + assert_enqueued_email_with CustomDeliveryMailer, :test do + silence_stream($stdout) do + CustomDeliveryMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_no_block + assert_nothing_raised do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + assert_enqueued_email_with TestHelperMailer, :test + end + end + end + + def test_assert_enqueued_email_with_with_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_args, args: ["some_email", "some_name"] do + silence_stream($stdout) do + TestHelperMailer.test_args("some_email", "some_name").deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_no_block_with_args + assert_nothing_raised do + silence_stream($stdout) do + TestHelperMailer.test_args("some_email", "some_name").deliver_later + assert_enqueued_email_with TestHelperMailer, :test_args, args: ["some_email", "some_name"] + end + end + end + + def test_assert_enqueued_email_with_with_parameterized_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_parameter_args, params: { all: "good" } do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_parameter_args.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_parameterized_mailer + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer.with(all: "good"), :test_parameter_args do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_parameter_args.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_named_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_named_args, args: [{ email: "some_email", name: "some_name" }] do + silence_stream($stdout) do + TestHelperMailer.test_named_args(email: "some_email", name: "some_name").deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_params_and_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_args, params: { all: "good" }, args: ["some_email", "some_name"] do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_args("some_email", "some_name").deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_params_and_named_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_named_args, params: { all: "good" }, args: [{ email: "some_email", name: "some_name" }] do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_named_args(email: "some_email", name: "some_name").deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_no_block_with_parameterized_args + assert_nothing_raised do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_parameter_args.deliver_later + end + assert_enqueued_email_with TestHelperMailer, :test_parameter_args, params: { all: "good" } + end + end + + def test_assert_enqueued_email_with_supports_params_matcher_proc + mail_params = { all: "good" } + + silence_stream($stdout) do + TestHelperMailer.with(mail_params).test_parameter_args.deliver_later + end + + matcher_params = nil + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_parameter_args, params: ->(params) { matcher_params = params } + end + + assert_equal mail_params, matcher_params + + assert_raises ActiveSupport::TestCase::Assertion do + assert_enqueued_email_with TestHelperMailer, :test_parameter_args, params: ->(_) { false } + end + end + + def test_assert_enqueued_email_with_supports_args_matcher_proc + mail_args = ["some_email", "some_name"] + + silence_stream($stdout) do + TestHelperMailer.test_args(*mail_args).deliver_later + end + + matcher_args = nil + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_args, args: ->(args) { matcher_args = args } + end + + assert_equal mail_args, matcher_args + + assert_raises ActiveSupport::TestCase::Assertion do + assert_enqueued_email_with TestHelperMailer, :test_args, args: ->(_) { false } + end + end + + def test_assert_enqueued_email_with_supports_named_args_matcher_proc + mail_args = [{ email: "some_email", name: "some_name" }] + + silence_stream($stdout) do + TestHelperMailer.test_named_args(**mail_args[0]).deliver_later + end + + matcher_args = nil + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_named_args, args: ->(args) { matcher_args = args } + end + + assert_equal mail_args, matcher_args + end + + def test_deliver_enqueued_emails_with_no_block + assert_nothing_raised do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + deliver_enqueued_emails + end + end + + assert_emails(1) + end + + def test_deliver_enqueued_emails_with_a_block + assert_nothing_raised do + deliver_enqueued_emails do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + + assert_emails(1) + end + + def test_deliver_enqueued_emails_with_custom_delivery_job + assert_nothing_raised do + deliver_enqueued_emails do + silence_stream($stdout) do + CustomDeliveryMailer.test.deliver_later + end + end + end + + assert_emails(1) + end + + def test_deliver_enqueued_emails_with_custom_queue + assert_nothing_raised do + deliver_enqueued_emails(queue: CustomQueueMailer.deliver_later_queue_name) do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + CustomQueueMailer.test.deliver_later + end + end + end + + assert_emails(1) + assert_enqueued_email_with(TestHelperMailer, :test) + end + + def test_deliver_enqueued_emails_with_at + assert_nothing_raised do + deliver_enqueued_emails(at: 1.hour.from_now) do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + TestHelperMailer.test.deliver_later(wait: 2.hours) + end + end + end + + assert_emails(1) + end end class AnotherTestHelperMailerTest < ActionMailer::TestCase @@ -219,3 +620,17 @@ def test_setup_shouldnt_conflict_with_mailer_setup assert_equal "a value", @test_var end end + +class AdapterIsNotTestAdapterTest < ActionMailer::TestCase + def queue_adapter_for_test + ActiveJob::QueueAdapters::InlineAdapter.new + end + + def test_can_send_email_using_any_active_job_adapter + assert_nothing_raised do + assert_emails 1 do + TestHelperMailer.test.deliver_now + end + end + end +end diff --git a/actionmailer/test/url_test.rb b/actionmailer/test/url_test.rb index 6dbfb3a1ffb24..123c194aacc2d 100644 --- a/actionmailer/test/url_test.rb +++ b/actionmailer/test/url_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller" @@ -6,11 +8,16 @@ class WelcomeController < ActionController::Base AppRoutes = ActionDispatch::Routing::RouteSet.new -class ActionMailer::Base - include AppRoutes.url_helpers +AppRoutes.draw do + get "/welcome" => "foo#bar", as: "welcome" + get "/dummy_model" => "foo#baz", as: "dummy_model" + get "/welcome/greeting", to: "welcome#greeting" + get "/a/b(/:id)", to: "a#b" end class UrlTestMailer < ActionMailer::Base + include AppRoutes.url_helpers + default_url_options[:host] = "www.basecamphq.com" configure do |c| @@ -34,7 +41,7 @@ def exercise_url_for(options) class ActionMailerUrlTest < ActionMailer::TestCase class DummyModel def self.model_name - OpenStruct.new(route_key: "dummy_model") + Struct.new(:route_key, :name).new("dummy_model", nil) end def persisted? @@ -50,10 +57,6 @@ def to_model end end - def encode(text, charset = "UTF-8") - quoted_printable(text, charset) - end - def new_mail(charset = "UTF-8") mail = Mail.new mail.mime_version = "1.0" @@ -78,14 +81,6 @@ def setup def test_url_for UrlTestMailer.delivery_method = :test - AppRoutes.draw do - ActiveSupport::Deprecation.silence do - get ":controller(/:action(/:id))" - get "/welcome" => "foo#bar", as: "welcome" - get "/dummy_model" => "foo#baz", as: "dummy_model" - end - end - # string assert_url_for "http://foo/", "http://foo/" @@ -103,23 +98,16 @@ def test_url_for assert_url_for "/dummy_model", DummyModel # array - assert_url_for "/dummy_model" , [DummyModel] + assert_url_for "/dummy_model", [DummyModel] end def test_signed_up_with_url UrlTestMailer.delivery_method = :test - AppRoutes.draw do - ActiveSupport::Deprecation.silence do - get ":controller(/:action(/:id))" - get "/welcome" => "foo#bar", as: "welcome" - end - end - expected = new_mail expected.to = @recipient expected.subject = "[Signed up] Welcome #{@recipient}" - expected.body = "Hello there,\n\nMr. #{@recipient}. Please see our greeting at http://example.com/welcome/greeting http://www.basecamphq.com/welcome\n\n\"Somelogo\"" + expected.body = "Hello there,\n\nMr. #{@recipient}. Please see our greeting at http://example.com/welcome/greeting http://www.basecamphq.com/welcome\n\n" expected.from = "system@loudthinking.com" expected.date = Time.local(2004, 12, 12) expected.content_type = "text/html" diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index a7a4aabc98852..89335a4401433 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,328 +1,157 @@ -* Remove deprecated `.to_prepare`, `.to_cleanup`, `.prepare!` and `.cleanup!` from `ActionDispatch::Reloader`. +* Add `config.action_controller.live.streaming_excluded_keys` to control execution state sharing in ActionController::Live. - *Rafael Mendonça França* + When using ActionController::Live, actions are executed in a separate thread that shares + state from the parent thread. This new configuration allows applications to opt-out specific + state keys that should not be shared. -* Remove deprecated `ActionDispatch::Callbacks.to_prepare` and `ActionDispatch::Callbacks.to_cleanup`. - - *Rafael Mendonça França* - -* Remove deprecated `ActionController::Metal.call`. - - *Rafael Mendonça França* - -* Remove deprecated `ActionController::Metal#env`. - - *Rafael Mendonça França* - -* Make `with_routing` test helper work when testing controllers inheriting from `ActionController::API` - - *Julia López* - -* Use accept header in integration tests with `as: :json` - - Instead of appending the `format` to the request path, Rails will figure - out the format from the header instead. - - This allows devs to use `:as` on routes that don't have a format. - - Fixes #27144. - - *Kasper Timm Hansen* - -* Reset a new session directly after its creation in `ActionDispatch::IntegrationTest#open_session`. - - Fixes #22742. - - *Tawan Sierek* - -* Fixes incorrect output from rails routes when using singular resources. - - Fixes #26606. - - *Erick Reyna* - -* Fixes multiple calls to `logger.fatal` instead of a single call, - for every line in an exception backtrace, when printing trace - from `DebugExceptions` middleware. - - Fixes #26134. - - *Vipul A M* - -* Add support for arbitrary hashes in strong parameters: + This is useful when streaming inside a `connected_to` block, where you may want + the streaming thread to use its own database connection context. ```ruby - params.permit(preferences: {}) + # config/application.rb + config.action_controller.live.streaming_excluded_keys = [:active_record_connected_to_stack] ``` - *Xavier Noria* - -* Add `ActionController::Parameters#merge!`, which behaves the same as `Hash#merge!`. - - *Yuji Yaginuma* - -* Allow keys not found in `RACK_KEY_TRANSLATION` for setting the environment when rendering - arbitrary templates. - - *Sammy Larbi* - -* Remove deprecated support to non-keyword arguments in `ActionDispatch::IntegrationTest#process`, - `#get`, `#post`, `#patch`, `#put`, `#delete`, and `#head`. - - *Rafael Mendonça França* - -* Remove deprecated `ActionDispatch::IntegrationTest#*_via_redirect`. - - *Rafael Mendonça França* - -* Remove deprecated `ActionDispatch::IntegrationTest#xml_http_request`. - - *Rafael Mendonça França* - -* Remove deprecated support for passing `:path` and route path as strings in `ActionDispatch::Routing::Mapper#match`. - - *Rafael Mendonça França* - -* Remove deprecated support for passing path as `nil` in `ActionDispatch::Routing::Mapper#match`. - - *Rafael Mendonça França* - -* Remove deprecated `cache_control` argument from `ActionDispatch::Static#initialize`. - - *Rafael Mendonça França* - -* Remove deprecated support to passing strings or symbols to the middleware stack. - - *Rafael Mendonça França* - -* Change HSTS subdomain to true. - - *Rafael Mendonça França* - -* Remove deprecated `host` and `port` ssl options. - - *Rafael Mendonça França* - -* Remove deprecated `const_error` argument in - `ActionDispatch::Session::SessionRestoreError#initialize`. - - *Rafael Mendonça França* - -* Remove deprecated `#original_exception` in `ActionDispatch::Session::SessionRestoreError`. - - *Rafael Mendonça França* - -* Deprecate `ActionDispatch::ParamsParser::ParseError` in favor of - `ActionDispatch::Http::Parameters::ParseError`. - - *Rafael Mendonça França* - -* Remove deprecated `ActionDispatch::ParamsParser`. + By default, all keys are shared. - *Rafael Mendonça França* + *Eileen M. Uchitelle* -* Remove deprecated `original_exception` and `message` arguments in - `ActionDispatch::ParamsParser::ParseError#initialize`. +* Add controller action source location to routes inspector. - *Rafael Mendonça França* + The routes inspector now shows where controller actions are defined. + In `rails routes --expanded`, a new "Action Location" field displays + the file and line number of each action method. -* Remove deprecated `#original_exception` in `ActionDispatch::ParamsParser::ParseError`. + On the routing error page, when `RAILS_EDITOR` or `EDITOR` is set, + a clickable âœï¸ icon appears next to each Controller#Action that opens + the action directly in the editor. - *Rafael Mendonça França* + *Guillermo Iguaran* -* Remove deprecated access to mime types through constants. +* Active Support notifications for CSRF warnings. - *Rafael Mendonça França* + Switches from direct logging to event-driven logging, allowing others to + subscribe to and act on CSRF events: -* Remove deprecated support to non-keyword arguments in `ActionController::TestCase#process`, - `#get`, `#post`, `#patch`, `#put`, `#delete`, and `#head`. + - `csrf_token_fallback.action_controller` + - `csrf_request_blocked.action_controller` + - `csrf_javascript_blocked.action_controller` - *Rafael Mendonça França* + *Jeremy Daer* -* Remove deprecated `xml_http_request` and `xhr` methods in `ActionController::TestCase`. +* Modern header-based CSRF protection. - *Rafael Mendonça França* + Modern browsers send the `Sec-Fetch-Site` header to indicate the relationship + between request initiator and target origins. Rails now uses this header to + verify same-origin requests without requiring authenticity tokens. -* Remove deprecated methods in `ActionController::Parameters`. + Two verification strategies are available via `protect_from_forgery using:`: - *Rafael Mendonça França* + * `:header_only` - Uses `Sec-Fetch-Site` header only. Rejects requests + without a valid header. Default for new Rails 8.2 applications. -* Remove deprecated support to comparing a `ActionController::Parameters` - with a `Hash`. + * `:header_or_legacy_token` - Uses `Sec-Fetch-Site` header when present, + falls back to authenticity token verification for older browsers. - *Rafael Mendonça França* + Configure trusted origins for legitimate cross-site requests (OAuth callbacks, + third-party embeds) with `trusted_origins:`: -* Remove deprecated support to `:text` in `render`. - - *Rafael Mendonça França* - -* Remove deprecated support to `:nothing` in `render`. - - *Rafael Mendonça França* - -* Remove deprecated support to `:back` in `redirect_to`. - - *Rafael Mendonça França* - -* Remove deprecated support to passing status as option `head`. - - *Rafael Mendonça França* - -* Remove deprecated support to passing original exception to `ActionController::BadRequest` - and the `ActionController::BadRequest#original_exception` method. - - *Rafael Mendonça França* - -* Remove deprecated methods `skip_action_callback`, `skip_filter`, `before_filter`, - `prepend_before_filter`, `skip_before_filter`, `append_before_filter`, `around_filter` - `prepend_around_filter`, `skip_around_filter`, `append_around_filter`, `after_filter`, - `prepend_after_filter`, `skip_after_filter` and `append_after_filter`. - - *Rafael Mendonça França* - -* Show an "unmatched constraints" error when params fail to match constraints - on a matched route, rather than a "missing keys" error. - - Fixes #26470. - - *Chris Carter* - -* Fix adding implicitly rendered template digests to ETags. - - Fixes a case when modifying an implicitly rendered template for a - controller action using `fresh_when` or `stale?` would not result in a new - `ETag` value. - - *Javan Makhmali* - -* Make `fixture_file_upload` work in integration tests. - - *Yuji Yaginuma* - -* Add `to_param` to `ActionController::Parameters` deprecations. - - In the future `ActionController::Parameters` are discouraged from being used - in URLs without explicit whitelisting. Go through `to_h` to use `to_param`. - - *Kir Shatrov* - -* Fix nested multiple roots - - The PR #20940 enabled the use of multiple roots with different constraints - at the top level but unfortunately didn't work when those roots were inside - a namespace and also broke the use of root inside a namespace after a top - level root was defined because the check for the existence of the named route - used the global :root name and not the namespaced name. - - This is fixed by using the name_for_action method to expand the :root name to - the full namespaced name. We can pass nil for the second argument as we're not - dealing with resource definitions so don't need to handle the cases for edit - and new routes. - - Fixes #26148. - - *Ryo Hashimoto*, *Andrew White* - -* Include the content of the flash in the auto-generated etag. This solves the following problem: - - 1. POST /messages - 2. redirect_to messages_url, notice: 'Message was created' - 3. GET /messages/1 - 4. GET /messages - - Step 4 would before still include the flash message, even though it's no longer relevant, - because the etag cache was recorded with the flash in place and didn't change when it was gone. - - *DHH* - -* SSL: Changes redirect behavior for all non-GET and non-HEAD requests - (like POST/PUT/PATCH etc) to `http://` resources to redirect to `https://` - with a [307 status code](http://tools.ietf.org/html/rfc7231#section-6.4.7) instead of [301 status code](http://tools.ietf.org/html/rfc7231#section-6.4.2). + ```ruby + protect_from_forgery trusted_origins: %w[ https://accounts.google.com ] + ``` - 307 status code instructs the HTTP clients to preserve the original - request method while redirecting. It has been part of HTTP RFC since - 1999 and is implemented/recognized by most (if not all) user agents. + `InvalidAuthenticityToken` is deprecated in favor of `InvalidCrossOriginRequest`. - # Before - POST http://example.com/articles (i.e. ArticlesContoller#create) - redirects to - GET https://example.com/articles (i.e. ArticlesContoller#index) + *Rosa Gutierrez* - # After - POST http://example.com/articles (i.e. ArticlesContoller#create) - redirects to - POST https://example.com/articles (i.e. ArticlesContoller#create) +* Fix `action_dispatch_request` early load hook call when building + Rails app middleware. - *Chirag Singhal* + *Gannon McGibbon* -* Add `:as` option to `ActionController:TestCase#process` and related methods. +* Emit a structured event when `action_on_open_redirect` is set to `:notify` + in addition to the existing Active Support Notification. - Specifying `as: mime_type` allows the `CONTENT_TYPE` header to be specified - in controller tests without manually doing this through `@request.headers['CONTENT_TYPE']`. + *Adrianna Chang*, *Hartley McGuire* - *Everest Stefan Munro-Zeisberger* +* Support `text/markdown` format in `DebugExceptions` middleware. -* Show cache hits and misses when rendering partials. + 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. - Partials using the `cache` helper will show whether a render hit or missed - the cache: + *Guillermo Iguaran* - ``` - Rendered messages/_message.html.erb in 1.2 ms [cache hit] - Rendered recordings/threads/_thread.html.erb in 1.5 ms [cache miss] - ``` +* Support dynamic `to:` and `within:` options in `rate_limit`. - This removes the need for the old fragment cache logging: + 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. - ``` - Read fragment views/v1/2914079/v1/2914079/recordings/70182313-20160225015037000000/d0bdf2974e1ef6d31685c3b392ad0b74 (0.6ms) - Rendered messages/_message.html.erb in 1.2 ms [cache hit] - Write fragment views/v1/2914079/v1/2914079/recordings/70182313-20160225015037000000/3b4e249ac9d168c617e32e84b99218b5 (1.1ms) - Rendered recordings/threads/_thread.html.erb in 1.5 ms [cache miss] + ```ruby + class APIController < ApplicationController + 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 ``` - Though that full output can be reenabled with - `config.action_controller.enable_fragment_cache_logging = true`. + *Murilo Duarte* - *Stan Lo* +* Define `ActionController::Parameters#deconstruct_keys` to support pattern matching -* Don't override the `Accept` header in integration tests when called with `xhr: true`. - - Fixes #25859. - - *David Chen* - -* Fix `defaults` option for root route. - - A regression from some refactoring for the 5.0 release, this change - fixes the use of `defaults` (default parameters) in the `root` routing method. - - *Chris Arcand* - -* Check `request.path_parameters` encoding at the point they're set. - - Check for any non-UTF8 characters in path parameters at the point they're - set in `env`. Previously they were checked for when used to get a controller - class, but this meant routes that went directly to a Rack app, or skipped - controller instantiation for some other reason, had to defend against - non-UTF8 characters themselves. - - *Grey Baker* + ```ruby + if params in { search:, page: } + Article.search(search).limit(page) + else + … + end + + 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 + ``` -* Don't raise `ActionController::UnknownHttpMethod` from `ActionDispatch::Static`. + *Sean Doyle* - Pass `Rack::Request` objects to `ActionDispatch::FileHandler` to avoid it - raising `ActionController::UnknownHttpMethod`. If an unknown method is - passed, it should pass exception higher in the stack instead, once we've had a - chance to define exception handling behaviour. +* Submit test requests using `as: :html` with `Content-Type: x-www-form-urlencoded` - *Grey Baker* + *Sean Doyle* -* Handle `Rack::QueryParser` errors in `ActionDispatch::ExceptionWrapper`. +* Add `svg:` renderer: - Updated `ActionDispatch::ExceptionWrapper` to handle the Rack 2.0 namespace - for `ParameterTypeError` and `InvalidParameterError` errors. + ```ruby + class Page + def to_svg + body + end + end + + class PagesController < ActionController::Base + def show + @page = Page.find(params[:id]) + + respond_to do |format| + format.html + format.svg { render svg: @page } + end + end + end + ``` - *Grey Baker* + *Thiago Youssef* -Please check [5-0-stable](https://github.com/rails/rails/blob/5-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/MIT-LICENSE b/actionpack/MIT-LICENSE index ac810e86d0210..7be9ac633faf0 100644 --- a/actionpack/MIT-LICENSE +++ b/actionpack/MIT-LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2004-2017 David Heinemeier Hansson +Copyright (c) David Heinemeier Hansson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/actionpack/README.rdoc b/actionpack/README.rdoc index 0720c66cb9089..8c209108804d6 100644 --- a/actionpack/README.rdoc +++ b/actionpack/README.rdoc @@ -2,9 +2,8 @@ Action Pack is a framework for handling and responding to web requests. It provides mechanisms for *routing* (mapping request URLs to actions), defining -*controllers* that implement actions, and generating responses by rendering -*views*, which are templates of various formats. In short, Action Pack -provides the view and controller layers in the MVC paradigm. +*controllers* that implement actions, and generating responses. In short, Action Pack +provides the controller layer in the MVC paradigm. It consists of several modules: @@ -17,12 +16,13 @@ It consists of several modules: subclassed to implement filters and actions to handle requests. The result of an action is typically content generated from views. -With the Ruby on Rails framework, users only directly interface with the +With the Ruby on \Rails framework, users only directly interface with the Action Controller module. Necessary Action Dispatch functionality is activated by default and Action View rendering is implicitly triggered by Action Controller. However, these modules are designed to function on their own and -can be used outside of Rails. +can be used outside of \Rails. +You can read more about Action Pack in the {Action Controller Overview}[https://guides.rubyonrails.org/action_controller_overview.html] guide. == Download and installation @@ -30,28 +30,28 @@ The latest version of Action Pack can be installed with RubyGems: $ gem install actionpack -Source code can be downloaded as part of the Rails project on GitHub +Source code can be downloaded as part of the \Rails project on GitHub: -* https://github.com/rails/rails/tree/master/actionpack +* https://github.com/rails/rails/tree/main/actionpack == License Action Pack is released under the MIT license: -* http://www.opensource.org/licenses/MIT +* https://opensource.org/licenses/MIT == Support -API documentation is at +API documentation is at: -* http://api.rubyonrails.org +* https://api.rubyonrails.org -Bug reports can be filed for the Ruby on Rails project here: +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://groups.google.com/forum/?fromgroups#!forum/rubyonrails-core +* https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/actionpack/Rakefile b/actionpack/Rakefile index 31dd1865f938f..fd087a55a46ea 100644 --- a/actionpack/Rakefile +++ b/actionpack/Rakefile @@ -1,11 +1,13 @@ +# frozen_string_literal: true + require "rake/testtask" -test_files = Dir.glob("test/**/*_test.rb") +test_files = FileList["test/**/*_test.rb"] desc "Default Task" task default: :test -task :package +ENV["RAILS_MINITEST_PLUGIN"] = "true" # Run the unit tests Rake::TestTask.new do |t| @@ -14,19 +16,26 @@ Rake::TestTask.new do |t| t.warning = true t.verbose = true + t.options = "--profile" if ENV["CI"] t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) 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 - load File.expand_path("..", File.dirname(__FILE__)) + "/tools/line_statistics" + load File.expand_path("../tools/line_statistics", __dir__) files = FileList["lib/**/*.rb"] CodeTools::LineStatistics.new(files).print_loc end diff --git a/actionpack/actionpack.gemspec b/actionpack/actionpack.gemspec index 2c24a54305c76..053897cc7dbcd 100644 --- a/actionpack/actionpack.gemspec +++ b/actionpack/actionpack.gemspec @@ -1,4 +1,6 @@ -version = File.read(File.expand_path("../../RAILS_VERSION", __FILE__)).strip +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY @@ -7,24 +9,39 @@ Gem::Specification.new do |s| s.summary = "Web-flow and rendering framework putting the VC in MVC (part of Rails)." s.description = "Web apps on Rails. Simple, battle-tested conventions for building and testing MVC web applications. Works with any Rack-compatible server." - s.required_ruby_version = ">= 2.2.2" + s.required_ruby_version = ">= 3.2.0" s.license = "MIT" s.author = "David Heinemeier Hansson" s.email = "david@loudthinking.com" - s.homepage = "http://rubyonrails.org" + s.homepage = "https://rubyonrails.org" s.files = Dir["CHANGELOG.md", "README.rdoc", "MIT-LICENSE", "lib/**/*"] s.require_path = "lib" s.requirements << "none" + s.metadata = { + "bug_tracker_uri" => "https://github.com/rails/rails/issues", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actionpack/CHANGELOG.md", + "documentation_uri" => "https://api.rubyonrails.org/v#{version}/", + "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actionpack", + "rubygems_mfa_required" => "true", + } + + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + s.add_dependency "activesupport", version - s.add_dependency "rack", "~> 2.0" - s.add_dependency "rack-test", "~> 0.6.3" - s.add_dependency "rails-html-sanitizer", "~> 1.0", ">= 1.0.2" - s.add_dependency "rails-dom-testing", "~> 2.0" + s.add_dependency "nokogiri", ">= 1.8.5" + s.add_dependency "rack", ">= 2.2.4" + s.add_dependency "rack-session", ">= 1.0.1" + s.add_dependency "rack-test", ">= 0.6.3" + s.add_dependency "rails-html-sanitizer", "~> 1.6" + s.add_dependency "rails-dom-testing", "~> 2.2" + s.add_dependency "useragent", "~> 0.16" s.add_dependency "actionview", version s.add_development_dependency "activemodel", version diff --git a/actionpack/bin/test b/actionpack/bin/test index a7beb14b271a3..c53377cc970f4 100755 --- a/actionpack/bin/test +++ b/actionpack/bin/test @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true COMPONENT_ROOT = File.expand_path("..", __dir__) -require File.expand_path("../tools/test", COMPONENT_ROOT) +require_relative "../../tools/test" diff --git a/actionpack/lib/abstract_controller.rb b/actionpack/lib/abstract_controller.rb index 8bd965b198f3a..6b3004045b4dd 100644 --- a/actionpack/lib/abstract_controller.rb +++ b/actionpack/lib/abstract_controller.rb @@ -1,10 +1,17 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_pack" +require "active_support" require "active_support/rails" require "active_support/i18n" +require "abstract_controller/deprecator" module AbstractController extend ActiveSupport::Autoload + autoload :ActionNotFound, "abstract_controller/base" autoload :Base autoload :Caching autoload :Callbacks @@ -20,5 +27,10 @@ module AbstractController def self.eager_load! super AbstractController::Caching.eager_load! + AbstractController::Base.descendants.each do |controller| + unless controller.abstract? + controller.eager_load! + end + end end end diff --git a/actionpack/lib/abstract_controller/asset_paths.rb b/actionpack/lib/abstract_controller/asset_paths.rb index e6170228d9ce5..44e30fb917bba 100644 --- a/actionpack/lib/abstract_controller/asset_paths.rb +++ b/actionpack/lib/abstract_controller/asset_paths.rb @@ -1,10 +1,16 @@ +# frozen_string_literal: true + +# :markup: markdown + module AbstractController - module AssetPaths #:nodoc: + module AssetPaths # :nodoc: extend ActiveSupport::Concern included do - config_accessor :asset_host, :assets_dir, :javascripts_dir, - :stylesheets_dir, :default_asset_host_protocol, :relative_url_root + singleton_class.delegate :asset_host, :asset_host=, :assets_dir, :assets_dir=, :javascripts_dir, :javascripts_dir=, + :stylesheets_dir, :stylesheets_dir=, :default_asset_host_protocol, :default_asset_host_protocol=, :relative_url_root, :relative_url_root=, to: :config + delegate :asset_host, :asset_host=, :assets_dir, :assets_dir=, :javascripts_dir, :javascripts_dir=, + :stylesheets_dir, :stylesheets_dir=, :default_asset_host_protocol, :default_asset_host_protocol=, :relative_url_root, :relative_url_root=, to: :config end end end diff --git a/actionpack/lib/abstract_controller/base.rb b/actionpack/lib/abstract_controller/base.rb index e7cb6347a2d28..622cafa939bdf 100644 --- a/actionpack/lib/abstract_controller/base.rb +++ b/actionpack/lib/abstract_controller/base.rb @@ -1,5 +1,8 @@ +# frozen_string_literal: true + +# :markup: markdown + require "abstract_controller/error" -require "active_support/configurable" require "active_support/descendants_tracker" require "active_support/core_ext/module/anonymous" require "active_support/core_ext/module/attr_internal" @@ -7,92 +10,117 @@ module AbstractController # Raised when a non-existing controller action is triggered. class ActionNotFound < StandardError + attr_reader :controller, :action # :nodoc: + + def initialize(message = nil, controller = nil, action = nil) # :nodoc: + @controller = controller + @action = action + super(message) + end + + if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker) + include DidYouMean::Correctable # :nodoc: + + def corrections # :nodoc: + @corrections ||= DidYouMean::SpellChecker.new(dictionary: controller.class.action_methods).correct(action) + end + end end - # AbstractController::Base is a low-level API. Nobody should be - # using it directly, and subclasses (like ActionController::Base) are - # expected to provide their own +render+ method, since rendering means - # different things depending on the context. + # # Abstract Controller Base + # + # AbstractController::Base is a low-level API. Nobody should be using it + # directly, and subclasses (like ActionController::Base) are expected to provide + # their own `render` method, since rendering means different things depending on + # the context. class Base + ## + # Returns the body of the HTTP response sent by the controller. attr_internal :response_body + + ## + # Returns the name of the action this controller is processing. attr_internal :action_name + + ## + # Returns the formats that can be processed by the controller. attr_internal :formats - include ActiveSupport::Configurable + class_attribute :config, instance_predicate: false, default: ActiveSupport::OrderedOptions.new extend ActiveSupport::DescendantsTracker class << self attr_reader :abstract alias_method :abstract?, :abstract - # Define a controller as abstract. See internal_methods for more - # details. + # Define a controller as abstract. See internal_methods for more details. def abstract! @abstract = true end def inherited(klass) # :nodoc: - # Define the abstract ivar on subclasses so that we don't get - # uninitialized ivar warnings + # Define the abstract ivar on subclasses so that we don't get uninitialized ivar + # warnings unless klass.instance_variable_defined?(:@abstract) klass.instance_variable_set(:@abstract, false) end + klass.config = ActiveSupport::InheritableOptions.new(config) super end - # A list of all internal methods for a controller. This finds the first - # abstract superclass of a controller, and gets a list of all public - # instance methods on that abstract class. Public instance methods of - # a controller would normally be considered action methods, so methods - # declared on abstract classes are being removed. - # (ActionController::Metal and ActionController::Base are defined as abstract) + # A list of all internal methods for a controller. This finds the first abstract + # superclass of a controller, and gets a list of all public instance methods on + # that abstract class. Public instance methods of a controller would normally be + # considered action methods, so methods declared on abstract classes are being + # removed. (ActionController::Metal and ActionController::Base are defined as + # abstract) def internal_methods controller = self + methods = [] + + until controller.abstract? + methods += controller.public_instance_methods(false) + controller = controller.superclass + end - controller = controller.superclass until controller.abstract? - controller.public_instance_methods(true) + controller.public_instance_methods(true) - methods end - # A list 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. + # 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. def action_methods @action_methods ||= begin - # All public instance methods of this class, including ancestors - methods = (public_instance_methods(true) - - # Except for public instance methods of Base and its ancestors - internal_methods + - # Be sure to include shadowed public instance methods of this class - public_instance_methods(false)).uniq.map(&:to_s) - + # 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 + methods.map!(&:name) methods.to_set end end - # action_methods are cached and there is sometimes a need to refresh - # them. ::clear_action_methods! allows you to do that, so next time - # you run action_methods, they will be recalculated. + # action_methods are cached and there is sometimes a need to refresh them. + # ::clear_action_methods! allows you to do that, so next time you run + # action_methods, they will be recalculated. def clear_action_methods! @action_methods = nil end # Returns the full controller name, underscored, without the ending Controller. # - # class MyApp::MyPostsController < AbstractController::Base + # class MyApp::MyPostsController < AbstractController::Base # - # end + # end # - # MyApp::MyPostsController.controller_path # => "my_app/my_posts" + # MyApp::MyPostsController.controller_path # => "my_app/my_posts" # - # ==== Returns - # * String def controller_path - @controller_path ||= name.sub(/Controller$/, "".freeze).underscore unless anonymous? + @controller_path ||= name.delete_suffix("Controller").underscore unless anonymous? + end + + def configure # :nodoc: + yield config end # Refresh the cached action_methods when a new action_method is added. @@ -100,147 +128,156 @@ def method_added(name) super clear_action_methods! end + + def eager_load! # :nodoc: + action_methods + nil + end end abstract! - # Calls the action going through the entire action dispatch stack. - # - # 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. + # Calls the action going through the entire Action Dispatch stack. # - # ==== Returns - # * self - def process(action, *args) + # 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. + def process(action, ...) @_action_name = action.to_s unless action_name = _find_action_name(@_action_name) - raise ActionNotFound, "The action '#{action}' could not be found for #{self.class.name}" + raise ActionNotFound.new("The action '#{action}' could not be found for #{self.class.name}", self, action) end @_response_body = nil - process_action(action_name, *args) + process_action(action_name, ...) end - # Delegates to the class' ::controller_path + # Delegates to the class's ::controller_path. def controller_path self.class.controller_path end - # Delegates to the class' ::action_methods + # Delegates to the class's ::action_methods. def action_methods self.class.action_methods end - # Returns true if a method for the action is available and - # can be dispatched, false otherwise. + # Returns true if a method for the action is available and can be dispatched, + # false otherwise. # - # Notice that action_methods.include?("foo") may return - # false and available_action?("foo") returns true because - # this method considers actions that are also available - # through other means, for example, implicit render ones. + # Notice that `action_methods.include?("foo")` may return false and + # `available_action?("foo")` returns true because this method considers actions + # that are also available through other means, for example, implicit render + # ones. + # + # #### Parameters + # * `action_name` - The name of an action to be tested # - # ==== Parameters - # * action_name - The name of an action to be tested def available_action?(action_name) _find_action_name(action_name) end - # Tests if a response body is set. Used to determine if the - # +process_action+ callback needs to be terminated in - # +AbstractController::Callbacks+. + # Tests if a response body is set. Used to determine if the `process_action` + # callback needs to be terminated in AbstractController::Callbacks. def performed? response_body end - # Returns true if the given controller is capable of rendering - # a path. A subclass of +AbstractController::Base+ - # may return false. An Email controller for example does not - # support paths, only full URLs. + # Returns true if the given controller is capable of rendering a path. A + # subclass of `AbstractController::Base` may return false. An Email controller + # for example does not support paths, only full URLs. def self.supports_path? true end - private + def config # :nodoc: + @_config ||= self.class.config.inheritable_copy + end - # Returns true if the name can be considered an action because - # it has a method defined in the controller. + def inspect # :nodoc: + "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" + end + + private + # Returns true if the name can be considered an action because it has a method + # defined in the controller. # - # ==== Parameters - # * name - The name of an action to be tested + # #### Parameters + # * `name` - The name of an action to be tested # - # :api: private def action_method?(name) self.class.action_methods.include?(name) end - # Call the action. Override this in a subclass to modify the - # behavior around processing an action. This, and not #process, - # is the intended way to override action dispatching. + # Call the action. Override this in a subclass to modify the behavior around + # processing an action. This, and not #process, is the intended way to override + # action dispatching. # - # Notice that the first argument is the method to be dispatched - # which is *not* necessarily the same as the action name. - def process_action(method_name, *args) - send_action(method_name, *args) + # Notice that the first argument is the method to be dispatched which is **not** + # necessarily the same as the action name. + def process_action(...) + send_action(...) end - # Actually call the method associated with the action. Override - # this method if you wish to change how action methods are called, - # not to add additional behavior around it. For example, you would - # override #send_action if you want to inject arguments into the - # method. + # Actually call the method associated with the action. Override this method if + # you wish to change how action methods are called, not to add additional + # behavior around it. For example, you would override #send_action if you want + # to inject arguments into the method. alias send_action send - # If the action name was not found, but a method called "action_missing" - # was found, #method_for_action will return "_handle_action_missing". - # This method calls #action_missing with the current action name. + # If the action name was not found, but a method called "action_missing" was + # found, #method_for_action will return "_handle_action_missing". This method + # calls #action_missing with the current action name. def _handle_action_missing(*args) action_missing(@_action_name, *args) end - # Takes an action name and returns the name of the method that will - # handle the action. + # Takes an action name and returns the name of the method that will handle the + # action. # # It checks if the action name is valid and returns false otherwise. # # See method_for_action for more information. # - # ==== Parameters - # * action_name - An action name to find a method name for + # #### Parameters + # * `action_name` - An action name to find a method name for + # # - # ==== Returns - # * string - The name of the method that handles the action - # * false - No valid method name could be found. - # Raise +AbstractController::ActionNotFound+. + # #### Returns + # * `string` - The name of the method that handles the action + # * false - No valid method name could be found. + # + # Raise `AbstractController::ActionNotFound`. def _find_action_name(action_name) _valid_action_name?(action_name) && method_for_action(action_name) end - # Takes an action name and returns the name of the method that will - # handle the action. In normal cases, this method returns the same - # name as it receives. By default, if #method_for_action receives - # a name that is not an action, it will look for an #action_missing - # method and return "_handle_action_missing" if one is found. + # Takes an action name and returns the name of the method that will handle the + # action. In normal cases, this method returns the same name as it receives. By + # default, if #method_for_action receives a name that is not an action, it will + # look for an #action_missing method and return "_handle_action_missing" if one + # is found. + # + # Subclasses may override this method to add additional conditions that should + # be considered an action. For instance, an HTTP controller with a template + # matching the action name is considered to exist. + # + # If you override this method to handle additional cases, you may also provide a + # method (like `_handle_method_missing`) to handle the case. # - # Subclasses may override this method to add additional conditions - # that should be considered an action. For instance, an HTTP controller - # with a template matching the action name is considered to exist. + # If none of these conditions are true, and `method_for_action` returns `nil`, + # an `AbstractController::ActionNotFound` exception will be raised. # - # If you override this method to handle additional cases, you may - # also provide a method (like +_handle_method_missing+) to handle - # the case. + # #### Parameters + # * `action_name` - An action name to find a method name for # - # If none of these conditions are true, and +method_for_action+ - # returns +nil+, an +AbstractController::ActionNotFound+ exception will be raised. # - # ==== Parameters - # * action_name - An action name to find a method name for + # #### Returns + # * `string` - The name of the method that handles the action + # * `nil` - No method name could be found. # - # ==== Returns - # * string - The name of the method that handles the action - # * nil - No method name could be found. def method_for_action(action_name) if action_method?(action_name) action_name diff --git a/actionpack/lib/abstract_controller/caching.rb b/actionpack/lib/abstract_controller/caching.rb index 26e3f08bc1d33..2b976395fede0 100644 --- a/actionpack/lib/abstract_controller/caching.rb +++ b/actionpack/lib/abstract_controller/caching.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module AbstractController module Caching extend ActiveSupport::Concern @@ -13,7 +17,7 @@ def cache_store end def cache_store=(store) - config.cache_store = ActiveSupport::Cache.lookup_store(store) + config.cache_store = ActiveSupport::Cache.lookup_store(*store) end private @@ -28,17 +32,19 @@ def cache_configured? included do extend ConfigMethods - config_accessor :default_static_extension + singleton_class.delegate :default_static_extension, :default_static_extension=, to: :config + delegate :default_static_extension, :default_static_extension=, to: :config self.default_static_extension ||= ".html" - config_accessor :perform_caching + singleton_class.delegate :perform_caching, :perform_caching=, to: :config + delegate :perform_caching, :perform_caching=, to: :config self.perform_caching = true if perform_caching.nil? - config_accessor :enable_fragment_cache_logging + singleton_class.delegate :enable_fragment_cache_logging, :enable_fragment_cache_logging=, to: :config + delegate :enable_fragment_cache_logging, :enable_fragment_cache_logging=, to: :config self.enable_fragment_cache_logging = false - class_attribute :_view_cache_dependencies - self._view_cache_dependencies = [] + class_attribute :_view_cache_dependencies, default: [] helper_method :view_cache_dependencies if respond_to?(:helper_method) end @@ -49,7 +55,7 @@ def view_cache_dependency(&dependency) end def view_cache_dependencies - self.class._view_cache_dependencies.map { |dep| instance_exec(&dep) }.compact + self.class._view_cache_dependencies.filter_map { |dep| instance_exec(&dep) } end private diff --git a/actionpack/lib/abstract_controller/caching/fragments.rb b/actionpack/lib/abstract_controller/caching/fragments.rb index 13fa2b393d401..8998e204aa6a5 100644 --- a/actionpack/lib/abstract_controller/caching/fragments.rb +++ b/actionpack/lib/abstract_controller/caching/fragments.rb @@ -1,18 +1,23 @@ +# frozen_string_literal: true + +# :markup: markdown + module AbstractController module Caching - # Fragment caching is used for caching various blocks within - # views without caching the entire action as a whole. This is - # useful when certain elements of an action change frequently or - # depend on complicated state while other parts rarely change or - # can be shared amongst multiple parties. The caching is done using - # the +cache+ helper available in the Action View. See + # # Abstract Controller Caching Fragments + # + # Fragment caching is used for caching various blocks within views without + # caching the entire action as a whole. This is useful when certain elements of + # an action change frequently or depend on complicated state while other parts + # rarely change or can be shared amongst multiple parties. The caching is done + # using the `cache` helper available in the Action View. See # ActionView::Helpers::CacheHelper for more information. # - # While it's strongly recommended that you use key-based cache - # expiration (see links in CacheHelper for more information), - # it is also possible to manually expire caches. For example: + # While it's strongly recommended that you use key-based cache expiration (see + # links in CacheHelper for more information), it is also possible to manually + # expire caches. For example: # - # expire_fragment('name_of_cache') + # expire_fragment('name_of_cache') module Fragments extend ActiveSupport::Concern @@ -25,54 +30,57 @@ module Fragments self.fragment_cache_keys = [] - helper_method :fragment_cache_key if respond_to?(:helper_method) + if respond_to?(:helper_method) + helper_method :combined_fragment_cache_key + end end module ClassMethods - # Allows you to specify controller-wide key prefixes for - # cache fragments. Pass either a constant +value+, or a block - # which computes a value each time a cache key is generated. + # Allows you to specify controller-wide key prefixes for cache fragments. Pass + # either a constant `value`, or a block which computes a value each time a cache + # key is generated. # - # For example, you may want to prefix all fragment cache keys - # with a global version identifier, so you can easily - # invalidate all caches. + # For example, you may want to prefix all fragment cache keys with a global + # version identifier, so you can easily invalidate all caches. # - # class ApplicationController - # fragment_cache_key "v1" - # end + # class ApplicationController + # fragment_cache_key "v1" + # end # - # When it's time to invalidate all fragments, simply change - # the string constant. Or, progressively roll out the cache - # invalidation using a computed value: + # When it's time to invalidate all fragments, simply change the string constant. + # Or, progressively roll out the cache invalidation using a computed value: # - # class ApplicationController - # fragment_cache_key do - # @account.id.odd? ? "v1" : "v2" + # class ApplicationController + # fragment_cache_key do + # @account.id.odd? ? "v1" : "v2" + # end # end - # end def fragment_cache_key(value = nil, &key) self.fragment_cache_keys += [key || -> { value }] end end - # Given a key (as described in +expire_fragment+), returns - # a key suitable for use in reading, writing, or expiring a - # cached fragment. All keys begin with views/, - # followed by any controller-wide key prefix values, ending - # with the specified +key+ value. The key is expanded using - # ActiveSupport::Cache.expand_cache_key. - def fragment_cache_key(key) + # Given a key (as described in `expire_fragment`), returns a key array suitable + # for use in reading, writing, or expiring a cached fragment. All keys begin + # with `:views`, followed by `ENV["RAILS_CACHE_ID"]` or + # `ENV["RAILS_APP_VERSION"]` if set, followed by any controller-wide key prefix + # values, ending with the specified `key` value. + def combined_fragment_cache_key(key) head = self.class.fragment_cache_keys.map { |k| instance_exec(&k) } tail = key.is_a?(Hash) ? url_for(key).split("://").last : key - ActiveSupport::Cache.expand_cache_key([*head, *tail], :views) + + cache_key = [:views, ENV["RAILS_CACHE_ID"] || ENV["RAILS_APP_VERSION"], head, tail] + cache_key.flatten!(1) + cache_key.compact! + cache_key end - # Writes +content+ to the location signified by - # +key+ (see +expire_fragment+ for acceptable formats). + # Writes `content` to the location signified by `key` (see `expire_fragment` for + # acceptable formats). def write_fragment(key, content, options = nil) return content unless cache_configured? - key = fragment_cache_key(key) + key = combined_fragment_cache_key(key) instrument_fragment_cache :write_fragment, key do content = content.to_str cache_store.write(key, content, options) @@ -80,23 +88,23 @@ def write_fragment(key, content, options = nil) content end - # Reads a cached fragment from the location signified by +key+ - # (see +expire_fragment+ for acceptable formats). + # Reads a cached fragment from the location signified by `key` (see + # `expire_fragment` for acceptable formats). def read_fragment(key, options = nil) return unless cache_configured? - key = fragment_cache_key(key) + key = combined_fragment_cache_key(key) instrument_fragment_cache :read_fragment, key do result = cache_store.read(key, options) result.respond_to?(:html_safe) ? result.html_safe : result end end - # Check if a cached fragment from the location signified by - # +key+ exists (see +expire_fragment+ for acceptable formats). + # Check if a cached fragment from the location signified by `key` exists (see + # `expire_fragment` for acceptable formats). def fragment_exist?(key, options = nil) return unless cache_configured? - key = fragment_cache_key(key) + key = combined_fragment_cache_key(key) instrument_fragment_cache :exist_fragment?, key do cache_store.exist?(key, options) @@ -105,25 +113,24 @@ def fragment_exist?(key, options = nil) # Removes fragments from the cache. # - # +key+ can take one of three forms: + # `key` can take one of three forms: + # + # * String - This would normally take the form of a path, like + # `pages/45/notes`. + # * Hash - Treated as an implicit call to `url_for`, like `{ controller: + # 'pages', action: 'notes', id: 45}` + # * Regexp - Will remove any fragment that matches, so `%r{pages/\d*/notes}` + # might remove all notes. Make sure you don't use anchors in the regex (`^` + # or `$`) because the actual filename matched looks like + # `./cache/filename/path.cache`. Note: Regexp expiration is only supported + # on caches that can iterate over all keys (unlike memcached). # - # * String - This would normally take the form of a path, like - # pages/45/notes. - # * Hash - Treated as an implicit call to +url_for+, like - # { controller: 'pages', action: 'notes', id: 45} - # * Regexp - Will remove any fragment that matches, so - # %r{pages/\d*/notes} might remove all notes. Make sure you - # don't use anchors in the regex (^ or $) because - # the actual filename matched looks like - # ./cache/filename/path.cache. Note: Regexp expiration is - # only supported on caches that can iterate over all keys (unlike - # memcached). # - # +options+ is passed through to the cache store's +delete+ - # method (or delete_matched, for Regexp keys). + # `options` is passed through to the cache store's `delete` method (or + # `delete_matched`, for Regexp keys). def expire_fragment(key, options = nil) return unless cache_configured? - key = fragment_cache_key(key) unless key.is_a?(Regexp) + key = combined_fragment_cache_key(key) unless key.is_a?(Regexp) instrument_fragment_cache :expire_fragment, key do if key.is_a?(Regexp) @@ -134,9 +141,8 @@ def expire_fragment(key, options = nil) end end - def instrument_fragment_cache(name, key) # :nodoc: - payload = instrument_payload(key) - ActiveSupport::Notifications.instrument("#{name}.#{instrument_name}".freeze, payload) { yield } + def instrument_fragment_cache(name, key, &block) # :nodoc: + ActiveSupport::Notifications.instrument("#{name}.#{instrument_name}", instrument_payload(key), &block) end end end diff --git a/actionpack/lib/abstract_controller/callbacks.rb b/actionpack/lib/abstract_controller/callbacks.rb index ce4ecf17ccb05..31bbb1b5ce9cd 100644 --- a/actionpack/lib/abstract_controller/callbacks.rb +++ b/actionpack/lib/abstract_controller/callbacks.rb @@ -1,75 +1,130 @@ +# frozen_string_literal: true + +# :markup: markdown + module AbstractController + # # Abstract Controller Callbacks + # + # Abstract Controller provides hooks during the life cycle of a controller + # action. Callbacks allow you to trigger logic during this cycle. Available + # callbacks are: + # + # * `after_action` + # * `append_after_action` + # * `append_around_action` + # * `append_before_action` + # * `around_action` + # * `before_action` + # * `prepend_after_action` + # * `prepend_around_action` + # * `prepend_before_action` + # * `skip_after_action` + # * `skip_around_action` + # * `skip_before_action` module Callbacks extend ActiveSupport::Concern - # Uses ActiveSupport::Callbacks as the base functionality. For - # more details on the whole callback system, read the documentation - # for ActiveSupport::Callbacks. + # Uses ActiveSupport::Callbacks as the base functionality. For more details on + # the whole callback system, read the documentation for + # 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 if result_lambda.is_a?(Proc); controller.performed? }, + terminator: ->(controller, result_lambda) { result_lambda.call; controller.performed? }, skip_after_callbacks_if_terminated: true + mattr_accessor :raise_on_missing_callback_actions, default: false end - # Override AbstractController::Base's process_action to run the - # process_action callbacks around the normal behavior. - def process_action(*args) - run_callbacks(:process_action) do - super + class ActionFilter # :nodoc: + def initialize(filters, conditional_key, actions) + @filters = filters.to_a + @conditional_key = conditional_key + @actions = Array(actions).map(&:to_s).to_set end + + def match?(controller) + if controller.raise_on_missing_callback_actions + missing_action = @actions.find { |action| !controller.available_action?(action) } + if missing_action + filter_names = @filters.length == 1 ? @filters.first.inspect : @filters.inspect + + message = <<~MSG + The #{missing_action} action could not be found for the #{filter_names} + callback on #{controller.class.name}, but it is listed in the controller's + #{@conditional_key.inspect} option. + + Raising for missing callback actions is a new default in Rails 7.1, if you'd + like to turn this off you can delete the option from the environment configurations + or set `config.action_controller.raise_on_missing_callback_actions` to `false`. + MSG + + raise ActionNotFound.new(message, controller, missing_action) + end + end + + @actions.include?(controller.action_name) + end + + alias after match? + alias before match? + alias around match? end module ClassMethods - # If +:only+ or +:except+ are used, convert the options into the - # +:if+ and +:unless+ options of ActiveSupport::Callbacks. + # If `:only` or `:except` are used, convert the options into the `:if` and + # `:unless` options of ActiveSupport::Callbacks. + # + # The basic idea is that `:only => :index` gets converted to `:if => proc {|c| + # c.action_name == "index" }`. # - # The basic idea is that :only => :index gets converted to - # :if => proc {|c| c.action_name == "index" }. + # Note that `:only` has priority over `:if` in case they are used together. # - # Note that :only has priority over :if in case they - # are used together. + # only: :index, if: -> { true } # the :if option will be ignored. # - # only: :index, if: -> { true } # the :if option will be ignored. + # Note that `:if` has priority over `:except` in case they are used together. # - # Note that :if has priority over :except in case they - # are used together. + # except: :index, if: -> { true } # the :except option will be ignored. # - # except: :index, if: -> { true } # the :except option will be ignored. + # #### Options + # * `only` - The callback should be run only for this action. + # * `except` - The callback should be run for all actions except this action. # - # ==== Options - # * only - The callback should be run only for this action. - # * except - The callback should be run for all actions except this action. def _normalize_callback_options(options) _normalize_callback_option(options, :only, :if) _normalize_callback_option(options, :except, :unless) end def _normalize_callback_option(options, from, to) # :nodoc: - if from = options[from] - _from = Array(from).map(&:to_s).to_set - from = proc { |c| _from.include? c.action_name } - options[to] = Array(options[to]).unshift(from) + if from_value = options.delete(from) + filters = options[:filters] + from_value = ActionFilter.new(filters, from, from_value) + options[to] = Array(options[to]).unshift(from_value) end end - # Take callback names and an optional callback proc, normalize them, - # then call the block with each callback. This allows us to abstract - # the normalization across several methods that use it. + # Take callback names and an optional callback proc, normalize them, then call + # the block with each callback. This allows us to abstract the normalization + # across several methods that use it. # - # ==== Parameters - # * callbacks - An array of callbacks, with an optional - # options hash as the last parameter. - # * block - A proc that should be added to the callbacks. + # #### Parameters + # * `callbacks` - An array of callbacks, with an optional options hash as the + # last parameter. + # * `block` - A proc that should be added to the callbacks. + # + # + # #### Block Parameters + # * `name` - The callback to be added. + # * `options` - A hash of options to be used when adding the callback. # - # ==== Block Parameters - # * name - The callback to be added. - # * options - A hash of options to be used when adding the callback. def _insert_callbacks(callbacks, block = nil) options = callbacks.extract_options! - _normalize_callback_options(options) callbacks.push(block) if block + options[:filters] = callbacks + _normalize_callback_options(options) + options.delete(:filters) callbacks.each do |callback| yield callback, options end @@ -81,13 +136,22 @@ def _insert_callbacks(callbacks, block = nil) # :call-seq: before_action(names, block) # # Append a callback before actions. See _insert_callbacks for parameter details. + # + # If the callback renders or redirects, the action will not run. If there are + # additional callbacks scheduled to run after that callback, they are also + # cancelled. ## # :method: prepend_before_action # # :call-seq: prepend_before_action(names, block) # - # Prepend a callback before actions. See _insert_callbacks for parameter details. + # Prepend a callback before actions. See _insert_callbacks for parameter + # details. + # + # If the callback renders or redirects, the action will not run. If there are + # additional callbacks scheduled to run after that callback, they are also + # cancelled. ## # :method: skip_before_action @@ -102,6 +166,10 @@ def _insert_callbacks(callbacks, block = nil) # :call-seq: append_before_action(names, block) # # Append a callback before actions. See _insert_callbacks for parameter details. + # + # If the callback renders or redirects, the action will not run. If there are + # additional callbacks scheduled to run after that callback, they are also + # cancelled. ## # :method: after_action @@ -143,7 +211,8 @@ def _insert_callbacks(callbacks, block = nil) # # :call-seq: prepend_around_action(names, block) # - # Prepend a callback around actions. See _insert_callbacks for parameter details. + # Prepend a callback around actions. See _insert_callbacks for parameter + # details. ## # :method: skip_around_action @@ -158,9 +227,8 @@ def _insert_callbacks(callbacks, block = nil) # :call-seq: append_around_action(names, block) # # Append a callback around actions. See _insert_callbacks for parameter details. - - # set up before_action, prepend_before_action, skip_before_action, etc. - # for each of before, after, and around. + # set up before_action, prepend_before_action, skip_before_action, etc. for each + # of before, after, and around. [:before, :after, :around].each do |callback| define_method "#{callback}_action" do |*names, &blk| _insert_callbacks(names, blk) do |name, options| @@ -174,8 +242,8 @@ def _insert_callbacks(callbacks, block = nil) end end - # Skip a before, after or around callback. See _insert_callbacks - # for details on the allowed parameters. + # Skip a before, after or around callback. See _insert_callbacks for details on + # the allowed parameters. define_method "skip_#{callback}_action" do |*names| _insert_callbacks(names) do |name, options| skip_callback(:process_action, callback, name, options) @@ -185,6 +253,19 @@ 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 + # Override `AbstractController::Base#process_action` to run the `process_action` + # callbacks around the normal behavior. + def process_action(...) + run_callbacks(:process_action) do + super + end + end end end diff --git a/actionpack/lib/abstract_controller/collector.rb b/actionpack/lib/abstract_controller/collector.rb index 40ae5aa1cadac..d9f41d4ef4879 100644 --- a/actionpack/lib/abstract_controller/collector.rb +++ b/actionpack/lib/abstract_controller/collector.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/http/mime_type" module AbstractController @@ -5,8 +9,8 @@ module Collector def self.generate_method_for_mime(mime) sym = mime.is_a?(Symbol) ? mime : mime.to_sym class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{sym}(*args, &block) - custom(Mime[:#{sym}], *args, &block) + def #{sym}(...) + custom(Mime[:#{sym}], ...) end RUBY end @@ -20,11 +24,10 @@ def #{sym}(*args, &block) end private - - def method_missing(symbol, &block) + def method_missing(symbol, ...) unless mime_constant = Mime[symbol] raise NoMethodError, "To respond to a custom format, register it as a MIME type first: " \ - "http://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 { ... } }" @@ -32,7 +35,7 @@ def method_missing(symbol, &block) if Mime::SET.include?(mime_constant) AbstractController::Collector.generate_method_for_mime(mime_constant) - send(symbol, &block) + public_send(symbol, ...) else super end diff --git a/actionpack/lib/abstract_controller/deprecator.rb b/actionpack/lib/abstract_controller/deprecator.rb new file mode 100644 index 0000000000000..989500fa136d3 --- /dev/null +++ b/actionpack/lib/abstract_controller/deprecator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# :markup: markdown + +module AbstractController + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end diff --git a/actionpack/lib/abstract_controller/error.rb b/actionpack/lib/abstract_controller/error.rb index 7fafce4dd4fed..812241bfdd0d1 100644 --- a/actionpack/lib/abstract_controller/error.rb +++ b/actionpack/lib/abstract_controller/error.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + +# :markup: markdown + module AbstractController - class Error < StandardError #:nodoc: + class Error < StandardError # :nodoc: end end diff --git a/actionpack/lib/abstract_controller/helpers.rb b/actionpack/lib/abstract_controller/helpers.rb index ef3be7af83e30..fc807f12a9cfd 100644 --- a/actionpack/lib/abstract_controller/helpers.rb +++ b/actionpack/lib/abstract_controller/helpers.rb @@ -1,120 +1,211 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/dependencies" +require "active_support/core_ext/name_error" module AbstractController module Helpers extend ActiveSupport::Concern included do - class_attribute :_helpers - self._helpers = Module.new + class_attribute :_helper_methods, default: Array.new - class_attribute :_helper_methods - self._helper_methods = Array.new + # This is here so that it is always higher in the inheritance chain than the + # definition in lib/action_view/rendering.rb + redefine_singleton_method(:_helpers) do + if @_helpers ||= nil + @_helpers + else + superclass._helpers + end + end + + self._helpers = define_helpers_module(self) end - class MissingHelperError < LoadError - def initialize(error, path) - @error = error - @path = "helpers/#{path}.rb" - set_backtrace error.backtrace + def _helpers + self.class._helpers + end - if error.path =~ /^#{path}(\.rb)?$/ - super("Missing helper file helpers/%s.rb" % path) - else - raise error + module Resolution # :nodoc: + def modules_for_helpers(modules_or_helper_prefixes) + modules_or_helper_prefixes.flatten.map! do |module_or_helper_prefix| + case module_or_helper_prefix + when Module + module_or_helper_prefix + when String, Symbol + helper_prefix = module_or_helper_prefix.to_s + helper_prefix = helper_prefix.camelize unless helper_prefix.start_with?(/[A-Z]/) + "#{helper_prefix}Helper".constantize + else + raise ArgumentError, "helper must be a String, Symbol, or Module" + end end end + + def all_helpers_from_path(path) + helpers = Array(path).flat_map do |_path| + names = Dir["#{_path}/**/*_helper.rb"].map { |file| file[_path.to_s.size + 1..-"_helper.rb".size - 1] } + names.sort! + end + helpers.uniq! + helpers + end + + def helper_modules_from_paths(paths) + modules_for_helpers(all_helpers_from_path(paths)) + end end + extend Resolution + module ClassMethods - # When a class is inherited, wrap its helper module in a new module. - # This ensures that the parent class's module can be changed - # independently of the child class's. + # When a class is inherited, wrap its helper module in a new module. This + # ensures that the parent class's module can be changed independently of the + # child class's. def inherited(klass) - helpers = _helpers - klass._helpers = Module.new { include helpers } + # Inherited from parent by default + klass._helpers = nil + klass.class_eval { default_helper_module! } unless klass.anonymous? super end + attr_writer :_helpers + + include Resolution + + ## + # :method: modules_for_helpers + # :call-seq: modules_for_helpers(modules_or_helper_prefixes) + # + # Given an array of values like the ones accepted by `helper`, this method + # returns an array with the corresponding modules, in the same order. + # + # ActionController::Base.modules_for_helpers(["application", "chart", "rubygems"]) + # # => [ApplicationHelper, ChartHelper, RubygemsHelper] + # + #-- + # Implemented by Resolution#modules_for_helpers. + + # :method: all_helpers_from_path + # :call-seq: all_helpers_from_path(path) + # + # Returns a list of helper names in a given path. + # + # ActionController::Base.all_helpers_from_path 'app/helpers' + # # => ["application", "chart", "rubygems"] + # + #-- + # Implemented by Resolution#all_helpers_from_path. + # Declare a controller method as a helper. For example, the following - # makes the +current_user+ and +logged_in?+ controller methods available + # makes the `current_user` and `logged_in?` controller methods available # to the view: - # class ApplicationController < ActionController::Base - # helper_method :current_user, :logged_in? # - # def current_user - # @current_user ||= User.find_by(id: session[:user]) - # end + # class ApplicationController < ActionController::Base + # helper_method :current_user, :logged_in? # - # def logged_in? - # current_user != nil + # private + # def current_user + # @current_user ||= User.find_by(id: session[:user]) + # end + # + # def logged_in? + # current_user != nil + # end # end - # end # # In a view: - # <% if logged_in? -%>Welcome, <%= current_user.name %><% end -%> - # - # ==== Parameters - # * method[, method] - A name or names of a method on the controller - # to be made available on the view. - def helper_method(*meths) - meths.flatten! - self._helper_methods += meths - - meths.each do |meth| - _helpers.class_eval <<-ruby_eval, __FILE__, __LINE__ + 1 - def #{meth}(*args, &blk) # def current_user(*args, &blk) - controller.send(%(#{meth}), *args, &blk) # controller.send(:current_user, *args, &blk) - end # end + # + # <% if logged_in? -%>Welcome, <%= current_user.name %><% end -%> + # + # #### Parameters + # * `method[, method]` - A name or names of a method on the controller to be + # made available on the view. + def helper_method(*methods) + methods.flatten! + self._helper_methods += methods + + location = caller_locations(1, 1).first + file, line = location.path, location.lineno + + methods.each do |method| + # def current_user(...) + # controller.send(:'current_user', ...) + # end + _helpers_for_modification.class_eval <<~ruby_eval.lines.map(&:strip).join(";"), file, line + def #{method}(...) + controller.send(:'#{method}', ...) + end ruby_eval end end - # The +helper+ class method can take a series of helper module names, a block, or both. + # Includes the given modules in the template class. # - # ==== Options - # * *args - Module, Symbol, String - # * block - A block defining helper methods + # Modules can be specified in different ways. All of the following calls include + # `FooHelper`: # - # When the argument is a module it will be included directly in the template class. - # helper FooHelper # => includes FooHelper + # # Module, recommended. + # helper FooHelper # - # When the argument is a string or symbol, the method will provide the "_helper" suffix, require the file - # and include the module in the template class. The second form illustrates how to include custom helpers - # when working with namespaced controllers, or other cases where the file containing the helper definition is not - # in one of Rails' standard load paths: - # helper :foo # => requires 'foo_helper' and includes FooHelper - # helper 'resources/foo' # => requires 'resources/foo_helper' and includes Resources::FooHelper + # # String/symbol without the "helper" suffix, camel or snake case. + # helper "Foo" + # helper :Foo + # helper "foo" + # helper :foo # - # Additionally, the +helper+ class method can receive and evaluate a block, making the methods defined available - # to the template. + # The last two assume that `"foo".camelize` returns "Foo". # - # # One line - # helper { def hello() "Hello, world!" end } + # When strings or symbols are passed, the method finds the actual module object + # using String#constantize. Therefore, if the module has not been yet loaded, it + # has to be autoloadable, which is normally the case. # - # # Multi-line - # helper do - # def foo(bar) - # "#{bar} is the very best" + # Namespaces are supported. The following calls include `Foo::BarHelper`: + # + # # Module, recommended. + # helper Foo::BarHelper + # + # # String/symbol without the "helper" suffix, camel or snake case. + # helper "Foo::Bar" + # helper :"Foo::Bar" + # helper "foo/bar" + # helper :"foo/bar" + # + # The last two assume that `"foo/bar".camelize` returns "Foo::Bar". + # + # The method accepts a block too. If present, the block is evaluated in the + # context of the controller helper module. This simple call makes the `wadus` + # method available in templates of the enclosing controller: + # + # helper do + # def wadus + # "wadus" + # end # end - # end # - # Finally, all the above styles can be mixed together, and the +helper+ method can be invoked with a mix of - # +symbols+, +strings+, +modules+ and blocks. + # Furthermore, all the above styles can be mixed together: # - # helper(:three, BlindHelper) { def mice() 'mice' end } + # helper FooHelper, "woo", "bar/baz" do + # def wadus + # "wadus" + # end + # end # def helper(*args, &block) modules_for_helpers(args).each do |mod| - add_template_helper(mod) + next if _helpers.include?(mod) + _helpers_for_modification.include(mod) end - _helpers.module_eval(&block) if block_given? + _helpers_for_modification.module_eval(&block) if block_given? end - # Clears up all existing helpers in this class, only keeping the helper - # with the same name as this class. + # Clears up all existing helpers in this class, only keeping the helper with the + # same name as this class. def clear_helpers inherited_helper_methods = _helper_methods self._helpers = Module.new @@ -124,71 +215,30 @@ def clear_helpers default_helper_module! unless anonymous? end - # Returns a list of modules, normalized from the acceptable kinds of - # helpers with the following behavior: - # - # String or Symbol:: :FooBar or "FooBar" becomes "foo_bar_helper", - # and "foo_bar_helper.rb" is loaded using require_dependency. - # - # Module:: No further processing - # - # After loading the appropriate files, the corresponding modules - # are returned. - # - # ==== Parameters - # * args - An array of helpers - # - # ==== Returns - # * Array - A normalized list of modules for the list of - # helpers provided. - def modules_for_helpers(args) - args.flatten.map! do |arg| - case arg - when String, Symbol - file_name = "#{arg.to_s.underscore}_helper" - begin - require_dependency(file_name) - rescue LoadError => e - raise AbstractController::Helpers::MissingHelperError.new(e, file_name) - end - - mod_name = file_name.camelize - begin - mod_name.constantize - rescue LoadError - # dependencies.rb gives a similar error message but its wording is - # not as clear because it mentions autoloading. To the user all it - # matters is that a helper module couldn't be loaded, autoloading - # is an internal mechanism that should not leak. - raise NameError, "Couldn't find #{mod_name}, expected it to be defined in helpers/#{file_name}.rb" - end - when Module - arg - else - raise ArgumentError, "helper must be a String, Symbol, or Module" - end + def _helpers_for_modification + unless @_helpers + self._helpers = define_helpers_module(self, superclass._helpers) end + _helpers end private - # Makes all the (instance) methods in the helper module available to templates - # rendered through this controller. - # - # ==== Parameters - # * module - The module to include into the current helper module - # for the class - def add_template_helper(mod) - _helpers.module_eval { include mod } + def define_helpers_module(klass, helpers = nil) + # In some tests inherited is called explicitly. In that case, just return the + # module from the first time it was defined + return klass.const_get(:HelperMethods) if klass.const_defined?(:HelperMethods, false) + + mod = Module.new + klass.const_set(:HelperMethods, mod) + mod.include(helpers) if helpers + mod end def default_helper_module! - module_name = name.sub(/Controller$/, "".freeze) - module_path = module_name.underscore - helper module_path - rescue LoadError => e - raise e unless e.is_missing? "helpers/#{module_path}_helper" + helper_prefix = name.delete_suffix("Controller") + helper(helper_prefix) rescue NameError => e - raise e unless e.missing_name? "#{module_name}Helper" + raise unless e.missing_name?("#{helper_prefix}Helper") end end end diff --git a/actionpack/lib/abstract_controller/logger.rb b/actionpack/lib/abstract_controller/logger.rb index c31ea6c5b52c7..5a81638dd69ea 100644 --- a/actionpack/lib/abstract_controller/logger.rb +++ b/actionpack/lib/abstract_controller/logger.rb @@ -1,11 +1,16 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/benchmarkable" module AbstractController - module Logger #:nodoc: + module Logger # :nodoc: extend ActiveSupport::Concern included do - config_accessor :logger + singleton_class.delegate :logger, :logger=, to: :config + delegate :logger, :logger=, to: :config include ActiveSupport::Benchmarkable end end diff --git a/actionpack/lib/abstract_controller/railties/routes_helpers.rb b/actionpack/lib/abstract_controller/railties/routes_helpers.rb index 14b574e322825..3e909512e41fc 100644 --- a/actionpack/lib/abstract_controller/railties/routes_helpers.rb +++ b/actionpack/lib/abstract_controller/railties/routes_helpers.rb @@ -1,3 +1,9 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/module/introspection" + module AbstractController module Railties module RoutesHelpers @@ -5,7 +11,8 @@ def self.with(routes, include_path_helpers = true) Module.new do define_method(:inherited) do |klass| super(klass) - if namespace = klass.parents.detect { |m| m.respond_to?(:railtie_routes_url_helpers) } + + if namespace = klass.module_parents.detect { |m| m.respond_to?(:railtie_routes_url_helpers) } klass.include(namespace.railtie_routes_url_helpers(include_path_helpers)) else klass.include(routes.url_helpers(include_path_helpers)) diff --git a/actionpack/lib/abstract_controller/rendering.rb b/actionpack/lib/abstract_controller/rendering.rb index 54af938a9319b..346bb4e2b9102 100644 --- a/actionpack/lib/abstract_controller/rendering.rb +++ b/actionpack/lib/abstract_controller/rendering.rb @@ -1,11 +1,14 @@ +# frozen_string_literal: true + +# :markup: markdown + require "abstract_controller/error" require "action_view" require "action_view/view_paths" -require "set" module AbstractController class DoubleRenderError < Error - DEFAULT_MESSAGE = "Render and/or redirect were called multiple times in this action. Please note that you may only call render OR redirect, and at most once per action. Also note that neither redirect nor render terminate execution of the action, so if you want to exit an action after redirecting, you need to do something like \"redirect_to(...) and return\"." + DEFAULT_MESSAGE = "Render and/or redirect were called multiple times in this action. Please note that you may only call render OR redirect, and at most once per action. Also note that neither redirect nor render terminate execution of the action, so if you want to exit an action after redirecting, you need to do something like \"redirect_to(...); return\"." def initialize(message = nil) super(message || DEFAULT_MESSAGE) @@ -16,9 +19,10 @@ module Rendering extend ActiveSupport::Concern include ActionView::ViewPaths - # Normalizes arguments, options and then delegates render_to_body and - # sticks the result in self.response_body. - # :api: public + # Normalizes arguments and options, and then delegates to render_to_body and + # sticks the result in `self.response_body`. + # + # Supported options depend on the underlying `render_to_body` implementation. def render(*args, &block) options = _normalize_render(*args, &block) rendered_body = render_to_body(options) @@ -27,58 +31,46 @@ def render(*args, &block) else _set_rendered_content_type rendered_format end + _set_vary_header self.response_body = rendered_body end - # Raw rendering of a template to a string. - # - # It is similar to render, except that it does not - # set the +response_body+ and it should be guaranteed - # to always return a string. + # Similar to #render, but only returns the rendered template as a string, + # instead of setting `self.response_body`. # - # If a component extends the semantics of +response_body+ - # (as ActionController extends it to be anything that - # responds to the method each), this method needs to be - # overridden in order to still return a string. - # :api: plugin + # If a component extends the semantics of `response_body` (as ActionController + # extends it to be anything that responds to the method each), this method needs + # to be overridden in order to still return a string. def render_to_string(*args, &block) options = _normalize_render(*args, &block) render_to_body(options) end # Performs the actual template rendering. - # :api: public def render_to_body(options = {}) end - # Returns Content-Type of rendered content - # :api: public + # Returns `Content-Type` of rendered content. def rendered_format Mime[:text] end - DEFAULT_PROTECTED_INSTANCE_VARIABLES = Set.new %i( - @_action_name @_response_body @_formats @_prefixes - ) + DEFAULT_PROTECTED_INSTANCE_VARIABLES = %i(@_action_name @_response_body @_formats @_prefixes) - # This method should return a hash with assigns. - # You can overwrite this configuration per controller. - # :api: public + # This method should return a hash with assigns. You can overwrite this + # configuration per controller. def view_assigns - protected_vars = _protected_ivars - variables = instance_variables + variables = instance_variables - _protected_ivars - variables.reject! { |s| protected_vars.include? s } - variables.each_with_object({}) { |name, hash| + variables.each_with_object({}) do |name, hash| hash[name.slice(1, name.length)] = instance_variable_get(name) - } + end end - # Normalize args by converting render "foo" to - # render :action => "foo" and render "foo/bar" to - # render :file => "foo/bar". - # :api: plugin - def _normalize_args(action = nil, options = {}) + private + # Normalize args by converting `render "foo"` to `render action: "foo"` and + # `render "foo/bar"` to `render file: "foo/bar"`. + def _normalize_args(action = nil, options = {}) # :doc: if action.respond_to?(:permitted?) if action.permitted? action @@ -93,20 +85,17 @@ def _normalize_args(action = nil, options = {}) end # Normalize options. - # :api: plugin - def _normalize_options(options) + def _normalize_options(options) # :doc: options end # Process extra options. - # :api: plugin - def _process_options(options) + def _process_options(options) # :doc: options end # Process the rendered format. - # :api: private - def _process_format(format) + def _process_format(format) # :nodoc: end def _process_variant(options) @@ -115,19 +104,21 @@ def _process_variant(options) def _set_html_content_type # :nodoc: end + def _set_vary_header # :nodoc: + end + def _set_rendered_content_type(format) # :nodoc: end # Normalize args and options. - # :api: private - def _normalize_render(*args, &block) + def _normalize_render(*args, &block) # :nodoc: options = _normalize_args(*args, &block) _process_variant(options) _normalize_options(options) options end - def _protected_ivars # :nodoc: + def _protected_ivars DEFAULT_PROTECTED_INSTANCE_VARIABLES end end diff --git a/actionpack/lib/abstract_controller/translation.rb b/actionpack/lib/abstract_controller/translation.rb index 9e3858802a0b4..212190471e63e 100644 --- a/actionpack/lib/abstract_controller/translation.rb +++ b/actionpack/lib/abstract_controller/translation.rb @@ -1,28 +1,41 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/html_safe_translation" + module AbstractController module Translation - # Delegates to I18n.translate. Also aliased as t. + # Delegates to `I18n.translate`. # # When the given key starts with a period, it will be scoped by the current - # controller and action. So if you call translate(".foo") from - # PeopleController#index, it will convert the call to - # I18n.translate("people.index.foo"). This makes it less repetitive - # to translate many keys within the same controller / action and gives you a - # simple framework for scoping them consistently. - def translate(key, options = {}) - if key.to_s.first == "." + # controller and action. So if you call `translate(".foo")` from + # `PeopleController#index`, it will convert the call to + # `I18n.translate("people.index.foo")`. This makes it less repetitive to + # translate many keys within the same controller / action and gives you a simple + # framework for scoping them consistently. + def translate(key, **options) + if key&.start_with?(".") path = controller_path.tr("/", ".") defaults = [:"#{path}#{key}"] defaults << options[:default] if options[:default] - options[:default] = defaults + options[:default] = defaults.flatten key = "#{path}.#{action_name}#{key}" end - I18n.translate(key, options) + + if options[:default] && ActiveSupport::HtmlSafeTranslation.html_safe_translation_key?(key) + options[:default] = Array(options[:default]).map do |value| + value.is_a?(String) ? ERB::Util.html_escape(value) : value + end + end + + ActiveSupport::HtmlSafeTranslation.translate(key, **options) end alias :t :translate - # Delegates to I18n.localize. Also aliased as l. - def localize(*args) - I18n.localize(*args) + # Delegates to `I18n.localize`. + def localize(object, **options) + I18n.localize(object, **options) end alias :l :localize end diff --git a/actionpack/lib/abstract_controller/url_for.rb b/actionpack/lib/abstract_controller/url_for.rb index 72d07b0927029..c2d2da709981d 100644 --- a/actionpack/lib/abstract_controller/url_for.rb +++ b/actionpack/lib/abstract_controller/url_for.rb @@ -1,10 +1,16 @@ +# frozen_string_literal: true + +# :markup: markdown + module AbstractController - # Includes +url_for+ into the host class (e.g. an abstract controller or mailer). The class - # has to provide a +RouteSet+ by implementing the _routes methods. Otherwise, an - # exception will be raised. + # # URL For + # + # Includes `url_for` into the host class (e.g. an abstract controller or + # mailer). The class has to provide a `RouteSet` by implementing the `_routes` + # methods. Otherwise, an exception will be raised. # - # Note that this module is completely decoupled from HTTP - the only requirement is a valid - # _routes implementation. + # Note that this module is completely decoupled from HTTP - the only requirement + # is a valid `_routes` implementation. module UrlFor extend ActiveSupport::Concern include ActionDispatch::Routing::UrlFor @@ -20,12 +26,10 @@ def _routes end def action_methods - @action_methods ||= begin - if _routes - super - _routes.named_routes.helper_names - else - super - end + @action_methods ||= if _routes + super - _routes.named_routes.helper_names + else + super end end end diff --git a/actionpack/lib/action_controller.rb b/actionpack/lib/action_controller.rb index 50f20aa789f67..847199aaefff0 100644 --- a/actionpack/lib/action_controller.rb +++ b/actionpack/lib/action_controller.rb @@ -1,16 +1,26 @@ -require "active_support/rails" +# frozen_string_literal: true + +# :markup: markdown + require "abstract_controller" require "action_dispatch" -require "action_controller/metal/live" +require "action_controller/deprecator" require "action_controller/metal/strong_parameters" +require "action_controller/metal/exceptions" +# # Action Controller +# +# Action Controller is a module of Action Pack. +# +# Action Controller provides a base controller class that can be subclassed to +# implement filters and actions to handle requests. The result of an action is +# typically content generated from views. module ActionController extend ActiveSupport::Autoload autoload :API autoload :Base autoload :Metal - autoload :Middleware autoload :Renderer autoload :FormBuilder @@ -19,21 +29,27 @@ module ActionController end autoload_under "metal" do + autoload :AllowBrowser autoload :ConditionalGet + autoload :ContentSecurityPolicy autoload :Cookies autoload :DataStreaming + autoload :DefaultHeaders autoload :EtagWithTemplateDigest autoload :EtagWithFlash + autoload :PermissionsPolicy autoload :Flash - autoload :ForceSSL autoload :Head autoload :Helpers autoload :HttpAuthentication autoload :BasicImplicitRender autoload :ImplicitRender autoload :Instrumentation + autoload :Live + autoload :Logging autoload :MimeResponds autoload :ParamsWrapper + autoload :RateLimiting autoload :Redirecting autoload :Renderers autoload :Rendering @@ -50,14 +66,15 @@ module ActionController autoload :ApiRendering end - autoload :TestCase, "action_controller/test_case" - autoload :TemplateAssertions, "action_controller/test_case" + autoload_at "action_controller/test_case" do + autoload :TestCase + autoload :TestRequest + autoload :TemplateAssertions + end end # Common Active Support usage in Action Controller require "active_support/core_ext/module/attribute_accessors" -require "active_support/core_ext/load_error" require "active_support/core_ext/module/attr_internal" require "active_support/core_ext/name_error" -require "active_support/core_ext/uri" require "active_support/inflector" diff --git a/actionpack/lib/action_controller/api.rb b/actionpack/lib/action_controller/api.rb index 5cd8d77ddb510..a44aa2232611c 100644 --- a/actionpack/lib/action_controller/api.rb +++ b/actionpack/lib/action_controller/api.rb @@ -1,104 +1,109 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_view" require "action_controller" require "action_controller/log_subscriber" +require "action_controller/structured_event_subscriber" module ActionController - # API Controller is a lightweight version of ActionController::Base, - # created for applications that don't require all functionalities that a complete - # \Rails controller provides, allowing you to create controllers with just the - # features that you need for API only applications. - # - # An API Controller is different from a normal controller in the sense that - # by default it doesn't include a number of features that are usually required - # by browser access only: layouts and templates rendering, cookies, sessions, - # flash, assets, and so on. This makes the entire controller stack thinner, - # suitable for API applications. It doesn't mean you won't have such - # features if you need them: they're all available for you to include in - # your application, they're just not part of the default API controller stack. - # - # Normally, +ApplicationController+ is the only controller that inherits from - # ActionController::API. All other controllers in turn inherit from - # +ApplicationController+. + # # Action Controller API + # + # API Controller is a lightweight version of ActionController::Base, created for + # applications that don't require all functionalities that a complete Rails + # controller provides, allowing you to create controllers with just the features + # that you need for API only applications. + # + # An API Controller is different from a normal controller in the sense that by + # default it doesn't include a number of features that are usually required by + # browser access only: layouts and templates rendering, flash, assets, and so + # on. This makes the entire controller stack thinner, suitable for API + # applications. It doesn't mean you won't have such features if you need them: + # they're all available for you to include in your application, they're just not + # part of the default API controller stack. + # + # Normally, `ApplicationController` is the only controller that inherits from + # `ActionController::API`. All other controllers in turn inherit from + # `ApplicationController`. # # A sample controller could look like this: # - # class PostsController < ApplicationController - # def index - # posts = Post.all - # render json: posts + # class PostsController < ApplicationController + # def index + # posts = Post.all + # render json: posts + # end # end - # end # # Request, response, and parameters objects all work the exact same way as - # ActionController::Base. + # ActionController::Base. # - # == Renders + # ## Renders # - # The default API Controller stack includes all renderers, which means you - # can use render :json and brothers freely in your controllers. Keep - # in mind that templates are not going to be rendered, so you need to ensure - # your controller is calling either render or redirect_to in - # all actions, otherwise it will return 204 No Content. + # The default API Controller stack includes all renderers, which means you can + # use `render :json` and siblings freely in your controllers. Keep in mind that + # templates are not going to be rendered, so you need to ensure your controller + # is calling either `render` or `redirect_to` in all actions, otherwise it will + # return `204 No Content`. # - # def show - # post = Post.find(params[:id]) - # render json: post - # end + # def show + # post = Post.find(params[:id]) + # render json: post + # end # - # == Redirects + # ## Redirects # # Redirects are used to move from one action to another. You can use the - # redirect_to method in your controllers in the same way as in - # ActionController::Base. For example: + # `redirect_to` method in your controllers in the same way as in + # ActionController::Base. For example: # - # def create - # redirect_to root_url and return if not_authorized? - # # do stuff here - # end + # def create + # redirect_to root_url and return if not_authorized? + # # do stuff here + # end # - # == Adding New Behavior + # ## Adding New Behavior # # In some scenarios you may want to add back some functionality provided by - # ActionController::Base that is not present by default in - # ActionController::API, for instance MimeResponds. This - # module gives you the respond_to method. Adding it is quite simple, - # you just need to include the module in a specific controller or in - # +ApplicationController+ in case you want it available in your entire - # application: - # - # class ApplicationController < ActionController::API - # include ActionController::MimeResponds - # end - # - # class PostsController < ApplicationController - # def index - # posts = Post.all - # - # respond_to do |format| - # format.json { render json: posts } - # format.xml { render xml: posts } + # ActionController::Base that is not present by default in + # `ActionController::API`, for instance `MimeResponds`. This module gives you + # the `respond_to` method. Adding it is quite simple, you just need to include + # the module in a specific controller or in `ApplicationController` in case you + # want it available in your entire application: + # + # class ApplicationController < ActionController::API + # include ActionController::MimeResponds + # end + # + # class PostsController < ApplicationController + # def index + # posts = Post.all + # + # respond_to do |format| + # format.json { render json: posts } + # format.xml { render xml: posts } + # end # end # end - # end # - # Quite straightforward. Make sure to check the modules included in - # ActionController::Base if you want to use any other - # functionality that is not provided by ActionController::API + # Make sure to check the modules included in ActionController::Base if you want + # to use any other functionality that is not provided by `ActionController::API` # out of the box. class API < Metal abstract! - # Shortcut helper that returns all the ActionController::API modules except - # the ones passed as arguments: + # Shortcut helper that returns all the ActionController::API modules except the + # ones passed as arguments: # - # class MyAPIBaseController < ActionController::Metal - # ActionController::API.without_modules(:ForceSSL, :UrlFor).each do |left| - # include left + # class MyAPIBaseController < ActionController::Metal + # ActionController::API.without_modules(:UrlFor).each do |left| + # include left + # end # end - # end # - # This gives better control over what you want to exclude and makes it easier - # to create an API controller class, instead of listing the modules required + # This gives better control over what you want to exclude and makes it easier to + # create an API controller class, instead of listing the modules required # manually. def self.without_modules(*modules) modules = modules.map do |m| @@ -118,23 +123,26 @@ def self.without_modules(*modules) ConditionalGet, BasicImplicitRender, StrongParameters, + RateLimiting, + Caching, - ForceSSL, DataStreaming, + DefaultHeaders, + Logging, - # Before callbacks should also be executed as early as possible, so - # also include them at the bottom. + # Before callbacks should also be executed as early as possible, so also include + # them at the bottom. AbstractController::Callbacks, # Append rescue at the bottom to wrap as much as possible. Rescue, - # Add instrumentations hooks at the bottom, to ensure they instrument - # all the methods properly. + # Add instrumentations hooks at the bottom, to ensure they instrument all the + # methods properly. Instrumentation, - # Params wrapper should come before instrumentation so they are - # properly showed in logs + # Params wrapper should come before instrumentation so they are properly showed + # in logs ParamsWrapper ] @@ -142,6 +150,7 @@ def self.without_modules(*modules) include mod end + ActiveSupport.run_load_hooks(:action_controller_api, self) ActiveSupport.run_load_hooks(:action_controller, self) end end diff --git a/actionpack/lib/action_controller/api/api_rendering.rb b/actionpack/lib/action_controller/api/api_rendering.rb index 3a08d28c393c2..dcb27315f1606 100644 --- a/actionpack/lib/action_controller/api/api_rendering.rb +++ b/actionpack/lib/action_controller/api/api_rendering.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController module ApiRendering extend ActiveSupport::Concern diff --git a/actionpack/lib/action_controller/base.rb b/actionpack/lib/action_controller/base.rb index ca8066cd822b8..c30e793df54f8 100644 --- a/actionpack/lib/action_controller/base.rb +++ b/actionpack/lib/action_controller/base.rb @@ -1,197 +1,225 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_view" require "action_controller/log_subscriber" +require "action_controller/structured_event_subscriber" require "action_controller/metal/params_wrapper" module ActionController - # Action Controllers are the core of a web request in \Rails. They are made up of one or more actions that are executed - # on request and then either it renders a template or redirects to another action. An action is defined as a public method - # on the controller, which will automatically be made accessible to the web-server through \Rails Routes. + # # Action Controller Base + # + # Action Controllers are the core of a web request in Rails. They are made up of + # one or more actions that are executed on request and then either it renders a + # template or redirects to another action. An action is defined as a public + # method on the controller, which will automatically be made accessible to the + # web-server through Rails Routes. # - # By default, only the ApplicationController in a \Rails application inherits from ActionController::Base. All other - # controllers in turn inherit from ApplicationController. This gives you one class to configure things such as + # By default, only the ApplicationController in a Rails application inherits + # from `ActionController::Base`. All other controllers inherit from + # ApplicationController. This gives you one class to configure things such as # request forgery protection and filtering of sensitive request parameters. # # A sample controller could look like this: # - # class PostsController < ApplicationController - # def index - # @posts = Post.all - # end + # class PostsController < ApplicationController + # def index + # @posts = Post.all + # end # - # def create - # @post = Post.create params[:post] - # redirect_to posts_path + # def create + # @post = Post.create params[:post] + # redirect_to posts_path + # end # end - # end - # - # Actions, by default, render a template in the app/views directory corresponding to the name of the controller and action - # after executing code in the action. For example, the +index+ action of the PostsController would render the - # template app/views/posts/index.html.erb by default after populating the @posts instance variable. # - # Unlike index, the create action will not render a template. After performing its main purpose (creating a - # new post), it initiates a redirect instead. This redirect works by returning an external - # "302 Moved" HTTP response that takes the user to the index action. + # Actions, by default, render a template in the `app/views` directory + # corresponding to the name of the controller and action after executing code in + # the action. For example, the `index` action of the PostsController would + # render the template `app/views/posts/index.html.erb` by default after + # populating the `@posts` instance variable. # - # These two methods represent the two basic action archetypes used in Action Controllers: Get-and-show and do-and-redirect. - # Most actions are variations on these themes. + # Unlike index, the create action will not render a template. After performing + # its main purpose (creating a new post), it initiates a redirect instead. This + # redirect works by returning an external `302 Moved` HTTP response that takes + # the user to the index action. # - # == Requests + # These two methods represent the two basic action archetypes used in Action + # Controllers: Get-and-show and do-and-redirect. Most actions are variations on + # these themes. # - # For every request, the router determines the value of the +controller+ and +action+ keys. These determine which controller - # and action are called. The remaining request parameters, the session (if one is available), and the full request with - # all the HTTP headers are made available to the action through accessor methods. Then the action is performed. + # ## Requests # - # The full request object is available via the request accessor and is primarily used to query for HTTP headers: + # For every request, the router determines the value of the `controller` and + # `action` keys. These determine which controller and action are called. The + # remaining request parameters, the session (if one is available), and the full + # request with all the HTTP headers are made available to the action through + # accessor methods. Then the action is performed. # - # def server_ip - # location = request.env["REMOTE_ADDR"] - # render plain: "This server hosted at #{location}" - # end + # The full request object is available via the request accessor and is primarily + # used to query for HTTP headers: # - # == Parameters + # def server_ip + # location = request.env["REMOTE_ADDR"] + # render plain: "This server hosted at #{location}" + # end # - # All request parameters, whether they come from a query string in the URL or form data submitted through a POST request are - # available through the params method which returns a hash. For example, an action that was performed through - # /posts?category=All&limit=5 will include { "category" => "All", "limit" => "5" } in params. + # ## Parameters # - # It's also possible to construct multi-dimensional parameter hashes by specifying keys using brackets, such as: + # All request parameters, whether they come from a query string in the URL or + # form data submitted through a POST request are available through the `params` + # method which returns a hash. For example, an action that was performed through + # `/posts?category=All&limit=5` will include `{ "category" => "All", "limit" => + # "5" }` in `params`. # - # - # + # It's also possible to construct multi-dimensional parameter hashes by + # specifying keys using brackets, such as: # - # A request stemming from a form holding these inputs will include { "post" => { "name" => "david", "address" => "hyacintvej" } }. - # If the address input had been named post[address][street], the params would have included - # { "post" => { "address" => { "street" => "hyacintvej" } } }. There's no limit to the depth of the nesting. + # + # # - # == Sessions + # A request coming from a form holding these inputs will include `{ "post" => { + # "name" => "david", "address" => "hyacintvej" } }`. If the address input had + # been named `post[address][street]`, the `params` would have included `{ "post" + # => { "address" => { "street" => "hyacintvej" } } }`. There's no limit to the + # depth of the nesting. # - # Sessions allow you to store objects in between requests. This is useful for objects that are not yet ready to be persisted, - # such as a Signup object constructed in a multi-paged process, or objects that don't change much and are needed all the time, such - # as a User object for a system that requires login. The session should not be used, however, as a cache for objects where it's likely - # they could be changed unknowingly. It's usually too much work to keep it all synchronized -- something databases already excel at. + # ## Sessions # - # You can place objects in the session by using the session method, which accesses a hash: + # Sessions allow you to store objects in between requests. This is useful for + # objects that are not yet ready to be persisted, such as a Signup object + # constructed in a multi-paged process, or objects that don't change much and + # are needed all the time, such as a User object for a system that requires + # login. The session should not be used, however, as a cache for objects where + # it's likely they could be changed unknowingly. It's usually too much work to + # keep it all synchronized -- something databases already excel at. # - # session[:person] = Person.authenticate(user_name, password) + # You can place objects in the session by using the `session` method, which + # accesses a hash: # - # And retrieved again through the same hash: + # session[:person] = Person.authenticate(user_name, password) # - # Hello #{session[:person]} + # You can retrieve it again through the same hash: # - # For removing objects from the session, you can either assign a single key to +nil+: + # "Hello #{session[:person]}" # - # # removes :person from session - # session[:person] = nil + # For removing objects from the session, you can either assign a single key to + # `nil`: # - # or you can remove the entire session with +reset_session+. + # # removes :person from session + # session[:person] = nil # - # Sessions are stored by default in a browser cookie that's cryptographically signed, but unencrypted. - # This prevents the user from tampering with the session but also allows them to see its contents. + # or you can remove the entire session with `reset_session`. # - # Do not put secret information in cookie-based sessions! + # By default, sessions are stored in an encrypted browser cookie (see + # ActionDispatch::Session::CookieStore). Thus the user will not be able to read + # or edit the session data. However, the user can keep a copy of the cookie even + # after it has expired, so you should avoid storing sensitive information in + # cookie-based sessions. # - # == Responses + # ## Responses # - # Each action results in a response, which holds the headers and document to be sent to the user's browser. The actual response - # object is generated automatically through the use of renders and redirects and requires no user intervention. + # Each action results in a response, which holds the headers and document to be + # sent to the user's browser. The actual response object is generated + # automatically through the use of renders and redirects and requires no user + # intervention. # - # == Renders + # ## Renders # - # Action Controller sends content to the user by using one of five rendering methods. The most versatile and common is the rendering - # of a template. Included in the Action Pack is the Action View, which enables rendering of ERB templates. It's automatically configured. - # The controller passes objects to the view by assigning instance variables: + # Action Controller sends content to the user by using one of five rendering + # methods. The most versatile and common is the rendering of a template. + # Also included with \Rails is Action View, which enables rendering of ERB + # templates. It's automatically configured. The controller passes objects to the + # view by assigning instance variables: # - # def show - # @post = Post.find(params[:id]) - # end + # def show + # @post = Post.find(params[:id]) + # end # # Which are then automatically available to the view: # - # Title: <%= @post.title %> + # Title: <%= @post.title %> # - # You don't have to rely on the automated rendering. For example, actions that could result in the rendering of different templates - # will use the manual rendering methods: + # You don't have to rely on the automated rendering. For example, actions that + # could result in the rendering of different templates will use the manual + # rendering methods: # - # def search - # @results = Search.find(params[:query]) - # case @results.count - # when 0 then render action: "no_results" - # when 1 then render action: "show" - # when 2..10 then render action: "show_many" + # def search + # @results = Search.find(params[:query]) + # case @results.count + # when 0 then render action: "no_results" + # when 1 then render action: "show" + # when 2..10 then render action: "show_many" + # end # end - # end # # Read more about writing ERB and Builder templates in ActionView::Base. # - # == Redirects + # ## Redirects # - # Redirects are used to move from one action to another. For example, after a create action, which stores a blog entry to the - # database, we might like to show the user the new entry. Because we're following good DRY principles (Don't Repeat Yourself), we're - # going to reuse (and redirect to) a show action that we'll assume has already been created. The code might look like this: + # Redirects are used to move from one action to another. For example, after a + # `create` action, which stores a blog entry to the database, we might like to + # show the user the new entry. Because we're following good DRY principles + # (Don't Repeat Yourself), we're going to reuse (and redirect to) a `show` + # action that we'll assume has already been created. The code might look like + # this: # - # def create - # @entry = Entry.new(params[:entry]) - # if @entry.save - # # The entry was saved correctly, redirect to show - # redirect_to action: 'show', id: @entry.id - # else - # # things didn't go so well, do something else + # def create + # @entry = Entry.new(params[:entry]) + # if @entry.save + # # The entry was saved correctly, redirect to show + # redirect_to action: 'show', id: @entry.id + # else + # # things didn't go so well, do something else + # end # end - # end # - # In this case, after saving our new entry to the database, the user is redirected to the show method, which is then executed. - # Note that this is an external HTTP-level redirection which will cause the browser to make a second request (a GET to the show action), - # and not some internal re-routing which calls both "create" and then "show" within one request. + # In this case, after saving our new entry to the database, the user is + # redirected to the `show` method, which is then executed. Note that this is an + # external HTTP-level redirection which will cause the browser to make a second + # request (a GET to the show action), and not some internal re-routing which + # calls both "create" and then "show" within one request. # - # Learn more about redirect_to and what options you have in ActionController::Redirecting. + # Learn more about `redirect_to` and what options you have in + # ActionController::Redirecting. # - # == Calling multiple redirects or renders + # ## Calling multiple redirects or renders # - # An action may contain only a single render or a single redirect. Attempting to try to do either again will result in a DoubleRenderError: + # An action may perform only a single render or a single redirect. Attempting to + # do either again will result in a DoubleRenderError: # - # def do_something - # redirect_to action: "elsewhere" - # render action: "overthere" # raises DoubleRenderError - # end + # def do_something + # redirect_to action: "elsewhere" + # render action: "overthere" # raises DoubleRenderError + # end # - # If you need to redirect on the condition of something, then be sure to add "and return" to halt execution. + # If you need to redirect on the condition of something, then be sure to add + # "return" to halt execution. # - # def do_something - # redirect_to(action: "elsewhere") and return if monkeys.nil? - # render action: "overthere" # won't be called if monkeys is nil - # end + # def do_something + # if monkeys.nil? + # redirect_to(action: "elsewhere") + # return + # end + # render action: "overthere" # won't be called if monkeys is nil + # end # class Base < Metal abstract! - # We document the request and response methods here because albeit they are - # implemented in ActionController::Metal, the type of the returned objects - # is unknown at that level. - - ## - # :method: request - # - # Returns an ActionDispatch::Request instance that represents the - # current request. - - ## - # :method: response - # - # Returns an ActionDispatch::Response that represents the current - # response. - # Shortcut helper that returns all the modules included in # ActionController::Base except the ones passed as arguments: # - # class MyBaseController < ActionController::Metal - # ActionController::Base.without_modules(:ParamsWrapper, :Streaming).each do |left| - # include left + # class MyBaseController < ActionController::Metal + # ActionController::Base.without_modules(:ParamsWrapper, :Streaming).each do |left| + # include left + # end # end - # end # - # This gives better control over what you want to exclude and makes it - # easier to create a bare controller class, instead of listing the modules - # required manually. + # This gives better control over what you want to exclude and makes it easier to + # create a bare controller class, instead of listing the modules required + # manually. def self.without_modules(*modules) modules = modules.map do |m| m.is_a?(Symbol) ? ActionController.const_get(m) : m @@ -204,7 +232,6 @@ def self.without_modules(*modules) AbstractController::Rendering, AbstractController::Translation, AbstractController::AssetPaths, - Helpers, UrlFor, Redirecting, @@ -223,44 +250,84 @@ def self.without_modules(*modules) Flash, FormBuilder, RequestForgeryProtection, - ForceSSL, + ContentSecurityPolicy, + PermissionsPolicy, + RateLimiting, + AllowBrowser, Streaming, DataStreaming, HttpAuthentication::Basic::ControllerMethods, HttpAuthentication::Digest::ControllerMethods, HttpAuthentication::Token::ControllerMethods, - - # Before callbacks should also be executed as early as possible, so - # also include them at the bottom. + DefaultHeaders, + Logging, AbstractController::Callbacks, - - # Append rescue at the bottom to wrap as much as possible. Rescue, - - # Add instrumentations hooks at the bottom, to ensure they instrument - # all the methods properly. Instrumentation, - - # Params wrapper should come before instrumentation so they are - # properly showed in logs ParamsWrapper ] - MODULES.each do |mod| - include mod - end + # Note: Documenting these severely degrades the performance of rdoc + # :stopdoc: + include AbstractController::Rendering + include AbstractController::Translation + include AbstractController::AssetPaths + include Helpers + include UrlFor + include Redirecting + include ActionView::Layouts + include Rendering + include Renderers::All + include ConditionalGet + include EtagWithTemplateDigest + include EtagWithFlash + include Caching + include MimeResponds + include ImplicitRender + include StrongParameters + include ParameterEncoding + include Cookies + include Flash + include FormBuilder + include RequestForgeryProtection + include ContentSecurityPolicy + include PermissionsPolicy + include RateLimiting + include AllowBrowser + include Streaming + include DataStreaming + include HttpAuthentication::Basic::ControllerMethods + include HttpAuthentication::Digest::ControllerMethods + include HttpAuthentication::Token::ControllerMethods + include DefaultHeaders + include Logging + # Before callbacks should also be executed as early as possible, so also include + # them at the bottom. + include AbstractController::Callbacks + # Append rescue at the bottom to wrap as much as possible. + include Rescue + # Add instrumentations hooks at the bottom, to ensure they instrument all the + # methods properly. + include Instrumentation + # Params wrapper should come before instrumentation so they are properly showed + # in logs + include ParamsWrapper + # :startdoc: setup_renderer! # Define some internal variables that should not be propagated to the view. PROTECTED_IVARS = AbstractController::Rendering::DEFAULT_PROTECTED_INSTANCE_VARIABLES + %i( @_params @_response @_request @_config @_url_options @_action_has_layout @_view_context_class @_view_renderer @_lookup_context @_routes @_view_runtime @_db_runtime @_helper_proxy + @_marked_for_same_origin_verification @_verify_authenticity_token_ran @_rendered_format ) - def _protected_ivars # :nodoc: + def _protected_ivars PROTECTED_IVARS end + private :_protected_ivars + ActiveSupport.run_load_hooks(:action_controller_base, self) ActiveSupport.run_load_hooks(:action_controller, self) end end diff --git a/actionpack/lib/action_controller/caching.rb b/actionpack/lib/action_controller/caching.rb index 954265ad97706..4a700952df12b 100644 --- a/actionpack/lib/action_controller/caching.rb +++ b/actionpack/lib/action_controller/caching.rb @@ -1,26 +1,31 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController - # \Caching is a cheap way of speeding up slow applications by keeping the result of - # calculations, renderings, and database calls around for subsequent requests. + # # Action Controller Caching # - # You can read more about each approach by clicking the modules below. + # Caching is a cheap way of speeding up slow applications by keeping the result + # of calculations, renderings, and database calls around for subsequent + # requests. # # Note: To turn off all caching provided by Action Controller, set - # config.action_controller.perform_caching = false # - # == \Caching stores + # config.action_controller.perform_caching = false # - # All the caching stores from ActiveSupport::Cache are available to be used as backends - # for Action Controller caching. + # ## Caching stores + # + # All the caching stores from ActiveSupport::Cache are available to be used as + # backends for Action Controller caching. # # Configuration examples (FileStore is the default): # - # config.action_controller.cache_store = :memory_store - # config.action_controller.cache_store = :file_store, '/path/to/cache/directory' - # config.action_controller.cache_store = :mem_cache_store, 'localhost' - # config.action_controller.cache_store = :mem_cache_store, Memcached::Rails.new('localhost:11211') - # config.action_controller.cache_store = MyOwnStore.new('parameter') + # config.action_controller.cache_store = :memory_store + # config.action_controller.cache_store = :file_store, '/path/to/cache/directory' + # config.action_controller.cache_store = :mem_cache_store, 'localhost' + # config.action_controller.cache_store = :mem_cache_store, Memcached::Rails.new('localhost:11211') + # config.action_controller.cache_store = MyOwnStore.new('parameter') module Caching - extend ActiveSupport::Autoload extend ActiveSupport::Concern included do @@ -28,7 +33,6 @@ module Caching end private - def instrument_payload(key) { controller: controller_name, @@ -38,7 +42,7 @@ def instrument_payload(key) end def instrument_name - "action_controller".freeze + "action_controller" end end end diff --git a/actionpack/lib/action_controller/deprecator.rb b/actionpack/lib/action_controller/deprecator.rb new file mode 100644 index 0000000000000..3c0ea2a2fb512 --- /dev/null +++ b/actionpack/lib/action_controller/deprecator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionController + def self.deprecator # :nodoc: + AbstractController.deprecator + end +end diff --git a/actionpack/lib/action_controller/form_builder.rb b/actionpack/lib/action_controller/form_builder.rb index f2656ca894a77..a14a6e9e19234 100644 --- a/actionpack/lib/action_controller/form_builder.rb +++ b/actionpack/lib/action_controller/form_builder.rb @@ -1,27 +1,33 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController - # Override the default form builder for all views rendered by this - # controller and any of its descendants. Accepts a subclass of - # +ActionView::Helpers::FormBuilder+. + # # Action Controller Form Builder + # + # Override the default form builder for all views rendered by this controller + # and any of its descendants. Accepts a subclass of + # ActionView::Helpers::FormBuilder. # # For example, given a form builder: # - # class AdminFormBuilder < ActionView::Helpers::FormBuilder - # def special_field(name) + # class AdminFormBuilder < ActionView::Helpers::FormBuilder + # def special_field(name) + # end # end - # end # # The controller specifies a form builder as its default: # - # class AdminAreaController < ApplicationController - # default_form_builder AdminFormBuilder - # end + # class AdminAreaController < ApplicationController + # default_form_builder AdminFormBuilder + # end # - # Then in the view any form using +form_for+ will be an instance of the - # specified form builder: + # Then in the view any form using `form_with` or `form_for` will be an + # instance of the specified form builder: # - # <%= form_for(@instance) do |builder| %> - # <%= builder.special_field(:name) %> - # <% end %> + # <%= form_with(model: @instance) do |builder| %> + # <%= builder.special_field(:name) %> + # <% end %> module FormBuilder extend ActiveSupport::Concern @@ -30,11 +36,12 @@ module FormBuilder end module ClassMethods - # Set the form builder to be used as the default for all forms - # in the views rendered by this controller and its subclasses. + # Set the form builder to be used as the default for all forms in the views + # rendered by this controller and its subclasses. # - # ==== Parameters - # * builder - Default form builder, an instance of +ActionView::Helpers::FormBuilder+ + # #### Parameters + # * `builder` - Default form builder. Accepts a subclass of + # ActionView::Helpers::FormBuilder def default_form_builder(builder) self._default_form_builder = builder end diff --git a/actionpack/lib/action_controller/log_subscriber.rb b/actionpack/lib/action_controller/log_subscriber.rb index d29a5fe68ff5c..6e706bb7683a8 100644 --- a/actionpack/lib/action_controller/log_subscriber.rb +++ b/actionpack/lib/action_controller/log_subscriber.rb @@ -1,76 +1,137 @@ +# frozen_string_literal: true + 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 - params = payload[:params].except(*INTERNAL_PARAMS) + def request_started(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? info "Processing by #{payload[:controller]}##{payload[:action]} as #{format}" info " Parameters: #{params.inspect}" unless params.empty? end + 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] - if status.nil? && payload[:exception].present? - exception_class_name = payload[:exception].first + + if status.nil? && (exception_class_name = payload[:exception]&.first) status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception_class_name) end - message = "Completed #{status} #{Rack::Utils::HTTP_STATUS_CODES[status]} in #{event.duration.round}ms" - message << " (#{additions.join(" | ".freeze)})" unless additions.empty? + + additions << "GC: #{payload[:gc_time_ms].round(1)}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 + 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 - def send_file(event) - info { "Sent file #{event.payload[:path]} (#{event.duration.round(1)}ms)" } + # 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 + event_log_level :rescue_from_handled, :info - def redirect_to(event) - info { "Redirected to #{event.payload[:location]}" } + def file_sent(event) + info { "Sent file #{event[:payload][:path]} (#{event[:payload][:duration_ms].round(1)}ms)" } end + event_log_level :file_sent, :info - def send_data(event) - info { "Sent data #{event.payload[:filename]} (#{event.duration.round(1)}ms)" } + def redirected(event) + info { "Redirected to #{event[:payload][:location]}" } + + if ActionDispatch.verbose_redirect_logs && (source = redirect_source_location) + info { "↳ #{source}" } + end + end + event_log_level :redirected, :info + + def data_sent(event) + info { "Sent data #{event[:payload][:filename]} (#{event[:payload][:duration_ms].round(1)}ms)" } end + event_log_level :data_sent, :info def unpermitted_parameters(event) debug do - unpermitted_keys = event.payload[:keys] - "Unpermitted parameter#{'s' if unpermitted_keys.size > 1}: #{unpermitted_keys.map { |e| ":#{e}" }.join(", ")}" + 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(", ") + color("Unpermitted parameter#{'s' if unpermitted_keys.size > 1}: #{display_unpermitted_keys}. Context: { #{context} }", RED) end end + event_log_level :unpermitted_parameters, :debug - %w(write_fragment read_fragment exist_fragment? - expire_fragment expire_page write_page).each do |method| - class_eval <<-METHOD, __FILE__, __LINE__ + 1 - def #{method}(event) - return unless logger.info? && ActionController::Base.enable_fragment_cache_logging - key_or_path = event.payload[:key] || event.payload[:path] - human_name = #{method.to_s.humanize.inspect} - info("\#{human_name} \#{key_or_path} (\#{event.duration.round(1)}ms)") - end - METHOD + def csrf_token_fallback(event) + return unless ActionController::Base.log_warning_on_csrf_failure + + warn do + payload = event[:payload] + "Falling back to CSRF token verification for #{payload[:controller]}##{payload[:action]}" + end + end + event_log_level :csrf_token_fallback, :info + + def csrf_request_blocked(event) + return unless ActionController::Base.log_warning_on_csrf_failure + + warn { event[:payload][:message] } + end + event_log_level :csrf_request_blocked, :info + + def csrf_javascript_blocked(event) + return unless ActionController::Base.log_warning_on_csrf_failure + + warn { event[:payload][:message] } end + event_log_level :csrf_javascript_blocked, :info - def logger + 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 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.rb b/actionpack/lib/action_controller/metal.rb index 337718afc013e..4c58735fa2911 100644 --- a/actionpack/lib/action_controller/metal.rb +++ b/actionpack/lib/action_controller/metal.rb @@ -1,18 +1,22 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/array/extract_options" require "action_dispatch/middleware/stack" -require "action_dispatch/http/request" -require "action_dispatch/http/response" module ActionController - # Extend ActionDispatch middleware stack to make it aware of options - # allowing the following syntax in controllers: + # # Action Controller MiddlewareStack + # + # Extend ActionDispatch middleware stack to make it aware of options allowing + # the following syntax in controllers: # - # class PostsController < ApplicationController - # use AuthenticationMiddleware, except: [:index, :show] - # end + # class PostsController < ApplicationController + # use AuthenticationMiddleware, except: [:index, :show] + # end # - class MiddlewareStack < ActionDispatch::MiddlewareStack #:nodoc: - class Middleware < ActionDispatch::MiddlewareStack::Middleware #:nodoc: + class MiddlewareStack < ActionDispatch::MiddlewareStack # :nodoc: + class Middleware < ActionDispatch::MiddlewareStack::Middleware # :nodoc: def initialize(klass, args, actions, strategy, block) @actions = actions @strategy = strategy @@ -24,16 +28,15 @@ def valid?(action) end end - def build(action, app = Proc.new) + def build(action, app = nil, &block) action = action.to_s - middlewares.reverse.inject(app) do |a, middleware| + middlewares.reverse.inject(app || block) do |a, middleware| middleware.valid?(action) ? middleware.build(a) : a end end private - INCLUDE = ->(list, action) { list.include? action } EXCLUDE = ->(list, action) { !list.include? action } NULL = ->(list, action) { true } @@ -59,99 +62,157 @@ def build_middleware(klass, args, block) end end - # ActionController::Metal is the simplest possible controller, providing a + # # Action Controller Metal + # + # `ActionController::Metal` is the simplest possible controller, providing a # valid Rack interface without the additional niceties provided by - # ActionController::Base. + # ActionController::Base. # # A sample metal controller might look like this: # - # class HelloController < ActionController::Metal - # def index - # self.response_body = "Hello World!" + # class HelloController < ActionController::Metal + # def index + # self.response_body = "Hello World!" + # end # end - # end # - # And then to route requests to your metal controller, you would add - # something like this to config/routes.rb: + # And then to route requests to your metal controller, you would add something + # like this to `config/routes.rb`: # - # get 'hello', to: HelloController.action(:index) + # get 'hello', to: HelloController.action(:index) # - # The +action+ method returns a valid Rack application for the \Rails - # router to dispatch to. + # The ::action method returns a valid Rack application for the Rails router to + # dispatch to. # - # == Rendering Helpers + # ## Rendering Helpers # - # ActionController::Metal by default provides no utilities for rendering - # views, partials, or other responses aside from explicitly calling of - # response_body=, content_type=, and status=. To - # add the render helpers you're used to having in a normal controller, you - # can do the following: + # By default, `ActionController::Metal` provides no utilities for rendering + # views, partials, or other responses aside from some low-level setters such + # as #response_body=, #content_type=, and #status=. To add the render helpers + # you're used to having in a normal controller, you can do the following: # - # class HelloController < ActionController::Metal - # include AbstractController::Rendering - # include ActionView::Layouts - # append_view_path "#{Rails.root}/app/views" + # class HelloController < ActionController::Metal + # include AbstractController::Rendering + # include ActionView::Layouts + # append_view_path "#{Rails.root}/app/views" # - # def index - # render "hello/index" + # def index + # render "hello/index" + # end # end - # end # - # == Redirection Helpers + # ## Redirection Helpers # # To add redirection helpers to your metal controller, do the following: # - # class HelloController < ActionController::Metal - # include ActionController::Redirecting - # include Rails.application.routes.url_helpers + # class HelloController < ActionController::Metal + # include ActionController::Redirecting + # include Rails.application.routes.url_helpers # - # def index - # redirect_to root_url + # def index + # redirect_to root_url + # end # end - # end # - # == Other Helpers - # - # You can refer to the modules included in ActionController::Base to see - # other features you can bring into your metal controller. + # ## Other Helpers # + # You can refer to the modules included in ActionController::Base to see other + # features you can bring into your metal controller. class Metal < AbstractController::Base abstract! - # Returns the last part of the controller's name, underscored, without the ending - # Controller. For instance, PostsController returns posts. - # Namespaces are left out, so Admin::PostsController returns posts as well. + # Returns the last part of the controller's name, underscored, without the + # ending `Controller`. For instance, `PostsController` returns `posts`. + # Namespaces are left out, so `Admin::PostsController` returns `posts` as well. # - # ==== Returns - # * string + # #### Returns + # * `string` def self.controller_name - @controller_name ||= name.demodulize.sub(/Controller$/, "").underscore + @controller_name ||= (name.demodulize.delete_suffix("Controller").underscore unless anonymous?) end def self.make_response!(request) - ActionDispatch::Response.create.tap do |res| + ActionDispatch::Response.new.tap do |res| res.request = request end end - def self.binary_params_for?(action) # :nodoc: + def self.action_encoding_template(action) # :nodoc: false end - # Delegates to the class' controller_name + class << self + private + def inherited(subclass) + super + subclass.middleware_stack = middleware_stack.dup + subclass.class_eval do + @controller_name = nil + end + end + end + + # Delegates to the class's ::controller_name. def controller_name self.class.controller_name end - attr_internal :response, :request + ## + # :attr_reader: request + # + # The ActionDispatch::Request instance for the current request. + attr_internal :request + + ## + # :attr_reader: response + # + # The ActionDispatch::Response instance for the current response. + attr_internal_reader :response + + ## + # The ActionDispatch::Request::Session instance for the current request. + # See further details in the + # [Active Controller Session guide](https://guides.rubyonrails.org/action_controller_overview.html#session). delegate :session, to: "@_request" - delegate :headers, :status=, :location=, :content_type=, - :status, :location, :content_type, to: "@_response" + + ## + # Delegates to ActionDispatch::Response#headers. + delegate :headers, to: "@_response" + + ## + # Delegates to ActionDispatch::Response#status= + delegate :status=, to: "@_response" + + ## + # Delegates to ActionDispatch::Response#location= + delegate :location=, to: "@_response" + + ## + # Delegates to ActionDispatch::Response#content_type= + delegate :content_type=, to: "@_response" + + ## + # Delegates to ActionDispatch::Response#status + delegate :status, to: "@_response" + + ## + # Delegates to ActionDispatch::Response#location + delegate :location, to: "@_response" + + ## + # Delegates to ActionDispatch::Response#content_type + delegate :content_type, to: "@_response" + + ## + # Delegates to ActionDispatch::Response#media_type + delegate :media_type, to: "@_response" def initialize @_request = nil @_response = nil + @_response_body = nil @_routes = nil + @_params = nil super end @@ -165,17 +226,19 @@ def params=(val) alias :response_code :status # :nodoc: - # Basic url_for that can be overridden for more robust functionality. + # Basic `url_for` that can be overridden for more robust functionality. def url_for(string) string end def response_body=(body) - body = [body] unless body.nil? || body.respond_to?(:each) - response.reset_body! - return unless body - response.body = body - super + if body + body = [body] if body.is_a?(String) + response.body = body + super + else + response.reset_body! + end end # Tests if render or redirect has already happened. @@ -183,7 +246,7 @@ def performed? response_body || response.committed? end - def dispatch(name, request, response) #:nodoc: + def dispatch(name, request, response) # :nodoc: set_request!(request) set_response!(response) process(name) @@ -192,15 +255,29 @@ def dispatch(name, request, response) #:nodoc: end def set_response!(response) # :nodoc: + if @_response + _, _, body = @_response + body.close if body.respond_to?(:close) + end + @_response = response end - def set_request!(request) #:nodoc: + # Assign the response and mark it as committed. No further processing will + # occur. + def response=(response) + set_response!(response) + + # Force `performed?` to return true: + @_response_body = true + end + + def set_request!(request) # :nodoc: @_request = request @_request.controller_instance = self end - def to_a #:nodoc: + def to_a # :nodoc: response.to_a end @@ -208,44 +285,49 @@ def reset_session @_request.reset_session end - class_attribute :middleware_stack - self.middleware_stack = ActionController::MiddlewareStack.new + class_attribute :middleware_stack, default: ActionController::MiddlewareStack.new - def self.inherited(base) # :nodoc: - base.middleware_stack = middleware_stack.dup - super - end - - # Pushes the given Rack middleware and its arguments to the bottom of the - # middleware stack. - def self.use(*args, &block) - middleware_stack.use(*args, &block) + class << self + # Pushes the given Rack middleware and its arguments to the bottom of the + # middleware stack. + def use(...) + middleware_stack.use(...) + end end - # Alias for +middleware_stack+. + # The middleware stack used by this controller. + # + # By default uses a variation of ActionDispatch::MiddlewareStack which allows + # for the following syntax: + # + # class PostsController < ApplicationController + # use AuthenticationMiddleware, except: [:index, :show] + # end + # + # Read more about [Rails middleware stack] + # (https://guides.rubyonrails.org/rails_on_rack.html#action-dispatcher-middleware-stack) + # in the guides. def self.middleware middleware_stack end # Returns a Rack endpoint for the given action name. def self.action(name) + app = lambda { |env| + req = ActionDispatch::Request.new(env) + res = make_response! req + new.dispatch(name, req, res) + } + if middleware_stack.any? - middleware_stack.build(name) do |env| - req = ActionDispatch::Request.new(env) - res = make_response! req - new.dispatch(name, req, res) - end + middleware_stack.build(name, app) else - lambda { |env| - req = ActionDispatch::Request.new(env) - res = make_response! req - new.dispatch(name, req, res) - } + app end end - # Direct dispatch to the controller. Instantiates the controller, then - # executes the action named +name+. + # Direct dispatch to the controller. Instantiates the controller, then executes + # the action named `name`. def self.dispatch(name, req, res) if middleware_stack.any? middleware_stack.build(name) { |env| new.dispatch(name, req, res) }.call req.env diff --git a/actionpack/lib/action_controller/metal/allow_browser.rb b/actionpack/lib/action_controller/metal/allow_browser.rb new file mode 100644 index 0000000000000..f32a7cb91b08b --- /dev/null +++ b/actionpack/lib/action_controller/metal/allow_browser.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionController # :nodoc: + module AllowBrowser + extend ActiveSupport::Concern + + module ClassMethods + # Specify the browser versions that will be allowed to access all actions (or + # some, as limited by `only:` or `except:`). Only browsers matched in the hash + # or named set passed to `versions:` will be blocked if they're below the + # versions specified. This means that all other 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 an HTTP status code of "406 Not + # Acceptable". + # + # In addition to specifically named browser versions, you can also pass + # `:modern` as the set to restrict support to browsers natively supporting webp + # images, web push, badges, import maps, CSS nesting, and CSS :has. This + # includes Safari 17.2+, Chrome 120+, Firefox 121+, Opera 106+. + # + # You can use https://caniuse.com to check for browser versions supporting the + # features you use. + # + # You can use `ActiveSupport::Notifications` to subscribe to events of browsers + # being blocked using the `browser_block.action_controller` event name. + # + # Examples: + # + # class ApplicationController < ActionController::Base + # # Allow only browsers natively supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has + # allow_browser versions: :modern + # end + # + # class ApplicationController < ActionController::Base + # # Allow only browsers natively supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has + # allow_browser versions: :modern, block: :handle_outdated_browser + # + # private + # def handle_outdated_browser + # render file: Rails.root.join("public/custom-error.html"), status: :not_acceptable + # end + # end + # + # class ApplicationController < ActionController::Base + # # All versions of Chrome and Opera will be allowed, but no versions of "internet explorer" (ie). Safari needs to be 16.4+ and Firefox 121+. + # allow_browser versions: { safari: 16.4, firefox: 121, ie: false } + # end + # + # class MessagesController < ApplicationController + # # In addition to the browsers blocked by ApplicationController, also block Opera below 104 and Chrome below 119 for the show action. + # allow_browser versions: { opera: 104, chrome: 119 }, only: :show + # end + def allow_browser(versions:, block: -> { render file: Rails.root.join("public/406-unsupported-browser.html"), layout: false, status: :not_acceptable }, **options) + before_action -> { allow_browser(versions: versions, block: block) }, **options + end + end + + private + def allow_browser(versions:, block:) + require "useragent" + + if BrowserBlocker.new(request, versions: versions).blocked? + ActiveSupport::Notifications.instrument("browser_block.action_controller", request: request, versions: versions) do + block.is_a?(Symbol) ? send(block) : instance_exec(&block) + end + end + end + + class BrowserBlocker # :nodoc: + SETS = { + modern: { safari: 17.2, chrome: 120, firefox: 121, opera: 106, ie: false } + } + + attr_reader :request, :versions + + def initialize(request, versions:) + @request, @versions = request, versions + end + + def blocked? + user_agent_version_reported? && unsupported_browser? + end + + private + def parsed_user_agent + @parsed_user_agent ||= UserAgent.parse(request.user_agent) + end + + def user_agent_version_reported? + request.user_agent.present? && parsed_user_agent.version.to_s.present? + end + + def unsupported_browser? + version_guarded_browser? && version_below_minimum_required? && !bot? + end + + def version_guarded_browser? + minimum_browser_version_for_browser != nil + end + + def bot? + parsed_user_agent.bot? + end + + def version_below_minimum_required? + if minimum_browser_version_for_browser + parsed_user_agent.version < UserAgent::Version.new(minimum_browser_version_for_browser.to_s) + else + true + end + end + + def minimum_browser_version_for_browser + expanded_versions[normalized_browser_name] + end + + def expanded_versions + @expanded_versions ||= (SETS[versions] || versions).with_indifferent_access + end + + def normalized_browser_name + case name = parsed_user_agent.browser.downcase + when "internet explorer" then "ie" + else name + end + end + end + end +end diff --git a/actionpack/lib/action_controller/metal/basic_implicit_render.rb b/actionpack/lib/action_controller/metal/basic_implicit_render.rb index cef65a362c5b0..08230e93f940b 100644 --- a/actionpack/lib/action_controller/metal/basic_implicit_render.rb +++ b/actionpack/lib/action_controller/metal/basic_implicit_render.rb @@ -1,10 +1,16 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController module BasicImplicitRender # :nodoc: def send_action(method, *args) - super.tap { default_render unless performed? } + ret = super + default_render unless performed? + ret end - def default_render(*args) + def default_render head :no_content end end diff --git a/actionpack/lib/action_controller/metal/conditional_get.rb b/actionpack/lib/action_controller/metal/conditional_get.rb index eb636fa3f6bb7..2f172279a312c 100644 --- a/actionpack/lib/action_controller/metal/conditional_get.rb +++ b/actionpack/lib/action_controller/metal/conditional_get.rb @@ -1,4 +1,9 @@ -require "active_support/core_ext/hash/keys" +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/object/try" +require "active_support/core_ext/integer/time" module ActionController module ConditionalGet @@ -7,101 +12,130 @@ module ConditionalGet include Head included do - class_attribute :etaggers - self.etaggers = [] + class_attribute :etaggers, default: [] end module ClassMethods - # Allows you to consider additional controller-wide information when generating an ETag. - # For example, if you serve pages tailored depending on who's logged in at the moment, you - # may want to add the current user id to be part of the ETag to prevent unauthorized displaying - # of cached pages. + # Allows you to consider additional controller-wide information when generating + # an ETag. For example, if you serve pages tailored depending on who's logged in + # at the moment, you may want to add the current user id to be part of the ETag + # to prevent unauthorized displaying of cached pages. # - # class InvoicesController < ApplicationController - # etag { current_user.try :id } + # class InvoicesController < ApplicationController + # etag { current_user&.id } # - # def show - # # Etag will differ even for the same invoice when it's viewed by a different current_user - # @invoice = Invoice.find(params[:id]) - # fresh_when(@invoice) + # def show + # # Etag will differ even for the same invoice when it's viewed by a different current_user + # @invoice = Invoice.find(params[:id]) + # fresh_when etag: @invoice + # end # end - # end def etag(&etagger) self.etaggers += [etagger] end end - # Sets the +etag+, +last_modified+, or both on the response and renders a - # 304 Not Modified response if the request is already fresh. - # - # === Parameters: - # - # * :etag Sets a "weak" ETag validator on the response. See the - # +:weak_etag+ option. - # * :weak_etag Sets a "weak" ETag validator on the response. - # Requests that set If-None-Match header may return a 304 Not Modified - # response if it matches the ETag exactly. A weak ETag indicates semantic - # equivalence, not byte-for-byte equality, so they're good for caching - # HTML pages in browser caches. They can't be used for responses that - # must be byte-identical, like serving Range requests within a PDF file. - # * :strong_etag Sets a "strong" ETag validator on the response. - # Requests that set If-None-Match header may return a 304 Not Modified - # response if it matches the ETag exactly. A strong ETag implies exact - # equality: the response must match byte for byte. This is necessary for - # doing Range requests within a large video or PDF file, for example, or - # for compatibility with some CDNs that don't support weak ETags. - # * :last_modified Sets a "weak" last-update validator on the - # response. Subsequent requests that set If-Modified-Since may return a - # 304 Not Modified response if last_modified <= If-Modified-Since. - # * :public By default the Cache-Control header is private, set this to - # +true+ if you want your application to be cacheable by other devices (proxy caches). - # * :template By default, the template digest for the current - # controller/action is included in ETags. If the action renders a - # different template, you can include its digest instead. If the action - # doesn't render a template at all, you can pass template: false - # to skip any attempt to check for a template digest. - # - # === Example: - # - # def show - # @article = Article.find(params[:id]) - # fresh_when(etag: @article, last_modified: @article.updated_at, public: true) - # end - # - # This will render the show template if the request isn't sending a matching ETag or - # If-Modified-Since header and just a 304 Not Modified response if there's a match. - # - # You can also just pass a record. In this case +last_modified+ will be set - # by calling +updated_at+ and +etag+ by passing the object itself. - # - # def show - # @article = Article.find(params[:id]) - # fresh_when(@article) - # end - # - # You can also pass an object that responds to +maximum+, such as a - # collection of active records. In this case +last_modified+ will be set by - # calling maximum(:updated_at) on the collection (the timestamp of the - # most recently updated record) and the +etag+ by passing the object itself. - # - # def index - # @articles = Article.all - # fresh_when(@articles) - # end - # - # When passing a record or a collection, you can still set the public header: - # - # def show - # @article = Article.find(params[:id]) - # fresh_when(@article, public: true) - # end - # - # When rendering a different template than the default controller/action - # style, you can indicate which digest to include in the ETag: - # - # before_action { fresh_when @article, template: 'widgets/show' } - # - def fresh_when(object = nil, etag: nil, weak_etag: nil, strong_etag: nil, last_modified: nil, public: false, template: nil) + # Sets the `etag`, `last_modified`, or both on the response, and renders a `304 + # Not Modified` response if the request is already fresh. + # + # #### Options + # + # `:etag` + # : Sets a "weak" ETag validator on the response. See the `:weak_etag` option. + # + # `:weak_etag` + # : Sets a "weak" ETag validator on the response. Requests that specify an + # `If-None-Match` header may receive a `304 Not Modified` response if the + # ETag matches exactly. + # + # : A weak ETag indicates semantic equivalence, not byte-for-byte equality, so + # they're good for caching HTML pages in browser caches. They can't be used + # for responses that must be byte-identical, like serving `Range` requests + # within a PDF file. + # + # `:strong_etag` + # : Sets a "strong" ETag validator on the response. Requests that specify an + # `If-None-Match` header may receive a `304 Not Modified` response if the + # ETag matches exactly. + # + # : A strong ETag implies exact equality -- the response must match byte for + # byte. This is necessary for serving `Range` requests within a large video + # or PDF file, for example, or for compatibility with some CDNs that don't + # support weak ETags. + # + # `:last_modified` + # : Sets a "weak" last-update validator on the response. Subsequent requests + # that specify an `If-Modified-Since` header may receive a `304 Not + # Modified` response if `last_modified` <= `If-Modified-Since`. + # + # `:public` + # : By default the `Cache-Control` header is private. Set this option to + # `true` if you want your application to be cacheable by other devices, such + # as proxy caches. + # + # `:cache_control` + # : When given, will overwrite an existing `Cache-Control` header. For a list + # of `Cache-Control` directives, see the [article on + # MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control). + # + # `:template` + # : By default, the template digest for the current controller/action is + # included in ETags. If the action renders a different template, you can + # include its digest instead. If the action doesn't render a template at + # all, you can pass `template: false` to skip any attempt to check for a + # template digest. + # + # + # #### Examples + # + # def show + # @article = Article.find(params[:id]) + # fresh_when(etag: @article, last_modified: @article.updated_at, public: true) + # end + # + # This will send a `304 Not Modified` response if the request specifies a + # matching ETag and `If-Modified-Since` header. Otherwise, it will render the + # `show` template. + # + # You can also just pass a record: + # + # def show + # @article = Article.find(params[:id]) + # fresh_when(@article) + # end + # + # `etag` will be set to the record, and `last_modified` will be set to the + # record's `updated_at`. + # + # You can also pass an object that responds to `maximum`, such as a collection + # of records: + # + # def index + # @articles = Article.all + # fresh_when(@articles) + # end + # + # In this case, `etag` will be set to the collection, and `last_modified` will + # be set to `maximum(:updated_at)` (the timestamp of the most recently updated + # record). + # + # When passing a record or a collection, you can still specify other options, + # such as `:public` and `:cache_control`: + # + # def show + # @article = Article.find(params[:id]) + # fresh_when(@article, public: true, cache_control: { no_cache: true }) + # end + # + # The above will set `Cache-Control: public, no-cache` in the response. + # + # When rendering a different template than the controller/action's default + # template, you can indicate which digest to include in the ETag: + # + # before_action { fresh_when @article, template: "widgets/show" } + # + def fresh_when(object = nil, etag: nil, weak_etag: nil, strong_etag: nil, last_modified: nil, public: false, cache_control: {}, template: nil) + response.cache_control.delete(:no_store) weak_etag ||= etag || object unless strong_etag last_modified ||= object.try(:updated_at) || object.try(:maximum, :updated_at) @@ -115,126 +149,153 @@ def fresh_when(object = nil, etag: nil, weak_etag: nil, strong_etag: nil, last_m response.last_modified = last_modified if last_modified response.cache_control[:public] = true if public + response.cache_control.merge!(cache_control) head :not_modified if request.fresh?(response) end - # Sets the +etag+ and/or +last_modified+ on the response and checks it against - # the client request. If the request doesn't match the options provided, the - # request is considered stale and should be generated from scratch. Otherwise, - # it's fresh and we don't need to generate anything and a reply of 304 Not Modified is sent. - # - # === Parameters: - # - # * :etag Sets a "weak" ETag validator on the response. See the - # +:weak_etag+ option. - # * :weak_etag Sets a "weak" ETag validator on the response. - # Requests that set If-None-Match header may return a 304 Not Modified - # response if it matches the ETag exactly. A weak ETag indicates semantic - # equivalence, not byte-for-byte equality, so they're good for caching - # HTML pages in browser caches. They can't be used for responses that - # must be byte-identical, like serving Range requests within a PDF file. - # * :strong_etag Sets a "strong" ETag validator on the response. - # Requests that set If-None-Match header may return a 304 Not Modified - # response if it matches the ETag exactly. A strong ETag implies exact - # equality: the response must match byte for byte. This is necessary for - # doing Range requests within a large video or PDF file, for example, or - # for compatibility with some CDNs that don't support weak ETags. - # * :last_modified Sets a "weak" last-update validator on the - # response. Subsequent requests that set If-Modified-Since may return a - # 304 Not Modified response if last_modified <= If-Modified-Since. - # * :public By default the Cache-Control header is private, set this to - # +true+ if you want your application to be cacheable by other devices (proxy caches). - # * :template By default, the template digest for the current - # controller/action is included in ETags. If the action renders a - # different template, you can include its digest instead. If the action - # doesn't render a template at all, you can pass template: false - # to skip any attempt to check for a template digest. - # - # === Example: - # - # def show - # @article = Article.find(params[:id]) - # - # if stale?(etag: @article, last_modified: @article.updated_at) - # @statistics = @article.really_expensive_call - # respond_to do |format| - # # all the supported formats + # Sets the `etag` and/or `last_modified` on the response and checks them against + # the request. If the request doesn't match the provided options, it is + # considered stale, and the response should be rendered from scratch. Otherwise, + # it is fresh, and a `304 Not Modified` is sent. + # + # #### Options + # + # See #fresh_when for supported options. + # + # #### Examples + # + # def show + # @article = Article.find(params[:id]) + # + # if stale?(etag: @article, last_modified: @article.updated_at) + # @statistics = @article.really_expensive_call + # respond_to do |format| + # # all the supported formats + # end # end # end - # end # - # You can also just pass a record. In this case +last_modified+ will be set - # by calling +updated_at+ and +etag+ by passing the object itself. + # You can also just pass a record: # - # def show - # @article = Article.find(params[:id]) + # def show + # @article = Article.find(params[:id]) # - # if stale?(@article) - # @statistics = @article.really_expensive_call - # respond_to do |format| - # # all the supported formats + # if stale?(@article) + # @statistics = @article.really_expensive_call + # respond_to do |format| + # # all the supported formats + # end # end # end - # end # - # You can also pass an object that responds to +maximum+, such as a - # collection of active records. In this case +last_modified+ will be set by - # calling +maximum(:updated_at)+ on the collection (the timestamp of the - # most recently updated record) and the +etag+ by passing the object itself. + # `etag` will be set to the record, and `last_modified` will be set to the + # record's `updated_at`. # - # def index - # @articles = Article.all + # You can also pass an object that responds to `maximum`, such as a collection + # of records: # - # if stale?(@articles) - # @statistics = @articles.really_expensive_call - # respond_to do |format| - # # all the supported formats + # def index + # @articles = Article.all + # + # if stale?(@articles) + # @statistics = @articles.really_expensive_call + # respond_to do |format| + # # all the supported formats + # end # end # end - # end # - # When passing a record or a collection, you can still set the public header: + # In this case, `etag` will be set to the collection, and `last_modified` will + # be set to `maximum(:updated_at)` (the timestamp of the most recently updated + # record). + # + # When passing a record or a collection, you can still specify other options, + # such as `:public` and `:cache_control`: # - # def show - # @article = Article.find(params[:id]) + # def show + # @article = Article.find(params[:id]) # - # if stale?(@article, public: true) - # @statistics = @article.really_expensive_call - # respond_to do |format| - # # all the supported formats + # if stale?(@article, public: true, cache_control: { no_cache: true }) + # @statistics = @articles.really_expensive_call + # respond_to do |format| + # # all the supported formats + # end # end # end - # end # - # When rendering a different template than the default controller/action - # style, you can indicate which digest to include in the ETag: + # The above will set `Cache-Control: public, no-cache` in the response. # - # def show - # super if stale? @article, template: 'widgets/show' - # end + # When rendering a different template than the controller/action's default + # template, you can indicate which digest to include in the ETag: + # + # def show + # super if stale?(@article, template: "widgets/show") + # end # def stale?(object = nil, **freshness_kwargs) fresh_when(object, **freshness_kwargs) !request.fresh?(response) end - # Sets an HTTP 1.1 Cache-Control header. Defaults to issuing a +private+ - # instruction, so that intermediate caches must not cache the response. + # Sets the `Cache-Control` header, overwriting existing directives. This method + # will also ensure an HTTP `Date` header for client compatibility. + # + # Defaults to issuing the `private` directive, so that intermediate caches must + # not cache the response. + # + # #### Options + # + # `:public` + # : If true, replaces the default `private` directive with the `public` + # directive. + # + # `:must_revalidate` + # : If true, adds the `must-revalidate` directive. + # + # `:stale_while_revalidate` + # : Sets the value of the `stale-while-revalidate` directive. + # + # `:stale_if_error` + # : Sets the value of the `stale-if-error` directive. + # + # `:immutable` + # : If true, adds the `immutable` directive. + # + # + # Any additional key-value pairs are concatenated as directives. For a list of + # supported `Cache-Control` directives, see the [article on + # MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control). + # + # #### Examples + # + # expires_in 10.minutes + # # => Cache-Control: max-age=600, private + # + # expires_in 10.minutes, public: true + # # => Cache-Control: max-age=600, public # - # expires_in 20.minutes - # expires_in 3.hours, public: true - # expires_in 3.hours, public: true, must_revalidate: true + # expires_in 10.minutes, public: true, must_revalidate: true + # # => Cache-Control: max-age=600, public, must-revalidate # - # This method will overwrite an existing Cache-Control header. - # See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html for more possibilities. + # expires_in 1.hour, stale_while_revalidate: 60.seconds + # # => Cache-Control: max-age=3600, private, stale-while-revalidate=60 + # + # expires_in 1.hour, stale_if_error: 5.minutes + # # => Cache-Control: max-age=3600, private, stale-if-error=300 + # + # expires_in 1.hour, public: true, "s-maxage": 3.hours, "no-transform": true + # # => Cache-Control: max-age=3600, public, s-maxage=10800, no-transform=true # - # The method will also ensure an HTTP Date header for client compatibility. def expires_in(seconds, options = {}) + response.cache_control.delete(:no_store) response.cache_control.merge!( max_age: seconds, public: options.delete(:public), - must_revalidate: options.delete(:must_revalidate) + must_revalidate: options.delete(:must_revalidate), + stale_while_revalidate: options.delete(:stale_while_revalidate), + stale_if_error: options.delete(:stale_if_error), + immutable: options.delete(:immutable), ) options.delete(:private) @@ -242,8 +303,8 @@ def expires_in(seconds, options = {}) response.date = Time.now unless response.date? end - # Sets an HTTP 1.1 Cache-Control header of no-cache. This means the - # resource will be marked as stale, so clients must always revalidate. + # Sets an HTTP 1.1 `Cache-Control` header of `no-cache`. This means the resource + # will be marked as stale, so clients must always revalidate. # Intermediate/browser caches may still store the asset. def expires_now response.cache_control.replace(no_cache: true) @@ -251,20 +312,51 @@ def expires_now # Cache or yield the block. The cache is supposed to never expire. # - # You can use this method when you have an HTTP response that never changes, - # and the browser and proxies should cache it indefinitely. + # You can use this method when you have an HTTP response that never changes, and + # the browser and proxies should cache it indefinitely. # - # * +public+: By default, HTTP responses are private, cached only on the - # user's web browser. To allow proxies to cache the response, set +true+ to - # indicate that they can serve the cached response to all users. + # * `public`: By default, HTTP responses are private, cached only on the + # user's web browser. To allow proxies to cache the response, set `true` to + # indicate that they can serve the cached response to all users. def http_cache_forever(public: false) - expires_in 100.years, public: public + expires_in 100.years, public: public, immutable: true yield if stale?(etag: request.fullpath, last_modified: Time.new(2011, 1, 1).utc, public: public) end + # Sets an HTTP 1.1 `Cache-Control` header of `no-store`. This means the resource + # may not be stored in any cache. + 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/content_security_policy.rb b/actionpack/lib/action_controller/metal/content_security_policy.rb new file mode 100644 index 0000000000000..6af6706c1cbcd --- /dev/null +++ b/actionpack/lib/action_controller/metal/content_security_policy.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionController # :nodoc: + module ContentSecurityPolicy + extend ActiveSupport::Concern + + include AbstractController::Helpers + include AbstractController::Callbacks + + included do + helper_method :content_security_policy? + helper_method :content_security_policy_nonce + end + + module ClassMethods + # Overrides parts of the globally configured `Content-Security-Policy` header: + # + # class PostsController < ApplicationController + # content_security_policy do |policy| + # policy.base_uri "https://www.example.com" + # end + # end + # + # Options can be passed similar to `before_action`. For example, pass `only: + # :index` to override the header on the index action only: + # + # class PostsController < ApplicationController + # content_security_policy(only: :index) do |policy| + # policy.default_src :self, :https + # end + # end + # + # Pass `false` to remove the `Content-Security-Policy` header: + # + # class PostsController < ApplicationController + # content_security_policy false, only: :index + # end + def content_security_policy(enabled = true, **options, &block) + before_action(options) do + if block_given? + policy = current_content_security_policy + instance_exec(policy, &block) + request.content_security_policy = policy + end + + unless enabled + request.content_security_policy = nil + end + end + end + + # Overrides the globally configured `Content-Security-Policy-Report-Only` + # header: + # + # class PostsController < ApplicationController + # content_security_policy_report_only only: :index + # end + # + # Pass `false` to remove the `Content-Security-Policy-Report-Only` header: + # + # class PostsController < ApplicationController + # content_security_policy_report_only false, only: :index + # end + def content_security_policy_report_only(report_only = true, **options) + before_action(options) do + request.content_security_policy_report_only = report_only + end + end + end + + private + def content_security_policy? + request.content_security_policy + end + + def content_security_policy_nonce + request.content_security_policy_nonce + end + + def current_content_security_policy + request.content_security_policy&.clone || ActionDispatch::ContentSecurityPolicy.new + end + end +end diff --git a/actionpack/lib/action_controller/metal/cookies.rb b/actionpack/lib/action_controller/metal/cookies.rb index 44925641a1cfe..a738532c18411 100644 --- a/actionpack/lib/action_controller/metal/cookies.rb +++ b/actionpack/lib/action_controller/metal/cookies.rb @@ -1,4 +1,8 @@ -module ActionController #:nodoc: +# frozen_string_literal: true + +# :markup: markdown + +module ActionController # :nodoc: module Cookies extend ActiveSupport::Concern @@ -7,7 +11,9 @@ module Cookies end private - def cookies + # The cookies for the current request. See ActionDispatch::Cookies for more + # information. + def cookies # :doc: request.cookie_jar end end diff --git a/actionpack/lib/action_controller/metal/data_streaming.rb b/actionpack/lib/action_controller/metal/data_streaming.rb index 731e03e2fc796..9c7ba594f7409 100644 --- a/actionpack/lib/action_controller/metal/data_streaming.rb +++ b/actionpack/lib/action_controller/metal/data_streaming.rb @@ -1,6 +1,13 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_controller/metal/exceptions" +require "action_dispatch/http/content_disposition" -module ActionController #:nodoc: +module ActionController # :nodoc: + # # Action Controller Data Streaming + # # Methods for sending arbitrary data and for streaming files to the browser, # instead of rendering. module DataStreaming @@ -8,62 +15,66 @@ module DataStreaming include ActionController::Rendering - DEFAULT_SEND_FILE_TYPE = "application/octet-stream".freeze #:nodoc: - DEFAULT_SEND_FILE_DISPOSITION = "attachment".freeze #:nodoc: + DEFAULT_SEND_FILE_TYPE = "application/octet-stream" # :nodoc: + DEFAULT_SEND_FILE_DISPOSITION = "attachment" # :nodoc: private - # Sends the file. This uses a server-appropriate method (such as X-Sendfile) - # via the Rack::Sendfile middleware. The header to use is set via - # +config.action_dispatch.x_sendfile_header+. - # Your server can also configure this for you by setting the X-Sendfile-Type header. - # - # Be careful to sanitize the path parameter if it is coming from a web - # page. send_file(params[:path]) allows a malicious user to - # download any file on your server. - # - # Options: - # * :filename - suggests a filename for the browser to use. - # Defaults to File.basename(path). - # * :type - specifies an HTTP content type. - # You can specify either a string or a symbol for a registered type with Mime::Type.register, for example :json. - # If omitted, the type will be inferred from the file extension specified in :filename. - # If no content type is registered for the extension, the default type 'application/octet-stream' will be used. - # * :disposition - specifies whether the file will be shown inline or downloaded. - # Valid values are 'inline' and 'attachment' (default). - # * :status - specifies the status code to send with the response. Defaults to 200. - # * :url_based_filename - set to +true+ if you want the browser to guess the filename from - # the URL, which is necessary for i18n filenames on certain browsers - # (setting :filename overrides this option). - # - # The default Content-Type and Content-Disposition headers are - # set to download arbitrary binary files in as many browsers as - # possible. IE versions 4, 5, 5.5, and 6 are all known to have - # a variety of quirks (especially when downloading over SSL). + # Sends the file. This uses a server-appropriate method (such as `X-Sendfile`) + # via the `Rack::Sendfile` middleware. The header to use is set via + # `config.action_dispatch.x_sendfile_header`. Your server can also configure + # this for you by setting the `X-Sendfile-Type` header. + # + # Be careful to sanitize the path parameter if it is coming from a web page. + # `send_file(params[:path])` allows a malicious user to download any file on + # your server. + # + # #### Options: + # + # * `:filename` - suggests a filename for the browser to use. Defaults to + # `File.basename(path)`. + # * `:type` - specifies an HTTP content type. You can specify either a string + # or a symbol for a registered type with `Mime::Type.register`, for example + # `:json`. If omitted, the type will be inferred from the file extension + # specified in `:filename`. If no content type is registered for the + # extension, the default type `application/octet-stream` will be used. + # * `:disposition` - specifies whether the file will be shown inline or + # downloaded. Valid values are `"inline"` and `"attachment"` (default). + # * `:status` - specifies the status code to send with the response. Defaults + # to 200. + # * `:url_based_filename` - set to `true` if you want the browser to guess the + # filename from the URL, which is necessary for i18n filenames on certain + # browsers (setting `:filename` overrides this option). + # + # + # The default `Content-Type` and `Content-Disposition` headers are set to + # download arbitrary binary files in as many browsers as possible. IE versions + # 4, 5, 5.5, and 6 are all known to have a variety of quirks (especially when + # downloading over SSL). # # Simple download: # - # send_file '/path/to.zip' + # send_file '/path/to.zip' # # Show a JPEG in the browser: # - # send_file '/path/to.jpeg', type: 'image/jpeg', disposition: 'inline' + # send_file '/path/to.jpeg', type: 'image/jpeg', disposition: 'inline' # # Show a 404 page in the browser: # - # send_file '/path/to/404.html', type: 'text/html; charset=utf-8', status: 404 + # send_file '/path/to/404.html', type: 'text/html; charset=utf-8', disposition: 'inline', status: 404 # - # Read about the other Content-* HTTP headers if you'd like to - # provide the user with more information (such as Content-Description) in - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11. + # You can use other `Content-*` HTTP headers to provide additional information + # to the client. See MDN for a [list of HTTP + # headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers). # - # Also be aware that the document may be cached by proxies and browsers. - # The Pragma and Cache-Control headers declare how the file may be cached - # by intermediaries. They default to require clients to validate with - # the server before releasing cached responses. See - # http://www.mnot.net/cache_docs/ for an overview of web caching and - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9 - # for the Cache-Control header spec. - def send_file(path, options = {}) #:doc: + # Also be aware that the document may be cached by proxies and browsers. The + # `Pragma` and `Cache-Control` headers declare how the file may be cached by + # intermediaries. They default to require clients to validate with the server + # before releasing cached responses. See https://www.mnot.net/cache_docs/ for an + # overview of web caching and [RFC + # 9111](https://www.rfc-editor.org/rfc/rfc9111.html#name-cache-control) for the + # `Cache-Control` header spec. + def send_file(path, options = {}) # :doc: raise MissingFile, "Cannot read file #{path}" unless File.file?(path) && File.readable?(path) options[:filename] ||= File.basename(path) unless options[:url_based_filename] @@ -74,36 +85,41 @@ def send_file(path, options = {}) #:doc: response.send_file path end - # Sends the given binary data to the browser. This method is similar to - # render plain: data, but also allows you to specify whether - # the browser should display the response as a file attachment (i.e. in a - # download dialog) or as inline data. You may also set the content type, - # the file name, and other things. - # - # Options: - # * :filename - suggests a filename for the browser to use. - # * :type - specifies an HTTP content type. Defaults to 'application/octet-stream'. - # You can specify either a string or a symbol for a registered type with Mime::Type.register, for example :json. - # If omitted, type will be inferred from the file extension specified in :filename. - # If no content type is registered for the extension, the default type 'application/octet-stream' will be used. - # * :disposition - specifies whether the file will be shown inline or downloaded. - # Valid values are 'inline' and 'attachment' (default). - # * :status - specifies the status code to send with the response. Defaults to 200. + # Sends the given binary data to the browser. This method is similar to `render + # plain: data`, but also allows you to specify whether the browser should + # display the response as a file attachment (i.e. in a download dialog) or as + # inline data. You may also set the content type, the file name, and other + # things. + # + # #### Options: + # + # * `:filename` - suggests a filename for the browser to use. + # * `:type` - specifies an HTTP content type. Defaults to + # `application/octet-stream`. You can specify either a string or a symbol + # for a registered type with `Mime::Type.register`, for example `:json`. If + # omitted, type will be inferred from the file extension specified in + # `:filename`. If no content type is registered for the extension, the + # default type `application/octet-stream` will be used. + # * `:disposition` - specifies whether the file will be shown inline or + # downloaded. Valid values are `"inline"` and `"attachment"` (default). + # * `:status` - specifies the status code to send with the response. Defaults + # to 200. + # # # Generic data download: # - # send_data buffer + # send_data buffer # # Download a dynamically-generated tarball: # - # send_data generate_tgz('dir'), filename: 'dir.tgz' + # send_data generate_tgz('dir'), filename: 'dir.tgz' # # Display an image Active Record in the browser: # - # send_data image.data, type: image.content_type, disposition: 'inline' + # send_data image.data, type: image.content_type, disposition: 'inline' # - # See +send_file+ for more information on HTTP Content-* headers and caching. - def send_data(data, options = {}) #:doc: + # See `send_file` for more information on HTTP `Content-*` headers and caching. + def send_data(data, options = {}) # :doc: send_file_headers! options render options.slice(:status, :content_type).merge(body: data) end @@ -111,16 +127,14 @@ def send_data(data, options = {}) #:doc: def send_file_headers!(options) type_provided = options.has_key?(:type) - self.content_type = DEFAULT_SEND_FILE_TYPE + content_type = options.fetch(:type, DEFAULT_SEND_FILE_TYPE) + self.content_type = content_type response.sending_file = true - content_type = options.fetch(:type, DEFAULT_SEND_FILE_TYPE) raise ArgumentError, ":type option required" if content_type.nil? if content_type.is_a?(Symbol) - extension = Mime[content_type] - raise ArgumentError, "Unknown MIME type #{options[:type]}" unless extension - self.content_type = extension + self.content_type = content_type else if !type_provided && options[:filename] # If type wasn't provided, try guessing from file extension. @@ -130,21 +144,11 @@ def send_file_headers!(options) end disposition = options.fetch(:disposition, DEFAULT_SEND_FILE_DISPOSITION) - unless disposition.nil? - disposition = disposition.to_s - disposition += %(; filename="#{options[:filename]}") if options[:filename] - headers["Content-Disposition"] = disposition + if disposition + headers["Content-Disposition"] = ActionDispatch::Http::ContentDisposition.format(disposition: disposition, filename: options[:filename]) end headers["Content-Transfer-Encoding"] = "binary" - - # Fix a problem with IE 6.0 on opening downloaded files: - # If Cache-Control: no-cache is set (which Rails does by default), - # IE removes the file it just downloaded from its cache immediately - # after it displays the "open/save" dialog, which means that if you - # hit "open" the file isn't there anymore when the application that - # is called for handling the download is run, so let's workaround that - response.cache_control[:public] ||= false end end end diff --git a/actionpack/lib/action_controller/metal/default_headers.rb b/actionpack/lib/action_controller/metal/default_headers.rb new file mode 100644 index 0000000000000..fef8e789467ef --- /dev/null +++ b/actionpack/lib/action_controller/metal/default_headers.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionController + # # Action Controller Default Headers + # + # Allows configuring default headers that will be automatically merged into each + # response. + module DefaultHeaders + extend ActiveSupport::Concern + + module ClassMethods + def make_response!(request) + ActionDispatch::Response.create.tap do |res| + res.request = request + end + end + end + end +end diff --git a/actionpack/lib/action_controller/metal/etag_with_flash.rb b/actionpack/lib/action_controller/metal/etag_with_flash.rb index 474d75f02e126..6caf8a4315323 100644 --- a/actionpack/lib/action_controller/metal/etag_with_flash.rb +++ b/actionpack/lib/action_controller/metal/etag_with_flash.rb @@ -1,16 +1,22 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController + # # Action Controller Etag With Flash + # # When you're using the flash, it's generally used as a conditional on the view. # This means the content of the view depends on the flash. Which in turn means - # that the etag for a response should be computed with the content of the flash + # that the ETag for a response should be computed with the content of the flash # in mind. This does that by including the content of the flash as a component - # in the etag that's generated for a response. + # in the ETag that's generated for a response. module EtagWithFlash extend ActiveSupport::Concern include ActionController::ConditionalGet included do - etag { flash unless flash.empty? } + etag { flash if request.respond_to?(:flash) && !flash.empty? } end end end diff --git a/actionpack/lib/action_controller/metal/etag_with_template_digest.rb b/actionpack/lib/action_controller/metal/etag_with_template_digest.rb index 798564db96e87..20715b1fc6c56 100644 --- a/actionpack/lib/action_controller/metal/etag_with_template_digest.rb +++ b/actionpack/lib/action_controller/metal/etag_with_template_digest.rb @@ -1,20 +1,26 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController - # When our views change, they should bubble up into HTTP cache freshness - # and bust browser caches. So the template digest for the current action - # is automatically included in the ETag. + # # Action Controller Etag With Template Digest + # + # When our views change, they should bubble up into HTTP cache freshness and + # bust browser caches. So the template digest for the current action is + # automatically included in the ETag. # # Enabled by default for apps that use Action View. Disable by setting # - # config.action_controller.etag_with_template_digest = false + # config.action_controller.etag_with_template_digest = false # - # Override the template to digest by passing +:template+ to +fresh_when+ - # and +stale?+ calls. For example: + # Override the template to digest by passing `:template` to `fresh_when` and + # `stale?` calls. For example: # - # # We're going to render widgets/show, not posts/show - # fresh_when @post, template: 'widgets/show' + # # We're going to render widgets/show, not posts/show + # fresh_when @post, template: 'widgets/show' # - # # We're not going to render a template, so omit it from the ETag. - # fresh_when @post, template: false + # # We're not going to render a template, so omit it from the ETag. + # fresh_when @post, template: false # module EtagWithTemplateDigest extend ActiveSupport::Concern @@ -22,13 +28,10 @@ module EtagWithTemplateDigest include ActionController::ConditionalGet included do - class_attribute :etag_with_template_digest - self.etag_with_template_digest = true + class_attribute :etag_with_template_digest, default: true - ActiveSupport.on_load :action_view, yield: true do - etag do |options| - determine_template_etag(options) if etag_with_template_digest - end + etag do |options| + determine_template_etag(options) if etag_with_template_digest end end @@ -39,18 +42,18 @@ def determine_template_etag(options) end end - # Pick the template digest to include in the ETag. If the +:template+ option - # is present, use the named template. If +:template+ is +nil+ or absent, use - # the default controller/action template. If +:template+ is false, omit the - # template digest from the ETag. + # Pick the template digest to include in the ETag. If the `:template` option is + # present, use the named template. If `:template` is `nil` or absent, use the + # default controller/action template. If `:template` is false, omit the template + # digest from the ETag. def pick_template_for_etag(options) unless options[:template] == false - options[:template] || "#{controller_path}/#{action_name}" + options[:template] || lookup_context.find_all(action_name, _prefixes).first&.virtual_path end end def lookup_and_digest_template(template) - ActionView::Digestor.digest name: template, finder: lookup_context + ActionView::Digestor.digest name: template, format: nil, finder: lookup_context end end end diff --git a/actionpack/lib/action_controller/metal/exceptions.rb b/actionpack/lib/action_controller/metal/exceptions.rb index 175dd9eb9e109..bed189eb77396 100644 --- a/actionpack/lib/action_controller/metal/exceptions.rb +++ b/actionpack/lib/action_controller/metal/exceptions.rb @@ -1,18 +1,22 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController - class ActionControllerError < StandardError #:nodoc: + class ActionControllerError < StandardError # :nodoc: end - class BadRequest < ActionControllerError #:nodoc: + class BadRequest < ActionControllerError # :nodoc: def initialize(msg = nil) super(msg) set_backtrace $!.backtrace if $! end end - class RenderError < ActionControllerError #:nodoc: + class RenderError < ActionControllerError # :nodoc: end - class RoutingError < ActionControllerError #:nodoc: + class RoutingError < ActionControllerError # :nodoc: attr_reader :failures def initialize(message, failures = []) super(message) @@ -20,35 +24,88 @@ def initialize(message, failures = []) end end - class ActionController::UrlGenerationError < ActionControllerError #:nodoc: + class UrlGenerationError < ActionControllerError # :nodoc: + attr_reader :routes, :route_name, :method_name + + def initialize(message, routes = nil, route_name = nil, method_name = nil) + @routes = routes + @route_name = route_name + @method_name = method_name + + super(message) + end + + if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker) + include DidYouMean::Correctable + + def corrections + @corrections ||= begin + maybe_these = routes&.named_routes&.helper_names&.grep(/#{route_name}/) || [] + maybe_these -= [method_name.to_s] # remove exact match + + DidYouMean::SpellChecker.new(dictionary: maybe_these).correct(route_name) + end + end + end end - class MethodNotAllowed < ActionControllerError #:nodoc: + class MethodNotAllowed < ActionControllerError # :nodoc: def initialize(*allowed_methods) - super("Only #{allowed_methods.to_sentence(locale: :en)} requests are allowed.") + super("Only #{allowed_methods.to_sentence} requests are allowed.") end end - class NotImplemented < MethodNotAllowed #:nodoc: + class NotImplemented < MethodNotAllowed # :nodoc: + end + + class MissingFile < ActionControllerError # :nodoc: end - class UnknownController < ActionControllerError #:nodoc: + class SessionOverflowError < ActionControllerError # :nodoc: + DEFAULT_MESSAGE = "Your session data is larger than the data column in which it is to be stored. You must increase the size of your data column if you intend to store large data." + + def initialize(message = nil) + super(message || DEFAULT_MESSAGE) + end end - class MissingFile < ActionControllerError #:nodoc: + class UnknownHttpMethod < ActionControllerError # :nodoc: end - class SessionOverflowError < ActionControllerError #:nodoc: - DEFAULT_MESSAGE = "Your session data is larger than the data column in which it is to be stored. You must increase the size of your data column if you intend to store large data." + class UnknownFormat < ActionControllerError # :nodoc: + end + + # Raised when a nested respond_to is triggered and the content types of each are + # incompatible. For example: + # + # respond_to do |outer_type| + # outer_type.js do + # respond_to do |inner_type| + # inner_type.html { render body: "HTML" } + # end + # end + # end + class RespondToMismatchError < ActionControllerError + DEFAULT_MESSAGE = "respond_to was called multiple times and matched with conflicting formats in this action. Please note that you may only call respond_to and match on a single format per action." def initialize(message = nil) super(message || DEFAULT_MESSAGE) end end - class UnknownHttpMethod < ActionControllerError #:nodoc: + class MissingExactTemplate < UnknownFormat # :nodoc: + attr_reader :controller, :action_name + + def initialize(message, controller, action_name) + @controller = controller + @action_name = action_name + + super(message) + end end - class UnknownFormat < ActionControllerError #:nodoc: + # 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/flash.rb b/actionpack/lib/action_controller/metal/flash.rb index 347fbf0e74077..a7ca78fea093f 100644 --- a/actionpack/lib/action_controller/metal/flash.rb +++ b/actionpack/lib/action_controller/metal/flash.rb @@ -1,10 +1,13 @@ -module ActionController #:nodoc: +# frozen_string_literal: true + +# :markup: markdown + +module ActionController # :nodoc: module Flash extend ActiveSupport::Concern included do - class_attribute :_flash_types, instance_accessor: false - self._flash_types = [] + class_attribute :_flash_types, instance_accessor: false, default: [] delegate :flash, to: :request add_flash_types(:alert, :notice) @@ -12,19 +15,19 @@ module Flash module ClassMethods # Creates new flash types. You can pass as many types as you want to create - # flash types other than the default alert and notice in - # your controllers and views. For instance: + # flash types other than the default `alert` and `notice` in your controllers + # and views. For instance: # - # # in application_controller.rb - # class ApplicationController < ActionController::Base - # add_flash_types :warning - # end + # # in application_controller.rb + # class ApplicationController < ActionController::Base + # add_flash_types :warning + # end # - # # in your controller - # redirect_to user_path(@user), warning: "Incomplete profile" + # # in your controller + # redirect_to user_path(@user), warning: "Incomplete profile" # - # # in your view - # <%= warning %> + # # in your view + # <%= warning %> # # This method will automatically define a new method for each of the given # names, and it will be available in your views. @@ -35,7 +38,8 @@ def add_flash_types(*types) define_method(type) do request.flash[type] end - helper_method type + private type + helper_method(type) if respond_to?(:helper_method) self._flash_types += [type] end @@ -43,18 +47,18 @@ def add_flash_types(*types) end private - def redirect_to(options = {}, response_status_and_flash = {}) #:doc: + def redirect_to(options = {}, response_options_and_flash = {}) # :doc: self.class._flash_types.each do |flash_type| - if type = response_status_and_flash.delete(flash_type) + if type = response_options_and_flash.delete(flash_type) flash[flash_type] = type end end - if other_flashes = response_status_and_flash.delete(:flash) + if other_flashes = response_options_and_flash.delete(:flash) flash.update(other_flashes) end - super(options, response_status_and_flash) + super(options, response_options_and_flash) end end end diff --git a/actionpack/lib/action_controller/metal/force_ssl.rb b/actionpack/lib/action_controller/metal/force_ssl.rb deleted file mode 100644 index 9d43e752ac58c..0000000000000 --- a/actionpack/lib/action_controller/metal/force_ssl.rb +++ /dev/null @@ -1,97 +0,0 @@ -require "active_support/core_ext/hash/except" -require "active_support/core_ext/hash/slice" - -module ActionController - # This module provides a method which will redirect the browser to use HTTPS - # protocol. This will ensure that user's sensitive information will be - # transferred safely over the internet. You _should_ always force the browser - # to use HTTPS when you're transferring sensitive information such as - # user authentication, account information, or credit card information. - # - # Note that if you are really concerned about your application security, - # you might consider using +config.force_ssl+ in your config file instead. - # That will ensure all the data transferred via HTTPS protocol and prevent - # the user from getting their session hijacked when accessing the site over - # unsecured HTTP protocol. - module ForceSSL - extend ActiveSupport::Concern - include AbstractController::Callbacks - - ACTION_OPTIONS = [:only, :except, :if, :unless] - URL_OPTIONS = [:protocol, :host, :domain, :subdomain, :port, :path] - REDIRECT_OPTIONS = [:status, :flash, :alert, :notice] - - module ClassMethods - # Force the request to this particular controller or specified actions to be - # under HTTPS protocol. - # - # If you need to disable this for any reason (e.g. development) then you can use - # an +:if+ or +:unless+ condition. - # - # class AccountsController < ApplicationController - # force_ssl if: :ssl_configured? - # - # def ssl_configured? - # !Rails.env.development? - # end - # end - # - # ==== URL Options - # You can pass any of the following options to affect the redirect url - # * host - Redirect to a different host name - # * subdomain - Redirect to a different subdomain - # * domain - Redirect to a different domain - # * port - Redirect to a non-standard port - # * path - Redirect to a different path - # - # ==== Redirect Options - # You can pass any of the following options to affect the redirect status and response - # * status - Redirect with a custom status (default is 301 Moved Permanently) - # * flash - Set a flash message when redirecting - # * alert - Set an alert message when redirecting - # * notice - Set a notice message when redirecting - # - # ==== Action Options - # You can pass any of the following options to affect the before_action callback - # * only - The callback should be run only for this action - # * except - The callback should be run for all actions except this action - # * if - A symbol naming an instance method or a proc; the - # callback will be called only when it returns a true value. - # * unless - A symbol naming an instance method or a proc; the - # callback will be called only when it returns a false value. - def force_ssl(options = {}) - action_options = options.slice(*ACTION_OPTIONS) - redirect_options = options.except(*ACTION_OPTIONS) - before_action(action_options) do - force_ssl_redirect(redirect_options) - end - end - end - - # Redirect the existing request to use the HTTPS protocol. - # - # ==== Parameters - # * host_or_options - Either a host name or any of the url & - # redirect options available to the force_ssl method. - def force_ssl_redirect(host_or_options = nil) - unless request.ssl? - options = { - protocol: "https://", - host: request.host, - path: request.fullpath, - status: :moved_permanently - } - - if host_or_options.is_a?(Hash) - options.merge!(host_or_options) - elsif host_or_options - options[:host] = host_or_options - end - - secure_url = ActionDispatch::Http::URL.url_for(options.slice(*URL_OPTIONS)) - flash.keep if respond_to?(:flash) && request.respond_to?(:flash) - redirect_to secure_url, options.slice(*REDIRECT_OPTIONS) - end - end - end -end diff --git a/actionpack/lib/action_controller/metal/head.rb b/actionpack/lib/action_controller/metal/head.rb index 0c50894bce781..254367fb85fae 100644 --- a/actionpack/lib/action_controller/metal/head.rb +++ b/actionpack/lib/action_controller/metal/head.rb @@ -1,45 +1,56 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController module Head - # Returns a response that has no content (merely headers). The options - # argument is interpreted to be a hash of header names and values. - # This allows you to easily return a response that consists only of - # significant headers: + # Returns a response that has no content (merely headers). The options argument + # is interpreted to be a hash of header names and values. This allows you to + # easily return a response that consists only of significant headers: # - # head :created, location: person_path(@person) + # head :created, location: person_path(@person) # - # head :created, location: @person + # head :created, location: @person # # It can also be used to return exceptional conditions: # - # return head(:method_not_allowed) unless request.post? - # return head(:bad_request) unless valid_request? - # render + # return head(:method_not_allowed) unless request.post? + # return head(:bad_request) unless valid_request? + # render # - # See Rack::Utils::SYMBOL_TO_STATUS_CODE for a full list of valid +status+ symbols. - def head(status, options = {}) + # See `Rack::Utils::SYMBOL_TO_STATUS_CODE` for a full list of valid `status` + # symbols. + def head(status, options = nil) if status.is_a?(Hash) raise ArgumentError, "#{status.inspect} is not a valid value for `status`." end + raise ::AbstractController::DoubleRenderError if response_body + status ||= :ok - location = options.delete(:location) - content_type = options.delete(:content_type) + if options + location = options.delete(:location) + content_type = options.delete(:content_type) - options.each do |key, value| - headers[key.to_s.dasherize.split("-").each { |v| v[0] = v[0].chr.upcase }.join("-")] = value.to_s + options.each do |key, value| + headers[key.to_s.split(/[-_]/).each { |v| v[0] = v[0].upcase }.join("-")] = value.to_s + end end self.status = status self.location = url_for(location) if location - self.response_body = "" - if include_content?(response_code) - self.content_type = content_type || (Mime[formats.first] if formats) + unless self.media_type + self.content_type = content_type || ((f = formats) && Mime[f.first]) || :html + end + response.charset = false end + self.response_body = "" + true end diff --git a/actionpack/lib/action_controller/metal/helpers.rb b/actionpack/lib/action_controller/metal/helpers.rb index 476d081239866..3b660843cb995 100644 --- a/actionpack/lib/action_controller/metal/helpers.rb +++ b/actionpack/lib/action_controller/metal/helpers.rb @@ -1,50 +1,64 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController - # The \Rails framework provides a large number of helpers for working with assets, dates, forms, - # numbers and model objects, to name a few. These helpers are available to all templates - # by default. + # # Action Controller Helpers # - # In addition to using the standard template helpers provided, creating custom helpers to - # extract complicated logic or reusable functionality is strongly encouraged. By default, each controller - # will include all helpers. These helpers are only accessible on the controller through #helpers + # The Rails framework provides a large number of helpers for working with + # assets, dates, forms, numbers and model objects, to name a few. These helpers + # are available to all templates by default. # - # In previous versions of \Rails the controller will include a helper which - # matches the name of the controller, e.g., MyController will automatically - # include MyHelper. To return old behavior set +config.action_controller.include_all_helpers+ to +false+. + # In addition to using the standard template helpers provided, creating custom + # helpers to extract complicated logic or reusable functionality is strongly + # encouraged. By default, each controller will include all helpers. These + # helpers are only accessible on the controller through `#helpers` # - # Additional helpers can be specified using the +helper+ class method in ActionController::Base or any - # controller which inherits from it. + # In previous versions of Rails the controller will include a helper which + # matches the name of the controller, e.g., `MyController` will automatically + # include `MyHelper`. You can revert to the old behavior with the following: + # + # # config/application.rb + # class Application < Rails::Application + # config.action_controller.include_all_helpers = false + # end # - # The +to_s+ method from the \Time class can be wrapped in a helper method to display a custom message if - # a \Time object is blank: + # Additional helpers can be specified using the `helper` class method in + # ActionController::Base or any controller which inherits from it. # - # module FormattedTimeHelper - # def format_time(time, format=:long, blank_message=" ") - # time.blank? ? blank_message : time.to_s(format) + # The `to_s` method from the Time class can be wrapped in a helper method to + # display a custom message if a Time object is blank: + # + # module FormattedTimeHelper + # def format_time(time, format=:long, blank_message=" ") + # time.blank? ? blank_message : time.to_fs(format) + # end # end - # end # - # FormattedTimeHelper can now be included in a controller, using the +helper+ class method: + # FormattedTimeHelper can now be included in a controller, using the `helper` + # class method: # - # class EventsController < ActionController::Base - # helper FormattedTimeHelper - # def index - # @events = Event.all + # class EventsController < ActionController::Base + # helper FormattedTimeHelper + # def index + # @events = Event.all + # end # end - # end # - # Then, in any view rendered by EventController, the format_time method can be called: + # Then, in any view rendered by `EventsController`, the `format_time` method can + # be called: # - # <% @events.each do |event| -%> - #

- # <%= format_time(event.time, :short, "N/A") %> | <%= event.name %> - #

- # <% end -%> + # <% @events.each do |event| -%> + #

+ # <%= format_time(event.time, :short, "N/A") %> | <%= event.name %> + #

+ # <% end -%> # - # Finally, assuming we have two event instances, one which has a time and one which does not, - # the output might look like this: + # Finally, assuming we have two event instances, one which has a time and one + # which does not, the output might look like this: # - # 23 Aug 11:30 | Carolina Railhawks Soccer Match - # N/A | Carolina Railhawks Training Workshop + # 23 Aug 11:30 | Carolina Railhawks Soccer Match + # N/A | Carolina Railhawks Training Workshop # module Helpers extend ActiveSupport::Concern @@ -53,62 +67,55 @@ class << self; attr_accessor :helpers_path; end include AbstractController::Helpers included do - class_attribute :helpers_path, :include_all_helpers - self.helpers_path ||= [] - self.include_all_helpers = true + class_attribute :helpers_path, default: [] + class_attribute :include_all_helpers, default: true end module ClassMethods # Declares helper accessors for controller attributes. For example, the - # following adds new +name+ and name= instance methods to a - # controller and makes them available to the view: - # attr_accessor :name - # helper_attr :name + # following adds new `name` and `name=` instance methods to a controller and + # makes them available to the view: + # attr_accessor :name + # helper_attr :name + # + # #### Parameters + # * `attrs` - Names of attributes to be converted into helpers. # - # ==== Parameters - # * attrs - Names of attributes to be converted into helpers. def helper_attr(*attrs) attrs.flatten.each { |attr| helper_method(attr, "#{attr}=") } end # Provides a proxy to access helper methods from outside the view. + # + # Note that the proxy is rendered under a different view context. This may cause + # incorrect behavior with capture methods. Consider using + # [helper](rdoc-ref:AbstractController::Helpers::ClassMethods#helper) instead + # when using `capture`. def helpers @helper_proxy ||= begin - proxy = ActionView::Base.new + proxy = ActionView::Base.empty proxy.config = config.inheritable_copy proxy.extend(_helpers) end end - # Overwrite modules_for_helpers to accept :all as argument, which loads - # all helpers in helpers_path. + # Override modules_for_helpers to accept `:all` as argument, which loads all + # helpers in helpers_path. # - # ==== Parameters - # * args - A list of helpers + # #### Parameters + # * `args` - A list of helpers + # + # + # #### Returns + # * `array` - A normalized list of modules for the list of helpers provided. # - # ==== Returns - # * array - A normalized list of modules for the list of helpers provided. def modules_for_helpers(args) args += all_application_helpers if args.delete(:all) super(args) end - # Returns a list of helper names in a given path. - # - # ActionController::Base.all_helpers_from_path 'app/helpers' - # # => ["application", "chart", "rubygems"] - def all_helpers_from_path(path) - helpers = Array(path).flat_map do |_path| - extract = /^#{Regexp.quote(_path.to_s)}\/?(.*)_helper.rb$/ - names = Dir["#{_path}/**/*_helper.rb"].map { |file| file.sub(extract, '\1'.freeze) } - names.sort! - end - helpers.uniq! - helpers - end - private - # Extract helper names from files in app/helpers/**/*_helper.rb + # Extract helper names from files in `app/helpers/***/**_helper.rb` def all_application_helpers all_helpers_from_path(helpers_path) end diff --git a/actionpack/lib/action_controller/metal/http_authentication.rb b/actionpack/lib/action_controller/metal/http_authentication.rb index 0575360068d83..a651242fc45a1 100644 --- a/actionpack/lib/action_controller/metal/http_authentication.rb +++ b/actionpack/lib/action_controller/metal/http_authentication.rb @@ -1,64 +1,71 @@ +# frozen_string_literal: true + +# :markup: markdown + require "base64" require "active_support/security_utils" +require "active_support/core_ext/array/access" module ActionController - # Makes it dead easy to do HTTP Basic, Digest and Token authentication. + # HTTP Basic, Digest, and Token authentication. module HttpAuthentication - # Makes it dead easy to do HTTP \Basic authentication. + # # HTTP Basic authentication # - # === Simple \Basic example + # ### Simple Basic example # - # class PostsController < ApplicationController - # http_basic_authenticate_with name: "dhh", password: "secret", except: :index + # class PostsController < ApplicationController + # http_basic_authenticate_with name: "dhh", password: "secret", except: :index # - # def index - # render plain: "Everyone can see me!" - # end + # def index + # render plain: "Everyone can see me!" + # end # - # def edit - # render plain: "I'm only accessible if you know the password" + # def edit + # render plain: "I'm only accessible if you know the password" + # end # end - # end # - # === Advanced \Basic example + # ### Advanced Basic example # - # Here is a more advanced \Basic example where only Atom feeds and the XML API is protected by HTTP authentication, - # the regular HTML interface is protected by a session approach: + # Here is a more advanced Basic example where only Atom feeds and the XML API + # are protected by HTTP authentication. The regular HTML interface is protected + # by a session approach: # - # class ApplicationController < ActionController::Base - # before_action :set_account, :authenticate + # class ApplicationController < ActionController::Base + # before_action :set_account, :authenticate # - # private - # def set_account - # @account = Account.find_by(url_name: request.subdomains.first) - # end + # private + # def set_account + # @account = Account.find_by(url_name: request.subdomains.first) + # end # - # def authenticate - # case request.format - # when Mime[:xml], Mime[:atom] - # if user = authenticate_with_http_basic { |u, p| @account.users.authenticate(u, p) } - # @current_user = user + # def authenticate + # case request.format + # when Mime[:xml], Mime[:atom] + # if user = authenticate_with_http_basic { |u, p| @account.users.authenticate(u, p) } + # @current_user = user + # else + # request_http_basic_authentication + # end # else - # request_http_basic_authentication - # end - # else - # if session_authenticated? - # @current_user = @account.users.find(session[:authenticated][:user_id]) - # else - # redirect_to(login_url) and return false + # if session_authenticated? + # @current_user = @account.users.find(session[:authenticated][:user_id]) + # else + # redirect_to(login_url) and return false + # end # end # end - # end - # end + # end # # In your integration tests, you can do something like this: # - # def test_access_granted_from_xml - # @request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(users(:dhh).name, users(:dhh).password) - # get "/notes/1.xml" + # def test_access_granted_from_xml + # authorization = ActionController::HttpAuthentication::Basic.encode_credentials(users(:dhh).name, users(:dhh).password) # - # assert_equal 200, status - # end + # get "/notes/1.xml", headers: { 'HTTP_AUTHORIZATION' => authorization } + # + # assert_equal 200, status + # end module Basic extend self @@ -66,21 +73,27 @@ module ControllerMethods extend ActiveSupport::Concern module ClassMethods - def http_basic_authenticate_with(options = {}) - before_action(options.except(:name, :password, :realm)) do - authenticate_or_request_with_http_basic(options[:realm] || "Application") do |name, password| - # This comparison uses & so that it doesn't short circuit and - # uses `variable_size_secure_compare` so that length information - # isn't leaked. - ActiveSupport::SecurityUtils.variable_size_secure_compare(name, options[:name]) & - ActiveSupport::SecurityUtils.variable_size_secure_compare(password, options[:password]) - end - end + # Enables HTTP Basic authentication. + # + # See ActionController::HttpAuthentication::Basic for example usage. + def http_basic_authenticate_with(name:, password:, realm: nil, **options) + raise ArgumentError, "Expected name: to be a String, got #{name.class}" unless name.is_a?(String) + raise ArgumentError, "Expected password: to be a String, got #{password.class}" unless password.is_a?(String) + before_action(options) { http_basic_authenticate_or_request_with name: name, password: password, realm: realm } end end - def authenticate_or_request_with_http_basic(realm = "Application", message = nil, &login_procedure) - authenticate_with_http_basic(&login_procedure) || request_http_basic_authentication(realm, message) + def http_basic_authenticate_or_request_with(name:, password:, realm: nil, message: nil) + authenticate_or_request_with_http_basic(realm, message) do |given_name, given_password| + # This comparison uses & so that it doesn't short circuit and uses + # `secure_compare` so that length information isn't leaked. + ActiveSupport::SecurityUtils.secure_compare(given_name.to_s, name) & + ActiveSupport::SecurityUtils.secure_compare(given_password.to_s, password) + end + end + + def authenticate_or_request_with_http_basic(realm = nil, message = nil, &login_procedure) + authenticate_with_http_basic(&login_procedure) || request_http_basic_authentication(realm || "Application", message) end def authenticate_with_http_basic(&login_procedure) @@ -124,81 +137,88 @@ def encode_credentials(user_name, password) def authentication_request(controller, realm, message) message ||= "HTTP Basic: Access denied.\n" - controller.headers["WWW-Authenticate"] = %(Basic realm="#{realm.tr('"'.freeze, "".freeze)}") + controller.headers["WWW-Authenticate"] = %(Basic realm="#{realm.tr('"', "")}") controller.status = 401 controller.response_body = message end end - # Makes it dead easy to do HTTP \Digest authentication. + # # HTTP Digest authentication # - # === Simple \Digest example + # ### Simple Digest example # - # require 'digest/md5' - # class PostsController < ApplicationController - # REALM = "SuperSecret" - # USERS = {"dhh" => "secret", #plain text password - # "dap" => Digest::MD5.hexdigest(["dap",REALM,"secret"].join(":"))} #ha1 digest password + # require "openssl" + # class PostsController < ApplicationController + # REALM = "SuperSecret" + # USERS = {"dhh" => "secret", #plain text password + # "dap" => OpenSSL::Digest::MD5.hexdigest(["dap",REALM,"secret"].join(":"))} #ha1 digest password # - # before_action :authenticate, except: [:index] + # before_action :authenticate, except: [:index] # - # def index - # render plain: "Everyone can see me!" - # end + # def index + # render plain: "Everyone can see me!" + # end # - # def edit - # render plain: "I'm only accessible if you know the password" - # end + # def edit + # render plain: "I'm only accessible if you know the password" + # end # - # private - # def authenticate - # authenticate_or_request_with_http_digest(REALM) do |username| - # USERS[username] + # private + # def authenticate + # authenticate_or_request_with_http_digest(REALM) do |username| + # USERS[username] + # end # end - # end - # end + # end # - # === Notes + # ### Notes # - # The +authenticate_or_request_with_http_digest+ block must return the user's password - # or the ha1 digest hash so the framework can appropriately hash to check the user's - # credentials. Returning +nil+ will cause authentication to fail. + # The `authenticate_or_request_with_http_digest` block must return the user's + # password or the ha1 digest hash so the framework can appropriately hash to + # check the user's credentials. Returning `nil` will cause authentication to + # fail. # - # Storing the ha1 hash: MD5(username:realm:password), is better than storing a plain password. If - # the password file or database is compromised, the attacker would be able to use the ha1 hash to - # authenticate as the user at this +realm+, but would not have the user's password to try using at - # other sites. + # Storing the ha1 hash: MD5(username:realm:password), is better than storing a + # plain password. If the password file or database is compromised, the attacker + # would be able to use the ha1 hash to authenticate as the user at this `realm`, + # but would not have the user's password to try using at other sites. # - # In rare instances, web servers or front proxies strip authorization headers before - # they reach your application. You can debug this situation by logging all environment - # variables, and check for HTTP_AUTHORIZATION, amongst others. + # In rare instances, web servers or front proxies strip authorization headers + # before they reach your application. You can debug this situation by logging + # all environment variables, and check for HTTP_AUTHORIZATION, amongst others. module Digest extend self module ControllerMethods + # Authenticate using an HTTP Digest, or otherwise render an HTTP header + # requesting the client to send a Digest. + # + # See ActionController::HttpAuthentication::Digest for example usage. def authenticate_or_request_with_http_digest(realm = "Application", message = nil, &password_procedure) authenticate_with_http_digest(realm, &password_procedure) || request_http_digest_authentication(realm, message) end - # Authenticate with HTTP Digest, returns true or false + # Authenticate using an HTTP Digest. Returns true if authentication is + # successful, false otherwise. def authenticate_with_http_digest(realm = "Application", &password_procedure) HttpAuthentication::Digest.authenticate(request, realm, &password_procedure) end - # Render output including the HTTP Digest authentication header + # Render an HTTP header requesting the client to send a Digest for + # authentication. def request_http_digest_authentication(realm = "Application", message = nil) HttpAuthentication::Digest.authentication_request(self, realm, message) end end - # Returns false on a valid response, true otherwise + # Returns true on a valid response, false otherwise. def authenticate(request, realm, &password_procedure) request.authorization && validate_digest_response(request, realm, &password_procedure) end - # Returns false unless the request credentials response value matches the expected value. - # First try the password as a ha1 digest password. If this fails, then try it as a plain - # text password. + # Returns false unless the request credentials response value matches the + # expected value. First try the password as a ha1 digest password. If this + # fails, then try it as a plain text password. def validate_digest_response(request, realm, &password_procedure) secret_key = secret_token(request) credentials = decode_credentials_header(request) @@ -221,17 +241,18 @@ def validate_digest_response(request, realm, &password_procedure) end end - # Returns the expected response for a request of +http_method+ to +uri+ with the decoded +credentials+ and the expected +password+ - # Optional parameter +password_is_ha1+ is set to +true+ by default, since best practice is to store ha1 digest instead - # of a plain-text password. + # Returns the expected response for a request of `http_method` to `uri` with the + # decoded `credentials` and the expected `password` Optional parameter + # `password_is_ha1` is set to `true` by default, since best practice is to store + # ha1 digest instead of a plain-text password. def expected_response(http_method, uri, credentials, password, password_is_ha1 = true) ha1 = password_is_ha1 ? password : ha1(credentials, password) - ha2 = ::Digest::MD5.hexdigest([http_method.to_s.upcase, uri].join(":")) - ::Digest::MD5.hexdigest([ha1, credentials[:nonce], credentials[:nc], credentials[:cnonce], credentials[:qop], ha2].join(":")) + ha2 = OpenSSL::Digest::MD5.hexdigest([http_method.to_s.upcase, uri].join(":")) + OpenSSL::Digest::MD5.hexdigest([ha1, credentials[:nonce], credentials[:nc], credentials[:cnonce], credentials[:qop], ha2].join(":")) end def ha1(credentials, password) - ::Digest::MD5.hexdigest([credentials[:username], credentials[:realm], password].join(":")) + OpenSSL::Digest::MD5.hexdigest([credentials[:username], credentials[:realm], password].join(":")) end def encode_credentials(http_method, credentials, password, password_is_ha1) @@ -246,7 +267,7 @@ def decode_credentials_header(request) def decode_credentials(header) ActiveSupport::HashWithIndifferentAccess[header.to_s.gsub(/^Digest\s+/, "").split(",").map do |pair| key, value = pair.split("=", 2) - [key.strip, value.to_s.gsub(/^"|"$/, "").delete('\'')] + [key.strip, value.to_s.gsub(/^"|"$/, "").delete("'")] end] end @@ -272,48 +293,51 @@ def secret_token(request) # Uses an MD5 digest based on time to generate a value to be used only once. # - # A server-specified data string which should be uniquely generated each time a 401 response is made. - # It is recommended that this string be base64 or hexadecimal data. - # Specifically, since the string is passed in the header lines as a quoted string, the double-quote character is not allowed. + # A server-specified data string which should be uniquely generated each time a + # 401 response is made. It is recommended that this string be base64 or + # hexadecimal data. Specifically, since the string is passed in the header lines + # as a quoted string, the double-quote character is not allowed. # - # The contents of the nonce are implementation dependent. - # The quality of the implementation depends on a good choice. - # A nonce might, for example, be constructed as the base 64 encoding of + # The contents of the nonce are implementation dependent. The quality of the + # implementation depends on a good choice. A nonce might, for example, be + # constructed as the base 64 encoding of # - # time-stamp H(time-stamp ":" ETag ":" private-key) + # time-stamp H(time-stamp ":" ETag ":" private-key) # - # where time-stamp is a server-generated time or other non-repeating value, - # ETag is the value of the HTTP ETag header associated with the requested entity, - # and private-key is data known only to the server. - # With a nonce of this form a server would recalculate the hash portion after receiving the client authentication header and - # reject the request if it did not match the nonce from that header or - # if the time-stamp value is not recent enough. In this way the server can limit the time of the nonce's validity. - # The inclusion of the ETag prevents a replay request for an updated version of the resource. - # (Note: including the IP address of the client in the nonce would appear to offer the server the ability - # to limit the reuse of the nonce to the same client that originally got it. - # However, that would break proxy farms, where requests from a single user often go through different proxies in the farm. - # Also, IP address spoofing is not that hard.) + # where time-stamp is a server-generated time or other non-repeating value, ETag + # is the value of the HTTP ETag header associated with the requested entity, and + # private-key is data known only to the server. With a nonce of this form a + # server would recalculate the hash portion after receiving the client + # authentication header and reject the request if it did not match the nonce + # from that header or if the time-stamp value is not recent enough. In this way + # the server can limit the time of the nonce's validity. The inclusion of the + # ETag prevents a replay request for an updated version of the resource. (Note: + # including the IP address of the client in the nonce would appear to offer the + # server the ability to limit the reuse of the nonce to the same client that + # originally got it. However, that would break proxy farms, where requests from + # a single user often go through different proxies in the farm. Also, IP address + # spoofing is not that hard.) # - # An implementation might choose not to accept a previously used nonce or a previously used digest, in order to - # protect against a replay attack. Or, an implementation might choose to use one-time nonces or digests for - # POST, PUT, or PATCH requests and a time-stamp for GET requests. For more details on the issues involved see Section 4 - # of this document. + # An implementation might choose not to accept a previously used nonce or a + # previously used digest, in order to protect against a replay attack. Or, an + # implementation might choose to use one-time nonces or digests for POST, PUT, + # or PATCH requests, and a time-stamp for GET requests. For more details on the + # issues involved see Section 4 of this document. # - # The nonce is opaque to the client. Composed of Time, and hash of Time with secret - # key from the Rails session secret generated upon creation of project. Ensures - # the time cannot be modified by client. + # The nonce is opaque to the client. Composed of Time, and hash of Time with + # secret key from the Rails session secret generated upon creation of project. + # Ensures the time cannot be modified by client. def nonce(secret_key, time = Time.now) t = time.to_i hashed = [t, secret_key] - digest = ::Digest::MD5.hexdigest(hashed.join(":")) + digest = OpenSSL::Digest::MD5.hexdigest(hashed.join(":")) ::Base64.strict_encode64("#{t}:#{digest}") end - # Might want a shorter timeout depending on whether the request - # is a PATCH, PUT, or POST, and if the client is a browser or web service. - # Can be much shorter if the Stale directive is implemented. This would - # allow a user to use new nonce without prompting the user again for their - # username and password. + # Might want a shorter timeout depending on whether the request is a PATCH, PUT, + # or POST, and if the client is a browser or web service. Can be much shorter if + # the Stale directive is implemented. This would allow a user to use new nonce + # without prompting the user again for their username and password. def validate_nonce(secret_key, request, value, seconds_to_timeout = 5 * 60) return false if value.nil? t = ::Base64.decode64(value).split(":").first.to_i @@ -322,122 +346,129 @@ def validate_nonce(secret_key, request, value, seconds_to_timeout = 5 * 60) # Opaque based on digest of secret key def opaque(secret_key) - ::Digest::MD5.hexdigest(secret_key) + OpenSSL::Digest::MD5.hexdigest(secret_key) end end - # Makes it dead easy to do HTTP Token authentication. + # # HTTP Token authentication # - # Simple Token example: + # ### Simple Token example # - # class PostsController < ApplicationController - # TOKEN = "secret" + # class PostsController < ApplicationController + # TOKEN = "secret" # - # before_action :authenticate, except: [ :index ] + # before_action :authenticate, except: [ :index ] # - # def index - # render plain: "Everyone can see me!" - # end - # - # def edit - # render plain: "I'm only accessible if you know the password" - # end + # def index + # render plain: "Everyone can see me!" + # end # - # private - # def authenticate - # authenticate_or_request_with_http_token do |token, options| - # # Compare the tokens in a time-constant manner, to mitigate - # # timing attacks. - # ActiveSupport::SecurityUtils.secure_compare( - # ::Digest::SHA256.hexdigest(token), - # ::Digest::SHA256.hexdigest(TOKEN) - # ) - # end + # def edit + # render plain: "I'm only accessible if you know the password" # end - # end # + # private + # def authenticate + # authenticate_or_request_with_http_token do |token, options| + # # Compare the tokens in a time-constant manner, to mitigate + # # timing attacks. + # ActiveSupport::SecurityUtils.secure_compare(token, TOKEN) + # end + # end + # end # - # Here is a more advanced Token example where only Atom feeds and the XML API is protected by HTTP token authentication, - # the regular HTML interface is protected by a session approach: + # Here is a more advanced Token example where only Atom feeds and the XML API + # are protected by HTTP token authentication. The regular HTML interface is + # protected by a session approach: # - # class ApplicationController < ActionController::Base - # before_action :set_account, :authenticate + # class ApplicationController < ActionController::Base + # before_action :set_account, :authenticate # - # private - # def set_account - # @account = Account.find_by(url_name: request.subdomains.first) - # end + # private + # def set_account + # @account = Account.find_by(url_name: request.subdomains.first) + # end # - # def authenticate - # case request.format - # when Mime[:xml], Mime[:atom] - # if user = authenticate_with_http_token { |t, o| @account.users.authenticate(t, o) } - # @current_user = user - # else - # request_http_token_authentication - # end - # else - # if session_authenticated? - # @current_user = @account.users.find(session[:authenticated][:user_id]) + # def authenticate + # case request.format + # when Mime[:xml], Mime[:atom] + # if user = authenticate_with_http_token { |t, o| @account.users.authenticate(t, o) } + # @current_user = user + # else + # request_http_token_authentication + # end # else - # redirect_to(login_url) and return false + # if session_authenticated? + # @current_user = @account.users.find(session[:authenticated][:user_id]) + # else + # redirect_to(login_url) and return false + # end # end # end - # end - # end - # + # end # # In your integration tests, you can do something like this: # - # def test_access_granted_from_xml - # get( - # "/notes/1.xml", nil, - # 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Token.encode_credentials(users(:dhh).token) - # ) + # def test_access_granted_from_xml + # authorization = ActionController::HttpAuthentication::Token.encode_credentials(users(:dhh).token) # - # assert_equal 200, status - # end + # get "/notes/1.xml", headers: { 'HTTP_AUTHORIZATION' => authorization } # + # assert_equal 200, status + # end # - # On shared hosts, Apache sometimes doesn't pass authentication headers to - # FCGI instances. If your environment matches this description and you cannot + # On shared hosts, Apache sometimes doesn't pass authentication headers to FCGI + # instances. If your environment matches this description and you cannot # authenticate, try this rule in your Apache setup: # - # RewriteRule ^(.*)$ dispatch.fcgi [E=X-HTTP_AUTHORIZATION:%{HTTP:Authorization},QSA,L] + # RewriteRule ^(.*)$ dispatch.fcgi [E=X-HTTP_AUTHORIZATION:%{HTTP:Authorization},QSA,L] module Token TOKEN_KEY = "token=" TOKEN_REGEX = /^(Token|Bearer)\s+/ - AUTHN_PAIR_DELIMITERS = /(?:,|;|\t+)/ + AUTHN_PAIR_DELIMITERS = /(?:,|;|\t)/ extend self module ControllerMethods + # Authenticate using an HTTP Bearer token, or otherwise render an HTTP header + # requesting the client to send a Bearer token. For the authentication to be + # considered successful, `login_procedure` must not return a false value. + # Typically, the authenticated user is returned. + # + # See ActionController::HttpAuthentication::Token for example usage. def authenticate_or_request_with_http_token(realm = "Application", message = nil, &login_procedure) authenticate_with_http_token(&login_procedure) || request_http_token_authentication(realm, message) end + # Authenticate using an HTTP Bearer token. Returns the return value of + # `login_procedure` if a token is found. Returns `nil` if no token is found. + # + # See ActionController::HttpAuthentication::Token for example usage. def authenticate_with_http_token(&login_procedure) Token.authenticate(self, &login_procedure) end + # Render an HTTP header requesting the client to send a Bearer token for + # authentication. def request_http_token_authentication(realm = "Application", message = nil) Token.authentication_request(self, realm, message) end end - # If token Authorization header is present, call the login - # procedure with the present token and options. + # If token Authorization header is present, call the login procedure with the + # present token and options. # - # [controller] - # ActionController::Base instance for the current request. + # Returns the return value of `login_procedure` if a token is found. Returns + # `nil` if no token is found. # - # [login_procedure] - # Proc to call if a token is present. The Proc should take two arguments: + # #### Parameters + # + # * `controller` - ActionController::Base instance for the current request. + # * `login_procedure` - Proc to call if a token is present. The Proc should + # take two arguments: + # + # authenticate(controller) { |token, options| ... } # - # authenticate(controller) { |token, options| ... } # - # Returns the return value of login_procedure if a - # token is found. Returns nil if no token is found. - def authenticate(controller, &login_procedure) token, options = token_and_options(controller.request) unless token.blank? @@ -445,17 +476,21 @@ def authenticate(controller, &login_procedure) end end - # Parses the token and options out of the token authorization header. - # The value for the Authorization header is expected to have the prefix - # "Token" or "Bearer". If the header looks like this: - # Authorization: Token token="abc", nonce="def" - # Then the returned token is "abc", and the options are - # {nonce: "def"} + # Parses the token and options out of the token Authorization header. The value + # for the Authorization header is expected to have the prefix `"Token"` or + # `"Bearer"`. If the header looks like this: + # + # Authorization: Token token="abc", nonce="def" + # + # Then the returned token is `"abc"`, and the options are `{nonce: "def"}`. + # + # Returns an `Array` of `[String, Hash]` if a token is present. Returns `nil` if + # no token is found. # - # request - ActionDispatch::Request instance with the current headers. + # #### Parameters + # + # * `request` - ActionDispatch::Request instance with the current headers. # - # Returns an +Array+ of [String, Hash] if a token is present. - # Returns +nil+ if no token is found. def token_and_options(request) authorization_request = request.authorization.to_s if authorization_request[TOKEN_REGEX] @@ -468,23 +503,24 @@ def token_params_from(auth) rewrite_param_values params_array_from raw_params auth end - # Takes raw_params and turns it into an array of parameters + # Takes `raw_params` and turns it into an array of parameters. def params_array_from(raw_params) raw_params.map { |param| param.split %r/=(.+)?/ } end - # This removes the " characters wrapping the value. + # This removes the `"` characters wrapping the value. def rewrite_param_values(array_params) - array_params.each { |param| (param[1] || "").gsub! %r/^"|"$/, "" } + array_params.each { |param| (param[1] || +"").gsub! %r/^"|"$/, "" } end - # This method takes an authorization body and splits up the key-value - # pairs by the standardized :, ;, or \t - # delimiters defined in +AUTHN_PAIR_DELIMITERS+. + # This method takes an authorization body and splits up the key-value pairs by + # the standardized `:`, `;`, or `\t` delimiters defined in + # `AUTHN_PAIR_DELIMITERS`. def raw_params(auth) - _raw_params = auth.sub(TOKEN_REGEX, "").split(/\s*#{AUTHN_PAIR_DELIMITERS}\s*/) + _raw_params = auth.sub(TOKEN_REGEX, "").split(AUTHN_PAIR_DELIMITERS).map(&:strip) + _raw_params.reject!(&:empty?) - if !(_raw_params.first =~ %r{\A#{TOKEN_KEY}}) + if !_raw_params.first&.start_with?(TOKEN_KEY) _raw_params[0] = "#{TOKEN_KEY}#{_raw_params.first}" end @@ -493,10 +529,13 @@ def raw_params(auth) # Encodes the given token and options into an Authorization header value. # - # token - String token. - # options - optional Hash of the options. - # # Returns String. + # + # #### Parameters + # + # * `token` - String token. + # * `options` - Optional Hash of the options. + # def encode_credentials(token, options = {}) values = ["#{TOKEN_KEY}#{token.to_s.inspect}"] + options.map do |key, value| "#{key}=#{value.to_s.inspect}" @@ -506,13 +545,16 @@ def encode_credentials(token, options = {}) # Sets a WWW-Authenticate header to let the client know a token is desired. # - # controller - ActionController::Base instance for the outgoing response. - # realm - String realm to use in the header. - # # Returns nothing. + # + # #### Parameters + # + # * `controller` - ActionController::Base instance for the outgoing response. + # * `realm` - String realm to use in the header. + # def authentication_request(controller, realm, message = nil) message ||= "HTTP Token: Access denied.\n" - controller.headers["WWW-Authenticate"] = %(Token realm="#{realm.tr('"'.freeze, "".freeze)}") + controller.headers["WWW-Authenticate"] = %(Token realm="#{realm.tr('"', "")}") controller.__send__ :render, plain: message, status: :unauthorized end end diff --git a/actionpack/lib/action_controller/metal/implicit_render.rb b/actionpack/lib/action_controller/metal/implicit_render.rb index dde924e682b2f..fe2590e74eee4 100644 --- a/actionpack/lib/action_controller/metal/implicit_render.rb +++ b/actionpack/lib/action_controller/metal/implicit_render.rb @@ -1,36 +1,42 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController - # Handles implicit rendering for a controller action that does not - # explicitly respond with +render+, +respond_to+, +redirect+, or +head+. + # # Action Controller Implicit Render + # + # Handles implicit rendering for a controller action that does not explicitly + # respond with `render`, `respond_to`, `redirect`, or `head`. # - # For API controllers, the implicit response is always 204 No Content. + # For API controllers, the implicit response is always `204 No Content`. # - # For all other controllers, we use these heuristics to decide whether to - # render a template, raise an error for a missing template, or respond with - # 204 No Content: + # For all other controllers, we use these heuristics to decide whether to render + # a template, raise an error for a missing template, or respond with `204 No + # Content`: # - # First, if we DO find a template, it's rendered. Template lookup accounts - # for the action name, locales, format, variant, template handlers, and more - # (see +render+ for details). + # First, if we DO find a template, it's rendered. Template lookup accounts for + # the action name, locales, format, variant, template handlers, and more (see + # `render` for details). # # Second, if we DON'T find a template but the controller action does have - # templates for other formats, variants, etc., then we trust that you meant - # to provide a template for this response, too, and we raise - # ActionController::UnknownFormat with an explanation. + # templates for other formats, variants, etc., then we trust that you meant to + # provide a template for this response, too, and we raise + # ActionController::UnknownFormat with an explanation. # # Third, if we DON'T find a template AND the request is a page load in a web - # browser (technically, a non-XHR GET request for an HTML response) where - # you reasonably expect to have rendered a template, then we raise - # ActionView::UnknownFormat with an explanation. + # browser (technically, a non-XHR GET request for an HTML response) where you + # reasonably expect to have rendered a template, then we raise + # ActionController::MissingExactTemplate with an explanation. # # Finally, if we DON'T find a template AND the request isn't a browser page - # load, then we implicitly respond with 204 No Content. + # load, then we implicitly respond with `204 No Content`. module ImplicitRender # :stopdoc: include BasicImplicitRender - def default_render(*args) + def default_render if template_exists?(action_name.to_s, _prefixes, variants: request.variant) - render(*args) + render elsif any_templates?(action_name.to_s, _prefixes) message = "#{self.class.name}\##{action_name} is missing a template " \ "for this request format and variant.\n" \ @@ -39,18 +45,8 @@ def default_render(*args) raise ActionController::UnknownFormat, message elsif interactive_browser_request? - message = "#{self.class.name}\##{action_name} is missing a template " \ - "for this request format and variant.\n\n" \ - "request.formats: #{request.formats.map(&:to_s).inspect}\n" \ - "request.variant: #{request.variant.inspect}\n\n" \ - "NOTE! For XHR/Ajax or API requests, this action would normally " \ - "respond with 204 No Content: an empty white screen. Since you're " \ - "loading it in a web browser, we assume that you expected to " \ - "actually render a template, not nothing, so we're showing an " \ - "error to be extra-clear. If you expect 204 No Content, carry on. " \ - "That's what you'll get from an XHR or API request. Give it a shot." - - raise ActionController::UnknownFormat, message + message = "#{self.class.name}\##{action_name} is missing a template for request formats: #{request.formats.map(&:to_s).join(',')}" + raise ActionController::MissingExactTemplate.new(message, self.class, action_name) else logger.info "No template found for #{self.class.name}\##{action_name}, rendering head :no_content" if logger super diff --git a/actionpack/lib/action_controller/metal/instrumentation.rb b/actionpack/lib/action_controller/metal/instrumentation.rb index 924686218f253..8f7f634841915 100644 --- a/actionpack/lib/action_controller/metal/instrumentation.rb +++ b/actionpack/lib/action_controller/metal/instrumentation.rb @@ -1,10 +1,16 @@ -require "benchmark" +# frozen_string_literal: true + +# :markup: markdown + require "abstract_controller/logger" module ActionController - # Adds instrumentation to several ends in ActionController::Base. It also provides - # some hooks related with process_action, this allows an ORM like Active Record - # and/or DataMapper to plug in ActionController and show related information. + # # Action Controller Instrumentation + # + # Adds instrumentation to several ends in ActionController::Base. It also + # provides some hooks related with process_action. This allows an ORM like + # Active Record and/or DataMapper to plug in ActionController and show related + # information. # # Check ActiveRecord::Railties::ControllerRuntime for an example. module Instrumentation @@ -14,34 +20,15 @@ module Instrumentation attr_internal :view_runtime - def process_action(*args) - raw_payload = { - controller: self.class.name, - action: action_name, - params: request.filtered_parameters, - headers: request.headers, - format: request.format.ref, - method: request.request_method, - path: request.fullpath - } - - ActiveSupport::Notifications.instrument("start_processing.action_controller", raw_payload.dup) - - ActiveSupport::Notifications.instrument("process_action.action_controller", raw_payload) do |payload| - begin - result = super - payload[:status] = response.status - result - ensure - append_info_to_payload(payload) - end - end + def initialize(...) # :nodoc: + super + self.view_runtime = nil end - def render(*args) + def render(*) render_output = nil self.view_runtime = cleanup_view_runtime do - Benchmark.ms { render_output = super } + ActiveSupport::Benchmark.realtime(:float_millisecond) { render_output = super } end render_output end @@ -59,8 +46,8 @@ def send_data(data, options = {}) end end - def redirect_to(*args) - ActiveSupport::Notifications.instrument("redirect_to.action_controller") do |payload| + def redirect_to(*) + ActiveSupport::Notifications.instrument("redirect_to.action_controller", request: request) do |payload| result = super payload[:status] = response.status payload[:location] = response.filtered_location @@ -68,42 +55,66 @@ def redirect_to(*args) end end - private + private + def process_action(*) + ActiveSupport::ExecutionContext[:controller] = self - # A hook invoked every time a before callback is halted. - def halted_callback_hook(filter) - ActiveSupport::Notifications.instrument("halted_callback.action_controller", filter: filter) - end + raw_payload = { + controller: self.class.name, + action: action_name, + request: request, + params: request.filtered_parameters, + headers: request.headers, + format: request.format.ref, + method: request.request_method, + path: request.filtered_path + } - # A hook which allows you to clean up any time, wrongly taken into account in - # views, like database querying time. - # - # def cleanup_view_runtime - # super - time_taken_in_something_expensive - # end - # - # :api: plugin - def cleanup_view_runtime - yield - end + ActiveSupport::Notifications.instrument("start_processing.action_controller", raw_payload) - # Every time after an action is processed, this method is invoked - # with the payload, so you can add more information. - # :api: plugin - def append_info_to_payload(payload) - payload[:view_runtime] = view_runtime - end + ActiveSupport::Notifications.instrument("process_action.action_controller", raw_payload) do |payload| + result = super + payload[:response] = response + payload[:status] = response.status + result + rescue => error + payload[:status] = ActionDispatch::ExceptionWrapper.status_code_for_exception(error.class.name) + raise + ensure + append_info_to_payload(payload) + end + end - module ClassMethods - # A hook which allows other frameworks to log what happened during - # controller process action. This method should return an array - # with the messages to be added. - # :api: plugin - def log_process_action(payload) #:nodoc: - messages, view_runtime = [], payload[:view_runtime] - messages << ("Views: %.1fms" % view_runtime.to_f) if view_runtime - messages + # A hook invoked every time a before callback is halted. + def halted_callback_hook(filter, _) + ActiveSupport::Notifications.instrument("halted_callback.action_controller", filter: filter) + end + + # A hook which allows you to clean up any time, wrongly taken into account in + # views, like database querying time. + # + # def cleanup_view_runtime + # super - time_taken_in_something_expensive + # end + def cleanup_view_runtime # :doc: + yield + end + + # Every time after an action is processed, this method is invoked with the + # payload, so you can add more information. + def append_info_to_payload(payload) # :doc: + payload[:view_runtime] = view_runtime + end + + module ClassMethods + # A hook which allows other frameworks to log what happened during controller + # process action. This method should return an array with the messages to be + # added. + def log_process_action(payload) # :nodoc: + messages, view_runtime = [], payload[:view_runtime] + messages << ("Views: %.1fms" % view_runtime.to_f) if view_runtime + messages + end end - end end end diff --git a/actionpack/lib/action_controller/metal/live.rb b/actionpack/lib/action_controller/metal/live.rb index fed99e6c82b4a..d2f415bb52374 100644 --- a/actionpack/lib/action_controller/metal/live.rb +++ b/actionpack/lib/action_controller/metal/live.rb @@ -1,43 +1,105 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/http/response" require "delegate" require "active_support/json" module ActionController - # Mix this module into your controller, and all actions in that controller - # will be able to stream data to the client as it's written. + # # Action Controller Live # - # class MyController < ActionController::Base - # include ActionController::Live + # Mix this module into your controller, and all actions in that controller will + # be able to stream data to the client as it's written. # - # def stream - # response.headers['Content-Type'] = 'text/event-stream' - # 100.times { - # response.stream.write "hello world\n" - # sleep 1 - # } - # ensure - # response.stream.close + # class MyController < ActionController::Base + # include ActionController::Live + # + # def stream + # response.headers['Content-Type'] = 'text/event-stream' + # 100.times { + # response.stream.write "hello world\n" + # sleep 1 + # } + # ensure + # response.stream.close + # end # end - # end # - # There are a few caveats with this module. You *cannot* write headers after the - # response has been committed (Response#committed? will return truthy). - # Calling +write+ or +close+ on the response stream will cause the response - # object to be committed. Make sure all headers are set before calling write - # or close on your stream. + # There are a few caveats with this module. You **cannot** write headers after + # the response has been committed (Response#committed? will return truthy). + # Calling `write` or `close` on the response stream will cause the response + # object to be committed. Make sure all headers are set before calling write or + # close on your stream. # - # You *must* call close on your stream when you're finished, otherwise the + # You **must** call close on your stream when you're finished, otherwise the # socket may be left open forever. # # The final caveat is that your actions are executed in a separate thread than - # the main thread. Make sure your actions are thread safe, and this shouldn't - # be a problem (don't share state across threads, etc). + # the main thread. Make sure your actions are thread safe, and this shouldn't be + # a problem (don't share state across threads, etc). + # + # Note that Rails includes `Rack::ETag` by default, which will buffer your + # response. As a result, streaming responses may not work properly with Rack + # 2.2.x, and you may need to implement workarounds in your application. You can + # either set the `ETag` or `Last-Modified` response headers or remove + # `Rack::ETag` from the middleware stack to address this issue. + # + # Here's an example of how you can set the `Last-Modified` header if your Rack + # version is 2.2.x: + # + # def stream + # response.headers["Content-Type"] = "text/event-stream" + # response.headers["Last-Modified"] = Time.now.httpdate # Add this line if your Rack version is 2.2.x + # ... + # end + # + # ## Streaming and Execution State + # + # When streaming, the action is executed in a separate thread. By default, this thread + # shares execution state from the parent thread. + # + # You can configure which execution state keys should be excluded from being shared + # using the `config.action_controller.live.streaming_excluded_keys` configuration: + # + # # config/application.rb + # config.action_controller.live.streaming_excluded_keys = [:active_record_connected_to_stack] + # + # This is useful when using ActionController::Live inside a `connected_to` block. For example, + # if the parent request is reading from a replica using `connected_to(role: :reading)`, you may + # want the streaming thread to use its own connection context instead of inheriting the read-only + # context: + # + # # Without configuration, streaming thread inherits read-only connection + # ActiveRecord::Base.connected_to(role: :reading) do + # @posts = Post.all + # render stream: true # Streaming thread cannot write to database + # end + # + # # With configuration, streaming thread gets fresh connection context + # # config.action_controller.live.streaming_excluded_keys = [:active_record_connected_to_stack] + # ActiveRecord::Base.connected_to(role: :reading) do + # @posts = Post.all + # render stream: true # Streaming thread can write to database if needed + # end + # + # Common keys you might want to exclude: + # - `:active_record_connected_to_stack` - Database connection routing and roles + # - `:active_record_prohibit_shard_swapping` - Shard swapping restrictions + # + # By default, no keys are excluded to maintain backward compatibility. module Live extend ActiveSupport::Concern + mattr_accessor :live_streaming_excluded_keys, default: [] + + included do + class_attribute :live_streaming_excluded_keys, instance_accessor: false, default: Live.live_streaming_excluded_keys + end + module ClassMethods def make_response!(request) - if request.get_header("HTTP_VERSION") == "HTTP/1.0" + if (request.get_header("SERVER_PROTOCOL") || request.get_header("HTTP_VERSION")) == "HTTP/1.0" super else Live::Response.new.tap do |res| @@ -47,44 +109,49 @@ def make_response!(request) end end - # This class provides the ability to write an SSE (Server Sent Event) - # to an IO stream. The class is initialized with a stream and can be used - # to either write a JSON string or an object which can be converted to JSON. + # # Action Controller Live Server Sent Events + # + # This class provides the ability to write an SSE (Server Sent Event) to an IO + # stream. The class is initialized with a stream and can be used to either write + # a JSON string or an object which can be converted to JSON. # # Writing an object will convert it into standard SSE format with whatever # options you have configured. You may choose to set the following options: # - # 1) Event. If specified, an event with this name will be dispatched on - # the browser. - # 2) Retry. The reconnection time in milliseconds used when attempting - # to send the event. - # 3) Id. If the connection dies while sending an SSE to the browser, then - # the server will receive a +Last-Event-ID+ header with value equal to +id+. + # `:event` + # : If specified, an event with this name will be dispatched on the browser. + # + # `:retry` + # : The reconnection time in milliseconds used when attempting to send the event. # - # After setting an option in the constructor of the SSE object, all future - # SSEs sent across the stream will use those options unless overridden. + # `:id` + # : If the connection dies while sending an SSE to the browser, then the + # server will receive a `Last-Event-ID` header with value equal to `id`. + # + # After setting an option in the constructor of the SSE object, all future SSEs + # sent across the stream will use those options unless overridden. # # Example Usage: # - # class MyController < ActionController::Base - # include ActionController::Live + # class MyController < ActionController::Base + # include ActionController::Live # - # def index - # response.headers['Content-Type'] = 'text/event-stream' - # sse = SSE.new(response.stream, retry: 300, event: "event-name") - # sse.write({ name: 'John'}) - # sse.write({ name: 'John'}, id: 10) - # sse.write({ name: 'John'}, id: 10, event: "other-event") - # sse.write({ name: 'John'}, id: 10, event: "other-event", retry: 500) - # ensure - # sse.close + # def index + # response.headers['Content-Type'] = 'text/event-stream' + # sse = SSE.new(response.stream, retry: 300, event: "event-name") + # sse.write({ name: 'John'}) + # sse.write({ name: 'John'}, id: 10) + # sse.write({ name: 'John'}, id: 10, event: "other-event") + # sse.write({ name: 'John'}, id: 10, event: "other-event", retry: 500) + # ensure + # sse.close + # end # end - # end # - # Note: SSEs are not currently supported by IE. However, they are supported - # by Chrome, Firefox, Opera, and Safari. + # Note: SSEs are not currently supported by IE. However, they are supported by + # Chrome, Firefox, Opera, and Safari. class SSE - WHITELISTED_OPTIONS = %w( retry event id ) + PERMITTED_OPTIONS = %w( retry event id ) def initialize(stream, options = {}) @stream = stream @@ -105,46 +172,50 @@ def write(object, options = {}) end private - def perform_write(json, options) current_options = @options.merge(options).stringify_keys - - WHITELISTED_OPTIONS.each do |option_name| + 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".freeze, "\ndata: ".freeze) - @stream.write "data: #{message}\n\n" + message = json.gsub("\n", "\ndata: ") + event << "data: #{message}\n\n" + @stream.write event end end class ClientDisconnected < RuntimeError end - class Buffer < ActionDispatch::Response::Buffer #:nodoc: + class Buffer < ActionDispatch::Response::Buffer # :nodoc: include MonitorMixin + class << self + attr_accessor :queue_size + end + @queue_size = 10 + # Ignore that the client has disconnected. # - # If this value is `true`, calling `write` after the client - # disconnects will result in the written content being silently - # discarded. If this value is `false` (the default), a - # ClientDisconnected exception will be raised. + # If this value is `true`, calling `write` after the client disconnects will + # result in the written content being silently discarded. If this value is + # `false` (the default), a ClientDisconnected exception will be raised. attr_accessor :ignore_disconnect def initialize(response) + super(response, build_queue(self.class.queue_size)) @error_callback = lambda { true } @cv = new_cond @aborted = false @ignore_disconnect = false - super(response, SizedQueue.new(10)) end def write(string) unless @response.committed? - @response.set_header "Cache-Control", "no-cache" + @response.headers["Cache-Control"] ||= "no-cache" @response.delete_header "Content-Length" end @@ -154,16 +225,20 @@ def write(string) @buf.clear unless @ignore_disconnect - # Raise ClientDisconnected, which is a RuntimeError (not an - # IOError), because that's more appropriate for something beyond - # the developer's control. + # Raise ClientDisconnected, which is a RuntimeError (not an IOError), because + # that's more appropriate for something beyond the developer's control. raise ClientDisconnected, "client disconnected" end end end - # Write a 'close' event to the buffer; the producer/writing thread - # uses this to notify us that it's finished supplying content. + # Same as `write` but automatically include a newline at the end of the string. + def writeln(string) + write string.end_with?("\n") ? string : "#{string}\n" + end + + # Write a 'close' event to the buffer; the producer/writing thread uses this to + # notify us that it's finished supplying content. # # See also #abort. def close @@ -174,9 +249,8 @@ def close end end - # Inform the producer/writing thread that the client has - # disconnected; the reading thread is no longer interested in - # anything that's being written. + # Inform the producer/writing thread that the client has disconnected; the + # reading thread is no longer interested in anything that's being written. # # See also #close. def abort @@ -188,8 +262,8 @@ def abort # Is the client still connected and waiting for content? # - # The result of calling `write` when this is `false` is determined - # by `ignore_disconnect`. + # The result of calling `write` when this is `false` is determined by + # `ignore_disconnect`. def connected? !@aborted end @@ -203,22 +277,19 @@ def call_on_error end 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 + + def build_queue(queue_size) + queue_size ? SizedQueue.new(queue_size) : Queue.new + end end - class Response < ActionDispatch::Response #:nodoc: all + class Response < ActionDispatch::Response # :nodoc: all private - def before_committed super jar = request.cookie_jar @@ -238,18 +309,18 @@ def process(name) locals = t1.keys.map { |key| [key, t1[key]] } error = nil - # 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 { + # 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 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. :'( + # 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 } - begin + ActiveSupport::IsolatedExecutionState.share_with(t1, except: self.class.live_streaming_excluded_keys) do super(name) rescue => e if @_response.committed? @@ -266,45 +337,97 @@ def process(name) error = e end ensure + 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 - # Spawn a new thread to serve up the controller in. This is to get - # around the fact that Rack isn't based around IOs and we need to use - # a thread to stream data from the response bodies. Nobody should call - # this method except in Rails internals. Seriously! - def new_controller_thread # :nodoc: - Thread.new { - t2 = Thread.current - t2.abort_on_exception = true - yield - } + def response_body=(body) + super + response.close if response end - def log_error(exception) - logger = ActionController::Base.logger - return unless logger - - logger.fatal do - message = "\n#{exception.class} (#{exception.message}):\n" - message << exception.annoted_source_code.to_s if exception.respond_to?(:annoted_source_code) - message << " " << exception.backtrace.join("\n ") - "#{message}\n\n" + # Sends a stream to the browser, which is helpful when you're generating exports + # or other running data where you don't want the entire file buffered in memory + # first. Similar to send_data, but where the data is generated live. + # + # #### Options: + # + # * `:filename` - suggests a filename for the browser to use. + # * `:type` - specifies an HTTP content type. You can specify either a string + # or a symbol for a registered type with `Mime::Type.register`, for example + # :json. If omitted, type will be inferred from the file extension specified + # in `:filename`. If no content type is registered for the extension, the + # default type 'application/octet-stream' will be used. + # * `:disposition` - specifies whether the file will be shown inline or + # downloaded. Valid values are 'inline' and 'attachment' (default). + # + # + # Example of generating a csv export: + # + # send_stream(filename: "subscribers.csv") do |stream| + # stream.write "email_address,updated_at\n" + # + # @subscribers.find_each do |subscriber| + # stream.write "#{subscriber.email_address},#{subscriber.updated_at}\n" + # end + # end + def send_stream(filename:, disposition: "attachment", type: nil) + payload = { filename: filename, disposition: disposition, type: type } + ActiveSupport::Notifications.instrument("send_stream.action_controller", payload) do + response.headers["Content-Type"] = + (type.is_a?(Symbol) ? Mime[type].to_s : type) || + Mime::Type.lookup_by_extension(File.extname(filename).downcase.delete("."))&.to_s || + "application/octet-stream" + + response.headers["Content-Disposition"] = + ActionDispatch::Http::ContentDisposition.format(disposition: disposition, filename: filename) + + yield response.stream end + ensure + response.stream.close end - def response_body=(body) - super - response.close if response - end + private + # Spawn a new thread to serve up the controller in. This is to get around the + # fact that Rack isn't based around IOs and we need to use a thread to stream + # data from the response bodies. Nobody should call this method except in Rails + # internals. Seriously! + def new_controller_thread # :nodoc: + ActionController::Live.live_thread_pool_executor.post do + t2 = Thread.current + t2.abort_on_exception = true + yield + end + end + + # Ensure we clean up any thread locals we copied so that the thread can reused. + def clean_up_thread_locals(locals, thread) # :nodoc: + locals.each { |k, _| thread[k] = nil } + end + + def self.live_thread_pool_executor + @live_thread_pool_executor ||= Concurrent::CachedThreadPool.new(name: "action_controller.live") + end + + def log_error(exception) + logger = ActionController::Base.logger + return unless logger + + logger.fatal do + message = +"\n#{exception.class} (#{exception.message}):\n" + message << exception.annotated_source_code.to_s if exception.respond_to?(:annotated_source_code) + message << " " << exception.backtrace.join("\n ") + "#{message}\n\n" + end + end end end diff --git a/actionpack/lib/action_controller/metal/logging.rb b/actionpack/lib/action_controller/metal/logging.rb new file mode 100644 index 0000000000000..c00f16141c9d9 --- /dev/null +++ b/actionpack/lib/action_controller/metal/logging.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionController + module Logging + extend ActiveSupport::Concern + + module ClassMethods + # Set a different log level per request. + # + # # Use the debug log level if a particular cookie is set. + # class ApplicationController < ActionController::Base + # log_at :debug, if: -> { cookies[:debug] } + # end + # + def log_at(level, **options) + around_action ->(_, action) { logger.log_at(level, &action) }, **options + end + end + end +end diff --git a/actionpack/lib/action_controller/metal/mime_responds.rb b/actionpack/lib/action_controller/metal/mime_responds.rb index f6aabcb102c78..c03b17700fca4 100644 --- a/actionpack/lib/action_controller/metal/mime_responds.rb +++ b/actionpack/lib/action_controller/metal/mime_responds.rb @@ -1,193 +1,213 @@ +# frozen_string_literal: true + +# :markup: markdown + require "abstract_controller/collector" -module ActionController #:nodoc: +module ActionController # :nodoc: module MimeResponds - # Without web-service support, an action which collects the data for displaying a list of people - # might look something like this: + # Without web-service support, an action which collects the data for displaying + # a list of people might look something like this: # - # def index - # @people = Person.all - # end + # def index + # @people = Person.all + # end # - # That action implicitly responds to all formats, but formats can also be whitelisted: + # That action implicitly responds to all formats, but formats can also be + # explicitly enumerated: # - # def index - # @people = Person.all - # respond_to :html, :js - # end + # def index + # @people = Person.all + # respond_to :html, :js + # end # # Here's the same action, with web-service support baked in: # - # def index - # @people = Person.all + # def index + # @people = Person.all # - # respond_to do |format| - # format.html - # format.js - # format.xml { render xml: @people } + # respond_to do |format| + # format.html + # format.js + # format.xml { render xml: @people } + # end # end - # end # - # What that says is, "if the client wants HTML or JS in response to this action, just respond as we - # would have before, but if the client wants XML, return them the list of people in XML format." - # (Rails determines the desired response format from the HTTP Accept header submitted by the client.) + # What that says is, "if the client wants HTML or JS in response to this action, + # just respond as we would have before, but if the client wants XML, return them + # the list of people in XML format." (Rails determines the desired response + # format from the HTTP Accept header submitted by the client.) # - # Supposing you have an action that adds a new person, optionally creating their company - # (by name) if it does not already exist, without web-services, it might look like this: + # Supposing you have an action that adds a new person, optionally creating their + # company (by name) if it does not already exist, without web-services, it might + # look like this: # - # def create - # @company = Company.find_or_create_by(name: params[:company][:name]) - # @person = @company.people.create(params[:person]) + # def create + # @company = Company.find_or_create_by(name: params[:company][:name]) + # @person = @company.people.create(params[:person]) # - # redirect_to(person_list_url) - # end + # redirect_to(person_list_url) + # end # # Here's the same action, with web-service support baked in: # - # def create - # company = params[:person].delete(:company) - # @company = Company.find_or_create_by(name: company[:name]) - # @person = @company.people.create(params[:person]) + # def create + # company = params[:person].delete(:company) + # @company = Company.find_or_create_by(name: company[:name]) + # @person = @company.people.create(params[:person]) # - # respond_to do |format| - # format.html { redirect_to(person_list_url) } - # format.js - # format.xml { render xml: @person.to_xml(include: @company) } + # respond_to do |format| + # format.html { redirect_to(person_list_url) } + # format.js + # format.xml { render xml: @person.to_xml(include: @company) } + # end # end - # end # - # If the client wants HTML, we just redirect them back to the person list. If they want JavaScript, - # then it is an Ajax request and we render the JavaScript template associated with this action. - # Lastly, if the client wants XML, we render the created person as XML, but with a twist: we also - # include the person's company in the rendered XML, so you get something like this: + # If the client wants HTML, we just redirect them back to the person list. If + # they want JavaScript, then it is an Ajax request and we render the JavaScript + # template associated with this action. Lastly, if the client wants XML, we + # render the created person as XML, but with a twist: we also include the + # person's company in the rendered XML, so you get something like this: # - # - # ... - # ... - # + # # ... - # ... # ... - # - # + # + # ... + # ... + # ... + # + # # # Note, however, the extra bit at the top of that action: # - # company = params[:person].delete(:company) - # @company = Company.find_or_create_by(name: company[:name]) + # company = params[:person].delete(:company) + # @company = Company.find_or_create_by(name: company[:name]) # - # This is because the incoming XML document (if a web-service request is in process) can only contain a - # single root-node. So, we have to rearrange things so that the request looks like this (url-encoded): + # This is because the incoming XML document (if a web-service request is in + # process) can only contain a single root-node. So, we have to rearrange things + # so that the request looks like this (url-encoded): # - # person[name]=...&person[company][name]=...&... + # person[name]=...&person[company][name]=...&... # # And, like this (xml-encoded): # - # - # ... - # + # # ... - # - # + # + # ... + # + # # - # In other words, we make the request so that it operates on a single entity's person. Then, in the action, - # we extract the company data from the request, find or create the company, and then create the new person - # with the remaining data. + # In other words, we make the request so that it operates on a single entity's + # person. Then, in the action, we extract the company data from the request, + # find or create the company, and then create the new person with the remaining + # data. # - # Note that you can define your own XML parameter parser which would allow you to describe multiple entities - # in a single request (i.e., by wrapping them all in a single root node), but if you just go with the flow - # and accept Rails' defaults, life will be much easier. + # Note that you can define your own XML parameter parser which would allow you + # to describe multiple entities in a single request (i.e., by wrapping them all + # in a single root node), but if you just go with the flow and accept Rails' + # defaults, life will be much easier. # - # If you need to use a MIME type which isn't supported by default, you can register your own handlers in - # +config/initializers/mime_types.rb+ as follows. + # If you need to use a MIME type which isn't supported by default, you can + # register your own handlers in `config/initializers/mime_types.rb` as follows. # - # Mime::Type.register "image/jpg", :jpg + # Mime::Type.register "image/jpeg", :jpg # - # Respond to also allows you to specify a common block for different formats by using +any+: + # `respond_to` also allows you to specify a common block for different formats + # by using `any`: # - # def index - # @people = Person.all + # def index + # @people = Person.all # - # respond_to do |format| - # format.html - # format.any(:xml, :json) { render request.format.to_sym => @people } + # respond_to do |format| + # format.html + # format.any(:xml, :json) { render request.format.to_sym => @people } + # end # end - # end # # In the example above, if the format is xml, it will render: # - # render xml: @people + # render xml: @people # # Or if the format is json: # - # render json: @people + # render json: @people + # + # `any` can also be used with no arguments, in which case it will be used for + # any format requested by the user: + # + # respond_to do |format| + # format.html + # format.any { redirect_to support_path } + # end # # Formats can have different variants. # - # The request variant is a specialization of the request format, like :tablet, - # :phone, or :desktop. + # The request variant is a specialization of the request format, like `:tablet`, + # `:phone`, or `:desktop`. # - # We often want to render different html/json/xml templates for phones, - # tablets, and desktop browsers. Variants make it easy. + # We often want to render different html/json/xml templates for phones, tablets, + # and desktop browsers. Variants make it easy. # - # You can set the variant in a +before_action+: + # You can set the variant in a `before_action`: # - # request.variant = :tablet if request.user_agent =~ /iPad/ + # request.variant = :tablet if /iPad/.match?(request.user_agent) # # Respond to variants in the action just like you respond to formats: # - # respond_to do |format| - # format.html do |variant| - # variant.tablet # renders app/views/projects/show.html+tablet.erb - # variant.phone { extra_setup; render ... } - # variant.none { special_setup } # executed only if there is no variant set + # respond_to do |format| + # format.html do |variant| + # variant.tablet # renders app/views/projects/show.html+tablet.erb + # variant.phone { extra_setup; render ... } + # variant.none { special_setup } # executed only if there is no variant set + # end # end - # end # # Provide separate templates 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 + # app/views/projects/show.html.erb + # app/views/projects/show.html+tablet.erb + # app/views/projects/show.html+phone.erb # - # When you're not sharing any code within the format, you can simplify defining variants - # using the inline syntax: + # When you're not sharing any code within the format, you can simplify defining + # variants using the inline syntax: # - # respond_to do |format| - # format.js { render "trash" } - # format.html.phone { redirect_to progress_path } - # format.html.none { render "trash" } - # end + # respond_to do |format| + # format.js { render "trash" } + # format.html.phone { redirect_to progress_path } + # format.html.none { render "trash" } + # end # - # Variants also support common +any+/+all+ block that formats have. + # Variants also support common `any`/`all` block that formats have. # # It works for both inline: # - # respond_to do |format| - # format.html.any { render html: "any" } - # format.html.phone { render html: "phone" } - # end + # respond_to do |format| + # format.html.any { render html: "any" } + # format.html.phone { render html: "phone" } + # end # # and block syntax: # - # respond_to do |format| - # format.html do |variant| - # variant.any(:tablet, :phablet){ render html: "any" } - # variant.phone { render html: "phone" } + # respond_to do |format| + # format.html do |variant| + # variant.any(:tablet, :phablet){ render html: "any" } + # variant.phone { render html: "phone" } + # end # end - # end # # You can also set an array of variants: # - # request.variant = [:tablet, :phone] + # request.variant = [:tablet, :phone] # - # which will work similarly to formats and MIME types negotiation. If there will be no - # +:tablet+ variant declared, +:phone+ variant will be picked: + # This will work similarly to formats and MIME types negotiation. If there is no + # `:tablet` variant declared, the `:phone` variant will be used: # - # respond_to do |format| - # format.html.none - # format.html.phone # this gets rendered - # end + # respond_to do |format| + # format.html.none + # format.html.phone # this gets rendered + # end def respond_to(*mimes) raise ArgumentError, "respond_to takes either types or a block, never both" if mimes.any? && block_given? @@ -195,8 +215,11 @@ def respond_to(*mimes) yield collector if block_given? if format = collector.negotiate_format(request) + if media_type && media_type != format + raise ActionController::RespondToMismatchError + end _process_format(format) - _set_rendered_content_type format + _set_rendered_content_type(format) unless collector.any_response? response = collector.response response.call if response else @@ -204,27 +227,26 @@ def respond_to(*mimes) end end - # A container for responses available from the current controller for - # requests for different mime-types sent to a particular action. - # - # The public controller methods +respond_to+ may be called with a block - # that is used to define responses to different mime-types, e.g. - # for +respond_to+ : - # - # respond_to do |format| - # format.html - # format.xml { render xml: @people } - # end - # - # In this usage, the argument passed to the block (+format+ above) is an - # instance of the ActionController::MimeResponds::Collector class. This - # object serves as a container in which available responses can be stored by - # calling any of the dynamically generated, mime-type-specific methods such - # as +html+, +xml+ etc on the Collector. Each response is represented by a - # corresponding block if present. - # - # A subsequent call to #negotiate_format(request) will enable the Collector - # to determine which specific mime-type it should respond with for the current + # A container for responses available from the current controller for requests + # for different mime-types sent to a particular action. + # + # The public controller methods `respond_to` may be called with a block that is + # used to define responses to different mime-types, e.g. for `respond_to` : + # + # respond_to do |format| + # format.html + # format.xml { render xml: @people } + # end + # + # In this usage, the argument passed to the block (`format` above) is an + # instance of the ActionController::MimeResponds::Collector class. This object + # serves as a container in which available responses can be stored by calling + # any of the dynamically generated, mime-type-specific methods such as `html`, + # `xml` etc on the Collector. Each response is represented by a corresponding + # block if present. + # + # A subsequent call to #negotiate_format(request) will enable the Collector to + # determine which specific mime-type it should respond with for the current # request, with this response then being accessible by calling #response. class Collector include AbstractController::Collector @@ -255,6 +277,10 @@ def custom(mime_type, &block) end end + def any_response? + !@responses.fetch(format, false) && @responses[Mime::ALL] + end + def response response = @responses.fetch(format, @responses[Mime::ALL]) if response.is_a?(VariantCollector) # `format.html.phone` - variant inline syntax @@ -272,7 +298,7 @@ def negotiate_format(request) @format = request.negotiate_mime(@responses.keys) end - class VariantCollector #:nodoc: + class VariantCollector # :nodoc: def initialize(variant = nil) @variant = variant @variants = {} @@ -289,7 +315,7 @@ def any(*args, &block) end alias :all :any - def method_missing(name, *args, &block) + def method_missing(name, *, &block) @variants[name] = block if block_given? end diff --git a/actionpack/lib/action_controller/metal/parameter_encoding.rb b/actionpack/lib/action_controller/metal/parameter_encoding.rb index 962532ff09857..cf570cd99c9e7 100644 --- a/actionpack/lib/action_controller/metal/parameter_encoding.rb +++ b/actionpack/lib/action_controller/metal/parameter_encoding.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController # Specify binary encoding for parameters for a given action. module ParameterEncoding @@ -10,39 +14,70 @@ def inherited(klass) # :nodoc: end def setup_param_encode # :nodoc: - @_parameter_encodings = {} + @_parameter_encodings = Hash.new { |h, k| h[k] = {} } end - def binary_params_for?(action) # :nodoc: - @_parameter_encodings[action.to_s] + def action_encoding_template(action) # :nodoc: + if @_parameter_encodings.has_key?(action.to_s) + @_parameter_encodings[action.to_s] + end end - # Specify that a given action's parameters should all be encoded as - # ASCII-8BIT (it "skips" the encoding default of UTF-8). + # Specify that a given action's parameters should all be encoded as ASCII-8BIT + # (it "skips" the encoding default of UTF-8). # # For example, a controller would use it like this: # - # class RepositoryController < ActionController::Base - # skip_parameter_encoding :show + # class RepositoryController < ActionController::Base + # skip_parameter_encoding :show # - # def show - # @repo = Repository.find_by_filesystem_path params[:file_path] + # def show + # @repo = Repository.find_by_filesystem_path params[:file_path] # - # # `repo_name` is guaranteed to be UTF-8, but was ASCII-8BIT, so - # # tag it as such - # @repo_name = params[:repo_name].force_encoding 'UTF-8' - # end + # # `repo_name` is guaranteed to be UTF-8, but was ASCII-8BIT, so + # # tag it as such + # @repo_name = params[:repo_name].force_encoding 'UTF-8' + # end # - # def index - # @repositories = Repository.all + # def index + # @repositories = Repository.all + # end # end - # end # # The show action in the above controller would have all parameter values - # encoded as ASCII-8BIT. This is useful in the case where an application - # must handle data but encoding of the data is unknown, like file system data. + # encoded as ASCII-8BIT. This is useful in the case where an application must + # handle data but encoding of the data is unknown, like file system data. def skip_parameter_encoding(action) - @_parameter_encodings[action.to_s] = true + @_parameter_encodings[action.to_s] = Hash.new { Encoding::ASCII_8BIT } + end + + # Specify the encoding for a parameter on an action. If not specified the + # default is UTF-8. + # + # You can specify a binary (ASCII_8BIT) parameter with: + # + # class RepositoryController < ActionController::Base + # # This specifies that file_path is not UTF-8 and is instead ASCII_8BIT + # param_encoding :show, :file_path, Encoding::ASCII_8BIT + # + # def show + # @repo = Repository.find_by_filesystem_path params[:file_path] + # + # # params[:repo_name] remains UTF-8 encoded + # @repo_name = params[:repo_name] + # end + # + # def index + # @repositories = Repository.all + # end + # end + # + # The file_path parameter on the show action would be encoded as ASCII-8BIT, but + # all other arguments will remain UTF-8 encoded. This is useful in the case + # where an application must handle data but encoding of the data is unknown, + # like file system data. + def param_encoding(action, param, encoding) + @_parameter_encodings[action.to_s][param.to_s] = encoding end end end diff --git a/actionpack/lib/action_controller/metal/params_wrapper.rb b/actionpack/lib/action_controller/metal/params_wrapper.rb index 7fc898f034d03..d91c624b9f434 100644 --- a/actionpack/lib/action_controller/metal/params_wrapper.rb +++ b/actionpack/lib/action_controller/metal/params_wrapper.rb @@ -1,24 +1,33 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/hash/slice" require "active_support/core_ext/hash/except" require "active_support/core_ext/module/anonymous" require "action_dispatch/http/mime_type" module ActionController + # # Action Controller Params Wrapper + # # Wraps the parameters hash into a nested hash. This will allow clients to # submit requests without having to specify any root elements. # - # This functionality is enabled in +config/initializers/wrap_parameters.rb+ - # and can be customized. + # This functionality is enabled by default for JSON, and can be customized by + # setting the format array: # - # You could also turn it on per controller by setting the format array to - # a non-empty array: + # class ApplicationController < ActionController::Base + # wrap_parameters format: [:json, :xml] + # end + # + # You could also turn it on per controller: # # class UsersController < ApplicationController # wrap_parameters format: [:json, :xml, :url_encoded_form, :multipart_form] # end # - # If you enable +ParamsWrapper+ for +:json+ format, instead of having to - # send JSON parameters like this: + # If you enable `ParamsWrapper` for `:json` format, instead of having to send + # JSON parameters like this: # # {"user": {"name": "Konata"}} # @@ -27,55 +36,56 @@ module ActionController # {"name": "Konata"} # # And it will be wrapped into a nested hash with the key name matching the - # controller's name. For example, if you're posting to +UsersController+, - # your new +params+ hash will look like this: + # controller's name. For example, if you're posting to `UsersController`, your + # new `params` hash will look like this: # # {"name" => "Konata", "user" => {"name" => "Konata"}} # - # You can also specify the key in which the parameters should be wrapped to, - # and also the list of attributes it should wrap by using either +:include+ or - # +:exclude+ options like this: + # You can also specify the key in which the parameters should be wrapped to, and + # also the list of attributes it should wrap by using either `:include` or + # `:exclude` options like this: # # class UsersController < ApplicationController # wrap_parameters :person, include: [:username, :password] # end # - # On Active Record models with no +:include+ or +:exclude+ option set, - # it will only wrap the parameters returned by the class method - # attribute_names. + # On Active Record models with no `:include` or `:exclude` option set, it will + # only wrap the parameters returned by the class method `attribute_names`. # - # If you're going to pass the parameters to an +ActiveModel+ object (such as - # User.new(params[:user])), you might consider passing the model class to - # the method instead. The +ParamsWrapper+ will actually try to determine the - # list of attribute names from the model and only wrap those attributes: + # If you're going to pass the parameters to an `ActiveModel` object (such as + # `User.new(params[:user])`), you might consider passing the model class to the + # method instead. The `ParamsWrapper` will actually try to determine the list of + # attribute names from the model and only wrap those attributes: # # class UsersController < ApplicationController # wrap_parameters Person # end # - # You still could pass +:include+ and +:exclude+ to set the list of attributes + # You still could pass `:include` and `:exclude` to set the list of attributes # you want to wrap. # # By default, if you don't specify the key in which the parameters would be - # wrapped to, +ParamsWrapper+ will actually try to determine if there's - # a model related to it or not. This controller, for example: + # wrapped to, `ParamsWrapper` will actually try to determine if there's a model + # related to it or not. This controller, for example: # # class Admin::UsersController < ApplicationController # end # - # will try to check if Admin::User or +User+ model exists, and use it to - # determine the wrapper key respectively. If both models don't exist, - # it will then fallback to use +user+ as the key. + # will try to check if `Admin::User` or `User` model exists, and use it to + # determine the wrapper key respectively. If both models don't exist, it will + # then fall back to use `user` as the key. + # + # To disable this functionality for a controller: + # + # class UsersController < ApplicationController + # wrap_parameters false + # end module ParamsWrapper extend ActiveSupport::Concern EXCLUDE_PARAMETERS = %w(authenticity_token _method utf8) - require "mutex_m" - class Options < Struct.new(:name, :format, :include, :exclude, :klass, :model) # :nodoc: - include Mutex_m - def self.from_hash(hash) name = hash[:name] format = Array(hash[:format]) @@ -86,19 +96,20 @@ def self.from_hash(hash) def initialize(name, format, include, exclude, klass, model) # :nodoc: super + @mutex = Mutex.new @include_set = include @name_set = name end def model - super || synchronize { super || self.model = _default_wrap_model } + super || self.model = _default_wrap_model end def include return super if @include_set m = model - synchronize do + @mutex.synchronize do return super if @include_set @include_set = true @@ -106,6 +117,22 @@ def include unless super || exclude if m.respond_to?(:attribute_names) && m.attribute_names.any? self.include = m.attribute_names + + if m.respond_to?(:stored_attributes) && !m.stored_attributes.empty? + self.include += m.stored_attributes.values.flatten.map(&:to_s) + end + + if m.respond_to?(:attribute_aliases) && m.attribute_aliases.any? + self.include += m.attribute_aliases.keys + end + + if m.respond_to?(:nested_attributes_options) && m.nested_attributes_options.keys.any? + self.include += m.nested_attributes_options.keys.map do |key| + (+key.to_s).concat("_attributes") + end + end + + self.include end end end @@ -115,7 +142,7 @@ def name return super if @name_set m = model - synchronize do + @mutex.synchronize do return super if @name_set @name_set = true @@ -128,16 +155,16 @@ def name end private - # Determine the wrapper model from the controller's name. By convention, - # this could be done by trying to find the defined model that has the - # same singular name as the controller. For example, +UsersController+ - # will try to find if the +User+ model exists. + # Determine the wrapper model from the controller's name. By convention, this + # could be done by trying to find the defined model that has the same singular + # name as the controller. For example, `UsersController` will try to find if the + # `User` model exists. # - # This method also does namespace lookup. Foo::Bar::UsersController will - # try to find Foo::Bar::User, Foo::User and finally User. + # This method also does namespace lookup. Foo::Bar::UsersController will try to + # find Foo::Bar::User, Foo::User and finally User. def _default_wrap_model return nil if klass.anonymous? - model_name = klass.name.sub(/Controller$/, "").classify + model_name = klass.name.delete_suffix("Controller").classify begin if model_klass = model_name.safe_constantize @@ -155,8 +182,7 @@ def _default_wrap_model end included do - class_attribute :_wrapper_options - self._wrapper_options = Options.from_hash(format: []) + class_attribute :_wrapper_options, default: Options.from_hash(format: []) end module ClassMethods @@ -164,33 +190,34 @@ def _set_wrapper_options(options) self._wrapper_options = Options.from_hash(options) end - # Sets the name of the wrapper key, or the model which +ParamsWrapper+ - # would use to determine the attribute names from. + # Sets the name of the wrapper key, or the model which `ParamsWrapper` would use + # to determine the attribute names from. + # + # #### Examples + # wrap_parameters format: :xml + # # enables the parameter wrapper for XML format # - # ==== Examples - # wrap_parameters format: :xml - # # enables the parameter wrapper for XML format + # wrap_parameters :person + # # wraps parameters into params[:person] hash # - # wrap_parameters :person - # # wraps parameters into +params[:person]+ hash + # wrap_parameters Person + # # wraps parameters by determining the wrapper key from Person class + # # (:person, in this case) and the list of attribute names # - # wrap_parameters Person - # # wraps parameters by determining the wrapper key from Person class - # (+person+, in this case) and the list of attribute names + # wrap_parameters include: [:username, :title] + # # wraps only :username and :title attributes from parameters. # - # wrap_parameters include: [:username, :title] - # # wraps only +:username+ and +:title+ attributes from parameters. + # wrap_parameters false + # # disables parameters wrapping for this controller altogether. # - # wrap_parameters false - # # disables parameters wrapping for this controller altogether. + # #### Options + # * `:format` - The list of formats in which the parameters wrapper will be + # enabled. + # * `:include` - The list of attribute names which parameters wrapper will + # wrap into a nested hash. + # * `:exclude` - The list of attribute names which parameters wrapper will + # exclude from a nested hash. # - # ==== Options - # * :format - The list of formats in which the parameters wrapper - # will be enabled. - # * :include - The list of attribute names which parameters wrapper - # will wrap into a nested hash. - # * :exclude - The list of attribute names which parameters wrapper - # will exclude from a nested hash. def wrap_parameters(name_or_model_or_options, options = {}) model = nil @@ -212,9 +239,8 @@ def wrap_parameters(name_or_model_or_options, options = {}) self._wrapper_options = opts end - # Sets the default wrapper key or model which will be used to determine - # wrapper key and attribute names. Will be called automatically when the - # module is inherited. + # Sets the default wrapper key or model which will be used to determine wrapper + # key and attribute names. Called automatically when the module is inherited. def inherited(klass) if klass._wrapper_options.format.any? params = klass._wrapper_options.dup @@ -225,30 +251,13 @@ def inherited(klass) end end - # Performs parameters wrapping upon the request. Will be called automatically - # by the metal call stack. - def process_action(*args) - if _wrapper_enabled? - if request.parameters[_wrapper_key].present? - wrapped_hash = _extract_parameters(request.parameters) - else - wrapped_hash = _wrap_parameters request.request_parameters - end - - wrapped_keys = request.request_parameters.keys - wrapped_filtered_hash = _wrap_parameters request.filtered_parameters.slice(*wrapped_keys) - - # This will make the wrapped hash accessible from controller and view - request.parameters.merge! wrapped_hash - request.request_parameters.merge! wrapped_hash - - # This will display the wrapped hash in the log file - request.filtered_parameters.merge! wrapped_filtered_hash - end - super - end - private + # Performs parameters wrapping upon the request. Called automatically by the + # metal call stack. + def process_action(*) + _perform_parameter_wrapping if _wrapper_enabled? + super + end # Returns the wrapper key which will be used to store wrapped parameters. def _wrapper_key @@ -268,9 +277,11 @@ def _wrap_parameters(parameters) def _extract_parameters(parameters) if include_only = _wrapper_options.include parameters.slice(*include_only) + elsif _wrapper_options.exclude + exclude = _wrapper_options.exclude + EXCLUDE_PARAMETERS + parameters.except(*exclude) else - exclude = _wrapper_options.exclude || [] - parameters.except(*(exclude + EXCLUDE_PARAMETERS)) + parameters.except(*EXCLUDE_PARAMETERS) end end @@ -279,7 +290,23 @@ def _wrapper_enabled? return false unless request.has_content_type? ref = request.content_mime_type.ref - _wrapper_formats.include?(ref) && _wrapper_key && !request.request_parameters[_wrapper_key] + + _wrapper_formats.include?(ref) && _wrapper_key && !request.parameters.key?(_wrapper_key) + rescue ActionDispatch::Http::Parameters::ParseError + false + end + + def _perform_parameter_wrapping + wrapped_hash = _wrap_parameters request.request_parameters + wrapped_keys = request.request_parameters.keys + wrapped_filtered_hash = _wrap_parameters request.filtered_parameters.slice(*wrapped_keys) + + # This will make the wrapped hash accessible from controller and view. + request.parameters.merge! wrapped_hash + request.request_parameters.merge! wrapped_hash + + # This will display the wrapped hash in the log file. + request.filtered_parameters.merge! wrapped_filtered_hash end end end diff --git a/actionpack/lib/action_controller/metal/permissions_policy.rb b/actionpack/lib/action_controller/metal/permissions_policy.rb new file mode 100644 index 0000000000000..97e372ccf8029 --- /dev/null +++ b/actionpack/lib/action_controller/metal/permissions_policy.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionController # :nodoc: + module PermissionsPolicy + extend ActiveSupport::Concern + + module ClassMethods + # Overrides parts of the globally configured `Feature-Policy` header: + # + # class PagesController < ApplicationController + # permissions_policy do |policy| + # policy.geolocation "https://example.com" + # end + # end + # + # Options can be passed similar to `before_action`. For example, pass `only: + # :index` to override the header on the index action only: + # + # class PagesController < ApplicationController + # permissions_policy(only: :index) do |policy| + # policy.camera :self + # end + # end + # + # Requires a global policy defined in an initializer, which can be + # empty: + # + # Rails.application.config.permissions_policy do |policy| + # # policy.gyroscope :none + # end + def permissions_policy(**options, &block) + before_action(options) do + unless request.respond_to?(:permissions_policy) + raise "Cannot override permissions_policy if no global permissions_policy configured." + end + if block_given? + policy = request.permissions_policy.clone + instance_exec(policy, &block) + request.permissions_policy = policy + end + end + end + end + end +end diff --git a/actionpack/lib/action_controller/metal/rate_limiting.rb b/actionpack/lib/action_controller/metal/rate_limiting.rb new file mode 100644 index 0000000000000..3592827cccedd --- /dev/null +++ b/actionpack/lib/action_controller/metal/rate_limiting.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionController # :nodoc: + module RateLimiting + extend ActiveSupport::Concern + + 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 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. + # + # 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. + # + # Rate limiting relies on a backing `ActiveSupport::Cache` store and defaults to + # `config.action_controller.cache_store`, which itself defaults to the global + # `config.cache_store`. If you don't want to store rate limits in the same + # datastore as your general caches, you can pass a custom store in the `store` + # parameter. + # + # If you want to use multiple rate limits per controller, you need to give each of + # them an explicit name via the `name:` option. + # + # Examples: + # + # class SessionsController < ApplicationController + # rate_limit to: 10, within: 3.minutes, only: :create + # end + # + # class SignupsController < ApplicationController + # rate_limit to: 1000, within: 10.seconds, + # 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: -> { 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:, 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, + 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 + end +end diff --git a/actionpack/lib/action_controller/metal/redirecting.rb b/actionpack/lib/action_controller/metal/redirecting.rb index 4dfcf4da28dec..25e066608a1d4 100644 --- a/actionpack/lib/action_controller/metal/redirecting.rb +++ b/actionpack/lib/action_controller/metal/redirecting.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController module Redirecting extend ActiveSupport::Concern @@ -5,99 +9,217 @@ module Redirecting include AbstractController::Logger include ActionController::UrlFor - # Redirects the browser to the target specified in +options+. This parameter can be any one of: + 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 + # be any one of: # - # * Hash - The URL will be generated by calling url_for with the +options+. - # * Record - The URL will be generated by calling url_for with the +options+, which will reference a named URL for that record. - # * String starting with protocol:// (like http://) or a protocol relative reference (like //) - Is passed straight through as the target for redirection. - # * String not containing a protocol - The current protocol and host is prepended to the string. - # * Proc - A block that will be executed in the controller's context. Should return any option accepted by +redirect_to+. + # * `Hash` - The URL will be generated by calling url_for with the `options`. + # * `Record` - The URL will be generated by calling url_for with the + # `options`, which will reference a named URL for that record. + # * `String` starting with `protocol://` (like `http://`) or a protocol + # relative reference (like `//`) - Is passed straight through as the target + # for redirection. + # * `String` not containing a protocol - The current protocol and host is + # prepended to the string. + # * `Proc` - A block that will be executed in the controller's context. Should + # return any option accepted by `redirect_to`. # - # === Examples: # - # redirect_to action: "show", id: 5 - # redirect_to @post - # redirect_to "http://www.rubyonrails.org" - # redirect_to "/images/screenshot.jpg" - # redirect_to posts_url - # redirect_to proc { edit_post_url(@post) } + # ### Examples # - # The redirection happens as a "302 Found" header unless otherwise specified using the :status option: + # redirect_to action: "show", id: 5 + # redirect_to @post + # redirect_to "http://www.rubyonrails.org" + # redirect_to "/images/screenshot.jpg" + # redirect_to posts_url + # redirect_to proc { edit_post_url(@post) } # - # redirect_to post_url(@post), status: :found - # redirect_to action: 'atom', status: :moved_permanently - # redirect_to post_url(@post), status: 301 - # redirect_to action: 'atom', status: 302 + # The redirection happens as a `302 Found` header unless otherwise specified + # using the `:status` option: # - # The status code can either be a standard {HTTP Status code}[http://www.iana.org/assignments/http-status-codes] as an - # integer, or a symbol representing the downcased, underscored and symbolized description. - # Note that the status code must be a 3xx HTTP code, or redirection will not occur. + # redirect_to post_url(@post), status: :found + # redirect_to action: 'atom', status: :moved_permanently + # redirect_to post_url(@post), status: 301 + # redirect_to action: 'atom', status: 302 + # + # The status code can either be a standard [HTTP Status + # code](https://www.iana.org/assignments/http-status-codes) as an integer, or a + # symbol representing the downcased, underscored and symbolized description. + # Note that the status code must be a 3xx HTTP code, or redirection will not + # occur. # # If you are using XHR requests other than GET or POST and redirecting after the # request then some browsers will follow the redirect using the original request # method. This may lead to undesirable behavior such as a double DELETE. To work - # around this you can return a 303 See Other status code which will be + # around this you can return a `303 See Other` status code which will be # followed using a GET request. # - # redirect_to posts_url, status: :see_other - # redirect_to action: 'index', status: 303 + # redirect_to posts_url, status: :see_other + # redirect_to action: 'index', status: 303 + # + # It is also possible to assign a flash message as part of the redirection. + # There are two special accessors for the commonly used flash names `alert` and + # `notice` as well as a general purpose `flash` bucket. # - # It is also possible to assign a flash message as part of the redirection. There are two special accessors for the commonly used flash names - # +alert+ and +notice+ as well as a general purpose +flash+ bucket. + # redirect_to post_url(@post), alert: "Watch it, mister!" + # redirect_to post_url(@post), status: :found, notice: "Pay attention to the road" + # redirect_to post_url(@post), status: 301, flash: { updated_post_id: @post.id } + # redirect_to({ action: 'atom' }, alert: "Something serious happened") # - # redirect_to post_url(@post), alert: "Watch it, mister!" - # redirect_to post_url(@post), status: :found, notice: "Pay attention to the road" - # redirect_to post_url(@post), status: 301, flash: { updated_post_id: @post.id } - # redirect_to({ action: 'atom' }, alert: "Something serious happened") + # Statements after `redirect_to` in our controller get executed, so + # `redirect_to` doesn't stop the execution of the function. To terminate the + # execution of the function immediately after the `redirect_to`, use return. # - def redirect_to(options = {}, response_status = {}) + # redirect_to post_url(@post) and return + # + # ### Open Redirect protection + # + # By default, Rails protects against redirecting to external hosts for your + # app's safety, so called open redirects. + # + # Here #redirect_to automatically validates the potentially-unsafe URL: + # + # redirect_to params[:redirect_url] + # + # 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 and structured event 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. + # + # redirect_to "https://rubyonrails.org", allow_other_host: true + # + # 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 - self.status = _extract_redirect_to_status(options, response_status) - self.location = _compute_redirect_to_location(request, options) - self.response_body = "You are being redirected." + allow_other_host = response_options.delete(:allow_other_host) + + proposed_status = _extract_redirect_to_status(options, response_options) + + redirect_to_location = _compute_redirect_to_location(request, options) + _ensure_url_is_http_header_safe(redirect_to_location) + + self.location = _enforce_open_redirect_protection(redirect_to_location, allow_other_host: allow_other_host) + self.response_body = "" + self.status = proposed_status end - # Redirects the browser to the page that issued the request (the referrer) - # if possible, otherwise redirects to the provided default fallback - # location. - # - # The referrer information is pulled from the HTTP `Referer` (sic) header on - # the request. This is an optional header and its presence on the request is - # subject to browser security settings and user preferences. If the request - # is missing this header, the fallback_location will be used. - # - # redirect_back fallback_location: { action: "show", id: 5 } - # redirect_back fallback_location: @post - # redirect_back fallback_location: "http://www.rubyonrails.org" - # redirect_back fallback_location: "/images/screenshot.jpg" - # redirect_back fallback_location: posts_url - # redirect_back fallback_location: proc { edit_post_url(@post) } - # - # All options that can be passed to redirect_to are accepted as - # options and the behavior is identical. - def redirect_back(fallback_location:, **args) - if referer = request.headers["Referer"] - redirect_to referer, **args + # Soft deprecated alias for #redirect_back_or_to where the `fallback_location` + # location is supplied as a keyword argument instead of the first positional + # argument. + def redirect_back(fallback_location:, allow_other_host: _allow_other_host, **args) + redirect_back_or_to fallback_location, allow_other_host: allow_other_host, **args + end + + # Redirects the browser to the page that issued the request (the referrer) if + # possible, otherwise redirects to the provided default fallback location. + # + # The referrer information is pulled from the HTTP `Referer` (sic) header on the + # request. This is an optional header and its presence on the request is subject + # to browser security settings and user preferences. If the request is missing + # this header, the `fallback_location` will be used. + # + # redirect_back_or_to({ action: "show", id: 5 }) + # redirect_back_or_to @post + # redirect_back_or_to "http://www.rubyonrails.org" + # redirect_back_or_to "/images/screenshot.jpg" + # redirect_back_or_to posts_url + # redirect_back_or_to proc { edit_post_url(@post) } + # redirect_back_or_to '/', allow_other_host: false + # + # #### Options + # * `:allow_other_host` - Allow or disallow redirection to the host that is + # different to the current host, defaults to true. + # + # + # All other options that can be passed to #redirect_to are accepted as options, + # and the behavior is identical. + def redirect_back_or_to(fallback_location, allow_other_host: _allow_other_host, **options) + if request.referer && (allow_other_host || _url_host_allowed?(request.referer)) + redirect_to request.referer, allow_other_host: allow_other_host, **options else - redirect_to fallback_location, **args + # The method level `allow_other_host` doesn't apply in the fallback case, omit + # and let the `redirect_to` handling take over. + redirect_to fallback_location, **options end end - def _compute_redirect_to_location(request, options) #:nodoc: + def _compute_redirect_to_location(request, options) # :nodoc: case options - # The scheme name consist of a letter followed by any combination of - # letters, digits, and the plus ("+"), period ("."), or hyphen ("-") - # characters; and is terminated by a colon (":"). - # See http://tools.ietf.org/html/rfc3986#section-3.1 - # The protocol relative scheme starts with a double slash "//". - when /\A([a-z][a-z\d\-+\.]*:|\/\/).*/i - options + # The scheme name consist of a letter followed by any combination of letters, + # digits, and the plus ("+"), period ("."), or hyphen ("-") characters; and is + # terminated by a colon (":"). See + # https://tools.ietf.org/html/rfc3986#section-3.1 The protocol relative scheme + # starts with a double slash "//". + 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, options.call + _compute_redirect_to_location request, instance_eval(&options) else url_for(options) end.delete("\0\r\n") @@ -105,15 +227,118 @@ def _compute_redirect_to_location(request, options) #:nodoc: module_function :_compute_redirect_to_location public :_compute_redirect_to_location + # Verifies the passed `location` is an internal URL that's safe to redirect to + # and returns it, or nil if not. Useful to wrap a params provided redirect URL + # and fall back to an alternate URL to redirect to: + # + # redirect_to url_from(params[:redirect_url]) || root_url + # + # The `location` is considered internal, and safe, if it's on the same host as + # `request.host`: + # + # # If request.host is example.com: + # url_from("https://example.com/profile") # => "https://example.com/profile" + # url_from("http://example.com/profile") # => "http://example.com/profile" + # url_from("http://evil.com/profile") # => nil + # + # Subdomains are considered part of the host: + # + # # If request.host is on https://example.com or https://app.example.com, you'd get: + # url_from("https://dev.example.com/profile") # => nil + # + # NOTE: there's a similarity with + # [url_for](rdoc-ref:ActionDispatch::Routing::UrlFor#url_for), which generates + # an internal URL from various options from within the app, e.g. + # `url_for(@post)`. However, #url_from is meant to take an external parameter to + # verify as in `url_from(params[:redirect_url])`. + def url_from(location) + location = location.presence + location if location && _url_host_allowed?(location) + end + private - def _extract_redirect_to_status(options, response_status) + def _allow_other_host + 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)) - elsif response_status.key?(:status) - Rack::Utils.status_code(response_status[:status]) + ActionDispatch::Response.rack_status_code(options.delete(:status)) + elsif response_options.key?(: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 OpenRedirectError.new(location) + end + end + + def _url_host_allowed?(url) + url_to_s = url.to_s + host = URI(url_to_s).host + + 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 + + def _ensure_url_is_http_header_safe(url) + # Attempt to comply with the set of valid token characters defined for an HTTP + # header value in https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 + if url.match?(ILLEGAL_HEADER_VALUE_REGEX) + msg = "The redirect URL #{url} contains one or more illegal HTTP header field character. " \ + "Set of legal characters defined in https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6" + 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 733aca195d702..2303ee2c50a24 100644 --- a/actionpack/lib/action_controller/metal/renderers.rb +++ b/actionpack/lib/action_controller/metal/renderers.rb @@ -1,17 +1,19 @@ -require "set" +# frozen_string_literal: true + +# :markup: markdown module ActionController - # See Renderers.add + # See Renderers.add def self.add_renderer(key, &block) Renderers.add(key, &block) end - # See Renderers.remove + # See Renderers.remove def self.remove_renderer(key) Renderers.remove(key) end - # See Responder#api_behavior + # See `Responder#api_behavior` class MissingRenderer < LoadError def initialize(format) super "No renderer defined for format: #{format}" @@ -22,16 +24,29 @@ module Renderers extend ActiveSupport::Concern # A Set containing renderer names that correspond to available renderer procs. - # Default values are :json, :js, :xml. + # 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 - self._renderers = Set.new.freeze + 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 + # Used in ActionController::Base and ActionController::API to include all # renderers by default. module All extend ActiveSupport::Concern @@ -42,35 +57,34 @@ module All end end - # Adds a new renderer to call within controller actions. - # A renderer is invoked by passing its name as an option to - # AbstractController::Rendering#render. To create a renderer - # pass it a name and a block. The block takes two arguments, the first - # is the value paired with its key and the second is the remaining - # hash of options passed to +render+. + # Adds a new renderer to call within controller actions. A renderer is invoked + # by passing its name as an option to AbstractController::Rendering#render. To + # create a renderer pass it a name and a block. The block takes two arguments, + # the first is the value paired with its key and the second is the remaining + # hash of options passed to `render`. # # Create a csv renderer: # - # ActionController::Renderers.add :csv do |obj, options| - # filename = options[:filename] || 'data' - # str = obj.respond_to?(:to_csv) ? obj.to_csv : obj.to_s - # send_data str, type: Mime[:csv], - # disposition: "attachment; filename=#{filename}.csv" - # end + # ActionController::Renderers.add :csv do |obj, options| + # filename = options[:filename] || 'data' + # str = obj.respond_to?(:to_csv) ? obj.to_csv : obj.to_s + # send_data str, type: Mime[:csv], + # disposition: "attachment; filename=#{filename}.csv" + # end # - # Note that we used Mime[:csv] for the csv mime type as it comes with Rails. + # Note that we used [Mime](:csv) for the csv mime type as it comes with Rails. # For a custom renderer, you'll need to register a mime type with - # Mime::Type.register. + # `Mime::Type.register`. # # To use the csv renderer in a controller action: # - # def show - # @csvable = Csvable.find(params[:id]) - # respond_to do |format| - # format.html - # format.csv { render csv: @csvable, filename: @csvable.name } + # def show + # @csvable = Csvable.find(params[:id]) + # respond_to do |format| + # format.html + # format.csv { render csv: @csvable, filename: @csvable.name } + # end # end - # end def self.add(key, &block) define_method(_render_with_renderer_method_name(key), &block) RENDERERS << key.to_sym @@ -80,51 +94,51 @@ def self.add(key, &block) # # To remove a csv renderer: # - # ActionController::Renderers.remove(:csv) + # ActionController::Renderers.remove(:csv) def self.remove(key) RENDERERS.delete(key.to_sym) method_name = _render_with_renderer_method_name(key) - remove_method(method_name) if method_defined?(method_name) + 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 module ClassMethods - # Adds, by name, a renderer or renderers to the +_renderers+ available - # to call within controller actions. + # Adds, by name, a renderer or renderers to the `_renderers` available to call + # within controller actions. # - # It is useful when rendering from an ActionController::Metal controller or + # It is useful when rendering from an ActionController::Metal controller or # otherwise to add an available renderer proc to a specific controller. # - # Both ActionController::Base and ActionController::API - # include ActionController::Renderers::All, making all renderers - # available in the controller. See Renderers::RENDERERS and Renderers.add. + # Both ActionController::Base and ActionController::API include + # ActionController::Renderers::All, making all renderers available in the + # controller. See Renderers::RENDERERS and Renderers.add. # - # Since ActionController::Metal controllers cannot render, the controller - # must include AbstractController::Rendering, ActionController::Rendering, - # and ActionController::Renderers, and have at least one renderer. + # Since ActionController::Metal controllers cannot render, the controller must + # include AbstractController::Rendering, ActionController::Rendering, and + # ActionController::Renderers, and have at least one renderer. # - # Rather than including ActionController::Renderers::All and including all renderers, - # you may specify which renderers to include by passing the renderer name or names to - # +use_renderers+. For example, a controller that includes only the :json renderer - # (+_render_with_renderer_json+) might look like: + # Rather than including ActionController::Renderers::All and including all + # renderers, you may specify which renderers to include by passing the renderer + # name or names to `use_renderers`. For example, a controller that includes only + # the `:json` renderer (`_render_with_renderer_json`) might look like: # - # class MetalRenderingController < ActionController::Metal - # include AbstractController::Rendering - # include ActionController::Rendering - # include ActionController::Renderers + # class MetalRenderingController < ActionController::Metal + # include AbstractController::Rendering + # include ActionController::Rendering + # include ActionController::Renderers # - # use_renderers :json + # use_renderers :json # - # def show - # render json: record + # def show + # render json: record + # end # end - # end # - # You must specify a +use_renderer+, else the +controller.renderer+ and - # +controller._renderers+ will be nil, and the action will fail. + # You must specify a `use_renderer`, else the `controller.renderer` and + # `controller._renderers` will be `nil`, and the action will fail. def use_renderers(*args) renderers = _renderers + args self._renderers = renderers.freeze @@ -132,16 +146,16 @@ def use_renderers(*args) alias use_renderer use_renderers end - # Called by +render+ in AbstractController::Rendering - # which sets the return value as the +response_body+. + # Called by `render` in AbstractController::Rendering which sets the return + # value as the `response_body`. # - # If no renderer is found, +super+ returns control to - # ActionView::Rendering.render_to_body, if present. + # If no renderer is found, `super` returns control to + # `ActionView::Rendering.render_to_body`, if present. 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,28 +167,40 @@ def _render_to_body_with_renderer(options) end add :json do |json, options| - json = json.to_json(options) unless json.kind_of?(String) + 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? - if content_type.nil? || content_type == Mime[:json] - self.content_type = Mime[:js] + if media_type.nil? || media_type == Mime[:json] + self.content_type = :js end "/**/#{options[:callback]}(#{json})" else - self.content_type ||= Mime[:json] + self.content_type = :json if media_type.nil? json end end add :js do |js, options| - self.content_type ||= Mime[:js] + self.content_type = :js if media_type.nil? js.respond_to?(:to_js) ? js.to_js(options) : js end add :xml do |xml, options| - self.content_type ||= Mime[:xml] + 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 + + add :svg do |svg, options| + self.content_type = :svg if media_type.nil? + svg.respond_to?(:to_svg) ? svg.to_svg : svg + end end end diff --git a/actionpack/lib/action_controller/metal/rendering.rb b/actionpack/lib/action_controller/metal/rendering.rb index 6b177193817dc..eb2ac71d2b285 100644 --- a/actionpack/lib/action_controller/metal/rendering.rb +++ b/actionpack/lib/action_controller/metal/rendering.rb @@ -1,4 +1,6 @@ -require "active_support/core_ext/string/filters" +# frozen_string_literal: true + +# :markup: markdown module ActionController module Rendering @@ -10,8 +12,8 @@ module ClassMethods # Documentation at ActionController::Renderer#render delegate :render, to: :renderer - # Returns a renderer instance (inherited from ActionController::Renderer) - # for the controller. + # Returns a renderer instance (inherited from ActionController::Renderer) for + # the controller. attr_reader :renderer def setup_renderer! # :nodoc: @@ -24,23 +26,161 @@ def inherited(klass) end end - # Before processing, set the request formats in current controller formats. - def process_action(*) #:nodoc: - self.formats = request.formats.map(&:ref).compact - super - end - + # Renders a template and assigns the result to `self.response_body`. + # + # If no rendering mode option is specified, the template will be derived from + # the first argument. + # + # render "posts/show" + # # => renders app/views/posts/show.html.erb + # + # # In a PostsController action... + # render :show + # # => renders app/views/posts/show.html.erb + # + # If the first argument responds to `render_in`, the template will be rendered + # by calling `render_in` with the current view context. + # + # class Greeting + # def render_in(view_context) + # view_context.render html: "

Hello, World

" + # end + # + # def format + # :html + # end + # end + # + # render(Greeting.new) + # # => "

Hello, World

" + # + # render(renderable: Greeting.new) + # # => "

Hello, World

" + # + # #### Rendering Mode + # + # `:partial` + # : See ActionView::PartialRenderer for details. + # + # render partial: "posts/form", locals: { post: Post.new } + # # => renders app/views/posts/_form.html.erb + # + # `:file` + # : Renders the contents of a file. This option should **not** be used with + # unsanitized user input. + # + # render file: "/path/to/some/file" + # # => renders /path/to/some/file + # + # `:inline` + # : Renders an ERB template string. + # + # @name = "World" + # render inline: "

Hello, <%= @name %>!

" + # # => renders "

Hello, World!

" + # + # `:body` + # : Renders the provided text, and sets the content type as `text/plain`. + # + # render body: "Hello, World!" + # # => renders "Hello, World!" + # + # `:plain` + # : Renders the provided text, and sets the content type as `text/plain`. + # + # render plain: "Hello, World!" + # # => renders "Hello, World!" + # + # `:html` + # : Renders the provided HTML string, and sets the content type as + # `text/html`. If the string is not `html_safe?`, performs HTML escaping on + # the string before rendering. + # + # render html: "

Hello, World!

".html_safe + # # => renders "

Hello, World!

" + # + # render html: "

Hello, World!

" + # # => renders "<h1>Hello, World!</h1>" + # + # `:json` + # : Renders the provided object as JSON, and sets the content type as + # `application/json`. If the object is not a string, it will be converted to + # JSON by calling `to_json`. + # + # render json: { hello: "world" } + # # => renders "{\"hello\":\"world\"}" + # + # `:renderable` + # : Renders the provided object by calling `render_in` with the current view + # context. The response format is determined by calling `format` on the + # renderable if it responds to `format`, falling back to `text/html` by + # default. + # + # render renderable: Greeting.new + # # => renders "

Hello, World

" + # + # + # By default, when a rendering mode is specified, no layout template is + # rendered. + # + # #### Options + # + # `:assigns` + # : Hash of instance variable assignments for the template. + # + # render inline: "

Hello, <%= @name %>!

", assigns: { name: "World" } + # # => renders "

Hello, World!

" + # + # `:locals` + # : Hash of local variable assignments for the template. + # + # render inline: "

Hello, <%= name %>!

", locals: { name: "World" } + # # => renders "

Hello, World!

" + # + # `:layout` + # : The layout template to render. Can also be `false` or `true` to disable or + # (re)enable the default layout template. + # + # render "posts/show", layout: "holiday" + # # => renders app/views/posts/show.html.erb with the app/views/layouts/holiday.html.erb layout + # + # render "posts/show", layout: false + # # => renders app/views/posts/show.html.erb with no layout + # + # render inline: "

Hello, World!

", layout: true + # # => renders "

Hello, World!

" with the default layout + # + # `:status` + # : The HTTP status code to send with the response. Can be specified as a + # number or as the status name in Symbol form. Defaults to 200. + # + # render "posts/new", status: 422 + # # => renders app/views/posts/new.html.erb with HTTP status code 422 + # + # render "posts/new", status: :unprocessable_entity + # # => renders app/views/posts/new.html.erb with HTTP status code 422 + # + # `:variants` + # : This tells Rails to look for the first template matching any of the variations. + # + # render "posts/index", variants: [:mobile] + # # => renders app/views/posts/index.html+mobile.erb + # + #-- # Check for double render errors and set the content_type after rendering. - def render(*args) #:nodoc: + def render(*args) raise ::AbstractController::DoubleRenderError if response_body super end - # Overwrite render_to_string because body can now be set to a rack body. + # Similar to #render, but only returns the rendered template as a string, + # instead of setting `self.response_body`. + #-- + # Override render_to_string because body can now be set to a Rack body. def render_to_string(*) result = super if result.respond_to?(:each) - string = "" + string = +"" result.each { |r| string << r } string else @@ -48,11 +188,16 @@ def render_to_string(*) end end - def render_to_body(options = {}) + def render_to_body(options = {}) # :nodoc: super || _render_in_priorities(options) || " " end private + # Before processing, set the request formats in current controller formats. + def process_action(*) # :nodoc: + self.formats = request.formats.filter_map(&:ref) + super + end def _process_variant(options) if defined?(request) && !request.nil? && request.variant.present? @@ -69,20 +214,19 @@ def _render_in_priorities(options) end def _set_html_content_type - self.content_type = Mime[:html].to_s + self.content_type = :html end def _set_rendered_content_type(format) - if format && !response.content_type + if format && !response.media_type self.content_type = format.to_s end end - # Normalize arguments by catching blocks and setting them on :update. - def _normalize_args(action = nil, options = {}, &blk) - options = super - options[:update] = blk if block_given? - options + def _set_vary_header + if response.headers["Vary"].blank? && request.should_apply_vary_header? + response.headers["Vary"] = "Accept" + end end # Normalize both text and status options. @@ -94,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 e8965a6561ab2..31c7c1fee660d 100644 --- a/actionpack/lib/action_controller/metal/request_forgery_protection.rb +++ b/actionpack/lib/action_controller/metal/request_forgery_protection.rb @@ -1,139 +1,335 @@ +# frozen_string_literal: true + +# :markup: markdown + require "rack/session/abstract/id" require "action_controller/metal/exceptions" require "active_support/security_utils" -module ActionController #:nodoc: - class InvalidAuthenticityToken < ActionControllerError #:nodoc: +module ActionController # :nodoc: + class InvalidCrossOriginRequest < ActionControllerError # :nodoc: end - class InvalidCrossOriginRequest < ActionControllerError #:nodoc: - end + include ActiveSupport::Deprecation::DeprecatedConstantAccessor + deprecate_constant "InvalidAuthenticityToken", "ActionController::InvalidCrossOriginRequest", + deprecator: ActionController.deprecator, + message: "ActionController::InvalidAuthenticityToken has been deprecated and will be removed in Rails 9.0. Use ActionController::InvalidCrossOriginRequest instead." - # Controller actions are protected from Cross-Site Request Forgery (CSRF) attacks - # by including a token in the rendered HTML for your application. This token is - # stored as a random string in the session, to which an attacker does not have - # access. When a request reaches your application, \Rails verifies the received - # token with the token in the session. All requests are checked except GET requests - # as these should be idempotent. Keep in mind that all session-oriented requests - # should be CSRF protected, including JavaScript and HTML requests. + # # Action Controller Request Forgery Protection + # + # Controller actions are protected from Cross-Site Request Forgery (CSRF) + # attacks by checking the Sec-Fetch-Site header sent by modern browsers to + # indicate the relationship between request's initiator origin and the origin + # of the requested resource + # (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site) + # + # For applications that need to support older browsers, there's a token-based + # fallback. A token is included in the rendered HTML for your application. This + # token is stored as a random string in the session, to which an attacker does + # not have access. When a request reaches your application, Rails verifies the + # received token with the token in the session. All requests are checked except + # GET requests as these should be idempotent. Keep in mind that all + # session-oriented requests are CSRF protected by default, including JavaScript + # and HTML requests. # # Since HTML and JavaScript requests are typically made from the browser, we - # need to ensure to verify request authenticity for the web browser. We can - # use session-oriented authentication for these types of requests, by using - # the `protect_from_forgery` method in our controllers. + # need to ensure to verify request authenticity for the web browser. We can use + # session-oriented authentication for these types of requests, by using the + # `protect_from_forgery` method in our controllers. # # GET requests are not protected since they don't have side effects like writing # to the database and don't leak sensitive information. JavaScript requests are - # an exception: a third-party site can use a - # - # The first two characters (">) are required in case the exception happens - # while rendering attributes for a given tag. You can check the real cause - # for the exception in your logger. - # - # == Web server support - # - # Not all web servers support streaming out-of-the-box. You need to check - # the instructions for each of them. - # - # ==== Unicorn + # ## Middlewares # - # Unicorn supports streaming but it needs to be configured. For this, you - # need to create a config file as follow: + # Middlewares that need to manipulate the body won't work with streaming. You + # should disable those middlewares whenever streaming in development or + # production. For instance, `Rack::Bug` won't work when streaming as it needs to + # inject contents in the HTML body. # - # # unicorn.config.rb - # listen 3000, tcp_nopush: false + # Also `Rack::Cache` won't work with streaming as it does not support streaming + # bodies yet. Whenever streaming `Cache-Control` is automatically set to + # "no-cache". # - # And use it on initialization: + # ## Errors # - # unicorn_rails --config-file unicorn.config.rb + # When it comes to streaming, exceptions get a bit more complicated. This + # happens because part of the template was already rendered and streamed to the + # client, making it impossible to render a whole exception page. # - # You may also want to configure other parameters like :tcp_nodelay. - # Please check its documentation for more information: http://unicorn.bogomips.org/Unicorn/Configurator.html#method-i-listen + # Currently, when an exception happens in development or production, Rails will + # automatically stream to the client: # - # If you are using Unicorn with NGINX, you may need to tweak NGINX. - # Streaming should work out of the box on Rainbows. + # "> # - # ==== Passenger + # The first two characters (`">`) are required in case the exception happens + # while rendering attributes for a given tag. You can check the real cause for + # the exception in your logger. # - # To be described. + # ## Web server support # + # Rack 3+ compatible servers all support streaming. module Streaming - extend ActiveSupport::Concern - private - - # Set proper cache control and transfer encoding when streaming - def _process_options(options) - super - if options[:stream] - if request.version == "HTTP/1.0" - options.delete(:stream) - else - headers["Cache-Control"] ||= "no-cache" - headers["Transfer-Encoding"] = "chunked" - headers.delete("Content-Length") - end - end - end - - # Call render_body if we are streaming instead of usual +render+. + # Call render_body if we are streaming instead of usual `render`. def _render_template(options) if options.delete(:stream) - Rack::Chunked::Body.new view_renderer.render_body(view_context, options) + # It shouldn't be necessary to set this. + headers["cache-control"] ||= "no-cache" + + view_renderer.render_body(view_context, options) else super end diff --git a/actionpack/lib/action_controller/metal/strong_parameters.rb b/actionpack/lib/action_controller/metal/strong_parameters.rb index d304dcf46801b..3180e8e3b38df 100644 --- a/actionpack/lib/action_controller/metal/strong_parameters.rb +++ b/actionpack/lib/action_controller/metal/strong_parameters.rb @@ -1,39 +1,61 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/hash/indifferent_access" -require "active_support/core_ext/hash/transform_values" require "active_support/core_ext/array/wrap" require "active_support/core_ext/string/filters" require "active_support/core_ext/object/to_query" -require "active_support/rescuable" +require "active_support/deep_mergeable" require "action_dispatch/http/upload" require "rack/test" require "stringio" -require "set" require "yaml" module ActionController # Raised when a required parameter is missing. # - # params = ActionController::Parameters.new(a: {}) - # params.fetch(:b) - # # => ActionController::ParameterMissing: param is missing or the value is empty: b - # params.require(:a) - # # => ActionController::ParameterMissing: param is missing or the value is empty: a + # params = ActionController::Parameters.new(a: {}) + # params.fetch(:b) + # # => ActionController::ParameterMissing: param is missing or the value is empty or invalid: b + # params.require(:a) + # # => ActionController::ParameterMissing: param is missing or the value is empty or invalid: a + # params.expect(a: []) + # # => ActionController::ParameterMissing: param is missing or the value is empty or invalid: a class ParameterMissing < KeyError - attr_reader :param # :nodoc: + attr_reader :param, :keys # :nodoc: - def initialize(param) # :nodoc: + def initialize(param, keys = nil) # :nodoc: @param = param - super("param is missing or the value is empty: #{param}") + @keys = keys + super("param is missing or the value is empty or invalid: #{param}") end + + if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker) + include DidYouMean::Correctable # :nodoc: + + def corrections # :nodoc: + @corrections ||= DidYouMean::SpellChecker.new(dictionary: keys).correct(param.to_s) + end + end + end + + # Raised from `expect!` when an expected parameter is missing or is of an + # incompatible type. + # + # params = ActionController::Parameters.new(a: {}) + # params.expect!(:a) + # # => ActionController::ExpectedParameterMissing: param is missing or the value is empty or invalid: a + class ExpectedParameterMissing < ParameterMissing end # Raised when a supplied parameter is not expected and - # ActionController::Parameters.action_on_unpermitted_parameters - # is set to :raise. + # ActionController::Parameters.action_on_unpermitted_parameters is set to + # `:raise`. # - # params = ActionController::Parameters.new(a: "123", b: "456") - # params.permit(:c) - # # => ActionController::UnpermittedParameters: found unpermitted parameters: :a, :b + # params = ActionController::Parameters.new(a: "123", b: "456") + # params.permit(:c) + # # => ActionController::UnpermittedParameters: found unpermitted parameters: :a, :b class UnpermittedParameters < IndexError attr_reader :params # :nodoc: @@ -43,198 +65,406 @@ def initialize(params) # :nodoc: end end - # == Action Controller \Parameters + # Raised when a Parameters instance is not marked as permitted and an operation + # to transform it to hash is called. # - # Allows you to choose which attributes should be whitelisted for mass updating + # params = ActionController::Parameters.new(a: "123", b: "456") + # params.to_h + # # => ActionController::UnfilteredParameters: unable to convert unpermitted parameters to hash + class UnfilteredParameters < ArgumentError + def initialize # :nodoc: + super("unable to convert unpermitted parameters to hash") + end + end + + # Raised when initializing Parameters with keys that aren't strings or symbols. + # + # ActionController::Parameters.new(123 => 456) + # # => ActionController::InvalidParameterKey: all keys must be Strings or Symbols, got: Integer + class InvalidParameterKey < ArgumentError + end + + # # Action Controller Parameters + # + # Allows you to choose which attributes should be permitted for mass updating # and thus prevent accidentally exposing that which shouldn't be exposed. - # Provides two methods for this purpose: #require and #permit. The former is - # used to mark parameters as required. The latter is used to set the parameter - # as permitted and limit which attributes should be allowed for mass updating. - # - # params = ActionController::Parameters.new({ - # person: { - # name: 'Francesco', - # age: 22, - # role: 'admin' - # } - # }) - # - # permitted = params.require(:person).permit(:name, :age) - # permitted # => "Francesco", "age"=>22} permitted: true> - # permitted.permitted? # => true - # - # Person.first.update!(permitted) - # # => # - # - # It provides two options that controls the top-level behavior of new instances: - # - # * +permit_all_parameters+ - If it's +true+, all the parameters will be - # permitted by default. The default is +false+. - # * +action_on_unpermitted_parameters+ - Allow to control the behavior when parameters - # that are not explicitly permitted are found. The values can be +false+ to just filter them - # out, :log to additionally write a message on the logger, or :raise to raise - # ActionController::UnpermittedParameters exception. The default value is :log - # in test and development environments, +false+ otherwise. + # + # Provides methods for filtering and requiring params: + # + # * `expect` to safely permit and require parameters in one step. + # * `permit` to filter params for mass assignment. + # * `require` to require a parameter or raise an error. # # Examples: # - # params = ActionController::Parameters.new - # params.permitted? # => false + # params = ActionController::Parameters.new({ + # person: { + # name: "Francesco", + # age: 22, + # role: "admin" + # } + # }) + # + # permitted = params.expect(person: [:name, :age]) + # permitted # => #"Francesco", "age"=>22} permitted: true> + # + # Person.first.update!(permitted) + # # => # # - # ActionController::Parameters.permit_all_parameters = true + # Parameters provides two options that control the top-level behavior of new + # instances: # - # params = ActionController::Parameters.new - # params.permitted? # => true + # * `permit_all_parameters` - If it's `true`, all the parameters will be + # permitted by default. The default is `false`. + # * `action_on_unpermitted_parameters` - Controls behavior when parameters + # that are not explicitly permitted are found. The default value is `:log` + # in test and development environments, `false` otherwise. The values can + # be: + # * `false` to take no action. + # * `:log` to emit an `ActiveSupport::Notifications.instrument` event on + # the `unpermitted_parameters.action_controller` topic and log at the + # DEBUG level. + # * `:raise` to raise an ActionController::UnpermittedParameters + # exception. + # + # Examples: # - # params = ActionController::Parameters.new(a: "123", b: "456") - # params.permit(:c) - # # => + # params = ActionController::Parameters.new + # params.permitted? # => false # - # ActionController::Parameters.action_on_unpermitted_parameters = :raise + # ActionController::Parameters.permit_all_parameters = true # - # params = ActionController::Parameters.new(a: "123", b: "456") - # params.permit(:c) - # # => ActionController::UnpermittedParameters: found unpermitted keys: a, b + # params = ActionController::Parameters.new + # params.permitted? # => true + # + # params = ActionController::Parameters.new(a: "123", b: "456") + # params.permit(:c) + # # => # + # + # ActionController::Parameters.action_on_unpermitted_parameters = :raise + # + # params = ActionController::Parameters.new(a: "123", b: "456") + # params.permit(:c) + # # => ActionController::UnpermittedParameters: found unpermitted keys: a, b # # Please note that these options *are not thread-safe*. In a multi-threaded # environment they should only be set once at boot-time and never mutated at # runtime. # - # You can fetch values of ActionController::Parameters using either - # :key or "key". + # You can fetch values of `ActionController::Parameters` using either `:key` or + # `"key"`. # - # params = ActionController::Parameters.new(key: 'value') - # params[:key] # => "value" - # params["key"] # => "value" + # params = ActionController::Parameters.new(key: "value") + # params[:key] # => "value" + # params["key"] # => "value" class Parameters - cattr_accessor :permit_all_parameters, instance_accessor: false - self.permit_all_parameters = false + include ActiveSupport::DeepMergeable + + cattr_accessor :permit_all_parameters, instance_accessor: false, default: false cattr_accessor :action_on_unpermitted_parameters, instance_accessor: false - delegate :keys, :key?, :has_key?, :values, :has_value?, :value?, :empty?, :include?, - :as_json, to: :@parameters + ## + # :method: deep_merge + # + # :call-seq: + # deep_merge(other_hash, &block) + # + # Returns a new `ActionController::Parameters` instance with `self` and + # `other_hash` merged recursively. + # + # Like with `Hash#merge` in the standard library, a block can be provided to + # merge values. + # + #-- + # Implemented by ActiveSupport::DeepMergeable#deep_merge. - # By default, never raise an UnpermittedParameters exception if these - # params are present. The default includes both 'controller' and 'action' - # because they are added by Rails and should be of no concern. One way - # to change these is to specify `always_permitted_parameters` in your - # config. For instance: + ## + # :method: deep_merge! + # + # :call-seq: + # deep_merge!(other_hash, &block) # - # config.always_permitted_parameters = %w( controller action format ) - cattr_accessor :always_permitted_parameters - self.always_permitted_parameters = %w( controller action ) + # Same as `#deep_merge`, but modifies `self`. + # + #-- + # Implemented by ActiveSupport::DeepMergeable#deep_merge!. - # Returns a new instance of ActionController::Parameters. - # Also, sets the +permitted+ attribute to the default value of - # ActionController::Parameters.permit_all_parameters. + ## + # :method: as_json + # + # :call-seq: + # as_json(options=nil) # - # class Person < ActiveRecord::Base - # end + # Returns a hash that can be used as the JSON representation for the parameters. + + ## + # :method: each_key # - # params = ActionController::Parameters.new(name: 'Francesco') - # params.permitted? # => false - # Person.new(params) # => ActiveModel::ForbiddenAttributesError + # :call-seq: + # each_key(&block) # - # ActionController::Parameters.permit_all_parameters = true + # Calls block once for each key in the parameters, passing the key. If no block + # is given, an enumerator is returned instead. + + ## + # :method: empty? # - # params = ActionController::Parameters.new(name: 'Francesco') - # params.permitted? # => true - # Person.new(params) # => # - def initialize(parameters = {}) + # :call-seq: + # empty?() + # + # Returns true if the parameters have no key/value pairs. + + ## + # :method: exclude? + # + # :call-seq: + # exclude?(key) + # + # Returns true if the given key is not present in the parameters. + + ## + # :method: include? + # + # :call-seq: + # include?(key) + # + # Returns true if the given key is present in the parameters. + + ## + # :method: keys + # + # :call-seq: + # keys() + # + # Returns a new array of the keys of the parameters. + + ## + # :method: to_s + # + # :call-seq: + # to_s() + # + # Returns the content of the parameters as a string. + + delegate :keys, :empty?, :exclude?, :include?, + :as_json, :to_s, :each_key, to: :@parameters + + alias_method :has_key?, :include? + alias_method :key?, :include? + alias_method :member?, :include? + + # By default, never raise an UnpermittedParameters exception if these params are + # present. The default includes both 'controller' and 'action' because they are + # added by Rails and should be of no concern. One way to change these is to + # specify `always_permitted_parameters` in your config. For instance: + # + # config.action_controller.always_permitted_parameters = %w( controller action format ) + cattr_accessor :always_permitted_parameters, default: %w( controller action ) + + class << self + def nested_attribute?(key, value) # :nodoc: + /\A-?\d+\z/.match?(key) && (value.is_a?(Hash) || value.is_a?(Parameters)) + end + end + + # Returns a new `ActionController::Parameters` instance. Also, sets the + # `permitted` attribute to the default value of + # `ActionController::Parameters.permit_all_parameters`. + # + # class Person < ActiveRecord::Base + # end + # + # params = ActionController::Parameters.new(name: "Francesco") + # params.permitted? # => false + # Person.new(params) # => ActiveModel::ForbiddenAttributesError + # + # ActionController::Parameters.permit_all_parameters = true + # + # params = ActionController::Parameters.new(name: "Francesco") + # params.permitted? # => true + # Person.new(params) # => # + def initialize(parameters = {}, logging_context = {}) + parameters.each_key do |key| + unless key.is_a?(String) || key.is_a?(Symbol) + raise InvalidParameterKey, "all keys must be Strings or Symbols, got: #{key.class}" + end + end + @parameters = parameters.with_indifferent_access + @logging_context = logging_context @permitted = self.class.permit_all_parameters end - # Returns true if another +Parameters+ object contains the same content and + # Returns true if another `Parameters` object contains the same content and # permitted flag. def ==(other) if other.respond_to?(:permitted?) permitted? == other.permitted? && parameters == other.parameters else - @parameters == other + super end end - # Returns a safe ActiveSupport::HashWithIndifferentAccess - # representation of this parameter with all unpermitted keys removed. + def eql?(other) + self.class == other.class && + permitted? == other.permitted? && + parameters.eql?(other.parameters) + end + + 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. # - # params = ActionController::Parameters.new({ - # name: 'Senjougahara Hitagi', - # oddity: 'Heavy stone crab' - # }) - # params.to_h # => {} + # params = ActionController::Parameters.new({ + # name: "Senjougahara Hitagi", + # oddity: "Heavy stone crab" + # }) + # params.to_h + # # => ActionController::UnfilteredParameters: unable to convert unpermitted parameters to hash # - # safe_params = params.permit(:name) - # safe_params.to_h # => {"name"=>"Senjougahara Hitagi"} - def to_h + # safe_params = params.permit(:name) + # safe_params.to_h # => {"name"=>"Senjougahara Hitagi"} + def to_h(&block) if permitted? - convert_parameters_to_hashes(@parameters, :to_h) + convert_parameters_to_hashes(@parameters, :to_h, &block) else - slice(*self.class.always_permitted_parameters).permit!.to_h + raise UnfilteredParameters end end - # Returns an unsafe, unfiltered - # ActiveSupport::HashWithIndifferentAccess representation of this - # parameter. + # Returns a safe `Hash` representation of the parameters with all unpermitted + # keys removed. + # + # params = ActionController::Parameters.new({ + # name: "Senjougahara Hitagi", + # oddity: "Heavy stone crab" + # }) + # params.to_hash + # # => ActionController::UnfilteredParameters: unable to convert unpermitted parameters to hash + # + # safe_params = params.permit(:name) + # safe_params.to_hash # => {"name"=>"Senjougahara Hitagi"} + def to_hash + to_h.to_hash + end + + # Returns a string representation of the receiver suitable for use as a URL + # query string: + # + # params = ActionController::Parameters.new({ + # name: "David", + # nationality: "Danish" + # }) + # params.to_query + # # => ActionController::UnfilteredParameters: unable to convert unpermitted parameters to hash + # + # safe_params = params.permit(:name, :nationality) + # safe_params.to_query + # # => "name=David&nationality=Danish" + # + # An optional namespace can be passed to enclose key names: + # + # params = ActionController::Parameters.new({ + # name: "David", + # nationality: "Danish" + # }) + # safe_params = params.permit(:name, :nationality) + # safe_params.to_query("user") + # # => "user%5Bname%5D=David&user%5Bnationality%5D=Danish" + # + # The string pairs `"key=value"` that conform the query string are sorted + # lexicographically in ascending order. + def to_query(*args) + to_h.to_query(*args) + end + alias_method :to_param, :to_query + + # Returns an unsafe, unfiltered ActiveSupport::HashWithIndifferentAccess + # representation of the parameters. # - # params = ActionController::Parameters.new({ - # name: 'Senjougahara Hitagi', - # oddity: 'Heavy stone crab' - # }) - # params.to_unsafe_h - # # => {"name"=>"Senjougahara Hitagi", "oddity" => "Heavy stone crab"} + # params = ActionController::Parameters.new({ + # name: "Senjougahara Hitagi", + # oddity: "Heavy stone crab" + # }) + # params.to_unsafe_h + # # => {"name"=>"Senjougahara Hitagi", "oddity" => "Heavy stone crab"} def to_unsafe_h convert_parameters_to_hashes(@parameters, :to_unsafe_h) end alias_method :to_unsafe_hash, :to_unsafe_h - # Convert all hashes in values into parameters, then yield each pair in - # the same way as Hash#each_pair + # Convert all hashes in values into parameters, then yield each pair in the same + # way as `Hash#each_pair`. def each_pair(&block) + return to_enum(__callee__) unless block_given? @parameters.each_pair do |key, value| - yield key, convert_hashes_to_parameters(key, value) + yield [key, convert_hashes_to_parameters(key, value)] end + + self end alias_method :each, :each_pair + # Convert all hashes in values into parameters, then yield each value in the + # same way as `Hash#each_value`. + def each_value(&block) + return to_enum(:each_value) unless block_given? + @parameters.each_pair do |key, value| + yield convert_hashes_to_parameters(key, value) + end + + self + end + + # Returns a new array of the values of the parameters. + def values + to_enum(:each_value).to_a + end + # Attribute that keeps track of converted arrays, if any, to avoid double - # looping in the common use case permit + mass-assignment. Defined in a - # method to instantiate it only if needed. + # looping in the common use case permit + mass-assignment. Defined in a method + # to instantiate it only if needed. # - # Testing membership still loops, but it's going to be faster than our own - # loop that converts values. Also, we are not going to build a new array - # object per fetch. + # Testing membership still loops, but it's going to be faster than our own loop + # that converts values. Also, we are not going to build a new array object per + # fetch. def converted_arrays @converted_arrays ||= Set.new end - # Returns +true+ if the parameter is permitted, +false+ otherwise. + # Returns `true` if the parameter is permitted, `false` otherwise. # - # params = ActionController::Parameters.new - # params.permitted? # => false - # params.permit! - # params.permitted? # => true + # params = ActionController::Parameters.new + # params.permitted? # => false + # params.permit! + # params.permitted? # => true def permitted? @permitted end - # Sets the +permitted+ attribute to +true+. This can be used to pass - # mass assignment. Returns +self+. + # Sets the `permitted` attribute to `true`. This can be used to pass mass + # assignment. Returns `self`. # - # class Person < ActiveRecord::Base - # end + # class Person < ActiveRecord::Base + # end # - # params = ActionController::Parameters.new(name: 'Francesco') - # params.permitted? # => false - # Person.new(params) # => ActiveModel::ForbiddenAttributesError - # params.permit! - # params.permitted? # => true - # Person.new(params) # => # + # params = ActionController::Parameters.new(name: "Francesco") + # params.permitted? # => false + # Person.new(params) # => ActiveModel::ForbiddenAttributesError + # params.permit! + # params.permitted? # => true + # Person.new(params) # => # def permit! each_pair do |key, value| - Array.wrap(value).each do |v| + Array.wrap(value).flatten.each do |v| v.permit! if v.respond_to? :permit! end end @@ -245,318 +475,493 @@ def permit! # This method accepts both a single key and an array of keys. # - # When passed a single key, if it exists and its associated value is - # either present or the singleton +false+, returns said value: + # When passed a single key, if it exists and its associated value is either + # present or the singleton `false`, returns said value: # - # ActionController::Parameters.new(person: { name: 'Francesco' }).require(:person) - # # => "Francesco"} permitted: false> + # ActionController::Parameters.new(person: { name: "Francesco" }).require(:person) + # # => #"Francesco"} permitted: false> # - # Otherwise raises ActionController::ParameterMissing: + # Otherwise raises ActionController::ParameterMissing: # - # ActionController::Parameters.new.require(:person) - # # ActionController::ParameterMissing: param is missing or the value is empty: person + # ActionController::Parameters.new.require(:person) + # # ActionController::ParameterMissing: param is missing or the value is empty or invalid: person # - # ActionController::Parameters.new(person: nil).require(:person) - # # ActionController::ParameterMissing: param is missing or the value is empty: person + # ActionController::Parameters.new(person: nil).require(:person) + # # ActionController::ParameterMissing: param is missing or the value is empty or invalid: person # - # ActionController::Parameters.new(person: "\t").require(:person) - # # ActionController::ParameterMissing: param is missing or the value is empty: person + # ActionController::Parameters.new(person: "\t").require(:person) + # # ActionController::ParameterMissing: param is missing or the value is empty or invalid: person # - # ActionController::Parameters.new(person: {}).require(:person) - # # ActionController::ParameterMissing: param is missing or the value is empty: person + # ActionController::Parameters.new(person: {}).require(:person) + # # ActionController::ParameterMissing: param is missing or the value is empty or invalid: person # - # When given an array of keys, the method tries to require each one of them - # in order. If it succeeds, an array with the respective return values is - # returned: + # When given an array of keys, the method tries to require each one of them in + # order. If it succeeds, an array with the respective return values is returned: # - # params = ActionController::Parameters.new(user: { ... }, profile: { ... }) - # user_params, profile_params = params.require([:user, :profile]) + # params = ActionController::Parameters.new(user: { ... }, profile: { ... }) + # user_params, profile_params = params.require([:user, :profile]) # # Otherwise, the method re-raises the first exception found: # - # params = ActionController::Parameters.new(user: {}, profile: {}) - # user_params, profile_params = params.require([:user, :profile]) - # # ActionController::ParameterMissing: param is missing or the value is empty: user + # params = ActionController::Parameters.new(user: {}, profile: {}) + # user_params, profile_params = params.require([:user, :profile]) + # # ActionController::ParameterMissing: param is missing or the value is empty or invalid: user # - # Technically this method can be used to fetch terminal values: + # This method is not recommended for fetching terminal values because it does + # not permit the values. For example, this can cause problems: # - # # CAREFUL - # params = ActionController::Parameters.new(person: { name: 'Finn' }) - # name = params.require(:person).require(:name) # CAREFUL + # # CAREFUL + # params = ActionController::Parameters.new(person: { name: "Finn" }) + # name = params.require(:person).require(:name) # CAREFUL # - # but take into account that at some point those ones have to be permitted: + # It is recommended to use `expect` instead: # - # def person_params - # params.require(:person).permit(:name).tap do |person_params| - # person_params.require(:name) # SAFER + # def person_params + # params.expect(person: :name).require(:name) # end - # end # - # for example. def require(key) return key.map { |k| require(k) } if key.is_a?(Array) value = self[key] if value.present? || value == false value else - raise ParameterMissing.new(key) + raise ParameterMissing.new(key, @parameters.keys) end end - # Alias of #require. alias :required :require - # Returns a new ActionController::Parameters instance that - # includes only the given +filters+ and sets the +permitted+ attribute - # for the object to +true+. This is useful for limiting which attributes - # should be allowed for mass updating. + # Returns a new `ActionController::Parameters` instance that includes only the + # given `filters` and sets the `permitted` attribute for the object to `true`. + # This is useful for limiting which attributes should be allowed for mass + # updating. # - # params = ActionController::Parameters.new(user: { name: 'Francesco', age: 22, role: 'admin' }) - # permitted = params.require(:user).permit(:name, :age) - # permitted.permitted? # => true - # permitted.has_key?(:name) # => true - # permitted.has_key?(:age) # => true - # permitted.has_key?(:role) # => false + # params = ActionController::Parameters.new(name: "Francesco", age: 22, role: "admin") + # permitted = params.permit(:name, :age) + # permitted.permitted? # => true + # permitted.has_key?(:name) # => true + # permitted.has_key?(:age) # => true + # permitted.has_key?(:role) # => false # # Only permitted scalars pass the filter. For example, given # - # params.permit(:name) - # - # +:name+ passes if it is a key of +params+ whose associated value is of type - # +String+, +Symbol+, +NilClass+, +Numeric+, +TrueClass+, +FalseClass+, - # +Date+, +Time+, +DateTime+, +StringIO+, +IO+, - # +ActionDispatch::Http::UploadedFile+ or +Rack::Test::UploadedFile+. - # Otherwise, the key +:name+ is filtered out. - # - # You may declare that the parameter should be an array of permitted scalars - # by mapping it to an empty array: - # - # params = ActionController::Parameters.new(tags: ['rails', 'parameters']) - # params.permit(tags: []) - # - # Sometimes it is not possible or convenient to declare the valid keys of - # a hash parameter or its internal structure. Just map to an empty hash: - # - # params.permit(preferences: {}) - # - # but be careful because this opens the door to arbitrary input. In this - # case, +permit+ ensures values in the returned structure are permitted - # scalars and filters out anything else. - # - # You can also use +permit+ on nested parameters, like: - # - # params = ActionController::Parameters.new({ - # person: { - # name: 'Francesco', - # age: 22, - # pets: [{ - # name: 'Purplish', - # category: 'dogs' - # }] - # } - # }) - # - # permitted = params.permit(person: [ :name, { pets: :name } ]) - # permitted.permitted? # => true - # permitted[:person][:name] # => "Francesco" - # permitted[:person][:age] # => nil - # permitted[:person][:pets][0][:name] # => "Purplish" - # permitted[:person][:pets][0][:category] # => nil - # - # Note that if you use +permit+ in a key that points to a hash, - # it won't allow all the hash. You also need to specify which - # attributes inside the hash should be whitelisted. - # - # params = ActionController::Parameters.new({ - # person: { - # contact: { - # email: 'none@test.com', - # phone: '555-1234' + # params.permit(:name) + # + # `:name` passes if it is a key of `params` whose associated value is of type + # `String`, `Symbol`, `NilClass`, `Numeric`, `TrueClass`, `FalseClass`, `Date`, + # `Time`, `DateTime`, `StringIO`, `IO`, ActionDispatch::Http::UploadedFile or + # `Rack::Test::UploadedFile`. Otherwise, the key `:name` is filtered out. + # + # You may declare that the parameter should be an array of permitted scalars by + # mapping it to an empty array: + # + # params = ActionController::Parameters.new(tags: ["rails", "parameters"]) + # params.permit(tags: []) + # + # Sometimes it is not possible or convenient to declare the valid keys of a hash + # parameter or its internal structure. Just map to an empty hash: + # + # params.permit(preferences: {}) + # + # Be careful because this opens the door to arbitrary input. In this case, + # `permit` ensures values in the returned structure are permitted scalars and + # filters out anything else. + # + # You can also use `permit` on nested parameters: + # + # params = ActionController::Parameters.new({ + # person: { + # name: "Francesco", + # age: 22, + # pets: [{ + # name: "Purplish", + # category: "dogs" + # }] + # } + # }) + # + # permitted = params.permit(person: [ :name, { pets: :name } ]) + # permitted.permitted? # => true + # permitted[:person][:name] # => "Francesco" + # permitted[:person][:age] # => nil + # permitted[:person][:pets][0][:name] # => "Purplish" + # permitted[:person][:pets][0][:category] # => nil + # + # This has the added benefit of rejecting user-modified inputs that send a + # string when a hash is expected. + # + # When followed by `require`, you can both filter and require parameters + # following the typical pattern of a Rails form. The `expect` method was + # made specifically for this use case and is the recommended way to require + # and permit parameters. + # + # permitted = params.expect(person: [:name, :age]) + # + # When using `permit` and `require` separately, pay careful attention to the + # order of the method calls. + # + # params = ActionController::Parameters.new(person: { name: "Martin", age: 40, role: "admin" }) + # permitted = params.permit(person: [:name, :age]).require(:person) # correct + # + # When require is used first, it is possible for users of your application to + # trigger a NoMethodError when the user, for example, sends a string for :person. + # + # params = ActionController::Parameters.new(person: "tampered") + # permitted = params.require(:person).permit(:name, :age) # not recommended + # # => NoMethodError: undefined method `permit' for an instance of String + # + # Note that if you use `permit` in a key that points to a hash, it won't allow + # all the hash. You also need to specify which attributes inside the hash should + # be permitted. + # + # params = ActionController::Parameters.new({ + # person: { + # contact: { + # email: "none@test.com", + # phone: "555-1234" + # } # } - # } - # }) + # }) + # + # params.permit(person: :contact).require(:person) + # # => ActionController::ParameterMissing: param is missing or the value is empty or invalid: person + # + # params.permit(person: { contact: :phone }).require(:person) + # # => ##"555-1234"} permitted: true>} permitted: true> # - # params.require(:person).permit(:contact) - # # => + # params.permit(person: { contact: [ :email, :phone ] }).require(:person) + # # => ##"none@test.com", "phone"=>"555-1234"} permitted: true>} permitted: true> # - # params.require(:person).permit(contact: :phone) - # # => "555-1234"} permitted: true>} permitted: true> + # If your parameters specify multiple parameters indexed by a number, you can + # permit each set of parameters under the numeric key to be the same using the + # same syntax as permitting a single item. # - # params.require(:person).permit(contact: [ :email, :phone ]) - # # => "none@test.com", "phone"=>"555-1234"} permitted: true>} permitted: true> + # params = ActionController::Parameters.new({ + # person: { + # '0': { + # email: "none@test.com", + # phone: "555-1234" + # }, + # '1': { + # email: "nothing@test.com", + # phone: "555-6789" + # }, + # } + # }) + # params.permit(person: [:email]).to_h + # # => {"person"=>{"0"=>{"email"=>"none@test.com"}, "1"=>{"email"=>"nothing@test.com"}}} + # + # If you want to specify what keys you want from each numeric key, you can + # instead specify each one individually + # + # params = ActionController::Parameters.new({ + # person: { + # '0': { + # email: "none@test.com", + # phone: "555-1234" + # }, + # '1': { + # email: "nothing@test.com", + # phone: "555-6789" + # }, + # } + # }) + # params.permit(person: { '0': [:email], '1': [:phone]}).to_h + # # => {"person"=>{"0"=>{"email"=>"none@test.com"}, "1"=>{"phone"=>"555-6789"}}} def permit(*filters) - params = self.class.new - - filters.flatten.each do |filter| - case filter - when Symbol, String - permitted_scalar_filter(params, filter) - when Hash - hash_filter(params, filter) - end - end + permit_filters(filters, on_unpermitted: self.class.action_on_unpermitted_parameters, explicit_arrays: false) + end - unpermitted_parameters!(params) if self.class.action_on_unpermitted_parameters + # `expect` is the preferred way to require and permit parameters. + # It is safer than the previous recommendation to call `permit` and `require` + # in sequence, which could allow user triggered 500 errors. + # + # `expect` is more strict with types to avoid a number of potential pitfalls + # that may be encountered with the `.require.permit` pattern. + # + # For example: + # + # params = ActionController::Parameters.new(comment: { text: "hello" }) + # params.expect(comment: [:text]) + # # => # + # + # params = ActionController::Parameters.new(comment: [{ text: "hello" }, { text: "world" }]) + # params.expect(comment: [:text]) + # # => ActionController::ParameterMissing: param is missing or the value is empty or invalid: comment + # + # In order to permit an array of parameters, the array must be defined + # explicitly. Use double array brackets, an array inside an array, to + # declare that an array of parameters is expected. + # + # params = ActionController::Parameters.new(comments: [{ text: "hello" }, { text: "world" }]) + # params.expect(comments: [[:text]]) + # # => [# "hello" } permitted: true>, + # # # "world" } permitted: true>] + # + # params = ActionController::Parameters.new(comments: { text: "hello" }) + # params.expect(comments: [[:text]]) + # # => ActionController::ParameterMissing: param is missing or the value is empty or invalid: comments + # + # `expect` is intended to protect against array tampering. + # + # params = ActionController::Parameters.new(user: "hack") + # # The previous way of requiring and permitting parameters will error + # params.require(:user).permit(:name, pets: [:name]) # wrong + # # => NoMethodError: undefined method `permit' for an instance of String + # + # # similarly with nested parameters + # params = ActionController::Parameters.new(user: { name: "Martin", pets: { name: "hack" } }) + # user_params = params.require(:user).permit(:name, pets: [:name]) # wrong + # # user_params[:pets] is expected to be an array but is a hash + # + # `expect` solves this by being more strict with types. + # + # params = ActionController::Parameters.new(user: "hack") + # params.expect(user: [ :name, pets: [[:name]] ]) + # # => ActionController::ParameterMissing: param is missing or the value is empty or invalid: user + # + # # with nested parameters + # params = ActionController::Parameters.new(user: { name: "Martin", pets: { name: "hack" } }) + # user_params = params.expect(user: [:name, pets: [[:name]] ]) + # user_params[:pets] # => nil + # + # As the examples show, `expect` requires the `:user` key, and any root keys + # similar to the `.require.permit` pattern. If multiple root keys are + # expected, they will all be required. + # + # params = ActionController::Parameters.new(name: "Martin", pies: [{ type: "dessert", flavor: "pumpkin"}]) + # name, pies = params.expect(:name, pies: [[:type, :flavor]]) + # name # => "Martin" + # pies # => [#"dessert", "flavor"=>"pumpkin"} permitted: true>] + # + # When called with a hash with multiple keys, `expect` will permit the + # parameters and require the keys in the order they are given in the hash, + # returning an array of the permitted parameters. + # + # params = ActionController::Parameters.new(subject: { name: "Martin" }, object: { pie: "pumpkin" }) + # subject, object = params.expect(subject: [:name], object: [:pie]) + # subject # => #"Martin"} permitted: true> + # object # => #"pumpkin"} permitted: true> + # + # Besides being more strict about array vs hash params, `expect` uses permit + # internally, so it will behave similarly. + # + # params = ActionController::Parameters.new({ + # person: { + # name: "Francesco", + # age: 22, + # pets: [{ + # name: "Purplish", + # category: "dogs" + # }] + # } + # }) + # + # permitted = params.expect(person: [ :name, { pets: [[:name]] } ]) + # permitted.permitted? # => true + # permitted[:name] # => "Francesco" + # permitted[:age] # => nil + # permitted[:pets][0][:name] # => "Purplish" + # permitted[:pets][0][:category] # => nil + # + # An array of permitted scalars may be expected with the following: + # + # params = ActionController::Parameters.new(tags: ["rails", "parameters"]) + # permitted = params.expect(tags: []) + # permitted # => ["rails", "parameters"] + # permitted.is_a?(Array) # => true + # permitted.size # => 2 + # + def expect(*filters) + params = permit_filters(filters) + keys = filters.flatten.flat_map { |f| f.is_a?(Hash) ? f.keys : f } + values = params.require(keys) + values.size == 1 ? values.first : values + end - params.permit! + # Same as `expect`, but raises an `ActionController::ExpectedParameterMissing` + # instead of `ActionController::ParameterMissing`. Unlike `expect` which + # will render a 400 response, `expect!` will raise an exception that is + # not handled. This is intended for debugging invalid params for an + # internal API where incorrectly formatted params would indicate a bug + # in a client library that should be fixed. + # + def expect!(*filters) + expect(*filters) + rescue ParameterMissing => e + raise ExpectedParameterMissing.new(e.param, e.keys) end - # Returns a parameter for the given +key+. If not found, - # returns +nil+. + # Returns a parameter for the given `key`. If not found, returns `nil`. # - # params = ActionController::Parameters.new(person: { name: 'Francesco' }) - # params[:person] # => "Francesco"} permitted: false> - # params[:none] # => nil + # params = ActionController::Parameters.new(person: { name: "Francesco" }) + # params[:person] # => #"Francesco"} permitted: false> + # params[:none] # => nil def [](key) convert_hashes_to_parameters(key, @parameters[key]) end - # Assigns a value to a given +key+. The given key may still get filtered out - # when +permit+ is called. + # Assigns a value to a given `key`. The given key may still get filtered out + # when #permit is called. def []=(key, value) @parameters[key] = value end - # Returns a parameter for the given +key+. If the +key+ - # can't be found, there are several options: With no other arguments, - # it will raise an ActionController::ParameterMissing error; - # if more arguments are given, then that will be returned; if a block - # is given, then that will be run and its result returned. + # Returns a parameter for the given `key`. If the `key` can't be found, there + # are several options: With no other arguments, it will raise an + # ActionController::ParameterMissing error; if a second argument is given, then + # that is returned (converted to an instance of `ActionController::Parameters` + # if possible); if a block is given, then that will be run and its result + # returned. # - # params = ActionController::Parameters.new(person: { name: 'Francesco' }) - # params.fetch(:person) # => "Francesco"} permitted: false> - # params.fetch(:none) # => ActionController::ParameterMissing: param is missing or the value is empty: none - # params.fetch(:none, 'Francesco') # => "Francesco" - # params.fetch(:none) { 'Francesco' } # => "Francesco" + # params = ActionController::Parameters.new(person: { name: "Francesco" }) + # params.fetch(:person) # => #"Francesco"} permitted: false> + # params.fetch(:none) # => ActionController::ParameterMissing: param is missing or the value is empty or invalid: none + # params.fetch(:none, {}) # => # + # params.fetch(:none, "Francesco") # => "Francesco" + # params.fetch(:none) { "Francesco" } # => "Francesco" def fetch(key, *args) convert_value_to_parameters( @parameters.fetch(key) { if block_given? yield else - args.fetch(0) { raise ActionController::ParameterMissing.new(key) } + args.fetch(0) { raise ActionController::ParameterMissing.new(key, @parameters.keys) } end } ) end - if Hash.method_defined?(:dig) - # Extracts the nested parameter from the given +keys+ by calling +dig+ - # at each step. Returns +nil+ if any intermediate step is +nil+. - # - # params = ActionController::Parameters.new(foo: { bar: { baz: 1 } }) - # params.dig(:foo, :bar, :baz) # => 1 - # params.dig(:foo, :zot, :xyz) # => nil - # - # params2 = ActionController::Parameters.new(foo: [10, 11, 12]) - # params2.dig(:foo, 1) # => 11 - def dig(*keys) - convert_value_to_parameters(@parameters.dig(*keys)) - end + # Extracts the nested parameter from the given `keys` by calling `dig` at each + # step. Returns `nil` if any intermediate step is `nil`. + # + # params = ActionController::Parameters.new(foo: { bar: { baz: 1 } }) + # params.dig(:foo, :bar, :baz) # => 1 + # params.dig(:foo, :zot, :xyz) # => nil + # + # params2 = ActionController::Parameters.new(foo: [10, 11, 12]) + # params2.dig(:foo, 1) # => 11 + def dig(*keys) + convert_hashes_to_parameters(keys.first, @parameters[keys.first]) + @parameters.dig(*keys) end - # Returns a new ActionController::Parameters instance that - # includes only the given +keys+. If the given +keys+ - # don't exist, returns an empty hash. + # Returns a new `ActionController::Parameters` instance that includes only the + # given `keys`. If the given `keys` don't exist, returns an empty hash. # - # params = ActionController::Parameters.new(a: 1, b: 2, c: 3) - # params.slice(:a, :b) # => 1, "b"=>2} permitted: false> - # params.slice(:d) # => + # params = ActionController::Parameters.new(a: 1, b: 2, c: 3) + # params.slice(:a, :b) # => #1, "b"=>2} permitted: false> + # params.slice(:d) # => # def slice(*keys) new_instance_with_inherited_permitted_status(@parameters.slice(*keys)) end - # Returns current ActionController::Parameters instance which - # contains only the given +keys+. + # Returns the current `ActionController::Parameters` instance which contains + # only the given `keys`. def slice!(*keys) @parameters.slice!(*keys) self end - # Returns a new ActionController::Parameters instance that - # filters out the given +keys+. + # Returns a new `ActionController::Parameters` instance that filters out the + # given `keys`. # - # params = ActionController::Parameters.new(a: 1, b: 2, c: 3) - # params.except(:a, :b) # => 3} permitted: false> - # params.except(:d) # => 1, "b"=>2, "c"=>3} permitted: false> + # params = ActionController::Parameters.new(a: 1, b: 2, c: 3) + # params.except(:a, :b) # => #3} permitted: false> + # params.except(:d) # => #1, "b"=>2, "c"=>3} permitted: false> def except(*keys) new_instance_with_inherited_permitted_status(@parameters.except(*keys)) end + alias_method :without, :except # Removes and returns the key/value pairs matching the given keys. # - # params = ActionController::Parameters.new(a: 1, b: 2, c: 3) - # params.extract!(:a, :b) # => 1, "b"=>2} permitted: false> - # params # => 3} permitted: false> + # params = ActionController::Parameters.new(a: 1, b: 2, c: 3) + # params.extract!(:a, :b) # => #1, "b"=>2} permitted: false> + # params # => #3} permitted: false> def extract!(*keys) new_instance_with_inherited_permitted_status(@parameters.extract!(*keys)) end - # Returns a new ActionController::Parameters with the results of - # running +block+ once for every value. The keys are unchanged. + # Returns a new `ActionController::Parameters` instance with the results of + # running `block` once for every value. The keys are unchanged. # - # params = ActionController::Parameters.new(a: 1, b: 2, c: 3) - # params.transform_values { |x| x * 2 } - # # => 2, "b"=>4, "c"=>6} permitted: false> - def transform_values(&block) - if block - new_instance_with_inherited_permitted_status( - @parameters.transform_values(&block) - ) - else - @parameters.transform_values - end + # params = ActionController::Parameters.new(a: 1, b: 2, c: 3) + # params.transform_values { |x| x * 2 } + # # => #2, "b"=>4, "c"=>6} permitted: false> + def transform_values + return to_enum(:transform_values) unless block_given? + new_instance_with_inherited_permitted_status( + @parameters.transform_values { |v| yield convert_value_to_parameters(v) } + ) end # Performs values transformation and returns the altered - # ActionController::Parameters instance. - def transform_values!(&block) - @parameters.transform_values!(&block) + # `ActionController::Parameters` instance. + def transform_values! + return to_enum(:transform_values!) unless block_given? + @parameters.transform_values! { |v| yield convert_value_to_parameters(v) } self end - # Returns a new ActionController::Parameters instance with the - # results of running +block+ once for every key. The values are unchanged. + # Returns a new `ActionController::Parameters` instance with the results of + # running `block` once for every key. The values are unchanged. def transform_keys(&block) - if block - new_instance_with_inherited_permitted_status( - @parameters.transform_keys(&block) - ) - else - @parameters.transform_keys - end + return to_enum(:transform_keys) unless block_given? + new_instance_with_inherited_permitted_status( + @parameters.transform_keys(&block) + ) end # Performs keys transformation and returns the altered - # ActionController::Parameters instance. + # `ActionController::Parameters` instance. def transform_keys!(&block) + return to_enum(:transform_keys!) unless block_given? @parameters.transform_keys!(&block) self end - # Deletes and returns a key-value pair from +Parameters+ whose key is equal - # to key. If the key is not found, returns the default value. If the - # optional code block is given and the key is not found, pass in the key - # and return the result of block. - def delete(key) - convert_value_to_parameters(@parameters.delete(key)) + # Returns a new `ActionController::Parameters` instance with the results of + # running `block` once for every key. This includes the keys from the root hash + # and from all nested hashes and arrays. The values are unchanged. + def deep_transform_keys(&block) + new_instance_with_inherited_permitted_status( + _deep_transform_keys_in_object(@parameters, &block).to_unsafe_h + ) + end + + # Returns the same `ActionController::Parameters` instance with changed keys. + # This includes the keys from the root hash and from all nested hashes and + # arrays. The values are unchanged. + def deep_transform_keys!(&block) + @parameters = _deep_transform_keys_in_object(@parameters, &block).to_unsafe_h + self end - # Returns a new instance of ActionController::Parameters with only - # items that the block evaluates to true. + # Deletes a key-value pair from `Parameters` and returns the value. If `key` is + # not found, returns `nil` (or, with optional code block, yields `key` and + # returns the result). This method is similar to #extract!, which returns the + # corresponding `ActionController::Parameters` object. + def delete(key, &block) + convert_value_to_parameters(@parameters.delete(key, &block)) + end + + # Returns a new `ActionController::Parameters` instance with only items that the + # block evaluates to true. def select(&block) new_instance_with_inherited_permitted_status(@parameters.select(&block)) end - # Equivalent to Hash#keep_if, but returns +nil+ if no changes were made. + # Equivalent to Hash#keep_if, but returns `nil` if no changes were made. def select!(&block) @parameters.select!(&block) self end alias_method :keep_if, :select! - # Returns a new instance of ActionController::Parameters with items - # that the block evaluates to true removed. + # Returns a new `ActionController::Parameters` instance with items that the + # block evaluates to true removed. def reject(&block) new_instance_with_inherited_permitted_status(@parameters.reject(&block)) end @@ -568,36 +973,91 @@ def reject!(&block) end alias_method :delete_if, :reject! - # Returns values that were assigned to the given +keys+. Note that all the - # +Hash+ objects will be converted to ActionController::Parameters. + # Returns a new `ActionController::Parameters` instance with `nil` values + # removed. + def compact + new_instance_with_inherited_permitted_status(@parameters.compact) + end + + # Removes all `nil` values in place and returns `self`, or `nil` if no changes + # were made. + def compact! + self if @parameters.compact! + end + + # Returns a new `ActionController::Parameters` instance without the blank + # values. Uses Object#blank? for determining if a value is blank. + def compact_blank + reject { |_k, v| v.blank? } + end + + # Removes all blank values in place and returns self. Uses Object#blank? for + # determining if a value is blank. + def compact_blank! + reject! { |_k, v| v.blank? } + end + + # Returns true if the given value is present for some key in the parameters. + def has_value?(value) + each_value.include?(convert_value_to_parameters(value)) + end + + alias value? has_value? + + # Returns values that were assigned to the given `keys`. Note that all the + # `Hash` objects will be converted to `ActionController::Parameters`. def values_at(*keys) convert_value_to_parameters(@parameters.values_at(*keys)) end - # Returns a new ActionController::Parameters with all keys from - # +other_hash+ merges into current hash. + # Returns a new `ActionController::Parameters` instance with all keys from + # `other_hash` merged into current hash. def merge(other_hash) new_instance_with_inherited_permitted_status( @parameters.merge(other_hash.to_h) ) end - # Returns current ActionController::Parameters instance which - # +other_hash+ merges into current hash. - def merge!(other_hash) - @parameters.merge!(other_hash.to_h) + ## + # :call-seq: merge!(other_hash) + # + # Returns the current `ActionController::Parameters` instance with `other_hash` + # merged into current hash. + def merge!(other_hash, &block) + @parameters.merge!(other_hash.to_h, &block) + self + end + + def deep_merge?(other_hash) # :nodoc: + other_hash.is_a?(ActiveSupport::DeepMergeable) + end + + # Returns a new `ActionController::Parameters` instance with all keys from + # current hash merged into `other_hash`. + def reverse_merge(other_hash) + new_instance_with_inherited_permitted_status( + other_hash.to_h.merge(@parameters) + ) + end + alias_method :with_defaults, :reverse_merge + + # Returns the current `ActionController::Parameters` instance with current hash + # merged into `other_hash`. + def reverse_merge!(other_hash) + @parameters.merge!(other_hash.to_h) { |key, left, right| left } self end + alias_method :with_defaults!, :reverse_merge! - # This is required by ActiveModel attribute assignment, so that user can - # pass +Parameters+ to a mass assignment methods in a model. It should not - # matter as we are using +HashWithIndifferentAccess+ internally. + # This is required by ActiveModel attribute assignment, so that user can pass + # `Parameters` to a mass assignment methods in a model. It should not matter as + # we are using `HashWithIndifferentAccess` internally. def stringify_keys # :nodoc: dup end def inspect - "<#{self.class} #{@parameters} permitted: #{@permitted}>" + "#<#{self.class} #{@parameters} permitted: #{@permitted}>" end def self.hook_into_yaml_loading # :nodoc: @@ -616,52 +1076,96 @@ def init_with(coder) # :nodoc: @parameters = coder.map.with_indifferent_access @permitted = false when "!ruby/hash-with-ivars:ActionController::Parameters" - # YAML 2.0.9's Hash subclass format where keys and values - # were stored under an elements hash and `permitted` within an ivars hash. + # YAML 2.0.9's Hash subclass format where keys and values were stored under an + # elements hash and `permitted` within an ivars hash. @parameters = coder.map["elements"].with_indifferent_access @permitted = coder.map["ivars"][:@permitted] when "!ruby/object:ActionController::Parameters" - # YAML's Object format. Only needed because of the format - # backwardscompability above, otherwise equivalent to YAML's initialization. + # YAML's Object format. Only needed because of the format backwards + # compatibility above, otherwise equivalent to YAML's initialization. @parameters, @permitted = coder.map["parameters"], coder.map["permitted"] end end - undef_method :to_param + def encode_with(coder) # :nodoc: + coder.map = { "parameters" => @parameters, "permitted" => @permitted } + end - # Returns duplicate of object including all parameters + # Returns a duplicate `ActionController::Parameters` instance with the same + # permitted parameters. def deep_dup - self.class.new(@parameters.deep_dup).tap do |duplicate| + self.class.new(@parameters.deep_dup, @logging_context).tap do |duplicate| duplicate.permitted = @permitted end end + # Returns parameter value for the given `key` separated by `delimiter`. + # + # params = ActionController::Parameters.new(id: "1_123", tags: "ruby,rails") + # params.extract_value(:id) # => ["1", "123"] + # params.extract_value(:tags, delimiter: ",") # => ["ruby", "rails"] + # params.extract_value(:non_existent_key) # => nil + # + # Note that if the given `key`'s value contains blank elements, then the + # returned array will include empty strings. + # + # params = ActionController::Parameters.new(tags: "ruby,rails,,web") + # params.extract_value(:tags, delimiter: ",") # => ["ruby", "rails", "", "web"] + def extract_value(key, delimiter: "_") + @parameters[key]&.split(delimiter, -1) + end + protected attr_reader :parameters - def permitted=(new_permitted) - @permitted = new_permitted + attr_writer :permitted + + def nested_attributes? + @parameters.any? { |k, v| Parameters.nested_attribute?(k, v) } end - def fields_for_style? - @parameters.all? { |k, v| k =~ /\A-?\d+\z/ && (v.is_a?(Hash) || v.is_a?(Parameters)) } + def each_nested_attribute + hash = self.class.new + self.each { |k, v| hash[k] = yield v if Parameters.nested_attribute?(k, v) } + hash + end + + # Filters self and optionally checks for unpermitted keys + def permit_filters(filters, on_unpermitted: nil, explicit_arrays: true) + params = self.class.new + + filters.flatten.each do |filter| + case filter + when Symbol, String + # Declaration [:name, "age"] + permitted_scalar_filter(params, filter) + when Hash + # Declaration [{ person: ... }] + hash_filter(params, filter, on_unpermitted:, explicit_arrays:) + end + end + + unpermitted_parameters!(params, on_unpermitted:) + + params.permit! end private def new_instance_with_inherited_permitted_status(hash) - self.class.new(hash).tap do |new_instance| + self.class.new(hash, @logging_context).tap do |new_instance| new_instance.permitted = @permitted end end - def convert_parameters_to_hashes(value, using) + def convert_parameters_to_hashes(value, using, &block) case value when Array value.map { |v| convert_parameters_to_hashes(v, using) } when Hash - value.transform_values do |v| + transformed = value.transform_values do |v| convert_parameters_to_hashes(v, using) - end.with_indifferent_access + end + (block_given? ? transformed.to_h(&block) : transformed).with_indifferent_access when Parameters value.send(using) else @@ -680,37 +1184,103 @@ def convert_value_to_parameters(value) when Array return value if converted_arrays.member?(value) converted = value.map { |_| convert_value_to_parameters(_) } - converted_arrays << converted + converted_arrays << converted.dup converted when Hash - self.class.new(value) + self.class.new(value, @logging_context) else value end end - def each_element(object) + def _deep_transform_keys_in_object(object, &block) case object + when Hash + object.each_with_object(self.class.new) do |(key, value), result| + result[yield(key)] = _deep_transform_keys_in_object(value, &block) + end + when Parameters + if object.permitted? + object.to_h.deep_transform_keys(&block) + else + object.to_unsafe_h.deep_transform_keys(&block) + end when Array - object.grep(Parameters).map { |el| yield el }.compact + object.map { |e| _deep_transform_keys_in_object(e, &block) } + else + object + end + end + + def _deep_transform_keys_in_object!(object, &block) + case object + when Hash + object.keys.each do |key| + value = object.delete(key) + object[yield(key)] = _deep_transform_keys_in_object!(value, &block) + end + object when Parameters - if object.fields_for_style? - hash = object.class.new - object.each { |k, v| hash[k] = yield v } - hash + if object.permitted? + object.to_h.deep_transform_keys!(&block) else - yield object + object.to_unsafe_h.deep_transform_keys!(&block) end + when Array + object.map! { |e| _deep_transform_keys_in_object!(e, &block) } + else + object + end + end + + def specify_numeric_keys?(filter) + if filter.respond_to?(:keys) + filter.keys.any? { |key| /\A-?\d+\z/.match?(key) } end end - def unpermitted_parameters!(params) + # When an array is expected, you must specify an array explicitly + # using the following format: + # + # params.expect(comments: [[:flavor]]) + # + # Which will match only the following array formats: + # + # { pies: [{ flavor: "rhubarb" }, { flavor: "apple" }] } + # { pies: { "0" => { flavor: "key lime" }, "1" => { flavor: "mince" } } } + # + # When using `permit`, arrays are specified the same way as hashes: + # + # params.expect(pies: [:flavor]) + # + # In this case, `permit` would also allow matching with a hash (or vice versa): + # + # { pies: { flavor: "cherry" } } + # + def array_filter?(filter) + filter.is_a?(Array) && filter.size == 1 && filter.first.is_a?(Array) + end + + # Called when an explicit array filter is encountered. + def each_array_element(object, filter, &block) + case object + when Array + object.grep(Parameters).filter_map(&block) + when Parameters + if object.nested_attributes? && !specify_numeric_keys?(filter) + object.each_nested_attribute(&block) + end + end + end + + def unpermitted_parameters!(params, on_unpermitted: self.class.action_on_unpermitted_parameters) + return unless on_unpermitted unpermitted_keys = unpermitted_keys(params) if unpermitted_keys.any? - case self.class.action_on_unpermitted_parameters + case on_unpermitted when :log name = "unpermitted_parameters.action_controller" - ActiveSupport::Notifications.instrument(name, keys: unpermitted_keys) + ActiveSupport::Notifications.instrument(name, keys: unpermitted_keys, context: @logging_context) when :raise raise ActionController::UnpermittedParameters.new(unpermitted_keys) end @@ -721,17 +1291,14 @@ def unpermitted_keys(params) keys - params.keys - always_permitted_parameters end + # This is a list of permitted scalar types that includes the ones supported in + # XML and JSON requests. # - # --- Filtering ---------------------------------------------------------- - # - - # This is a white list of permitted scalar types that includes the ones - # supported in XML and JSON requests. - # - # This list is in particular used to filter ordinary requests, String goes - # as first element to quickly short-circuit the common case. + # This list is in particular used to filter ordinary requests, String goes as + # first element to quickly short-circuit the common case. # - # If you modify this collection please update the API of +permit+ above. + # If you modify this collection please update the one in the #permit doc as + # well. PERMITTED_SCALAR_TYPES = [ String, Symbol, @@ -752,21 +1319,28 @@ def permitted_scalar?(value) PERMITTED_SCALAR_TYPES.any? { |type| value.is_a?(type) } end - def permitted_scalar_filter(params, key) - if has_key?(key) && permitted_scalar?(self[key]) - params[key] = self[key] - end + # Adds existing keys to the params if their values are scalar. + # + # For example: + # + # puts self.keys #=> ["zipcode(90210i)"] + # params = {} + # + # permitted_scalar_filter(params, "zipcode") + # + # puts params.keys # => ["zipcode"] + def permitted_scalar_filter(params, permitted_key) + permitted_key = permitted_key.to_s - keys.grep(/\A#{Regexp.escape(key)}\(\d+[if]?\)\z/) do |k| - if permitted_scalar?(self[k]) - params[k] = self[k] - end + if has_key?(permitted_key) && permitted_scalar?(self[permitted_key]) + params[permitted_key] = self[permitted_key] end - end - def array_of_permitted_scalars?(value) - if value.is_a?(Array) && value.all? { |element| permitted_scalar?(element) } - yield value + each_key do |key| + next unless key =~ /\(\d+[if]?\)\z/ + next unless $~.pre_match == permitted_key + + params[key] = self[key] if permitted_scalar?(self[key]) end end @@ -774,35 +1348,59 @@ def non_scalar?(value) value.is_a?(Array) || value.is_a?(Parameters) end - EMPTY_ARRAY = [] - EMPTY_HASH = {} - def hash_filter(params, filter) + EMPTY_ARRAY = [] # :nodoc: + EMPTY_HASH = {} # :nodoc: + def hash_filter(params, filter, on_unpermitted: self.class.action_on_unpermitted_parameters, explicit_arrays: false) filter = filter.with_indifferent_access # Slicing filters out non-declared keys. slice(*filter.keys).each do |key, value| next unless value next unless has_key? key + result = permit_value(value, filter[key], on_unpermitted:, explicit_arrays:) + params[key] = result unless result.nil? + end + end - if filter[key] == EMPTY_ARRAY - # Declaration { comment_ids: [] }. - array_of_permitted_scalars?(self[key]) do |val| - params[key] = val - end - elsif filter[key] == EMPTY_HASH - # Declaration { preferences: {} }. - if value.is_a?(Parameters) - params[key] = permit_any_in_parameters(value) - end - elsif non_scalar?(value) - # Declaration { user: :name } or { user: [:name, :age, { address: ... }] }. - params[key] = each_element(value) do |element| - element.permit(*Array.wrap(filter[key])) - end - end + def permit_value(value, filter, on_unpermitted:, explicit_arrays:) + if filter == EMPTY_ARRAY # Declaration { comment_ids: [] }. + permit_array_of_scalars(value) + elsif filter == EMPTY_HASH # Declaration { preferences: {} }. + permit_hash(value, filter, on_unpermitted:, explicit_arrays:) + elsif array_filter?(filter) # Declaration { comments: [[:text]] } + permit_array_of_hashes(value, filter.first, on_unpermitted:, explicit_arrays:) + elsif explicit_arrays # Declaration { user: { address: ... } } or { user: [:name, ...] } (only allows hash value) + permit_hash(value, filter, on_unpermitted:, explicit_arrays:) + elsif non_scalar?(value) # Declaration { user: { address: ... } } or { user: [:name, ...] } + permit_hash_or_array(value, filter, on_unpermitted:, explicit_arrays:) end end + def permit_array_of_scalars(value) + value if value.is_a?(Array) && value.all? { |element| permitted_scalar?(element) } + end + + def permit_array_of_hashes(value, filter, on_unpermitted:, explicit_arrays:) + each_array_element(value, filter) do |element| + element.permit_filters(Array.wrap(filter), on_unpermitted:, explicit_arrays:) + end + end + + def permit_hash(value, filter, on_unpermitted:, explicit_arrays:) + return unless value.is_a?(Parameters) + + if filter == EMPTY_HASH + permit_any_in_parameters(value) + else + value.permit_filters(Array.wrap(filter), on_unpermitted:, explicit_arrays:) + end + end + + def permit_hash_or_array(value, filter, on_unpermitted:, explicit_arrays:) + permit_array_of_hashes(value, filter, on_unpermitted:, explicit_arrays:) || + permit_hash(value, filter, on_unpermitted:, explicit_arrays:) + end + def permit_any_in_parameters(params) self.class.new.tap do |sanitized| params.each do |key, value| @@ -826,6 +1424,8 @@ def permit_any_in_array(array) case element when ->(e) { permitted_scalar?(e) } sanitized << element + when Array + sanitized << permit_any_in_array(element) when Parameters sanitized << permit_any_in_parameters(element) else @@ -841,87 +1441,92 @@ def initialize_copy(source) end end - # == Strong \Parameters + # # Strong Parameters # - # It provides an interface for protecting attributes from end-user - # assignment. This makes Action Controller parameters forbidden - # to be used in Active Model mass assignment until they have been - # whitelisted. + # It provides an interface for protecting attributes from end-user assignment. + # This makes Action Controller parameters forbidden to be used in Active Model + # mass assignment until they have been explicitly enumerated. # # In addition, parameters can be marked as required and flow through a - # predefined raise/rescue flow to end up as a 400 Bad Request with no - # effort. - # - # class PeopleController < ActionController::Base - # # Using "Person.create(params[:person])" would raise an - # # ActiveModel::ForbiddenAttributesError exception because it'd - # # be using mass assignment without an explicit permit step. - # # This is the recommended form: - # def create - # Person.create(person_params) - # end + # predefined raise/rescue flow to end up as a `400 Bad Request` with no effort. # - # # This will pass with flying colors as long as there's a person key in the - # # parameters, otherwise it'll raise an ActionController::MissingParameter - # # exception, which will get caught by ActionController::Base and turned - # # into a 400 Bad Request reply. - # def update - # redirect_to current_account.people.find(params[:id]).tap { |person| - # person.update!(person_params) - # } - # end + # class PeopleController < ActionController::Base + # # Using "Person.create(params[:person])" would raise an + # # ActiveModel::ForbiddenAttributesError exception because it'd + # # be using mass assignment without an explicit permit step. + # # This is the recommended form: + # def create + # Person.create(person_params) + # end # - # private - # # Using a private method to encapsulate the permissible parameters is - # # just a good pattern since you'll be able to reuse the same permit - # # list between create and update. Also, you can specialize this method - # # with per-user checking of permissible attributes. - # def person_params - # params.require(:person).permit(:name, :age) + # # This will pass with flying colors as long as there's a person key in the + # # parameters, otherwise it'll raise an ActionController::ParameterMissing + # # exception, which will get caught by ActionController::Base and turned + # # into a 400 Bad Request reply. + # def update + # redirect_to current_account.people.find(params[:id]).tap { |person| + # person.update!(person_params) + # } # end - # end # - # In order to use accepts_nested_attributes_for with Strong \Parameters, you - # will need to specify which nested attributes should be whitelisted. You might want - # to allow +:id+ and +:_destroy+, see ActiveRecord::NestedAttributes for more information. + # private + # # Using a private method to encapsulate the permissible parameters is + # # a good pattern since you'll be able to reuse the same permit + # # list between create and update. Also, you can specialize this method + # # with per-user checking of permissible attributes. + # def person_params + # params.expect(person: [:name, :age]) + # end + # end # - # class Person - # has_many :pets - # accepts_nested_attributes_for :pets - # end + # In order to use `accepts_nested_attributes_for` with Strong Parameters, you + # will need to specify which nested attributes should be permitted. You might + # want to allow `:id` and `:_destroy`, see ActiveRecord::NestedAttributes for + # more information. # - # class PeopleController < ActionController::Base - # def create - # Person.create(person_params) + # class Person + # has_many :pets + # accepts_nested_attributes_for :pets # end # - # ... + # class PeopleController < ActionController::Base + # def create + # Person.create(person_params) + # end + # + # ... # - # private + # private # - # def person_params - # # It's mandatory to specify the nested attributes that should be whitelisted. - # # If you use `permit` with just the key that points to the nested attributes hash, - # # it will return an empty hash. - # params.require(:person).permit(:name, :age, pets_attributes: [ :id, :name, :category ]) - # end - # end + # def person_params + # # It's mandatory to specify the nested attributes that should be permitted. + # # If you use `permit` with just the key that points to the nested attributes hash, + # # it will return an empty hash. + # params.expect(person: [ :name, :age, pets_attributes: [ :id, :name, :category ] ]) + # end + # end # - # See ActionController::Parameters.require and ActionController::Parameters.permit - # for more information. + # See ActionController::Parameters.expect, + # See ActionController::Parameters.require, and + # ActionController::Parameters.permit for more information. module StrongParameters - extend ActiveSupport::Concern - include ActiveSupport::Rescuable - - # Returns a new ActionController::Parameters object that - # has been instantiated with the request.parameters. + # Returns a new ActionController::Parameters object that has been instantiated + # with the `request.parameters`. def params - @_params ||= Parameters.new(request.parameters) + @_params ||= begin + context = { + controller: self.class.name, + action: action_name, + request: request, + params: request.filtered_parameters + } + Parameters.new(request.parameters, context) + end end - # Assigns the given +value+ to the +params+ hash. If +value+ - # is a Hash, this will create an ActionController::Parameters - # object that has been instantiated with the given +value+ hash. + # Assigns the given `value` to the `params` hash. If `value` is a Hash, this + # will create an ActionController::Parameters object that has been instantiated + # with the given `value` hash. def params=(value) @_params = value.is_a?(Hash) ? Parameters.new(value) : value end diff --git a/actionpack/lib/action_controller/metal/testing.rb b/actionpack/lib/action_controller/metal/testing.rb index 9bb416178a59b..125299f7514de 100644 --- a/actionpack/lib/action_controller/metal/testing.rb +++ b/actionpack/lib/action_controller/metal/testing.rb @@ -1,20 +1,25 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController module Testing - extend ActiveSupport::Concern - # Behavior specific to functional tests module Functional # :nodoc: + def clear_instance_variables_between_requests + if defined?(@_ivars) + new_ivars = instance_variables - @_ivars + new_ivars.each { |ivar| remove_instance_variable(ivar) } + end + + @_ivars = instance_variables + end + def recycle! @_url_options = nil self.formats = nil self.params = nil end end - - module ClassMethods - def before_filters - _process_action_callbacks.find_all { |x| x.kind == :before }.map(&:name) - end - end end end diff --git a/actionpack/lib/action_controller/metal/url_for.rb b/actionpack/lib/action_controller/metal/url_for.rb index 9f3cc099d6fc9..c240399bc301e 100644 --- a/actionpack/lib/action_controller/metal/url_for.rb +++ b/actionpack/lib/action_controller/metal/url_for.rb @@ -1,30 +1,39 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController - # Includes +url_for+ into the host class. The class has to provide a +RouteSet+ by implementing - # the _routes method. Otherwise, an exception will be raised. + # # Action Controller UrlFor + # + # Includes `url_for` into the host class. The class has to provide a `RouteSet` + # by implementing the `_routes` method. Otherwise, an exception will be raised. # - # In addition to AbstractController::UrlFor, this module accesses the HTTP layer to define - # url options like the +host+. In order to do so, this module requires the host class - # to implement +env+ which needs to be Rack-compatible and +request+ - # which is either an instance of +ActionDispatch::Request+ or an object - # that responds to the +host+, +optional_port+, +protocol+ and - # +symbolized_path_parameter+ methods. + # In addition to AbstractController::UrlFor, this module accesses the HTTP layer + # to define URL options like the `host`. In order to do so, this module requires + # the host class to implement `env` which needs to be Rack-compatible, and + # `request` which returns an ActionDispatch::Request instance. # - # class RootUrl - # include ActionController::UrlFor - # include Rails.application.routes.url_helpers + # class RootUrl + # include ActionController::UrlFor + # include Rails.application.routes.url_helpers # - # delegate :env, :request, to: :controller + # delegate :env, :request, to: :controller # - # def initialize(controller) - # @controller = controller - # @url = root_path # named route from the application. + # def initialize(controller) + # @controller = controller + # @url = root_path # named route from the application. + # end # end - # end module UrlFor extend ActiveSupport::Concern include AbstractController::UrlFor + def initialize(...) + super + @_url_options = nil + end + def url_options @_url_options ||= { host: request.host, @@ -42,7 +51,7 @@ def url_options options[:original_script_name] = original_script_name else if same_origin - options[:script_name] = request.script_name.empty? ? "".freeze : request.script_name.dup + options[:script_name] = request.script_name.empty? ? "" : request.script_name.dup else options[:script_name] = script_name end diff --git a/actionpack/lib/action_controller/railtie.rb b/actionpack/lib/action_controller/railtie.rb index a7cdfe6a98e2a..a545bb8ce9f07 100644 --- a/actionpack/lib/action_controller/railtie.rb +++ b/actionpack/lib/action_controller/railtie.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "rails" require "action_controller" require "action_dispatch/railtie" @@ -6,11 +10,21 @@ require "action_view/railtie" module ActionController - class Railtie < Rails::Railtie #:nodoc: + class Railtie < Rails::Railtie # :nodoc: config.action_controller = ActiveSupport::OrderedOptions.new + 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 + initializer "action_controller.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_controller] = ActionController.deprecator + end + initializer "action_controller.assets_config", group: :all do |app| app.config.action_controller.assets_dir ||= app.config.paths["public"].first end @@ -19,16 +33,27 @@ class Railtie < Rails::Railtie #:nodoc: ActionController::Helpers.helpers_path = app.helpers_paths end + initializer "action_controller.live_streaming_excluded_keys" do |app| + ActionController::Live.live_streaming_excluded_keys = app.config.action_controller.live_streaming_excluded_keys + end + initializer "action_controller.parameters_config" do |app| options = app.config.action_controller - ActionController::Parameters.permit_all_parameters = options.delete(:permit_all_parameters) { false } - if app.config.action_controller[:always_permitted_parameters] - ActionController::Parameters.always_permitted_parameters = - app.config.action_controller.delete(:always_permitted_parameters) - end - ActionController::Parameters.action_on_unpermitted_parameters = options.delete(:action_on_unpermitted_parameters) do - (Rails.env.test? || Rails.env.development?) ? :log : false + ActiveSupport.on_load(:action_controller, run_once: true) do + ActionController::Parameters.permit_all_parameters = options.permit_all_parameters || false + if app.config.action_controller[:always_permitted_parameters] + ActionController::Parameters.always_permitted_parameters = + app.config.action_controller.always_permitted_parameters + end + + action_on_unpermitted_parameters = options.action_on_unpermitted_parameters + + if action_on_unpermitted_parameters.nil? + action_on_unpermitted_parameters = Rails.env.local? ? :log : false + end + + ActionController::Parameters.action_on_unpermitted_parameters = action_on_unpermitted_parameters end end @@ -36,13 +61,14 @@ 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 options.stylesheets_dir ||= paths["public/stylesheets"].first - # Ensure readers methods get compiled + # Ensure readers methods get compiled. options.asset_host ||= app.config.asset_host options.relative_url_root ||= app.config.relative_url_root @@ -51,7 +77,20 @@ class Railtie < Rails::Railtie #:nodoc: extend ::AbstractController::Railties::RoutesHelpers.with(app.routes) extend ::ActionController::Railties::Helpers - options.each do |k, v| + wrap_parameters format: [:json] if options.wrap_parameters_by_default && respond_to?(:wrap_parameters) + + # Configs used in other initializers + filtered_options = options.except( + :default_protect_from_forgery, + :log_query_tags_around_actions, + :permit_all_parameters, + :action_on_unpermitted_parameters, + :always_permitted_parameters, + :wrap_parameters_by_default, + :live_streaming_excluded_keys + ) + + filtered_options.each do |k, v| k = "#{k}=" if respond_to?(k) send(k, v) @@ -62,9 +101,58 @@ class Railtie < Rails::Railtie #:nodoc: end end - initializer "action_controller.compile_config_methods" do + initializer "action_controller.request_forgery_protection" do |app| + ActiveSupport.on_load(:action_controller_base) do + if app.config.action_controller.default_protect_from_forgery + protect_from_forgery with: :exception + end + 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 && + app.config.action_controller.log_query_tags_around_actions + + if query_logs_tags_enabled + app.config.active_record.query_log_tags |= [:controller] unless app.config.active_record.query_log_tags.include?(:namespaced_controller) + app.config.active_record.query_log_tags |= [:action] + + ActiveSupport.on_load(:active_record) do + ActiveRecord::QueryLogs.taggings = ActiveRecord::QueryLogs.taggings.merge( + controller: ->(context) { context[:controller]&.controller_name }, + action: ->(context) { context[:controller]&.action_name }, + namespaced_controller: ->(context) { context[:controller]&.controller_path } + ) + end + end + end + + initializer "action_controller.test_case" do |app| + ActiveSupport.on_load(:action_controller_test_case) do + 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 - config.compile_methods! if config.respond_to?(:compile_methods!) + ActionController::LogSubscriber.backtrace_cleaner = Rails.backtrace_cleaner end end end diff --git a/actionpack/lib/action_controller/railties/helpers.rb b/actionpack/lib/action_controller/railties/helpers.rb index 3985c6b273422..2ae7c87773fdf 100644 --- a/actionpack/lib/action_controller/railties/helpers.rb +++ b/actionpack/lib/action_controller/railties/helpers.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController module Railties module Helpers @@ -5,7 +9,7 @@ def inherited(klass) super return unless klass.respond_to?(:helpers_path=) - if namespace = klass.parents.detect { |m| m.respond_to?(:railtie_helpers_paths) } + if namespace = klass.module_parents.detect { |m| m.respond_to?(:railtie_helpers_paths) } paths = namespace.railtie_helpers_paths else paths = ActionController::Helpers.helpers_path diff --git a/actionpack/lib/action_controller/renderer.rb b/actionpack/lib/action_controller/renderer.rb index acb400cd15dee..dd0f01c345b56 100644 --- a/actionpack/lib/action_controller/renderer.rb +++ b/actionpack/lib/action_controller/renderer.rb @@ -1,78 +1,132 @@ -require "active_support/core_ext/hash/keys" +# frozen_string_literal: true + +# :markup: markdown module ActionController - # ActionController::Renderer allows you to render arbitrary templates - # without requirement of being in controller actions. - # - # You get a concrete renderer class by invoking ActionController::Base#renderer. - # For example, - # - # ApplicationController.renderer - # - # It allows you to call method #render directly. - # - # ApplicationController.renderer.render template: '...' - # - # You can use this shortcut in a controller, instead of the previous example: - # - # ApplicationController.render template: '...' + # # Action Controller Renderer # - # #render allows you to use the same options that you can use when rendering in a controller. - # For example, + # ActionController::Renderer allows you to render arbitrary templates without + # being inside a controller action. # - # FooController.render :action, locals: { ... }, assigns: { ... } + # You can get a renderer instance by calling `renderer` on a controller class: # - # The template will be rendered in a Rack environment which is accessible through - # ActionController::Renderer#env. You can set it up in two ways: + # ApplicationController.renderer + # PostsController.renderer # - # * by changing renderer defaults, like + # and render a template by calling the #render method: # - # ApplicationController.renderer.defaults # => hash with default Rack environment + # ApplicationController.renderer.render template: "posts/show", assigns: { post: Post.first } + # PostsController.renderer.render :show, assigns: { post: Post.first } # - # * by initializing an instance of renderer by passing it a custom environment. + # As a shortcut, you can also call `render` directly on the controller class + # itself: # - # ApplicationController.renderer.new(method: 'post', https: true) + # ApplicationController.render template: "posts/show", assigns: { post: Post.first } + # PostsController.render :show, assigns: { post: Post.first } # class Renderer - attr_reader :defaults, :controller + attr_reader :controller DEFAULTS = { - http_host: "example.org", - https: false, method: "get", - script_name: "", input: "" }.freeze - # Create a new renderer instance for a specific controller class. - def self.for(controller, env = {}, defaults = DEFAULTS.dup) + def self.normalize_env(env) # :nodoc: + new_env = {} + + env.each_pair do |key, value| + case key + when :https + value = value ? "on" : "off" + when :method + value = -value.upcase + end + + key = RACK_KEY_TRANSLATION[key] || key.to_s + + new_env[key] = value + end + + if new_env["HTTP_HOST"] + new_env["HTTPS"] ||= "off" + new_env["SCRIPT_NAME"] ||= "" + end + + if new_env["HTTPS"] + new_env["rack.url_scheme"] = new_env["HTTPS"] == "on" ? "https" : "http" + end + + new_env + end + + # Creates a new renderer using the given controller class. See ::new. + def self.for(controller, env = nil, defaults = DEFAULTS) new(controller, env, defaults) end - # Create a new renderer for the same controller but with a new env. - def new(env = {}) - self.class.new controller, env, defaults + # Creates a new renderer using the same controller, but with a new Rack env. + # + # ApplicationController.renderer.new(method: "post") + # + def new(env = nil) + self.class.new controller, env, @defaults end - # Create a new renderer for the same controller but with new defaults. + # Creates a new renderer using the same controller, but with the given defaults + # merged on top of the previous defaults. def with_defaults(defaults) - self.class.new controller, env, self.defaults.merge(defaults) + self.class.new controller, @env, @defaults.merge(defaults) end - # Accepts a custom Rack environment to render templates in. - # It will be merged with the default Rack environment defined by - # +ActionController::Renderer::DEFAULTS+. + # Initializes a new Renderer. + # + # #### Parameters + # + # * `controller` - The controller class to instantiate for rendering. + # * `env` - The Rack env to use for mocking a request when rendering. Entries + # can be typical Rack env keys and values, or they can be any of the + # following, which will be converted appropriately: + # * `:http_host` - The HTTP host for the incoming request. Converts to + # Rack's `HTTP_HOST`. + # * `:https` - Boolean indicating whether the incoming request uses HTTPS. + # Converts to Rack's `HTTPS`. + # * `:method` - The HTTP method for the incoming request, + # case-insensitive. Converts to Rack's `REQUEST_METHOD`. + # * `: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. + # + # + # If no `http_host` is specified, the env HTTP host will be derived from the + # routes' `default_url_options`. In this case, the `https` boolean and the + # `script_name` will also be derived from `default_url_options` if they were not + # specified. Additionally, the `https` boolean will fall back to + # `Rails.application.config.force_ssl` if `default_url_options` does not specify + # a `protocol`. def initialize(controller, env, defaults) @controller = controller @defaults = defaults - @env = normalize_keys defaults.merge(env) + if env.blank? && @defaults == DEFAULTS + @env = DEFAULT_ENV + else + @env = normalize_env(@defaults) + @env.merge!(normalize_env(env)) unless env.blank? + end end - # Render templates with any options from ActionController::Base#render_to_string. - def render(*args) - raise "missing controller" unless controller + def defaults + @defaults = @defaults.dup if @defaults.frozen? + @defaults + end - request = ActionDispatch::Request.new @env + # Renders a template to a string, just like + # ActionController::Rendering#render_to_string. + def render(*args) + request = ActionDispatch::Request.new(env_for_request) request.routes = controller._routes instance = controller.new @@ -80,14 +134,9 @@ def render(*args) instance.set_response! controller.make_response!(request) instance.render_to_string(*args) end + alias_method :render_to_string, :render # :nodoc: private - def normalize_keys(env) - new_env = {} - env.each_pair { |k, v| new_env[rack_key_for(k)] = rack_value_for(k, v) } - new_env - end - RACK_KEY_TRANSLATION = { http_host: "HTTP_HOST", https: "HTTPS", @@ -96,19 +145,16 @@ def normalize_keys(env) input: "rack.input" } - IDENTITY = ->(_) { _ } + DEFAULT_ENV = normalize_env(DEFAULTS).freeze # :nodoc: - RACK_VALUE_TRANSLATION = { - https: ->(v) { v ? "on" : "off" }, - method: ->(v) { v.upcase }, - } - - def rack_key_for(key) - RACK_KEY_TRANSLATION.fetch(key, key.to_s) - end + delegate :normalize_env, to: :class - def rack_value_for(key, value) - RACK_VALUE_TRANSLATION.fetch(key, IDENTITY).call value + def env_for_request + if @env.key?("HTTP_HOST") || controller._routes.nil? + @env.dup + else + controller._routes.default_env.merge(@env) + end end end end 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..c2f8efb521c07 --- /dev/null +++ b/actionpack/lib/action_controller/structured_event_subscriber.rb @@ -0,0 +1,147 @@ +# 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 open_redirect(event) + payload = event.payload + + emit_event("action_controller.open_redirect", + location: payload[:location], + request_method: payload[:request]&.method, + request_path: payload[:request]&.path, + stacktrace: payload[:stack_trace], + ) + 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 csrf_token_fallback(event) + emit_csrf_event "action_controller.csrf_token_fallback", event.payload + end + + def csrf_request_blocked(event) + emit_csrf_event "action_controller.csrf_request_blocked", event.payload + end + + def csrf_javascript_blocked(event) + emit_csrf_event "action_controller.csrf_javascript_blocked", event.payload + end + + private def emit_csrf_event(name, payload) + emit_event name, + controller: payload[:controller], + action: payload[:action], + sec_fetch_site: payload[:sec_fetch_site], + message: payload[:message] + end + + 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_controller/template_assertions.rb b/actionpack/lib/action_controller/template_assertions.rb index 0179f4afcd63b..c3bf41faeec52 100644 --- a/actionpack/lib/action_controller/template_assertions.rb +++ b/actionpack/lib/action_controller/template_assertions.rb @@ -1,9 +1,13 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionController - module TemplateAssertions + module TemplateAssertions # :nodoc: def assert_template(options = {}, message = nil) raise NoMethodError, - "assert_template has been extracted to a gem. To continue using it, - add `gem 'rails-controller-testing'` to your Gemfile." + 'assert_template has been extracted to a gem. To continue using it, + add `gem "rails-controller-testing"` to your Gemfile.' end end end diff --git a/actionpack/lib/action_controller/test_case.rb b/actionpack/lib/action_controller/test_case.rb index 7229c67f30951..d3a3bf212a6c4 100644 --- a/actionpack/lib/action_controller/test_case.rb +++ b/actionpack/lib/action_controller/test_case.rb @@ -1,7 +1,12 @@ +# frozen_string_literal: true + +# :markup: markdown + require "rack/session/abstract/id" require "active_support/core_ext/hash/conversions" require "active_support/core_ext/object/to_query" require "active_support/core_ext/module/anonymous" +require "active_support/core_ext/module/redefine_method" require "active_support/core_ext/hash/keys" require "active_support/testing/constant_lookup" require "action_controller/template_assertions" @@ -13,19 +18,32 @@ class Metal end module Live - # Disable controller / rendering threads in tests. User tests can access - # the database on the main thread, so they could open a txn, then the - # controller thread will open a new connection and try to access data - # that's only visible to the main thread's txn. This is the problem in #23483 - remove_method :new_controller_thread + # Disable controller / rendering threads in tests. User tests can access the + # database on the main thread, so they could open a txn, then the controller + # thread will open a new connection and try to access data that's only visible + # to the main thread's txn. This is the problem in #23483. + alias_method :original_new_controller_thread, :new_controller_thread + + silence_redefinition_of_method :new_controller_thread def new_controller_thread # :nodoc: yield end + + # Because of the above, we need to prevent the clearing of thread locals, since + # no new thread is actually spawned in the test environment. + alias_method :original_clean_up_thread_locals, :clean_up_thread_locals + + silence_redefinition_of_method :clean_up_thread_locals + def clean_up_thread_locals(*args) # :nodoc: + end + + # Avoid a deadlock from the queue filling up + Buffer.queue_size = nil end - # ActionController::TestCase will be deprecated and moved to a gem in Rails 5.1. - # Please use ActionDispatch::IntegrationTest going forward. - class TestRequest < ActionDispatch::TestRequest #:nodoc: + # ActionController::TestCase will be deprecated and moved to a gem in the + # future. Please use ActionDispatch::IntegrationTest going forward. + class TestRequest < ActionDispatch::TestRequest # :nodoc: DEFAULT_ENV = ActionDispatch::TestRequest::DEFAULT_ENV.dup DEFAULT_ENV.delete "PATH_INFO" @@ -35,7 +53,7 @@ def self.new_session attr_reader :controller_class - # Create a new test request with default `env` values + # Create a new test request with default `env` values. def self.create(controller_class) env = {} env = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application @@ -81,7 +99,7 @@ def assign_parameters(routes, controller_path, action, parameters, generated_pat value = value.to_param end - path_parameters[key] = value + path_parameters[key.to_sym] = value end end @@ -98,7 +116,7 @@ def assign_parameters(routes, controller_path, action, parameters, generated_pat set_header k, "application/x-www-form-urlencoded" end - case content_mime_type.to_sym + case content_mime_type&.to_sym when nil raise "Unknown Content-Type: #{content_type}" when :json @@ -113,7 +131,7 @@ def assign_parameters(routes, controller_path, action, parameters, generated_pat end end - data_stream = StringIO.new(data) + data_stream = StringIO.new(data.b) set_header "CONTENT_LENGTH", data_stream.length.to_s set_header "rack.input", data_stream end @@ -121,6 +139,9 @@ def assign_parameters(routes, controller_path, action, parameters, generated_pat fetch_header("PATH_INFO") do |k| set_header k, generated_path end + fetch_header("ORIGINAL_FULLPATH") do |k| + set_header k, fullpath + end path_parameters[:controller] = controller_path path_parameters[:action] = action @@ -131,7 +152,7 @@ def assign_parameters(routes, controller_path, action, parameters, generated_pat include Rack::Test::Utils def should_multipart?(params) - # FIXME: lifted from Rack-Test. We should push this separation upstream + # FIXME: lifted from Rack-Test. We should push this separation upstream. multipart = false query = lambda { |value| case value @@ -155,7 +176,6 @@ def content_type end.new private - def params_parsers super.merge @custom_param_parsers end @@ -174,14 +194,15 @@ class LiveTestResponse < Live::Response # Methods #destroy and #load! are overridden to avoid calling methods on the # @store object, which does not exist for the TestSession class. - class TestSession < Rack::Session::Abstract::SessionHash #:nodoc: + class TestSession < Rack::Session::Abstract::PersistedSecure::SecureSessionHash # :nodoc: DEFAULT_OPTIONS = Rack::Session::Abstract::Persisted::DEFAULT_OPTIONS - def initialize(session = {}) + def initialize(session = {}, id = Rack::Session::SessionId.new(SecureRandom.hex(16))) super(nil, nil) - @id = SecureRandom.hex(16) + @id = id @data = stringify_keys(session) @loaded = true + @initially_empty = @data.empty? end def exists? @@ -200,129 +221,153 @@ def destroy clear end + def dig(*keys) + keys = keys.map.with_index { |key, i| i.zero? ? key.to_s : key } + @data.dig(*keys) + end + def fetch(key, *args, &block) @data.fetch(key.to_s, *args, &block) end - private + def enabled? + true + end + + def id_was + @id + end + private def load! @id end end - # Superclass for ActionController functional tests. Functional tests allow you to - # test a single controller action per test method. + # # Action Controller Test Case + # + # Superclass for ActionController functional tests. Functional tests allow you + # to test a single controller action per test method. # - # == Use integration style controller tests over functional style controller tests. + # ## Use integration style controller tests over functional style controller tests. # # Rails discourages the use of functional tests in favor of integration tests # (use ActionDispatch::IntegrationTest). # - # New Rails applications no longer generate functional style controller tests and they should - # only be used for backward compatibility. Integration style controller tests perform actual - # requests, whereas functional style controller tests merely simulate a request. Besides, - # integration tests are as fast as functional tests and provide lot of helpers such as +as+, - # +parsed_body+ for effective testing of controller actions including even API endpoints. + # New Rails applications no longer generate functional style controller tests + # and they should only be used for backward compatibility. Integration style + # controller tests perform actual requests, whereas functional style controller + # tests merely simulate a request. Besides, integration tests are as fast as + # functional tests and provide lot of helpers such as `as`, `parsed_body` for + # effective testing of controller actions including even API endpoints. # - # == Basic example + # ## Basic example # # Functional tests are written as follows: - # 1. First, one uses the +get+, +post+, +patch+, +put+, +delete+ or +head+ method to simulate - # an HTTP request. - # 2. Then, one asserts whether the current state is as expected. "State" can be anything: - # the controller's HTTP response, the database contents, etc. + # 1. First, one uses the `get`, `post`, `patch`, `put`, `delete`, or `head` + # method to simulate an HTTP request. + # 2. Then, one asserts whether the current state is as expected. "State" can be + # anything: the controller's HTTP response, the database contents, etc. + # # # For example: # - # class BooksControllerTest < ActionController::TestCase - # def test_create - # # Simulate a POST response with the given HTTP parameters. - # post(:create, params: { book: { title: "Love Hina" }}) + # class BooksControllerTest < ActionController::TestCase + # def test_create + # # Simulate a POST response with the given HTTP parameters. + # post(:create, params: { book: { title: "Love Hina" }}) # - # # Asserts that the controller tried to redirect us to - # # the created book's URI. - # assert_response :found + # # Asserts that the controller tried to redirect us to + # # the created book's URI. + # assert_response :found # - # # Asserts that the controller really put the book in the database. - # assert_not_nil Book.find_by(title: "Love Hina") + # # Asserts that the controller really put the book in the database. + # assert_not_nil Book.find_by(title: "Love Hina") + # end # end - # end # # You can also send a real document in the simulated HTTP request. # - # def test_create - # json = {book: { title: "Love Hina" }}.to_json - # post :create, json - # end + # def test_create + # json = {book: { title: "Love Hina" }}.to_json + # post :create, body: json + # end + # + # ## Special instance variables + # + # ActionController::TestCase will also automatically provide the following + # instance variables for use in the tests: # - # == Special instance variables + # @controller + # : The controller instance that will be tested. # - # ActionController::TestCase will also automatically provide the following instance - # variables for use in the tests: + # @request + # : An ActionController::TestRequest, representing the current HTTP request. + # You can modify this object before sending the HTTP request. For example, + # you might want to set some session properties before sending a GET + # request. # - # @controller:: - # The controller instance that will be tested. - # @request:: - # An ActionController::TestRequest, representing the current HTTP - # request. You can modify this object before sending the HTTP request. For example, - # you might want to set some session properties before sending a GET request. - # @response:: - # An ActionDispatch::TestResponse object, representing the response - # of the last HTTP response. In the above example, @response becomes valid - # after calling +post+. If the various assert methods are not sufficient, then you - # may use this object to inspect the HTTP response in detail. + # @response + # : An ActionDispatch::TestResponse object, representing the response of the + # last HTTP response. In the above example, `@response` becomes valid after + # calling `post`. If the various assert methods are not sufficient, then you + # may use this object to inspect the HTTP response in detail. # - # (Earlier versions of \Rails required each functional test to subclass - # Test::Unit::TestCase and define @controller, @request, @response in +setup+.) # - # == Controller is automatically inferred + # ## Controller is automatically inferred # # ActionController::TestCase will automatically infer the controller under test # from the test class name. If the controller cannot be inferred from the test - # class name, you can explicitly set it with +tests+. + # class name, you can explicitly set it with `tests`. + # + # class SpecialEdgeCaseWidgetsControllerTest < ActionController::TestCase + # tests WidgetController + # end # - # class SpecialEdgeCaseWidgetsControllerTest < ActionController::TestCase - # tests WidgetController - # end + # ## Testing controller internals # - # == \Testing controller internals + # In addition to these specific assertions, you also have easy access to various + # collections that the regular test/unit assertions can be used against. These + # collections are: # - # In addition to these specific assertions, you also have easy access to various collections that the regular test/unit assertions - # can be used against. These collections are: + # * session: Objects being saved in the session. + # * flash: The flash objects currently in the session. + # * cookies: Cookies being sent to the user on this request. # - # * session: Objects being saved in the session. - # * flash: The flash objects currently in the session. - # * cookies: \Cookies being sent to the user on this request. # # These collections can be used just like any other hash: # - # assert_equal "Dave", cookies[:name] # makes sure that a cookie called :name was set as "Dave" - # assert flash.empty? # makes sure that there's nothing in the flash + # assert_equal "Dave", cookies[:name] # makes sure that a cookie called :name was set as "Dave" + # assert flash.empty? # makes sure that there's nothing in the flash # - # On top of the collections, you have the complete url that a given action redirected to available in redirect_to_url. + # On top of the collections, you have the complete URL that a given action + # redirected to available in `redirect_to_url`. # - # For redirects within the same controller, you can even call follow_redirect and the redirect will be followed, triggering another - # action call which can then be asserted against. + # For redirects within the same controller, you can even call follow_redirect + # and the redirect will be followed, triggering another action call which can + # then be asserted against. # - # == Manipulating session and cookie variables + # ## Manipulating session and cookie variables # - # Sometimes you need to set up the session and cookie variables for a test. - # To do this just assign a value to the session or cookie collection: + # Sometimes you need to set up the session and cookie variables for a test. To + # do this just assign a value to the session or cookie collection: # - # session[:key] = "value" - # cookies[:key] = "value" + # session[:key] = "value" + # cookies[:key] = "value" # # To clear the cookies for a test just clear the cookie collection: # - # cookies.clear + # cookies.clear # - # == \Testing named routes + # ## Testing named routes # - # If you're using named routes, they can be easily tested using the original named routes' methods straight in the test case. + # If you're using named routes, they can be easily tested using the original + # named routes' methods straight in the test case. # - # assert_redirected_to page_url(title: 'foo') + # assert_redirected_to page_url(title: 'foo') class TestCase < ActiveSupport::TestCase + singleton_class.attr_accessor :executor_around_each_request + module Behavior extend ActiveSupport::Concern include ActionDispatch::TestProcess @@ -332,12 +377,12 @@ module Behavior attr_reader :response, :request module ClassMethods - # Sets the controller class name. Useful if the name can't be inferred from test class. - # Normalizes +controller_class+ before using. + # Sets the controller class name. Useful if the name can't be inferred from test + # class. Normalizes `controller_class` before using. # - # tests WidgetController - # tests :widget - # tests 'widget' + # tests WidgetController + # tests :widget + # tests 'widget' def tests(controller_class) case controller_class when String, Symbol @@ -370,97 +415,105 @@ def determine_default_controller_class(name) # Simulate a GET request with the given parameters. # - # - +action+: The controller action to call. - # - +params+: The hash with HTTP parameters that you want to pass. This may be +nil+. - # - +body+: The request body with a string that is appropriately encoded - # (application/x-www-form-urlencoded or multipart/form-data). - # - +session+: A hash of parameters to store in the session. This may be +nil+. - # - +flash+: A hash of parameters to store in the flash. This may be +nil+. + # * `action`: The controller action to call. + # * `params`: The hash with HTTP parameters that you want to pass. This may be + # `nil`. + # * `body`: The request body with a string that is appropriately encoded + # (`application/x-www-form-urlencoded` or `multipart/form-data`). + # * `session`: A hash of parameters to store in the session. This may be + # `nil`. + # * `flash`: A hash of parameters to store in the flash. This may be `nil`. + # # - # You can also simulate POST, PATCH, PUT, DELETE, and HEAD requests with - # +post+, +patch+, +put+, +delete+, and +head+. - # Example sending parameters, session and setting a flash message: + # You can also simulate POST, PATCH, PUT, DELETE, and HEAD requests with `post`, + # `patch`, `put`, `delete`, and `head`. Example sending parameters, session, and + # setting a flash message: # - # get :show, - # params: { id: 7 }, - # session: { user_id: 1 }, - # flash: { notice: 'This is flash message' } + # get :show, + # params: { id: 7 }, + # session: { user_id: 1 }, + # flash: { notice: 'This is flash message' } # # Note that the request method is not verified. The different methods are # available to make the tests more expressive. def get(action, **args) - res = process(action, method: "GET", **args) - cookies.update res.cookies - res + process(action, method: "GET", **args) end # Simulate a POST request with the given parameters and set/volley the response. - # See +get+ for more details. + # See `get` for more details. def post(action, **args) process(action, method: "POST", **args) end - # Simulate a PATCH request with the given parameters and set/volley the response. - # See +get+ for more details. + # Simulate a PATCH request with the given parameters and set/volley the + # response. See `get` for more details. def patch(action, **args) process(action, method: "PATCH", **args) end # Simulate a PUT request with the given parameters and set/volley the response. - # See +get+ for more details. + # See `get` for more details. def put(action, **args) process(action, method: "PUT", **args) end - # Simulate a DELETE request with the given parameters and set/volley the response. - # See +get+ for more details. + # Simulate a DELETE request with the given parameters and set/volley the + # response. See `get` for more details. def delete(action, **args) process(action, method: "DELETE", **args) end # Simulate a HEAD request with the given parameters and set/volley the response. - # See +get+ for more details. + # See `get` for more details. def head(action, **args) process(action, method: "HEAD", **args) end - # Simulate an HTTP request to +action+ by specifying request method, - # parameters and set/volley the response. + # Simulate an HTTP request to `action` by specifying request method, parameters + # and set/volley the response. + # + # * `action`: The controller action to call. + # * `method`: Request method used to send the HTTP request. Possible values + # are `GET`, `POST`, `PATCH`, `PUT`, `DELETE`, `HEAD`. Defaults to `GET`. + # Can be a symbol. + # * `params`: The hash with HTTP parameters that you want to pass. This may be + # `nil`. + # * `body`: The request body with a string that is appropriately encoded + # (`application/x-www-form-urlencoded` or `multipart/form-data`). + # * `session`: A hash of parameters to store in the session. This may be + # `nil`. + # * `flash`: A hash of parameters to store in the flash. This may be `nil`. + # * `format`: Request format. Defaults to `nil`. Can be string or symbol. + # * `as`: Content type. Defaults to `nil`. Must be a symbol that corresponds + # to a mime type. # - # - +action+: The controller action to call. - # - +method+: Request method used to send the HTTP request. Possible values - # are +GET+, +POST+, +PATCH+, +PUT+, +DELETE+, +HEAD+. Defaults to +GET+. Can be a symbol. - # - +params+: The hash with HTTP parameters that you want to pass. This may be +nil+. - # - +body+: The request body with a string that is appropriately encoded - # (application/x-www-form-urlencoded or multipart/form-data). - # - +session+: A hash of parameters to store in the session. This may be +nil+. - # - +flash+: A hash of parameters to store in the flash. This may be +nil+. - # - +format+: Request format. Defaults to +nil+. Can be string or symbol. - # - +as+: Content type. Defaults to +nil+. Must be a symbol that corresponds - # to a mime type. # - # Example calling +create+ action and sending two params: + # Example calling `create` action and sending two params: # - # process :create, - # method: 'POST', - # params: { - # user: { name: 'Gaurish Sharma', email: 'user@example.com' } - # }, - # session: { user_id: 1 }, - # flash: { notice: 'This is flash message' } + # process :create, + # method: 'POST', + # params: { + # user: { name: 'Gaurish Sharma', email: 'user@example.com' } + # }, + # session: { user_id: 1 }, + # flash: { notice: 'This is flash message' } # - # To simulate +GET+, +POST+, +PATCH+, +PUT+, +DELETE+ and +HEAD+ requests - # prefer using #get, #post, #patch, #put, #delete and #head methods - # respectively which will make tests more expressive. + # To simulate `GET`, `POST`, `PATCH`, `PUT`, `DELETE`, and `HEAD` requests + # prefer using #get, #post, #patch, #put, #delete and #head methods respectively + # which will make tests more expressive. + # + # It's not recommended to make more than one request in the same test. Instance + # variables that are set in one request will not persist to the next request, + # but it's not guaranteed that all Rails internal state will be reset. Prefer + # ActionDispatch::IntegrationTest for making multiple requests in the same test. # # Note that the request method is not verified. - def process(action, method: "GET", params: {}, session: nil, body: nil, flash: {}, format: nil, xhr: false, as: nil) + def process(action, method: "GET", params: nil, session: nil, body: nil, flash: {}, format: nil, xhr: false, as: nil) check_required_ivars + @controller.clear_instance_variables_between_requests - if body - @request.set_header "RAW_POST_DATA", body - end - + action = +action.to_s http_method = method.to_s.upcase @html_document = nil @@ -475,6 +528,10 @@ def process(action, method: "GET", params: {}, session: nil, body: nil, flash: { @response.request = @request @controller.recycle! + if body + @request.set_header "RAW_POST_DATA", body + end + @request.set_header "REQUEST_METHOD", http_method if as @@ -482,64 +539,14 @@ def process(action, method: "GET", params: {}, session: nil, body: nil, flash: { format ||= as end - parameters = params.symbolize_keys + parameters = (params || {}).symbolize_keys if format parameters[:format] = format end - generated_extras = @routes.generate_extras(parameters.merge(controller: controller_class_name, action: action.to_s)) - generated_path = generated_path(generated_extras) - query_string_keys = query_parameter_names(generated_extras) - - @request.assign_parameters(@routes, controller_class_name, action.to_s, parameters, generated_path, query_string_keys) - - @request.session.update(session) if session - @request.flash.update(flash || {}) - - if xhr - @request.set_header "HTTP_X_REQUESTED_WITH", "XMLHttpRequest" - @request.fetch_header("HTTP_ACCEPT") do |k| - @request.set_header k, [Mime[:js], Mime[:html], Mime[:xml], "text/xml", "*/*"].join(", ") - end - end - - @request.fetch_header("SCRIPT_NAME") do |k| - @request.set_header k, @controller.config.relative_url_root - end - - begin - @controller.recycle! - @controller.dispatch(action, @request, @response) - ensure - @request = @controller.request - @response = @controller.response - - if @request.have_cookie_jar? - unless @request.cookie_jar.committed? - @request.cookie_jar.write(@response) - cookies.update(@request.cookie_jar.instance_variable_get(:@cookies)) - end - end - @response.prepare! - - if flash_value = @request.flash.to_session_value - @request.session["flash"] = flash_value - else - @request.session.delete("flash") - end - - if xhr - @request.delete_header "HTTP_X_REQUESTED_WITH" - @request.delete_header "HTTP_ACCEPT" - end - @request.query_string = "" - @request.env.delete "PATH_INFO" - - @response.sent! - end - - @response + setup_request(controller_class_name, action, parameters, session, flash, xhr) + process_controller_response(action, cookies, xhr) end def controller_class_name @@ -595,13 +602,79 @@ def build_response(klass) end private + def setup_request(controller_class_name, action, parameters, session, flash, xhr) + generated_extras = @routes.generate_extras(parameters.merge(controller: controller_class_name, action: action)) + generated_path = generated_path(generated_extras) + query_string_keys = query_parameter_names(generated_extras) + + @request.assign_parameters(@routes, controller_class_name, action, parameters, generated_path, query_string_keys) + + @request.session.update(session) if session + @request.flash.update(flash || {}) + + if xhr + @request.set_header "HTTP_X_REQUESTED_WITH", "XMLHttpRequest" + @request.fetch_header("HTTP_ACCEPT") do |k| + @request.set_header k, [Mime[:js], Mime[:html], Mime[:xml], "text/xml", "*/*"].join(", ") + end + end + + @request.fetch_header("SCRIPT_NAME") do |k| + @request.set_header k, @controller.config.relative_url_root + end + end + + def wrap_execution(&block) + if ActionController::TestCase.executor_around_each_request && defined?(Rails.application) && Rails.application + Rails.application.executor.wrap(&block) + else + yield + end + end + + def process_controller_response(action, cookies, xhr) + begin + @controller.recycle! + + wrap_execution { @controller.dispatch(action, @request, @response) } + ensure + @request = @controller.request + @response = @controller.response + + if @request.have_cookie_jar? + unless @request.cookie_jar.committed? + @request.cookie_jar.write(@response) + cookies.update(@request.cookie_jar.instance_variable_get(:@cookies)) + cookies.update(@response.cookies) + end + end + @response.prepare! + + if flash_value = @request.flash.to_session_value + @request.session["flash"] = flash_value + else + @request.session.delete("flash") + end + + if xhr + @request.delete_header "HTTP_X_REQUESTED_WITH" + @request.delete_header "HTTP_ACCEPT" + end + @request.query_string = "" + + @response.sent! + end + + @response + end def scrub_env!(env) - env.delete_if { |k, v| k =~ /^(action_dispatch|rack)\.request/ } - env.delete_if { |k, v| k =~ /^action_dispatch\.rescue/ } - env.delete "action_dispatch.request.query_parameters" - env.delete "action_dispatch.request.request_parameters" + env.delete_if do |k, _| + k.start_with?("rack.request", "action_dispatch.request", "action_dispatch.rescue") + end env["rack.input"] = StringIO.new + env.delete "CONTENT_LENGTH" + env.delete "RAW_POST_DATA" env end @@ -610,8 +683,8 @@ def document_root_element end def check_required_ivars - # Sanity check for required instance variables so we can give an - # understandable error message. + # Check for required instance variables so we can give an understandable error + # message. [:@routes, :@controller, :@request, :@response].each do |iv_name| if !instance_variable_defined?(iv_name) || instance_variable_get(iv_name).nil? raise "#{iv_name} is nil: make sure you set it in your test's setup method." diff --git a/actionpack/lib/action_dispatch.rb b/actionpack/lib/action_dispatch.rb index 028177ace2d46..c24348d13e7e5 100644 --- a/actionpack/lib/action_dispatch.rb +++ b/actionpack/lib/action_dispatch.rb @@ -1,67 +1,93 @@ +# frozen_string_literal: true + #-- -# Copyright (c) 2004-2017 David Heinemeier Hansson +# Copyright (c) David Heinemeier Hansson # -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: # -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. # -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. #++ +# :markup: markdown + require "active_support" require "active_support/rails" require "active_support/core_ext/module/attribute_accessors" require "action_pack" require "rack" +require "action_dispatch/deprecator" -module Rack +module Rack # :nodoc: autoload :Test, "rack/test" end +# # Action Dispatch +# +# Action Dispatch is a module of Action Pack. +# +# Action Dispatch parses information about the web request, handles routing as +# defined by the user, and does advanced processing related to HTTP such as +# MIME-type negotiation, decoding parameters in POST, PATCH, or PUT bodies, +# handling HTTP caching logic, cookies and sessions. module ActionDispatch extend ActiveSupport::Autoload - class IllegalStateError < StandardError + class MissingController < NameError end eager_autoload do autoload_under "http" do + autoload :ContentSecurityPolicy + autoload :InvalidParameterError, "action_dispatch/http/param_error" + autoload :ParamBuilder + autoload :ParamError + autoload :ParameterTypeError, "action_dispatch/http/param_error" + autoload :ParamsTooDeepError, "action_dispatch/http/param_error" + autoload :PermissionsPolicy + autoload :QueryParser autoload :Request autoload :Response end end autoload_under "middleware" do + autoload :AssumeSSL + autoload :HostAuthorization autoload :RequestId autoload :Callbacks autoload :Cookies + autoload :ActionableExceptions autoload :DebugExceptions autoload :DebugLocks + autoload :DebugView autoload :ExceptionWrapper autoload :Executor autoload :Flash autoload :PublicExceptions autoload :Reloader autoload :RemoteIp + autoload :ServerTiming autoload :ShowExceptions autoload :SSL autoload :Static end + autoload :Constants autoload :Journey autoload :MiddlewareStack, "action_dispatch/middleware/stack" autoload :Routing @@ -73,17 +99,29 @@ module Http autoload :Headers autoload :MimeNegotiation autoload :Parameters - autoload :ParameterFilter - autoload :Upload autoload :UploadedFile, "action_dispatch/http/upload" autoload :URL end module Session - autoload :AbstractStore, "action_dispatch/middleware/session/abstract_store" - autoload :CookieStore, "action_dispatch/middleware/session/cookie_store" - autoload :MemCacheStore, "action_dispatch/middleware/session/mem_cache_store" - autoload :CacheStore, "action_dispatch/middleware/session/cache_store" + autoload :AbstractStore, "action_dispatch/middleware/session/abstract_store" + autoload :AbstractSecureStore, "action_dispatch/middleware/session/abstract_store" + autoload :CookieStore, "action_dispatch/middleware/session/cookie_store" + autoload :MemCacheStore, "action_dispatch/middleware/session/mem_cache_store" + autoload :CacheStore, "action_dispatch/middleware/session/cache_store" + + def self.resolve_store(session_store) # :nodoc: + self.const_get(session_store.to_s.camelize) + rescue NameError + raise <<~ERROR + Unable to resolve session store #{session_store.inspect}. + + #{session_store.inspect} resolves to ActionDispatch::Session::#{session_store.to_s.camelize}, + but that class is undefined. + + Is #{session_store.inspect} spelled correctly, and are any necessary gems installed? + ERROR + end end mattr_accessor :test_app @@ -97,11 +135,27 @@ module Session autoload :TestResponse autoload :AssertionResponse end + + 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! + end end autoload :Mime, "action_dispatch/http/mime_type" ActiveSupport.on_load(:action_view) do ActionView::Base.default_formats ||= Mime::SET.symbols - ActionView::Template::Types.delegate_to Mime + ActionView::Template.mime_types_implementation = Mime + ActionView::LookupContext::DetailsKey.clear end diff --git a/actionpack/lib/action_dispatch/constants.rb b/actionpack/lib/action_dispatch/constants.rb new file mode 100644 index 0000000000000..325040400305e --- /dev/null +++ b/actionpack/lib/action_dispatch/constants.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "rack/version" + +module ActionDispatch + module Constants + # Response Header keys for Rack 2.x and 3.x + if Gem::Version.new(Rack::RELEASE) < Gem::Version.new("3") + VARY = "Vary" + CONTENT_ENCODING = "Content-Encoding" + CONTENT_SECURITY_POLICY = "Content-Security-Policy" + CONTENT_SECURITY_POLICY_REPORT_ONLY = "Content-Security-Policy-Report-Only" + LOCATION = "Location" + FEATURE_POLICY = "Feature-Policy" + X_REQUEST_ID = "X-Request-Id" + X_CASCADE = "X-Cascade" + SERVER_TIMING = "Server-Timing" + STRICT_TRANSPORT_SECURITY = "Strict-Transport-Security" + else + VARY = "vary" + CONTENT_ENCODING = "content-encoding" + CONTENT_SECURITY_POLICY = "content-security-policy" + CONTENT_SECURITY_POLICY_REPORT_ONLY = "content-security-policy-report-only" + LOCATION = "location" + FEATURE_POLICY = "feature-policy" + X_REQUEST_ID = "x-request-id" + X_CASCADE = "x-cascade" + 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/deprecator.rb b/actionpack/lib/action_dispatch/deprecator.rb new file mode 100644 index 0000000000000..453ad29463565 --- /dev/null +++ b/actionpack/lib/action_dispatch/deprecator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionDispatch + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end diff --git a/actionpack/lib/action_dispatch/http/cache.rb b/actionpack/lib/action_dispatch/http/cache.rb index 985e0fb97215b..1486309d85edd 100644 --- a/actionpack/lib/action_dispatch/http/cache.rb +++ b/actionpack/lib/action_dispatch/http/cache.rb @@ -1,9 +1,15 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Http module Cache module Request - HTTP_IF_MODIFIED_SINCE = "HTTP_IF_MODIFIED_SINCE".freeze - HTTP_IF_NONE_MATCH = "HTTP_IF_NONE_MATCH".freeze + HTTP_IF_MODIFIED_SINCE = "HTTP_IF_MODIFIED_SINCE" + HTTP_IF_NONE_MATCH = "HTTP_IF_NONE_MATCH" + + mattr_accessor :strict_freshness, default: false def if_modified_since if since = get_header(HTTP_IF_MODIFIED_SINCE) @@ -16,7 +22,7 @@ def if_none_match end def if_none_match_etags - if_none_match ? if_none_match.split(/\s*,\s*/) : [] + if_none_match ? if_none_match.split(",").each(&:strip!) : [] end def not_modified?(modified_at) @@ -30,19 +36,140 @@ def etag_matches?(etag) end end - # Check response freshness (Last-Modified and ETag) against request - # If-Modified-Since and If-None-Match conditions. If both headers are - # supplied, both must match, or the request is not considered fresh. + # Check response freshness (`Last-Modified` and `ETag`) against request + # `If-Modified-Since` and `If-None-Match` conditions. + # If both headers are supplied, based on configuration, either `ETag` is preferred over `Last-Modified` + # or both are considered equally. You can adjust the preference with + # `config.action_dispatch.strict_freshness`. + # Reference: http://tools.ietf.org/html/rfc7232#section-6 def fresh?(response) - last_modified = if_modified_since - etag = if_none_match + if Request.strict_freshness + if if_none_match + etag_matches?(response.etag) + elsif if_modified_since + not_modified?(response.last_modified) + else + false + end + else + last_modified = if_modified_since + etag = if_none_match - return false unless last_modified || etag + return false unless last_modified || etag - success = true - success &&= not_modified?(response.last_modified) if last_modified - success &&= etag_matches?(response.etag) if etag - success + success = true + success &&= not_modified?(response.last_modified) if last_modified + success &&= etag_matches?(response.etag) if etag + 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 @@ -77,25 +204,24 @@ def date=(utc_time) set_header DATE, utc_time.httpdate end - # This method sets a weak ETag validator on the response so browsers - # and proxies may cache the response, keyed on the ETag. On subsequent - # requests, the If-None-Match header is set to the cached ETag. If it - # matches the current ETag, we can return a 304 Not Modified response - # with no body, letting the browser or proxy know that their cache is - # current. Big savings in request time and network bandwidth. + # This method sets a weak ETag validator on the response so browsers and proxies + # may cache the response, keyed on the ETag. On subsequent requests, the + # `If-None-Match` header is set to the cached ETag. If it matches the current + # ETag, we can return a `304 Not Modified` response with no body, letting the + # browser or proxy know that their cache is current. Big savings in request time + # and network bandwidth. # - # Weak ETags are considered to be semantically equivalent but not - # byte-for-byte identical. This is perfect for browser caching of HTML - # pages where we don't care about exact equality, just what the user - # is viewing. + # Weak ETags are considered to be semantically equivalent but not byte-for-byte + # identical. This is perfect for browser caching of HTML pages where we don't + # care about exact equality, just what the user is viewing. # - # Strong ETags are considered byte-for-byte identical. They allow a - # browser or proxy cache to support Range requests, useful for paging - # through a PDF file or scrubbing through a video. Some CDNs only - # support strong ETags and will ignore weak ETags entirely. + # Strong ETags are considered byte-for-byte identical. They allow a browser or + # proxy cache to support `Range` requests, useful for paging through a PDF file + # or scrubbing through a video. Some CDNs only support strong ETags and will + # ignore weak ETags entirely. # - # Weak ETags are what we almost always need, so they're the default. - # Check out `#strong_etag=` to provide a strong ETag validator. + # Weak ETags are what we almost always need, so they're the default. Check out + # #strong_etag= to provide a strong ETag validator. def etag=(weak_validators) self.weak_etag = weak_validators end @@ -110,47 +236,45 @@ def strong_etag=(strong_validators) def etag?; etag; end - # True if an ETag is set and it's a weak validator (preceded with W/) + # True if an ETag is set, and it's a weak validator (preceded with `W/`). def weak_etag? - etag? && etag.starts_with?('W/"') + etag? && etag.start_with?('W/"') end - # True if an ETag is set and it isn't a weak validator (not preceded with W/) + # True if an ETag is set, and it isn't a weak validator (not preceded with + # `W/`). def strong_etag? etag? && !weak_etag? end private - - DATE = "Date".freeze - LAST_MODIFIED = "Last-Modified".freeze - SPECIAL_KEYS = Set.new(%w[extras no-cache max-age public private must-revalidate]) + DATE = "Date" + LAST_MODIFIED = "Last-Modified" + 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)}" end def generate_strong_etag(validators) - %("#{Digest::MD5.hexdigest(ActiveSupport::Cache.expand_cache_key(validators))}") + %("#{ActiveSupport::Digest.hexdigest(ActiveSupport::Cache.expand_cache_key(validators))}") end def cache_control_segments if cache_control = _cache_control cache_control.delete(" ").split(",") - else - [] end end def cache_control_headers cache_control = {} - cache_control_segments.each do |segment| + cache_control_segments&.each do |segment| directive, argument = segment.split("=", 2) if SPECIAL_KEYS.include? directive - key = directive.tr("-", "_") - cache_control[key.to_sym] = argument || true + directive.tr!("-", "_") + cache_control[directive.to_sym] = argument || true else cache_control[:extras] ||= [] cache_control[:extras] << segment @@ -164,49 +288,70 @@ def prepare_cache_control! @cache_control = cache_control_headers end + DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate" + NO_STORE = "no-store" + NO_CACHE = "no-cache" + PUBLIC = "public" + PRIVATE = "private" + MUST_REVALIDATE = "must-revalidate" + IMMUTABLE = "immutable" + MUST_UNDERSTAND = "must-understand" + def handle_conditional_get! - if etag? || last_modified? || !@cache_control.empty? - set_conditional_cache_control!(@cache_control) + # Normally default cache control setting is handled by ETag middleware. But, if + # an etag is already set, the middleware defaults to `no-cache` unless a default + # `Cache-Control` value is previously set. So, set a default one here. + if (etag? || last_modified?) && !self._cache_control + self._cache_control = DEFAULT_CACHE_CONTROL end end - DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate".freeze - NO_CACHE = "no-cache".freeze - PUBLIC = "public".freeze - PRIVATE = "private".freeze - MUST_REVALIDATE = "must-revalidate".freeze + def merge_and_normalize_cache_control!(cache_control) + control = cache_control_headers + + return if control.empty? && cache_control.empty? # Let middleware handle default behavior + + if cache_control.any? + # Any caching directive coming from a controller overrides no-cache/no-store in + # the default Cache-Control header. + control.delete(:no_cache) + control.delete(:no_store) + + if extras = control.delete(:extras) + cache_control[:extras] ||= [] + cache_control[:extras] += extras + cache_control[:extras].uniq! + end - def set_conditional_cache_control!(cache_control) - control = {} - cc_headers = cache_control_headers - if extras = cc_headers.delete(:extras) - cache_control[:extras] ||= [] - cache_control[:extras] += extras - cache_control[:extras].uniq! + control.merge! cache_control end - control.merge! cc_headers - control.merge! cache_control + options = [] - if control.empty? - self._cache_control = DEFAULT_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] - self._cache_control = NO_CACHE - if control[:extras] - self._cache_control = _cache_control + ", #{control[:extras].join(', ')}" - end + options << PUBLIC if control[:public] + options << NO_CACHE + options.concat(control[:extras]) if control[:extras] else - extras = control[:extras] + extras = control[:extras] max_age = control[:max_age] + stale_while_revalidate = control[:stale_while_revalidate] + stale_if_error = control[:stale_if_error] - options = [] options << "max-age=#{max_age.to_i}" if max_age options << (control[:public] ? PUBLIC : PRIVATE) options << MUST_REVALIDATE if control[:must_revalidate] + options << "stale-while-revalidate=#{stale_while_revalidate.to_i}" if stale_while_revalidate + options << "stale-if-error=#{stale_if_error.to_i}" if stale_if_error + options << IMMUTABLE if control[:immutable] options.concat(extras) if extras - - self._cache_control = options.join(", ") end + + self._cache_control = options.join(", ") end end end diff --git a/actionpack/lib/action_dispatch/http/content_disposition.rb b/actionpack/lib/action_dispatch/http/content_disposition.rb new file mode 100644 index 0000000000000..da5ce42b55977 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/content_disposition.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionDispatch + module Http + class ContentDisposition # :nodoc: + def self.format(disposition:, filename:) + new(disposition: disposition, filename: filename).to_s + end + + attr_reader :disposition, :filename + + def initialize(disposition:, filename:) + @disposition = disposition + @filename = filename + end + + TRADITIONAL_ESCAPED_CHAR = /[^ A-Za-z0-9!\#$+.^_`|~-]/ + + def ascii_filename + 'filename="' + percent_escape(I18n.transliterate(filename), TRADITIONAL_ESCAPED_CHAR) + '"' + end + + RFC_5987_ESCAPED_CHAR = /[^A-Za-z0-9!\#$&+.^_`|~-]/ + + def utf8_filename + "filename*=UTF-8''" + percent_escape(filename, RFC_5987_ESCAPED_CHAR) + end + + def to_s + if filename + "#{disposition}; #{ascii_filename}; #{utf8_filename}" + else + "#{disposition}" + end + end + + private + def percent_escape(string, pattern) + string.gsub(pattern) do |char| + char.bytes.map { |byte| "%%%02X" % byte }.join + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/content_security_policy.rb b/actionpack/lib/action_dispatch/http/content_security_policy.rb new file mode 100644 index 0000000000000..2c21ff9a4120f --- /dev/null +++ b/actionpack/lib/action_dispatch/http/content_security_policy.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/object/deep_dup" +require "active_support/core_ext/array/wrap" + +module ActionDispatch # :nodoc: + # # Action Dispatch Content Security Policy + # + # Configures the HTTP [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) + # response header to help protect against XSS and + # injection attacks. + # + # Example global policy: + # + # Rails.application.config.content_security_policy do |policy| + # policy.default_src :self, :https + # policy.font_src :self, :https, :data + # policy.img_src :self, :https, :data + # policy.object_src :none + # policy.script_src :self, :https + # policy.style_src :self, :https + # + # # Specify URI for violation reports + # policy.report_uri "/csp-violation-report-endpoint" + # end + class ContentSecurityPolicy + class InvalidDirectiveError < StandardError + end + + class Middleware + def initialize(app) + @app = app + end + + def call(env) + status, headers, _ = response = @app.call(env) + + # Returning CSP headers with a 304 Not Modified is harmful, since nonces in the + # new CSP headers might not match nonces in the cached HTML. + return response if status == 304 + + return response if policy_present?(headers) + + request = ActionDispatch::Request.new env + + if policy = request.content_security_policy + nonce = request.content_security_policy_nonce + nonce_directives = request.content_security_policy_nonce_directives + context = request.controller_instance || request + headers[header_name(request)] = policy.build(context, nonce, nonce_directives) + end + + response + end + + private + def header_name(request) + if request.content_security_policy_report_only + ActionDispatch::Constants::CONTENT_SECURITY_POLICY_REPORT_ONLY + else + ActionDispatch::Constants::CONTENT_SECURITY_POLICY + end + end + + def policy_present?(headers) + headers[ActionDispatch::Constants::CONTENT_SECURITY_POLICY] || + headers[ActionDispatch::Constants::CONTENT_SECURITY_POLICY_REPORT_ONLY] + end + end + + module Request + POLICY = "action_dispatch.content_security_policy" + POLICY_REPORT_ONLY = "action_dispatch.content_security_policy_report_only" + NONCE_GENERATOR = "action_dispatch.content_security_policy_nonce_generator" + NONCE = "action_dispatch.content_security_policy_nonce" + NONCE_DIRECTIVES = "action_dispatch.content_security_policy_nonce_directives" + + def content_security_policy + get_header(POLICY) + end + + def content_security_policy=(policy) + set_header(POLICY, policy) + end + + def content_security_policy_report_only + get_header(POLICY_REPORT_ONLY) + end + + def content_security_policy_report_only=(value) + set_header(POLICY_REPORT_ONLY, value) + end + + def content_security_policy_nonce_generator + get_header(NONCE_GENERATOR) + end + + def content_security_policy_nonce_generator=(generator) + set_header(NONCE_GENERATOR, generator) + end + + def content_security_policy_nonce_directives + get_header(NONCE_DIRECTIVES) + end + + def content_security_policy_nonce_directives=(generator) + set_header(NONCE_DIRECTIVES, generator) + end + + def content_security_policy_nonce + if content_security_policy_nonce_generator + if nonce = get_header(NONCE) + nonce + else + set_header(NONCE, generate_content_security_policy_nonce) + end + end + end + + private + def generate_content_security_policy_nonce + content_security_policy_nonce_generator.call(self) + end + end + + MAPPINGS = { + self: "'self'", + unsafe_eval: "'unsafe-eval'", + wasm_unsafe_eval: "'wasm-unsafe-eval'", + unsafe_hashes: "'unsafe-hashes'", + unsafe_inline: "'unsafe-inline'", + none: "'none'", + http: "http:", + https: "https:", + data: "data:", + mediastream: "mediastream:", + allow_duplicates: "'allow-duplicates'", + blob: "blob:", + filesystem: "filesystem:", + report_sample: "'report-sample'", + script: "'script'", + strict_dynamic: "'strict-dynamic'", + ws: "ws:", + wss: "wss:" + }.freeze + + DIRECTIVES = { + base_uri: "base-uri", + child_src: "child-src", + connect_src: "connect-src", + default_src: "default-src", + font_src: "font-src", + form_action: "form-action", + frame_ancestors: "frame-ancestors", + frame_src: "frame-src", + img_src: "img-src", + manifest_src: "manifest-src", + media_src: "media-src", + object_src: "object-src", + prefetch_src: "prefetch-src", + require_trusted_types_for: "require-trusted-types-for", + script_src: "script-src", + script_src_attr: "script-src-attr", + script_src_elem: "script-src-elem", + style_src: "style-src", + style_src_attr: "style-src-attr", + style_src_elem: "style-src-elem", + trusted_types: "trusted-types", + 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 + + attr_reader :directives + + def initialize + @directives = {} + yield self if block_given? + end + + def initialize_copy(other) + @directives = other.directives.deep_dup + end + + DIRECTIVES.each do |name, directive| + define_method(name) do |*sources| + if sources.first + @directives[directive] = apply_mappings(sources) + else + @directives.delete(directive) + end + end + end + + # Specify whether to prevent the user agent from loading any assets over HTTP + # when the page uses HTTPS: + # + # policy.block_all_mixed_content + # + # Pass `false` to allow it again: + # + # policy.block_all_mixed_content false + # + def block_all_mixed_content(enabled = true) + if enabled + @directives["block-all-mixed-content"] = true + else + @directives.delete("block-all-mixed-content") + end + end + + # Restricts the set of plugins that can be embedded: + # + # policy.plugin_types "application/x-shockwave-flash" + # + # Leave empty to allow all plugins: + # + # policy.plugin_types + # + def plugin_types(*types) + if types.first + @directives["plugin-types"] = types + else + @directives.delete("plugin-types") + end + end + + # Enable the [report-uri](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri) + # directive. Violation reports will be sent to the + # specified URI: + # + # policy.report_uri "/csp-violation-report-endpoint" + # + def report_uri(uri) + @directives["report-uri"] = [uri] + end + + # Specify asset types for which [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) is required: + # + # policy.require_sri_for :script, :style + # + # Leave empty to not require Subresource Integrity: + # + # policy.require_sri_for + # + def require_sri_for(*types) + if types.first + @directives["require-sri-for"] = types + else + @directives.delete("require-sri-for") + end + end + + # Specify whether a [sandbox](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox) + # should be enabled for the requested resource: + # + # policy.sandbox + # + # Values can be passed as arguments: + # + # policy.sandbox "allow-scripts", "allow-modals" + # + # Pass `false` to disable the sandbox: + # + # policy.sandbox false + # + def sandbox(*values) + if values.empty? + @directives["sandbox"] = true + elsif values.first + @directives["sandbox"] = values + else + @directives.delete("sandbox") + end + end + + # Specify whether user agents should treat any assets over HTTP as HTTPS: + # + # policy.upgrade_insecure_requests + # + # Pass `false` to disable it: + # + # policy.upgrade_insecure_requests false + # + def upgrade_insecure_requests(enabled = true) + if enabled + @directives["upgrade-insecure-requests"] = true + else + @directives.delete("upgrade-insecure-requests") + end + end + + def build(context = nil, nonce = nil, nonce_directives = nil) + nonce_directives = DEFAULT_NONCE_DIRECTIVES if nonce_directives.nil? + build_directives(context, nonce, nonce_directives).compact.join("; ") + end + + private + def apply_mappings(sources) + sources.map do |source| + case source + when Symbol + apply_mapping(source) + when String + if hash_source?(source) + "'#{source}'" + else + source + end + when Proc + source + else + raise ArgumentError, "Invalid content security policy source: #{source.inspect}" + end + end + end + + def apply_mapping(source) + MAPPINGS.fetch(source) do + raise ArgumentError, "Unknown content security policy source mapping: #{source.inspect}" + end + end + + def build_directives(context, nonce, nonce_directives) + @directives.map do |directive, sources| + if sources.is_a?(Array) + if nonce && nonce_directive?(directive, nonce_directives) + "#{directive} #{build_directive(directive, sources, context).join(' ')} 'nonce-#{nonce}'" + else + "#{directive} #{build_directive(directive, sources, context).join(' ')}" + end + elsif sources + directive + else + nil + end + end + end + + def validate(directive, sources) + sources.flatten.each do |source| + if source.include?(";") || source != source.gsub(/[[:space:]]/, "") + raise InvalidDirectiveError, <<~MSG.squish + Invalid Content Security Policy #{directive}: "#{source}". + Directive values must not contain whitespace or semicolons. + Please use multiple arguments or other directive methods instead. + MSG + end + end + end + + def build_directive(directive, sources, context) + resolved_sources = sources.map { |source| resolve_source(source, context) } + + validate(directive, resolved_sources) + end + + def resolve_source(source, context) + case source + when String + source + when Symbol + source.to_s + when Proc + if context.nil? + raise RuntimeError, "Missing context for the dynamic content security policy source: #{source.inspect}" + else + resolved = context.instance_exec(&source) + apply_mappings(Array.wrap(resolved)) + end + else + raise RuntimeError, "Unexpected content security policy source: #{source.inspect}" + end + end + + 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/filter_parameters.rb b/actionpack/lib/action_dispatch/http/filter_parameters.rb index e584b84d920ee..a79a4a923d1e3 100644 --- a/actionpack/lib/action_dispatch/http/filter_parameters.rb +++ b/actionpack/lib/action_dispatch/http/filter_parameters.rb @@ -1,44 +1,41 @@ -require "action_dispatch/http/parameter_filter" +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/parameter_filter" module ActionDispatch module Http - # Allows you to specify sensitive parameters which will be replaced from - # the request log by looking in the query string of the request and all - # sub-hashes of the params hash to filter. Filtering only certain sub-keys - # from a hash is possible by using the dot notation: 'credit_card.number'. - # If a block is given, each key and value of the params hash and all - # sub-hashes is passed to it, the value or key can be replaced using - # String#replace or similar method. + # # Action Dispatch HTTP Filter Parameters # - # env["action_dispatch.parameter_filter"] = [:password] - # => replaces the value to all keys matching /password/i with "[FILTERED]" + # Allows you to specify sensitive query string and POST parameters to filter + # from the request log. # - # env["action_dispatch.parameter_filter"] = [:foo, "bar"] - # => replaces the value to all keys matching /foo|bar/i with "[FILTERED]" + # # Replaces values with "[FILTERED]" for keys that match /foo|bar/i. + # env["action_dispatch.parameter_filter"] = [:foo, "bar"] # - # env["action_dispatch.parameter_filter"] = [ "credit_card.code" ] - # => replaces { credit_card: {code: "xxxx"} } with "[FILTERED]", does not - # change { file: { code: "xxxx"} } - # - # env["action_dispatch.parameter_filter"] = -> (k, v) do - # v.reverse! if k =~ /secret/i - # end - # => reverses the value to all keys matching /secret/i + # For more information about filter behavior, see + # ActiveSupport::ParameterFilter. module FilterParameters - ENV_MATCH = [/RAW_POST_DATA/, "rack.request.form_vars"] # :nodoc: - NULL_PARAM_FILTER = ParameterFilter.new # :nodoc: - NULL_ENV_FILTER = ParameterFilter.new ENV_MATCH # :nodoc: + # :stopdoc: + ENV_MATCH = [/RAW_POST_DATA/, "rack.request.form_vars"] + NULL_PARAM_FILTER = ActiveSupport::ParameterFilter.new + NULL_ENV_FILTER = ActiveSupport::ParameterFilter.new ENV_MATCH + # :startdoc: def initialize super @filtered_parameters = nil @filtered_env = nil @filtered_path = nil + @parameter_filter = nil end # Returns a hash of parameters with all sensitive data replaced. def filtered_parameters @filtered_parameters ||= parameter_filter.filter(parameters) + rescue ActionDispatch::Http::Parameters::ParseError + @filtered_parameters = {} end # Returns a hash of request.env with all sensitive data replaced. @@ -46,19 +43,22 @@ def filtered_env @filtered_env ||= env_filter.filter(@env) end - # Reconstructed a path with all sensitive GET parameters replaced. + # Reconstructs a path with all sensitive GET parameters replaced. def filtered_path @filtered_path ||= query_string.empty? ? path : "#{path}?#{filtered_query_string}" end - private - - def parameter_filter # :doc: - parameter_filter_for fetch_header("action_dispatch.parameter_filter") { - return NULL_PARAM_FILTER - } + # Returns the `ActiveSupport::ParameterFilter` object used to filter in this + # request. + def parameter_filter + @parameter_filter ||= if has_header?("action_dispatch.parameter_filter") + parameter_filter_for get_header("action_dispatch.parameter_filter") + else + NULL_PARAM_FILTER + end end + private def env_filter # :doc: user_key = fetch_header("action_dispatch.parameter_filter") { return NULL_ENV_FILTER @@ -67,15 +67,20 @@ def env_filter # :doc: end def parameter_filter_for(filters) # :doc: - ParameterFilter.new(filters) + ActiveSupport::ParameterFilter.new(filters) end - KV_RE = "[^&;=]+" - PAIR_RE = %r{(#{KV_RE})=(#{KV_RE})} def filtered_query_string # :doc: - query_string.gsub(PAIR_RE) do |_| - parameter_filter.filter([[$1, $2]]).first.join("=") + parts = query_string.split(/([&;])/) + filtered_parts = parts.map do |part| + if part.include?("=") + key, value = part.split("=", 2) + parameter_filter.filter(key => value).first.join("=") + else + part + end end + filtered_parts.join("") end end end diff --git a/actionpack/lib/action_dispatch/http/filter_redirect.rb b/actionpack/lib/action_dispatch/http/filter_redirect.rb index fc3c44582a3af..ed9e81f7a3dd3 100644 --- a/actionpack/lib/action_dispatch/http/filter_redirect.rb +++ b/actionpack/lib/action_dispatch/http/filter_redirect.rb @@ -1,18 +1,21 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Http module FilterRedirect - FILTERED = "[FILTERED]".freeze # :nodoc: + FILTERED = "[FILTERED]" # :nodoc: def filtered_location # :nodoc: if location_filter_match? FILTERED else - location + parameter_filtered_location end end private - def location_filters if request request.get_header("action_dispatch.redirect_filter") || [] @@ -26,9 +29,28 @@ def location_filter_match? if String === filter location.include?(filter) elsif Regexp === filter - location =~ filter + location.match?(filter) + end + end + end + + def parameter_filtered_location + uri = URI.parse(location) + unless uri.query.nil? || uri.query.empty? + parts = uri.query.split(/([&;])/) + filtered_parts = parts.map do |part| + if part.include?("=") + key, value = part.split("=", 2) + request.parameter_filter.filter(key => value).first.join("=") + else + part + end end + uri.query = filtered_parts.join("") end + uri.to_s + rescue URI::Error + FILTERED end end end diff --git a/actionpack/lib/action_dispatch/http/headers.rb b/actionpack/lib/action_dispatch/http/headers.rb index 3c03976f03ab9..d703acf3047c1 100644 --- a/actionpack/lib/action_dispatch/http/headers.rb +++ b/actionpack/lib/action_dispatch/http/headers.rb @@ -1,24 +1,30 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Http + # # Action Dispatch HTTP Headers + # # Provides access to the request's HTTP headers from the environment. # - # env = { "CONTENT_TYPE" => "text/plain", "HTTP_USER_AGENT" => "curl/7.43.0" } - # headers = ActionDispatch::Http::Headers.from_hash(env) - # headers["Content-Type"] # => "text/plain" - # headers["User-Agent"] # => "curl/7.43.0" + # env = { "CONTENT_TYPE" => "text/plain", "HTTP_USER_AGENT" => "curl/7.43.0" } + # headers = ActionDispatch::Http::Headers.from_hash(env) + # headers["Content-Type"] # => "text/plain" + # headers["User-Agent"] # => "curl/7.43.0" # # Also note that when headers are mapped to CGI-like variables by the Rack # server, both dashes and underscores are converted to underscores. This # ambiguity cannot be resolved at this stage anymore. Both underscores and # dashes have to be interpreted as if they were originally sent as dashes. # - # # GET / HTTP/1.1 - # # ... - # # User-Agent: curl/7.43.0 - # # X_Custom_Header: token + # # GET / HTTP/1.1 + # # ... + # # User-Agent: curl/7.43.0 + # # X_Custom_Header: token # - # headers["X_Custom_Header"] # => nil - # headers["X-Custom-Header"] # => "token" + # headers["X_Custom_Header"] # => nil + # headers["X-Custom-Header"] # => "token" class Headers CGI_VARIABLES = Set.new(%W[ AUTH_TYPE @@ -63,7 +69,7 @@ def []=(key, value) @req.set_header env_name(key), value end - # Add a value to a multivalued header like Vary or Accept-Encoding. + # Add a value to a multivalued header like `Vary` or `Accept-Encoding`. def add(key, value) @req.add_header env_name(key), value end @@ -77,11 +83,10 @@ def key?(key) # Returns the value for the given key mapped to @env. # - # If the key is not found and an optional code block is not provided, - # raises a KeyError exception. + # If the key is not found and an optional code block is not provided, raises a + # `KeyError` exception. # - # If the code block is provided, then it will be run and - # its result returned. + # If the code block is provided, then it will be run and its result returned. def fetch(key, default = DEFAULT) @req.fetch_header(env_name(key)) do return default unless default == DEFAULT @@ -95,16 +100,15 @@ def each(&block) end # Returns a new Http::Headers instance containing the contents of - # headers_or_env and the original instance. + # `headers_or_env` and the original instance. def merge(headers_or_env) headers = @req.dup.headers headers.merge!(headers_or_env) headers end - # Adds the contents of headers_or_env to original instance - # entries; duplicate keys are overwritten with the values from - # headers_or_env. + # Adds the contents of `headers_or_env` to original instance entries; duplicate + # keys are overwritten with the values from `headers_or_env`. def merge!(headers_or_env) headers_or_env.each do |key, value| @req.set_header env_name(key), value @@ -114,14 +118,14 @@ def merge!(headers_or_env) def env; @req.env.dup; end private - - # Converts an HTTP header name to an environment variable name if it is - # not contained within the headers hash. + # Converts an HTTP header name to an environment variable name if it is not + # contained within the headers hash. def env_name(key) key = key.to_s - if key =~ HTTP_HEADER - key = key.upcase.tr("-", "_") - key = "HTTP_" + key unless CGI_VARIABLES.include?(key) + if HTTP_HEADER.match?(key) + key = key.upcase + key.tr!("-", "_") + key.prepend("HTTP_") unless CGI_VARIABLES.include?(key) end key end diff --git a/actionpack/lib/action_dispatch/http/mime_negotiation.rb b/actionpack/lib/action_dispatch/http/mime_negotiation.rb index c4fe3a5c09fad..2947c719f17cd 100644 --- a/actionpack/lib/action_dispatch/http/mime_negotiation.rb +++ b/actionpack/lib/action_dispatch/http/mime_negotiation.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/module/attribute_accessors" module ActionDispatch @@ -5,30 +9,31 @@ module Http module MimeNegotiation extend ActiveSupport::Concern + class InvalidType < ::Mime::Type::InvalidMimeType; end + + RESCUABLE_MIME_FORMAT_ERRORS = [ + ActionController::BadRequest, + ActionDispatch::Http::Parameters::ParseError, + ] + included do - mattr_accessor :ignore_accept_header - self.ignore_accept_header = false + mattr_accessor :ignore_accept_header, default: false end - # The MIME type of the HTTP request, such as Mime[:xml]. - # - # For backward compatibility, the post \format is extracted from the - # X-Post-Data-Format HTTP header if present. + # The MIME type of the HTTP request, such as [Mime](:xml). def content_mime_type fetch_header("action_dispatch.request.content_type") do |k| - v = if get_header("CONTENT_TYPE") =~ /^([^,\;]*)/ + v = if get_header("CONTENT_TYPE") =~ /^([^,;]*)/ Mime::Type.lookup($1.strip.downcase) else nil end set_header k, v + rescue ::Mime::Type::InvalidMimeType => e + raise InvalidType, e.message end end - def content_type - content_mime_type && content_mime_type.to_s - end - def has_content_type? # :nodoc: get_header "CONTENT_TYPE" end @@ -44,31 +49,32 @@ def accepts Mime::Type.parse(header) end set_header k, v + rescue ::Mime::Type::InvalidMimeType => e + raise InvalidType, e.message end end - # Returns the MIME type for the \format used in the request. + # 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.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 + # request.format # => Mime[:html] or Mime[:js], or request.accepts.first # - def format(view_path = []) + def format(_view_path = nil) formats.first || Mime::NullType.instance end def formats fetch_header("action_dispatch.request.formats") do |k| - params_readable = begin - parameters[:format] - rescue ActionController::BadRequest - false - end - - v = if params_readable + v = if params_readable? Array(Mime[parameters[:format]]) elsif use_accept_header && valid_accept_header - accepts + accepts.dup elsif extension_format = format_from_path_extension [extension_format] elsif xhr? @@ -76,58 +82,115 @@ def formats else [Mime[:html]] end + + v.select! do |format| + format.symbol || format.ref == "*/*" + end + set_header k, v 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) - if variant.all? { |v| v.is_a?(Symbol) } + if variant.all?(Symbol) @variant = ActiveSupport::ArrayInquirer.new(variant) else - raise ArgumentError, "request.variant must be set to a Symbol or an Array of Symbols. " \ - "For security reasons, never directly set the variant to a user-provided value, " \ - "like params[:variant].to_sym. Check user-provided value against a whitelist first, " \ - "then set the variant: request.variant = :tablet if params[:variant] == 'tablet'" + raise ArgumentError, "request.variant must be set to a Symbol or an Array of Symbols." 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 - # Sets the \format by string extension, which can be used to force custom formats + # Sets the format by string extension, which can be used to force custom formats # that are not controlled by the extension. # - # class ApplicationController < ActionController::Base - # before_action :adjust_format_for_iphone + # class ApplicationController < ActionController::Base + # before_action :adjust_format_for_iphone # - # private - # def adjust_format_for_iphone - # request.format = :iphone if request.env["HTTP_USER_AGENT"][/iPhone/] - # end - # end + # private + # def adjust_format_for_iphone + # request.format = :iphone if request.env["HTTP_USER_AGENT"][/iPhone/] + # end + # end def format=(extension) parameters[:format] = extension.to_s set_header "action_dispatch.request.formats", [Mime::Type.lookup_by_extension(parameters[:format])] end - # Sets the \formats by string extensions. This differs from #format= by allowing you - # to set multiple, ordered formats, which is useful when you want to have a fallback. + # Sets the formats by string extensions. This differs from #format= by allowing + # you to set multiple, ordered formats, which is useful when you want to have a + # fallback. # - # In this example, the :iphone format will be used if it's available, otherwise it'll fallback - # to the :html format. + # In this example, the `:iphone` format will be used if it's available, + # otherwise it'll fall back to the `:html` format. # - # class ApplicationController < ActionController::Base - # before_action :adjust_format_for_iphone_with_html_fallback + # class ApplicationController < ActionController::Base + # before_action :adjust_format_for_iphone_with_html_fallback # - # private - # def adjust_format_for_iphone_with_html_fallback - # request.formats = [ :iphone, :html ] if request.env["HTTP_USER_AGENT"][/iPhone/] - # end - # end + # private + # def adjust_format_for_iphone_with_html_fallback + # request.formats = [ :iphone, :html ] if request.env["HTTP_USER_AGENT"][/iPhone/] + # end + # end def formats=(extensions) parameters[:format] = extensions.first.to_s set_header "action_dispatch.request.formats", extensions.collect { |extension| @@ -135,9 +198,7 @@ def formats=(extensions) } end - # Receives an array of mimes and return the first user sent mime that - # matches the order array. - # + # Returns the first MIME type that matches the provided array of MIME types. def negotiate_mime(order) formats.each do |priority| if priority == Mime::ALL @@ -150,20 +211,31 @@ def negotiate_mime(order) order.include?(Mime::ALL) ? format : nil end - private + def should_apply_vary_header? + !params_readable? && use_accept_header && valid_accept_header + end + private + # We use normal content negotiation unless you include **/** in your list, in + # which case we assume you're a browser and send HTML. BROWSER_LIKE_ACCEPTS = /,\s*\*\/\*|\*\/\*\s*,/ - def valid_accept_header # :doc: + def params_readable? + parameters[:format] + rescue *RESCUABLE_MIME_FORMAT_ERRORS + false + end + + def valid_accept_header (xhr? && (accept.present? || content_mime_type)) || - (accept.present? && accept !~ BROWSER_LIKE_ACCEPTS) + (accept.present? && !accept.match?(BROWSER_LIKE_ACCEPTS)) end - def use_accept_header # :doc: + def use_accept_header !self.class.ignore_accept_header end - def format_from_path_extension # :doc: + def format_from_path_extension path = get_header("action_dispatch.original_path") || get_header("PATH_INFO") if match = path && path.match(/\.(\w+)\z/) Mime[match.captures.first] diff --git a/actionpack/lib/action_dispatch/http/mime_type.rb b/actionpack/lib/action_dispatch/http/mime_type.rb index 1583a8f87f364..e91d2b1371814 100644 --- a/actionpack/lib/action_dispatch/http/mime_type.rb +++ b/actionpack/lib/action_dispatch/http/mime_type.rb @@ -1,32 +1,45 @@ -# -*- frozen-string-literal: true -*- +# frozen_string_literal: true + +# :markup: markdown require "singleton" -require "active_support/core_ext/string/starts_ends_with" module Mime class Mimes + attr_reader :symbols + include Enumerable def initialize @mimes = [] - @symbols = nil + @symbols = [] + @symbols_set = Set.new end - def each - @mimes.each { |x| yield x } + def each(&block) + @mimes.each(&block) end def <<(type) @mimes << type - @symbols = nil + sym_type = type.to_sym + @symbols << sym_type + @symbols_set << sym_type end def delete_if - @mimes.delete_if { |x| yield x }.tap { @symbols = nil } + @mimes.delete_if do |x| + if yield x + sym_type = x.to_sym + @symbols.delete(sym_type) + @symbols_set.delete(sym_type) + true + end + end end - def symbols - @symbols ||= map(&:to_sym) + def valid_symbols?(symbols) # :nodoc + symbols.all? { |s| @symbols_set.include?(s) } end end @@ -40,39 +53,48 @@ def [](type) Type.lookup_by_extension(type) end - def fetch(type) + def symbols + SET.symbols + end + + def valid_symbols?(symbols) # :nodoc: + SET.valid_symbols?(symbols) + end + + def fetch(type, &block) return type if type.is_a?(Type) - EXTENSION_LOOKUP.fetch(type.to_s) { |k| yield k } + EXTENSION_LOOKUP.fetch(type.to_s, &block) end end - # Encapsulates the notion of a mime type. Can be used at render time, for example, with: + # Encapsulates the notion of a MIME type. Can be used at render time, for + # example, with: # - # class PostsController < ActionController::Base - # def show - # @post = Post.find(params[:id]) + # class PostsController < ActionController::Base + # def show + # @post = Post.find(params[:id]) # - # respond_to do |format| - # format.html - # format.ics { render body: @post.to_ics, mime_type: Mime::Type.lookup("text/calendar") } - # format.xml { render xml: @post } + # respond_to do |format| + # format.html + # format.ics { render body: @post.to_ics, mime_type: Mime::Type.lookup("text/calendar") } + # format.xml { render xml: @post } + # end # end # end - # end class Type attr_reader :symbol @register_callbacks = [] - # A simple helper class used in parsing the accept header - class AcceptItem #:nodoc: + # A simple helper class used in parsing the accept header. + class AcceptItem # :nodoc: attr_accessor :index, :name, :q alias :to_s :name def initialize(index, name, q = nil) @index = index @name = name - q ||= 0.0 if @name == "*/*".freeze # default wildcard match to end of list + q ||= 0.0 if @name == "*/*" # Default wildcard match to end of list. @q = ((q || 1.0).to_f * 100).to_i end @@ -83,29 +105,29 @@ def <=>(item) end end - class AcceptList #:nodoc: + class AcceptList # :nodoc: def self.sort!(list) list.sort! text_xml_idx = find_item_by_name list, "text/xml" app_xml_idx = find_item_by_name list, Mime[:xml].to_s - # Take care of the broken text/xml entry by renaming or deleting it + # Take care of the broken text/xml entry by renaming or deleting it. if text_xml_idx && app_xml_idx app_xml = list[app_xml_idx] text_xml = list[text_xml_idx] - app_xml.q = [text_xml.q, app_xml.q].max # set the q value to the max of the two - if app_xml_idx > text_xml_idx # make sure app_xml is ahead of text_xml in the list + app_xml.q = [text_xml.q, app_xml.q].max # Set the q value to the max of the two. + if app_xml_idx > text_xml_idx # Make sure app_xml is ahead of text_xml in the list. list[app_xml_idx], list[text_xml_idx] = text_xml, app_xml app_xml_idx, text_xml_idx = text_xml_idx, app_xml_idx end - list.delete_at(text_xml_idx) # delete text_xml from the list + list.delete_at(text_xml_idx) # Delete text_xml from the list. elsif text_xml_idx list[text_xml_idx].name = Mime[:xml].to_s end - # Look for more specific XML-based types and sort them ahead of app/xml + # Look for more specific XML-based types and sort them ahead of app/xml. if app_xml_idx app_xml = list[app_xml_idx] idx = app_xml_idx @@ -114,7 +136,7 @@ def self.sort!(list) type = list[idx] break if type.q < app_xml.q - if type.name.ends_with? "+xml" + if type.name.end_with? "+xml" list[app_xml_idx], list[idx] = list[idx], app_xml app_xml_idx = idx end @@ -133,13 +155,20 @@ def self.find_item_by_name(array, name) class << self TRAILING_STAR_REGEXP = /^(text|application)\/\*/ - PARAMETER_SEPARATOR_REGEXP = /;\s*\w+="?\w+"?/ + # all media-type parameters need to be before the q-parameter + # https://www.rfc-editor.org/rfc/rfc7231#section-5.3.2 + PARAMETER_SEPARATOR_REGEXP = /;\s*q="?/ + ACCEPT_HEADER_REGEXP = /[^,\s"](?:[^,"]|"[^"]*")*/ def register_callback(&block) @register_callbacks << block end def lookup(string) + return LOOKUP[string] if LOOKUP.key?(string) + + # fallback to the media-type without parameters if it was not found + string = string.split(";", 2)[0]&.rstrip LOOKUP[string] || Type.new(string) end @@ -147,8 +176,9 @@ def lookup_by_extension(extension) EXTENSION_LOOKUP[extension.to_s] end - # Registers an alias that's not used on mime type lookup, but can be referenced directly. Especially useful for - # rendering different HTML versions depending on the user agent, like an iPhone. + # Registers an alias that's not used on MIME type lookup, but can be referenced + # directly. Especially useful for rendering different HTML versions depending on + # the user agent, like an iPhone. def register_alias(string, symbol, extension_synonyms = []) register(string, symbol, [], extension_synonyms, true) end @@ -169,11 +199,14 @@ def register(string, symbol, mime_type_synonyms = [], extension_synonyms = [], s def parse(accept_header) if !accept_header.include?(",") - accept_header = accept_header.split(PARAMETER_SEPARATOR_REGEXP).first - parse_trailing_star(accept_header) || [Mime::Type.lookup(accept_header)].compact + if (index = accept_header.index(PARAMETER_SEPARATOR_REGEXP)) + accept_header = accept_header[0, index].strip + end + return [] if accept_header.blank? + parse_trailing_star(accept_header) || Array(Mime::Type.lookup(accept_header)) else list, index = [], 0 - accept_header.split(",").each do |header| + accept_header.scan(ACCEPT_HEADER_REGEXP).each do |header| params, q = header.split(PARAMETER_SEPARATOR_REGEXP) next unless params @@ -195,20 +228,20 @@ def parse_trailing_star(accept_header) parse_data_with_trailing_star($1) if accept_header =~ TRAILING_STAR_REGEXP end - # For an input of 'text', returns [Mime[:json], Mime[:xml], Mime[:ics], - # Mime[:html], Mime[:css], Mime[:csv], Mime[:js], Mime[:yaml], Mime[:text]. + # For an input of `'text'`, returns `[Mime[:json], Mime[:xml], Mime[:ics], + # Mime[:html], Mime[:css], Mime[:csv], Mime[:js], Mime[:yaml], Mime[:text]]`. # - # For an input of 'application', returns [Mime[:html], Mime[:js], - # Mime[:xml], Mime[:yaml], Mime[:atom], Mime[:json], Mime[:rss], Mime[:url_encoded_form]. + # For an input of `'application'`, returns `[Mime[:html], Mime[:js], Mime[:xml], + # Mime[:yaml], Mime[:atom], Mime[:json], Mime[:rss], Mime[:url_encoded_form]]`. def parse_data_with_trailing_star(type) - Mime::SET.select { |m| m =~ type } + Mime::SET.select { |m| m.match?(type) } end # This method is opposite of register method. # # To unregister a MIME type: # - # Mime::Type.unregister(:mobile) + # Mime::Type.unregister(:mobile) def unregister(symbol) symbol = symbol.downcase if mime = Mime[symbol] @@ -221,7 +254,17 @@ def unregister(symbol) attr_reader :hash + MIME_NAME = "[a-zA-Z0-9][a-zA-Z0-9#{Regexp.escape('!#$&-^_.+')}]{0,126}" + MIME_PARAMETER_VALUE = "(?:#{MIME_NAME}|\"[^\"\r\\\\]*\")" + MIME_PARAMETER = "\s*;\s*#{MIME_NAME}(?:=#{MIME_PARAMETER_VALUE})?" + MIME_REGEXP = /\A(?:\*\/\*|#{MIME_NAME}\/(?:\*|#{MIME_NAME})(?>#{MIME_PARAMETER})*\s*)\z/ + + class InvalidMimeType < StandardError; end + def initialize(string, symbol = nil, synonyms = []) + unless MIME_REGEXP.match?(string) + raise InvalidMimeType, "#{string.inspect} is not a valid MIME type" + end @symbol, @synonyms = symbol, synonyms @string = string @hash = [@string, @synonyms, @symbol].hash @@ -271,25 +314,27 @@ def =~(mime_type) @synonyms.any? { |synonym| synonym.to_s =~ regexp } || @string =~ regexp end + def match?(mime_type) + return false unless mime_type + regexp = Regexp.new(Regexp.quote(mime_type.to_s)) + @synonyms.any? { |synonym| synonym.to_s.match?(regexp) } || @string.match?(regexp) + end + def html? - symbol == :html || @string =~ /html/ + (symbol == :html) || @string.include?("html") end def all?; false; end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. protected - attr_reader :string, :synonyms private - def to_ary; end def to_a; end - def method_missing(method, *args) - if method.to_s.ends_with? "?" + def method_missing(method, ...) + if method.end_with?("?") method[0..-2].downcase.to_sym == to_sym else super @@ -297,7 +342,7 @@ def method_missing(method, *args) end def respond_to_missing?(method, include_private = false) - (method.to_s.ends_with? "?") || super + method.end_with?("?") || super end end @@ -305,16 +350,16 @@ class AllType < Type include Singleton def initialize - super "*/*", :all + super "*/*", nil end def all?; true; end def html?; true; end end - # ALL isn't a real MIME type, so we don't register it for lookup with the - # other concrete types. It's a wildcard match that we use for `respond_to` - # negotiation internals. + # ALL isn't a real MIME type, so we don't register it for lookup with the other + # concrete types. It's a wildcard match that we use for `respond_to` negotiation + # internals. ALL = AllType.instance class NullType @@ -324,15 +369,19 @@ def nil? true end - def ref; end - - def respond_to_missing?(method, include_private = false) - method.to_s.ends_with? "?" + def to_s + "" end + def ref; end + private - def method_missing(method, *args) - false if method.to_s.ends_with? "?" + def respond_to_missing?(method, _) + method.end_with?("?") + end + + def method_missing(method, ...) + false if method.end_with?("?") end end end diff --git a/actionpack/lib/action_dispatch/http/mime_types.rb b/actionpack/lib/action_dispatch/http/mime_types.rb index 8b04174f1feb6..f03c7e1981846 100644 --- a/actionpack/lib/action_dispatch/http/mime_types.rb +++ b/actionpack/lib/action_dispatch/http/mime_types.rb @@ -1,5 +1,9 @@ +# frozen_string_literal: true + # Build list of Mime types for HTTP responses -# http://www.iana.org/assignments/media-types/ +# https://www.iana.org/assignments/media-types/ + +# :markup: markdown Mime::Type.register "text/html", :html, %w( application/xhtml+xml ), %w( xhtml ) Mime::Type.register "text/plain", :text, [], %w(txt) @@ -8,16 +12,31 @@ Mime::Type.register "text/calendar", :ics 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) Mime::Type.register "image/gif", :gif, [], %w(gif) Mime::Type.register "image/bmp", :bmp, [], %w(bmp) Mime::Type.register "image/tiff", :tiff, [], %w(tif tiff) -Mime::Type.register "image/svg+xml", :svg +Mime::Type.register "image/svg+xml", :svg, [], %w(svg) +Mime::Type.register "image/webp", :webp, [], %w(webp) Mime::Type.register "video/mpeg", :mpeg, [], %w(mpg mpeg mpe) +Mime::Type.register "audio/mpeg", :mp3, [], %w(mp1 mp2 mp3) +Mime::Type.register "audio/ogg", :ogg, [], %w(oga ogg spx opus) +Mime::Type.register "audio/aac", :m4a, %w( audio/mp4 ), %w(m4a mpg4 aac) + +Mime::Type.register "video/webm", :webm, [], %w(webm) +Mime::Type.register "video/mp4", :mp4, [], %w(mp4 m4v) + +Mime::Type.register "font/otf", :otf, [], %w(otf) +Mime::Type.register "font/ttf", :ttf, [], %w(ttf) +Mime::Type.register "font/woff", :woff, [], %w(woff) +Mime::Type.register "font/woff2", :woff2, [], %w(woff2) + Mime::Type.register "application/xml", :xml, %w( text/xml application/x-xml ) Mime::Type.register "application/rss+xml", :rss Mime::Type.register "application/atom+xml", :atom @@ -26,9 +45,10 @@ Mime::Type.register "multipart/form-data", :multipart_form Mime::Type.register "application/x-www-form-urlencoded", :url_encoded_form -# http://www.ietf.org/rfc/rfc4627.txt +# https://www.ietf.org/rfc/rfc4627.txt # http://www.json.org/JSONRequest.html -Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest ) +# https://www.ietf.org/rfc/rfc7807.txt +Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest application/problem+json ) Mime::Type.register "application/pdf", :pdf, [], %w(pdf) Mime::Type.register "application/zip", :zip, [], %w(zip) diff --git a/actionpack/lib/action_dispatch/http/param_builder.rb b/actionpack/lib/action_dispatch/http/param_builder.rb new file mode 100644 index 0000000000000..6e8a9e1281293 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/param_builder.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +module ActionDispatch + class ParamBuilder + # -- + # This implementation is based on Rack::QueryParser, + # Copyright (C) 2007-2021 Leah Neukirchen + + def self.make_default(param_depth_limit) + new param_depth_limit + end + + attr_reader :param_depth_limit + + def initialize(param_depth_limit) + @param_depth_limit = param_depth_limit + end + + 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) + from_pairs QueryParser.each_pair(qs, separator), encoding_template: encoding_template + end + + def from_pairs(pairs, encoding_template: nil) + params = make_params + + pairs.each do |k, v| + if Hash === v + v = ActionDispatch::Http::UploadedFile.new(v) + end + + store_nested_param(params, k, v, 0, encoding_template) + end + + params + rescue ArgumentError => e + raise InvalidParameterError, e.message, e.backtrace + end + + def from_hash(hash, encoding_template: nil) + # Force encodings from encoding template + hash = Request::Utils::CustomParamEncoder.encode_for_template(hash, encoding_template) + + # Assert valid encoding + Request::Utils.check_param_encoding(hash) + + # Convert hashes to HWIA (or UploadedFile), and deep-munge nils + # out of arrays + hash = Request::Utils.normalize_encode_params(hash) + + hash + end + + private + def store_nested_param(params, name, v, depth, encoding_template = nil) + raise ParamsTooDeepError if depth >= param_depth_limit + + if !name + # nil name, treat same as empty string (required by tests) + k = after = "" + elsif depth == 0 + # 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 + elsif name.start_with?("[]") + # Array nesting + k = "[]" + after = name[2, name.length] + elsif name.start_with?("[") && (start = name.index("]", 1)) + # Hash nesting, use the part inside brackets as the key + k = name[1, start - 1] + after = name[start + 1, name.length] + else + # Probably malformed input, nested but not starting with [ + # treat full name as key for backwards compatibility. + k = name + after = "" + end + + 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 + if encoding_template && (designated_encoding = encoding_template[k]) && !v.frozen? + v.force_encoding(designated_encoding) + end + + # ... and we can't validate the encoding until after we've + # applied any template override + unless v.valid_encoding? + raise InvalidParameterError, "Invalid encoding for parameter: #{v.scrub}" + end + end + + if after == "" + if k == "[]" && depth != 0 + return (v || !ActionDispatch::Request::Utils.perform_deep_munge) ? [v] : [] + else + params[k] = v + end + elsif after == "[" + params[name] = v + elsif after == "[]" + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + params[k] << v if v || !ActionDispatch::Request::Utils.perform_deep_munge + elsif after.start_with?("[]") + # Recognize x[][y] (hash inside array) parameters + unless after[2] == "[" && after.end_with?("]") && (child_key = after[3, after.length - 4]) && !child_key.empty? && !child_key.index("[") && !child_key.index("]") + # Handle other nested array parameters + child_key = after[2, after.length] + end + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key) + store_nested_param(params[k].last, child_key, v, depth + 1) + else + params[k] << store_nested_param(make_params, child_key, v, depth + 1) + end + else + params[k] ||= make_params + raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) + params[k] = store_nested_param(params[k], after, v, depth + 1) + end + + params + end + + def make_params + ActiveSupport::HashWithIndifferentAccess.new + end + + def new_depth_limit(param_depth_limit) + self.class.new @params_class, param_depth_limit + end + + def params_hash_type?(obj) + Hash === obj + end + + def params_hash_has_key?(hash, key) + return false if key.include?("[]") + + key.split(/[\[\]]+/).inject(hash) do |h, part| + next h if part == "" + return false unless params_hash_type?(h) && h.key?(part) + h[part] + end + + true + end + end +end diff --git a/actionpack/lib/action_dispatch/http/param_error.rb b/actionpack/lib/action_dispatch/http/param_error.rb new file mode 100644 index 0000000000000..4bf8a1af60e67 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/param_error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActionDispatch + class ParamError < ActionDispatch::Http::Parameters::ParseError + def initialize(message = nil) + super + end + + def self.===(other) + super || ( + defined?(Rack::Utils::ParameterTypeError) && Rack::Utils::ParameterTypeError === other || + defined?(Rack::Utils::InvalidParameterError) && Rack::Utils::InvalidParameterError === other || + defined?(Rack::QueryParser::ParamsTooDeepError) && Rack::QueryParser::ParamsTooDeepError === other + ) + end + end + + class ParameterTypeError < ParamError + end + + class InvalidParameterError < ParamError + end + + class ParamsTooDeepError < ParamError + end +end diff --git a/actionpack/lib/action_dispatch/http/parameter_filter.rb b/actionpack/lib/action_dispatch/http/parameter_filter.rb deleted file mode 100644 index 889f55a52a07e..0000000000000 --- a/actionpack/lib/action_dispatch/http/parameter_filter.rb +++ /dev/null @@ -1,84 +0,0 @@ -require "active_support/core_ext/object/duplicable" - -module ActionDispatch - module Http - class ParameterFilter - FILTERED = "[FILTERED]".freeze # :nodoc: - - def initialize(filters = []) - @filters = filters - end - - def filter(params) - compiled_filter.call(params) - end - - private - - def compiled_filter - @compiled_filter ||= CompiledFilter.compile(@filters) - end - - class CompiledFilter # :nodoc: - def self.compile(filters) - return lambda { |params| params.dup } if filters.empty? - - strings, regexps, blocks = [], [], [] - - filters.each do |item| - case item - when Proc - blocks << item - when Regexp - regexps << item - else - strings << Regexp.escape(item.to_s) - end - end - - deep_regexps, regexps = regexps.partition { |r| r.to_s.include?("\\.".freeze) } - deep_strings, strings = strings.partition { |s| s.include?("\\.".freeze) } - - regexps << Regexp.new(strings.join("|".freeze), true) unless strings.empty? - deep_regexps << Regexp.new(deep_strings.join("|".freeze), true) unless deep_strings.empty? - - new regexps, deep_regexps, blocks - end - - attr_reader :regexps, :deep_regexps, :blocks - - def initialize(regexps, deep_regexps, blocks) - @regexps = regexps - @deep_regexps = deep_regexps.any? ? deep_regexps : nil - @blocks = blocks - end - - def call(original_params, parents = []) - filtered_params = {} - - original_params.each do |key, value| - parents.push(key) if deep_regexps - if regexps.any? { |r| key =~ r } - value = FILTERED - elsif deep_regexps && (joined = parents.join(".")) && deep_regexps.any? { |r| joined =~ r } - value = FILTERED - elsif value.is_a?(Hash) - value = call(value, parents) - elsif value.is_a?(Array) - value = value.map { |v| v.is_a?(Hash) ? call(v, parents) : v } - elsif blocks.any? - key = key.dup if key.duplicable? - value = value.dup if value.duplicable? - blocks.each { |b| b.call(key, value) } - end - parents.pop if deep_regexps - - filtered_params[key] = value - end - - filtered_params - end - end - end - end -end diff --git a/actionpack/lib/action_dispatch/http/parameters.rb b/actionpack/lib/action_dispatch/http/parameters.rb index 8f21eca440660..ce65885dfec1f 100644 --- a/actionpack/lib/action_dispatch/http/parameters.rb +++ b/actionpack/lib/action_dispatch/http/parameters.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Http module Parameters @@ -12,11 +16,11 @@ module Parameters } } - # Raised when raw data from the request cannot be parsed by the parser - # defined for request's content mime type. + # Raised when raw data from the request cannot be parsed by the parser defined + # for request's content MIME type. class ParseError < StandardError - def initialize - super($!.message) + def initialize(message = $!.message) + super(message) end end @@ -30,10 +34,10 @@ class << self end module ClassMethods - # Configure the parameter parser for a given mime type. + # Configure the parameter parser for a given MIME type. # - # It accepts a hash where the key is the symbol of the mime type - # and the value is a proc. + # It accepts a hash where the key is the symbol of the MIME type and the value + # is a proc. # # original_parsers = ActionDispatch::Request.parameter_parsers # xml_parser = -> (raw_post) { Hash.from_xml(raw_post) || {} } @@ -44,7 +48,7 @@ def parameter_parsers=(parsers) end end - # Returns both GET and POST \parameters in a single hash. + # Returns both GET and POST parameters in a single hash. def parameters params = get_header("action_dispatch.request.parameters") return params if params @@ -55,44 +59,33 @@ def parameters query_parameters.dup end params.merge!(path_parameters) - params = set_binary_encoding(params) set_header("action_dispatch.request.parameters", params) params end alias :params :parameters - def path_parameters=(parameters) #:nodoc: - delete_header("action_dispatch.request.parameters") + def path_parameters=(parameters) # :nodoc: + @env.delete("action_dispatch.request.parameters") - # If any of the path parameters has an invalid encoding then - # raise since it's likely to trigger errors further on. + parameters = Request::Utils.set_binary_encoding(self, parameters, parameters[:controller], parameters[:action]) + # If any of the path parameters has an invalid encoding then raise since it's + # likely to trigger errors further on. Request::Utils.check_param_encoding(parameters) - set_header PARAMETERS_KEY, parameters + @env[PARAMETERS_KEY] = parameters rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e raise ActionController::BadRequest.new("Invalid path parameters: #{e.message}") end - # Returns a hash with the \parameters used to form the \path of the request. - # Returned hash keys are strings: + # Returns a hash with the parameters used to form the path of the request. + # Returned hash keys are symbols: # - # {'action' => 'my_action', 'controller' => 'my_controller'} + # { action: "my_action", controller: "my_controller" } def path_parameters - get_header(PARAMETERS_KEY) || set_header(PARAMETERS_KEY, {}) + @env[PARAMETERS_KEY] ||= {} end private - - def set_binary_encoding(params) - action = params[:action] - if controller_class.binary_params_for?(action) - ActionDispatch::Request::Utils.each_param_value(params) do |param| - param.force_encoding ::Encoding::ASCII_8BIT - end - end - params - end - def parse_formatted_parameters(parsers) return yield if content_length.zero? || content_mime_type.nil? @@ -100,11 +93,21 @@ def parse_formatted_parameters(parsers) begin strategy.call(raw_post) - rescue # JSON or Ruby code block errors - my_logger = logger || ActiveSupport::Logger.new($stderr) - my_logger.debug "Error occurred while parsing request parameters.\nContents:\n\n#{raw_post}" + rescue # JSON or Ruby code block errors. + log_parse_error_once + raise ParseError, "Error occurred while parsing request parameters" + end + end + + def log_parse_error_once + @parse_error_logged ||= begin + parse_logger = logger || ActiveSupport::Logger.new($stderr) + parse_logger.debug <<~MSG.chomp + Error occurred while parsing request parameters. + Contents: - raise ParseError + #{raw_post} + MSG end end @@ -113,8 +116,4 @@ def params_parsers end end end - - module ParamsParser - ParseError = ActiveSupport::Deprecation::DeprecatedConstantProxy.new("ActionDispatch::ParamsParser::ParseError", "ActionDispatch::Http::Parameters::ParseError") - end end diff --git a/actionpack/lib/action_dispatch/http/permissions_policy.rb b/actionpack/lib/action_dispatch/http/permissions_policy.rb new file mode 100644 index 0000000000000..03a8738754f6e --- /dev/null +++ b/actionpack/lib/action_dispatch/http/permissions_policy.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/object/deep_dup" + +module ActionDispatch # :nodoc: + # # Action Dispatch PermissionsPolicy + # + # Configures the HTTP + # [Feature-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy) + # response header to specify which browser features the current + # document and its iframes can use. + # + # Example global policy: + # + # Rails.application.config.permissions_policy do |policy| + # policy.camera :none + # policy.gyroscope :none + # policy.microphone :none + # policy.usb :none + # policy.fullscreen :self + # policy.payment :self, "https://secure.example.com" + # end + # + # The Feature-Policy header has been renamed to Permissions-Policy. The + # Permissions-Policy requires a different implementation and isn't yet supported + # by all browsers. To avoid having to rename this middleware in the future we + # use the new name for the middleware but keep the old header name and + # implementation for now. + class PermissionsPolicy + class Middleware + def initialize(app) + @app = app + end + + def call(env) + _, headers, _ = response = @app.call(env) + + return response if policy_present?(headers) + + request = ActionDispatch::Request.new(env) + + if policy = request.permissions_policy + headers[ActionDispatch::Constants::FEATURE_POLICY] = policy.build(request.controller_instance) + end + + if policy_empty?(policy) + headers.delete(ActionDispatch::Constants::FEATURE_POLICY) + end + + response + end + + private + def policy_present?(headers) + headers[ActionDispatch::Constants::FEATURE_POLICY] + end + + def policy_empty?(policy) + policy&.directives&.empty? + end + end + + module Request + POLICY = "action_dispatch.permissions_policy" + + def permissions_policy + get_header(POLICY) + end + + def permissions_policy=(policy) + set_header(POLICY, policy) + end + end + + MAPPINGS = { + self: "'self'", + none: "'none'", + }.freeze + + # List of available permissions can be found at + # https://github.com/w3c/webappsec-permissions-policy/blob/main/features.md#policy-controlled-features + DIRECTIVES = { + accelerometer: "accelerometer", + ambient_light_sensor: "ambient-light-sensor", + autoplay: "autoplay", + camera: "camera", + display_capture: "display-capture", + encrypted_media: "encrypted-media", + fullscreen: "fullscreen", + geolocation: "geolocation", + gyroscope: "gyroscope", + hid: "hid", + idle_detection: "idle-detection", + keyboard_map: "keyboard-map", + magnetometer: "magnetometer", + microphone: "microphone", + midi: "midi", + payment: "payment", + picture_in_picture: "picture-in-picture", + screen_wake_lock: "screen-wake-lock", + serial: "serial", + sync_xhr: "sync-xhr", + usb: "usb", + web_share: "web-share", + }.freeze + + private_constant :MAPPINGS, :DIRECTIVES + + attr_reader :directives + + def initialize + @directives = {} + yield self if block_given? + end + + def initialize_copy(other) + @directives = other.directives.deep_dup + end + + DIRECTIVES.each do |name, directive| + define_method(name) do |*sources| + if sources.first + @directives[directive] = apply_mappings(sources) + else + @directives.delete(directive) + end + end + end + + def build(context = nil) + build_directives(context).compact.join("; ") + end + + private + def apply_mappings(sources) + sources.map do |source| + case source + when Symbol + apply_mapping(source) + when String, Proc + source + else + raise ArgumentError, "Invalid HTTP permissions policy source: #{source.inspect}" + end + end + end + + def apply_mapping(source) + MAPPINGS.fetch(source) do + raise ArgumentError, "Unknown HTTP permissions policy source mapping: #{source.inspect}" + end + end + + def build_directives(context) + @directives.map do |directive, sources| + if sources.is_a?(Array) + "#{directive} #{build_directive(sources, context).join(' ')}" + elsif sources + directive + else + nil + end + end + end + + def build_directive(sources, context) + sources.map { |source| resolve_source(source, context) } + end + + def resolve_source(source, context) + case source + when String + source + when Symbol + source.to_s + when Proc + if context.nil? + raise RuntimeError, "Missing context for the dynamic permissions policy source: #{source.inspect}" + else + context.instance_exec(&source) + end + else + raise RuntimeError, "Unexpected permissions policy source: #{source.inspect}" + end + end + end + + ActiveSupport.on_load(:action_dispatch_request) do + include ActionDispatch::PermissionsPolicy::Request + end +end diff --git a/actionpack/lib/action_dispatch/http/query_parser.rb b/actionpack/lib/action_dispatch/http/query_parser.rb new file mode 100644 index 0000000000000..6afdb64434fc0 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/query_parser.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "uri" +require "rack" + +module ActionDispatch + class QueryParser + DEFAULT_SEP = /& */n + COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n, "&;" => /[&;] */n } + + 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 + + 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 + # giving a nil value for keys that do not use '='. Callers that need + # the standard's interpretation can use `v.to_s`. + def self.each_pair(s, separator = nil) + return enum_for(:each_pair, s, separator) unless block_given? + + s ||= "" + + splitter = + if separator + COMMON_SEP[separator] || /[#{separator}] */n + else + DEFAULT_SEP + end + + s.split(splitter).each do |part| + next if part.empty? + + k, v = part.split("=", 2) + + k = URI.decode_www_form_component(k) + v &&= URI.decode_www_form_component(v) + + yield k, v + end + + nil + end + end +end diff --git a/actionpack/lib/action_dispatch/http/rack_cache.rb b/actionpack/lib/action_dispatch/http/rack_cache.rb index 003ae4029d9ac..1429e74b7cf69 100644 --- a/actionpack/lib/action_dispatch/http/rack_cache.rb +++ b/actionpack/lib/action_dispatch/http/rack_cache.rb @@ -1,3 +1,9 @@ +# frozen_string_literal: true + +# :enddoc: + +# :markup: markdown + require "rack/cache" require "rack/cache/context" require "active_support/cache" diff --git a/actionpack/lib/action_dispatch/http/request.rb b/actionpack/lib/action_dispatch/http/request.rb index 19fa42ce12aba..9c019f0852d79 100644 --- a/actionpack/lib/action_dispatch/http/request.rb +++ b/actionpack/lib/action_dispatch/http/request.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "stringio" require "active_support/inflector" @@ -20,6 +24,7 @@ class Request include ActionDispatch::Http::Parameters include ActionDispatch::Http::FilterParameters include ActionDispatch::Http::URL + include ActionDispatch::ContentSecurityPolicy::Request include Rack::Request::Env autoload :Session, "action_dispatch/request/session" @@ -38,23 +43,28 @@ class Request HTTP_NEGOTIATE HTTP_PRAGMA HTTP_CLIENT_IP HTTP_X_FORWARDED_FOR HTTP_ORIGIN HTTP_VERSION HTTP_X_CSRF_TOKEN HTTP_X_REQUEST_ID HTTP_X_FORWARDED_HOST - SERVER_ADDR ].freeze ENV_METHODS.each do |env| class_eval <<-METHOD, __FILE__, __LINE__ + 1 - def #{env.sub(/^HTTP_/n, '').downcase} # def accept_charset - get_header "#{env}".freeze # get_header "HTTP_ACCEPT_CHARSET".freeze - end # end + # frozen_string_literal: true + def #{env.delete_prefix("HTTP_").downcase} # def accept_charset + get_header "#{env}" # get_header "HTTP_ACCEPT_CHARSET" + end # end METHOD end + TRANSFER_ENCODING = "HTTP_TRANSFER_ENCODING" # :nodoc: + def self.empty new({}) end def initialize(env) super + + @rack_request = Rack::Request.new(env) + @method = nil @request_method = nil @remote_ip = nil @@ -63,23 +73,36 @@ def initialize(env) @ip = nil end + attr_reader :rack_request + def commit_cookie_jar! # :nodoc: end PASS_NOT_FOUND = Class.new { # :nodoc: def self.action(_); self; end - def self.call(_); [404, { "X-Cascade" => "pass" }, []]; end - def self.binary_params_for?(action); false; end + def self.call(_); [404, { Constants::X_CASCADE => "pass" }, []]; end + def self.action_encoding_template(action); false; end } def controller_class params = path_parameters - - if params.key?(:controller) - controller_param = params[:controller].underscore - params[:action] ||= "index" - const_name = "#{controller_param.camelize}Controller" - ActiveSupport::Dependencies.constantize(const_name) + params[:action] ||= "index" + controller_class_for(params[:controller]) + end + + def controller_class_for(name) + if name + controller_param = name.underscore + const_name = controller_param.camelize << "Controller" + begin + const_name.constantize + rescue NameError => error + if error.missing_name == const_name || const_name.start_with?("#{error.missing_name}::") + raise MissingController.new(error.message, error.name) + else + raise + end + end else PASS_NOT_FOUND end @@ -87,54 +110,72 @@ def controller_class # Returns true if the request has a header matching the given key parameter. # - # request.key? :ip_spoofing_check # => true + # request.key? :ip_spoofing_check # => true def key?(key) has_header? key end - # List of HTTP request methods from the following RFCs: - # Hypertext Transfer Protocol -- HTTP/1.1 (http://www.ietf.org/rfc/rfc2616.txt) - # HTTP Extensions for Distributed Authoring -- WEBDAV (http://www.ietf.org/rfc/rfc2518.txt) - # Versioning Extensions to WebDAV (http://www.ietf.org/rfc/rfc3253.txt) - # Ordered Collections Protocol (WebDAV) (http://www.ietf.org/rfc/rfc3648.txt) - # Web Distributed Authoring and Versioning (WebDAV) Access Control Protocol (http://www.ietf.org/rfc/rfc3744.txt) - # Web Distributed Authoring and Versioning (WebDAV) SEARCH (http://www.ietf.org/rfc/rfc5323.txt) - # Calendar Extensions to WebDAV (http://www.ietf.org/rfc/rfc4791.txt) - # PATCH Method for HTTP (http://www.ietf.org/rfc/rfc5789.txt) + # HTTP methods from [RFC 2616: Hypertext Transfer Protocol -- HTTP/1.1](https://www.ietf.org/rfc/rfc2616.txt) RFC2616 = %w(OPTIONS GET HEAD POST PUT DELETE TRACE CONNECT) + # HTTP methods from [RFC 2518: HTTP Extensions for Distributed Authoring -- WEBDAV](https://www.ietf.org/rfc/rfc2518.txt) RFC2518 = %w(PROPFIND PROPPATCH MKCOL COPY MOVE LOCK UNLOCK) + # HTTP methods from [RFC 3253: Versioning Extensions to WebDAV](https://www.ietf.org/rfc/rfc3253.txt) RFC3253 = %w(VERSION-CONTROL REPORT CHECKOUT CHECKIN UNCHECKOUT MKWORKSPACE UPDATE LABEL MERGE BASELINE-CONTROL MKACTIVITY) + # HTTP methods from [RFC 3648: WebDAV Ordered Collections Protocol](https://www.ietf.org/rfc/rfc3648.txt) RFC3648 = %w(ORDERPATCH) + # HTTP methods from [RFC 3744: WebDAV Access Control Protocol](https://www.ietf.org/rfc/rfc3744.txt) RFC3744 = %w(ACL) + # HTTP methods from [RFC 5323: WebDAV SEARCH](https://www.ietf.org/rfc/rfc5323.txt) RFC5323 = %w(SEARCH) + # HTTP methods from [RFC 4791: Calendaring Extensions to WebDAV](https://www.ietf.org/rfc/rfc4791.txt) RFC4791 = %w(MKCALENDAR) + # HTTP methods from [RFC 5789: PATCH Method for HTTP](https://www.ietf.org/rfc/rfc5789.txt) RFC5789 = %w(PATCH) HTTP_METHODS = RFC2616 + RFC2518 + RFC3253 + RFC3648 + RFC3744 + RFC5323 + RFC4791 + RFC5789 HTTP_METHOD_LOOKUP = {} - # Populate the HTTP method lookup cache + # Populate the HTTP method lookup cache. HTTP_METHODS.each { |method| - HTTP_METHOD_LOOKUP[method] = method.underscore.to_sym + HTTP_METHOD_LOOKUP[method] = method.downcase.tap { |m| m.tr!("-", "_") }.to_sym } - # Returns the HTTP \method that the application should see. - # In the case where the \method was overridden by a middleware - # (for instance, if a HEAD request was converted to a GET, - # or if a _method parameter was used to determine the \method - # the application should use), this \method returns the overridden - # value, not the original. + alias raw_request_method request_method # :nodoc: + + # Returns the HTTP method that the application should see. In the case where the + # method was overridden by a middleware (for instance, if a HEAD request was + # converted to a GET, or if a _method parameter was used to determine the method + # the application should use), this method returns the overridden value, not the + # original. def request_method @request_method ||= check_method(super) end + # Returns the URI pattern of the matched route for the request, using the same + # format as `bin/rails routes`: + # + # request.route_uri_pattern # => "/:controller(/:action(/:id))(.:format)" + def route_uri_pattern + unless pattern = get_header("action_dispatch.route_uri_pattern") + route = get_header("action_dispatch.route") + return if route.nil? + pattern = route.path.spec.to_s + set_header("action_dispatch.route_uri_pattern", pattern) + end + pattern + end + + def route=(route) # :nodoc: + @env["action_dispatch.route"] = route + end + def routes # :nodoc: - get_header("action_dispatch.routes".freeze) + get_header("action_dispatch.routes") end def routes=(routes) # :nodoc: - set_header("action_dispatch.routes".freeze, routes) + set_header("action_dispatch.routes", routes) end def engine_script_name(_routes) # :nodoc: @@ -145,129 +186,153 @@ def engine_script_name=(name) # :nodoc: set_header(routes.env_key, name.dup) end - def request_method=(request_method) #:nodoc: + def request_method=(request_method) # :nodoc: if check_method(request_method) @request_method = set_header("REQUEST_METHOD", request_method) end end def controller_instance # :nodoc: - get_header("action_controller.instance".freeze) + get_header("action_controller.instance") end def controller_instance=(controller) # :nodoc: - set_header("action_controller.instance".freeze, controller) + set_header("action_controller.instance", controller) end def http_auth_salt get_header "action_dispatch.http_auth_salt" end - def show_exceptions? # :nodoc: - # We're treating `nil` as "unset", and we want the default setting to be - # `true`. This logic should be extracted to `env_config` and calculated - # once. - !(get_header("action_dispatch.show_exceptions".freeze) == false) - end - - # Returns a symbol form of the #request_method + # Returns a symbol form of the #request_method. def request_method_symbol HTTP_METHOD_LOOKUP[request_method] end - # Returns the original value of the environment's REQUEST_METHOD, - # even if it was overridden by middleware. See #request_method for - # more information. - def method - @method ||= check_method(get_header("rack.methodoverride.original_method") || get_header("REQUEST_METHOD")) + # Returns the original value of the environment's REQUEST_METHOD, even if it was + # overridden by middleware. See #request_method for more information. + # + # For debugging purposes, when called with arguments this method will fall back + # to Object#method + def method(*args) + if args.empty? + @method ||= check_method( + get_header("rack.methodoverride.original_method") || + get_header("REQUEST_METHOD") + ) + else + super + end end + ruby2_keywords(:method) - # Returns a symbol form of the #method + # Returns a symbol form of the #method. def method_symbol HTTP_METHOD_LOOKUP[method] end # Provides access to the request's HTTP headers, for example: # - # request.headers["Content-Type"] # => "text/plain" + # request.headers["Content-Type"] # => "text/plain" def headers @headers ||= Http::Headers.new(self) end - # Returns a +String+ with the last requested path including their params. + # Early Hints is an HTTP/2 status code that indicates hints to help a client + # start making preparations for processing the final response. + # + # If the env contains `rack.early_hints` then the server accepts HTTP2 push for + # link headers. + # + # The `send_early_hints` method accepts a hash of links as follows: + # + # send_early_hints("link" => "; rel=preload; as=style,; rel=preload") + # + # If you are using {javascript_include_tag}[rdoc-ref:ActionView::Helpers::AssetTagHelper#javascript_include_tag] + # or {stylesheet_link_tag}[rdoc-ref:ActionView::Helpers::AssetTagHelper#stylesheet_link_tag] + # the Early Hints headers are included by default if supported. + def send_early_hints(links) + env["rack.early_hints"]&.call(links) + end + + # Returns a `String` with the last requested path including their params. # - # # get '/foo' - # request.original_fullpath # => '/foo' + # # get '/foo' + # request.original_fullpath # => '/foo' # - # # get '/foo?bar' - # request.original_fullpath # => '/foo?bar' + # # get '/foo?bar' + # request.original_fullpath # => '/foo?bar' def original_fullpath @original_fullpath ||= (get_header("ORIGINAL_FULLPATH") || fullpath) end - # Returns the +String+ full path including params of the last URL requested. + # Returns the `String` full path including params of the last URL requested. # - # # get "/articles" - # request.fullpath # => "/articles" + # # get "/articles" + # request.fullpath # => "/articles" # - # # get "/articles?page=2" - # request.fullpath # => "/articles?page=2" + # # get "/articles?page=2" + # request.fullpath # => "/articles?page=2" def fullpath @fullpath ||= super end - # Returns the original request URL as a +String+. + # Returns the original request URL as a `String`. # - # # get "/articles?page=2" - # request.original_url # => "http://www.example.com/articles?page=2" + # # get "/articles?page=2" + # request.original_url # => "http://www.example.com/articles?page=2" def original_url base_url + original_fullpath end - # The +String+ MIME type of the request. + # The `String` MIME type of the request. # - # # get "/articles" - # request.media_type # => "application/x-www-form-urlencoded" + # # get "/articles" + # request.media_type # => "application/x-www-form-urlencoded" def media_type - content_mime_type.to_s + content_mime_type&.to_s end # Returns the content length of the request as an integer. def content_length + return raw_post.bytesize if has_header?(TRANSFER_ENCODING) super.to_i end - # Returns true if the "X-Requested-With" header contains "XMLHttpRequest" + # Returns true if the `X-Requested-With` header contains "XMLHttpRequest" # (case-insensitive), which may need to be manually added depending on the # choice of JavaScript libraries and frameworks. def xml_http_request? - get_header("HTTP_X_REQUESTED_WITH") =~ /XMLHttpRequest/i + /XMLHttpRequest/i.match?(get_header("HTTP_X_REQUESTED_WITH")) end alias :xhr? :xml_http_request? - # Returns the IP address of client as a +String+. + # Returns the IP address of client as a `String`. def ip @ip ||= super end - # Returns the IP address of client as a +String+, - # usually set by the RemoteIp middleware. + # Returns the IP address of client as a `String`, usually set by the RemoteIp + # middleware. def remote_ip @remote_ip ||= (get_header("action_dispatch.remote_ip") || ip).to_s end def remote_ip=(remote_ip) - set_header "action_dispatch.remote_ip".freeze, remote_ip + @remote_ip = nil + set_header "action_dispatch.remote_ip", remote_ip end - ACTION_DISPATCH_REQUEST_ID = "action_dispatch.request_id".freeze # :nodoc: + ACTION_DISPATCH_REQUEST_ID = "action_dispatch.request_id" # :nodoc: - # Returns the unique request id, which is based on either the X-Request-Id header that can - # be generated by a firewall, load balancer, or web server or by the RequestId middleware - # (which sets the action_dispatch.request_id environment variable). + # Returns the unique request id, which is based on either the `X-Request-Id` + # header that can be generated by a firewall, load balancer, or web server, or + # by the RequestId middleware (which sets the `action_dispatch.request_id` + # environment variable). # - # This unique ID is useful for tracing a request from end-to-end as part of logging or debugging. - # This relies on the rack variable set by the ActionDispatch::RequestId middleware. + # This unique ID is useful for tracing a request from end-to-end as part of + # logging or debugging. This relies on the Rack variable set by the + # ActionDispatch::RequestId middleware. def request_id get_header ACTION_DISPATCH_REQUEST_ID end @@ -283,13 +348,11 @@ def server_software (get_header("SERVER_SOFTWARE") && /^([a-zA-Z]+)/ =~ get_header("SERVER_SOFTWARE")) ? $1.downcase : nil end - # Read the request \body. This is useful for web services that need to - # work with raw requests directly. + # Read the request body. This is useful for web services that need to work with + # raw requests directly. def raw_post unless has_header? "RAW_POST_DATA" - raw_post_body = body - set_header("RAW_POST_DATA", raw_post_body.read(content_length)) - raw_post_body.rewind if raw_post_body.respond_to?(:rewind) + set_header("RAW_POST_DATA", read_body_stream) end get_header "RAW_POST_DATA" end @@ -298,40 +361,34 @@ def raw_post # variable is already set, wrap it in a StringIO. def body if raw_post = get_header("RAW_POST_DATA") - raw_post.force_encoding(Encoding::BINARY) + raw_post = (+raw_post).force_encoding(Encoding::BINARY) StringIO.new(raw_post) else body_stream end end - # Determine whether the request body contains form-data by checking - # the request Content-Type for one of the media-types: - # "application/x-www-form-urlencoded" or "multipart/form-data". The - # list of form-data media types can be modified through the - # +FORM_DATA_MEDIA_TYPES+ array. + # Determine whether the request body contains form-data by checking the request + # `Content-Type` for one of the media-types: `application/x-www-form-urlencoded` + # or `multipart/form-data`. The list of form-data media types can be modified + # through the `FORM_DATA_MEDIA_TYPES` array. # - # A request body is not assumed to contain form-data when no - # Content-Type header is provided and the request_method is POST. + # A request body is not assumed to contain form-data when no `Content-Type` + # header is provided and the request_method is POST. def form_data? FORM_DATA_MEDIA_TYPES.include?(media_type) end - def body_stream #:nodoc: + def body_stream # :nodoc: get_header("rack.input") end - # TODO This should be broken apart into AD::Request::Session and probably - # be included by the session middleware. def reset_session - if session && session.respond_to?(:destroy) - session.destroy - else - self.session = {} - end + session.destroy + reset_csrf_token end - def session=(session) #:nodoc: + def session=(session) # :nodoc: Session.set self, session end @@ -339,37 +396,72 @@ def session_options=(options) Session::Options.set self, options end - # Override Rack's GET method to support indifferent access + # Override Rack's GET method to support indifferent access. def GET fetch_header("action_dispatch.request.query_parameters") do |k| - rack_query_params = super || {} - # Check for non UTF-8 parameter values, which would cause errors later - Request::Utils.check_param_encoding(rack_query_params) - set_header k, Request::Utils.normalize_encode_params(rack_query_params) + encoding_template = Request::Utils::CustomParamEncoder.action_encoding_template(self, path_parameters[:controller], path_parameters[:action]) + rack_query_params = ActionDispatch::ParamBuilder.from_query_string(rack_request.query_string, encoding_template: encoding_template) + + set_header k, rack_query_params end - rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e + rescue ActionDispatch::ParamError => e raise ActionController::BadRequest.new("Invalid query parameters: #{e.message}") end alias :query_parameters :GET - # Override Rack's POST method to support indifferent access + # Override Rack's POST method to support indifferent access. def POST fetch_header("action_dispatch.request.request_parameters") do - pr = parse_formatted_parameters(params_parsers) do |params| - super || {} + encoding_template = Request::Utils::CustomParamEncoder.action_encoding_template(self, path_parameters[:controller], path_parameters[:action]) + + param_list = nil + pr = parse_formatted_parameters(params_parsers) do + if param_list = request_parameters_list + ActionDispatch::ParamBuilder.from_pairs(param_list, encoding_template: encoding_template) + else + # We're not using a version of Rack that provides raw form + # pairs; we must use its hash (and thus post-process it below). + fallback_request_parameters + end + end + + # If the request body was parsed by a custom parser like JSON + # (and thus the above block was not run), we need to + # post-process the result hash. + if param_list.nil? + pr = ActionDispatch::ParamBuilder.from_hash(pr, encoding_template: encoding_template) end - self.request_parameters = Request::Utils.normalize_encode_params(pr) + + self.request_parameters = pr end - rescue Http::Parameters::ParseError # one of the parse strategies blew up - self.request_parameters = Request::Utils.normalize_encode_params(super || {}) - raise - rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e + rescue ActionDispatch::ParamError, EOFError => e raise ActionController::BadRequest.new("Invalid request parameters: #{e.message}") end alias :request_parameters :POST - # Returns the authorization header regardless of whether it was specified directly or through one of the - # proxy alternatives. + def request_parameters_list + # We don't use Rack's parse result, but we must call it so Rack + # can populate the rack.request.* keys we need. + rack_post = rack_request.POST + + if form_pairs = get_header("rack.request.form_pairs") + # Multipart + form_pairs + elsif form_vars = get_header("rack.request.form_vars") + # URL-encoded + ActionDispatch::QueryParser.each_pair(form_vars) + elsif rack_post && !rack_post.empty? + # It was multipart, but Rack did not preserve a pair list + # (probably too old). Flat parameter list is not available. + nil + else + # No request body, or not a format Rack knows + [] + end + end + + # Returns the authorization header regardless of whether it was specified + # directly or through one of the proxy alternatives. def authorization get_header("HTTP_AUTHORIZATION") || get_header("X-HTTP_AUTHORIZATION") || @@ -379,29 +471,76 @@ def authorization # True if the request came from localhost, 127.0.0.1, or ::1. def local? - LOCALHOST =~ remote_addr && LOCALHOST =~ remote_ip + LOCALHOST.match?(remote_addr) && LOCALHOST.match?(remote_ip) end def request_parameters=(params) raise if params.nil? - set_header("action_dispatch.request.request_parameters".freeze, params) + set_header("action_dispatch.request.request_parameters", params) end def logger - get_header("action_dispatch.logger".freeze) + get_header("action_dispatch.logger") end def commit_flash end - def ssl? - super || scheme == "wss".freeze + def inspect # :nodoc: + "#<#{self.class.name} #{method} #{original_url.dump} for #{remote_ip}>" + end + + def reset_csrf_token + controller_instance.reset_csrf_token(self) if controller_instance.respond_to?(:reset_csrf_token) + end + + def commit_csrf_token + controller_instance.commit_csrf_token(self) if controller_instance.respond_to?(:commit_csrf_token) end private def check_method(name) - HTTP_METHOD_LOOKUP[name] || raise(ActionController::UnknownHttpMethod, "#{name}, accepted HTTP methods are #{HTTP_METHODS[0...-1].join(', ')}, and #{HTTP_METHODS[-1]}") + if name + HTTP_METHOD_LOOKUP[name] || raise(ActionController::UnknownHttpMethod, "#{name}, accepted HTTP methods are #{HTTP_METHODS.to_sentence(locale: false)}") + end + name end + + def default_session + Session.disabled(self) + end + + def read_body_stream + if body_stream + reset_stream(body_stream) do + if has_header?(TRANSFER_ENCODING) + body_stream.read # Read body stream until EOF if "Transfer-Encoding" is present + else + body_stream.read(content_length) + end + end + end + end + + def reset_stream(body_stream) + if body_stream.respond_to?(:rewind) + body_stream.rewind + + content = yield + + body_stream.rewind + + content + else + yield + end + end + + def fallback_request_parameters + rack_request.POST + end end end + +ActiveSupport.run_load_hooks :action_dispatch_request, ActionDispatch::Request diff --git a/actionpack/lib/action_dispatch/http/response.rb b/actionpack/lib/action_dispatch/http/response.rb index dc159596c46a8..a29b99cf8eb88 100644 --- a/actionpack/lib/action_dispatch/http/response.rb +++ b/actionpack/lib/action_dispatch/http/response.rb @@ -1,73 +1,92 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/module/attribute_accessors" require "action_dispatch/http/filter_redirect" require "action_dispatch/http/cache" require "monitor" module ActionDispatch # :nodoc: + # # Action Dispatch Response + # # Represents an HTTP response generated by a controller action. Use it to # retrieve the current state of the response, or customize the response. It can - # either represent a real HTTP response (i.e. one that is meant to be sent - # back to the web browser) or a TestResponse (i.e. one that is generated - # from integration tests). + # either represent a real HTTP response (i.e. one that is meant to be sent back + # to the web browser) or a TestResponse (i.e. one that is generated from + # integration tests). # - # \Response is mostly a Ruby on \Rails framework implementation detail, and - # should never be used directly in controllers. Controllers should use the - # methods defined in ActionController::Base instead. For example, if you want - # to set the HTTP response's content MIME type, then use - # ActionControllerBase#headers instead of Response#headers. + # The Response object for the current request is exposed on controllers as + # ActionController::Metal#response. ActionController::Metal also provides a few + # additional methods that delegate to attributes of the Response such as + # ActionController::Metal#headers. # - # Nevertheless, integration tests may want to inspect controller responses in - # more detail, and that's when \Response can be useful for application - # developers. Integration test methods such as - # ActionDispatch::Integration::Session#get and - # ActionDispatch::Integration::Session#post return objects of type - # TestResponse (which are of course also of type \Response). + # Integration tests will likely also want to inspect responses in more detail. + # Methods such as Integration::RequestHelpers#get and + # Integration::RequestHelpers#post return instances of TestResponse (which + # inherits from Response) for this purpose. # # For example, the following demo integration test prints the body of the # controller response to the console: # - # class DemoControllerTest < ActionDispatch::IntegrationTest - # def test_print_root_path_to_console - # get('/') - # puts response.body - # end - # end + # class DemoControllerTest < ActionDispatch::IntegrationTest + # def test_print_root_path_to_console + # get('/') + # puts response.body + # end + # end class Response - class Header < DelegateClass(Hash) # :nodoc: - def initialize(response, header) - @response = response - super(header) - end - - def []=(k, v) - if @response.sending? || @response.sent? - raise ActionDispatch::IllegalStateError, "header already sent" + begin + # For `Rack::Headers` (Rack 3+): + require "rack/headers" + Headers = ::Rack::Headers + rescue LoadError + # For `Rack::Utils::HeaderHash`: + require "rack/utils" + 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 - - super - end - - def merge(other) - self.class.new @response, __getobj__.merge(other) - end - - def to_hash - __getobj__.dup end end + # To be deprecated: + Header = Headers + # The request that the response is responding to. attr_accessor :request # The HTTP status code. attr_reader :status - # Get headers for this response. - attr_reader :header + # The headers for the response. + # + # header["Content-Type"] # => "text/plain" + # header["Content-Type"] = "application/json" + # header["Content-Type"] # => "application/json" + # + # Also aliased as `headers`. + # + # headers["Content-Type"] # => "text/plain" + # headers["Content-Type"] = "application/json" + # headers["Content-Type"] # => "application/json" + # + # Also aliased as `header` for compatibility. + attr_reader :headers - alias_method :headers, :header + alias_method :header, :headers - delegate :[], :[]=, to: :@header + delegate :[], :[]=, to: :@headers def each(&block) sending! @@ -76,16 +95,15 @@ def each(&block) x end - CONTENT_TYPE = "Content-Type".freeze - SET_COOKIE = "Set-Cookie".freeze - LOCATION = "Location".freeze - NO_CONTENT_CODES = [100, 101, 102, 204, 205, 304] + CONTENT_TYPE = "Content-Type" + SET_COOKIE = "Set-Cookie" + NO_CONTENT_CODES = [100, 101, 102, 103, 204, 205, 304] - cattr_accessor(:default_charset) { "utf-8" } - cattr_accessor(:default_headers) + cattr_accessor :default_charset, default: "utf-8" + cattr_accessor :default_headers include Rack::Response::Helpers - # Aliasing these off because AD::Http::Cache::Response defines them + # Aliasing these off because AD::Http::Cache::Response defines them. alias :_cache_control :cache_control alias :_cache_control= :cache_control= @@ -101,9 +119,27 @@ def initialize(response, buf) @str_body = nil end + BODY_METHODS = { to_ary: true } + + def respond_to?(method, include_private = false) + if BODY_METHODS.key?(method) + @buf.respond_to?(method) + else + super + end + end + + def to_ary + if @str_body + [body] + else + @buf = @buf.to_ary + end + end + def body @str_body ||= begin - buf = "" + buf = +"" each { |chunk| buf << chunk } buf end @@ -116,6 +152,7 @@ def write(string) @response.commit! @buf.push string end + alias_method :<<, :write def each(&block) if @str_body @@ -140,15 +177,14 @@ def closed? end private - def each_chunk(&block) - @buf.each(&block) # extract into own method + @buf.each(&block) end end - def self.create(status = 200, header = {}, body = [], default_headers: self.default_headers) - header = merge_default_headers(header, default_headers) - new status, header, body + def self.create(status = 200, headers = {}, body = [], default_headers: self.default_headers) + headers = merge_default_headers(headers, default_headers) + new status, headers, body end def self.merge_default_headers(original, default) @@ -158,10 +194,14 @@ def self.merge_default_headers(original, default) # The underlying body, as a streamable object. attr_reader :stream - def initialize(status = 200, header = {}, body = []) + def initialize(status = 200, headers = nil, body = []) super() - @header = Header.new(self, header) + @headers = Headers.new + + headers&.each do |key, value| + @headers[key] = value + end self.body, self.status = body, status @@ -175,10 +215,10 @@ def initialize(status = 200, header = {}, body = []) yield self if block_given? end - def has_header?(key); headers.key? key; end - def get_header(key); headers[key]; end - def set_header(key, v); headers[key] = v; end - def delete_header(key); headers.delete key; end + def has_header?(key); @headers.key? key; end + def get_header(key); @headers[key]; end + def set_header(key, v); @headers[key] = v; end + def delete_header(key); @headers.delete key; end def await_commit synchronize do @@ -217,31 +257,60 @@ def sending?; synchronize { @sending }; end def committed?; synchronize { @committed }; end def sent?; synchronize { @sent }; end + ## + # :method: location + # + # Location of the response. + + ## + # :method: location= + # + # :call-seq: location=(location) + # + # Sets the location of the response + # Sets the HTTP status code. def status=(status) - @status = Rack::Utils.status_code(status) + @status = Response.rack_status_code(status) end - # Sets the HTTP content type. + # Sets the HTTP response's content MIME type. For example, in the controller you + # could write this: + # + # response.content_type = "text/html" + # + # This method also accepts a symbol with the extension of the MIME type: + # + # response.content_type = :html + # + # If a character set has been defined for this response (see #charset=) then the + # character set information will also be included in the content type + # information. def content_type=(content_type) - return unless content_type - new_header_info = parse_content_type(content_type.to_s) + case content_type + when NilClass + return + when Symbol + mime_type = Mime[content_type] + raise ArgumentError, "Unknown MIME type #{content_type}" unless mime_type + new_header_info = ContentTypeHeader.new(mime_type.to_s) + else + new_header_info = parse_content_type(content_type.to_s) + end + prev_header_info = parsed_content_type_header charset = new_header_info.charset || prev_header_info.charset charset ||= self.class.default_charset unless prev_header_info.mime_type set_content_type new_header_info.mime_type, charset end - # Sets the HTTP response's content MIME type. For example, in the controller - # you could write this: - # - # response.content_type = "text/plain" - # - # If a character set has been defined for this response (see charset=) then - # the character set information will also be included in the content type - # information. - + # Content type of response. def content_type + super.presence + end + + # Media type of response. + def media_type parsed_content_type_header.mime_type end @@ -251,23 +320,22 @@ def sending_file=(v) end end - # Sets the HTTP character set. In case of +nil+ parameter - # it sets the charset to utf-8. + # Sets the HTTP character set. In case of `nil` parameter it sets the charset to + # `default_charset`. # - # response.charset = 'utf-16' # => 'utf-16' - # response.charset = nil # => 'utf-8' + # response.charset = 'utf-16' # => 'utf-16' + # response.charset = nil # => 'utf-8' def charset=(charset) - header_info = parsed_content_type_header + content_type = parsed_content_type_header.mime_type if false == charset - set_header CONTENT_TYPE, header_info.mime_type + set_content_type content_type, nil else - content_type = header_info.mime_type set_content_type content_type, charset || self.class.default_charset end end - # The charset of the response. HTML wants to know the encoding of the - # content you're giving them, so we need to send that along. + # The charset of the response. HTML wants to know the encoding of the content + # you're giving them, so we need to send that along. def charset header_info = parsed_content_type_header header_info.charset || self.class.default_charset @@ -278,28 +346,34 @@ def response_code @status end - # Returns a string to ensure compatibility with Net::HTTPResponse. + # Returns a string to ensure compatibility with `Net::HTTPResponse`. def code @status.to_s end # Returns the corresponding message for the current HTTP status code: # - # response.status = 200 - # response.message # => "OK" + # response.status = 200 + # response.message # => "OK" # - # response.status = 404 - # response.message # => "Not Found" + # response.status = 404 + # response.message # => "Not Found" # def message Rack::Utils::HTTP_STATUS_CODES[@status] end alias_method :status_message, :message - # Returns the content of the response as a string. This contains the contents - # of any calls to render. + # Returns the content of the response as a string. This contains the contents of + # any calls to `render`. def body - @stream.body + if @stream.respond_to?(:to_ary) + @stream.to_ary.join + elsif @stream.respond_to?(:body) + @stream.body + else + @stream + end end def write(string) @@ -308,19 +382,24 @@ def write(string) # Allows you to manually set or override the response body. def body=(body) - if body.respond_to?(:to_path) - @stream = body - else - synchronize do - @stream = build_buffer self, munge_body_object(body) + # Prevent ActionController::Metal::Live::Response from committing the response prematurely. + synchronize do + if body.respond_to?(:to_str) + @stream = build_buffer(self, [body]) + elsif body.respond_to?(:to_path) + @stream = body + elsif body.respond_to?(:to_ary) + @stream = build_buffer(self, body) + else + @stream = body end end end - # Avoid having to pass an open file handle as the response body. - # Rack::Sendfile will usually intercept the response and uses - # the path directly, so there is no reason to open the file. - class FileBody #:nodoc: + # Avoid having to pass an open file handle as the response body. Rack::Sendfile + # will usually intercept the response and uses the path directly, so there is no + # reason to open the file. + class FileBody # :nodoc: attr_reader :to_path def initialize(path) @@ -341,7 +420,7 @@ def each end end - # Send the file stored at +path+ as the response body. + # Send the file stored at `path` as the response body. def send_file(path) commit! @stream = FileBody.new(path) @@ -368,26 +447,25 @@ def abort if stream.respond_to?(:abort) stream.abort elsif stream.respond_to?(:close) - # `stream.close` should really be reserved for a close from the - # other direction, but we must fall back to it for - # compatibility. + # `stream.close` should really be reserved for a close from the other direction, + # but we must fall back to it for compatibility. stream.close end end - # Turns the Response into a Rack-compatible array of the status, headers, - # and body. Allows explicit splatting: + # Turns the Response into a Rack-compatible array of the status, headers, and + # body. Allows explicit splatting: # - # status, headers, body = *response + # status, headers, body = *response def to_a commit! - rack_response @status, @header.to_hash + rack_response @status, @headers.to_hash end alias prepare! to_a # Returns the response cookies, converted to a Hash of (name => value) pairs # - # assert_equal 'AuthorOfNewPage', r.cookies['author'] + # assert_equal 'AuthorOfNewPage', r.cookies['author'] def cookies cookies = {} if header = get_header(SET_COOKIE) @@ -403,15 +481,18 @@ def cookies end private - ContentTypeHeader = Struct.new :mime_type, :charset NullContentTypeHeader = ContentTypeHeader.new nil, nil + CONTENT_TYPE_PARSER = / + \A + (?[^;\s]+\s*(?:;\s*(?:(?!charset)[^;\s])+)*)? + (?:;\s*charset=(?"?)(?[^;\s]+)\k)? + /x # :nodoc: + def parse_content_type(content_type) - if content_type - type, charset = content_type.split(/;\s*charset=/) - type = nil if type && type.empty? - ContentTypeHeader.new(type, charset) + if content_type && match = CONTENT_TYPE_PARSER.match(content_type) + ContentTypeHeader.new(match[:mime_type], match[:charset]) else NullContentTypeHeader end @@ -424,40 +505,35 @@ def parsed_content_type_header end def set_content_type(content_type, charset) - type = (content_type || "").dup - type << "; charset=#{charset.to_s.downcase}" if charset + type = content_type || "" + type = "#{type}; charset=#{charset.to_s.downcase}" if charset set_header CONTENT_TYPE, type end def before_committed return if committed? assign_default_content_type_and_charset! + merge_and_normalize_cache_control!(@cache_control) handle_conditional_get! handle_no_content! end def before_sending - # Normally we've already committed by now, but it's possible - # (e.g., if the controller action tries to read back its own - # response) to get here before that. In that case, we must force - # an "early" commit: we're about to freeze the headers, so this is - # our last chance. + # Normally we've already committed by now, but it's possible (e.g., if the + # controller action tries to read back its own response) to get here before + # that. In that case, we must force an "early" commit: we're about to freeze the + # headers, so this is our last chance. commit! unless committed? - headers.freeze - request.commit_cookie_jar! unless committed? + @request.commit_cookie_jar! unless committed? end def build_buffer(response, body) Buffer.new response, body end - def munge_body_object(body) - body.respond_to?(:each) ? body : [body] - end - def assign_default_content_type_and_charset! - return if content_type + return if media_type ct = parsed_content_type_header set_content_type(ct.mime_type || Mime[:html].to_s, @@ -469,13 +545,11 @@ def initialize(response) @response = response end - def each(*args, &block) - @response.each(*args, &block) - end + attr :response def close - # Rack "close" maps to Response#abort, and *not* Response#close - # (which is used when the controller's finished writing) + # Rack "close" maps to Response#abort, and **not** Response#close (which is used + # when the controller's finished writing) @response.abort end @@ -483,36 +557,48 @@ def body @response.body end + BODY_METHODS = { to_ary: true, each: true, call: true, to_path: true } + def respond_to?(method, include_private = false) - if method.to_s == "to_path" + if BODY_METHODS.key?(method) @response.stream.respond_to?(method) else super end end - def to_path - @response.stream.to_path + def to_ary + @response.stream.to_ary end - def to_ary - nil + def each(*args, &block) + @response.each(*args, &block) + end + + def call(*arguments, &block) + @response.stream.call(*arguments, &block) + end + + def to_path + @response.stream.to_path end end def handle_no_content! if NO_CONTENT_CODES.include?(@status) - @header.delete CONTENT_TYPE - @header.delete "Content-Length" + @headers.delete CONTENT_TYPE + @headers.delete "Content-Length" end end - def rack_response(status, header) + def rack_response(status, headers) if NO_CONTENT_CODES.include?(status) - [status, header, []] + [status, headers, []] else - [status, header, RackBody.new(self)] + [status, headers, RackBody.new(self)] end end end + + ActiveSupport.run_load_hooks(:action_dispatch_response, Response) end diff --git a/actionpack/lib/action_dispatch/http/upload.rb b/actionpack/lib/action_dispatch/http/upload.rb index 61ba052e4586a..9b14b54a907fc 100644 --- a/actionpack/lib/action_dispatch/http/upload.rb +++ b/actionpack/lib/action_dispatch/http/upload.rb @@ -1,13 +1,19 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Http + # # Action Dispatch HTTP UploadedFile + # # Models uploaded files. # - # The actual file is accessible via the +tempfile+ accessor, though some - # of its interface is available directly for convenience. + # The actual file is accessible via the `tempfile` accessor, though some of its + # interface is available directly for convenience. # - # Uploaded files are temporary files whose lifespan is one request. When - # the object is finalized Ruby unlinks the file, so there is no need to - # clean them with a separate maintenance task. + # Uploaded files are temporary files whose lifespan is one request. When the + # object is finalized Ruby unlinks the file, so there is no need to clean them + # with a separate maintenance task. class UploadedFile # The basename of the file in the client. attr_accessor :original_filename @@ -15,10 +21,9 @@ class UploadedFile # A string with the MIME type of the file. attr_accessor :content_type - # A +Tempfile+ object with the actual uploaded file. Note that some of - # its interface is available directly. + # A `Tempfile` object with the actual uploaded file. Note that some of its + # interface is available directly. attr_accessor :tempfile - alias :to_io :tempfile # A string with the headers of the multipart request. attr_accessor :headers @@ -27,52 +32,76 @@ def initialize(hash) # :nodoc: @tempfile = hash[:tempfile] raise(ArgumentError, ":tempfile is required") unless @tempfile - @original_filename = hash[:filename] - if @original_filename + @content_type = hash[:type] + + if hash[:filename] + @original_filename = hash[:filename].dup + begin @original_filename.encode!(Encoding::UTF_8) rescue EncodingError @original_filename.force_encoding(Encoding::UTF_8) end + else + @original_filename = nil + end + + if hash[:head] + @headers = hash[:head].dup + + begin + @headers.encode!(Encoding::UTF_8) + rescue EncodingError + @headers.force_encoding(Encoding::UTF_8) + end + else + @headers = nil end - @content_type = hash[:type] - @headers = hash[:head] end - # Shortcut for +tempfile.read+. + # Shortcut for `tempfile.read`. def read(length = nil, buffer = nil) @tempfile.read(length, buffer) end - # Shortcut for +tempfile.open+. + # Shortcut for `tempfile.open`. def open @tempfile.open end - # Shortcut for +tempfile.close+. + # Shortcut for `tempfile.close`. def close(unlink_now = false) @tempfile.close(unlink_now) end - # Shortcut for +tempfile.path+. + # Shortcut for `tempfile.path`. def path @tempfile.path end - # Shortcut for +tempfile.rewind+. + # Shortcut for `tempfile.to_path`. + def to_path + @tempfile.to_path + end + + # Shortcut for `tempfile.rewind`. def rewind @tempfile.rewind end - # Shortcut for +tempfile.size+. + # Shortcut for `tempfile.size`. def size @tempfile.size end - # Shortcut for +tempfile.eof?+. + # Shortcut for `tempfile.eof?`. def eof? @tempfile.eof? end + + def to_io + @tempfile.to_io + end end end end diff --git a/actionpack/lib/action_dispatch/http/url.rb b/actionpack/lib/action_dispatch/http/url.rb index a6937d54ff210..e7fecc7b756a7 100644 --- a/actionpack/lib/action_dispatch/http/url.rb +++ b/actionpack/lib/action_dispatch/http/url.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/module/attribute_accessors" module ActionDispatch @@ -7,26 +11,123 @@ module URL HOST_REGEXP = /(^[^:]+:\/\/)?(\[[^\]]+\]|[^:]+)(?::(\d+$))?/ PROTOCOL_REGEXP = /^([^:]+)(:)?(\/\/)?$/ - mattr_accessor :tld_length - self.tld_length = 1 + # 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. # - # # Top-level domain example - # extract_domain('www.example.com', 1) # => "example.com" - # # Second-level domain example - # extract_domain('dev.www.example.co.uk', 2) # => "example.co.uk" + # # Top-level domain example + # extract_domain('www.example.com', 1) # => "example.com" + # # Second-level domain example + # extract_domain('dev.www.example.co.uk', 2) # => "example.co.uk" def extract_domain(host, tld_length) extract_domain_from(host, tld_length) if named_host?(host) end # Returns the subdomains of a host as an Array given the domain level. # - # # Top-level domain example - # extract_subdomains('www.example.com', 1) # => ["www"] - # # Second-level domain example - # extract_subdomains('dev.www.example.co.uk', 2) # => ["dev", "www"] + # # Top-level domain example + # extract_subdomains('www.example.com', 1) # => ["www"] + # # Second-level domain example + # extract_subdomains('dev.www.example.co.uk', 2) # => ["dev", "www"] def extract_subdomains(host, tld_length) if named_host?(host) extract_subdomains_from(host, tld_length) @@ -37,10 +138,10 @@ def extract_subdomains(host, tld_length) # Returns the subdomains of a host as a String given the domain level. # - # # Top-level domain example - # extract_subdomain('www.example.com', 1) # => "www" - # # Second-level domain example - # extract_subdomain('dev.www.example.co.uk', 2) # => "dev.www" + # # Top-level domain example + # extract_subdomain('www.example.com', 1) # => "www" + # # Second-level domain example + # extract_subdomain('dev.www.example.co.uk', 2) # => "dev.www" def extract_subdomain(host, tld_length) extract_subdomains(host, tld_length).join(".") end @@ -66,10 +167,11 @@ def full_url_for(options) end def path_for(options) - path = options[:script_name].to_s.chomp("/".freeze) + path = options[:script_name].to_s.chomp("/") path << options[:path] if options.key?(:path) - add_trailing_slash(path) if options[:trailing_slash] + path = "/" if options[:trailing_slash] && path.blank? + add_params(path, options[:params]) if options.key?(:params) add_anchor(path, options[:anchor]) if options.key?(:anchor) @@ -77,111 +179,99 @@ def path_for(options) end private - - def add_params(path, params) - params = { params: params } unless params.is_a?(Hash) - params.reject! { |_, v| v.to_param.nil? } - query = params.to_query - path << "?#{query}" unless query.empty? - end - - def add_anchor(path, anchor) - if anchor - path << "##{Journey::Router::Utils.escape_fragment(anchor.to_param)}" + def add_params(path, params) + params = { params: params } unless params.is_a?(Hash) + params.reject! { |_, v| v.to_param.nil? } + query = params.to_query + path << "?#{query}" unless query.empty? end - end - def extract_domain_from(host, tld_length) - host.split(".").last(1 + tld_length).join(".") - end - - def extract_subdomains_from(host, tld_length) - parts = host.split(".") - parts[0..-(tld_length + 2)] - end + def add_anchor(path, anchor) + if anchor + path << "##{Journey::Router::Utils.escape_fragment(anchor.to_param)}" + end + end - def add_trailing_slash(path) - # includes querysting - if path.include?("?") - path.sub!(/\?/, '/\&') - # does not have a .format - elsif !path.include?(".") - path.sub!(/[^\/]\z|\A\z/, '\&/') + def extract_domain_from(host, tld_length) + domain_extractor.domain_from(host, tld_length) end - 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 + def extract_subdomains_from(host, tld_length) + domain_extractor.subdomains_from(host, tld_length) end - protocol = normalize_protocol protocol - host = normalize_host(host, options) + def build_host_url(host, port, protocol, options, path) + if match = host.match(HOST_REGEXP) + protocol_from_host = match[1] if protocol.nil? + host = match[2] + port = match[3] unless options.key? :port + end - result = protocol.dup + protocol = protocol_from_host || normalize_protocol(protocol).dup + host = normalize_host(host, options) + port = normalize_port(port, protocol) - if options[:user] && options[:password] - result << "#{Rack::Utils.escape(options[:user])}:#{Rack::Utils.escape(options[:password])}@" - end + result = protocol - result << host - normalize_port(port, protocol) { |normalized_port| - result << ":#{normalized_port}" - } + if options[:user] && options[:password] + result << "#{Rack::Utils.escape(options[:user])}:#{Rack::Utils.escape(options[:password])}@" + end - result.concat path - end + result << host - def named_host?(host) - IP_HOST_REGEXP !~ host - end + result << ":" << port.to_s if port - def normalize_protocol(protocol) - case protocol - when nil - "http://" - when false, "//" - "//" - when PROTOCOL_REGEXP - "#{$1}://" - else - raise ArgumentError, "Invalid :protocol option: #{protocol.inspect}" + result.concat path + end + + def named_host?(host) + !IP_HOST_REGEXP.match?(host) + end + + def normalize_protocol(protocol) + case protocol + when nil + secure_protocol ? "https://" : "http://" + when false, "//" + "//" + when PROTOCOL_REGEXP + "#{$1}://" + else + raise ArgumentError, "Invalid :protocol option: #{protocol.inspect}" + end end - end - def normalize_host(_host, options) - return _host unless named_host?(_host) + def normalize_host(_host, options) + return _host unless named_host?(_host) - tld_length = options[:tld_length] || @@tld_length - subdomain = options.fetch :subdomain, true - domain = options[:domain] + tld_length = options[:tld_length] || @@tld_length + subdomain = options.fetch :subdomain, true + domain = options[:domain] - host = "" - if subdomain == true - return _host if domain.nil? + host = +"" + if subdomain == true + return _host if domain.nil? - host << extract_subdomains_from(_host, tld_length).join(".") - elsif subdomain - host << subdomain.to_param + host << extract_subdomains_from(_host, tld_length).join(".") + elsif subdomain + host << subdomain.to_param + end + host << "." unless host.empty? + host << (domain || extract_domain_from(_host, tld_length)) + host end - host << "." unless host.empty? - host << (domain || extract_domain_from(_host, tld_length)) - host - end - def normalize_port(port, protocol) - return unless port + def normalize_port(port, protocol) + return unless port - case protocol - when "//" then yield port - when "https://" - yield port unless port.to_i == 443 - else - yield port unless port.to_i == 80 + case protocol + when "//" then port + when "https://" + port unless port.to_i == 443 + else + port unless port.to_i == 80 + end end - end end def initialize @@ -192,157 +282,156 @@ def initialize # Returns the complete URL used for this request. # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' - # req.url # => "http://example.com" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.url # => "http://example.com" def url protocol + host_with_port + fullpath end # Returns 'https://' if this is an SSL request and 'http://' otherwise. # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' - # req.protocol # => "http://" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.protocol # => "http://" # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com', 'HTTPS' => 'on' - # req.protocol # => "https://" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com', 'HTTPS' => 'on' + # req.protocol # => "https://" def protocol @protocol ||= ssl? ? "https://" : "http://" end - # Returns the \host and port for this request, such as "example.com:8080". + # Returns the host and port for this request, such as "example.com:8080". # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' - # req.raw_host_with_port # => "example.com" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.raw_host_with_port # => "example.com" # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' - # req.raw_host_with_port # => "example.com:80" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.raw_host_with_port # => "example.com:80" # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' - # req.raw_host_with_port # => "example.com:8080" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.raw_host_with_port # => "example.com:8080" def raw_host_with_port if forwarded = x_forwarded_host.presence forwarded.split(/,\s?/).last else - get_header("HTTP_HOST") || "#{server_name || server_addr}:#{get_header('SERVER_PORT')}" + get_header("HTTP_HOST") || "#{server_name}:#{get_header('SERVER_PORT')}" end end # Returns the host for this request, such as "example.com". # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' - # req.host # => "example.com" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.host # => "example.com" def host - raw_host_with_port.sub(/:\d+$/, "".freeze) + raw_host_with_port.sub(/:\d+$/, "") end - # Returns a \host:\port string for this request, such as "example.com" or - # "example.com:8080". Port is only included if it is not a default port - # (80 or 443) + # Returns a host:port string for this request, such as "example.com" or + # "example.com:8080". Port is only included if it is not a default port (80 or + # 443) # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' - # req.host_with_port # => "example.com" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.host_with_port # => "example.com" # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' - # req.host_with_port # => "example.com" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.host_with_port # => "example.com" # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' - # req.host_with_port # => "example.com:8080" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.host_with_port # => "example.com:8080" def host_with_port "#{host}#{port_string}" end # Returns the port number of this request as an integer. # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' - # req.port # => 80 + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.port # => 80 # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' - # req.port # => 8080 + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.port # => 8080 def port - @port ||= begin - if raw_host_with_port =~ /:(\d+)$/ - $1.to_i - else - standard_port - end + @port ||= if raw_host_with_port =~ /:(\d+)$/ + $1.to_i + else + standard_port end end - # Returns the standard \port number for this request's protocol. + # Returns the standard port number for this request's protocol. # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' - # req.standard_port # => 80 + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.standard_port # => 80 def standard_port - case protocol - when "https://" then 443 - else 80 + if "https://" == protocol + 443 + else + 80 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 + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.standard_port? # => true # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' - # req.standard_port? # => false + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.standard_port? # => false def standard_port? port == standard_port end - # Returns a number \port suffix like 8080 if the \port number of this request - # is not the default HTTP \port 80 or HTTPS \port 443. + # Returns a number port suffix like 8080 if the port number of this request is + # not the default HTTP port 80 or HTTPS port 443. # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' - # req.optional_port # => nil + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.optional_port # => nil # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' - # req.optional_port # => 8080 + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.optional_port # => 8080 def optional_port standard_port? ? nil : port end - # Returns a string \port suffix, including colon, like ":8080" if the \port - # number of this request is not the default HTTP \port 80 or HTTPS \port 443. + # Returns a string port suffix, including colon, like ":8080" if the port number + # of this request is not the default HTTP port 80 or HTTPS port 443. # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' - # req.port_string # => "" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.port_string # => "" # - # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' - # req.port_string # => ":8080" + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.port_string # => ":8080" 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 + # req = ActionDispatch::Request.new 'SERVER_PORT' => '80' + # req.server_port # => 80 # - # req = ActionDispatch::Request.new 'SERVER_PORT' => '8080' - # req.server_port # => 8080 + # req = ActionDispatch::Request.new 'SERVER_PORT' => '8080' + # req.server_port # => 8080 def server_port get_header("SERVER_PORT").to_i end - # Returns the \domain part of a \host, such as "rubyonrails.org" in "www.rubyonrails.org". You can specify - # a different tld_length, such as 2 to catch rubyonrails.co.uk in "www.rubyonrails.co.uk". + # Returns the domain part of a host, such as "rubyonrails.org" in + # "www.rubyonrails.org". You can specify a different `tld_length`, such as 2 to + # catch rubyonrails.co.uk in "www.rubyonrails.co.uk". def domain(tld_length = @@tld_length) ActionDispatch::Http::URL.extract_domain(host, tld_length) end - # Returns all the \subdomains as an array, so ["dev", "www"] would be - # returned for "dev.www.rubyonrails.org". You can specify a different tld_length, - # such as 2 to catch ["www"] instead of ["www", "rubyonrails"] - # in "www.rubyonrails.co.uk". + # Returns all the subdomains as an array, so `["dev", "www"]` would be returned + # for "dev.www.rubyonrails.org". You can specify a different `tld_length`, such + # as 2 to catch `["www"]` instead of `["www", "rubyonrails"]` in + # "www.rubyonrails.co.uk". def subdomains(tld_length = @@tld_length) ActionDispatch::Http::URL.extract_subdomains(host, tld_length) end - # Returns all the \subdomains as a string, so "dev.www" would be - # returned for "dev.www.rubyonrails.org". You can specify a different tld_length, - # such as 2 to catch "www" instead of "www.rubyonrails" - # in "www.rubyonrails.co.uk". + # Returns all the subdomains as a string, so `"dev.www"` would be returned for + # "dev.www.rubyonrails.org". You can specify a different `tld_length`, such as 2 + # to catch `"www"` instead of `"www.rubyonrails"` in "www.rubyonrails.co.uk". def subdomain(tld_length = @@tld_length) ActionDispatch::Http::URL.extract_subdomain(host, tld_length) end diff --git a/actionpack/lib/action_dispatch/journey.rb b/actionpack/lib/action_dispatch/journey.rb index d1cfc51f3e26d..336469f8cc056 100644 --- a/actionpack/lib/action_dispatch/journey.rb +++ b/actionpack/lib/action_dispatch/journey.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/journey/router" require "action_dispatch/journey/gtg/builder" require "action_dispatch/journey/gtg/simulator" -require "action_dispatch/journey/nfa/builder" -require "action_dispatch/journey/nfa/simulator" diff --git a/actionpack/lib/action_dispatch/journey/formatter.rb b/actionpack/lib/action_dispatch/journey/formatter.rb index f3b8e82d32d0b..8343616fdcb97 100644 --- a/actionpack/lib/action_dispatch/journey/formatter.rb +++ b/actionpack/lib/action_dispatch/journey/formatter.rb @@ -1,10 +1,14 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_controller/metal/exceptions" module ActionDispatch # :stopdoc: module Journey # The Formatter class is used for formatting URLs. For example, parameters - # passed to +url_for+ in Rails will eventually call Formatter#generate. + # passed to `url_for` in Rails will eventually call Formatter#generate. class Formatter attr_reader :routes @@ -13,22 +17,74 @@ def initialize(routes) @cache = nil end - def generate(name, options, path_parameters, parameterize = nil) + class RouteWithParams + attr_reader :params + + def initialize(route, parameterized_parts, params) + @route = route + @parameterized_parts = parameterized_parts + @params = params + end + + def path(_) + @route.format(@parameterized_parts) + end + end + + class MissingRoute + attr_reader :routes, :name, :constraints, :missing_keys, :unmatched_keys + + def initialize(constraints, missing_keys, unmatched_keys, routes, name) + @constraints = constraints + @missing_keys = missing_keys + @unmatched_keys = unmatched_keys + @routes = routes + @name = name + end + + def path(method_name) + raise ActionController::UrlGenerationError.new(message, routes, name, method_name) + end + + def params + path("unknown") + end + + def message + message = +"No route matches #{Hash[constraints.sort_by { |k, v| k.to_s }].inspect}" + message << ", missing required keys: #{missing_keys.sort.inspect}" if missing_keys && !missing_keys.empty? + message << ", possible unmatched constraints: #{unmatched_keys.sort.inspect}" if unmatched_keys && !unmatched_keys.empty? + message + end + end + + def generate(name, options, path_parameters) + original_options = options.dup + path_params = options.delete(:path_params) + if path_params.is_a?(Hash) + options = path_params.merge(options) + else + path_params = nil + options = options.dup + end constraints = path_parameters.merge(options) - missing_keys = nil # need for variable scope + missing_keys = nil match_route(name, constraints) do |route| - parameterized_parts = extract_parameterized_parts(route, options, path_parameters, parameterize) + parameterized_parts = extract_parameterized_parts(route, options, path_parameters) - # Skip this route unless a name has been provided or it is a - # standard Rails route since we can't determine whether an options - # hash passed to url_for matches a Rack application or a redirect. + # Skip this route unless a name has been provided or it is a standard Rails + # route since we can't determine whether an options hash passed to url_for + # matches a Rack application or a redirect. next unless name || route.dispatcher? missing_keys = missing_keys(route, parameterized_parts) next if missing_keys && !missing_keys.empty? - params = options.dup.delete_if do |key, _| - parameterized_parts.key?(key) || route.defaults.key?(key) + params = options.delete_if do |key, _| + # top-level params' normal behavior of generating query_params should be + # preserved even if the same key is also a bind_param + parameterized_parts.key?(key) || route.defaults.key?(key) || + (path_params&.key?(key) && !original_options.key?(key)) end defaults = route.defaults @@ -42,43 +98,45 @@ def generate(name, options, path_parameters, parameterize = nil) parameterized_parts.delete(key) end - return [route.format(parameterized_parts), params] + return RouteWithParams.new(route, parameterized_parts, params) end unmatched_keys = (missing_keys || []) & constraints.keys missing_keys = (missing_keys || []) - unmatched_keys - message = "No route matches #{Hash[constraints.sort_by { |k, v| k.to_s }].inspect}" - message << ", missing required keys: #{missing_keys.sort.inspect}" if missing_keys && !missing_keys.empty? - message << ", possible unmatched constraints: #{unmatched_keys.sort.inspect}" if unmatched_keys && !unmatched_keys.empty? - - raise ActionController::UrlGenerationError, message + MissingRoute.new(constraints, missing_keys, unmatched_keys, routes, name) end def clear @cache = nil end - private + def eager_load! + cache + nil + end - def extract_parameterized_parts(route, options, recall, parameterize = nil) + private + def extract_parameterized_parts(route, options, recall) parameterized_parts = recall.merge(options) keys_to_keep = route.parts.reverse_each.drop_while { |part| - !options.key?(part) || (options[part] || recall[part]).nil? + !(options.key?(part) || route.scope_options.key?(part)) || (options[part].nil? && recall[part].nil?) } | route.required_parts parameterized_parts.delete_if do |bad_key, _| !keys_to_keep.include?(bad_key) end - if parameterize - parameterized_parts.each do |k, v| - parameterized_parts[k] = parameterize.call(k, v) + parameterized_parts.each do |k, v| + if k == :controller + parameterized_parts[k] = v + else + parameterized_parts[k] = v.to_param end end - parameterized_parts.keep_if { |_, v| v } + parameterized_parts.compact! parameterized_parts end @@ -124,19 +182,10 @@ def non_recursive(cache, options) routes end - module RegexCaseComparator - DEFAULT_INPUT = /[-_.a-zA-Z0-9]+\/[-_.a-zA-Z0-9]+/ - DEFAULT_REGEX = /\A#{DEFAULT_INPUT}\Z/ - - def self.===(regex) - DEFAULT_INPUT == regex - end - end - # Returns an array populated with missing keys if any are present. def missing_keys(route, parts) missing_keys = nil - tests = route.path.requirements + tests = route.path.requirements_for_missing_keys_check route.required_parts.each { |key| case tests[key] when nil @@ -144,13 +193,8 @@ def missing_keys(route, parts) missing_keys ||= [] missing_keys << key end - when RegexCaseComparator - unless RegexCaseComparator::DEFAULT_REGEX === parts[key] - missing_keys ||= [] - missing_keys << key - end else - unless /\A#{tests[key]}\Z/ === parts[key] + unless tests[key].match?(parts[key]) missing_keys ||= [] missing_keys << key end diff --git a/actionpack/lib/action_dispatch/journey/gtg/builder.rb b/actionpack/lib/action_dispatch/journey/gtg/builder.rb index 0f8bed89bfd7e..dbcefa71e18f8 100644 --- a/actionpack/lib/action_dispatch/journey/gtg/builder.rb +++ b/actionpack/lib/action_dispatch/journey/gtg/builder.rb @@ -1,55 +1,58 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/journey/gtg/transition_table" module ActionDispatch module Journey # :nodoc: module GTG # :nodoc: class Builder # :nodoc: - DUMMY = Nodes::Dummy.new + DUMMY_END_NODE = Nodes::Dummy.new attr_reader :root, :ast, :endpoints def initialize(root) @root = root - @ast = Nodes::Cat.new root, DUMMY - @followpos = nil + @ast = Nodes::Cat.new root, DUMMY_END_NODE + @followpos = build_followpos end def transition_table dtrans = TransitionTable.new - marked = {} - state_id = Hash.new { |h, k| h[k] = h.length } + marked = {}.compare_by_identity + state_id = Hash.new { |h, k| h[k] = h.length }.compare_by_identity + dstates = [firstpos(root)] - start = firstpos(root) - dstates = [start] until dstates.empty? s = dstates.shift next if marked[s] marked[s] = true # mark s s.group_by { |state| symbol(state) }.each do |sym, ps| - u = ps.flat_map { |l| followpos(l) } + u = ps.flat_map { |l| @followpos[l] }.uniq next if u.empty? - if u.uniq == [DUMMY] - from = state_id[s] - to = state_id[Object.new] - dtrans[from, to] = sym + from = state_id[s] + if u.all? { |pos| pos == DUMMY_END_NODE } + to = state_id[Object.new] + dtrans[from, to] = sym dtrans.add_accepting(to) + ps.each { |state| dtrans.add_memo(to, state.memo) } else - dtrans[state_id[s], state_id[u]] = sym - - if u.include?(DUMMY) - to = state_id[u] - - accepting = ps.find_all { |l| followpos(l).include?(DUMMY) } + to = state_id[u] + dtrans[from, to] = sym - accepting.each { |accepting_state| - dtrans.add_memo(to, accepting_state.memo) - } + if u.include?(DUMMY_END_NODE) + ps.each do |state| + if @followpos[state].include?(DUMMY_END_NODE) + dtrans.add_memo(to, state.memo) + end + end - dtrans.add_accepting(state_id[u]) + dtrans.add_accepting(to) end end @@ -65,7 +68,9 @@ def nullable?(node) when Nodes::Group true when Nodes::Star - true + # the default star regex is /(.+)/ which is NOT nullable but since different + # constraints can be provided we must actually check if this is the case or not. + node.regexp.match?("") when Nodes::Or node.children.any? { |c| nullable?(c) } when Nodes::Cat @@ -90,7 +95,7 @@ def firstpos(node) firstpos(node.left) end when Nodes::Or - node.children.flat_map { |c| firstpos(c) }.uniq + node.children.flat_map { |c| firstpos(c) }.tap(&:uniq!) when Nodes::Unary firstpos(node.left) when Nodes::Terminal @@ -103,9 +108,9 @@ def firstpos(node) def lastpos(node) case node when Nodes::Star - firstpos(node.left) + lastpos(node.left) when Nodes::Or - node.children.flat_map { |c| lastpos(c) }.uniq + node.children.flat_map { |c| lastpos(c) }.tap(&:uniq!) when Nodes::Cat if nullable?(node.right) lastpos(node.left) | lastpos(node.right) @@ -121,40 +126,22 @@ def lastpos(node) end end - def followpos(node) - followpos_table[node] - end - private - - def followpos_table - @followpos ||= build_followpos - end - def build_followpos - table = Hash.new { |h, k| h[k] = [] } + table = Hash.new { |h, k| h[k] = [] }.compare_by_identity @ast.each do |n| case n when Nodes::Cat lastpos(n.left).each do |i| table[i] += firstpos(n.right) end - when Nodes::Star - lastpos(n).each do |i| - table[i] += firstpos(n) - end end end table end def symbol(edge) - case edge - when Journey::Nodes::Symbol - edge.regexp - else - edge.left - end + edge.symbol? ? edge.regexp : edge.left end end end diff --git a/actionpack/lib/action_dispatch/journey/gtg/simulator.rb b/actionpack/lib/action_dispatch/journey/gtg/simulator.rb index d692f6415cdd1..cb682b1727e8d 100644 --- a/actionpack/lib/action_dispatch/journey/gtg/simulator.rb +++ b/actionpack/lib/action_dispatch/journey/gtg/simulator.rb @@ -1,4 +1,6 @@ -require "strscan" +# frozen_string_literal: true + +# :markup: markdown module ActionDispatch module Journey # :nodoc: @@ -12,34 +14,56 @@ def initialize(memos) end class Simulator # :nodoc: + STATIC_TOKENS = Array.new(64) + STATIC_TOKENS[".".ord] = "." + STATIC_TOKENS["/".ord] = "/" + STATIC_TOKENS["?".ord] = "?" + STATIC_TOKENS.freeze + + INITIAL_STATE = [0, nil].freeze + attr_reader :tt def initialize(transition_table) @tt = transition_table end - def simulate(string) - ms = memos(string) { return } - MatchData.new(ms) - end + def memos(string) + state = INITIAL_STATE - alias :=~ :simulate - alias :match :simulate + pos = 0 + eos = string.bytesize - def memos(string) - input = StringScanner.new(string) - state = [0] - while sym = input.scan(%r([/.?]|[^/.?]+)) - state = tt.move(state, sym) - end + while pos < eos + start_index = pos + pos += 1 - acceptance_states = state.find_all { |s| - tt.accepting? s - } + if (token = STATIC_TOKENS[string.getbyte(start_index)]) + state = tt.move(state, string, token, start_index, false) + else + while pos < eos && STATIC_TOKENS[string.getbyte(pos)].nil? + pos += 1 + end - return yield if acceptance_states.empty? + token = string.byteslice(start_index, pos - start_index) + state = tt.move(state, string, token, start_index, true) + end + end + + acceptance_states = [] + states_count = state.size + i = 0 + while i < states_count + if state[i + 1].nil? + s = state[i] + if tt.accepting?(s) + acceptance_states.concat(tt.memo(s)) + end + end + i += 2 + end - acceptance_states.flat_map { |x| tt.memo(x) }.compact + acceptance_states.empty? ? yield : acceptance_states end end end diff --git a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb index beb9f1ef3b4f3..239135ec8af0d 100644 --- a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb +++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/journey/nfa/dot" module ActionDispatch @@ -8,11 +12,14 @@ class TransitionTable # :nodoc: attr_reader :memos + DEFAULT_EXP = /[^.\/?]+/ + def initialize - @regexp_states = {} - @string_states = {} - @accepting = {} - @memos = Hash.new { |h, k| h[k] = [] } + @stdparam_states = {} + @regexp_states = {} + @string_states = {} + @accepting = {} + @memos = Hash.new { |h, k| h[k] = [] } end def add_accepting(state) @@ -39,20 +46,58 @@ def eclosure(t) Array(t) end - def move(t, a) + def move(t, full_string, token, start_index, token_matches_default) return [] if t.empty? - regexps = [] + next_states = [] + + transitions_count = t.size + i = 0 + while i < transitions_count + s = t[i] + previous_start = t[i + 1] + if previous_start.nil? + # In the simple case of a "default" param regex do this fast-path and add all + # next states. + if token_matches_default && std_state = @stdparam_states[s] + next_states << std_state << nil + end - t.map { |s| - if states = @regexp_states[s] - regexps.concat states.map { |re, v| re === a ? v : nil } + # When we have a literal string, we can just pull the next state + if states = @string_states[s] + state = states[token] + next_states << state << nil unless state.nil? + end end - if states = @string_states[s] - states[a] + # For regexes that aren't the "default" style, they may potentially not be + # terminated by the first "token" [./?], so we need to continue to attempt to + # match this regexp as well as any successful paths that continue out of it. + # both paths could be valid. + if states = @regexp_states[s] + slice_start = if previous_start.nil? + start_index + else + previous_start + end + + slice_length = start_index + token.length - slice_start + curr_slice = full_string.slice(slice_start, slice_length) + + states.each { |re, v| + # if we match, we can try moving past this + next_states << v << nil if !v.nil? && re.match?(curr_slice) + } + + # and regardless, we must continue accepting tokens and retrying this regexp. we + # need to remember where we started as well so we can take bigger slices. + next_states << s << slice_start end - }.compact.concat regexps + + i += 2 + end + + next_states end def as_json(options = nil) @@ -65,9 +110,10 @@ def as_json(options = nil) end { - regexp_states: simple_regexp, - string_states: @string_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 @@ -82,14 +128,14 @@ def to_svg end def visualizer(paths, title = "FSM") - viz_dir = File.join File.dirname(__FILE__), "..", "visualizer" + viz_dir = File.join __dir__, "..", "visualizer" fsm_js = File.read File.join(viz_dir, "fsm.js") fsm_css = File.read File.join(viz_dir, "fsm.css") erb = File.read File.join(viz_dir, "index.html.erb") states = "function tt() { return #{to_json}; }" fun_routes = paths.sample(3).map do |ast| - ast.map { |n| + ast.filter_map { |n| case n when Nodes::Symbol case n.left @@ -102,14 +148,13 @@ def visualizer(paths, title = "FSM") else nil end - }.compact.join + }.join end stylesheets = [fsm_css] svg = to_svg javascripts = [states, fsm_js] - # Annoying hack warnings fun_routes = fun_routes stylesheets = stylesheets svg = svg @@ -121,36 +166,43 @@ def visualizer(paths, title = "FSM") end def []=(from, to, sym) - to_mappings = states_hash_for(sym)[from] ||= {} - to_mappings[sym] = to + case sym + when String, Symbol + to_mapping = @string_states[from] ||= {} + # account for symbols in the constraints the same as strings + to_mapping[sym.to_s] = to + when Regexp + if sym == DEFAULT_EXP + @stdparam_states[from] = to + else + to_mapping = @regexp_states[from] ||= {} + # we must match the whole string to a token boundary + to_mapping[/\A#{sym}\Z/] = to + end + else + raise ArgumentError, "unknown symbol: %s" % sym.class + end end def states ss = @string_states.keys + @string_states.values.flat_map(&:values) + ps = @stdparam_states.keys + @stdparam_states.values rs = @regexp_states.keys + @regexp_states.values.flat_map(&:values) - (ss + rs).uniq + (ss + ps + rs).uniq 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] } + @regexp_states.flat_map { |from, hash| - hash.map { |s, to| [from, s, to] } + hash.map { |r, to| [from, r.source.gsub("\\") { "\\\\" }, to] } } end - - private - - def states_hash_for(sym) - case sym - when String - @string_states - when Regexp - @regexp_states - else - raise ArgumentError, "unknown symbol: %s" % sym.class - end - end end end end diff --git a/actionpack/lib/action_dispatch/journey/nfa/builder.rb b/actionpack/lib/action_dispatch/journey/nfa/builder.rb deleted file mode 100644 index 532f765094e1f..0000000000000 --- a/actionpack/lib/action_dispatch/journey/nfa/builder.rb +++ /dev/null @@ -1,76 +0,0 @@ -require "action_dispatch/journey/nfa/transition_table" -require "action_dispatch/journey/gtg/transition_table" - -module ActionDispatch - module Journey # :nodoc: - module NFA # :nodoc: - class Visitor < Visitors::Visitor # :nodoc: - def initialize(tt) - @tt = tt - @i = -1 - end - - def visit_CAT(node) - left = visit(node.left) - right = visit(node.right) - - @tt.merge(left.last, right.first) - - [left.first, right.last] - end - - def visit_GROUP(node) - from = @i += 1 - left = visit(node.left) - to = @i += 1 - - @tt.accepting = to - - @tt[from, left.first] = nil - @tt[left.last, to] = nil - @tt[from, to] = nil - - [from, to] - end - - def visit_OR(node) - from = @i += 1 - children = node.children.map { |c| visit(c) } - to = @i += 1 - - children.each do |child| - @tt[from, child.first] = nil - @tt[child.last, to] = nil - end - - @tt.accepting = to - - [from, to] - end - - def terminal(node) - from_i = @i += 1 # new state - to_i = @i += 1 # new state - - @tt[from_i, to_i] = node - @tt.accepting = to_i - @tt.add_memo(to_i, node.memo) - - [from_i, to_i] - end - end - - class Builder # :nodoc: - def initialize(ast) - @ast = ast - end - - def transition_table - tt = TransitionTable.new - Visitor.new(tt).accept(@ast) - tt - end - end - end - end -end diff --git a/actionpack/lib/action_dispatch/journey/nfa/dot.rb b/actionpack/lib/action_dispatch/journey/nfa/dot.rb index 8119e5d9dac86..130cb1ccb035f 100644 --- a/actionpack/lib/action_dispatch/journey/nfa/dot.rb +++ b/actionpack/lib/action_dispatch/journey/nfa/dot.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Journey # :nodoc: module NFA # :nodoc: @@ -7,17 +11,6 @@ def to_dot " #{from} -> #{to} [label=\"#{sym || 'ε'}\"];" } - #memo_nodes = memos.values.flatten.map { |n| - # label = n - # if Journey::Route === n - # label = "#{n.verb.source} #{n.path.spec}" - # end - # " #{n.object_id} [label=\"#{label}\", shape=box];" - #} - #memo_edges = memos.flat_map { |k, memos| - # (memos || []).map { |v| " #{k} -> #{v.object_id};" } - #}.uniq - <<-eodot digraph nfa { rankdir=LR; diff --git a/actionpack/lib/action_dispatch/journey/nfa/simulator.rb b/actionpack/lib/action_dispatch/journey/nfa/simulator.rb deleted file mode 100644 index 324d0eed1583e..0000000000000 --- a/actionpack/lib/action_dispatch/journey/nfa/simulator.rb +++ /dev/null @@ -1,47 +0,0 @@ -require "strscan" - -module ActionDispatch - module Journey # :nodoc: - module NFA # :nodoc: - class MatchData # :nodoc: - attr_reader :memos - - def initialize(memos) - @memos = memos - end - end - - class Simulator # :nodoc: - attr_reader :tt - - def initialize(transition_table) - @tt = transition_table - end - - def simulate(string) - input = StringScanner.new(string) - state = tt.eclosure(0) - until input.eos? - sym = input.scan(%r([/.?]|[^/.?]+)) - - # FIXME: tt.eclosure is not needed for the GTG - state = tt.eclosure(tt.move(state, sym)) - end - - acceptance_states = state.find_all { |s| - tt.accepting?(tt.eclosure(s).sort.last) - } - - return if acceptance_states.empty? - - memos = acceptance_states.flat_map { |x| tt.memo(x) }.compact - - MatchData.new(memos) - end - - alias :=~ :simulate - alias :match :simulate - end - end - end -end diff --git a/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb b/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb deleted file mode 100644 index 543a670da0a67..0000000000000 --- a/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb +++ /dev/null @@ -1,118 +0,0 @@ -require "action_dispatch/journey/nfa/dot" - -module ActionDispatch - module Journey # :nodoc: - module NFA # :nodoc: - class TransitionTable # :nodoc: - include Journey::NFA::Dot - - attr_accessor :accepting - attr_reader :memos - - def initialize - @table = Hash.new { |h, f| h[f] = {} } - @memos = {} - @accepting = nil - @inverted = nil - end - - def accepting?(state) - accepting == state - end - - def accepting_states - [accepting] - end - - def add_memo(idx, memo) - @memos[idx] = memo - end - - def memo(idx) - @memos[idx] - end - - def []=(i, f, s) - @table[f][i] = s - end - - def merge(left, right) - @memos[right] = @memos.delete(left) - @table[right] = @table.delete(left) - end - - def states - (@table.keys + @table.values.flat_map(&:keys)).uniq - end - - # Returns set of NFA states to which there is a transition on ast symbol - # +a+ from some state +s+ in +t+. - def following_states(t, a) - Array(t).flat_map { |s| inverted[s][a] }.uniq - end - - # Returns set of NFA states to which there is a transition on ast symbol - # +a+ from some state +s+ in +t+. - def move(t, a) - Array(t).map { |s| - inverted[s].keys.compact.find_all { |sym| - sym === a - }.map { |sym| inverted[s][sym] } - }.flatten.uniq - end - - def alphabet - inverted.values.flat_map(&:keys).compact.uniq.sort_by(&:to_s) - end - - # Returns a set of NFA states reachable from some NFA state +s+ in set - # +t+ on nil-transitions alone. - def eclosure(t) - stack = Array(t) - seen = {} - children = [] - - until stack.empty? - s = stack.pop - next if seen[s] - - seen[s] = true - children << s - - stack.concat(inverted[s][nil]) - end - - children.uniq - end - - def transitions - @table.flat_map { |to, hash| - hash.map { |from, sym| [from, sym, to] } - } - end - - private - - def inverted - return @inverted if @inverted - - @inverted = Hash.new { |h, from| - h[from] = Hash.new { |j, s| j[s] = [] } - } - - @table.each { |to, hash| - hash.each { |from, sym| - if sym - sym = Nodes::Symbol === sym ? sym.regexp : sym.left - end - - @inverted[from][sym] << to - } - } - - @inverted - end - end - end - end -end diff --git a/actionpack/lib/action_dispatch/journey/nodes/node.rb b/actionpack/lib/action_dispatch/journey/nodes/node.rb index 0d874a84c9cb4..53bec2228ca9f 100644 --- a/actionpack/lib/action_dispatch/journey/nodes/node.rb +++ b/actionpack/lib/action_dispatch/journey/nodes/node.rb @@ -1,7 +1,70 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/journey/visitors" module ActionDispatch module Journey # :nodoc: + class Ast # :nodoc: + attr_reader :names, :path_params, :tree, :wildcard_options, :terminals + alias :root :tree + + def initialize(tree, formatted) + @tree = tree + @path_params = [] + @names = [] + @symbols = [] + @stars = [] + @terminals = [] + @wildcard_options = {} + + visit_tree(formatted) + end + + def requirements=(requirements) + # inject any regexp requirements for `star` nodes so they can be determined + # nullable, which requires knowing if the regex accepts an empty string. + (symbols + stars).each do |node| + re = requirements[node.to_sym] + node.regexp = re if re + end + end + + def route=(route) + terminals.each { |n| n.memo = route } + end + + def glob? + stars.any? + end + + private + attr_reader :symbols, :stars + + def visit_tree(formatted) + tree.each do |node| + if node.symbol? + path_params << node.to_sym + names << node.name + symbols << node + elsif node.star? + stars << node + + if formatted != false + # Add a constraint for wildcard route to make it non-greedy and match the + # optional format part of the route by default. + wildcard_options[node.name.to_sym] ||= /.+?/m + end + end + + if node.terminal? + terminals << node + end + end + end + end + module Nodes # :nodoc: class Node # :nodoc: include Enumerable @@ -11,6 +74,7 @@ class Node # :nodoc: def initialize(left) @left = left @memo = nil + @to_s = nil end def each(&block) @@ -18,7 +82,7 @@ def each(&block) end def to_s - Visitors::String::INSTANCE.accept(self, "") + @to_s ||= Visitors::String::INSTANCE.accept(self, "".dup).freeze end def to_dot @@ -30,7 +94,7 @@ def to_sym end def name - left.tr "*:".freeze, "".freeze + -left.tr("*:", "") end def type @@ -63,12 +127,12 @@ def initialize(x = Object.new) def literal?; false; end end - %w{ Symbol Slash Dot }.each do |t| - class_eval <<-eoruby, __FILE__, __LINE__ + 1 - class #{t} < Terminal; - def type; :#{t.upcase}; end - end - eoruby + class Slash < Terminal # :nodoc: + def type; :SLASH; end + end + + class Dot < Terminal # :nodoc: + def type; :DOT; end end class Symbol < Terminal # :nodoc: @@ -76,17 +140,15 @@ class Symbol < Terminal # :nodoc: alias :symbol :regexp attr_reader :name - DEFAULT_EXP = /[^\.\/\?]+/ - def initialize(left) - super - @regexp = DEFAULT_EXP - @name = left.tr "*:".freeze, "".freeze - end - - def default_regexp? - regexp == DEFAULT_EXP + DEFAULT_EXP = /[^.\/?]+/ + GREEDY_EXP = /(.+)/ + def initialize(left, regexp = DEFAULT_EXP) + super(left) + @regexp = regexp + @name = -left.tr("*:", "") end + def type; :SYMBOL; end def symbol?; true; end end @@ -100,6 +162,15 @@ def group?; true; end end class Star < Unary # :nodoc: + attr_accessor :regexp + + def initialize(left) + super(left) + + # By default wildcard routes are non-greedy and must match something. + @regexp = /.+?/m + end + def star?; true; end def type; :STAR; end diff --git a/actionpack/lib/action_dispatch/journey/parser.rb b/actionpack/lib/action_dispatch/journey/parser.rb index e002755bcf924..70520e13264a8 100644 --- a/actionpack/lib/action_dispatch/journey/parser.rb +++ b/actionpack/lib/action_dispatch/journey/parser.rb @@ -1,199 +1,103 @@ -# -# DO NOT MODIFY!!!! -# This file is automatically generated by Racc 1.4.14 -# from Racc grammar file "". -# +# frozen_string_literal: true -require 'racc/parser.rb' +require "action_dispatch/journey/scanner" +require "action_dispatch/journey/nodes/node" -# :stopdoc: - -require "action_dispatch/journey/parser_extras" module ActionDispatch - module Journey - class Parser < Racc::Parser -##### State transition tables begin ### - -racc_action_table = [ - 13, 15, 14, 7, 19, 16, 8, 19, 13, 15, - 14, 7, 17, 16, 8, 13, 15, 14, 7, 21, - 16, 8, 13, 15, 14, 7, 24, 16, 8 ] - -racc_action_check = [ - 2, 2, 2, 2, 22, 2, 2, 2, 19, 19, - 19, 19, 1, 19, 19, 7, 7, 7, 7, 17, - 7, 7, 0, 0, 0, 0, 20, 0, 0 ] - -racc_action_pointer = [ - 20, 12, -2, nil, nil, nil, nil, 13, nil, nil, - nil, nil, nil, nil, nil, nil, nil, 19, nil, 6, - 20, nil, -5, nil, nil ] - -racc_action_default = [ - -19, -19, -2, -3, -4, -5, -6, -19, -10, -11, - -12, -13, -14, -15, -16, -17, -18, -19, -1, -19, - -19, 25, -8, -9, -7 ] - -racc_goto_table = [ - 1, 22, 18, 23, nil, nil, nil, 20 ] - -racc_goto_check = [ - 1, 2, 1, 3, nil, nil, nil, 1 ] - -racc_goto_pointer = [ - nil, 0, -18, -16, nil, nil, nil, nil, nil, nil, - nil ] - -racc_goto_default = [ - nil, nil, 2, 3, 4, 5, 6, 9, 10, 11, - 12 ] - -racc_reduce_table = [ - 0, 0, :racc_error, - 2, 11, :_reduce_1, - 1, 11, :_reduce_2, - 1, 11, :_reduce_none, - 1, 12, :_reduce_none, - 1, 12, :_reduce_none, - 1, 12, :_reduce_none, - 3, 15, :_reduce_7, - 3, 13, :_reduce_8, - 3, 13, :_reduce_9, - 1, 16, :_reduce_10, - 1, 14, :_reduce_none, - 1, 14, :_reduce_none, - 1, 14, :_reduce_none, - 1, 14, :_reduce_none, - 1, 19, :_reduce_15, - 1, 17, :_reduce_16, - 1, 18, :_reduce_17, - 1, 20, :_reduce_18 ] - -racc_reduce_n = 19 - -racc_shift_n = 25 - -racc_token_table = { - false => 0, - :error => 1, - :SLASH => 2, - :LITERAL => 3, - :SYMBOL => 4, - :LPAREN => 5, - :RPAREN => 6, - :DOT => 7, - :STAR => 8, - :OR => 9 } - -racc_nt_base = 10 - -racc_use_result_var = false - -Racc_arg = [ - racc_action_table, - racc_action_check, - racc_action_default, - racc_action_pointer, - racc_goto_table, - racc_goto_check, - racc_goto_default, - racc_goto_pointer, - racc_nt_base, - racc_reduce_table, - racc_token_table, - racc_shift_n, - racc_reduce_n, - racc_use_result_var ] - -Racc_token_to_s_table = [ - "$end", - "error", - "SLASH", - "LITERAL", - "SYMBOL", - "LPAREN", - "RPAREN", - "DOT", - "STAR", - "OR", - "$start", - "expressions", - "expression", - "or", - "terminal", - "group", - "star", - "symbol", - "literal", - "slash", - "dot" ] - -Racc_debug_parser = false - -##### State transition tables end ##### - -# reduce 0 omitted - -def _reduce_1(val, _values) - Cat.new(val.first, val.last) -end - -def _reduce_2(val, _values) - val.first -end - -# reduce 3 omitted - -# reduce 4 omitted - -# reduce 5 omitted - -# reduce 6 omitted - -def _reduce_7(val, _values) - Group.new(val[1]) -end - -def _reduce_8(val, _values) - Or.new([val.first, val.last]) -end - -def _reduce_9(val, _values) - Or.new([val.first, val.last]) -end - -def _reduce_10(val, _values) - Star.new(Symbol.new(val.last)) + module Journey # :nodoc: + class Parser # :nodoc: + include Journey::Nodes + + def self.parse(string) + new.parse string + end + + def initialize + @scanner = Scanner.new + @next_token = nil + end + + def parse(string) + @scanner.scan_setup(string) + advance_token + do_parse + end + + private + def advance_token + @next_token = @scanner.next_token + end + + def do_parse + parse_expressions + end + + def parse_expressions + node = parse_expression + + while @next_token + case @next_token + when :RPAREN + break + when :OR + node = parse_or(node) + else + node = Cat.new(node, parse_expressions) + end + end + + node + end + + def parse_or(lhs) + advance_token + node = parse_expression + Or.new([lhs, node]) + end + + def parse_expression + if @next_token == :STAR + parse_star + elsif @next_token == :LPAREN + parse_group + else + parse_terminal + end + end + + def parse_star + node = Star.new(Symbol.new(@scanner.last_string, Symbol::GREEDY_EXP)) + advance_token + node + end + + def parse_group + advance_token + node = parse_expressions + if @next_token == :RPAREN + node = Group.new(node) + advance_token + node + else + raise ArgumentError, "missing right parenthesis." + end + end + + def parse_terminal + node = case @next_token + when :SYMBOL + Symbol.new(@scanner.last_string) + when :LITERAL + Literal.new(@scanner.last_literal) + when :SLASH + Slash.new("/") + when :DOT + Dot.new(".") + end + + advance_token + node + end + end + end end - -# reduce 11 omitted - -# reduce 12 omitted - -# reduce 13 omitted - -# reduce 14 omitted - -def _reduce_15(val, _values) - Slash.new(val.first) -end - -def _reduce_16(val, _values) - Symbol.new(val.first) -end - -def _reduce_17(val, _values) - Literal.new(val.first) -end - -def _reduce_18(val, _values) - Dot.new(val.first) -end - -def _reduce_none(val, _values) - val[0] -end - - end # class Parser - end # module Journey - end # module ActionDispatch diff --git a/actionpack/lib/action_dispatch/journey/parser.y b/actionpack/lib/action_dispatch/journey/parser.y deleted file mode 100644 index f9b1a7a958b08..0000000000000 --- a/actionpack/lib/action_dispatch/journey/parser.y +++ /dev/null @@ -1,50 +0,0 @@ -class ActionDispatch::Journey::Parser - options no_result_var -token SLASH LITERAL SYMBOL LPAREN RPAREN DOT STAR OR - -rule - expressions - : expression expressions { Cat.new(val.first, val.last) } - | expression { val.first } - | or - ; - expression - : terminal - | group - | star - ; - group - : LPAREN expressions RPAREN { Group.new(val[1]) } - ; - or - : expression OR expression { Or.new([val.first, val.last]) } - | expression OR or { Or.new([val.first, val.last]) } - ; - star - : STAR { Star.new(Symbol.new(val.last)) } - ; - terminal - : symbol - | literal - | slash - | dot - ; - slash - : SLASH { Slash.new(val.first) } - ; - symbol - : SYMBOL { Symbol.new(val.first) } - ; - literal - : LITERAL { Literal.new(val.first) } - ; - dot - : DOT { Dot.new(val.first) } - ; - -end - ----- header -# :stopdoc: - -require "action_dispatch/journey/parser_extras" diff --git a/actionpack/lib/action_dispatch/journey/parser_extras.rb b/actionpack/lib/action_dispatch/journey/parser_extras.rb deleted file mode 100644 index 4c7e82d93c948..0000000000000 --- a/actionpack/lib/action_dispatch/journey/parser_extras.rb +++ /dev/null @@ -1,29 +0,0 @@ -require "action_dispatch/journey/scanner" -require "action_dispatch/journey/nodes/node" - -module ActionDispatch - # :stopdoc: - module Journey - class Parser < Racc::Parser - include Journey::Nodes - - def self.parse(string) - new.parse string - end - - def initialize - @scanner = Scanner.new - end - - def parse(string) - @scanner.scan_setup(string) - do_parse - end - - def next_token - @scanner.next_token - end - end - end - # :startdoc: -end diff --git a/actionpack/lib/action_dispatch/journey/path/pattern.rb b/actionpack/lib/action_dispatch/journey/path/pattern.rb index cf0108ec32f62..c0f24d3145bec 100644 --- a/actionpack/lib/action_dispatch/journey/path/pattern.rb +++ b/actionpack/lib/action_dispatch/journey/path/pattern.rb @@ -1,26 +1,29 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Journey # :nodoc: module Path # :nodoc: class Pattern # :nodoc: - attr_reader :spec, :requirements, :anchored + REGEXP_CACHE = {} - def self.from_string(string) - build(string, {}, "/.?", true) + class << self + def dedup_regexp(regexp) + REGEXP_CACHE[regexp.source] ||= regexp + end end - def self.build(path, requirements, separators, anchored) - parser = Journey::Parser.new - ast = parser.parse path - new ast, requirements, separators, anchored - end + attr_reader :ast, :names, :requirements, :anchored, :spec def initialize(ast, requirements, separators, anchored) - @spec = ast + @ast = ast + @spec = ast.root @requirements = requirements @separators = separators @anchored = anchored - @names = nil + @names = ast.names @optional_names = nil @required_names = nil @re = nil @@ -35,25 +38,29 @@ def eager_load! required_names offsets to_regexp - nil + @ast = nil end - def ast - @spec.find_all(&:symbol?).each do |node| - re = @requirements[node.to_sym] - node.regexp = re if re - end + def requirements_anchored? + # each required param must not be surrounded by a literal, otherwise it isn't + # simple to chunk-match the url piecemeal + terminals = ast.terminals - @spec.find_all(&:star?).each do |node| - node = node.left - node.regexp = @requirements[node.to_sym] || /(.+)/ - end + terminals.each_with_index { |s, index| + next if index < 1 + next if s.type == :DOT || s.type == :SLASH - @spec - end + back = terminals[index - 1] + fwd = terminals[index + 1] + + # we also don't support this yet, constraints must be regexps + return false if s.symbol? && s.regexp.is_a?(Array) + + return false if back.literal? + return false if !fwd.nil? && fwd.literal? + } - def names - @names ||= spec.find_all(&:symbol?).map(&:name) + true end def required_names @@ -75,11 +82,11 @@ 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) - [visit(node.left), visit(node.right)].join + "#{visit(node.left)}#{visit(node.right)}" end def visit_SYMBOL(node) @@ -88,7 +95,7 @@ def visit_SYMBOL(node) return @separator_re unless @matchers.key?(node) re = @matchers[node] - "(#{re})" + "(#{Regexp.union(re)})" end def visit_GROUP(node) @@ -105,8 +112,8 @@ def visit_SLASH(node) end def visit_STAR(node) - re = @matchers[node.left.to_sym] || ".+" - "(#{re})" + re = @matchers[node.left.to_sym] + re ? "(#{re})" : "(.+)" end def visit_OR(node) @@ -117,7 +124,8 @@ def visit_OR(node) class UnanchoredRegexp < AnchoredRegexp # :nodoc: def accept(node) - %r{\A#{visit node}} + path = visit node + path == "/" ? %r{\A/} : Pattern.dedup_regexp(%r{\A#{path}(?:\b|\Z|/)}) end end @@ -134,6 +142,10 @@ def captures Array.new(length - 1) { |i| self[i + 1] } end + def named_captures + @names.zip(captures).to_h + end + def [](x) idx = @offsets[x - 1] + x @match[idx] @@ -158,6 +170,10 @@ def match(other) end alias :=~ :match + def match?(other) + to_regexp.match?(other) + end + def source to_regexp.source end @@ -166,29 +182,34 @@ def to_regexp @re ||= regexp_visitor.new(@separators, @requirements).accept spec end - private + def requirements_for_missing_keys_check + @requirements_for_missing_keys_check ||= requirements.transform_values do |regex| + Pattern.dedup_regexp(/\A#{regex}\Z/) + end + end + private def regexp_visitor @anchored ? AnchoredRegexp : UnanchoredRegexp end def offsets - return @offsets if @offsets - - @offsets = [0] - - spec.find_all(&:symbol?).each do |node| - node = node.to_sym - - if @requirements.key?(node) - re = /#{@requirements[node]}|/ - @offsets.push((re.match("").length - 1) + @offsets.last) - else - @offsets << @offsets.last + @offsets ||= begin + offsets = [0] + + spec.find_all(&:symbol?).each do |node| + node = node.to_sym + + if @requirements.key?(node) + re = Pattern.dedup_regexp(/#{Regexp.union(@requirements[node])}|/) + offsets.push((re.match("").length - 1) + offsets.last) + else + offsets << offsets.last + end end - end - @offsets + offsets + end end end end diff --git a/actionpack/lib/action_dispatch/journey/route.rb b/actionpack/lib/action_dispatch/journey/route.rb index 927fd369c46f3..a745ac309f0e2 100644 --- a/actionpack/lib/action_dispatch/journey/route.rb +++ b/actionpack/lib/action_dispatch/journey/route.rb @@ -1,20 +1,25 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch # :stopdoc: module Journey class Route - attr_reader :app, :path, :defaults, :name, :precedence + attr_reader :app, :path, :defaults, :name, :precedence, :constraints, + :internal, :scope_options, :ast, :source_location - attr_reader :constraints, :internal alias :conditions :constraints module VerbMatchers VERBS = %w{ DELETE GET HEAD OPTIONS LINK PATCH POST PUT TRACE UNLINK } VERBS.each do |v| - class_eval <<-eoc - class #{v} - def self.verb; name.split("::").last; end - def self.call(req); req.#{v.downcase}?; end - end + class_eval <<-eoc, __FILE__, __LINE__ + 1 + # frozen_string_literal: true + class #{v} + def self.verb; name.split("::").last; end + def self.call(req); req.#{v.downcase}?; end + end eoc end @@ -25,7 +30,7 @@ def initialize(verb) @verb = verb end - def call(request); @verb === request.request_method; end + def call(request); @verb == request.request_method; end end class All @@ -33,66 +38,84 @@ def self.call(_); true; end def self.verb; ""; end end + class Or + attr_reader :verb + + def initialize(verbs) + @verbs = verbs + @verb = @verbs.map(&:verb).join("|") + end + + def call(req) + @verbs.any? { |v| v.call req } + end + end + VERB_TO_CLASS = VERBS.each_with_object(all: All) do |verb, hash| klass = const_get verb hash[verb] = klass hash[verb.downcase] = klass hash[verb.downcase.to_sym] = klass end - end - def self.verb_matcher(verb) - VerbMatchers::VERB_TO_CLASS.fetch(verb) do + VERB_TO_CLASS.default_proc = proc do |_, verb| VerbMatchers::Unknown.new verb.to_s.dasherize.upcase end - end - def self.build(name, app, path, constraints, required_defaults, defaults) - request_method_match = verb_matcher(constraints.delete(:request_method)) - new name, app, path, constraints, required_defaults, defaults, request_method_match, 0 + def self.for(verbs) + if verbs.any? { |v| VERB_TO_CLASS[v] == All } + All + elsif verbs.one? + VERB_TO_CLASS[verbs.first] + else + Or.new(verbs.map { |v| VERB_TO_CLASS[v] }) + end + end end ## # +path+ is a path constraint. - # +constraints+ is a hash of constraints to be applied to this route. - def initialize(name, app, path, constraints, required_defaults, defaults, request_method_match, precedence, internal = false) + # `constraints` is a hash of constraints to be applied to this route. + def initialize(name:, app: nil, path:, constraints: {}, required_defaults: [], defaults: {}, via: nil, precedence: 0, scope_options: {}, internal: false, source_location: nil) @name = name @app = app @path = path - @request_method_match = request_method_match + @request_method_match = via && VerbMatchers.for(via) @constraints = constraints @defaults = defaults @required_defaults = nil @_required_defaults = required_defaults @required_parts = nil @parts = nil - @decorated_ast = nil @precedence = precedence @path_formatter = @path.build_formatter + @scope_options = scope_options @internal = internal + @source_location = source_location + + @ast = @path.ast.root + @path.ast.route = self end def eager_load! path.eager_load! - ast parts required_defaults nil end - def ast - @decorated_ast ||= begin - decorated_ast = path.ast - decorated_ast.find_all(&:terminal?).each { |n| n.memo = self } - decorated_ast - end - end - + # Needed for `bin/rails routes`. Picks up succinctly defined requirements for a + # route, for example route + # + # get 'photo/:id', :controller => 'photos', :action => 'show', + # :id => /[A-Z]\d{5}/ + # + # will have {:controller=>"photos", :action=>"show", :[id=>/](A-Z){5}/} as + # requirements. def requirements - # needed for rails `rails routes` @defaults.merge(path.requirements).delete_if { |_, v| - /.+?/ == v + /.+?/m == v } end @@ -105,18 +128,11 @@ def required_keys end def score(supplied_keys) - required_keys = path.required_names - - required_keys.each do |k| + path.required_names.each do |k| return -1 unless supplied_keys.include?(k) end - score = 0 - path.names.each do |k| - score += 1 if supplied_keys.include?(k) - end - - score + (required_defaults.length * 2) + (required_defaults.length * 2) + path.names.count { |k| supplied_keys.include?(k) } end def parts @@ -143,7 +159,7 @@ def required_defaults end def glob? - !path.spec.grep(Nodes::Star).empty? + path.ast.glob? end def dispatcher? @@ -151,21 +167,23 @@ def dispatcher? end def matches?(request) - match_verb(request) && - constraints.all? { |method, value| - case value - when Regexp, String - value === request.send(method).to_s - when Array - value.include?(request.send(method)) - when TrueClass - request.send(method).present? - when FalseClass - request.send(method).blank? - else - value === request.send(method) - end - } + @request_method_match.call(request) && ( + constraints.empty? || + constraints.all? { |method, value| + case value + when Regexp, String + value === request.send(method).to_s + when Array + value.include?(request.send(method)) + when TrueClass + request.send(method).present? + when FalseClass + request.send(method).blank? + else + value === request.send(method) + end + } + ) end def ip @@ -173,21 +191,12 @@ def ip end def requires_matching_verb? - !@request_method_match.all? { |x| x == VerbMatchers::All } + @request_method_match != VerbMatchers::All end def verb - verbs.join("|") + @request_method_match.verb end - - private - def verbs - @request_method_match.map(&:verb) - end - - def match_verb(request) - @request_method_match.any? { |m| m.call request } - end end end # :startdoc: diff --git a/actionpack/lib/action_dispatch/journey/router.rb b/actionpack/lib/action_dispatch/journey/router.rb index d55e1399e4ebc..fc8ffd0f3a1ae 100644 --- a/actionpack/lib/action_dispatch/journey/router.rb +++ b/actionpack/lib/action_dispatch/journey/router.rb @@ -1,21 +1,19 @@ +# frozen_string_literal: true + +# :markup: markdown + +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" - -before = $-w -$-w = false require "action_dispatch/journey/parser" -$-w = before - require "action_dispatch/journey/route" require "action_dispatch/journey/path/pattern" module ActionDispatch module Journey # :nodoc: class Router # :nodoc: - class RoutingError < ::StandardError # :nodoc: - end - attr_accessor :routes def initialize(routes) @@ -23,71 +21,85 @@ def initialize(routes) end def eager_load! - # Eagerly trigger the simulator's initialization so - # it doesn't happen during a request cycle. + # Eagerly trigger the simulator's initialization so it doesn't happen during a + # request cycle. simulator nil end def serve(req) - find_routes(req).each do |match, parameters, route| - set_params = req.path_parameters - path_info = req.path_info - script_name = req.script_name - - unless route.path.anchored - req.script_name = (script_name.to_s + match.to_s).chomp("/") - req.path_info = match.post_match - req.path_info = "/" + req.path_info unless req.path_info.start_with? "/" - end - - req.path_parameters = set_params.merge parameters + recognize(req) do |route, parameters| + req.path_parameters = parameters + req.route = route - status, headers, body = route.app.serve(req) - - if "pass" == headers["X-Cascade"] - req.script_name = script_name - req.path_info = path_info - req.path_parameters = set_params - next - end + _, headers, _ = response = route.app.serve(req) - return [status, headers, body] + return response unless headers[Constants::X_CASCADE] == "pass" end - return [404, { "X-Cascade" => "pass" }, ["Not Found"]] + [404, { Constants::X_CASCADE => "pass" }, ["Not Found"]] end - def recognize(rails_req) - find_routes(rails_req).each do |match, parameters, route| - unless route.path.anchored - rails_req.script_name = match.to_s - rails_req.path_info = match.post_match.sub(/^([^\/])/, '/\1') - end + def recognize(req, &block) + req_params = req.path_parameters + path_info = req.path_info + script_name = req.script_name - yield(route, parameters) - end - end + routes = filter_routes(path_info) - def visualizer - tt = GTG::Builder.new(ast).transition_table - groups = partitioned_routes.first.map(&:ast).group_by(&:to_s) - asts = groups.values.map(&:first) - tt.visualizer(asts) - end + custom_routes.each { |r| + routes << r if r.path.match?(path_info) + } - private + if req.head? + routes = match_head_routes(routes, req) + else + routes.select! { |r| r.matches?(req) } + end - def partitioned_routes - routes.partition { |r| - r.path.anchored && r.ast.grep(Nodes::Symbol).all? { |n| n.default_regexp? } - } + if routes.size > 1 + routes.sort! do |a, b| + a.precedence <=> b.precedence + end end - def ast - routes.ast + routes.each do |r| + match_data = r.path.match(path_info) + + path_parameters = req_params.merge r.defaults + + index = 1 + match_data.names.each do |name| + if val = match_data[index] + val = if val.include?("%") + CGI.unescapeURIComponent(val) + else + val + end + val.force_encoding(::Encoding::UTF_8) + path_parameters[name.to_sym] = val + end + index += 1 + end + + if r.path.anchored + yield(r, path_parameters) + else + req.script_name = (script_name.to_s + match_data.to_s).chomp("/") + req.path_info = match_data.post_match + req.path_info = "/" + req.path_info unless req.path_info.start_with? "/" + + yield(r, path_parameters) + + req.script_name = script_name + req.path_info = path_info + end + + req.path_parameters = req_params end + end + private def simulator routes.simulator end @@ -97,53 +109,21 @@ def custom_routes end def filter_routes(path) - return [] unless ast simulator.memos(path) { [] } end - def find_routes(req) - routes = filter_routes(req.path_info).concat custom_routes.find_all { |r| - r.path.match(req.path_info) - } - - routes = - if req.head? - match_head_routes(routes, req) - else - match_routes(routes, req) - end - - routes.sort_by!(&:precedence) - - routes.map! { |r| - match_data = r.path.match(req.path_info) - path_parameters = r.defaults.dup - match_data.names.zip(match_data.captures) { |name, val| - path_parameters[name.to_sym] = Utils.unescape_uri(val) if val - } - [match_data, path_parameters, r] - } - end - def match_head_routes(routes, req) - verb_specific_routes = routes.select(&:requires_matching_verb?) - head_routes = match_routes(verb_specific_routes, req) - - if head_routes.empty? - begin - req.request_method = "GET" - match_routes(routes, req) - ensure - req.request_method = "HEAD" - end - else - head_routes + head_routes = routes.select { |r| r.requires_matching_verb? && r.matches?(req) } + return head_routes unless head_routes.empty? + + begin + req.request_method = "GET" + routes.select! { |r| r.matches?(req) } + routes + ensure + req.request_method = "HEAD" end end - - def match_routes(routes, req) - routes.select { |r| r.matches?(req) } - end end end end diff --git a/actionpack/lib/action_dispatch/journey/router/utils.rb b/actionpack/lib/action_dispatch/journey/router/utils.rb index d6416423383c7..0e99bc6c561df 100644 --- a/actionpack/lib/action_dispatch/journey/router/utils.rb +++ b/actionpack/lib/action_dispatch/journey/router/utils.rb @@ -1,45 +1,60 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Journey # :nodoc: class Router # :nodoc: class Utils # :nodoc: # Normalizes URI path. # - # Strips off trailing slash and ensures there is a leading slash. - # Also converts downcase url encoded string to uppercase. + # Strips off trailing slash and ensures there is a leading slash. Also converts + # downcase URL encoded string to uppercase. # - # normalize_path("/foo") # => "/foo" - # normalize_path("/foo/") # => "/foo" - # normalize_path("foo") # => "/foo" - # normalize_path("") # => "/" - # normalize_path("/%ab") # => "/%AB" + # normalize_path("/foo") # => "/foo" + # normalize_path("/foo/") # => "/foo" + # normalize_path("foo") # => "/foo" + # normalize_path("") # => "/" + # normalize_path("/%ab") # => "/%AB" def self.normalize_path(path) - path = "/#{path}" - path.squeeze!("/".freeze) - path.sub!(%r{/+\Z}, "".freeze) - path.gsub!(/(%[a-f0-9]{2})/) { $1.upcase } - path = "/" if path == "".freeze - path + return "/".dup unless path + + # Fast path for the overwhelming majority of paths that don't need to be normalized + if path == "/" || (path.start_with?("/") && !path.end_with?("/") && !path.match?(%r{%|//})) + return path.dup + end + + # Slow path + encoding = path.encoding + path = +"/#{path}" + path.squeeze!("/") + + unless path == "/" + path.delete_suffix!("/") + path.gsub!(/(%[a-f0-9]{2})/) { $1.upcase } + end + + path.force_encoding(encoding) end - # URI path and fragment escaping - # http://tools.ietf.org/html/rfc3986 + # URI path and fragment escaping https://tools.ietf.org/html/rfc3986 class UriEncoder # :nodoc: - ENCODE = "%%%02X".freeze + ENCODE = "%%%02X" US_ASCII = Encoding::US_ASCII UTF_8 = Encoding::UTF_8 - EMPTY = "".force_encoding(US_ASCII).freeze - DEC2HEX = (0..255).to_a.map { |i| ENCODE % i }.map { |s| s.force_encoding(US_ASCII) } + EMPTY = (+"").force_encoding(US_ASCII).freeze + DEC2HEX = (0..255).map { |i| (ENCODE % i).force_encoding(US_ASCII) } - ALPHA = "a-zA-Z".freeze - DIGIT = "0-9".freeze - UNRESERVED = "#{ALPHA}#{DIGIT}\\-\\._~".freeze - SUB_DELIMS = "!\\$&'\\(\\)\\*\\+,;=".freeze + ALPHA = "a-zA-Z" + DIGIT = "0-9" + UNRESERVED = "#{ALPHA}#{DIGIT}\\-\\._~" + SUB_DELIMS = "!\\$&'\\(\\)\\*\\+,;=" - ESCAPED = /%[a-zA-Z0-9]{2}/.freeze + ESCAPED = /%[a-zA-Z0-9]{2}/ - FRAGMENT = /[^#{UNRESERVED}#{SUB_DELIMS}:@\/\?]/.freeze - SEGMENT = /[^#{UNRESERVED}#{SUB_DELIMS}:@]/.freeze - PATH = /[^#{UNRESERVED}#{SUB_DELIMS}:@\/]/.freeze + FRAGMENT = /[^#{UNRESERVED}#{SUB_DELIMS}:@\/?]/ + SEGMENT = /[^#{UNRESERVED}#{SUB_DELIMS}:@]/ + PATH = /[^#{UNRESERVED}#{SUB_DELIMS}:@\/]/ def escape_fragment(fragment) escape(fragment, FRAGMENT) @@ -53,17 +68,12 @@ def escape_segment(segment) escape(segment, SEGMENT) end - def unescape_uri(uri) - encoding = uri.encoding == US_ASCII ? UTF_8 : uri.encoding - uri.gsub(ESCAPED) { |match| [match[1, 2].hex].pack("C") }.force_encoding(encoding) - end - private - def escape(component, pattern) # :doc: + def escape(component, pattern) component.gsub(pattern) { |unsafe| percent_encode(unsafe) }.force_encoding(US_ASCII) end - def percent_encode(unsafe) # :doc: + def percent_encode(unsafe) safe = EMPTY.dup unsafe.each_byte { |b| safe << DEC2HEX[b] } safe @@ -83,10 +93,6 @@ def self.escape_segment(segment) def self.escape_fragment(fragment) ENCODER.escape_fragment(fragment.to_s) end - - def self.unescape_uri(uri) - ENCODER.unescape_uri(uri) - end end end end diff --git a/actionpack/lib/action_dispatch/journey/routes.rb b/actionpack/lib/action_dispatch/journey/routes.rb index f7b009109ea3c..326eb76f0620e 100644 --- a/actionpack/lib/action_dispatch/journey/routes.rb +++ b/actionpack/lib/action_dispatch/journey/routes.rb @@ -1,14 +1,18 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Journey # :nodoc: - # The Routing table. Contains all routes for a system. Routes can be - # added to the table by calling Routes#add_route. + # The Routing table. Contains all routes for a system. Routes can be added to + # the table by calling Routes#add_route. class Routes # :nodoc: include Enumerable attr_reader :routes, :custom_routes, :anchored_routes - def initialize - @routes = [] + def initialize(routes = []) + @routes = routes @ast = nil @anchored_routes = [] @custom_routes = [] @@ -39,7 +43,7 @@ def clear end def partition_route(route) - if route.path.anchored && route.ast.grep(Nodes::Symbol).all?(&:default_regexp?) + if route.path.anchored && route.path.requirements_anchored? anchored_routes << route else custom_routes << route @@ -48,8 +52,8 @@ def partition_route(route) def ast @ast ||= begin - asts = anchored_routes.map(&:ast) - Nodes::Or.new(asts) unless asts.empty? + nodes = anchored_routes.map(&:ast) + Nodes::Or.new(nodes) end end @@ -68,8 +72,14 @@ def add_route(name, mapping) route end - private + def visualizer + tt = GTG::Builder.new(ast).transition_table + groups = anchored_routes.map(&:ast).group_by(&:to_s) + asts = groups.values.map(&:first) + tt.visualizer(asts) + end + private def clear_cache! @ast = nil @simulator = nil diff --git a/actionpack/lib/action_dispatch/journey/scanner.rb b/actionpack/lib/action_dispatch/journey/scanner.rb index 7dbb39b26d3dd..02fc2e11855ea 100644 --- a/actionpack/lib/action_dispatch/journey/scanner.rb +++ b/actionpack/lib/action_dispatch/journey/scanner.rb @@ -1,63 +1,74 @@ # frozen_string_literal: true + +# :markup: markdown + require "strscan" module ActionDispatch module Journey # :nodoc: class Scanner # :nodoc: + STATIC_TOKENS = Array.new(150) + STATIC_TOKENS[".".ord] = :DOT + STATIC_TOKENS["/".ord] = :SLASH + STATIC_TOKENS["(".ord] = :LPAREN + STATIC_TOKENS[")".ord] = :RPAREN + STATIC_TOKENS["|".ord] = :OR + STATIC_TOKENS[":".ord] = :SYMBOL + STATIC_TOKENS["*".ord] = :STAR + STATIC_TOKENS.freeze + + class Scanner < StringScanner + unless method_defined?(:peek_byte) # https://github.com/ruby/strscan/pull/89 + def peek_byte + string.getbyte(pos) + end + end + end + def initialize - @ss = nil + @scanner = nil + @length = nil end def scan_setup(str) - @ss = StringScanner.new(str) + @scanner = Scanner.new(str) end - def eos? - @ss.eos? - end + def next_token + return if @scanner.eos? - def pos - @ss.pos + until token = scan || @scanner.eos?; end + token end - def pre_match - @ss.pre_match + def last_string + -@scanner.string.byteslice(@scanner.pos - @length, @length) end - def next_token - return if @ss.eos? - - until token = scan || @ss.eos?; end - token + def last_literal + last_str = @scanner.string.byteslice(@scanner.pos - @length, @length) + last_str.tr! "\\", "" + -last_str end private - def scan + next_byte = @scanner.peek_byte case - # / - when @ss.skip(/\//) - [:SLASH, "/"] - when @ss.skip(/\(/) - [:LPAREN, "("] - when @ss.skip(/\)/) - [:RPAREN, ")"] - when @ss.skip(/\|/) - [:OR, "|"] - when @ss.skip(/\./) - [:DOT, "."] - when text = @ss.scan(/:\w+/) - [:SYMBOL, text] - when text = @ss.scan(/\*\w+/) - [:STAR, text] - when text = @ss.scan(/(?:[\w%\-~!$&'*+,;=@]|\\[:()])+/) - text.tr! "\\", "" - [:LITERAL, text] - # any char - when text = @ss.scan(/./) - [:LITERAL, text] + when (token = STATIC_TOKENS[next_byte]) && (token != :SYMBOL || next_byte_is_not_a_token?) + @scanner.pos += 1 + @length = @scanner.skip(/\w+/).to_i + 1 if token == :SYMBOL || token == :STAR + token + when @length = @scanner.skip(/(?:[\w%\-~!$&'*+,;=@]|\\[:()])+/) + :LITERAL + when @length = @scanner.skip(/./) + :LITERAL end end + + def next_byte_is_not_a_token? + !STATIC_TOKENS[@scanner.string.getbyte(@scanner.pos + 1)] + end end end end diff --git a/actionpack/lib/action_dispatch/journey/visitors.rb b/actionpack/lib/action_dispatch/journey/visitors.rb index 1c50192867e90..e1cfc9824608d 100644 --- a/actionpack/lib/action_dispatch/journey/visitors.rb +++ b/actionpack/lib/action_dispatch/journey/visitors.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch # :stopdoc: module Journey @@ -38,7 +42,7 @@ def evaluate(hash) @parameters.each do |index| param = parts[index] value = hash[param.name] - return "".freeze unless value + return "" if value.nil? parts[index] = param.escape value end @@ -57,7 +61,6 @@ def accept(node) end private - def visit(node) send(DISPATCH_CACHE[node.type], node) end @@ -125,8 +128,8 @@ def visit_SLASH(n, seed); terminal(n, seed); end def visit_DOT(n, seed); terminal(n, seed); end instance_methods(false).each do |pim| - next unless pim =~ /^visit_(.*)$/ - DISPATCH_CACHE[$1.to_sym] = pim + next unless pim.start_with?("visit_") + DISPATCH_CACHE[pim.name.delete_prefix("visit_").to_sym] = pim end end @@ -154,7 +157,7 @@ def visit_SYMBOL(n) end end - # Loop through the requirements AST + # Loop through the requirements AST. class Each < FunctionalVisitor # :nodoc: def visit(node, block) block.call(node) @@ -164,33 +167,64 @@ def visit(node, block) INSTANCE = new end - class String < FunctionalVisitor # :nodoc: - private - - def binary(node, seed) - visit(node.right, visit(node.left, seed)) - end - - def nary(node, seed) + class String # :nodoc: + def accept(node, seed) + case node.type + when :DOT + seed << node.left + when :LITERAL + seed << node.left + when :SYMBOL + seed << node.left + when :SLASH + seed << node.left + when :CAT + accept(node.right, accept(node.left, seed)) + when :STAR + accept(node.left, seed) + when :OR last_child = node.children.last - node.children.inject(seed) { |s, c| - string = visit(c, s) - string << "|".freeze unless last_child == c - string - } - end - - def terminal(node, seed) - seed + node.left - end - - def visit_GROUP(node, seed) - visit(node.left, seed << "(".freeze) << ")".freeze + node.children.each do |c| + accept(c, seed) + seed << "|" unless last_child == c + end + seed + when :GROUP + accept(node.left, seed << "(") << ")" + else + raise "Unknown node type: #{node.type}" end + end - INSTANCE = new + INSTANCE = new end + # class String < FunctionalVisitor # :nodoc: + # private + # def binary(node, seed) + # visit(node.right, visit(node.left, seed)) + # end + # + # def nary(node, seed) + # last_child = node.children.last + # node.children.inject(seed) { |s, c| + # string = visit(c, s) + # string << "|" unless last_child == c + # string + # } + # end + # + # def terminal(node, seed) + # seed + node.left + # end + # + # def visit_GROUP(node, seed) + # visit(node.left, seed.dup << "(") << ")" + # end + # + # INSTANCE = new + # end + class Dot < FunctionalVisitor # :nodoc: def initialize @nodes = [] @@ -212,7 +246,6 @@ def accept(node, seed = [[], []]) end private - def binary(node, seed) seed.last.concat node.children.map { |c| "#{node.object_id} -> #{c.object_id};" diff --git a/actionpack/lib/action_dispatch/journey/visualizer/fsm.js b/actionpack/lib/action_dispatch/journey/visualizer/fsm.js index d9bcaef928016..caacd60c0ad12 100644 --- a/actionpack/lib/action_dispatch/journey/visualizer/fsm.js +++ b/actionpack/lib/action_dispatch/journey/visualizer/fsm.js @@ -68,7 +68,7 @@ function highlight_state(index, color) { } function highlight_finish(index) { - svg_nodes[index].select('polygon') + svg_nodes[index].select('ellipse') .style("fill", "while") .transition().duration(500) .style("fill", "blue"); @@ -76,54 +76,77 @@ function highlight_finish(index) { function match(input) { reset_graph(); - var table = tt(); - var states = [0]; - var regexp_states = table['regexp_states']; - var string_states = table['string_states']; - var accepting = table['accepting']; + var table = tt(); + var states = [[0, null]]; + var regexp_states = table['regexp_states']; + var string_states = table['string_states']; + var stdparam_states = table['stdparam_states']; + var accepting = table['accepting']; + var default_re = new RegExp("^[^.\/?]+$"); + var start_index = 0; highlight_state(0); tokenize(input, function(token) { + var end_index = start_index + token.length; + var new_states = []; for(var key in states) { - var state = states[key]; + var state_parts = states[key]; + var state = state_parts[0]; + var previous_start = state_parts[1]; + + if(previous_start == null) { + if(string_states[state] && string_states[state][token]) { + var new_state = string_states[state][token]; + highlight_edge(state, new_state); + highlight_state(new_state); + new_states.push([new_state, null]); + } - if(string_states[state] && string_states[state][token]) { - var new_state = string_states[state][token]; - highlight_edge(state, new_state); - highlight_state(new_state); - new_states.push(new_state); + if(stdparam_states[state] && default_re.test(token)) { + var new_state = stdparam_states[state]; + highlight_edge(state, new_state); + highlight_state(new_state); + new_states.push([new_state, null]); + } } if(regexp_states[state]) { + var slice_start = previous_start != null ? previous_start : start_index; + for(var key in regexp_states[state]) { var re = new RegExp("^" + key + "$"); - if(re.test(token)) { + + var accumulation = input.slice(slice_start, end_index); + + if(re.test(accumulation)) { var new_state = regexp_states[state][key]; highlight_edge(state, new_state); highlight_state(new_state); - new_states.push(new_state); + new_states.push([new_state, null]); } + + // retry the same regexp with the accumulated data either way + new_states.push([state, slice_start]); } } } - if(new_states.length == 0) { - return; - } states = new_states; + start_index = end_index; }); for(var key in states) { - var state = states[key]; + var state_parts = states[key]; + var state = state_parts[0]; + var slice_start = state_parts[1]; + + // we must ignore ones that are still accepting more data + if (slice_start != null) continue; + if(accepting[state]) { - for(var mkey in svg_edges[state]) { - if(!regexp_states[mkey] && !string_states[mkey]) { - highlight_edge(state, mkey); - highlight_finish(mkey); - } - } + highlight_finish(state); } else { highlight_state(state, "red"); } diff --git a/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb b/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb index 9b28a652009e5..40d3073f95d38 100644 --- a/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb +++ b/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb @@ -8,7 +8,7 @@ <%= style %> <% end %> - +
diff --git a/actionpack/lib/action_dispatch/log_subscriber.rb b/actionpack/lib/action_dispatch/log_subscriber.rb new file mode 100644 index 0000000000000..94161c9563214 --- /dev/null +++ b/actionpack/lib/action_dispatch/log_subscriber.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ActionDispatch + class LogSubscriber < ActiveSupport::EventReporter::LogSubscriber # :nodoc: + class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new + + self.namespace = "action_dispatch" + + def redirect(event) + 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} #{status_name} in #{payload[:duration_ms].round}ms" + message << "\n\n" if defined?(Rails.env) && Rails.env.development? + + message + end + end + event_log_level :redirect, :info + + def self.default_logger + ActionController::Base.logger + end + end +end + +ActiveSupport.event_reporter.subscribe( + ActionDispatch::LogSubscriber.new, &ActionDispatch::LogSubscriber.subscription_filter +) diff --git a/actionpack/lib/action_dispatch/middleware/actionable_exceptions.rb b/actionpack/lib/action_dispatch/middleware/actionable_exceptions.rb new file mode 100644 index 0000000000000..03dabc7337543 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/actionable_exceptions.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "uri" +require "active_support/actionable_error" + +module ActionDispatch + class ActionableExceptions # :nodoc: + cattr_accessor :endpoint, default: "/rails/actions" + + def initialize(app) + @app = app + end + + def call(env) + request = ActionDispatch::Request.new(env) + return @app.call(env) unless actionable_request?(request) + + ActiveSupport::ActionableError.dispatch(request.params[:error].to_s.safe_constantize, request.params[:action]) + + redirect_to request.params[:location] + end + + private + def actionable_request?(request) + request.get_header("action_dispatch.show_detailed_exceptions") && request.post? && request.path == endpoint + end + + def redirect_to(location) + uri = URI.parse location + + if uri.relative? || uri.scheme == "http" || uri.scheme == "https" + body = "" + else + return [400, { Rack::CONTENT_TYPE => "text/plain; charset=utf-8" }, ["Invalid redirection URI"]] + end + + [302, { + Rack::CONTENT_TYPE => "text/html; charset=#{Response.default_charset}", + Rack::CONTENT_LENGTH => body.bytesize.to_s, + ActionDispatch::Constants::LOCATION => location, + }, [body]] + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/assume_ssl.rb b/actionpack/lib/action_dispatch/middleware/assume_ssl.rb new file mode 100644 index 0000000000000..3569cc1f911ef --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/assume_ssl.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionDispatch + # # Action Dispatch AssumeSSL + # + # When proxying through a load balancer that terminates SSL, the forwarded + # request will appear as though it's HTTP instead of HTTPS to the application. + # This makes redirects and cookie security target HTTP instead of HTTPS. This + # middleware makes the server assume that the proxy already terminated SSL, and + # that the request really is HTTPS. + class AssumeSSL + def initialize(app) + @app = app + end + + def call(env) + env["HTTPS"] = "on" + env["HTTP_X_FORWARDED_PORT"] = "443" + env["HTTP_X_FORWARDED_PROTO"] = "https" + env["rack.url_scheme"] = "https" + + @app.call(env) + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/callbacks.rb b/actionpack/lib/action_dispatch/middleware/callbacks.rb index ff129cf96a30c..e4d2b08352e02 100644 --- a/actionpack/lib/action_dispatch/middleware/callbacks.rb +++ b/actionpack/lib/action_dispatch/middleware/callbacks.rb @@ -1,4 +1,10 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch + # # Action Dispatch Callbacks + # # Provides callbacks to be executed before and after dispatching the request. class Callbacks include ActiveSupport::Callbacks @@ -22,10 +28,8 @@ def initialize(app) def call(env) error = nil result = run_callbacks :call do - begin - @app.call(env) - rescue => error - end + @app.call(env) + rescue => error end raise error if error result diff --git a/actionpack/lib/action_dispatch/middleware/cookies.rb b/actionpack/lib/action_dispatch/middleware/cookies.rb index c61cb3fd68e0e..351daf9224167 100644 --- a/actionpack/lib/action_dispatch/middleware/cookies.rb +++ b/actionpack/lib/action_dispatch/middleware/cookies.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/hash/keys" require "active_support/key_generator" require "active_support/message_verifier" @@ -5,9 +9,9 @@ require "rack/utils" module ActionDispatch - class Request + module RequestCookieMethods def cookie_jar - fetch_header("action_dispatch.cookies".freeze) do + fetch_header("action_dispatch.cookies") do self.cookie_jar = Cookies::CookieJar.build(self, cookies) end end @@ -20,11 +24,11 @@ def commit_cookie_jar! } def have_cookie_jar? - has_header? "action_dispatch.cookies".freeze + has_header? "action_dispatch.cookies" end def cookie_jar=(jar) - set_header "action_dispatch.cookies".freeze, jar + set_header "action_dispatch.cookies", jar end def key_generator @@ -43,8 +47,20 @@ def encrypted_signed_cookie_salt get_header Cookies::ENCRYPTED_SIGNED_COOKIE_SALT end - def secret_token - get_header Cookies::SECRET_TOKEN + def authenticated_encrypted_cookie_salt + get_header Cookies::AUTHENTICATED_ENCRYPTED_COOKIE_SALT + end + + def use_authenticated_cookie_encryption + get_header Cookies::USE_AUTHENTICATED_COOKIE_ENCRYPTION + end + + def encrypted_cookie_cipher + get_header Cookies::ENCRYPTED_COOKIE_CIPHER + end + + def signed_cookie_digest + get_header Cookies::SIGNED_COOKIE_DIGEST end def secret_key_base @@ -55,104 +71,143 @@ def cookies_serializer get_header Cookies::COOKIES_SERIALIZER end + def cookies_same_site_protection + get_header(Cookies::COOKIES_SAME_SITE_PROTECTION)&.call(self) + end + def cookies_digest get_header Cookies::COOKIES_DIGEST end + + def cookies_rotations + get_header Cookies::COOKIES_ROTATIONS + end + + def use_cookies_with_metadata + get_header Cookies::USE_COOKIES_WITH_METADATA + end + # :startdoc: end - # \Cookies are read and written through ActionController#cookies. + ActiveSupport.on_load(:action_dispatch_request) do + include RequestCookieMethods + end + + # Read and write data to cookies through ActionController::Cookies#cookies. # - # The cookies being read are the ones received along with the request, the cookies - # being written will be sent out with the response. Reading a cookie does not get - # the cookie object itself back, just the value it holds. + # When reading cookie data, the data is read from the HTTP request header, + # Cookie. When writing cookie data, the data is sent out in the HTTP response + # header, `Set-Cookie`. # # Examples of writing: # - # # Sets a simple session cookie. - # # This cookie will be deleted when the user's browser is closed. - # cookies[:user_name] = "david" + # # Sets a simple session cookie. + # # This cookie will be deleted when the user's browser is closed. + # cookies[:user_name] = "david" # - # # Cookie values are String based. Other data types need to be serialized. - # cookies[:lat_lon] = JSON.generate([47.68, -122.37]) + # # Cookie values are String-based. Other data types need to be serialized. + # cookies[:lat_lon] = JSON.generate([47.68, -122.37]) # - # # Sets a cookie that expires in 1 hour. - # cookies[:login] = { value: "XJ-122", expires: 1.hour.from_now } + # # Sets a cookie that expires in 1 hour. + # cookies[:login] = { value: "XJ-122", expires: 1.hour } # - # # Sets a signed cookie, which prevents users from tampering with its value. - # # The cookie is signed by your app's `secrets.secret_key_base` value. - # # It can be read using the signed method `cookies.signed[:name]` - # cookies.signed[:user_id] = current_user.id + # # Sets a cookie that expires at a specific time. + # cookies[:login] = { value: "XJ-122", expires: Time.utc(2020, 10, 15, 5) } # - # # Sets an encrypted cookie value before sending it to the client which - # # prevent users from reading and tampering with its value. - # # The cookie is signed by your app's `secrets.secret_key_base` value. - # # It can be read using the encrypted method `cookies.encrypted[:name]` - # cookies.encrypted[:discount] = 45 + # # Sets a signed cookie, which prevents users from tampering with its value. + # cookies.signed[:user_id] = current_user.id + # # It can be read using the signed method. + # cookies.signed[:user_id] # => 123 # - # # Sets a "permanent" cookie (which expires in 20 years from now). - # cookies.permanent[:login] = "XJ-122" + # # Sets an encrypted cookie value before sending it to the client which + # # prevent users from reading and tampering with its value. + # cookies.encrypted[:discount] = 45 + # # It can be read using the encrypted method. + # cookies.encrypted[:discount] # => 45 # - # # You can also chain these methods: - # cookies.permanent.signed[:login] = "XJ-122" + # # Sets a "permanent" cookie (which expires in 20 years from now). + # cookies.permanent[:login] = "XJ-122" + # + # # You can also chain these methods: + # cookies.signed.permanent[:login] = "XJ-122" # # Examples of reading: # - # cookies[:user_name] # => "david" - # cookies.size # => 2 - # JSON.parse(cookies[:lat_lon]) # => [47.68, -122.37] - # cookies.signed[:login] # => "XJ-122" - # cookies.encrypted[:discount] # => 45 + # cookies[:user_name] # => "david" + # cookies.size # => 2 + # JSON.parse(cookies[:lat_lon]) # => [47.68, -122.37] + # cookies.signed[:login] # => "XJ-122" + # cookies.encrypted[:discount] # => 45 # # Example for deleting: # - # cookies.delete :user_name + # cookies.delete :user_name # - # Please note that if you specify a :domain when setting a cookie, you must also specify the domain when deleting the cookie: + # Please note that if you specify a `:domain` when setting a cookie, you must + # also specify the domain when deleting the cookie: # - # cookies[:name] = { - # value: 'a yummy cookie', - # expires: 1.year.from_now, - # domain: 'domain.com' - # } + # cookies[:name] = { + # value: 'a yummy cookie', + # expires: 1.year, + # domain: 'domain.com' + # } # - # cookies.delete(:name, domain: 'domain.com') + # cookies.delete(:name, domain: 'domain.com') # # The option symbols for setting cookies are: # - # * :value - The cookie's value. - # * :path - The path for which this cookie applies. Defaults to the root - # of the application. - # * :domain - The domain for which this cookie applies so you can - # restrict to the domain level. If you use a schema like www.example.com - # and want to share session with user.example.com set :domain - # to :all. Make sure to specify the :domain option with - # :all or Array again when deleting cookies. + # * `:value` - The cookie's value. + # * `:path` - The path for which this cookie applies. Defaults to the root of + # the application. + # * `:domain` - The domain for which this cookie applies so you can restrict + # to the domain level. If you use a schema like www.example.com and want to + # share session with user.example.com set `:domain` to `:all`. To support + # multiple domains, provide an array, and the first domain matching + # `request.host` will be used. Make sure to specify the `:domain` option + # with `:all` or `Array` again when deleting cookies. For more flexibility + # you can set the domain on a per-request basis by specifying `:domain` with + # a proc. + # + # domain: nil # Does not set cookie domain. (default) + # domain: :all # Allow the cookie for the top most level + # # domain and subdomains. + # domain: %w(.example.com .example.org) # Allow the cookie + # # for concrete domain names. + # domain: proc { Tenant.current.cookie_domain } # Set cookie domain dynamically + # domain: proc { |req| ".sub.#{req.host}" } # Set cookie domain dynamically based on request # - # domain: nil # Does not set cookie domain. (default) - # domain: :all # Allow the cookie for the top most level - # # domain and subdomains. - # domain: %w(.example.com .example.org) # Allow the cookie - # # for concrete domain names. + # * `:tld_length` - When using `:domain => :all`, this option can be used to + # explicitly set the TLD length when using a short (<= 3 character) domain + # that is being interpreted as part of a TLD. For example, to share cookies + # between user1.lvh.me and user2.lvh.me, set `:tld_length` to 2. + # * `:expires` - The time at which this cookie expires, as a Time or + # ActiveSupport::Duration object. + # * `:secure` - Whether this cookie is only transmitted to HTTPS servers. + # Default is `false`. + # * `:httponly` - Whether this cookie is accessible via scripting or only + # HTTP. Defaults to `false`. + # * `:same_site` - The value of the `SameSite` cookie attribute, which + # determines how this cookie should be restricted in cross-site contexts. + # Possible values are `nil`, `:none`, `:lax`, and `:strict`. Defaults to + # `:lax`. # - # * :tld_length - When using :domain => :all, this option can be used to explicitly - # set the TLD length when using a short (<= 3 character) domain that is being interpreted as part of a TLD. - # For example, to share cookies between user1.lvh.me and user2.lvh.me, set :tld_length to 1. - # * :expires - The time at which this cookie expires, as a \Time object. - # * :secure - Whether this cookie is only transmitted to HTTPS servers. - # Default is +false+. - # * :httponly - Whether this cookie is accessible via scripting or - # only HTTP. Defaults to +false+. class Cookies - HTTP_HEADER = "Set-Cookie".freeze - GENERATOR_KEY = "action_dispatch.key_generator".freeze - SIGNED_COOKIE_SALT = "action_dispatch.signed_cookie_salt".freeze - ENCRYPTED_COOKIE_SALT = "action_dispatch.encrypted_cookie_salt".freeze - ENCRYPTED_SIGNED_COOKIE_SALT = "action_dispatch.encrypted_signed_cookie_salt".freeze - SECRET_TOKEN = "action_dispatch.secret_token".freeze - SECRET_KEY_BASE = "action_dispatch.secret_key_base".freeze - COOKIES_SERIALIZER = "action_dispatch.cookies_serializer".freeze - COOKIES_DIGEST = "action_dispatch.cookies_digest".freeze + HTTP_HEADER = "Set-Cookie" + GENERATOR_KEY = "action_dispatch.key_generator" + SIGNED_COOKIE_SALT = "action_dispatch.signed_cookie_salt" + ENCRYPTED_COOKIE_SALT = "action_dispatch.encrypted_cookie_salt" + ENCRYPTED_SIGNED_COOKIE_SALT = "action_dispatch.encrypted_signed_cookie_salt" + AUTHENTICATED_ENCRYPTED_COOKIE_SALT = "action_dispatch.authenticated_encrypted_cookie_salt" + USE_AUTHENTICATED_COOKIE_ENCRYPTION = "action_dispatch.use_authenticated_cookie_encryption" + ENCRYPTED_COOKIE_CIPHER = "action_dispatch.encrypted_cookie_cipher" + SIGNED_COOKIE_DIGEST = "action_dispatch.signed_cookie_digest" + SECRET_KEY_BASE = "action_dispatch.secret_key_base" + COOKIES_SERIALIZER = "action_dispatch.cookies_serializer" + COOKIES_DIGEST = "action_dispatch.cookies_digest" + COOKIES_ROTATIONS = "action_dispatch.cookies_rotations" + COOKIES_SAME_SITE_PROTECTION = "action_dispatch.cookies_same_site_protection" + USE_COOKIES_WITH_METADATA = "action_dispatch.use_cookies_with_metadata" # Cookies can typically store 4096 bytes. MAX_COOKIE_SIZE = 4096 @@ -160,72 +215,69 @@ class Cookies # Raised when storing more than 4K of session data. CookieOverflow = Class.new StandardError - # Include in a cookie jar to allow chaining, e.g. cookies.permanent.signed + # Include in a cookie jar to allow chaining, e.g. `cookies.permanent.signed`. module ChainedCookieJars - # Returns a jar that'll automatically set the assigned cookies to have an expiration date 20 years from now. Example: + # Returns a jar that'll automatically set the assigned cookies to have an + # expiration date 20 years from now. Example: # - # cookies.permanent[:prefers_open_id] = true - # # => Set-Cookie: prefers_open_id=true; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT + # cookies.permanent[:prefers_open_id] = true + # # => Set-Cookie: prefers_open_id=true; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT # - # This jar is only meant for writing. You'll read permanent cookies through the regular accessor. + # This jar is only meant for writing. You'll read permanent cookies through the + # regular accessor. # - # This jar allows chaining with the signed jar as well, so you can set permanent, signed cookies. Examples: + # This jar allows chaining with the signed jar as well, so you can set + # permanent, signed cookies. Examples: # - # cookies.permanent.signed[:remember_me] = current_user.id - # # => Set-Cookie: remember_me=BAhU--848956038e692d7046deab32b7131856ab20e14e; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT + # cookies.permanent.signed[:remember_me] = current_user.id + # # => Set-Cookie: remember_me=BAhU--848956038e692d7046deab32b7131856ab20e14e; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT def permanent @permanent ||= PermanentCookieJar.new(self) end - # Returns a jar that'll automatically generate a signed representation of cookie value and verify it when reading from - # the cookie again. This is useful for creating cookies with values that the user is not supposed to change. If a signed - # cookie was tampered with by the user (or a 3rd party), +nil+ will be returned. + # Returns a jar that'll automatically generate a signed representation of cookie + # value and verify it when reading from the cookie again. This is useful for + # creating cookies with values that the user is not supposed to change. If a + # signed cookie was tampered with by the user (or a 3rd party), `nil` will be + # returned. # - # If +secrets.secret_key_base+ and +secrets.secret_token+ (deprecated) are both set, - # legacy cookies signed with the old key generator will be transparently upgraded. - # - # This jar requires that you set a suitable secret for the verification on your app's +secrets.secret_key_base+. + # This jar requires that you set a suitable secret for the verification on your + # app's `secret_key_base`. # # Example: # - # cookies.signed[:discount] = 45 - # # => Set-Cookie: discount=BAhpMg==--2c1c6906c90a3bc4fd54a51ffb41dffa4bf6b5f7; path=/ + # cookies.signed[:discount] = 45 + # # => Set-Cookie: discount=BAhpMg==--2c1c6906c90a3bc4fd54a51ffb41dffa4bf6b5f7; path=/ # - # cookies.signed[:discount] # => 45 + # cookies.signed[:discount] # => 45 def signed - @signed ||= - if upgrade_legacy_signed_cookies? - UpgradeLegacySignedCookieJar.new(self) - else - SignedCookieJar.new(self) - end + @signed ||= SignedKeyRotatingCookieJar.new(self) end - # Returns a jar that'll automatically encrypt cookie values before sending them to the client and will decrypt them for read. - # If the cookie was tampered with by the user (or a 3rd party), +nil+ will be returned. + # Returns a jar that'll automatically encrypt cookie values before sending them + # to the client and will decrypt them for read. If the cookie was tampered with + # by the user (or a 3rd party), `nil` will be returned. # - # If +secrets.secret_key_base+ and +secrets.secret_token+ (deprecated) are both set, - # legacy cookies signed with the old key generator will be transparently upgraded. + # If `config.action_dispatch.encrypted_cookie_salt` and + # `config.action_dispatch.encrypted_signed_cookie_salt` are both set, legacy + # cookies encrypted with HMAC AES-256-CBC will be transparently upgraded. # - # This jar requires that you set a suitable secret for the verification on your app's +secrets.secret_key_base+. + # This jar requires that you set a suitable secret for the verification on your + # app's `secret_key_base`. # # Example: # - # cookies.encrypted[:discount] = 45 - # # => Set-Cookie: discount=ZS9ZZ1R4cG1pcUJ1bm80anhQang3dz09LS1mbDZDSU5scGdOT3ltQ2dTdlhSdWpRPT0%3D--ab54663c9f4e3bc340c790d6d2b71e92f5b60315; path=/ + # cookies.encrypted[:discount] = 45 + # # => Set-Cookie: discount=DIQ7fw==--K3n//8vvnSbGq9dA--7Xh91HfLpwzbj1czhBiwOg==; path=/ # - # cookies.encrypted[:discount] # => 45 + # cookies.encrypted[:discount] # => 45 def encrypted - @encrypted ||= - if upgrade_legacy_signed_cookies? - UpgradeLegacyEncryptedCookieJar.new(self) - else - EncryptedCookieJar.new(self) - end + @encrypted ||= EncryptedKeyRotatingCookieJar.new(self) end - # Returns the +signed+ or +encrypted+ jar, preferring +encrypted+ if +secret_key_base+ is set. - # Used by ActionDispatch::Session::CookieStore to avoid the need to introduce new cookie stores. + # Returns the `signed` or `encrypted` jar, preferring `encrypted` if + # `secret_key_base` is set. Used by ActionDispatch::Session::CookieStore to + # avoid the need to introduce new cookie stores. def signed_or_encrypted @signed_or_encrypted ||= if request.secret_key_base.present? @@ -236,57 +288,35 @@ def signed_or_encrypted end private - - def upgrade_legacy_signed_cookies? - request.secret_token.present? && request.secret_key_base.present? + def upgrade_legacy_hmac_aes_cbc_cookies? + request.secret_key_base.present? && + request.encrypted_signed_cookie_salt.present? && + request.encrypted_cookie_salt.present? && + request.use_authenticated_cookie_encryption end - end - # Passing the ActiveSupport::MessageEncryptor::NullSerializer downstream - # to the Message{Encryptor,Verifier} allows us to handle the - # (de)serialization step within the cookie jar, which gives us the - # opportunity to detect and migrate legacy cookies. - module VerifyAndUpgradeLegacySignedMessage # :nodoc: - def initialize(*args) - super - @legacy_verifier = ActiveSupport::MessageVerifier.new(request.secret_token, serializer: ActiveSupport::MessageEncryptor::NullSerializer) - end + def prepare_upgrade_legacy_hmac_aes_cbc_cookies? + request.secret_key_base.present? && + request.authenticated_encrypted_cookie_salt.present? && + !request.use_authenticated_cookie_encryption + end - def verify_and_upgrade_legacy_signed_message(name, signed_message) - deserialize(name, @legacy_verifier.verify(signed_message)).tap do |value| - self[name] = { value: value } + def encrypted_cookie_cipher + request.encrypted_cookie_cipher || "aes-256-gcm" end - rescue ActiveSupport::MessageVerifier::InvalidSignature - nil - end - private - def parse(name, signed_message) - super || verify_and_upgrade_legacy_signed_message(name, signed_message) + def signed_cookie_digest + request.signed_cookie_digest || "SHA1" end end - class CookieJar #:nodoc: + class CookieJar # :nodoc: include Enumerable, ChainedCookieJars - # This regular expression is used to split the levels of a domain. - # The top level domain can be any string without a period or - # **.**, ***.** style TLDs like co.uk or com.au - # - # www.example.co.uk gives: - # $& => example.co.uk - # - # example.com gives: - # $& => example.com - # - # lots.of.subdomains.example.local gives: - # $& => example.local - DOMAIN_REGEXP = /[^.]*\.([^.]*|..\...|...\...)$/ - def self.build(req, cookies) - new(req).tap do |hash| - hash.update(cookies) - end + jar = new(req) + jar.update(cookies) + jar end attr_reader :request @@ -311,7 +341,7 @@ def each(&block) @cookies.each(&block) end - # Returns the value of the cookie by +name+, or +nil+ if no such cookie exists. + # Returns the value of the cookie by `name`, or `nil` if no such cookie exists. def [](name) @cookies[name.to_s] end @@ -325,6 +355,9 @@ def key?(name) end alias :has_key? :key? + # Returns the cookies as Hash. + alias :to_hash :to_h + def update(other_hash) @cookies.update other_hash.stringify_keys self @@ -332,7 +365,7 @@ def update(other_hash) def update_cookies_from_jar request_jar = @request.cookie_jar.instance_variable_get(:@cookies) - set_cookies = request_jar.reject { |k, _| @delete_cookies.key?(k) } + set_cookies = request_jar.reject { |k, _| @delete_cookies.key?(k) || @set_cookies.key?(k) } @cookies.update set_cookies if set_cookies end @@ -341,26 +374,8 @@ def to_header @cookies.map { |k, v| "#{escape(k)}=#{escape(v)}" }.join "; " end - def handle_options(options) #:nodoc: - options[:path] ||= "/" - - if options[:domain] == :all || options[:domain] == "all" - # if there is a provided tld length then we use it otherwise default domain regexp - domain_regexp = options[:tld_length] ? /([^.]+\.?){#{options[:tld_length]}}$/ : DOMAIN_REGEXP - - # if host is not ip and matches domain regexp - # (ip confirms to domain regexp so we explicitly check for ip) - options[:domain] = if (request.host !~ /^[\d.]+$/) && (request.host =~ domain_regexp) - ".#{$&}" - end - elsif options[:domain].is_a? Array - # if host matches one of the supplied domains without a dot in front of it - options[:domain] = options[:domain].find { |domain| request.host.include? domain.sub(/^\./, "") } - end - end - - # Sets the cookie named +name+. The second argument may be the cookie's - # value or a hash of options as documented above. + # Sets the cookie named `name`. The second argument may be the cookie's value or + # a hash of options as documented above. def []=(name, options) if options.is_a?(Hash) options.symbolize_keys! @@ -381,9 +396,11 @@ def []=(name, options) value end - # Removes the cookie on the client machine by setting the value to an empty string - # and the expiration date in the past. Like []=, you can pass in - # an options hash to delete cookies with extra data such as a :path. + # Removes the cookie on the client machine by setting the value to an empty + # string and the expiration date in the past. Like `[]=`, you can pass in an + # options hash to delete cookies with extra data such as a `:path`. + # + # Returns the value of the cookie, or `nil` if the cookie does not exist. def delete(name, options = {}) return unless @cookies.has_key? name.to_s @@ -395,50 +412,94 @@ def delete(name, options = {}) value end - # Whether the given cookie is to be deleted by this CookieJar. - # Like []=, you can pass in an options hash to test if a - # deletion applies to a specific :path, :domain etc. + # Whether the given cookie is to be deleted by this CookieJar. Like `[]=`, you + # can pass in an options hash to test if a deletion applies to a specific + # `:path`, `:domain` etc. def deleted?(name, options = {}) options.symbolize_keys! handle_options(options) @delete_cookies[name.to_s] == options end - # Removes all cookies on the client machine by calling delete for each cookie + # Removes all cookies on the client machine by calling `delete` for each cookie. def clear(options = {}) @cookies.each_key { |k| delete(k, options) } end - def write(headers) - if header = make_set_cookie_header(headers[HTTP_HEADER]) - headers[HTTP_HEADER] = header + def write(response) + @set_cookies.each do |name, value| + if write_cookie?(value) + response.set_cookie(name, value) + end + end + + @delete_cookies.each do |name, value| + response.delete_cookie(name, value) end end - mattr_accessor :always_write_cookie - self.always_write_cookie = false + mattr_accessor :always_write_cookie, default: false private - def escape(string) ::Rack::Utils.escape(string) end - def make_set_cookie_header(header) - header = @set_cookies.inject(header) { |m, (k, v)| - if write_cookie?(v) - ::Rack::Utils.add_cookie_to_header(m, k, v) + def write_cookie?(cookie) + request.ssl? || !cookie[:secure] || always_write_cookie || request.host.end_with?(".onion") + end + + def handle_options(options) + if options[:expires].respond_to?(:from_now) + options[:expires] = options[:expires].from_now + end + + options[:path] ||= "/" + + unless options.key?(:same_site) + options[:same_site] = request.cookies_same_site_protection + end + + if options[:domain] == :all || options[:domain] == "all" + cookie_domain = "" + dot_splitted_host = request.host.split(".", -1) + + # Case where request.host is not an IP address or it's an invalid domain (ip + # confirms to the domain structure we expect so we explicitly check for ip) + if request.host.match?(/^[\d.]+$/) || dot_splitted_host.include?("") || dot_splitted_host.length == 1 + options[:domain] = nil + return + end + + # If there is a provided tld length then we use it otherwise default domain. + if options[:tld_length].present? + # Case where the tld_length provided is valid + if dot_splitted_host.length >= options[:tld_length] + cookie_domain = dot_splitted_host.last(options[:tld_length]).join(".") + end + # Case where tld_length is not provided else - m + # Regular TLDs + if !(/\.[^.]{2,3}\.[^.]{2}\z/.match?(request.host)) + cookie_domain = dot_splitted_host.last(2).join(".") + # **.**, ***.** style TLDs like co.uk and com.au + else + cookie_domain = dot_splitted_host.last(3).join(".") + end end - } - @delete_cookies.inject(header) { |m, (k, v)| - ::Rack::Utils.add_remove_cookie_to_header(m, k, v) - } - end - def write_cookie?(cookie) - request.ssl? || !cookie[:secure] || always_write_cookie + options[:domain] = if cookie_domain.present? + cookie_domain + end + elsif options[:domain].is_a? Array + # If host matches one of the supplied domains. + options[:domain] = options[:domain].find do |domain| + domain = domain.delete_prefix(".") + request.host == domain || request.host.end_with?(".#{domain}") + end + elsif options[:domain].respond_to?(:call) + options[:domain] = options[:domain].call(request) + end end end @@ -451,7 +512,13 @@ def initialize(parent_jar) def [](name) if data = @parent_jar[name.to_s] - parse name, data + result = parse(name, data, purpose: "cookie.#{name}") + + if result.nil? + parse(name, data) + else + result + end end end @@ -462,7 +529,7 @@ def []=(name, options) options = { value: options } end - commit(options) + commit(name, options) @parent_jar[name] = options end @@ -470,159 +537,185 @@ def []=(name, options) def request; @parent_jar.request; end private - def parse(name, data); data; end - def commit(options); end + def expiry_options(options) + if options[:expires].respond_to?(:from_now) + { expires_in: options[:expires] } + else + { expires_at: options[:expires] } + end + end + + def cookie_metadata(name, options) + expiry_options(options).tap do |metadata| + metadata[:purpose] = "cookie.#{name}" if request.use_cookies_with_metadata + end + end + + def parse(name, data, purpose: nil); data; end + def commit(name, options); end end class PermanentCookieJar < AbstractCookieJar # :nodoc: private - def commit(options) + def commit(name, options) options[:expires] = 20.years.from_now end end - class JsonSerializer # :nodoc: - def self.load(value) - ActiveSupport::JSON.decode(value) - end - - def self.dump(value) - ActiveSupport::JSON.encode(value) - end - end - module SerializedCookieJars # :nodoc: - MARSHAL_SIGNATURE = "\x04\x08".freeze + SERIALIZER = ActiveSupport::MessageEncryptor::NullSerializer protected - def needs_migration?(value) - request.cookies_serializer == :hybrid && value.start_with?(MARSHAL_SIGNATURE) - end - - def serialize(value) - serializer.dump(value) + def digest + request.cookies_digest || "SHA1" end - def deserialize(name, value) - if value - if needs_migration?(value) - Marshal.load(value).tap do |v| - self[name] = { value: v } - end + private + def serializer + @serializer ||= + case request.cookies_serializer + when nil + ActiveSupport::Messages::SerializerWithFallback[:marshal] + when :hybrid + ActiveSupport::Messages::SerializerWithFallback[:json_allow_marshal] + when Symbol + ActiveSupport::Messages::SerializerWithFallback[request.cookies_serializer] else - serializer.load(value) + request.cookies_serializer end - end end - def serializer - serializer = request.cookies_serializer || :marshal - case serializer - when :marshal - Marshal - when :json, :hybrid - JsonSerializer - else - serializer + def reserialize?(dumped) + serializer.is_a?(ActiveSupport::Messages::SerializerWithFallback) && + serializer != ActiveSupport::Messages::SerializerWithFallback[:marshal] && + !serializer.dumped?(dumped) + end + + def parse(name, dumped, force_reserialize: false, **) + if dumped + begin + value = serializer.load(dumped) + rescue StandardError + return + end + + self[name] = { value: value } if force_reserialize || reserialize?(dumped) + + value end end - def digest - request.cookies_digest || "SHA1" + def commit(name, options) + options[:value] = serializer.dump(options[:value]) end - def key_generator - request.key_generator + def check_for_overflow!(name, options) + 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 - class SignedCookieJar < AbstractCookieJar # :nodoc: + class SignedKeyRotatingCookieJar < AbstractCookieJar # :nodoc: include SerializedCookieJars def initialize(parent_jar) super - secret = key_generator.generate_key(request.signed_cookie_salt) - @verifier = ActiveSupport::MessageVerifier.new(secret, digest: digest, serializer: ActiveSupport::MessageEncryptor::NullSerializer) + + secret = request.key_generator.generate_key(request.signed_cookie_salt) + @verifier = ActiveSupport::MessageVerifier.new(secret, digest: signed_cookie_digest, serializer: SERIALIZER) + + request.cookies_rotations.signed.each do |(*secrets)| + options = secrets.extract_options! + @verifier.rotate(*secrets, serializer: SERIALIZER, **options) + end end private - def parse(name, signed_message) - deserialize name, @verifier.verified(signed_message) + def parse(name, signed_message, purpose: nil) + rotated = false + data = @verifier.verified(signed_message, purpose: purpose, on_rotation: -> { rotated = true }) + super(name, data, force_reserialize: rotated) end - def commit(options) - options[:value] = @verifier.generate(serialize(options[:value])) - - raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE + def commit(name, options) + super + options[:value] = @verifier.generate(options[:value], **cookie_metadata(name, options)) + check_for_overflow!(name, options) end end - # UpgradeLegacySignedCookieJar is used instead of SignedCookieJar if - # secrets.secret_token and secrets.secret_key_base are both set. It reads - # legacy cookies signed with the old dummy key generator and signs and - # re-saves them using the new key generator to provide a smooth upgrade path. - class UpgradeLegacySignedCookieJar < SignedCookieJar #:nodoc: - include VerifyAndUpgradeLegacySignedMessage - end - - class EncryptedCookieJar < AbstractCookieJar # :nodoc: + class EncryptedKeyRotatingCookieJar < AbstractCookieJar # :nodoc: include SerializedCookieJars def initialize(parent_jar) super - if ActiveSupport::LegacyKeyGenerator === key_generator - raise "You didn't set secrets.secret_key_base, which is required for this cookie jar. " \ - "Read the upgrade documentation to learn more about this new config option." + if request.use_authenticated_cookie_encryption + key_len = ActiveSupport::MessageEncryptor.key_len(encrypted_cookie_cipher) + secret = request.key_generator.generate_key(request.authenticated_encrypted_cookie_salt, key_len) + @encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: encrypted_cookie_cipher, serializer: SERIALIZER) + else + key_len = ActiveSupport::MessageEncryptor.key_len("aes-256-cbc") + secret = request.key_generator.generate_key(request.encrypted_cookie_salt, key_len) + sign_secret = request.key_generator.generate_key(request.encrypted_signed_cookie_salt) + @encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, cipher: "aes-256-cbc", serializer: SERIALIZER) end - secret = key_generator.generate_key(request.encrypted_cookie_salt || "")[0, ActiveSupport::MessageEncryptor.key_len] - sign_secret = key_generator.generate_key(request.encrypted_signed_cookie_salt || "") - @encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, digest: digest, serializer: ActiveSupport::MessageEncryptor::NullSerializer) + request.cookies_rotations.encrypted.each do |(*secrets)| + options = secrets.extract_options! + @encryptor.rotate(*secrets, serializer: SERIALIZER, **options) + end + + if upgrade_legacy_hmac_aes_cbc_cookies? + legacy_cipher = "aes-256-cbc" + secret = request.key_generator.generate_key(request.encrypted_cookie_salt, ActiveSupport::MessageEncryptor.key_len(legacy_cipher)) + sign_secret = request.key_generator.generate_key(request.encrypted_signed_cookie_salt) + + @encryptor.rotate(secret, sign_secret, cipher: legacy_cipher, digest: digest, serializer: SERIALIZER) + elsif prepare_upgrade_legacy_hmac_aes_cbc_cookies? + future_cipher = encrypted_cookie_cipher + secret = request.key_generator.generate_key(request.authenticated_encrypted_cookie_salt, ActiveSupport::MessageEncryptor.key_len(future_cipher)) + + @encryptor.rotate(secret, nil, cipher: future_cipher, serializer: SERIALIZER) + end end private - def parse(name, encrypted_message) - deserialize name, @encryptor.decrypt_and_verify(encrypted_message) - rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage + def parse(name, encrypted_message, purpose: nil) + rotated = false + data = @encryptor.decrypt_and_verify(encrypted_message, purpose: purpose, on_rotation: -> { rotated = true }) + super(name, data, force_reserialize: rotated) + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature nil end - def commit(options) - options[:value] = @encryptor.encrypt_and_sign(serialize(options[:value])) - - raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE + def commit(name, options) + super + options[:value] = @encryptor.encrypt_and_sign(options[:value], **cookie_metadata(name, options)) + check_for_overflow!(name, options) end end - # UpgradeLegacyEncryptedCookieJar is used by ActionDispatch::Session::CookieStore - # instead of EncryptedCookieJar if secrets.secret_token and secrets.secret_key_base - # are both set. It reads legacy cookies signed with the old dummy key generator and - # encrypts and re-saves them using the new key generator to provide a smooth upgrade path. - class UpgradeLegacyEncryptedCookieJar < EncryptedCookieJar #:nodoc: - include VerifyAndUpgradeLegacySignedMessage - end - def initialize(app) @app = app end def call(env) - request = ActionDispatch::Request.new env - - status, headers, body = @app.call(env) + request = ActionDispatch::Request.new(env) + response = @app.call(env) if request.have_cookie_jar? cookie_jar = request.cookie_jar unless cookie_jar.committed? - cookie_jar.write(headers) - if headers[HTTP_HEADER].respond_to?(:join) - headers[HTTP_HEADER] = headers[HTTP_HEADER].join("\n") - end + response = Rack::Response[*response] + cookie_jar.write(response) end end - [status, headers, body] + response.to_a end end end diff --git a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb index 1c720c5a8e2c4..819bc4a25af66 100644 --- a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb @@ -1,99 +1,90 @@ -require "action_dispatch/http/request" +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/middleware/exception_wrapper" require "action_dispatch/routing/inspector" -require "action_view" -require "action_view/base" -require "pp" +require "action_view" module ActionDispatch - # This middleware is responsible for logging exceptions and - # showing a debugging page in case the request is local. + # # Action Dispatch DebugExceptions + # + # This middleware is responsible for logging exceptions and showing a debugging + # page in case the request is local. class DebugExceptions - RESCUES_TEMPLATE_PATH = File.expand_path("../templates", __FILE__) - - class DebugView < ActionView::Base - def debug_params(params) - clean_params = params.clone - clean_params.delete("action") - clean_params.delete("controller") - - if clean_params.empty? - "None" - else - PP.pp(clean_params, "", 200) - end - end + cattr_reader :interceptors, instance_accessor: false, default: [] - def debug_headers(headers) - if headers.present? - headers.inspect.gsub(",", ",\n") - else - "None" - end - end - - def debug_hash(object) - object.to_hash.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}: #{v.inspect rescue $!.message}" }.join("\n") - end - - def render(*) - logger = ActionView::Base.logger - - if logger && logger.respond_to?(:silence) - logger.silence { super } - else - super - end - end + def self.register_interceptor(object = nil, &block) + interceptor = object || block + interceptors << interceptor end - def initialize(app, routes_app = nil, response_format = :default) + def initialize(app, routes_app = nil, response_format = :default, interceptors = self.class.interceptors) @app = app @routes_app = routes_app @response_format = response_format + @interceptors = interceptors end def call(env) - request = ActionDispatch::Request.new env _, headers, body = response = @app.call(env) - if headers["X-Cascade"] == "pass" + if headers[Constants::X_CASCADE] == "pass" body.close if body.respond_to?(:close) raise ActionController::RoutingError, "No route matches [#{env['REQUEST_METHOD']}] #{env['PATH_INFO'].inspect}" end response rescue Exception => exception - raise exception unless request.show_exceptions? - render_exception(request, exception) + request = ActionDispatch::Request.new env + backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner") + wrapper = ExceptionWrapper.new(backtrace_cleaner, exception) + + invoke_interceptors(request, exception, wrapper) + raise exception unless wrapper.show?(request) + render_exception(request, exception, wrapper) end private + def invoke_interceptors(request, exception, wrapper) + @interceptors.each do |interceptor| + interceptor.call(request, exception) + rescue Exception + log_error(request, wrapper) + end + end - def render_exception(request, exception) - backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner") - wrapper = ExceptionWrapper.new(backtrace_cleaner, exception) + def render_exception(request, exception, wrapper) log_error(request, wrapper) if request.get_header("action_dispatch.show_detailed_exceptions") - content_type = request.formats.first + begin + content_type = request.formats.first + rescue ActionDispatch::Http::MimeNegotiation::InvalidType + 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 @@ -110,7 +101,7 @@ def render_for_api_request(content_type, wrapper) wrapper.status_code, Rack::Utils::HTTP_STATUS_CODES[500] ), - exception: wrapper.exception.inspect, + exception: wrapper.exception_inspect, traces: wrapper.traces } @@ -128,57 +119,76 @@ def render_for_api_request(content_type, wrapper) end def create_template(request, wrapper) - traces = wrapper.traces - - trace_to_show = "Application Trace" - if traces[trace_to_show].empty? && wrapper.rescue_template != "routing_error" - trace_to_show = "Full Trace" - end - - if source_to_show = traces[trace_to_show].first - source_to_show_id = source_to_show[:id] - end - - DebugView.new([RESCUES_TEMPLATE_PATH], + DebugView.new( request: request, + exception_wrapper: wrapper, + # Everything should use the wrapper, but we need to pass `exception` for legacy + # code. exception: wrapper.exception, - traces: traces, - show_source_idx: source_to_show_id, - trace_to_show: trace_to_show, - routes_inspector: routes_inspector(wrapper.exception), + traces: wrapper.traces, + show_source_idx: wrapper.source_to_show_id, + trace_to_show: wrapper.trace_to_show, + routes_inspector: routes_inspector(wrapper), source_extracts: wrapper.source_extracts, - line_number: wrapper.line_number, - file: wrapper.file + exception_message_for_copy: compose_exception_message(wrapper).join("\n"), ) end def render(status, body, format) - [status, { "Content-Type" => "#{format}; charset=#{Response.default_charset}", "Content-Length" => body.bytesize.to_s }, [body]] + [status, { Rack::CONTENT_TYPE => "#{format}; charset=#{Response.default_charset}", Rack::CONTENT_LENGTH => body.bytesize.to_s }, [body]] end def log_error(request, wrapper) logger = logger(request) + return unless logger + return if !log_rescued_responses?(request) && wrapper.rescue_response? + + message = compose_exception_message(wrapper) + log_array(logger, message, request) + end - exception = wrapper.exception + def compose_exception_message(wrapper) + trace = wrapper.exception_trace - trace = wrapper.application_trace - trace = wrapper.framework_trace if trace.empty? + message = [] + message << " " + if wrapper.has_cause? + message << "#{wrapper.exception_class_name} (#{wrapper.message})" + wrapper.wrapped_causes.each do |wrapped_cause| + message << "Caused by: #{wrapped_cause.exception_class_name} (#{wrapped_cause.message})" + end + + message << "\nInformation for: #{wrapper.exception_class_name} (#{wrapper.message}):" + else + message << "#{wrapper.exception_class_name} (#{wrapper.message}):" + end + + message.concat(wrapper.annotated_source_code) + message << " " + message.concat(trace) - ActiveSupport::Deprecation.silence do - logger.fatal " " - logger.fatal "#{exception.class} (#{exception.message}):" - log_array logger, exception.annoted_source_code if exception.respond_to?(:annoted_source_code) - logger.fatal " " - log_array logger, trace + if wrapper.has_cause? + wrapper.wrapped_causes.each do |wrapped_cause| + message << "\nInformation for cause: #{wrapped_cause.exception_class_name} (#{wrapped_cause.message}):" + message.concat(wrapped_cause.annotated_source_code) + message << " " + message.concat(wrapped_cause.exception_trace) + end end + + message end - def log_array(logger, array) + def log_array(logger, lines, request) + return if lines.empty? + + level = request.get_header("action_dispatch.debug_exception_log_level") + if logger.formatter && logger.formatter.respond_to?(:tags_text) - logger.fatal array.join("\n#{logger.formatter.tags_text}") + logger.add(level, lines.join("\n#{logger.formatter.tags_text}")) else - logger.fatal array.join("\n") + logger.add(level, lines.join("\n")) end end @@ -191,7 +201,7 @@ def stderr_logger end def routes_inspector(exception) - if @routes_app.respond_to?(:routes) && (exception.is_a?(ActionController::RoutingError) || exception.is_a?(ActionView::Template::Error)) + if @routes_app.respond_to?(:routes) && (exception.routing_error? || exception.template_error?) ActionDispatch::Routing::RoutesInspector.new(@routes_app.routes.routes) end end @@ -199,5 +209,9 @@ def routes_inspector(exception) def api_request?(content_type) @response_format == :api && !content_type.html? end + + def log_rescued_responses?(request) + request.get_header("action_dispatch.log_rescued_responses") + end end end diff --git a/actionpack/lib/action_dispatch/middleware/debug_locks.rb b/actionpack/lib/action_dispatch/middleware/debug_locks.rb index 74b952528ead3..314d7bd28c1c3 100644 --- a/actionpack/lib/action_dispatch/middleware/debug_locks.rb +++ b/actionpack/lib/action_dispatch/middleware/debug_locks.rb @@ -1,15 +1,21 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch + # # Action Dispatch DebugLocks + # # This middleware can be used to diagnose deadlocks in the autoload interlock. # # To use it, insert it near the top of the middleware stack, using - # config/application.rb: + # `config/application.rb`: # # config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks # - # After restarting the application and re-triggering the deadlock condition, - # /rails/locks will show a summary of all threads currently known to - # the interlock, which lock level they are holding or awaiting, and their - # current backtrace. + # After restarting the application and re-triggering the deadlock condition, the + # route `/rails/locks` will show a summary of all threads currently known to the + # interlock, which lock level they are holding or awaiting, and their current + # backtrace. # # Generally a deadlock will be caused by the interlock conflicting with some # other external lock or blocking I/O call. These cannot be automatically @@ -30,7 +36,7 @@ def call(env) req = ActionDispatch::Request.new env if req.get? - path = req.path_info.chomp("/".freeze) + path = req.path_info.chomp("/") if path == @path return render_details(req) end @@ -41,39 +47,39 @@ def call(env) private def render_details(req) - threads = ActiveSupport::Dependencies.interlock.raw_state do |threads| - # The Interlock itself comes to a complete halt as long as this block - # is executing. That gives us a more consistent picture of everything, - # but creates a pretty strong Observer Effect. + threads = ActiveSupport::Dependencies.interlock.raw_state do |raw_threads| + # The Interlock itself comes to a complete halt as long as this block is + # executing. That gives us a more consistent picture of everything, but creates + # a pretty strong Observer Effect. # - # Most directly, that means we need to do as little as possible in - # this block. More widely, it means this middleware should remain a - # strictly diagnostic tool (to be used when something has gone wrong), - # and not for any sort of general monitoring. + # Most directly, that means we need to do as little as possible in this block. + # More widely, it means this middleware should remain a strictly diagnostic tool + # (to be used when something has gone wrong), and not for any sort of general + # monitoring. - threads.each.with_index do |(thread, info), idx| + raw_threads.each.with_index do |(thread, info), idx| info[:index] = idx info[:backtrace] = thread.backtrace end - threads + raw_threads end str = threads.map do |thread, info| if info[:exclusive] - lock_state = "Exclusive" + lock_state = +"Exclusive" elsif info[:sharing] > 0 - lock_state = "Sharing" + lock_state = +"Sharing" lock_state << " x#{info[:sharing]}" if info[:sharing] > 1 else - lock_state = "No lock" + lock_state = +"No lock" end if info[:waiting] lock_state << " (yielded share)" end - msg = "Thread #{info[:index]} [0x#{thread.__id__.to_s(16)} #{thread.status || 'dead'}] #{lock_state}\n" + msg = +"Thread #{info[:index]} [0x#{thread.__id__.to_s(16)} #{thread.status || 'dead'}] #{lock_state}\n" if info[:sleeper] msg << " Waiting in #{info[:sleeper]}" @@ -95,7 +101,8 @@ def render_details(req) msg << "\n#{info[:backtrace].join("\n")}\n" if info[:backtrace] end.join("\n\n---\n\n\n") - [200, { "Content-Type" => "text/plain", "Content-Length" => str.size }, [str]] + [200, { Rack::CONTENT_TYPE => "text/plain; charset=#{ActionDispatch::Response.default_charset}", + Rack::CONTENT_LENGTH => str.size.to_s }, [str]] end def blocked_by?(victim, blocker, all_threads) diff --git a/actionpack/lib/action_dispatch/middleware/debug_view.rb b/actionpack/lib/action_dispatch/middleware/debug_view.rb new file mode 100644 index 0000000000000..758c0d607649b --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/debug_view.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "pp" + +require "action_view" +require "action_view/base" + +module ActionDispatch + class DebugView < ActionView::Base # :nodoc: + RESCUES_TEMPLATE_PATHS = [File.expand_path("templates", __dir__)] + + def initialize(assigns) + paths = RESCUES_TEMPLATE_PATHS.dup + lookup_context = ActionView::LookupContext.new(paths) + super(lookup_context, assigns, nil) + end + + def compiled_method_container + self.class + end + + def debug_params(params) + clean_params = params.clone + clean_params.delete("action") + clean_params.delete("controller") + + if clean_params.empty? + "None" + else + PP.pp(clean_params, +"", 200) + end + end + + def debug_headers(headers) + if headers.present? + headers.inspect.gsub(",", ",\n") + else + "None" + end + end + + def debug_hash(object) + object.to_hash.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}: #{v.inspect rescue $!.message}" }.join("\n") + end + + def render(*) + logger = ActionView::Base.logger + + if logger && logger.respond_to?(:silence) + logger.silence { super } + else + super + 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 + + def params_valid? + @request.parameters + rescue ActionController::BadRequest + false + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb index 397f0a8b92a2f..ab4b69288b006 100644 --- a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb +++ b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb @@ -1,50 +1,136 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/module/attribute_accessors" +require "active_support/syntax_error_proxy" +require "active_support/core_ext/thread/backtrace/location" require "rack/utils" module ActionDispatch class ExceptionWrapper - cattr_accessor :rescue_responses - @@rescue_responses = Hash.new(:internal_server_error) - @@rescue_responses.merge!( - "ActionController::RoutingError" => :not_found, - "AbstractController::ActionNotFound" => :not_found, - "ActionController::MethodNotAllowed" => :method_not_allowed, - "ActionController::UnknownHttpMethod" => :method_not_allowed, - "ActionController::NotImplemented" => :not_implemented, - "ActionController::UnknownFormat" => :not_acceptable, - "ActionController::InvalidAuthenticityToken" => :unprocessable_entity, - "ActionController::InvalidCrossOriginRequest" => :unprocessable_entity, - "ActionDispatch::Http::Parameters::ParseError" => :bad_request, - "ActionController::BadRequest" => :bad_request, - "ActionController::ParameterMissing" => :bad_request, - "Rack::QueryParser::ParameterTypeError" => :bad_request, - "Rack::QueryParser::InvalidParameterError" => :bad_request + cattr_accessor :rescue_responses, default: Hash.new(:internal_server_error).merge!( + "ActionController::RoutingError" => :not_found, + "AbstractController::ActionNotFound" => :not_found, + "ActionController::MethodNotAllowed" => :method_not_allowed, + "ActionController::UnknownHttpMethod" => :method_not_allowed, + "ActionController::NotImplemented" => :not_implemented, + "ActionController::UnknownFormat" => :not_acceptable, + "ActionDispatch::Http::MimeNegotiation::InvalidType" => :not_acceptable, + "ActionController::MissingExactTemplate" => :not_acceptable, + "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 ) - cattr_accessor :rescue_templates - @@rescue_templates = Hash.new("diagnostics") - @@rescue_templates.merge!( - "ActionView::MissingTemplate" => "missing_template", - "ActionController::RoutingError" => "routing_error", - "AbstractController::ActionNotFound" => "unknown_action", - "ActionView::Template::Error" => "template_error" + cattr_accessor :rescue_templates, default: Hash.new("diagnostics").merge!( + "ActionView::MissingTemplate" => "missing_template", + "ActionController::RoutingError" => "routing_error", + "AbstractController::ActionNotFound" => "unknown_action", + "ActiveRecord::StatementInvalid" => "invalid_statement", + "ActionView::Template::Error" => "template_error", + "ActionController::MissingExactTemplate" => "missing_exact_template", ) - attr_reader :backtrace_cleaner, :exception, :line_number, :file + cattr_accessor :wrapper_exceptions, default: [ + "ActionView::Template::Error" + ] + + cattr_accessor :silent_exceptions, default: [ + "ActionController::RoutingError", + "ActionDispatch::Http::MimeNegotiation::InvalidType" + ] + + attr_reader :backtrace_cleaner, :wrapped_causes, :exception_class_name, :exception def initialize(backtrace_cleaner, exception) @backtrace_cleaner = backtrace_cleaner - @exception = original_exception(exception) + @exception_class_name = exception.class.name + @wrapped_causes = wrapped_causes_for(exception, backtrace_cleaner) + @exception = exception + if exception.is_a?(SyntaxError) + @exception = ActiveSupport::SyntaxErrorProxy.new(exception) + end + @backtrace = build_backtrace + end + + def routing_error? + @exception.is_a?(ActionController::RoutingError) + end + + def template_error? + @exception.is_a?(ActionView::Template::Error) + end + + def sub_template_message + @exception.sub_template_message + end + + def has_cause? + @exception.cause + end + + def failures + @exception.failures + end + + def has_corrections? + @exception.respond_to?(:original_message) && @exception.respond_to?(:corrections) + end + + def original_message + @exception.original_message + end + + def corrections + @exception.corrections + end + + def file_name + @exception.file_name + end - expand_backtrace if exception.is_a?(SyntaxError) || exception.cause.is_a?(SyntaxError) + def line_number + @exception.line_number + end + + def actions + ActiveSupport::ActionableError.actions(@exception) + end + + def unwrapped_exception + if wrapper_exceptions.include?(@exception_class_name) + @exception.cause + else + @exception + end + end + + def annotated_source_code + if exception.respond_to?(:annotated_source_code) + exception.annotated_source_code + else + [] + end end def rescue_template - @@rescue_templates[@exception.class.name] + @@rescue_templates[@exception_class_name] end def status_code - self.class.status_code_for_exception(@exception.class.name) + self.class.status_code_for_exception(unwrapped_exception.class.name) + end + + def exception_trace + trace = application_trace + trace = framework_trace if trace.empty? && !silent_exceptions.include?(@exception_class_name) + trace end def application_trace @@ -63,11 +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| - trace_with_id = { id: idx, trace: trace } + filtered_trace = backtrace_cleaner&.clean_frame(trace, :all) || trace + + trace_with_id = { + exception_object_id: @exception.object_id, + id: idx, + 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 @@ -84,34 +179,117 @@ 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) + # We're treating `nil` as "unset", and we want the default setting to be `:all`. + # This logic should be extracted to `env_config` and calculated once. + config = request.get_header("action_dispatch.show_exceptions") + + case config + when :none + false + when :rescuable + rescue_response? + else + true + end + end + + def rescue_response? + @@rescue_responses.key?(exception.class.name) end def source_extracts backtrace.map do |trace| - file, line_number = extract_file_and_line_number(trace) + extract_source(trace).merge(trace: trace) + end + end - { - code: source_fragment(file, line_number), - line_number: line_number - } + def trace_to_show + if traces["Application Trace"].empty? && rescue_template != "routing_error" + "Full Trace" + else + "Application Trace" end end + def source_to_show_id + (traces[trace_to_show].first || {})[:id] + end + + def exception_name + exception.cause.class.to_s + end + + def message + exception.message + end + + def exception_inspect + exception.inspect + end + + def exception_id + exception.object_id + end + private + class SourceMapLocation < ActiveSupport::Delegation::DelegateClass(Thread::Backtrace::Location) # :nodoc: + def initialize(location, template) + super(location) + @template = template + end + + def spot(exc) + if RubyVM::AbstractSyntaxTree.respond_to?(:node_id_for_backtrace_location) && __getobj__.is_a?(Thread::Backtrace::Location) + location = @template.spot(__getobj__) + else + location = super + end - def backtrace - Array(@exception.backtrace) + if location + @template.translate_location(__getobj__, location) + end + end end - def original_exception(exception) - if @@rescue_responses.has_key?(exception.cause.class.name) - exception.cause - else - exception + attr_reader :backtrace + + def build_backtrace + built_methods = {} + + ActionView::PathRegistry.all_resolvers.each do |resolver| + resolver.built_templates.each do |template| + built_methods[template.method_name] = template + end + end + + (@exception.backtrace_locations || []).map do |loc| + if built_methods.key?(loc.base_label) + thread_backtrace_location = if loc.respond_to?(:__getobj__) + loc.__getobj__ + else + loc + end + SourceMapLocation.new(thread_backtrace_location, built_methods[loc.base_label]) + else + loc + end end end + def causes_for(exception) + return enum_for(__method__, exception) unless block_given? + + yield exception while exception = exception.cause + end + + def wrapped_causes_for(exception, backtrace_cleaner) + causes_for(exception).map { |cause| self.class.new(backtrace_cleaner, cause) } + end + def clean_backtrace(*args) if backtrace_cleaner backtrace_cleaner.clean(backtrace, *args) @@ -120,29 +298,53 @@ def clean_backtrace(*args) end end + def extract_source(trace) + spot = trace.spot(@exception) + + if spot + line = spot[:first_lineno] + code = extract_source_fragment_lines(spot[:script_lines], line) + + if line == spot[:last_lineno] + code[line] = [ + code[line][0, spot[:first_column]], + code[line][spot[:first_column]...spot[:last_column]], + code[line][spot[:last_column]..-1], + ] + end + + return { + code: code, + line_number: line + } + end + + file, line_number = extract_file_and_line_number(trace) + + { + code: source_fragment(file, line_number), + line_number: line_number + } + end + + def extract_source_fragment_lines(source_lines, line) + start = [line - 3, 0].max + lines = source_lines.drop(start).take(6) + Hash[*(start + 1..(lines.count + start)).zip(lines).flatten] + end + def source_fragment(path, line) return unless Rails.respond_to?(:root) && Rails.root full_path = Rails.root.join(path) if File.exist?(full_path) File.open(full_path, "r") do |file| - start = [line - 3, 0].max - lines = file.each_line.drop(start).take(6) - Hash[*(start + 1..(lines.count + start)).zip(lines).flatten] + extract_source_fragment_lines(file.each_line, line) end end end def extract_file_and_line_number(trace) - # Split by the first colon followed by some digits, which works for both - # Windows and Unix path styles. - file, line = trace.match(/^(.+?):(\d+).*$/, &:captures) || trace - [file, line.to_i] - end - - def expand_backtrace - @exception.backtrace.unshift( - @exception.to_s.split("\n") - ).flatten! + [trace.path, trace.lineno] end end end diff --git a/actionpack/lib/action_dispatch/middleware/executor.rb b/actionpack/lib/action_dispatch/middleware/executor.rb index 3d43f97a2b9ed..bc83ecfbf3ff1 100644 --- a/actionpack/lib/action_dispatch/middleware/executor.rb +++ b/actionpack/lib/action_dispatch/middleware/executor.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "rack/body_proxy" module ActionDispatch @@ -7,12 +11,34 @@ def initialize(app, executor) end def call(env) - state = @executor.run! + state = @executor.run!(reset: true) + if response_finished = env["rack.response_finished"] + response_finished << proc { state.complete! } + end + begin response = @app.call(env) - returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! } + + if env["action_dispatch.report_exception"] + error = env["action_dispatch.exception"] + @executor.error_reporter.report(error, handled: false, source: "application.action_dispatch") + end + + 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") + wrapper = ExceptionWrapper.new(backtrace_cleaner, error) + @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/flash.rb b/actionpack/lib/action_dispatch/middleware/flash.rb index cbe2f4be4de25..a2fa837cfe261 100644 --- a/actionpack/lib/action_dispatch/middleware/flash.rb +++ b/actionpack/lib/action_dispatch/middleware/flash.rb @@ -1,47 +1,59 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/hash/keys" module ActionDispatch - # The flash provides a way to pass temporary primitive-types (String, Array, Hash) between actions. Anything you place in the flash will be exposed - # to the very next action and then cleared out. This is a great way of doing notices and alerts, such as a create - # action that sets flash[:notice] = "Post successfully created" before redirecting to a display action that can - # then expose the flash to its template. Actually, that exposure is automatically done. + # # Action Dispatch Flash # - # class PostsController < ActionController::Base - # def create - # # save post - # flash[:notice] = "Post successfully created" - # redirect_to @post - # end + # The flash provides a way to pass temporary primitive-types (String, Array, + # Hash) between actions. Anything you place in the flash will be exposed to the + # very next action and then cleared out. This is a great way of doing notices + # and alerts, such as a create action that sets `flash[:notice] = "Post + # successfully created"` before redirecting to a display action that can then + # expose the flash to its template. Actually, that exposure is automatically + # done. + # + # class PostsController < ActionController::Base + # def create + # # save post + # flash[:notice] = "Post successfully created" + # redirect_to @post + # end # - # def show - # # doesn't need to assign the flash notice to the template, that's done automatically + # def show + # # doesn't need to assign the flash notice to the template, that's done automatically + # end # end - # end # - # show.html.erb + # Then in `show.html.erb`: + # # <% if flash[:notice] %> #
<%= flash[:notice] %>
# <% end %> # - # Since the +notice+ and +alert+ keys are a common idiom, convenience accessors are available: + # Since the `notice` and `alert` keys are a common idiom, convenience accessors + # are available: # - # flash.alert = "You must be logged in" - # flash.notice = "Post successfully created" + # flash.alert = "You must be logged in" + # flash.notice = "Post successfully created" # - # This example places a string in the flash. And of course, you can put as many as you like at a time too. If you want to pass - # non-primitive types, you will have to handle that in your application. Example: To show messages with links, you will have to - # use sanitize helper. + # This example places a string in the flash. And of course, you can put as many + # as you like at a time too. If you want to pass non-primitive types, you will + # have to handle that in your application. Example: To show messages with links, + # you will have to use sanitize helper. # # Just remember: They'll be gone by the time the next action has been performed. # # See docs on the FlashHash class for more details about the flash. class Flash - KEY = "action_dispatch.request.flash_hash".freeze + KEY = "action_dispatch.request.flash_hash" module RequestMethods - # Access the contents of the flash. Use flash["notice"] to - # read a notice you put there or flash["notice"] = "hello" - # to put a new one. + # Access the contents of the flash. Returns a ActionDispatch::Flash::FlashHash. + # + # See ActionDispatch::Flash for example usage. def flash flash = flash_hash return flash if flash @@ -57,27 +69,25 @@ def flash_hash # :nodoc: end def commit_flash # :nodoc: - session = self.session || {} - flash_hash = self.flash_hash + return unless session.enabled? if flash_hash && (flash_hash.present? || session.key?("flash")) session["flash"] = flash_hash.to_session_value self.flash = flash_hash.dup end - if (!session.respond_to?(:loaded?) || session.loaded?) && # (reset_session uses {}, which doesn't implement #loaded?) - session.key?("flash") && session["flash"].nil? + if session.loaded? && session.key?("flash") && session["flash"].nil? session.delete("flash") end end - def reset_session # :nodoc + def reset_session # :nodoc: super self.flash = nil end end - class FlashNow #:nodoc: + class FlashNow # :nodoc: attr_accessor :flash def initialize(flash) @@ -95,12 +105,12 @@ def [](k) @flash[k.to_s] end - # Convenience accessor for flash.now[:alert]=. + # Convenience accessor for `flash.now[:alert]=`. def alert=(message) self[:alert] = message end - # Convenience accessor for flash.now[:notice]=. + # Convenience accessor for `flash.now[:notice]=`. def notice=(message) self[:notice] = message end @@ -109,7 +119,7 @@ def notice=(message) class FlashHash include Enumerable - def self.from_session_value(value) #:nodoc: + def self.from_session_value(value) # :nodoc: case value when FlashHash # Rails 3.1, 3.2 flashes = value.instance_variable_get(:@flashes) @@ -128,15 +138,15 @@ def self.from_session_value(value) #:nodoc: end end - # Builds a hash containing the flashes to keep for the next request. - # If there are none to keep, returns +nil+. - def to_session_value #:nodoc: + # Builds a hash containing the flashes to keep for the next request. If there + # are none to keep, returns `nil`. + def to_session_value # :nodoc: flashes_to_keep = @flashes.except(*@discard) return nil if flashes_to_keep.empty? { "discard" => [], "flashes" => flashes_to_keep } end - def initialize(flashes = {}, discard = []) #:nodoc: + def initialize(flashes = {}, discard = []) # :nodoc: @discard = Set.new(stringify_array(discard)) @flashes = flashes.stringify_keys @now = nil @@ -160,7 +170,7 @@ def [](k) @flashes[k.to_s] end - def update(h) #:nodoc: + def update(h) # :nodoc: @discard.subtract stringify_array(h.keys) @flashes.update h.stringify_keys self @@ -174,6 +184,8 @@ def key?(name) @flashes.key? name.to_s end + # Immediately deletes the single flash entry. Use this method when you want + # remove the message within the current action. See also #discard. def delete(key) key = key.to_s @discard.delete key @@ -200,48 +212,55 @@ def each(&block) alias :merge! :update - def replace(h) #:nodoc: + def replace(h) # :nodoc: @discard.clear @flashes.replace h.stringify_keys self end - # Sets a flash that will not be available to the next action, only to the current. + # Sets a flash that will not be available to the next action, only to the + # current. # # flash.now[:message] = "Hello current action" # - # This method enables you to use the flash as a central messaging system in your app. - # When you need to pass an object to the next action, you use the standard flash assign ([]=). - # When you need to pass an object to the current action, you use now, and your object will - # vanish when the current action is done. + # This method enables you to use the flash as a central messaging system in your + # app. When you need to pass an object to the next action, you use the standard + # flash assign (`[]=`). When you need to pass an object to the current action, + # you use `now`, and your object will vanish when the current action is done. # - # Entries set via now are accessed the same way as standard entries: flash['my-key']. + # Entries set via `now` are accessed the same way as standard entries: + # `flash['my-key']`. # # Also, brings two convenience accessors: # - # flash.now.alert = "Beware now!" - # # Equivalent to flash.now[:alert] = "Beware now!" + # flash.now.alert = "Beware now!" + # # Equivalent to flash.now[:alert] = "Beware now!" # - # flash.now.notice = "Good luck now!" - # # Equivalent to flash.now[:notice] = "Good luck now!" + # flash.now.notice = "Good luck now!" + # # Equivalent to flash.now[:notice] = "Good luck now!" def now @now ||= FlashNow.new(self) end - # Keeps either the entire current flash or a specific flash entry available for the next action: + # Keeps either the entire current flash or a specific flash entry available for + # the next action: # - # flash.keep # keeps the entire flash - # flash.keep(:notice) # keeps only the "notice" entry, the rest of the flash is discarded + # flash.keep # keeps the entire flash + # flash.keep(:notice) # keeps only the "notice" entry, the rest of the flash is discarded def keep(k = nil) k = k.to_s if k @discard.subtract Array(k || keys) k ? self[k] : self end - # Marks the entire flash or a single flash entry to be discarded by the end of the current action: + # Marks the entire flash or a single flash entry to be discarded by the end of + # the current action: # # flash.discard # discard the entire flash at the end of the current action # flash.discard(:warning) # discard only the "warning" entry at the end of the current action + # + # Use this method when you want to display the message in the current action but + # not in the next one. See also #delete. def discard(k = nil) k = k.to_s if k @discard.merge Array(k || keys) @@ -250,28 +269,29 @@ def discard(k = nil) # Mark for removal entries that were kept, and delete unkept ones. # - # This method is called automatically by filters, so you generally don't need to care about it. - def sweep #:nodoc: + # This method is called automatically by filters, so you generally don't need to + # care about it. + def sweep # :nodoc: @discard.each { |k| @flashes.delete k } @discard.replace @flashes.keys end - # Convenience accessor for flash[:alert]. + # Convenience accessor for `flash[:alert]`. def alert self[:alert] end - # Convenience accessor for flash[:alert]=. + # Convenience accessor for `flash[:alert]=`. def alert=(message) self[:alert] = message end - # Convenience accessor for flash[:notice]. + # Convenience accessor for `flash[:notice]`. def notice self[:notice] end - # Convenience accessor for flash[:notice]=. + # Convenience accessor for `flash[:notice]=`. def notice=(message) self[:notice] = message end @@ -292,7 +312,7 @@ def stringify_array(array) # :doc: def self.new(app) app; end end - class Request + ActiveSupport.on_load(:action_dispatch_request) do prepend Flash::RequestMethods end end diff --git a/actionpack/lib/action_dispatch/middleware/host_authorization.rb b/actionpack/lib/action_dispatch/middleware/host_authorization.rb new file mode 100644 index 0000000000000..66749cfc1889a --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/host_authorization.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionDispatch + # # Action Dispatch HostAuthorization + # + # This middleware guards from DNS rebinding attacks by explicitly permitting the + # hosts a request can be sent to, and is passed the options set in + # `config.host_authorization`. + # + # Requests can opt-out of Host Authorization with `exclude`: + # + # config.host_authorization = { exclude: ->(request) { request.path =~ /healthcheck/ } } + # + # When a request comes to an unauthorized host, the `response_app` application + # will be executed and rendered. If no `response_app` is given, a default one + # will run. The default response app logs blocked host info with level 'error' + # and responds with `403 Forbidden`. The body of the response contains debug + # info if `config.consider_all_requests_local` is set to true, otherwise the + # body is empty. + class HostAuthorization + ALLOWED_HOSTS_IN_DEVELOPMENT = [".localhost", ".test", IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0")] + PORT_REGEX = /(?::\d+)/ # :nodoc: + SUBDOMAIN_REGEX = /(?:[a-z0-9-]+\.)/i # :nodoc: + IPV4_HOSTNAME = /(?\d+\.\d+\.\d+\.\d+)#{PORT_REGEX}?/ # :nodoc: + IPV6_HOSTNAME = /(?[a-f0-9]*:[a-f0-9.:]+)/i # :nodoc: + IPV6_HOSTNAME_WITH_PORT = /\[#{IPV6_HOSTNAME}\]#{PORT_REGEX}/i # :nodoc: + VALID_IP_HOSTNAME = Regexp.union( # :nodoc: + /\A#{IPV4_HOSTNAME}\z/, + /\A#{IPV6_HOSTNAME}\z/, + /\A#{IPV6_HOSTNAME_WITH_PORT}\z/, + ) + + class Permissions # :nodoc: + def initialize(hosts) + @hosts = sanitize_hosts(hosts) + end + + def empty? + @hosts.empty? + end + + def allows?(host) + @hosts.any? do |allowed| + if allowed.is_a?(IPAddr) + begin + allowed === extract_hostname(host) + rescue + # IPAddr#=== raises an error if you give it a hostname instead of IP. Treat + # similar errors as blocked access. + false + end + else + allowed === host + end + end + end + + private + def sanitize_hosts(hosts) + Array(hosts).map do |host| + case host + when Regexp then sanitize_regexp(host) + when String then sanitize_string(host) + else host + end + end + end + + def sanitize_regexp(host) + /\A#{host}#{PORT_REGEX}?\z/ + end + + def sanitize_string(host) + if host.start_with?(".") + /\A#{SUBDOMAIN_REGEX}?#{Regexp.escape(host[1..-1])}#{PORT_REGEX}?\z/i + else + /\A#{Regexp.escape host}#{PORT_REGEX}?\z/i + end + end + + def extract_hostname(host) + host.slice(VALID_IP_HOSTNAME, "host") || host + end + end + + class DefaultResponseApp # :nodoc: + RESPONSE_STATUS = 403 + + def call(env) + request = Request.new(env) + format = request.xhr? ? "text/plain" : "text/html" + + log_error(request) + response(format, response_body(request)) + end + + private + def response_body(request) + return "" unless request.get_header("action_dispatch.show_detailed_exceptions") + + template = DebugView.new(hosts: request.env["action_dispatch.blocked_hosts"]) + template.render(template: "rescues/blocked_host", layout: "rescues/layout") + end + + def response(format, body) + [RESPONSE_STATUS, + { Rack::CONTENT_TYPE => "#{format}; charset=#{Response.default_charset}", + Rack::CONTENT_LENGTH => body.bytesize.to_s }, + [body]] + end + + def log_error(request) + logger = available_logger(request) + + return unless logger + + logger.error("[#{self.class.name}] Blocked hosts: #{request.env["action_dispatch.blocked_hosts"].join(", ")}") + end + + def available_logger(request) + request.logger || ActionView::Base.logger + end + end + + def initialize(app, hosts, exclude: nil, response_app: nil) + @app = app + @permissions = Permissions.new(hosts) + @exclude = exclude + + @response_app = response_app || DefaultResponseApp.new + end + + def call(env) + return @app.call(env) if @permissions.empty? + + request = Request.new(env) + hosts = blocked_hosts(request) + + if hosts.empty? || excluded?(request) + mark_as_authorized(request) + @app.call(env) + else + env["action_dispatch.blocked_hosts"] = hosts + @response_app.call(env) + end + end + + private + def blocked_hosts(request) + hosts = [] + + origin_host = request.get_header("HTTP_HOST") + hosts << origin_host unless @permissions.allows?(origin_host) + + forwarded_host = request.x_forwarded_host&.split(/,\s?/)&.last + hosts << forwarded_host unless forwarded_host.blank? || @permissions.allows?(forwarded_host) + + hosts + end + + def excluded?(request) + @exclude && @exclude.call(request) + end + + def mark_as_authorized(request) + request.set_header("action_dispatch.authorized_host", request.host) + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb index 46f0f675b96de..51a5b13d0d018 100644 --- a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb @@ -1,11 +1,17 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch + # # Action Dispatch PublicExceptions + # # When called, this middleware renders an error page. By default if an HTML # response is expected it will render static error pages from the `/public` # directory. For example when this middleware receives a 500 response it will - # render the template found in `/public/500.html`. - # If an internationalized locale is set, this middleware will attempt to render - # the template in `/public/500..html`. If an internationalized template - # is not found it will fall back on `/public/500.html`. + # render the template found in `/public/500.html`. If an internationalized + # locale is set, this middleware will attempt to render the template in + # `/public/500..html`. If an internationalized template is not found it + # will fall back on `/public/500.html`. # # When a request with a content type other than HTML is made, this middleware # will attempt to convert error information into the appropriate response type. @@ -20,13 +26,16 @@ def call(env) request = ActionDispatch::Request.new(env) status = request.path_info[1..-1].to_i content_type = request.formats.first - body = { status: status, error: Rack::Utils::HTTP_STATUS_CODES.fetch(status, Rack::Utils::HTTP_STATUS_CODES[500]) } + 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 - def render(status, content_type, body) format = "to_#{content_type.to_sym}" if content_type if format && body.respond_to?(format) @@ -37,8 +46,8 @@ def render(status, content_type, body) end def render_format(status, content_type, body) - [status, { "Content-Type" => "#{content_type}; charset=#{ActionDispatch::Response.default_charset}", - "Content-Length" => body.bytesize.to_s }, [body]] + [status, { Rack::CONTENT_TYPE => "#{content_type}; charset=#{ActionDispatch::Response.default_charset}", + Rack::CONTENT_LENGTH => body.bytesize.to_s }, [body]] end def render_html(status) @@ -48,7 +57,7 @@ def render_html(status) if found || File.exist?(path) render_format(status, "text/html", File.read(path)) else - [404, { "X-Cascade" => "pass" }, []] + [404, { Constants::X_CASCADE => "pass" }, []] end end end diff --git a/actionpack/lib/action_dispatch/middleware/reloader.rb b/actionpack/lib/action_dispatch/middleware/reloader.rb index 6d64b1424ba84..f83c4b102ad3f 100644 --- a/actionpack/lib/action_dispatch/middleware/reloader.rb +++ b/actionpack/lib/action_dispatch/middleware/reloader.rb @@ -1,10 +1,16 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch - # ActionDispatch::Reloader wraps the request with callbacks provided by ActiveSupport::Reloader - # callbacks, intended to assist with code reloading during development. + # # Action Dispatch Reloader # - # By default, ActionDispatch::Reloader is included in the middleware stack - # only in the development environment; specifically, when +config.cache_classes+ - # is false. + # ActionDispatch::Reloader wraps the request with callbacks provided by + # ActiveSupport::Reloader, intended to assist with code reloading during + # development. + # + # ActionDispatch::Reloader is included in the middleware stack only if reloading + # is enabled, which it is by the default in `development` mode. class Reloader < Executor end end diff --git a/actionpack/lib/action_dispatch/middleware/remote_ip.rb b/actionpack/lib/action_dispatch/middleware/remote_ip.rb index 8bae5bfeff83c..c3aaefbd1a2a5 100644 --- a/actionpack/lib/action_dispatch/middleware/remote_ip.rb +++ b/actionpack/lib/action_dispatch/middleware/remote_ip.rb @@ -1,62 +1,69 @@ +# frozen_string_literal: true + +# :markup: markdown + require "ipaddr" module ActionDispatch - # This middleware calculates the IP address of the remote client that is - # making the request. It does this by checking various headers that could - # contain the address, and then picking the last-set address that is not - # on the list of trusted IPs. This follows the precedent set by e.g. - # {the Tomcat server}[https://issues.apache.org/bugzilla/show_bug.cgi?id=50453], - # with {reasoning explained at length}[http://blog.gingerlime.com/2012/rails-ip-spoofing-vulnerabilities-and-protection] - # by @gingerlime. A more detailed explanation of the algorithm is given - # at GetIp#calculate_ip. + # # Action Dispatch RemoteIp + # + # This middleware calculates the IP address of the remote client that is making + # the request. It does this by checking various headers that could contain the + # address, and then picking the last-set address that is not on the list of + # trusted IPs. This follows the precedent set by e.g. [the Tomcat + # server](https://issues.apache.org/bugzilla/show_bug.cgi?id=50453). A more + # detailed explanation of the algorithm is given at GetIp#calculate_ip. # - # Some Rack servers concatenate repeated headers, like {HTTP RFC 2616}[http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2] - # requires. Some Rack servers simply drop preceding headers, and only report - # the value that was {given in the last header}[http://andre.arko.net/2011/12/26/repeated-headers-and-ruby-web-servers]. - # If you are behind multiple proxy servers (like NGINX to HAProxy to Unicorn) - # then you should test your Rack server to make sure your data is good. + # Some Rack servers concatenate repeated headers, like [HTTP RFC + # 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2) requires. + # Some Rack servers simply drop preceding headers, and only report the value + # that was [given in the last + # header](https://andre.arko.net/2011/12/26/repeated-headers-and-ruby-web-servers). + # If you are behind multiple proxy servers (like NGINX to HAProxy to + # Unicorn) then you should test your Rack server to make sure your data is good. # - # IF YOU DON'T USE A PROXY, THIS MAKES YOU VULNERABLE TO IP SPOOFING. - # This middleware assumes that there is at least one proxy sitting around - # and setting headers with the client's remote IP address. If you don't use - # a proxy, because you are hosted on e.g. Heroku without SSL, any client can - # claim to have any IP address by setting the X-Forwarded-For header. If you - # care about that, then you need to explicitly drop or ignore those headers - # sometime before this middleware runs. + # IF YOU DON'T USE A PROXY, THIS MAKES YOU VULNERABLE TO IP SPOOFING. This + # middleware assumes that there is at least one proxy sitting around and setting + # headers with the client's remote IP address. If you don't use a proxy, because + # you are hosted on e.g. Heroku without SSL, any client can claim to have any IP + # address by setting the `X-Forwarded-For` header. If you care about that, then + # you need to explicitly drop or ignore those headers sometime before this + # middleware runs. Alternatively, remove this middleware to avoid inadvertently + # relying on it. class RemoteIp class IpSpoofAttackError < StandardError; end - # The default trusted IPs list simply includes IP addresses that are - # guaranteed by the IP specification to be private addresses. Those will - # not be the ultimate client IP in production, and so are discarded. See - # http://en.wikipedia.org/wiki/Private_network for details. + # The default trusted IPs list simply includes IP addresses that are guaranteed + # by the IP specification to be private addresses. Those will not be the + # ultimate client IP in production, and so are discarded. See + # https://en.wikipedia.org/wiki/Private_network for details. TRUSTED_PROXIES = [ - "127.0.0.1", # localhost IPv4 + "127.0.0.0/8", # localhost IPv4 range, per RFC-3330 "::1", # localhost IPv6 "fc00::/7", # private IPv6 range fc00::/7 "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 - # Create a new +RemoteIp+ middleware instance. + # Create a new `RemoteIp` middleware instance. # - # The +ip_spoofing_check+ option is on by default. When on, an exception - # is raised if it looks like the client is trying to lie about its own IP - # address. It makes sense to turn off this check on sites aimed at non-IP - # clients (like WAP devices), or behind proxies that set headers in an - # incorrect or confusing way (like AWS ELB). + # The `ip_spoofing_check` option is on by default. When on, an exception is + # raised if it looks like the client is trying to lie about its own IP address. + # It makes sense to turn off this check on sites aimed at non-IP clients (like + # WAP devices), or behind proxies that set headers in an incorrect or confusing + # way (like AWS ELB). # - # The +custom_proxies+ argument can take an Array of string, IPAddr, or - # Regexp objects which will be used instead of +TRUSTED_PROXIES+. If a - # single string, IPAddr, or Regexp object is provided, it will be used in - # addition to +TRUSTED_PROXIES+. Any proxy setup will put the value you - # want in the middle (or at the beginning) of the X-Forwarded-For list, - # with your proxy servers after it. If your proxies aren't removed, pass - # them in via the +custom_proxies+ parameter. That way, the middleware will - # ignore those IP addresses, and return the one that you want. + # The `custom_proxies` argument can take an enumerable which will be used + # instead of `TRUSTED_PROXIES`. Any proxy setup will put the value you want in + # the middle (or at the beginning) of the `X-Forwarded-For` list, with your + # proxy servers after it. If your proxies aren't removed, pass them in via the + # `custom_proxies` parameter. That way, the middleware will ignore those IP + # addresses, and return the one that you want. def initialize(app, ip_spoofing_check = true, custom_proxies = nil) @app = app @check_ip = ip_spoofing_check @@ -65,23 +72,35 @@ def initialize(app, ip_spoofing_check = true, custom_proxies = nil) elsif custom_proxies.respond_to?(:any?) custom_proxies else - Array(custom_proxies) + TRUSTED_PROXIES + raise(ArgumentError, <<~EOM) + Setting config.action_dispatch.trusted_proxies to a single value isn't + supported. Please set this to an enumerable instead. For + example, instead of: + + config.action_dispatch.trusted_proxies = IPAddr.new("10.0.0.0/8") + + Wrap the value in an Array: + + config.action_dispatch.trusted_proxies = [IPAddr.new("10.0.0.0/8")] + + Note that passing an enumerable will *replace* the default set of trusted proxies. + EOM end end - # Since the IP address may not be needed, we store the object here - # without calculating the IP to keep from slowing down the majority of - # requests. For those requests that do need to know the IP, the - # GetIp#calculate_ip method will calculate the memoized client IP address. + # Since the IP address may not be needed, we store the object here without + # calculating the IP to keep from slowing down the majority of requests. For + # those requests that do need to know the IP, the GetIp#calculate_ip method will + # calculate the memoized client IP address. def call(env) req = ActionDispatch::Request.new env req.remote_ip = GetIp.new(req, check_ip, proxies) @app.call(req.env) end - # The GetIp class exists as a way to defer processing of the request data - # into an actual IP address. If the ActionDispatch::Request#remote_ip method - # is called, this class will calculate the value and then memoize it. + # The GetIp class exists as a way to defer processing of the request data into + # an actual IP address. If the ActionDispatch::Request#remote_ip method is + # called, this class will calculate the value and then memoize it. class GetIp def initialize(req, check_ip, proxies) @req = req @@ -89,62 +108,65 @@ def initialize(req, check_ip, proxies) @proxies = proxies end - # Sort through the various IP address headers, looking for the IP most - # likely to be the address of the actual remote client making this - # request. + # Sort through the various IP address headers, looking for the IP most likely to + # be the address of the actual remote client making this request. # - # REMOTE_ADDR will be correct if the request is made directly against the - # Ruby process, on e.g. Heroku. When the request is proxied by another - # server like HAProxy or NGINX, the IP address that made the original - # request will be put in an X-Forwarded-For header. If there are multiple - # proxies, that header may contain a list of IPs. Other proxy services - # set the Client-Ip header instead, so we check that too. + # REMOTE_ADDR will be correct if the request is made directly against the Ruby + # process, on e.g. Heroku. When the request is proxied by another server like + # HAProxy or NGINX, the IP address that made the original request will be put in + # an `X-Forwarded-For` header. If there are multiple proxies, that header may + # contain a list of IPs. Other proxy services set the `Client-Ip` header + # instead, so we check that too. # - # As discussed in {this post about Rails IP Spoofing}[http://blog.gingerlime.com/2012/rails-ip-spoofing-vulnerabilities-and-protection/], - # while the first IP in the list is likely to be the "originating" IP, - # it could also have been set by the client maliciously. + # As discussed in [this post about Rails IP + # Spoofing](https://web.archive.org/web/20170626095448/https://blog.gingerlime.com/2012/rails-ip-spoofing-vulnerabilities-and-protection/), + # while the first IP in the list is likely to be the "originating" IP, it + # could also have been set by the client maliciously. # - # In order to find the first address that is (probably) accurate, we - # take the list of IPs, remove known and trusted proxies, and then take - # the last address left, which was presumably set by one of those proxies. + # In order to find the first address that is (probably) accurate, we take the + # list of IPs, remove known and trusted proxies, and then take the last address + # 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: + # `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they + # are both set, it means that either: # # 1) This request passed through two proxies with incompatible IP header - # conventions. - # 2) The client passed one of +Client-Ip+ or +X-Forwarded-For+ - # (whichever the proxy servers weren't using) themselves. + # conventions. + # + # 2) The client passed one of `Client-Ip` or `X-Forwarded-For` + # (whichever the proxy servers weren't using) themselves. # - # Either way, there is no way for us to determine which header is the - # right one after the fact. Since we have no idea, if we are concerned - # about IP spoofing we need to give up and explode. (If you're not - # concerned about IP spoofing you can turn the +ip_spoofing_check+ - # option off.) + # Either way, there is no way for us to determine which header is the right one + # after the fact. Since we have no idea, if we are concerned about IP spoofing + # we need to give up and explode. (If you're not concerned about IP spoofing you + # can turn the `ip_spoofing_check` option off.) should_check_ip = @check_ip && client_ips.last && forwarded_ips.last if should_check_ip && !forwarded_ips.include?(client_ips.last) # 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: # - # - X-Forwarded-For will be a list of IPs, one per proxy, or blank - # - Client-Ip is propagated from the outermost proxy, or is blank - # - REMOTE_ADDR will be the IP that made the request to Rack - ips = [forwarded_ips, client_ips, remote_addr].flatten.compact + # - X-Forwarded-For will be a list of IPs, one per proxy, or blank + # - Client-Ip is propagated from the outermost proxy, or is blank + # - REMOTE_ADDR will be the IP that made the request to Rack + ips = forwarded_ips + client_ips + ips.compact! - # If every single IP option is in the trusted list, just return REMOTE_ADDR - filter_proxies(ips).first || remote_addr + # If every single IP option is in the trusted list, return the IP that's + # furthest away + filter_proxies(ips + [remote_addr]).first || ips.last || remote_addr end # Memoizes the value returned by #calculate_ip and returns it for @@ -154,21 +176,22 @@ def to_s end private - def ips_from(header) # :doc: return [] unless header - # Split the comma-separated list into an array of strings - ips = header.strip.split(/[,\s]+/) - ips.select do |ip| - begin - # Only return IPs that are valid according to the IPAddr#new method - range = IPAddr.new(ip).to_range - # we want to make sure nobody is sneaking a netmask in - range.begin == range.end - rescue ArgumentError - nil - end + # Split the comma-separated list into an array of strings. + 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 + # We want to make sure nobody is sneaking a netmask in. + range.begin == range.end + rescue ArgumentError + nil end + ips end def filter_proxies(ips) # :doc: diff --git a/actionpack/lib/action_dispatch/middleware/request_id.rb b/actionpack/lib/action_dispatch/middleware/request_id.rb index 1925ffd9dd331..7d2e3078cb17f 100644 --- a/actionpack/lib/action_dispatch/middleware/request_id.rb +++ b/actionpack/lib/action_dispatch/middleware/request_id.rb @@ -1,34 +1,43 @@ +# frozen_string_literal: true + +# :markup: markdown + require "securerandom" require "active_support/core_ext/string/access" module ActionDispatch - # Makes a unique request id available to the +action_dispatch.request_id+ env variable (which is then accessible - # through ActionDispatch::Request#request_id or the alias ActionDispatch::Request#uuid) and sends - # the same id to the client via the X-Request-Id header. + # # Action Dispatch RequestId # - # The unique request id is either based on the X-Request-Id header in the request, which would typically be generated - # by a firewall, load balancer, or the web server, or, if this header is not available, a random uuid. If the - # header is accepted from the outside world, we sanitize it to a max of 255 chars and alphanumeric and dashes only. + # Makes a unique request id available to the `action_dispatch.request_id` env + # variable (which is then accessible through ActionDispatch::Request#request_id + # or the alias ActionDispatch::Request#uuid) and sends the same id to the client + # via the `X-Request-Id` header. # - # The unique request id can be used to trace a request end-to-end and would typically end up being part of log files - # from multiple pieces of the stack. + # The unique request id is either based on the `X-Request-Id` header in the + # request, which would typically be generated by a firewall, load balancer, or + # the web server, or, if this header is not available, a random uuid. If the + # header is accepted from the outside world, we sanitize it to a max of 255 + # chars and alphanumeric and dashes only. + # + # The unique request id can be used to trace a request end-to-end and would + # typically end up being part of log files from multiple pieces of the stack. class RequestId - X_REQUEST_ID = "X-Request-Id".freeze #:nodoc: - - def initialize(app) + def initialize(app, header:) @app = app + @header = header + @env_header = "HTTP_#{header.upcase.tr("-", "_")}" end def call(env) req = ActionDispatch::Request.new env - req.request_id = make_request_id(req.x_request_id) - @app.call(env).tap { |_status, headers, _body| headers[X_REQUEST_ID] = req.request_id } + req.request_id = make_request_id(req.get_header(@env_header)) + @app.call(env).tap { |_status, headers, _body| headers[@header] = req.request_id } end private def make_request_id(request_id) if request_id.presence - request_id.gsub(/[^\w\-]/, "".freeze).first(255) + request_id.gsub(/[^\w\-@]/, "").first(255) else internal_request_id end diff --git a/actionpack/lib/action_dispatch/middleware/server_timing.rb b/actionpack/lib/action_dispatch/middleware/server_timing.rb new file mode 100644 index 0000000000000..2ab4390875023 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/server_timing.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/notifications" + +module ActionDispatch + class ServerTiming + class Subscriber # :nodoc: + include Singleton + KEY = :action_dispatch_server_timing_events + + def initialize + @mutex = Mutex.new + end + + def call(event) + if events = ActiveSupport::IsolatedExecutionState[KEY] + events << event + end + end + + def collect_events + events = [] + ActiveSupport::IsolatedExecutionState[KEY] = events + yield + events + ensure + ActiveSupport::IsolatedExecutionState.delete(KEY) + end + + def ensure_subscribed + @mutex.synchronize do + # Subscribe to all events, except those beginning with "!" Ideally we would be + # more selective of what is being measured + @subscriber ||= ActiveSupport::Notifications.subscribe(/\A[^!]/, self) + end + end + + def unsubscribe + @mutex.synchronize do + ActiveSupport::Notifications.unsubscribe @subscriber + @subscriber = nil + end + end + end + + def self.unsubscribe # :nodoc: + Subscriber.instance.unsubscribe + end + + def initialize(app) + @app = app + @subscriber = Subscriber.instance + @subscriber.ensure_subscribed + end + + def call(env) + response = nil + events = @subscriber.collect_events do + response = @app.call(env) + end + + headers = response[1] + + header_info = events.group_by(&:name).map do |event_name, events_collection| + "%s;dur=%.2f" % [event_name, events_collection.sum(&:duration)] + end + + if headers[ActionDispatch::Constants::SERVER_TIMING].present? + header_info.prepend(headers[ActionDispatch::Constants::SERVER_TIMING]) + end + headers[ActionDispatch::Constants::SERVER_TIMING] = header_info.join(", ") + + response + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb b/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb index d9f018c8ac5fb..bea38110d6055 100644 --- a/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb +++ b/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb @@ -1,12 +1,15 @@ +# frozen_string_literal: true + +# :markup: markdown + require "rack/utils" require "rack/request" require "rack/session/abstract/id" require "action_dispatch/middleware/cookies" -require "action_dispatch/request/session" module ActionDispatch module Session - class SessionRestoreError < StandardError #:nodoc: + class SessionRestoreError < StandardError # :nodoc: def initialize super("Session contains objects whose class definition isn't available.\n" \ "Remember to require the classes for all objects kept in the session.\n" \ @@ -28,7 +31,6 @@ def generate_sid end private - def initialize_sid # :doc: @default_options.delete(:sidbits) @default_options.delete(:secure_random) @@ -53,7 +55,7 @@ def stale_session_check! rescue ArgumentError => argument_error if argument_error.message =~ %r{undefined class/module ([\w:]*\w)} begin - # Note that the regexp does not allow $1 to end with a ':' + # Note that the regexp does not allow $1 to end with a ':'. $1.constantize rescue LoadError, NameError raise ActionDispatch::Session::SessionRestoreError @@ -66,6 +68,11 @@ def stale_session_check! end module SessionObject # :nodoc: + def commit_session(req, res) + req.commit_csrf_token + super(req, res) + end + def prepare_session(req) Request::Session.create(self, req, @default_options) end @@ -81,8 +88,22 @@ class AbstractStore < Rack::Session::Abstract::Persisted include SessionObject private + def set_cookie(request, response, cookie) + request.cookie_jar[key] = cookie + end + end - def set_cookie(request, session_id, cookie) + class AbstractSecureStore < Rack::Session::Abstract::PersistedSecure + include Compatibility + include StaleSessionCheck + include SessionObject + + def generate_sid + Rack::Session::SessionId.new(super) + end + + private + def set_cookie(request, response, cookie) request.cookie_jar[key] = cookie end end diff --git a/actionpack/lib/action_dispatch/middleware/session/cache_store.rb b/actionpack/lib/action_dispatch/middleware/session/cache_store.rb index 71274bc13ac17..6c6e95b66e998 100644 --- a/actionpack/lib/action_dispatch/middleware/session/cache_store.rb +++ b/actionpack/lib/action_dispatch/middleware/session/cache_store.rb @@ -1,25 +1,39 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/middleware/session/abstract_store" module ActionDispatch module Session - # A session store that uses an ActiveSupport::Cache::Store to store the sessions. This store is most useful - # if you don't store critical data in your sessions and you don't need them to live for extended periods - # of time. + # # Action Dispatch Session CacheStore + # + # A session store that uses an ActiveSupport::Cache::Store to store the + # sessions. This store is most useful if you don't store critical data in your + # sessions and you don't need them to live for extended periods of time. + # + # #### Options + # * `cache` - The cache to use. If it is not specified, `Rails.cache` + # will be used. + # * `expire_after` - The length of time a session will be stored before + # automatically expiring. By default, the `:expires_in` option of the cache + # is used. + # * `check_collisions` - Check if newly generated session ids aren't already in use. + # If for some reason 128 bits of randomness aren't considered secure enough to avoid + # collisions, this option can be enabled to ensure newly generated ids aren't in use. + # By default, it is set to `false` to avoid additional cache write operations. # - # ==== Options - # * cache - The cache to use. If it is not specified, Rails.cache will be used. - # * expire_after - The length of time a session will be stored before automatically expiring. - # By default, the :expires_in option of the cache is used. - class CacheStore < AbstractStore + class CacheStore < AbstractSecureStore def initialize(app, options = {}) @cache = options[:cache] || Rails.cache options[:expire_after] ||= @cache.options[:expires_in] + @check_collisions = options[:check_collisions] || false super end # Get a session from the cache. def find_session(env, sid) - unless sid && (session = @cache.read(cache_key(sid))) + unless sid && (session = get_session_with_fallback(sid)) sid, session = generate_sid, {} end [sid, session] @@ -27,7 +41,7 @@ def find_session(env, sid) # Set a session in the cache. def write_session(env, sid, session, options) - key = cache_key(sid) + key = cache_key(sid.private_id) if session @cache.write(key, session, expires_in: options[:expire_after]) else @@ -38,14 +52,31 @@ def write_session(env, sid, session, options) # Remove a session from the cache. def delete_session(env, sid, options) - @cache.delete(cache_key(sid)) + @cache.delete(cache_key(sid.private_id)) + @cache.delete(cache_key(sid.public_id)) generate_sid end private # Turn the session id into a cache key. - def cache_key(sid) - "_session_id:#{sid}" + def cache_key(id) + "_session_id:#{id}" + end + + def get_session_with_fallback(sid) + @cache.read(cache_key(sid.private_id)) || @cache.read(cache_key(sid.public_id)) + end + + def generate_sid + if @check_collisions + loop do + sid = super + key = cache_key(sid.private_id) + break sid if @cache.write(key, {}, unless_exist: true, expires_in: default_options[:expire_after]) + end + else + super + end end end end diff --git a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb index 57d325a9d8b23..191ab27624270 100644 --- a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb +++ b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb @@ -1,76 +1,76 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/hash/keys" require "action_dispatch/middleware/session/abstract_store" require "rack/session/cookie" module ActionDispatch module Session - # This cookie-based session store is the Rails default. It is - # dramatically faster than the alternatives. + # # Action Dispatch Session CookieStore # - # Sessions typically contain at most a user_id and flash message; both fit - # within the 4K cookie size limit. A CookieOverflow exception is raised if - # you attempt to store more than 4K of data. + # This cookie-based session store is the Rails default. It is dramatically + # faster than the alternatives. # - # The cookie jar used for storage is automatically configured to be the - # best possible option given your application's configuration. + # Sessions typically contain at most a user ID and flash message; both fit + # within the 4096 bytes cookie size limit. A `CookieOverflow` exception is + # raised if you attempt to store more than 4096 bytes of data. # - # If you only have secret_token set, your cookies will be signed, but - # not encrypted. This means a user cannot alter their +user_id+ without - # knowing your app's secret key, but can easily read their +user_id+. This - # was the default for Rails 3 apps. + # The cookie jar used for storage is automatically configured to be the best + # possible option given your application's configuration. # - # If you have secret_key_base set, your cookies will be encrypted. This - # goes a step further than signed cookies in that encrypted cookies cannot + # Your cookies will be encrypted using your application's `secret_key_base`. + # This goes a step further than signed cookies in that encrypted cookies cannot # be altered or read by users. This is the default starting in Rails 4. # - # If you have both secret_token and secret_key_base set, your cookies will - # be encrypted, and signed cookies generated by Rails 3 will be - # transparently read and encrypted to provide a smooth upgrade path. - # - # Configure your session store in config/initializers/session_store.rb: + # Configure your session store in an initializer: # - # Rails.application.config.session_store :cookie_store, key: '_your_app_session' + # Rails.application.config.session_store :cookie_store, key: '_your_app_session' # - # Configure your secret key in config/secrets.yml: + # In the development and test environments your application's `secret_key_base` + # is generated by Rails and stored in a temporary file in + # `tmp/local_secret.txt`. In all other environments, it is stored encrypted in + # the `config/credentials.yml.enc` file. # - # development: - # secret_key_base: 'secret key' + # If your application was not updated to Rails 5.2 defaults, the + # `secret_key_base` will be found in the old `config/secrets.yml` file. # - # To generate a secret key for an existing application, run `rails secret`. + # Note that changing your `secret_key_base` will invalidate all existing + # session. Additionally, you should take care to make sure you are not relying + # on the ability to decode signed cookies generated by your app in external + # applications or JavaScript before changing it. # - # If you are upgrading an existing Rails 3 app, you should leave your - # existing secret_token in place and simply add the new secret_key_base. - # Note that you should wait to set secret_key_base until you have 100% of - # your userbase on Rails 4 and are reasonably sure you will not need to - # rollback to Rails 3. This is because cookies signed based on the new - # secret_key_base in Rails 4 are not backwards compatible with Rails 3. - # You are free to leave your existing secret_token in place, not set the - # new secret_key_base, and ignore the deprecation warnings until you are - # reasonably sure that your upgrade is otherwise complete. Additionally, - # you should take care to make sure you are not relying on the ability to - # decode signed cookies generated by your app in external applications or - # JavaScript before upgrading. + # Because CookieStore extends `Rack::Session::Abstract::Persisted`, many of the + # options described there can be used to customize the session cookie that is + # generated. For example: # - # Note that changing the secret key will invalidate all existing sessions! - # - # Because CookieStore extends Rack::Session::Abstract::Persisted, many of the - # options described there can be used to customize the session cookie that - # is generated. For example: - # - # Rails.application.config.session_store :cookie_store, expire_after: 14.days + # Rails.application.config.session_store :cookie_store, expire_after: 14.days # # would set the session cookie to expire automatically 14 days after creation. - # Other useful options include :key, :secure and - # :httponly. - class CookieStore < AbstractStore + # Other useful options include `:key`, `:secure`, `:httponly`, and `:same_site`. + class CookieStore < AbstractSecureStore + class SessionId < ActiveSupport::Delegation::DelegateClass(Rack::Session::SessionId) + attr_reader :cookie_value + + def initialize(session_id, cookie_value = {}) + super(session_id) + @cookie_value = cookie_value + end + end + + DEFAULT_SAME_SITE = proc { |request| request.cookies_same_site_protection } # :nodoc: + def initialize(app, options = {}) - super(app, options.merge!(cookie_only: true)) + options[:cookie_only] = true + options[:same_site] = DEFAULT_SAME_SITE if !options.key?(:same_site) + super end def delete_session(req, session_id, options) new_sid = generate_sid unless options[:drop] # Reset hash and Assign the new session id - req.set_header("action_dispatch.request.unsigned_session_cookie", new_sid ? { "session_id" => new_sid } : {}) + req.set_header("action_dispatch.request.unsigned_session_cookie", new_sid ? { "session_id" => new_sid.public_id } : {}) new_sid end @@ -78,15 +78,15 @@ def load_session(req) stale_session_check! do data = unpacked_cookie_data(req) data = persistent_session_id!(data) - [data["session_id"], data] + [Rack::Session::SessionId.new(data["session_id"]), data] end end private - def extract_session_id(req) stale_session_check! do - unpacked_cookie_data(req)["session_id"] + sid = unpacked_cookie_data(req)["session_id"] + sid && Rack::Session::SessionId.new(sid) end end @@ -104,13 +104,13 @@ def unpacked_cookie_data(req) def persistent_session_id!(data, sid = nil) data ||= {} - data["session_id"] ||= sid || generate_sid + data["session_id"] ||= sid || generate_sid.public_id data end def write_session(req, sid, session_data, options) - session_data["session_id"] = sid - session_data + session_data["session_id"] = sid.public_id + SessionId.new(sid, session_data) end def set_cookie(request, session_id, cookie) diff --git a/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb b/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb index ee2b1f26ad6ba..2c86799568774 100644 --- a/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb +++ b/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb @@ -1,17 +1,25 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/middleware/session/abstract_store" begin require "rack/session/dalli" rescue LoadError => e - $stderr.puts "You don't have dalli installed in your application. Please add it to your Gemfile and run bundle install" + warn "You don't have dalli installed in your application. Please add it to your Gemfile and run bundle install" raise e end module ActionDispatch module Session + # # Action Dispatch Session MemCacheStore + # # A session store that uses MemCache to implement storage. # - # ==== Options - # * expire_after - The length of time a session will be stored before automatically expiring. + # #### Options + # * `expire_after` - The length of time a session will be stored before + # automatically expiring. + # class MemCacheStore < Rack::Session::Dalli include Compatibility include StaleSessionCheck diff --git a/actionpack/lib/action_dispatch/middleware/show_exceptions.rb b/actionpack/lib/action_dispatch/middleware/show_exceptions.rb index 90f26a1c33dd1..d07f4003b104f 100644 --- a/actionpack/lib/action_dispatch/middleware/show_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/show_exceptions.rb @@ -1,60 +1,88 @@ -require "action_dispatch/http/request" +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/middleware/exception_wrapper" module ActionDispatch - # This middleware rescues any exception returned by the application - # and calls an exceptions app that will wrap it in a format for the end user. + # # Action Dispatch ShowExceptions + # + # This middleware rescues any exception returned by the application and calls an + # exceptions app that will wrap it in a format for the end user. + # + # The exceptions app should be passed as a parameter on initialization of + # `ShowExceptions`. Every time there is an exception, `ShowExceptions` will + # store the exception in `env["action_dispatch.exception"]`, rewrite the + # `PATH_INFO` to the exception status code, and call the Rack app. # - # The exceptions app should be passed as parameter on initialization - # of ShowExceptions. Every time there is an exception, ShowExceptions will - # store the exception in env["action_dispatch.exception"], rewrite the - # PATH_INFO to the exception status code and call the rack app. + # In Rails applications, the exceptions app can be configured with + # `config.exceptions_app`, which defaults to ActionDispatch::PublicExceptions. # - # If the application returns a "X-Cascade" pass response, this middleware - # will send an empty response as result with the correct status code. - # If any exception happens inside the exceptions app, this middleware - # catches the exceptions and returns a FAILSAFE_RESPONSE. + # If the application returns a response with the `X-Cascade` header set to + # `"pass"`, this middleware will send an empty response as a result with the + # correct status code. If any exception happens inside the exceptions app, this + # middleware catches the exceptions and returns a failsafe response. class ShowExceptions - FAILSAFE_RESPONSE = [500, { "Content-Type" => "text/plain" }, - ["500 Internal Server Error\n" \ - "If you are the administrator of this website, then please read this web " \ - "application's log file and/or the web server's log file to find out what " \ - "went wrong."]] - def initialize(app, exceptions_app) @app = app @exceptions_app = exceptions_app end def call(env) - request = ActionDispatch::Request.new env @app.call(env) rescue Exception => exception - if request.show_exceptions? - render_exception(request, exception) + request = ActionDispatch::Request.new env + backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner") + wrapper = ExceptionWrapper.new(backtrace_cleaner, exception) + request.set_header "action_dispatch.exception", wrapper.unwrapped_exception + request.set_header "action_dispatch.report_exception", !wrapper.rescue_response? + + if wrapper.show?(request) + render_exception(request.dup, wrapper) else raise exception end end private - - def render_exception(request, exception) - backtrace_cleaner = request.get_header "action_dispatch.backtrace_cleaner" - wrapper = ExceptionWrapper.new(backtrace_cleaner, exception) - status = wrapper.status_code - request.set_header "action_dispatch.exception", wrapper.exception + def render_exception(request, wrapper) + status = wrapper.status_code request.set_header "action_dispatch.original_path", request.path_info + request.set_header "action_dispatch.original_request_method", request.raw_request_method + fallback_to_html_format_if_invalid_mime_type(request) request.path_info = "/#{status}" + request.request_method = "GET" response = @exceptions_app.call(request.env) - response[1]["X-Cascade"] == "pass" ? pass_response(status) : response + response[1][Constants::X_CASCADE] == "pass" ? pass_response(status) : response rescue Exception => failsafe_error $stderr.puts "Error during failsafe response: #{failsafe_error}\n #{failsafe_error.backtrace * "\n "}" - FAILSAFE_RESPONSE + + [500, { Rack::CONTENT_TYPE => "text/plain; charset=utf-8" }, + ["500 Internal Server Error\n" \ + "If you are the administrator of this website, then please read this web " \ + "application's log file and/or the web server's log file to find out what " \ + "went wrong."]] + end + + def fallback_to_html_format_if_invalid_mime_type(request) + # If the MIME type for the request is invalid then the @exceptions_app may not + # be able to handle it. To make it easier to handle, we switch to HTML. + begin + request.content_mime_type + rescue ActionDispatch::Http::MimeNegotiation::InvalidType + request.set_header "CONTENT_TYPE", "text/html" + end + + begin + request.formats + rescue ActionDispatch::Http::MimeNegotiation::InvalidType + request.set_header "HTTP_ACCEPT", "text/html" + end end def pass_response(status) - [status, { "Content-Type" => "text/html; charset=#{Response.default_charset}", "Content-Length" => "0" }, []] + [status, { Rack::CONTENT_TYPE => "text/html; charset=#{Response.default_charset}", + Rack::CONTENT_LENGTH => "0" }, []] end end end diff --git a/actionpack/lib/action_dispatch/middleware/ssl.rb b/actionpack/lib/action_dispatch/middleware/ssl.rb index 557721c301bf9..6a1146adf9627 100644 --- a/actionpack/lib/action_dispatch/middleware/ssl.rb +++ b/actionpack/lib/action_dispatch/middleware/ssl.rb @@ -1,54 +1,79 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch - # This middleware is added to the stack when `config.force_ssl = true`, and is passed - # the options set in `config.ssl_options`. It does three jobs to enforce secure HTTP - # requests: - # - # 1. TLS redirect: Permanently redirects http:// requests to https:// - # with the same URL host, path, etc. Enabled by default. Set `config.ssl_options` - # to modify the destination URL - # (e.g. `redirect: { host: "secure.widgets.com", port: 8080 }`), or set - # `redirect: false` to disable this feature. - # - # 2. Secure cookies: Sets the `secure` flag on cookies to tell browsers they - # mustn't be sent along with http:// requests. Enabled by default. Set - # `config.ssl_options` with `secure_cookies: false` to disable this feature. - # - # 3. HTTP Strict Transport Security (HSTS): Tells the browser to remember - # this site as TLS-only and automatically redirect non-TLS requests. - # Enabled by default. Configure `config.ssl_options` with `hsts: false` to disable. - # - # Set `config.ssl_options` with `hsts: { … }` to configure HSTS: - # * `expires`: How long, in seconds, these settings will stick. The minimum - # required to qualify for browser preload lists is `18.weeks`. Defaults to - # `180.days` (recommended). - # * `subdomains`: Set to `true` to tell the browser to apply these settings - # to all subdomains. This protects your cookies from interception by a - # vulnerable site on a subdomain. Defaults to `true`. - # * `preload`: Advertise that this site may be included in browsers' - # preloaded HSTS lists. HSTS protects your site on every visit *except the - # first visit* since it hasn't seen your HSTS header yet. To close this - # gap, browser vendors include a baked-in list of HSTS-enabled sites. - # Go to https://hstspreload.appspot.com to submit your site for inclusion. - # Defaults to `false`. - # - # To turn off HSTS, omitting the header is not enough. Browsers will remember the - # original HSTS directive until it expires. Instead, use the header to tell browsers to - # expire HSTS immediately. Setting `hsts: false` is a shortcut for - # `hsts: { expires: 0 }`. - # - # Requests can opt-out of redirection with `exclude`: - # - # config.ssl_options = { redirect: { exclude: -> request { request.path =~ /healthcheck/ } } } + # # Action Dispatch SSL + # + # This middleware is added to the stack when `config.force_ssl = true`, and is + # passed the options set in `config.ssl_options`. It does three jobs to enforce + # secure HTTP requests: + # + # 1. **TLS redirect**: Permanently redirects `http://` requests to `https://` + # with the same URL host, path, etc. Enabled by default. Set + # `config.ssl_options` to modify the destination URL: + # + # config.ssl_options = { redirect: { host: "secure.widgets.com", port: 8080 }` + # + # Or set `redirect: false` to disable redirection. + # + # Requests can opt-out of redirection with `exclude`: + # + # config.ssl_options = { redirect: { exclude: -> request { request.path == "/up" } } } + # + # Cookies will not be flagged as secure for excluded requests. + # + # When proxying through a load balancer that terminates SSL, the forwarded + # request will appear as though it's HTTP instead of HTTPS to the application. + # This makes redirects and cookie security target HTTP instead of HTTPS. + # To make the server assume that the proxy already terminated SSL, and + # that the request really is HTTPS, set `config.assume_ssl` to `true`: + # + # config.assume_ssl = true + # + # 2. **Secure cookies**: Sets the `secure` flag on cookies to tell browsers + # they must not be sent along with `http://` requests. Enabled by default. + # Set `config.ssl_options` with `secure_cookies: false` to disable this + # feature. + # + # 3. **HTTP Strict Transport Security (HSTS)**: Tells the browser to remember + # this site as TLS-only and automatically redirect non-TLS requests. Enabled + # by default. Configure `config.ssl_options` with `hsts: false` to disable. + # + # Set `config.ssl_options` with `hsts: { ... }` to configure HSTS: + # + # * `expires`: How long, in seconds, these settings will stick. The + # minimum required to qualify for browser preload lists is 1 year. + # Defaults to 2 years (recommended). + # + # * `subdomains`: Set to `true` to tell the browser to apply these + # settings to all subdomains. This protects your cookies from + # interception by a vulnerable site on a subdomain. Defaults to `true`. + # + # * `preload`: Advertise that this site may be included in browsers' + # preloaded HSTS lists. HSTS protects your site on every visit *except + # the first visit* since it hasn't seen your HSTS header yet. To close + # this gap, browser vendors include a baked-in list of HSTS-enabled + # sites. Go to https://hstspreload.org to submit your site for + # inclusion. Defaults to `false`. + # + # + # To turn off HSTS, omitting the header is not enough. Browsers will + # remember the original HSTS directive until it expires. Instead, use the + # header to tell browsers to expire HSTS immediately. Setting `hsts: false` + # is a shortcut for `hsts: { expires: 0 }`. + # class SSL - # Default to 180 days, the low end for https://www.ssllabs.com/ssltest/ - # and greater than the 18-week requirement for browser preload lists. - HSTS_EXPIRES_IN = 15552000 + # :stopdoc: Default to 2 years as recommended on hstspreload.org. + HSTS_EXPIRES_IN = 63072000 + + PERMANENT_REDIRECT_REQUEST_METHODS = %w[GET HEAD] # :nodoc: def self.default_hsts_options { expires: HSTS_EXPIRES_IN, subdomains: true, preload: false } end - def initialize(app, redirect: {}, hsts: {}, secure_cookies: true) + def initialize(app, redirect: {}, hsts: {}, secure_cookies: true, ssl_default_redirect_status: nil) @app = app @redirect = redirect @@ -57,6 +82,7 @@ def initialize(app, redirect: {}, hsts: {}, secure_cookies: true) @secure_cookies = secure_cookies @hsts_header = build_hsts_header(normalize_hsts_options(hsts)) + @ssl_default_redirect_status = ssl_default_redirect_status end def call(env) @@ -65,7 +91,7 @@ def call(env) if request.ssl? @app.call(env).tap do |status, headers, body| set_hsts_header! headers - flag_cookies_as_secure! headers if @secure_cookies + flag_cookies_as_secure! headers if @secure_cookies && !@exclude.call(request) end else return redirect_to_https request unless @exclude.call(request) @@ -75,13 +101,13 @@ def call(env) private def set_hsts_header!(headers) - headers["Strict-Transport-Security".freeze] ||= @hsts_header + headers[Constants::STRICT_TRANSPORT_SECURITY] ||= @hsts_header end def normalize_hsts_options(options) case options - # Explicitly disabling HSTS clears the existing setting from browsers - # by setting expiry to 0. + # Explicitly disabling HSTS clears the existing setting from browsers by setting + # expiry to 0. when false self.class.default_hsts_options.merge(expires: 0) # Default to enabled, with default options. @@ -92,38 +118,50 @@ def normalize_hsts_options(options) end end - # http://tools.ietf.org/html/rfc6797#section-6.1 + # https://tools.ietf.org/html/rfc6797#section-6.1 def build_hsts_header(hsts) - value = "max-age=#{hsts[:expires].to_i}" + value = +"max-age=#{hsts[:expires].to_i}" value << "; includeSubDomains" if hsts[:subdomains] value << "; preload" if hsts[:preload] value end def flag_cookies_as_secure!(headers) - if cookies = headers["Set-Cookie".freeze] - cookies = cookies.split("\n".freeze) + cookies = headers[Rack::SET_COOKIE] + return unless cookies - headers["Set-Cookie".freeze] = cookies.map { |cookie| - if cookie !~ /;\s*secure\s*(;|$)/i + if Gem::Version.new(Rack::RELEASE) < Gem::Version.new("3") + cookies = cookies.split("\n") + headers[Rack::SET_COOKIE] = cookies.map { |cookie| + if !/;\s*secure\s*(;|$)/i.match?(cookie) + "#{cookie}; secure" + else + cookie + end + }.join("\n") + else + headers[Rack::SET_COOKIE] = Array(cookies).map do |cookie| + if !/;\s*secure\s*(;|$)/i.match?(cookie) "#{cookie}; secure" else cookie end - }.join("\n".freeze) + end end end def redirect_to_https(request) [ @redirect.fetch(:status, redirection_status(request)), - { "Content-Type" => "text/html", - "Location" => https_location_for(request) }, - @redirect.fetch(:body, []) ] + { Rack::CONTENT_TYPE => "text/html; charset=utf-8", + Constants::LOCATION => https_location_for(request) }, + (@redirect[:body] || []) ] end def redirection_status(request) - if request.get? || request.head? + if PERMANENT_REDIRECT_REQUEST_METHODS.include?(request.raw_request_method) 301 # Issue a permanent redirect via a GET request. + elsif @ssl_default_redirect_status + @ssl_default_redirect_status else 307 # Issue a fresh request redirect to preserve the HTTP method. end @@ -133,7 +171,7 @@ def https_location_for(request) host = @redirect[:host] || request.host port = @redirect[:port] || request.port - location = "https://#{host}" + location = +"https://#{host}" location << ":#{port}" if port != 80 && port != 443 location << request.fullpath location diff --git a/actionpack/lib/action_dispatch/middleware/stack.rb b/actionpack/lib/action_dispatch/middleware/stack.rb index 6949b31e758e1..fa4a470a3a4d2 100644 --- a/actionpack/lib/action_dispatch/middleware/stack.rb +++ b/actionpack/lib/action_dispatch/middleware/stack.rb @@ -1,7 +1,16 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/inflector/methods" require "active_support/dependencies" module ActionDispatch + # # Action Dispatch MiddlewareStack + # + # Read more about [Rails middleware + # stack](https://guides.rubyonrails.org/rails_on_rack.html#action-dispatcher-middleware-stack) + # in the guides. class MiddlewareStack class Middleware attr_reader :args, :block, :klass @@ -18,13 +27,13 @@ def ==(middleware) case middleware when Middleware klass == middleware.klass - when Class + when Module klass == middleware end end def inspect - if klass.is_a?(Class) + if klass.is_a?(Module) klass.to_s else klass.class.to_s @@ -34,6 +43,30 @@ def inspect def build(app) klass.new(app, *args, &block) end + + def build_instrumented(app) + InstrumentationProxy.new(build(app), inspect) + end + end + + # This class is used to instrument the execution of a single middleware. It + # proxies the `call` method transparently and instruments the method call. + class InstrumentationProxy + EVENT_NAME = "process_middleware.action_dispatch" + + def initialize(middleware, class_name) + @middleware = middleware + + @payload = { + middleware: class_name, + } + end + + def call(env) + ActiveSupport::Notifications.instrument(EVENT_NAME, @payload) do + @middleware.call(env) + end + end end include Enumerable @@ -45,8 +78,8 @@ def initialize(*args) yield(self) if block_given? end - def each - @middlewares.each { |x| yield x } + def each(&block) + @middlewares.each(&block) end def size @@ -64,6 +97,7 @@ def [](i) def unshift(klass, *args, &block) middlewares.unshift(build_middleware(klass, args, block)) end + ruby2_keywords(:unshift) def initialize_copy(other) self.middlewares = other.middlewares.dup @@ -73,6 +107,7 @@ def insert(index, klass, *args, &block) index = assert_index(index, :before) middlewares.insert(index, build_middleware(klass, args, block)) end + ruby2_keywords(:insert) alias_method :insert_before, :insert @@ -80,29 +115,68 @@ def insert_after(index, *args, &block) index = assert_index(index, :after) insert(index + 1, *args, &block) end + ruby2_keywords(:insert_after) def swap(target, *args, &block) index = assert_index(target, :before) insert(index, *args, &block) middlewares.delete_at(index + 1) end + ruby2_keywords(:swap) + # Deletes a middleware from the middleware stack. + # + # Returns the array of middlewares not including the deleted item, or returns + # nil if the target is not found. def delete(target) - middlewares.delete_if { |m| m.klass == target } + middlewares.reject! { |m| m.name == target.name } + end + + # Deletes a middleware from the middleware stack. + # + # Returns the array of middlewares not including the deleted item, or raises + # `RuntimeError` if the target is not found. + def delete!(target) + delete(target) || (raise "No such middleware to remove: #{target.inspect}") + end + + def move(target, source) + source_index = assert_index(source, :before) + source_middleware = middlewares.delete_at(source_index) + + target_index = assert_index(target, :before) + middlewares.insert(target_index, source_middleware) + end + + alias_method :move_before, :move + + def move_after(target, source) + source_index = assert_index(source, :after) + source_middleware = middlewares.delete_at(source_index) + + target_index = assert_index(target, :after) + middlewares.insert(target_index + 1, source_middleware) end def use(klass, *args, &block) middlewares.push(build_middleware(klass, args, block)) end + ruby2_keywords(:use) - def build(app = Proc.new) - middlewares.freeze.reverse.inject(app) { |a, e| e.build(a) } + def build(app = nil, &block) + instrumenting = ActiveSupport::Notifications.notifier.listening?(InstrumentationProxy::EVENT_NAME) + middlewares.freeze.reverse.inject(app || block) do |a, e| + if instrumenting + e.build_instrumented(a) + else + e.build(a) + end + end end private - def assert_index(index, where) - i = index.is_a?(Integer) ? index : middlewares.index { |m| m.klass == index } + i = index.is_a?(Integer) ? index : index_of(index) raise "No such middleware to insert #{where}: #{index.inspect}" unless i i end @@ -110,5 +184,11 @@ def assert_index(index, where) def build_middleware(klass, args, block) Middleware.new(klass, args, block) end + + def index_of(klass) + middlewares.index do |m| + m.name == klass.name + end + end end end diff --git a/actionpack/lib/action_dispatch/middleware/static.rb b/actionpack/lib/action_dispatch/middleware/static.rb index 5d10129d21773..d2bf29c1e8894 100644 --- a/actionpack/lib/action_dispatch/middleware/static.rb +++ b/actionpack/lib/action_dispatch/middleware/static.rb @@ -1,128 +1,192 @@ +# frozen_string_literal: true + +# :markup: markdown + require "rack/utils" -require "active_support/core_ext/uri" module ActionDispatch - # This middleware returns a file's contents from disk in the body response. - # When initialized, it can accept optional HTTP headers, which will be set - # when a response containing a file's contents is delivered. + # # Action Dispatch Static # - # This middleware will render the file specified in `env["PATH_INFO"]` - # where the base path is in the +root+ directory. For example, if the +root+ - # is set to `public/`, then a request with `env["PATH_INFO"]` of - # `assets/application.js` will return a response with the contents of a file - # located at `public/assets/application.js` if the file exists. If the file - # does not exist, a 404 "File not Found" response will be returned. - class FileHandler - def initialize(root, index: "index", headers: {}) - @root = root.chomp("/") - @file_server = ::Rack::File.new(@root, headers) - @index = index + # This middleware serves static files from disk, if available. If no file is + # found, it hands off to the main app. + # + # In Rails apps, this middleware is configured to serve assets from the + # `public/` directory. + # + # Only GET and HEAD requests are served. POST and other HTTP methods are handed + # off to the main app. + # + # Only files in the root directory are served; path traversal is denied. + class Static + def initialize(app, path, index: "index", headers: {}) + @app = app + @file_handler = FileHandler.new(path, index: index, headers: headers) end - # Takes a path to a file. If the file is found, has valid encoding, and has - # correct read permissions, the return value is a URI-escaped string - # representing the filename. Otherwise, false is returned. - # - # Used by the `Static` class to check the existence of a valid file - # in the server's `public/` directory (see Static#call). - def match?(path) - path = ::Rack::Utils.unescape_path path - return false unless ::Rack::Utils.valid_path? path - path = ::Rack::Utils.clean_path_info path - - paths = [path, "#{path}#{ext}", "#{path}/#{@index}#{ext}"] - - if match = paths.detect { |p| - path = File.join(@root, p.force_encoding(Encoding::UTF_8)) - begin - File.file?(path) && File.readable?(path) - rescue SystemCallError - false - end + def call(env) + @file_handler.attempt(env) || @app.call(env) + end + end - } - return ::Rack::Utils.escape_path(match) - end + # # Action Dispatch FileHandler + # + # This endpoint serves static files from disk using `Rack::Files`. + # + # URL paths are matched with static files according to expected conventions: + # `path`, `path`.html, `path`/index.html. + # + # Precompressed versions of these files are checked first. Brotli (.br) and gzip + # (.gz) files are supported. If `path`.br exists, this endpoint returns that + # file with a `content-encoding: br` header. + # + # If no matching file is found, this endpoint responds `404 Not Found`. + # + # Pass the `root` directory to search for matching files, an optional `index: + # "index"` to change the default `path`/index.html, and optional additional + # response headers. + class FileHandler + # `Accept-Encoding` value -> file extension + PRECOMPRESSED = { + "br" => ".br", + "gzip" => ".gz", + "identity" => nil + } + + def initialize(root, index: "index", headers: {}, precompressed: %i[ br gzip ], compressible_content_types: /\A(?:text\/|application\/javascript|image\/svg\+xml)/) + @root = root.chomp("/").b + @index = index + + @precompressed = Array(precompressed).map(&:to_s) | %w[ identity ] + @compressible_content_types = compressible_content_types + + @file_server = ::Rack::Files.new(@root, headers) end def call(env) - serve(Rack::Request.new(env)) + attempt(env) || @file_server.call(env) end - def serve(request) - path = request.path_info - gzip_path = gzip_file_path(path) + def attempt(env) + request = Rack::Request.new env - if gzip_path && gzip_encoding_accepted?(request) - request.path_info = gzip_path - status, headers, body = @file_server.call(request.env) - if status == 304 - return [status, headers, body] + if request.get? || request.head? + if found = find_file(request.path_info, accept_encoding: request.accept_encoding) + serve request, *found end - headers["Content-Encoding"] = "gzip" - headers["Content-Type"] = content_type(path) - else - status, headers, body = @file_server.call(request.env) end - - headers["Vary"] = "Accept-Encoding" if gzip_path - - return [status, headers, body] - ensure - request.path_info = path end private - def ext - ::ActionController::Base.default_static_extension + def serve(request, filepath, content_headers) + original, request.path_info = + request.path_info, ::Rack::Utils.escape_path(filepath).b + + @file_server.call(request.env).tap do |status, headers, body| + # Omit content-encoding/type/etc headers for 304 Not Modified + if status != 304 + headers.update(content_headers) + end + end + ensure + request.path_info = original end - def content_type(path) - ::Rack::Mime.mime_type(::File.extname(path), "text/plain".freeze) + # Match a URI path to a static file to be served. + # + # Used by the `Static` class to negotiate a servable file in the `public/` + # directory (see Static#call). + # + # Checks for `path`, `path`.html, and `path`/index.html files, in that order, + # including .br and .gzip compressed extensions. + # + # If a matching file is found, the path and necessary response headers + # (Content-Type, Content-Encoding) are returned. + def find_file(path_info, accept_encoding:) + each_candidate_filepath(path_info) do |filepath, content_type| + if response = try_files(filepath, content_type, accept_encoding: accept_encoding) + return response + end + end end - def gzip_encoding_accepted?(request) - request.accept_encoding.any? { |enc, quality| enc =~ /\bgzip\b/i } + def try_files(filepath, content_type, accept_encoding:) + headers = { Rack::CONTENT_TYPE => content_type } + + if compressible? content_type + try_precompressed_files filepath, headers, accept_encoding: accept_encoding + elsif file_readable? filepath + [ filepath, headers ] + end end - def gzip_file_path(path) - can_gzip_mime = content_type(path) =~ /\A(?:text\/|application\/javascript)/ - gzip_path = "#{path}.gz" - if can_gzip_mime && File.exist?(File.join(@root, ::Rack::Utils.unescape_path(gzip_path))) - gzip_path - else - false + def try_precompressed_files(filepath, headers, accept_encoding:) + each_precompressed_filepath(filepath) do |content_encoding, precompressed_filepath| + if file_readable? precompressed_filepath + # Identity encoding is default, so we skip Accept-Encoding negotiation and + # needn't set Content-Encoding. + # + # Vary header is expected when we've found other available encodings that + # Accept-Encoding ruled out. + if content_encoding == "identity" + return precompressed_filepath, headers + else + headers[ActionDispatch::Constants::VARY] = "accept-encoding" + + if accept_encoding.any? { |enc, _| /\b#{content_encoding}\b/i.match?(enc) } + headers[ActionDispatch::Constants::CONTENT_ENCODING] = content_encoding + return precompressed_filepath, headers + end + end + end end end - end - # This middleware will attempt to return the contents of a file's body from - # disk in the response. If a file is not found on disk, the request will be - # delegated to the application stack. This middleware is commonly initialized - # to serve assets from a server's `public/` directory. - # - # This middleware verifies the path to ensure that only files - # living in the root directory can be rendered. A request cannot - # produce a directory traversal using this middleware. Only 'GET' and 'HEAD' - # requests will result in a file being returned. - class Static - def initialize(app, path, index: "index", headers: {}) - @app = app - @file_handler = FileHandler.new(path, index: index, headers: headers) - end + def file_readable?(path) + file_path = File.join(@root, path.b) + File.file?(file_path) && File.readable?(file_path) + end - def call(env) - req = Rack::Request.new env + def compressible?(content_type) + @compressible_content_types.match?(content_type) + end - if req.get? || req.head? - path = req.path_info.chomp("/".freeze) - if match = @file_handler.match?(path) - req.path_info = match - return @file_handler.serve(req) + def each_precompressed_filepath(filepath) + @precompressed.each do |content_encoding| + precompressed_ext = PRECOMPRESSED.fetch(content_encoding) + yield content_encoding, "#{filepath}#{precompressed_ext}" end + + nil end - @app.call(req.env) - end + def each_candidate_filepath(path_info) + return unless path = clean_path(path_info) + + ext = ::File.extname(path) + content_type = ::Rack::Mime.mime_type(ext, nil) + yield path, content_type || "text/plain" + + # Tack on .html and /index.html only for paths that don't have an explicit, + # resolvable file extension. No need to check for foo.js.html and + # foo.js/index.html. + unless content_type + default_ext = ::ActionController::Base.default_static_extension + if ext != default_ext + default_content_type = ::Rack::Mime.mime_type(default_ext, "text/plain") + + yield "#{path}#{default_ext}", default_content_type + yield "#{path}/#{@index}#{default_ext}", default_content_type + end + end + + nil + end + + def clean_path(path_info) + path = ::Rack::Utils.unescape_path path_info.chomp("/") + if ::Rack::Utils.valid_path? path + ::Rack::Utils.clean_path_info path + end + end end end diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_actions.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_actions.html.erb new file mode 100644 index 0000000000000..81d41d00a4a61 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_actions.html.erb @@ -0,0 +1,13 @@ +<% actions = exception_wrapper.actions %> + +<% if actions.any? %> +
+ <% actions.each do |action, _| %> + <%= button_to action, ActionDispatch::ActionableExceptions.endpoint, params: { + error: exception_wrapper.exception_class_name, + action: action, + location: request.path + } %> + <% end %> +
+<% end %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_actions.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_actions.text.erb new file mode 100644 index 0000000000000..e69de29bb2d1d 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/_message_and_suggestions.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_message_and_suggestions.html.erb new file mode 100644 index 0000000000000..fff22977b6a0f --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_message_and_suggestions.html.erb @@ -0,0 +1,22 @@ +<% if exception_wrapper.has_corrections? %> +
+ <%= simple_format h(exception_wrapper.original_message), { class: "message" }, wrapper_tag: "div" %> +
+ <% + # The 'did_you_mean' gem can raise exceptions when calling #corrections on + # the exception. If it does there are no corrections to show. + corrections = exception_wrapper.corrections rescue [] + %> + <% if corrections.any? %> + Did you mean? +
    + <% corrections.each do |correction| %> +
  • <%= h correction %>
  • + <% end %> +
+ <% end %> +<% else %> +
+ <%= simple_format h(exception_wrapper.message), { class: "message" }, wrapper_tag: "div" %> +
+<% end %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.html.erb index 49b1e83551ca4..d27a193a88692 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.html.erb @@ -1,22 +1,17 @@ -<% unless @exception.blamed_files.blank? %> - <% if (hide = @exception.blamed_files.length > 8) %> - Toggle blamed files - <% end %> -
><%= @exception.describe_blame %>
+

Request

+<% if params_valid? %> +

Parameters:

<%= debug_params(@request.filtered_parameters) %>
<% end %> -

Request

-

Parameters:

<%= debug_params(@request.filtered_parameters) %>
-
- +
- +
-

Response

+

Response

Headers:

<%= debug_headers(defined?(@response) ? @response.headers : {}) %>
diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.text.erb index 396768ecee6f7..ca42a6fa8b354 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.text.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.text.erb @@ -1,5 +1,5 @@ <% - clean_params = @request.filtered_parameters.clone + clean_params = params_valid? ? @request.filtered_parameters.clone : {} clean_params.delete("action") clean_params.delete("controller") 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 e7b913bbe4845..9a02e0d5ca986 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb @@ -1,6 +1,8 @@ -<% @source_extracts.each_with_index do |source_extract, index| %> +<% error_index = local_assigns[:error_index] || 0 %> + +<% source_extracts.each_with_index do |source_extract, index| %> <% if source_extract[:code] %> -
" id="frame-source-<%=index%>"> +
" id="frame-source-<%= error_index %>-<%= index %>">
Extracted source (around line #<%= source_extract[:line_number] %>):
@@ -9,14 +11,19 @@
-                <% 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 %>
               
-<% source_extract[:code].each do |line, source| -%>
"><%= source -%>
<% end -%> +<% source_extract[:code].each do |line, source| -%> +
"><% if source.is_a?(Array) -%><%= source[0] -%><%= source[1] -%><%= source[2] -%> +<% else -%> +<%= source -%> +<% end -%>
<% 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 ab57b11c7d32a..77948a466629c 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb @@ -1,52 +1,66 @@ -<% names = @traces.keys %> +<% names = traces.keys %> +<% error_index = local_assigns[:error_index] || 0 %>

Rails.root: <%= defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : "unset" %>

-
+
<% names.each do |name| %> <% - show = "show('#{name.gsub(/\s/, '-')}');" - hide = (names - [name]).collect {|hide_name| "hide('#{hide_name.gsub(/\s/, '-')}');"} + show = "show('#{name.gsub(/\s/, '-')}-#{error_index}');" + hide = (names - [name]).collect {|hide_name| "hide('#{hide_name.gsub(/\s/, '-')}-#{error_index}');"} %> <%= name %> <%= '|' unless names.last == name %> <% end %> - <% @traces.each do |name, trace| %> -
-
<% trace.each do |frame| %><%= frame[:trace] %>
<% end %>
+ <% traces.each do |name, trace| %> +
" class="trace-container" style="display: <%= (name == trace_to_show) ? 'block' : 'none' %>;"> + + <% trace.each do |frame| %> +
+ <% file_url = editor_url(frame[:trace]) %> + <%= file_url && link_to("âœï¸", file_url, class: "edit-icon") %> + + <%= frame[:trace] %> + +
+
+ <% end %> +
<% 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 new file mode 100644 index 0000000000000..6ee17f4ad8999 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb @@ -0,0 +1,13 @@ +
+ <%= render "rescues/copy_button" %> +

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

+
+
+

To allow requests to these hosts, make sure they are valid hostnames (containing only numbers, letters, dashes and dots), then add the following to your environment configuration:

+
+  <% @hosts.each do |host| %>
+    config.hosts << "<%= host %>"
+  <% end %>
+  
+

For more details view: the Host Authorization guide

+
diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/blocked_host.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/blocked_host.text.erb new file mode 100644 index 0000000000000..bc5b5fe8ee8df --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/blocked_host.text.erb @@ -0,0 +1,9 @@ +Blocked hosts: <%= @hosts.join(", ") %> + +To allow requests to these hosts, make sure they are valid hostnames (containing only numbers, letters, dashes and dots), then add the following to your environment configuration: + +<% @hosts.each do |host| %> + config.hosts << "<%= host %>" +<% end %> + +For more details on host authorization view: https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization 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 f154021ae66d5..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,16 +1,36 @@
+ <%= render "rescues/copy_button" %>

- <%= @exception.class.to_s %> - <% if @request.parameters['controller'] %> + <%= @exception_wrapper.exception_class_name %> + <% if params_valid? && @request.parameters['controller'] %> in <%= @request.parameters['controller'].camelize %>Controller<% if @request.parameters['action'] %>#<%= @request.parameters['action'] %><% end %> <% end %>

-
-

<%= h @exception.message %>

+
+ <%= render "rescues/message_and_suggestions", exception: @exception, exception_wrapper: @exception_wrapper %> + <%= render "rescues/actions", exception: @exception, request: @request, exception_wrapper: @exception_wrapper %> + + <%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx, error_index: 0 %> + <%= render "rescues/trace", traces: @traces, trace_to_show: @trace_to_show, error_index: 0 %> + + <% if @exception_wrapper.has_cause? %> +

Exception Causes

+ <% end %> + + <% @exception_wrapper.wrapped_causes.each.with_index(1) do |wrapper, index| %> + + + + <% end %> - <%= render template: "rescues/_source" %> - <%= render template: "rescues/_trace" %> <%= render template: "rescues/_request_and_response" %> -
+ diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.text.erb index 603de54b8b8af..52d0483eae99b 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.text.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.text.erb @@ -1,9 +1,9 @@ -<%= @exception.class.to_s %><% - if @request.parameters['controller'] +<%= @exception_wrapper.exception_class_name %><% + if params_valid? && @request.parameters['controller'] %> in <%= @request.parameters['controller'].camelize %>Controller<% if @request.parameters['action'] %>#<%= @request.parameters['action'] %><% end %> <% end %> -<%= @exception.message %> +<%= @exception_wrapper.message %> <%= render template: "rescues/_source" %> <%= render template: "rescues/_trace" %> <%= render template: "rescues/_request_and_response" %> 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 new file mode 100644 index 0000000000000..030838860d925 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb @@ -0,0 +1,28 @@ +
+ <%= render "rescues/copy_button" %> +

+ <%= @exception.class.to_s %> + <% if @request.parameters['controller'] %> + in <%= @request.parameters['controller'].camelize %>Controller<% if @request.parameters['action'] %>#<%= @request.parameters['action'] %><% end %> + <% end %> +

+
+ +
+

+ <%= 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 %> + <% if defined?(ActionMailbox) && @exception.message.match?(%r{#{ActionMailbox::InboundEmail.table_name}}) %> +
To resolve this issue run: bin/rails action_mailbox:install + <% end %> +

+ + <%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx %> + <%= render "rescues/trace", traces: @traces, trace_to_show: @trace_to_show %> + <%= render template: "rescues/_request_and_response" %> +
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 new file mode 100644 index 0000000000000..dc3a05a1a523b --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb @@ -0,0 +1,19 @@ +<%= @exception.class.to_s %><% + if @request.parameters['controller'] +%> in <%= @request.parameters['controller'].camelize %>Controller<% if @request.parameters['action'] %>#<%= @request.parameters['action'] %><% end %> +<% 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 %> +<% if defined?(ActionMailbox) && @exception.message.match?(%r{#{ActionMailbox::InboundEmail.table_name}}) %> +To resolve this issue run: bin/rails action_mailbox:install +<% end %> + +<%= render template: "rescues/_source" %> +<%= render template: "rescues/_trace" %> +<%= render template: "rescues/_request_and_response" %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb index e0509f56f40ba..34ffdb79d5a87 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb @@ -2,11 +2,15 @@ + + Action Controller: Exception caught -<%= yield %> + <%= 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 new file mode 100644 index 0000000000000..ffafefcd29d91 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb @@ -0,0 +1,24 @@ +
+ <%= render "rescues/copy_button" %> +

No view template for interactive request

+
+ +
+

<%= h @exception.message %>

+ +
+

+ NOTE: Rails usually expects a controller action to render a view template with the same name. +

+

+ For example, a <%= @exception.controller %>#<%= @exception.action_name %> action defined in app/controllers/<%= @exception.controller.controller_path %>_controller.rb should have a corresponding view template + in a file named app/views/<%= @exception.controller.controller_path %>/<%= @exception.action_name %>.html.erb. +

+

+ However, if this controller is an API endpoint responding with 204 (No Content), which does not require a view template because it doesn't serve an HTML response, then this error will occur when trying to access it with a browser. In this particular scenario, you can ignore this error. +

+

+ You can find more about view template rendering conventions in the Rails Guides on Layouts and Rendering in Rails. +

+
+
diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.text.erb new file mode 100644 index 0000000000000..fcdbe6069d9ea --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.text.erb @@ -0,0 +1,3 @@ +Missing exact template + +<%= @exception.message %> 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 2a65fd06ad89d..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,11 +1,12 @@ -
+
+ <%= render "rescues/copy_button" %>

Template is missing

-
-

<%= h @exception.message %>

+
+

<%= h @exception_wrapper.message %>

- <%= render template: "rescues/_source" %> - <%= render template: "rescues/_trace" %> + <%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx %> + <%= render "rescues/trace", traces: @traces, trace_to_show: @trace_to_show %> <%= render template: "rescues/_request_and_response" %> -
+ 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 55dd5ddc7b68a..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,20 +1,21 @@ -
+
+ <%= render "rescues/copy_button" %>

Routing Error

-
-

<%= h @exception.message %>

- <% unless @exception.failures.empty? %> +
+

<%= h @exception_wrapper.message %>

+ <% unless @exception_wrapper.failures.empty? %>

Failure reasons:

    - <% @exception.failures.each do |route, reason| %> + <% @exception_wrapper.failures.each do |route, reason| %>
  1. <%= route.inspect.delete('\\') %> failed because <%= reason.downcase %>
  2. <% end %>

<% end %> - <%= render template: "rescues/_trace" %> + <%= render "rescues/trace", traces: @traces, trace_to_show: @trace_to_show %> <% if @routes_inspector %>

@@ -29,4 +30,4 @@ <% end %> <%= render template: "rescues/_request_and_response" %> -

+ 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 5060da9369123..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,20 +1,21 @@ -
+
+ <%= render "rescues/copy_button" %>

- <%= @exception.cause.class.to_s %> in + <%= @exception_wrapper.exception_name %> in <%= @request.parameters["controller"].camelize if @request.parameters["controller"] %>#<%= @request.parameters["action"] %>

-
+

- Showing <%= @exception.file_name %> where line #<%= @exception.line_number %> raised: + Showing <%= @exception_wrapper.file_name %> where line #<%= @exception_wrapper.line_number %> raised:

-
<%= h @exception.message %>
+
<%= h @exception_wrapper.message %>
- <%= render template: "rescues/_source" %> + <%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx %> -

<%= @exception.sub_template_message %>

+

<%= @exception_wrapper.sub_template_message %>

- <%= render template: "rescues/_trace" %> + <%= render "rescues/trace", traces: @traces, trace_to_show: @trace_to_show %> <%= render template: "rescues/_request_and_response" %> -
+ 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 259fb2bb3b472..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,6 +1,7 @@ -
+
+ <%= render "rescues/copy_button" %>

Unknown action

-
-

<%= h @exception.message %>

-
+
+ <%= render "rescues/message_and_suggestions", exception: @exception, exception_wrapper: @exception_wrapper %> +
diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.text.erb index 83973addcbadc..e7f0991f9734c 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.text.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.text.erb @@ -1,3 +1,3 @@ Unknown action -<%= @exception.message %> +<%= @exception_wrapper.message %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/routes/_route.html.erb b/actionpack/lib/action_dispatch/middleware/templates/routes/_route.html.erb index 6e995c85c16f5..576b047772d96 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/routes/_route.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/routes/_route.html.erb @@ -11,6 +11,17 @@ <%= route[:path] %> - <%=simple_format route[:reqs] %> + <% + editor = ActiveSupport::Editor.current + action_file = route[:action_source_file] + action_line = route[:action_source_line] + %> + <% if editor && action_file && action_line %> + <%= link_to("âœï¸", editor.url_for(action_file, action_line), class: "edit-icon") %> + <% end %> + <%= route[:reqs] %> + + + <%=simple_format route[:source_location] %> 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 429ea7057c3cd..a6b813c017c12 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb @@ -1,22 +1,47 @@ <% content_for :style do %> + h2, p { + padding-left: 30px; + } + #route_table { margin: 0; border-collapse: collapse; + word-wrap:break-word; + table-layout: fixed; + width:100%; } #route_table thead tr { border-bottom: 2px solid #ddd; } + #route_table th { + padding-left: 30px; + text-align: left; + } + #route_table thead tr.bottom { border-bottom: none; } #route_table thead tr.bottom th { - padding: 10px 0; + padding: 10px 30px; line-height: 15px; } + #route_table #search_container { + padding: 7px 30px; + } + + #route_table thead tr th input#search { + -webkit-appearance: textfield; + width:100%; + } + + #route_table thead th.http-verb { + width: 10%; + } + #route_table tbody tr { border-bottom: 1px solid #ddd; } @@ -41,34 +66,38 @@ padding: 4px 30px; } - #path_search { - width: 80%; - font-size: inherit; + #route_table .edit-icon { + text-decoration: none; + } + + @media (prefers-color-scheme: dark) { + #route_table tbody tr:nth-child(odd) { + background: #282828; + } + + #route_table tbody.exact_matches tr, + #route_table tbody.fuzzy_matches tr { + background: DarkSlateGrey; + } } <% end %> - +
- - - - - - - - - - + + + + + + + @@ -80,23 +109,24 @@
HelperHTTP VerbPathController#Action
<%# Helper %> - <%= link_to "Path", "#", 'data-route-helper' => '_path', + Helper + (<%= link_to "Path", "#", 'data-route-helper' => '_path', title: "Returns a relative path (without the http or domain)" %> / <%= link_to "Url", "#", 'data-route-helper' => '_url', - title: "Returns an absolute url (with the http and domain)" %> - <%# HTTP Verb %> - <%# Path %> - <%= search_field(:path, nil, id: 'search', placeholder: "Path Match") %> - <%# Controller#action %> + title: "Returns an absolute URL (with the http and domain)" %>) HTTP VerbPathController#ActionSource Location
<%= search_field(:query, nil, id: 'search', placeholder: "Search") %>
- diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 16a18a7f2539f..8c4863f78a46f 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -1,11 +1,19 @@ +# frozen_string_literal: true + +# :markup: markdown + 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: config.action_dispatch = ActiveSupport::OrderedOptions.new config.action_dispatch.x_sendfile_header = nil config.action_dispatch.ip_spoofing_check = true - config.action_dispatch.show_exceptions = true + config.action_dispatch.show_exceptions = :all config.action_dispatch.tld_length = 1 config.action_dispatch.ignore_accept_header = false config.action_dispatch.rescue_templates = {} @@ -16,22 +24,62 @@ class Railtie < Rails::Railtie # :nodoc: config.action_dispatch.signed_cookie_salt = "signed cookie" config.action_dispatch.encrypted_cookie_salt = "encrypted cookie" config.action_dispatch.encrypted_signed_cookie_salt = "signed encrypted cookie" + config.action_dispatch.authenticated_encrypted_cookie_salt = "authenticated encrypted cookie" + config.action_dispatch.use_authenticated_cookie_encryption = false + config.action_dispatch.use_cookies_with_metadata = false config.action_dispatch.perform_deep_munge = true + config.action_dispatch.request_id_header = ActionDispatch::Constants::X_REQUEST_ID + config.action_dispatch.log_rescued_responses = true + config.action_dispatch.debug_exception_log_level = :fatal + config.action_dispatch.strict_freshness = false + + 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", "X-XSS-Protection" => "1; mode=block", - "X-Content-Type-Options" => "nosniff" + "X-Content-Type-Options" => "nosniff", + "X-Download-Options" => "noopen", + "X-Permitted-Cross-Domain-Policies" => "none", + "Referrer-Policy" => "strict-origin-when-cross-origin" } + config.action_dispatch.cookies_rotations = ActiveSupport::Messages::RotationConfiguration.new + config.eager_load_namespaces << ActionDispatch + initializer "action_dispatch.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_dispatch] = ActionDispatch.deprecator + end + initializer "action_dispatch.configure" do |app| + ActionDispatch::Http::URL.secure_protocol = app.config.force_ssl ActionDispatch::Http::URL.tld_length = app.config.action_dispatch.tld_length - ActionDispatch::Request.ignore_accept_header = app.config.action_dispatch.ignore_accept_header - ActionDispatch::Request::Utils.perform_deep_munge = app.config.action_dispatch.perform_deep_munge - ActionDispatch::Response.default_charset = app.config.action_dispatch.default_charset || app.config.encoding - ActionDispatch::Response.default_headers = app.config.action_dispatch.default_headers + + 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 + ActionDispatch::Request::Utils.perform_deep_munge = app.config.action_dispatch.perform_deep_munge + end + + ActiveSupport.on_load(:action_dispatch_response) do + self.default_charset = app.config.action_dispatch.default_charset || app.config.encoding + self.default_headers = app.config.action_dispatch.default_headers + end ActionDispatch::ExceptionWrapper.rescue_responses.merge!(config.action_dispatch.rescue_responses) ActionDispatch::ExceptionWrapper.rescue_templates.merge!(config.action_dispatch.rescue_templates) @@ -39,6 +87,9 @@ class Railtie < Rails::Railtie # :nodoc: config.action_dispatch.always_write_cookie = Rails.env.development? if config.action_dispatch.always_write_cookie.nil? ActionDispatch::Cookies::CookieJar.always_write_cookie = config.action_dispatch.always_write_cookie + ActionDispatch::Routing::Mapper.route_source_locations = Rails.env.development? + + ActionDispatch::Http::Cache::Request.strict_freshness = app.config.action_dispatch.strict_freshness ActionDispatch.test_app = app end end diff --git a/actionpack/lib/action_dispatch/request/session.rb b/actionpack/lib/action_dispatch/request/session.rb index a2a80f39fc378..06e61500289c7 100644 --- a/actionpack/lib/action_dispatch/request/session.rb +++ b/actionpack/lib/action_dispatch/request/session.rb @@ -1,16 +1,21 @@ +# frozen_string_literal: true + +# :markup: markdown + require "rack/session/abstract/id" module ActionDispatch class Request # Session is responsible for lazily loading the session from store. class Session # :nodoc: + DisabledSessionError = Class.new(StandardError) ENV_SESSION_KEY = Rack::RACK_SESSION # :nodoc: ENV_SESSION_OPTIONS_KEY = Rack::RACK_SESSION_OPTIONS # :nodoc: - # Singleton object used to determine if an optional param wasn't specified + # Singleton object used to determine if an optional param wasn't specified. Unspecified = Object.new - # Creates a session hash, merging the properties of the previous session if any + # Creates a session hash, merging the properties of the previous session if any. def self.create(store, req, default_options) session_was = find req session = Request::Session.new(store, req) @@ -21,6 +26,12 @@ def self.create(store, req, default_options) session end + def self.disabled(req) + new(nil, req, enabled: false).tap do + Session::Options.set(req, Session::Options.new(nil, { id: nil })) + end + end + def self.find(req) req.get_header ENV_SESSION_KEY end @@ -29,7 +40,11 @@ def self.set(req, session) req.set_header ENV_SESSION_KEY, session end - class Options #:nodoc: + def self.delete(req) + req.delete_header ENV_SESSION_KEY + end + + class Options # :nodoc: def self.set(req, options) req.set_header ENV_SESSION_OPTIONS_KEY, options end @@ -58,37 +73,61 @@ def to_hash; @delegate.dup; end def values_at(*args); @delegate.values_at(*args); end end - def initialize(by, req) + def initialize(by, req, enabled: true) @by = by @req = req @delegate = {} @loaded = false - @exists = nil # we haven't checked yet + @exists = nil # We haven't checked yet. + @enabled = enabled + @id_was = nil + @id_was_initialized = false end def id options.id(@req) end + def enabled? + @enabled + end + def options Options.find @req end def destroy clear - options = self.options || {} - @by.send(:delete_session, @req, options.id(@req), options) - # Load the new sid to be written with the response - @loaded = false - load_for_write! + if enabled? + options = self.options || {} + @by.send(:delete_session, @req, options.id(@req), options) + + # Load the new sid to be written with the response. + @loaded = false + load_for_write! + end end - # Returns value of the key stored in the session or - # +nil+ if the given key is not found in the session. + # Returns value of the key stored in the session or `nil` if the given key is + # not found in the session. def [](key) load_for_read! - @delegate[key.to_s] + key = key.to_s + + if key == "session_id" + id&.public_id + else + @delegate[key] + end + end + + # Returns the nested value specified by the sequence of keys, returning `nil` if + # any intermediate step is `nil`. + def dig(*keys) + load_for_read! + keys = keys.map.with_index { |key, i| i.zero? ? key.to_s : key } + @delegate.dig(*keys) end # Returns true if the session has the given key or false. @@ -101,11 +140,13 @@ def has_key?(key) # Returns keys of the session as Array. def keys + load_for_read! @delegate.keys end # Returns values of the session as Array. def values + load_for_read! @delegate.values end @@ -114,10 +155,11 @@ def []=(key, value) load_for_write! @delegate[key.to_s] = value end + alias store []= # Clears the session. def clear - load_for_write! + load_for_delete! @delegate.clear end @@ -126,42 +168,48 @@ def to_hash load_for_read! @delegate.dup.delete_if { |_, v| v.nil? } end + alias :to_h :to_hash # Updates the session with given Hash. # - # session.to_hash - # # => {"session_id"=>"e29b9ea315edf98aad94cc78c34cc9b2"} + # session.to_hash + # # => {"session_id"=>"e29b9ea315edf98aad94cc78c34cc9b2"} # - # session.update({ "foo" => "bar" }) - # # => {"session_id"=>"e29b9ea315edf98aad94cc78c34cc9b2", "foo" => "bar"} + # session.update({ "foo" => "bar" }) + # # => {"session_id"=>"e29b9ea315edf98aad94cc78c34cc9b2", "foo" => "bar"} # - # session.to_hash - # # => {"session_id"=>"e29b9ea315edf98aad94cc78c34cc9b2", "foo" => "bar"} + # session.to_hash + # # => {"session_id"=>"e29b9ea315edf98aad94cc78c34cc9b2", "foo" => "bar"} def update(hash) + unless hash.respond_to?(:to_hash) + raise TypeError, "no implicit conversion of #{hash.class.name} into Hash" + end + load_for_write! - @delegate.update stringify_keys(hash) + @delegate.update hash.to_hash.stringify_keys end + alias :merge! :update # Deletes given key from the session. def delete(key) - load_for_write! + load_for_delete! @delegate.delete key.to_s end - # Returns value of the given key from the session, or raises +KeyError+ - # if can't find the given key and no default value is set. - # Returns default value if specified. + # Returns value of the given key from the session, or raises `KeyError` if can't + # find the given key and no default value is set. Returns default value if + # specified. # - # session.fetch(:foo) - # # => KeyError: key not found: "foo" + # session.fetch(:foo) + # # => KeyError: key not found: "foo" # - # session.fetch(:foo, :bar) - # # => :bar + # session.fetch(:foo, :bar) + # # => :bar # - # session.fetch(:foo) do - # :bar - # end - # # => :bar + # session.fetch(:foo) do + # :bar + # end + # # => :bar def fetch(key, default = Unspecified, &block) load_for_read! if default == Unspecified @@ -180,6 +228,7 @@ def inspect end def exists? + return false unless enabled? return @exists unless @exists.nil? @exists = @by.send(:session_exists?, @req) end @@ -193,36 +242,42 @@ def empty? @delegate.empty? end - def merge!(other) - load_for_write! - @delegate.merge!(other) - end - def each(&block) to_hash.each(&block) end - private + def id_was + load_for_read! + @id_was + end + private def load_for_read! load! if !loaded? && exists? end def load_for_write! - load! unless loaded? + if enabled? + load! unless loaded? + else + raise DisabledSessionError, "Your application has sessions disabled. To write to the session you must first configure a session store" + end end - def load! - id, session = @by.load_session @req - options[:id] = id - @delegate.replace(stringify_keys(session)) - @loaded = true + def load_for_delete! + load! if enabled? && !loaded? end - def stringify_keys(other) - other.each_with_object({}) { |(key, value), hash| - hash[key.to_s] = value - } + def load! + if enabled? + @id_was_initialized = true unless exists? + id, session = @by.load_session @req + options[:id] = id + @delegate.replace(session.stringify_keys) + @id_was = id unless @id_was_initialized + end + @id_was_initialized = true + @loaded = true end end end diff --git a/actionpack/lib/action_dispatch/request/utils.rb b/actionpack/lib/action_dispatch/request/utils.rb index 01bc871e5f7f8..c8ac5e23bb414 100644 --- a/actionpack/lib/action_dispatch/request/utils.rb +++ b/actionpack/lib/action_dispatch/request/utils.rb @@ -1,8 +1,13 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/hash/indifferent_access" + module ActionDispatch class Request class Utils # :nodoc: - mattr_accessor :perform_deep_munge - self.perform_deep_munge = true + mattr_accessor :perform_deep_munge, default: true def self.each_param_value(params, &block) case params @@ -33,14 +38,17 @@ def self.check_param_encoding(params) unless params.valid_encoding? # Raise Rack::Utils::InvalidParameterError for consistency with Rack. # ActionDispatch::Request#GET will re-raise as a BadRequest error. - raise Rack::Utils::InvalidParameterError, "Non UTF-8 value: #{params}" + raise Rack::Utils::InvalidParameterError, "Invalid encoding for parameter: #{params.scrub}" end end end + def self.set_binary_encoding(request, params, controller, action) + CustomParamEncoder.encode(request, params, controller, action) + end + class ParamEncoder # :nodoc: # Convert nested Hash to HashWithIndifferentAccess. - # def self.normalize_encode_params(params) case params when Array @@ -49,9 +57,11 @@ def self.normalize_encode_params(params) if params.has_key?(:tempfile) ActionDispatch::Http::UploadedFile.new(params) else - params.each_with_object({}) do |(key, val), new_hash| - new_hash[key] = normalize_encode_params(val) - end.with_indifferent_access + hwia = ActiveSupport::HashWithIndifferentAccess.new + params.each_pair do |key, val| + hwia[key] = normalize_encode_params(val) + end + hwia end else params @@ -63,7 +73,7 @@ def self.handle_array(params) end end - # Remove nils from the params hash + # Remove nils from the params hash. class NoNilParamEncoder < ParamEncoder # :nodoc: def self.handle_array(params) list = super @@ -71,6 +81,35 @@ def self.handle_array(params) list end end + + class CustomParamEncoder # :nodoc: + def self.encode_for_template(params, encoding_template) + return params unless encoding_template + params.except(:controller, :action).each do |key, value| + ActionDispatch::Request::Utils.each_param_value(value) do |param| + # If `param` is frozen, it comes from the router defaults + next if param.frozen? + + if encoding_template[key.to_s] + param.force_encoding(encoding_template[key.to_s]) + end + end + end + params + end + + def self.encode(request, params, controller, action) + encoding_template = action_encoding_template(request, controller, action) + encode_for_template(params, encoding_template) + end + + def self.action_encoding_template(request, controller, action) # :nodoc: + controller && controller.valid_encoding? && + request.controller_class_for(controller).action_encoding_template(action) + rescue MissingController + nil + end + end end end end diff --git a/actionpack/lib/action_dispatch/routing.rb b/actionpack/lib/action_dispatch/routing.rb index c554ce98bc738..e1990a2d58a78 100644 --- a/actionpack/lib/action_dispatch/routing.rb +++ b/actionpack/lib/action_dispatch/routing.rb @@ -1,267 +1,262 @@ -require "active_support/core_ext/string/filters" +# frozen_string_literal: true + +# :markup: markdown module ActionDispatch # The routing module provides URL rewriting in native Ruby. It's a way to # redirect incoming requests to controllers and actions. This replaces - # mod_rewrite rules. Best of all, Rails' \Routing works with any web server. - # Routes are defined in config/routes.rb. + # mod_rewrite rules. Best of all, Rails' Routing works with any web server. + # Routes are defined in `config/routes.rb`. # # Think of creating routes as drawing a map for your requests. The map tells # them where to go based on some predefined pattern: # - # Rails.application.routes.draw do - # Pattern 1 tells some request to go to one place - # Pattern 2 tell them to go to another - # ... - # end + # Rails.application.routes.draw do + # Pattern 1 tells some request to go to one place + # Pattern 2 tell them to go to another + # ... + # end # # The following symbols are special: # - # :controller maps to your controller name - # :action maps to an action with your controllers + # :controller maps to your controller name + # :action maps to an action with your controllers # - # Other names simply map to a parameter as in the case of :id. + # Other names simply map to a parameter as in the case of `:id`. # - # == Resources + # ## Resources # - # Resource routing allows you to quickly declare all of the common routes - # for a given resourceful controller. Instead of declaring separate routes - # for your +index+, +show+, +new+, +edit+, +create+, +update+ and +destroy+ - # actions, a resourceful route declares them in a single line of code: + # Resource routing allows you to quickly declare all of the common routes for a + # given resourceful controller. Instead of declaring separate routes for your + # `index`, `show`, `new`, `edit`, `create`, `update`, and `destroy` actions, a + # resourceful route declares them in a single line of code: # - # resources :photos + # resources :photos # - # Sometimes, you have a resource that clients always look up without - # referencing an ID. A common example, /profile always shows the profile of - # the currently logged in user. In this case, you can use a singular resource - # to map /profile (rather than /profile/:id) to the show action. + # Sometimes, you have a resource that clients always look up without referencing + # an ID. A common example, /profile always shows the profile of the currently + # logged in user. In this case, you can use a singular resource to map /profile + # (rather than /profile/:id) to the show action. # - # resource :profile + # resource :profile # - # It's common to have resources that are logically children of other - # resources: + # It's common to have resources that are logically children of other resources: # - # resources :magazines do - # resources :ads - # end + # resources :magazines do + # resources :ads + # end # # You may wish to organize groups of controllers under a namespace. Most - # commonly, you might group a number of administrative controllers under - # an +admin+ namespace. You would place these controllers under the - # app/controllers/admin directory, and you can group them together - # in your router: + # commonly, you might group a number of administrative controllers under an + # `admin` namespace. You would place these controllers under the + # `app/controllers/admin` directory, and you can group them together in your + # router: # - # namespace "admin" do - # resources :posts, :comments - # end + # namespace "admin" do + # resources :posts, :comments + # end # # Alternatively, you can add prefixes to your path without using a separate - # directory by using +scope+. +scope+ takes additional options which - # apply to all enclosed routes. + # directory by using `scope`. `scope` takes additional options which apply to + # all enclosed routes. # - # scope path: "/cpanel", as: 'admin' do - # resources :posts, :comments - # end + # scope path: "/cpanel", as: 'admin' do + # resources :posts, :comments + # end # - # For more, see Routing::Mapper::Resources#resources, - # Routing::Mapper::Scoping#namespace, and - # Routing::Mapper::Scoping#scope. + # For more, see Routing::Mapper::Resources#resources, + # Routing::Mapper::Scoping#namespace, and Routing::Mapper::Scoping#scope. # - # == Non-resourceful routes + # ## Non-resourceful routes # - # For routes that don't fit the resources mold, you can use the HTTP helper - # methods get, post, patch, put and delete. + # For routes that don't fit the `resources` mold, you can use the HTTP helper + # methods `get`, `post`, `patch`, `put` and `delete`. # - # get 'post/:id' => 'posts#show' - # post 'post/:id' => 'posts#create_comment' + # get 'post/:id', to: 'posts#show' + # post 'post/:id', to: 'posts#create_comment' # - # Now, if you POST to /posts/:id, it will route to the create_comment action. A GET on the same - # URL will route to the show action. + # Now, if you POST to `/posts/:id`, it will route to the `create_comment` + # action. A GET on the same URL will route to the `show` action. # - # If your route needs to respond to more than one HTTP method (or all methods) then using the - # :via option on match is preferable. + # If your route needs to respond to more than one HTTP method (or all methods) + # then using the `:via` option on `match` is preferable. # - # match 'post/:id' => 'posts#show', via: [:get, :post] + # match 'post/:id', to: 'posts#show', via: [:get, :post] # - # == Named routes + # ## Named routes # - # Routes can be named by passing an :as option, - # allowing for easy reference within your source as +name_of_route_url+ - # for the full URL and +name_of_route_path+ for the URI path. + # Routes can be named by passing an `:as` option, allowing for easy reference + # within your source as `name_of_route_url` for the full URL and + # `name_of_route_path` for the URI path. # # Example: # - # # In config/routes.rb - # get '/login' => 'accounts#login', as: 'login' + # # In config/routes.rb + # get '/login', to: 'accounts#login', as: 'login' # - # # With render, redirect_to, tests, etc. - # redirect_to login_url + # # With render, redirect_to, tests, etc. + # redirect_to login_url # # Arguments can be passed as well. # - # redirect_to show_item_path(id: 25) + # redirect_to show_item_path(id: 25) # - # Use root as a shorthand to name a route for the root path "/". + # Use `root` as a shorthand to name a route for the root path "/". # - # # In config/routes.rb - # root to: 'blogs#index' + # # In config/routes.rb + # root to: 'blogs#index' # - # # would recognize http://www.example.com/ as - # params = { controller: 'blogs', action: 'index' } + # # would recognize http://www.example.com/ as + # params = { controller: 'blogs', action: 'index' } # - # # and provide these named routes - # root_url # => 'http://www.example.com/' - # root_path # => '/' + # # and provide these named routes + # root_url # => 'http://www.example.com/' + # root_path # => '/' # - # Note: when using +controller+, the route is simply named after the - # method you call on the block parameter rather than map. + # Note: when using `controller`, the route is simply named after the method you + # call on the block parameter rather than map. # - # # In config/routes.rb - # controller :blog do - # get 'blog/show' => :list - # get 'blog/delete' => :delete - # get 'blog/edit' => :edit - # end + # # In config/routes.rb + # controller :blog do + # get 'blog/show' => :list + # get 'blog/delete' => :delete + # get 'blog/edit' => :edit + # end # - # # provides named routes for show, delete, and edit - # link_to @article.title, blog_show_path(id: @article.id) + # # provides named routes for show, delete, and edit + # link_to @article.title, blog_show_path(id: @article.id) # - # == Pretty URLs + # ## Pretty URLs # # Routes can generate pretty URLs. For example: # - # get '/articles/:year/:month/:day' => 'articles#find_by_id', constraints: { - # year: /\d{4}/, - # month: /\d{1,2}/, - # day: /\d{1,2}/ - # } + # get '/articles/:year/:month/:day', to: 'articles#find_by_id', constraints: { + # year: /\d{4}/, + # month: /\d{1,2}/, + # day: /\d{1,2}/ + # } # # Using the route above, the URL "http://localhost:3000/articles/2005/11/06" # maps to # - # params = {year: '2005', month: '11', day: '06'} + # params = {year: '2005', month: '11', day: '06'} # - # == Regular Expressions and parameters + # ## Regular Expressions and parameters # You can specify a regular expression to define a format for a parameter. # - # controller 'geocode' do - # get 'geocode/:postalcode' => :show, constraints: { - # postalcode: /\d{5}(-\d{4})?/ - # } - # end + # controller 'geocode' do + # get 'geocode/:postalcode', to: :show, constraints: { + # postalcode: /\d{5}(-\d{4})?/ + # } + # end # # Constraints can include the 'ignorecase' and 'extended syntax' regular # expression modifiers: # - # controller 'geocode' do - # get 'geocode/:postalcode' => :show, constraints: { - # postalcode: /hx\d\d\s\d[a-z]{2}/i - # } - # end - # - # controller 'geocode' do - # get 'geocode/:postalcode' => :show, constraints: { - # postalcode: /# Postalcode format - # \d{5} #Prefix - # (-\d{4})? #Suffix - # /x - # } - # end + # controller 'geocode' do + # get 'geocode/:postalcode', to: :show, constraints: { + # postalcode: /hx\d\d\s\d[a-z]{2}/i + # } + # end # - # Using the multiline modifier will raise an +ArgumentError+. - # Encoding regular expression modifiers are silently ignored. The - # match will always use the default encoding or ASCII. + # controller 'geocode' do + # get 'geocode/:postalcode', to: :show, constraints: { + # postalcode: /# Postalcode format + # \d{5} #Prefix + # (-\d{4})? #Suffix + # /x + # } + # end # - # == External redirects + # Using the multiline modifier will raise an `ArgumentError`. Encoding regular + # expression modifiers are silently ignored. The match will always use the + # default encoding or ASCII. # - # You can redirect any path to another path using the redirect helper in your router: + # ## External redirects # - # get "/stories" => redirect("/posts") + # You can redirect any path to another path using the redirect helper in your + # router: # - # == Unicode character routes + # get "/stories", to: redirect("/posts") + # + # ## Unicode character routes # # You can specify unicode character routes in your router: # - # get "ã“ã‚“ã«ã¡ã¯" => "welcome#index" + # get "ã“ã‚“ã«ã¡ã¯", to: "welcome#index" # - # == Routing to Rack Applications + # ## Routing to Rack Applications # - # Instead of a String, like posts#index, which corresponds to the - # index action in the PostsController, you can specify any Rack application - # as the endpoint for a matcher: + # Instead of a String, like `posts#index`, which corresponds to the index action + # in the PostsController, you can specify any Rack application as the endpoint + # for a matcher: # - # get "/application.js" => Sprockets + # get "/application.js", to: Sprockets # - # == Reloading routes + # ## Reloading routes # # You can reload routes if you feel you must: # - # Rails.application.reload_routes! + # Rails.application.reload_routes! # - # This will clear all named routes and reload config/routes.rb if the file has been modified from - # last load. To absolutely force reloading, use reload!. + # This will clear all named routes and reload config/routes.rb if the file has + # been modified from last load. To absolutely force reloading, use `reload!`. # - # == Testing Routes + # ## Testing Routes # # The two main methods for testing your routes: # - # === +assert_routing+ - # - # def test_movie_route_properly_splits - # opts = {controller: "plugin", action: "checkout", id: "2"} - # assert_routing "plugin/checkout/2", opts - # end + # ### `assert_routing` # - # +assert_routing+ lets you test whether or not the route properly resolves into options. + # def test_movie_route_properly_splits + # opts = {controller: "plugin", action: "checkout", id: "2"} + # assert_routing "plugin/checkout/2", opts + # end # - # === +assert_recognizes+ + # `assert_routing` lets you test whether or not the route properly resolves into + # options. # - # def test_route_has_options - # opts = {controller: "plugin", action: "show", id: "12"} - # assert_recognizes opts, "/plugins/show/12" - # end + # ### `assert_recognizes` # - # Note the subtle difference between the two: +assert_routing+ tests that - # a URL fits options while +assert_recognizes+ tests that a URL - # breaks into parameters properly. + # def test_route_has_options + # opts = {controller: "plugin", action: "show", id: "12"} + # assert_recognizes opts, "/plugins/show/12" + # end # - # In tests you can simply pass the URL or named route to +get+ or +post+. + # Note the subtle difference between the two: `assert_routing` tests that a URL + # fits options while `assert_recognizes` tests that a URL breaks into parameters + # properly. # - # def send_to_jail - # get '/jail' - # assert_response :success - # end + # In tests you can simply pass the URL or named route to `get` or `post`. # - # def goes_to_login - # get login_url - # #... - # end + # def send_to_jail + # get '/jail' + # assert_response :success + # end # - # == View a list of all your routes + # def goes_to_login + # get login_url + # #... + # end # - # rails routes + # ## View a list of all your routes # - # Target specific controllers by prefixing the command with -c option. + # $ bin/rails routes # + # Target a specific controller with `-c`, or grep routes using `-g`. Useful in + # conjunction with `--expanded` which displays routes vertically. module Routing extend ActiveSupport::Autoload autoload :Mapper autoload :RouteSet - autoload :RoutesProxy + eager_autoload do + autoload :RoutesProxy + end autoload :UrlFor autoload :PolymorphicRoutes - SEPARATORS = %w( / . ? ) #:nodoc: - HTTP_METHODS = [:get, :head, :post, :patch, :put, :delete, :options] #:nodoc: - - #:stopdoc: - INSECURE_URL_PARAMETERS_MESSAGE = <<-MSG.squish - Attempting to generate a URL from non-sanitized request parameters! - - An attacker can inject malicious data into the generated URL, such as - changing the host. Whitelist and sanitize passed parameters to be secure. - MSG - #:startdoc: + SEPARATORS = %w( / . ? ) # :nodoc: + HTTP_METHODS = [:get, :head, :post, :patch, :put, :delete, :options] # :nodoc: end end diff --git a/actionpack/lib/action_dispatch/routing/endpoint.rb b/actionpack/lib/action_dispatch/routing/endpoint.rb index 88aa13c3e8a44..0202e66a8ac33 100644 --- a/actionpack/lib/action_dispatch/routing/endpoint.rb +++ b/actionpack/lib/action_dispatch/routing/endpoint.rb @@ -1,10 +1,19 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Routing class Endpoint # :nodoc: def dispatcher?; false; end def redirect?; false; end - def matches?(req); true; end - def app; self; end + def matches?(req); true; end + def app; self; end + def rack_app; app; end + + def engine? + rack_app.is_a?(Class) && rack_app < Rails::Engine + end end end end diff --git a/actionpack/lib/action_dispatch/routing/inspector.rb b/actionpack/lib/action_dispatch/routing/inspector.rb index b91ffb8419dc5..7348cc9ea0a39 100644 --- a/actionpack/lib/action_dispatch/routing/inspector.rb +++ b/actionpack/lib/action_dispatch/routing/inspector.rb @@ -1,11 +1,28 @@ +# frozen_string_literal: true + +# :markup: markdown + require "delegate" -require "active_support/core_ext/string/strip" +require "io/console/size" module ActionDispatch module Routing - class RouteWrapper < SimpleDelegator + class RouteWrapper < SimpleDelegator # :nodoc: + def matches_filter?(filter, value) + return __getobj__.path.match(value) if filter == :exact_path_match + + value.match?(public_send(filter)) + end + def endpoint - app.dispatcher? ? "#{controller}##{action}" : rack_app.inspect + case + when app.dispatcher? + "#{controller}##{action}" + when rack_app.is_a?(Proc) + "Inline handler (Proc/Lambda)" + else + rack_app.inspect + end end def constraints @@ -13,7 +30,7 @@ def constraints end def rack_app - app.app + app.rack_app end def path @@ -44,142 +61,273 @@ def internal? internal end + def action_source_location + file, line = action_source_file_and_line + return unless file + + "#{file}:#{line}" + end + + def action_source_file_and_line + return unless app.dispatcher? + return unless controller && action + + controller_name = controller.to_s + action_name = action.to_s + return if controller_name.start_with?(":") || action_name.start_with?(":") + + begin + controller_class = "#{controller_name.camelize}Controller".constantize + method = controller_class.instance_method(action_name.to_sym) + method.source_location + rescue NameError, TypeError + nil + end + end + def engine? - rack_app.respond_to?(:routes) + app.engine? + end + + def to_h + file, line = action_source_file_and_line + { name: name, + verb: verb, + path: path, + reqs: reqs, + source_location: source_location, + action_source_location: action_source_location, + action_source_file: file, + action_source_line: line } end end ## # This class is just used for displaying route information when someone - # executes `rails routes` or looks at the RoutingError page. - # People should not use this class. + # executes `bin/rails routes` or looks at the RoutingError page. People should + # 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 = nil) - routes_to_display = filter_routes(normalize_filter(filter)) - routes = collect_routes(routes_to_display) - if routes.none? - formatter.no_routes(collect_routes(@routes)) - return formatter.result - end - - formatter.header routes - formatter.section routes + def format(formatter, filter = {}) + all_routes = { nil => @routes }.merge(@engines) - @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.is_a?(Hash) && filter[:controller] - { controller: /#{filter[:controller].downcase.sub(/_?controller\z/, '').sub('::', '/')}/ } - elsif filter - { controller: /#{filter}/, action: /#{filter}/, verb: /#{filter}/, name: /#{filter}/, path: /#{filter}/ } + if filter[:controller] + { controller: /#{filter[:controller].underscore.sub(/_?controller\z/, "")}/ } + elsif filter[:grep] + grep_pattern = Regexp.new(filter[:grep]) + path = URI::RFC2396_PARSER.escape(filter[:grep]) + normalized_path = ("/" + path).squeeze("/") + + { + controller: grep_pattern, + action: grep_pattern, + verb: grep_pattern, + name: grep_pattern, + path: grep_pattern, + exact_path_match: normalized_path, + } end end - def filter_routes(filter) + def filter_routes(routes, filter) if filter - @routes.select do |route| - route_wrapper = RouteWrapper.new(route) - filter.any? { |default, value| route_wrapper.send(default) =~ value } + routes.select do |route| + filter.any? { |filter_type, value| route.matches_filter?(filter_type, value) } end else - @routes + routes end end + end - def collect_routes(routes) - routes.collect do |route| - RouteWrapper.new(route) - end.reject(&:internal?).collect do |route| - collect_engine_routes(route) + module ConsoleFormatter + class Base + def initialize + @buffer = [] + end - { name: route.name, - verb: route.verb, - path: route.path, - reqs: route.reqs } - end + def result + @buffer.join("\n") end - def collect_engine_routes(route) - name = route.endpoint - return unless route.engine? - return if @engines[name] + def section_title(title) + end - routes = route.rack_app.routes - if routes.is_a?(ActionDispatch::Routing::RouteSet) - @engines[name] = collect_routes(routes.routes) - end + def section(routes) end - end - class ConsoleFormatter - def initialize - @buffer = [] - end + def header(routes) + end - def result - @buffer.join("\n") - end + def footer(routes) + end - def section_title(title) - @buffer << "\n#{title}:" - end + 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 - def section(routes) - @buffer << draw_section(routes) + unless engine + @buffer << "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." + end + end end - def header(routes) - @buffer << draw_header(routes) - end + class Sheet < Base + def section_title(title) + @buffer << "#{title}:" + end - def no_routes(routes) - @buffer << - if routes.none? - <<-MESSAGE.strip_heredoc - You don't have any routes defined! + def section(routes) + @buffer << draw_section(routes) + end - Please add some routes in config/routes.rb. - MESSAGE - else - "No routes were found for this controller" + def header(routes) + @buffer << draw_header(routes) end - @buffer << "For more information about routes, see the Rails guide: http://guides.rubyonrails.org/routing.html." - end - private - def draw_section(routes) - header_lengths = ["Prefix", "Verb", "URI Pattern"].map(&:length) - name_width, verb_width, path_width = widths(routes).zip(header_lengths).map(&:max) + def footer(routes) + @buffer << "" + end + + private + def draw_section(routes) + header_lengths = ["Prefix", "Verb", "URI Pattern"].map(&:length) + name_width, verb_width, path_width = widths(routes).zip(header_lengths).map(&:max) + + routes.map do |r| + "#{r[:name].rjust(name_width)} #{r[:verb].ljust(verb_width)} #{r[:path].ljust(path_width)} #{r[:reqs]}" + end + end + + def draw_header(routes) + name_width, verb_width, path_width = widths(routes) - routes.map do |r| - "#{r[:name].rjust(name_width)} #{r[:verb].ljust(verb_width)} #{r[:path].ljust(path_width)} #{r[:reqs]}" + "#{"Prefix".rjust(name_width)} #{"Verb".ljust(verb_width)} #{"URI Pattern".ljust(path_width)} Controller#Action" end + + def widths(routes) + [routes.map { |r| r[:name].length }.max || 0, + routes.map { |r| r[:verb].length }.max || 0, + routes.map { |r| r[:path].length }.max || 0] + end + end + + class Expanded < Base + def initialize(width: IO.console_size[1]) + @width = width + super() + end + + def section_title(title) + @buffer << "#{"[ #{title} ]"}" + end + + def section(routes) + @buffer << draw_expanded_section(routes) + end + + def footer(routes) + @buffer << "" end - def draw_header(routes) - name_width, verb_width, path_width = widths(routes) + private + def draw_expanded_section(routes) + routes.map.each_with_index do |r, i| + route_rows = <<~MESSAGE.chomp + #{route_header(index: i + 1)} + Prefix | #{r[:name]} + Verb | #{r[:verb]} + URI | #{r[:path]} + Controller#Action | #{r[:reqs]} + MESSAGE + route_rows += "\nSource Location | #{r[:source_location]}" if r[:source_location].present? + route_rows += "\nAction Location | #{r[:action_source_location]}" if r[:action_source_location].present? + route_rows + end + end + + def route_header(index:) + "--[ Route #{index} ]".ljust(@width, "-") + end + end - "#{"Prefix".rjust(name_width)} #{"Verb".ljust(verb_width)} #{"URI Pattern".ljust(path_width)} Controller#Action" + class Unused < Sheet + def header(routes) + @buffer << <<~MSG + Found #{routes.count} unused #{"route".pluralize(routes.count)}: + MSG + + super end - def widths(routes) - [routes.map { |r| r[:name].length }.max || 0, - routes.map { |r| r[:verb].length }.max || 0, - routes.map { |r| r[:path].length }.max || 0] + def no_routes(engine, routes, filter) + @buffer << + if filter.none? + "No unused routes found." + elsif filter.key?(:controller) + "No unused routes found for this controller." + elsif filter.key?(:grep) + "No unused routes found for this grep pattern." + end end + end end class HtmlTableFormatter @@ -189,28 +337,31 @@ def initialize(view) end def section_title(title) - @buffer << %(#{title}) + @buffer << %(#{title}) end def section(routes) @buffer << @view.render(partial: "routes/route", collection: routes) end - # the header is part of the HTML page, so we don't construct it here. + # The header is part of the HTML page, so we don't construct it here. def header(routes) end + def footer(routes) + end + def no_routes(*) - @buffer << <<-MESSAGE.strip_heredoc + @buffer << <<~MESSAGE

You don't have any routes defined!

- MESSAGE + MESSAGE end def result diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index 8d9f70e3c6ffd..9b96de1b1a9b4 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/hash/slice" require "active_support/core_ext/enumerable" require "active_support/core_ext/array/extract_options" @@ -8,19 +12,31 @@ module ActionDispatch module Routing class Mapper + class BacktraceCleaner < ActiveSupport::BacktraceCleaner # :nodoc: + def initialize + super + remove_silencers! + add_core_silencer + add_stdlib_silencer + end + end + URL_OPTIONS = [:protocol, :subdomain, :domain, :host, :port] - class Constraints < Routing::Endpoint #:nodoc: + cattr_accessor :route_source_locations, instance_accessor: false, default: false + cattr_accessor :backtrace_cleaner, instance_accessor: false, default: BacktraceCleaner.new + + class Constraints < Routing::Endpoint # :nodoc: attr_reader :app, :constraints SERVE = ->(app, req) { app.serve req } CALL = ->(app, req) { app.call req.env } def initialize(app, constraints, strategy) - # Unwrap Constraints objects. I don't actually think it's possible - # to pass a Constraints object to this constructor, but there were - # multiple places that kept testing children of this object. I - # *think* they were just being defensive, but I have no idea. + # Unwrap Constraints objects. I don't actually think it's possible to pass a + # Constraints object to this constructor, but there were multiple places that + # kept testing children of this object. I **think** they were just being + # defensive, but I have no idea. if app.is_a?(self.class) constraints += app.constraints app = app.app @@ -41,31 +57,48 @@ def matches?(req) end def serve(req) - return [ 404, { "X-Cascade" => "pass" }, [] ] unless matches?(req) + return [ 404, { Constants::X_CASCADE => "pass" }, [] ] unless matches?(req) @strategy.call @app, req end private def constraint_args(constraint, request) - constraint.arity == 1 ? [request] : [request.path_parameters, request] + arity = if constraint.respond_to?(:arity) + constraint.arity + else + constraint.method(:call).arity + end + + if arity < 1 + [] + elsif arity == 1 + [request] + else + [request.path_parameters, request] + end end end - class Mapping #:nodoc: + class Mapping # :nodoc: ANCHOR_CHARACTERS_REGEX = %r{\A(\\A|\^)|(\\Z|\\z|\$)\Z} + OPTIONAL_FORMAT_REGEX = %r{(?:\(\.:format\)+|\.:format|/)\Z} + + attr_reader :path, :requirements, :defaults, :to, :default_controller, + :default_action, :required_defaults, :ast, :scope_options + + def self.build(scope, set, ast, controller, default_action, to, via, formatted, options_constraints, anchor, internal, options) + scope_params = { + blocks: scope[:blocks] || [], + constraints: scope[:constraints] || {}, + defaults: (scope[:defaults] || {}).dup, + module: scope[:module], + options: scope[:options] || {} + } - attr_reader :requirements, :defaults - attr_reader :to, :default_controller, :default_action - attr_reader :required_defaults, :ast - - def self.build(scope, set, ast, controller, default_action, to, via, formatted, options_constraints, anchor, options) - options = scope[:options].merge(options) if scope[:options] - - defaults = (scope[:defaults] || {}).dup - scope_constraints = scope[:constraints] || {} - - new set, ast, defaults, controller, default_action, scope[:module], to, formatted, scope_constraints, scope[:blocks] || [], via, options_constraints, anchor, options + new set: set, ast: ast, controller: controller, default_action: default_action, + to: to, formatted: formatted, via: via, options_constraints: options_constraints, + anchor: anchor, scope_params: scope_params, internal: internal, options: scope_params[:options].merge(options) end def self.check_via(via) @@ -93,43 +126,41 @@ def self.normalize_path(path, format) end def self.optional_format?(path, format) - format != false && !path.include?(":format") && !path.end_with?("/") + format != false && !path.match?(OPTIONAL_FORMAT_REGEX) end - def initialize(set, ast, defaults, controller, default_action, modyoule, to, formatted, scope_constraints, blocks, via, options_constraints, anchor, options) - @defaults = defaults - @set = set - - @to = to - @default_controller = controller - @default_action = default_action - @ast = ast + def initialize(set:, ast:, controller:, default_action:, to:, formatted:, via:, options_constraints:, anchor:, scope_params:, internal:, options:) + @defaults = scope_params[:defaults] + @set = set + @to = intern(to) + @default_controller = intern(controller) + @default_action = intern(default_action) @anchor = anchor @via = via - @internal = options.delete(:internal) - - path_params = ast.find_all(&:symbol?).map(&:to_sym) + @internal = internal + @scope_options = scope_params[:options] + ast = Journey::Ast.new(ast, formatted) - options = add_wildcard_options(options, formatted, ast) + options = ast.wildcard_options.merge!(options) - options = normalize_options!(options, path_params, modyoule) + options = normalize_options!(options, ast.path_params, scope_params[:module]) - split_options = constraints(options, path_params) + split_options = constraints(options, ast.path_params) - constraints = scope_constraints.merge Hash[split_options[:constraints] || []] + constraints = scope_params[:constraints].merge Hash[split_options[:constraints] || []] if options_constraints.is_a?(Hash) @defaults = Hash[options_constraints.find_all { |key, default| URL_OPTIONS.include?(key) && (String === default || Integer === default) }].merge @defaults - @blocks = blocks + @blocks = scope_params[:blocks] constraints.merge! options_constraints else @blocks = blocks(options_constraints) end - requirements, conditions = split_constraints path_params, constraints - verify_regexp_requirements requirements.map(&:last).grep(Regexp) + requirements, conditions = split_constraints ast.path_params, constraints + verify_regexp_requirements requirements, ast.wildcard_options formats = normalize_format(formatted) @@ -137,35 +168,29 @@ def initialize(set, ast, defaults, controller, default_action, modyoule, to, for @conditions = Hash[conditions] @defaults = formats[:defaults].merge(@defaults).merge(normalize_defaults(options)) - if path_params.include?(:action) && !@requirements.key?(:action) + if ast.path_params.include?(:action) && !@requirements.key?(:action) @defaults[:action] ||= "index" end @required_defaults = (split_options[:required_defaults] || []).map(&:first) + + ast.requirements = @requirements + @path = Journey::Path::Pattern.new(ast, @requirements, JOINED_SEPARATORS, @anchor) end + JOINED_SEPARATORS = SEPARATORS.join # :nodoc: + def make_route(name, precedence) - route = Journey::Route.new(name, - application, - path, - conditions, - required_defaults, - defaults, - request_method, - precedence, - @internal) - - route + Journey::Route.new(name: name, app: application, path: path, constraints: conditions, + required_defaults: required_defaults, defaults: defaults, + via: @via, precedence: precedence, + scope_options: scope_options, internal: @internal, source_location: route_source_location) end def application app(@blocks) end - def path - build_path @ast, requirements, @anchor - end - def conditions build_conditions @conditions, @set.request_class end @@ -179,53 +204,9 @@ def build_conditions(current_conditions, request_class) end private :build_conditions - def request_method - @via.map { |x| Journey::Route.verb_matcher(x) } - end - private :request_method - - JOINED_SEPARATORS = SEPARATORS.join # :nodoc: - - def build_path(ast, requirements, anchor) - pattern = Journey::Path::Pattern.new(ast, requirements, JOINED_SEPARATORS, anchor) - - # Find all the symbol nodes that are adjacent to literal nodes and alter - # the regexp so that Journey will partition them into custom routes. - ast.find_all { |node| - next unless node.cat? - - if node.left.literal? && node.right.symbol? - symbol = node.right - elsif node.left.literal? && node.right.cat? && node.right.left.symbol? - symbol = node.right.left - elsif node.left.symbol? && node.right.literal? - symbol = node.left - elsif node.left.symbol? && node.right.cat? && node.right.left.literal? - symbol = node.left - else - next - end - - if symbol - symbol.regexp = /(?:#{Regexp.union(symbol.regexp, '-')})+/ - end - } - - pattern - end - private :build_path - private - def add_wildcard_options(options, formatted, path_ast) - # Add a constraint for wildcard route to make it non-greedy and match the - # optional format part of the route by default - if formatted != false - path_ast.grep(Journey::Nodes::Star).each_with_object({}) { |node, hash| - hash[node.name.to_sym] ||= /.+?/ - }.merge options - else - options - end + def intern(object) + object.is_a?(String) ? -object : object end def normalize_options!(options, path_params, modyoule) @@ -235,16 +216,28 @@ def normalize_options!(options, path_params, modyoule) # Add a default constraint for :controller path segments that matches namespaced # controllers with default routes like :controller/:action/:id(.:format), e.g: # GET /admin/products/show/1 - # => { controller: 'admin/products', action: 'show', id: '1' } + # # > { controller: 'admin/products', action: 'show', id: '1' } options[:controller] ||= /.+?/ end if to.respond_to?(:action) || to.respond_to?(:call) options else - to_endpoint = split_to to - controller = to_endpoint[0] || default_controller - action = to_endpoint[1] || default_action + if to.nil? + controller = default_controller + action = default_action + elsif to.is_a?(String) + if to.include?("#") + to_endpoint = to.split("#").map!(&:-@) + controller = to_endpoint[0] + action = to_endpoint[1] + else + controller = default_controller + action = to + end + else + raise ArgumentError, ":to must respond to `action` or `call`, or it must be a String that includes '#', or the controller should be implicit" + end controller = add_controller_module(controller, modyoule) @@ -274,14 +267,18 @@ def normalize_format(formatted) end end - def verify_regexp_requirements(requirements) - requirements.each do |requirement| - if requirement.source =~ ANCHOR_CHARACTERS_REGEX + def verify_regexp_requirements(requirements, wildcard_options) + requirements.each do |requirement, regex| + next unless regex.is_a? Regexp + + if ANCHOR_CHARACTERS_REGEX.match?(regex.source) raise ArgumentError, "Regexp anchor characters are not allowed in routing requirements: #{requirement.inspect}" end - if requirement.multiline? - raise ArgumentError, "Regexp multiline option is not allowed in routing requirements: #{requirement.inspect}" + if regex.multiline? + next if wildcard_options.key?(requirement) + + raise ArgumentError, "Regexp multiline option is not allowed in routing requirements: #{regex.inspect}" end end end @@ -305,8 +302,8 @@ def app(blocks) def check_controller_and_action(path_params, controller, action) hash = check_part(:controller, controller, path_params, {}) do |part| translate_controller(part) { - message = "'#{part}' is not a supported controller name. This can lead to potential routing problems." - message << " See http://guides.rubyonrails.org/routing.html#specifying-a-controller-to-use" + message = +"'#{part}' is not a supported controller name. This can lead to potential routing problems." + message << " See https://guides.rubyonrails.org/routing.html#specifying-a-controller-to-use" raise ArgumentError, message } @@ -329,20 +326,12 @@ def check_part(name, part, path_params, hash) hash end - def split_to(to) - if to =~ /#/ - to.split("#") - else - [] - end - end - def add_controller_module(controller, modyoule) if modyoule && !controller.is_a?(Regexp) - if controller =~ %r{\A/} - controller[1..-1] + if controller&.start_with?("/") + -controller[1..-1] else - [modyoule, controller].compact.join("/") + -[modyoule, controller].compact.join("/") end else controller @@ -351,7 +340,7 @@ def add_controller_module(controller, modyoule) def translate_controller(controller) return controller if Regexp === controller - return controller.to_s if controller =~ /\A[a-z_0-9][a-z_0-9\/]*\z/ + return controller.to_s if /\A[a-z_0-9][a-z_0-9\/]*\z/.match?(controller) yield end @@ -380,14 +369,38 @@ def constraints(options, path_params) def dispatcher(raise_on_name_error) Routing::RouteSet::Dispatcher.new raise_on_name_error end + + def route_source_location + if Mapper.route_source_locations + action_dispatch_dir = File.expand_path("..", __dir__) + Thread.each_caller_location do |location| + next if location.path.start_with?(action_dispatch_dir) + + cleaned_path = Mapper.backtrace_cleaner.clean_frame(location.path) + next if cleaned_path.nil? + + return "#{cleaned_path}:#{location.lineno}" + end + nil + end + end end - # Invokes Journey::Router::Utils.normalize_path and ensure that - # (:locale) becomes (/:locale) instead of /(:locale). Except - # for root cases, where the latter is the correct one. + # Invokes Journey::Router::Utils.normalize_path, then ensures that /(:locale) + # becomes (/:locale). Except for root cases, where the former is the correct + # one. def self.normalize_path(path) path = Journey::Router::Utils.normalize_path(path) - path.gsub!(%r{/(\(+)/?}, '\1/') unless path =~ %r{^/\(+[^)]+\)$} + + # the path for a root URL at this point can be something like + # "/(/:locale)(/:platform)/(:browser)", and we would want + # "/(:locale)(/:platform)(/:browser)" reverse "/(", "/((" etc to "(/", "((/" etc + path.gsub!(%r{/(\(+)/?}, '\1/') + # if a path is all optional segments, change the leading "(/" back to "/(" so it + # evaluates to "/" when interpreted with no options. Unless, however, at least + # one secondary segment consists of a static part, ex. + # "(/:locale)(/pages/:page)" + path.sub!(%r{^(\(+)/}, '/\1') if %r{^(\(+[^)]+\))(\(+/:[^)]+\))*$}.match?(path) path end @@ -396,209 +409,225 @@ def self.normalize_name(name) end module Base - # Matches a url pattern to one or more routes. + # Matches a URL pattern to one or more routes. # - # You should not use the +match+ method in your router - # without specifying an HTTP method. + # You should not use the `match` method in your router without specifying an + # HTTP method. # # If you want to expose your action to both GET and POST, use: # - # # sets :controller, :action and :id in params - # match ':controller/:action/:id', via: [:get, :post] + # # sets :controller, :action, and :id in params + # match ':controller/:action/:id', via: [:get, :post] # - # Note that +:controller+, +:action+ and +:id+ are interpreted as url - # query parameters and thus available through +params+ in an action. + # Note that `:controller`, `:action`, and `:id` are interpreted as URL query + # parameters and thus available through `params` in an action. # - # If you want to expose your action to GET, use +get+ in the router: + # If you want to expose your action to GET, use `get` in the router: # # Instead of: # - # match ":controller/:action/:id" + # match ":controller/:action/:id" # # Do: # - # get ":controller/:action/:id" + # get ":controller/:action/:id" # - # Two of these symbols are special, +:controller+ maps to the controller - # and +:action+ to the controller's action. A pattern can also map - # wildcard segments (globs) to params: + # Two of these symbols are special, `:controller` maps to the controller and + # `:action` to the controller's action. A pattern can also map wildcard segments + # (globs) to params: # - # get 'songs/*category/:title', to: 'songs#show' + # get 'songs/*category/:title', to: 'songs#show' # - # # 'songs/rock/classic/stairway-to-heaven' sets - # # params[:category] = 'rock/classic' - # # params[:title] = 'stairway-to-heaven' + # # 'songs/rock/classic/stairway-to-heaven' sets + # # params[:category] = 'rock/classic' + # # params[:title] = 'stairway-to-heaven' # - # To match a wildcard parameter, it must have a name assigned to it. - # Without a variable name to attach the glob parameter to, the route - # can't be parsed. + # To match a wildcard parameter, it must have a name assigned to it. Without a + # variable name to attach the glob parameter to, the route can't be parsed. # - # When a pattern points to an internal route, the route's +:action+ and - # +:controller+ should be set in options or hash shorthand. Examples: + # When a pattern points to an internal route, the route's `:action` and + # `:controller` should be set in options or hash shorthand. Examples: # - # match 'photos/:id' => 'photos#show', via: :get - # match 'photos/:id', to: 'photos#show', via: :get - # match 'photos/:id', controller: 'photos', action: 'show', via: :get + # match 'photos/:id', to: 'photos#show', via: :get + # match 'photos/:id', controller: 'photos', action: 'show', via: :get # - # A pattern can also point to a +Rack+ endpoint i.e. anything that - # responds to +call+: + # A pattern can also point to a `Rack` endpoint i.e. anything that responds to + # `call`: # - # match 'photos/:id', to: -> (hash) { [200, {}, ["Coming soon"]] }, via: :get - # match 'photos/:id', to: PhotoRackApp, via: :get - # # Yes, controller actions are just rack endpoints - # match 'photos/:id', to: PhotosController.action(:show), via: :get + # match 'photos/:id', to: -> (hash) { [200, {}, ["Coming soon"]] }, via: :get + # match 'photos/:id', to: PhotoRackApp, via: :get + # # Yes, controller actions are just rack endpoints + # match 'photos/:id', to: PhotosController.action(:show), via: :get # # Because requesting various HTTP verbs with a single action has security - # implications, you must either specify the actions in - # the via options or use one of the HttpHelpers[rdoc-ref:HttpHelpers] - # instead +match+ + # implications, you must either specify the actions in the via options or use + # one of the [HttpHelpers](rdoc-ref:HttpHelpers) instead `match` # - # === Options + # ### Options # - # Any options not seen here are passed on as params with the url. + # Any options not seen here are passed on as params with the URL. # - # [:controller] - # The route's controller. + # :controller + # : The route's controller. # - # [:action] - # The route's action. + # :action + # : The route's action. # - # [:param] - # Overrides the default resource identifier +:id+ (name of the - # dynamic segment used to generate the routes). - # You can access that segment from your controller using - # params[<:param>]. - # In your router: + # :param + # : Overrides the default resource identifier `:id` (name of the dynamic + # segment used to generate the routes). You can access that segment from + # your controller using `params[<:param>]`. In your router: # - # resources :user, param: :name + # resources :users, param: :name # - # You can override ActiveRecord::Base#to_param of a related - # model to construct a URL: + # The `users` resource here will have the following routes generated for it: # - # class User < ActiveRecord::Base - # def to_param - # name - # end - # end + # GET /users(.:format) + # POST /users(.:format) + # GET /users/new(.:format) + # GET /users/:name/edit(.:format) + # GET /users/:name(.:format) + # PATCH/PUT /users/:name(.:format) + # DELETE /users/:name(.:format) # - # user = User.find_by(name: 'Phusion') - # user_path(user) # => "/users/Phusion" + # You can override `ActiveRecord::Base#to_param` of a related model to + # construct a URL: # - # [:path] - # The path prefix for the routes. + # class User < ActiveRecord::Base + # def to_param + # name + # end + # end # - # [:module] - # The namespace for :controller. + # user = User.find_by(name: 'Phusion') + # user_path(user) # => "/users/Phusion" # - # match 'path', to: 'c#a', module: 'sekret', controller: 'posts', via: :get - # # => Sekret::PostsController + # :path + # : The path prefix for the routes. # - # See Scoping#namespace for its scope equivalent. + # :module + # : The namespace for :controller. # - # [:as] - # The name used to generate routing helpers. + # match 'path', to: 'c#a', module: 'sekret', controller: 'posts', via: :get + # # => Sekret::PostsController # - # [:via] - # Allowed HTTP verb(s) for route. + # See `Scoping#namespace` for its scope equivalent. # - # match 'path', to: 'c#a', via: :get - # match 'path', to: 'c#a', via: [:get, :post] - # match 'path', to: 'c#a', via: :all + # :as + # : The name used to generate routing helpers. # - # [:to] - # Points to a +Rack+ endpoint. Can be an object that responds to - # +call+ or a string representing a controller's action. + # :via + # : Allowed HTTP verb(s) for route. # - # match 'path', to: 'controller#action', via: :get - # match 'path', to: -> (env) { [200, {}, ["Success!"]] }, via: :get - # match 'path', to: RackApp, via: :get + # match 'path', to: 'c#a', via: :get + # match 'path', to: 'c#a', via: [:get, :post] + # match 'path', to: 'c#a', via: :all # - # [:on] - # Shorthand for wrapping routes in a specific RESTful context. Valid - # values are +:member+, +:collection+, and +:new+. Only use within - # resource(s) block. For example: + # :to + # : Points to a `Rack` endpoint. Can be an object that responds to `call` or a + # string representing a controller's action. # - # resource :bar do - # match 'foo', to: 'c#a', on: :member, via: [:get, :post] - # end + # match 'path', to: 'controller#action', via: :get + # match 'path', to: -> (env) { [200, {}, ["Success!"]] }, via: :get + # match 'path', to: RackApp, via: :get # - # Is equivalent to: + # :on + # : Shorthand for wrapping routes in a specific RESTful context. Valid values + # are `:member`, `:collection`, and `:new`. Only use within `resource(s)` + # block. For example: # - # resource :bar do - # member do - # match 'foo', to: 'c#a', via: [:get, :post] - # end - # end + # resource :bar do + # match 'foo', to: 'c#a', on: :member, via: [:get, :post] + # end # - # [:constraints] - # Constrains parameters with a hash of regular expressions - # or an object that responds to matches?. In addition, constraints - # other than path can also be specified with any object - # that responds to === (eg. String, Array, Range, etc.). + # Is equivalent to: # - # match 'path/:id', constraints: { id: /[A-Z]\d{5}/ }, via: :get + # resource :bar do + # member do + # match 'foo', to: 'c#a', via: [:get, :post] + # end + # end # - # match 'json_only', constraints: { format: 'json' }, via: :get + # :constraints + # : Constrains parameters with a hash of regular expressions or an object that + # responds to `matches?`. In addition, constraints other than path can also + # be specified with any object that responds to `===` (e.g. String, Array, + # Range, etc.). # - # class Whitelist - # def matches?(request) request.remote_ip == '1.2.3.4' end - # end - # match 'path', to: 'c#a', constraints: Whitelist.new, via: :get + # match 'path/:id', constraints: { id: /[A-Z]\d{5}/ }, via: :get # - # See Scoping#constraints for more examples with its scope - # equivalent. + # match 'json_only', constraints: { format: 'json' }, via: :get # - # [:defaults] - # Sets defaults for parameters + # class PermitList + # def matches?(request) request.remote_ip == '1.2.3.4' end + # end + # match 'path', to: 'c#a', constraints: PermitList.new, via: :get # - # # Sets params[:format] to 'jpg' by default - # match 'path', to: 'c#a', defaults: { format: 'jpg' }, via: :get + # See `Scoping#constraints` for more examples with its scope equivalent. # - # See Scoping#defaults for its scope equivalent. + # :defaults + # : Sets defaults for parameters # - # [:anchor] - # Boolean to anchor a match pattern. Default is true. When set to - # false, the pattern matches any request prefixed with the given path. + # # Sets params[:format] to 'jpg' by default + # match 'path', to: 'c#a', defaults: { format: 'jpg' }, via: :get # - # # Matches any request starting with 'path' - # match 'path', to: 'c#a', anchor: false, via: :get + # See `Scoping#defaults` for its scope equivalent. + # + # :anchor + # : Boolean to anchor a `match` pattern. Default is true. When set to false, + # the pattern matches any request prefixed with the given path. + # + # # Matches any request starting with 'path' + # match 'path', to: 'c#a', anchor: false, via: :get + # + # :format + # : Allows you to specify the default value for optional `format` segment or + # disable it by supplying `false`. # - # [:format] - # Allows you to specify the default value for optional +format+ - # segment or disable it by supplying +false+. def match(path, options = nil) end # Mount a Rack-based application to be used within the application. # - # mount SomeRackApp, at: "some_route" - # - # Alternatively: + # mount SomeRackApp, at: "some_route" # - # mount(SomeRackApp => "some_route") + # For options, see `match`, as `mount` uses it internally. # - # For options, see +match+, as +mount+ uses it internally. + # All mounted applications come with routing helpers to access them. These are + # named after the class specified, so for the above example the helper is either + # `some_rack_app_path` or `some_rack_app_url`. To customize this helper's name, + # use the `:as` option: # - # All mounted applications come with routing helpers to access them. - # These are named after the class specified, so for the above example - # the helper is either +some_rack_app_path+ or +some_rack_app_url+. - # To customize this helper's name, use the +:as+ option: + # mount(SomeRackApp, at: "some_route", as: "exciting") # - # mount(SomeRackApp => "some_route", as: "exciting") - # - # This will generate the +exciting_path+ and +exciting_url+ helpers - # which can be used to navigate to this mounted app. - def mount(app, options = nil) - if options - path = options.delete(:at) - elsif Hash === app - options = app - app, path = options.find { |k, _| k.respond_to?(:call) } - options.delete(app) if app + # This will generate the `exciting_path` and `exciting_url` helpers which can be + # used to navigate to this mounted app. + def mount(app = nil, deprecated_options = nil, as: DEFAULT, via: nil, at: nil, defaults: nil, constraints: nil, anchor: false, format: false, path: nil, internal: nil, **mapping, &block) + if deprecated_options.is_a?(Hash) + as = assign_deprecated_option(deprecated_options, :as, :mount) if deprecated_options.key?(:as) + via ||= assign_deprecated_option(deprecated_options, :via, :mount) + at ||= assign_deprecated_option(deprecated_options, :at, :mount) + defaults ||= assign_deprecated_option(deprecated_options, :defaults, :mount) + constraints ||= assign_deprecated_option(deprecated_options, :constraints, :mount) + anchor = assign_deprecated_option(deprecated_options, :anchor, :mount) if deprecated_options.key?(:anchor) + format = assign_deprecated_option(deprecated_options, :format, :mount) if deprecated_options.key?(:format) + path ||= assign_deprecated_option(deprecated_options, :path, :mount) + internal ||= assign_deprecated_option(deprecated_options, :internal, :mount) + assign_deprecated_options(deprecated_options, mapping, :mount) + end + + path_or_action = at + + if app.nil? + hash_app, hash_path = mapping.find { |key, _| key.respond_to?(:call) } + mapping.delete(hash_app) if hash_app + + app ||= hash_app + path_or_action ||= hash_path end raise ArgumentError, "A rack application must be specified" unless app.respond_to?(:call) - raise ArgumentError, <<-MSG.strip_heredoc unless path + raise ArgumentError, <<~MSG unless path_or_action Must be called with mount point mount SomeRackApp, at: "some_route" @@ -607,12 +636,12 @@ def mount(app, options = nil) MSG rails_app = rails_app? app - options[:as] ||= app_name(app, rails_app) + as = app_name(app, rails_app) if as == DEFAULT - target_as = name_for_action(options[:as], path) - options[:via] ||= :all + target_as = name_for_action(as, path_or_action) + via ||= :all - match(path, options.merge(to: app, anchor: false, format: false)) + match(path_or_action, to: app, as:, via:, defaults:, constraints:, anchor:, format:, path:, internal:, **mapping, &block) define_generate_prefix(app, target_as) if rails_app self @@ -624,17 +653,35 @@ def default_url_options=(options) alias_method :default_url_options, :default_url_options= def with_default_scope(scope, &block) - scope(scope) do + scope(**scope) do instance_exec(&block) end end # Query if the following named route was already defined. def has_named_route?(name) - @set.named_routes.key? name + @set.named_routes.key?(name) end private + 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. Support to hash argument will be removed in Rails 8.2. + MSG + deprecated_value + end + end + + 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. Support to hash argument will be removed in Rails 8.2. + MSG + options[key] = value + end + end + def rails_app?(app) app.is_a?(Class) && app < Rails::Railtie end @@ -651,18 +698,32 @@ def app_name(app, rails_app) def define_generate_prefix(app, name) _route = @set.named_routes.get name _routes = @set - app.routes.define_mounted_helper(name) + _url_helpers = @set.url_helpers + + script_namer = ->(options) do + prefix_options = options.slice(*_route.segment_keys) + prefix_options[:script_name] = "" if options[:original_script_name] + + if options[:_recall] + prefix_options.reverse_merge!(options[:_recall].slice(*_route.segment_keys)) + end + + # We must actually delete prefix segment keys to avoid passing them to next + # url_for. + _route.segment_keys.each { |k| options.delete(k) } + _url_helpers.public_send("#{name}_path", prefix_options) + end + + app.routes.define_mounted_helper(name, script_namer) + app.routes.extend Module.new { def optimize_routes_generation?; false; end + define_method :find_script_name do |options| - if options.key? :script_name + if options.key?(:script_name) && options[:script_name].present? super(options) else - prefix_options = options.slice(*_route.segment_keys) - prefix_options[:relative_url_root] = "".freeze - # we must actually delete prefix segment keys to avoid passing them to next url_for - _route.segment_keys.each { |k| options.delete(k) } - _routes.url_helpers.send("#{name}_path", prefix_options) + script_namer.call(options) end end } @@ -670,142 +731,268 @@ def optimize_routes_generation?; false; end end module HttpHelpers - # Define a route that only recognizes HTTP GET. - # For supported arguments, see match[rdoc-ref:Base#match] - # - # get 'bacon', to: 'food#bacon' - def get(*args, &block) - map_method(:get, args, &block) + # Define a route that only recognizes HTTP GET. For supported arguments, see + # [match](rdoc-ref:Base#match) + # + # get 'bacon', to: 'food#bacon' + def get(*path_or_actions, as: DEFAULT, 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, :get) if deprecated_options.key?(:as) + to ||= assign_deprecated_option(deprecated_options, :to, :get) + controller ||= assign_deprecated_option(deprecated_options, :controller, :get) + action ||= assign_deprecated_option(deprecated_options, :action, :get) + on ||= assign_deprecated_option(deprecated_options, :on, :get) + defaults ||= assign_deprecated_option(deprecated_options, :defaults, :get) + constraints ||= assign_deprecated_option(deprecated_options, :constraints, :get) + anchor = assign_deprecated_option(deprecated_options, :anchor, :get) if deprecated_options.key?(:anchor) + format = assign_deprecated_option(deprecated_options, :format, :get) if deprecated_options.key?(:format) + path ||= assign_deprecated_option(deprecated_options, :path, :get) + internal ||= assign_deprecated_option(deprecated_options, :internal, :get) + assign_deprecated_options(deprecated_options, mapping, :get) + end + + match(*path_or_actions, as:, to:, controller:, action:, on:, defaults:, constraints:, anchor:, format:, path:, internal:, **mapping, via: :get, &block) + self end - # Define a route that only recognizes HTTP POST. - # For supported arguments, see match[rdoc-ref:Base#match] - # - # post 'bacon', to: 'food#bacon' - def post(*args, &block) - map_method(:post, args, &block) + # Define a route that only recognizes HTTP POST. For supported arguments, see + # [match](rdoc-ref:Base#match) + # + # post 'bacon', to: 'food#bacon' + def post(*path_or_actions, as: DEFAULT, 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, :post) if deprecated_options.key?(:as) + to ||= assign_deprecated_option(deprecated_options, :to, :post) + controller ||= assign_deprecated_option(deprecated_options, :controller, :post) + action ||= assign_deprecated_option(deprecated_options, :action, :post) + on ||= assign_deprecated_option(deprecated_options, :on, :post) + defaults ||= assign_deprecated_option(deprecated_options, :defaults, :post) + constraints ||= assign_deprecated_option(deprecated_options, :constraints, :post) + anchor = assign_deprecated_option(deprecated_options, :anchor, :post) if deprecated_options.key?(:anchor) + format = assign_deprecated_option(deprecated_options, :format, :post) if deprecated_options.key?(:format) + path ||= assign_deprecated_option(deprecated_options, :path, :post) + internal ||= assign_deprecated_option(deprecated_options, :internal, :post) + assign_deprecated_options(deprecated_options, mapping, :post) + end + + match(*path_or_actions, as:, to:, controller:, action:, on:, defaults:, constraints:, anchor:, format:, path:, internal:, **mapping, via: :post, &block) + self end - # Define a route that only recognizes HTTP PATCH. - # For supported arguments, see match[rdoc-ref:Base#match] - # - # patch 'bacon', to: 'food#bacon' - def patch(*args, &block) - map_method(:patch, args, &block) + # Define a route that only recognizes HTTP PATCH. For supported arguments, see + # [match](rdoc-ref:Base#match) + # + # patch 'bacon', to: 'food#bacon' + def patch(*path_or_actions, as: DEFAULT, 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, :patch) if deprecated_options.key?(:as) + to ||= assign_deprecated_option(deprecated_options, :to, :patch) + controller ||= assign_deprecated_option(deprecated_options, :controller, :patch) + action ||= assign_deprecated_option(deprecated_options, :action, :patch) + on ||= assign_deprecated_option(deprecated_options, :on, :patch) + defaults ||= assign_deprecated_option(deprecated_options, :defaults, :patch) + constraints ||= assign_deprecated_option(deprecated_options, :constraints, :patch) + anchor = assign_deprecated_option(deprecated_options, :anchor, :patch) if deprecated_options.key?(:anchor) + format = assign_deprecated_option(deprecated_options, :format, :patch) if deprecated_options.key?(:format) + path ||= assign_deprecated_option(deprecated_options, :path, :patch) + internal ||= assign_deprecated_option(deprecated_options, :internal, :patch) + assign_deprecated_options(deprecated_options, mapping, :patch) + end + + match(*path_or_actions, as:, to:, controller:, action:, on:, defaults:, constraints:, anchor:, format:, path:, internal:, **mapping, via: :patch, &block) + self end - # Define a route that only recognizes HTTP PUT. - # For supported arguments, see match[rdoc-ref:Base#match] - # - # put 'bacon', to: 'food#bacon' - def put(*args, &block) - map_method(:put, args, &block) + # Define a route that only recognizes HTTP PUT. For supported arguments, see + # [match](rdoc-ref:Base#match) + # + # put 'bacon', to: 'food#bacon' + def put(*path_or_actions, as: DEFAULT, 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, :put) if deprecated_options.key?(:as) + to ||= assign_deprecated_option(deprecated_options, :to, :put) + controller ||= assign_deprecated_option(deprecated_options, :controller, :put) + action ||= assign_deprecated_option(deprecated_options, :action, :put) + on ||= assign_deprecated_option(deprecated_options, :on, :put) + defaults ||= assign_deprecated_option(deprecated_options, :defaults, :put) + constraints ||= assign_deprecated_option(deprecated_options, :constraints, :put) + anchor = assign_deprecated_option(deprecated_options, :anchor, :put) if deprecated_options.key?(:anchor) + format = assign_deprecated_option(deprecated_options, :format, :put) if deprecated_options.key?(:format) + path ||= assign_deprecated_option(deprecated_options, :path, :put) + internal ||= assign_deprecated_option(deprecated_options, :internal, :put) + assign_deprecated_options(deprecated_options, mapping, :put) + end + + match(*path_or_actions, as:, to:, controller:, action:, on:, defaults:, constraints:, anchor:, format:, path:, internal:, **mapping, via: :put, &block) + self end - # Define a route that only recognizes HTTP DELETE. - # For supported arguments, see match[rdoc-ref:Base#match] - # - # delete 'broccoli', to: 'food#broccoli' - def delete(*args, &block) - map_method(:delete, args, &block) + # Define a route that only recognizes HTTP DELETE. For supported arguments, see + # [match](rdoc-ref:Base#match) + # + # delete 'broccoli', to: 'food#broccoli' + def delete(*path_or_actions, as: DEFAULT, 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, :delete) if deprecated_options.key?(:as) + to ||= assign_deprecated_option(deprecated_options, :to, :delete) + controller ||= assign_deprecated_option(deprecated_options, :controller, :delete) + action ||= assign_deprecated_option(deprecated_options, :action, :delete) + on ||= assign_deprecated_option(deprecated_options, :on, :delete) + defaults ||= assign_deprecated_option(deprecated_options, :defaults, :delete) + constraints ||= assign_deprecated_option(deprecated_options, :constraints, :delete) + anchor = assign_deprecated_option(deprecated_options, :anchor, :delete) if deprecated_options.key?(:anchor) + format = assign_deprecated_option(deprecated_options, :format, :delete) if deprecated_options.key?(:format) + path ||= assign_deprecated_option(deprecated_options, :path, :delete) + internal ||= assign_deprecated_option(deprecated_options, :internal, :delete) + assign_deprecated_options(deprecated_options, mapping, :delete) + end + + match(*path_or_actions, as:, to:, controller:, action:, on:, defaults:, constraints:, anchor:, format:, path:, internal:, **mapping, via: :delete, &block) + self end - private - def map_method(method, args, &block) - options = args.extract_options! - options[:via] = method - match(*args, options, &block) - self - end + # Define a route that only recognizes HTTP OPTIONS. For supported arguments, see + # [match](rdoc-ref:Base#match) + # + # options 'carrots', to: 'food#carrots' + def options(*path_or_actions, as: DEFAULT, to: nil, controller: nil, action: nil, on: nil, defaults: nil, constraints: nil, anchor: false, format: false, 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, :options) if deprecated_options.key?(:as) + to ||= assign_deprecated_option(deprecated_options, :to, :options) + controller ||= assign_deprecated_option(deprecated_options, :controller, :options) + action ||= assign_deprecated_option(deprecated_options, :action, :options) + on ||= assign_deprecated_option(deprecated_options, :on, :options) + defaults ||= assign_deprecated_option(deprecated_options, :defaults, :options) + constraints ||= assign_deprecated_option(deprecated_options, :constraints, :options) + anchor = assign_deprecated_option(deprecated_options, :anchor, :options) if deprecated_options.key?(:anchor) + format = assign_deprecated_option(deprecated_options, :format, :options) if deprecated_options.key?(:format) + path ||= assign_deprecated_option(deprecated_options, :path, :options) + internal ||= assign_deprecated_option(deprecated_options, :internal, :options) + assign_deprecated_options(deprecated_options, mapping, :options) + end + + match(*path_or_actions, as:, to:, controller:, action:, on:, defaults:, constraints:, anchor:, format:, path:, internal:, **mapping, via: :options, &block) + self + end + + # Define a route that recognizes HTTP CONNECT (and GET) requests. More + # specifically this recognizes HTTP/1 protocol upgrade requests and HTTP/2 + # CONNECT requests with the protocol pseudo header. For supported arguments, + # see [match](rdoc-ref:Base#match) + # + # connect 'live', to: 'live#index' + def connect(*path_or_actions, as: DEFAULT, to: nil, controller: nil, action: nil, on: nil, defaults: nil, constraints: nil, anchor: false, format: false, 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, :connect) if deprecated_options.key?(:as) + to ||= assign_deprecated_option(deprecated_options, :to, :connect) + controller ||= assign_deprecated_option(deprecated_options, :controller, :connect) + action ||= assign_deprecated_option(deprecated_options, :action, :connect) + on ||= assign_deprecated_option(deprecated_options, :on, :connect) + defaults ||= assign_deprecated_option(deprecated_options, :defaults, :connect) + constraints ||= assign_deprecated_option(deprecated_options, :constraints, :connect) + anchor = assign_deprecated_option(deprecated_options, :anchor, :connect) if deprecated_options.key?(:anchor) + format = assign_deprecated_option(deprecated_options, :format, :connect) if deprecated_options.key?(:format) + path ||= assign_deprecated_option(deprecated_options, :path, :connect) + internal ||= assign_deprecated_option(deprecated_options, :internal, :connect) + assign_deprecated_options(deprecated_options, mapping, :connect) + end + + match(*path_or_actions, as:, to:, controller:, action:, on:, defaults:, constraints:, anchor:, format:, path:, internal:, **mapping, via: [:get, :connect], &block) + self + end end - # You may wish to organize groups of controllers under a namespace. - # Most commonly, you might group a number of administrative controllers - # under an +admin+ namespace. You would place these controllers under - # the app/controllers/admin directory, and you can group them - # together in your router: + # You may wish to organize groups of controllers under a namespace. Most + # commonly, you might group a number of administrative controllers under an + # `admin` namespace. You would place these controllers under the + # `app/controllers/admin` directory, and you can group them together in your + # router: # - # namespace "admin" do - # resources :posts, :comments - # end + # namespace "admin" do + # resources :posts, :comments + # end # # This will create a number of routes for each of the posts and comments - # controller. For Admin::PostsController, Rails will create: + # controller. For `Admin::PostsController`, Rails will create: # - # GET /admin/posts - # GET /admin/posts/new - # POST /admin/posts - # GET /admin/posts/1 - # GET /admin/posts/1/edit - # PATCH/PUT /admin/posts/1 - # DELETE /admin/posts/1 + # GET /admin/posts + # GET /admin/posts/new + # POST /admin/posts + # GET /admin/posts/1 + # GET /admin/posts/1/edit + # PATCH/PUT /admin/posts/1 + # DELETE /admin/posts/1 # # If you want to route /posts (without the prefix /admin) to - # Admin::PostsController, you could use + # `Admin::PostsController`, you could use # - # scope module: "admin" do - # resources :posts - # end + # scope module: "admin" do + # resources :posts + # end # # or, for a single case # - # resources :posts, module: "admin" + # resources :posts, module: "admin" # - # If you want to route /admin/posts to +PostsController+ - # (without the Admin:: module prefix), you could use + # If you want to route /admin/posts to `PostsController` (without the `Admin::` + # module prefix), you could use # - # scope "/admin" do - # resources :posts - # end + # scope "/admin" do + # resources :posts + # end # # or, for a single case # - # resources :posts, path: "/admin/posts" + # resources :posts, path: "/admin/posts" # - # In each of these cases, the named routes remain the same as if you did - # not use scope. In the last case, the following paths map to - # +PostsController+: + # In each of these cases, the named routes remain the same as if you did not use + # scope. In the last case, the following paths map to `PostsController`: # - # GET /admin/posts - # GET /admin/posts/new - # POST /admin/posts - # GET /admin/posts/1 - # GET /admin/posts/1/edit - # PATCH/PUT /admin/posts/1 - # DELETE /admin/posts/1 + # GET /admin/posts + # GET /admin/posts/new + # POST /admin/posts + # GET /admin/posts/1 + # GET /admin/posts/1/edit + # PATCH/PUT /admin/posts/1 + # DELETE /admin/posts/1 module Scoping # Scopes a set of routes to the given default options. # # Take the following route definition as an example: # - # scope path: ":account_id", as: "account" do - # resources :projects - # end + # scope path: ":account_id", as: "account" do + # resources :projects + # end # - # This generates helpers such as +account_projects_path+, just like +resources+ does. - # The difference here being that the routes generated are like /:account_id/projects, - # rather than /accounts/:account_id/projects. + # This generates helpers such as `account_projects_path`, just like `resources` + # does. The difference here being that the routes generated are like + # /:account_id/projects, rather than /accounts/:account_id/projects. # - # === Options + # ### Options # - # Takes same options as Base#match and Resources#resources. + # Takes same options as `Base#match` and `Resources#resources`. # - # # route /posts (without the prefix /admin) to Admin::PostsController - # scope module: "admin" do - # resources :posts - # end + # # route /posts (without the prefix /admin) to Admin::PostsController + # scope module: "admin" do + # resources :posts + # end # - # # prefix the posts resource's requests with '/admin' - # scope path: "/admin" do - # resources :posts - # end + # # prefix the posts resource's requests with '/admin' + # scope path: "/admin" do + # resources :posts + # end # - # # prefix the routing helper name: +sekret_posts_path+ instead of +posts_path+ - # scope as: "sekret" do - # resources :posts - # end - def scope(*args) - options = args.extract_options!.dup + # # prefix the routing helper name: sekret_posts_path instead of posts_path + # scope as: "sekret" do + # resources :posts + # end + def scope(*args, only: nil, except: nil, **options) + if args.grep(Hash).any? && (deprecated_options = args.extract_options!) + only ||= assign_deprecated_option(deprecated_options, :only, :scope) + only ||= assign_deprecated_option(deprecated_options, :except, :scope) + assign_deprecated_options(deprecated_options, options, :scope) + end + scope = {} options[:path] = args.flatten.join("/") if args.any? @@ -826,9 +1013,8 @@ def scope(*args) block, options[:constraints] = options[:constraints], {} end - if options.key?(:only) || options.key?(:except) - scope[:action_options] = { only: options.delete(:only), - except: options.delete(:except) } + if only || except + scope[:action_options] = { only:, except: } end if options.key? :anchor @@ -860,9 +1046,9 @@ def scope(*args) # Scopes routes to a specific controller # - # controller "food" do - # match "bacon", action: :bacon, via: :get - # end + # controller "food" do + # match "bacon", action: :bacon, via: :get + # end def controller(controller) @scope = @scope.new(controller: controller) yield @@ -872,121 +1058,132 @@ def controller(controller) # Scopes routes to a specific namespace. For example: # - # namespace :admin do - # resources :posts - # end + # namespace :admin do + # resources :posts + # end # # This generates the following routes: # - # admin_posts GET /admin/posts(.:format) admin/posts#index - # admin_posts POST /admin/posts(.:format) admin/posts#create - # new_admin_post GET /admin/posts/new(.:format) admin/posts#new - # edit_admin_post GET /admin/posts/:id/edit(.:format) admin/posts#edit - # admin_post GET /admin/posts/:id(.:format) admin/posts#show - # admin_post PATCH/PUT /admin/posts/:id(.:format) admin/posts#update - # admin_post DELETE /admin/posts/:id(.:format) admin/posts#destroy - # - # === Options - # - # The +:path+, +:as+, +:module+, +:shallow_path+ and +:shallow_prefix+ - # options all default to the name of the namespace. - # - # For options, see Base#match. For +:shallow_path+ option, see - # Resources#resources. - # - # # accessible through /sekret/posts rather than /admin/posts - # namespace :admin, path: "sekret" do - # resources :posts - # end - # - # # maps to Sekret::PostsController rather than Admin::PostsController - # namespace :admin, module: "sekret" do - # resources :posts - # end - # - # # generates +sekret_posts_path+ rather than +admin_posts_path+ - # namespace :admin, as: "sekret" do - # resources :posts - # end - def namespace(path, options = {}) - path = path.to_s - - defaults = { - module: path, - as: options.fetch(:as, path), - shallow_path: options.fetch(:path, path), - shallow_prefix: options.fetch(:as, path) - } + # admin_posts GET /admin/posts(.:format) admin/posts#index + # admin_posts POST /admin/posts(.:format) admin/posts#create + # new_admin_post GET /admin/posts/new(.:format) admin/posts#new + # edit_admin_post GET /admin/posts/:id/edit(.:format) admin/posts#edit + # admin_post GET /admin/posts/:id(.:format) admin/posts#show + # admin_post PATCH/PUT /admin/posts/:id(.:format) admin/posts#update + # admin_post DELETE /admin/posts/:id(.:format) admin/posts#destroy + # + # ### Options + # + # The `:path`, `:as`, `:module`, `:shallow_path`, and `:shallow_prefix` options + # all default to the name of the namespace. + # + # For options, see `Base#match`. For `:shallow_path` option, see + # `Resources#resources`. + # + # # accessible through /sekret/posts rather than /admin/posts + # namespace :admin, path: "sekret" do + # resources :posts + # end + # + # # maps to Sekret::PostsController rather than Admin::PostsController + # namespace :admin, module: "sekret" do + # resources :posts + # end + # + # # generates sekret_posts_path rather than admin_posts_path + # namespace :admin, as: "sekret" do + # resources :posts + # end + def namespace(name, deprecated_options = nil, as: DEFAULT, path: DEFAULT, shallow_path: DEFAULT, shallow_prefix: DEFAULT, **options, &block) + if deprecated_options.is_a?(Hash) + as = assign_deprecated_option(deprecated_options, :as, :namespace) if deprecated_options.key?(:as) + path ||= assign_deprecated_option(deprecated_options, :path, :namespace) if deprecated_options.key?(:path) + shallow_path ||= assign_deprecated_option(deprecated_options, :shallow_path, :namespace) if deprecated_options.key?(:shallow_path) + shallow_prefix ||= assign_deprecated_option(deprecated_options, :shallow_prefix, :namespace) if deprecated_options.key?(:shallow_prefix) + assign_deprecated_options(deprecated_options, options, :namespace) + end - path_scope(options.delete(:path) { path }) do - scope(defaults.merge!(options)) { yield } + name = name.to_s + options[:module] ||= name + as = name if as == DEFAULT + path = name if path == DEFAULT + shallow_path = path if shallow_path == DEFAULT + shallow_prefix = as if shallow_prefix == DEFAULT + + path_scope(path) do + scope(**options, as:, shallow_path:, shallow_prefix:, &block) end end - # === Parameter Restriction - # Allows you to constrain the nested routes based on a set of rules. - # For instance, in order to change the routes to allow for a dot character in the +id+ parameter: + # ### Parameter Restriction + # Allows you to constrain the nested routes based on a set of rules. For + # instance, in order to change the routes to allow for a dot character in the + # `id` parameter: # - # constraints(id: /\d+\.\d+/) do - # resources :posts - # end + # constraints(id: /\d+\.\d+/) do + # resources :posts + # end # - # Now routes such as +/posts/1+ will no longer be valid, but +/posts/1.1+ will be. - # The +id+ parameter must match the constraint passed in for this example. + # Now routes such as `/posts/1` will no longer be valid, but `/posts/1.1` will + # be. The `id` parameter must match the constraint passed in for this example. # # You may use this to also restrict other parameters: # - # resources :posts do - # constraints(post_id: /\d+\.\d+/) do - # resources :comments + # resources :posts do + # constraints(post_id: /\d+\.\d+/) do + # resources :comments + # end # end - # end # - # === Restricting based on IP + # ### Restricting based on IP # # Routes can also be constrained to an IP or a certain range of IP addresses: # - # constraints(ip: /192\.168\.\d+\.\d+/) do - # resources :posts - # end + # constraints(ip: /192\.168\.\d+\.\d+/) do + # resources :posts + # end # - # Any user connecting from the 192.168.* range will be able to see this resource, - # where as any user connecting outside of this range will be told there is no such route. + # Any user connecting from the 192.168.* range will be able to see this + # resource, where as any user connecting outside of this range will be told + # there is no such route. # - # === Dynamic request matching + # ### Dynamic request matching # # Requests to routes can be constrained based on specific criteria: # - # constraints(-> (req) { req.env["HTTP_USER_AGENT"] =~ /iPhone/ }) do - # resources :iphones - # end + # constraints(-> (req) { /iPhone/.match?(req.env["HTTP_USER_AGENT"]) }) do + # resources :iphones + # end # - # You are able to move this logic out into a class if it is too complex for routes. - # This class must have a +matches?+ method defined on it which either returns +true+ - # if the user should be given access to that route, or +false+ if the user should not. + # You are able to move this logic out into a class if it is too complex for + # routes. This class must have a `matches?` method defined on it which either + # returns `true` if the user should be given access to that route, or `false` if + # the user should not. # - # class Iphone - # def self.matches?(request) - # request.env["HTTP_USER_AGENT"] =~ /iPhone/ - # end - # end + # class Iphone + # def self.matches?(request) + # /iPhone/.match?(request.env["HTTP_USER_AGENT"]) + # end + # end # - # An expected place for this code would be +lib/constraints+. + # An expected place for this code would be `lib/constraints`. # # This class is then used like this: # - # constraints(Iphone) do - # resources :iphones - # end - def constraints(constraints = {}) - scope(constraints: constraints) { yield } + # constraints(Iphone) do + # resources :iphones + # end + def constraints(constraints = {}, &block) + scope(constraints: constraints, &block) end # Allows you to set default parameters for a route, such as this: - # defaults id: 'home' do - # match 'scoped_pages/(:id)', to: 'pages#show' - # end - # Using this, the +:id+ parameter here will default to 'home'. + # + # defaults id: 'home' do + # match 'scoped_pages/(:id)', to: 'pages#show' + # end + # + # Using this, the `:id` parameter here will default to 'home'. def defaults(defaults = {}) @scope = @scope.new(defaults: merge_defaults_scope(@scope[:defaults], defaults)) yield @@ -1062,56 +1259,74 @@ def merge_to_scope(parent, child) end end - # Resource routing allows you to quickly declare all of the common routes - # for a given resourceful controller. Instead of declaring separate routes - # for your +index+, +show+, +new+, +edit+, +create+, +update+ and +destroy+ - # actions, a resourceful route declares them in a single line of code: + # Resource routing allows you to quickly declare all of the common routes for a + # given resourceful controller. Instead of declaring separate routes for your + # `index`, `show`, `new`, `edit`, `create`, `update`, and `destroy` actions, a + # resourceful route declares them in a single line of code: # - # resources :photos + # resources :photos # - # Sometimes, you have a resource that clients always look up without - # referencing an ID. A common example, /profile always shows the profile of - # the currently logged in user. In this case, you can use a singular resource - # to map /profile (rather than /profile/:id) to the show action. + # Sometimes, you have a resource that clients always look up without referencing + # an ID. A common example, /profile always shows the profile of the currently + # logged in user. In this case, you can use a singular resource to map /profile + # (rather than /profile/:id) to the show action. # - # resource :profile + # resource :profile # - # It's common to have resources that are logically children of other - # resources: + # It's common to have resources that are logically children of other resources: # - # resources :magazines do - # resources :ads - # end + # resources :magazines do + # resources :ads + # end # # You may wish to organize groups of controllers under a namespace. Most - # commonly, you might group a number of administrative controllers under - # an +admin+ namespace. You would place these controllers under the - # app/controllers/admin directory, and you can group them together - # in your router: + # commonly, you might group a number of administrative controllers under an + # `admin` namespace. You would place these controllers under the + # `app/controllers/admin` directory, and you can group them together in your + # router: # - # namespace "admin" do - # resources :posts, :comments - # end + # namespace "admin" do + # resources :posts, :comments + # end # - # By default the +:id+ parameter doesn't accept dots. If you need to - # use dots as part of the +:id+ parameter add a constraint which - # overrides this restriction, e.g: + # By default the `:id` parameter doesn't accept dots. If you need to use dots as + # part of the `:id` parameter add a constraint which overrides this restriction, + # e.g: # - # resources :articles, id: /[^\/]+/ - # - # This allows any character other than a slash as part of your +:id+. + # resources :articles, id: /[^\/]+/ # + # This allows any character other than a slash as part of your `:id`. module Resources - # CANONICAL_ACTIONS holds all actions that does not need a prefix or - # a path appended since they fit properly in their scope level. + # CANONICAL_ACTIONS holds all actions that does not need a prefix or a path + # appended since they fit properly in their scope level. VALID_ON_OPTIONS = [:new, :collection, :member] RESOURCE_OPTIONS = [:as, :controller, :path, :only, :except, :param, :concerns] CANONICAL_ACTIONS = %w(index create new show update destroy) - class Resource #:nodoc: + class Resource # :nodoc: + class << self + def default_actions(api_only) + if api_only + [:index, :create, :show, :update, :destroy] + else + [:index, :create, :new, :show, :update, :destroy, :edit] + end + end + end + attr_reader :controller, :path, :param - def initialize(entities, api_only, shallow, options = {}) + def initialize(entities, api_only, shallow, only: nil, except: nil, **options) + if options[:param].to_s.include?(":") + raise ArgumentError, ":param option can't contain colons" + end + + valid_actions = self.class.default_actions(false) # ignore api_only for this validation + if (invalid_actions = invalid_only_except_options(valid_actions, only:, except:).presence) + error_prefix = "Route `resource#{"s" unless singleton?} :#{entities}`" + raise ArgumentError, "#{error_prefix} - :only and :except must include only #{valid_actions}, but also included #{invalid_actions}" + end + @name = entities.to_s @path = (options[:path] || @name).to_s @controller = (options[:controller] || @name).to_s @@ -1120,23 +1335,25 @@ def initialize(entities, api_only, shallow, options = {}) @options = options @shallow = shallow @api_only = api_only - @only = options.delete :only - @except = options.delete :except + @only = only + @except = except end def default_actions - if @api_only - [:index, :create, :show, :update, :destroy] + self.class.default_actions(@api_only) + end + + def actions + if @except + available_actions - Array(@except).map(&:to_sym) else - [:index, :create, :new, :show, :update, :destroy, :edit] + available_actions end end - def actions + def available_actions if @only Array(@only).map(&:to_sym) - elsif @except - default_actions - Array(@except).map(&:to_sym) else default_actions end @@ -1156,8 +1373,8 @@ def singular alias :member_name :singular - # Checks for uncountable plurals, and appends "_index" if the plural - # and singular form are the same. + # Checks for uncountable plurals, and appends "_index" if the plural and + # singular form are the same. def collection_name singular == plural ? "#{plural}_index" : plural end @@ -1191,10 +1408,25 @@ def shallow? end def singleton?; false; end + + private + def invalid_only_except_options(valid_actions, only:, except:) + [only, except].flatten.compact.uniq.map(&:to_sym) - valid_actions + end end - class SingletonResource < Resource #:nodoc: - def initialize(entities, api_only, shallow, options) + class SingletonResource < Resource # :nodoc: + class << self + def default_actions(api_only) + if api_only + [:show, :create, :update, :destroy] + else + [:show, :create, :update, :destroy, :new, :edit] + end + end + end + + def initialize(entities, api_only, shallow, **options) super @as = nil @controller = (options[:controller] || plural).to_s @@ -1202,11 +1434,7 @@ def initialize(entities, api_only, shallow, options) end def default_actions - if @api_only - [:show, :create, :update, :destroy] - else - [:show, :create, :update, :destroy, :new, :edit] - end + self.class.default_actions(@api_only) end def plural @@ -1230,40 +1458,51 @@ def resources_path_names(options) @scope[:path_names].merge!(options) end - # Sometimes, you have a resource that clients always look up without - # referencing an ID. A common example, /profile always shows the - # profile of the currently logged in user. In this case, you can use - # a singular resource to map /profile (rather than /profile/:id) to - # the show action: + # Sometimes, you have a resource that clients always look up without referencing + # an ID. A common example, /profile always shows the profile of the currently + # logged in user. In this case, you can use a singular resource to map /profile + # (rather than /profile/:id) to the show action: + # + # resource :profile + # + # This creates six different routes in your application, all mapping to the + # `Profiles` controller (note that the controller is named after the plural): + # + # GET /profile/new + # GET /profile + # GET /profile/edit + # PATCH/PUT /profile + # DELETE /profile + # POST /profile # - # resource :profile + # If you want instances of a model to work with this resource via record + # identification (e.g. in `form_with` or `redirect_to`), you will need to call + # [resolve](rdoc-ref:CustomUrls#resolve): # - # creates six different routes in your application, all mapping to - # the +Profiles+ controller (note that the controller is named after - # the plural): + # resource :profile + # resolve('Profile') { [:profile] } # - # GET /profile/new - # GET /profile - # GET /profile/edit - # PATCH/PUT /profile - # DELETE /profile - # POST /profile + # # Enables this to work with singular routes: + # form_with(model: @profile) {} # - # === Options - # Takes same options as +resources+. - def resource(*resources, &block) - options = resources.extract_options!.dup + # ### Options + # Takes same options as [resources](rdoc-ref:#resources) + def resource(*resources, concerns: nil, **options, &block) + if resources.grep(Hash).any? && (deprecated_options = resources.extract_options!) + concerns = assign_deprecated_option(deprecated_options, :concerns, :resource) if deprecated_options.key?(:concerns) + assign_deprecated_options(deprecated_options, options, :resource) + end - if apply_common_behavior_for(:resource, resources, options, &block) + if apply_common_behavior_for(:resource, resources, concerns:, **options, &block) return self end with_scope_level(:resource) do - options = apply_action_options options - resource_scope(SingletonResource.new(resources.pop, api_only?, @scope[:shallow], options)) do + options = apply_action_options :resource, options + resource_scope(SingletonResource.new(resources.pop, api_only?, @scope[:shallow], **options)) do yield if block_given? - concerns(options[:concerns]) if options[:concerns] + concerns(*concerns) if concerns new do get :new @@ -1280,151 +1519,163 @@ def resource(*resources, &block) self end - # In Rails, a resourceful route provides a mapping between HTTP verbs - # and URLs and controller actions. By convention, each action also maps - # to particular CRUD operations in a database. A single entry in the - # routing file, such as + # In Rails, a resourceful route provides a mapping between HTTP verbs and URLs + # and controller actions. By convention, each action also maps to particular + # CRUD operations in a database. A single entry in the routing file, such as # - # resources :photos + # resources :photos # - # creates seven different routes in your application, all mapping to - # the +Photos+ controller: + # creates seven different routes in your application, all mapping to the + # `Photos` controller: # - # GET /photos - # GET /photos/new - # POST /photos - # GET /photos/:id - # GET /photos/:id/edit - # PATCH/PUT /photos/:id - # DELETE /photos/:id + # GET /photos + # GET /photos/new + # POST /photos + # GET /photos/:id + # GET /photos/:id/edit + # PATCH/PUT /photos/:id + # DELETE /photos/:id # # Resources can also be nested infinitely by using this block syntax: # - # resources :photos do - # resources :comments - # end + # resources :photos do + # resources :comments + # end # # This generates the following comments routes: # - # GET /photos/:photo_id/comments - # GET /photos/:photo_id/comments/new - # POST /photos/:photo_id/comments - # GET /photos/:photo_id/comments/:id - # GET /photos/:photo_id/comments/:id/edit - # PATCH/PUT /photos/:photo_id/comments/:id - # DELETE /photos/:photo_id/comments/:id + # GET /photos/:photo_id/comments + # GET /photos/:photo_id/comments/new + # POST /photos/:photo_id/comments + # GET /photos/:photo_id/comments/:id + # GET /photos/:photo_id/comments/:id/edit + # PATCH/PUT /photos/:photo_id/comments/:id + # DELETE /photos/:photo_id/comments/:id # - # === Options - # Takes same options as Base#match as well as: + # ### Options + # Takes same options as [match](rdoc-ref:Base#match) as well as: # - # [:path_names] - # Allows you to change the segment component of the +edit+ and +new+ actions. - # Actions not specified are not changed. + # :path_names + # : Allows you to change the segment component of the `edit` and `new` + # actions. Actions not specified are not changed. # - # resources :posts, path_names: { new: "brand_new" } + # resources :posts, path_names: { new: "brand_new" } # - # The above example will now change /posts/new to /posts/brand_new + # The above example will now change /posts/new to /posts/brand_new. # - # [:path] - # Allows you to change the path prefix for the resource. + # :path + # : Allows you to change the path prefix for the resource. # - # resources :posts, path: 'postings' + # resources :posts, path: 'postings' # - # The resource and all segments will now route to /postings instead of /posts + # The resource and all segments will now route to /postings instead of + # /posts. # - # [:only] - # Only generate routes for the given actions. + # :only + # : Only generate routes for the given actions. # - # resources :cows, only: :show - # resources :cows, only: [:show, :index] + # resources :cows, only: :show + # resources :cows, only: [:show, :index] # - # [:except] - # Generate all routes except for the given actions. + # :except + # : Generate all routes except for the given actions. # - # resources :cows, except: :show - # resources :cows, except: [:show, :index] + # resources :cows, except: :show + # resources :cows, except: [:show, :index] # - # [:shallow] - # Generates shallow routes for nested resource(s). When placed on a parent resource, - # generates shallow routes for all nested resources. + # :shallow + # : Generates shallow routes for nested resource(s). When placed on a parent + # resource, generates shallow routes for all nested resources. # - # resources :posts, shallow: true do - # resources :comments - # end + # resources :posts, shallow: true do + # resources :comments + # end # - # Is the same as: + # Is the same as: # - # resources :posts do - # resources :comments, except: [:show, :edit, :update, :destroy] - # end - # resources :comments, only: [:show, :edit, :update, :destroy] + # resources :posts do + # resources :comments, except: [:show, :edit, :update, :destroy] + # end + # resources :comments, only: [:show, :edit, :update, :destroy] # - # This allows URLs for resources that otherwise would be deeply nested such - # as a comment on a blog post like /posts/a-long-permalink/comments/1234 - # to be shortened to just /comments/1234. + # This allows URLs for resources that otherwise would be deeply nested such + # as a comment on a blog post like `/posts/a-long-permalink/comments/1234` + # to be shortened to just `/comments/1234`. # - # [:shallow_path] - # Prefixes nested shallow routes with the specified path. + # Set `shallow: false` on a child resource to ignore a parent's shallow + # parameter. # - # scope shallow_path: "sekret" do - # resources :posts do - # resources :comments, shallow: true - # end - # end + # :shallow_path + # : Prefixes nested shallow routes with the specified path. # - # The +comments+ resource here will have the following routes generated for it: + # scope shallow_path: "sekret" do + # resources :posts do + # resources :comments, shallow: true + # end + # end # - # post_comments GET /posts/:post_id/comments(.:format) - # post_comments POST /posts/:post_id/comments(.:format) - # new_post_comment GET /posts/:post_id/comments/new(.:format) - # edit_comment GET /sekret/comments/:id/edit(.:format) - # comment GET /sekret/comments/:id(.:format) - # comment PATCH/PUT /sekret/comments/:id(.:format) - # comment DELETE /sekret/comments/:id(.:format) + # The `comments` resource here will have the following routes generated for + # it: # - # [:shallow_prefix] - # Prefixes nested shallow route names with specified prefix. + # post_comments GET /posts/:post_id/comments(.:format) + # post_comments POST /posts/:post_id/comments(.:format) + # new_post_comment GET /posts/:post_id/comments/new(.:format) + # edit_comment GET /sekret/comments/:id/edit(.:format) + # comment GET /sekret/comments/:id(.:format) + # comment PATCH/PUT /sekret/comments/:id(.:format) + # comment DELETE /sekret/comments/:id(.:format) # - # scope shallow_prefix: "sekret" do - # resources :posts do - # resources :comments, shallow: true - # end - # end + # :shallow_prefix + # : Prefixes nested shallow route names with specified prefix. + # + # scope shallow_prefix: "sekret" do + # resources :posts do + # resources :comments, shallow: true + # end + # end + # + # The `comments` resource here will have the following routes generated for + # it: # - # The +comments+ resource here will have the following routes generated for it: + # post_comments GET /posts/:post_id/comments(.:format) + # post_comments POST /posts/:post_id/comments(.:format) + # new_post_comment GET /posts/:post_id/comments/new(.:format) + # edit_sekret_comment GET /comments/:id/edit(.:format) + # sekret_comment GET /comments/:id(.:format) + # sekret_comment PATCH/PUT /comments/:id(.:format) + # sekret_comment DELETE /comments/:id(.:format) # - # post_comments GET /posts/:post_id/comments(.:format) - # post_comments POST /posts/:post_id/comments(.:format) - # new_post_comment GET /posts/:post_id/comments/new(.:format) - # edit_sekret_comment GET /comments/:id/edit(.:format) - # sekret_comment GET /comments/:id(.:format) - # sekret_comment PATCH/PUT /comments/:id(.:format) - # sekret_comment DELETE /comments/:id(.:format) + # :format + # : Allows you to specify the default value for optional `format` segment or + # disable it by supplying `false`. # - # [:format] - # Allows you to specify the default value for optional +format+ - # segment or disable it by supplying +false+. + # :param + # : Allows you to override the default param name of `:id` in the URL. # - # === Examples # - # # routes call Admin::PostsController - # resources :posts, module: "admin" + # ### Examples # - # # resource actions are at /admin/posts. - # resources :posts, path: "admin/posts" - def resources(*resources, &block) - options = resources.extract_options!.dup + # # routes call Admin::PostsController + # resources :posts, module: "admin" + # + # # resource actions are at /admin/posts. + # resources :posts, path: "admin/posts" + def resources(*resources, concerns: nil, **options, &block) + if resources.grep(Hash).any? && (deprecated_options = resources.extract_options!) + concerns = assign_deprecated_option(deprecated_options, :concerns, :resources) if deprecated_options.key?(:concerns) + assign_deprecated_options(deprecated_options, options, :resources) + end - if apply_common_behavior_for(:resources, resources, options, &block) + if apply_common_behavior_for(:resources, resources, concerns:, **options, &block) return self end with_scope_level(:resources) do - options = apply_action_options options - resource_scope(Resource.new(resources.pop, api_only?, @scope[:shallow], options)) do + options = apply_action_options :resources, options + resource_scope(Resource.new(resources.pop, api_only?, @scope[:shallow], **options)) do yield if block_given? - concerns(options[:concerns]) if options[:concerns] + concerns(*concerns) if concerns collection do get :index if parent_resource.actions.include?(:index) @@ -1444,40 +1695,37 @@ def resources(*resources, &block) # To add a route to the collection: # - # resources :photos do - # collection do - # get 'search' + # resources :photos do + # collection do + # get 'search' + # end # end - # end # - # This will enable Rails to recognize paths such as /photos/search - # with GET, and route to the search action of +PhotosController+. It will also - # create the search_photos_url and search_photos_path - # route helpers. - def collection + # This will enable Rails to recognize paths such as `/photos/search` with GET, + # and route to the search action of `PhotosController`. It will also create the + # `search_photos_url` and `search_photos_path` route helpers. + def collection(&block) unless resource_scope? raise ArgumentError, "can't use collection outside resource(s) scope" end with_scope_level(:collection) do - path_scope(parent_resource.collection_scope) do - yield - end + path_scope(parent_resource.collection_scope, &block) end end # To add a member route, add a member block into the resource block: # - # resources :photos do - # member do - # get 'preview' + # resources :photos do + # member do + # get 'preview' + # end # end - # end # - # This will recognize /photos/1/preview with GET, and route to the - # preview action of +PhotosController+. It will also create the - # preview_photo_url and preview_photo_path helpers. - def member + # This will recognize `/photos/1/preview` with GET, and route to the preview + # action of `PhotosController`. It will also create the `preview_photo_url` and + # `preview_photo_path` helpers. + def member(&block) unless resource_scope? raise ArgumentError, "can't use member outside resource(s) scope" end @@ -1485,27 +1733,25 @@ def member with_scope_level(:member) do if shallow? shallow_scope { - path_scope(parent_resource.member_scope) { yield } + path_scope(parent_resource.member_scope, &block) } else - path_scope(parent_resource.member_scope) { yield } + path_scope(parent_resource.member_scope, &block) end end end - def new + def new(&block) unless resource_scope? raise ArgumentError, "can't use new outside resource(s) scope" end with_scope_level(:new) do - path_scope(parent_resource.new_scope(action_path(:new))) do - yield - end + path_scope(parent_resource.new_scope(action_path(:new)), &block) end end - def nested + def nested(&block) unless resource_scope? raise ArgumentError, "can't use nested outside resource(s) scope" end @@ -1514,19 +1760,19 @@ def nested if shallow? && shallow_nesting_depth >= 1 shallow_scope do path_scope(parent_resource.nested_scope) do - scope(nested_options) { yield } + scope(**nested_options, &block) end end else path_scope(parent_resource.nested_scope) do - scope(nested_options) { yield } + scope(**nested_options, &block) end end end end - # See ActionDispatch::Routing::Mapper::Scoping#namespace - def namespace(path, options = {}) + # See ActionDispatch::Routing::Mapper::Scoping#namespace. + def namespace(name, deprecated_options = nil, as: DEFAULT, path: DEFAULT, shallow_path: DEFAULT, shallow_prefix: DEFAULT, **options, &block) if resource_scope? nested { super } else @@ -1545,59 +1791,115 @@ def shallow? !parent_resource.singleton? && @scope[:shallow] end - # Matches a url pattern to one or more routes. - # For more information, see match[rdoc-ref:Base#match]. + # Loads another routes file with the given `name` located inside the + # `config/routes` directory. In that file, you can use the normal routing DSL, + # but *do not* surround it with a `Rails.application.routes.draw` block. # - # match 'path' => 'controller#action', via: patch - # match 'path', to: 'controller#action', via: :post - # match 'path', 'otherpath', on: :member, via: :get - def match(path, *rest, &block) - if rest.empty? && Hash === path - options = path - path, to = options.find { |name, _value| name.is_a?(String) } + # # config/routes.rb + # Rails.application.routes.draw do + # draw :admin # Loads `config/routes/admin.rb` + # draw "third_party/some_gem" # Loads `config/routes/third_party/some_gem.rb` + # end + # + # # config/routes/admin.rb + # namespace :admin do + # resources :accounts + # end + # + # # config/routes/third_party/some_gem.rb + # mount SomeGem::Engine, at: "/some_gem" + # + # **CAUTION:** Use this feature with care. Having multiple routes files can + # negatively impact discoverability and readability. For most applications — + # even those with a few hundred routes — it's easier for developers to have a + # single routes file. + def draw(name) + path = @draw_paths.find do |_path| + File.exist? "#{_path}/#{name}.rb" + end + + unless path + msg = "Your router tried to #draw the external file #{name}.rb,\n" \ + "but the file was not found in:\n\n" + msg += @draw_paths.map { |_path| " * #{_path}" }.join("\n") + raise ArgumentError, msg + end - raise ArgumentError, "Route path not specified" if path.nil? + route_path = "#{path}/#{name}.rb" + instance_eval(File.read(route_path), route_path.to_s) + end - case to - when Symbol - options[:action] = to - when String - if to =~ /#/ - options[:to] = to - else - options[:controller] = to - end + # Matches a URL pattern to one or more routes. For more information, see + # [match](rdoc-ref:Base#match). + # + # match 'path', to: 'controller#action', via: :post + # 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) + via ||= assign_deprecated_option(deprecated_options, :via, :match) + to ||= assign_deprecated_option(deprecated_options, :to, :match) + controller ||= assign_deprecated_option(deprecated_options, :controller, :match) + action ||= assign_deprecated_option(deprecated_options, :action, :match) + on ||= assign_deprecated_option(deprecated_options, :on, :match) + defaults ||= assign_deprecated_option(deprecated_options, :defaults, :match) + constraints ||= assign_deprecated_option(deprecated_options, :constraints, :match) + anchor = assign_deprecated_option(deprecated_options, :anchor, :match) if deprecated_options.key?(:anchor) + format = assign_deprecated_option(deprecated_options, :format, :match) if deprecated_options.key?(:format) + path ||= assign_deprecated_option(deprecated_options, :path, :match) + internal ||= assign_deprecated_option(deprecated_options, :internal, :match) + assign_deprecated_options(deprecated_options, mapping, :match) + end + + 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) } + if hash_path.nil? + raise ArgumentError, "Route path not specified" else - options[:to] = to + mapping.delete(hash_path) end - options.delete(path) - paths = [path] - else - options = rest.pop || {} - paths = [path] + rest + if hash_path + path_or_actions.push hash_path + case hash_to + when Symbol + action ||= hash_to + when String + if hash_to.include?("#") + to ||= hash_to + else + controller ||= hash_to + end + else + to ||= hash_to + end + end end - if options.key?(:defaults) - defaults(options.delete(:defaults)) { map_match(paths, options, &block) } - else - map_match(paths, options, &block) + path_or_actions.each do |path_or_action| + if defaults + defaults(defaults) { map_match(path_or_action, as:, via:, to:, controller:, action:, on:, constraints:, anchor:, format:, path:, internal:, mapping:, &block) } + else + map_match(path_or_action, as:, via:, to:, controller:, action:, on:, constraints:, anchor:, format:, path:, internal:, mapping:, &block) + end end end # You can specify what Rails should route "/" to with the root method: # - # root to: 'pages#main' + # root to: 'pages#main' # - # For options, see +match+, as +root+ uses it internally. + # For options, see `match`, as `root` uses it internally. # # You can also pass a string which will expand # - # root 'pages#main' + # root 'pages#main' # - # You should put the root route at the top of config/routes.rb, - # because this means it will be matched first. As this is the most popular route - # of most Rails applications, this is beneficial. + # You should put the root route at the top of `config/routes.rb`, because this + # means it will be matched first. As this is the most popular route of most + # Rails applications, this is beneficial. def root(path, options = {}) if path.is_a?(String) options[:to] = path @@ -1619,26 +1921,25 @@ def root(path, options = {}) end private - def parent_resource @scope[:scope_level_resource] end - def apply_common_behavior_for(method, resources, options, &block) + def apply_common_behavior_for(method, resources, shallow: nil, **options, &block) if resources.length > 1 - resources.each { |r| send(method, r, options, &block) } + resources.each { |r| public_send(method, r, shallow:, **options, &block) } return true end - if options.delete(:shallow) - shallow do - send(method, resources.pop, options, &block) + if shallow + self.shallow do + public_send(method, resources.pop, **options, &block) end return true end if resource_scope? - nested { send(method, resources.pop, options, &block) } + nested { public_send(method, resources.pop, shallow:, **options, &block) } return true end @@ -1647,9 +1948,11 @@ def apply_common_behavior_for(method, resources, options, &block) end scope_options = options.slice!(*RESOURCE_OPTIONS) + scope_options[:shallow] = shallow unless shallow.nil? + unless scope_options.empty? - scope(scope_options) do - send(method, resources.pop, options, &block) + scope(**scope_options) do + public_send(method, resources.pop, **options, &block) end return true end @@ -1657,17 +1960,32 @@ def apply_common_behavior_for(method, resources, options, &block) false end - def apply_action_options(options) + def apply_action_options(method, options) return options if action_options? options - options.merge scope_action_options + options.merge scope_action_options(method) end def action_options?(options) options[:only] || options[:except] end - def scope_action_options - @scope[:action_options] || {} + def scope_action_options(method) + return {} unless @scope[:action_options] + + actions = applicable_actions_for(method) + @scope[:action_options].dup.tap do |options| + (options[:only] = Array(options[:only]) & actions) if options[:only] + (options[:except] = Array(options[:except]) & actions) if options[:except] + end + end + + def applicable_actions_for(method) + case method + when :resource + SingletonResource.default_actions(api_only?) + when :resources + Resource.default_actions(api_only?) + end end def resource_scope? @@ -1689,10 +2007,10 @@ def with_scope_level(kind) # :doc: @scope = @scope.parent end - def resource_scope(resource) + def resource_scope(resource, &block) @scope = @scope.new(scope_level_resource: resource) - controller(resource.resource_scope) { yield } + controller(resource.resource_scope, &block) ensure @scope = @scope.parent end @@ -1725,9 +2043,10 @@ def canonical_action?(action) end def shallow_scope - scope = { as: @scope[:shallow_prefix], - path: @scope[:shallow_path] } - @scope = @scope.new scope + @scope = @scope.new( + as: @scope[:shallow_prefix], + path: @scope[:shallow_path], + ) yield ensure @@ -1749,7 +2068,7 @@ def action_path(name) end def prefix_name_for_action(as, action) - if as + if as && as != DEFAULT prefix = as elsif !canonical_action?(action) prefix = action @@ -1765,7 +2084,7 @@ def name_for_action(as, action) name_prefix = @scope[:as] if parent_resource - return nil unless as || action + return nil unless as != DEFAULT || action collection_name = parent_resource.collection_name member_name = parent_resource.member_name @@ -1775,11 +2094,11 @@ def name_for_action(as, action) candidate = action_name.select(&:present?).join("_") unless candidate.empty? - # If a name was not explicitly given, we check if it is valid - # and return nil in case it isn't. Otherwise, we pass the invalid name - # forward so the underlying router engine treats it and raises an exception. - if as.nil? - candidate unless candidate !~ /\A[_a-z]/i || has_named_route?(candidate) + # If a name was not explicitly given, we check if it is valid and return nil in + # case it isn't. Otherwise, we pass the invalid name forward so the underlying + # router engine treats it and raises an exception. + if as == DEFAULT + candidate unless !candidate.match?(/\A[_a-z]/i) || has_named_route?(candidate) else candidate end @@ -1809,42 +2128,35 @@ def path_scope(path) @scope = @scope.parent end - def map_match(paths, options) - if options[:on] && !VALID_ON_OPTIONS.include?(options[:on]) + def map_match(path_or_action, constraints: nil, anchor: nil, format: nil, path: nil, as: DEFAULT, via: nil, to: nil, controller: nil, action: nil, on: nil, internal: nil, mapping: nil) + if on && !VALID_ON_OPTIONS.include?(on) raise ArgumentError, "Unknown scope #{on.inspect} given to :on" end if @scope[:to] - options[:to] ||= @scope[:to] + to ||= @scope[:to] end if @scope[:controller] && @scope[:action] - options[:to] ||= "#{@scope[:controller]}##{@scope[:action]}" + to ||= "#{@scope[:controller]}##{@scope[:action]}" end - controller = options.delete(:controller) || @scope[:controller] - option_path = options.delete :path - to = options.delete :to - via = Mapping.check_via Array(options.delete(:via) { - @scope[:via] - }) - formatted = options.delete(:format) { @scope[:format] } - anchor = options.delete(:anchor) { true } - options_constraints = options.delete(:constraints) || {} - - path_types = paths.group_by(&:class) - path_types.fetch(String, []).each do |_path| - route_options = options.dup - if _path && option_path - raise ArgumentError, "Ambigous route definition. Both :path and the route path where specified as strings." - end - to = get_to_from_path(_path, to, route_options[:action]) - decomposed_match(_path, controller, route_options, _path, to, via, formatted, anchor, options_constraints) - end + controller ||= @scope[:controller] + via = Mapping.check_via Array(via || @scope[:via]) + format ||= @scope[:format] if format.nil? + anchor ||= true if anchor.nil? + constraints ||= {} - path_types.fetch(Symbol, []).each do |action| - route_options = options.dup - decomposed_match(action, controller, route_options, option_path, to, via, formatted, anchor, options_constraints) + case path_or_action + when String + if path_or_action && path + raise ArgumentError, "Ambiguous route definition. Both :path and the route path were specified as strings." + end + path = path_or_action + to = get_to_from_path(path_or_action, to, action) + decomposed_match(path, controller, as, action, path, to, via, format, anchor, constraints, internal, mapping, on) + when Symbol + decomposed_match(path_or_action, controller, as, action, path, to, via, format, anchor, constraints, internal, mapping, on) end self @@ -1855,143 +2167,133 @@ def get_to_from_path(path, to, action) path_without_format = path.sub(/\(\.:format\)$/, "") if using_match_shorthand?(path_without_format) - path_without_format.gsub(%r{^/}, "").sub(%r{/([^/]*)$}, '#\1').tr("-", "_") + path_without_format.delete_prefix("/").sub(%r{/([^/]*)$}, '#\1').tr("-", "_") else nil end end def using_match_shorthand?(path) - path =~ %r{^/?[-\w]+/[-\w/]+$} + %r{^/?[-\w]+/[-\w/]+$}.match?(path) end - def decomposed_match(path, controller, options, _path, to, via, formatted, anchor, options_constraints) - if on = options.delete(:on) - send(on) { decomposed_match(path, controller, options, _path, to, via, formatted, anchor, options_constraints) } + def decomposed_match(path, controller, as, action, _path, to, via, formatted, anchor, options_constraints, internal, options_mapping, on = nil) + if on + send(on) { decomposed_match(path, controller, as, action, _path, to, via, formatted, anchor, options_constraints, internal, options_mapping) } else case @scope.scope_level when :resources - nested { decomposed_match(path, controller, options, _path, to, via, formatted, anchor, options_constraints) } + nested { decomposed_match(path, controller, as, action, _path, to, via, formatted, anchor, options_constraints, internal, options_mapping) } when :resource - member { decomposed_match(path, controller, options, _path, to, via, formatted, anchor, options_constraints) } + member { decomposed_match(path, controller, as, action, _path, to, via, formatted, anchor, options_constraints, internal, options_mapping) } else - add_route(path, controller, options, _path, to, via, formatted, anchor, options_constraints) + add_route(path, controller, as, action, _path, to, via, formatted, anchor, options_constraints, internal, options_mapping) end end end - def add_route(action, controller, options, _path, to, via, formatted, anchor, options_constraints) + def add_route(action, controller, as, options_action, _path, to, via, formatted, anchor, options_constraints, internal, options_mapping) path = path_for_action(action, _path) raise ArgumentError, "path is required" if path.blank? action = action.to_s - default_action = options.delete(:action) || @scope[:action] + default_action = options_action || @scope[:action] - if action =~ /^[\w\-\/]+$/ + if /^[\w\-\/]+$/.match?(action) default_action ||= action.tr("-", "_") unless action.include?("/") else action = nil end - as = if !options.fetch(:as, true) # if it's set to nil or false - options.delete(:as) - else - name_for_action(options.delete(:as), action) - end - - path = Mapping.normalize_path URI.parser.escape(path), formatted - ast = Journey::Parser.parse path + as = name_for_action(as, action) if as + path = Mapping.normalize_path URI::RFC2396_PARSER.escape(path), formatted + ast = Journey::Parser.parse path - mapping = Mapping.build(@scope, @set, ast, controller, default_action, to, via, formatted, options_constraints, anchor, options) - @set.add_route(mapping, ast, as, anchor) + mapping = Mapping.build(@scope, @set, ast, controller, default_action, to, via, formatted, options_constraints, anchor, internal, options_mapping) + @set.add_route(mapping, as) end def match_root_route(options) - name = has_named_route?(name_for_action(:root, nil)) ? nil : :root - args = ["/", { as: name, via: :get }.merge!(options)] - - match(*args) + match("/", as: :root, via: :get, **options) end end - # Routing Concerns allow you to declare common routes that can be reused - # inside others resources and routes. + # Routing Concerns allow you to declare common routes that can be reused inside + # others resources and routes. # - # concern :commentable do - # resources :comments - # end + # concern :commentable do + # resources :comments + # end # - # concern :image_attachable do - # resources :images, only: :index - # end + # concern :image_attachable do + # resources :images, only: :index + # end # # These concerns are used in Resources routing: # - # resources :messages, concerns: [:commentable, :image_attachable] + # resources :messages, concerns: [:commentable, :image_attachable] # # or in a scope or namespace: # - # namespace :posts do - # concerns :commentable - # end + # namespace :posts do + # concerns :commentable + # end module Concerns # Define a routing concern using a name. # - # Concerns may be defined inline, using a block, or handled by - # another object, by passing that object as the second parameter. + # Concerns may be defined inline, using a block, or handled by another object, + # by passing that object as the second parameter. # - # The concern object, if supplied, should respond to call, - # which will receive two parameters: + # The concern object, if supplied, should respond to `call`, which will receive + # two parameters: # - # * The current mapper - # * A hash of options which the concern object may use + # * The current mapper + # * A hash of options which the concern object may use # - # Options may also be used by concerns defined in a block by accepting - # a block parameter. So, using a block, you might do something as - # simple as limit the actions available on certain resources, passing - # standard resource options through the concern: + # Options may also be used by concerns defined in a block by accepting a block + # parameter. So, using a block, you might do something as simple as limit the + # actions available on certain resources, passing standard resource options + # through the concern: # - # concern :commentable do |options| - # resources :comments, options - # end + # concern :commentable do |options| + # resources :comments, options + # end # - # resources :posts, concerns: :commentable - # resources :archived_posts do - # # Don't allow comments on archived posts - # concerns :commentable, only: [:index, :show] - # end + # resources :posts, concerns: :commentable + # resources :archived_posts do + # # Don't allow comments on archived posts + # concerns :commentable, only: [:index, :show] + # end # - # Or, using a callable object, you might implement something more - # specific to your application, which would be out of place in your - # routes file. + # Or, using a callable object, you might implement something more specific to + # your application, which would be out of place in your routes file. # - # # purchasable.rb - # class Purchasable - # def initialize(defaults = {}) - # @defaults = defaults - # end + # # purchasable.rb + # class Purchasable + # def initialize(defaults = {}) + # @defaults = defaults + # end # - # def call(mapper, options = {}) - # options = @defaults.merge(options) - # mapper.resources :purchases - # mapper.resources :receipts - # mapper.resources :returns if options[:returnable] + # def call(mapper, options = {}) + # options = @defaults.merge(options) + # mapper.resources :purchases + # mapper.resources :receipts + # mapper.resources :returns if options[:returnable] + # end # end - # end # - # # routes.rb - # concern :purchasable, Purchasable.new(returnable: true) + # # routes.rb + # concern :purchasable, Purchasable.new(returnable: true) # - # resources :toys, concerns: :purchasable - # resources :electronics, concerns: :purchasable - # resources :pets do - # concerns :purchasable, returnable: false - # end + # resources :toys, concerns: :purchasable + # resources :electronics, concerns: :purchasable + # resources :pets do + # concerns :purchasable, returnable: false + # end # - # Any routing helpers can be used inside a concern. If using a - # callable, they're accessible from the Mapper that's passed to - # call. + # Any routing helpers can be used inside a concern. If using a callable, they're + # accessible from the Mapper that's passed to `call`. def concern(name, callable = nil, &block) callable ||= lambda { |mapper, options| mapper.instance_exec(options, &block) } @concerns[name] = callable @@ -1999,17 +2301,16 @@ def concern(name, callable = nil, &block) # Use the named concerns # - # resources :posts do - # concerns :commentable - # end + # resources :posts do + # concerns :commentable + # end # - # concerns also work in any routes helper that you want to use: + # Concerns also work in any routes helper that you want to use: # - # namespace :posts do - # concerns :commentable - # end - def concerns(*args) - options = args.extract_options! + # namespace :posts do + # concerns :commentable + # end + def concerns(*args, **options) args.flatten.each do |name| if concern = @concerns[name] concern.call(self, options) @@ -2020,6 +2321,122 @@ def concerns(*args) end end + module CustomUrls + # Define custom URL helpers that will be added to the application's routes. This + # allows you to override and/or replace the default behavior of routing helpers, + # e.g: + # + # direct :homepage do + # "https://rubyonrails.org" + # end + # + # direct :commentable do |model| + # [ model, anchor: model.dom_id ] + # end + # + # direct :main do + # { controller: "pages", action: "index", subdomain: "www" } + # end + # + # The return value from the block passed to `direct` must be a valid set of + # arguments for `url_for` which will actually build the URL string. This can be + # one of the following: + # + # * A string, which is treated as a generated URL + # * A hash, e.g. `{ controller: "pages", action: "index" }` + # * An array, which is passed to `polymorphic_url` + # * An Active Model instance + # * An Active Model class + # + # + # NOTE: Other URL helpers can be called in the block but be careful not to + # invoke your custom URL helper again otherwise it will result in a stack + # overflow error. + # + # You can also specify default options that will be passed through to your URL + # helper definition, e.g: + # + # direct :browse, page: 1, size: 10 do |options| + # [ :products, options.merge(params.permit(:page, :size).to_h.symbolize_keys) ] + # end + # + # In this instance the `params` object comes from the context in which the block + # is executed, e.g. generating a URL inside a controller action or a view. If + # the block is executed where there isn't a `params` object such as this: + # + # Rails.application.routes.url_helpers.browse_path + # + # then it will raise a `NameError`. Because of this you need to be aware of the + # context in which you will use your custom URL helper when defining it. + # + # NOTE: The `direct` method can't be used inside of a scope block such as + # `namespace` or `scope` and will raise an error if it detects that it is. + def direct(name, options = {}, &block) + unless @scope.root? + raise RuntimeError, "The direct method can't be used inside a routes scope block" + end + + @set.add_url_helper(name, options, &block) + end + + # Define custom polymorphic mappings of models to URLs. This alters the behavior + # of `polymorphic_url` and consequently the behavior of `link_to`, `form_with` + # and `form_for` when passed a model instance, e.g: + # + # resource :basket + # + # resolve "Basket" do + # [:basket] + # end + # + # This will now generate "/basket" when a `Basket` instance is passed to + # `link_to`, `form_with` or `form_for` instead of the standard "/baskets/:id". + # + # NOTE: This custom behavior only applies to simple polymorphic URLs where a + # single model instance is passed and not more complicated forms, e.g: + # + # # config/routes.rb + # resource :profile + # namespace :admin do + # resources :users + # end + # + # resolve("User") { [:profile] } + # + # # app/views/application/_menu.html.erb + # link_to "Profile", @current_user + # link_to "Profile", [:admin, @current_user] + # + # The first `link_to` will generate "/profile" but the second will generate the + # standard polymorphic URL of "/admin/users/1". + # + # You can pass options to a polymorphic mapping - the arity for the block needs + # to be two as the instance is passed as the first argument, e.g: + # + # resolve "Basket", anchor: "items" do |basket, options| + # [:basket, options] + # end + # + # This generates the URL "/basket#items" because when the last item in an array + # passed to `polymorphic_url` is a hash then it's treated as options to the URL + # helper that gets called. + # + # NOTE: The `resolve` method can't be used inside of a scope block such as + # `namespace` or `scope` and will raise an error if it detects that it is. + def resolve(*args, &block) + unless @scope.root? + raise RuntimeError, "The resolve method can't be used inside a routes scope block" + end + + options = args.extract_options! + args = args.flatten(1) + + args.each do |klass| + @set.add_polymorphic_mapping(klass, options, &block) + end + end + end + class Scope # :nodoc: OPTIONS = [:path, :shallow_path, :as, :shallow_prefix, :module, :controller, :action, :path_names, :constraints, @@ -2030,9 +2447,9 @@ class Scope # :nodoc: attr_reader :parent, :scope_level - def initialize(hash, parent = NULL, scope_level = nil) - @hash = hash + def initialize(hash, parent = ROOT, scope_level = nil) @parent = parent + @hash = parent ? parent.frame.merge(hash) : hash @scope_level = scope_level end @@ -2040,6 +2457,14 @@ def nested? scope_level == :nested end + def null? + @hash.nil? && @parent.nil? + end + + def root? + @parent == ROOT + end + def resources? scope_level == :resources end @@ -2082,27 +2507,29 @@ def new_level(level) end def [](key) - scope = find { |node| node.frame.key? key } - scope && scope.frame[key] + frame[key] end + def frame; @hash; end + include Enumerable def each node = self - until node.equal? NULL + until node.equal? ROOT yield node node = node.parent end end - def frame; @hash; end - - NULL = Scope.new(nil, nil) + ROOT = Scope.new({}, nil) end - def initialize(set) #:nodoc: + DEFAULT = Object.new # :nodoc: + + def initialize(set) # :nodoc: @set = set + @draw_paths = set.draw_paths @scope = Scope.new(path_names: @set.resources_path_names) @concerns = {} end @@ -2113,6 +2540,7 @@ def initialize(set) #:nodoc: include Scoping include Concerns include Resources + include CustomUrls end end end diff --git a/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb b/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb index 432b9bf4c1bb8..02c8a84d019f6 100644 --- a/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb +++ b/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb @@ -1,100 +1,111 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Routing - # Polymorphic URL helpers are methods for smart resolution to a named route call when - # given an Active Record model instance. They are to be used in combination with - # ActionController::Resources. + # # Action Dispatch Routing PolymorphicRoutes + # + # Polymorphic URL helpers are methods for smart resolution to a named route call + # when given an Active Record model instance. They are to be used in combination + # with ActionController::Resources. # - # These methods are useful when you want to generate the correct URL or path to a RESTful - # resource without having to know the exact type of the record in question. + # These methods are useful when you want to generate the correct URL or path to + # a RESTful resource without having to know the exact type of the record in + # question. # - # Nested resources and/or namespaces are also supported, as illustrated in the example: + # Nested resources and/or namespaces are also supported, as illustrated in the + # example: # - # polymorphic_url([:admin, @article, @comment]) + # polymorphic_url([:admin, @article, @comment]) # # results in: # - # admin_article_comment_url(@article, @comment) + # admin_article_comment_url(@article, @comment) + # + # ## Usage within the framework + # + # Polymorphic URL helpers are used in a number of places throughout the Rails + # framework: # - # == Usage within the framework + # * `url_for`, so you can use it with a record as the argument, e.g. + # `url_for(@article)`; + # * ActionView::Helpers::FormHelper uses `polymorphic_path`, so you can write + # `form_with(model: @article)` without having to specify `:url` parameter for the + # form action; + # * `redirect_to` (which, in fact, uses `url_for`) so you can write + # `redirect_to(post)` in your controllers; + # * ActionView::Helpers::AtomFeedHelper, so you don't have to explicitly + # specify URLs for feed entries. # - # Polymorphic URL helpers are used in a number of places throughout the \Rails framework: # - # * url_for, so you can use it with a record as the argument, e.g. - # url_for(@article); - # * ActionView::Helpers::FormHelper uses polymorphic_path, so you can write - # form_for(@article) without having to specify :url parameter for the form - # action; - # * redirect_to (which, in fact, uses url_for) so you can write - # redirect_to(post) in your controllers; - # * ActionView::Helpers::AtomFeedHelper, so you don't have to explicitly specify URLs - # for feed entries. + # ## Prefixed polymorphic helpers # - # == Prefixed polymorphic helpers + # In addition to `polymorphic_url` and `polymorphic_path` methods, a number of + # prefixed helpers are available as a shorthand to `action: "..."` in options. + # Those are: # - # In addition to polymorphic_url and polymorphic_path methods, a - # number of prefixed helpers are available as a shorthand to action: "..." - # in options. Those are: + # * `edit_polymorphic_url`, `edit_polymorphic_path` + # * `new_polymorphic_url`, `new_polymorphic_path` # - # * edit_polymorphic_url, edit_polymorphic_path - # * new_polymorphic_url, new_polymorphic_path # # Example usage: # - # edit_polymorphic_path(@post) # => "/posts/1/edit" - # polymorphic_path(@post, format: :pdf) # => "/posts/1.pdf" + # edit_polymorphic_path(@post) # => "/posts/1/edit" + # polymorphic_path(@post, format: :pdf) # => "/posts/1.pdf" # - # == Usage with mounted engines + # ## Usage with mounted engines # # If you are using a mounted engine and you need to use a polymorphic_url # pointing at the engine's routes, pass in the engine's route proxy as the first # argument to the method. For example: # - # polymorphic_url([blog, @post]) # calls blog.post_path(@post) - # form_for([blog, @post]) # => "/blog/posts/1" + # polymorphic_url([blog, @post]) # calls blog.post_path(@post) + # form_with(model: [blog, @post]) # => "/blog/posts/1" # module PolymorphicRoutes - # Constructs a call to a named RESTful route for the given record and returns the - # resulting URL string. For example: + # Constructs a call to a named RESTful route for the given record and returns + # the resulting URL string. For example: # - # # calls post_url(post) - # polymorphic_url(post) # => "http://example.com/posts/1" - # polymorphic_url([blog, post]) # => "http://example.com/blogs/1/posts/1" - # polymorphic_url([:admin, blog, post]) # => "http://example.com/admin/blogs/1/posts/1" - # polymorphic_url([user, :blog, post]) # => "http://example.com/users/1/blog/posts/1" - # polymorphic_url(Comment) # => "http://example.com/comments" + # # calls post_url(post) + # polymorphic_url(post) # => "http://example.com/posts/1" + # polymorphic_url([blog, post]) # => "http://example.com/blogs/1/posts/1" + # polymorphic_url([:admin, blog, post]) # => "http://example.com/admin/blogs/1/posts/1" + # polymorphic_url([user, :blog, post]) # => "http://example.com/users/1/blog/posts/1" + # polymorphic_url(Comment) # => "http://example.com/comments" # - # ==== Options + # #### Options # - # * :action - Specifies the action prefix for the named route: - # :new or :edit. Default is no prefix. - # * :routing_type - Allowed values are :path or :url. - # Default is :url. + # * `:action` - Specifies the action prefix for the named route: `:new` or + # `:edit`. Default is no prefix. + # * `:routing_type` - Allowed values are `:path` or `:url`. Default is `:url`. # - # Also includes all the options from url_for. These include such - # things as :anchor or :trailing_slash. Example usage - # is given below: # - # polymorphic_url([blog, post], anchor: 'my_anchor') - # # => "http://example.com/blogs/1/posts/1#my_anchor" - # polymorphic_url([blog, post], anchor: 'my_anchor', script_name: "/my_app") - # # => "http://example.com/my_app/blogs/1/posts/1#my_anchor" + # Also includes all the options from `url_for`. These include such things as + # `:anchor` or `:trailing_slash`. Example usage is given below: # - # For all of these options, see the documentation for {url_for}[rdoc-ref:ActionDispatch::Routing::UrlFor]. + # polymorphic_url([blog, post], anchor: 'my_anchor') + # # => "http://example.com/blogs/1/posts/1#my_anchor" + # polymorphic_url([blog, post], anchor: 'my_anchor', script_name: "/my_app") + # # => "http://example.com/my_app/blogs/1/posts/1#my_anchor" # - # ==== Functionality + # For all of these options, see the documentation for + # [url_for](rdoc-ref:ActionDispatch::Routing::UrlFor). # - # # an Article record - # polymorphic_url(record) # same as article_url(record) + # #### Functionality # - # # a Comment record - # polymorphic_url(record) # same as comment_url(record) + # # an Article record + # polymorphic_url(record) # same as article_url(record) # - # # it recognizes new records and maps to the collection - # record = Comment.new - # polymorphic_url(record) # same as comments_url() + # # a Comment record + # polymorphic_url(record) # same as comment_url(record) # - # # the class of a record will also map to the collection - # polymorphic_url(Comment) # same as comments_url() + # # it recognizes new records and maps to the collection + # record = Comment.new + # polymorphic_url(record) # same as comments_url() + # + # # the class of a record will also map to the collection + # polymorphic_url(Comment) # same as comments_url() # def polymorphic_url(record_or_hash_or_array, options = {}) if Hash === record_or_hash_or_array @@ -103,6 +114,10 @@ def polymorphic_url(record_or_hash_or_array, options = {}) return polymorphic_url record, options end + if mapping = polymorphic_mapping(record_or_hash_or_array) + return mapping.call(self, [record_or_hash_or_array, options], false) + end + opts = options.dup action = opts.delete :action type = opts.delete(:routing_type) || :url @@ -114,8 +129,7 @@ def polymorphic_url(record_or_hash_or_array, options = {}) opts end - # Returns the path component of a URL for the given record. It uses - # polymorphic_url with routing_type: :path. + # Returns the path component of a URL for the given record. def polymorphic_path(record_or_hash_or_array, options = {}) if Hash === record_or_hash_or_array options = record_or_hash_or_array.merge(options) @@ -123,6 +137,10 @@ def polymorphic_path(record_or_hash_or_array, options = {}) return polymorphic_path record, options end + if mapping = polymorphic_mapping(record_or_hash_or_array) + return mapping.call(self, [record_or_hash_or_array, options], true) + end + opts = options.dup action = opts.delete :action type = :path @@ -136,6 +154,7 @@ def polymorphic_path(record_or_hash_or_array, options = {}) %w(edit new).each do |action| module_eval <<-EOT, __FILE__, __LINE__ + 1 + # frozen_string_literal: true def #{action}_polymorphic_url(record_or_hash, options = {}) polymorphic_url_for_action("#{action}", record_or_hash, options) end @@ -147,7 +166,6 @@ def #{action}_polymorphic_path(record_or_hash, options = {}) end private - def polymorphic_url_for_action(action, record_or_hash, options) polymorphic_url(record_or_hash, options.merge(action: action)) end @@ -156,16 +174,24 @@ def polymorphic_path_for_action(action, record_or_hash, options) polymorphic_path(record_or_hash, options.merge(action: action)) end + def polymorphic_mapping(record) + if record.respond_to?(:to_model) + _routes.polymorphic_mappings[record.to_model.model_name.name] + else + _routes.polymorphic_mappings[record.class.name] + end + end + class HelperMethodBuilder # :nodoc: - CACHE = { "path" => {}, "url" => {} } + CACHE = { path: {}, url: {} } def self.get(action, type) - type = type.to_s + type = type.to_sym CACHE[type].fetch(action) { build action, type } end - def self.url; CACHE["url".freeze][nil]; end - def self.path; CACHE["path".freeze][nil]; end + def self.url; CACHE[:url][nil]; end + def self.path; CACHE[:path][nil]; end def self.build(action, type) prefix = action ? "#{action}_" : "" @@ -211,9 +237,9 @@ def self.polymorphic_method(recipient, record_or_hash_or_array, action, type, op end if options.empty? - recipient.send(method, *args) + recipient.public_send(method, *args) else - recipient.send(method, *args, options) + recipient.public_send(method, *args, options) end end @@ -230,7 +256,7 @@ def handle_string(record) end def handle_string_call(target, str) - target.send get_method_for_string str + target.public_send get_method_for_string str end def handle_class(klass) @@ -238,7 +264,7 @@ def handle_class(klass) end def handle_class_call(target, klass) - target.send get_method_for_class klass + target.public_send get_method_for_class klass end def handle_model(record) @@ -255,9 +281,13 @@ def handle_model(record) [named_route, args] end - def handle_model_call(target, model) - method, args = handle_model model - target.send(method, *args) + def handle_model_call(target, record) + if mapping = polymorphic_mapping(target, record) + mapping.call(target, [record], suffix == "path") + else + method, args = handle_model(record) + target.public_send(method, *args) + end end def handle_list(list) @@ -266,10 +296,12 @@ def handle_list(list) args = [] - route = record_list.map { |parent| + route = record_list.map do |parent| case parent - when Symbol, String + when Symbol parent.to_s + when String + raise(ArgumentError, "Please use symbols for polymorphic route arguments.") when Class args << parent parent.model_name.singular_route_key @@ -277,12 +309,14 @@ def handle_list(list) args << parent.to_model parent.to_model.model_name.singular_route_key end - } + end route << case record - when Symbol, String + when Symbol record.to_s + when String + raise(ArgumentError, "Please use symbols for polymorphic route arguments.") when Class @key_strategy.call record.model_name else @@ -302,6 +336,13 @@ def handle_list(list) end private + def polymorphic_mapping(target, record) + if record.respond_to?(:to_model) + target._routes.polymorphic_mappings[record.to_model.model_name.name] + else + target._routes.polymorphic_mappings[record.class.name] + end + end def get_method_for_class(klass) name = @key_strategy.call klass.model_name @@ -313,8 +354,8 @@ def get_method_for_string(str) end [nil, "new", "edit"].each do |action| - CACHE["url"][action] = build action, "url" - CACHE["path"][action] = build action, "path" + CACHE[:url][action] = build action, "url" + CACHE[:path][action] = build action, "path" end end end diff --git a/actionpack/lib/action_dispatch/routing/redirection.rb b/actionpack/lib/action_dispatch/routing/redirection.rb index dabc045007782..78fd4209ee2ff 100644 --- a/actionpack/lib/action_dispatch/routing/redirection.rb +++ b/actionpack/lib/action_dispatch/routing/redirection.rb @@ -1,5 +1,7 @@ -require "action_dispatch/http/request" -require "active_support/core_ext/uri" +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/array/extract_options" require "rack/utils" require "action_controller/metal/exceptions" @@ -10,18 +12,29 @@ 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 def call(env) - serve Request.new env + ActiveSupport::Notifications.instrument("redirect.action_dispatch") do |payload| + request = Request.new(env) + response = build_response(request) + + payload[:status] = @status + payload[:location] = response.headers["Location"] + payload[:request] = request + payload[:source_location] = @source_location if @source_location + + response.to_a + end end - def serve(req) + def build_response(req) uri = URI.parse(path(req.path_parameters, req)) unless uri.host @@ -36,15 +49,17 @@ def serve(req) uri.host ||= req.host uri.port ||= req.port unless req.standard_port? - body = %(You are being redirected.) + req.commit_flash + + body = "" headers = { "Location" => uri.to_s, - "Content-Type" => "text/html", + "Content-Type" => "text/html; charset=#{ActionDispatch::Response.default_charset}", "Content-Length" => body.length.to_s } - [ status, headers, [body] ] + ActionDispatch::Response.new(status, headers, body) end def path(params, request) @@ -57,19 +72,19 @@ def inspect private def relative_path?(path) - path && !path.empty? && path[0] != "/" + path && !path.empty? && !path.start_with?("/") end def escape(params) - Hash[params.map { |k, v| [k, Rack::Utils.escape(v)] }] + params.transform_values { |v| Rack::Utils.escape(v) } end def escape_fragment(params) - Hash[params.map { |k, v| [k, Journey::Router::Utils.escape_fragment(v)] }] + params.transform_values { |v| Journey::Router::Utils.escape_fragment(v) } end def escape_path(params) - Hash[params.map { |k, v| [k, Journey::Router::Utils.escape_path(v)] }] + params.transform_values { |v| Journey::Router::Utils.escape_path(v) } end end @@ -135,62 +150,71 @@ def inspect module Redirection # Redirect any path to another path: # - # get "/stories" => redirect("/posts") + # get "/stories" => redirect("/posts") + # + # This will redirect the user, while ignoring certain parts of the request, + # including query string, etc. `/stories`, `/stories?foo=bar`, etc all redirect + # to `/posts`. + # + # The redirect will use a `301 Moved Permanently` status code by default. This + # can be overridden with the `:status` option: # - # This will redirect the user, while ignoring certain parts of the request, including query string, etc. - # `/stories`, `/stories?foo=bar`, etc all redirect to `/posts`. + # get "/stories" => redirect("/posts", status: 307) # # You can also use interpolation in the supplied redirect argument: # - # get 'docs/:article', to: redirect('/wiki/%{article}') + # get 'docs/:article', to: redirect('/wiki/%{article}') # - # Note that if you return a path without a leading slash then the url is prefixed with the - # current SCRIPT_NAME environment variable. This is typically '/' but may be different in - # a mounted engine or where the application is deployed to a subdirectory of a website. + # Note that if you return a path without a leading slash then the URL is + # prefixed with the current SCRIPT_NAME environment variable. This is typically + # '/' but may be different in a mounted engine or where the application is + # deployed to a subdirectory of a website. # # Alternatively you can use one of the other syntaxes: # - # The block version of redirect allows for the easy encapsulation of any logic associated with - # the redirect in question. Either the params and request are supplied as arguments, or just - # params, depending of how many arguments your block accepts. A string is required as a - # return value. + # The block version of redirect allows for the easy encapsulation of any logic + # associated with the redirect in question. Either the params and request are + # supplied as arguments, or just params, depending of how many arguments your + # block accepts. A string is required as a return value. # - # get 'jokes/:number', to: redirect { |params, request| - # path = (params[:number].to_i.even? ? "wheres-the-beef" : "i-love-lamp") - # "http://#{request.host_with_port}/#{path}" - # } + # get 'jokes/:number', to: redirect { |params, request| + # path = (params[:number].to_i.even? ? "wheres-the-beef" : "i-love-lamp") + # "http://#{request.host_with_port}/#{path}" + # } # - # Note that the +do end+ syntax for the redirect block wouldn't work, as Ruby would pass - # the block to +get+ instead of +redirect+. Use { ... } instead. + # Note that the `do end` syntax for the redirect block wouldn't work, as Ruby + # would pass the block to `get` instead of `redirect`. Use `{ ... }` instead. # - # The options version of redirect allows you to supply only the parts of the url which need - # to change, it also supports interpolation of the path similar to the first example. + # The options version of redirect allows you to supply only the parts of the URL + # which need to change, it also supports interpolation of the path similar to + # the first example. # - # get 'stores/:name', to: redirect(subdomain: 'stores', path: '/%{name}') - # get 'stores/:name(*all)', to: redirect(subdomain: 'stores', path: '/%{name}%{all}') - # get '/stories', to: redirect(path: '/posts') + # get 'stores/:name', to: redirect(subdomain: 'stores', path: '/%{name}') + # get 'stores/:name(*all)', to: redirect(subdomain: 'stores', path: '/%{name}%{all}') + # get '/stories', to: redirect(path: '/posts') # - # This will redirect the user, while changing only the specified parts of the request, - # for example the `path` option in the last example. - # `/stories`, `/stories?foo=bar`, redirect to `/posts` and `/posts?foo=bar` respectively. + # This will redirect the user, while changing only the specified parts of the + # request, for example the `path` option in the last example. `/stories`, + # `/stories?foo=bar`, redirect to `/posts` and `/posts?foo=bar` respectively. # - # Finally, an object which responds to call can be supplied to redirect, allowing you to reuse - # common redirect routes. The call method must accept two arguments, params and request, and return - # a string. + # Finally, an object which responds to call can be supplied to redirect, + # allowing you to reuse common redirect routes. The call method must accept two + # arguments, params and request, and return a string. # - # get 'accounts/:name' => redirect(SubdomainRedirector.new('api')) + # 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 5b873aeab7640..a8bea98432708 100644 --- a/actionpack/lib/action_dispatch/routing/route_set.rb +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -1,20 +1,39 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/journey" require "active_support/core_ext/object/to_query" -require "active_support/core_ext/hash/slice" +require "active_support/core_ext/module/redefine_method" require "active_support/core_ext/module/remove_method" require "active_support/core_ext/array/extract_options" require "action_controller/metal/exceptions" -require "action_dispatch/http/request" require "action_dispatch/routing/endpoint" module ActionDispatch module Routing - # :stopdoc: + # The RouteSet contains a collection of Route instances, representing the routes + # typically defined in `config/routes.rb`. class RouteSet - # Since the router holds references to many parts of the system - # like engines, controllers and the application itself, inspecting - # the route set can actually be really slow, therefore we default - # alias inspect to to_s. + # Returns a Route matching the given requirements, or `nil` if none are found. + # + # This is intended for use by tools such as Language Servers. + # + # Given the routes are defined as: + # + # resources :posts + # + # Then the following will return the Route for the `show` action: + # + # Rails.application.routes.from_requirements(controller: "posts", action: "show") + def from_requirements(requirements) + routes.find { |route| route.requirements == requirements } + end + # :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 + # be really slow, therefore we default alias inspect to to_s. alias inspect to_s class Dispatcher < Routing::Endpoint @@ -33,21 +52,18 @@ def serve(req) if @raise_on_name_error raise else - return [404, { "X-Cascade" => "pass" }, []] + [404, { Constants::X_CASCADE => "pass" }, []] end end - private - - def controller(req) - req.controller_class - rescue NameError => e - raise ActionController::RoutingError, e.message, e.backtrace - end + private + def controller(req) + req.controller_class + end - def dispatch(controller, action, req, res) - controller.dispatch(action, req, res) - end + def dispatch(controller, action, req, res) + controller.dispatch(action, req, res) + end end class StaticDispatcher < Dispatcher @@ -57,7 +73,6 @@ def initialize(controller_class) end private - def controller(_); @controller_class; end end @@ -88,11 +103,11 @@ def helper_names def clear! @path_helpers.each do |helper| - @path_helpers_module.send :undef_method, helper + @path_helpers_module.remove_method helper end @url_helpers.each do |helper| - @url_helpers_module.send :undef_method, helper + @url_helpers_module.remove_method helper end @routes.clear @@ -106,12 +121,14 @@ def add(name, route) url_name = :"#{name}_url" if routes.key? key - @path_helpers_module.send :undef_method, path_name - @url_helpers_module.send :undef_method, url_name + @path_helpers_module.undef_method path_name + @url_helpers_module.undef_method url_name end routes[key] = route - define_url_helper @path_helpers_module, route, path_name, route.defaults, name, PATH - define_url_helper @url_helpers_module, route, url_name, route.defaults, name, UNKNOWN + + helper = UrlHelper.create(route, route.defaults, name) + define_url_helper @path_helpers_module, path_name, helper, PATH + define_url_helper @url_helpers_module, url_name, helper, UNKNOWN @path_helpers << path_name @url_helpers << url_name @@ -130,8 +147,8 @@ def key?(name) alias [] get alias clear clear! - def each - routes.each { |name, route| yield name, route } + def each(&block) + routes.each(&block) self end @@ -143,34 +160,71 @@ def length routes.length end + # Given a `name`, defines name_path and name_url helpers. Used by 'direct', + # 'resolve', and 'polymorphic' route helpers. + def add_url_helper(name, defaults, &block) + helper = CustomUrlHelper.new(name, defaults, &block) + path_name = :"#{name}_path" + url_name = :"#{name}_url" + + @path_helpers_module.module_eval do + redefine_method(path_name) do |*args| + helper.call(self, args, true) + end + end + + @url_helpers_module.module_eval do + redefine_method(url_name) do |*args| + helper.call(self, args, false) + end + end + + @path_helpers << path_name + @url_helpers << url_name + + self + end + class UrlHelper - def self.create(route, options, route_name, url_strategy) + def self.create(route, options, route_name) if optimize_helper?(route) - OptimizedUrlHelper.new(route, options, route_name, url_strategy) + OptimizedUrlHelper.new(route, options, route_name) else - new route, options, route_name, url_strategy + new(route, options, route_name) end end def self.optimize_helper?(route) - !route.glob? && route.path.requirements.empty? + route.path.requirements.empty? && !route.glob? end - attr_reader :url_strategy, :route_name + attr_reader :route_name class OptimizedUrlHelper < UrlHelper attr_reader :arg_size - def initialize(route, options, route_name, url_strategy) + def initialize(route, options, route_name) super @required_parts = @route.required_parts @arg_size = @required_parts.size end - def call(t, args, inner_options) + def call(t, method_name, args, inner_options, url_strategy) if args.size == arg_size && !inner_options && optimize_routes_generation?(t) options = t.url_options.merge @options - options[:path] = optimized_helper(args) + path = optimized_helper(args) + path << "/" if options[:trailing_slash] && !path.end_with?("/") + options[:path] = path + + original_script_name = options.delete(:original_script_name) + script_name = t._routes.find_script_name(options) + + if original_script_name + script_name = original_script_name + script_name + end + + options[:script_name] = script_name + url_strategy.call options else super @@ -178,7 +232,6 @@ def call(t, args, inner_options) end private - def optimized_helper(args) params = parameterize_args(args) do raise_generation_error(args) @@ -208,22 +261,21 @@ def raise_generation_error(args) missing_keys << missing_key } constraints = Hash[@route.requirements.merge(params).sort_by { |k, v| k.to_s }] - message = "No route matches #{constraints.inspect}" + message = +"No route matches #{constraints.inspect}" message << ", missing required keys: #{missing_keys.sort.inspect}" raise ActionController::UrlGenerationError, message end end - def initialize(route, options, route_name, url_strategy) + def initialize(route, options, route_name) @options = options @segment_keys = route.segment_keys.uniq @route = route - @url_strategy = url_strategy @route_name = route_name end - def call(t, args, inner_options) + def call(t, method_name, args, inner_options, url_strategy) controller_options = t.url_options options = controller_options.merge @options hash = handle_positional_args(controller_options, @@ -232,7 +284,7 @@ def call(t, args, inner_options) options, @segment_keys) - t._routes.url_for(hash, route_name, url_strategy) + t._routes.url_for(hash, route_name, url_strategy, method_name) end def handle_positional_args(controller_options, inner_options, args, result, path_params) @@ -246,7 +298,9 @@ def handle_positional_args(controller_options, inner_options, args, result, path if args.size < path_params_size path_params -= controller_options.keys - path_params -= result.keys + path_params -= (result[:path_params] || {}).merge(result).keys + else + path_params = path_params.dup end inner_options.each_key do |key| path_params.delete(key) @@ -263,49 +317,42 @@ def handle_positional_args(controller_options, inner_options, args, result, path end private - # Create a url helper allowing ordered parameters to be associated - # with corresponding dynamic segments, so you can do: + # Create a URL helper allowing ordered parameters to be associated with + # corresponding dynamic segments, so you can do: # - # foo_url(bar, baz, bang) + # foo_url(bar, baz, bang) # # Instead of: # - # foo_url(bar: bar, baz: baz, bang: bang) + # foo_url(bar: bar, baz: baz, bang: bang) # # Also allow options hash, so you can do: # - # foo_url(bar, baz, bang, sort_by: 'baz') + # foo_url(bar, baz, bang, sort_by: 'baz') # - def define_url_helper(mod, route, name, opts, route_key, url_strategy) - helper = UrlHelper.create(route, opts, route_key, url_strategy) - mod.module_eval do - define_method(name) do |*args| - last = args.last - options = \ - case last - when Hash - args.pop - when ActionController::Parameters - if last.permitted? - args.pop.to_h - else - raise ArgumentError, ActionDispatch::Routing::INSECURE_URL_PARAMETERS_MESSAGE - end - end - helper.call self, args, options - end + def define_url_helper(mod, name, helper, url_strategy) + mod.define_method(name) do |*args| + last = args.last + options = \ + case last + when Hash + args.pop + when ActionController::Parameters + args.pop.to_h + end + helper.call(self, name, args, options, url_strategy) end end end - # strategy for building urls to send to the client + # strategy for building URLs to send to the client PATH = ->(options) { ActionDispatch::Http::URL.path_for(options) } UNKNOWN = ->(options) { ActionDispatch::Http::URL.url_for(options) } - attr_accessor :formatter, :set, :named_routes, :default_scope, :router + attr_accessor :formatter, :set, :named_routes, :router attr_accessor :disable_clear_and_finalize, :resources_path_names - attr_accessor :default_url_options - attr_reader :env_key + attr_accessor :default_url_options, :draw_paths + attr_reader :env_key, :polymorphic_mappings alias :routes :set @@ -314,7 +361,7 @@ def self.default_resources_path_names end def self.new_with_config(config) - route_set_config = DEFAULT_CONFIG + route_set_config = DEFAULT_CONFIG.dup # engines apparently don't have this set if config.respond_to? :relative_url_root @@ -325,33 +372,41 @@ def self.new_with_config(config) route_set_config.api_only = config.api_only end + if config.respond_to? :default_scope + route_set_config.default_scope = config.default_scope + end + new route_set_config end - Config = Struct.new :relative_url_root, :api_only + Config = Struct.new :relative_url_root, :api_only, :default_scope - DEFAULT_CONFIG = Config.new(nil, false) + DEFAULT_CONFIG = Config.new(nil, false, nil) - def initialize(config = DEFAULT_CONFIG) + def initialize(config = DEFAULT_CONFIG.dup) self.named_routes = NamedRouteCollection.new self.resources_path_names = self.class.default_resources_path_names self.default_url_options = {} + self.draw_paths = [] @config = config @append = [] @prepend = [] @disable_clear_and_finalize = false @finalized = false - @env_key = "ROUTES_#{object_id}_SCRIPT_NAME".freeze + @env_key = "ROUTES_#{object_id}_SCRIPT_NAME" + @default_env = nil @set = Journey::Routes.new @router = Journey::Router.new @set @formatter = Journey::Formatter.new self + @polymorphic_mappings = {} end def eager_load! router.eager_load! routes.each(&:eager_load!) + formatter.eager_load! nil end @@ -363,6 +418,14 @@ def api_only? @config.api_only end + def default_scope + @config.default_scope + end + + def default_scope=(new_default_scope) + @config.default_scope = new_default_scope + end + def request_class ActionDispatch::Request end @@ -372,6 +435,25 @@ def make_request(env) end private :make_request + def default_env + if default_url_options != @default_env&.[]("action_dispatch.routes.default_url_options") + url_options = default_url_options.dup.freeze + uri = URI(ActionDispatch::Http::URL.full_url_for(host: "example.org", **url_options)) + + @default_env = { + "action_dispatch.routes" => self, + "action_dispatch.routes.default_url_options" => url_options, + "HTTPS" => uri.scheme == "https" ? "on" : "off", + "rack.url_scheme" => uri.scheme, + "HTTP_HOST" => uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}", + "SCRIPT_NAME" => uri.path.chomp("/"), + "rack.input" => "", + }.freeze + end + + @default_env + end + def draw(&block) clear! unless @disable_clear_and_finalize eval_block(block) @@ -408,6 +490,7 @@ def clear! named_routes.clear set.clear formatter.clear + @polymorphic_mappings.clear @prepend.each { |blk| eval_block(blk) } end @@ -416,15 +499,14 @@ module MountedHelpers include UrlFor end - # Contains all the mounted helpers across different - # engines and the `main_app` helper for the application. - # You can include this in your classes if you want to - # access routes for other engines. + # Contains all the mounted helpers across different engines and the `main_app` + # helper for the application. You can include this in your classes if you want + # to access routes for other engines. def mounted_helpers MountedHelpers end - def define_mounted_helper(name) + def define_mounted_helper(name, script_namer = nil) return if MountedHelpers.method_defined?(name) routes = self @@ -432,7 +514,7 @@ def define_mounted_helper(name) MountedHelpers.class_eval do define_method "_#{name}" do - RoutesProxy.new(routes, _routes_context, helpers) + RoutesProxy.new(routes, _routes_context, helpers, script_namer) end end @@ -444,6 +526,14 @@ def #{name} end def url_helpers(supports_path = true) + if supports_path + @url_helpers_with_paths ||= generate_url_helpers(true) + else + @url_helpers_without_paths ||= generate_url_helpers(false) + end + end + + def generate_url_helpers(supports_path) routes = self Module.new do @@ -452,29 +542,60 @@ def url_helpers(supports_path = true) # Define url_for in the singleton level so one can do: # Rails.application.routes.url_helpers.url_for(args) - @_routes = routes + proxy_class = Class.new do + include UrlFor + include routes.named_routes.path_helpers_module + include routes.named_routes.url_helpers_module + + attr_reader :_routes + + def initialize(routes) + @_routes = routes + end + + def optimize_routes_generation? + @_routes.optimize_routes_generation? + end + end + + @_proxy = proxy_class.new(routes) + class << self def url_for(options) - @_routes.url_for(options) + @_proxy.url_for(options) + end + + def full_url_for(options) + @_proxy.full_url_for(options) + end + + def route_for(name, *args) + @_proxy.route_for(name, *args) end def optimize_routes_generation? - @_routes.optimize_routes_generation? + @_proxy.optimize_routes_generation? end - attr_reader :_routes + def polymorphic_url(record_or_hash_or_array, options = {}) + @_proxy.polymorphic_url(record_or_hash_or_array, options) + end + + def polymorphic_path(record_or_hash_or_array, options = {}) + @_proxy.polymorphic_path(record_or_hash_or_array, options) + end + + def _routes; @_proxy._routes; end def url_options; {}; end end url_helpers = routes.named_routes.url_helpers_module - # Make named_routes available in the module singleton - # as well, so one can do: + # Make named_routes available in the module singleton as well, so one can do: # Rails.application.routes.url_helpers.posts_path extend url_helpers - # Any class that includes this module will get all - # named routes... + # Any class that includes this module will get all named routes... include url_helpers if supports_path @@ -486,12 +607,11 @@ def url_options; {}; end # plus a singleton class method called _routes ... included do - singleton_class.send(:redefine_method, :_routes) { routes } + redefine_singleton_method(:_routes) { routes } end - # And an instance method _routes. Note that - # UrlFor (included in this module) add extra - # conveniences for working with @_routes. + # And an instance method _routes. Note that UrlFor (included in this module) add + # extra conveniences for working with @_routes. define_method(:_routes) { @_routes || routes } define_method(:_generate_paths_by_default) do @@ -499,6 +619,20 @@ def url_options; {}; end end private :_generate_paths_by_default + + # If the module is included more than once (for example, in a subclass of an + # ancestor that includes the module), ensure that the `_routes` singleton and + # instance methods return the desired route set by including a new copy of the + # module (recursively if necessary). Note that this method is called for each + # inclusion, whereas the above `included` block is run only for the initial + # inclusion of each copy. + def self.included(base) + super + if base.respond_to?(:_routes) && !base._routes.equal?(@_proxy._routes) + @dup_for_reinclude ||= self.dup + base.include @dup_for_reinclude + end + end end end @@ -506,7 +640,7 @@ def empty? routes.empty? end - def add_route(mapping, path_ast, name, anchor) + def add_route(mapping, name) raise ArgumentError, "Invalid route name: '#{name}'" unless name.blank? || name.to_s.match(/^[_a-z]\w*$/i) if name && named_routes[name] @@ -514,38 +648,68 @@ def add_route(mapping, path_ast, name, anchor) "You may have defined two routes with the same name using the `:as` option, or " \ "you may be overriding a route already defined by a resource with the same naming. " \ "For the latter, you can restrict the routes created with `resources` as explained here: \n" \ - "http://guides.rubyonrails.org/routing.html#restricting-the-routes-created" + "https://guides.rubyonrails.org/routing.html#restricting-the-routes-created" end route = @set.add_route(name, mapping) named_routes[name] = route if name if route.segment_keys.include?(:controller) - ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActionDispatch.deprecator.warn(<<-MSG.squish) Using a dynamic :controller segment in a route is deprecated and - will be removed in Rails 5.1. + will be removed in Rails 9.0. MSG end if route.segment_keys.include?(:action) - ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActionDispatch.deprecator.warn(<<-MSG.squish) Using a dynamic :action segment in a route is deprecated and - will be removed in Rails 5.1. + will be removed in Rails 9.0. MSG end route end - class Generator - PARAMETERIZE = lambda do |name, value| - if name == :controller - value + def add_polymorphic_mapping(klass, options, &block) + @polymorphic_mappings[klass] = CustomUrlHelper.new(klass, options, &block) + end + + def add_url_helper(name, options, &block) + named_routes.add_url_helper(name, options, &block) + end + + class CustomUrlHelper + attr_reader :name, :defaults, :block + + def initialize(name, defaults, &block) + @name = name + @defaults = defaults + @block = block + end + + def call(t, args, only_path = false) + options = args.extract_options! + url = t.full_url_for(eval_block(t, args, options)) + + if only_path + "/" + url.partition(%r{(?config/routes.rb you define URL-to-controller mappings, but the reverse + # # Action Dispatch Routing UrlFor + # + # In `config/routes.rb` you define URL-to-controller mappings, but the reverse # is also possible: a URL can be generated from one of your routing definitions. # URL generation functionality is centralized in this module. # - # See ActionDispatch::Routing for general information about routing and routes.rb. - # - # Tip: If you need to generate URLs from your models or some other place, - # then ActionController::UrlFor is what you're looking for. Read on for - # an introduction. In general, this module should not be included on its own, - # as it is usually included by url_helpers (as in Rails.application.routes.url_helpers). - # - # == URL generation from parameters - # - # As you may know, some functions, such as ActionController::Base#url_for - # and ActionView::Helpers::UrlHelper#link_to, can generate URLs given a set - # of parameters. For example, you've probably had the chance to write code - # like this in one of your views: - # - # <%= link_to('Click here', controller: 'users', - # action: 'new', message: 'Welcome!') %> - # # => Click here - # - # link_to, and all other functions that require URL generation functionality, - # actually use ActionController::UrlFor under the hood. And in particular, - # they use the ActionController::UrlFor#url_for method. One can generate - # the same path as the above example by using the following code: - # - # include UrlFor - # url_for(controller: 'users', - # action: 'new', - # message: 'Welcome!', - # only_path: true) - # # => "/users/new?message=Welcome%21" - # - # Notice the only_path: true part. This is because UrlFor has no - # information about the website hostname that your Rails app is serving. So if you - # want to include the hostname as well, then you must also pass the :host - # argument: - # - # include UrlFor - # url_for(controller: 'users', - # action: 'new', - # message: 'Welcome!', - # host: 'www.example.com') - # # => "http://www.example.com/users/new?message=Welcome%21" - # - # By default, all controllers and views have access to a special version of url_for, - # that already knows what the current hostname is. So if you use url_for in your - # controllers or your views, then you don't need to explicitly pass the :host - # argument. - # - # For convenience reasons, mailers provide a shortcut for ActionController::UrlFor#url_for. - # So within mailers, you only have to type +url_for+ instead of 'ActionController::UrlFor#url_for' - # in full. However, mailers don't have hostname information, and you still have to provide - # the +:host+ argument or set the default host that will be used in all mailers using the - # configuration option +config.action_mailer.default_url_options+. For more information on - # url_for in mailers read the ActionMailer#Base documentation. - # - # - # == URL generation for named routes + # See ActionDispatch::Routing for general information about routing and + # `config/routes.rb`. + # + # **Tip:** If you need to generate URLs from your models or some other place, + # then ActionDispatch::Routing::UrlFor is what you're looking for. Read on for + # an introduction. In general, this module should not be included on its own, as + # it is usually included by `url_helpers` (as in + # `Rails.application.routes.url_helpers`). + # + # ## URL generation from parameters + # + # As you may know, some functions, such as `ActionController::Base#url_for` and + # ActionView::Helpers::UrlHelper#link_to, can generate URLs given a set of + # parameters. For example, you've probably had the chance to write code like + # this in one of your views: + # + # <%= link_to('Click here', controller: 'users', + # action: 'new', message: 'Welcome!') %> + # # => Click here + # + # `link_to`, and all other functions that require URL generation functionality, + # actually use ActionDispatch::Routing::UrlFor under the hood. And in + # particular, they use the ActionDispatch::Routing::UrlFor#url_for method. One + # can generate the same path as the above example by using the following code: + # + # include ActionDispatch::Routing::UrlFor + # url_for(controller: 'users', + # action: 'new', + # message: 'Welcome!', + # only_path: true) + # # => "/users/new?message=Welcome%21" + # + # Notice the `only_path: true` part. This is because UrlFor has no information + # about the website hostname that your Rails app is serving. So if you want to + # include the hostname as well, then you must also pass the `:host` argument: + # + # include UrlFor + # url_for(controller: 'users', + # action: 'new', + # message: 'Welcome!', + # host: 'www.example.com') + # # => "http://www.example.com/users/new?message=Welcome%21" + # + # By default, all controllers and views have access to a special version of + # `url_for`, that already knows what the current hostname is. So if you use + # `url_for` in your controllers or your views, then you don't need to explicitly + # pass the `:host` argument. + # + # For convenience, mailers also include ActionDispatch::Routing::UrlFor. So + # within mailers, you can use url_for. However, mailers cannot access incoming + # web requests in order to derive hostname information, so you have to provide + # the `:host` option or set the default host using `default_url_options`. For + # more information on url_for in mailers see the ActionMailer::Base + # documentation. + # + # ## URL generation for named routes # # UrlFor also allows one to access methods that have been auto-generated from # named routes. For example, suppose that you have a 'users' resource in your - # config/routes.rb: + # `config/routes.rb`: # - # resources :users + # resources :users # - # This generates, among other things, the method users_path. By default, - # this method is accessible from your controllers, views and mailers. If you need - # to access this auto-generated method from other places (such as a model), then - # you can do that by including Rails.application.routes.url_helpers in your class: + # This generates, among other things, the method `users_path`. By default, this + # method is accessible from your controllers, views, and mailers. If you need to + # access this auto-generated method from other places (such as a model), then + # you can do that by including `Rails.application.routes.url_helpers` in your + # class: # - # class User < ActiveRecord::Base - # include Rails.application.routes.url_helpers + # class User < ActiveRecord::Base + # include Rails.application.routes.url_helpers # - # def base_uri - # user_path(self) + # def base_uri + # user_path(self) + # end # end - # end # - # User.find(1).base_uri # => "/users/1" + # User.find(1).base_uri # => "/users/1" # module UrlFor extend ActiveSupport::Concern @@ -101,83 +108,85 @@ module UrlFor include(*_url_for_modules) if respond_to?(:_url_for_modules) end - def initialize(*) + def initialize(...) @_routes = nil super end - # Hook overridden in controller to add request information - # with `default_url_options`. Application logic should not - # go into url_options. + # Hook overridden in controller to add request information with + # `default_url_options`. Application logic should not go into url_options. def url_options default_url_options end - # Generate a url based on the options provided, default_url_options and the - # routes defined in routes.rb. The following options are supported: + # Generate a URL based on the options provided, `default_url_options`, and the + # routes defined in `config/routes.rb`. The following options are supported: # - # * :only_path - If true, the relative url is returned. Defaults to +false+. - # * :protocol - The protocol to connect to. Defaults to 'http'. - # * :host - Specifies the host the link should be targeted at. - # If :only_path is false, this option must be - # provided either explicitly, or via +default_url_options+. - # * :subdomain - Specifies the subdomain of the link, using the +tld_length+ - # to split the subdomain from the host. - # If false, removes all subdomains from the host part of the link. - # * :domain - Specifies the domain of the link, using the +tld_length+ - # to split the domain from the host. - # * :tld_length - Number of labels the TLD id composed of, only used if - # :subdomain or :domain are supplied. Defaults to - # ActionDispatch::Http::URL.tld_length, which in turn defaults to 1. - # * :port - Optionally specify the port to connect to. - # * :anchor - An anchor name to be appended to the path. - # * :trailing_slash - If true, adds a trailing slash, as in "/archive/2009/" - # * :script_name - Specifies application path relative to domain root. If provided, prepends application path. + # * `:only_path` - If true, the relative URL is returned. Defaults to `false`. + # * `:protocol` - The protocol to connect to. Defaults to `"http"`. + # * `:host` - Specifies the host the link should be targeted at. If + # `:only_path` is false, this option must be provided either explicitly, or + # via `default_url_options`. + # * `:subdomain` - Specifies the subdomain of the link, using the `tld_length` + # to split the subdomain from the host. If false, removes all subdomains + # from the host part of the link. + # * `:domain` - Specifies the domain of the link, using the `tld_length` to + # split the domain from the host. + # * `:tld_length` - Number of labels the TLD id composed of, only used if + # `:subdomain` or `:domain` are supplied. Defaults to + # `ActionDispatch::Http::URL.tld_length`, which in turn defaults to 1. + # * `:port` - Optionally specify the port to connect to. + # * `:anchor` - An anchor name to be appended to the path. + # * `:params` - The query parameters to be appended to the path. + # * `:path_params` - The query parameters that will only be used for the named + # dynamic segments of path. If unused, they will be discarded. + # * `:trailing_slash` - If true, adds a trailing slash, as in + # `"/archive/2009/"`. + # * `:script_name` - Specifies application path relative to domain root. If + # provided, prepends application path. # - # Any other key (:controller, :action, etc.) given to - # +url_for+ is forwarded to the Routes module. # - # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', port: '8080' - # # => 'http://somehost.org:8080/tasks/testing' - # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', anchor: 'ok', only_path: true - # # => '/tasks/testing#ok' - # url_for controller: 'tasks', action: 'testing', trailing_slash: true - # # => 'http://somehost.org/tasks/testing/' - # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', number: '33' - # # => 'http://somehost.org/tasks/testing?number=33' - # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', script_name: "/myapp" - # # => 'http://somehost.org/myapp/tasks/testing' - # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', script_name: "/myapp", only_path: true - # # => '/myapp/tasks/testing' + # Any other key (`:controller`, `:action`, etc.) given to `url_for` is forwarded + # to the Routes module. + # + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', port: '8080' + # # => 'http://somehost.org:8080/tasks/testing' + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', anchor: 'ok', only_path: true + # # => '/tasks/testing#ok' + # url_for controller: 'tasks', action: 'testing', trailing_slash: true + # # => 'http://somehost.org/tasks/testing/' + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', number: '33' + # # => 'http://somehost.org/tasks/testing?number=33' + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', script_name: "/myapp" + # # => 'http://somehost.org/myapp/tasks/testing' + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', script_name: "/myapp", only_path: true + # # => '/myapp/tasks/testing' # # Missing routes keys may be filled in from the current request's parameters - # (e.g. +:controller+, +:action+, +:id+ and any other parameters that are - # placed in the path). Given that the current action has been reached - # through `GET /users/1`: + # (e.g. `:controller`, `:action`, `:id`, and any other parameters that are + # placed in the path). Given that the current action has been reached through + # `GET /users/1`: # - # url_for(only_path: true) # => '/users/1' - # url_for(only_path: true, action: 'edit') # => '/users/1/edit' - # url_for(only_path: true, action: 'edit', id: 2) # => '/users/2/edit' + # url_for(only_path: true) # => '/users/1' + # url_for(only_path: true, action: 'edit') # => '/users/1/edit' + # url_for(only_path: true, action: 'edit', id: 2) # => '/users/2/edit' # - # Notice that no +:id+ parameter was provided to the first +url_for+ call - # and the helper used the one from the route's path. Any path parameter - # implicitly used by +url_for+ can always be overwritten like shown on the - # last +url_for+ calls. + # Notice that no `:id` parameter was provided to the first `url_for` call and + # the helper used the one from the route's path. Any path parameter implicitly + # used by `url_for` can always be overwritten like shown on the last `url_for` + # calls. def url_for(options = nil) + full_url_for(options) + end + + def full_url_for(options = nil) # :nodoc: case options when nil _routes.url_for(url_options.symbolize_keys) - when Hash - route_name = options.delete :use_route - _routes.url_for(options.symbolize_keys.reverse_merge!(url_options), - route_name) - when ActionController::Parameters - unless options.permitted? - raise ArgumentError.new(ActionDispatch::Routing::INSECURE_URL_PARAMETERS_MESSAGE) - end + when Hash, ActionController::Parameters route_name = options.delete :use_route - _routes.url_for(options.to_h.symbolize_keys. - reverse_merge!(url_options), route_name) + merged_url_options = options.to_h.symbolize_keys.reverse_merge!(url_options) + _routes.url_for(merged_url_options, route_name) when String options when Symbol @@ -192,14 +201,34 @@ def url_for(options = nil) end end - protected + # Allows calling direct or regular named route. + # + # resources :buckets + # + # direct :recordable do |recording| + # route_for(:bucket, recording.bucket) + # end + # + # direct :threadable do |threadable| + # route_for(:recordable, threadable.parent) + # end + # + # This maintains the context of the original caller on whether to return a path + # or full URL, e.g: + # + # threadable_path(threadable) # => "/buckets/1" + # threadable_url(threadable) # => "http://example.com/buckets/1" + # + def route_for(name, *args) + public_send(:"#{name}_url", *args) + end + protected def optimize_routes_generation? _routes.optimize_routes_generation? && default_url_options.empty? end private - def _with_routes(routes) # :doc: old_routes, @_routes = @_routes, routes yield 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/system_test_case.rb b/actionpack/lib/action_dispatch/system_test_case.rb new file mode 100644 index 0000000000000..8f294f299c36d --- /dev/null +++ b/actionpack/lib/action_dispatch/system_test_case.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +# :markup: markdown + +gem "capybara", ">= 3.26" + +require "capybara/dsl" +require "capybara/minitest" +require "action_controller" +require "action_dispatch/system_testing/driver" +require "action_dispatch/system_testing/browser" +require "action_dispatch/system_testing/server" +require "action_dispatch/system_testing/test_helpers/screenshot_helper" +require "action_dispatch/system_testing/test_helpers/setup_and_teardown" + +module ActionDispatch + # # System Testing + # + # System tests let you test applications in the browser. Because system tests + # use a real browser experience, you can test all of your JavaScript easily from + # your test suite. + # + # To create a system test in your application, extend your test class from + # `ApplicationSystemTestCase`. System tests use Capybara as a base and allow you + # to configure the settings through the `application_system_test_case.rb` file, + # which is created when you generate your first system test. + # + # Here is an example system test: + # + # require "application_system_test_case" + # + # class Users::CreateTest < ApplicationSystemTestCase + # test "adding a new user" do + # visit users_path + # click_on 'New User' + # + # fill_in 'Name', with: 'Arya' + # click_on 'Create User' + # + # assert_text 'Arya' + # end + # end + # + # When generating system tests, an + # `application_system_test_case.rb` file will also be generated containing the + # base class for system testing. This is where you can change the driver, add + # Capybara settings, and other configuration for your system tests. + # + # require "test_helper" + # + # class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # driven_by :selenium, using: :chrome, screen_size: [1400, 1400] + # end + # + # By default, `ActionDispatch::SystemTestCase` is driven by the Selenium driver, + # with the Chrome browser, and a browser size of 1400x1400. + # + # Changing the driver configuration options is easy. Let's say you want to use + # the Firefox browser instead of Chrome. In your + # `application_system_test_case.rb` file add the following: + # + # require "test_helper" + # + # class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # driven_by :selenium, using: :firefox + # end + # + # `driven_by` has a required argument for the driver name. The keyword arguments + # are `:using` for the browser and `:screen_size` to change the size of the + # browser screen. These two options are not applicable for headless drivers and + # will be silently ignored if passed. + # + # Headless browsers such as headless Chrome and headless Firefox are also + # supported. You can use these browsers by setting the `:using` argument to + # `:headless_chrome` or `:headless_firefox`. + # + # To use a headless driver, like Cuprite, update your Gemfile to use Cuprite + # instead of Selenium and then declare the driver name in the + # `application_system_test_case.rb` file. In this case, you would leave out the + # `:using` option because the driver is headless, but you can still use + # `:screen_size` to change the size of the browser screen, also you can use + # `:options` to pass options supported by the driver. Please refer to your + # driver documentation to learn about supported options. + # + # require "test_helper" + # require "capybara/cuprite" + # + # class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # driven_by :cuprite, screen_size: [1400, 1400], options: + # { js_errors: true } + # end + # + # Some drivers require browser capabilities to be passed as a block instead of + # through the `options` hash. + # + # As an example, if you want to add mobile emulation on chrome, you'll have to + # create an instance of selenium's `Chrome::Options` object and add capabilities + # with a block. + # + # The block will be passed an instance of `::Options` where you can + # define the capabilities you want. Please refer to your driver documentation to + # learn about supported options. + # + # class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # driven_by :selenium, using: :chrome, screen_size: [1024, 768] do |driver_option| + # driver_option.add_emulation(device_name: 'iPhone 6') + # driver_option.add_extension('path/to/chrome_extension.crx') + # end + # end + # + # Because `ActionDispatch::SystemTestCase` is a shim between Capybara and Rails, + # any driver that is supported by Capybara is supported by system tests as long + # as you include the required gems and files. + class SystemTestCase < ActiveSupport::TestCase + include Capybara::DSL + include Capybara::Minitest::Assertions + include SystemTesting::TestHelpers::SetupAndTeardown + include SystemTesting::TestHelpers::ScreenshotHelper + + DEFAULT_HOST = "http://127.0.0.1" + + def initialize(*) # :nodoc: + super + self.class.driven_by(:selenium) unless self.class.driver? + self.class.driver.use + end + + def self.start_application # :nodoc: + Capybara.app = Rack::Builder.new do + map "/" do + run Rails.application + end + end + + SystemTesting::Server.new.run + end + + class_attribute :driver, instance_accessor: false + + # System Test configuration options + # + # The default settings are Selenium, using Chrome, with a screen size of + # 1400x1400. + # + # Examples: + # + # driven_by :cuprite + # + # driven_by :selenium, screen_size: [800, 800] + # + # driven_by :selenium, using: :chrome + # + # driven_by :selenium, using: :headless_chrome + # + # driven_by :selenium, using: :firefox + # + # driven_by :selenium, using: :headless_firefox + def self.driven_by(driver, using: :chrome, screen_size: [1400, 1400], options: {}, &capabilities) + driver_options = { using: using, screen_size: screen_size, options: options } + + self.driver = SystemTesting::Driver.new(driver, **driver_options, &capabilities) + end + + # Configuration for the System Test application server. + # + # By default this is localhost. This method allows the host and port to be specified manually. + def self.served_by(host:, port:) + Capybara.server_host = host + Capybara.server_port = port + end + + private + def url_helpers + @url_helpers ||= + if ActionDispatch.test_app + Class.new do + include ActionDispatch.test_app.routes.url_helpers + include ActionDispatch.test_app.routes.mounted_helpers + + def url_options + default_url_options.reverse_merge(host: app_host) + end + + def app_host + Capybara.app_host || Capybara.current_session.server_url || DEFAULT_HOST + end + end.new + end + end + + def method_missing(name, ...) + if url_helpers.respond_to?(name) + url_helpers.public_send(name, ...) + else + super + end + end + + def respond_to_missing?(name, include_private = false) + url_helpers.respond_to?(name) + end + end +end + +ActiveSupport.run_load_hooks :action_dispatch_system_test_case, ActionDispatch::SystemTestCase +ActionDispatch::SystemTestCase.start_application diff --git a/actionpack/lib/action_dispatch/system_testing/browser.rb b/actionpack/lib/action_dispatch/system_testing/browser.rb new file mode 100644 index 0000000000000..e08e487691352 --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/browser.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionDispatch + module SystemTesting + class Browser # :nodoc: + attr_reader :name + + def initialize(name) + @name = name + end + + def type + case name + when :headless_chrome + :chrome + when :headless_firefox + :firefox + else + name + end + end + + def options + @options ||= + case type + when :chrome + default_chrome_options + when :firefox + default_firefox_options + end + end + + def configure + yield options if block_given? + end + + # driver_path is lazily initialized by default. Eagerly set it to avoid race + # conditions when using parallel tests. + def preload + case type + when :chrome + resolve_driver_path(::Selenium::WebDriver::Chrome) + when :firefox + resolve_driver_path(::Selenium::WebDriver::Firefox) + end + end + + private + def default_chrome_options + options = ::Selenium::WebDriver::Chrome::Options.new + options.add_argument("--disable-search-engine-choice-screen") + options.add_argument("--headless") if name == :headless_chrome + options.add_argument("--disable-gpu") if Gem.win_platform? + options + end + + def default_firefox_options + options = ::Selenium::WebDriver::Firefox::Options.new + options.add_argument("-headless") if name == :headless_firefox + options + end + + def resolve_driver_path(namespace) + # The path method has been deprecated in 4.20.0 + if Gem::Version.new(::Selenium::WebDriver::VERSION) >= Gem::Version.new("4.20.0") + namespace::Service.driver_path = ::Selenium::WebDriver::DriverFinder.new(options, namespace::Service.new).driver_path + else + namespace::Service.driver_path = ::Selenium::WebDriver::DriverFinder.path(options, namespace::Service) + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_testing/driver.rb b/actionpack/lib/action_dispatch/system_testing/driver.rb new file mode 100644 index 0000000000000..6389ffa3389e5 --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/driver.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionDispatch + module SystemTesting + class Driver # :nodoc: + attr_reader :name + + def initialize(driver_type, **options, &capabilities) + @driver_type = driver_type + @screen_size = options[:screen_size] + @options = options[:options] || {} + @name = @options.delete(:name) || driver_type + @capabilities = capabilities + + if driver_type == :selenium + gem "selenium-webdriver", ">= 4.0.0" + require "selenium/webdriver" + @browser = Browser.new(options[:using]) + @browser.preload unless @options[:browser] == :remote + else + @browser = nil + end + end + + def use + register if registerable? + + setup + end + + private + def registerable? + [:selenium, :cuprite, :rack_test, :playwright].include?(@driver_type) + end + + def register + @browser&.configure(&@capabilities) + + Capybara.register_driver name do |app| + case @driver_type + when :selenium then register_selenium(app) + when :cuprite then register_cuprite(app) + when :rack_test then register_rack_test(app) + when :playwright then register_playwright(app) + end + end + end + + def browser_options + @options.merge(options: @browser.options).compact + end + + def register_selenium(app) + Capybara::Selenium::Driver.new(app, browser: @browser.type, **browser_options).tap do |driver| + driver.browser.manage.window.size = Selenium::WebDriver::Dimension.new(*@screen_size) + end + end + + def register_cuprite(app) + Capybara::Cuprite::Driver.new(app, @options.merge(window_size: @screen_size)) + end + + def register_rack_test(app) + Capybara::RackTest::Driver.new(app, respect_data_method: true, **@options) + end + + def register_playwright(app) + screen = { width: @screen_size[0], height: @screen_size[1] } if @screen_size + options = { + screen: screen, + viewport: screen, + **@options + }.compact + + Capybara::Playwright::Driver.new(app, **options) + end + + def setup + Capybara.current_driver = name + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_testing/server.rb b/actionpack/lib/action_dispatch/system_testing/server.rb new file mode 100644 index 0000000000000..ae3e2e9c2c57a --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/server.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionDispatch + module SystemTesting + class Server # :nodoc: + class << self + attr_accessor :silence_puma + end + + self.silence_puma = false + + def run + setup + end + + private + def setup + set_server + set_port + end + + def set_server + Capybara.server = :puma, { Silent: self.class.silence_puma } if Capybara.server == Capybara.servers[:default] + end + + def set_port + Capybara.always_include_port = true + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb b/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb new file mode 100644 index 0000000000000..3d4f5fb3e57fc --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionDispatch + module SystemTesting + module TestHelpers + # Screenshot helper for system testing. + module ScreenshotHelper + # Takes a screenshot of the current page in the browser. + # + # `take_screenshot` can be used at any point in your system tests to take a + # screenshot of the current state. This can be useful for debugging or + # automating visual testing. You can take multiple screenshots per test to + # investigate changes at different points during your test. These will be named + # with a sequential prefix (or 'failed' for failing tests) + # + # The default screenshots directory is `tmp/screenshots` but you can set a + # different one with `Capybara.save_path` + # + # You can use the `html` argument or set the + # `RAILS_SYSTEM_TESTING_SCREENSHOT_HTML` environment variable to save the HTML + # from the page that is being screenshotted so you can investigate the elements + # on the page at the time of the screenshot + # + # You can use the `screenshot` argument or set the + # `RAILS_SYSTEM_TESTING_SCREENSHOT` environment variable to control the output. + # Possible values are: + # `simple` (default) + # : Only displays the screenshot path. This is the default value. + # + # `inline` + # : Display the screenshot in the terminal using the iTerm image protocol + # (https://iterm2.com/documentation-images.html). + # + # `artifact` + # : Display the screenshot in the terminal, using the terminal artifact + # format (https://buildkite.github.io/terminal-to-html/inline-images/). + # + # + def take_screenshot(html: false, screenshot: nil) + showing_html = html || html_from_env? + + increment_unique + save_html if showing_html + save_image + show display_image(html: showing_html, screenshot_output: screenshot) + end + + # Takes a screenshot of the current page in the browser if the test failed. + # + # `take_failed_screenshot` is called during system test teardown. + def take_failed_screenshot + return unless failed? && supports_screenshot? && Capybara::Session.instance_created? + + take_screenshot + metadata[:failure_screenshot_path] = relative_image_path if Minitest::Runnable.method_defined?(:metadata) + end + + private + attr_accessor :_screenshot_counter + + def html_from_env? + ENV["RAILS_SYSTEM_TESTING_SCREENSHOT_HTML"] == "1" + end + + def increment_unique + @_screenshot_counter ||= 0 + @_screenshot_counter += 1 + end + + def unique + failed? ? "failures" : (_screenshot_counter || 0).to_s + end + + def image_name + sanitized_method_name = method_name.gsub(/[^\w]+/, "-") + name = "#{unique}_#{sanitized_method_name}" + name[0...225] + end + + def image_path + absolute_image_path.to_s + end + + def html_path + absolute_html_path.to_s + end + + def absolute_path + Rails.root.join(screenshots_dir, image_name) + end + + def screenshots_dir + Capybara.save_path.presence || "tmp/screenshots" + end + + def absolute_image_path + "#{absolute_path}.png" + end + + def relative_image_path + "#{absolute_path.relative_path_from(Rails.root)}.png" + end + + def absolute_html_path + "#{absolute_path}.html" + end + + # rubocop:disable Lint/Debugger + def save_html + page.save_page(absolute_html_path) + end + + def save_image + page.save_screenshot(absolute_image_path) + end + # rubocop:enable Lint/Debugger + + def output_type + # Environment variables have priority + output_type = ENV["RAILS_SYSTEM_TESTING_SCREENSHOT"] || ENV["CAPYBARA_INLINE_SCREENSHOT"] + + # Default to outputting a path to the screenshot + output_type ||= "simple" + + output_type + end + + def show(img) + puts img + end + + def display_image(html:, screenshot_output:) + message = +"[Screenshot Image]: #{image_path} \n" + message << +"[Screenshot HTML]: #{html_path} \n" if html + + case screenshot_output || output_type + when "artifact" + message << "\e]1338;url=artifact://#{absolute_image_path}\a\n" + when "inline" + name = inline_base64(File.basename(absolute_image_path)) + image = inline_base64(File.read(absolute_image_path)) + message << "\e]1337;File=name=#{name};height=400px;inline=1:#{image}\a\n" + end + + message + end + + def inline_base64(path) + Base64.strict_encode64(path) + end + + def failed? + !passed? && !skipped? + end + + def supports_screenshot? + Capybara.current_driver != :rack_test + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb b/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb new file mode 100644 index 0000000000000..f0eb3459426a4 --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionDispatch + module SystemTesting + module TestHelpers + module SetupAndTeardown # :nodoc: + def before_teardown + take_failed_screenshot + ensure + super + end + + def after_teardown + Capybara.reset_sessions! + ensure + super + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertion_response.rb b/actionpack/lib/action_dispatch/testing/assertion_response.rb index c37726957e718..753285ac89cf3 100644 --- a/actionpack/lib/action_dispatch/testing/assertion_response.rb +++ b/actionpack/lib/action_dispatch/testing/assertion_response.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch - # This is a class that abstracts away an asserted response. It purposely - # does not inherit from Response because it doesn't need it. That means it - # does not have headers or a body. + # This is a class that abstracts away an asserted response. It purposely does + # not inherit from Response because it doesn't need it. That means it does not + # have headers or a body. class AssertionResponse attr_reader :code, :name @@ -12,9 +16,9 @@ class AssertionResponse error: "5XX" } - # Accepts a specific response status code as an Integer (404) or String - # ('404') or a response status range as a Symbol pseudo-code (:success, - # indicating any 200-299 status code). + # Accepts a specific response status code as an Integer (404) or String ('404') + # or a response status range as a Symbol pseudo-code (:success, indicating any + # 200-299 status code). def initialize(code_or_name) if code_or_name.is_a?(Symbol) @name = code_or_name @@ -33,9 +37,8 @@ def code_and_name end private - def code_from_name(name) - GENERIC_RESPONSE_CODES[name] || Rack::Utils::SYMBOL_TO_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.rb b/actionpack/lib/action_dispatch/testing/assertions.rb index 4ea18d671d648..e32858ddcc88e 100644 --- a/actionpack/lib/action_dispatch/testing/assertions.rb +++ b/actionpack/lib/action_dispatch/testing/assertions.rb @@ -1,10 +1,13 @@ +# frozen_string_literal: true + +# :markup: markdown + require "rails-dom-testing" +require "action_dispatch/testing/assertions/response" +require "action_dispatch/testing/assertions/routing" module ActionDispatch module Assertions - autoload :ResponseAssertions, "action_dispatch/testing/assertions/response" - autoload :RoutingAssertions, "action_dispatch/testing/assertions/routing" - extend ActiveSupport::Concern include ResponseAssertions @@ -12,10 +15,10 @@ module Assertions include Rails::Dom::Testing::Assertions def html_document - @html_document ||= if @response.content_type.to_s.end_with?("xml") + @html_document ||= if @response.media_type&.end_with?("xml") Nokogiri::XML::Document.parse(@response.body) else - Nokogiri::HTML::Document.parse(@response.body) + Rails::Dom::Testing.html_document.parse(@response.body) end end end diff --git a/actionpack/lib/action_dispatch/testing/assertions/response.rb b/actionpack/lib/action_dispatch/testing/assertions/response.rb index 817737341ce09..816d5f6192728 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/response.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/response.rb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionDispatch module Assertions - # A small suite of assertions that test responses from \Rails applications. + # A small suite of assertions that test responses from Rails applications. module ResponseAssertions RESPONSE_PREDICATES = { # :nodoc: success: :successful?, @@ -11,56 +15,76 @@ module ResponseAssertions # Asserts that the response is one of the following types: # - # * :success - Status code was in the 200-299 range - # * :redirect - Status code was in the 300-399 range - # * :missing - Status code was 404 - # * :error - Status code was in the 500-599 range + # * `:success` - Status code was in the 200-299 range + # * `:redirect` - Status code was in the 300-399 range + # * `:missing` - Status code was 404 + # * `:error` - Status code was in the 500-599 range # - # You can also pass an explicit status number like assert_response(501) - # or its symbolic equivalent assert_response(:not_implemented). - # See Rack::Utils::SYMBOL_TO_STATUS_CODE for a full list. # - # # Asserts that the response was a redirection - # assert_response :redirect + # You can also pass an explicit status number like `assert_response(501)` or its + # symbolic equivalent `assert_response(:not_implemented)`. See + # `Rack::Utils::SYMBOL_TO_STATUS_CODE` for a full list. # - # # Asserts that the response code was status code 401 (unauthorized) - # assert_response 401 + # # Asserts that the response was a redirection + # assert_response :redirect + # + # # Asserts that the response code was status code 401 (unauthorized) + # assert_response 401 def assert_response(type, message = nil) message ||= generate_response_message(type) - if RESPONSE_PREDICATES.keys.include?(type) - assert @response.send(RESPONSE_PREDICATES[type]), message + if RESPONSE_PREDICATES.key?(type) + assert @response.public_send(RESPONSE_PREDICATES[type]), message else assert_equal AssertionResponse.new(type).code, @response.response_code, message end end - # Asserts that the redirection options passed in match those of the redirect called in the latest action. - # This match can be partial, such that assert_redirected_to(controller: "weblog") will also - # match the redirection of redirect_to(controller: "weblog", action: "show") and so on. + # Asserts that the response is a redirect to a URL matching the given options. + # + # # Asserts that the redirection was to the "index" action on the WeblogController + # assert_redirected_to controller: "weblog", action: "index" # - # # Asserts that the redirection was to the "index" action on the WeblogController - # assert_redirected_to controller: "weblog", action: "index" + # # Asserts that the redirection was to the named route login_url + # assert_redirected_to login_url # - # # Asserts that the redirection was to the named route login_url - # assert_redirected_to login_url + # # Asserts that the redirection was to the URL for @customer + # assert_redirected_to @customer # - # # Asserts that the redirection was to the url for @customer - # assert_redirected_to @customer + # # Asserts that the redirection matches the regular expression + # assert_redirected_to %r(\Ahttp://example.org) # - # # Asserts that the redirection matches the regular expression - # assert_redirected_to %r(\Ahttp://example.org) - def assert_redirected_to(options = {}, message = nil) - assert_response(:redirect, message) - return true if options === @response.location + # # Asserts that the redirection has the HTTP status code 301 (Moved + # # Permanently). + # assert_redirected_to "/some/path", status: :moved_permanently + def assert_redirected_to(url_options = {}, options = {}, message = nil) + options, message = {}, options unless options.is_a?(Hash) + + status = options[:status] || :redirect + assert_response(status, message) + return true if url_options === @response.location redirect_is = normalize_argument_to_redirection(@response.location) - redirect_expected = normalize_argument_to_redirection(options) + redirect_expected = normalize_argument_to_redirection(url_options) message ||= "Expected response to be a redirect to <#{redirect_expected}> but was a redirect to <#{redirect_is}>" 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) @@ -77,9 +101,13 @@ def normalize_argument_to_redirection(fragment) end def generate_response_message(expected, actual = @response.response_code) - "Expected response to be a <#{code_with_name(expected)}>,"\ - " but was a <#{code_with_name(actual)}>" - .concat(location_if_redirected).concat(response_body_if_short) + lambda do + (+"Expected response to be a <#{code_with_name(expected)}>,"\ + " but was a <#{code_with_name(actual)}>"). + concat(location_if_redirected). + concat(exception_if_present). + concat(response_body_if_short) + end end def response_body_if_short @@ -87,6 +115,11 @@ def response_body_if_short "\nResponse body: #{@response.body}" end + def exception_if_present + return "" unless ex = @request&.env&.[]("action_dispatch.exception") + "\n\nException while processing request: #{Minitest::UnexpectedError.new(ex).message}\n" + end + def location_if_redirected return "" unless @response.redirection? && @response.location.present? location = normalize_argument_to_redirection(@response.location) @@ -94,7 +127,7 @@ def location_if_redirected end def code_with_name(code_or_name) - if RESPONSE_PREDICATES.values.include?("#{code_or_name}?".to_sym) + if RESPONSE_PREDICATES.value?("#{code_or_name}?".to_sym) code_or_name = RESPONSE_PREDICATES.invert["#{code_or_name}?".to_sym] end diff --git a/actionpack/lib/action_dispatch/testing/assertions/routing.rb b/actionpack/lib/action_dispatch/testing/assertions/routing.rb index 37c1ca02b6fb1..af1131d519e48 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/routing.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/routing.rb @@ -1,42 +1,178 @@ +# frozen_string_literal: true + +# :markup: markdown + require "uri" require "active_support/core_ext/hash/indifferent_access" require "active_support/core_ext/string/access" +require "active_support/core_ext/module/redefine_method" require "action_controller/metal/exceptions" module ActionDispatch module Assertions - # Suite of assertions to test routes generated by \Rails and the handling of requests made to them. + # Suite of assertions to test routes generated by Rails and the handling of + # requests made to them. module RoutingAssertions - # Asserts that the routing of the given +path+ was handled correctly and that the parsed options (given in the +expected_options+ hash) - # match +path+. Basically, it asserts that \Rails recognizes the route given by +expected_options+. + extend ActiveSupport::Concern + + module WithIntegrationRouting # :nodoc: + extend ActiveSupport::Concern + + module ClassMethods + def with_routing(&block) + old_routes = nil + old_routes_call_method = nil + old_integration_session = nil + + setup do + old_routes = initialize_lazy_routes(app.routes) + old_routes_call_method = old_routes.method(:call) + old_integration_session = integration_session + create_routes(&block) + end + + teardown do + reset_routes(old_routes, old_routes_call_method, old_integration_session) + end + end + end + + def with_routing(&block) + old_routes = initialize_lazy_routes(app.routes) + old_routes_call_method = old_routes.method(:call) + old_integration_session = integration_session + create_routes(&block) + ensure + reset_routes(old_routes, old_routes_call_method, old_integration_session) + 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 + + @original_routes ||= app.routes + @original_routes.singleton_class.redefine_method(:call, &routes.method(:call)) + + https = integration_session.https? + host = integration_session.host + + app.instance_variable_set(:@routes, routes) + @integration_session = Class.new(ActionDispatch::Integration::Session) do + include app.routes.url_helpers + include app.routes.mounted_helpers + end.new(app) + @integration_session.https! https + @integration_session.host! host + @routes = routes + + yield routes + end + + def reset_routes(old_routes, old_routes_call_method, old_integration_session) + app.instance_variable_set(:@routes, old_routes) + @original_routes.singleton_class.redefine_method(:call, &old_routes_call_method) + @integration_session = old_integration_session + @routes = old_routes + end + end + + module ClassMethods + # A helper to make it easier to test different route configurations. This method + # temporarily replaces @routes with a new RouteSet instance before each test. + # + # The new instance is yielded to the passed block. Typically the block will + # create some routes using `set.draw { match ... }`: + # + # with_routing do |set| + # set.draw do + # resources :users + # end + # end + # + def with_routing(&block) + old_routes, old_controller = nil + + setup do + old_routes, old_controller = @routes, @controller + create_routes(&block) + end + + teardown do + reset_routes(old_routes, old_controller) + end + end + end + + def setup # :nodoc: + @routes ||= nil + super + end + + # A helper to make it easier to test different route configurations. This method + # temporarily replaces @routes with a new RouteSet instance. + # + # The new instance is yielded to the passed block. Typically the block will + # create some routes using `set.draw { match ... }`: + # + # with_routing do |set| + # set.draw do + # resources :users + # end + # assert_equal "/users", users_path + # end + # + def with_routing(config = nil, &block) + old_routes, old_controller = @routes, @controller + create_routes(config, &block) + ensure + reset_routes(old_routes, old_controller) + end + + # Asserts that the routing of the given `path` was handled correctly and that + # the parsed options (given in the `expected_options` hash) match `path`. + # Basically, it asserts that Rails recognizes the route given by + # `expected_options`. # - # Pass a hash in the second argument (+path+) to specify the request method. This is useful for routes - # requiring a specific HTTP method. The hash should contain a :path with the incoming request path - # and a :method containing the required HTTP verb. + # Pass a hash in the second argument (`path`) to specify the request method. + # This is useful for routes requiring a specific HTTP method. The hash should + # contain a `:path` with the incoming request path and a `:method` containing + # the required HTTP verb. # - # # Asserts that POSTing to /items will call the create action on ItemsController - # assert_recognizes({controller: 'items', action: 'create'}, {path: 'items', method: :post}) + # # Asserts that POSTing to /items will call the create action on ItemsController + # assert_recognizes({controller: 'items', action: 'create'}, {path: 'items', method: :post}) # - # You can also pass in +extras+ with a hash containing URL parameters that would normally be in the query string. This can be used - # to assert that values in the query string will end up in the params hash correctly. To test query strings you must use the - # extras argument, appending the query string on the path directly will not work. For example: + # You can also pass in `extras` with a hash containing URL parameters that would + # normally be in the query string. This can be used to assert that values in the + # query string will end up in the params hash correctly. To test query strings + # you must use the extras argument because appending the query string on the + # path directly will not work. For example: # - # # Asserts that a path of '/items/list/1?view=print' returns the correct options - # assert_recognizes({controller: 'items', action: 'list', id: '1', view: 'print'}, 'items/list/1', { view: "print" }) + # # Asserts that a path of '/items/list/1?view=print' returns the correct options + # assert_recognizes({controller: 'items', action: 'list', id: '1', view: 'print'}, 'items/list/1', { view: "print" }) # - # The +message+ parameter allows you to pass in an error message that is displayed upon failure. + # The `message` parameter allows you to pass in an error message that is + # displayed upon failure. # - # # Check the default route (i.e., the index action) - # assert_recognizes({controller: 'items', action: 'index'}, 'items') + # # Check the default route (i.e., the index action) + # assert_recognizes({controller: 'items', action: 'index'}, 'items') # - # # Test a specific action - # assert_recognizes({controller: 'items', action: 'list'}, 'items/list') + # # Test a specific action + # assert_recognizes({controller: 'items', action: 'list'}, 'items/list') # - # # Test an action with a parameter - # assert_recognizes({controller: 'items', action: 'destroy', id: '1'}, 'items/destroy/1') + # # Test an action with a parameter + # assert_recognizes({controller: 'items', action: 'destroy', id: '1'}, 'items/destroy/1') # - # # Test a custom route - # assert_recognizes({controller: 'items', action: 'show', id: '1'}, 'view/item1') + # # Test a custom route + # assert_recognizes({controller: 'items', action: 'show', id: '1'}, 'view/item1') def assert_recognizes(expected_options, path, extras = {}, msg = nil) if path.is_a?(Hash) && path[:method].to_s == "all" [:get, :post, :put, :delete].each do |method| @@ -58,33 +194,34 @@ def assert_recognizes(expected_options, path, extras = {}, msg = nil) end end - # Asserts that the provided options can be used to generate the provided path. This is the inverse of +assert_recognizes+. - # The +extras+ parameter is used to tell the request the names and values of additional request parameters that would be in - # a query string. The +message+ parameter allows you to specify a custom error message for assertion failures. + # Asserts that the provided options can be used to generate the provided path. + # This is the inverse of `assert_recognizes`. The `extras` parameter is used to + # tell the request the names and values of additional request parameters that + # would be in a query string. The `message` parameter allows you to specify a + # custom error message for assertion failures. # - # The +defaults+ parameter is unused. + # The `defaults` parameter is unused. # - # # Asserts that the default action is generated for a route with no action - # assert_generates "/items", controller: "items", action: "index" + # # Asserts that the default action is generated for a route with no action + # assert_generates "/items", controller: "items", action: "index" # - # # Tests that the list action is properly routed - # assert_generates "/items/list", controller: "items", action: "list" + # # Tests that the list action is properly routed + # assert_generates "/items/list", controller: "items", action: "list" # - # # Tests the generation of a route with a parameter - # assert_generates "/items/list/1", { controller: "items", action: "list", id: "1" } + # # Tests the generation of a route with a parameter + # assert_generates "/items/list/1", { controller: "items", action: "list", id: "1" } # - # # Asserts that the generated route gives us our custom route - # assert_generates "changesets/12", { controller: 'scm', action: 'show_diff', revision: "12" } + # # Asserts that the generated route gives us our custom route + # assert_generates "changesets/12", { controller: 'scm', action: 'show_diff', revision: "12" } def assert_generates(expected_path, options, defaults = {}, extras = {}, message = nil) - if expected_path =~ %r{://} + if expected_path.include?("://") fail_on(URI::InvalidURIError, message) do uri = URI.parse(expected_path) expected_path = uri.path.to_s.empty? ? "/" : uri.path end else - expected_path = "/#{expected_path}" unless expected_path.first == "/" + expected_path = "/#{expected_path}" unless expected_path.start_with?("/") end - # Load routes.rb if it hasn't been loaded. options = options.clone generated_path, query_string_keys = @routes.generate_extras(options, defaults) @@ -98,27 +235,28 @@ def assert_generates(expected_path, options, defaults = {}, extras = {}, message assert_equal(expected_path, generated_path, msg) end - # Asserts that path and options match both ways; in other words, it verifies that path generates - # options and then that options generates path. This essentially combines +assert_recognizes+ - # and +assert_generates+ into one step. + # Asserts that path and options match both ways; in other words, it verifies + # that `path` generates `options` and then that `options` generates `path`. This + # essentially combines `assert_recognizes` and `assert_generates` into one step. # - # The +extras+ hash allows you to specify options that would normally be provided as a query string to the action. The - # +message+ parameter allows you to specify a custom error message to display upon failure. + # The `extras` hash allows you to specify options that would normally be + # provided as a query string to the action. The `message` parameter allows you + # to specify a custom error message to display upon failure. # - # # Asserts a basic route: a controller with the default action (index) - # assert_routing '/home', controller: 'home', action: 'index' + # # Asserts a basic route: a controller with the default action (index) + # assert_routing '/home', controller: 'home', action: 'index' # - # # Test a route generated with a specific controller, action, and parameter (id) - # assert_routing '/entries/show/23', controller: 'entries', action: 'show', id: 23 + # # Test a route generated with a specific controller, action, and parameter (id) + # assert_routing '/entries/show/23', controller: 'entries', action: 'show', id: 23 # - # # Asserts a basic route (controller + default action), with an error message if it fails - # assert_routing '/store', { controller: 'store', action: 'index' }, {}, {}, 'Route for store index not generated properly' + # # Asserts a basic route (controller + default action), with an error message if it fails + # assert_routing '/store', { controller: 'store', action: 'index' }, {}, {}, 'Route for store index not generated properly' # - # # Tests a route, providing a defaults hash - # assert_routing 'controller/action/9', {id: "9", item: "square"}, {controller: "controller", action: "action"}, {}, {item: "square"} + # # Tests a route, providing a defaults hash + # assert_routing 'controller/action/9', {id: "9", item: "square"}, {controller: "controller", action: "action"}, {}, {item: "square"} # - # # Tests a route with an HTTP method - # assert_routing({ method: 'put', path: '/product/321' }, { controller: "product", action: "update", id: "321" }) + # # Tests a route with an HTTP method + # assert_routing({ method: 'put', path: '/product/321' }, { controller: "product", action: "update", id: "321" }) def assert_routing(path, options, defaults = {}, extras = {}, message = nil) assert_recognizes(options, path, extras, message) @@ -131,52 +269,47 @@ def assert_routing(path, options, defaults = {}, extras = {}, message = nil) assert_generates(path.is_a?(Hash) ? path[:path] : path, generate_options, defaults, extras, message) end - # A helper to make it easier to test different route configurations. - # This method temporarily replaces @routes - # with a new RouteSet instance. - # - # The new instance is yielded to the passed block. Typically the block - # will create some routes using set.draw { match ... }: - # - # with_routing do |set| - # set.draw do - # resources :users - # end - # assert_equal "/users", users_path - # end - # - def with_routing - old_routes, @routes = @routes, ActionDispatch::Routing::RouteSet.new - if defined?(@controller) && @controller - old_controller, @controller = @controller, @controller.clone - _routes = @routes + # ROUTES TODO: These assertions should really work in an integration context + def method_missing(selector, ...) + if @controller && @routes&.named_routes&.route_defined?(selector) + @controller.public_send(selector, ...) + else + super + end + end + + private + def create_routes(config = nil) + @routes = ActionDispatch::Routing::RouteSet.new(config || ActionDispatch::Routing::RouteSet::DEFAULT_CONFIG) + if @controller + @controller = @controller.clone + _routes = @routes + + @controller.singleton_class.include(_routes.url_helpers) - @controller.singleton_class.include(_routes.url_helpers) + if @controller.respond_to? :view_context_class + view_context_class = Class.new(@controller.view_context_class) do + include _routes.url_helpers + end - if @controller.respond_to? :view_context_class - @controller.view_context_class = Class.new(@controller.view_context_class) do - include _routes.url_helpers + custom_view_context = Module.new { + define_method(:view_context_class) do + view_context_class + end + } + @controller.extend(custom_view_context) end end + yield @routes end - yield @routes - ensure - @routes = old_routes - if defined?(@controller) && @controller - @controller = old_controller - end - end - # ROUTES TODO: These assertions should really work in an integration context - def method_missing(selector, *args, &block) - if defined?(@controller) && @controller && defined?(@routes) && @routes && @routes.named_routes.route_defined?(selector) - @controller.send(selector, *args, &block) - else - super + def reset_routes(old_routes, old_controller) + @routes = old_routes + if @controller + @controller = old_controller + end end - end - private # Recognizes the route for a given path. def recognized_request_for(path, extras = {}, msg) if path.is_a?(Hash) @@ -186,10 +319,10 @@ def recognized_request_for(path, extras = {}, msg) method = :get end - # Assume given controller - request = ActionController::TestRequest.create @controller.class + controller = @controller if defined?(@controller) + request = ActionController::TestRequest.create controller&.class - if path =~ %r{://} + if path.include?("://") fail_on(URI::InvalidURIError, msg) do uri = URI.parse(path) request.env["rack.url_scheme"] = uri.scheme || "http" @@ -198,7 +331,7 @@ def recognized_request_for(path, extras = {}, msg) request.path = uri.path.to_s.empty? ? "/" : uri.path end else - path = "/#{path}" unless path.first == "/" + path = "/#{path}" unless path.start_with?("/") request.path = path end @@ -215,7 +348,7 @@ def recognized_request_for(path, extras = {}, msg) def fail_on(exception_class, message) yield rescue exception_class => e - raise Minitest::Assertion, message || e.message + flunk(message || e.message) end end end diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb index 5fa0b727abee2..2cd77cbc321e7 100644 --- a/actionpack/lib/action_dispatch/testing/integration.rb +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -1,82 +1,101 @@ +# frozen_string_literal: true + +# :markup: markdown + require "stringio" require "uri" -require "active_support/core_ext/kernel/singleton_class" -require "active_support/core_ext/object/try" require "rack/test" -require "minitest" +require "active_support/test_case" require "action_dispatch/testing/request_encoder" +require "action_dispatch/testing/test_helpers/page_dump_helper" module ActionDispatch - module Integration #:nodoc: + module Integration # :nodoc: module RequestHelpers - # Performs a GET request with the given parameters. See +#process+ for more - # details. + # Performs a GET request with the given parameters. See + # ActionDispatch::Integration::Session#process for more details. def get(path, **args) process(:get, path, **args) end - # Performs a POST request with the given parameters. See +#process+ for more - # details. + # Performs a POST request with the given parameters. See + # ActionDispatch::Integration::Session#process for more details. def post(path, **args) process(:post, path, **args) end - # Performs a PATCH request with the given parameters. See +#process+ for more - # details. + # Performs a PATCH request with the given parameters. See + # ActionDispatch::Integration::Session#process for more details. def patch(path, **args) process(:patch, path, **args) end - # Performs a PUT request with the given parameters. See +#process+ for more - # details. + # Performs a PUT request with the given parameters. See + # ActionDispatch::Integration::Session#process for more details. def put(path, **args) process(:put, path, **args) end - # Performs a DELETE request with the given parameters. See +#process+ for - # more details. + # Performs a DELETE request with the given parameters. See + # ActionDispatch::Integration::Session#process for more details. def delete(path, **args) process(:delete, path, **args) end - # Performs a HEAD request with the given parameters. See +#process+ for more - # details. - def head(path, *args) - process(:head, path, *args) + # Performs a HEAD request with the given parameters. See + # ActionDispatch::Integration::Session#process for more details. + def head(path, **args) + process(:head, path, **args) + end + + # Performs an OPTIONS request with the given parameters. See + # ActionDispatch::Integration::Session#process for more details. + def options(path, **args) + process(:options, path, **args) end - # Follow a single redirect response. If the last response was not a - # redirect, an exception will be raised. Otherwise, the redirect is - # performed on the location header. - def follow_redirect! + # Follow a single redirect response. If the last response was not a redirect, an + # exception will be raised. Otherwise, the redirect is performed on the location + # header. If the redirection is a 307 or 308 redirect, the same HTTP verb will + # be used when redirecting, otherwise a GET request will be performed. Any + # arguments are passed to the underlying request. + # + # The HTTP_REFERER header will be set to the previous url. + def follow_redirect!(headers: {}, **args) raise "not a redirect! #{status} #{status_message}" unless redirect? - get(response.location) + + method = + if [307, 308].include?(response.status) + request.method.downcase + else + :get + end + + if [ :HTTP_REFERER, "HTTP_REFERER" ].none? { |key| headers.key? key } + headers["HTTP_REFERER"] = request.url + end + + public_send(method, response.location, headers: headers, **args) status end end - # An instance of this class represents a set of requests and responses - # performed sequentially by a test process. Because you can instantiate - # multiple sessions and run them side-by-side, you can also mimic (to some - # limited extent) multiple simultaneous users interacting with your system. + # An instance of this class represents a set of requests and responses performed + # sequentially by a test process. Because you can instantiate multiple sessions + # and run them side-by-side, you can also mimic (to some limited extent) + # multiple simultaneous users interacting with your system. # - # Typically, you will instantiate a new session using - # IntegrationTest#open_session, rather than instantiating - # Integration::Session directly. + # Typically, you will instantiate a new session using Runner#open_session, + # rather than instantiating a Session directly. class Session DEFAULT_HOST = "www.example.com" include Minitest::Assertions include TestProcess, RequestHelpers, Assertions - %w( status status_message headers body redirect? ).each do |method| - delegate method, to: :response, allow_nil: true - end - - %w( path ).each do |method| - delegate method, to: :request, allow_nil: true - end + delegate :status, :status_message, :headers, :body, :redirect?, to: :response, allow_nil: true + delegate :path, to: :request, allow_nil: true # The hostname used in the last request. def host @@ -90,8 +109,8 @@ def host # The Accept header to send. attr_accessor :accept - # A map of the cookies returned by the last response, and which will be - # sent with the next request. + # A map of the cookies returned by the last response, and which will be sent + # with the next request. def cookies _mock_session.cookie_jar end @@ -120,7 +139,7 @@ def initialize(app) def url_options @url_options ||= default_url_options.dup.tap do |url_options| - url_options.reverse_merge!(controller.url_options) if controller + url_options.reverse_merge!(controller.url_options) if controller.respond_to?(:url_options) if @app.respond_to?(:routes) url_options.reverse_merge!(@app.routes.default_url_options) @@ -130,11 +149,10 @@ def url_options end end - # Resets the instance. This can be used to reset the state information - # in an existing session instance, so it can be used from a clean-slate - # condition. + # Resets the instance. This can be used to reset the state information in an + # existing session instance, so it can be used from a clean-slate condition. # - # session.reset! + # session.reset! def reset! @https = false @controller = @request = @response = nil @@ -149,57 +167,61 @@ def reset! "*/*;q=0.5" unless defined? @named_routes_configured - # the helpers are made protected by default--we make them public for - # easier access during testing and troubleshooting. + # the helpers are made protected by default--we make them public for easier + # access during testing and troubleshooting. @named_routes_configured = true end end # Specify whether or not the session should mimic a secure HTTPS request. # - # session.https! - # session.https!(false) + # session.https! + # session.https!(false) def https!(flag = true) @https = flag end - # Returns +true+ if the session is mimicking a secure HTTPS request. + # Returns `true` if the session is mimicking a secure HTTPS request. # - # if session.https? - # ... - # end + # if session.https? + # ... + # end def https? @https end # Performs the actual request. # - # - +method+: The HTTP method (GET, POST, PATCH, PUT, DELETE, HEAD, OPTIONS) - # as a symbol. - # - +path+: The URI (as a String) on which you want to perform the - # request. - # - +params+: The HTTP parameters that you want to pass. This may - # be +nil+, - # a Hash, or a String that is appropriately encoded - # (application/x-www-form-urlencoded or - # multipart/form-data). - # - +headers+: Additional headers to pass, as a Hash. The headers will be - # merged into the Rack env hash. - # - +env+: Additional env to pass, as a Hash. The headers will be - # merged into the Rack env hash. + # * `method`: The HTTP method (GET, POST, PATCH, PUT, DELETE, HEAD, OPTIONS) + # as a symbol. + # * `path`: The URI (as a String) on which you want to perform the request. + # * `params`: The HTTP parameters that you want to pass. This may be `nil`, a + # Hash, or a String that is appropriately encoded + # (`application/x-www-form-urlencoded` or `multipart/form-data`). + # * `headers`: Additional headers to pass, as a Hash. The headers will be + # merged into the Rack env hash. + # * `env`: Additional env to pass, as a Hash. The headers will be merged into + # the Rack env hash. + # * `xhr`: Set to `true` if you want to make an Ajax request. Adds request + # headers characteristic of XMLHttpRequest e.g. HTTP_X_REQUESTED_WITH. The + # headers will be merged into the Rack env hash. + # * `as`: Used for encoding the request with different content type. Supports + # `:json` by default and will set the appropriate request headers. The + # headers will be merged into the Rack env hash. + # # - # This method is rarely used directly. Use +#get+, +#post+, or other standard - # HTTP methods in integration tests. +#process+ is only required when using a - # request method that doesn't have a method defined in the integration tests. + # This method is rarely used directly. Use RequestHelpers#get, + # RequestHelpers#post, or other standard HTTP methods in integration tests. + # `#process` is only required when using a request method that doesn't have a + # method defined in the integration tests. # - # This method returns a Response object, which one can use to - # inspect the details of the response. Furthermore, if this method was - # called from an ActionDispatch::IntegrationTest object, then that - # object's @response instance variable will point to the same - # response object. + # This method returns the response status, after performing the request. + # Furthermore, if this method was called from an ActionDispatch::IntegrationTest + # object, then that object's `@response` instance variable will point to a + # Response object which one can use to inspect the details of the response. # # Example: - # process :get, '/author', params: { since: 201501011400 } + # process :get, '/author', params: { since: 201501011400 } def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: nil) request_encoder = RequestEncoder.encoder(as) headers ||= {} @@ -209,7 +231,7 @@ def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: n method = :post end - if path =~ %r{://} + if path.include?("://") path = build_expanded_path(path) do |location| https! URI::HTTPS === location if location.scheme @@ -235,10 +257,13 @@ def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: n "REQUEST_URI" => path, "HTTP_HOST" => host, "REMOTE_ADDR" => remote_addr, - "CONTENT_TYPE" => request_encoder.content_type, "HTTP_ACCEPT" => request_encoder.accept_header || accept } + if request_encoder.content_type + request_env["CONTENT_TYPE"] = request_encoder.content_type + end + wrapped_headers = Http::Headers.from_hash({}) wrapped_headers.merge!(headers) if headers @@ -247,7 +272,7 @@ def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: n wrapped_headers["HTTP_ACCEPT"] ||= [Mime[:js], Mime[:html], Mime[:xml], "text/xml", "*/*"].join(", ") end - # this modifies the passed request_env directly + # This modifies the passed request_env directly. if wrapped_headers.present? Http::Headers.from_hash(request_env).merge!(wrapped_headers) end @@ -257,9 +282,19 @@ def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: n session = Rack::Test::Session.new(_mock_session) - # NOTE: rack-test v0.5 doesn't build a default uri correctly - # Make sure requested path is always a full uri - session.request(build_full_uri(path, request_env), request_env) + # NOTE: rack-test v0.5 doesn't build a default uri correctly Make sure requested + # path is always a full URI. + uri = build_full_uri(path, request_env) + + if method == :get && String === request_env[:params] + # rack-test will needlessly parse and rebuild a :params + # querystring, using Rack's query parser. At best that's a + # waste of time; at worst it can change the value. + + uri << "?" << request_env.delete(:params) + end + + session.request(uri, request_env) @request_count += 1 @request = ActionDispatch::Request.new(session.last_request.env) @@ -276,7 +311,7 @@ def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: n # Set the host name to use in the next request. # - # session.host! "www.example.com" + # session.host! "www.example.com" alias :host! :host= private @@ -302,6 +337,7 @@ module Runner APP_SESSIONS = {} attr_reader :app + attr_accessor :root_session # :nodoc: def initialize(*args, &blk) super(*args, &blk) @@ -317,17 +353,17 @@ def integration_session @integration_session ||= create_session(app) end - # Reset the current session. This is useful for testing multiple sessions - # in a single test case. + # Reset the current session. This is useful for testing multiple sessions in a + # single test case. def reset! @integration_session = create_session(app) end def create_session(app) klass = APP_SESSIONS[app] ||= Class.new(Integration::Session) { - # If the app is a Rails app, make url_helpers available on the session - # This makes app.url_for and app.foo_path available in the console - if app.respond_to?(:routes) + # If the app is a Rails app, make url_helpers available on the session. This + # makes app.url_for and app.foo_path available in the console. + if app.respond_to?(:routes) && app.routes.is_a?(ActionDispatch::Routing::RouteSet) include app.routes.url_helpers include app.routes.mounted_helpers end @@ -339,40 +375,51 @@ def remove! # :nodoc: @integration_session = nil end - %w(get post patch put head delete cookies assigns - xml_http_request xhr get_via_redirect post_via_redirect).each do |method| - define_method(method) do |*args| - # reset the html_document variable, except for cookies/assigns calls - unless method == "cookies" || method == "assigns" - @html_document = nil - end + %w(get post patch put head delete cookies assigns follow_redirect!).each do |method| + # reset the html_document variable, except for cookies/assigns calls + unless method == "cookies" || method == "assigns" + reset_html_document = "@html_document = nil" + end + + module_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{method}(...) + #{reset_html_document} - integration_session.__send__(method, *args).tap do + result = integration_session.#{method}(...) copy_session_variables! + result end - end + RUBY end - # Open a new session instance. If a block is given, the new session is - # yielded to the block before being returned. + # Open a new session instance. If a block is given, the new session is yielded + # to the block before being returned. # - # session = open_session do |sess| - # sess.extend(CustomAssertions) - # end + # session = open_session do |sess| + # sess.extend(CustomAssertions) + # end # - # By default, a single session is automatically created for you, but you - # can use this method to open multiple sessions that ought to be tested - # simultaneously. + # By default, a single session is automatically created for you, but you can use + # this method to open multiple sessions that ought to be tested simultaneously. def open_session dup.tap do |session| session.reset! + session.root_session = self.root_session || self yield session if block_given? end end - # Copy the instance variables from the current session instance into the - # test instance. - def copy_session_variables! #:nodoc: + def assertions # :nodoc: + root_session ? root_session.assertions : super + end + + def assertions=(assertions) # :nodoc: + root_session ? root_session.assertions = assertions : super + end + + # Copy the instance variables from the current session instance into the test + # instance. + def copy_session_variables! # :nodoc: @controller = @integration_session.controller @response = @integration_session.response @request = @integration_session.request @@ -386,14 +433,15 @@ def default_url_options=(options) integration_session.default_url_options = options end - def respond_to_missing?(method, include_private = false) - integration_session.respond_to?(method, include_private) || super + private + def respond_to_missing?(method, _) + integration_session.respond_to?(method) || super end # Delegate unhandled messages to the current session instance. - def method_missing(sym, *args, &block) - if integration_session.respond_to?(sym) - integration_session.__send__(sym, *args, &block).tap do + def method_missing(method, ...) + if integration_session.respond_to?(method) + integration_session.public_send(method, ...).tap do copy_session_variables! end else @@ -403,198 +451,200 @@ def method_missing(sym, *args, &block) end end - # An integration test spans multiple controllers and actions, - # tying them all together to ensure they work together as expected. It tests - # more completely than either unit or functional tests do, exercising the - # entire stack, from the dispatcher to the database. + # An integration test spans multiple controllers and actions, tying them all + # together to ensure they work together as expected. It tests more completely + # than either unit or functional tests do, exercising the entire stack, from the + # dispatcher to the database. # - # At its simplest, you simply extend IntegrationTest and write your tests - # using the get/post methods: + # At its simplest, you simply extend `IntegrationTest` and write your tests + # using the Integration::RequestHelpers#get and/or + # Integration::RequestHelpers#post methods: # - # require "test_helper" + # require "test_helper" # - # class ExampleTest < ActionDispatch::IntegrationTest - # fixtures :people + # class ExampleTest < ActionDispatch::IntegrationTest + # fixtures :people # - # def test_login - # # get the login page - # get "/login" - # assert_equal 200, status + # def test_login + # # get the login page + # get "/login" + # assert_equal 200, status # - # # post the login and follow through to the home page - # post "/login", params: { username: people(:jamis).username, - # password: people(:jamis).password } - # follow_redirect! - # assert_equal 200, status - # assert_equal "/home", path + # # post the login and follow through to the home page + # post "/login", params: { username: people(:jamis).username, + # password: people(:jamis).password } + # follow_redirect! + # assert_equal 200, status + # assert_equal "/home", path + # end # end - # end # - # However, you can also have multiple session instances open per test, and - # even extend those instances with assertions and methods to create a very - # powerful testing DSL that is specific for your application. You can even - # reference any named routes you happen to have defined. + # However, you can also have multiple session instances open per test, and even + # extend those instances with assertions and methods to create a very powerful + # testing DSL that is specific for your application. You can even reference any + # named routes you happen to have defined. # - # require "test_helper" + # require "test_helper" # - # class AdvancedTest < ActionDispatch::IntegrationTest - # fixtures :people, :rooms + # class AdvancedTest < ActionDispatch::IntegrationTest + # fixtures :people, :rooms # - # def test_login_and_speak - # jamis, david = login(:jamis), login(:david) - # room = rooms(:office) + # def test_login_and_speak + # jamis, david = login(:jamis), login(:david) + # room = rooms(:office) # - # jamis.enter(room) - # jamis.speak(room, "anybody home?") + # jamis.enter(room) + # jamis.speak(room, "anybody home?") # - # david.enter(room) - # david.speak(room, "hello!") - # end - # - # private - # - # module CustomAssertions - # def enter(room) - # # reference a named route, for maximum internal consistency! - # get(room_url(id: room.id)) - # assert(...) - # ... - # end + # david.enter(room) + # david.speak(room, "hello!") + # end # - # def speak(room, message) - # post "/say/#{room.id}", xhr: true, params: { message: message } - # assert(...) - # ... + # private + # + # module CustomAssertions + # def enter(room) + # # reference a named route, for maximum internal consistency! + # get(room_url(id: room.id)) + # assert(...) + # ... + # end + # + # def speak(room, message) + # post "/say/#{room.id}", xhr: true, params: { message: message } + # assert(...) + # ... + # end # end - # end # - # def login(who) - # open_session do |sess| - # sess.extend(CustomAssertions) - # who = people(who) - # sess.post "/login", params: { username: who.username, - # password: who.password } - # assert(...) + # def login(who) + # open_session do |sess| + # sess.extend(CustomAssertions) + # who = people(who) + # sess.post "/login", params: { username: who.username, + # password: who.password } + # assert(...) + # end # end - # end - # end + # end # # Another longer example would be: # # A simple integration test that exercises multiple controllers: # - # require 'test_helper' + # require "test_helper" # - # class UserFlowsTest < ActionDispatch::IntegrationTest - # test "login and browse site" do - # # login via https - # https! - # get "/login" - # assert_response :success + # class UserFlowsTest < ActionDispatch::IntegrationTest + # test "login and browse site" do + # # login via https + # https! + # get "/login" + # assert_response :success # - # post "/login", params: { username: users(:david).username, password: users(:david).password } - # follow_redirect! - # assert_equal '/welcome', path - # assert_equal 'Welcome david!', flash[:notice] + # post "/login", params: { username: users(:david).username, password: users(:david).password } + # follow_redirect! + # assert_equal '/welcome', path + # assert_equal 'Welcome david!', flash[:notice] # - # https!(false) - # get "/articles/all" - # assert_response :success - # assert_select 'h1', 'Articles' + # https!(false) + # get "/articles/all" + # assert_response :success + # assert_dom 'h1', 'Articles' + # end # end - # end # # As you can see the integration test involves multiple controllers and # exercises the entire stack from database to dispatcher. In addition you can - # have multiple session instances open simultaneously in a test and extend - # those instances with assertion methods to create a very powerful testing - # DSL (domain-specific language) just for your application. + # have multiple session instances open simultaneously in a test and extend those + # instances with assertion methods to create a very powerful testing DSL + # (domain-specific language) just for your application. # # Here's an example of multiple sessions and custom DSL in an integration test # - # require 'test_helper' + # require "test_helper" # - # class UserFlowsTest < ActionDispatch::IntegrationTest - # test "login and browse site" do - # # User david logs in - # david = login(:david) - # # User guest logs in - # guest = login(:guest) + # class UserFlowsTest < ActionDispatch::IntegrationTest + # test "login and browse site" do + # # User david logs in + # david = login(:david) + # # User guest logs in + # guest = login(:guest) # - # # Both are now available in different sessions - # assert_equal 'Welcome david!', david.flash[:notice] - # assert_equal 'Welcome guest!', guest.flash[:notice] + # # Both are now available in different sessions + # assert_equal 'Welcome david!', david.flash[:notice] + # assert_equal 'Welcome guest!', guest.flash[:notice] # - # # User david can browse site - # david.browses_site - # # User guest can browse site as well - # guest.browses_site + # # User david can browse site + # david.browses_site + # # User guest can browse site as well + # guest.browses_site # - # # Continue with other assertions - # end + # # Continue with other assertions + # end # - # private + # private # - # module CustomDsl - # def browses_site - # get "/products/all" - # assert_response :success - # assert_select 'h1', 'Products' + # module CustomDsl + # def browses_site + # get "/products/all" + # assert_response :success + # assert_dom 'h1', 'Products' + # end # end - # end # - # def login(user) - # open_session do |sess| - # sess.extend(CustomDsl) - # u = users(user) - # sess.https! - # sess.post "/login", params: { username: u.username, password: u.password } - # assert_equal '/welcome', sess.path - # sess.https!(false) + # def login(user) + # open_session do |sess| + # sess.extend(CustomDsl) + # u = users(user) + # sess.https! + # sess.post "/login", params: { username: u.username, password: u.password } + # assert_equal '/welcome', sess.path + # sess.https!(false) + # end # end - # end - # 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 + # ### Changing the request encoding # - # You can also test your JSON API easily by setting what the request should - # be encoded as: + # You can also test your JSON API easily by setting what the request should be + # encoded as: # - # require "test_helper" + # require "test_helper" # - # class ApiTest < ActionDispatch::IntegrationTest - # test "creates articles" do - # assert_difference -> { Article.count } do - # post articles_path, params: { article: { title: "Ahoy!" } }, as: :json - # end + # class ApiTest < ActionDispatch::IntegrationTest + # test "creates articles" do + # assert_difference -> { Article.count } do + # post articles_path, params: { article: { title: "Ahoy!" } }, as: :json + # end # - # assert_response :success - # assert_equal({ id: Arcticle.last.id, title: "Ahoy!" }, response.parsed_body) + # assert_response :success + # assert_equal({ "id" => Article.last.id, "title" => "Ahoy!" }, response.parsed_body) + # end # end - # end # - # The +as+ option passes an "application/json" Accept header (thereby setting + # The `as` option passes an "application/json" Accept header (thereby setting # the request format to JSON unless overridden), sets the content type to # "application/json" and encodes the parameters as JSON. # - # Calling +parsed_body+ on the response parses the response body based on the - # last response MIME type. + # Calling TestResponse#parsed_body on the response parses the response body + # based on the last response MIME type. # - # Out of the box, only :json is supported. But for any custom MIME - # types you've registered, you can add your own encoders with: + # Out of the box, only `:json` is supported. But for any custom MIME types + # you've registered, you can add your own encoders with: # - # ActionDispatch::IntegrationTest.register_encoder :wibble, - # param_encoder: -> params { params.to_wibble }, - # response_parser: -> body { body } + # ActionDispatch::IntegrationTest.register_encoder :wibble, + # param_encoder: -> params { params.to_wibble }, + # response_parser: -> body { body } # - # Where +param_encoder+ defines how the params should be encoded and - # +response_parser+ defines how the response body should be parsed through - # +parsed_body+. + # Where `param_encoder` defines how the params should be encoded and + # `response_parser` defines how the response body should be parsed through + # TestResponse#parsed_body. # - # Consult the Rails Testing Guide for more. + # Consult the [Rails Testing Guide](https://guides.rubyonrails.org/testing.html) + # for more. class IntegrationTest < ActiveSupport::TestCase include TestProcess::FixtureFile @@ -611,10 +661,12 @@ module Behavior include Integration::Runner include ActionController::TemplateAssertions + include TestHelpers::PageDumpHelper included do include ActionDispatch::Routing::UrlFor include UrlOptions # don't let UrlFor override the url_options method + include ActionDispatch::Assertions::RoutingAssertions::WithIntegrationRouting ActiveSupport.run_load_hooks(:action_dispatch_integration_test, self) @@app = nil end @@ -632,8 +684,8 @@ def app=(app) @@app = app end - def register_encoder(*args) - RequestEncoder.register_encoder(*args) + def register_encoder(*args, **options) + RequestEncoder.register_encoder(*args, **options) end end diff --git a/actionpack/lib/action_dispatch/testing/request_encoder.rb b/actionpack/lib/action_dispatch/testing/request_encoder.rb index 8c27e9ecb7ee7..e0ce6b30af113 100644 --- a/actionpack/lib/action_dispatch/testing/request_encoder.rb +++ b/actionpack/lib/action_dispatch/testing/request_encoder.rb @@ -1,3 +1,10 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "nokogiri" +require "action_dispatch/http/mime_type" + module ActionDispatch class RequestEncoder # :nodoc: class IdentityEncoder @@ -9,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 @@ -21,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 @@ -32,22 +36,25 @@ def accept_header end def encode_params(params) - @param_encoder.call(params) + @param_encoder.call(params) if params end def self.parser(content_type) - mime = Mime::Type.lookup(content_type) - encoder(mime ? mime.ref : nil).response_parser + type = Mime::Type.lookup(content_type).ref if content_type + encoder(type).response_parser end 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 :json, response_parser: -> body { JSON.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_dispatch/testing/test_helpers/page_dump_helper.rb b/actionpack/lib/action_dispatch/testing/test_helpers/page_dump_helper.rb new file mode 100644 index 0000000000000..5642145c9c5be --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_helpers/page_dump_helper.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ActionDispatch + module TestHelpers + module PageDumpHelper + class InvalidResponse < StandardError; end + + # Saves the content of response body to a file and tries to open it in your browser. + # Launchy must be present in your Gemfile for the page to open automatically. + def save_and_open_page(path = html_dump_default_path) + save_page(path).tap { |s_path| open_file(s_path) } + end + + private + def save_page(path = html_dump_default_path) + raise InvalidResponse.new("Response is a redirection!") if response.redirection? + path = Pathname.new(path) + path.dirname.mkpath + File.write(path, response.body) + path + end + + def open_file(path) + require "launchy" + Launchy.open(path) + rescue LoadError + warn "File saved to #{path}.\nPlease install the launchy gem to open the file automatically." + end + + def html_dump_default_path + Rails.root.join("tmp/html_dump", "#{method_name}_#{DateTime.current.to_i}.html").to_s + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/test_process.rb b/actionpack/lib/action_dispatch/testing/test_process.rb index 0282eb15c3703..a017e8b398fe5 100644 --- a/actionpack/lib/action_dispatch/testing/test_process.rb +++ b/actionpack/lib/action_dispatch/testing/test_process.rb @@ -1,32 +1,40 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/middleware/cookies" require "action_dispatch/middleware/flash" module ActionDispatch module TestProcess module FixtureFile - # Shortcut for Rack::Test::UploadedFile.new(File.join(ActionDispatch::IntegrationTest.fixture_path, path), type): + # Shortcut for + # `Rack::Test::UploadedFile.new(File.join(ActionDispatch::IntegrationTest.file_fixture_path, path), type)`: # - # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png') + # post :change_avatar, params: { avatar: file_fixture_upload('david.png', 'image/png') } # - # To upload binary files on Windows, pass :binary as the last parameter. - # This will not affect other platforms: + # Default fixture files location is `test/fixtures/files`. # - # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png', :binary) - def fixture_file_upload(path, mime_type = nil, binary = false) - if self.class.respond_to?(:fixture_path) && self.class.fixture_path && - !File.exist?(path) - path = File.join(self.class.fixture_path, path) + # To upload binary files on Windows, pass `:binary` as the last parameter. This + # will not affect other platforms: + # + # post :change_avatar, params: { avatar: file_fixture_upload('david.png', 'image/png', :binary) } + def file_fixture_upload(path, mime_type = nil, binary = false) + if self.class.file_fixture_path && !File.exist?(path) + path = file_fixture(path) end + Rack::Test::UploadedFile.new(path, mime_type, binary) end + alias_method :fixture_file_upload, :file_fixture_upload end include FixtureFile def assigns(key = nil) raise NoMethodError, - "assigns has been extracted to a gem. To continue using it, - add `gem 'rails-controller-testing'` to your Gemfile." + 'assigns has been extracted to a gem. To continue using it, + add `gem "rails-controller-testing"` to your Gemfile.' end def session diff --git a/actionpack/lib/action_dispatch/testing/test_request.rb b/actionpack/lib/action_dispatch/testing/test_request.rb index 91b25ec1552f8..9609640c08a74 100644 --- a/actionpack/lib/action_dispatch/testing/test_request.rb +++ b/actionpack/lib/action_dispatch/testing/test_request.rb @@ -1,15 +1,19 @@ +# frozen_string_literal: true + +# :markup: markdown + require "active_support/core_ext/hash/indifferent_access" require "rack/utils" module ActionDispatch class TestRequest < Request DEFAULT_ENV = Rack::MockRequest.env_for("/", - "HTTP_HOST" => "test.host", - "REMOTE_ADDR" => "0.0.0.0", - "HTTP_USER_AGENT" => "Rails Testing", + "HTTP_HOST" => "test.host".b, + "REMOTE_ADDR" => "0.0.0.0".b, + "HTTP_USER_AGENT" => "Rails Testing".b, ) - # Create a new test request with default `env` values + # Create a new test request with default `env` values. def self.create(env = {}) env = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application env["rack.request.cookie_hash"] ||= {}.with_indifferent_access @@ -30,7 +34,7 @@ def host=(host) end def port=(number) - set_header("SERVER_PORT", number.to_i) + set_header("SERVER_PORT", number) end def request_uri=(uri) diff --git a/actionpack/lib/action_dispatch/testing/test_response.rb b/actionpack/lib/action_dispatch/testing/test_response.rb index 5c89f9c75e03d..a5adf866e128a 100644 --- a/actionpack/lib/action_dispatch/testing/test_response.rb +++ b/actionpack/lib/action_dispatch/testing/test_response.rb @@ -1,10 +1,13 @@ +# frozen_string_literal: true + +# :markup: markdown + require "action_dispatch/testing/request_encoder" module ActionDispatch - # Integration test methods such as ActionDispatch::Integration::Session#get - # and ActionDispatch::Integration::Session#post return objects of class - # TestResponse, which represent the HTTP response results of the requested - # controller actions. + # Integration test methods such as Integration::RequestHelpers#get and + # Integration::RequestHelpers#post return objects of class TestResponse, which + # represent the HTTP response results of the requested controller actions. # # See Response for more information on controller response objects. class TestResponse < Response @@ -12,22 +15,44 @@ def self.from_response(response) new response.status, response.headers, response.body end - def initialize(*) # :nodoc: - super - @response_parser = RequestEncoder.parser(content_type) + # Returns a parsed body depending on the response MIME type. When a parser + # corresponding to the MIME type is not found, it returns the raw body. + # + # #### Examples + # get "/posts" + # response.content_type # => "text/html; charset=utf-8" + # response.parsed_body.class # => Nokogiri::HTML5::Document + # response.parsed_body.to_html # => "\n\n..." + # + # assert_pattern { response.parsed_body.at("main") => { content: "Hello, world" } } + # + # response.parsed_body.at("main") => {name:, content:} + # assert_equal "main", name + # assert_equal "Some main content", content + # + # get "/posts.json" + # response.content_type # => "application/json; charset=utf-8" + # response.parsed_body.class # => Array + # response.parsed_body # => [{"id"=>42, "title"=>"Title"},... + # + # assert_pattern { response.parsed_body => [{ id: 42 }] } + # + # get "/posts/42.json" + # response.content_type # => "application/json; charset=utf-8" + # response.parsed_body.class # => ActiveSupport::HashWithIndifferentAccess + # response.parsed_body # => {"id"=>42, "title"=>"Title"} + # + # assert_pattern { response.parsed_body => [{ title: /title/i }] } + # + # response.parsed_body => {id:, title:} + # assert_equal 42, id + # assert_equal "Title", title + def parsed_body + @parsed_body ||= response_parser.call(body) end - # Was the response successful? - alias_method :success?, :successful? - - # Was the URL not found? - alias_method :missing?, :not_found? - - # Was there a server-side error? - alias_method :error?, :server_error? - - def parsed_body - @parsed_body ||= @response_parser.call(body) + def response_parser + @response_parser ||= RequestEncoder.parser(media_type) end end end diff --git a/actionpack/lib/action_pack.rb b/actionpack/lib/action_pack.rb index eec622e08555b..53cd40d189d1c 100644 --- a/actionpack/lib/action_pack.rb +++ b/actionpack/lib/action_pack.rb @@ -1,24 +1,27 @@ +# frozen_string_literal: true + #-- -# Copyright (c) 2004-2017 David Heinemeier Hansson +# Copyright (c) David Heinemeier Hansson # -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: # -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. # -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. #++ +# :markup: markdown + require "action_pack/version" diff --git a/actionpack/lib/action_pack/gem_version.rb b/actionpack/lib/action_pack/gem_version.rb index d8f86630b1c12..a812190bf5d8e 100644 --- a/actionpack/lib/action_pack/gem_version.rb +++ b/actionpack/lib/action_pack/gem_version.rb @@ -1,12 +1,16 @@ +# frozen_string_literal: true + +# :markup: markdown + module ActionPack - # Returns the version of the currently loaded Action Pack as a Gem::Version + # Returns the currently loaded version of Action Pack as a `Gem::Version`. def self.gem_version Gem::Version.new VERSION::STRING end module VERSION - MAJOR = 5 - MINOR = 1 + MAJOR = 8 + MINOR = 2 TINY = 0 PRE = "alpha" diff --git a/actionpack/lib/action_pack/version.rb b/actionpack/lib/action_pack/version.rb index 3d96158431f0b..1d5cfac807cf8 100644 --- a/actionpack/lib/action_pack/version.rb +++ b/actionpack/lib/action_pack/version.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + +# :markup: markdown + require_relative "gem_version" module ActionPack - # Returns the version of the currently loaded ActionPack as a Gem::Version + # Returns the currently loaded version of Action Pack as a `Gem::Version`. def self.version gem_version end diff --git a/actionpack/test/abstract/callbacks_test.rb b/actionpack/test/abstract/callbacks_test.rb index 9c2261bf7678b..88f333cf8ad7d 100644 --- a/actionpack/test/abstract/callbacks_test.rb +++ b/actionpack/test/abstract/callbacks_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module AbstractController @@ -42,7 +44,7 @@ def second def aroundz @aroundz = "FIRST" yield - @aroundz << "SECOND" + @aroundz += "SECOND" end def index @@ -152,7 +154,49 @@ def setup test "when :except is specified, an after action is not triggered on that action" do @controller.process(:index) - assert !@controller.instance_variable_defined?("@authenticated") + assert_not @controller.instance_variable_defined?("@authenticated") + end + end + + class CallbacksWithReusedConditions < ControllerWithCallbacks + options = { only: :index } + before_action :list, options + before_action :authenticate, options + + def index + self.response_body = @list.join(", ") + end + + def public_data + @authenticated = "false" + self.response_body = @authenticated + end + + private + def list + @list = ["Hello", "World"] + end + + def authenticate + @list ||= [] + @authenticated = "true" + end + end + + class TestCallbacksWithReusedConditions < ActiveSupport::TestCase + def setup + @controller = CallbacksWithReusedConditions.new + end + + test "when :only is specified, both actions triggered on that action" do + @controller.process(:index) + assert_equal "Hello, World", @controller.response_body + assert_equal "true", @controller.instance_variable_get("@authenticated") + end + + test "when :only is specified, both actions are not triggered on other actions" do + @controller.process(:public_data) + assert_equal "false", @controller.response_body end end @@ -196,7 +240,7 @@ def setup test "when :except is specified with an array, an after action is not triggered on that action" do @controller.process(:index) - assert !@controller.instance_variable_defined?("@authenticated") + assert_not @controller.instance_variable_defined?("@authenticated") end end @@ -264,5 +308,190 @@ class TestCallbacksWithArgs < ActiveSupport::TestCase assert_equal "Hello world Howdy!", controller.response_body end end + + class TestCallbacksWithMissingConditions < ActiveSupport::TestCase + class CallbacksWithMissingOnly < ControllerWithCallbacks + before_action :callback, only: :showw + + def index + end + + def show + end + + private + def callback + end + end + + test "callbacks raise exception when their 'only' condition is a missing action" do + with_raise_on_missing_callback_actions do + controller = CallbacksWithMissingOnly.new + assert_raises(AbstractController::ActionNotFound) do + controller.process(:index) + end + end + end + + class CallbacksWithMissingOnlyInArray < ControllerWithCallbacks + before_action :callback, only: [:index, :showw] + + def index + end + + def show + end + + private + def callback + end + end + + test "callbacks raise exception when their 'only' array condition contains a missing action" do + with_raise_on_missing_callback_actions do + controller = CallbacksWithMissingOnlyInArray.new + assert_raises(AbstractController::ActionNotFound) do + controller.process(:index) + end + end + end + + class CallbacksWithMissingExcept < ControllerWithCallbacks + before_action :callback, except: :showw + + def index + end + + def show + end + + private + def callback + end + end + + test "callbacks raise exception when their 'except' condition is a missing action" do + with_raise_on_missing_callback_actions do + controller = CallbacksWithMissingExcept.new + assert_raises(AbstractController::ActionNotFound) do + controller.process(:index) + end + end + end + + class CallbacksWithMissingExceptInArray < ControllerWithCallbacks + before_action :callback, except: [:index, :showw] + + def index + end + + def show + end + + private + def callback + end + end + + test "callbacks raise exception when their 'except' array condition contains a missing action" do + with_raise_on_missing_callback_actions do + controller = CallbacksWithMissingExceptInArray.new + assert_raises(AbstractController::ActionNotFound) do + controller.process(:index) + end + end + end + + class MultipleCallbacksWithMissingOnly < ControllerWithCallbacks + before_action :callback1, :callback2, ->() { }, only: :showw + + def index + end + + def show + end + + private + def callback1 + end + + def callback2 + end + end + + test "raised exception message includes the names of callback actions and missing conditional action" do + with_raise_on_missing_callback_actions do + controller = MultipleCallbacksWithMissingOnly.new + error = assert_raises(AbstractController::ActionNotFound) do + controller.process(:index) + end + + assert_includes error.message, ":callback1" + assert_includes error.message, ":callback2" + assert_includes error.message, "#Hello World", + hello_html: "Hello World", + interpolated_html: "Hello %{word}", + nested: { html: "nested" } }, no_action: "no_action_tr", }, @@ -41,12 +47,23 @@ def test_action_controller_base_responds_to_l assert_respond_to @controller, :l end + def test_raises_missing_translation_message_with_raise_option + assert_raise(I18n::MissingTranslationData) do + @controller.t(:"translations.missing", raise: true) + end + end + def test_lazy_lookup @controller.stub :action_name, :index do assert_equal "bar", @controller.t(".foo") end end + def test_nil_key_lookup + default = "foo" + assert_equal default, @controller.t(nil, default: default) + end + def test_lazy_lookup_with_symbol @controller.stub :action_name, :index do assert_equal "bar", @controller.t(:'.foo') @@ -62,6 +79,39 @@ def test_lazy_lookup_fallback def test_default_translation @controller.stub :action_name, :index do assert_equal "bar", @controller.t("one.two") + assert_equal "baz", @controller.t(".twoz", default: ["baz", :twoz]) + end + end + + def test_default_translation_as_unsafe_html + @controller.stub :action_name, :index do + translation = @controller.t(".twoz", default: [""]) + assert_equal "", translation + assert_equal false, translation.html_safe? + end + end + + def test_default_translation_as_safe_html + @controller.stub :action_name, :index do + translation = @controller.t(".twoz_html", default: [""]) + assert_equal "<tag>", translation + assert_equal true, translation.html_safe? + end + end + + def test_default_translation_with_raise_as_unsafe_html + @controller.stub :action_name, :index do + translation = @controller.t(".twoz", raise: true, default: [""]) + assert_equal "", translation + assert_equal false, translation.html_safe? + end + end + + def test_default_translation_with_raise_as_safe_html + @controller.stub :action_name, :index do + translation = @controller.t(".twoz_html", raise: true, default: [""]) + assert_equal "<tag>", translation + assert_equal true, translation.html_safe? end end @@ -71,6 +121,62 @@ def test_localize assert_equal expected, @controller.l(time) end end + + def test_translate_does_not_mark_plain_text_as_safe_html + @controller.stub :action_name, :index do + translation = @controller.t(".hello") + assert_equal "Hello World", translation + assert_equal false, translation.html_safe? + end + end + + def test_translate_marks_translations_with_a_html_suffix_as_safe_html + @controller.stub :action_name, :index do + translation = @controller.t(".hello_html") + assert_equal "Hello World", translation + assert_equal true, translation.html_safe? + end + end + + def test_translate_marks_translation_with_nested_html_key + @controller.stub :action_name, :index do + translation = @controller.t(".nested.html") + assert_equal "nested", translation + assert_equal true, translation.html_safe? + end + end + + def test_translate_escapes_interpolations_in_translations_with_a_html_suffix + word_struct = Struct.new(:to_s) + @controller.stub :action_name, :index do + translation = @controller.t(".interpolated_html", word: "") + assert_equal "Hello <World>", translation + assert_equal true, translation.html_safe? + + translation = @controller.t(".interpolated_html", word: word_struct.new("")) + assert_equal "Hello <World>", translation + assert_equal true, translation.html_safe? + end + end + + def test_translate_marks_translation_with_missing_html_key_as_safe_html + @controller.stub :action_name, :index do + translation = @controller.t(".html") + assert_equal false, translation.html_safe? + assert_equal "Translation missing: en..html", translation + end + end + def test_translate_marks_translation_with_missing_nested_html_key_as_safe_html + @controller.stub :action_name, :index do + translation = @controller.t("..html") + assert_equal false, translation.html_safe? + assert_equal(<<~MSG.strip, translation) + Translation missing. Options considered were: + - en.abstract_controller.testing.translation.index..html + - en.abstract_controller.testing.translation..html + MSG + end + end end end end diff --git a/actionpack/test/abstract_unit.rb b/actionpack/test/abstract_unit.rb index 459b0d6c54827..c3376814239df 100644 --- a/actionpack/test/abstract_unit.rb +++ b/actionpack/test/abstract_unit.rb @@ -1,6 +1,8 @@ -$:.unshift(File.dirname(__FILE__) + "/lib") -$:.unshift(File.dirname(__FILE__) + "/fixtures/helpers") -$:.unshift(File.dirname(__FILE__) + "/fixtures/alternate_helpers") +# frozen_string_literal: true + +require_relative "../../tools/strict_warnings" + +$:.unshift File.expand_path("lib", __dir__) require "active_support/core_ext/kernel/reporting" @@ -11,18 +13,7 @@ Encoding.default_external = Encoding::UTF_8 end -require "drb" -begin - require "drb/unix" -rescue LoadError - puts "'drb/unix' is not available" -end - -if ENV["TRAVIS"] - PROCESS_COUNT = 0 -else - PROCESS_COUNT = (ENV["N"] || 4).to_i -end +PROCESS_COUNT = (ENV["MT_CPU"] || 4).to_i require "active_support/testing/autorun" require "abstract_controller" @@ -33,8 +24,11 @@ require "action_dispatch" require "active_support/dependencies" require "active_model" +require "zeitwerk" + +require_relative "support/rack_parsing_override" -require "pp" # require 'pp' early to prevent hidden_methods from not picking up the pretty-print methods until too late +ActiveSupport::Cache.format_version = 7.1 module Rails class << self @@ -42,26 +36,40 @@ def env @_env ||= ActiveSupport::StringInquirer.new(ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "test") end - def root; end; + def application; end + + def root; end + end +end + +module ActionPackTestSuiteUtils + def self.require_helpers(helpers_dirs) + Array(helpers_dirs).each do |helpers_dir| + Dir.glob("#{helpers_dir}/**/*_helper.rb") do |helper_file| + require helper_file + end + end end end -ActiveSupport::Dependencies.hook! +ActionPackTestSuiteUtils.require_helpers("#{__dir__}/fixtures/helpers") +ActionPackTestSuiteUtils.require_helpers("#{__dir__}/fixtures/alternate_helpers") Thread.abort_on_exception = true # Show backtraces for deprecated behavior for quicker cleanup. -ActiveSupport::Deprecation.debug = true +ActionController.deprecator.debug = true +ActionDispatch.deprecator.debug = true # Disable available locale checks to avoid warnings running the test suite. I18n.enforce_available_locales = false -FIXTURE_LOAD_PATH = File.join(File.dirname(__FILE__), "fixtures") +FIXTURE_LOAD_PATH = File.join(__dir__, "fixtures") SharedTestRoutes = ActionDispatch::Routing::RouteSet.new SharedTestRoutes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller(/:action)" end end @@ -69,7 +77,10 @@ def root; end; module ActionDispatch module SharedRoutes def before_setup - @routes = SharedTestRoutes + @routes = Routing::RouteSet.new + ActionDispatch.deprecator.silence do + @routes.draw { get ":controller(/:action)" } + end super end end @@ -78,21 +89,29 @@ def before_setup module ActiveSupport class TestCase if RUBY_ENGINE == "ruby" && PROCESS_COUNT > 0 - parallelize_me! + parallelize(workers: PROCESS_COUNT) end end end class RoutedRackApp + class Config < Struct.new(:middleware) + end + attr_reader :routes def initialize(routes, &blk) @routes = routes - @stack = ActionDispatch::MiddlewareStack.new(&blk).build(@routes) + @stack = ActionDispatch::MiddlewareStack.new(&blk) + @app = @stack.build(@routes) end def call(env) - @stack.call(env) + @app.call(env) + end + + def config + Config.new(@stack) end end @@ -101,6 +120,7 @@ def self.build_app(routes = nil) RoutedRackApp.new(routes || ActionDispatch::Routing::RouteSet.new) do |middleware| middleware.use ActionDispatch::ShowExceptions, ActionDispatch::PublicExceptions.new("#{FIXTURE_LOAD_PATH}/public") middleware.use ActionDispatch::DebugExceptions + middleware.use ActionDispatch::ActionableExceptions middleware.use ActionDispatch::Callbacks middleware.use ActionDispatch::Cookies middleware.use ActionDispatch::Flash @@ -113,7 +133,7 @@ def self.build_app(routes = nil) self.app = build_app app.routes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller(/:action)" end end @@ -142,31 +162,14 @@ def self.stub_controllers(config = ActionDispatch::Routing::RouteSet::DEFAULT_CO yield DeadEndRoutes.new(config) end - def with_routing(&block) - temporary_routes = ActionDispatch::Routing::RouteSet.new - old_app, self.class.app = self.class.app, self.class.build_app(temporary_routes) - old_routes = SharedTestRoutes - silence_warnings { Object.const_set(:SharedTestRoutes, temporary_routes) } - - yield temporary_routes - ensure - self.class.app = old_app - remove! - silence_warnings { Object.const_set(:SharedTestRoutes, old_routes) } - end - def with_autoload_path(path) - path = File.join(File.dirname(__FILE__), "fixtures", path) - if ActiveSupport::Dependencies.autoload_paths.include?(path) + path = File.join(__dir__, "fixtures", path) + Zeitwerk.with_loader do |loader| + loader.push_dir(path) + loader.setup yield - else - begin - ActiveSupport::Dependencies.autoload_paths << path - yield - ensure - ActiveSupport::Dependencies.autoload_paths.reject! { |p| p == path } - ActiveSupport::Dependencies.clear - end + ensure + loader.unload end end end @@ -175,15 +178,15 @@ def with_autoload_path(path) class Rack::TestCase < ActionDispatch::IntegrationTest def self.testing(klass = nil) if klass - @testing = "/#{klass.name.underscore}".sub!(/_controller$/, "") + @testing = "/#{klass.name.underscore}".delete_suffix("_controller") else @testing end end - def get(thing, *args) + def get(thing, *args, **options) if thing.is_a?(Symbol) - super("#{self.class.testing}/#{thing}", *args) + super("#{self.class.testing}/#{thing}", *args, **options) else super end @@ -230,6 +233,7 @@ def self.test_routes(&block) routes = ActionDispatch::Routing::RouteSet.new routes.draw(&block) include routes.url_helpers + routes end end @@ -310,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 @@ -338,7 +342,6 @@ def initialize(block, strict = false) end private - def make_request(env) Request.new super, url_helpers, @block, strict end @@ -356,86 +359,160 @@ class ImagesController < ResourcesController; end require "active_support/testing/method_call_assertions" -class ForkingExecutor - class Server - include DRb::DRbUndumped - - def initialize - @queue = Queue.new - end - - def record(reporter, result) - reporter.record result - end - - def <<(o) - o[2] = DRbObject.new(o[2]) if o - @queue << o - end - def pop; @queue.pop; end - end - - def initialize(size) - @size = size - @queue = Server.new - file = File.join Dir.tmpdir, Dir::Tmpname.make_tmpname("rails-tests", "fd") - @url = "drbunix://#{file}" - @pool = nil - DRb.start_service @url, @queue - end - - def <<(work); @queue << work; end - - def shutdown - pool = @size.times.map { - fork { - DRb.stop_service - queue = DRbObject.new_with_uri @url - while job = queue.pop - klass = job[0] - method = job[1] - reporter = job[2] - result = Minitest.run_one_method klass, method - if result.error? - translate_exceptions result - end - queue.record reporter, result - end - } - } - @size.times { @queue << nil } - pool.each { |pid| Process.waitpid pid } - end - - private - def translate_exceptions(result) - result.failures.map! { |e| - begin - Marshal.dump e - e - rescue TypeError - ex = Exception.new e.message - ex.set_backtrace e.backtrace - Minitest::UnexpectedError.new ex - end - } - end +class ActiveSupport::TestCase + include ActiveSupport::Testing::MethodCallAssertions end -if RUBY_ENGINE == "ruby" && PROCESS_COUNT > 0 - # Use N processes (N defaults to 4) - Minitest.parallel_executor = ForkingExecutor.new(PROCESS_COUNT) +module CookieAssertions + def parse_set_cookie_attributes(fields, attributes = {}) + if fields.is_a?(String) + fields = fields.split(";").map(&:strip) + end + + fields.each do |field| + key, value = field.split("=", 2) + + # Normalize the key to lowercase: + key.downcase! + + if value + value.downcase! + attributes[key] = value + else + attributes[key] = true + end + end + + attributes + end + + # Parse the set-cookie header and return a hash of cookie names and values. + # + # Example: + # set_cookies = headers["set-cookie"] + # parse_set_cookies_headers(set_cookies) + def parse_set_cookies_headers(set_cookies) + if set_cookies.is_a?(String) + set_cookies = set_cookies.split("\n") + end + + cookies = {} + + set_cookies&.each do |cookie_string| + attributes = {} + + fields = cookie_string.split(";").map(&:strip) + + # The first one is the cookie name: + name, value = fields.shift.split("=", 2) + + attributes[:value] = value + + cookies[name] = parse_set_cookie_attributes(fields, attributes) + end + + cookies + end + + def assert_set_cookie_attributes(name, attributes, header = @response.headers["Set-Cookie"]) + cookies = parse_set_cookies_headers(header) + attributes = parse_set_cookie_attributes(attributes) if attributes.is_a?(String) + + assert cookies.key?(name), "No cookie found with the name '#{name}', found cookies: #{cookies.keys.join(', ')}" + cookie = cookies[name] + + attributes.each do |key, value| + assert cookie.key?(key), "No attribute '#{key}' found for cookie '#{name}'" + assert_equal value, cookie[key] + end + end + + def assert_not_set_cookie_attributes(name, attributes, header = @response.headers["Set-Cookie"]) + cookies = parse_set_cookies_headers(header) + attributes = parse_set_cookie_attributes(attributes) if attributes.is_a?(String) + + assert cookies.key?(name), "No cookie found with the name '#{name}'" + cookie = cookies[name] + + attributes.each do |key, value| + if value == true + assert_nil cookie[key] + else + assert_not_equal value, cookie[key] + end + end + end + + def assert_set_cookie_header(expected, header = @response.headers["Set-Cookie"]) + # In Rack v2, this is newline delimited. In Rack v3, this is an array. + # Normalize the comparison so that we can assert equality in both cases. + + if header.is_a?(String) + header = header.split("\n").sort + end + + if expected.is_a?(String) + expected = expected.split("\n").sort + end + + # While not strictly speaking correct, this is probably good enough for now: + header = parse_set_cookies_headers(header) + expected = parse_set_cookies_headers(expected) + + expected.each do |key, value| + assert_equal value, header[key] + end + end + + def assert_not_set_cookie_header(expected, header = @response.headers["Set-Cookie"]) + if header.is_a?(String) + header = header.split("\n").sort + end + + if expected.is_a?(String) + expected = expected.split("\n").sort + end + + # While not strictly speaking correct, this is probably good enough for now: + header = parse_set_cookies_headers(header) + + expected.each do |name| + assert_not_includes(header, name) + end + end end -class ActiveSupport::TestCase - include ActiveSupport::Testing::MethodCallAssertions +module HeadersAssertions + def normalize_headers(headers) + headers.transform_keys(&:downcase) + end + + def assert_headers(expected, actual = @response.headers) + actual = normalize_headers(actual) + expected.each do |key, value| + assert_equal value, actual[key] + end + end + + def assert_header(key, value, actual = @response.headers) + actual = normalize_headers(actual) + assert_equal value, actual[key] + end - # Skips the current run on Rubinius using Minitest::Assertions#skip - private def rubinius_skip(message = "") - skip message if RUBY_ENGINE == "rbx" + def assert_not_header(key, actual = @response.headers) + actual = normalize_headers(actual) + assert_not_includes(actual, key) end - # Skips the current run on JRuby using Minitest::Assertions#skip - private def jruby_skip(message = "") - skip message if defined?(JRUBY_VERSION) + + # This works for most headers, but not all, e.g. `set-cookie`. + def normalized_join_header(header) + header.is_a?(Array) ? header.join(",") : header + end + + def assert_header_value(expected, header) + header = normalized_join_header(header) + assert_equal header, expected end end + +require_relative "../../tools/test_common" diff --git a/actionpack/test/assertions/response_assertions_test.rb b/actionpack/test/assertions/response_assertions_test.rb index 14a04ccdb1886..bbd594c4cc15a 100644 --- a/actionpack/test/assertions/response_assertions_test.rb +++ b/actionpack/test/assertions/response_assertions_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_dispatch/testing/assertions/response" @@ -134,6 +136,19 @@ def test_error_message_does_not_show_long_response_body " but was a <400: Bad Request>" assert_match expected, error.message end + + def test_error_message_shows_rescued_exception + @response = ActionDispatch::Response.new + @response.status = 500 + + @request = ActionDispatch::Request.new("action_dispatch.exception" => RuntimeError.new("example error")) + + error = assert_raises(Minitest::Assertion) { assert_response 200 } + expected = "Expected response to be a <200: OK>,"\ + " but was a <500: Internal Server Error>\n\n"\ + "Exception while processing request: RuntimeError: example error\n" + assert_match expected, error.message + end end end end diff --git a/actionpack/test/controller/action_pack_assertions_test.rb b/actionpack/test/controller/action_pack_assertions_test.rb index 9ab152fc5c685..46306d5a66c22 100644 --- a/actionpack/test/controller/action_pack_assertions_test.rb +++ b/actionpack/test/controller/action_pack_assertions_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "controller/fake_controllers" @@ -34,6 +36,8 @@ def redirect_external() redirect_to "http://www.rubyonrails.org"; end def redirect_external_protocol_relative() redirect_to "//www.rubyonrails.org"; end + def redirect_permanently() redirect_to "/some/path", status: :moved_permanently end + def response404() head "404 AWOL" end def response500() head "500 Sorry" end @@ -83,7 +87,7 @@ def raise_exception_on_post end def render_file_absolute_path - render file: File.expand_path("../../../README.rdoc", __FILE__) + render file: File.expand_path("../../README.rdoc", __dir__) end def render_file_relative_path @@ -173,9 +177,11 @@ def test_get_post_request_switch end def test_string_constraint - with_routing do |set| - set.draw do - get "photos", to: "action_pack_assertions#nothing", constraints: { subdomain: "admin" } + assert_nothing_raised do + with_routing do |set| + set.draw do + get "photos", to: "action_pack_assertions#nothing", constraints: { subdomain: "admin" } + end end end end @@ -200,7 +206,7 @@ def test_assert_redirect_to_named_route_failure get "route_one", to: "action_pack_assertions#nothing", as: :route_one get "route_two", to: "action_pack_assertions#nothing", id: "two", as: :route_two - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -227,7 +233,7 @@ def test_assert_redirect_to_nested_named_route set.draw do get "admin/inner_module", to: "admin/inner_module#index", as: :admin_inner_module - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -244,7 +250,7 @@ def test_assert_redirected_to_top_level_named_route_from_nested_controller set.draw do get "/action_pack_assertions/:id", to: "action_pack_assertions#index", as: :top_level - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -263,7 +269,7 @@ def test_assert_redirected_to_top_level_named_route_with_same_controller_name_in # this controller exists in the admin namespace as well which is the only difference from previous test get "/user/:id", to: "user#index", as: :top_level - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -274,43 +280,41 @@ def test_assert_redirected_to_top_level_named_route_with_same_controller_name_in end def test_assert_redirect_failure_message_with_protocol_relative_url - begin - process :redirect_external_protocol_relative - assert_redirected_to "/foo" - rescue ActiveSupport::TestCase::Assertion => ex - assert_no_match( - /#{request.protocol}#{request.host}\/\/www.rubyonrails.org/, - ex.message, - "protocol relative url was incorrectly normalized" - ) - end + process :redirect_external_protocol_relative + assert_redirected_to "/foo" + rescue ActiveSupport::TestCase::Assertion => ex + assert_no_match( + /#{request.protocol}#{request.host}\/\/www.rubyonrails.org/, + ex.message, + "protocol relative URL was incorrectly normalized" + ) end def test_template_objects_exist process :assign_this - assert !@controller.instance_variable_defined?(:"@hi") + assert_not @controller.instance_variable_defined?(:"@hi") assert @controller.instance_variable_get(:"@howdy") end def test_template_objects_missing process :nothing - assert !@controller.instance_variable_defined?(:@howdy) + assert_not @controller.instance_variable_defined?(:@howdy) end def test_empty_flash process :flash_me_naked - assert flash.empty? + assert_empty flash end def test_flash_exist process :flash_me - assert flash.any? - assert flash["hello"].present? + assert_predicate flash, :any? + assert_predicate flash["hello"], :present? end def test_flash_does_not_exist process :nothing - assert flash.empty? + assert_empty flash end def test_session_exist @@ -320,7 +324,7 @@ def test_session_exist def session_does_not_exist process :nothing - assert session.empty? + assert_empty session end def test_redirection_location @@ -341,46 +345,46 @@ def test_no_redirect_url def test_server_error_response_code process :response500 - assert @response.server_error? + assert_predicate @response, :server_error? process :response599 - assert @response.server_error? + assert_predicate @response, :server_error? process :response404 - assert !@response.server_error? + assert_not_predicate @response, :server_error? end def test_missing_response_code process :response404 - assert @response.not_found? + assert_predicate @response, :not_found? end def test_client_error_response_code process :response404 - assert @response.client_error? + assert_predicate @response, :client_error? end def test_redirect_url_match process :redirect_external - assert @response.redirect? + assert_predicate @response, :redirect? assert_match(/rubyonrails/, @response.redirect_url) - assert !/perloffrails/.match(@response.redirect_url) + assert_no_match(/perloffrails/, @response.redirect_url) end def test_redirection process :redirect_internal - assert @response.redirect? + assert_predicate @response, :redirect? process :redirect_external - assert @response.redirect? + assert_predicate @response, :redirect? process :nothing - assert !@response.redirect? + assert_not_predicate @response, :redirect? end def test_successful_response_code process :nothing - assert @response.successful? + assert_predicate @response, :successful? end def test_response_object @@ -440,6 +444,34 @@ def test_assert_redirection_with_symbol } end + def test_assert_redirection_with_custom_message + error = assert_raise(ActiveSupport::TestCase::Assertion) do + assert_redirected_to "http://test.host/some/path", "wrong redirect" + end + + assert_equal("wrong redirect", error.message) + end + + def test_assert_redirection_with_status + process :redirect_to_path + assert_redirected_to "http://test.host/some/path", status: :found + assert_raise ActiveSupport::TestCase::Assertion do + assert_redirected_to "http://test.host/some/path", status: :moved_permanently + end + assert_raise ActiveSupport::TestCase::Assertion, "Custom message" do + assert_redirected_to "http://test.host/some/path", { status: :moved_permanently }, "Custom message" + end + + process :redirect_permanently + assert_redirected_to "http://test.host/some/path", status: :moved_permanently + assert_raise ActiveSupport::TestCase::Assertion do + assert_redirected_to "http://test.host/some/path", status: :found + end + assert_raise ActiveSupport::TestCase::Assertion, "Custom message" do + assert_redirected_to "http://test.host/some/path", { status: :found }, "Custom message" + end + end + def test_redirected_to_with_nested_controller @controller = Admin::InnerModuleController.new get :redirect_to_absolute_controller @@ -464,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/allow_browser_test.rb b/actionpack/test/controller/allow_browser_test.rb new file mode 100644 index 0000000000000..f9cb195fadaa6 --- /dev/null +++ b/actionpack/test/controller/allow_browser_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class AllowBrowserController < ActionController::Base + allow_browser versions: { safari: "16.4", chrome: "119", firefox: "123", opera: "106", ie: false }, block: -> { head :upgrade_required }, only: :hello + def hello + head :ok + end + + allow_browser versions: { safari: "16.4", chrome: "119", firefox: "123", opera: "106", ie: false }, block: :head_upgrade_required, only: :hello_method_name + def hello_method_name + head :ok + end + + allow_browser versions: :modern, block: -> { head :upgrade_required }, only: :modern + def modern + head :ok + end + + private + def head_upgrade_required + head :upgrade_required + end +end + +class AllowBrowserTest < ActionController::TestCase + tests AllowBrowserController + + CHROME_118 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118 Safari/537.36" + CHROME_120 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36" + SAFARI_17_2_0 = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.0 Safari/605.1.15" + FIREFOX_114 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0" + IE_11 = "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko" + OPERA_106 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0" + GOOGLE_BOT = "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" + + test "blocked browser below version limit with callable" do + get_with_agent :hello, FIREFOX_114 + assert_response :upgrade_required + end + + test "blocked browser below version limit with method name" do + get_with_agent :hello_method_name, FIREFOX_114 + assert_response :upgrade_required + end + + test "blocked browser by name" do + get_with_agent :hello, IE_11 + assert_response :upgrade_required + end + + test "allowed browsers above specific version limit" do + get_with_agent :hello, SAFARI_17_2_0 + assert_response :ok + + get_with_agent :hello, CHROME_120 + assert_response :ok + + get_with_agent :hello, OPERA_106 + assert_response :ok + end + + test "browsers against modern limit" do + get_with_agent :modern, SAFARI_17_2_0 + assert_response :ok + + get_with_agent :modern, CHROME_118 + assert_response :upgrade_required + + get_with_agent :modern, CHROME_120 + assert_response :ok + + get_with_agent :modern, OPERA_106 + assert_response :ok + end + + test "bots" do + get_with_agent :hello, GOOGLE_BOT + assert_response :ok + + get_with_agent :modern, GOOGLE_BOT + assert_response :ok + end + + test "a blocked request instruments a browser_block.action_controller event" do + notification = assert_notification("browser_block.action_controller") do + get_with_agent :modern, CHROME_118 + end + + assert_equal request, notification.payload[:request] + assert_not_empty notification.payload[:versions] + end + + private + def get_with_agent(action, agent) + @request.headers["User-Agent"] = agent + get action + end +end diff --git a/actionpack/test/controller/api/conditional_get_test.rb b/actionpack/test/controller/api/conditional_get_test.rb index 7b70829101e60..95f19e97d4e17 100644 --- a/actionpack/test/controller/api/conditional_get_test.rb +++ b/actionpack/test/controller/api/conditional_get_test.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_support/core_ext/integer/time" require "active_support/core_ext/numeric/time" +require "support/etag_helper" class ConditionalGetApiController < ActionController::API before_action :handle_last_modified_and_etags, only: :two @@ -16,13 +19,13 @@ def two end private - def handle_last_modified_and_etags fresh_when(last_modified: Time.now.utc.beginning_of_day, etag: [ :foo, 123 ]) end end class ConditionalGetApiTest < ActionController::TestCase + include EtagHelper tests ConditionalGetApiController def setup @@ -51,7 +54,75 @@ def test_request_not_modified @request.if_modified_since = @last_modified get :one assert_equal 304, @response.status.to_i - assert @response.body.blank? + assert_predicate @response.body, :blank? assert_equal @last_modified, @response.headers["Last-Modified"] end + + def test_if_none_match_is_asterisk + @request.if_none_match = "*" + get :one + assert_response :not_modified + end + + def test_etag_matches + @request.if_none_match = weak_etag([:foo, 123]) + get :one + assert_response :not_modified + end + + def test_strict_freshness_with_etag + with_strict_freshness(true) do + @request.if_none_match = weak_etag([:foo, 123]) + + get :one + assert_response :not_modified + end + end + + def test_strict_freshness_with_last_modified + with_strict_freshness(true) do + @request.if_modified_since = @last_modified + + get :one + assert_response :not_modified + end + end + + def test_strict_freshness_etag_precedence_over_last_modified + with_strict_freshness(true) do + # Not modified because the etag matches + @request.if_modified_since = 5.years.ago.httpdate + @request.if_none_match = weak_etag([:foo, 123]) + + get :one + assert_response :not_modified + + # stale because the etag doesn't match + @request.if_none_match = weak_etag([:bar, 124]) + @request.if_modified_since = @last_modified + + get :one + assert_response :success + end + end + + def test_both_should_be_used_when_strict_freshness_is_false + with_strict_freshness(false) do + # stale because the etag match but the last modified doesn't + @request.if_modified_since = 5.years.ago.httpdate + @request.if_none_match = weak_etag([:foo, 123]) + + get :one + assert_response :ok + end + end + + private + def with_strict_freshness(value) + old_value = ActionDispatch::Http::Cache::Request.strict_freshness + ActionDispatch::Http::Cache::Request.strict_freshness = value + yield + ensure + ActionDispatch::Http::Cache::Request.strict_freshness = old_value + end end diff --git a/actionpack/test/controller/api/data_streaming_test.rb b/actionpack/test/controller/api/data_streaming_test.rb index f15b78d102cc7..6081168e1c5c3 100644 --- a/actionpack/test/controller/api/data_streaming_test.rb +++ b/actionpack/test/controller/api/data_streaming_test.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require "abstract_unit" module TestApiFileUtils - def file_path() File.expand_path(__FILE__) end - def file_data() @data ||= File.open(file_path, "rb") { |f| f.read } end + def file_path() __FILE__ end + def file_data() @data ||= File.binread(file_path) end end class DataStreamingApiController < ActionController::API diff --git a/actionpack/test/controller/api/force_ssl_test.rb b/actionpack/test/controller/api/force_ssl_test.rb deleted file mode 100644 index d239964e4a695..0000000000000 --- a/actionpack/test/controller/api/force_ssl_test.rb +++ /dev/null @@ -1,20 +0,0 @@ -require "abstract_unit" - -class ForceSSLApiController < ActionController::API - force_ssl - - def one; end - def two - head :ok - end -end - -class ForceSSLApiTest < ActionController::TestCase - tests ForceSSLApiController - - def test_redirects_to_https - get :two - assert_response 301 - assert_equal "https://test.host/force_ssl_api/two", redirect_to_url - end -end diff --git a/actionpack/test/controller/api/implicit_render_test.rb b/actionpack/test/controller/api/implicit_render_test.rb index b51ee0cf42c60..8cefaf9b61c29 100644 --- a/actionpack/test/controller/api/implicit_render_test.rb +++ b/actionpack/test/controller/api/implicit_render_test.rb @@ -1,8 +1,15 @@ +# frozen_string_literal: true + require "abstract_unit" class ImplicitRenderAPITestController < ActionController::API def empty_action end + + def returning_mock + require "minitest/mock" + Minitest::Mock.new + end end class ImplicitRenderAPITest < ActionController::TestCase @@ -12,4 +19,9 @@ def test_implicit_no_content_response get :empty_action assert_response :no_content end + + def test_result_independence + get :returning_mock + assert_response :no_content + end end diff --git a/actionpack/test/controller/api/params_wrapper_test.rb b/actionpack/test/controller/api/params_wrapper_test.rb index a1da852040262..814c24bfd861e 100644 --- a/actionpack/test/controller/api/params_wrapper_test.rb +++ b/actionpack/test/controller/api/params_wrapper_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class ParamsWrapperForApiTest < ActionController::TestCase diff --git a/actionpack/test/controller/api/rate_limiting_test.rb b/actionpack/test/controller/api/rate_limiting_test.rb new file mode 100644 index 0000000000000..fa8da78ec3db9 --- /dev/null +++ b/actionpack/test/controller/api/rate_limiting_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ApiRateLimitedController < ActionController::API + self.cache_store = ActiveSupport::Cache::MemoryStore.new + rate_limit to: 2, within: 2.seconds, only: :limited_to_two + + def limited_to_two + head :ok + end +end + +class ApiRateLimitingTest < ActionController::TestCase + tests ApiRateLimitedController + + setup do + ApiRateLimitedController.cache_store.clear + end + + test "exceeding basic limit" do + get :limited_to_two + get :limited_to_two + assert_response :ok + + assert_raises ActionController::TooManyRequests do + get :limited_to_two + end + end + + test "limit resets after time" do + get :limited_to_two + get :limited_to_two + assert_response :ok + + travel_to Time.now + 3.seconds do + get :limited_to_two + assert_response :ok + end + end +end diff --git a/actionpack/test/controller/api/redirect_to_test.rb b/actionpack/test/controller/api/redirect_to_test.rb index ab14409f4017f..f8230dd6a94d1 100644 --- a/actionpack/test/controller/api/redirect_to_test.rb +++ b/actionpack/test/controller/api/redirect_to_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class RedirectToApiController < ActionController::API diff --git a/actionpack/test/controller/api/renderers_test.rb b/actionpack/test/controller/api/renderers_test.rb index 04e34a1f8f68a..e7a9a4b2da0bf 100644 --- a/actionpack/test/controller/api/renderers_test.rb +++ b/actionpack/test/controller/api/renderers_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_support/core_ext/hash/conversions" diff --git a/actionpack/test/controller/api/url_for_test.rb b/actionpack/test/controller/api/url_for_test.rb index cb4ae7a88a964..aa3428bc85d21 100644 --- a/actionpack/test/controller/api/url_for_test.rb +++ b/actionpack/test/controller/api/url_for_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class UrlForApiController < ActionController::API diff --git a/actionpack/test/controller/api/with_cookies_test.rb b/actionpack/test/controller/api/with_cookies_test.rb index 8928237dfd493..1a6e12a4f3eea 100644 --- a/actionpack/test/controller/api/with_cookies_test.rb +++ b/actionpack/test/controller/api/with_cookies_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class WithCookiesController < ActionController::API diff --git a/actionpack/test/controller/api/with_helpers_test.rb b/actionpack/test/controller/api/with_helpers_test.rb new file mode 100644 index 0000000000000..00179d35056e6 --- /dev/null +++ b/actionpack/test/controller/api/with_helpers_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "abstract_unit" + +module ApiWithHelper + def my_helper + "helper" + end +end + +class WithHelpersController < ActionController::API + include ActionController::Helpers + helper ApiWithHelper + + def with_helpers + render plain: self.class.helpers.my_helper + end +end + +class SubclassWithHelpersController < WithHelpersController + def with_helpers + render plain: self.class.helpers.my_helper + end +end + +class WithHelpersTest < ActionController::TestCase + tests WithHelpersController + + def test_with_helpers + get :with_helpers + + assert_equal "helper", response.body + end +end + +class SubclassWithHelpersTest < ActionController::TestCase + tests WithHelpersController + + def test_with_helpers + get :with_helpers + + assert_equal "helper", response.body + end +end diff --git a/actionpack/test/controller/base_test.rb b/actionpack/test/controller/base_test.rb index 42a51570101d5..6fc978d5fb40e 100644 --- a/actionpack/test/controller/base_test.rb +++ b/actionpack/test/controller/base_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_support/logger" require "controller/fake_models" @@ -11,6 +13,19 @@ class ContainedEmptyController < ActionController::Base class EmptyController < ActionController::Base end +class SimpleController < ActionController::Base + def status + head :ok + end + + def hello + self.response_body = "hello" + end +end + +class ChildController < SimpleController +end + class NonEmptyController < ActionController::Base def public_action head :ok @@ -57,6 +72,29 @@ def action_missing(action) end end +class WithoutRouterController < ActionController::Base + after_action :log_request_details + + def index + head :ok + end + + private + def log_request_details + request.route_uri_pattern + end +end + +class WithoutRouterTest < ActionController::TestCase + tests WithoutRouterController + + def test_request_route_uri_pattern_in_after_action_callback + assert_nothing_raised do + get :index + end + end +end + class ControllerClassTests < ActiveSupport::TestCase def test_controller_path assert_equal "empty", EmptyController.controller_path @@ -75,14 +113,14 @@ def test_no_deprecation_when_action_view_record_identifier_is_included record.save dom_id = nil - assert_not_deprecated do + assert_not_deprecated(ActionController.deprecator) do dom_id = RecordIdentifierIncludedController.new.dom_id(record) end assert_equal "comment_1", dom_id dom_class = nil - assert_not_deprecated do + assert_not_deprecated(ActionController.deprecator) do dom_class = RecordIdentifierIncludedController.new.dom_class(record) end assert_equal "comment", dom_class @@ -99,17 +137,23 @@ def setup end def test_performed? - assert !@empty.performed? + assert_not_predicate @empty, :performed? @empty.response_body = ["sweet"] - assert @empty.performed? + assert_predicate @empty, :performed? end - def test_action_methods + def test_empty_controller_action_methods @empty_controllers.each do |c| assert_equal Set.new, c.class.action_methods, "#{c.controller_path} should be empty!" end end + def test_action_methods_with_inherited_shadowed_internal_method + assert_includes ActionController::Base.instance_methods, :status + assert_equal Set.new(["status", "hello"]), SimpleController.action_methods + assert_equal Set.new(["status", "hello"]), ChildController.action_methods + end + def test_temporary_anonymous_controllers name = "ExamplesController" klass = Class.new(ActionController::Base) @@ -118,6 +162,31 @@ def test_temporary_anonymous_controllers controller = klass.new assert_equal "examples", controller.controller_path end + + def test_response_has_default_headers + original_default_headers = ActionDispatch::Response.default_headers + + ActionDispatch::Response.default_headers = { + "X-Frame-Options" => "DENY", + "X-Content-Type-Options" => "nosniff", + "X-XSS-Protection" => "0" + } + + response_headers = SimpleController.action("hello").call( + "REQUEST_METHOD" => "GET", + "rack.input" => -> { } + )[1] + + assert response_headers.key?("X-Frame-Options") + assert response_headers.key?("X-Content-Type-Options") + assert response_headers.key?("X-XSS-Protection") + ensure + ActionDispatch::Response.default_headers = original_default_headers + end + + def test_inspect + assert_match(/\A#\z/, @empty.inspect) + end end class PerformActionTest < ActionController::TestCase @@ -139,6 +208,14 @@ def test_process_should_be_precise assert_equal "The action 'non_existent' could not be found for EmptyController", exception.message end + def test_exceptions_have_suggestions_for_fix + use_controller SimpleController + exception = assert_raise AbstractController::ActionNotFound do + get :ello + end + assert_match "Did you mean?", exception.detailed_message + end + def test_action_missing_should_work use_controller ActionMissingController get :arbitrary_action @@ -175,7 +252,7 @@ def test_url_options_override set.draw do get "from_view", to: "url_options#from_view", as: :from_view - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -183,7 +260,7 @@ def test_url_options_override get :from_view, params: { route: "from_view_url" } assert_equal "http://www.override.com/from_view", @response.body - assert_equal "http://www.override.com/from_view", @controller.send(:from_view_url) + assert_equal "http://www.override.com/from_view", @controller.from_view_url assert_equal "http://www.override.com/default_url_options/index", @controller.url_for(controller: "default_url_options") end end @@ -212,7 +289,7 @@ def test_default_url_options_override set.draw do get "from_view", to: "default_url_options#from_view", as: :from_view - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -220,7 +297,7 @@ def test_default_url_options_override get :from_view, params: { route: "from_view_url" } assert_equal "http://www.override.com/from_view?locale=en", @response.body - assert_equal "http://www.override.com/from_view?locale=en", @controller.send(:from_view_url) + assert_equal "http://www.override.com/from_view?locale=en", @controller.from_view_url assert_equal "http://www.override.com/default_url_options/new?locale=en", @controller.url_for(controller: "default_url_options") end end @@ -232,7 +309,7 @@ def test_default_url_options_are_used_in_non_positional_parameters resources :descriptions end - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -240,16 +317,16 @@ def test_default_url_options_are_used_in_non_positional_parameters get :from_view, params: { route: "description_path(1)" } assert_equal "/en/descriptions/1", @response.body - assert_equal "/en/descriptions", @controller.send(:descriptions_path) - assert_equal "/pl/descriptions", @controller.send(:descriptions_path, "pl") - assert_equal "/pl/descriptions", @controller.send(:descriptions_path, locale: "pl") - assert_equal "/pl/descriptions.xml", @controller.send(:descriptions_path, "pl", "xml") - assert_equal "/en/descriptions.xml", @controller.send(:descriptions_path, format: "xml") - assert_equal "/en/descriptions/1", @controller.send(:description_path, 1) - assert_equal "/pl/descriptions/1", @controller.send(:description_path, "pl", 1) - assert_equal "/pl/descriptions/1", @controller.send(:description_path, 1, locale: "pl") - assert_equal "/pl/descriptions/1.xml", @controller.send(:description_path, "pl", 1, "xml") - assert_equal "/en/descriptions/1.xml", @controller.send(:description_path, 1, format: "xml") + assert_equal "/en/descriptions", @controller.descriptions_path + assert_equal "/pl/descriptions", @controller.descriptions_path("pl") + assert_equal "/pl/descriptions", @controller.descriptions_path(locale: "pl") + assert_equal "/pl/descriptions.xml", @controller.descriptions_path("pl", "xml") + assert_equal "/en/descriptions.xml", @controller.descriptions_path(format: "xml") + assert_equal "/en/descriptions/1", @controller.description_path(1) + assert_equal "/pl/descriptions/1", @controller.description_path("pl", 1) + assert_equal "/pl/descriptions/1", @controller.description_path(1, locale: "pl") + assert_equal "/pl/descriptions/1.xml", @controller.description_path("pl", 1, "xml") + assert_equal "/en/descriptions/1.xml", @controller.description_path(1, format: "xml") end end end @@ -288,7 +365,19 @@ def test_named_routes_with_path_without_doing_a_request_first resources :things end - assert_equal "/things", @controller.send(:things_path) + assert_equal "/things", @controller.things_path end end end + +class BaseTest < ActiveSupport::TestCase + def test_included_modules_are_tracked + base_content = File.read("#{__dir__}/../../lib/action_controller/base.rb") + included_modules = base_content.scan(/(?<=include )[A-Z].*/) + + assert_equal( + ActionController::Base::MODULES.map { |m| m.to_s.delete_prefix("ActionController::") }, + included_modules + ) + end +end diff --git a/actionpack/test/controller/caching_test.rb b/actionpack/test/controller/caching_test.rb index fa8d9dc09a97e..3540de0942211 100644 --- a/actionpack/test/controller/caching_test.rb +++ b/actionpack/test/controller/caching_test.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + require "fileutils" require "abstract_unit" require "lib/controller/fake_models" CACHE_DIR = "test_cache" # Don't change '/../temp/' cavalierly or you might hose something you don't want hosed -FILE_STORE_PATH = File.join(File.dirname(__FILE__), "/../temp/", CACHE_DIR) +FILE_STORE_PATH = File.join(__dir__, "../temp/", CACHE_DIR) class FragmentCachingMetalTestController < ActionController::Metal abstract! @@ -26,10 +28,6 @@ def setup @controller.request = @request @controller.response = @response end - - def test_fragment_cache_key - assert_equal "views/what a key", @controller.fragment_cache_key("what a key") - end end class CachingController < ActionController::Base @@ -43,6 +41,8 @@ def some_action; end end class FragmentCachingTest < ActionController::TestCase + ModelWithKeyAndVersion = Struct.new(:cache_key, :cache_version) + def setup super @store = ActiveSupport::Cache::MemoryStore.new @@ -53,12 +53,17 @@ def setup @controller.params = @params @controller.request = @request @controller.response = @response + + @m1v1 = ModelWithKeyAndVersion.new("model/1", "1") + @m1v2 = ModelWithKeyAndVersion.new("model/1", "2") + @m2v1 = ModelWithKeyAndVersion.new("model/2", "1") + @m2v2 = ModelWithKeyAndVersion.new("model/2", "2") end - def test_fragment_cache_key - assert_equal "views/what a key", @controller.fragment_cache_key("what a key") - assert_equal "views/test.host/fragment_caching_test/some_action", - @controller.fragment_cache_key(controller: "fragment_caching_test", action: "some_action") + def test_combined_fragment_cache_key + assert_equal [ :views, "what a key" ], @controller.combined_fragment_cache_key("what a key") + assert_equal [ :views, "test.host/fragment_caching_test/some_action" ], + @controller.combined_fragment_cache_key(controller: "fragment_caching_test", action: "some_action") end def test_read_fragment_with_caching_enabled @@ -72,17 +77,23 @@ def test_read_fragment_with_caching_disabled assert_nil @controller.read_fragment("name") end + def test_read_fragment_with_versioned_model + @controller.write_fragment([ "stuff", @m1v1 ], "hello") + assert_equal "hello", @controller.read_fragment([ "stuff", @m1v1 ]) + assert_nil @controller.read_fragment([ "stuff", @m1v2 ]) + end + def test_fragment_exist_with_caching_enabled @store.write("views/name", "value") assert @controller.fragment_exist?("name") - assert !@controller.fragment_exist?("other_name") + assert_not @controller.fragment_exist?("other_name") end def test_fragment_exist_with_caching_disabled @controller.perform_caching = false @store.write("views/name", "value") - assert !@controller.fragment_exist?("name") - assert !@controller.fragment_exist?("other_name") + assert_not @controller.fragment_exist?("name") + assert_not @controller.fragment_exist?("other_name") end def test_write_fragment_with_caching_enabled @@ -125,7 +136,7 @@ def test_fragment_for buffer = "generated till now -> ".html_safe buffer << view_context.send(:fragment_for, "expensive") { fragment_computed = true } - assert !fragment_computed + assert_not fragment_computed assert_equal "generated till now -> fragment content", buffer end @@ -140,7 +151,7 @@ def test_html_safety html_safe = @controller.read_fragment("name") assert_equal content, html_safe - assert html_safe.html_safe? + assert_predicate html_safe, :html_safe? end end @@ -154,6 +165,9 @@ def html_fragment_cached_with_partial end end + def xml_fragment_cached_with_html_partial + end + def formatted_fragment_cached respond_to do |format| format.html @@ -194,11 +208,11 @@ def test_fragment_caching Hello This bit's fragment cached Ciao -CACHED + CACHED assert_equal expected_body, @response.body assert_equal "This bit's fragment cached", - @store.read("views/test.host/functional_caching/fragment_cached/#{template_digest("functional_caching/fragment_cached")}") + @store.read("views/functional_caching/fragment_cached:#{template_digest("functional_caching/fragment_cached", "html")}/fragment") end def test_fragment_caching_in_partials @@ -207,7 +221,7 @@ def test_fragment_caching_in_partials assert_match(/Old fragment caching in a partial/, @response.body) assert_match("Old fragment caching in a partial", - @store.read("views/test.host/functional_caching/html_fragment_cached_with_partial/#{template_digest("functional_caching/_partial")}")) + @store.read("views/functional_caching/_partial:#{template_digest("functional_caching/_partial", "html")}/test.host/functional_caching/html_fragment_cached_with_partial")) end def test_skipping_fragment_cache_digesting @@ -237,68 +251,66 @@ def test_render_inline_before_fragment_caching assert_match(/Some inline content/, @response.body) assert_match(/Some cached content/, @response.body) assert_match("Some cached content", - @store.read("views/test.host/functional_caching/inline_fragment_cached/#{template_digest("functional_caching/inline_fragment_cached")}")) + @store.read("views/functional_caching/inline_fragment_cached:#{template_digest("functional_caching/inline_fragment_cached", "html")}/test.host/functional_caching/inline_fragment_cached")) end def test_fragment_cache_instrumentation - payload = nil - - subscriber = proc do |*args| - event = ActiveSupport::Notifications::Event.new(*args) - payload = event.payload - end - - ActiveSupport::Notifications.subscribed(subscriber, "read_fragment.action_controller") do + assert_notification("read_fragment.action_controller", controller: "functional_caching", action: "inline_fragment_cached") do get :inline_fragment_cached end - - assert_equal "functional_caching", payload[:controller] - assert_equal "inline_fragment_cached", payload[:action] end def test_html_formatted_fragment_caching - get :formatted_fragment_cached, format: "html" + format = "html" + get :formatted_fragment_cached, format: format assert_response :success expected_body = "\n

ERB

\n\n" assert_equal expected_body, @response.body assert_equal "

ERB

", - @store.read("views/test.host/functional_caching/formatted_fragment_cached/#{template_digest("functional_caching/formatted_fragment_cached")}") + @store.read("views/functional_caching/formatted_fragment_cached:#{template_digest("functional_caching/formatted_fragment_cached", format)}/fragment") end def test_xml_formatted_fragment_caching - get :formatted_fragment_cached, format: "xml" + format = "xml" + get :formatted_fragment_cached, format: format assert_response :success expected_body = "\n

Builder

\n\n" assert_equal expected_body, @response.body assert_equal "

Builder

\n", - @store.read("views/test.host/functional_caching/formatted_fragment_cached/#{template_digest("functional_caching/formatted_fragment_cached")}") + @store.read("views/functional_caching/formatted_fragment_cached:#{template_digest("functional_caching/formatted_fragment_cached", format)}/fragment") end def test_fragment_caching_with_variant - get :formatted_fragment_cached_with_variant, format: "html", params: { v: :phone } + format = "html" + get :formatted_fragment_cached_with_variant, format: format, params: { v: :phone } assert_response :success expected_body = "\n

PHONE

\n\n" assert_equal expected_body, @response.body assert_equal "

PHONE

", - @store.read("views/test.host/functional_caching/formatted_fragment_cached_with_variant/#{template_digest("functional_caching/formatted_fragment_cached_with_variant")}") + @store.read("views/functional_caching/formatted_fragment_cached_with_variant:#{template_digest("functional_caching/formatted_fragment_cached_with_variant", format)}/fragment") + end + + def test_fragment_caching_with_html_partials_in_xml + get :xml_fragment_cached_with_html_partial, format: "*/*" + assert_response :success end private - def template_digest(name) - ActionView::Digestor.digest(name: name, finder: @controller.lookup_context) + def template_digest(name, format) + ActionView::Digestor.digest(name: name, format: format, finder: @controller.lookup_context) end end class CacheHelperOutputBufferTest < ActionController::TestCase class MockController def read_fragment(name, options) - return false + false end def write_fragment(name, fragment, options) @@ -314,39 +326,16 @@ def test_output_buffer output_buffer = ActionView::OutputBuffer.new controller = MockController.new cache_helper = Class.new do - def self.controller; end; - def self.output_buffer; end; - def self.output_buffer=; end; - end - cache_helper.extend(ActionView::Helpers::CacheHelper) - - cache_helper.stub :controller, controller do - cache_helper.stub :output_buffer, output_buffer do - assert_called_with cache_helper, :output_buffer=, [output_buffer.class.new(output_buffer)] do - assert_nothing_raised do - cache_helper.send :fragment_for, "Test fragment name", "Test fragment", &Proc.new { nil } - end - end - end - end - end - - def test_safe_buffer - output_buffer = ActiveSupport::SafeBuffer.new - controller = MockController.new - cache_helper = Class.new do - def self.controller; end; - def self.output_buffer; end; - def self.output_buffer=; end; + def self.controller; end + def self.output_buffer; end + def self.output_buffer=; end end cache_helper.extend(ActionView::Helpers::CacheHelper) cache_helper.stub :controller, controller do cache_helper.stub :output_buffer, output_buffer do - assert_called_with cache_helper, :output_buffer=, [output_buffer.class.new(output_buffer)] do - assert_nothing_raised do - cache_helper.send :fragment_for, "Test fragment name", "Test fragment", &Proc.new { nil } - end + assert_nothing_raised do + cache_helper.send :fragment_for, "Test fragment name", "Test fragment", &Proc.new { nil } end end end @@ -363,7 +352,7 @@ class HasDependenciesController < ActionController::Base end def test_view_cache_dependencies_are_empty_by_default - assert NoDependenciesController.new.view_cache_dependencies.empty? + assert_empty NoDependenciesController.new.view_cache_dependencies end def test_view_cache_dependencies_are_listed_in_declaration_order @@ -412,7 +401,7 @@ def setup def test_collection_fetches_cached_views get :index assert_equal 1, @controller.partial_rendered_times - assert_customer_cached "david/1", "david, 1" + assert_match "david, 1", ActionView::PartialRenderer.collection_cache.read("views/customers/_customer:7c228ab609f0baf0b1f2367469210937/david/1") get :index assert_equal 1, @controller.partial_rendered_times @@ -425,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 @@ -444,14 +433,8 @@ def test_caching_works_with_beginning_comment def test_caching_with_callable_cache_key get :index_with_callable_cache_key - assert_customer_cached "cached_david", "david, 1" + assert_match "david, 1", ActionView::PartialRenderer.collection_cache.read("views/customers/_customer:7c228ab609f0baf0b1f2367469210937/cached_david") end - - private - def assert_customer_cached(key, content) - assert_match content, - ActionView::PartialRenderer.collection_cache.read("views/#{key}/7c228ab609f0baf0b1f2367469210937") - end end class FragmentCacheKeyTestController < CachingController @@ -470,11 +453,21 @@ def setup @controller.cache_store = @store end - def test_fragment_cache_key + def test_combined_fragment_cache_key @controller.account_id = "123" - assert_equal "views/v1/123/what a key", @controller.fragment_cache_key("what a key") + assert_equal [ :views, "v1", "123", "what a key" ], @controller.combined_fragment_cache_key("what a key") @controller.account_id = nil - assert_equal "views/v1//what a key", @controller.fragment_cache_key("what a key") + assert_equal [ :views, "v1", "what a key" ], @controller.combined_fragment_cache_key("what a key") + end + + def test_combined_fragment_cache_key_with_envs + ENV["RAILS_APP_VERSION"] = "55" + assert_equal [ :views, "55", "v1", "what a key" ], @controller.combined_fragment_cache_key("what a key") + + ENV["RAILS_CACHE_ID"] = "66" + assert_equal [ :views, "66", "v1", "what a key" ], @controller.combined_fragment_cache_key("what a key") + ensure + ENV["RAILS_CACHE_ID"] = ENV["RAILS_APP_VERSION"] = nil end end diff --git a/actionpack/test/controller/chunked_test.rb b/actionpack/test/controller/chunked_test.rb new file mode 100644 index 0000000000000..3402daa5157c1 --- /dev/null +++ b/actionpack/test/controller/chunked_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "stringio" +require "abstract_unit" + +class ChunkedTest < ActionDispatch::IntegrationTest + class ChunkedController < ApplicationController + def chunk + render json: { + raw_post: request.raw_post, + content_length: request.content_length + } + end + end + + # The TestInput class prevents Rack::MockRequest from adding a Content-Length when the method `size` is defined + class TestInput < StringIO + undef_method :size + end + + test "parses request raw_post correctly when request has Transfer-Encoding header without a Content-Length value" do + @app = self.class.build_app + @app.routes.draw do + post "chunked", to: ChunkedController.action(:chunk) + end + + post "/chunked", params: TestInput.new("foo=bar"), headers: { "Transfer-Encoding" => "gzip, chunked;foo=bar" } + + assert_equal 7, response.parsed_body["content_length"] + assert_equal "foo=bar", response.parsed_body["raw_post"] + end +end 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/content_type_test.rb b/actionpack/test/controller/content_type_test.rb index fcb2632b80e8c..8df6edfdc34a2 100644 --- a/actionpack/test/controller/content_type_test.rb +++ b/actionpack/test/controller/content_type_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class OldContentTypeController < ActionController::Base @@ -48,6 +50,16 @@ def render_default_content_types_for_respond_to format.rss { render body: "hello world!", content_type: Mime[:xml] } end end + + def render_content_type_with_charset + response.content_type = "text/html; fragment; charset=utf-16" + render body: "hello world!" + end + + def render_content_type_with_symbol + response.content_type = :rss + render body: "hello world!" + end end class ContentTypeTest < ActionController::TestCase @@ -64,73 +76,84 @@ def setup def test_render_defaults get :render_defaults assert_equal "utf-8", @response.charset - assert_equal Mime[:text], @response.content_type + assert_equal Mime[:text], @response.media_type end def test_render_changed_charset_default with_default_charset "utf-16" do get :render_defaults assert_equal "utf-16", @response.charset - assert_equal Mime[:text], @response.content_type + assert_equal Mime[:text], @response.media_type end end # :ported: def test_content_type_from_body get :render_content_type_from_body - assert_equal Mime[:rss], @response.content_type + assert_equal Mime[:rss], @response.media_type assert_equal "utf-8", @response.charset end # :ported: def test_content_type_from_render get :render_content_type_from_render - assert_equal Mime[:rss], @response.content_type + assert_equal Mime[:rss], @response.media_type assert_equal "utf-8", @response.charset end # :ported: def test_charset_from_body get :render_charset_from_body - assert_equal Mime[:text], @response.content_type + assert_equal Mime[:text], @response.media_type assert_equal "utf-16", @response.charset end # :ported: def test_nil_charset_from_body get :render_nil_charset_from_body - assert_equal Mime[:text], @response.content_type + assert_equal Mime[:text], @response.media_type assert_equal "utf-8", @response.charset, @response.headers.inspect end def test_nil_default_for_erb with_default_charset nil do get :render_default_for_erb - assert_equal Mime[:html], @response.content_type + assert_equal Mime[:html], @response.media_type assert_nil @response.charset, @response.headers.inspect end end def test_default_for_erb get :render_default_for_erb - assert_equal Mime[:html], @response.content_type + assert_equal Mime[:html], @response.media_type assert_equal "utf-8", @response.charset end def test_default_for_builder get :render_default_for_builder - assert_equal Mime[:xml], @response.content_type + assert_equal Mime[:xml], @response.media_type assert_equal "utf-8", @response.charset end def test_change_for_builder get :render_change_for_builder - assert_equal Mime[:html], @response.content_type + assert_equal Mime[:html], @response.media_type assert_equal "utf-8", @response.charset end - private + def test_content_type_with_charset + get :render_content_type_with_charset + assert_equal "text/html; fragment", @response.media_type + assert_equal "utf-16", @response.charset + end + def test_content_type_with_symbol + get :render_content_type_with_symbol + assert_equal Mime[:rss], @response.media_type + assert_equal "utf-8", @response.charset + end + + private def with_default_charset(charset) old_default_charset = ActionDispatch::Response.default_charset ActionDispatch::Response.default_charset = charset @@ -146,22 +169,22 @@ class AcceptBasedContentTypeTest < ActionController::TestCase def test_render_default_content_types_for_respond_to @request.accept = Mime[:html].to_s get :render_default_content_types_for_respond_to - assert_equal Mime[:html], @response.content_type + assert_equal Mime[:html], @response.media_type @request.accept = Mime[:js].to_s get :render_default_content_types_for_respond_to - assert_equal Mime[:js], @response.content_type + assert_equal Mime[:js], @response.media_type end def test_render_default_content_types_for_respond_to_with_template @request.accept = Mime[:xml].to_s get :render_default_content_types_for_respond_to - assert_equal Mime[:xml], @response.content_type + assert_equal Mime[:xml], @response.media_type end def test_render_default_content_types_for_respond_to_with_overwrite @request.accept = Mime[:rss].to_s get :render_default_content_types_for_respond_to - assert_equal Mime[:xml], @response.content_type + assert_equal Mime[:xml], @response.media_type end end diff --git a/actionpack/test/controller/default_url_options_with_before_action_test.rb b/actionpack/test/controller/default_url_options_with_before_action_test.rb index e3fe7a64957a6..fc5b8288cdc72 100644 --- a/actionpack/test/controller/default_url_options_with_before_action_test.rb +++ b/actionpack/test/controller/default_url_options_with_before_action_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class ControllerWithBeforeActionAndDefaultUrlOptions < ActionController::Base diff --git a/actionpack/test/controller/filters_test.rb b/actionpack/test/controller/filters_test.rb index 5f1463cfa8cba..b0b12f002668f 100644 --- a/actionpack/test/controller/filters_test.rb +++ b/actionpack/test/controller/filters_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class ActionController::Base @@ -10,7 +12,7 @@ class << self def before_actions filters = _process_action_callbacks.select { |c| c.kind == :before } - filters.map!(&:raw_filter) + filters.map!(&:filter) end end end @@ -92,7 +94,7 @@ class RenderingForPrependAfterActionController < RenderingController private def unreached_prepend_after_action - @ran_filter << "unreached_preprend_after_action_after_render" + @ran_filter << "unreached_prepend_after_action_after_render" end end @@ -193,7 +195,7 @@ class ExceptConditionClassController < ConditionalFilterController before_action ConditionalClassFilter, except: :show_without_action end - class AnomolousYetValidConditionController < ConditionalFilterController + class AnomalousYetValidConditionController < ConditionalFilterController before_action(ConditionalClassFilter, :ensure_login, Proc.new { |c| c.instance_variable_set(:"@ran_proc_action1", true) }, except: :show_without_action) { |c| c.instance_variable_set(:"@ran_proc_action2", true) } end @@ -308,7 +310,6 @@ class ConditionalParentOfConditionalSkippingController < ConditionalFilterContro after_action :conditional_in_parent_after, only: [:show, :another_action] private - def conditional_in_parent_before @ran_filter ||= [] @ran_filter << "conditional_in_parent_before" @@ -346,7 +347,7 @@ def self.before(controller) class AroundFilter def before(controller) @execution_log = "before" - controller.class.execution_log << " before aroundfilter " if controller.respond_to? :execution_log + controller.class.execution_log += " before aroundfilter " if controller.respond_to? :execution_log controller.instance_variable_set(:"@before_ran", true) end @@ -392,11 +393,9 @@ class AroundFilterController < PrependingController end class BeforeAfterClassFilterController < PrependingController - begin - filter = AroundFilter.new - before_action filter - after_action filter - end + filter = AroundFilter.new + before_action filter + after_action filter end class MixedFilterController < PrependingController @@ -455,6 +454,7 @@ class PrependingBeforeAndAfterController < ActionController::Base prepend_before_action :before_all prepend_after_action :after_all before_action :between_before_all_and_after_all + after_action :between_before_all_and_after_all def before_all @ran_filter ||= [] @@ -470,6 +470,7 @@ def between_before_all_and_after_all @ran_filter ||= [] @ran_filter << "between_before_all_and_after_all" end + def show render plain: "hello" end @@ -504,7 +505,6 @@ def index end private - def filter_one @filters ||= [] @filters << "filter_one" @@ -514,7 +514,7 @@ def action_two @filters << "action_two" end - def non_yielding_action + def non_yielding_action(&) @filters << "it didn't yield" end @@ -528,7 +528,6 @@ class ImplicitActionsController < ActionController::Base before_action :find_except, except: :edit private - def find_only @only = "Only" end @@ -545,6 +544,31 @@ def test_non_yielding_around_actions_do_not_raise end end + def test_around_action_can_use_yield_inline_with_passed_action + controller = Class.new(ActionController::Base) do + around_action do |c, a| + c.values << "before" + a.call + c.values << "after" + end + + def index + values << "action" + render inline: "index" + end + + def values + @values ||= [] + end + end.new + + assert_nothing_raised do + test_process(controller, "index") + end + + assert_equal ["before", "action", "after"], controller.values + end + def test_after_actions_are_not_run_if_around_action_does_not_yield controller = NonYieldingAroundFilterController.new test_process(controller, "index") @@ -585,13 +609,13 @@ def test_running_actions_with_class end def test_running_anomalous_yet_valid_condition_actions - test_process(AnomolousYetValidConditionController) + test_process(AnomalousYetValidConditionController) assert_equal %w( ensure_login ), @controller.instance_variable_get(:@ran_filter) assert @controller.instance_variable_get(:@ran_class_action) assert @controller.instance_variable_get(:@ran_proc_action1) assert @controller.instance_variable_get(:@ran_proc_action2) - test_process(AnomolousYetValidConditionController, "show_without_action") + test_process(AnomalousYetValidConditionController, "show_without_action") assert_not @controller.instance_variable_defined?(:@ran_filter) assert_not @controller.instance_variable_defined?(:@ran_class_action) assert_not @controller.instance_variable_defined?(:@ran_proc_action1) @@ -728,13 +752,13 @@ def test_before_action_redirects_breaks_actioning_chain_for_after_action assert_equal %w( before_action_redirects ), @controller.instance_variable_get(:@ran_filter) end - def test_before_action_rendering_breaks_actioning_chain_for_preprend_after_action + def test_before_action_rendering_breaks_actioning_chain_for_prepend_after_action test_process(RenderingForPrependAfterActionController) assert_equal %w( before_action_rendering ), @controller.instance_variable_get(:@ran_filter) assert_not @controller.instance_variable_defined?(:@ran_action) end - def test_before_action_redirects_breaks_actioning_chain_for_preprend_after_action + def test_before_action_redirects_breaks_actioning_chain_for_prepend_after_action test_process(BeforeActionRedirectionForPrependAfterActionController) assert_response :redirect assert_equal "http://test.host/filter_test/before_action_redirection_for_prepend_after_action/target_of_redirection", redirect_to_url @@ -763,7 +787,7 @@ def test_dynamic_dispatch def test_running_prepended_before_and_after_action test_process(PrependingBeforeAndAfterController) - assert_equal %w( before_all between_before_all_and_after_all after_all ), @controller.instance_variable_get(:@ran_filter) + assert_equal %w( before_all between_before_all_and_after_all between_before_all_and_after_all after_all ), @controller.instance_variable_get(:@ran_filter) end def test_skipping_and_limiting_controller @@ -785,7 +809,7 @@ def test_conditional_skipping_of_actions assert_equal %w( ensure_login find_user ), @controller.instance_variable_get(:@ran_filter) test_process(ConditionalSkippingController, "login") - assert !@controller.instance_variable_defined?("@ran_after_action") + assert_not @controller.instance_variable_defined?("@ran_after_action") test_process(ConditionalSkippingController, "change_password") assert_equal %w( clean_up ), @controller.instance_variable_get("@ran_after_action") end @@ -817,7 +841,7 @@ def test_a_rescuing_around_action response = test_process(RescuedController) end - assert response.successful? + assert_predicate response, :successful? assert_equal("I rescued this: #", response.body) end @@ -884,7 +908,7 @@ def without_exception yield # Do stuff... - wtf += 1 + wtf + 1 end end @@ -996,16 +1020,12 @@ def test_with_proc def test_nested_actions controller = ControllerWithNestedFilters assert_nothing_raised do - begin - test_process(controller, "raises_both") - rescue Before, After - end + test_process(controller, "raises_both") + rescue Before, After end assert_raise Before do - begin - test_process(controller, "raises_both") - rescue After - end + test_process(controller, "raises_both") + rescue After end end diff --git a/actionpack/test/controller/flash_hash_test.rb b/actionpack/test/controller/flash_hash_test.rb index 45b598a5941bf..929d8a113034f 100644 --- a/actionpack/test/controller/flash_hash_test.rb +++ b/actionpack/test/controller/flash_hash_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch @@ -42,7 +44,7 @@ def test_delete @hash["foo"] = "bar" @hash.delete "foo" - assert !@hash.key?("foo") + assert_not @hash.key?("foo") assert_nil @hash["foo"] end @@ -51,7 +53,7 @@ def test_to_hash assert_equal({ "foo" => "bar" }, @hash.to_hash) @hash.to_hash["zomg"] = "aaron" - assert !@hash.key?("zomg") + assert_not @hash.key?("zomg") assert_equal({ "foo" => "bar" }, @hash.to_hash) end @@ -80,7 +82,7 @@ def test_from_session_value def test_from_session_value_on_json_serializer decrypted_data = "{ \"session_id\":\"d98bdf6d129618fc2548c354c161cfb5\", \"flash\":{\"discard\":[\"farewell\"], \"flashes\":{\"greeting\":\"Hello\",\"farewell\":\"Goodbye\"}} }" - session = ActionDispatch::Cookies::JsonSerializer.load(decrypted_data) + session = ActiveSupport::Messages::SerializerWithFallback[:json].load(decrypted_data) hash = Flash::FlashHash.from_session_value(session["flash"]) assert_equal({ "greeting" => "Hello" }, hash.to_hash) @@ -90,11 +92,11 @@ def test_from_session_value_on_json_serializer end def test_empty? - assert @hash.empty? + assert_empty @hash @hash["zomg"] = "bears" - assert !@hash.empty? + assert_not_empty @hash @hash.clear - assert @hash.empty? + assert_empty @hash end def test_each diff --git a/actionpack/test/controller/flash_test.rb b/actionpack/test/controller/flash_test.rb index dc641c19abda6..e7c3d99d93ae5 100644 --- a/actionpack/test/controller/flash_test.rb +++ b/actionpack/test/controller/flash_test.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + require "abstract_unit" -require "active_support/key_generator" +require "active_support/messages/rotation_configuration" class FlashTest < ActionController::TestCase class TestController < ActionController::Base @@ -187,27 +189,27 @@ def test_keep_and_discard_return_values def test_redirect_to_with_alert get :redirect_with_alert - assert_equal "Beware the nowheres!", @controller.send(:flash)[:alert] + assert_equal "Beware the nowheres!", @controller.flash[:alert] end def test_redirect_to_with_notice get :redirect_with_notice - assert_equal "Good luck in the somewheres!", @controller.send(:flash)[:notice] + assert_equal "Good luck in the somewheres!", @controller.flash[:notice] end def test_render_with_flash_now_alert get :render_with_flash_now_alert - assert_equal "Beware the nowheres now!", @controller.send(:flash)[:alert] + assert_equal "Beware the nowheres now!", @controller.flash[:alert] end def test_render_with_flash_now_notice get :render_with_flash_now_notice - assert_equal "Good luck in the somewheres now!", @controller.send(:flash)[:notice] + assert_equal "Good luck in the somewheres now!", @controller.flash[:notice] end def test_redirect_to_with_other_flashes get :redirect_with_other_flashes - assert_equal "Horses!", @controller.send(:flash)[:joyride] + assert_equal "Horses!", @controller.flash[:joyride] end def test_redirect_to_with_adding_flash_types @@ -217,11 +219,18 @@ def test_redirect_to_with_adding_flash_types end @controller = test_controller_with_flash_type_foo.new get :redirect_with_foo_flash - assert_equal "for great justice", @controller.send(:flash)[:foo] + assert_equal "for great justice", @controller.flash[:foo] ensure @controller = original_controller end + def test_additional_flash_types_are_not_listed_in_actions_set + test_controller_with_flash_type_foo = Class.new(TestController) do + add_flash_types :foo + end + assert_not_includes test_controller_with_flash_type_foo.action_methods, "foo" + end + def test_add_flash_type_to_subclasses test_controller_with_flash_type_foo = Class.new(TestController) do add_flash_types :foo @@ -240,7 +249,11 @@ def test_does_not_add_flash_type_to_parent_class class FlashIntegrationTest < ActionDispatch::IntegrationTest SessionKey = "_myapp_session" - Generator = ActiveSupport::LegacyKeyGenerator.new("b3c631c314c0bbca50c1b2843150fe33") + Generator = ActiveSupport::CachingKeyGenerator.new( + ActiveSupport::KeyGenerator.new("b3c631c314c0bbca50c1b2843150fe33", iterations: 1000) + ) + Rotations = ActiveSupport::Messages::RotationConfiguration.new + SIGNED_COOKIE_SALT = "signed cookie" class TestController < ActionController::Base add_flash_types :bar @@ -250,6 +263,11 @@ def set_flash head :ok end + def set_html_flash + flash["that"] = ActiveSupport::SafeBuffer.new("

Hello world

") + head :ok + end + def set_flash_now flash.now["that"] = "hello" head :ok @@ -261,7 +279,7 @@ def use_flash def set_bar flash[:bar] = "for great justice" - head :ok + render inline: "<%= bar %>" end def set_flash_optionally @@ -284,6 +302,18 @@ def test_flash end end + def test_flash_safebuffer + with_test_route_set do + get "/set_html_flash", env: { "action_dispatch.cookies_serializer" => :message_pack } + assert_response :success + assert_equal "

Hello world

", @request.flash["that"] + + get "/use_flash", env: { "action_dispatch.cookies_serializer" => :message_pack } + assert_response :success + assert_equal "flash:

Hello world

", @response.body + end + end + def test_just_using_flash_does_not_stream_a_cookie_back with_test_route_set do get "/use_flash" @@ -297,7 +327,9 @@ def test_setting_flash_does_not_raise_in_following_requests with_test_route_set do env = { "action_dispatch.request.flash_hash" => ActionDispatch::Flash::FlashHash.new } get "/set_flash", env: env - get "/set_flash", env: env + assert_nothing_raised do + get "/set_flash", env: env + end end end @@ -305,7 +337,9 @@ def test_setting_flash_now_does_not_raise_in_following_requests with_test_route_set do env = { "action_dispatch.request.flash_hash" => ActionDispatch::Flash::FlashHash.new } get "/set_flash_now", env: env - get "/set_flash_now", env: env + assert_nothing_raised do + get "/set_flash_now", env: env + end end end @@ -313,7 +347,7 @@ def test_added_flash_types_method with_test_route_set do get "/set_bar" assert_response :success - assert_equal "for great justice", @controller.bar + assert_equal "for great justice", response.body end end @@ -339,30 +373,50 @@ def test_flash_factored_into_etag end end - private + def test_flash_unusable_in_metal_without_helper + controller_class = nil + + assert_nothing_raised do + controller_class = Class.new(ActionController::Metal) do + include ActionController::Flash + end + end + controller = controller_class.new + + assert_not_respond_to controller, :alert + assert_not_respond_to controller, :notice + + assert_includes controller.private_methods, :alert + assert_includes controller.private_methods, :notice + end + + private # Overwrite get to send SessionSecret in env hash - def get(path, *args) - args[0] ||= {} - args[0][:env] ||= {} - args[0][:env]["action_dispatch.key_generator"] ||= Generator - super(path, *args) + def get(path, **options) + options[:env] ||= {} + options[:env]["action_dispatch.key_generator"] ||= Generator + options[:env]["action_dispatch.cookies_rotations"] = Rotations + options[:env]["action_dispatch.signed_cookie_salt"] = SIGNED_COOKIE_SALT + super(path, **options) + end + + def app + @app ||= self.class.build_app do |middleware| + middleware.use ActionDispatch::Session::CookieStore, key: SessionKey + middleware.use ActionDispatch::Flash + middleware.delete ActionDispatch::ShowExceptions + end end def with_test_route_set with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action", to: FlashIntegrationTest::TestController end end - @app = self.class.build_app(set) do |middleware| - middleware.use ActionDispatch::Session::CookieStore, key: SessionKey - middleware.use ActionDispatch::Flash - middleware.delete ActionDispatch::ShowExceptions - end - yield end end diff --git a/actionpack/test/controller/force_ssl_test.rb b/actionpack/test/controller/force_ssl_test.rb deleted file mode 100644 index 2b3859aa57402..0000000000000 --- a/actionpack/test/controller/force_ssl_test.rb +++ /dev/null @@ -1,331 +0,0 @@ -require "abstract_unit" - -class ForceSSLController < ActionController::Base - def banana - render plain: "monkey" - end - - def cheeseburger - render plain: "sikachu" - end -end - -class ForceSSLControllerLevel < ForceSSLController - force_ssl -end - -class ForceSSLCustomOptions < ForceSSLController - force_ssl host: "secure.example.com", only: :redirect_host - force_ssl port: 8443, only: :redirect_port - force_ssl subdomain: "secure", only: :redirect_subdomain - force_ssl domain: "secure.com", only: :redirect_domain - force_ssl path: "/foo", only: :redirect_path - force_ssl status: :found, only: :redirect_status - force_ssl flash: { message: "Foo, Bar!" }, only: :redirect_flash - force_ssl alert: "Foo, Bar!", only: :redirect_alert - force_ssl notice: "Foo, Bar!", only: :redirect_notice - - def force_ssl_action - render plain: action_name - end - - alias_method :redirect_host, :force_ssl_action - alias_method :redirect_port, :force_ssl_action - alias_method :redirect_subdomain, :force_ssl_action - alias_method :redirect_domain, :force_ssl_action - alias_method :redirect_path, :force_ssl_action - alias_method :redirect_status, :force_ssl_action - alias_method :redirect_flash, :force_ssl_action - alias_method :redirect_alert, :force_ssl_action - alias_method :redirect_notice, :force_ssl_action - - def use_flash - render plain: flash[:message] - end - - def use_alert - render plain: flash[:alert] - end - - def use_notice - render plain: flash[:notice] - end -end - -class ForceSSLOnlyAction < ForceSSLController - force_ssl only: :cheeseburger -end - -class ForceSSLExceptAction < ForceSSLController - force_ssl except: :banana -end - -class ForceSSLIfCondition < ForceSSLController - force_ssl if: :use_force_ssl? - - def use_force_ssl? - action_name == "cheeseburger" - end -end - -class ForceSSLFlash < ForceSSLController - force_ssl except: [:banana, :set_flash, :use_flash] - - def set_flash - flash["that"] = "hello" - redirect_to "/force_ssl_flash/cheeseburger" - end - - def use_flash - @flash_copy = {}.update flash - @flashy = flash["that"] - render inline: "hello" - end -end - -class RedirectToSSL < ForceSSLController - def banana - force_ssl_redirect || render(plain: "monkey") - end - def cheeseburger - force_ssl_redirect("secure.cheeseburger.host") || render(plain: "ihaz") - end -end - -class ForceSSLControllerLevelTest < ActionController::TestCase - def test_banana_redirects_to_https - get :banana - assert_response 301 - assert_equal "https://test.host/force_ssl_controller_level/banana", redirect_to_url - end - - def test_banana_redirects_to_https_with_extra_params - get :banana, params: { token: "secret" } - assert_response 301 - assert_equal "https://test.host/force_ssl_controller_level/banana?token=secret", redirect_to_url - end - - def test_cheeseburger_redirects_to_https - get :cheeseburger - assert_response 301 - assert_equal "https://test.host/force_ssl_controller_level/cheeseburger", redirect_to_url - end -end - -class ForceSSLCustomOptionsTest < ActionController::TestCase - def setup - @request.env["HTTP_HOST"] = "www.example.com:80" - end - - def test_redirect_to_custom_host - get :redirect_host - assert_response 301 - assert_equal "https://secure.example.com/force_ssl_custom_options/redirect_host", redirect_to_url - end - - def test_redirect_to_custom_port - get :redirect_port - assert_response 301 - assert_equal "https://www.example.com:8443/force_ssl_custom_options/redirect_port", redirect_to_url - end - - def test_redirect_to_custom_subdomain - get :redirect_subdomain - assert_response 301 - assert_equal "https://secure.example.com/force_ssl_custom_options/redirect_subdomain", redirect_to_url - end - - def test_redirect_to_custom_domain - get :redirect_domain - assert_response 301 - assert_equal "https://www.secure.com/force_ssl_custom_options/redirect_domain", redirect_to_url - end - - def test_redirect_to_custom_path - get :redirect_path - assert_response 301 - assert_equal "https://www.example.com/foo", redirect_to_url - end - - def test_redirect_to_custom_status - get :redirect_status - assert_response 302 - assert_equal "https://www.example.com/force_ssl_custom_options/redirect_status", redirect_to_url - end - - def test_redirect_to_custom_flash - get :redirect_flash - assert_response 301 - assert_equal "https://www.example.com/force_ssl_custom_options/redirect_flash", redirect_to_url - - get :use_flash - assert_response 200 - assert_equal "Foo, Bar!", @response.body - end - - def test_redirect_to_custom_alert - get :redirect_alert - assert_response 301 - assert_equal "https://www.example.com/force_ssl_custom_options/redirect_alert", redirect_to_url - - get :use_alert - assert_response 200 - assert_equal "Foo, Bar!", @response.body - end - - def test_redirect_to_custom_notice - get :redirect_notice - assert_response 301 - assert_equal "https://www.example.com/force_ssl_custom_options/redirect_notice", redirect_to_url - - get :use_notice - assert_response 200 - assert_equal "Foo, Bar!", @response.body - end -end - -class ForceSSLOnlyActionTest < ActionController::TestCase - def test_banana_not_redirects_to_https - get :banana - assert_response 200 - end - - def test_cheeseburger_redirects_to_https - get :cheeseburger - assert_response 301 - assert_equal "https://test.host/force_ssl_only_action/cheeseburger", redirect_to_url - end -end - -class ForceSSLExceptActionTest < ActionController::TestCase - def test_banana_not_redirects_to_https - get :banana - assert_response 200 - end - - def test_cheeseburger_redirects_to_https - get :cheeseburger - assert_response 301 - assert_equal "https://test.host/force_ssl_except_action/cheeseburger", redirect_to_url - end -end - -class ForceSSLIfConditionTest < ActionController::TestCase - def test_banana_not_redirects_to_https - get :banana - assert_response 200 - end - - def test_cheeseburger_redirects_to_https - get :cheeseburger - assert_response 301 - assert_equal "https://test.host/force_ssl_if_condition/cheeseburger", redirect_to_url - end -end - -class ForceSSLFlashTest < ActionController::TestCase - def test_cheeseburger_redirects_to_https - get :set_flash - assert_response 302 - assert_equal "http://test.host/force_ssl_flash/cheeseburger", redirect_to_url - - @request.env.delete("PATH_INFO") - - get :cheeseburger - assert_response 301 - assert_equal "https://test.host/force_ssl_flash/cheeseburger", redirect_to_url - - @request.env.delete("PATH_INFO") - - get :use_flash - assert_equal "hello", @controller.instance_variable_get("@flash_copy")["that"] - assert_equal "hello", @controller.instance_variable_get("@flashy") - end -end - -class ForceSSLDuplicateRoutesTest < ActionController::TestCase - tests ForceSSLControllerLevel - - def test_force_ssl_redirects_to_same_path - with_routing do |set| - set.draw do - get "/foo", to: "force_ssl_controller_level#banana" - get "/bar", to: "force_ssl_controller_level#banana" - end - - @request.env["PATH_INFO"] = "/bar" - - get :banana - assert_response 301 - assert_equal "https://test.host/bar", redirect_to_url - end - end -end - -class ForceSSLFormatTest < ActionController::TestCase - tests ForceSSLControllerLevel - - def test_force_ssl_redirects_to_same_format - with_routing do |set| - set.draw do - get "/foo", to: "force_ssl_controller_level#banana" - end - - get :banana, format: :json - assert_response 301 - assert_equal "https://test.host/foo.json", redirect_to_url - end - end -end - -class ForceSSLOptionalSegmentsTest < ActionController::TestCase - tests ForceSSLControllerLevel - - def test_force_ssl_redirects_to_same_format - with_routing do |set| - set.draw do - scope "(:locale)" do - defaults locale: "en" do - get "/foo", to: "force_ssl_controller_level#banana" - end - end - end - - @request.env["PATH_INFO"] = "/en/foo" - get :banana, params: { locale: "en" } - assert_equal "en", @controller.params[:locale] - assert_response 301 - assert_equal "https://test.host/en/foo", redirect_to_url - end - end -end - -class RedirectToSSLTest < ActionController::TestCase - def test_banana_redirects_to_https_if_not_https - get :banana - assert_response 301 - assert_equal "https://test.host/redirect_to_ssl/banana", redirect_to_url - end - - def test_cheeseburgers_redirects_to_https_with_new_host_if_not_https - get :cheeseburger - assert_response 301 - assert_equal "https://secure.cheeseburger.host/redirect_to_ssl/cheeseburger", redirect_to_url - end - - def test_cheeseburgers_does_not_redirect_if_already_https - request.env["HTTPS"] = "on" - get :cheeseburger - assert_response 200 - assert_equal "ihaz", response.body - end -end - -class ForceSSLControllerLevelTest < ActionController::TestCase - def test_no_redirect_websocket_ssl_request - request.env["rack.url_scheme"] = "wss" - request.env["Upgrade"] = "websocket" - get :cheeseburger - assert_response 200 - end -end diff --git a/actionpack/test/controller/form_builder_test.rb b/actionpack/test/controller/form_builder_test.rb index 5a3dc2ee03bca..2db0834c5ecee 100644 --- a/actionpack/test/controller/form_builder_test.rb +++ b/actionpack/test/controller/form_builder_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class FormBuilderController < ActionController::Base diff --git a/actionpack/test/controller/helper_test.rb b/actionpack/test/controller/helper_test.rb index 4c6a77206222b..2f3ffacc97ecd 100644 --- a/actionpack/test/controller/helper_test.rb +++ b/actionpack/test/controller/helper_test.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require "abstract_unit" -ActionController::Base.helpers_path = File.expand_path("../../fixtures/helpers", __FILE__) +ActionController::Base.helpers_path = File.expand_path("../fixtures/helpers", __dir__) module Fun class GamesController < ActionController::Base @@ -35,7 +37,7 @@ class JustMeController < ActionController::Base clear_helpers def flash - render inline: "

<%= notice %>

" + render inline: "

<%= request.flash[:notice] %>

" end def lib @@ -48,11 +50,12 @@ class MeTooController < JustMeController class HelpersPathsController < ActionController::Base paths = ["helpers2_pack", "helpers1_pack"].map do |path| - File.join(File.expand_path("../../fixtures", __FILE__), path) + File.join(File.expand_path("../fixtures", __dir__), path) end - $:.unshift(*paths) self.helpers_path = paths + ActionPackTestSuiteUtils.require_helpers(helpers_path) + helper :all def index @@ -61,9 +64,8 @@ def index end class HelpersTypoController < ActionController::Base - path = File.expand_path("../../fixtures/helpers_typo", __FILE__) - $:.unshift(path) - self.helpers_path = path + self.helpers_path = File.expand_path("../fixtures/helpers_typo", __dir__) + ActionPackTestSuiteUtils.require_helpers(helpers_path) end module LocalAbcHelper @@ -83,18 +85,10 @@ def test_helpers_paths_priority end class HelpersTypoControllerTest < ActiveSupport::TestCase - def setup - @autoload_paths = ActiveSupport::Dependencies.autoload_paths - ActiveSupport::Dependencies.autoload_paths = Array(HelpersTypoController.helpers_path) - end - def test_helper_typo_error_message e = assert_raise(NameError) { HelpersTypoController.helper "admin/users" } - assert_equal "Couldn't find Admin::UsersHelper, expected it to be defined in helpers/admin/users_helper.rb", e.message - end - - def teardown - ActiveSupport::Dependencies.autoload_paths = @autoload_paths + assert_includes e.message, "uninitialized constant Admin::UsersHelper" + assert_includes e.detailed_message, "Did you mean? Admin::UsersHelpeR" end end @@ -102,16 +96,19 @@ class HelperTest < ActiveSupport::TestCase class TestController < ActionController::Base attr_accessor :delegate_attr def delegate_method() end + def delegate_method_arg(arg); arg; end + def delegate_method_kwarg(hi:); hi; end + def method_that_raises + raise "an error occurred" + end end def setup # Increment symbol counter. - @symbol = (@@counter ||= "A0").succ!.dup + @symbol = (@@counter ||= "A0").succ.dup # Generate new controller class. - controller_class_name = "Helper#{@symbol}Controller" - eval("class #{controller_class_name} < TestController; end") - @controller_class = self.class.const_get(controller_class_name) + @controller_class = Class.new(TestController) # Set default test helper. self.test_helper = LocalAbcHelper @@ -128,6 +125,39 @@ def test_helper_method assert_includes master_helper_methods, :delegate_method end + def test_helper_method_arg + assert_nothing_raised { @controller_class.helper_method :delegate_method_arg } + assert_equal({ hi: :there }, @controller_class.new.helpers.delegate_method_arg({ hi: :there })) + end + + def test_helper_method_arg_does_not_call_to_hash + assert_nothing_raised { @controller_class.helper_method :delegate_method_arg } + + my_class = Class.new do + def to_hash + { hi: :there } + end + end.new + + assert_equal(my_class, @controller_class.new.helpers.delegate_method_arg(my_class)) + end + + def test_helper_method_kwarg + assert_nothing_raised { @controller_class.helper_method :delegate_method_kwarg } + + assert_equal(:there, @controller_class.new.helpers.delegate_method_kwarg(hi: :there)) + end + + def test_helper_method_with_error_has_correct_backgrace + @controller_class.helper_method :method_that_raises + expected_backtrace_pattern = "#{__FILE__}:#{__LINE__ - 1}" + + error = assert_raises(RuntimeError) do + @controller_class.new.helpers.method_that_raises + end + assert_not_nil error.backtrace.find { |line| line.include?(expected_backtrace_pattern) } + end + def test_helper_attr assert_nothing_raised { @controller_class.helper_attr :delegate_attr } assert_includes master_helper_methods, :delegate_attr @@ -148,8 +178,8 @@ def test_helper_for_acronym_controller end def test_default_helpers_only - assert_equal [JustMeHelper], JustMeController._helpers.ancestors.reject(&:anonymous?) - assert_equal [MeTooHelper, JustMeHelper], MeTooController._helpers.ancestors.reject(&:anonymous?) + assert_equal %w[JustMeHelper], JustMeController._helpers.ancestors.reject(&:anonymous?).map(&:to_s) + assert_equal %w[MeTooController::HelperMethods MeTooHelper JustMeHelper], MeTooController._helpers.ancestors.reject(&:anonymous?).map(&:to_s) end def test_base_helper_methods_after_clear_helpers @@ -178,7 +208,8 @@ def test_all_helpers end def test_all_helpers_with_alternate_helper_dir - @controller_class.helpers_path = File.expand_path("../../fixtures/alternate_helpers", __FILE__) + @controller_class.helpers_path = File.expand_path("../fixtures/alternate_helpers", __dir__) + ActionPackTestSuiteUtils.require_helpers(@controller_class.helpers_path) # Reload helpers @controller_class._helpers = Module.new diff --git a/actionpack/test/controller/http_basic_authentication_test.rb b/actionpack/test/controller/http_basic_authentication_test.rb index d9ae78768987e..6494b04b89296 100644 --- a/actionpack/test/controller/http_basic_authentication_test.rb +++ b/actionpack/test/controller/http_basic_authentication_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class HttpBasicAuthenticationTest < ActionController::TestCase @@ -29,8 +31,14 @@ def search render plain: "All inline" end - private + def no_password + username, password = authenticate_with_http_basic do |username, password| + [username, password] + end + render plain: "Hello #{username} (password: #{password.inspect})" + end + private def authenticate authenticate_or_request_with_http_basic do |username, password| username == "lifo" && password == "world" @@ -136,6 +144,21 @@ def test_encode_credentials_has_no_newline assert_equal 'Basic realm="SuperSecret"', @response.headers["WWW-Authenticate"] end + test "authentication request with a missing password" do + @request.env["HTTP_AUTHORIZATION"] = "Basic #{::Base64.encode64("David")}" + get :search + + assert_response :unauthorized + end + + test "authentication request with no required password" do + @request.env["HTTP_AUTHORIZATION"] = "Basic #{::Base64.encode64("George")}" + get :no_password + + assert_response :success + assert_equal "Hello George (password: nil)", @response.body + end + test "authentication request with valid credential" do @request.env["HTTP_AUTHORIZATION"] = encode_credentials("pretty", "please") get :display @@ -170,7 +193,6 @@ def test_encode_credentials_has_no_newline end private - def encode_credentials(username, password) "Basic #{::Base64.encode64("#{username}:#{password}")}" end diff --git a/actionpack/test/controller/http_digest_authentication_test.rb b/actionpack/test/controller/http_digest_authentication_test.rb index 0b59e123d7022..c68bf62b7e059 100644 --- a/actionpack/test/controller/http_digest_authentication_test.rb +++ b/actionpack/test/controller/http_digest_authentication_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_support/key_generator" @@ -7,7 +9,7 @@ class DummyDigestController < ActionController::Base before_action :authenticate_with_request, only: :display USERS = { "lifo" => "world", "pretty" => "please", - "dhh" => ::Digest::MD5::hexdigest(["dhh", "SuperSecret", "secret"].join(":")) } + "dhh" => OpenSSL::Digest::MD5.hexdigest(["dhh", "SuperSecret", "secret"].join(":")) } def index render plain: "Hello Secret" @@ -18,7 +20,6 @@ def display end private - def authenticate authenticate_or_request_with_http_digest("SuperSecret") do |username| # Returns the password @@ -42,7 +43,10 @@ def authenticate_with_request setup do # Used as secret in generating nonce to prevent tampering of timestamp @secret = "4fb45da9e4ab4ddeb7580d6a35503d99" - @request.env["action_dispatch.key_generator"] = ActiveSupport::LegacyKeyGenerator.new(@secret) + @request.env["action_dispatch.key_generator"] = ActiveSupport::CachingKeyGenerator.new( + ActiveSupport::KeyGenerator.new(@secret) + ) + @request.env["action_dispatch.http_auth_salt"] = "http authentication" end teardown do @@ -179,9 +183,10 @@ def authenticate_with_request end test "authentication request with password stored as ha1 digest hash" do - @request.env["HTTP_AUTHORIZATION"] = encode_credentials(username: "dhh", - password: ::Digest::MD5::hexdigest(["dhh", "SuperSecret", "secret"].join(":")), - password_is_ha1: true) + @request.env["HTTP_AUTHORIZATION"] = encode_credentials( + username: "dhh", + password: OpenSSL::Digest::MD5.hexdigest(["dhh", "SuperSecret", "secret"].join(":")), + password_is_ha1: true) get :display assert_response :success @@ -199,7 +204,7 @@ def authenticate_with_request test "validate_digest_response should fail with nil returning password_procedure" do @request.env["HTTP_AUTHORIZATION"] = encode_credentials(username: nil, password: nil) - assert !ActionController::HttpAuthentication::Digest.validate_digest_response(@request, "SuperSecret") { nil } + assert_not ActionController::HttpAuthentication::Digest.validate_digest_response(@request, "SuperSecret") { nil } end test "authentication request with request-uri ending in '/'" do @@ -248,7 +253,6 @@ def authenticate_with_request end private - def encode_credentials(options) options.reverse_merge!(nc: "00000001", cnonce: "0a4f113b", password_is_ha1: false) password = options.delete(:password) @@ -269,7 +273,7 @@ def encode_credentials(options) credentials.merge!(options) path_info = @request.env["PATH_INFO"].to_s uri = options[:uri] || path_info - credentials.merge!(uri: uri) + credentials[:uri] = uri @request.env["ORIGINAL_FULLPATH"] = path_info ActionController::HttpAuthentication::Digest.encode_credentials(method, credentials, password, options[:password_is_ha1]) end diff --git a/actionpack/test/controller/http_token_authentication_test.rb b/actionpack/test/controller/http_token_authentication_test.rb index 09d2793c9a1c8..cf768dde6f8b9 100644 --- a/actionpack/test/controller/http_token_authentication_test.rb +++ b/actionpack/test/controller/http_token_authentication_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class HttpTokenAuthenticationTest < ActionController::TestCase @@ -19,7 +21,6 @@ def show end private - def authenticate authenticate_or_request_with_http_token do |token, _| token == "lifo" @@ -87,6 +88,16 @@ def authenticate_long_credentials assert_equal "HTTP Token: Access denied.\n", @response.body, "Authentication header was not properly parsed" end + test "authentication request with evil header" do + @request.env["HTTP_AUTHORIZATION"] = "Token ." + " " * (1024 * 80 - 8) + "." + Timeout.timeout(1) do + get :index + end + + assert_response :unauthorized + assert_equal "HTTP Token: Access denied.\n", @response.body, "Authentication header was not properly parsed" + end + test "successful authentication request with Bearer instead of Token" do @request.env["HTTP_AUTHORIZATION"] = "Bearer lifo" get :index @@ -148,13 +159,13 @@ def authenticate_long_credentials end test "token_and_options returns empty string with empty token" do - token = "" + token = +"" actual = ActionController::HttpAuthentication::Token.token_and_options(sample_request(token)).first expected = token assert_equal(expected, actual) end - test "token_and_options returns correct token with nounce option" do + test "token_and_options returns correct token with nonce option" do token = "rcHu+HzSFw89Ypyhn/896A=" nonce_hash = { nonce: "123abc" } actual = ActionController::HttpAuthentication::Token.token_and_options(sample_request(token, nonce_hash)) @@ -169,6 +180,16 @@ def authenticate_long_credentials assert_nil actual end + test "token_and_options ignores empty elements in header value" do + token = "foo,,bar, , , baz=qux" + expected_token = "foo" + expected_options = { "bar" => nil, "baz" => "qux" } + + actual = ActionController::HttpAuthentication::Token.token_and_options(sample_request(token, {})) + assert_equal expected_token, actual.first + assert_equal expected_options, actual.last + end + test "raw_params returns a tuple of two key value pair strings" do auth = sample_request("rcHu+HzSFw89Ypyhn/896A=").authorization.to_s actual = ActionController::HttpAuthentication::Token.raw_params(auth) @@ -176,6 +197,20 @@ def authenticate_long_credentials assert_equal(expected, actual) end + test "raw_params returns a tuple of key value pair strings when auth does not contain a token key" do + auth = sample_request_without_token_key("rcHu+HzSFw89Ypyhn/896A=").authorization.to_s + actual = ActionController::HttpAuthentication::Token.raw_params(auth) + expected = ["token=rcHu+HzSFw89Ypyhn/896A="] + assert_equal(expected, actual) + end + + test "raw_params returns a tuple of key strings when auth does not contain a token key and value" do + auth = sample_request_without_token_key(nil).authorization.to_s + actual = ActionController::HttpAuthentication::Token.raw_params(auth) + expected = ["token="] + assert_equal(expected, actual) + end + test "token_and_options returns right token when token key is not specified in header" do token = "rcHu+HzSFw89Ypyhn/896A=" @@ -188,7 +223,6 @@ def authenticate_long_credentials end private - def sample_request(token, options = { nonce: "def" }) authorization = options.inject([%{Token token="#{token}"}]) do |arr, (k, v)| arr << "#{k}=\"#{v}\"" @@ -205,7 +239,7 @@ def sample_request_without_token_key(token) end def mock_authorization_request(authorization) - OpenStruct.new(authorization: authorization) + Struct.new(:authorization).new(authorization) end def encode_credentials(token, options = {}) diff --git a/actionpack/test/controller/integration_test.rb b/actionpack/test/controller/integration_test.rb index 57f58fd835568..9457b38eb8d7b 100644 --- a/actionpack/test/controller/integration_test.rb +++ b/actionpack/test/controller/integration_test.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + require "abstract_unit" require "controller/fake_controllers" require "rails/engine" +require "launchy" class SessionTest < ActiveSupport::TestCase StubApp = lambda { |env| @@ -12,11 +15,11 @@ def setup end def test_https_bang_works_and_sets_truth_by_default - assert !@session.https? + assert_not_predicate @session, :https? @session.https! - assert @session.https? + assert_predicate @session, :https? @session.https! false - assert !@session.https? + assert_not_predicate @session, :https? end def test_host! @@ -34,91 +37,91 @@ def test_follow_redirect_raises_when_no_redirect def test_get path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:get, path, params: params, headers: headers] do + assert_called_with @session, :process, [:get, path], params: params, headers: headers do @session.get(path, params: params, headers: headers) end end def test_get_with_env_and_headers path = "/index"; params = "blah"; headers = { location: "blah" }; env = { "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest" } - assert_called_with @session, :process, [:get, path, params: params, headers: headers, env: env] do + assert_called_with @session, :process, [:get, path], params: params, headers: headers, env: env do @session.get(path, params: params, headers: headers, env: env) end end def test_post path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:post, path, params: params, headers: headers] do + assert_called_with @session, :process, [:post, path], params: params, headers: headers do @session.post(path, params: params, headers: headers) end end def test_patch path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:patch, path, params: params, headers: headers] do + assert_called_with @session, :process, [:patch, path], params: params, headers: headers do @session.patch(path, params: params, headers: headers) end end def test_put path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:put, path, params: params, headers: headers] do + assert_called_with @session, :process, [:put, path], params: params, headers: headers do @session.put(path, params: params, headers: headers) end end def test_delete path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:delete, path, params: params, headers: headers] do + assert_called_with @session, :process, [:delete, path], params: params, headers: headers do @session.delete(path, params: params, headers: headers) end end def test_head path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:head, path, params: params, headers: headers] do + assert_called_with @session, :process, [:head, path], params: params, headers: headers do @session.head(path, params: params, headers: headers) end end def test_xml_http_request_get path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:get, path, params: params, headers: headers, xhr: true] do + assert_called_with @session, :process, [:get, path], params: params, headers: headers, xhr: true do @session.get(path, params: params, headers: headers, xhr: true) end end def test_xml_http_request_post path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:post, path, params: params, headers: headers, xhr: true] do + assert_called_with @session, :process, [:post, path], params: params, headers: headers, xhr: true do @session.post(path, params: params, headers: headers, xhr: true) end end def test_xml_http_request_patch path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:patch, path, params: params, headers: headers, xhr: true] do + assert_called_with @session, :process, [:patch, path], params: params, headers: headers, xhr: true do @session.patch(path, params: params, headers: headers, xhr: true) end end def test_xml_http_request_put path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:put, path, params: params, headers: headers, xhr: true] do + assert_called_with @session, :process, [:put, path], params: params, headers: headers, xhr: true do @session.put(path, params: params, headers: headers, xhr: true) end end def test_xml_http_request_delete path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:delete, path, params: params, headers: headers, xhr: true] do + assert_called_with @session, :process, [:delete, path], params: params, headers: headers, xhr: true do @session.delete(path, params: params, headers: headers, xhr: true) end end def test_xml_http_request_head path = "/index"; params = "blah"; headers = { location: "blah" } - assert_called_with @session, :process, [:head, path, params: params, headers: headers, xhr: true] do + assert_called_with @session, :process, [:head, path], params: params, headers: headers, xhr: true do @session.head(path, params: params, headers: headers, xhr: true) end end @@ -133,7 +136,15 @@ def test_opens_new_session session1 = @test.open_session { |sess| } session2 = @test.open_session # implicit session - assert !session1.equal?(session2) + assert_not session1.equal?(session2) + end + + def test_child_session_assertions_bubble_up_to_root + assertions_before = @test.assertions + @test.open_session.assert(true) + assertions_after = @test.assertions + + assert_equal 1, assertions_after - assertions_before end # RSpec mixes Matchers (which has a #method_missing) into @@ -141,8 +152,8 @@ def test_opens_new_session # try to delegate these methods to the session object. def test_does_not_prevent_method_missing_passing_up_to_ancestors mixin = Module.new do - def method_missing(name, *args) - name.to_s == "foo" ? "pass" : super + def method_missing(name, ...) + name == :foo ? "pass" : super end end @test.class.superclass.include(mixin) @@ -150,7 +161,27 @@ def method_missing(name, *args) assert_equal "pass", @test.foo ensure # leave other tests as unaffected as possible - mixin.__send__(:remove_method, :method_missing) + mixin.remove_method :method_missing + end + end +end + +class RackLintIntegrationTest < ActionDispatch::IntegrationTest + test "integration test follows rack SPEC" do + with_routing do |set| + set.draw do + get "/", to: ->(_) { [200, {}, [""]] } + end + + get "/" + + assert_equal 200, status + end + end + + def app + @app ||= self.class.build_app do |middleware| + middleware.unshift Rack::Lint end end end @@ -160,9 +191,10 @@ def method_missing(name, *args) class IntegrationTestUsesCorrectClass < ActionDispatch::IntegrationTest def test_integration_methods_called reset! + headers = { "Origin" => "*" } - %w( get post head patch put delete ).each do |verb| - assert_nothing_raised { __send__(verb, "/") } + %w( get post head patch put delete options ).each do |verb| + assert_nothing_raised { __send__(verb, "/", headers: headers) } end end end @@ -180,6 +212,15 @@ def get end end + def get_with_vary_set_x_requested_with + respond_to do |format| + format.json do + response.headers["Vary"] = "X-Requested-With" + render json: "JSON OK", status: 200 + end + end + end + def get_with_params render plain: "foo: #{params[:foo]}", status: 200 end @@ -211,6 +252,14 @@ def redirect redirect_to action_url("get") end + def redirect_307 + redirect_to action_url("post"), status: 307 + end + + def redirect_308 + redirect_to action_url("post"), status: 308 + end + def remove_header response.headers.delete params[:header] head :ok, "c" => "3" @@ -267,12 +316,14 @@ def test_post end end + include CookieAssertions + test "response cookies are added to the cookie jar for the next request" do with_test_route_set do cookies["cookie_1"] = "sugar" cookies["cookie_2"] = "oatmeal" get "/cookie_monster" - assert_equal "cookie_1=; path=/\ncookie_3=chocolate; path=/", headers["Set-Cookie"] + assert_set_cookie_header "cookie_1=; path=/\ncookie_3=chocolate; path=/", headers["Set-Cookie"] assert_equal({ "cookie_1" => "", "cookie_2" => "oatmeal", "cookie_3" => "chocolate" }, cookies.to_hash) end end @@ -321,11 +372,12 @@ def test_redirect assert_response 302 assert_response :redirect assert_response :found - assert_equal "You are being redirected.", response.body + assert_equal "", response.body assert_kind_of Nokogiri::HTML::Document, html_document assert_equal 1, request_count follow_redirect! + assert_equal "http://www.example.com/redirect", request.referer assert_response :success assert_equal "/get", path @@ -335,6 +387,46 @@ def test_redirect end end + def test_307_redirect_uses_the_same_http_verb + with_test_route_set do + post "/redirect_307" + assert_equal 307, status + follow_redirect! + assert_equal "POST", request.method + end + end + + def test_308_redirect_uses_the_same_http_verb + with_test_route_set do + post "/redirect_308" + assert_equal 308, status + follow_redirect! + assert_equal "POST", request.method + end + end + + def test_redirect_reset_html_document + with_test_route_set do + get "/redirect" + previous_html_document = html_document + + follow_redirect! + + assert_response :ok + assert_not_same previous_html_document, html_document + end + end + + def test_redirect_with_arguments + with_test_route_set do + get "/redirect" + follow_redirect! params: { foo: :bar } + + assert_response :ok + assert_equal "bar", request.parameters["foo"] + end + end + def test_xml_http_request_get with_test_route_set do get "/get", xhr: true @@ -361,7 +453,7 @@ def test_request_with_bad_format a = open_session b = open_session - refute_same(a.integration_session, b.integration_session) + assert_not_same(a.integration_session, b.integration_session) end def test_get_with_query_string @@ -398,11 +490,12 @@ def test_post_then_get_with_parameters_do_not_leak_across_requests get "/get_with_params", params: { foo: "bar" } - assert request.env["rack.input"].string.empty? + input = request.env["rack.input"] + assert(input.nil? || input.read == "") assert_equal "foo=bar", request.env["QUERY_STRING"] assert_equal "foo=bar", request.query_string assert_equal "bar", request.parameters["foo"] - assert request.parameters["leaks"].nil? + assert_nil request.parameters["leaks"] end end @@ -498,14 +591,47 @@ def test_accept_not_overridden_when_xhr_true with_test_route_set do get "/get", headers: { "Accept" => "application/json" }, xhr: true assert_equal "application/json", request.accept - assert_equal "application/json", response.content_type + assert_equal "application/json", response.media_type get "/get", headers: { "HTTP_ACCEPT" => "application/json" }, xhr: true assert_equal "application/json", request.accept - assert_equal "application/json", response.content_type + assert_equal "application/json", response.media_type end end + def test_setting_vary_header_when_request_is_xhr_with_accept_header + with_test_route_set do + get "/get", headers: { "Accept" => "application/json" }, xhr: true + assert_equal "Accept", response.headers["Vary"] + end + end + + def test_not_setting_vary_header_when_format_is_provided + with_test_route_set do + get "/get", params: { format: "json" } + assert_nil response.headers["Vary"] + end + end + + def test_not_setting_vary_header_when_it_has_already_been_set + with_test_route_set do + get "/get_with_vary_set_x_requested_with", headers: { "Accept" => "application/json" }, xhr: true + assert_equal "X-Requested-With", response.headers["Vary"] + end + end + + def test_not_setting_vary_header_when_ignore_accept_header_is_set + original_ignore_accept_header = ActionDispatch::Request.ignore_accept_header + ActionDispatch::Request.ignore_accept_header = true + + with_test_route_set do + get "/get", headers: { "Accept" => "application/json" }, xhr: true + assert_nil response.headers["Vary"] + end + ensure + ActionDispatch::Request.ignore_accept_header = original_ignore_accept_header + end + private def with_default_headers(headers) original = ActionDispatch::Response.default_headers @@ -525,7 +651,7 @@ def with_test_route_set set.draw do get "moved" => redirect("/method") - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do match ":action", to: controller, via: [:get, :post], as: :action get "get/:action", to: controller, as: :get_action end @@ -543,7 +669,7 @@ class MetalIntegrationTest < ActionDispatch::IntegrationTest class Poller def self.call(env) - if env["PATH_INFO"] =~ /^\/success/ + if /^\/success/.match?(env["PATH_INFO"]) [200, { "Content-Type" => "text/plain", "Content-Length" => "12" }, ["Hello World!"]] else [404, { "Content-Type" => "text/plain", "Content-Length" => "0" }, []] @@ -617,6 +743,12 @@ def test_keeps_uncommon_ports_in_host end class ApplicationIntegrationTest < ActionDispatch::IntegrationTest + class MetalController < ActionController::Metal + def new + self.status = 200 + end + end + class TestController < ActionController::Base def index render plain: "index" @@ -647,11 +779,14 @@ def self.call(*) routes.draw do get "", to: "application_integration_test/test#index", as: :empty_string + get "metal", to: "application_integration_test/metal#new", as: :new_metal + get "foo", to: "application_integration_test/test#index", as: :foo get "bar", to: "application_integration_test/test#index", as: :bar mount MountedApp => "/mounted", :as => "mounted" - get "fooz" => proc { |env| [ 200, { "X-Cascade" => "pass" }, [ "omg" ] ] }, :anchor => false + get "fooz" => proc { |env| [ 200, { ActionDispatch::Constants::X_CASCADE => "pass" }, [ "omg" ] ] }, + :anchor => false get "fooz", to: "application_integration_test/test#index" end @@ -686,6 +821,11 @@ def app assert_equal "/bar", bar_path end + test "route helpers after metal controller access" do + get "/metal" + assert_equal "/foo?q=solution", foo_path(q: "solution") + end + test "missing route helper before controller access" do assert_raise(NameError) { missing_path } end @@ -736,6 +876,30 @@ def app end end +class ControllerWithHeadersMethodIntegrationTest < ActionDispatch::IntegrationTest + class TestController < ActionController::Base + def index + render plain: "ok" + end + + def headers + {}.freeze + end + end + + test "doesn't call controller's headers method" do + with_routing do |routes| + routes.draw do + get "/ok" => "controller_with_headers_method_integration_test/test#index" + end + + get "/ok" + + assert_response 200 + end + end +end + class UrlOptionsIntegrationTest < ActionDispatch::IntegrationTest class FooController < ActionController::Base def index @@ -784,17 +948,17 @@ def app end end - test "session uses default url options from routes" do + test "session uses default URL options from routes" do assert_equal "http://foo.com/foo", foos_url end - test "current host overrides default url options from routes" do + test "current host overrides default URL options from routes" do get "/foo" assert_response :success assert_equal "http://www.example.com/foo", foos_url end - test "controller can override default url options from request" do + test "controller can override default URL options from request" do get "/bar" assert_response :success assert_equal "http://bar.com/foo", foos_url @@ -902,7 +1066,7 @@ def ok def test_request with_routing do |routes| routes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action" => FooController end end @@ -934,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 @@ -941,12 +1111,16 @@ def foos_json def foos_wibble render plain: "ok" end + + def foos_json_api + render plain: "ok" + end end def test_standard_json_encoding_works with_routing do |routes| routes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do post ":action" => FooController end end @@ -959,10 +1133,21 @@ 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 - assert_equal "application/json", request.content_type + assert_equal "application/json", request.media_type assert_equal "application/json", request.accepts.first.to_s assert_equal :json, request.format.ref assert_equal({ "foo" => "fighters" }, request.request_parameters) @@ -973,7 +1158,7 @@ def test_encoding_as_json def test_doesnt_mangle_request_path with_routing do |routes| routes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do post ":action" => FooController end end @@ -1001,7 +1186,7 @@ def test_registering_custom_encoder post_to_foos as: :wibble do assert_response :success assert_equal "/foos_wibble", request.path - assert_equal "text/wibble", request.content_type + assert_equal "text/wibble", request.media_type assert_equal "text/wibble", request.accepts.first.to_s assert_equal :wibble, request.format.ref assert_equal Hash.new, request.request_parameters # Unregistered MIME Type can't be parsed. @@ -1011,10 +1196,30 @@ def test_registering_custom_encoder Mime::Type.unregister :wibble end + def test_registering_custom_encoder_including_parameters + accept_header = 'application/vnd.api+json; profile="https://jsonapi.org/profiles/ethanresnick/cursor-pagination/"; ext="https://jsonapi.org/ext/atomic"' + Mime::Type.register accept_header, :json_api + + ActionDispatch::IntegrationTest.register_encoder(:json_api, + param_encoder: -> params { params }) + + post_to_foos as: :json_api do + assert_response :success + assert_equal "/foos_json_api", request.path + assert_equal "application/vnd.api+json", request.media_type + assert_equal accept_header, request.accepts.first.to_s + assert_equal :json_api, request.format.ref + assert_equal Hash.new, request.request_parameters # Unregistered MIME Type can't be parsed. + assert_equal "ok", response.parsed_body + end + ensure + Mime::Type.unregister :json_api + end + def test_parsed_body_without_as_option with_routing do |routes| routes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action" => FooController end end @@ -1028,7 +1233,7 @@ def test_parsed_body_without_as_option def test_get_parameters_with_as_option with_routing do |routes| routes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action" => FooController end end @@ -1042,7 +1247,7 @@ def test_get_parameters_with_as_option def test_get_request_with_json_uses_method_override_and_sends_a_post_request with_routing do |routes| routes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action" => FooController end end @@ -1055,11 +1260,25 @@ def test_get_request_with_json_uses_method_override_and_sends_a_post_request end end + def test_get_request_with_json_excludes_null_query_string + with_routing do |routes| + routes.draw do + ActionDispatch.deprecator.silence do + get ":action" => FooController + end + end + + get "/foos_json", as: :json + + assert_equal "http://www.example.com/foos_json", request.url + end + end + private def post_to_foos(as:) with_routing do |routes| routes.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do post ":action" => FooController end end @@ -1090,8 +1309,8 @@ def app self.class end - def self.fixture_path - File.dirname(__FILE__) + "/../fixtures/multipart" + def self.file_fixture_path + File.expand_path("../fixtures/multipart", __dir__) end routes.draw do @@ -1101,8 +1320,94 @@ def self.fixture_path def test_fixture_file_upload post "/test_file_upload", params: { - file: fixture_file_upload("/ruby_on_rails.jpg", "image/jpg") + file: fixture_file_upload("/ruby_on_rails.jpg", "image/jpeg") } assert_equal "45142", @response.body end end + +# rubocop:disable Lint/Debugger +class PageDumpIntegrationTest < ActionDispatch::IntegrationTest + class FooController < ActionController::Base + def index + render plain: "Hello world" + end + + def redirect + redirect_to action: :index + end + end + + def with_root(&block) + Rails.stub(:root, Pathname.getwd.join("test"), &block) + end + + def setup + with_root do + remove_dumps + end + end + + def teardown + with_root do + remove_dumps + end + end + + def self.routes + @routes ||= ActionDispatch::Routing::RouteSet.new + end + + def self.call(env) + routes.call(env) + end + + def app + self.class + end + + def dump_path + Pathname.new(Dir["#{Rails.root}/tmp/html_dump/#{method_name}*"].sole) + end + + def remove_dumps + Dir["#{Rails.root}/tmp/html_dump/#{method_name}*"].each(&File.method(:delete)) + end + + routes.draw do + get "/" => "page_dump_integration_test/foo#index" + get "/redirect" => "page_dump_integration_test/foo#redirect" + end + + test "save_and_open_page saves a copy of the page and call to Launchy" do + launchy_called = false + get "/" + with_root do + Launchy.stub(:open, ->(path) { launchy_called = (path == dump_path) }) do + save_and_open_page + end + assert launchy_called + assert_equal File.read(dump_path), response.body + end + end + + test "prints a warning to install launchy if it can't be loaded" do + get "/" + with_root do + Launchy.stub(:open, ->(path) { raise LoadError.new }) do + self.stub(:warn, ->(warning) { warning.include?("Please install the launchy gem to open the file automatically.") }) do + save_and_open_page + end + end + assert_equal File.read(dump_path), response.body + end + end + + test "raises when called after a redirect" do + with_root do + get "/redirect" + assert_raise(InvalidResponse) { save_and_open_page } + end + end +end +# rubocop:enable Lint/Debugger diff --git a/actionpack/test/controller/live_stream_test.rb b/actionpack/test/controller/live_stream_test.rb index e76628b9369b9..d0b3150f90ded 100644 --- a/actionpack/test/controller/live_stream_test.rb +++ b/actionpack/test/controller/live_stream_test.rb @@ -1,5 +1,10 @@ +# frozen_string_literal: true + require "abstract_unit" +require "timeout" require "concurrent/atomic/count_down_latch" +require "zeitwerk" + Thread.abort_on_exception = true module ActionController @@ -27,7 +32,7 @@ def sse_with_event def sse_with_retry sse = SSE.new(response.stream, retry: 1000) sse.write("{\"name\":\"John\"}") - sse.write({ name: "Ryan" }, retry: 1500) + sse.write({ name: "Ryan" }, { retry: 1500 }) ensure sse.close end @@ -35,7 +40,7 @@ def sse_with_retry def sse_with_id sse = SSE.new(response.stream) sse.write("{\"name\":\"John\"}", id: 1) - sse.write({ name: "Ryan" }, id: 2) + sse.write({ name: "Ryan" }, { id: 2 }) ensure sse.close end @@ -58,16 +63,16 @@ def test_basic_sse get :basic_sse wait_for_response_stream_close - assert_match(/data: {\"name\":\"John\"}/, response.body) - assert_match(/data: {\"name\":\"Ryan\"}/, response.body) + assert_match(/data: {"name":"John"}/, response.body) + assert_match(/data: {"name":"Ryan"}/, response.body) end def test_sse_with_event_name get :sse_with_event wait_for_response_stream_close - assert_match(/data: {\"name\":\"John\"}/, response.body) - assert_match(/data: {\"name\":\"Ryan\"}/, response.body) + assert_match(/data: {"name":"John"}/, response.body) + assert_match(/data: {"name":"Ryan"}/, response.body) assert_match(/event: send-name/, response.body) end @@ -76,10 +81,10 @@ def test_sse_with_retry wait_for_response_stream_close first_response, second_response = response.body.split("\n\n") - assert_match(/data: {\"name\":\"John\"}/, first_response) + assert_match(/data: {"name":"John"}/, first_response) assert_match(/retry: 1000/, first_response) - assert_match(/data: {\"name\":\"Ryan\"}/, second_response) + assert_match(/data: {"name":"Ryan"}/, second_response) assert_match(/retry: 1500/, second_response) end @@ -88,10 +93,10 @@ def test_sse_with_id wait_for_response_stream_close first_response, second_response = response.body.split("\n\n") - assert_match(/data: {\"name\":\"John\"}/, first_response) + assert_match(/data: {"name":"John"}/, first_response) assert_match(/id: 1/, first_response) - assert_match(/data: {\"name\":\"Ryan\"}/, second_response) + assert_match(/data: {"name":"Ryan"}/, second_response) assert_match(/id: 2/, second_response) end @@ -109,6 +114,10 @@ class LiveStreamTest < ActionController::TestCase class Exception < StandardError end + class CurrentState < ActiveSupport::CurrentAttributes + attribute :id + end + class TestController < ActionController::Base include ActionController::Live @@ -128,6 +137,12 @@ def render_text render plain: "zomg" end + def write_lines + response.stream.writeln "hello\n" + response.stream.writeln "world" + response.stream.close + end + def default_header response.stream.write "hi" response.stream.close @@ -141,6 +156,39 @@ def basic_stream response.stream.close end + def basic_send_stream + send_stream(filename: "my.csv") do |stream| + stream.write "name,age\ndavid,41" + end + end + + def send_stream_with_options + send_stream(filename: "export", disposition: "inline", type: :json) do |stream| + stream.write %[{ name: "David", age: 41 }] + end + end + + def send_stream_with_inferred_content_type + send_stream(filename: "sample.csv") do |stream| + stream.writeln "fruit,quantity" + stream.writeln "apple,5" + end + end + + def send_stream_with_implicit_content_type + send_stream(filename: "sample.csv", type: :csv) do |stream| + stream.writeln "fruit,quantity" + stream.writeln "apple,5" + end + end + + def send_stream_with_explicit_content_type + send_stream(filename: "sample.csv", type: "text/csv") do |stream| + stream.writeln "fruit,quantity" + stream.writeln "apple,5" + end + end + def blocking_stream response.headers["Content-Type"] = "text/event-stream" %w{ hello world }.each do |word| @@ -151,23 +199,37 @@ def blocking_stream end def write_sleep_autoload - path = File.join(File.dirname(__FILE__), "../fixtures") - ActiveSupport::Dependencies.autoload_paths << path + path = File.expand_path("../fixtures", __dir__) + Zeitwerk.with_loader do |loader| + loader.push_dir(path) + loader.ignore(File.join(path, "公共")) + loader.setup + + response.headers["Content-Type"] = "text/event-stream" + response.stream.write "before load" + sleep 0.01 + silence_warnings do + ::LoadMe + end + response.stream.close + latch.count_down + ensure + loader.unload + end + end + + def thread_locals + tc.assert_equal "aaron", Thread.current[:setting] response.headers["Content-Type"] = "text/event-stream" - response.stream.write "before load" - sleep 0.01 - silence_warning do - ::LoadMe + %w{ hello world }.each do |word| + response.stream.write word end response.stream.close - latch.count_down - - ActiveSupport::Dependencies.autoload_paths.reject! { |p| p == path } end - def thread_locals - tc.assert_equal "aaron", Thread.current[:setting] + def no_thread_locals + tc.assert_nil Thread.current[:setting] response.headers["Content-Type"] = "text/event-stream" %w{ hello world }.each do |word| @@ -176,6 +238,19 @@ def thread_locals response.stream.close end + def isolated_state + render plain: CurrentState.id.inspect + end + + def raw_isolated_state + render plain: ActiveSupport::IsolatedExecutionState[:raw_isolated_state].inspect + end + + def connected_to_stack_not_inherited + stack = ActiveSupport::IsolatedExecutionState[:active_record_connected_to_stack] + render plain: stack.inspect + end + def with_stale render plain: "stale" if stale?(etag: "123", template: false) end @@ -243,6 +318,13 @@ def overfill_buffer_and_die end end + def overfill_default_buffer + ("a".."z").each do |char| + response.stream.write(char) + end + response.stream.close + end + def ignore_client_disconnect response.stream.ignore_disconnect = true @@ -261,9 +343,9 @@ def ignore_client_disconnect tests TestController def assert_stream_closed - assert response.stream.closed?, "stream should be closed" - assert response.committed?, "response should be committed" - assert response.sent?, "response should be sent" + assert_predicate response.stream, :closed?, "stream should be closed" + assert_predicate response, :committed?, "response should be committed" + assert_predicate response, :sent?, "response should be sent" end def capture_log_output @@ -280,8 +362,12 @@ def capture_log_output def setup super - def @controller.new_controller_thread - Thread.new { yield } + def @controller.new_controller_thread(&block) + original_new_controller_thread(&block) + end + + def @controller.clean_up_thread_locals(*args) + original_clean_up_thread_locals(*args) end end @@ -297,17 +383,79 @@ def test_write_to_stream assert_equal "text/event-stream", @response.headers["Content-Type"] end + def test_write_lines_to_stream + get :write_lines + assert_equal "hello\nworld\n", @response.body + end + + def test_send_stream + get :basic_send_stream + assert_equal "name,age\ndavid,41", @response.body + assert_equal "text/csv", @response.headers["Content-Type"] + assert_match "attachment", @response.headers["Content-Disposition"] + assert_match "my.csv", @response.headers["Content-Disposition"] + end + + def test_send_stream_instrumentation + expected_payload = { + filename: "sample.csv", + disposition: "attachment", + type: "text/csv" + } + + assert_notification("send_stream.action_controller", expected_payload) do + get :send_stream_with_explicit_content_type + end + end + + def test_send_stream_with_options + get :send_stream_with_options + assert_equal %[{ name: "David", age: 41 }], @response.body + assert_equal "application/json", @response.headers["Content-Type"] + assert_match "inline", @response.headers["Content-Disposition"] + assert_match "export", @response.headers["Content-Disposition"] + end + + def test_send_stream_with_explicit_content_type + get :send_stream_with_explicit_content_type + + assert_equal "fruit,quantity\napple,5\n", @response.body + + content_type = @response.headers.fetch("Content-Type") + assert_equal String, content_type.class + assert_equal "text/csv", content_type + end + + def test_send_stream_with_implicit_content_type + get :send_stream_with_implicit_content_type + + assert_equal "fruit,quantity\napple,5\n", @response.body + + content_type = @response.headers.fetch("Content-Type") + assert_equal String, content_type.class + assert_equal "text/csv", content_type + end + + def test_send_stream_with_inferred_content_type + get :send_stream_with_inferred_content_type + + assert_equal "fruit,quantity\napple,5\n", @response.body + + content_type = @response.headers.fetch("Content-Type") + assert_equal String, content_type.class + assert_equal "text/csv", content_type + end + def test_delayed_autoload_after_write_within_interlock_hook # Simulate InterlockHook ActiveSupport::Dependencies.interlock.start_running res = get :write_sleep_autoload - res.each {} + res.each { } ActiveSupport::Dependencies.interlock.done_running + pass end def test_async_stream - rubinius_skip "https://github.com/rubinius/rubinius/issues/2934" - @controller.latch = Concurrent::CountDownLatch.new parts = ["hello", "world"] @@ -326,7 +474,15 @@ def test_async_stream assert t.join(3), "timeout expired before the thread terminated" end + def test_infinite_test_buffer + get :overfill_default_buffer + assert_equal ("a".."z").to_a.join, response.stream.body + end + def test_abort_with_full_buffer + old_queue_size = ActionController::Live::Buffer.queue_size + ActionController::Live::Buffer.queue_size = 10 + @controller.latch = Concurrent::CountDownLatch.new @controller.error_latch = Concurrent::CountDownLatch.new @@ -347,6 +503,8 @@ def test_abort_with_full_buffer @controller.error_latch.wait assert_match "Error while streaming", output.rewind && output.read end + ensure + ActionController::Live::Buffer.queue_size = old_queue_size end def test_ignore_client_disconnect @@ -380,6 +538,71 @@ def test_thread_locals_get_copied get :thread_locals end + def test_isolated_state_get_copied + @controller.tc = self + CurrentState.id = "isolated_state" + + get :isolated_state + assert_equal "isolated_state".inspect, response.body + assert_stream_closed + end + + def test_thread_locals_get_reset + @controller.tc = self + + Thread.current[:originating_thread] = Thread.current.object_id + Thread.current[:setting] = "aaron" + + get :thread_locals + + Thread.current[:setting] = nil + + get :no_thread_locals + end + + def test_isolated_state_get_reset + @controller.tc = self + ActiveSupport::IsolatedExecutionState[:raw_isolated_state] = "buffy" + + get :raw_isolated_state + assert_equal "buffy".inspect, response.body + assert_stream_closed + + ActiveSupport::IsolatedExecutionState.clear + + get :isolated_state + assert_equal nil.inspect, response.body + assert_stream_closed + end + + def test_connected_to_stack_not_inherited + original = @controller.class.live_streaming_excluded_keys + @controller.class.live_streaming_excluded_keys = [:active_record_connected_to_stack] + + stack = [{ role: :reading, shard: :default, prevent_writes: true, klasses: [] }] + ActiveSupport::IsolatedExecutionState[:active_record_connected_to_stack] = stack + + get :connected_to_stack_not_inherited + + assert_equal "nil", response.body + assert_stream_closed + ensure + @controller.class.live_streaming_excluded_keys = original + ActiveSupport::IsolatedExecutionState.delete(:active_record_connected_to_stack) + end + + def test_live_streaming_doesnt_exclude_by_default + stack = [{ role: :reading, shard: :default, prevent_writes: true, klasses: [] }] + ActiveSupport::IsolatedExecutionState[:active_record_connected_to_stack] = stack + + get :connected_to_stack_not_inherited + + assert_match(/role.*reading/, response.body) + assert_stream_closed + ensure + ActiveSupport::IsolatedExecutionState.delete(:active_record_connected_to_stack) + end + def test_live_stream_default_header get :default_header assert response.headers["Content-Type"] @@ -420,14 +643,9 @@ def test_exception_handling_plain_text end def test_exception_callback_when_committed - current_threads = Thread.list - capture_log_output do |output| get :exception_with_callback, format: "text/event-stream" - # Wait on the execution of all threads - (Thread.list - current_threads).each(&:join) - assert_equal %(data: "500 Internal Server Error"\n\n), response.body assert_match "An exception occurred...", output.rewind && output.read assert_stream_closed @@ -461,10 +679,52 @@ def test_stale_without_etag end def test_stale_with_etag - @request.if_none_match = %(W/"#{Digest::MD5.hexdigest('123')}") + @request.if_none_match = %(W/"#{ActiveSupport::Digest.hexdigest('123')}") get :with_stale assert_equal 304, response.status.to_i end + + def test_response_buffer_do_not_respond_to_to_ary + get :basic_stream + # `response.to_a` wraps the response with RackBody. + # RackBody is the body we return to Rack. + # Therefore we want to assert directly on it. + # The Rack spec requires bodies that cannot be + # buffered to return false to `respond_to?(:to_ary)` + assert_not response.to_a.last.respond_to? :to_ary + end + end + + class LiveControllerThreadTest < ActionController::TestCase + class TestController < ActionController::Base + include ActionController::Live + + def greet + response.headers["Content-Type"] = "text/event-stream" + %w{ hello world }.each do |word| + response.stream.write word + end + response.stream.close + end + end + + tests TestController + + def test_thread_locals_do_not_get_reset_in_test_environment + Thread.current[:setting] = "aaron" + + get :greet + + 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 @@ -509,7 +769,7 @@ def app get "/test" assert_response :ok - assert_match(/data: {\"name\":\"John\"}/, response.body) - assert_match(/data: {\"name\":\"Ryan\"}/, response.body) + assert_match(/data: {"name":"John"}/, response.body) + assert_match(/data: {"name":"Ryan"}/, response.body) end end diff --git a/actionpack/test/controller/localized_templates_test.rb b/actionpack/test/controller/localized_templates_test.rb index 0f2242b693ac7..d4cd82368e57d 100644 --- a/actionpack/test/controller/localized_templates_test.rb +++ b/actionpack/test/controller/localized_templates_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class LocalizedController < ActionController::Base @@ -41,6 +43,13 @@ def test_localized_template_has_correct_header_with_no_format_in_template_name I18n.locale = :it get :hello_world assert_equal "Ciao Mondo", @response.body - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type + end + + def test_use_locale_with_lowdash + I18n.locale = :"de_AT" + + get :hello_world + assert_equal "Guten Morgen", @response.body end end diff --git a/actionpack/test/controller/log_subscriber_test.rb b/actionpack/test/controller/log_subscriber_test.rb index 45a120acb68cf..dd58e495f87aa 100644 --- a/actionpack/test/controller/log_subscriber_test.rb +++ b/actionpack/test/controller/log_subscriber_test.rb @@ -1,112 +1,124 @@ +# frozen_string_literal: true + require "abstract_unit" 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 data_sender - send_data "cool data", filename: "file.txt" - end + def filterable_redirector_with_params + redirect_to "http://secret.foo.bar?username=repinel&password=1234" + end - def file_sender - send_file File.expand_path("company.rb", FIXTURE_LOAD_PATH) - end + def filterable_redirector_bad_uri + redirect_to " s:/invalid-string0uri" + end - def with_fragment_cache - render inline: "<%= cache('foo'){ 'bar' } %>" - end + def data_sender + send_data "cool data", filename: "file.txt" + end - def with_fragment_cache_and_percent_in_key - render inline: "<%= cache('foo%bar'){ 'Contains % sign in key' } %>" - end + def file_sender + send_file File.expand_path("company.rb", FIXTURE_LOAD_PATH) + end - def with_fragment_cache_if_with_true_condition - render inline: "<%= cache_if(true, 'foo') { 'bar' } %>" - end + def with_fragment_cache + render inline: "<%= cache('foo'){ 'bar' } %>" + end - def with_fragment_cache_if_with_false_condition - render inline: "<%= cache_if(false, '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_unless_with_false_condition - render inline: "<%= cache_unless(false, 'foo') { 'bar' } %>" - end + def with_fragment_cache_if_with_true_condition + render inline: "<%= cache_if(true, 'foo') { 'bar' } %>" + end - def with_fragment_cache_unless_with_true_condition - render inline: "<%= cache_unless(true, 'foo') { 'bar' } %>" - end + def with_fragment_cache_if_with_false_condition + render inline: "<%= cache_if(false, 'foo') { 'bar' } %>" + end - def with_exception - raise Exception - end + def with_fragment_cache_unless_with_false_condition + render inline: "<%= cache_unless(false, 'foo') { 'bar' } %>" + end - def with_rescued_exception - raise SpecialException - end + def with_fragment_cache_unless_with_true_condition + render inline: "<%= cache_unless(true, 'foo') { 'bar' } %>" + end - def with_action_not_found - raise AbstractController::ActionNotFound - end + def with_throw + throw :halt + end - def append_info_to_payload(payload) - super - payload[:test_key] = "test_value" - @last_payload = payload - end + def with_exception + raise Exception, "Oopsie" + end + + def with_rescued_exception + raise SpecialException, "Oops" + end - def last_payload - @last_payload + 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 + + 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 + @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + @old_logger = ActionController::LogSubscriber.logger + ActionController::LogSubscriber.logger = @logger - @old_logger = ActionController::Base.logger - - @cache_path = File.join Dir.tmpdir, Dir::Tmpname.make_tmpname("tmp", "cache") + @cache_path = Dir.mktmpdir(%w[tmp cache]) @controller.cache_store = :file_store, @cache_path - ActionController::LogSubscriber.attach_to :action_controller + @controller.config.perform_caching = true 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) @@ -115,21 +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" + assert_equal 2, logs.size + assert_equal "Processing by #{Another::LogSubscribersController}#show as JSON", logs.first + end + + def test_start_processing_as_non_exten + get :show, format: "noext" + assert_equal 2, logs.size + 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) @@ -137,48 +164,54 @@ def test_process_action def test_process_action_without_parameters get :show - wait - assert_nil logs.detect { |l| l =~ /Parameters/ } + 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] + assert_equal "Parameters: #{{ "id" => "10" }}", logs[1] end 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] + assert_equal "Parameters: #{{ "id" => "10" }}", logs[1] + assert_equal "Parameters: #{{ "id" => "20" }}", logs[4] end 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"}', logs[1] + assert_match({ "person" => { "name" => "jose" } }.inspect[1..-2], logs[1]) end 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" } + assert_match(/\/show\?password=\[FILTERED\]/, @controller.last_payload[:path]) + end + + def test_process_action_with_throw + catch(:halt) do + get :with_throw + end + assert_match(/Completed in \d+ms/, logs[1]) + end + def test_append_info_to_payload_is_called_even_with_exception begin get :with_exception - wait rescue Exception end @@ -187,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 @@ -197,26 +229,24 @@ def test_process_action_with_filter_parameters get :show, params: { lifo: "Pratik", amount: "420", step: "1" } - wait params = logs[1] - assert_match(/"amount"=>"\[FILTERED\]"/, params) - assert_match(/"lifo"=>"\[FILTERED\]"/, params) - assert_match(/"step"=>"1"/, params) + assert_match({ "amount" => "[FILTERED]" }.inspect[1..-2], params) + assert_match({ "lifo" => "[FILTERED]" }.inspect[1..-2], params) + assert_match({ "step" => "1" }.inspect[1..-2], params) end def test_redirect_to get :redirector - wait assert_equal 3, logs.size assert_equal "Redirected to http://foo.bar/", logs[1] + assert_match(/Completed 302/, logs.last) end 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] @@ -225,15 +255,61 @@ 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] end + def test_does_not_filter_redirect_params_by_default + get :filterable_redirector_with_params + + assert_equal 3, logs.size + assert_equal "Redirected to http://secret.foo.bar?username=repinel&password=1234", logs[1] + end + + def test_filter_redirect_params_by_string + @request.env["action_dispatch.parameter_filter"] = ["password"] + get :filterable_redirector_with_params + + assert_equal 3, logs.size + assert_equal "Redirected to http://secret.foo.bar?username=repinel&password=[FILTERED]", logs[1] + end + + def test_filter_redirect_params_by_regexp + @request.env["action_dispatch.parameter_filter"] = [/pass.+/] + get :filterable_redirector_with_params + + assert_equal 3, logs.size + assert_equal "Redirected to http://secret.foo.bar?username=repinel&password=[FILTERED]", logs[1] + end + + def test_filter_redirect_bad_uri + @request.env["action_dispatch.parameter_filter"] = [/pass.+/] + + get :filterable_redirector_bad_uri + + 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]) @@ -241,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]) @@ -249,95 +324,66 @@ def test_send_file end def test_with_fragment_cache - @controller.config.perform_caching = true get :with_fragment_cache - wait assert_equal 4, logs.size assert_match(/Read fragment views\/foo/, logs[1]) assert_match(/Write fragment views\/foo/, logs[2]) - ensure - @controller.config.perform_caching = true end def test_with_fragment_cache_when_log_disabled - @controller.config.perform_caching = true 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]) - ensure - @controller.config.perform_caching = true ActionController::Base.enable_fragment_cache_logging = true end def test_with_fragment_cache_if_with_true - @controller.config.perform_caching = true get :with_fragment_cache_if_with_true_condition - wait assert_equal 4, logs.size assert_match(/Read fragment views\/foo/, logs[1]) assert_match(/Write fragment views\/foo/, logs[2]) - ensure - @controller.config.perform_caching = true end def test_with_fragment_cache_if_with_false - @controller.config.perform_caching = true get :with_fragment_cache_if_with_false_condition - wait assert_equal 2, logs.size assert_no_match(/Read fragment views\/foo/, logs[1]) assert_no_match(/Write fragment views\/foo/, logs[2]) - ensure - @controller.config.perform_caching = true end def test_with_fragment_cache_unless_with_true - @controller.config.perform_caching = true get :with_fragment_cache_unless_with_true_condition - wait assert_equal 2, logs.size assert_no_match(/Read fragment views\/foo/, logs[1]) assert_no_match(/Write fragment views\/foo/, logs[2]) - ensure - @controller.config.perform_caching = true end def test_with_fragment_cache_unless_with_false - @controller.config.perform_caching = true get :with_fragment_cache_unless_with_false_condition - wait assert_equal 4, logs.size assert_match(/Read fragment views\/foo/, logs[1]) assert_match(/Write fragment views\/foo/, logs[2]) - ensure - @controller.config.perform_caching = true end def test_with_fragment_cache_and_percent_in_key - @controller.config.perform_caching = true get :with_fragment_cache_and_percent_in_key - wait assert_equal 4, logs.size assert_match(/Read fragment views\/foo/, logs[1]) assert_match(/Write fragment views\/foo/, logs[2]) - ensure - @controller.config.perform_caching = true end def test_process_action_with_exception_includes_http_status_code begin get :with_exception - wait rescue Exception end assert_equal 2, logs.size @@ -346,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/logging_test.rb b/actionpack/test/controller/logging_test.rb new file mode 100644 index 0000000000000..693f3db37d4a6 --- /dev/null +++ b/actionpack/test/controller/logging_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class LoggingTest < ActionController::TestCase + class TestController < ActionController::Base + log_at :debug, if: -> { params[:level] == "debug" } + log_at :warn, if: -> { params[:level] == "warn" } + + def show + render plain: logger.level + end + end + + tests TestController + + setup do + @logger = @controller.logger = ActiveSupport::Logger.new(nil, level: Logger::INFO) + end + + test "logging at the default level" do + get :show + assert_equal Logger::INFO.to_s, response.body + end + + test "logging at a noisier level per request" do + assert_no_changes -> { @logger.level } do + get :show, params: { level: "debug" } + assert_equal Logger::DEBUG.to_s, response.body + end + end + + test "logging at a quieter level per request" do + assert_no_changes -> { @logger.level } do + get :show, params: { level: "warn" } + assert_equal Logger::WARN.to_s, response.body + end + end +end diff --git a/actionpack/test/controller/metal/renderers_test.rb b/actionpack/test/controller/metal/renderers_test.rb index 7dc3dd6a6df66..f6558f13545ce 100644 --- a/actionpack/test/controller/metal/renderers_test.rb +++ b/actionpack/test/controller/metal/renderers_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_support/core_ext/hash/conversions" @@ -36,13 +38,13 @@ def test_render_json get :one assert_response :success assert_equal({ a: "b" }.to_json, @response.body) - assert_equal "application/json", @response.content_type + assert_equal "application/json", @response.media_type end def test_render_xml get :two assert_response :success assert_equal(" ", @response.body) - assert_equal "text/plain", @response.content_type + assert_equal "text/plain", @response.media_type end end diff --git a/actionpack/test/controller/metal_test.rb b/actionpack/test/controller/metal_test.rb new file mode 100644 index 0000000000000..67049bc49d1d1 --- /dev/null +++ b/actionpack/test/controller/metal_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class MetalControllerInstanceTests < ActiveSupport::TestCase + class SimpleController < ActionController::Metal + def hello + self.response_body = "hello" + end + end + + def test_response_does_not_have_default_headers + original_default_headers = ActionDispatch::Response.default_headers + + ActionDispatch::Response.default_headers = { + "X-Frame-Options" => "DENY", + "X-Content-Type-Options" => "nosniff", + "X-XSS-Protection" => "0" + } + + response_headers = SimpleController.action("hello").call( + "REQUEST_METHOD" => "GET", + "rack.input" => -> { } + )[1] + + assert_not response_headers.key?("X-Frame-Options") + assert_not response_headers.key?("X-Content-Type-Options") + assert_not response_headers.key?("X-XSS-Protection") + ensure + ActionDispatch::Response.default_headers = original_default_headers + end + + def test_inspect + controller = SimpleController.new + assert_match(/\A#\z/, controller.inspect) + end +end diff --git a/actionpack/test/controller/mime/accept_format_test.rb b/actionpack/test/controller/mime/accept_format_test.rb index a22fa39051104..fb038ae15810c 100644 --- a/actionpack/test/controller/mime/accept_format_test.rb +++ b/actionpack/test/controller/mime/accept_format_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class StarStarMimeController < ActionController::Base @@ -29,7 +31,7 @@ def test_javascript_with_no_format_only_star_star end class AbstractPostController < ActionController::Base - self.view_paths = File.dirname(__FILE__) + "/../../fixtures/post_test/" + self.view_paths = File.expand_path("../../fixtures/post_test", __dir__) end # For testing layouts which are set automatically @@ -41,7 +43,6 @@ def index end private - def with_iphone request.format = "iphone" if request.env["HTTP_ACCEPT"] == "text/iphone" yield diff --git a/actionpack/test/controller/mime/respond_to_test.rb b/actionpack/test/controller/mime/respond_to_test.rb index 61bd5c80c4f84..e86a00284e593 100644 --- a/actionpack/test/controller/mime/respond_to_test.rb +++ b/actionpack/test/controller/mime/respond_to_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_support/log_subscriber/test_helper" @@ -11,6 +13,12 @@ class RespondToController < ActionController::Base end } + def my_html_fragment + respond_to do |type| + type.html_fragment { render body: "neat" } + end + end + def html_xml_or_rss respond_to do |type| type.html { render body: "HTML" } @@ -76,7 +84,7 @@ def using_defaults def missing_templates respond_to do |type| # This test requires a block that is empty - type.json {} + type.json { } type.xml end end @@ -100,10 +108,30 @@ def made_for_content_type end end + def using_conflicting_nested_js_then_html + respond_to do |outer_type| + outer_type.js do + respond_to do |inner_type| + inner_type.html { render body: "HTML" } + end + end + end + end + + def using_non_conflicting_nested_js_then_js + respond_to do |outer_type| + outer_type.js do + respond_to do |inner_type| + inner_type.js { render body: "JS" } + end + end + end + end + def custom_type_handling respond_to do |type| type.html { render body: "HTML" } - type.custom("application/crazy-xml") { render body: "Crazy XML" } + type.custom("application/fancy-xml") { render body: "Fancy XML" } type.all { render body: "Nothing" } end end @@ -129,6 +157,13 @@ def handle_any end end + def handle_any_doesnt_set_request_content_type + respond_to do |type| + type.html { render body: "HTML" } + type.any { render json: { foo: "bar" } } + end + end + def handle_any_any respond_to do |type| type.html { render body: "HTML" } @@ -136,6 +171,12 @@ def handle_any_any end end + def handle_any_with_template + respond_to do |type| + type.any { render "test/hello_world" } + end + end + def all_types_with_layout respond_to do |type| type.html @@ -292,12 +333,25 @@ def setup @request.host = "www.example.com" Mime::Type.register_alias("text/html", :iphone) Mime::Type.register("text/x-mobile", :mobile) + Mime::Type.register("application/fancy-xml", :fancy_xml) + Mime::Type.register("text/html; fragment", :html_fragment) + ActionView::LookupContext::DetailsKey.clear end def teardown super Mime::Type.unregister(:iphone) Mime::Type.unregister(:mobile) + Mime::Type.unregister(:fancy_xml) + Mime::Type.unregister(:html_fragment) + ActionView::LookupContext::DetailsKey.clear + end + + def test_html_fragment + @request.accept = "text/html; fragment" + get :my_html_fragment + assert_equal "text/html; fragment; charset=utf-8", @response.headers["Content-Type"] + assert_equal "neat", @response.body end def test_html @@ -393,12 +447,12 @@ def test_js_or_anything def test_using_defaults @request.accept = "*/*" get :using_defaults - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "Hello world!", @response.body @request.accept = "application/xml" get :using_defaults - assert_equal "application/xml", @response.content_type + assert_equal "application/xml", @response.media_type assert_equal "

Hello world!

\n", @response.body end @@ -419,15 +473,29 @@ def test_using_defaults_with_all def test_using_defaults_with_type_list @request.accept = "*/*" get :using_defaults_with_type_list - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "Hello world!", @response.body @request.accept = "application/xml" get :using_defaults_with_type_list - assert_equal "application/xml", @response.content_type + assert_equal "application/xml", @response.media_type assert_equal "

Hello world!

\n", @response.body end + def test_using_conflicting_nested_js_then_html + @request.accept = "*/*" + assert_raises(ActionController::RespondToMismatchError) do + get :using_conflicting_nested_js_then_html + end + end + + def test_using_non_conflicting_nested_js_then_js + @request.accept = "*/*" + get :using_non_conflicting_nested_js_then_js + assert_equal "text/javascript", @response.media_type + assert_equal "JS", @response.body + end + def test_with_atom_content_type @request.accept = "" @request.env["CONTENT_TYPE"] = "application/atom+xml" @@ -453,14 +521,14 @@ def test_synonyms end def test_custom_types - @request.accept = "application/crazy-xml" + @request.accept = "application/fancy-xml" get :custom_type_handling - assert_equal "application/crazy-xml", @response.content_type - assert_equal "Crazy XML", @response.body + assert_equal "application/fancy-xml", @response.media_type + assert_equal "Fancy XML", @response.body @request.accept = "text/html" get :custom_type_handling - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "HTML", @response.body end @@ -490,6 +558,12 @@ def test_handle_any assert_equal "Either JS or XML", @response.body end + def test_handle_any_doesnt_set_request_content_type + @request.accept = "text/csv" + get :handle_any_doesnt_set_request_content_type + assert_equal "application/json", @response.media_type + end + def test_handle_any_any @request.accept = "*/*" get :handle_any_any @@ -534,6 +608,13 @@ def test_browser_check_with_any_any assert_equal "HTML", @response.body end + def test_handle_any_with_template + @request.accept = "*/*" + + get :handle_any_with_template + assert_equal "Hello world!", @response.body + end + def test_html_type_with_layout @request.accept = "text/html" get :all_types_with_layout @@ -544,7 +625,7 @@ def test_json_with_callback_sets_javascript_content_type @request.accept = "application/json" get :json_with_callback assert_equal "/**/alert(JS)", @response.body - assert_equal "text/javascript", @response.content_type + assert_equal "text/javascript", @response.media_type end def test_xhr @@ -554,13 +635,13 @@ def test_xhr def test_custom_constant get :custom_constant_handling, format: "mobile" - assert_equal "text/x-mobile", @response.content_type + assert_equal "text/x-mobile", @response.media_type assert_equal "Mobile", @response.body end def test_custom_constant_handling_without_block get :custom_constant_handling_without_block, format: "mobile" - assert_equal "text/x-mobile", @response.content_type + assert_equal "text/x-mobile", @response.media_type assert_equal "Mobile", @response.body end @@ -613,7 +694,7 @@ def test_format_with_custom_response_type assert_equal '
Hello future from Firefox!
', @response.body get :iphone_with_html_response_type, format: "iphone" - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal '
Hello iPhone future from iPhone!
', @response.body end @@ -621,7 +702,7 @@ def test_format_with_custom_response_type_and_request_headers @request.accept = "text/iphone" get :iphone_with_html_response_type assert_equal '
Hello iPhone future from iPhone!
', @response.body - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type end def test_invalid_format @@ -651,18 +732,18 @@ def test_variant_not_set_regular_unknown_format def test_variant_with_implicit_template_rendering get :variant_with_implicit_template_rendering, params: { v: :mobile } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "mobile", @response.body end def test_variant_without_implicit_rendering_from_browser - assert_raises(ActionController::UnknownFormat) do + assert_raises(ActionController::MissingExactTemplate) do get :variant_without_implicit_template_rendering, params: { v: :does_not_matter } end end def test_variant_variant_not_set_and_without_implicit_rendering_from_browser - assert_raises(ActionController::UnknownFormat) do + assert_raises(ActionController::MissingExactTemplate) do get :variant_without_implicit_template_rendering end end @@ -705,137 +786,137 @@ def test_variant_variant_not_set_and_without_implicit_rendering_from_xhr def test_variant_with_format_and_custom_render get :variant_with_format_and_custom_render, params: { v: :phone } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "mobile", @response.body end def test_multiple_variants_for_format get :multiple_variants_for_format, params: { v: :tablet } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "tablet", @response.body end def test_no_variant_in_variant_setup get :variant_plus_none_for_format - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "none", @response.body end def test_variant_inline_syntax get :variant_inline_syntax - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "none", @response.body get :variant_inline_syntax, params: { v: :phone } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phone", @response.body end def test_variant_inline_syntax_with_format get :variant_inline_syntax, format: :js - assert_equal "text/javascript", @response.content_type + assert_equal "text/javascript", @response.media_type assert_equal "js", @response.body end def test_variant_inline_syntax_without_block get :variant_inline_syntax_without_block, params: { v: :phone } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phone", @response.body end def test_variant_any get :variant_any, params: { v: :phone } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phone", @response.body get :variant_any, params: { v: :tablet } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "any", @response.body get :variant_any, params: { v: :phablet } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "any", @response.body end def test_variant_any_any get :variant_any_any - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "any", @response.body get :variant_any_any, params: { v: :phone } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phone", @response.body get :variant_any_any, params: { v: :yolo } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "any", @response.body end def test_variant_inline_any get :variant_any, params: { v: :phone } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phone", @response.body get :variant_inline_any, params: { v: :tablet } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "any", @response.body get :variant_inline_any, params: { v: :phablet } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "any", @response.body end def test_variant_inline_any_any get :variant_inline_any_any, params: { v: :phone } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phone", @response.body get :variant_inline_any_any, params: { v: :yolo } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "any", @response.body end def test_variant_any_implicit_render get :variant_any_implicit_render, params: { v: :tablet } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "tablet", @response.body get :variant_any_implicit_render, params: { v: :phablet } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phablet", @response.body end def test_variant_any_with_none get :variant_any_with_none - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "none or phone", @response.body get :variant_any_with_none, params: { v: :phone } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "none or phone", @response.body end def test_format_any_variant_any get :format_any_variant_any, format: :js, params: { v: :tablet } - assert_equal "text/javascript", @response.content_type + assert_equal "text/javascript", @response.media_type assert_equal "tablet", @response.body end def test_variant_negotiation_inline_syntax get :variant_inline_syntax_without_block, params: { v: [:tablet, :phone] } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phone", @response.body end def test_variant_negotiation_block_syntax get :variant_plus_none_for_format, params: { v: [:tablet, :phone] } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phone", @response.body end def test_variant_negotiation_without_block get :variant_inline_syntax_without_block, params: { v: [:tablet, :phone] } - assert_equal "text/html", @response.content_type + assert_equal "text/html", @response.media_type assert_equal "phone", @response.body end end diff --git a/actionpack/test/controller/new_base/bare_metal_test.rb b/actionpack/test/controller/new_base/bare_metal_test.rb index 054757fab3f6c..909518be62104 100644 --- a/actionpack/test/controller/new_base/bare_metal_test.rb +++ b/actionpack/test/controller/new_base/bare_metal_test.rb @@ -1,17 +1,34 @@ +# frozen_string_literal: true + require "abstract_unit" +require "active_support/core_ext/array/access" module BareMetalTest class BareController < ActionController::Metal def index self.response_body = "Hello world" end + + def assign_response_array + self.response = [200, { "content-type" => "text/html" }, ["Hello world"]] + end + + def assign_response_object + self.response = Rack::Response.new("Hello world", 200, { "content-type" => "text/html" }) + end + + def assign_response_body_proc + self.response_body = proc do |stream| + stream.close + end + end end class BareTest < ActiveSupport::TestCase test "response body is a Rack-compatible response" do status, headers, body = BareController.action(:index).call(Rack::MockRequest.env_for("/")) assert_equal 200, status - string = "" + string = +"" body.each do |part| assert part.is_a?(String), "Each part of the body must be a String" @@ -29,9 +46,45 @@ class BareTest < ActiveSupport::TestCase controller.set_request!(ActionDispatch::Request.empty) controller.set_response!(BareController.make_response!(controller.request)) controller.index + + assert_predicate controller, :performed? assert_equal ["Hello world"], controller.response_body end + test "can assign response array as part of the controller execution" do + controller = BareController.new + controller.set_request!(ActionDispatch::Request.empty) + controller.assign_response_array + + assert_predicate controller, :performed? + assert_equal true, controller.response_body + assert_equal 200, controller.response[0] + assert_equal "text/html", controller.response[1]["content-type"] + end + + test "can assign response object as part of the controller execution" do + controller = BareController.new + controller.set_request!(ActionDispatch::Request.empty) + controller.assign_response_object + + assert_predicate controller, :performed? + assert_equal true, controller.response_body + assert_equal 200, controller.response.status + assert_equal "text/html", controller.response.headers["content-type"] + end + + test "can assign response body streamable object as part of the controller execution" do + controller = BareController.new + controller.set_request!(ActionDispatch::Request.empty) + controller.set_response!(BareController.make_response!(controller.request)) + controller.assign_response_body_proc + + assert_predicate controller, :performed? + assert controller.response_body.is_a?(Proc) + assert_equal 200, controller.response.status + assert_predicate controller.response.headers, :empty? + end + test "connect a request to controller instance without dispatch" do env = {} controller = BareController.new @@ -78,6 +131,11 @@ def processing head 102 end + def early_hints + self.content_type = "text/html" + head 103 + end + def no_content self.content_type = "text/html" head 204 @@ -118,6 +176,12 @@ class HeadTest < ActiveSupport::TestCase assert_nil headers["Content-Length"] end + test "head :early_hints (103) does not return a content-type header" do + headers = HeadController.action(:early_hints).call(Rack::MockRequest.env_for("/")).second + assert_nil headers["Content-Type"] + assert_nil headers["Content-Length"] + end + test "head :no_content (204) does not return a content-type header" do headers = HeadController.action(:no_content).call(Rack::MockRequest.env_for("/")).second assert_nil headers["Content-Type"] diff --git a/actionpack/test/controller/new_base/base_test.rb b/actionpack/test/controller/new_base/base_test.rb index b891df4c0ff5f..cdcb52891a0f1 100644 --- a/actionpack/test/controller/new_base/base_test.rb +++ b/actionpack/test/controller/new_base/base_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" # Tests the controller dispatching happy path @@ -25,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 @@ -45,7 +51,6 @@ def self.controller_path; "i_am_extremely_not_default"; end end class BaseTest < Rack::TestCase - # :api: plugin test "simple dispatching" do get "/dispatching/simple/index" @@ -54,14 +59,12 @@ class BaseTest < Rack::TestCase assert_content_type "text/plain; charset=utf-8" end - # :api: plugin test "directly modifying response body" do get "/dispatching/simple/modify_response_body" assert_body "success" end - # :api: plugin test "directly modifying response body twice" do get "/dispatching/simple/modify_response_body_twice" @@ -120,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/new_base/content_negotiation_test.rb b/actionpack/test/controller/new_base/content_negotiation_test.rb index b8707450316cf..548fa4300db61 100644 --- a/actionpack/test/controller/new_base/content_negotiation_test.rb +++ b/actionpack/test/controller/new_base/content_negotiation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ContentNegotiation @@ -18,9 +20,19 @@ class TestContentNegotiation < Rack::TestCase assert_body "Hello world */*!" end - test "Not all mimes are converted to symbol" do + test "A js or */* Accept header will return HTML" do + get "/content_negotiation/basic/hello", headers: { "HTTP_ACCEPT" => "text/javascript, */*" } + assert_body "Hello world text/html!" + end + + test "A js or */* Accept header on xhr will return JavaScript" do + get "/content_negotiation/basic/hello", headers: { "HTTP_ACCEPT" => "text/javascript, */*" }, xhr: true + assert_body "Hello world text/javascript!" + end + + test "Unregistered mimes are ignored" do get "/content_negotiation/basic/all", headers: { "HTTP_ACCEPT" => "text/plain, mime/another" } - assert_body '[:text, "mime/another"]' + assert_body "[:text]" end end end diff --git a/actionpack/test/controller/new_base/content_type_test.rb b/actionpack/test/controller/new_base/content_type_test.rb index 85089bafe2d22..6c476c19b6cab 100644 --- a/actionpack/test/controller/new_base/content_type_test.rb +++ b/actionpack/test/controller/new_base/content_type_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ContentType @@ -43,7 +45,7 @@ class ExplicitContentTypeTest < Rack::TestCase test "default response is text/plain and UTF8" do with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller", action: "index" end end diff --git a/actionpack/test/controller/new_base/middleware_test.rb b/actionpack/test/controller/new_base/middleware_test.rb index 0493291c03b20..1f9e9c8d95f1f 100644 --- a/actionpack/test/controller/new_base/middleware_test.rb +++ b/actionpack/test/controller/new_base/middleware_test.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require "abstract_unit" module MiddlewareTest class MyMiddleware - def initialize(app) + def initialize(app, kw: nil) @app = app end @@ -15,13 +17,13 @@ def call(env) end class ExclaimerMiddleware - def initialize(app) + def initialize(app, kw: nil) @app = app end def call(env) result = @app.call(env) - result[1]["Middleware-Order"] << "!" + result[1]["Middleware-Order"] += "!" result end end @@ -44,8 +46,8 @@ class MyController < ActionController::Metal use BlockMiddleware do |config| config.configurable_message = "Configured by block." end - use MyMiddleware - middleware.insert_before MyMiddleware, ExclaimerMiddleware + use MyMiddleware, kw: 1 + middleware.insert_before MyMiddleware, ExclaimerMiddleware, kw: 1 def index self.response_body = "Hello World" @@ -56,8 +58,8 @@ class InheritedController < MyController end class ActionsController < ActionController::Metal - use MyMiddleware, only: :show - middleware.insert_before MyMiddleware, ExclaimerMiddleware, except: :index + use MyMiddleware, only: :show, kw: 1 + middleware.insert_before MyMiddleware, ExclaimerMiddleware, except: :index, kw: 1 def index self.response_body = "index" diff --git a/actionpack/test/controller/new_base/render_action_test.rb b/actionpack/test/controller/new_base/render_action_test.rb index 4b59a3d6769b3..31dbc6e8979c5 100644 --- a/actionpack/test/controller/new_base/render_action_test.rb +++ b/actionpack/test/controller/new_base/render_action_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderAction @@ -87,7 +89,7 @@ def setup test "rendering with layout => true" do assert_raise(ArgumentError) do - get "/render_action/basic/hello_world_with_layout", headers: { "action_dispatch.show_exceptions" => false } + get "/render_action/basic/hello_world_with_layout", headers: { "action_dispatch.show_exceptions" => :none } end end @@ -107,7 +109,7 @@ def setup test "rendering with layout => 'greetings'" do assert_raise(ActionView::MissingTemplate) do - get "/render_action/basic/hello_world_with_custom_layout", headers: { "action_dispatch.show_exceptions" => false } + get "/render_action/basic/hello_world_with_custom_layout", headers: { "action_dispatch.show_exceptions" => :none } end end end diff --git a/actionpack/test/controller/new_base/render_body_test.rb b/actionpack/test/controller/new_base/render_body_test.rb index b1467a0deb6e0..9bedde6af5789 100644 --- a/actionpack/test/controller/new_base/render_body_test.rb +++ b/actionpack/test/controller/new_base/render_body_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderBody @@ -85,7 +87,7 @@ class RenderBodyTest < Rack::TestCase test "rendering body from an action with default options renders the body with the layout" do with_routing do |set| - set.draw { ActiveSupport::Deprecation.silence { get ":controller", action: "index" } } + set.draw { ActionDispatch.deprecator.silence { get ":controller", action: "index" } } get "/render_body/simple" assert_body "hello david" @@ -95,7 +97,7 @@ class RenderBodyTest < Rack::TestCase test "rendering body from an action with default options renders the body without the layout" do with_routing do |set| - set.draw { ActiveSupport::Deprecation.silence { get ":controller", action: "index" } } + set.draw { ActionDispatch.deprecator.silence { get ":controller", action: "index" } } get "/render_body/with_layout" diff --git a/actionpack/test/controller/new_base/render_context_test.rb b/actionpack/test/controller/new_base/render_context_test.rb deleted file mode 100644 index 25b73ac78cf06..0000000000000 --- a/actionpack/test/controller/new_base/render_context_test.rb +++ /dev/null @@ -1,52 +0,0 @@ -require "abstract_unit" - -# This is testing the decoupling of view renderer and view context -# by allowing the controller to be used as view context. This is -# similar to the way sinatra renders templates. -module RenderContext - class BasicController < ActionController::Base - self.view_paths = [ActionView::FixtureResolver.new( - "render_context/basic/hello_world.html.erb" => "<%= @value %> from <%= self.__controller_method__ %>", - "layouts/basic.html.erb" => "?<%= yield %>?" - )] - - # 1) Include ActionView::Context to bring the required dependencies - include ActionView::Context - - # 2) Call _prepare_context that will do the required initialization - before_action :_prepare_context - - def hello_world - @value = "Hello" - render action: "hello_world", layout: false - end - - def with_layout - @value = "Hello" - render action: "hello_world", layout: "basic" - end - - protected def __controller_method__ - "controller context!" - end - - # 3) Set view_context to self - private def view_context - self - end - end - - class RenderContextTest < Rack::TestCase - test "rendering using the controller as context" do - get "/render_context/basic/hello_world" - assert_body "Hello from controller context!" - assert_status 200 - end - - test "rendering using the controller as context with layout" do - get "/render_context/basic/with_layout" - assert_body "?Hello from controller context!?" - assert_status 200 - end - end -end diff --git a/actionpack/test/controller/new_base/render_file_test.rb b/actionpack/test/controller/new_base/render_file_test.rb index 6d651e0104d53..58dad7003db30 100644 --- a/actionpack/test/controller/new_base/render_file_test.rb +++ b/actionpack/test/controller/new_base/render_file_test.rb @@ -1,70 +1,31 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderFile class BasicController < ActionController::Base - self.view_paths = File.dirname(__FILE__) + self.view_paths = __dir__ def index - render file: File.join(File.dirname(__FILE__), *%w[.. .. fixtures test hello_world]) - end - - def with_instance_variables - @secret = "in the sauce" - render file: File.join(File.dirname(__FILE__), "../../fixtures/test/render_file_with_ivar") - end - - def relative_path - @secret = "in the sauce" - render file: "../../fixtures/test/render_file_with_ivar" - end - - def relative_path_with_dot - @secret = "in the sauce" - render file: "../../fixtures/test/dot.directory/render_file_with_ivar" + render file: File.expand_path("../../fixtures/test/hello_world.erb", __dir__) end def pathname - @secret = "in the sauce" - render file: Pathname.new(File.dirname(__FILE__)).join(*%w[.. .. fixtures test dot.directory render_file_with_ivar]) - end - - def with_locals - path = File.join(File.dirname(__FILE__), "../../fixtures/test/render_file_with_locals") - render file: path, locals: { secret: "in the sauce" } + render file: Pathname.new(__dir__).join(*%w[.. .. fixtures test dot.directory render_file_with_ivar.erb]) end end class TestBasic < Rack::TestCase testing RenderFile::BasicController - test "rendering simple template" do + test "rendering simple file" do get :index assert_response "Hello world!" end - test "rendering template with ivar" do - get :with_instance_variables - assert_response "The secret is in the sauce\n" - end - - test "rendering a relative path" do - get :relative_path - assert_response "The secret is in the sauce\n" - end - - test "rendering a relative path with dot" do - get :relative_path_with_dot - assert_response "The secret is in the sauce\n" - end - test "rendering a Pathname" do get :pathname - assert_response "The secret is in the sauce\n" - end - - test "rendering file with locals" do - get :with_locals - assert_response "The secret is in the sauce\n" + assert_response "The secret is <%= @secret %>\n" end end end diff --git a/actionpack/test/controller/new_base/render_html_test.rb b/actionpack/test/controller/new_base/render_html_test.rb index 8019aa1eb5e9d..3296871d1a997 100644 --- a/actionpack/test/controller/new_base/render_html_test.rb +++ b/actionpack/test/controller/new_base/render_html_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderHtml @@ -88,7 +90,7 @@ class RenderHtmlTest < Rack::TestCase test "rendering text from an action with default options renders the text with the layout" do with_routing do |set| - set.draw { ActiveSupport::Deprecation.silence { get ":controller", action: "index" } } + set.draw { ActionDispatch.deprecator.silence { get ":controller", action: "index" } } get "/render_html/simple" assert_body "hello david" @@ -98,7 +100,7 @@ class RenderHtmlTest < Rack::TestCase test "rendering text from an action with default options renders the text without the layout" do with_routing do |set| - set.draw { ActiveSupport::Deprecation.silence { get ":controller", action: "index" } } + set.draw { ActionDispatch.deprecator.silence { get ":controller", action: "index" } } get "/render_html/with_layout" @@ -163,14 +165,14 @@ class RenderHtmlTest < Rack::TestCase assert_status 200 end - test "rendering html should escape the string if it is not html safe" do + test "rendering HTML should escape the string if it is not HTML safe" do get "/render_html/with_layout/with_unsafe_html_tag" assert_body "<p>hello world</p>" assert_status 200 end - test "rendering html should not escape the string if it is html safe" do + test "rendering HTML should not escape the string if it is HTML safe" do get "/render_html/with_layout/with_safe_html_tag" assert_body "

hello world

" diff --git a/actionpack/test/controller/new_base/render_implicit_action_test.rb b/actionpack/test/controller/new_base/render_implicit_action_test.rb index 796283466af5e..8c26d34b00235 100644 --- a/actionpack/test/controller/new_base/render_implicit_action_test.rb +++ b/actionpack/test/controller/new_base/render_implicit_action_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderImplicitAction @@ -6,7 +8,7 @@ class SimpleController < ::ApplicationController "render_implicit_action/simple/hello_world.html.erb" => "Hello world!", "render_implicit_action/simple/hyphen-ated.html.erb" => "Hello hyphen-ated!", "render_implicit_action/simple/not_implemented.html.erb" => "Not Implemented" - ), ActionView::FileSystemResolver.new(File.expand_path("../../../controller", __FILE__))] + ), ActionView::FileSystemResolver.new(File.expand_path("../../controller", __dir__))] def hello_world() end end diff --git a/actionpack/test/controller/new_base/render_layout_test.rb b/actionpack/test/controller/new_base/render_layout_test.rb index 0a3809560e5b6..2e6f7efd3ae4d 100644 --- a/actionpack/test/controller/new_base/render_layout_test.rb +++ b/actionpack/test/controller/new_base/render_layout_test.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "abstract_unit" +require "test_renderable" module ControllerLayouts class ImplicitController < ::ApplicationController @@ -17,6 +20,10 @@ def override render template: "basic", layout: "override" end + def override_renderable + render TestRenderable.new, layout: "override" + end + def layout_false render layout: false end @@ -34,6 +41,10 @@ class ImplicitNameController < ::ApplicationController def index render template: "basic" end + + def renderable + render TestRenderable.new + end end class RenderLayoutTest < Rack::TestCase @@ -51,6 +62,20 @@ class RenderLayoutTest < Rack::TestCase assert_status 200 end + test "rendering a renderable object, using the implicit layout" do + get "/controller_layouts/implicit_name/renderable" + + assert_body "Implicit Hello, World! Layout" + assert_status 200 + end + + test "rendering a renderable object, using the override layout" do + get "/controller_layouts/implicit/override_renderable" + + assert_body "Override! Hello, World!" + assert_status 200 + end + test "overriding an implicit layout with render :layout option" do get "/controller_layouts/implicit/override" assert_body "Override! Hello world!" diff --git a/actionpack/test/controller/new_base/render_partial_test.rb b/actionpack/test/controller/new_base/render_partial_test.rb index 4511826978f91..ced630289bf6f 100644 --- a/actionpack/test/controller/new_base/render_partial_test.rb +++ b/actionpack/test/controller/new_base/render_partial_test.rb @@ -1,14 +1,16 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderPartial class BasicController < ActionController::Base self.view_paths = [ActionView::FixtureResolver.new( "render_partial/basic/_basic.html.erb" => "BasicPartial!", - "render_partial/basic/basic.html.erb" => "<%= @test_unchanged = 'goodbye' %><%= render :partial => 'basic' %><%= @test_unchanged %>", - "render_partial/basic/with_json.html.erb" => "<%= render :partial => 'with_json', :formats => [:json] %>", - "render_partial/basic/_with_json.json.erb" => "<%= render :partial => 'final', :formats => [:json] %>", + "render_partial/basic/basic.html.erb" => "<%= @test_unchanged = 'goodbye' %><%= render partial: 'basic' %><%= @test_unchanged %>", + "render_partial/basic/with_json.html.erb" => "<%= render partial: 'with_json', formats: [:json] %>", + "render_partial/basic/_with_json.json.erb" => "<%= render partial: 'final', formats: [:json] %>", "render_partial/basic/_final.json.erb" => "{ final: json }", - "render_partial/basic/overridden.html.erb" => "<%= @test_unchanged = 'goodbye' %><%= render :partial => 'overridden' %><%= @test_unchanged %>", + "render_partial/basic/overridden.html.erb" => "<%= @test_unchanged = 'goodbye' %><%= render partial: 'overridden' %><%= @test_unchanged %>", "render_partial/basic/_overridden.html.erb" => "ParentPartial!", "render_partial/child/_overridden.html.erb" => "OverriddenPartial!" )] diff --git a/actionpack/test/controller/new_base/render_plain_test.rb b/actionpack/test/controller/new_base/render_plain_test.rb index 44be8dd380da7..9a8b3d0b7520f 100644 --- a/actionpack/test/controller/new_base/render_plain_test.rb +++ b/actionpack/test/controller/new_base/render_plain_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderPlain @@ -80,7 +82,7 @@ class RenderPlainTest < Rack::TestCase test "rendering text from an action with default options renders the text with the layout" do with_routing do |set| - set.draw { ActiveSupport::Deprecation.silence { get ":controller", action: "index" } } + set.draw { ActionDispatch.deprecator.silence { get ":controller", action: "index" } } get "/render_plain/simple" assert_body "hello david" @@ -90,7 +92,7 @@ class RenderPlainTest < Rack::TestCase test "rendering text from an action with default options renders the text without the layout" do with_routing do |set| - set.draw { ActiveSupport::Deprecation.silence { get ":controller", action: "index" } } + set.draw { ActionDispatch.deprecator.silence { get ":controller", action: "index" } } get "/render_plain/with_layout" diff --git a/actionpack/test/controller/new_base/render_streaming_test.rb b/actionpack/test/controller/new_base/render_streaming_test.rb index 1177b8b03e004..01907f4fca7ee 100644 --- a/actionpack/test/controller/new_base/render_streaming_test.rb +++ b/actionpack/test/controller/new_base/render_streaming_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderStreaming @@ -43,44 +45,78 @@ def explicit_cache class StreamingTest < Rack::TestCase test "rendering with streaming enabled at the class level" do + env = Rack::MockRequest.env_for("/render_streaming/basic/hello_world") + status, headers, body = app.call(env) + assert_streaming!(status, headers, body) + assert_chunks ["Hello world", ", I'm here!"], body + get "/render_streaming/basic/hello_world" - assert_body "b\r\nHello world\r\nb\r\n, I'm here!\r\n0\r\n\r\n" - assert_streaming! + assert_body "Hello world, I'm here!" end test "rendering with streaming given to render" do + env = Rack::MockRequest.env_for("/render_streaming/basic/explicit") + status, headers, body = app.call(env) + assert_streaming!(status, headers, body) + assert_chunks ["Hello world", ", I'm here!"], body + get "/render_streaming/basic/explicit" - assert_body "b\r\nHello world\r\nb\r\n, I'm here!\r\n0\r\n\r\n" - assert_streaming! + assert_body "Hello world, I'm here!" + assert_cache_control! end test "rendering with streaming do not override explicit cache control given to render" do + env = Rack::MockRequest.env_for("/render_streaming/basic/explicit_cache") + status, headers, body = app.call(env) + assert_streaming!(status, headers, body) + assert_chunks ["Hello world", ", I'm here!"], body + get "/render_streaming/basic/explicit_cache" - assert_body "b\r\nHello world\r\nb\r\n, I'm here!\r\n0\r\n\r\n" - assert_streaming! "private" + assert_body "Hello world, I'm here!" + assert_cache_control! "private" end test "rendering with streaming no layout" do + env = Rack::MockRequest.env_for("/render_streaming/basic/no_layout") + status, headers, body = app.call(env) + assert_streaming!(status, headers, body) + assert_chunks ["Hello world"], body + get "/render_streaming/basic/no_layout" - assert_body "b\r\nHello world\r\n0\r\n\r\n" - assert_streaming! + assert_body "Hello world" + assert_cache_control! end test "skip rendering with streaming at render level" do + env = Rack::MockRequest.env_for("/render_streaming/basic/skip") + status, _, body = app.call(env) + assert_equal 200, status + assert_chunks ["Hello world, I'm here!"], body + get "/render_streaming/basic/skip" assert_body "Hello world, I'm here!" end test "rendering with layout exception" do + env = Rack::MockRequest.env_for("/render_streaming/basic/layout_exception") + status, headers, body = app.call(env) + assert_streaming!(status, headers, body) + assert_chunks [""], body + get "/render_streaming/basic/layout_exception" - assert_body "d\r\n\r\n0\r\n\r\n" - assert_streaming! + assert_body "" + assert_cache_control! end test "rendering with template exception" do + env = Rack::MockRequest.env_for("/render_streaming/basic/template_exception") + status, headers, body = app.call(env) + assert_streaming!(status, headers, body) + assert_chunks ["\">"], body + get "/render_streaming/basic/template_exception" - assert_body "37\r\n\">\r\n0\r\n\r\n" - assert_streaming! + assert_body "\">" + assert_cache_control! end test "rendering with template exception logs the exception" do @@ -96,19 +132,28 @@ class StreamingTest < Rack::TestCase end end - test "do not stream on HTTP/1.0" do - get "/render_streaming/basic/hello_world", headers: { "HTTP_VERSION" => "HTTP/1.0" } - assert_body "Hello world, I'm here!" - assert_status 200 - assert_equal "22", headers["Content-Length"] - assert_nil headers["Transfer-Encoding"] + def assert_streaming!(status, headers, body) + assert_equal 200, status + + # It should not have a content length + assert_nil headers["content-length"] + + # The body should not respond to `#to_ary` + assert_not_respond_to body, :to_ary end - def assert_streaming!(cache = "no-cache") - assert_status 200 - assert_nil headers["Content-Length"] - assert_equal "chunked", headers["Transfer-Encoding"] - assert_equal cache, headers["Cache-Control"] + def assert_cache_control!(value = "no-cache", headers: self.headers) + assert_equal value, headers["cache-control"] + end + + def assert_chunks(expected, body) + index = 0 + body.each do |chunk| + assert_equal expected[index], chunk + index += 1 + end + + assert_equal expected.size, index end end end diff --git a/actionpack/test/controller/new_base/render_template_test.rb b/actionpack/test/controller/new_base/render_template_test.rb index 1102305f3e567..2a46f7ad6a233 100644 --- a/actionpack/test/controller/new_base/render_template_test.rb +++ b/actionpack/test/controller/new_base/render_template_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderTemplate @@ -8,10 +10,10 @@ class WithoutLayoutController < ActionController::Base "locals.html.erb" => "The secret is <%= secret %>", "xml_template.xml.builder" => "xml.html do\n xml.p 'Hello'\nend", "with_raw.html.erb" => "Hello <%=raw 'this is raw' %>", - "with_implicit_raw.html.erb" => "Hello <%== 'this is also raw' %> in an html template", + "with_implicit_raw.html.erb" => "Hello <%== 'this is also raw' %> in an HTML template", "with_implicit_raw.text.erb" => "Hello <%== 'this is also raw' %> in a text template", - "test/with_json.html.erb" => "<%= render :template => 'test/with_json', :formats => [:json] %>", - "test/with_json.json.erb" => "<%= render :template => 'test/final', :formats => [:json] %>", + "test/with_json.html.erb" => "<%= render template: 'test/with_json', formats: [:json] %>", + "test/with_json.json.erb" => "<%= render template: 'test/final', formats: [:json] %>", "test/final.json.erb" => "{ final: json }", "test/with_error.html.erb" => "<%= raise 'i do not exist' %>" )] @@ -44,8 +46,12 @@ def with_locals render template: "locals", locals: { secret: "area51" } end - def with_locals_without_key - render "locals", locals: { secret: "area51" } + def with_locals_with_slash + render template: "/locals", locals: { secret: "area51" } + end + + def with_locals_with_slash_without_key + render "/locals", locals: { secret: "area51" } end def builder_template @@ -65,7 +71,6 @@ def with_error end private - def show_detailed_exceptions? request.local? end @@ -104,8 +109,13 @@ class TestWithoutLayout < Rack::TestCase assert_response "The secret is area51" end - test "rendering a template with local variables without key" do - get :with_locals + test "rendering a template with local variables with a leading slash" do + get :with_locals_with_slash + assert_response "The secret is area51" + end + + test "rendering a template with local variables with a slash without key" do + get :with_locals_with_slash_without_key assert_response "The secret is area51" end @@ -122,7 +132,7 @@ class TestWithoutLayout < Rack::TestCase get :with_implicit_raw - assert_body "Hello this is also raw in an html template" + assert_body "Hello this is also raw in an HTML template" assert_status 200 get :with_implicit_raw, params: { format: "text" } @@ -176,7 +186,7 @@ def with_custom_layout class TestWithLayout < Rack::TestCase test "rendering with implicit layout" do with_routing do |set| - set.draw { ActiveSupport::Deprecation.silence { get ":controller", action: :index } } + set.draw { ActionDispatch.deprecator.silence { get ":controller", action: :index } } get "/render_template/with_layout" diff --git a/actionpack/test/controller/new_base/render_test.rb b/actionpack/test/controller/new_base/render_test.rb index cea3f9b5fd782..eb496a0dd42e8 100644 --- a/actionpack/test/controller/new_base/render_test.rb +++ b/actionpack/test/controller/new_base/render_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module Render @@ -35,7 +37,6 @@ def overridden end private - def secretz render plain: "FAIL WHALE!" end @@ -48,6 +49,13 @@ def index end end + class DoubleRenderWithHeadController < ActionController::Base + def index + render plain: "hello" + head :bad_request + end + end + class ChildRenderController < BlankRenderController append_view_path ActionView::FixtureResolver.new("render/child_render/overridden_with_own_view_paths_appended.html.erb" => "child content") prepend_view_path ActionView::FixtureResolver.new("render/child_render/overridden_with_own_view_paths_prepended.html.erb" => "child content") @@ -57,7 +65,7 @@ class RenderTest < Rack::TestCase test "render with blank" do with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller", action: "index" end end @@ -72,13 +80,27 @@ class RenderTest < Rack::TestCase test "rendering more than once raises an exception" do with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do + get ":controller", action: "index" + end + end + + assert_raises(AbstractController::DoubleRenderError) do + get "/render/double_render", headers: { "action_dispatch.show_exceptions" => :none } + end + end + end + + test "using head after rendering raises an exception" do + with_routing do |set| + set.draw do + ActionDispatch.deprecator.silence do get ":controller", action: "index" end end assert_raises(AbstractController::DoubleRenderError) do - get "/render/double_render", headers: { "action_dispatch.show_exceptions" => false } + get "/render/double_render_with_head", headers: { "action_dispatch.show_exceptions" => :none } end end end @@ -88,13 +110,13 @@ class TestOnlyRenderPublicActions < Rack::TestCase # Only public methods on actual controllers are callable actions test "raises an exception when a method of Object is called" do assert_raises(AbstractController::ActionNotFound) do - get "/render/blank_render/clone", headers: { "action_dispatch.show_exceptions" => false } + get "/render/blank_render/clone", headers: { "action_dispatch.show_exceptions" => :none } end end test "raises an exception when a private method is called" do assert_raises(AbstractController::ActionNotFound) do - get "/render/blank_render/secretz", headers: { "action_dispatch.show_exceptions" => false } + get "/render/blank_render/secretz", headers: { "action_dispatch.show_exceptions" => :none } end end end diff --git a/actionpack/test/controller/new_base/render_xml_test.rb b/actionpack/test/controller/new_base/render_xml_test.rb index 8bab41337785b..0dc16d64e2263 100644 --- a/actionpack/test/controller/new_base/render_xml_test.rb +++ b/actionpack/test/controller/new_base/render_xml_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module RenderXml diff --git a/actionpack/test/controller/output_escaping_test.rb b/actionpack/test/controller/output_escaping_test.rb index c7047d95ae7d9..d683bc73e67d6 100644 --- a/actionpack/test/controller/output_escaping_test.rb +++ b/actionpack/test/controller/output_escaping_test.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require "abstract_unit" class OutputEscapingTest < ActiveSupport::TestCase test "escape_html shouldn't die when passed nil" do - assert ERB::Util.h(nil).blank? + assert_predicate ERB::Util.h(nil), :blank? end test "escapeHTML should escape strings" do diff --git a/actionpack/test/controller/parameter_encoding_test.rb b/actionpack/test/controller/parameter_encoding_test.rb index 234d0bddd1ede..ab59cecbb85fe 100644 --- a/actionpack/test/controller/parameter_encoding_test.rb +++ b/actionpack/test/controller/parameter_encoding_test.rb @@ -1,39 +1,53 @@ +# frozen_string_literal: true + require "abstract_unit" class ParameterEncodingController < ActionController::Base - skip_parameter_encoding :test_bar - skip_parameter_encoding :test_all_values_encoding - - def test_foo + def test_undeclared_parameter render body: params[:foo].encoding end - def test_bar + skip_parameter_encoding :test_skip_parameter_encoding + def test_skip_parameter_encoding render body: params[:bar].encoding end + param_encoding :test_param_encoding, :baz, Encoding::SHIFT_JIS + def test_param_encoding + render body: ::JSON.dump({ "baz" => params[:baz].encoding, "qux" => params[:qux].encoding }) + end + + skip_parameter_encoding :test_all_values_encoding def test_all_values_encoding - render body: ::JSON.dump(params.values.map(&:encoding).map(&:name)) + render body: ::JSON.dump(params.except(:action, :controller).values.map(&:encoding).map(&:name)) end end class ParameterEncodingTest < ActionController::TestCase tests ParameterEncodingController - test "properly transcodes UTF8 parameters into declared encodings" do - post :test_foo, params: { "foo" => "foo", "bar" => "bar", "baz" => "baz" } + test "properly transcodes undeclared parameters into UTF-8 encodings" do + post :test_undeclared_parameter, params: { "foo" => "foo" } assert_response :success assert_equal "UTF-8", @response.body end - test "properly encodes ASCII_8BIT parameters into binary" do - post :test_bar, params: { "foo" => "foo", "bar" => "bar", "baz" => "baz" } + test "properly transcodes parameters of the action specified by skip_parameter_encoding to ASCII_8BIT" do + post :test_skip_parameter_encoding, params: { "bar" => "bar" } assert_response :success assert_equal "ASCII-8BIT", @response.body end + test "properly transcodes declared parameters into specified encodings" do + post :test_param_encoding, params: { "baz" => "baz", "qux" => "qux" } + + assert_response :success + assert_equal "Shift_JIS", JSON.parse(@response.body)["baz"] + assert_equal "UTF-8", JSON.parse(@response.body)["qux"] + end + test "properly encodes all ASCII_8BIT parameters into binary" do post :test_all_values_encoding, params: { "foo" => "foo", "bar" => "bar", "baz" => "baz" } @@ -42,7 +56,7 @@ class ParameterEncodingTest < ActionController::TestCase end test "does not raise an error when passed a param declared as ASCII-8BIT that contains invalid bytes" do - get :test_bar, params: { "bar" => URI.parser.escape("bar\xE2baz".b) } + get :test_skip_parameter_encoding, params: { "bar" => URI::RFC2396_PARSER.escape("bar\xE2baz".b) } assert_response :success assert_equal "ASCII-8BIT", @response.body diff --git a/actionpack/test/controller/parameters/accessors_test.rb b/actionpack/test/controller/parameters/accessors_test.rb index 2893eb7b91f84..a3c201114efea 100644 --- a/actionpack/test/controller/parameters/accessors_test.rb +++ b/actionpack/test/controller/parameters/accessors_test.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller/metal/strong_parameters" -require "active_support/core_ext/hash/transform_values" class ParametersAccessorsTest < ActiveSupport::TestCase setup do @@ -18,15 +19,27 @@ class ParametersAccessorsTest < ActiveSupport::TestCase ) end + test "each returns self" do + assert_same @params, @params.each { |_| _ } + end + + test "each_pair returns self" do + assert_same @params, @params.each_pair { |_| _ } + end + + test "each_value returns self" do + assert_same @params, @params.each_value { |_| _ } + end + test "[] retains permitted status" do @params.permit! - assert @params[:person].permitted? - assert @params[:person][:name].permitted? + assert_predicate @params[:person], :permitted? + assert_predicate @params[:person][:name], :permitted? end test "[] retains unpermitted status" do - assert_not @params[:person].permitted? - assert_not @params[:person][:name].permitted? + assert_not_predicate @params[:person], :permitted? + assert_not_predicate @params[:person][:name], :permitted? end test "as_json returns the JSON representation of the parameters hash" do @@ -35,112 +48,309 @@ class ParametersAccessorsTest < ActiveSupport::TestCase assert @params.as_json.key? "person" end + test "to_s returns the string representation of the parameters hash" do + assert_equal({ "person" => { "age" => "32", "name" => { "first" => "David", "last" => "Heinemeier Hansson" }, + "addresses" => [{ "city" => "Chicago", "state" => "Illinois" }] } }.inspect, @params.to_s) + end + test "each carries permitted status" do @params.permit! - @params.each { |key, value| assert(value.permitted?) if key == "person" } + @params.each { |key, value| assert_predicate(value, :permitted?) if key == "person" } end test "each carries unpermitted status" do @params.each { |key, value| assert_not(value.permitted?) if key == "person" } end + test "each returns key,value array for block with arity 1" do + @params.each do |arg| + assert_kind_of Array, arg + assert_equal "person", arg[0] + assert_kind_of ActionController::Parameters, arg[1] + end + end + + test "each without a block returns an enumerator" do + assert_kind_of Enumerator, @params.each + assert_equal @params, ActionController::Parameters.new(@params.each.to_h) + end + test "each_pair carries permitted status" do @params.permit! - @params.each_pair { |key, value| assert(value.permitted?) if key == "person" } + @params.each_pair { |key, value| assert_predicate(value, :permitted?) if key == "person" } end test "each_pair carries unpermitted status" do @params.each_pair { |key, value| assert_not(value.permitted?) if key == "person" } end + test "each_pair returns key,value array for block with arity 1" do + @params.each_pair do |arg| + assert_kind_of Array, arg + assert_equal "person", arg[0] + assert_kind_of ActionController::Parameters, arg[1] + end + end + + test "each_pair without a block returns an enumerator" do + assert_kind_of Enumerator, @params.each_pair + assert_equal @params, ActionController::Parameters.new(@params.each_pair.to_h) + end + + test "each_value carries permitted status" do + @params.permit! + @params.each_value do |value| + assert_predicate(value, :permitted?) + end + end + + test "each_value carries unpermitted status" do + @params.each_value do |value| + assert_not_predicate(value, :permitted?) + end + end + + test "each_value without a block returns an enumerator" do + assert_kind_of Enumerator, @params.each_value + assert_equal @params.values, @params.each_value.to_a + end + + test "each_key converts to hash for permitted" do + @params.permit! + @params.each_key { |key| assert_kind_of(String, key) if key == "person" } + end + + test "each_key converts to hash for unpermitted" do + @params.each_key { |key| assert_kind_of(String, key) if key == "person" } + end + + test "each_key without a block returns an enumerator" do + assert_kind_of Enumerator, @params.each_key + assert_equal @params.keys, @params.each_key.to_a + end + + test "empty? returns true when params contains no key/value pairs" do + params = ActionController::Parameters.new + assert_empty params + end + + test "empty? returns false when any params are present" do + assert_not_empty @params + end + test "except retains permitted status" do @params.permit! - assert @params.except(:person).permitted? - assert @params[:person].except(:name).permitted? + assert_predicate @params.except(:person), :permitted? + assert_predicate @params[:person].except(:name), :permitted? end test "except retains unpermitted status" do - assert_not @params.except(:person).permitted? - assert_not @params[:person].except(:name).permitted? + assert_not_predicate @params.except(:person), :permitted? + assert_not_predicate @params[:person].except(:name), :permitted? + end + + test "without retains permitted status" do + @params.permit! + assert_predicate @params.without(:person), :permitted? + assert_predicate @params[:person].without(:name), :permitted? + end + + test "without retains unpermitted status" do + assert_not_predicate @params.without(:person), :permitted? + assert_not_predicate @params[:person].without(:name), :permitted? + end + + test "exclude? returns true if the given key is not present in the params" do + assert @params.exclude?(:address) + end + + test "exclude? returns false if the given key is present in the params" do + assert_not @params.exclude?(:person) end test "fetch retains permitted status" do @params.permit! - assert @params.fetch(:person).permitted? - assert @params[:person].fetch(:name).permitted? + assert_predicate @params.fetch(:person), :permitted? + assert_predicate @params[:person].fetch(:name), :permitted? end test "fetch retains unpermitted status" do - assert_not @params.fetch(:person).permitted? - assert_not @params[:person].fetch(:name).permitted? + assert_not_predicate @params.fetch(:person), :permitted? + assert_not_predicate @params[:person].fetch(:name), :permitted? + end + + test "has_key? returns true if the given key is present in the params" do + assert @params.has_key?(:person) + end + + test "has_key? returns false if the given key is not present in the params" do + assert_not @params.has_key?(:address) + end + + test "has_value? returns true if the given value is present in the params" do + params = ActionController::Parameters.new(city: "Chicago", state: "Illinois") + assert params.has_value?("Chicago") + end + + test "has_value? returns false if the given value is not present in the params" do + params = ActionController::Parameters.new(city: "Chicago", state: "Illinois") + assert_not params.has_value?("New York") + end + + test "include? returns true if the given key is present in the params" do + assert @params.include?(:person) + end + + test "include? returns false if the given key is not present in the params" do + assert_not @params.include?(:address) + end + + test "key? returns true if the given key is present in the params" do + assert @params.key?(:person) + end + + test "key? returns false if the given key is not present in the params" do + assert_not @params.key?(:address) + end + + test "member? returns true if the given key is present in the params" do + assert @params.member?(:person) + end + + test "member? returns false if the given key is not present in the params" do + assert_not @params.member?(:address) + end + + test "keys returns an array of the keys of the params" do + assert_equal ["person"], @params.keys + assert_equal ["age", "name", "addresses"], @params[:person].keys end test "reject retains permitted status" do - assert_not @params.reject { |k| k == "person" }.permitted? + assert_not_predicate @params.reject { |k| k == "person" }, :permitted? end test "reject retains unpermitted status" do @params.permit! - assert @params.reject { |k| k == "person" }.permitted? + assert_predicate @params.reject { |k| k == "person" }, :permitted? end test "select retains permitted status" do @params.permit! - assert @params.select { |k| k == "person" }.permitted? + assert_predicate @params.select { |k| k == "person" }, :permitted? end test "select retains unpermitted status" do - assert_not @params.select { |k| k == "person" }.permitted? + assert_not_predicate @params.select { |k| k == "person" }, :permitted? end test "slice retains permitted status" do @params.permit! - assert @params.slice(:person).permitted? + assert_predicate @params.slice(:person), :permitted? end test "slice retains unpermitted status" do - assert_not @params.slice(:person).permitted? + assert_not_predicate @params.slice(:person), :permitted? end test "transform_keys retains permitted status" do @params.permit! - assert @params.transform_keys { |k| k }.permitted? + assert_predicate @params.transform_keys { |k| k }, :permitted? end test "transform_keys retains unpermitted status" do - assert_not @params.transform_keys { |k| k }.permitted? + assert_not_predicate @params.transform_keys { |k| k }, :permitted? + end + + test "transform_keys without a block returns an enumerator" do + assert_kind_of Enumerator, @params.transform_keys + assert_kind_of ActionController::Parameters, @params.transform_keys.each { |k| k } + end + + test "transform_keys! without a block returns an enumerator" do + assert_kind_of Enumerator, @params.transform_keys! + assert_kind_of ActionController::Parameters, @params.transform_keys!.each { |k| k } + end + + test "deep_transform_keys retains permitted status" do + @params.permit! + assert_predicate @params.deep_transform_keys { |k| k }, :permitted? + end + + test "deep_transform_keys retains unpermitted status" do + assert_not_predicate @params.deep_transform_keys { |k| k }, :permitted? end test "transform_values retains permitted status" do @params.permit! - assert @params.transform_values { |v| v }.permitted? + assert_predicate @params.transform_values { |v| v }, :permitted? end test "transform_values retains unpermitted status" do - assert_not @params.transform_values { |v| v }.permitted? + assert_not_predicate @params.transform_values { |v| v }, :permitted? + end + + test "transform_values converts hashes to parameters" do + @params.transform_values do |value| + assert_kind_of ActionController::Parameters, value + value + end + end + + test "transform_values without a block returns an enumerator" do + assert_kind_of Enumerator, @params.transform_values + assert_kind_of ActionController::Parameters, @params.transform_values.each { |v| v } + end + + test "transform_values! converts hashes to parameters" do + @params.transform_values! do |value| + assert_kind_of ActionController::Parameters, value + end + end + + test "transform_values! without a block returns an enumerator" do + assert_kind_of Enumerator, @params.transform_values! + assert_kind_of ActionController::Parameters, @params.transform_values!.each { |v| v } + end + + test "value? returns true if the given value is present in the params" do + params = ActionController::Parameters.new(city: "Chicago", state: "Illinois") + assert params.value?("Chicago") + end + + test "value? returns false if the given value is not present in the params" do + params = ActionController::Parameters.new(city: "Chicago", state: "Illinois") + assert_not params.value?("New York") + end + + test "values returns an array of the values of the params" do + params = ActionController::Parameters.new(city: "Chicago", state: "Illinois", person: ActionController::Parameters.new(first_name: "David")) + assert_equal ["Chicago", "Illinois", ActionController::Parameters.new(first_name: "David")], params.values end test "values_at retains permitted status" do @params.permit! - assert @params.values_at(:person).first.permitted? - assert @params[:person].values_at(:name).first.permitted? + assert_predicate @params.values_at(:person).first, :permitted? + assert_predicate @params[:person].values_at(:name).first, :permitted? end test "values_at retains unpermitted status" do - assert_not @params.values_at(:person).first.permitted? - assert_not @params[:person].values_at(:name).first.permitted? + assert_not_predicate @params.values_at(:person).first, :permitted? + assert_not_predicate @params[:person].values_at(:name).first, :permitted? end test "is equal to Parameters instance with same params" do params1 = ActionController::Parameters.new(a: 1, b: 2) params2 = ActionController::Parameters.new(a: 1, b: 2) assert(params1 == params2) + assert(params1.hash == params2.hash) end test "is equal to Parameters instance with same permitted params" do params1 = ActionController::Parameters.new(a: 1, b: 2).permit(:a) params2 = ActionController::Parameters.new(a: 1, b: 2).permit(:a) assert(params1 == params2) + assert(params1.hash == params2.hash) end test "is equal to Parameters instance with same different source params, but same permitted params" do @@ -148,6 +358,8 @@ class ParametersAccessorsTest < ActiveSupport::TestCase params2 = ActionController::Parameters.new(a: 1, c: 3).permit(:a) assert(params1 == params2) assert(params2 == params1) + assert(params1.hash == params2.hash) + assert(params2.hash == params1.hash) end test "is not equal to an unpermitted Parameters instance with same params" do @@ -155,6 +367,8 @@ class ParametersAccessorsTest < ActiveSupport::TestCase params2 = ActionController::Parameters.new(a: 1) assert(params1 != params2) assert(params2 != params1) + assert(params1.hash != params2.hash) + assert(params2.hash != params1.hash) end test "is not equal to Parameters instance with different permitted params" do @@ -162,6 +376,8 @@ class ParametersAccessorsTest < ActiveSupport::TestCase params2 = ActionController::Parameters.new(a: 1, b: 2).permit(:a) assert(params1 != params2) assert(params2 != params1) + assert(params1.hash != params2.hash) + assert(params2.hash != params1.hash) end test "equality with simple types works" do @@ -171,10 +387,19 @@ class ParametersAccessorsTest < ActiveSupport::TestCase end test "inspect shows both class name, parameters and permitted flag" do + hash = { + "person" => { + "age" => "32", + "name" => { + "first" => "David", + "last" => "Heinemeier Hansson" + }, + "addresses" => [{ "city" => "Chicago", "state" => "Illinois" }] + }, + } + assert_equal( - '{"age"=>"32", '\ - '"name"=>{"first"=>"David", "last"=>"Heinemeier Hansson"}, ' \ - '"addresses"=>[{"city"=>"Chicago", "state"=>"Illinois"}]}} permitted: false>', + "#", @params.inspect ) end @@ -187,23 +412,35 @@ class ParametersAccessorsTest < ActiveSupport::TestCase assert_match(/permitted: true/, @params.inspect) end - if Hash.method_defined?(:dig) - test "#dig delegates the dig method to its values" do - assert_equal "David", @params.dig(:person, :name, :first) - assert_equal "Chicago", @params.dig(:person, :addresses, 0, :city) - end + test "#dig delegates the dig method to its values" do + assert_equal "David", @params.dig(:person, :name, :first) + assert_equal "Chicago", @params.dig(:person, :addresses, 0, :city) + end - test "#dig converts hashes to parameters" do - assert_kind_of ActionController::Parameters, @params.dig(:person) - assert_kind_of ActionController::Parameters, @params.dig(:person, :addresses, 0) - assert @params.dig(:person, :addresses).all? do |value| - value.is_a?(ActionController::Parameters) - end - end - else - test "ActionController::Parameters does not respond to #dig on Ruby 2.2" do - assert_not ActionController::Parameters.method_defined?(:dig) - assert_not @params.respond_to?(:dig) - end + test "#dig converts hashes to parameters" do + assert_kind_of ActionController::Parameters, @params.dig(:person) + assert_kind_of ActionController::Parameters, @params.dig(:person, :addresses, 0) + assert @params.dig(:person, :addresses).all?(ActionController::Parameters) + end + + test "mutating #dig return value mutates underlying parameters" do + @params.dig(:person, :name)[:first] = "Bill" + assert_equal "Bill", @params.dig(:person, :name, :first) + + @params.dig(:person, :addresses)[0] = { city: "Boston", state: "Massachusetts" } + assert_equal "Boston", @params.dig(:person, :addresses, 0, :city) + end + + test "#extract_value splits param by delimiter" do + params = ActionController::Parameters.new( + id: "1_123", + tags: "ruby,rails,web", + blank_tags: ",ruby,,rails," + ) + + assert_equal(["1", "123"], params.extract_value(:id)) + assert_equal(["ruby", "rails", "web"], params.extract_value(:tags, delimiter: ",")) + assert_equal(["", "ruby", "", "rails", ""], params.extract_value(:blank_tags, delimiter: ",")) + assert_nil(params.extract_value(:non_existent_key)) end end diff --git a/actionpack/test/controller/parameters/always_permitted_parameters_test.rb b/actionpack/test/controller/parameters/always_permitted_parameters_test.rb index cd7c98f1120ab..974612fb7b146 100644 --- a/actionpack/test/controller/parameters/always_permitted_parameters_test.rb +++ b/actionpack/test/controller/parameters/always_permitted_parameters_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller/metal/strong_parameters" @@ -18,11 +20,11 @@ def teardown end end - test "permits parameters that are whitelisted" do + test "allows both explicitly listed and always-permitted parameters" do params = ActionController::Parameters.new( book: { pages: 65 }, format: "json") permitted = params.permit book: [:pages] - assert permitted.permitted? + assert_predicate permitted, :permitted? end end diff --git a/actionpack/test/controller/parameters/dup_test.rb b/actionpack/test/controller/parameters/dup_test.rb index fb707a1354323..5403fc6d93bc0 100644 --- a/actionpack/test/controller/parameters/dup_test.rb +++ b/actionpack/test/controller/parameters/dup_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller/metal/strong_parameters" require "active_support/core_ext/object/deep_dup" @@ -21,7 +23,7 @@ class ParametersDupTest < ActiveSupport::TestCase test "a duplicate maintains the original's permitted status" do @params.permit! dupped_params = @params.dup - assert dupped_params.permitted? + assert_predicate dupped_params, :permitted? end test "a duplicate maintains the original's parameters" do @@ -55,11 +57,11 @@ class ParametersDupTest < ActiveSupport::TestCase dupped_params = @params.deep_dup dupped_params.permit! - assert_not @params.permitted? + assert_not_predicate @params, :permitted? end test "deep_dup @permitted is being copied" do @params.permit! - assert @params.deep_dup.permitted? + assert_predicate @params.deep_dup, :permitted? end end diff --git a/actionpack/test/controller/parameters/equality_test.rb b/actionpack/test/controller/parameters/equality_test.rb new file mode 100644 index 0000000000000..2c836a61480a6 --- /dev/null +++ b/actionpack/test/controller/parameters/equality_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "action_controller/metal/strong_parameters" + +class ParametersAccessorsTest < ActiveSupport::TestCase + setup do + ActionController::Parameters.permit_all_parameters = false + + @params = ActionController::Parameters.new( + person: { + age: "32", + name: { + first: "David", + last: "Heinemeier Hansson" + }, + addresses: [{ city: "Chicago", state: "Illinois" }] + } + ) + end + + test "parameters are not equal to the hash" do + @hash = @params.each_pair.to_h + assert_not_equal @params, @hash + end + + test "not eql? to equivalent hash" do + @hash = {} + @params = ActionController::Parameters.new(@hash) + assert_not @params.eql?(@hash) + end + + test "not eql? to equivalent nested hash" do + @params1 = ActionController::Parameters.new({ foo: {} }) + @params2 = ActionController::Parameters.new({ foo: ActionController::Parameters.new({}) }) + assert_not @params1.eql?(@params2) + end + + test "not eql? when permitted is different" do + permitted = @params.permit(:person) + assert_not @params.eql?(permitted) + end + + test "eql? when equivalent" do + permitted = @params.permit(:person) + assert @params.permit(:person).eql?(permitted) + end + + test "has_value? converts hashes to parameters" do + params = ActionController::Parameters.new(foo: { bar: "baz" }) + assert params.has_value?("bar" => "baz") + params[:foo] # converts value to AC::Params + assert params.has_value?("bar" => "baz") + end + + test "has_value? works with parameters" do + 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 c800c1d3df328..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,7 +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 @@ -10,23 +15,28 @@ 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 - params = ActionController::Parameters.new( - book: { pages: 65 }, - fishing: "Turnips") + request_params = { book: { pages: 65 }, fishing: "Turnips" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) - assert_logged("Unpermitted parameter: :fishing") do + assert_logged("Unpermitted parameter: :fishing. Context: { action: my_action, controller: my_controller }") do params.permit(book: [:pages]) end end test "logs on unexpected params" do - params = ActionController::Parameters.new( - book: { pages: 65 }, - fishing: "Turnips", - car: "Mersedes") + request_params = { book: { pages: 65 }, fishing: "Turnips", car: "Mercedes" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) - assert_logged("Unpermitted parameters: :fishing, :car") do + assert_logged("Unpermitted parameters: :fishing, :car. Context: { action: my_action, controller: my_controller }") do params.permit(book: [:pages]) end end @@ -35,7 +45,7 @@ def teardown params = ActionController::Parameters.new( book: { pages: 65, title: "Green Cats and where to find then." }) - assert_logged("Unpermitted parameter: :title") do + assert_logged("Unpermitted parameter: :title. Context: { }") do params.permit(book: [:pages]) end end @@ -44,17 +54,160 @@ def teardown params = ActionController::Parameters.new( book: { pages: 65, title: "Green Cats and where to find then.", author: "G. A. Dog" }) - assert_logged("Unpermitted parameters: :title, :author") do + assert_logged("Unpermitted parameters: :title, :author. Context: { }") do params.permit(book: [:pages]) end end - private + test "does not log on unexpected nested params with expect" do + request_params = { book: { pages: 65, title: "Green Cats and where to find then.", author: "G. A. Dog" } } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("") do + params.expect(book: :pages) + end + end + + test "does not log on unexpected nested params with expect!" do + request_params = { book: { pages: 65, title: "Green Cats and where to find then.", author: "G. A. Dog" } } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("") do + params.expect!(book: :pages) + end + end + + test "logs on unexpected param with deep_dup" do + request_params = { book: { pages: 3, author: "YY" } } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameter: :author. Context: { action: my_action, controller: my_controller }") do + params.deep_dup.permit(book: [:pages]) + end + end + + test "logs on unexpected params with slice" do + request_params = { food: "tomato", fishing: "Turnips", car: "Mercedes", music: "No. 9" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameters: :fishing, :car. Context: { action: my_action, controller: my_controller }") do + params.slice(:food, :fishing, :car).permit(:food) + end + end + + test "logs on unexpected params with except" do + request_params = { food: "tomato", fishing: "Turnips", car: "Mercedes", music: "No. 9" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameters: :fishing, :car. Context: { action: my_action, controller: my_controller }") do + params.except(:music).permit(:food) + end + end + + test "logs on unexpected params with extract!" do + request_params = { food: "tomato", fishing: "Turnips", car: "Mercedes", music: "No. 9" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameters: :fishing, :car. Context: { action: my_action, controller: my_controller }") do + params.extract!(:food, :fishing, :car).permit(:food) + end + + assert_logged("Unpermitted parameter: :music. Context: { action: my_action, controller: my_controller }") do + params.permit(:food) + end + end + test "logs on unexpected params with transform_values" do + request_params = { food: "tomato", fishing: "Turnips", car: "Mercedes", music: "No. 9" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameters: :fishing, :car, :music. Context: { action: my_action, controller: my_controller }") do + params.transform_values { |v| v.upcase }.permit(:food) + end + end + + test "logs on unexpected params with transform_keys" do + request_params = { food: "tomato", fishing: "Turnips", car: "Mercedes", music: "No. 9" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameters: :FISHING, :CAR, :MUSIC. Context: { action: my_action, controller: my_controller }") do + params.transform_keys { |k| k.upcase }.permit(:FOOD) + end + end + + test "logs on unexpected param with deep_transform_keys" do + request_params = { book: { pages: 48, title: "Hope" } } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameter: :TITLE. Context: { action: my_action, controller: my_controller }") do + params.deep_transform_keys { |k| k.upcase }.permit(BOOK: [:PAGES]) + end + end + + test "logs on unexpected param with select" do + request_params = { food: "tomato", fishing: "Turnips", car: "Mercedes", music: "No. 9" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameter: :music. Context: { action: my_action, controller: my_controller }") do + params.select { |k| k == "music" }.permit(:food) + end + end + + test "logs on unexpected params with reject" do + request_params = { food: "tomato", fishing: "Turnips", car: "Mercedes", music: "No. 9" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameters: :fishing, :car. Context: { action: my_action, controller: my_controller }") do + params.reject { |k| k == "music" }.permit(:food) + end + end + + test "logs on unexpected param with compact" do + request_params = { food: "tomato", fishing: "Turnips", car: nil, music: nil } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameter: :fishing. Context: { action: my_action, controller: my_controller }") do + params.compact.permit(:food) + end + end + + test "logs on unexpected param with merge" do + request_params = { food: "tomato" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameter: :album. Context: { action: my_action, controller: my_controller }") do + params.merge(album: "My favorites").permit(:food) + end + end + + test "logs on unexpected param with reverse_merge" do + request_params = { food: "tomato" } + context = { "action" => "my_action", "controller" => "my_controller" } + params = ActionController::Parameters.new(request_params, context) + + assert_logged("Unpermitted parameter: :album. Context: { action: my_action, controller: my_controller }") do + params.reverse_merge(album: "My favorites").permit(:food) + end + end + + 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 @@ -62,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/parameters/multi_parameter_attributes_test.rb b/actionpack/test/controller/parameters/multi_parameter_attributes_test.rb index 88fb477c10b53..c890839727c0e 100644 --- a/actionpack/test/controller/parameters/multi_parameter_attributes_test.rb +++ b/actionpack/test/controller/parameters/multi_parameter_attributes_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller/metal/strong_parameters" @@ -19,7 +21,7 @@ class MultiParameterAttributesTest < ActiveSupport::TestCase permitted = params.permit book: [ :shipped_at, :price ] - assert permitted.permitted? + assert_predicate permitted, :permitted? assert_equal "2012", permitted[:book]["shipped_at(1i)"] assert_equal "3", permitted[:book]["shipped_at(2i)"] diff --git a/actionpack/test/controller/parameters/mutators_test.rb b/actionpack/test/controller/parameters/mutators_test.rb index e61bbdbe137a4..f620bab731330 100644 --- a/actionpack/test/controller/parameters/mutators_test.rb +++ b/actionpack/test/controller/parameters/mutators_test.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller/metal/strong_parameters" -require "active_support/core_ext/hash/transform_values" class ParametersMutatorsTest < ActiveSupport::TestCase setup do @@ -18,82 +19,202 @@ class ParametersMutatorsTest < ActiveSupport::TestCase test "delete retains permitted status" do @params.permit! - assert @params.delete(:person).permitted? + assert_predicate @params.delete(:person), :permitted? end test "delete retains unpermitted status" do - assert_not @params.delete(:person).permitted? + assert_not_predicate @params.delete(:person), :permitted? + end + + test "delete returns the value when the key is present" do + assert_equal "32", @params[:person].delete(:age) + end + + test "delete removes the entry when the key present" do + @params[:person].delete(:age) + assert_not @params[:person].key?(:age) + end + + test "delete returns nil when the key is not present" do + assert_nil @params[:person].delete(:first_name) + end + + test "delete returns the value of the given block when the key is not present" do + assert_equal "David", @params[:person].delete(:first_name) { "David" } + end + + test "delete yields the key to the given block when the key is not present" do + assert_equal "first_name: David", @params[:person].delete(:first_name) { |k| "#{k}: David" } end test "delete_if retains permitted status" do @params.permit! - assert @params.delete_if { |k| k == "person" }.permitted? + assert_predicate @params.delete_if { |k| k == "person" }, :permitted? end test "delete_if retains unpermitted status" do - assert_not @params.delete_if { |k| k == "person" }.permitted? + assert_not_predicate @params.delete_if { |k| k == "person" }, :permitted? end test "extract! retains permitted status" do @params.permit! - assert @params.extract!(:person).permitted? + assert_predicate @params.extract!(:person), :permitted? end test "extract! retains unpermitted status" do - assert_not @params.extract!(:person).permitted? + assert_not_predicate @params.extract!(:person), :permitted? end test "keep_if retains permitted status" do @params.permit! - assert @params.keep_if { |k, v| k == "person" }.permitted? + assert_predicate @params.keep_if { |k, v| k == "person" }, :permitted? end test "keep_if retains unpermitted status" do - assert_not @params.keep_if { |k, v| k == "person" }.permitted? + assert_not_predicate @params.keep_if { |k, v| k == "person" }, :permitted? end test "reject! retains permitted status" do @params.permit! - assert @params.reject! { |k| k == "person" }.permitted? + assert_predicate @params.reject! { |k| k == "person" }, :permitted? end test "reject! retains unpermitted status" do - assert_not @params.reject! { |k| k == "person" }.permitted? + assert_not_predicate @params.reject! { |k| k == "person" }, :permitted? end test "select! retains permitted status" do @params.permit! - assert @params.select! { |k| k != "person" }.permitted? + assert_predicate @params.select! { |k| k != "person" }, :permitted? end test "select! retains unpermitted status" do - assert_not @params.select! { |k| k != "person" }.permitted? + assert_not_predicate @params.select! { |k| k != "person" }, :permitted? end test "slice! retains permitted status" do @params.permit! - assert @params.slice!(:person).permitted? + assert_predicate @params.slice!(:person), :permitted? end test "slice! retains unpermitted status" do - assert_not @params.slice!(:person).permitted? + assert_not_predicate @params.slice!(:person), :permitted? end test "transform_keys! retains permitted status" do @params.permit! - assert @params.transform_keys! { |k| k }.permitted? + assert_predicate @params.transform_keys! { |k| k }, :permitted? end test "transform_keys! retains unpermitted status" do - assert_not @params.transform_keys! { |k| k }.permitted? + assert_not_predicate @params.transform_keys! { |k| k }, :permitted? end test "transform_values! retains permitted status" do @params.permit! - assert @params.transform_values! { |v| v }.permitted? + assert_predicate @params.transform_values! { |v| v }, :permitted? end test "transform_values! retains unpermitted status" do - assert_not @params.transform_values! { |v| v }.permitted? + assert_not_predicate @params.transform_values! { |v| v }, :permitted? + end + + test "deep_transform_keys! retains permitted status" do + @params.permit! + assert_predicate @params.deep_transform_keys! { |k| k }, :permitted? + end + + test "deep_transform_keys! transforms nested keys" do + @params.permit! + @params.deep_transform_keys!(&:upcase) + + expected_hash = { "PERSON" => { "AGE" => "32", "NAME" => { "FIRST" => "David", "LAST" => "Heinemeier Hansson" }, "ADDRESSES" => [{ "CITY" => "Chicago", "STATE" => "Illinois" }] } } + assert_equal @params.to_hash, expected_hash + end + + test "deep_transform_keys transforms nested keys" do + original_hash = @params.to_unsafe_h + @params.permit! + new_params = @params.deep_transform_keys(&:upcase) + + assert_equal @params.to_hash, original_hash + + expected_hash = { "PERSON" => { "AGE" => "32", "NAME" => { "FIRST" => "David", "LAST" => "Heinemeier Hansson" }, "ADDRESSES" => [{ "CITY" => "Chicago", "STATE" => "Illinois" }] } } + assert_equal new_params.to_hash, expected_hash + end + + test "deep_transform_keys! retains unpermitted status" do + assert_not_predicate @params.deep_transform_keys! { |k| k }, :permitted? + end + + test "compact retains permitted status" do + @params.permit! + assert_predicate @params.compact, :permitted? + end + + test "compact retains unpermitted status" do + assert_not_predicate @params.compact, :permitted? + end + + test "compact! returns nil when no values are nil" do + assert_nil @params.compact! + end + + test "compact! retains permitted status" do + @params[:person] = nil + @params.permit! + assert_predicate @params.compact!, :permitted? + end + + test "compact! retains unpermitted status" do + @params[:person] = nil + assert_not_predicate @params.compact!, :permitted? + end + + test "compact_blank retains permitted status" do + @params.permit! + assert_predicate @params.compact_blank, :permitted? + end + + test "compact_blank retains unpermitted status" do + assert_not_predicate @params.compact_blank, :permitted? + end + + test "compact_blank! retains permitted status" do + @params.permit! + assert_predicate @params.compact_blank!, :permitted? + end + + test "compact_blank! retains unpermitted status" do + assert_not_predicate @params.compact_blank!, :permitted? + end + + test "to_h returns a ActiveSupport::HashWithIndifferentAccess" do + @params.permit! + params_hash = @params.to_h + assert_instance_of ActiveSupport::HashWithIndifferentAccess, params_hash + end + + # rubocop:disable Style/HashTransformKeys + test "to_h receives a block and transforms keys" do + params = ActionController::Parameters.new(name: "Alex", age: "40", location: "Beijing") + params.permit! + params_hash = params.to_h { |key, value| [:"#{key}_modified", value] } + assert_equal %w(name_modified age_modified location_modified), params_hash.keys + end + # rubocop:enable Style/HashTransformKeys + + # rubocop:disable Style/HashTransformValues + test "to_h receives a block and transforms values" do + params = ActionController::Parameters.new(name: "Alex", age: "40", location: "Beijing") + params.permit! + params_hash = params.to_h { |key, value| [key, value.is_a?(String) ? "#{value}_modified" : value] } + assert_equal %w(Alex_modified 40_modified Beijing_modified), params_hash.values + end + # rubocop:enable Style/HashTransformValues + + test "to_h does not include unpermitted params" do + params = ActionController::Parameters.new(name: "Alex", age: "40", location: "Beijing") + assert_raises(ActionController::UnfilteredParameters) { params.to_h { |key, value| [key, value] } } end end diff --git a/actionpack/test/controller/parameters/nested_parameters_permit_test.rb b/actionpack/test/controller/parameters/nested_parameters_permit_test.rb index 00e591d5a7222..8bc1b119275a5 100644 --- a/actionpack/test/controller/parameters/nested_parameters_permit_test.rb +++ b/actionpack/test/controller/parameters/nested_parameters_permit_test.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller/metal/strong_parameters" class NestedParametersPermitTest < ActiveSupport::TestCase def assert_filtered_out(params, key) - assert !params.has_key?(key), "key #{key.inspect} has not been filtered out" + assert_not params.has_key?(key), "key #{key.inspect} has not been filtered out" end test "permitted nested parameters" do @@ -30,7 +32,7 @@ def assert_filtered_out(params, key) permitted = params.permit book: [ :title, { authors: [ :name ] }, { details: :pages }, :id ] - assert permitted.permitted? + assert_predicate permitted, :permitted? assert_equal "Romeo and Juliet", permitted[:book][:title] assert_equal "William Shakespeare", permitted[:book][:authors][0][:name] assert_equal "Christopher Marlowe", permitted[:book][:authors][1][:name] @@ -123,7 +125,7 @@ def assert_filtered_out(params, key) assert_nil permitted[:book][:genre] end - test "fields_for-style nested params" do + test "nested params with numeric keys" do params = ActionController::Parameters.new( book: { authors_attributes: { @@ -148,7 +150,33 @@ def assert_filtered_out(params, key) assert_filtered_out permitted[:book][:authors_attributes]["0"], :age_of_death end - test "fields_for-style nested params with negative numbers" do + test "nested params with non_numeric keys" do + params = ActionController::Parameters.new( + book: { + authors_attributes: { + '0': { name: "William Shakespeare", age_of_death: "52" }, + '1': { name: "Unattributed Assistant" }, + '2': "Not a hash", + 'new_record': { name: "Some name" } + } + }) + permitted = params.permit book: { authors_attributes: [ :name ] } + + assert_not_nil permitted[:book][:authors_attributes]["0"] + assert_not_nil permitted[:book][:authors_attributes]["1"] + + assert_nil permitted[:book][:authors_attributes]["2"] + assert_nil permitted[:book][:authors_attributes]["new_record"] + assert_equal "William Shakespeare", permitted[:book][:authors_attributes]["0"][:name] + assert_equal "Unattributed Assistant", permitted[:book][:authors_attributes]["1"][:name] + + assert_equal( + { "book" => { "authors_attributes" => { "0" => { "name" => "William Shakespeare" }, "1" => { "name" => "Unattributed Assistant" } } } }, + permitted.to_h + ) + end + + test "nested params with negative numeric keys" do params = ActionController::Parameters.new( book: { authors_attributes: { @@ -166,6 +194,75 @@ def assert_filtered_out(params, key) assert_filtered_out permitted[:book][:authors_attributes]["-1"], :age_of_death end + test "nested params with numeric keys addressing individual numeric keys" do + params = ActionController::Parameters.new( + book: { + authors_attributes: { + '0': { name: "William Shakespeare", age_of_death: "52" }, + '1': { name: "Unattributed Assistant" }, + '2': { name: %w(injected names) } + } + }) + permitted = params.permit book: { authors_attributes: { '1': [ :name ], '0': [ :name, :age_of_death ] } } + + assert_equal( + { "book" => { "authors_attributes" => { "0" => { "name" => "William Shakespeare", "age_of_death" => "52" }, "1" => { "name" => "Unattributed Assistant" } } } }, + permitted.to_h + ) + end + + test "nested params with numeric keys addressing individual numeric keys using require first" do + params = ActionController::Parameters.new( + book: { + authors_attributes: { + '0': { name: "William Shakespeare", age_of_death: "52" }, + '1': { name: "Unattributed Assistant" }, + '2': { name: %w(injected names) } + } + }) + + permitted = params.expect(book: { authors_attributes: { '1': [:name] } }) + + assert_equal( + { "authors_attributes" => { "1" => { "name" => "Unattributed Assistant" } } }, + permitted.to_h + ) + end + + test "nested params with numeric keys addressing individual numeric keys to arrays" do + params = ActionController::Parameters.new( + book: { + authors_attributes: { + '0': ["draft 1", "draft 2", "draft 3"], + '1': ["final draft"], + '2': { name: %w(injected names) } + } + }) + permitted = params.permit book: { authors_attributes: { '2': [ :name ], '0': [] } } + + assert_equal( + { "book" => { "authors_attributes" => { "2" => {}, "0" => ["draft 1", "draft 2", "draft 3"] } } }, + permitted.to_h + ) + end + + test "nested params with numeric keys addressing individual numeric keys to more nested params" do + params = ActionController::Parameters.new( + book: { + authors_attributes: { + '0': ["draft 1", "draft 2", "draft 3"], + '1': ["final draft"], + '2': { name: { "projects" => [ "hamlet", "Othello" ] } } + } + }) + permitted = params.permit book: { authors_attributes: { '2': { name: { projects: [] } }, '0': [] } } + + assert_equal( + { "book" => { "authors_attributes" => { "2" => { "name" => { "projects" => ["hamlet", "Othello"] } }, "0" => ["draft 1", "draft 2", "draft 3"] } } }, + permitted.to_h + ) + end + test "nested number as key" do params = ActionController::Parameters.new( product: { @@ -174,7 +271,7 @@ def assert_filtered_out(params, key) "1" => "prop1" } }) - params = params.require(:product).permit(properties: ["0"]) + params = params.expect(product: { properties: ["0"] }) assert_not_nil params[:properties]["0"] assert_nil params[:properties]["1"] assert_equal "prop0", params[:properties]["0"] diff --git a/actionpack/test/controller/parameters/parameters_expect_test.rb b/actionpack/test/controller/parameters/parameters_expect_test.rb new file mode 100644 index 0000000000000..f879a56332707 --- /dev/null +++ b/actionpack/test/controller/parameters/parameters_expect_test.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "action_dispatch/http/upload" +require "action_controller/metal/strong_parameters" + +class ParametersExpectTest < ActiveSupport::TestCase + setup do + @params = ActionController::Parameters.new( + person: { + age: "32", + name: { + first: "David", + last: "Heinemeier Hansson" + }, + addresses: [{ city: "Chicago", state: "Illinois" }] + } + ) + end + + test "key to array: returns only permitted scalar keys" do + permitted = @params.expect(person: [:age, :name, :addresses]) + + assert_equal({ "age" => "32" }, permitted.to_unsafe_h) + end + + test "key to hash: returns permitted params" do + permitted = @params.expect(person: { name: [:first, :last] }) + + assert_equal({ "name" => { "first" => "David", "last" => "Heinemeier Hansson" } }, permitted.to_h) + end + + test "key to empty hash: permits all params" do + permitted = @params.expect(person: {}) + + assert_equal({ "age" => "32", "name" => { "first" => "David", "last" => "Heinemeier Hansson" }, "addresses" => [{ "city" => "Chicago", "state" => "Illinois" }] }, permitted.to_h) + assert_predicate permitted, :permitted? + end + + test "keys to arrays: returns permitted params in hash key order" do + name, addresses = @params[:person].expect(name: [:first, :last], addresses: [[:city]]) + + assert_equal({ "first" => "David", "last" => "Heinemeier Hansson" }, name.to_h) + assert_equal({ "city" => "Chicago" }, addresses.first.to_h) + end + + test "key to array of keys: raises when params is an array" do + params = ActionController::Parameters.new(name: "Martin", pies: [{ flavor: "pumpkin" }]) + + assert_raises(ActionController::ParameterMissing) do + params.expect(pies: [:flavor]) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(pies: [:flavor]) + end + end + + test "key to explicit array: returns permitted array" do + params = ActionController::Parameters.new(name: "Martin", pies: [{ flavor: "pumpkin" }, { flavor: "chicken pot" }]) + pies = params.expect(pies: [[:flavor]]) + + assert_equal({ "flavor" => "pumpkin" }, pies[0].to_h) + assert_equal({ "flavor" => "chicken pot" }, pies[1].to_h) + end + + test "key to explicit array: returns array when params is a hash" do + params = ActionController::Parameters.new(name: "Martin", pies: { flavor: "pumpkin" }) + + assert_raises(ActionController::ParameterMissing) do + params.expect(pies: [[:flavor]]) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(pies: [[:flavor]]) + end + end + + test "key to explicit array: returns empty array when params empty array" do + params = ActionController::Parameters.new(name: "Martin", pies: []) + + assert_raises(ActionController::ParameterMissing) do + params.expect(pies: [[:flavor]]) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(pies: [[:flavor]]) + end + end + + test "key to mixed array: returns permitted params" do + permitted = @params.expect(person: [ :age, name: [:first, :last] ]) + + assert_equal({ "age" => "32", "name" => { "first" => "David", "last" => "Heinemeier Hansson" } }, permitted.to_h) + end + + test "chain of keys: returns permitted params" do + params = ActionController::Parameters.new(person: { name: "David" }) + name = params.expect(person: :name).expect(:name) + + assert_equal "David", name + end + + test "array of key: returns single permitted param" do + params = ActionController::Parameters.new(a: 1, b: 2) + a = params.expect(:a) + + assert_equal 1, a + end + + test "array of keys: returns multiple permitted params" do + params = ActionController::Parameters.new(a: 1, b: 2) + a, b = params.expect(:a, :b) + + assert_equal 1, a + assert_equal 2, b + end + + test "key: raises ParameterMissing on nil, blank, non-scalar or non-permitted type" do + values = [nil, "", {}, [], [1], { foo: "bar" }, Object.new] + values.each do |value| + params = ActionController::Parameters.new(id: value) + + assert_raises(ActionController::ParameterMissing) do + params.expect(:id) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(pies: [[:flavor]]) + end + end + end + + test "key: raises ParameterMissing if not present in params" do + params = ActionController::Parameters.new(name: "Joe") + assert_raises(ActionController::ParameterMissing) do + params.expect(:id) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(:id) + end + end + + test "key to empty array: raises ParameterMissing on empty" do + params = ActionController::Parameters.new(ids: []) + assert_raises(ActionController::ParameterMissing) do + params.expect(ids: []) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(ids: []) + end + end + + test "key to empty array: raises ParameterMissing on scalar" do + params = ActionController::Parameters.new(person: 1) + assert_raises(ActionController::ParameterMissing) do + params.expect(ids: []) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(ids: []) + end + end + + test "key to non-scalar: raises ParameterMissing on scalar" do + params = ActionController::Parameters.new(foo: "bar") + + assert_raises(ActionController::ParameterMissing) do + params.expect(foo: []) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(foo: []) + end + assert_raises(ActionController::ParameterMissing) do + params.expect(foo: [:bar]) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(foo: [:bar]) + end + assert_raises(ActionController::ParameterMissing) do + params.expect(foo: :bar) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(foo: :bar) + end + end + + test "key to empty hash: raises ParameterMissing on empty" do + params = ActionController::Parameters.new(person: {}) + + assert_raises(ActionController::ParameterMissing) do + params.expect(person: {}) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(person: {}) + end + end + + test "key to empty hash: raises ParameterMissing on scalar" do + params = ActionController::Parameters.new(person: 1) + + assert_raises(ActionController::ParameterMissing) do + params.expect(person: {}) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(person: {}) + end + end + + test "key: permitted scalar values" do + values = ["a", :a] + values += [0, 1.0, 2**128, BigDecimal(1)] + values += [true, false] + values += [Date.today, Time.now, DateTime.now] + values += [STDOUT, StringIO.new, ActionDispatch::Http::UploadedFile.new(tempfile: __FILE__), + Rack::Test::UploadedFile.new(__FILE__)] + + values.each do |value| + params = ActionController::Parameters.new(id: value) + + assert_equal value, params.expect(:id) + end + end + + test "key: unknown keys are filtered out" do + params = ActionController::Parameters.new(id: "1234", injected: "injected") + + assert_equal "1234", params.expect(:id) + end + + test "array of keys: raises ParameterMissing when one is missing" do + params = ActionController::Parameters.new(a: 1) + + assert_raises(ActionController::ParameterMissing) do + params.expect([:a, :b]) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!([:a, :b]) + end + end + + test "array of keys: raises ParameterMissing when one is non-scalar" do + params = ActionController::Parameters.new(a: 1, b: []) + + assert_raises(ActionController::ParameterMissing) do + params.expect([:a, :b]) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!([:a, :b]) + end + end + + test "key to empty array: arrays of permitted scalars pass" do + [["foo"], [1], ["foo", "bar"], [1, 2, 3]].each do |array| + params = ActionController::Parameters.new(id: array) + permitted = params.expect(id: []) + assert_equal array, permitted + end + end + + test "key to empty array: arrays of non-permitted scalar do not pass" do + [[Object.new], [[]], [[1]], [{}], [{ id: "1" }]].each do |non_permitted_scalar| + params = ActionController::Parameters.new(id: non_permitted_scalar) + assert_raises(ActionController::ParameterMissing) do + params.expect(id: []) + end + assert_raises(ActionController::ExpectedParameterMissing) do + params.expect!(id: []) + end + end + end +end diff --git a/actionpack/test/controller/parameters/parameters_permit_test.rb b/actionpack/test/controller/parameters/parameters_permit_test.rb index 8920914af1cb6..97acfd46529b9 100644 --- a/actionpack/test/controller/parameters/parameters_permit_test.rb +++ b/actionpack/test/controller/parameters/parameters_permit_test.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_dispatch/http/upload" require "action_controller/metal/strong_parameters" class ParametersPermitTest < ActiveSupport::TestCase def assert_filtered_out(params, key) - assert !params.has_key?(key), "key #{key.inspect} has not been filtered out" + assert_not params.has_key?(key), "key #{key.inspect} has not been filtered out" end setup do @@ -51,13 +53,13 @@ def walk_permitted(params) test "if nothing is permitted, the hash becomes empty" do params = ActionController::Parameters.new(id: "1234") permitted = params.permit - assert permitted.permitted? - assert permitted.empty? + assert_predicate permitted, :permitted? + assert_empty permitted end test "key: permitted scalar values" do values = ["a", :a, nil] - values += [0, 1.0, 2**128, BigDecimal.new(1)] + values += [0, 1.0, 2**128, BigDecimal(1)] values += [true, false] values += [Date.today, Time.now, DateTime.now] values += [STDOUT, StringIO.new, ActionDispatch::Http::UploadedFile.new(tempfile: __FILE__), @@ -134,7 +136,7 @@ def walk_permitted(params) test "key: it is not assigned if not present in params" do params = ActionController::Parameters.new(name: "Joe") permitted = params.permit(:id) - assert !permitted.has_key?(:id) + assert_not permitted.has_key?(:id) end test "key to empty array: empty arrays pass" do @@ -188,7 +190,8 @@ def walk_permitted(params) tabstops: [4, 8, 12, 16], suspicious: [true, Object.new, false, /yo!/], dubious: [{ a: :a, b: /wtf!/ }, { c: :c }], - injected: Object.new + injected: Object.new, + nested: [[1, 2], [3, 4]] }, hacked: 1 # not a hash ) @@ -203,6 +206,7 @@ def walk_permitted(params) assert_equal [true, false], permitted[:preferences][:suspicious] assert_equal :a, permitted[:preferences][:dubious][0][:a] assert_equal :c, permitted[:preferences][:dubious][1][:c] + assert_equal [[1, 2], [3, 4]], permitted[:preferences][:nested] assert_filtered_out permitted[:preferences][:dubious][0], :b assert_filtered_out permitted[:preferences], :injected @@ -225,7 +229,7 @@ def walk_permitted(params) test "hashes in array values get wrapped" do params = ActionController::Parameters.new(foo: [{}, {}]) params[:foo].each do |hash| - assert !hash.permitted? + assert_not_predicate hash, :permitted? end end @@ -248,15 +252,28 @@ def walk_permitted(params) permitted = params.permit(users: [:id]) permitted[:users] << { injected: 1 } - assert_not permitted[:users].last.permitted? + assert_not_predicate permitted[:users].last, :permitted? + end + + test "grow until set rehashes" do + params = ActionController::Parameters.new(users: [{ id: 1 }]) + + permitted = params.permit(users: [:id]) + permitted[:users] << { injected: 1 } + 20.times { |i| + list = ["foo#{i}"] + permitted[:xx] = list + assert_equal permitted[:xx], list + } + assert_not_predicate permitted[:users].last, :permitted? end - test "fetch doesnt raise ParameterMissing exception if there is a default" do + test "fetch doesn't raise ParameterMissing exception if there is a default" do assert_equal "monkey", @params.fetch(:foo, "monkey") assert_equal "monkey", @params.fetch(:foo) { "monkey" } end - test "fetch doesnt raise ParameterMissing exception if there is a default that is nil" do + test "fetch doesn't raise ParameterMissing exception if there is a default that is nil" do assert_nil @params.fetch(:foo, nil) assert_nil @params.fetch(:foo) { nil } end @@ -270,12 +287,12 @@ def walk_permitted(params) end test "not permitted is sticky beyond merges" do - assert !@params.merge(a: "b").permitted? + assert_not_predicate @params.merge(a: "b"), :permitted? end test "permitted is sticky beyond merges" do @params.permit! - assert @params.merge(a: "b").permitted? + assert_predicate @params.merge(a: "b"), :permitted? end test "merge with parameters" do @@ -286,12 +303,12 @@ def walk_permitted(params) end test "not permitted is sticky beyond merge!" do - assert_not @params.merge!(a: "b").permitted? + assert_not_predicate @params.merge!(a: "b"), :permitted? end test "permitted is sticky beyond merge!" do @params.permit! - assert @params.merge!(a: "b").permitted? + assert_predicate @params.merge!(a: "b"), :permitted? end test "merge! with parameters" do @@ -302,6 +319,103 @@ def walk_permitted(params) assert_equal "32", @params[:person][:age] end + test "not permitted is sticky beyond deep merges" do + assert_not_predicate @params.deep_merge(a: "b"), :permitted? + end + + test "permitted is sticky beyond deep merges" do + @params.permit! + assert_predicate @params.deep_merge(a: "b"), :permitted? + end + + test "not permitted is sticky beyond deep_merge!" do + assert_not_predicate @params.deep_merge!(a: "b"), :permitted? + end + + test "permitted is sticky beyond deep_merge!" do + @params.permit! + assert_predicate @params.deep_merge!(a: "b"), :permitted? + end + + test "deep_merge with other Hash" do + first, last = @params.dig(:person, :name).values_at(:first, :last) + merged_params = @params.deep_merge(person: { name: { last: "A." } }) + + assert_equal first, merged_params.dig(:person, :name, :first) + assert_not_equal last, merged_params.dig(:person, :name, :last) + assert_equal "A.", merged_params.dig(:person, :name, :last) + end + + test "deep_merge! with other Hash" do + first, last = @params.dig(:person, :name).values_at(:first, :last) + @params.deep_merge!(person: { name: { last: "A." } }) + + assert_equal first, @params.dig(:person, :name, :first) + assert_not_equal last, @params.dig(:person, :name, :last) + assert_equal "A.", @params.dig(:person, :name, :last) + end + + test "deep_merge with other Parameters" do + first, last = @params.dig(:person, :name).values_at(:first, :last) + other_params = ActionController::Parameters.new(person: { name: { last: "A." } }).permit! + merged_params = @params.deep_merge(other_params) + + assert_equal first, merged_params.dig(:person, :name, :first) + assert_not_equal last, merged_params.dig(:person, :name, :last) + assert_equal "A.", merged_params.dig(:person, :name, :last) + end + + test "deep_merge! with other Parameters" do + first, last = @params.dig(:person, :name).values_at(:first, :last) + other_params = ActionController::Parameters.new(person: { name: { last: "A." } }).permit! + @params.deep_merge!(other_params) + + assert_equal first, @params.dig(:person, :name, :first) + assert_not_equal last, @params.dig(:person, :name, :last) + assert_equal "A.", @params.dig(:person, :name, :last) + end + + test "#reverse_merge with parameters" do + default_params = ActionController::Parameters.new(id: "1234", person: {}).permit! + merged_params = @params.reverse_merge(default_params) + + assert_equal "1234", merged_params[:id] + assert_not_predicate merged_params[:person], :empty? + end + + test "#with_defaults is an alias of reverse_merge" do + default_params = ActionController::Parameters.new(id: "1234", person: {}).permit! + merged_params = @params.with_defaults(default_params) + + assert_equal "1234", merged_params[:id] + assert_not_predicate merged_params[:person], :empty? + end + + test "not permitted is sticky beyond reverse_merge" do + assert_not_predicate @params.reverse_merge(a: "b"), :permitted? + end + + test "permitted is sticky beyond reverse_merge" do + @params.permit! + assert_predicate @params.reverse_merge(a: "b"), :permitted? + end + + test "#reverse_merge! with parameters" do + default_params = ActionController::Parameters.new(id: "1234", person: {}).permit! + @params.reverse_merge!(default_params) + + assert_equal "1234", @params[:id] + assert_not_predicate @params[:person], :empty? + end + + test "#with_defaults! is an alias of reverse_merge!" do + default_params = ActionController::Parameters.new(id: "1234", person: {}).permit! + @params.with_defaults!(default_params) + + assert_equal "1234", @params[:id] + assert_not_predicate @params[:person], :empty? + end + test "modifying the parameters" do @params[:person][:hometown] = "Chicago" @params[:person][:family] = { brother: "Jonas" } @@ -310,83 +424,111 @@ def walk_permitted(params) assert_equal "Jonas", @params[:person][:family][:brother] end - test "permit is recursive" do + test "permit! is recursive" do + @params[:nested_array] = [[{ x: 2, y: 3 }, { x: 21, y: 42 }]] @params.permit! - assert @params.permitted? - assert @params[:person].permitted? - assert @params[:person][:name].permitted? - assert @params[:person][:addresses][0].permitted? + assert_predicate @params, :permitted? + assert_predicate @params[:person], :permitted? + assert_predicate @params[:person][:name], :permitted? + assert_predicate @params[:person][:addresses][0], :permitted? + assert_predicate @params[:nested_array][0][0], :permitted? + assert_predicate @params[:nested_array][0][1], :permitted? end test "permitted takes a default value when Parameters.permit_all_parameters is set" do - begin - ActionController::Parameters.permit_all_parameters = true - params = ActionController::Parameters.new(person: { - age: "32", name: { first: "David", last: "Heinemeier Hansson" } - }) - - assert params.slice(:person).permitted? - assert params[:person][:name].permitted? - ensure - ActionController::Parameters.permit_all_parameters = false - end + ActionController::Parameters.permit_all_parameters = true + params = ActionController::Parameters.new(person: { + age: "32", name: { first: "David", last: "Heinemeier Hansson" } + }) + + assert_predicate params.slice(:person), :permitted? + assert_predicate params[:person][:name], :permitted? + ensure + ActionController::Parameters.permit_all_parameters = false end test "permitting parameters as an array" do assert_equal "32", @params[:person].permit([ :age ])[:age] end - test "to_h returns empty hash on unpermitted params" do - assert @params.to_h.is_a? ActiveSupport::HashWithIndifferentAccess - assert_not @params.to_h.is_a? ActionController::Parameters - assert @params.to_h.empty? + test "to_h raises UnfilteredParameters on unfiltered params" do + assert_raises(ActionController::UnfilteredParameters) do + @params.to_h + end end test "to_h returns converted hash on permitted params" do @params.permit! - assert @params.to_h.is_a? ActiveSupport::HashWithIndifferentAccess - assert_not @params.to_h.is_a? ActionController::Parameters + assert_instance_of ActiveSupport::HashWithIndifferentAccess, @params.to_h + assert_not_kind_of ActionController::Parameters, @params.to_h end test "to_h returns converted hash when .permit_all_parameters is set" do - begin - ActionController::Parameters.permit_all_parameters = true - params = ActionController::Parameters.new(crab: "Senjougahara Hitagi") - - assert params.to_h.is_a? ActiveSupport::HashWithIndifferentAccess - assert_not @params.to_h.is_a? ActionController::Parameters - assert_equal({ "crab" => "Senjougahara Hitagi" }, params.to_h) - ensure - ActionController::Parameters.permit_all_parameters = false + ActionController::Parameters.permit_all_parameters = true + params = ActionController::Parameters.new(crab: "Senjougahara Hitagi") + + assert_instance_of ActiveSupport::HashWithIndifferentAccess, params.to_h + assert_not_kind_of ActionController::Parameters, params.to_h + assert_equal({ "crab" => "Senjougahara Hitagi" }, params.to_h) + ensure + ActionController::Parameters.permit_all_parameters = false + end + + test "to_hash raises UnfilteredParameters on unfiltered params" do + assert_raises(ActionController::UnfilteredParameters) do + @params.to_hash end end - test "to_h returns always permitted parameter on unpermitted params" do - params = ActionController::Parameters.new( - controller: "users", - action: "create", - user: { - name: "Sengoku Nadeko" - } - ) + test "to_hash returns converted hash on permitted params" do + @params.permit! + + assert_instance_of Hash, @params.to_hash + assert_not_kind_of ActionController::Parameters, @params.to_hash + end + + test "parameters can be implicit converted to Hash" do + params = ActionController::Parameters.new + params.permit! + + assert_equal({ a: 1 }, { a: 1 }.merge!(params)) + end - assert_equal({ "controller" => "users", "action" => "create" }, params.to_h) + test "to_hash returns converted hash when .permit_all_parameters is set" do + ActionController::Parameters.permit_all_parameters = true + params = ActionController::Parameters.new(crab: "Senjougahara Hitagi") + + assert_instance_of Hash, params.to_hash + assert_not_kind_of ActionController::Parameters, params.to_hash + assert_equal({ "crab" => "Senjougahara Hitagi" }, params.to_hash) + ensure + ActionController::Parameters.permit_all_parameters = false end test "to_unsafe_h returns unfiltered params" do - assert @params.to_unsafe_h.is_a? ActiveSupport::HashWithIndifferentAccess - assert_not @params.to_unsafe_h.is_a? ActionController::Parameters + assert_instance_of ActiveSupport::HashWithIndifferentAccess, @params.to_unsafe_h + assert_not_kind_of ActionController::Parameters, @params.to_unsafe_h end test "to_unsafe_h returns unfiltered params even after accessing few keys" do params = ActionController::Parameters.new("f" => { "language_facet" => ["Tibetan"] }) expected = { "f" => { "language_facet" => ["Tibetan"] } } - assert params["f"].is_a? ActionController::Parameters + assert_instance_of ActionController::Parameters, params["f"] assert_equal expected, params.to_unsafe_h end + test "to_unsafe_h does not mutate the parameters" do + params = ActionController::Parameters.new("f" => { "language_facet" => ["Tibetan"] }) + params[:f] + + params.to_unsafe_h + + assert_not_predicate params, :permitted? + assert_not_predicate params[:f], :permitted? + end + test "to_h only deep dups Ruby collections" do company = Class.new do attr_reader :dupped @@ -425,9 +567,9 @@ def dup; @dupped = true; end params = ActionController::Parameters.new(foo: "bar") assert params.permit(:foo).has_key?(:foo) - refute params.permit(foo: []).has_key?(:foo) - refute params.permit(foo: [:bar]).has_key?(:foo) - refute params.permit(foo: :bar).has_key?(:foo) + assert_not params.permit(foo: []).has_key?(:foo) + assert_not params.permit(foo: [:bar]).has_key?(:foo) + assert_not params.permit(foo: :bar).has_key?(:foo) end test "#permitted? is false by default" do @@ -435,4 +577,10 @@ def dup; @dupped = true; end assert_equal false, params.permitted? end + + test "only String and Symbol keys are allowed" do + assert_raises(ActionController::InvalidParameterKey) do + ActionController::Parameters.new({ foo: 1 } => :bar) + end + end end diff --git a/actionpack/test/controller/parameters/raise_on_unpermitted_params_test.rb b/actionpack/test/controller/parameters/raise_on_unpermitted_params_test.rb index 8fab7b28e904b..3f9f280f7399c 100644 --- a/actionpack/test/controller/parameters/raise_on_unpermitted_params_test.rb +++ b/actionpack/test/controller/parameters/raise_on_unpermitted_params_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller/metal/strong_parameters" @@ -22,10 +24,34 @@ def teardown test "raises on unexpected nested params" do params = ActionController::Parameters.new( - book: { pages: 65, title: "Green Cats and where to find then." }) + book: { pages: 65, title: "Green Cats and where to find them." }) assert_raises(ActionController::UnpermittedParameters) do params.permit(book: [:pages]) end end + + test "expect never raises on unexpected params" do + params = ActionController::Parameters.new( + id: 1, + book: { pages: 65, title: "Green Cats and where to find them." }) + + assert_nothing_raised do + params.expect(book: [:pages]) + params.expect(book: [:pages, :title]) + params.expect(:id) + end + end + + test "expect! never raises on unexpected params" do + params = ActionController::Parameters.new( + id: 1, + book: { pages: 65, title: "Green Cats and where to find them." }) + + assert_nothing_raised do + params.expect!(book: [:pages]) + params.expect!(book: [:pages, :title]) + params.expect!(:id) + end + end end diff --git a/actionpack/test/controller/parameters/serialization_test.rb b/actionpack/test/controller/parameters/serialization_test.rb index 6fba2fde91886..abc83bfe48c23 100644 --- a/actionpack/test/controller/parameters/serialization_test.rb +++ b/actionpack/test/controller/parameters/serialization_test.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_controller/metal/strong_parameters" -require "active_support/core_ext/string/strip" class ParametersSerializationTest < ActiveSupport::TestCase setup do @@ -12,7 +13,7 @@ class ParametersSerializationTest < ActiveSupport::TestCase ActionController::Parameters.permit_all_parameters = @old_permitted_parameters end - test "yaml serialization" do + test "YAML serialization" do params = ActionController::Parameters.new(key: :value) yaml_dump = YAML.dump(params) assert_match("--- !ruby/object:ActionController::Parameters", yaml_dump) @@ -20,34 +21,37 @@ class ParametersSerializationTest < ActiveSupport::TestCase assert_match("permitted: false", yaml_dump) end - test "yaml deserialization" do + test "YAML deserialization" do params = ActionController::Parameters.new(key: :value) - roundtripped = YAML.load(YAML.dump(params)) + payload = YAML.dump(params) + roundtripped = YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(payload) : YAML.load(payload) assert_equal params, roundtripped - assert_not roundtripped.permitted? + assert_not_predicate roundtripped, :permitted? end - test "yaml backwardscompatible with psych 2.0.8 format" do - params = YAML.load <<-end_of_yaml.strip_heredoc + test "YAML backwardscompatible with psych 2.0.8 format" do + payload = <<~end_of_yaml --- !ruby/hash:ActionController::Parameters key: :value end_of_yaml + params = YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(payload) : YAML.load(payload) assert_equal :value, params[:key] - assert_not params.permitted? + assert_not_predicate params, :permitted? end - test "yaml backwardscompatible with psych 2.0.9+ format" do - params = YAML.load(<<-end_of_yaml.strip_heredoc) + test "YAML backwardscompatible with psych 2.0.9+ format" do + payload = <<~end_of_yaml --- !ruby/hash-with-ivars:ActionController::Parameters elements: key: :value ivars: :@permitted: false end_of_yaml + params = YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(payload) : YAML.load(payload) assert_equal :value, params[:key] - assert_not params.permitted? + assert_not_predicate params, :permitted? end end diff --git a/actionpack/test/controller/parameters_integration_test.rb b/actionpack/test/controller/parameters_integration_test.rb new file mode 100644 index 0000000000000..2b6d85aebda6e --- /dev/null +++ b/actionpack/test/controller/parameters_integration_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class IntegrationController < ActionController::Base + def yaml_params + render plain: params.to_yaml + end + + def permit_params + params.permit( + key1: {} + ) + + render plain: "Home" + end +end + +class ActionControllerParametersIntegrationTest < ActionController::TestCase + tests IntegrationController + + test "parameters can be serialized as YAML" do + post :yaml_params, params: { person: { name: "Mjallo!" } } + expected = <<~YAML +--- !ruby/object:ActionController::Parameters +parameters: !ruby/hash:ActiveSupport::HashWithIndifferentAccess + person: !ruby/hash:ActiveSupport::HashWithIndifferentAccess + name: Mjallo! + controller: integration + action: yaml_params +permitted: false + YAML + assert_equal expected, response.body + end + + # Ensure no deprecation warning from comparing AC::Parameters against Hash + # See https://github.com/rails/rails/issues/44940 + test "identical arrays can be permitted" do + params = { + key1: { + a: [{ same_key: { c: 1 } }], + b: [{ same_key: { c: 1 } }] + } + } + + assert_not_deprecated(ActionController.deprecator) do + post :permit_params, params: params + end + assert_response :ok + end +end diff --git a/actionpack/test/controller/params_parse_test.rb b/actionpack/test/controller/params_parse_test.rb new file mode 100644 index 0000000000000..091b567473ecb --- /dev/null +++ b/actionpack/test/controller/params_parse_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ParamsParseTest < ActionController::TestCase + class UsersController < ActionController::Base + def create + head :ok + end + end + + tests UsersController + + def test_parse_error_logged_once + log_output = capture_log_output do + post :create, body: "{", as: :json + end + assert_equal <<~LOG, log_output + Error occurred while parsing request parameters. + Contents: + + { + LOG + end + + private + def capture_log_output + output = StringIO.new + request.set_header "action_dispatch.logger", ActiveSupport::Logger.new(output) + yield + output.string + end +end diff --git a/actionpack/test/controller/params_wrapper_test.rb b/actionpack/test/controller/params_wrapper_test.rb index faa57c4559722..da3e89defaee6 100644 --- a/actionpack/test/controller/params_wrapper_test.rb +++ b/actionpack/test/controller/params_wrapper_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module Admin; class User; end; end @@ -32,6 +34,10 @@ class User def self.attribute_names [] end + + def self.stored_attributes + { settings: [:color, :size] } + end end class Person @@ -62,6 +68,17 @@ def test_derived_name_from_controller end end + def test_store_accessors_wrapped + assert_called(User, :attribute_names, times: 2, returns: ["username"]) do + with_default_wrapper_options do + @request.env["CONTENT_TYPE"] = "application/json" + post :parse, params: { "username" => "sikachu", "color" => "blue", "size" => "large" } + assert_parameters("username" => "sikachu", "color" => "blue", "size" => "large", + "user" => { "username" => "sikachu", "color" => "blue", "size" => "large" }) + end + end + end + def test_specify_wrapper_name with_default_wrapper_options do UsersController.wrap_parameters :person @@ -155,6 +172,14 @@ def test_no_double_wrap_if_key_exists end end + def test_no_double_wrap_if_key_exists_and_value_is_nil + with_default_wrapper_options do + @request.env["CONTENT_TYPE"] = "application/json" + post :parse, params: { "user" => nil } + assert_parameters("user" => nil) + end + end + def test_nested_params with_default_wrapper_options do @request.env["CONTENT_TYPE"] = "application/json" @@ -203,6 +228,14 @@ def test_preserves_query_string_params end end + def test_preserves_query_string_params_in_filtered_params + with_default_wrapper_options do + @request.env["CONTENT_TYPE"] = "application/json" + get :parse, params: { "user" => { "username" => "nixon" } } + assert_equal({ "controller" => "params_wrapper_test/users", "action" => "parse", "user" => { "username" => "nixon" } }, @request.filtered_parameters) + end + end + def test_empty_parameter_set with_default_wrapper_options do @request.env["CONTENT_TYPE"] = "application/json" @@ -222,6 +255,23 @@ def test_handles_empty_content_type assert_equal "", @response.body end end + + def test_derived_wrapped_keys_from_nested_attributes + assert_not_respond_to User, :nested_attributes_options + User.define_singleton_method(:nested_attributes_options) do + { person: {} } + end + + assert_called(User, :attribute_names, times: 2, returns: ["username"]) do + with_default_wrapper_options do + @request.env["CONTENT_TYPE"] = "application/json" + post :parse, params: { "username" => "sikachu", "person_attributes" => { "title" => "Developer" } } + assert_parameters("username" => "sikachu", "person_attributes" => { "title" => "Developer" }, "user" => { "username" => "sikachu", "person_attributes" => { "title" => "Developer" } }) + end + end + ensure + User.singleton_class.undef_method(:nested_attributes_options) + end end class NamespacedParamsWrapperTest < ActionController::TestCase @@ -229,7 +279,7 @@ class NamespacedParamsWrapperTest < ActionController::TestCase module Admin module Users - class UsersController < ActionController::Base; + class UsersController < ActionController::Base class << self attr_accessor :last_parameters end @@ -246,6 +296,10 @@ class SampleOne def self.attribute_names ["username"] end + + def self.attribute_aliases + { "nick" => "username" } + end end class SampleTwo @@ -281,6 +335,19 @@ def test_namespace_lookup_from_model end end + def test_namespace_lookup_from_model_alias + Admin.const_set(:User, Class.new(SampleOne)) + begin + with_default_wrapper_options do + @request.env["CONTENT_TYPE"] = "application/json" + post :parse, params: { "nick" => "sikachu", "title" => "Developer" } + assert_parameters({ "nick" => "sikachu", "title" => "Developer", "user" => { "nick" => "sikachu" } }) + end + ensure + Admin.send :remove_const, :User + end + end + def test_hierarchy_namespace_lookup_from_model Object.const_set(:User, Class.new(SampleTwo)) begin @@ -364,7 +431,6 @@ def test_uses_model_attribute_names_with_irregular_inflection end private - def with_dup original = ActiveSupport::Inflector::Inflections.instance_variable_get(:@__instance__)[:en] ActiveSupport::Inflector::Inflections.instance_variable_set(:@__instance__, en: original.dup) diff --git a/actionpack/test/controller/permitted_params_test.rb b/actionpack/test/controller/permitted_params_test.rb index 6205a09816430..caac88ffb2150 100644 --- a/actionpack/test/controller/permitted_params_test.rb +++ b/actionpack/test/controller/permitted_params_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class PeopleController < ActionController::Base diff --git a/actionpack/test/controller/rate_limiting_test.rb b/actionpack/test/controller/rate_limiting_test.rb new file mode 100644 index 0000000000000..a6bb3bbfdf0a0 --- /dev/null +++ b/actionpack/test/controller/rate_limiting_test.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class RateLimitedController < ActionController::Base + self.cache_store = ActiveSupport::Cache::MemoryStore.new + rate_limit to: 2, within: 2.seconds, only: :limited + rate_limit to: 5, within: 1.minute, name: "long-term", only: :limited + + def limited + head :ok + end + + rate_limit to: 2, within: 2.seconds, by: -> { params[:rate_limit_key] }, with: -> { head :forbidden }, only: :limited_with + 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 + tests RateLimitedController + + setup do + RateLimitedController.cache_store.clear + end + + test "exceeding basic limit" do + get :limited + get :limited + assert_response :ok + + assert_raises ActionController::TooManyRequests do + get :limited + end + end + + test "notification on limit action" do + get :limited + 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 3.seconds + get :limited + get :limited + assert_response :ok + + travel 3.seconds + get :limited + assert_raises ActionController::TooManyRequests do + get :limited + end + end + + test "limit resets after time" do + get :limited + get :limited + assert_response :ok + + travel_to Time.now + 3.seconds do + get :limited + assert_response :ok + end + end + + test "limit by callable" do + get :limited_with + get :limited_with + get :limited_with + assert_response :forbidden + + get :limited_with, params: { rate_limit_key: "other" } + assert_response :ok + end + + 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 e4e968dfdbe82..a198528a6d4c4 100644 --- a/actionpack/test/controller/redirect_test.rb +++ b/actionpack/test/controller/redirect_test.rb @@ -1,8 +1,17 @@ +# frozen_string_literal: true + require "abstract_unit" +require "active_support/log_subscriber/test_helper" class Workshop extend ActiveModel::Naming include ActiveModel::Conversion + + OUT_OF_SCOPE_BLOCK = proc do + raise "Not executed in controller's context" unless RedirectController === self + request.original_url + end + attr_accessor :id def initialize(id) @@ -21,8 +30,8 @@ def to_s class RedirectController < ActionController::Base # empty method not used anywhere to ensure methods like # `status` and `location` aren't called on `redirect_to` calls - def status; render plain: "called status"; end - def location; render plain: "called location"; end + def status; raise "Should not be called!"; end + def location; raise "Should not be called!"; end def simple_redirect redirect_to action: "hello_world" @@ -33,7 +42,7 @@ def redirect_with_status end def redirect_with_status_hash - redirect_to({ action: "hello_world" }, status: 301) + redirect_to({ action: "hello_world" }, { status: 301 }) end def redirect_with_protocol @@ -57,6 +66,58 @@ def relative_url_redirect_with_status_hash end def redirect_back_with_status + redirect_back_or_to "/things/stuff", status: 307 + end + + def redirect_back_with_status_and_fallback_location_to_another_host + redirect_back_or_to "http://www.rubyonrails.org/", status: 307 + end + + def safe_redirect_back_with_status + redirect_back_or_to "/things/stuff", status: 307, allow_other_host: false + end + + def safe_redirect_back_with_status_and_fallback_location_to_another_host + redirect_back_or_to "http://www.rubyonrails.org/", status: 307, allow_other_host: false + end + + def safe_redirect_to_root + redirect_to url_from("/") + end + + def unsafe_redirect + redirect_to "http://www.rubyonrails.org/" + end + + def unsafe_redirect_back + redirect_back_or_to "http://www.rubyonrails.org/" + end + + def unsafe_redirect_malformed + redirect_to "http:///www.rubyonrails.org/" + end + + def unsafe_redirect_protocol_relative_double_slash + redirect_to "//www.rubyonrails.org/" + end + + def unsafe_redirect_protocol_relative_triple_slash + redirect_to "///www.rubyonrails.org/" + end + + def unsafe_redirect_with_illegal_http_header_value_character + redirect_to "javascript:alert(document.domain)\b", allow_other_host: true + end + + def only_path_redirect + redirect_to action: "other_host", only_path: true + end + + def safe_redirect_with_fallback + redirect_to url_from(params[:redirect_url]) || "/fallback" + end + + def redirect_back_with_explicit_fallback_kwarg redirect_back(fallback_location: "/things/stuff", status: 307) end @@ -72,6 +133,16 @@ def redirect_to_url redirect_to "http://www.rubyonrails.org/" end + def redirect_to_url_with_stringlike + stringlike = Object.new + + def stringlike.to_str + "http://www.rubyonrails.org/" + end + + redirect_to stringlike + end + def redirect_to_url_with_unescaped_query_string redirect_to "http://example.com/query?status=new" end @@ -84,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 @@ -96,6 +179,14 @@ def redirect_to_nil redirect_to nil end + def redirect_to_polymorphic + redirect_to [:internal, Workshop.new(5)] + end + + def redirect_to_polymorphic_string_args + redirect_to ["internal", Workshop.new(5)] + end + def redirect_to_params redirect_to ActionController::Parameters.new(status: 200, protocol: "javascript", f: "%0Aeval(name)") end @@ -113,6 +204,10 @@ def redirect_to_with_block_and_options redirect_to proc { { action: "hello_world" } } end + def redirect_to_out_of_scope_block + redirect_to Workshop::OUT_OF_SCOPE_BLOCK + end + def redirect_with_header_break redirect_to "/lol\r\nwat" end @@ -121,10 +216,16 @@ def redirect_with_null_bytes redirect_to "\000/lol\r\nwat" end + def redirect_to_external_with_rescue + redirect_to "http://www.rubyonrails.org/", allow_other_host: false + rescue ActionController::Redirecting::UnsafeRedirectError + render plain: "caught error" + end + def rescue_errors(e) raise e end private - def dashbord_url(id, message) + def dashboard_url(id, message) url_for action: "dashboard", params: { "id" => id, "message" => message } end end @@ -198,6 +299,13 @@ def test_relative_url_redirect_with_status_hash assert_equal "http://test.host/things/stuff", redirect_to_url end + def test_relative_url_redirect_host_with_port + request.host = "test.host:1234" + get :relative_url_redirect_with_status + assert_response 302 + assert_equal "http://test.host:1234/things/stuff", redirect_to_url + end + def test_simple_redirect_using_options get :host_redirect assert_response :redirect @@ -222,6 +330,12 @@ def test_redirect_to_url assert_redirected_to "http://www.rubyonrails.org/" end + def test_redirect_to_url_with_stringlike + get :redirect_to_url_with_stringlike + assert_response :redirect + assert_redirected_to "http://www.rubyonrails.org/" + end + def test_redirect_to_url_with_unescaped_query_string get :redirect_to_url_with_unescaped_query_string assert_response :redirect @@ -234,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 @@ -257,12 +389,66 @@ def test_redirect_back_with_no_referer assert_equal "http://test.host/things/stuff", redirect_to_url end + def test_redirect_back_with_no_referer_redirects_to_another_host + get :redirect_back_with_status_and_fallback_location_to_another_host + + assert_response 307 + assert_equal "http://www.rubyonrails.org/", redirect_to_url + end + + def test_safe_redirect_back_from_other_host + @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 + + def test_safe_redirect_back_from_the_same_host + referer = "http://test.host/coming/from" + @request.env["HTTP_REFERER"] = referer + get :safe_redirect_back_with_status + + assert_response 307 + assert_equal referer, redirect_to_url + end + + def test_safe_redirect_back_with_no_referer + get :safe_redirect_back_with_status + + assert_response 307 + assert_equal "http://test.host/things/stuff", redirect_to_url + end + + def test_safe_redirect_back_with_no_referer_redirects_to_another_host + get :safe_redirect_back_with_status_and_fallback_location_to_another_host + + assert_response 307 + assert_equal "http://www.rubyonrails.org/", redirect_to_url + end + + def test_safe_redirect_to_root + get :safe_redirect_to_root + + assert_equal "http://test.host/", redirect_to_url + end + + def test_redirect_back_with_explicit_fallback_kwarg + referer = "http://www.example.com/coming/from" + @request.env["HTTP_REFERER"] = referer + + get :redirect_back_with_explicit_fallback_kwarg + + assert_response 307 + assert_equal referer, redirect_to_url + end + def test_redirect_to_record with_routing do |set| set.draw do resources :workshops - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -277,6 +463,43 @@ def test_redirect_to_record end end + def test_polymorphic_redirect + with_routing do |set| + set.draw do + namespace :internal do + resources :workshops + end + + ActionDispatch.deprecator.silence do + get ":controller/:action" + end + end + + get :redirect_to_polymorphic + assert_equal "http://test.host/internal/workshops/5", redirect_to_url + assert_redirected_to [:internal, Workshop.new(5)] + end + end + + def test_polymorphic_redirect_with_string_args + with_routing do |set| + set.draw do + namespace :internal do + resources :workshops + end + + ActionDispatch.deprecator.silence do + get ":controller/:action" + end + end + + error = assert_raises(ArgumentError) do + get :redirect_to_polymorphic_string_args + end + assert_equal("Please use symbols for polymorphic route arguments.", error.message) + end + end + def test_redirect_to_nil error = assert_raise(ActionController::ActionControllerError) do get :redirect_to_nil @@ -285,10 +508,10 @@ def test_redirect_to_nil end def test_redirect_to_params - error = assert_raise(ArgumentError) do + error = assert_raise(ActionController::UnfilteredParameters) do get :redirect_to_params end - assert_equal ActionDispatch::Routing::INSECURE_URL_PARAMETERS_MESSAGE, error.message + assert_equal "unable to convert unpermitted parameters to hash", error.message end def test_redirect_to_with_block @@ -303,10 +526,16 @@ def test_redirect_to_with_block_and_assigns assert_redirected_to "http://www.rubyonrails.org/" end + def test_redirect_to_out_of_scope_block + get :redirect_to_out_of_scope_block + assert_response :redirect + assert_redirected_to "http://test.host/redirect/redirect_to_out_of_scope_block" + end + def test_redirect_to_with_block_and_accepted_options with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -317,6 +546,487 @@ def test_redirect_to_with_block_and_accepted_options assert_redirected_to "http://test.host/redirect/hello_world" end end + + def test_unsafe_redirect + with_raise_on_open_redirects do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :unsafe_redirect + end + + assert_equal "Unsafe redirect to \"http://www.rubyonrails.org/\", pass allow_other_host: true to redirect anyway.", error.message + end + end + + def test_unsafe_redirect_back + with_raise_on_open_redirects do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :unsafe_redirect_back + end + + assert_equal "Unsafe redirect to \"http://www.rubyonrails.org/\", pass allow_other_host: true to redirect anyway.", error.message + end + end + + def test_unsafe_redirect_with_malformed_url + with_raise_on_open_redirects do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :unsafe_redirect_malformed + end + + assert_equal "Unsafe redirect to \"http:///www.rubyonrails.org/\", pass allow_other_host: true to redirect anyway.", error.message + end + end + + def test_unsafe_redirect_with_protocol_relative_double_slash_url + with_raise_on_open_redirects do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :unsafe_redirect_protocol_relative_double_slash + end + + assert_equal "Unsafe redirect to \"//www.rubyonrails.org/\", pass allow_other_host: true to redirect anyway.", error.message + end + end + + def test_unsafe_redirect_with_protocol_relative_triple_slash_url + with_raise_on_open_redirects do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :unsafe_redirect_protocol_relative_triple_slash + end + + assert_equal "Unsafe redirect to \"///www.rubyonrails.org/\", pass allow_other_host: true to redirect anyway.", error.message + end + end + + def test_unsafe_redirect_with_illegal_http_header_value_character + with_raise_on_open_redirects do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :unsafe_redirect_with_illegal_http_header_value_character + end + + msg = "The redirect URL javascript:alert(document.domain)\b contains one or more illegal HTTP header field character. " \ + "Set of legal characters defined in https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6" + + assert_equal msg, error.message + end + end + + def test_only_path_redirect + with_raise_on_open_redirects do + get :only_path_redirect + assert_response :redirect + assert_redirected_to "/redirect/other_host" + end + end + + def test_url_from + with_raise_on_open_redirects do + get :safe_redirect_with_fallback, params: { redirect_url: "http://test.host/app" } + assert_response :redirect + assert_redirected_to "http://test.host/app" + end + end + + def test_url_from_fallback + with_raise_on_open_redirects do + get :safe_redirect_with_fallback, params: { redirect_url: "http://www.rubyonrails.org/" } + assert_response :redirect + assert_redirected_to "http://test.host/fallback" + + get :safe_redirect_with_fallback, params: { redirect_url: "" } + assert_response :redirect + assert_redirected_to "http://test.host/fallback" + end + end + + def test_redirect_to_instrumentation + notification = assert_notification("redirect_to.action_controller", status: 302, location: "http://test.host/redirect/hello_world") do + get :simple_redirect + end + + assert_kind_of ActionDispatch::Request, notification.payload[:request] + end + + def test_redirect_to_external_with_rescue + get :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 + yield + 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_js_test.rb b/actionpack/test/controller/render_js_test.rb index 290218d4a2732..da8f6e80624a6 100644 --- a/actionpack/test/controller/render_js_test.rb +++ b/actionpack/test/controller/render_js_test.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true + require "abstract_unit" require "controller/fake_models" -require "pathname" class RenderJSTest < ActionController::TestCase class TestController < ActionController::Base @@ -24,7 +25,7 @@ def show_partial def test_render_vanilla_js get :render_vanilla_js_hello, xhr: true assert_equal "alert('hello')", @response.body - assert_equal "text/javascript", @response.content_type + assert_equal "text/javascript", @response.media_type end def test_should_render_js_partial diff --git a/actionpack/test/controller/render_json_test.rb b/actionpack/test/controller/render_json_test.rb index 79552ec8f1cb7..900ed069e4feb 100644 --- a/actionpack/test/controller/render_json_test.rb +++ b/actionpack/test/controller/render_json_test.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require "abstract_unit" require "controller/fake_models" require "active_support/logger" -require "pathname" +require "active_support/core_ext/object/with" class RenderJsonTest < ActionController::TestCase class JsonRenderable @@ -16,6 +18,12 @@ def to_json(options = {}) end end + class InspectOptions + def as_json(options = {}) + { options: options } + end + end + class TestController < ActionController::Base protect_from_forgery @@ -27,10 +35,6 @@ def render_json_nil render json: nil end - def render_json_render_to_string - render plain: render_to_string(json: "[]") - end - def render_json_hello_world render json: ActiveSupport::JSON.encode(hello: "world") end @@ -43,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/debug_locks_test.rb b/actionpack/test/dispatch/debug_locks_test.rb new file mode 100644 index 0000000000000..565e83071bf58 --- /dev/null +++ b/actionpack/test/dispatch/debug_locks_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class DebugLocksTest < ActionDispatch::IntegrationTest + setup do + build_app + end + + def test_render_threads_status + thread_ready = Concurrent::CountDownLatch.new + test_terminated = Concurrent::CountDownLatch.new + + thread = Thread.new do + ActiveSupport::Dependencies.interlock.running do + thread_ready.count_down + test_terminated.wait + end + end + + thread_ready.wait + + get "/rails/locks" + + test_terminated.count_down + + assert_match(/Thread.*?Sharing/, @response.body) + ensure + thread.join + end + + private + def build_app + @app = self.class.build_app do |middleware| + middleware.use Rack::Lint + middleware.use ActionDispatch::DebugLocks + middleware.use Rack::Lint + end + end +end diff --git a/actionpack/test/dispatch/exception_wrapper_test.rb b/actionpack/test/dispatch/exception_wrapper_test.rb index 316661a116eb5..88953d3e1ceb6 100644 --- a/actionpack/test/dispatch/exception_wrapper_test.rb +++ b/actionpack/test/dispatch/exception_wrapper_test.rb @@ -1,12 +1,24 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch class ExceptionWrapperTest < ActionDispatch::IntegrationTest class TestError < StandardError - attr_reader :backtrace + end + + class TopErrorProxy < StandardError + def initialize(ex, n) + @ex = ex + @n = n + end + + def backtrace + @ex.backtrace.first(@n) + end - def initialize(*backtrace) - @backtrace = backtrace.flatten + def backtrace_locations + @ex.backtrace_locations.first(@n) end end @@ -16,45 +28,178 @@ def backtrace end end + class TestTemplate + attr_reader :method_name + + def initialize(method_name) + @method_name = method_name + end + + def spot(backtrace_location) + { first_lineno: 1, script_lines: ["compiled @ #{backtrace_location.base_label}:#{backtrace_location.lineno}"] } + end + + def translate_location(backtrace_location, spot) + # note: extract_source_fragment_lines pulls lines from script_lines for indexes near first_lineno + # since we're mocking the behavior, we need to leave the first_lineno close to 1 + { first_lineno: 1, script_lines: ["translated @ #{backtrace_location.base_label}:#{backtrace_location.lineno}"] } + end + end + setup do @cleaner = ActiveSupport::BacktraceCleaner.new - @cleaner.add_silencer { |line| line !~ /^lib/ } + @cleaner.remove_filters! + @cleaner.add_silencer { |line| !line.start_with?("lib") } end + class_eval "def index; raise TestError; end", "lib/file.rb", 42 + test "#source_extracts fetches source fragments for every backtrace entry" do - exception = TestError.new("lib/file.rb:42:in `index'") - wrapper = ExceptionWrapper.new(nil, exception) + 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 + class_eval "def ms_index; raise TestError; end", "c:/path/to/rails/app/controller.rb", 27 + test "#source_extracts works with Windows paths" do - exc = TestError.new("c:/path/to/rails/app/controller.rb:27:in 'index':") + exc = begin ms_index; rescue TestError => ex; ex; end - wrapper = ExceptionWrapper.new(nil, exc) + 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 + class_eval "def invalid_ex; raise TestError; end", "invalid", 0 + test "#source_extracts works with non standard backtrace" do - exc = TestError.new("invalid") + exc = begin invalid_ex; rescue TestError => ex; ex; end - wrapper = ExceptionWrapper.new(nil, exc) + 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 + + class_eval "def throw_syntax_error; eval %( + pluralize { # create a syntax error without a parser warning + ); end", "lib/file.rb", 42 + + test "#source_extracts works with eval syntax error" do + 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, trace: trace ], wrapper.source_extracts + end + end + + test "#source_extracts works with nil backtrace_locations" do + exception = begin eval "class Foo; yield; end"; rescue SyntaxError => ex; ex; end + + wrapper = ExceptionWrapper.new(nil, exception) + + assert_empty wrapper.source_extracts + end + + test "#source_extracts works with error_highlight" do + lineno = __LINE__ + begin + 1.time + rescue NameError => exc + end + + wrapper = ExceptionWrapper.new(nil, exc) + + code = {} + File.foreach(__FILE__).to_a.drop(lineno - 1).take(6).each_with_index do |line, i| + code[lineno + i] = line + end + code[lineno + 2] = [" 1", ".time", "\n"] + 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; + raise TestError; end", "app/views/tests/show.html.erb", 2 + + test "#source_extracts wraps template lines in a SourceMapLocation" do + exception = begin _app_views_tests_show_html_erb; rescue TestError => ex; ex; end + + template = TestTemplate.new("_app_views_tests_show_html_erb") + resolver = Data.define(:built_templates).new(built_templates: [template]) + + wrapper = nil + assert_called(ActionView::PathRegistry, :all_resolvers, nil, returns: [resolver]) do + wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exception, 1)) end + + assert_equal [{ + code: { 1 => "translated @ _app_views_tests_show_html_erb:3" }, + line_number: 1, + trace: wrapper.source_extracts.first[:trace] + }], wrapper.source_extracts + end + + class_eval "def _app_views_tests_nested_html_erb; + [1].each do + [2].each do + raise TestError + end + end + end", "app/views/tests/nested.html.erb", 2 + + test "#source_extracts works with nested template code" do + exception = begin _app_views_tests_nested_html_erb; rescue TestError => ex; ex; end + + template = TestTemplate.new("_app_views_tests_nested_html_erb") + resolver = Data.define(:built_templates).new(built_templates: [template]) + + wrapper = nil + assert_called(ActionView::PathRegistry, :all_resolvers, nil, returns: [resolver]) do + wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exception, 5)) + end + + extracts = wrapper.source_extracts + assert_equal({ + code: { 1 => "translated @ _app_views_tests_nested_html_erb:5" }, + 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, + 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, + trace: extracts[4][:trace] + }, extracts[4]) end test "#application_trace returns traces only from the application" do - exception = TestError.new(caller.prepend("lib/file.rb:42:in `index'")) - wrapper = ExceptionWrapper.new(@cleaner, exception) + exception = begin index; rescue TestError => ex; ex; end + wrapper = ExceptionWrapper.new(@cleaner, TopErrorProxy.new(exception, 1)) - assert_equal [ "lib/file.rb:42:in `index'" ], wrapper.application_trace + if RUBY_VERSION >= "3.4" + assert_equal [ "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'" ], wrapper.application_trace.map(&:to_s) + else + assert_equal [ "lib/file.rb:42:in `index'" ], wrapper.application_trace.map(&:to_s) + end end test "#status_code returns 400 for Rack::Utils::ParameterTypeError" do @@ -63,6 +208,18 @@ def backtrace assert_equal 400, wrapper.status_code end + test "#rescue_response? returns false for an exception that's not in rescue_responses" do + exception = RuntimeError.new + wrapper = ExceptionWrapper.new(@cleaner, exception) + assert_equal false, wrapper.rescue_response? + end + + test "#rescue_response? returns true for an exception that is in rescue_responses" do + exception = ActionController::RoutingError.new("") + wrapper = ExceptionWrapper.new(@cleaner, exception) + assert_equal true, wrapper.rescue_response? + end + test "#application_trace cannot be nil" do nil_backtrace_wrapper = ExceptionWrapper.new(@cleaner, BadlyDefinedError.new) nil_cleaner_wrapper = ExceptionWrapper.new(nil, BadlyDefinedError.new) @@ -72,10 +229,13 @@ def backtrace end test "#framework_trace returns traces outside the application" do - exception = TestError.new(caller.prepend("lib/file.rb:42:in `index'")) + exception = begin index; rescue TestError => ex; ex; end wrapper = ExceptionWrapper.new(@cleaner, exception) - assert_equal caller, wrapper.framework_trace + # The exception gets one more frame for the `begin`. It's hard to + # get a stack trace exactly the same, so just drop that frame and + # make sure the rest are OK + assert_equal caller, wrapper.framework_trace.drop(1).map(&:to_s) end test "#framework_trace cannot be nil" do @@ -87,10 +247,10 @@ def backtrace end test "#full_trace returns application and framework traces" do - exception = TestError.new(caller.prepend("lib/file.rb:42:in `index'")) + exception = begin index; rescue TestError => ex; ex; end wrapper = ExceptionWrapper.new(@cleaner, exception) - assert_equal exception.backtrace, wrapper.full_trace + assert_equal exception.backtrace, wrapper.full_trace.map(&:to_s) end test "#full_trace cannot be nil" do @@ -101,18 +261,111 @@ def backtrace assert_equal [], nil_cleaner_wrapper.full_trace end + class_eval "def in_rack; index; end", "/gems/rack.rb", 43 + test "#traces returns every trace by category enumerated with an index" do - exception = TestError.new("lib/file.rb:42:in `index'", "/gems/rack.rb:43:in `index'") + exception = begin in_rack; rescue TestError => ex; TopErrorProxy.new(ex, 2); end wrapper = ExceptionWrapper.new(@cleaner, exception) - assert_equal({ - "Application Trace" => [ id: 0, trace: "lib/file.rb:42:in `index'" ], - "Framework Trace" => [ id: 1, trace: "/gems/rack.rb:43:in `index'" ], - "Full Trace" => [ - { id: 0, trace: "lib/file.rb:42:in `index'" }, - { id: 1, trace: "/gems/rack.rb:43:in `index'" } - ] - }, wrapper.traces) + if RUBY_VERSION >= "3.4" + assert_equal({ + "Application Trace" => [ + exception_object_id: exception.object_id, + id: 0, + 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: 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: wrapper.source_extracts.first[:trace], + filtered_trace: "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'" + }, + { + exception_object_id: exception.object_id, + id: 1, + trace: wrapper.source_extracts.second[:trace], + filtered_trace: "/gems/rack.rb:43:in 'ActionDispatch::ExceptionWrapperTest#in_rack'" + } + ] + }.inspect, wrapper.traces.inspect) + else + assert_equal({ + "Application Trace" => [ + exception_object_id: exception.object_id, + id: 0, + 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: 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: wrapper.source_extracts.first[:trace], + filtered_trace: "lib/file.rb:42:in `index'" + }, + { + exception_object_id: exception.object_id, + id: 1, + trace: wrapper.source_extracts.last[:trace], + filtered_trace: "/gems/rack.rb:43:in `in_rack'" + } + ] + }.inspect, wrapper.traces.inspect) + end + end + + test "#show? returns false when using :rescuable and the exceptions is not rescuable" do + exception = RuntimeError.new("") + wrapper = ExceptionWrapper.new(nil, exception) + + env = { "action_dispatch.show_exceptions" => :rescuable } + request = ActionDispatch::Request.new(env) + + assert_equal false, wrapper.show?(request) + end + + test "#show? returns true when using :rescuable and the exceptions is rescuable" do + exception = AbstractController::ActionNotFound.new("") + wrapper = ExceptionWrapper.new(nil, exception) + + env = { "action_dispatch.show_exceptions" => :rescuable } + request = ActionDispatch::Request.new(env) + + assert_equal true, wrapper.show?(request) + end + + test "#show? returns false when using :none and the exceptions is rescuable" do + exception = AbstractController::ActionNotFound.new("") + wrapper = ExceptionWrapper.new(nil, exception) + + env = { "action_dispatch.show_exceptions" => :none } + request = ActionDispatch::Request.new(env) + + assert_equal false, wrapper.show?(request) + end + + test "#show? returns true when using :all and the exceptions is not rescuable" do + exception = RuntimeError.new("") + wrapper = ExceptionWrapper.new(nil, exception) + + env = { "action_dispatch.show_exceptions" => :all } + request = ActionDispatch::Request.new(env) + + assert_equal true, wrapper.show?(request) end end end diff --git a/actionpack/test/dispatch/executor_test.rb b/actionpack/test/dispatch/executor_test.rb index 0b4e0849c3e31..c458bbaa00bdc 100644 --- a/actionpack/test/dispatch/executor_test.rb +++ b/actionpack/test/dispatch/executor_test.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "abstract_unit" +require "active_support/core_ext/object/with" class ExecutorTest < ActiveSupport::TestCase class MyBody < Array @@ -34,42 +37,18 @@ def test_returned_body_object_always_responds_to_close_even_if_called_twice body.close end - def test_returned_body_object_behaves_like_underlying_object - body = call_and_return_body do - b = MyBody.new - b << "hello" - b << "world" - [200, { "Content-Type" => "text/html" }, b] - end - assert_equal 2, body.size - assert_equal "hello", body[0] - assert_equal "world", body[1] - assert_equal "foo", body.foo - assert_equal "bar", body.bar - end - def test_it_calls_close_on_underlying_object_when_close_is_called_on_body close_called = false body = call_and_return_body do b = MyBody.new do close_called = true end - [200, { "Content-Type" => "text/html" }, b] + [200, { "content-type" => "text/html" }, b] end body.close assert close_called end - def test_returned_body_object_responds_to_all_methods_supported_by_underlying_object - body = call_and_return_body do - [200, { "Content-Type" => "text/html" }, MyBody.new] - end - assert_respond_to body, :size - assert_respond_to body, :each - assert_respond_to body, :foo - assert_respond_to body, :bar - end - def test_run_callbacks_are_called_before_close running = false executor.to_run { running = true } @@ -79,7 +58,7 @@ def test_run_callbacks_are_called_before_close running = false body.close - assert !running + assert_not running end def test_complete_callbacks_are_called_on_close @@ -87,7 +66,7 @@ def test_complete_callbacks_are_called_on_close executor.to_complete { completed = true } body = call_and_return_body - assert !completed + assert_not completed body.close assert completed @@ -114,18 +93,129 @@ def test_callbacks_execute_in_shared_context call_and_return_body.close assert result - assert !defined?(@in_shared_context) # it's not in the test itself + assert_not defined?(@in_shared_context) # it's not in the test itself + end + + def test_body_abandoned + total = 0 + ran = 0 + completed = 0 + + executor.to_run { total += 1; ran += 1 } + executor.to_complete { total += 1; completed += 1 } + + app = proc { [200, {}, []] } + env = Rack::MockRequest.env_for("", {}) + + stack = middleware(app) + + requests_count = 5 + + requests_count.times do + stack.call(env) + end + + assert_equal (requests_count * 2) - 1, total + assert_equal requests_count, ran + assert_equal requests_count - 1, completed + end + + def test_error_reporting + raised_error = nil + error_report = assert_error_reported(Exception) do + raised_error = assert_raises Exception do + call_and_return_body { raise Exception } + end + end + assert_same raised_error, error_report.error + end + + def test_error_reporting_with_show_exception + middleware = Rack::Lint.new( + ActionDispatch::Executor.new( + ActionDispatch::ShowExceptions.new( + Rack::Lint.new(->(_env) { 1 + "1" }), + ->(_env) { [500, {}, ["Oops"]] }, + ), + executor, + ) + ) + + env = Rack::MockRequest.env_for("", {}) + error_report = assert_error_reported do + middleware.call(env) + end + assert_instance_of TypeError, error_report.error + end + + class BusinessAsUsual < StandardError; end + + def test_handled_error_is_not_reported + middleware = Rack::Lint.new( + ActionDispatch::Executor.new( + ActionDispatch::ShowExceptions.new( + Rack::Lint.new(->(_env) { raise BusinessAsUsual }), + ->(env) { [418, {}, ["I'm a teapot"]] }, + ), + executor, + ) + ) + + env = Rack::MockRequest.env_for("", {}) + ActionDispatch::ExceptionWrapper.with(rescue_responses: { BusinessAsUsual.name => 418 }) do + assert_no_error_reported do + response = middleware.call(env) + assert_equal 418, response[0] + end + 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) - app = middleware(block || proc { [200, {}, "response"] }) - _, _, body = app.call("rack.input" => StringIO.new("")) + def call_and_return_body(env = Rack::MockRequest.env_for, &block) + app = block || proc { [200, {}, []] } + _, _, body = middleware(app).call(env) body end def middleware(inner_app) - ActionDispatch::Executor.new(inner_app, executor) + Rack::Lint.new(ActionDispatch::Executor.new(Rack::Lint.new(inner_app), executor)) end def executor diff --git a/actionpack/test/dispatch/header_test.rb b/actionpack/test/dispatch/header_test.rb index 958450072ef96..bd2a5b35fb5ad 100644 --- a/actionpack/test/dispatch/header_test.rb +++ b/actionpack/test/dispatch/header_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class HeaderTest < ActiveSupport::TestCase @@ -154,7 +156,7 @@ def make_headers(hash) env = { "HTTP_REFERER" => "/" } headers = make_headers(env) headers["Referer"] = "http://example.com/" - headers.merge! "CONTENT_TYPE" => "text/plain" + headers["CONTENT_TYPE"] = "text/plain" assert_equal({ "HTTP_REFERER" => "http://example.com/", "CONTENT_TYPE" => "text/plain" }, env) end diff --git a/actionpack/test/dispatch/host_authorization_test.rb b/actionpack/test/dispatch/host_authorization_test.rb new file mode 100644 index 0000000000000..9c3f6d26fe8be --- /dev/null +++ b/actionpack/test/dispatch/host_authorization_test.rb @@ -0,0 +1,509 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "ipaddr" + +class HostAuthorizationTest < ActionDispatch::IntegrationTest + App = -> env { [200, {}, %w(Success)] } + + test "blocks requests to unallowed host with empty body" do + @app = build_app(%w(only.com)) + + get "/" + + assert_response :forbidden + assert_empty response.body + end + + test "renders debug info when all requests considered as local" do + @app = build_app(%w(only.com)) + + get "/", env: { "action_dispatch.show_detailed_exceptions" => true } + + assert_response :forbidden + assert_match "Blocked hosts: www.example.com", response.body + end + + test "allows all requests if hosts is empty" do + @app = build_app(nil) + + get "/" + + assert_response :ok + assert_equal "Success", body + end + + test "hosts can be a single element array" do + @app = build_app(%w(www.example.com)) + + get "/" + + assert_response :ok + assert_equal "Success", body + end + + test "hosts can be a string" do + @app = build_app("www.example.com") + + get "/" + + assert_response :ok + assert_equal "Success", body + end + + test "hosts are matched case insensitive" do + @app = build_app("Example.local") + + get "/", env: { + "HOST" => "example.local", + } + + assert_response :ok + assert_equal "Success", body + end + + test "hosts are matched case insensitive with titlecased host" do + @app = build_app("example.local") + + get "/", env: { + "HOST" => "Example.local", + } + + assert_response :ok + assert_equal "Success", body + end + + test "hosts are matched case insensitive with hosts array" do + @app = build_app(["Example.local"]) + + get "/", env: { + "HOST" => "example.local", + } + + assert_response :ok + assert_equal "Success", body + end + + test "regex matches are not title cased" do + @app = build_app([/www.Example.local/]) + + get "/", env: { + "HOST" => "www.example.local", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: www.example.local", response.body + end + + test "passes requests to allowed hosts with domain name notation" do + @app = build_app(".example.com") + + get "/" + + assert_response :ok + assert_equal "Success", body + end + + test "does not allow domain name notation in the HOST header itself" do + @app = build_app(".example.com") + + get "/", env: { + "HOST" => ".example.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: .example.com", response.body + end + + test "checks for requests with #=== to support wider range of host checks" do + @app = build_app([-> input { input == "www.example.com" }]) + + get "/" + + assert_response :ok + assert_equal "Success", body + end + + test "mark the host when authorized" do + @app = build_app(".example.com") + + get "/" + + assert_equal "www.example.com", request.get_header("action_dispatch.authorized_host") + end + + test "sanitizes regular expressions to prevent accidental matches" do + @app = build_app([/w.example.co/]) + + get "/", env: { "action_dispatch.show_detailed_exceptions" => true } + + assert_response :forbidden + assert_match "Blocked hosts: www.example.com", response.body + end + + test "blocks requests to unallowed host supporting custom responses" do + @app = build_app(["w.example.co"], response_app: -> env do + [401, {}, %w(Custom)] + end) + + get "/" + + assert_response :unauthorized + assert_equal "Custom", body + end + + test "localhost works in dev" do + @app = build_app(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT) + + get "/", env: { + "HOST" => "localhost:3000", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "localhost using IPV4 works in dev" do + @app = build_app(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT) + + get "/", env: { + "HOST" => "127.0.0.1", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "localhost using IPV4 with port works in dev" do + @app = build_app(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT) + + get "/", env: { + "HOST" => "127.0.0.1:3000", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "localhost using IPV4 binding in all addresses works in dev" do + @app = build_app(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT) + + get "/", env: { + "HOST" => "0.0.0.0", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "localhost using IPV4 with port binding in all addresses works in dev" do + @app = build_app(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT) + + get "/", env: { + "HOST" => "0.0.0.0:3000", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "localhost using IPV6 works in dev" do + @app = build_app(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT) + + get "/", env: { + "HOST" => "[::1]", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "localhost using IPV6 with port works in dev" do + @app = build_app(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT) + + get "/", env: { + "HOST" => "[::1]:3000", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "localhost using IPV6 binding in all addresses works in dev" do + @app = build_app(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT) + + get "/", env: { + "HOST" => "[::]", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "localhost using IPV6 with port binding in all addresses works in dev" do + @app = build_app(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT) + + get "/", env: { + "HOST" => "[::]:3000", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "hosts with port works" do + @app = build_app(["host.test"]) + + get "/", env: { + "HOST" => "host.test:3000", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :ok + assert_match "Success", response.body + end + + test "blocks requests with spoofed X-FORWARDED-HOST" do + @app = build_app([IPAddr.new("127.0.0.1")]) + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "127.0.0.1", + "HOST" => "www.example.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: www.example.com", response.body + end + + test "blocks requests with spoofed relative X-FORWARDED-HOST" do + @app = build_app(["www.example.com"]) + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "//randomhost.com", + "HOST" => "www.example.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: //randomhost.com", response.body + end + + test "forwarded secondary hosts are allowed when permitted" do + @app = build_app(".domain.com") + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "example.com, my-sub.domain.com", + "HOST" => "domain.com", + } + + assert_response :ok + assert_equal "Success", body + end + + test "forwarded secondary hosts are blocked when mismatch" do + @app = build_app("domain.com") + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "domain.com, evil.com", + "HOST" => "domain.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: evil.com", response.body + end + + test "does not consider IP addresses in X-FORWARDED-HOST spoofed when disabled" do + @app = build_app(nil) + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "127.0.0.1", + "HOST" => "www.example.com", + } + + assert_response :ok + assert_equal "Success", body + end + + test "detects localhost domain spoofing" do + @app = build_app("localhost") + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "localhost", + "HOST" => "www.example.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: www.example.com", response.body + end + + test "forwarded hosts should be permitted" do + @app = build_app("domain.com") + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "sub.domain.com", + "HOST" => "domain.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: sub.domain.com", response.body + end + + test "sub-sub domains should not be permitted" do + @app = build_app(".domain.com") + + get "/", env: { + "HOST" => "secondary.sub.domain.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: secondary.sub.domain.com", response.body + end + + test "forwarded hosts are allowed when permitted" do + @app = build_app(".domain.com") + + get "/", env: { + "HTTP_X_FORWARDED_HOST" => "my-sub.domain.com", + "HOST" => "domain.com", + } + + assert_response :ok + assert_equal "Success", body + end + + test "lots of NG hosts" do + ng_hosts = [ + "hacker%E3%80%82com", + "hacker%00.com", + "www.theirsite.com@yoursite.com", + "hacker.com/test/", + "hacker%252ecom", + ".hacker.com", + "/\/\/hacker.com/", + "/hacker.com", + "../hacker.com", + ".hacker.com", + "@hacker.com", + "hacker.com", + "hacker.com%23@example.com", + "hacker.com/.jpg", + "hacker.com\texample.com/", + "hacker.com/example.com", + "hacker.com\@example.com", + "hacker.com/example.com", + "hacker.com/" + ] + + @app = build_app("example.com") + + ng_hosts.each do |host| + get "/", env: { + "HTTP_X_FORWARDED_HOST" => host, + "HOST" => "example.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: #{host}", response.body + end + end + + test "exclude matches allow any host" do + @app = build_app("only.com", exclude: ->(req) { req.path == "/foo" }) + + get "/foo" + + assert_response :ok + assert_equal "Success", body + end + + test "exclude misses block unallowed hosts" do + @app = build_app("only.com", exclude: ->(req) { req.path == "/bar" }) + + get "/foo", env: { "action_dispatch.show_detailed_exceptions" => true } + + assert_response :forbidden + assert_match "Blocked hosts: www.example.com", response.body + end + + test "blocks requests with invalid hostnames" do + @app = build_app(".example.com", lint: false) + + get "/", env: { + "HOST" => "attacker.com#x.example.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: attacker.com#x.example.com", response.body + end + + test "blocks requests to similar host" do + @app = build_app("sub.example.com") + + get "/", env: { + "HOST" => "sub-example.com", + "action_dispatch.show_detailed_exceptions" => true + } + + assert_response :forbidden + assert_match "Blocked hosts: sub-example.com", response.body + end + + test "uses logger from the env" do + @app = build_app(%w(only.com)) + output = StringIO.new + + get "/", env: { "action_dispatch.logger" => Logger.new(output) } + + assert_response :forbidden + assert_match "Blocked hosts: www.example.com", output.rewind && output.read + end + + test "uses ActionView::Base logger when no logger in the env" do + @app = build_app(%w(only.com)) + output = StringIO.new + logger = Logger.new(output) + + _old, ActionView::Base.logger = ActionView::Base.logger, logger + begin + get "/" + ensure + ActionView::Base.logger = _old + end + + assert_response :forbidden + assert_match "Blocked hosts: www.example.com", output.rewind && output.read + end + + private + def build_app(hosts, exclude: nil, response_app: nil, lint: true, app: App) + if lint + app = Rack::Lint.new(app) + end + + app = ActionDispatch::HostAuthorization.new(app, hosts, exclude: exclude, response_app: response_app) + + if lint + Rack::Lint.new(app) + end + + app + end +end diff --git a/actionpack/test/dispatch/live_response_test.rb b/actionpack/test/dispatch/live_response_test.rb index d10fc7d575fbe..c0a5cb1ed255e 100644 --- a/actionpack/test/dispatch/live_response_test.rb +++ b/actionpack/test/dispatch/live_response_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "concurrent/atomic/count_down_latch" @@ -10,9 +12,9 @@ def setup end def test_header_merge - header = @response.header.merge("Foo" => "Bar") - assert_kind_of(ActionController::Live::Response::Header, header) - assert_not_equal header, @response.header + headers = @response.headers.merge("Foo" => "Bar") + assert_kind_of(ActionController::Live::Response::Headers, headers) + assert_not_equal headers, @response.headers end def test_initialize_with_default_headers @@ -22,8 +24,9 @@ def self.default_headers end end - header = r.new.header - assert_kind_of(ActionController::Live::Response::Header, header) + headers = r.create.headers + assert_kind_of(ActionController::Live::Response::Headers, headers) + assert_equal "g", headers["omg"] end def test_parallel @@ -49,47 +52,39 @@ def test_setting_body_populates_buffer assert_equal ["omg"], @response.body_parts end - def test_cache_control_is_set + def test_cache_control_is_set_by_default @response.stream.write "omg" assert_equal "no-cache", @response.headers["Cache-Control"] end - def test_content_length_is_removed - @response.headers["Content-Length"] = "1234" + def test_cache_control_is_set_manually + @response.set_header("Cache-Control", "public") @response.stream.write "omg" - assert_nil @response.headers["Content-Length"] + assert_equal "public", @response.headers["Cache-Control"] end - def test_headers_cannot_be_written_after_webserver_reads + def test_cache_control_no_store_default_standalone + @response.set_header("Cache-Control", "no-store") @response.stream.write "omg" - latch = Concurrent::CountDownLatch.new - - t = Thread.new { - @response.each do - latch.count_down - end - } - - latch.wait - assert @response.headers.frozen? - e = assert_raises(ActionDispatch::IllegalStateError) do - @response.headers["Content-Length"] = "zomg" - end + assert_equal "no-store", @response.headers["Cache-Control"] + end - assert_equal "header already sent", e.message - @response.stream.close - t.join + def test_cache_control_no_store_is_respected + @response.set_header("Cache-Control", "public, no-store") + @response.stream.write "omg" + assert_equal "no-store", @response.headers["Cache-Control"] end - def test_headers_cannot_be_written_after_close - @response.stream.close - # we can add data until it's actually written, which happens on `each` - @response.each { |x| } + def test_cache_control_no_store_private + @response.set_header("Cache-Control", "private, no-store") + @response.stream.write "omg" + assert_equal "private, no-store", @response.headers["Cache-Control"] + end - e = assert_raises(ActionDispatch::IllegalStateError) do - @response.headers["Content-Length"] = "zomg" - end - assert_equal "header already sent", e.message + def test_content_length_is_removed + @response.headers["Content-Length"] = "1234" + @response.stream.write "omg" + assert_nil @response.headers["Content-Length"] end end end diff --git a/actionpack/test/dispatch/mapper_test.rb b/actionpack/test/dispatch/mapper_test.rb index 1596d23b1ea4f..206579bf949bc 100644 --- a/actionpack/test/dispatch/mapper_test.rb +++ b/actionpack/test/dispatch/mapper_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch @@ -12,10 +14,6 @@ def request_class ActionDispatch::Request end - def dispatcher_class - RouteSet::Dispatcher - end - def defaults routes.map(&:defaults) end @@ -34,7 +32,9 @@ def asts end def test_initialize - Mapper.new FakeSet.new + assert_nothing_raised do + Mapper.new FakeSet.new + end end def test_scope_raises_on_anchor @@ -89,8 +89,8 @@ def test_mapping_requirements options = {} scope = Mapper::Scope.new({}) ast = Journey::Parser.parse "/store/:name(*rest)" - m = Mapper::Mapping.build(scope, FakeSet.new, ast, "foo", "bar", nil, [:get], nil, {}, true, options) - assert_equal(/.+?/, m.requirements[:rest]) + m = Mapper::Mapping.build(scope, FakeSet.new, ast, "foo", "bar", nil, [:get], nil, {}, true, nil, options) + assert_equal(/.+?/m, m.requirements[:rest]) end def test_via_scope @@ -125,7 +125,6 @@ def test_map_more_slashes fakeset = FakeSet.new mapper = Mapper.new fakeset - # FIXME: is this a desired behavior? mapper.get "/one/two/", to: "posts#index", as: :main assert_equal "/one/two(.:format)", fakeset.asts.first.to_s end @@ -135,7 +134,7 @@ def test_map_wildcard mapper = Mapper.new fakeset mapper.get "/*path", to: "pages#show" assert_equal "/*path(.:format)", fakeset.asts.first.to_s - assert_equal(/.+?/, fakeset.requirements.first[:path]) + assert_equal(/.+?/m, fakeset.requirements.first[:path]) end def test_map_wildcard_with_other_element @@ -143,7 +142,7 @@ def test_map_wildcard_with_other_element mapper = Mapper.new fakeset mapper.get "/*path/foo/:bar", to: "pages#show" assert_equal "/*path/foo/:bar(.:format)", fakeset.asts.first.to_s - assert_equal(/.+?/, fakeset.requirements.first[:path]) + assert_equal(/.+?/m, fakeset.requirements.first[:path]) end def test_map_wildcard_with_multiple_wildcard @@ -151,8 +150,8 @@ def test_map_wildcard_with_multiple_wildcard mapper = Mapper.new fakeset mapper.get "/*foo/*bar", to: "pages#show" assert_equal "/*foo/*bar(.:format)", fakeset.asts.first.to_s - assert_equal(/.+?/, fakeset.requirements.first[:foo]) - assert_equal(/.+?/, fakeset.requirements.first[:bar]) + assert_equal(/.+?/m, fakeset.requirements.first[:foo]) + assert_equal(/.+?/m, fakeset.requirements.first[:bar]) end def test_map_wildcard_with_format_false @@ -170,6 +169,15 @@ def test_map_wildcard_with_format_true assert_equal "/*path.:format", fakeset.asts.first.to_s end + def test_can_pass_anchor_to_mount + fakeset = FakeSet.new + mapper = Mapper.new fakeset + app = lambda { |env| [200, {}, [""]] } + mapper.mount app => "/path", anchor: true + assert_equal "/path", fakeset.asts.first.to_s + assert fakeset.routes.first.path.anchored + end + def test_raising_error_when_path_is_not_passed fakeset = FakeSet.new mapper = Mapper.new fakeset @@ -191,6 +199,16 @@ def test_raising_error_when_rack_app_is_not_passed end end + def test_raising_error_when_invalid_on_option_is_given + fakeset = FakeSet.new + mapper = Mapper.new fakeset + error = assert_raise ArgumentError do + mapper.get "/foo", on: :invalid_option + end + + assert_equal "Unknown scope :invalid_option given to :on", error.message + end + def test_scope_does_not_destructively_mutate_default_options fakeset = FakeSet.new mapper = Mapper.new fakeset @@ -203,6 +221,63 @@ def test_scope_does_not_destructively_mutate_default_options end end end + + def test_deprecated_hash + fakeset = FakeSet.new + mapper = Mapper.new fakeset + + assert_deprecated(ActionDispatch.deprecator) do + mapper.get "/foo", { to: "home#index" } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.post "/foo", { to: "home#index" } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.put "/foo", { to: "home#index" } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.patch "/foo", { to: "home#index" } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.delete "/foo", { to: "home#index" } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.options "/foo", { to: "home#index" } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.connect "/foo", { to: "home#index" } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.match "/foo", { to: "home#index", via: :get } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.mount(lambda { |env| [200, {}, [""]] }, { at: "/" }) + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.scope("/hello", { only: :get }) { } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.namespace(:admin, { module: "sekret" }) { } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.resource(:user, { only: :show }) { } + end + + assert_deprecated(ActionDispatch.deprecator) do + mapper.resources(:users, { only: :show }) { } + end + end end end end diff --git a/actionpack/test/dispatch/middleware_stack_test.rb b/actionpack/test/dispatch/middleware_stack_test.rb index 481aa22b1079d..cdb6d5fa43d4f 100644 --- a/actionpack/test/dispatch/middleware_stack_test.rb +++ b/actionpack/test/dispatch/middleware_stack_test.rb @@ -1,13 +1,26 @@ +# frozen_string_literal: true + require "abstract_unit" class MiddlewareStackTest < ActiveSupport::TestCase - class FooMiddleware; end - class BarMiddleware; end - class BazMiddleware; end - class HiyaMiddleware; end - class BlockMiddleware + class Base + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + end + end + + class FooMiddleware < Base; end + class BarMiddleware < Base; end + class BazMiddleware < Base; end + class HiyaMiddleware < Base; end + class BlockMiddleware < Base attr_reader :block - def initialize(&block) + def initialize(app, &block) + super(app) @block = block end end @@ -24,6 +37,24 @@ def test_delete_works end end + test "delete ignores middleware not in the stack" do + assert_no_difference "@stack.size" do + @stack.delete BazMiddleware + end + end + + test "delete! deletes the middleware" do + assert_difference "@stack.size", -1 do + @stack.delete! FooMiddleware + end + end + + test "delete! requires the middleware to be in the stack" do + assert_raises RuntimeError do + @stack.delete! BazMiddleware + end + end + test "use should push middleware as class onto the stack" do assert_difference "@stack.size" do @stack.use BazMiddleware @@ -40,7 +71,7 @@ def test_delete_works end test "use should push middleware class with block arguments onto the stack" do - proc = Proc.new {} + proc = Proc.new { } assert_difference "@stack.size" do @stack.use(BlockMiddleware, &proc) end @@ -80,6 +111,60 @@ def test_delete_works assert_equal FooMiddleware, @stack[0].klass end + test "move moves middleware at the integer index" do + @stack.move(0, BarMiddleware) + assert_equal BarMiddleware, @stack[0].klass + assert_equal FooMiddleware, @stack[1].klass + end + + test "move requires the moved middleware to be in the stack" do + assert_raises RuntimeError do + @stack.move(0, BazMiddleware) + end + end + + test "move preserves the arguments of the moved middleware" do + @stack.use BazMiddleware, true, foo: "bar" + @stack.move_before(FooMiddleware, BazMiddleware) + + assert_equal [true, foo: "bar"], @stack.first.args + end + + test "move_before moves middleware before another middleware class" do + @stack.move_before(FooMiddleware, BarMiddleware) + assert_equal BarMiddleware, @stack[0].klass + assert_equal FooMiddleware, @stack[1].klass + end + + test "move_after requires the moved middleware to be in the stack" do + assert_raises RuntimeError do + @stack.move_after(BarMiddleware, BazMiddleware) + end + end + + test "move_after moves middleware after the integer index" do + @stack.insert_after(BarMiddleware, BazMiddleware) + @stack.move_after(0, BazMiddleware) + assert_equal FooMiddleware, @stack[0].klass + assert_equal BazMiddleware, @stack[1].klass + assert_equal BarMiddleware, @stack[2].klass + end + + test "move_after moves middleware after another middleware class" do + @stack.insert_after(BarMiddleware, BazMiddleware) + @stack.move_after(BarMiddleware, FooMiddleware) + assert_equal BarMiddleware, @stack[0].klass + assert_equal FooMiddleware, @stack[1].klass + assert_equal BazMiddleware, @stack[2].klass + end + + test "move_afters preserves the arguments of the moved middleware" do + @stack.use BazMiddleware, true, foo: "bar" + @stack.move_after(FooMiddleware, BazMiddleware) + + assert_equal [true, foo: "bar"], @stack[1].args + end + test "unshift adds a new middleware at the beginning of the stack" do @stack.unshift MiddlewareStackTest::BazMiddleware assert_equal BazMiddleware, @stack.first.klass @@ -107,6 +192,25 @@ def test_delete_works assert_equal @stack.last, @stack.last end + test "instruments the execution of middlewares" do + notification_name = "process_middleware.action_dispatch" + + assert_notifications_count(notification_name, 2) do + assert_notification(notification_name, { middleware: "MiddlewareStackTest::BarMiddleware" }) do + assert_notification(notification_name, { middleware: "MiddlewareStackTest::FooMiddleware" }) do + app = Rack::Lint.new( + @stack.build(Rack::Lint.new(proc { |env| [200, {}, []] })) + ) + + env = Rack::MockRequest.env_for("", {}) + assert_nothing_raised do + app.call(env) + end + end + end + end + end + test "includes a middleware" do assert_equal true, @stack.include?(ActionDispatch::MiddlewareStack::Middleware.new(BarMiddleware, nil, nil)) end diff --git a/actionpack/test/dispatch/mime_type_test.rb b/actionpack/test/dispatch/mime_type_test.rb index 2ca03c535a1d8..fe2d7da5ad40f 100644 --- a/actionpack/test/dispatch/mime_type_test.rb +++ b/actionpack/test/dispatch/mime_type_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class MimeTypeTest < ActiveSupport::TestCase @@ -28,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[: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, parsed + 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[: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, parsed + 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[: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 @@ -66,6 +68,12 @@ class MimeTypeTest < ActiveSupport::TestCase assert_equal expect.map(&:to_s), Mime::Type.parse(accept).map(&:to_s) end + test "parse with q and media type parameters" do + accept = "text/xml,application/xhtml+xml,text/yaml; q=0.3,application/xml,text/html; q=0.8,image/png,text/plain; q=0.5,application/pdf,*/*; encoding=UTF-8; q=0.2" + expect = [Mime[:html], Mime[:xml], Mime[:png], Mime[:pdf], Mime[:text], Mime[:yaml], "*/*"] + assert_equal expect.map(&:to_s), Mime::Type.parse(accept).map(&:to_s) + end + test "parse single media range with q" do accept = "text/html;q=0.9" expect = [Mime[:html]] @@ -78,6 +86,24 @@ class MimeTypeTest < ActiveSupport::TestCase assert_equal expect, Mime::Type.parse(accept) end + test "parse arbitrary media type parameters with comma" do + accept = 'multipart/form-data; boundary="simple, boundary"' + expect = [Mime[:multipart_form]] + assert_equal expect, Mime::Type.parse(accept) + end + + test "parse arbitrary media type parameters with comma and additional media type" do + accept = 'multipart/form-data; boundary="simple, boundary", text/xml' + expect = [Mime[:multipart_form], Mime[:xml]] + assert_equal expect, Mime::Type.parse(accept) + end + + test "parse wildcard with arbitrary media type parameters" do + accept = '*/*; boundary="simple"' + expect = ["*/*"] + assert_equal expect, Mime::Type.parse(accept) + end + # Accept header send with user HTTP_USER_AGENT: Sunrise/0.42j (Windows XP) test "parse broken acceptlines" do accept = "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/*,,*/*;q=0.5" @@ -94,62 +120,61 @@ class MimeTypeTest < ActiveSupport::TestCase end test "custom type" do - begin - type = Mime::Type.register("image/foo", :foo) - assert_equal type, Mime[:foo] - ensure - Mime::Type.unregister(:foo) - end + type = Mime::Type.register("image/foo", :foo) + assert_equal type, Mime[:foo] + ensure + Mime::Type.unregister(:foo) end test "custom type with type aliases" do - begin - Mime::Type.register "text/foobar", :foobar, ["text/foo", "text/bar"] - %w[text/foobar text/foo text/bar].each do |type| - assert_equal Mime[:foobar], type - end - ensure - Mime::Type.unregister(:foobar) + Mime::Type.register "text/foobar", :foobar, ["text/foo", "text/bar"] + %w[text/foobar text/foo text/bar].each do |type| + assert_equal Mime[:foobar], type end + ensure + Mime::Type.unregister(:foobar) end - test "register callbacks" do - begin - registered_mimes = [] - Mime::Type.register_callback do |mime| - registered_mimes << mime - end + test "custom type with url parameter" do + accept = 'application/vnd.api+json; profile="https://jsonapi.org/profiles/example"' + type = Mime::Type.register(accept, :example_api) + assert_equal type, Mime[:example_api] + assert_equal [type], Mime::Type.parse(accept) + ensure + Mime::Type.unregister(:example_api) + end - mime = Mime::Type.register("text/foo", :foo) - assert_equal [mime], registered_mimes - ensure - Mime::Type.unregister(:foo) + test "register callbacks" do + registered_mimes = [] + Mime::Type.register_callback do |mime| + registered_mimes << mime end + + mime = Mime::Type.register("text/foo", :foo) + assert_equal [mime], registered_mimes + ensure + Mime::Type.unregister(:foo) end test "custom type with extension aliases" do - begin - Mime::Type.register "text/foobar", :foobar, [], [:foo, "bar"] - %w[foobar foo bar].each do |extension| - assert_equal Mime[:foobar], Mime::EXTENSION_LOOKUP[extension] - end - ensure - Mime::Type.unregister(:foobar) + Mime::Type.register "text/foobar", :foobar, [], [:foo, "bar"] + %w[foobar foo bar].each do |extension| + assert_equal Mime[:foobar], Mime::EXTENSION_LOOKUP[extension] end + ensure + Mime::Type.unregister(:foobar) end test "register alias" do - begin - Mime::Type.register_alias "application/xhtml+xml", :foobar - assert_equal Mime[:html], Mime::EXTENSION_LOOKUP["foobar"] - ensure - Mime::Type.unregister(:foobar) - end + Mime::Type.register_alias "application/xhtml+xml", :foobar + assert_equal Mime[:html], Mime::EXTENSION_LOOKUP["foobar"] + ensure + Mime::Type.unregister(:foobar) end test "type should be equal to symbol" do - assert_equal Mime[:html], "application/xhtml+xml" - assert_equal Mime[:html], :html + assert_operator Mime[:html], :==, "application/xhtml+xml" + assert_operator Mime[:html], :==, :html end test "type convenience methods" do @@ -157,7 +182,7 @@ class MimeTypeTest < ActiveSupport::TestCase types.each do |type| mime = Mime[type] - assert mime.respond_to?("#{type}?"), "#{mime.inspect} does not respond to #{type}?" + assert_respond_to mime, "#{type}?" assert_equal type, mime.symbol, "#{mime.inspect} is not #{type}?" invalid_types = types - [type] invalid_types.delete(:html) @@ -178,8 +203,82 @@ class MimeTypeTest < ActiveSupport::TestCase assert Mime[:js] =~ "text/javascript" assert Mime[:js] =~ "application/javascript" assert Mime[:js] !~ "text/html" - assert !(Mime[:js] !~ "text/javascript") - assert !(Mime[:js] !~ "application/javascript") + assert_not (Mime[:js] !~ "text/javascript") + assert_not (Mime[:js] !~ "application/javascript") assert Mime[:html] =~ "application/xhtml+xml" end + + test "match?" do + assert Mime[:js].match?("text/javascript") + assert Mime[:js].match?("application/javascript") + assert_not Mime[:js].match?("text/html") + end + + test "can be initialized with wildcards" do + assert_equal "*/*", Mime::Type.new("*/*").to_s + assert_equal "text/*", Mime::Type.new("text/*").to_s + assert_equal "video/*", Mime::Type.new("video/*").to_s + end + + test "can be initialized with parameters" do + assert_equal "text/html; parameter", Mime::Type.new("text/html; parameter").to_s + assert_equal "text/html; parameter=abc", Mime::Type.new("text/html; parameter=abc").to_s + assert_equal 'text/html; parameter="abc"', Mime::Type.new('text/html; parameter="abc"').to_s + assert_equal 'text/html; parameter=abc; parameter2="xyz"', Mime::Type.new('text/html; parameter=abc; parameter2="xyz"').to_s + end + + test "can be initialized with parameters without having space after ;" do + assert_equal "text/html;parameter", Mime::Type.new("text/html;parameter").to_s + assert_equal 'text/html;parameter=abc;parameter2="xyz"', Mime::Type.new('text/html;parameter=abc;parameter2="xyz"').to_s + end + + test "invalid mime types raise error" do + assert_raises Mime::Type::InvalidMimeType do + Mime::Type.new("too/many/slash") + end + + assert_raises Mime::Type::InvalidMimeType do + Mime::Type.new("missingslash") + end + + assert_raises Mime::Type::InvalidMimeType do + Mime::Type.new("improper/semicolon;") + end + + assert_raises Mime::Type::InvalidMimeType do + Mime::Type.new('improper/semicolon; parameter=abc; parameter2="xyz";') + end + + assert_raises Mime::Type::InvalidMimeType do + Mime::Type.new("text/html, text/plain") + end + + assert_raises Mime::Type::InvalidMimeType do + Mime::Type.new("*/html") + end + + assert_raises Mime::Type::InvalidMimeType do + Mime::Type.new("") + end + + assert_raises Mime::Type::InvalidMimeType do + Mime::Type.new(nil) + end + + assert_raises Mime::Type::InvalidMimeType do + Timeout.timeout(1) do # Shouldn't take more than 1s + Mime::Type.new("text/html ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0 ;0;") + end + end + end + + test "holds a reference to mime symbols" do + old_symbols = Mime::SET.symbols + Mime::Type.register_alias "application/xhtml+xml", :foobar + new_symbols = Mime::SET.symbols + + assert_same(old_symbols, new_symbols) + ensure + Mime::Type.unregister(:foobar) + end end diff --git a/actionpack/test/dispatch/mount_test.rb b/actionpack/test/dispatch/mount_test.rb index a7d5ba2345554..758cee9930ca9 100644 --- a/actionpack/test/dispatch/mount_test.rb +++ b/actionpack/test/dispatch/mount_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "rails/engine" @@ -25,6 +27,7 @@ def self.call(env) } mount SprocketsApp, at: "/sprockets" + mount SprocketsApp, at: "/star*" mount SprocketsApp => "/shorthand" mount SinatraLikeApp, at: "/fakeengine", as: :fake @@ -56,6 +59,14 @@ def test_app_name_is_properly_generated_when_engine_is_mounted_in_resources def test_mounting_at_root_path get "/omg" assert_equal " -- /omg", response.body + + get "/~omg" + assert_equal " -- /~omg", response.body + end + + def test_mounting_at_path_with_non_word_character + get "/star*/omg" + assert_equal "/star* -- /omg", response.body end def test_mounting_sets_script_name @@ -78,6 +89,12 @@ def test_mounting_with_shorthand assert_equal "/shorthand -- /omg", response.body end + def test_mounting_does_not_match_similar_paths + get "/shorthandomg" + assert_not_equal "/shorthand -- /omg", response.body + assert_equal " -- /shorthandomg", response.body + end + def test_mounting_works_with_via get "/getfake" assert_equal "OK", response.body diff --git a/actionpack/test/dispatch/param_builder_test.rb b/actionpack/test/dispatch/param_builder_test.rb new file mode 100644 index 0000000000000..544c4ef70d4fc --- /dev/null +++ b/actionpack/test/dispatch/param_builder_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ParamBuilderTest < ActiveSupport::TestCase + # Much of the behavioral details are covered by long-standing + # integration tests in test/request/query_string_parsing_test.rb + # + # This test doesn't need to duplicate all of that: it just + # offers a simple baseline of unit tests. + + test "simple query string" do + result = ActionDispatch::ParamBuilder.from_query_string("foo=bar&baz=quux") + assert_equal({ "foo" => "bar", "baz" => "quux" }, result) + assert_instance_of ActiveSupport::HashWithIndifferentAccess, result + end + + test "nested parameters" do + result = ActionDispatch::ParamBuilder.from_query_string("foo[bar]=baz") + assert_equal({ "foo" => { "bar" => "baz" } }, result) + assert_instance_of ActiveSupport::HashWithIndifferentAccess, result[:foo] + end + + 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) + end +end diff --git a/actionpack/test/dispatch/permissions_policy_test.rb b/actionpack/test/dispatch/permissions_policy_test.rb new file mode 100644 index 0000000000000..7c61bf4a2723c --- /dev/null +++ b/actionpack/test/dispatch/permissions_policy_test.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class PermissionsPolicyTest < ActiveSupport::TestCase + def setup + @policy = ActionDispatch::PermissionsPolicy.new + end + + def test_mappings + @policy.midi :self + assert_equal "midi 'self'", @policy.build + + @policy.midi :none + assert_equal "midi 'none'", @policy.build + end + + def test_multiple_sources_for_a_single_directive + @policy.geolocation :self, "https://example.com" + assert_equal "geolocation 'self' https://example.com", @policy.build + end + + def test_single_directive_for_multiple_directives + @policy.geolocation :self + @policy.usb :none + assert_equal "geolocation 'self'; usb 'none'", @policy.build + end + + def test_multiple_directives_for_multiple_directives + @policy.geolocation :self, "https://example.com" + @policy.usb :none, "https://example.com" + assert_equal "geolocation 'self' https://example.com; usb 'none' https://example.com", @policy.build + end + + def test_invalid_directive_source + exception = assert_raises(ArgumentError) do + @policy.geolocation [:non_existent] + end + + assert_equal "Invalid HTTP permissions policy source: [:non_existent]", exception.message + end +end + +class PermissionsPolicyMiddlewareTest < ActionDispatch::IntegrationTest + APP = ->(env) { [200, {}, []] } + + POLICY = ActionDispatch::PermissionsPolicy.new do |p| + p.gyroscope :self + end + + class PolicyConfigMiddleware + def initialize(app) + @app = app + end + + def call(env) + env["action_dispatch.permissions_policy"] = POLICY + env["action_dispatch.show_exceptions"] = :none + + @app.call(env) + end + end + + test "html requests will set a policy" do + @app = build_app(->(env) { [200, { Rack::CONTENT_TYPE => "text/html" }, []] }) + + get "/index" + + assert_equal "gyroscope 'self'", response.headers[ActionDispatch::Constants::FEATURE_POLICY] + end + + test "non-html requests will set a policy" do + @app = build_app(->(env) { [200, { Rack::CONTENT_TYPE => "application/json" }, []] }) + + get "/index" + + assert_equal "gyroscope 'self'", response.headers[ActionDispatch::Constants::FEATURE_POLICY] + end + + test "existing policies will not be overwritten" do + @app = build_app(->(env) { [200, { ActionDispatch::Constants::FEATURE_POLICY => "gyroscope 'none'" }, []] }) + + get "/index" + + assert_equal "gyroscope 'none'", response.headers[ActionDispatch::Constants::FEATURE_POLICY] + end + + private + def build_app(app) + PolicyConfigMiddleware.new( + Rack::Lint.new( + ActionDispatch::PermissionsPolicy::Middleware.new( + Rack::Lint.new(app), + ), + ), + ) + end +end + +class PermissionsPolicyIntegrationTest < ActionDispatch::IntegrationTest + class PolicyController < ActionController::Base + permissions_policy only: :index do |f| + f.gyroscope :none + end + + permissions_policy only: :sample_controller do |f| + f.gyroscope nil + f.usb :self + end + + permissions_policy only: :multiple_directives do |f| + f.gyroscope nil + f.usb :self + f.autoplay "https://example.com" + f.payment "https://secure.example.com" + end + + def index + head :ok + end + + def sample_controller + head :ok + end + + def multiple_directives + head :ok + end + end + + ROUTES = ActionDispatch::Routing::RouteSet.new + ROUTES.draw do + scope module: "permissions_policy_integration_test" do + get "/", to: "policy#index" + get "/sample_controller", to: "policy#sample_controller" + get "/multiple_directives", to: "policy#multiple_directives" + end + end + + POLICY = ActionDispatch::PermissionsPolicy.new do |p| + p.gyroscope :self + end + + class PolicyConfigMiddleware + def initialize(app) + @app = app + end + + def call(env) + env["action_dispatch.permissions_policy"] = POLICY + env["action_dispatch.show_exceptions"] = :none + + @app.call(env) + end + end + + APP = build_app(ROUTES) do |middleware| + middleware.use PolicyConfigMiddleware + middleware.use Rack::Lint + middleware.use ActionDispatch::PermissionsPolicy::Middleware + middleware.use Rack::Lint + end + + def app + APP + end + + def test_generates_permissions_policy_header + get "/" + assert_policy "gyroscope 'none'" + end + + def test_generates_per_controller_permissions_policy_header + get "/sample_controller" + assert_policy "usb 'self'" + end + + def test_generates_multiple_directives_permissions_policy_header + get "/multiple_directives" + assert_policy "usb 'self'; autoplay https://example.com; payment https://secure.example.com" + end + + private + def assert_policy(expected) + assert_response :success + assert_equal expected, response.headers["Feature-Policy"] + end +end + +class PermissionsPolicyWithHelpersIntegrationTest < ActionDispatch::IntegrationTest + module ApplicationHelper + def pigs_can_fly? + false + end + end + + class ApplicationController < ActionController::Base + helper_method :sky_is_blue? + def sky_is_blue? + true + end + end + + class PolicyController < ApplicationController + permissions_policy do |f| + f.gyroscope :none unless helpers.pigs_can_fly? + f.usb :self if helpers.sky_is_blue? + end + + def index + head :ok + end + end + + ROUTES = ActionDispatch::Routing::RouteSet.new + ROUTES.draw do + scope module: "permissions_policy_with_helpers_integration_test" do + get "/", to: "policy#index" + end + end + + POLICY = ActionDispatch::PermissionsPolicy.new do |p| + p.gyroscope :self + end + + class PolicyConfigMiddleware + def initialize(app) + @app = app + end + + def call(env) + env["action_dispatch.permissions_policy"] = POLICY + env["action_dispatch.show_exceptions"] = :none + + @app.call(env) + end + end + + APP = build_app(ROUTES) do |middleware| + middleware.use PolicyConfigMiddleware + middleware.use Rack::Lint + middleware.use ActionDispatch::PermissionsPolicy::Middleware + middleware.use Rack::Lint + end + + def app + APP + end + + def test_generates_permissions_policy_header + get "/" + assert_policy "gyroscope 'none'; usb 'self'" + end + + private + def assert_policy(expected) + assert_response :success + assert_equal expected, response.headers[ActionDispatch::Constants::FEATURE_POLICY] + end +end diff --git a/actionpack/test/dispatch/prefix_generation_test.rb b/actionpack/test/dispatch/prefix_generation_test.rb index 0e093d218812a..5ee1a611ca130 100644 --- a/actionpack/test/dispatch/prefix_generation_test.rb +++ b/actionpack/test/dispatch/prefix_generation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "rack/test" require "rails/engine" @@ -11,7 +13,7 @@ def to_param end def self.model_name - klass = "Post" + klass = +"Post" def klass.name; self end ActiveModel::Name.new(klass) @@ -124,12 +126,19 @@ def ivar_usage end end + module KwObject + def initialize(kw:) + end + end + class EngineObject + include KwObject include ActionDispatch::Routing::UrlFor include BlogEngine.routes.url_helpers end class AppObject + include KwObject include ActionDispatch::Routing::UrlFor include RailsApplication.routes.url_helpers end @@ -142,24 +151,24 @@ def app def setup RailsApplication.routes.default_url_options = {} - @engine_object = EngineObject.new - @app_object = AppObject.new + @engine_object = EngineObject.new(kw: 1) + @app_object = AppObject.new(kw: 2) end include BlogEngine.routes.mounted_helpers # Inside Engine - test "[ENGINE] generating engine's url use SCRIPT_NAME from request" do + test "[ENGINE] generating engine's URL use SCRIPT_NAME from request" do get "/pure-awesomeness/blog/posts/1" assert_equal "/pure-awesomeness/blog/posts/1", response.body end - test "[ENGINE] generating application's url never uses SCRIPT_NAME from request" do + test "[ENGINE] generating application's URL never uses SCRIPT_NAME from request" do get "/pure-awesomeness/blog/url_to_application" assert_equal "/generate", response.body end - test "[ENGINE] generating engine's url with polymorphic path" do + test "[ENGINE] generating engine's URL with polymorphic path" do get "/pure-awesomeness/blog/polymorphic_path_for_engine" assert_equal "/pure-awesomeness/blog/posts/1", response.body end @@ -241,7 +250,7 @@ def setup assert_equal "/something/awesome/blog/posts/1", response.body end - test "[APP] generating engine's url with polymorphic path" do + test "[APP] generating engine's URL with polymorphic path" do get "/polymorphic_path_for_engine" assert_equal "/awesome/blog/posts/1", response.body end @@ -251,7 +260,7 @@ def setup assert_equal "/posts/1", response.body end - test "[APP] generating engine's url with url_for(@post)" do + test "[APP] generating engine's URL with url_for(@post)" do get "/polymorphic_with_url_for" assert_equal "http://www.example.com/awesome/blog/posts/1", response.body end @@ -302,7 +311,7 @@ def setup assert_equal "/omg/blog/posts/1", path end - test "[OBJECT] generating engine's route with named helpers" do + test "[OBJECT] generating engine's route with named route helpers" do path = engine_object.posts_path assert_equal "/awesome/blog/posts", path @@ -322,11 +331,7 @@ def setup def verify_redirect(url, status = 301) assert_equal status, response.status assert_equal url, response.headers["Location"] - assert_equal expected_redirect_body(url), response.body - end - - def expected_redirect_body(url) - %(You are being redirected.) + assert_equal "", response.body end end @@ -451,11 +456,7 @@ def app def verify_redirect(url, status = 301) assert_equal status, response.status assert_equal url, response.headers["Location"] - assert_equal expected_redirect_body(url), response.body - end - - def expected_redirect_body(url) - %(You are being redirected.) + assert_equal "", response.body end end end diff --git a/actionpack/test/dispatch/query_parser_test.rb b/actionpack/test/dispatch/query_parser_test.rb new file mode 100644 index 0000000000000..02a1e25238bfa --- /dev/null +++ b/actionpack/test/dispatch/query_parser_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class QueryParserTest < ActiveSupport::TestCase + test "simple query string" do + assert_equal [["foo", "bar"], ["baz", "quux"]], parsed_pairs("foo=bar&baz=quux") + end + + test "query string with empty and missing values" do + assert_equal [["foo", "bar"], ["empty", ""], ["missing", nil], ["baz", "quux"]], parsed_pairs("foo=bar&empty=&missing&baz=quux") + end + + test "custom separator" do + assert_equal [["foo", "bar"], ["baz", "quux"]], parsed_pairs("foo=bar;baz=quux", ";") + end + + test "non-standard separator" do + assert_equal [["foo", "bar"], ["baz", "quux"]], parsed_pairs("foo=bar/baz=quux", "/") + end + + test "mixed separators" do + assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&;") + end + + 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 + def parsed_pairs(query, separator = nil) + ActionDispatch::QueryParser.each_pair(query, separator).to_a + end +end diff --git a/actionpack/test/dispatch/rack_cache_test.rb b/actionpack/test/dispatch/rack_cache_test.rb index d7bb90abbf98e..86b375a2a8008 100644 --- a/actionpack/test/dispatch/rack_cache_test.rb +++ b/actionpack/test/dispatch/rack_cache_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_dispatch/http/rack_cache" diff --git a/actionpack/test/dispatch/reloader_test.rb b/actionpack/test/dispatch/reloader_test.rb index 9eb78fe059b15..4f73a8446bdb2 100644 --- a/actionpack/test/dispatch/reloader_test.rb +++ b/actionpack/test/dispatch/reloader_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class ReloaderTest < ActiveSupport::TestCase @@ -64,56 +66,33 @@ def test_condition_specifies_when_to_reload reloader.to_complete { |*args| j += 1 } app = middleware(lambda { |env| [200, {}, []] }, reloader) + env = Rack::MockRequest.env_for("", {}) 5.times do - resp = app.call({}) + resp = app.call(env) resp[2].close end assert_equal 3, i assert_equal 3, j end - def test_returned_body_object_behaves_like_underlying_object - body = call_and_return_body do - b = MyBody.new - b << "hello" - b << "world" - [200, { "Content-Type" => "text/html" }, b] - end - assert_equal 2, body.size - assert_equal "hello", body[0] - assert_equal "world", body[1] - assert_equal "foo", body.foo - assert_equal "bar", body.bar - end - def test_it_calls_close_on_underlying_object_when_close_is_called_on_body close_called = false body = call_and_return_body do b = MyBody.new do close_called = true end - [200, { "Content-Type" => "text/html" }, b] + [200, { "content-type" => "text/html" }, b] end body.close assert close_called end - def test_returned_body_object_responds_to_all_methods_supported_by_underlying_object - body = call_and_return_body do - [200, { "Content-Type" => "text/html" }, MyBody.new] - end - assert_respond_to body, :size - assert_respond_to body, :each - assert_respond_to body, :foo - assert_respond_to body, :bar - end - def test_complete_callbacks_are_called_when_body_is_closed completed = false reloader.to_complete { completed = true } body = call_and_return_body - assert !completed + assert_not completed body.close assert completed @@ -127,7 +106,7 @@ def test_prepare_callbacks_arent_called_when_body_is_closed prepared = false body.close - assert !prepared + assert_not prepared end def test_complete_callbacks_are_called_on_exceptions @@ -146,13 +125,14 @@ def test_complete_callbacks_are_called_on_exceptions private def call_and_return_body(&block) - app = middleware(block || proc { [200, {}, "response"] }) - _, _, body = app.call("rack.input" => StringIO.new("")) + app = block || proc { [200, {}, "response"] } + env = Rack::MockRequest.env_for("", {}) + _, _, body = middleware(app).call(env) body end def middleware(inner_app, reloader = reloader()) - ActionDispatch::Reloader.new(inner_app, reloader) + Rack::Lint.new(ActionDispatch::Reloader.new(Rack::Lint.new(inner_app), reloader)) end def reloader(check = lambda { true }) diff --git a/actionpack/test/dispatch/request/json_params_parsing_test.rb b/actionpack/test/dispatch/request/json_params_parsing_test.rb index 10234a481597d..650119a56ee05 100644 --- a/actionpack/test/dispatch/request/json_params_parsing_test.rb +++ b/actionpack/test/dispatch/request/json_params_parsing_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class JsonParamsParsingTest < ActionDispatch::IntegrationTest @@ -16,27 +18,34 @@ def teardown TestController.last_request_parameters = nil end - test "parses json params for application json" do + test "parses JSON params for application JSON" do assert_parses( { "person" => { "name" => "David" } }, "{\"person\": {\"name\": \"David\"}}", "CONTENT_TYPE" => "application/json" ) end - test "parses boolean and number json params for application json" do + test "parses boolean and number JSON params for application JSON" do assert_parses( { "item" => { "enabled" => false, "count" => 10 } }, "{\"item\": {\"enabled\": false, \"count\": 10}}", "CONTENT_TYPE" => "application/json" ) end - test "parses json params for application jsonrequest" do + test "parses JSON params for application jsonrequest" do assert_parses( { "person" => { "name" => "David" } }, "{\"person\": {\"name\": \"David\"}}", "CONTENT_TYPE" => "application/jsonrequest" ) end + test "parses JSON params for application problem+json" do + assert_parses( + { "person" => { "name" => "David" } }, + "{\"person\": {\"name\": \"David\"}}", "CONTENT_TYPE" => "application/problem+json" + ) + end + test "does not parse unregistered media types such as application/vnd.api+json" do assert_parses( {}, @@ -63,26 +72,24 @@ def teardown with_test_routing do output = StringIO.new json = "[\"person]\": {\"name\": \"David\"}}" - post "/parse", params: json, headers: { "CONTENT_TYPE" => "application/json", "action_dispatch.show_exceptions" => true, "action_dispatch.logger" => ActiveSupport::Logger.new(output) } + post "/parse", params: json, headers: { "CONTENT_TYPE" => "application/json", "action_dispatch.show_exceptions" => :all, "action_dispatch.logger" => ActiveSupport::Logger.new(output) } assert_response :bad_request output.rewind && err = output.read - assert err =~ /Error occurred while parsing request parameters/ + assert err.match?(/Error occurred while parsing request parameters/) end end test "occurring a parse error if parsing unsuccessful" do with_test_routing do - begin - $stderr = StringIO.new # suppress the log - json = "[\"person]\": {\"name\": \"David\"}}" - exception = assert_raise(ActionDispatch::Http::Parameters::ParseError) do - post "/parse", params: json, headers: { "CONTENT_TYPE" => "application/json", "action_dispatch.show_exceptions" => false } - end - assert_equal JSON::ParserError, exception.cause.class - assert_equal exception.cause.message, exception.message - ensure - $stderr = STDERR + $stderr = StringIO.new # suppress the log + json = "[\"person]\": {\"name\": \"David\"}}" + exception = assert_raise(ActionDispatch::Http::Parameters::ParseError) do + post "/parse", params: json, headers: { "CONTENT_TYPE" => "application/json", "action_dispatch.show_exceptions" => :none } end + assert_equal JSON::ParserError, exception.cause.class + assert_equal "Error occurred while parsing request parameters", exception.message + ensure + $stderr = STDERR end end @@ -105,7 +112,7 @@ def assert_parses(expected, actual, headers = {}) def with_test_routing with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do post ":action", to: ::JsonParamsParsingTest::TestController end end @@ -133,53 +140,56 @@ def teardown UsersController.last_request_parameters = nil end - test "parses json params for application json" do + test "parses JSON params for application JSON" do assert_parses( { "user" => { "username" => "sikachu" }, "username" => "sikachu" }, "{\"username\": \"sikachu\"}", "CONTENT_TYPE" => "application/json" ) end - test "parses json params for application jsonrequest" do + test "parses JSON params for application jsonrequest" do assert_parses( { "user" => { "username" => "sikachu" }, "username" => "sikachu" }, "{\"username\": \"sikachu\"}", "CONTENT_TYPE" => "application/jsonrequest" ) end - test "parses json with non-object JSON content" do + test "parses JSON params for application problem+json" do + assert_parses( + { "user" => { "username" => "sikachu" }, "username" => "sikachu" }, + "{\"username\": \"sikachu\"}", "CONTENT_TYPE" => "application/problem+json" + ) + end + + test "parses JSON with non-object JSON content" do assert_parses( { "user" => { "_json" => "string content" }, "_json" => "string content" }, "\"string content\"", "CONTENT_TYPE" => "application/json" ) end - test "parses json params after custom json mime type registered" do - begin - Mime::Type.unregister :json - Mime::Type.register "application/json", :json, %w(application/vnd.rails+json) - assert_parses( - { "user" => { "username" => "meinac" }, "username" => "meinac" }, - "{\"username\": \"meinac\"}", "CONTENT_TYPE" => "application/json" - ) - ensure - Mime::Type.unregister :json - Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest ) - end + test "parses JSON params after custom JSON mime type registered" do + Mime::Type.unregister :json + Mime::Type.register "application/json", :json, %w(application/vnd.rails+json) + assert_parses( + { "user" => { "username" => "meinac" }, "username" => "meinac" }, + "{\"username\": \"meinac\"}", "CONTENT_TYPE" => "application/json" + ) + ensure + Mime::Type.unregister :json + Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest application/problem+json ) end - test "parses json params after custom json mime type registered with synonym" do - begin - Mime::Type.unregister :json - Mime::Type.register "application/json", :json, %w(application/vnd.rails+json) - assert_parses( - { "user" => { "username" => "meinac" }, "username" => "meinac" }, - "{\"username\": \"meinac\"}", "CONTENT_TYPE" => "application/vnd.rails+json" - ) - ensure - Mime::Type.unregister :json - Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest ) - end + test "parses JSON params after custom JSON mime type registered with synonym" do + Mime::Type.unregister :json + Mime::Type.register "application/json", :json, %w(application/vnd.rails+json) + assert_parses( + { "user" => { "username" => "meinac" }, "username" => "meinac" }, + "{\"username\": \"meinac\"}", "CONTENT_TYPE" => "application/vnd.rails+json" + ) + ensure + Mime::Type.unregister :json + Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest application/problem+json ) end private @@ -195,7 +205,7 @@ def assert_parses(expected, actual, headers = {}) def with_test_routing(controller) with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do post ":action", to: controller end end diff --git a/actionpack/test/dispatch/request/multipart_params_parsing_test.rb b/actionpack/test/dispatch/request/multipart_params_parsing_test.rb index 01c5ff142974f..b13b12a030a66 100644 --- a/actionpack/test/dispatch/request/multipart_params_parsing_test.rb +++ b/actionpack/test/dispatch/request/multipart_params_parsing_test.rb @@ -1,11 +1,25 @@ +# frozen_string_literal: true + require "abstract_unit" class MultipartParamsParsingTest < ActionDispatch::IntegrationTest class TestController < ActionController::Base + skip_parameter_encoding("parse_binary") + class << self attr_accessor :last_request_parameters, :last_parameters end + def parse_binary + self.class.last_request_parameters = begin + request.request_parameters + rescue EOFError + {} + end + self.class.last_parameters = request.parameters + head :ok + end + def parse self.class.last_request_parameters = begin request.request_parameters @@ -21,7 +35,7 @@ def read end end - FIXTURE_PATH = File.dirname(__FILE__) + "/../../fixtures/multipart" + FIXTURE_PATH = File.expand_path("../../fixtures/multipart", __dir__) def teardown TestController.last_request_parameters = nil @@ -116,7 +130,7 @@ def teardown end test "parses mixed files" do - params = parse_multipart("mixed_files") + params = parse_multipart("mixed_files", "/parse_binary") assert_equal %w(files foo), params.keys.sort assert_equal "bar", params["foo"] @@ -143,8 +157,9 @@ def teardown test "uploads and reads binary file" do with_test_routing do fixture = FIXTURE_PATH + "/ruby_on_rails.jpg" - params = { uploaded_data: fixture_file_upload(fixture, "image/jpg") } + params = { uploaded_data: fixture_file_upload(fixture, "image/jpeg") } post "/read", params: params + assert_equal Encoding::ASCII_8BIT, response.body.encoding end end @@ -159,7 +174,7 @@ def teardown test "does not raise EOFError on GET request with multipart content-type" do with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action", controller: "multipart_params_parsing_test/test" end end @@ -178,10 +193,10 @@ def fixture(name) end end - def parse_multipart(name) + def parse_multipart(name, path = "/parse") with_test_routing do headers = fixture(name) - post "/parse", params: headers.delete("rack.input"), headers: headers + post path, params: headers.delete("rack.input"), headers: headers assert_response :ok TestController.last_request_parameters end @@ -190,7 +205,7 @@ def parse_multipart(name) def with_test_routing with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do post ":action", controller: "multipart_params_parsing_test/test" end end diff --git a/actionpack/test/dispatch/request/query_string_parsing_test.rb b/actionpack/test/dispatch/request/query_string_parsing_test.rb index 2499c33cef291..0dff563e8661d 100644 --- a/actionpack/test/dispatch/request/query_string_parsing_test.rb +++ b/actionpack/test/dispatch/request/query_string_parsing_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class QueryStringParsingTest < ActionDispatch::IntegrationTest @@ -16,9 +18,13 @@ def initialize(app) @app = app end + def populate_rack_cache(env) + Rack::Request.new(env).params + end + def call(env) # Trigger a Rack parse so that env caches the query params - Rack::Request.new(env).params + populate_rack_cache(env) @app.call(env) end end @@ -144,7 +150,7 @@ def test_array_parses_without_nil test "ambiguous query string returns a bad request" do with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action", to: ::QueryStringParsingTest::TestController end end @@ -155,16 +161,19 @@ def test_array_parses_without_nil end private + def app + @app ||= self.class.build_app do |middleware| + middleware.use(EarlyParse) + end + end + def assert_parses(expected, actual) with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action", to: ::QueryStringParsingTest::TestController end end - @app = self.class.build_app(set) do |middleware| - middleware.use(EarlyParse) - end get "/parse", params: actual assert_response :ok diff --git a/actionpack/test/dispatch/request/session_test.rb b/actionpack/test/dispatch/request/session_test.rb index 311b80ea0a6a6..169c8ea3197d3 100644 --- a/actionpack/test/dispatch/request/session_test.rb +++ b/actionpack/test/dispatch/request/session_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_dispatch/middleware/session/abstract_store" @@ -20,6 +22,7 @@ def test_to_hash s["foo"] = "bar" assert_equal "bar", s["foo"] assert_equal({ "foo" => "bar" }, s.to_hash) + assert_equal({ "foo" => "bar" }, s.to_h) end def test_create_merges_old @@ -47,6 +50,12 @@ def test_destroy assert_empty s end + def test_store + s = Session.create(store, req, {}) + s.store("foo", "bar") + assert_equal "bar", s["foo"] + end + def test_keys s = Session.create(store, req, {}) s["rails"] = "ftw" @@ -54,6 +63,11 @@ def test_keys assert_equal %w[rails adequate], s.keys end + def test_keys_with_deferred_loading + s = Session.create(store_with_data, req, {}) + assert_equal %w[sample_key], s.keys + end + def test_values s = Session.create(store, req, {}) s["rails"] = "ftw" @@ -61,6 +75,11 @@ def test_values assert_equal %w[ftw awesome], s.values end + def test_values_with_deferred_loading + s = Session.create(store_with_data, req, {}) + assert_equal %w[sample_value], s.values + end + def test_clear s = Session.create(store, req, {}) s["rails"] = "ftw" @@ -105,6 +124,52 @@ def test_fetch end end + def test_dig + session = Session.create(store, req, {}) + session["one"] = { "two" => "3" } + + assert_equal "3", session.dig("one", "two") + assert_equal "3", session.dig(:one, "two") + + assert_nil session.dig("three", "two") + assert_nil session.dig("one", "three") + assert_nil session.dig("one", :two) + end + + def test_id_was_for_new_session_that_does_not_exist + session = Session.create(store_for_session_that_does_not_exist, req, {}) + assert_nil session.id_was + end + + def test_id_was_for_session_that_does_not_exist_after_writing + session = Session.create(store_for_session_that_does_not_exist, req, {}) + session["one"] = "1" + assert_nil session.id_was + end + + def test_id_was_for_session_that_does_not_exist_after_destroying + session = Session.create(store_for_session_that_does_not_exist, req, {}) + session.destroy + assert_nil session.id_was + end + + def test_id_was_for_existing_session + session = Session.create(store, req, {}) + assert_equal 1, session.id_was + end + + def test_id_was_for_existing_session_after_write + session = Session.create(store, req, {}) + session["one"] = "1" + assert_equal 1, session.id_was + end + + def test_id_was_for_existing_session_after_destroy + session = Session.create(store, req, {}) + session.destroy + assert_equal 1, session.id_was + end + private def store Class.new { @@ -113,6 +178,22 @@ def session_exists?(env); true; end def delete_session(env, id, options); 123; end }.new end + + def store_with_data + Class.new { + def load_session(env); [1, { "sample_key" => "sample_value" }]; end + def session_exists?(env); true; end + def delete_session(env, id, options); 123; end + }.new + end + + def store_for_session_that_does_not_exist + Class.new { + def load_session(env); [1, {}]; end + def session_exists?(env); false; end + def delete_session(env, id, options); 123; end + }.new + end end class SessionIntegrationTest < ActionDispatch::IntegrationTest @@ -130,7 +211,11 @@ def call(env) end def app - @app ||= RoutedRackApp.new(Router) + @app ||= RoutedRackApp.new(Router) do |middleware| + @cache = ActiveSupport::Cache::MemoryStore.new + middleware.use ActionDispatch::Session::CacheStore, key: "_session_id", cache: @cache + middleware.use Rack::Lint + end end def test_session_follows_rack_api_contract_1 diff --git a/actionpack/test/dispatch/request/url_encoded_params_parsing_test.rb b/actionpack/test/dispatch/request/url_encoded_params_parsing_test.rb index 1169bf0cdbbb7..56acbe8d8ed3f 100644 --- a/actionpack/test/dispatch/request/url_encoded_params_parsing_test.rb +++ b/actionpack/test/dispatch/request/url_encoded_params_parsing_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class UrlEncodedParamsParsingTest < ActionDispatch::IntegrationTest @@ -107,7 +109,7 @@ def teardown query = [ "customers[boston][first][name]=David", "something_else=blah", - "logo=#{File.expand_path(__FILE__)}" + "logo=#{__FILE__}" ].join("&") expected = { "customers" => { @@ -118,7 +120,7 @@ def teardown } }, "something_else" => "blah", - "logo" => File.expand_path(__FILE__), + "logo" => __FILE__, } assert_parses expected, query end @@ -140,7 +142,7 @@ def teardown def with_test_routing with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do post ":action", to: ::UrlEncodedParamsParsingTest::TestController end end diff --git a/actionpack/test/dispatch/request_id_test.rb b/actionpack/test/dispatch/request_id_test.rb index 4fcd45acf55e3..eb813c6d6ba4e 100644 --- a/actionpack/test/dispatch/request_id_test.rb +++ b/actionpack/test/dispatch/request_id_test.rb @@ -1,16 +1,27 @@ +# frozen_string_literal: true + require "abstract_unit" class RequestIdTest < ActiveSupport::TestCase test "passing on the request id from the outside" do - assert_equal "external-uu-rid", stub_request("HTTP_X_REQUEST_ID" => "external-uu-rid").request_id + assert_equal "external-uu-rid", stub_request({ "HTTP_X_REQUEST_ID" => "external-uu-rid" }).request_id + end + + test "passing on the request id via a configured header" do + assert_equal "external-uu-rid", stub_request({ "HTTP_TRACER_ID" => "external-uu-rid" }, header: "tracer-id").request_id end test "ensure that only alphanumeric uurids are accepted" do - assert_equal "X-Hacked-HeaderStuff", stub_request("HTTP_X_REQUEST_ID" => "; X-Hacked-Header: Stuff").request_id + assert_equal "X-Hacked-HeaderStuff", stub_request({ "HTTP_X_REQUEST_ID" => "; X-Hacked-Header: Stuff" }).request_id + end + + test "accept Apache mod_unique_id format" do + mod_unique_id = "abcxyz@ABCXYZ-0123456789" + assert_equal mod_unique_id, stub_request({ "HTTP_X_REQUEST_ID" => mod_unique_id }).request_id end test "ensure that 255 char limit on the request id is being enforced" do - assert_equal "X" * 255, stub_request("HTTP_X_REQUEST_ID" => "X" * 500).request_id + assert_equal "X" * 255, stub_request({ "HTTP_X_REQUEST_ID" => "X" * 500 }).request_id end test "generating a request id when none is supplied" do @@ -18,13 +29,21 @@ class RequestIdTest < ActiveSupport::TestCase end test "uuid alias" do - assert_equal "external-uu-rid", stub_request("HTTP_X_REQUEST_ID" => "external-uu-rid").uuid + assert_equal "external-uu-rid", stub_request({ "HTTP_X_REQUEST_ID" => "external-uu-rid" }).uuid end private + def stub_request(env = {}, header: "x-request-id") + app = lambda { |_env| [ 200, {}, [] ] } + env = Rack::MockRequest.env_for("", env) + + Rack::Lint.new( + ActionDispatch::RequestId.new( + Rack::Lint.new(app), + header: header, + ) + ).call(env) - def stub_request(env = {}) - ActionDispatch::RequestId.new(lambda { |environment| [ 200, environment, [] ] }).call(env) ActionDispatch::Request.new(env) end end @@ -36,6 +55,10 @@ def index end end + setup do + @header = "X-Request-Id" + end + test "request id is passed all the way to the response" do with_test_route_set do get "/" @@ -50,18 +73,29 @@ def index end end + test "using a custom request_id header key" do + @header = "X-Tracer-Id" + with_test_route_set do + get "/" + assert_match(/\w+/, @response.headers["X-Tracer-Id"]) + end + end + private + def app + @app ||= self.class.build_app do |middleware| + middleware.use Rack::Lint + middleware.use ActionDispatch::RequestId, header: @header + middleware.use Rack::Lint + end + end - def with_test_route_set + def with_test_route_set(header: "X-Request-Id") with_routing do |set| set.draw do get "/", to: ::RequestIdResponseTest::TestController.action(:index) end - @app = self.class.build_app(set) do |middleware| - middleware.use ActionDispatch::RequestId - end - yield end end diff --git a/actionpack/test/dispatch/request_test.rb b/actionpack/test/dispatch/request_test.rb index 2f9228a62d400..27d3cb25f1396 100644 --- a/actionpack/test/dispatch/request_test.rb +++ b/actionpack/test/dispatch/request_test.rb @@ -1,11 +1,9 @@ +# frozen_string_literal: true + require "abstract_unit" class BaseRequestTest < ActiveSupport::TestCase def setup - @env = { - :ip_spoofing_check => true, - "rack.input" => "foo" - } @original_tld_length = ActionDispatch::Http::URL.tld_length end @@ -21,13 +19,23 @@ def url_for(options = {}) private def stub_request(env = {}) ip_spoofing_check = env.key?(:ip_spoofing_check) ? env.delete(:ip_spoofing_check) : true - @trusted_proxies ||= nil - ip_app = ActionDispatch::RemoteIp.new(Proc.new {}, ip_spoofing_check, @trusted_proxies) ActionDispatch::Http::URL.tld_length = env.delete(:tld_length) if env.key?(:tld_length) + uri = env["HTTPS"] == "on" ? "https://www.example.org" : "http://www.example.org" + + env = Rack::MockRequest.env_for(uri, env) + @additional_trusted_proxy ||= nil + trusted_proxies = ActionDispatch::RemoteIp::TRUSTED_PROXIES + [@additional_trusted_proxy] + + ip_app = Rack::Lint.new( + ActionDispatch::RemoteIp.new( + Rack::Lint.new(Proc.new { [200, {}, []] }), + ip_spoofing_check, + trusted_proxies, + ) + ) ip_app.call(env) - env = @env.merge(env) ActionDispatch::Request.new(env) end end @@ -39,9 +47,6 @@ class RequestUrlFor < BaseRequestTest assert_equal "/books", url_for(only_path: true, path: "/books") - assert_equal "http://www.example.com/books/?q=code", url_for(trailing_slash: true, path: "/books?q=code") - assert_equal "http://www.example.com/books/?spareslashes=////", url_for(trailing_slash: true, path: "/books?spareslashes=////") - assert_equal "http://www.example.com", url_for assert_equal "http://api.example.com", url_for(subdomain: "api") assert_equal "http://example.com", url_for(subdomain: false) @@ -75,6 +80,10 @@ class RequestIP < BaseRequestTest "HTTP_X_FORWARDED_FOR" => "3.4.5.6" assert_equal "3.4.5.6", request.remote_ip + request = stub_request "REMOTE_ADDR" => "127.0.0.1", + "HTTP_X_FORWARDED_FOR" => "172.31.4.4, 10.0.0.1" + assert_equal "172.31.4.4", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "3.4.5.6,unknown" assert_equal "3.4.5.6", request.remote_ip @@ -87,20 +96,31 @@ class RequestIP < BaseRequestTest request = stub_request "HTTP_X_FORWARDED_FOR" => "3.4.5.6,10.0.0.1" assert_equal "3.4.5.6", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "172.31.4.4, 10.0.0.1" + assert_equal "172.31.4.4", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "3.4.5.6, 10.0.0.1, 10.0.0.1" assert_equal "3.4.5.6", request.remote_ip 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_nil request.remote_ip + assert_equal "192.168.0.1", request.remote_ip request = stub_request "HTTP_X_FORWARDED_FOR" => "9.9.9.9, 3.4.5.6, 172.31.4.4, 10.0.0.1" assert_equal "3.4.5.6", request.remote_ip request = stub_request "HTTP_X_FORWARDED_FOR" => "not_ip_address" assert_nil request.remote_ip + + request = stub_request "REMOTE_ADDR" => "1.2.3.4" + assert_equal "1.2.3.4", request.remote_ip + request.remote_ip = "2.3.4.5" + assert_equal "2.3.4.5", request.remote_ip end test "remote ip spoof detection" do @@ -110,8 +130,25 @@ class RequestIP < BaseRequestTest request.remote_ip } assert_match(/IP spoofing attack/, e.message) - assert_match(/HTTP_X_FORWARDED_FOR="1.1.1.1"/, e.message) - assert_match(/HTTP_CLIENT_IP="2.2.2.2"/, e.message) + assert_match(/HTTP_X_FORWARDED_FOR="1\.1\.1\.1"/, e.message) + 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 @@ -133,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" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329,unknown" + 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]: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_nil request.remote_ip + 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 @@ -167,25 +207,25 @@ 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 - @trusted_proxies = "67.205.106.73" + @additional_trusted_proxy = "67.205.106.73" request = stub_request "REMOTE_ADDR" => "3.4.5.6", "HTTP_X_FORWARDED_FOR" => "67.205.106.73" @@ -200,14 +240,14 @@ class RequestIP < BaseRequestTest assert_equal "3.4.5.6", request.remote_ip request = stub_request "HTTP_X_FORWARDED_FOR" => "67.205.106.73,unknown" - assert_nil request.remote_ip + assert_equal "67.205.106.73", request.remote_ip # change request = stub_request "HTTP_X_FORWARDED_FOR" => "9.9.9.9, 3.4.5.6, 10.0.0.1, 67.205.106.73" assert_equal "3.4.5.6", request.remote_ip end test "remote ip v6 with user specified trusted proxies String" do - @trusted_proxies = "fe80:0000:0000:0000:0202:b3ff:fe1e:8329" + @additional_trusted_proxy = "fe80:0000:0000:0000:0202:b3ff:fe1e:8329" 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" @@ -219,17 +259,17 @@ class RequestIP < BaseRequestTest request = stub_request "REMOTE_ADDR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329,::1", "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329" - assert_equal "::1", request.remote_ip + assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip request = stub_request "HTTP_X_FORWARDED_FOR" => "unknown,fe80:0000:0000:0000:0202:b3ff:fe1e:8329" - assert_nil request.remote_ip + 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,2001:0db8:85a3:0000:0000:8a2e:0370:7334" assert_equal "2001:0db8:85a3:0000:0000:8a2e:0370:7334", request.remote_ip end test "remote ip with user specified trusted proxies Regexp" do - @trusted_proxies = /^67\.205\.106\.73$/i + @additional_trusted_proxy = /^67\.205\.106\.73$/i request = stub_request "REMOTE_ADDR" => "67.205.106.73", "HTTP_X_FORWARDED_FOR" => "3.4.5.6" @@ -240,7 +280,7 @@ class RequestIP < BaseRequestTest end test "remote ip v6 with user specified trusted proxies Regexp" do - @trusted_proxies = /^fe80:0000:0000:0000:0202:b3ff:fe1e:8329$/i + @additional_trusted_proxy = /^fe80:0000:0000:0000:0202:b3ff:fe1e:8329$/i 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" @@ -294,7 +334,7 @@ class RequestDomain < BaseRequestTest assert_equal %w( 192 168 1 ), request.subdomains assert_equal "192.168.1", request.subdomain - request = stub_request "HTTP_HOST" => nil + request = stub_request "HTTP_HOST" => "" assert_equal [], request.subdomains assert_equal "", request.subdomain @@ -316,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 @@ -327,20 +395,20 @@ class RequestPort < BaseRequestTest test "standard_port?" do request = stub_request - assert !request.ssl? - assert request.standard_port? + assert_not_predicate request, :ssl? + assert_predicate request, :standard_port? request = stub_request "HTTPS" => "on" - assert request.ssl? - assert request.standard_port? + assert_predicate request, :ssl? + assert_predicate request, :standard_port? request = stub_request "HTTP_HOST" => "www.example.org:8080" - assert !request.ssl? - assert !request.standard_port? + assert_not_predicate request, :ssl? + assert_not_predicate request, :standard_port? request = stub_request "HTTP_HOST" => "www.example.org:8443", "HTTPS" => "on" - assert request.ssl? - assert !request.standard_port? + assert_predicate request, :ssl? + assert_not_predicate request, :standard_port? end test "optional port" do @@ -366,7 +434,7 @@ class RequestPort < BaseRequestTest request = stub_request "SERVER_PORT" => "80" assert_equal 80, request.server_port - request = stub_request "SERVER_PORT" => "" + request = stub_request "SERVER_PORT" => "0" assert_equal 0, request.server_port end end @@ -409,7 +477,7 @@ class RequestPath < BaseRequestTest assert_equal "/foo?bar", path end - test "original_url returns url built using ORIGINAL_FULLPATH" do + test "original_url returns URL built using ORIGINAL_FULLPATH" do request = stub_request("ORIGINAL_FULLPATH" => "/foo?bar", "HTTP_HOST" => "example.org", "rack.url_scheme" => "http") @@ -569,7 +637,7 @@ class RequestCGI < BaseRequestTest class LocalhostTest < BaseRequestTest test "IPs that match localhost" do request = stub_request("REMOTE_IP" => "127.1.1.1", "REMOTE_ADDR" => "127.1.1.1") - assert request.local? + assert_predicate request, :local? end end @@ -578,57 +646,85 @@ class RequestCookie < BaseRequestTest request = stub_request("HTTP_COOKIE" => "_session_id=c84ace84796670c052c6ceb2451fb0f2; is_admin=yes") assert_equal "c84ace84796670c052c6ceb2451fb0f2", request.cookies["_session_id"], request.cookies.inspect assert_equal "yes", request.cookies["is_admin"], request.cookies.inspect - - # some Nokia phone browsers omit the space after the semicolon separator. - # some developers have grown accustomed to using comma in cookie values. - request = stub_request("HTTP_COOKIE" => "_session_id=c84ace847,96670c052c6ceb2451fb0f2;is_admin=yes") - assert_equal "c84ace847", request.cookies["_session_id"], request.cookies.inspect - assert_equal "yes", request.cookies["is_admin"], request.cookies.inspect end end class RequestParamsParsing < BaseRequestTest - test "doesnt break when content type has charset" do + test "doesn't break when content type has charset" do request = stub_request( "REQUEST_METHOD" => "POST", - "CONTENT_LENGTH" => "flamenco=love".length, + "CONTENT_LENGTH" => "flamenco=love".length.to_s, "CONTENT_TYPE" => "application/x-www-form-urlencoded; charset=utf-8", - "rack.input" => StringIO.new("flamenco=love") + :input => "flamenco=love" ) assert_equal({ "flamenco" => "love" }, request.request_parameters) end - test "doesnt interpret request uri as query string when missing" do + test "doesn't interpret request uri as query string when missing" do request = stub_request("REQUEST_URI" => "foo") assert_equal({}, request.query_parameters) end -end -class RequestRewind < BaseRequestTest - test "body should be rewound" do - data = "rewind" - env = { - "rack.input" => StringIO.new(data), - "CONTENT_LENGTH" => data.length, - "CONTENT_TYPE" => "application/x-www-form-urlencoded; charset=utf-8" - } + # partially mimics https://github.com/rack/rack/blob/249dd785625f0cbe617d3144401de90ecf77025a/test/spec_multipart.rb#L114 + test "request_parameters raises BadRequest when content length lower than actual data length for a multipart request" do + request = stub_request( + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => "9", # lower than data length + "REQUEST_METHOD" => "POST", + :input => "0123456789" + ) - # Read the request body by parsing params. - request = stub_request(env) - request.request_parameters + err = assert_raises(ActionController::BadRequest) do + request.request_parameters + end - # Should have rewound the body. - assert_equal 0, request.body.pos + # original error message is Rack::Multipart::EmptyContentError for rack > 3 otherwise EOFError + assert_match "Invalid request parameters:", err.message end - test "raw_post rewinds rack.input if RAW_POST_DATA is nil" do + test "request_parameters raises BadRequest when content length is higher than actual data length" do request = stub_request( - "rack.input" => StringIO.new("raw"), - "CONTENT_LENGTH" => 3 + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => "11", # higher than data length + "REQUEST_METHOD" => "POST", + :input => "0123456789" ) - assert_equal "raw", request.raw_post - assert_equal "raw", request.env["rack.input"].read + + err = assert_raises(ActionController::BadRequest) do + request.request_parameters + end + + assert_equal "Invalid request parameters: bad content body", err.message + end +end + +if Rack.release < "3" + class RequestRewind < BaseRequestTest + test "body should be rewound" do + data = "rewind" + env = { + :input => data, + "CONTENT_LENGTH" => data.length.to_s, + "CONTENT_TYPE" => "application/x-www-form-urlencoded; charset=utf-8" + } + + # Read the request body by parsing params. + request = stub_request(env) + request.request_parameters + + # Should have rewound the body. + assert_equal "rewind", request.body.read + end + + test "raw_post rewinds rack.input if RAW_POST_DATA is nil" do + request = stub_request( + :input => "raw", + "CONTENT_LENGTH" => "3" + ) + assert_equal "raw", request.raw_post + assert_equal "raw", request.env["rack.input"].read + end end end @@ -641,37 +737,37 @@ class RequestProtocol < BaseRequestTest test "xml http request" do request = stub_request - assert !request.xml_http_request? - assert !request.xhr? + assert_not_predicate request, :xml_http_request? + assert_not_predicate request, :xhr? request = stub_request "HTTP_X_REQUESTED_WITH" => "DefinitelyNotAjax1.0" - assert !request.xml_http_request? - assert !request.xhr? + assert_not_predicate request, :xml_http_request? + assert_not_predicate request, :xhr? request = stub_request "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest" - assert request.xml_http_request? - assert request.xhr? + assert_predicate request, :xml_http_request? + assert_predicate request, :xhr? end test "reports ssl" do - assert !stub_request.ssl? - assert stub_request("HTTPS" => "on").ssl? + assert_not_predicate stub_request, :ssl? + assert_predicate stub_request("HTTPS" => "on"), :ssl? end test "reports ssl when proxied via lighttpd" do - assert stub_request("HTTP_X_FORWARDED_PROTO" => "https").ssl? + assert_predicate stub_request("HTTP_X_FORWARDED_PROTO" => "https"), :ssl? end test "scheme returns https when proxied" do request = stub_request "rack.url_scheme" => "http" - assert !request.ssl? + assert_not_predicate request, :ssl? assert_equal "http", request.scheme request = stub_request( "rack.url_scheme" => "http", "HTTP_X_FORWARDED_PROTO" => "https" ) - assert request.ssl? + assert_predicate request, :ssl? assert_equal "https", request.scheme end end @@ -679,7 +775,6 @@ class RequestProtocol < BaseRequestTest class RequestMethod < BaseRequestTest test "method returns environment's request method when it has not been overridden by middleware".squish do - ActionDispatch::Request::HTTP_METHODS.each do |method| request = stub_request("REQUEST_METHOD" => method) @@ -698,7 +793,7 @@ class RequestMethod < BaseRequestTest assert_equal "GET", request.request_method assert_equal "GET", request.env["REQUEST_METHOD"] - assert request.get? + assert_predicate request, :get? end test "invalid http method raises exception" do @@ -746,7 +841,7 @@ class RequestMethod < BaseRequestTest assert_equal "POST", request.method assert_equal "PATCH", request.request_method - assert request.patch? + assert_predicate request, :patch? end test "post masquerading as put" do @@ -756,12 +851,11 @@ class RequestMethod < BaseRequestTest ) assert_equal "POST", request.method assert_equal "PUT", request.request_method - assert request.put? + assert_predicate request, :put? end test "post uneffected by local inflections" do existing_acronyms = ActiveSupport::Inflector.inflections.acronyms.dup - existing_acronym_regex = ActiveSupport::Inflector.inflections.acronym_regex.dup begin ActiveSupport::Inflector.inflections do |inflect| inflect.acronym "POS" @@ -770,63 +864,65 @@ class RequestMethod < BaseRequestTest request = stub_request "REQUEST_METHOD" => "POST" assert_equal :post, ActionDispatch::Request::HTTP_METHOD_LOOKUP["POST"] assert_equal :post, request.method_symbol - assert request.post? + assert_predicate request, :post? ensure # Reset original acronym set ActiveSupport::Inflector.inflections do |inflect| - inflect.send(:instance_variable_set, "@acronyms", existing_acronyms) - inflect.send(:instance_variable_set, "@acronym_regex", existing_acronym_regex) + inflect.instance_variable_set :@acronyms, existing_acronyms + inflect.send(:define_acronym_regex_patterns) end end end + + test "delegates to Object#method if an argument is passed" do + request = stub_request + + assert_nothing_raised do + request.method(:POST) + end + end end class RequestFormat < BaseRequestTest test "xml format" do - request = stub_request - assert_called(request, :parameters, times: 2, returns: { format: :xml }) do - assert_equal Mime[:xml], request.format - end + request = stub_request "QUERY_STRING" => "format=xml" + + assert_equal Mime[:xml], request.format end test "xhtml format" do - request = stub_request - assert_called(request, :parameters, times: 2, returns: { format: :xhtml }) do - assert_equal Mime[:html], request.format - end + request = stub_request "QUERY_STRING" => "format=xhtml" + + assert_equal Mime[:html], request.format end test "txt format" do - request = stub_request - assert_called(request, :parameters, times: 2, returns: { format: :txt }) do - assert_equal Mime[:text], request.format - end + request = stub_request "QUERY_STRING" => "format=txt" + + assert_equal Mime[:text], request.format end test "XMLHttpRequest" do request = stub_request( "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest", - "HTTP_ACCEPT" => [Mime[:js], Mime[:html], Mime[:xml], "text/xml", "*/*"].join(",") + "HTTP_ACCEPT" => [Mime[:js], Mime[:html], Mime[:xml], "text/xml", "*/*"].join(","), + "QUERY_STRING" => "" ) - assert_called(request, :parameters, times: 1, returns: {}) do - assert request.xhr? - assert_equal Mime[:js], request.format - end + assert_predicate request, :xhr? + assert_equal Mime[:js], request.format end test "can override format with parameter negative" do - request = stub_request - assert_called(request, :parameters, times: 2, returns: { format: :txt }) do - assert !request.format.xml? - end + request = stub_request("QUERY_STRING" => "format=txt") + + assert_not_predicate request.format, :xml? end test "can override format with parameter positive" do - request = stub_request - assert_called(request, :parameters, times: 2, returns: { format: :xml }) do - assert request.format.xml? - end + request = stub_request("QUERY_STRING" => "format=xml") + + assert_predicate request.format, :xml? end test "formats text/html with accept header" do @@ -851,40 +947,54 @@ class RequestFormat < BaseRequestTest end test "formats format:text with accept header" do - request = stub_request - assert_called(request, :parameters, times: 2, returns: { format: :txt }) do - assert_equal [Mime[:text]], request.formats - end + request = stub_request("QUERY_STRING" => "format=txt") + + assert_equal [Mime[:text]], request.formats end test "formats format:unknown with accept header" do - request = stub_request - assert_called(request, :parameters, times: 2, returns: { format: :unknown }) do - assert_instance_of Mime::NullType, request.format - end + request = stub_request("QUERY_STRING" => "format=unknown") + + assert_instance_of Mime::NullType, request.format end test "format is not nil with unknown format" do - request = stub_request - assert_called(request, :parameters, times: 2, returns: { format: :hello }) do - assert request.format.nil? - assert_not request.format.html? - assert_not request.format.xml? - assert_not request.format.json? - end + request = stub_request("QUERY_STRING" => "format=hello") + + assert_nil request.format + assert_not_predicate request.format, :html? + assert_not_predicate request.format, :xml? + assert_not_predicate request.format, :json? end - test "format does not throw exceptions when malformed parameters" do + test "format does not throw exceptions when malformed GET parameters" do request = stub_request("QUERY_STRING" => "x[y]=1&x[y][][w]=2") assert request.formats - assert request.format.html? + assert_predicate request.format, :html? + end + + test "format does not throw exceptions when invalid POST parameters" do + body = "{record:{content:127.0.0.1}}" + + request = stub_request( + "REQUEST_METHOD" => "POST", + "CONTENT_LENGTH" => body.length.to_s, + "CONTENT_TYPE" => "application/json", + :input => body, + "action_dispatch.logger" => Logger.new(output = StringIO.new) + ) + assert request.formats + assert_predicate request.format, :html? + + output.rewind && (err = output.read) + assert_match(/Error occurred while parsing request parameters/, err) end test "formats with xhr request" do - request = stub_request "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest" - assert_called(request, :parameters, times: 1, returns: {}) do - assert_equal [Mime[:js]], request.formats - end + request = stub_request "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest", + "QUERY_STRING" => "" + + assert_equal [Mime[:js]], request.formats end test "ignore_accept_header" do @@ -892,80 +1002,100 @@ class RequestFormat < BaseRequestTest ActionDispatch::Request.ignore_accept_header = true begin - request = stub_request "HTTP_ACCEPT" => "application/xml" - assert_called(request, :parameters, times: 1, returns: {}) do - assert_equal [ Mime[:html] ], request.formats - end + request = stub_request "HTTP_ACCEPT" => "application/xml", + "QUERY_STRING" => "" - request = stub_request "HTTP_ACCEPT" => "koz-asked/something-crazy" - assert_called(request, :parameters, times: 1, returns: {}) do - assert_equal [ Mime[:html] ], request.formats - end + assert_equal [ Mime[:html] ], request.formats - request = stub_request "HTTP_ACCEPT" => "*/*;q=0.1" - assert_called(request, :parameters, times: 1, returns: {}) do - assert_equal [ Mime[:html] ], request.formats - end + request = stub_request "HTTP_ACCEPT" => "koz-asked/something-wild", + "QUERY_STRING" => "" - request = stub_request "HTTP_ACCEPT" => "application/jxw" - assert_called(request, :parameters, times: 1, returns: {}) do - assert_equal [ Mime[:html] ], request.formats - end + assert_equal [ Mime[:html] ], request.formats + + request = stub_request "HTTP_ACCEPT" => "*/*;q=0.1", + "QUERY_STRING" => "" + + assert_equal [ Mime[:html] ], request.formats + + request = stub_request "HTTP_ACCEPT" => "application/jxw", + "QUERY_STRING" => "" + + assert_equal [ Mime[:html] ], request.formats request = stub_request "HTTP_ACCEPT" => "application/xml", - "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest" + "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest", + "QUERY_STRING" => "" - assert_called(request, :parameters, times: 1, returns: {}) do - assert_equal [ Mime[:js] ], request.formats - end + assert_equal [ Mime[:js] ], request.formats request = stub_request "HTTP_ACCEPT" => "application/xml", - "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest" - assert_called(request, :parameters, times: 2, returns: { format: :json }) do - assert_equal [ Mime[:json] ], request.formats - end + "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest", + "QUERY_STRING" => "format=json" + + assert_equal [ Mime[:json] ], request.formats ensure ActionDispatch::Request.ignore_accept_header = old_ignore_accept_header end end test "format taken from the path extension" do - request = stub_request "PATH_INFO" => "/foo.xml" - assert_called(request, :parameters, times: 1, returns: {}) do - assert_equal [Mime[:xml]], request.formats - end + request = stub_request "PATH_INFO" => "/foo.xml", "QUERY_STRING" => "" - request = stub_request "PATH_INFO" => "/foo.123" - assert_called(request, :parameters, times: 1, returns: {}) do - assert_equal [Mime[:html]], request.formats - end + assert_equal [Mime[:xml]], request.formats + + request = stub_request "PATH_INFO" => "/foo.123", "QUERY_STRING" => "" + + assert_equal [Mime[:html]], request.formats end test "formats from accept headers have higher precedence than path extension" do request = stub_request "HTTP_ACCEPT" => "application/json", - "PATH_INFO" => "/foo.xml" + "PATH_INFO" => "/foo.xml", + "QUERY_STRING" => "" - assert_called(request, :parameters, times: 1, returns: {}) do - assert_equal [Mime[:json]], request.formats - end + assert_equal [Mime[:json]], request.formats end end class RequestMimeType < BaseRequestTest test "content type" do - assert_equal Mime[:html], stub_request("CONTENT_TYPE" => "text/html").content_mime_type + request = stub_request("CONTENT_TYPE" => "text/html") + + assert_equal(Mime[:html], request.content_mime_type) + assert_equal("text/html", request.media_type) + assert_nil(request.content_charset) + assert_equal({}, request.media_type_params) + assert_equal("text/html", request.content_type) end test "no content type" do - assert_nil stub_request.content_mime_type + request = stub_request + + assert_nil(request.content_mime_type) + assert_nil(request.media_type) + assert_nil(request.content_charset) + assert_equal({}, request.media_type_params) + assert_nil(request.content_type) end test "content type is XML" do - assert_equal Mime[:xml], stub_request("CONTENT_TYPE" => "application/xml").content_mime_type + request = stub_request("CONTENT_TYPE" => "application/xml") + + assert_equal(Mime[:xml], request.content_mime_type) + assert_equal("application/xml", request.media_type) + assert_nil(request.content_charset) + assert_equal({}, request.media_type_params) + assert_equal("application/xml", request.content_type) end test "content type with charset" do - assert_equal Mime[:xml], stub_request("CONTENT_TYPE" => "application/xml; charset=UTF-8").content_mime_type + request = stub_request("CONTENT_TYPE" => "application/xml; charset=UTF-8") + + assert_equal(Mime[:xml], request.content_mime_type) + assert_equal("application/xml", request.media_type) + assert_equal("UTF-8", request.content_charset) + assert_equal({ "charset" => "UTF-8" }, request.media_type_params) + assert_equal("application/xml; charset=UTF-8", request.content_type) end test "user agent" do @@ -995,15 +1125,14 @@ class RequestMimeType < BaseRequestTest class RequestParameters < BaseRequestTest test "parameters" do - request = stub_request + request = stub_request "CONTENT_TYPE" => "application/json", + "CONTENT_LENGTH" => "9", + "RAW_POST_DATA" => '{"foo":1}', + "QUERY_STRING" => "bar=2" - assert_called(request, :request_parameters, times: 2, returns: { "foo" => 1 }) do - assert_called(request, :query_parameters, times: 2, returns: { "bar" => 2 }) do - assert_equal({ "foo" => 1, "bar" => 2 }, request.parameters) - assert_equal({ "foo" => 1 }, request.request_parameters) - assert_equal({ "bar" => 2 }, request.query_parameters) - end - end + assert_equal({ "foo" => 1, "bar" => "2" }, request.parameters) + assert_equal({ "foo" => 1 }, request.request_parameters) + assert_equal({ "bar" => "2" }, request.query_parameters) end test "parameters not accessible after rack parse error" do @@ -1024,12 +1153,18 @@ class RequestParameters < BaseRequestTest request.path_parameters = { foo: "\xBE" } end - assert_equal "Invalid path parameters: Non UTF-8 value: \xBE", err.message + assert_predicate err.message, :valid_encoding? + assert_equal "Invalid path parameters: Invalid encoding for parameter: �", err.message end - test "parameters not accessible after rack parse error of invalid UTF8 character" do - request = stub_request("QUERY_STRING" => "foo%81E=1") - assert_raises(ActionController::BadRequest) { request.parameters } + test "path parameters don't re-encode frozen strings" do + request = stub_request + + ActionDispatch::Request::Utils::CustomParamEncoder.stub(:action_encoding_template, Hash.new { Encoding::BINARY }) do + request.path_parameters = { foo: "frozen", bar: +"mutable", controller: "test_controller" } + assert_equal Encoding::BINARY, request.params[:bar].encoding + assert_equal Encoding::UTF_8, request.params[:foo].encoding + end end test "parameters containing an invalid UTF8 character" do @@ -1037,17 +1172,63 @@ 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 } end + test "POST parameters containing invalid UTF8 character" do + data = "foo=%81E" + request = stub_request( + "REQUEST_METHOD" => "POST", + "CONTENT_LENGTH" => data.length.to_s, + "CONTENT_TYPE" => "application/x-www-form-urlencoded; charset=utf-8", + :input => data + ) + + err = assert_raises(ActionController::BadRequest) { request.parameters } + + assert_predicate err.message, :valid_encoding? + assert_equal "Invalid request parameters: Invalid encoding for parameter: �E", err.message + end + + test "query parameters specified as ASCII_8BIT encoded do not raise InvalidParameterError" do + request = stub_request("QUERY_STRING" => "foo=%81E") + + ActionDispatch::Request::Utils::CustomParamEncoder.stub(:action_encoding_template, { "foo" => Encoding::ASCII_8BIT }) do + assert_nothing_raised do + request.parameters + end + end + end + + test "POST parameters specified as ASCII_8BIT encoded do not raise InvalidParameterError" do + data = "foo=%81E" + request = stub_request( + "REQUEST_METHOD" => "POST", + "CONTENT_LENGTH" => data.length.to_s, + "CONTENT_TYPE" => "application/x-www-form-urlencoded; charset=utf-8", + :input => data + ) + + ActionDispatch::Request::Utils::CustomParamEncoder.stub(:action_encoding_template, { "foo" => Encoding::ASCII_8BIT }) do + assert_nothing_raised do + request.parameters + end + end + end + test "parameters not accessible after rack parse error 1" do request = stub_request( "REQUEST_METHOD" => "POST", - "CONTENT_LENGTH" => "a%=".length, + "CONTENT_LENGTH" => "a%=".length.to_s, "CONTENT_TYPE" => "application/x-www-form-urlencoded; charset=utf-8", - "rack.input" => StringIO.new("a%=") + :input => "a%=" ) assert_raises(ActionController::BadRequest) do @@ -1067,37 +1248,16 @@ class RequestParameters < BaseRequestTest assert_not_nil e.cause assert_equal e.cause.backtrace, e.backtrace end -end -class RequestParameterFilter < BaseRequestTest - test "process parameter filter" do - test_hashes = [ - [{ "foo" => "bar" }, { "foo" => "bar" }, %w'food'], - [{ "foo" => "bar" }, { "foo" => "[FILTERED]" }, %w'foo'], - [{ "foo" => "bar", "bar" => "foo" }, { "foo" => "[FILTERED]", "bar" => "foo" }, %w'foo baz'], - [{ "foo" => "bar", "baz" => "foo" }, { "foo" => "[FILTERED]", "baz" => "[FILTERED]" }, %w'foo baz'], - [{ "bar" => { "foo" => "bar", "bar" => "foo" } }, { "bar" => { "foo" => "[FILTERED]", "bar" => "foo" } }, %w'fo'], - [{ "foo" => { "foo" => "bar", "bar" => "foo" } }, { "foo" => "[FILTERED]" }, %w'f banana'], - [{ "deep" => { "cc" => { "code" => "bar", "bar" => "foo" }, "ss" => { "code" => "bar" } } }, { "deep" => { "cc" => { "code" => "[FILTERED]", "bar" => "foo" }, "ss" => { "code" => "bar" } } }, %w'deep.cc.code'], - [{ "baz" => [{ "foo" => "baz" }, "1"] }, { "baz" => [{ "foo" => "[FILTERED]" }, "1"] }, [/foo/]]] - - test_hashes.each do |before_filter, after_filter, filter_words| - parameter_filter = ActionDispatch::Http::ParameterFilter.new(filter_words) - assert_equal after_filter, parameter_filter.filter(before_filter) - - filter_words << "blah" - filter_words << lambda { |key, value| - value.reverse! if key =~ /bargain/ - } - - parameter_filter = ActionDispatch::Http::ParameterFilter.new(filter_words) - before_filter["barg"] = { :bargain => "gain", "blah" => "bar", "bar" => { "bargain" => { "blah" => "foo" } } } - after_filter["barg"] = { :bargain => "niag", "blah" => "[FILTERED]", "bar" => { "bargain" => { "blah" => "[FILTERED]" } } } + test "raw_post does not raise when rack.input is nil" do + request = stub_request - assert_equal after_filter, parameter_filter.filter(before_filter) - end + # "" on Rack < 3.1, nil on Rack 3.1+ + assert_predicate request.raw_post, :blank? end +end +class RequestParameterFilter < BaseRequestTest test "filtered_parameters returns params filtered" do request = stub_request( "action_dispatch.request.parameters" => { @@ -1175,6 +1335,18 @@ class RequestParameterFilter < BaseRequestTest path = request.filtered_path assert_equal request.script_name + "/authenticate?secret", path end + + test "parameter_filter returns the same instance of ActiveSupport::ParameterFilter" do + request = stub_request( + "action_dispatch.parameter_filter" => [:secret] + ) + + filter = request.parameter_filter + + assert_kind_of ActiveSupport::ParameterFilter, filter + assert_equal({ "secret" => "[FILTERED]", "something" => "bar" }, filter.filter("secret" => "foo", "something" => "bar")) + assert_same filter, request.parameter_filter + end end class RequestEtag < BaseRequestTest @@ -1218,7 +1390,7 @@ class RequestEtag < BaseRequestTest assert_equal header, request.if_none_match assert_equal expected, request.if_none_match_etags expected.each do |etag| - assert request.etag_matches?(etag), etag + assert request.etag_matches?(etag), "Etag #{etag} did not match HTTP_IF_NONE_MATCH values" end end end @@ -1232,8 +1404,8 @@ def setup test "setting variant to a symbol" do @request.variant = :phone - assert @request.variant.phone? - assert_not @request.variant.tablet? + assert_predicate @request.variant, :phone? + assert_not_predicate @request.variant, :tablet? assert @request.variant.any?(:phone, :tablet) assert_not @request.variant.any?(:tablet, :desktop) end @@ -1241,9 +1413,9 @@ def setup test "setting variant to an array of symbols" do @request.variant = [:phone, :tablet] - assert @request.variant.phone? - assert @request.variant.tablet? - assert_not @request.variant.desktop? + assert_predicate @request.variant, :phone? + assert_predicate @request.variant, :tablet? + assert_not_predicate @request.variant, :desktop? assert @request.variant.any?(:tablet, :desktop) assert_not @request.variant.any?(:desktop, :watch) end @@ -1251,8 +1423,8 @@ def setup test "clearing variant" do @request.variant = nil - assert @request.variant.empty? - assert_not @request.variant.phone? + assert_empty @request.variant + assert_not_predicate @request.variant, :phone? assert_not @request.variant.any?(:phone, :tablet) end @@ -1271,20 +1443,164 @@ def setup class RequestFormData < BaseRequestTest test "media_type is from the FORM_DATA_MEDIA_TYPES array" do - assert stub_request("CONTENT_TYPE" => "application/x-www-form-urlencoded").form_data? - assert stub_request("CONTENT_TYPE" => "multipart/form-data").form_data? + assert_predicate stub_request("CONTENT_TYPE" => "application/x-www-form-urlencoded"), :form_data? + assert_predicate stub_request("CONTENT_TYPE" => "multipart/form-data"), :form_data? end test "media_type is not from the FORM_DATA_MEDIA_TYPES array" do - assert !stub_request("CONTENT_TYPE" => "application/xml").form_data? - assert !stub_request("CONTENT_TYPE" => "multipart/related").form_data? + assert_not_predicate stub_request("CONTENT_TYPE" => "application/xml"), :form_data? + assert_not_predicate stub_request("CONTENT_TYPE" => "multipart/related"), :form_data? end test "no Content-Type header is provided and the request_method is POST" do request = stub_request("REQUEST_METHOD" => "POST") - assert_equal "", request.media_type + assert_nil request.media_type assert_equal "POST", request.request_method - assert !request.form_data? + assert_not_predicate request, :form_data? + end +end + +class EarlyHintsRequestTest < BaseRequestTest + def setup + super + @request = stub_request({ "rack.early_hints" => lambda { |links| links } }) + end + + test "when early hints is set in the env link headers are sent" do + early_hints = @request.send_early_hints("link" => "; rel=preload; as=style,; rel=preload") + expected_hints = { "link" => "; rel=preload; as=style,; rel=preload" } + + assert_equal expected_hints, early_hints + end +end + +class RequestInspectTest < BaseRequestTest + test "inspect" do + request = stub_request( + "REQUEST_METHOD" => "POST", + "REMOTE_ADDR" => "1.2.3.4", + "HTTP_X_FORWARDED_PROTO" => "https", + "HTTP_X_FORWARDED_HOST" => "example.com:443", + "PATH_INFO" => "/path/", + "QUERY_STRING" => "q=1" + ) + assert_match %r(#), request.inspect + end +end + +class RequestSession < BaseRequestTest + def setup + super + @request = stub_request + end + + test "#session" do + @request.session + + assert_not_predicate(ActionDispatch::Request::Session.find(@request), :enabled?) + 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/response_test.rb b/actionpack/test/dispatch/response_test.rb index 7433c5ce0cb6f..5dd3338cb9b30 100644 --- a/actionpack/test/dispatch/response_test.rb +++ b/actionpack/test/dispatch/response_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "timeout" require "rack/content_length" @@ -13,13 +15,13 @@ def test_can_wait_until_commit @response.await_commit } @response.commit! - assert @response.committed? + assert_predicate @response, :committed? assert t.join(0.5) end def test_stream_close @response.stream.close - assert @response.stream.closed? + assert_predicate @response.stream, :closed? end def test_stream_write @@ -40,7 +42,12 @@ def test_write_after_close def test_each_isnt_called_if_str_body_is_written # Controller writes and reads response body each_counter = 0 - @response.body = Object.new.tap { |o| o.singleton_class.send(:define_method, :each) { |&block| each_counter += 1; block.call "foo" } } + + @response.body = Object.new.tap do |object| + object.singleton_class.define_method(:each) { |&block| each_counter += 1; block.call "foo" } + object.singleton_class.define_method(:to_ary) { enum_for(:each).to_a } + end + @response["X-Foo"] = @response.body assert_equal 1, each_counter, "#each was not called once" @@ -82,8 +89,8 @@ def test_read_body_during_action # the response can be built. status, headers, body = @response.to_a assert_equal 200, status - assert_equal({ - "Content-Type" => "text/html; charset=utf-8" + assert_headers({ + "content-type" => "text/html; charset=utf-8" }, headers) parts = [] @@ -120,9 +127,8 @@ def test_empty_content_type_returns_nil status, headers, body = @response.to_a assert_equal 200, status - assert_equal({ - "Content-Type" => "text/html; charset=utf-8" - }, headers) + + assert_headers({ "content-type" => "text/html; charset=utf-8" }, headers) parts = [] body.each { |part| parts << part } @@ -145,23 +151,23 @@ def test_only_set_charset_still_defaults_to_text_html status, headers, _ = @response.to_a assert_equal 200, status - assert_equal({ - "Content-Type" => "text/html; charset=utf-8" + assert_headers({ + "content-type" => "text/html; charset=utf-8" }, headers) end test "content length" do - [100, 101, 102, 204].each do |c| + [100, 101, 102, 103, 204].each do |c| @response = ActionDispatch::Response.new @response.status = c.to_s @response.set_header "Content-Length", "0" _, headers, _ = @response.to_a - assert !headers.has_key?("Content-Length"), "#{c} must not have a Content-Length header field" + assert_not headers.has_key?("Content-Length"), "#{c} must not have a Content-Length header field" end end test "does not contain a message-body" do - [100, 101, 102, 204, 304].each do |c| + [100, 101, 102, 103, 204, 304].each do |c| @response = ActionDispatch::Response.new @response.status = c.to_s @response.body = "Body must not be included" @@ -175,7 +181,7 @@ def test_only_set_charset_still_defaults_to_text_html @response = ActionDispatch::Response.new @response.status = c.to_s _, headers, _ = @response.to_a - assert !headers.has_key?("Content-Type"), "#{c} should not have Content-Type header" + assert_not headers.has_key?("Content-Type"), "#{c} should not have Content-Type header" end [200, 302, 404, 500].each do |c| @@ -189,7 +195,7 @@ def test_only_set_charset_still_defaults_to_text_html test "does not include Status header" do @response.status = "200 OK" _, headers, _ = @response.to_a - assert !headers.has_key?("Status") + assert_not headers.has_key?("Status") end test "response code" do @@ -225,10 +231,12 @@ def test_only_set_charset_still_defaults_to_text_html assert_equal "OK", @response.message end + include CookieAssertions + test "cookies" do @response.set_cookie("user_name", value: "david", path: "/") _status, headers, _body = @response.to_a - assert_equal "user_name=david; path=/", headers["Set-Cookie"] + assert_set_cookie_header "user_name=david; path=/", headers["Set-Cookie"] assert_equal({ "user_name" => "david" }, @response.cookies) end @@ -236,7 +244,7 @@ def test_only_set_charset_still_defaults_to_text_html @response.set_cookie("user_name", value: "david", path: "/") @response.set_cookie("login", value: "foo&bar", path: "/", expires: Time.utc(2005, 10, 10, 5)) _status, headers, _body = @response.to_a - assert_equal "user_name=david; path=/\nlogin=foo%26bar; path=/; expires=Mon, 10 Oct 2005 05:00:00 -0000", headers["Set-Cookie"] + assert_set_cookie_header "user_name=david; path=/\nlogin=foo%26bar; path=/; expires=Mon, 10 Oct 2005 05:00:00 GMT", headers["Set-Cookie"] assert_equal({ "login" => "foo&bar", "user_name" => "david" }, @response.cookies) end @@ -255,9 +263,9 @@ def test_only_set_charset_still_defaults_to_text_html } resp.to_a - assert resp.etag? - assert resp.weak_etag? - assert_not resp.strong_etag? + assert_predicate resp, :etag? + assert_predicate resp, :weak_etag? + assert_not_predicate resp, :strong_etag? assert_equal('W/"202cb962ac59075b964b07152d234b70"', resp.etag) assert_equal({ public: true }, resp.cache_control) @@ -273,9 +281,9 @@ def test_only_set_charset_still_defaults_to_text_html } resp.to_a - assert resp.etag? - assert_not resp.weak_etag? - assert resp.strong_etag? + assert_predicate resp, :etag? + assert_not_predicate resp, :weak_etag? + assert_predicate resp, :strong_etag? assert_equal('"202cb962ac59075b964b07152d234b70"', resp.etag) end @@ -288,19 +296,36 @@ def test_only_set_charset_still_defaults_to_text_html resp.to_a assert_equal("utf-16", resp.charset) - assert_equal(Mime[:xml], resp.content_type) - + assert_equal(Mime[:xml], resp.media_type) + assert_equal("application/xml; charset=utf-16", resp.content_type) assert_equal("application/xml; charset=utf-16", resp.headers["Content-Type"]) end + test "respect no-store cache-control" do + resp = ActionDispatch::Response.new.tap { |response| + response.cache_control[:public] = true + response.cache_control[:no_store] = true + response.body = "Hello" + } + resp.to_a + + assert_equal("no-store", resp.headers["Cache-Control"]) + end + + test "respect private, no-store cache-control" do + resp = ActionDispatch::Response.new.tap { |response| + response.cache_control[:private] = true + response.cache_control[:no_store] = true + response.body = "Hello" + } + resp.to_a + + assert_equal("private, no-store", resp.headers["Cache-Control"]) + end + test "read content type with default charset utf-8" do - original = ActionDispatch::Response.default_charset - begin - resp = ActionDispatch::Response.new(200, "Content-Type" => "text/xml") - assert_equal("utf-8", resp.charset) - ensure - ActionDispatch::Response.default_charset = original - end + resp = ActionDispatch::Response.new(200, "Content-Type" => "text/xml") + assert_equal("utf-8", resp.charset) end test "read content type with charset utf-16" do @@ -314,13 +339,15 @@ def test_only_set_charset_still_defaults_to_text_html end end - test "read x_frame_options, x_content_type_options and x_xss_protection" do + test "read x_frame_options, x_content_type_options, x_xss_protection, x_permitted_cross_domain_policies and referrer_policy" do original_default_headers = ActionDispatch::Response.default_headers begin ActionDispatch::Response.default_headers = { "X-Frame-Options" => "DENY", "X-Content-Type-Options" => "nosniff", - "X-XSS-Protection" => "1;" + "X-XSS-Protection" => "0", + "X-Permitted-Cross-Domain-Policies" => "none", + "Referrer-Policy" => "strict-origin-when-cross-origin" } resp = ActionDispatch::Response.create.tap { |response| response.body = "Hello" @@ -329,7 +356,9 @@ def test_only_set_charset_still_defaults_to_text_html assert_equal("DENY", resp.headers["X-Frame-Options"]) assert_equal("nosniff", resp.headers["X-Content-Type-Options"]) - assert_equal("1;", resp.headers["X-XSS-Protection"]) + assert_equal("0", resp.headers["X-XSS-Protection"]) + assert_equal("none", resp.headers["X-Permitted-Cross-Domain-Policies"]) + assert_equal("strict-origin-when-cross-origin", resp.headers["Referrer-Policy"]) ensure ActionDispatch::Response.default_headers = original_default_headers end @@ -353,26 +382,28 @@ def test_only_set_charset_still_defaults_to_text_html end test "respond_to? accepts include_private" do - assert_not @response.respond_to?(:method_missing) + assert_not_respond_to @response, :method_missing assert @response.respond_to?(:method_missing, true) end + include HeadersAssertions + test "can be explicitly destructured into status, headers and an enumerable body" do response = ActionDispatch::Response.new(404, { "Content-Type" => "text/plain" }, ["Not Found"]) response.request = ActionDispatch::Request.empty status, headers, body = *response assert_equal 404, status - assert_equal({ "Content-Type" => "text/plain" }, headers) + assert_headers({ "content-type" => "text/plain" }, headers) assert_equal ["Not Found"], body.each.to_a end test "[response.to_a].flatten does not recurse infinitely" do Timeout.timeout(1) do # use a timeout to prevent it stalling indefinitely status, headers, body = [@response.to_a].flatten - assert_equal @response.status, status - assert_equal @response.headers, headers - assert_equal @response.body, body.each.to_a.join + assert_equal 200, status + assert_equal headers, @response.headers + assert_nil body end end @@ -381,11 +412,11 @@ def test_only_set_charset_still_defaults_to_text_html app = lambda { |env| @response.to_a } env = Rack::MockRequest.env_for("/") - status, headers, body = app.call(env) - assert_nil headers["Content-Length"] + _status, headers, _body = app.call(env) + assert_not_header "content-length", headers - status, headers, body = Rack::ContentLength.new(app).call(env) - assert_equal "5", headers["Content-Length"] + _status, headers, _body = Rack::ContentLength.new(app).call(env) + assert_header "content-length", "5", headers end end @@ -397,14 +428,12 @@ def setup test "has_header?" do assert @response.has_header? "Foo" - assert_not @response.has_header? "foo" - assert_not @response.has_header? nil + assert @response.has_header? "foo" end test "get_header" do assert_equal "1", @response.get_header("Foo") - assert_nil @response.get_header("foo") - assert_nil @response.get_header(nil) + assert_equal "1", @response.get_header("foo") end test "set_header" do @@ -418,23 +447,20 @@ def setup end test "delete_header" do - assert_nil @response.delete_header(nil) - - assert_nil @response.delete_header("foo") - assert @response.has_header?("Foo") - assert_equal "1", @response.delete_header("Foo") assert_not @response.has_header?("Foo") end + include HeadersAssertions + test "add_header" do # Add a value to an existing header - assert_equal "1,2", @response.add_header("Foo", "2") - assert_equal "1,2", @response.get_header("Foo") + assert_header_value "1,2", @response.add_header("Foo", "2") + assert_header_value "1,2", @response.get_header("Foo") # Add nil to an existing header - assert_equal "1,2", @response.add_header("Foo", nil) - assert_equal "1,2", @response.get_header("Foo") + assert_header_value "1,2", @response.add_header("Foo", nil) + assert_header_value "1,2", @response.get_header("Foo") # Add nil to a nonexistent header assert_nil @response.add_header("Bar", nil) @@ -442,9 +468,9 @@ def setup assert_nil @response.get_header("Bar") # Add a value to a nonexistent header - assert_equal "1", @response.add_header("Bar", "1") + assert_header_value "1", @response.add_header("Bar", "1") assert @response.has_header?("Bar") - assert_equal "1", @response.get_header("Bar") + assert_header_value "1", @response.get_header("Bar") end end @@ -500,8 +526,8 @@ class ResponseIntegrationTest < ActionDispatch::IntegrationTest assert_response :success assert_equal("utf-16", @response.charset) - assert_equal(Mime[:xml], @response.content_type) - + assert_equal(Mime[:xml], @response.media_type) + assert_equal("application/xml; charset=utf-16", @response.content_type) assert_equal("application/xml; charset=utf-16", @response.headers["Content-Type"]) end @@ -516,8 +542,8 @@ class ResponseIntegrationTest < ActionDispatch::IntegrationTest assert_response :success assert_equal("utf-16", @response.charset) - assert_equal(Mime[:xml], @response.content_type) - + assert_equal(Mime[:xml], @response.media_type) + assert_equal("application/xml; charset=utf-16", @response.content_type) assert_equal("application/xml; charset=utf-16", @response.headers["Content-Type"]) end @@ -536,4 +562,107 @@ class ResponseIntegrationTest < ActionDispatch::IntegrationTest assert_equal('"202cb962ac59075b964b07152d234b70"', @response.headers["ETag"]) assert_equal('"202cb962ac59075b964b07152d234b70"', @response.etag) end + + test "response Content-Type with optional parameters" do + @app = lambda { |env| + [ + 200, + { "Content-Type" => "text/csv; charset=utf-16; header=present" }, + ["Hello"] + ] + } + + get "/" + assert_response :success + + assert_equal("text/csv; charset=utf-16; header=present", @response.headers["Content-Type"]) + assert_equal("text/csv; charset=utf-16; header=present", @response.content_type) + assert_equal("text/csv", @response.media_type) + assert_equal("utf-16", @response.charset) + end + + test "response Content-Type with optional parameters that set before charset" do + @app = lambda { |env| + [ + 200, + { "Content-Type" => "text/csv; header=present; charset=utf-16" }, + ["Hello"] + ] + } + + get "/" + assert_response :success + + assert_equal("text/csv; header=present; charset=utf-16", @response.headers["Content-Type"]) + assert_equal("text/csv; header=present; charset=utf-16", @response.content_type) + assert_equal("text/csv; header=present", @response.media_type) + assert_equal("utf-16", @response.charset) + end + + test "response Content-Type with quoted-string" do + @app = lambda { |env| + [ + 200, + { "Content-Type" => 'text/csv; header=present; charset="utf-16"' }, + ["Hello"] + ] + } + + get "/" + assert_response :success + + assert_equal('text/csv; header=present; charset="utf-16"', @response.headers["Content-Type"]) + assert_equal('text/csv; header=present; charset="utf-16"', @response.content_type) + assert_equal("text/csv; header=present", @response.media_type) + assert_equal("utf-16", @response.charset) + end + + test "response body with enumerator" do + @app = lambda { |env| + [ + 200, + { "Content-Type" => "text/plain" }, + Enumerator.new { |enumerator| 10.times { |n| enumerator << n.to_s } } + ] + } + get "/" + assert_response :success + + assert_equal("text/plain", @response.headers["Content-Type"]) + assert_equal("text/plain", @response.content_type) + assert_equal("text/plain", @response.media_type) + assert_equal("utf-8", @response.charset) + assert_equal("0123456789", @response.body) + end + + test "response body with lazy enumerator" do + @app = lambda { |env| + [ + 200, + { "Content-Type" => "text/plain" }, + (0..10).lazy + ] + } + get "/" + assert_response :success + + assert_equal("text/plain", @response.headers["Content-Type"]) + assert_equal("text/plain", @response.content_type) + assert_equal("text/plain", @response.media_type) + assert_equal("utf-8", @response.charset) + assert_equal("012345678910", @response.body) + end + + test "response does not buffer enumerator body" do + # This is an enumerable body, and it should not be buffered: + body = Enumerator.new do |enumerator| + enumerator << "Hello World" + end + + # The response created here should not attempt to buffer the body: + response = ActionDispatch::Response.new(200, { "content-type" => "text/plain" }, body) + + # The body should be the same enumerator object, i.e. it should be passed through unchanged: + assert_equal body, response.body + end end diff --git a/actionpack/test/dispatch/routing/concerns_test.rb b/actionpack/test/dispatch/routing/concerns_test.rb index 2d71c375629a7..3c80772f9faf2 100644 --- a/actionpack/test/dispatch/routing/concerns_test.rb +++ b/actionpack/test/dispatch/routing/concerns_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class ReviewsController < ResourcesController; end @@ -5,14 +7,14 @@ class ReviewsController < ResourcesController; end class RoutingConcernsTest < ActionDispatch::IntegrationTest class Reviewable def self.call(mapper, options = {}) - mapper.resources :reviews, options + mapper.resources :reviews, **options end end Routes = ActionDispatch::Routing::RouteSet.new.tap do |app| app.draw do concern :commentable do |options| - resources :comments, options + resources :comments, **options end concern :image_attachable do @@ -109,14 +111,4 @@ def test_with_an_invalid_concern_name assert_equal "No concern named foo was found!", e.message end - - def test_concerns_executes_block_in_context_of_current_mapper - mapper = ActionDispatch::Routing::Mapper.new(ActionDispatch::Routing::RouteSet.new) - mapper.concern :test_concern do - resources :things - return self - end - - assert_equal mapper, mapper.concerns(:test_concern) - end end diff --git a/actionpack/test/dispatch/routing/custom_url_helpers_test.rb b/actionpack/test/dispatch/routing/custom_url_helpers_test.rb new file mode 100644 index 0000000000000..22c6a4c62b416 --- /dev/null +++ b/actionpack/test/dispatch/routing/custom_url_helpers_test.rb @@ -0,0 +1,344 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class TestCustomUrlHelpers < ActionDispatch::IntegrationTest + class Linkable + attr_reader :id + + def self.name + super.demodulize + end + + def initialize(id) + @id = id + end + + def linkable_type + self.class.name.underscore + end + end + + class Category < Linkable; end + class Collection < Linkable; end + class Product < Linkable; end + class Manufacturer < Linkable; end + + class Model + extend ActiveModel::Naming + include ActiveModel::Conversion + + attr_reader :id + + def initialize(id = nil) + @id = id + end + + remove_method :model_name + def model_name + @_model_name ||= ActiveModel::Name.new(self.class, nil, self.class.name.demodulize) + end + + def persisted? + false + end + end + + class Basket < Model; end + class User < Model; end + class Video < Model; end + + class Article + attr_reader :id + + def self.name + "Article" + end + + def initialize(id) + @id = id + end + end + + class Page + attr_reader :id + + def self.name + super.demodulize + end + + def initialize(id) + @id = id + end + end + + class CategoryPage < Page; end + class ProductPage < Page; end + + Routes = ActionDispatch::Routing::RouteSet.new + Routes.draw do + default_url_options host: "www.example.com" + + root to: "pages#index" + get "/basket", to: "basket#show", as: :basket + get "/posts/:id", to: "posts#show", as: :post + get "/profile", to: "users#profile", as: :profile + get "/media/:id", to: "media#show", as: :media + get "/pages/:id", to: "pages#show", as: :page + + resources :categories, :collections, :products, :manufacturers + + namespace :admin do + get "/dashboard", to: "dashboard#index" + end + + direct(:website) { "http://www.rubyonrails.org" } + direct("string") { "http://www.rubyonrails.org" } + direct(:helper) { basket_url } + direct(:linkable) { |linkable| [:"#{linkable.linkable_type}", { id: linkable.id }] } + direct(:nested) { |linkable| route_for(:linkable, linkable) } + direct(:params) { |params| params } + direct(:symbol) { :basket } + direct(:hash) { { controller: "basket", action: "show" } } + direct(:array) { [:admin, :dashboard] } + direct(:options) { |options| [:products, options] } + direct(:defaults, size: 10) { |options| [:products, options] } + + direct(:browse, page: 1, size: 10) do |options| + [:products, options.merge(params.permit(:page, :size).to_h.symbolize_keys)] + end + + resolve("Article") { |article| [:post, { id: article.id }] } + resolve("Basket") { |basket| [:basket] } + resolve("Manufacturer") { |manufacturer| route_for(:linkable, manufacturer) } + resolve("User", anchor: "details") { |user, options| [:profile, options] } + resolve("Video") { |video| [:media, { id: video.id }] } + resolve(%w[Page CategoryPage ProductPage]) { |page| [:page, { id: page.id }] } + end + + APP = build_app Routes + + def app + APP + end + + include Routes.url_helpers + + def setup + @category = Category.new("1") + @collection = Collection.new("2") + @product = Product.new("3") + @manufacturer = Manufacturer.new("apple") + @basket = Basket.new + @user = User.new + @video = Video.new("4") + @article = Article.new("5") + @page = Page.new("6") + @category_page = CategoryPage.new("7") + @product_page = ProductPage.new("8") + @path_params = { "controller" => "pages", "action" => "index" } + @unsafe_params = ActionController::Parameters.new(@path_params) + @safe_params = ActionController::Parameters.new(@path_params).permit(:controller, :action) + end + + def params + ActionController::Parameters.new(page: 2, size: 25) + end + + def test_direct_paths + assert_equal "/", website_path + assert_equal "/", Routes.url_helpers.website_path + + assert_equal "/", string_path + assert_equal "/", Routes.url_helpers.string_path + + assert_equal "/basket", helper_path + assert_equal "/basket", Routes.url_helpers.helper_path + + assert_equal "/categories/1", linkable_path(@category) + assert_equal "/categories/1", Routes.url_helpers.linkable_path(@category) + assert_equal "/collections/2", linkable_path(@collection) + assert_equal "/collections/2", Routes.url_helpers.linkable_path(@collection) + assert_equal "/products/3", linkable_path(@product) + assert_equal "/products/3", Routes.url_helpers.linkable_path(@product) + + assert_equal "/categories/1", nested_path(@category) + assert_equal "/categories/1", Routes.url_helpers.nested_path(@category) + + assert_equal "/", params_path(@safe_params) + assert_equal "/", Routes.url_helpers.params_path(@safe_params) + assert_raises(ActionController::UnfilteredParameters) { params_path(@unsafe_params) } + assert_raises(ActionController::UnfilteredParameters) { Routes.url_helpers.params_path(@unsafe_params) } + + assert_equal "/basket", symbol_path + assert_equal "/basket", Routes.url_helpers.symbol_path + assert_equal "/basket", hash_path + assert_equal "/basket", Routes.url_helpers.hash_path + assert_equal "/admin/dashboard", array_path + assert_equal "/admin/dashboard", Routes.url_helpers.array_path + + assert_equal "/products?page=2", options_path(page: 2) + assert_equal "/products?page=2", Routes.url_helpers.options_path(page: 2) + assert_equal "/products?size=10", defaults_path + assert_equal "/products?size=10", Routes.url_helpers.defaults_path + assert_equal "/products?size=20", defaults_path(size: 20) + assert_equal "/products?size=20", Routes.url_helpers.defaults_path(size: 20) + + assert_equal "/products?page=2&size=25", browse_path + assert_raises(NameError) { Routes.url_helpers.browse_path } + end + + def test_direct_urls + assert_equal "http://www.rubyonrails.org", website_url + assert_equal "http://www.rubyonrails.org", Routes.url_helpers.website_url + + assert_equal "http://www.rubyonrails.org", string_url + assert_equal "http://www.rubyonrails.org", Routes.url_helpers.string_url + + assert_equal "http://www.example.com/basket", helper_url + assert_equal "http://www.example.com/basket", Routes.url_helpers.helper_url + + assert_equal "http://www.example.com/categories/1", linkable_url(@category) + assert_equal "http://www.example.com/categories/1", Routes.url_helpers.linkable_url(@category) + assert_equal "http://www.example.com/collections/2", linkable_url(@collection) + assert_equal "http://www.example.com/collections/2", Routes.url_helpers.linkable_url(@collection) + assert_equal "http://www.example.com/products/3", linkable_url(@product) + assert_equal "http://www.example.com/products/3", Routes.url_helpers.linkable_url(@product) + + assert_equal "http://www.example.com/categories/1", nested_url(@category) + assert_equal "http://www.example.com/categories/1", Routes.url_helpers.nested_url(@category) + + assert_equal "http://www.example.com/", params_url(@safe_params) + assert_equal "http://www.example.com/", Routes.url_helpers.params_url(@safe_params) + assert_raises(ActionController::UnfilteredParameters) { params_url(@unsafe_params) } + assert_raises(ActionController::UnfilteredParameters) { Routes.url_helpers.params_url(@unsafe_params) } + + assert_equal "http://www.example.com/basket", symbol_url + assert_equal "http://www.example.com/basket", Routes.url_helpers.symbol_url + assert_equal "http://www.example.com/basket", hash_url + assert_equal "http://www.example.com/basket", Routes.url_helpers.hash_url + assert_equal "http://www.example.com/admin/dashboard", array_url + assert_equal "http://www.example.com/admin/dashboard", Routes.url_helpers.array_url + + assert_equal "http://www.example.com/products?page=2", options_url(page: 2) + assert_equal "http://www.example.com/products?page=2", Routes.url_helpers.options_url(page: 2) + assert_equal "http://www.example.com/products?size=10", defaults_url + assert_equal "http://www.example.com/products?size=10", Routes.url_helpers.defaults_url + assert_equal "http://www.example.com/products?size=20", defaults_url(size: 20) + assert_equal "http://www.example.com/products?size=20", Routes.url_helpers.defaults_url(size: 20) + + assert_equal "http://www.example.com/products?page=2&size=25", browse_url + assert_raises(NameError) { Routes.url_helpers.browse_url } + end + + def test_resolve_paths + assert_equal "/basket", polymorphic_path(@basket) + assert_equal "/basket", Routes.url_helpers.polymorphic_path(@basket) + + assert_equal "/profile#details", polymorphic_path(@user) + assert_equal "/profile#details", Routes.url_helpers.polymorphic_path(@user) + + assert_equal "/profile#password", polymorphic_path(@user, anchor: "password") + assert_equal "/profile#password", Routes.url_helpers.polymorphic_path(@user, anchor: "password") + + assert_equal "/media/4", polymorphic_path(@video) + assert_equal "/media/4", Routes.url_helpers.polymorphic_path(@video) + assert_equal "/media/4", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.path.handle_model_call(self, @video) + + assert_equal "/posts/5", polymorphic_path(@article) + assert_equal "/posts/5", Routes.url_helpers.polymorphic_path(@article) + assert_equal "/posts/5", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.path.handle_model_call(self, @article) + + assert_equal "/pages/6", polymorphic_path(@page) + assert_equal "/pages/6", Routes.url_helpers.polymorphic_path(@page) + assert_equal "/pages/6", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.path.handle_model_call(self, @page) + + assert_equal "/pages/7", polymorphic_path(@category_page) + assert_equal "/pages/7", Routes.url_helpers.polymorphic_path(@category_page) + assert_equal "/pages/7", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.path.handle_model_call(self, @category_page) + + assert_equal "/pages/8", polymorphic_path(@product_page) + assert_equal "/pages/8", Routes.url_helpers.polymorphic_path(@product_page) + assert_equal "/pages/8", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.path.handle_model_call(self, @product_page) + + assert_equal "/manufacturers/apple", polymorphic_path(@manufacturer) + assert_equal "/manufacturers/apple", Routes.url_helpers.polymorphic_path(@manufacturer) + end + + def test_resolve_urls + assert_equal "http://www.example.com/basket", polymorphic_url(@basket) + assert_equal "http://www.example.com/basket", Routes.url_helpers.polymorphic_url(@basket) + assert_equal "http://www.example.com/basket", polymorphic_url(@basket) + assert_equal "http://www.example.com/basket", Routes.url_helpers.polymorphic_url(@basket) + + assert_equal "http://www.example.com/profile#details", polymorphic_url(@user) + assert_equal "http://www.example.com/profile#details", Routes.url_helpers.polymorphic_url(@user) + + assert_equal "http://www.example.com/profile#password", polymorphic_url(@user, anchor: "password") + assert_equal "http://www.example.com/profile#password", Routes.url_helpers.polymorphic_url(@user, anchor: "password") + + assert_equal "http://www.example.com/media/4", polymorphic_url(@video) + assert_equal "http://www.example.com/media/4", Routes.url_helpers.polymorphic_url(@video) + assert_equal "http://www.example.com/media/4", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url.handle_model_call(self, @video) + + assert_equal "http://www.example.com/posts/5", polymorphic_url(@article) + assert_equal "http://www.example.com/posts/5", Routes.url_helpers.polymorphic_url(@article) + assert_equal "http://www.example.com/posts/5", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url.handle_model_call(self, @article) + + assert_equal "http://www.example.com/pages/6", polymorphic_url(@page) + assert_equal "http://www.example.com/pages/6", Routes.url_helpers.polymorphic_url(@page) + assert_equal "http://www.example.com/pages/6", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url.handle_model_call(self, @page) + + assert_equal "http://www.example.com/pages/7", polymorphic_url(@category_page) + assert_equal "http://www.example.com/pages/7", Routes.url_helpers.polymorphic_url(@category_page) + assert_equal "http://www.example.com/pages/7", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url.handle_model_call(self, @category_page) + + assert_equal "http://www.example.com/pages/8", polymorphic_url(@product_page) + assert_equal "http://www.example.com/pages/8", Routes.url_helpers.polymorphic_url(@product_page) + assert_equal "http://www.example.com/pages/8", ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url.handle_model_call(self, @product_page) + + assert_equal "http://www.example.com/manufacturers/apple", polymorphic_url(@manufacturer) + assert_equal "http://www.example.com/manufacturers/apple", Routes.url_helpers.polymorphic_url(@manufacturer) + end + + def test_url_helpers_module_can_be_included_directly_in_an_active_support_concern + concern = Module.new do + extend ActiveSupport::Concern + include Routes.url_helpers + end + + concerned = Class.new { include concern }.new + + assert_equal "http://www.example.com/", concerned.root_url + end + + def test_defining_direct_inside_a_scope_raises_runtime_error + routes = ActionDispatch::Routing::RouteSet.new + + assert_raises RuntimeError do + routes.draw do + namespace :admin do + direct(:rubyonrails) { "http://www.rubyonrails.org" } + end + end + end + end + + def test_defining_resolve_inside_a_scope_raises_runtime_error + routes = ActionDispatch::Routing::RouteSet.new + + assert_raises RuntimeError do + routes.draw do + namespace :admin do + resolve("User") { "/profile" } + end + end + end + end + + def test_defining_direct_url_registers_helper_method + assert_equal "http://www.example.com/basket", Routes.url_helpers.symbol_url + assert_equal true, Routes.named_routes.route_defined?(:symbol_url), "'symbol_url' named helper not found" + assert_equal true, Routes.named_routes.route_defined?(:symbol_path), "'symbol_path' named helper not found" + end +end diff --git a/actionpack/test/dispatch/routing/inspector_test.rb b/actionpack/test/dispatch/routing/inspector_test.rb index a4babf85546da..e1f16b1926d0e 100644 --- a/actionpack/test/dispatch/routing/inspector_test.rb +++ b/actionpack/test/dispatch/routing/inspector_test.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + require "abstract_unit" require "rails/engine" require "action_dispatch/routing/inspector" +require "io/console/size" class MountedRackApp def self.call(env) @@ -10,19 +13,30 @@ def self.call(env) class Rails::DummyController end +module InspectorTestApp + class PostsController < ActionController::Base + def index + end + + def show + end + end + + module Admin + class UsersController < ActionController::Base + def index + end + end + end +end + module ActionDispatch module Routing class RoutesInspectorTest < ActiveSupport::TestCase - def setup + setup do @set = ActionDispatch::Routing::RouteSet.new end - def draw(options = nil, &block) - @set.draw(&block) - inspector = ActionDispatch::Routing::RoutesInspector.new(@set.routes) - inspector.format(ActionDispatch::Routing::ConsoleFormatter.new, options).split("\n") - end - def test_displaying_routes_for_engines engine = Class.new(Rails::Engine) do def self.inspect @@ -39,11 +53,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 @@ -62,10 +78,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 @@ -133,7 +151,7 @@ def test_inspect_routes_shows_root_route def test_inspect_routes_shows_dynamic_action_route output = draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get "api/:action" => "api" end end @@ -146,7 +164,7 @@ def test_inspect_routes_shows_dynamic_action_route def test_inspect_routes_shows_controller_and_action_only_route output = draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action" end end @@ -159,14 +177,14 @@ def test_inspect_routes_shows_controller_and_action_only_route def test_inspect_routes_shows_controller_and_action_route_with_constraints output = draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller(/:action(/:id))", id: /\d+/ end end assert_equal [ "Prefix Verb URI Pattern Controller#Action", - " GET /:controller(/:action(/:id))(.:format) :controller#:action {:id=>/\\d+/}" + " GET /:controller(/:action(/:id))(.:format) :controller#:action #{{ id: /\d+/ }}" ], output end @@ -177,7 +195,7 @@ def test_rails_routes_shows_route_with_defaults assert_equal [ "Prefix Verb URI Pattern Controller#Action", - ' GET /photos/:id(.:format) photos#show {:format=>"jpg"}' + " GET /photos/:id(.:format) photos#show #{{ format: "jpg" }}" ], output end @@ -188,7 +206,7 @@ def test_rails_routes_shows_route_with_constraints assert_equal [ "Prefix Verb URI Pattern Controller#Action", - " GET /photos/:id(.:format) photos#show {:id=>/[A-Z]\\d{5}/}" + " GET /photos/:id(.:format) photos#show #{{ id: /[A-Z]\d{5}/ }}" ], output end @@ -222,7 +240,7 @@ def test_rails_routes_shows_route_with_rack_app assert_equal [ "Prefix Verb URI Pattern Controller#Action", - " GET /foo/:id(.:format) MountedRackApp {:id=>/[A-Z]\\d{5}/}" + " GET /foo/:id(.:format) MountedRackApp #{{ id: /[A-Z]\d{5}/ }}" ], output end @@ -263,7 +281,7 @@ def inspect assert_equal [ " Prefix Verb URI Pattern Controller#Action", - "mounted_rack_app /foo MountedRackApp {:constraint=>( my custom constraint )}" + "mounted_rack_app /foo MountedRackApp #{{ constraint: constraint.new }}" ], output end @@ -296,14 +314,14 @@ def test_redirect assert_equal [ "Prefix Verb URI Pattern Controller#Action", - " foo GET /foo(.:format) redirect(301, /foo/bar) {:subdomain=>\"admin\"}", + " foo GET /foo(.:format) redirect(301, /foo/bar) #{{ subdomain: "admin" }}", " bar GET /bar(.:format) redirect(307, path: /foo/bar)", "foobar GET /foobar(.:format) redirect(301)" ], output end def test_routes_can_be_filtered - output = draw("posts") do + output = draw(grep: "posts") do resources :articles resources :posts end @@ -319,8 +337,83 @@ def test_routes_can_be_filtered " DELETE /posts/:id(.:format) posts#destroy"], output end + def test_routes_when_expanded + ActionDispatch::Routing::Mapper.route_source_locations = true + engine = Class.new(Rails::Engine) do + def self.inspect + "Blog::Engine" + end + end + file_name = ActiveSupport::BacktraceCleaner.new.clean([__FILE__]).first + lineno = __LINE__ + engine.routes.draw do + get "/cart", to: "cart#show" + end + + output = draw(formatter: ActionDispatch::Routing::ConsoleFormatter::Expanded.new(width: 23)) do + get "/custom/assets", to: "custom_assets#show" + get "/custom/furnitures", to: "custom_furnitures#show" + mount engine => "/blog", :as => "blog" + end + + expected = [ "[ Routes for application ]", + "--[ Route 1 ]----------", + "Prefix | custom_assets", + "Verb | GET", + "URI | /custom/assets(.:format)", + "Controller#Action | custom_assets#show", + "Source Location | #{file_name}:#{lineno + 6}", + "--[ Route 2 ]----------", + "Prefix | custom_furnitures", + "Verb | GET", + "URI | /custom/furnitures(.:format)", + "Controller#Action | custom_furnitures#show", + "Source Location | #{file_name}:#{lineno + 7}", + "--[ Route 3 ]----------", + "Prefix | blog", + "Verb | ", + "URI | /blog", + "Controller#Action | Blog::Engine", + "Source Location | #{file_name}:#{lineno + 8}", + "", + "[ Routes for Blog::Engine ]", + "--[ Route 1 ]----------", + "Prefix | cart", + "Verb | GET", + "URI | /cart(.:format)", + "Controller#Action | cart#show", + "Source Location | #{file_name}:#{lineno + 2}"] + + assert_equal expected, output + ensure + ActionDispatch::Routing::Mapper.route_source_locations = false + end + + def test_no_routes_matched_filter_when_expanded + output = draw(grep: "rails/dummy", formatter: ActionDispatch::Routing::ConsoleFormatter::Expanded.new) do + get "photos/:id" => "photos#show", :id => /[A-Z]\d{5}/ + end + + assert_equal [ + "No routes were found for this grep pattern.", + "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." + ], output + end + + def test_not_routes_when_expanded + output = draw(formatter: ActionDispatch::Routing::ConsoleFormatter::Expanded.new) { } + + assert_equal [ + "You don't have any routes defined!", + "", + "Please add some routes in config/routes.rb.", + "", + "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." + ], output + end + def test_routes_can_be_filtered_with_namespaced_controllers - output = draw("admin/posts") do + output = draw(grep: "admin/posts") do resources :articles namespace :admin do resources :posts @@ -340,7 +433,7 @@ def test_routes_can_be_filtered_with_namespaced_controllers def test_regression_route_with_controller_regexp output = draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller(/:action)", controller: /api\/[^\/]+/, format: false end end @@ -349,50 +442,37 @@ def test_regression_route_with_controller_regexp " GET /:controller(/:action) :controller#:action"], output end - def test_inspect_routes_shows_resources_route_when_assets_disabled - @set = ActionDispatch::Routing::RouteSet.new - - output = draw do - get "/cart", to: "cart#show" - end - - assert_equal [ - "Prefix Verb URI Pattern Controller#Action", - " cart GET /cart(.:format) cart#show" - ], output - end - def test_routes_with_undefined_filter output = draw(controller: "Rails::MissingController") do get "photos/:id" => "photos#show", :id => /[A-Z]\d{5}/ end assert_equal [ - "No routes were found for this controller", - "For more information about routes, see the Rails guide: http://guides.rubyonrails.org/routing.html." + "No routes were found for this controller.", + "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." ], output end def test_no_routes_matched_filter - output = draw("rails/dummy") do + output = draw(grep: "rails/dummy") do get "photos/:id" => "photos#show", :id => /[A-Z]\d{5}/ end assert_equal [ - "No routes were found for this controller", - "For more information about routes, see the Rails guide: http://guides.rubyonrails.org/routing.html." + "No routes were found for this grep pattern.", + "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." ], output end def test_no_routes_were_defined - output = draw("Rails::DummyController") {} + output = draw { } assert_equal [ "You don't have any routes defined!", "", "Please add some routes in config/routes.rb.", "", - "For more information about routes, see the Rails guide: http://guides.rubyonrails.org/routing.html." + "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." ], output end @@ -418,6 +498,199 @@ def self.inspect "custom_assets GET /custom/assets(.:format) custom_assets#show", ], output end + + def test_route_with_proc_handler + output = draw do + get "/health", to: proc { [200, {}, ["OK"]] } + end + assert_equal [ + "Prefix Verb URI Pattern Controller#Action", + "health GET /health(.:format) Inline handler (Proc/Lambda)" + ], 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 + + def test_action_source_location_for_controller_action + @set.draw do + get "/posts", to: "inspector_test_app/posts#index" + get "/posts/:id", to: "inspector_test_app/posts#show" + end + + routes = @set.routes.routes.map { |r| RouteWrapper.new(r) } + posts_index = routes.find { |r| r.reqs == "inspector_test_app/posts#index" } + posts_show = routes.find { |r| r.reqs == "inspector_test_app/posts#show" } + + assert_match(/inspector_test\.rb:\d+/, posts_index.action_source_location) + assert_match(/inspector_test\.rb:\d+/, posts_show.action_source_location) + assert_not_equal posts_index.action_source_location, posts_show.action_source_location + end + + def test_action_source_location_for_namespaced_controller + @set.draw do + get "/admin/users", to: "inspector_test_app/admin/users#index" + end + + routes = @set.routes.routes.map { |r| RouteWrapper.new(r) } + admin_users_index = routes.find { |r| r.reqs == "inspector_test_app/admin/users#index" } + + assert_match(/inspector_test\.rb:\d+/, admin_users_index.action_source_location) + end + + def test_action_source_location_returns_nil_for_missing_controller + @set.draw do + get "/missing", to: "missing#index" + end + + routes = @set.routes.routes.map { |r| RouteWrapper.new(r) } + missing = routes.find { |r| r.reqs == "missing#index" } + + assert_nil missing.action_source_location + end + + def test_action_source_location_returns_nil_for_missing_action + @set.draw do + get "/posts/missing", to: "inspector_test_app/posts#missing" + end + + routes = @set.routes.routes.map { |r| RouteWrapper.new(r) } + missing = routes.find { |r| r.reqs == "inspector_test_app/posts#missing" } + + assert_nil missing.action_source_location + end + + def test_action_source_location_returns_nil_for_rack_app + @set.draw do + get "/health", to: proc { [200, {}, ["OK"]] } + end + + routes = @set.routes.routes.map { |r| RouteWrapper.new(r) } + health = routes.first + + assert_nil health.action_source_location + end + + def test_action_source_location_returns_nil_for_dynamic_controller + @set.draw do + ActionDispatch.deprecator.silence do + get ":controller/:action" + end + end + + routes = @set.routes.routes.map { |r| RouteWrapper.new(r) } + dynamic = routes.first + + assert_nil dynamic.action_source_location + end + + def test_action_source_location_included_in_to_h + @set.draw do + get "/posts", to: "inspector_test_app/posts#index" + end + + routes = @set.routes.routes.map { |r| RouteWrapper.new(r) } + hash = routes.first.to_h + + assert hash.key?(:action_source_location) + assert_match(/inspector_test\.rb:\d+/, hash[:action_source_location]) + + assert hash.key?(:action_source_file) + assert_match(/inspector_test\.rb/, hash[:action_source_file]) + + assert hash.key?(:action_source_line) + assert_kind_of Integer, hash[:action_source_line] + end + + def test_action_source_file_and_line_returns_tuple + @set.draw do + get "/posts", to: "inspector_test_app/posts#index" + end + + routes = @set.routes.routes.map { |r| RouteWrapper.new(r) } + route = routes.find { |r| r.reqs == "inspector_test_app/posts#index" } + file, line = route.action_source_file_and_line + + assert_match(/inspector_test\.rb/, file) + assert_kind_of Integer, line + end + + def test_action_source_file_and_line_returns_nil_for_missing_action + @set.draw do + get "/posts/missing", to: "inspector_test_app/posts#missing" + end + + routes = @set.routes.routes.map { |r| RouteWrapper.new(r) } + route = routes.find { |r| r.reqs == "inspector_test_app/posts#missing" } + + assert_nil route.action_source_file_and_line + end + + def test_action_source_location_in_expanded_output + @set.draw do + get "/posts", to: "inspector_test_app/posts#index" + end + + inspector = RoutesInspector.new(@set.routes) + output = inspector.format(ConsoleFormatter::Expanded.new(width: 23)) + + assert_match(/Action Location.*inspector_test\.rb:\d+/m, output) + end + + private + def draw(formatter: ActionDispatch::Routing::ConsoleFormatter::Sheet.new, **options, &block) + @set.draw(&block) + inspector = ActionDispatch::Routing::RoutesInspector.new(@set.routes) + inspector.format(formatter, options).split("\n") + end end end end diff --git a/actionpack/test/dispatch/routing/instrumentation_test.rb b/actionpack/test/dispatch/routing/instrumentation_test.rb new file mode 100644 index 0000000000000..1e27f1b7317dd --- /dev/null +++ b/actionpack/test/dispatch/routing/instrumentation_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class RoutingInstrumentationTest < ActionDispatch::IntegrationTest + test "redirect is instrumented" do + draw do + get "redirect", to: redirect("/login") + end + + notification = assert_notification("redirect.action_dispatch", status: 301, location: "http://www.example.com/login") do + get "/redirect" + end + + assert_kind_of ActionDispatch::Request, notification.payload[:request] + 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 diff --git a/actionpack/test/dispatch/routing/ipv6_redirect_test.rb b/actionpack/test/dispatch/routing/ipv6_redirect_test.rb index 179aee9ba7b4b..31559bffc70bd 100644 --- a/actionpack/test/dispatch/routing/ipv6_redirect_test.rb +++ b/actionpack/test/dispatch/routing/ipv6_redirect_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class IPv6IntegrationTest < ActionDispatch::IntegrationTest diff --git a/actionpack/test/dispatch/routing/log_subscriber_test.rb b/actionpack/test/dispatch/routing/log_subscriber_test.rb new file mode 100644 index 0000000000000..a228457c2eb3f --- /dev/null +++ b/actionpack/test/dispatch/routing/log_subscriber_test.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +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 + setup do + @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + @old_logger = ActionDispatch::LogSubscriber.logger + ActionDispatch::LogSubscriber.logger = @logger + end + + teardown do + ActionDispatch::LogSubscriber.logger = @old_logger + end + + test "redirect is logged" do + draw do + get "redirect", to: redirect("/login") + end + + get "/redirect" + + 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| + routes.default_url_options = { host: "www.example.com" } + routes.draw(&block) + @app = RoutedRackApp.new routes + end + end + + def get(path, **options) + super(path, **options.merge(headers: { "action_dispatch.routes" => @app.routes })) + end + + def logs + @logs ||= @logger.logged(:info) + end +end diff --git a/actionpack/test/dispatch/routing/non_dispatch_routed_app_test.rb b/actionpack/test/dispatch/routing/non_dispatch_routed_app_test.rb new file mode 100644 index 0000000000000..676a8c38d4e3e --- /dev/null +++ b/actionpack/test/dispatch/routing/non_dispatch_routed_app_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "abstract_unit" + +module ActionDispatch + module Routing + class NonDispatchRoutedAppTest < ActionDispatch::IntegrationTest + # For example, Grape::API + class SimpleApp + def self.call(env) + [ 200, { "Content-Type" => "text/plain" }, [] ] + end + + def self.routes + [] + end + end + + setup { @app = SimpleApp } + + test "does not except" do + get "/foo" + assert_response :success + end + end + end +end diff --git a/actionpack/test/dispatch/routing/route_set_test.rb b/actionpack/test/dispatch/routing/route_set_test.rb index ace35dda53ea3..6ad64b2946d64 100644 --- a/actionpack/test/dispatch/routing/route_set_test.rb +++ b/actionpack/test/dispatch/routing/route_set_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch @@ -18,23 +20,23 @@ def call(env) end test "not being empty when route is added" do - assert empty? + assert_empty @set draw do get "foo", to: SimpleApp.new("foo#index") end - assert_not empty? + assert_not_empty @set end - test "url helpers are added when route is added" do + test "URL helpers are added when route is added" do draw do get "foo", to: SimpleApp.new("foo#index") end assert_equal "/foo", url_helpers.foo_path assert_raises NoMethodError do - assert_equal "/bar", url_helpers.bar_path + url_helpers.bar_path end draw do @@ -46,7 +48,7 @@ def call(env) assert_equal "/bar", url_helpers.bar_path end - test "url helpers are updated when route is updated" do + test "URL helpers are updated when route is updated" do draw do get "bar", to: SimpleApp.new("bar#index"), as: :bar end @@ -60,7 +62,7 @@ def call(env) assert_equal "/baz", url_helpers.bar_path end - test "url helpers are removed when route is removed" do + test "URL helpers are removed when route is removed" do draw do get "foo", to: SimpleApp.new("foo#index") get "bar", to: SimpleApp.new("bar#index") @@ -75,7 +77,7 @@ def call(env) assert_equal "/foo", url_helpers.foo_path assert_raises NoMethodError do - assert_equal "/bar", url_helpers.bar_path + url_helpers.bar_path end end @@ -93,7 +95,7 @@ def call(env) end assert_raises ArgumentError do - assert_equal "http://example.com/foo", url_helpers.foo_url(only_path: false) + url_helpers.foo_url(only_path: false) end end @@ -138,6 +140,53 @@ def call(env) assert_equal "/a/users/1", url_helpers.user_path(1, foo: "a") end + test "implicit path components consistently return the same result" do + draw do + resources :users, to: SimpleApp.new("foo#index") + end + assert_equal "/users/1.json", url_helpers.user_path(1, :json) + assert_equal "/users/1.json", url_helpers.user_path(1, format: :json) + assert_equal "/users/1.json", url_helpers.user_path(1, :json) + end + + test "escape new line for dynamic params" do + draw do + get "/dynamic/:dynamic_segment", to: SimpleApp.new("foo#index"), as: :dynamic + end + + assert_equal "/dynamic/a%0Anewline", url_helpers.dynamic_path(dynamic_segment: "a\nnewline") + end + + test "escape new line for wildcard params" do + draw do + get "/wildcard/*wildcard_segment", to: SimpleApp.new("foo#index"), as: :wildcard + end + + assert_equal "/wildcard/a%0Anewline", url_helpers.wildcard_path(wildcard_segment: "a\nnewline") + end + + test "find a route for the given requirements" do + draw do + resources :foo + resources :bar + end + + route = @set.from_requirements(controller: "bar", action: "index") + + assert_equal "bar_index", route.name + end + + test "find a route for the given requirements returns nil for no match" do + draw do + resources :foo + resources :bar + end + + route = @set.from_requirements(controller: "baz", action: "index") + + assert_nil route + end + private def draw(&block) @set.draw(&block) diff --git a/actionpack/test/dispatch/routing_assertions_test.rb b/actionpack/test/dispatch/routing_assertions_test.rb index 917ce7e668668..f7450bf1ea145 100644 --- a/actionpack/test/dispatch/routing_assertions_test.rb +++ b/actionpack/test/dispatch/routing_assertions_test.rb @@ -1,12 +1,55 @@ +# frozen_string_literal: true + require "abstract_unit" +require "rails/engine" require "controller/fake_controllers" -class SecureArticlesController < ArticlesController; end +class SecureArticlesController < ArticlesController + def index + render(inline: "") + end +end class BlockArticlesController < ArticlesController; end class QueryArticlesController < ArticlesController; end -class RoutingAssertionsTest < ActionController::TestCase +class SecureBooksController < BooksController; end +class BlockBooksController < BooksController; end +class QueryBooksController < BooksController; end + +module RoutingAssertionsSharedTests def setup + root_engine = Class.new(Rails::Engine) do + def self.name + "root_engine" + end + end + + root_engine.routes.draw do + root to: "books#index" + end + + engine = Class.new(Rails::Engine) do + def self.name + "blog_engine" + end + end + + engine.routes.draw do + resources :books + + scope "secure", constraints: { protocol: "https://" } do + resources :books, controller: "secure_books" + end + + scope "block", constraints: lambda { |r| r.ssl? } do + resources :books, controller: "block_books" + end + + scope "query", constraints: lambda { |r| r.params[:use_query] == "true" } do + resources :books, controller: "query_books" + end + end + @routes = ActionDispatch::Routing::RouteSet.new @routes.draw do resources :articles @@ -22,6 +65,16 @@ def setup scope "query", constraints: lambda { |r| r.params[:use_query] == "true" } do resources :articles, controller: "query_articles" end + + mount engine => "/shelf" + + mount root_engine => "/" + + get "/shelf/foo", controller: "query_articles", action: "index" + + get "*path", to: "symbols#index", constraints: ->(request) do + request.fullpath == "/request_mutated" + end end end @@ -31,11 +84,11 @@ def test_assert_generates end def test_assert_generates_with_defaults - assert_generates("/articles/1/edit", { controller: "articles", action: "edit" }, id: "1") + assert_generates("/articles/1/edit", { controller: "articles", action: "edit" }, { id: "1" }) end def test_assert_generates_with_extras - assert_generates("/articles", { controller: "articles", action: "index", page: "1" }, {}, page: "1") + assert_generates("/articles", { controller: "articles", action: "index", page: "1" }, {}, { page: "1" }) end def test_assert_recognizes @@ -48,45 +101,100 @@ def test_assert_recognizes_with_extras end def test_assert_recognizes_with_method - assert_recognizes({ controller: "articles", action: "create" }, path: "/articles", method: :post) - assert_recognizes({ controller: "articles", action: "update", id: "1" }, path: "/articles/1", method: :put) + assert_recognizes({ controller: "articles", action: "create" }, { path: "/articles", method: :post }) + assert_recognizes({ controller: "articles", action: "update", id: "1" }, { path: "/articles/1", method: :put }) end def test_assert_recognizes_with_hash_constraint - assert_raise(Assertion) do + assert_raise(Minitest::Assertion) do assert_recognizes({ controller: "secure_articles", action: "index" }, "http://test.host/secure/articles") end assert_recognizes({ controller: "secure_articles", action: "index", protocol: "https://" }, "https://test.host/secure/articles") end def test_assert_recognizes_with_block_constraint - assert_raise(Assertion) do + assert_raise(Minitest::Assertion) do assert_recognizes({ controller: "block_articles", action: "index" }, "http://test.host/block/articles") end assert_recognizes({ controller: "block_articles", action: "index" }, "https://test.host/block/articles") end def test_assert_recognizes_with_query_constraint - assert_raise(Assertion) do + assert_raise(Minitest::Assertion) do assert_recognizes({ controller: "query_articles", action: "index", use_query: "false" }, "/query/articles", use_query: "false") end assert_recognizes({ controller: "query_articles", action: "index", use_query: "true" }, "/query/articles", use_query: "true") end def test_assert_recognizes_raises_message - err = assert_raise(Assertion) do + err = assert_raise(Minitest::Assertion) do assert_recognizes({ controller: "secure_articles", action: "index" }, "http://test.host/secure/articles", {}, "This is a really bad msg") end assert_match err.message, "This is a really bad msg" end + def test_assert_recognizes_with_engine + assert_recognizes({ controller: "books", action: "index" }, "/shelf/books") + assert_recognizes({ controller: "books", action: "show", id: "1" }, "/shelf/books/1") + end + + def test_assert_recognizes_with_engine_at_root + assert_recognizes({ controller: "books", action: "index" }, "/") + end + + def test_assert_recognizes_with_engine_and_extras + assert_recognizes({ controller: "books", action: "index", page: "1" }, "/shelf/books", page: "1") + end + + def test_assert_recognizes_with_engine_and_method + assert_recognizes({ controller: "books", action: "create" }, { path: "/shelf/books", method: :post }) + assert_recognizes({ controller: "books", action: "update", id: "1" }, { path: "/shelf/books/1", method: :put }) + end + + def test_assert_recognizes_with_engine_and_hash_constraint + assert_raise(Minitest::Assertion) do + assert_recognizes({ controller: "secure_books", action: "index" }, "http://test.host/shelf/secure/books") + end + assert_recognizes({ controller: "secure_books", action: "index", protocol: "https://" }, "https://test.host/shelf/secure/books") + end + + def test_assert_recognizes_with_engine_and_block_constraint + assert_raise(Minitest::Assertion) do + assert_recognizes({ controller: "block_books", action: "index" }, "http://test.host/shelf/block/books") + end + assert_recognizes({ controller: "block_books", action: "index" }, "https://test.host/shelf/block/books") + end + + def test_assert_recognizes_with_engine_and_query_constraint + assert_raise(Minitest::Assertion) do + assert_recognizes({ controller: "query_books", action: "index", use_query: "false" }, "/shelf/query/books", use_query: "false") + end + assert_recognizes({ controller: "query_books", action: "index", use_query: "true" }, "/shelf/query/books", use_query: "true") + end + + def test_assert_recognizes_raises_message_with_engine + err = assert_raise(Minitest::Assertion) do + assert_recognizes({ controller: "secure_books", action: "index" }, "http://test.host/shelf/secure/books", {}, "This is a really bad msg") + end + + assert_match err.message, "This is a really bad msg" + end + + def test_assert_recognizes_continue_to_recognize_after_it_tried_engines + assert_recognizes({ controller: "query_articles", action: "index" }, "/shelf/foo") + end + + def test_assert_recognizes_doesnt_mutate_request + assert_recognizes({ controller: "symbols", action: "index", path: "request_mutated" }, "/request_mutated") + end + def test_assert_routing assert_routing("/articles", controller: "articles", action: "index") end def test_assert_routing_raises_message - err = assert_raise(Assertion) do + err = assert_raise(Minitest::Assertion) do assert_routing("/thisIsNotARoute", { controller: "articles", action: "edit", id: "1" }, { id: "1" }, {}, "This is a really bad msg") end @@ -94,22 +202,22 @@ def test_assert_routing_raises_message end def test_assert_routing_with_defaults - assert_routing("/articles/1/edit", { controller: "articles", action: "edit", id: "1" }, id: "1") + assert_routing("/articles/1/edit", { controller: "articles", action: "edit", id: "1" }, { id: "1" }) end def test_assert_routing_with_extras - assert_routing("/articles", { controller: "articles", action: "index", page: "1" }, {}, page: "1") + assert_routing("/articles", { controller: "articles", action: "index", page: "1" }, {}, { page: "1" }) end def test_assert_routing_with_hash_constraint - assert_raise(Assertion) do + assert_raise(Minitest::Assertion) do assert_routing("http://test.host/secure/articles", controller: "secure_articles", action: "index") end assert_routing("https://test.host/secure/articles", controller: "secure_articles", action: "index", protocol: "https://") end def test_assert_routing_with_block_constraint - assert_raise(Assertion) do + assert_raise(Minitest::Assertion) do assert_routing("http://test.host/block/articles", controller: "block_articles", action: "index") end assert_routing("https://test.host/block/articles", controller: "block_articles", action: "index") @@ -122,9 +230,175 @@ def test_with_routing end assert_routing("/artikel", controller: "articles", action: "index") - assert_raise(Assertion) do + assert_raise(Minitest::Assertion) do assert_routing("/articles", controller: "articles", action: "index") end end end + + module WithRoutingSharedTests + extend ActiveSupport::Concern + + def before_setup + @routes = ActionDispatch::Routing::RouteSet.new + @routes.draw do + resources :articles + end + + super + end + + included do + with_routing do |routes| + routes.draw do + resources :articles, path: "artikel" + end + end + end + + def test_with_routing_for_the_entire_test_file + assert_routing("/artikel", controller: "articles", action: "index") + assert_raise(Minitest::Assertion) do + assert_routing("/articles", controller: "articles", action: "index") + end + end + + def test_with_routing_for_entire_test_file_can_be_overwritten_for_individual_test + with_routing do |routes| + routes.draw do + resources :articles, path: "articolo" + end + + assert_routing("/articolo", controller: "articles", action: "index") + assert_raise(Minitest::Assertion) do + assert_routing("/artikel", controller: "articles", action: "index") + end + end + + assert_routing("/artikel", controller: "articles", action: "index") + assert_raise(Minitest::Assertion) do + assert_routing("/articolo", controller: "articles", action: "index") + end + end + end +end + +class RoutingAssertionsControllerTest < ActionController::TestCase + include RoutingAssertionsSharedTests + + class WithRoutingTest < ActionController::TestCase + include RoutingAssertionsSharedTests::WithRoutingSharedTests + + test "with_routing routes are reachable" do + @controller = SecureArticlesController.new + + with_routing do |routes| + routes.draw do + get :new_route, to: "secure_articles#index" + end + + get :index + + assert_predicate(response, :ok?) + end + end + end +end + +class RoutingAssertionsIntegrationTest < ActionDispatch::IntegrationTest + include RoutingAssertionsSharedTests + + test "https and host settings are set on new session" do + https! + host! "newhost.com" + + with_routing do |routes| + routes.draw { } + assert_predicate integration_session, :https? + assert_equal "newhost.com", integration_session.host + end + end + + class WithRoutingTest < ActionDispatch::IntegrationTest + include RoutingAssertionsSharedTests::WithRoutingSharedTests + + test "with_routing routes are reachable" do + with_routing do |routes| + routes.draw do + get :new_route, to: "secure_articles#index" + end + + get "/new_route" + + assert_predicate(response, :ok?) + end + end + end + + class WithRoutingSettingsTest < ActionDispatch::IntegrationTest + setup do + https! + host! "newhost.com" + end + + with_routing do |routes| + routes.draw { } + end + + test "https and host settings are set on new session" do + assert_predicate integration_session, :https? + assert_equal "newhost.com", integration_session.host + end + end +end + +class WithRoutingResetTest < ActionDispatch::IntegrationTest + test "with_routing doesn't rebuild the middleware stack" do + middleware = Class.new do + def initialize(app) + @app = app + @config = {} + + yield(@config) + end + + def call(env) + [200, { Rack::CONTENT_TYPE => "text/plain; charset=UTF-8" }, [@config[:foo]]] + end + end + + middleware_config = nil + + @app = self.class.build_app do |middleware_stack| + middleware_stack.use middleware do |config| + middleware_config = config + end + end + @app.routes.draw do + get("/purchase", to: "store#purchase") + end + + middleware_config[:foo] = "bar" + + # Ensure the middleware is properly configured before calling `with_routing` + get "/purchase" + assert_response(:ok) + assert_equal("bar", response.body) + + with_routing do |route_set| + route_set.draw do + get "/purchase", to: "store#purchase" + end + + # Ensure the middleware is properly configured inside `with_routing` + get "/purchase" + assert_response(:ok) + assert_equal("bar", response.body) + end + + # Ensure the middleware is properly configured after `with_routing` + get "/purchase" + assert_response(:ok) + assert_equal("bar", response.body) + end end diff --git a/actionpack/test/dispatch/routing_test.rb b/actionpack/test/dispatch/routing_test.rb index 53758a4fbcffa..11570c53e07da 100644 --- a/actionpack/test/dispatch/routing_test.rb +++ b/actionpack/test/dispatch/routing_test.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + require "erb" require "abstract_unit" require "controller/fake_controllers" +require "active_support/messages/rotation_configuration" class TestRoutingMapper < ActionDispatch::IntegrationTest SprocketsApp = lambda { |env| @@ -9,7 +12,7 @@ class TestRoutingMapper < ActionDispatch::IntegrationTest class IpRestrictor def self.matches?(request) - request.ip =~ /192\.168\.1\.1\d\d/ + /192\.168\.1\.1\d\d/.match?(request.ip) end end @@ -112,11 +115,26 @@ def test_redirect_with_passing_constraint assert_equal 301, status end + def test_accepts_a_constraint_object_responding_to_call + constraint = Class.new do + def call(*); true; end + def matches?(*); false; end + end + + draw do + get "/", to: "home#show", constraints: constraint.new + end + + assert_nothing_raised do + get "/" + end + end + def test_namespace_with_controller_segment assert_raise(ArgumentError) do draw do namespace :admin do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get "/:controller(/:action(/:id(.:format)))" end end @@ -127,7 +145,7 @@ def test_namespace_with_controller_segment def test_namespace_without_controller_segment draw do namespace :admin do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get "hello/:controllers/:action" end end @@ -336,6 +354,20 @@ def test_openid assert_equal "openid#login", @response.body end + def test_websocket + draw do + connect "chat/live", to: "chat#live" + end + + # HTTP/1.1 connection upgrade: + get "/chat/live", headers: { "REQUEST_METHOD" => "GET", "HTTP_CONNECTION" => "Upgrade", "HTTP_UPGRADE" => "websocket" } + assert_equal "chat#live", @response.body + + # `rack.protocol` connection: + get "/chat/live", headers: { "REQUEST_METHOD" => "CONNECT", "rack.protocol" => "websocket" } + assert_equal "chat#live", @response.body + end + def test_bookmarks draw do scope "bookmark", controller: "bookmarks", as: :bookmark do @@ -412,19 +444,19 @@ def test_admin assert_equal "queenbee#index", @response.body get "/admin", headers: { "REMOTE_ADDR" => "10.0.0.100" } - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] get "/admin/accounts", headers: { "REMOTE_ADDR" => "192.168.1.100" } assert_equal "queenbee#accounts", @response.body get "/admin/accounts", headers: { "REMOTE_ADDR" => "10.0.0.100" } - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] get "/admin/passwords", headers: { "REMOTE_ADDR" => "192.168.1.100" } assert_equal "queenbee#passwords", @response.body get "/admin/passwords", headers: { "REMOTE_ADDR" => "10.0.0.100" } - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] end def test_global @@ -434,7 +466,7 @@ def test_global get "global/export", action: :export, as: :export_request get "/export/:id/:file", action: :export, as: :export_download, constraints: { file: /.*/ } - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get "global/:action" end end @@ -459,7 +491,7 @@ def test_global def test_local draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get "/local/:action", controller: "local" end end @@ -474,7 +506,7 @@ def test_url_for_with_no_side_effects get "/projects/status(.:format)" end - # without dup, additional (and possibly unwanted) values will be present in the options (eg. :host) + # without dup, additional (and possibly unwanted) values will be present in the options (e.g. :host) original_options = { controller: "projects", action: "status" } options = original_options.dup @@ -745,7 +777,8 @@ def test_projects_people member do put :accessible_projects - post :resend, :generate_new_password + post :resend + post :generate_new_password end end end @@ -794,7 +827,8 @@ def test_projects_posts draw do resources :projects do resources :posts do - get :archive, :toggle_view, on: :collection + get :archive, on: :collection + get :toggle_view, on: :collection post :preview, on: :member resource :subscription @@ -872,13 +906,13 @@ def test_resource_routes_with_only_and_except assert_equal "/posts/1/comments", post_comments_path(post_id: 1) post "/posts" - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] put "/posts/1" - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] delete "/posts/1" - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] delete "/posts/1/comments" - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] end def test_resource_routes_only_create_update_destroy @@ -954,13 +988,13 @@ def test_resources_for_uncountable_names def test_resource_does_not_modify_passed_options options = { id: /.+?/, format: /json|xml/ } - draw { resource :user, options } + draw { resource :user, **options } assert_equal({ id: /.+?/, format: /json|xml/ }, options) end def test_resources_does_not_modify_passed_options options = { id: /.+?/, format: /json|xml/ } - draw { resources :users, options } + draw { resources :users, **options } assert_equal({ id: /.+?/, format: /json|xml/ }, options) end @@ -1307,7 +1341,7 @@ def test_articles_with_id assert_equal "articles#with_id", @response.body get "/articles/123/1" - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] assert_equal "/articles/rails/1", article_with_title_path(title: "rails", id: 1) end @@ -1364,6 +1398,95 @@ def test_scoped_root_as_name assert_equal "projects#index", @response.body end + def test_optional_scoped_root_hierarchy + draw do + scope "(:locale)" do + scope "(:platform)" do + scope "(:browser)" do + root to: "projects#index" + end + end + end + end + + assert_equal "/", root_path + assert_equal "/en", root_path(locale: "en") + assert_equal "/en/osx", root_path(locale: "en", platform: "osx") + assert_equal "/en/osx/chrome", + root_path(locale: "en", platform: "osx", browser: "chrome") + + get "/" + assert_equal "projects#index", @response.body + + get "/en" + assert_equal "projects#index", @response.body + + get "/en/osx" + assert_equal "projects#index", @response.body + + get "/en/osx/chrome" + assert_equal "projects#index", @response.body + end + + def test_optional_scoped_root_multiple_choice + draw do + scope "(:locale)" do + scope "(p/:platform)" do + scope "(b/:browser)" do + root to: "projects#index" + end + end + end + end + + # Note, in this particular case where we rely on pattern matching instead + # of hierarchy to match parameters in a root path, root_path returns "" + # when given no path parameters. + + assert_equal "/en", root_path(locale: "en") + assert_equal "/p/osx", root_path(platform: "osx") + assert_equal "/en/p/osx", root_path(locale: "en", platform: "osx") + assert_equal "/b/chrome", root_path(browser: "chrome") + assert_equal "/en/b/chrome", root_path(locale: "en", browser: "chrome") + assert_equal "/p/osx/b/chrome", + root_path(platform: "osx", browser: "chrome") + assert_equal "/en/p/osx/b/chrome", + root_path(locale: "en", platform: "osx", browser: "chrome") + + get "/en" + assert_equal "projects#index", @response.body + + get "/p/osx" + assert_equal "projects#index", @response.body + + get "/en/p/osx" + assert_equal "projects#index", @response.body + + get "/b/chrome" + assert_equal "projects#index", @response.body + + get "/en/b/chrome" + assert_equal "projects#index", @response.body + + get "/p/osx/b/chrome" + assert_equal "projects#index", @response.body + + get "/en/p/osx/b/chrome" + assert_equal "projects#index", @response.body + end + + def test_optional_part_of_segment + draw do + get "/star-trek(-tng)/:episode", to: "star_trek#show" + end + + get "/star-trek/02-15-the-trouble-with-tribbles" + assert_equal "star_trek#show", @response.body + + get "/star-trek-tng/05-02-darmok" + assert_equal "star_trek#show", @response.body + end + def test_scope_with_format_option draw do get "direct/index", as: :no_format_direct, format: false @@ -1426,18 +1549,11 @@ def test_index end def test_match_with_many_paths_containing_a_slash - draw do - get "get/first", "get/second", "get/third", to: "get#show" + 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 @@ -1463,17 +1579,13 @@ def test_match_shorthand_inside_namespace end def test_match_shorthand_with_multiple_paths_inside_namespace - draw do - namespace :proposals do - put "activate", "inactivate" + 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 @@ -1517,7 +1629,7 @@ def test_match_shorthand_inside_nested_namespaces_and_scopes_with_controller def test_not_matching_shorthand_with_dynamic_parameters draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller/:action/admin" end end @@ -1555,7 +1667,7 @@ def test_dynamically_generated_helpers_on_collection_do_not_clobber_resources_ur def test_scoped_controller_with_namespace_and_action draw do namespace :account do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action/callback", action: /twitter|github/, controller: "callbacks", as: :callback end end @@ -1802,7 +1914,7 @@ def test_resource_constraints end get "/products/1" - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] get "/products" assert_equal "products#root", @response.body get "/products/favorite" @@ -1811,14 +1923,14 @@ def test_resource_constraints assert_equal "products#show", @response.body get "/products/1/images" - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] get "/products/0001/images" assert_equal "images#index", @response.body get "/products/0001/images/0001" assert_equal "images#show", @response.body get "/dashboard", headers: { "REMOTE_ADDR" => "10.0.0.100" } - assert_equal "pass", @response.headers["X-Cascade"] + assert_equal "pass", @response.headers["x-cascade"] get "/dashboard", headers: { "REMOTE_ADDR" => "192.168.1.100" } assert_equal "dashboards#show", @response.body end @@ -1888,7 +2000,7 @@ def test_symbol_scope def test_url_generator_for_generic_route draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get "whatever/:controller(/:action(/:id))" end end @@ -1902,7 +2014,7 @@ def test_url_generator_for_generic_route def test_url_generator_for_namespaced_generic_route draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get "whatever/:controller(/:action(/:id))", id: /\d+/ end end @@ -2166,6 +2278,37 @@ def test_shallow_nested_resources_inside_resource assert_equal "cards#destroy", @response.body end + def test_shallow_false_inside_nested_shallow_resource + draw do + resources :blogs, shallow: true do + resources :posts do + resources :comments, shallow: false + resources :tags + end + end + end + + get "/posts/1/comments" + assert_equal "comments#index", @response.body + assert_equal "/posts/1/comments", post_comments_path("1") + + get "/posts/1/comments/new" + assert_equal "comments#new", @response.body + assert_equal "/posts/1/comments/new", new_post_comment_path("1") + + get "/posts/1/comments/2" + assert_equal "comments#show", @response.body + assert_equal "/posts/1/comments/2", post_comment_path("1", "2") + + get "/posts/1/comments/2/edit" + assert_equal "comments#edit", @response.body + assert_equal "/posts/1/comments/2/edit", edit_post_comment_path("1", "2") + + get "/tags/3" + assert_equal "tags#show", @response.body + assert_equal "/tags/3", tag_path("3") + end + def test_shallow_deeply_nested_resources draw do resources :blogs do @@ -3150,7 +3293,7 @@ def test_named_route_check after = has_named_route?(:hello) end - assert !before, "expected to not have named route :hello before route definition" + assert_not before, "expected to not have named route :hello before route definition" assert after, "expected to have named route :hello after route definition" end @@ -3163,7 +3306,7 @@ def test_explicitly_avoiding_the_named_route end end - assert !respond_to?(:routes_no_collision_path) + assert_not respond_to?(:routes_no_collision_path) end def test_controller_name_with_leading_slash_raise_error @@ -3304,13 +3447,23 @@ def test_shallow_custom_param assert_equal "0c0c0b68-d24b-11e1-a861-001ff3fffe6f", @request.params[:download] end - def test_action_from_path_is_not_frozen + def test_colon_containing_custom_param + ex = assert_raises(ArgumentError) { + draw do + resources :profiles, param: "username/:is_admin" + end + } + + assert_match(/:param option can't contain colon/, ex.message) + end + + def test_action_from_path_is_frozen draw do get "search" => "search" end get "/search" - assert !@request.params[:action].frozen? + assert_predicate @request.params[:action], :frozen? end def test_multiple_positional_args_with_the_same_name @@ -3361,16 +3514,16 @@ def test_trailing_slash end get "/streams" - assert @response.ok?, "route without trailing slash should work" + assert_predicate @response, :ok?, "route without trailing slash should work" get "/streams/" - assert @response.ok?, "route with trailing slash should work" + assert_predicate @response, :ok?, "route with trailing slash should work" get "/streams?foobar" - assert @response.ok?, "route without trailing slash and with QUERY_STRING should work" + assert_predicate @response, :ok?, "route without trailing slash and with QUERY_STRING should work" get "/streams/?foobar" - assert @response.ok?, "route with trailing slash and with QUERY_STRING should work" + assert_predicate @response, :ok?, "route with trailing slash and with QUERY_STRING should work" end def test_route_with_dashes_in_path @@ -3633,7 +3786,7 @@ def test_passing_action_parameters_to_url_helpers_raises_error_if_parameters_are end params = ActionController::Parameters.new(id: "1") - assert_raises ArgumentError do + assert_raises ActionController::UnfilteredParameters do root_path(params) end end @@ -3649,7 +3802,7 @@ def test_passing_action_parameters_to_url_helpers_is_allowed_if_parameters_are_p end def test_dynamic_controller_segments_are_deprecated - assert_deprecated do + assert_deprecated(ActionDispatch.deprecator) do draw do get "/:controller", action: "index" end @@ -3657,22 +3810,32 @@ def test_dynamic_controller_segments_are_deprecated end def test_dynamic_action_segments_are_deprecated - assert_deprecated do + assert_deprecated(ActionDispatch.deprecator) do draw do get "/pages/:action", controller: "pages" end end end - def test_multiple_roots + def test_multiple_roots_raises_error + ex = assert_raises(ArgumentError) { + draw do + root "pages#index", constraints: { host: "www.example.com" } + root "admin/pages#index", constraints: { host: "admin.example.com" } + end + } + assert_match(/Invalid route name, already in use: 'root'/, ex.message) + end + + def test_multiple_named_roots draw do namespace :foo do root "pages#index", constraints: { host: "www.example.com" } - root "admin/pages#index", constraints: { host: "admin.example.com" } + root "admin/pages#index", constraints: { host: "admin.example.com" }, as: :admin_root end root "pages#index", constraints: { host: "www.example.com" } - root "admin/pages#index", constraints: { host: "admin.example.com" } + root "admin/pages#index", constraints: { host: "admin.example.com" }, as: :admin_root end get "http://www.example.com/foo" @@ -3706,8 +3869,35 @@ def test_multiple_namespaced_roots assert_equal "/bar", bar_root_path end -private + def test_nested_routes_under_format_resource + draw do + resources :formats do + resources :items + end + end + + get "/formats/1/items.json" + assert_equal 200, @response.status + assert_equal "items#index", @response.body + assert_equal "/formats/1/items.json", format_items_path(1, :json) + get "/formats/1/items/2.json" + assert_equal 200, @response.status + assert_equal "items#show", @response.body + assert_equal "/formats/1/items/2.json", format_item_path(1, 2, :json) + end + + def test_routes_with_double_colon + draw do + get "/sort::sort", to: "sessions#sort" + end + + get "/sort:asc" + assert_equal "asc", @request.params[:sort] + assert_equal "sessions#sort", @response.body + end + +private def draw(&block) self.class.stub_controllers do |routes| routes.default_url_options = { host: "www.example.com" } @@ -3720,9 +3910,9 @@ def url_for(options = {}) @app.routes.url_helpers.url_for(options) end - def method_missing(method, *args, &block) - if method.to_s =~ /_(path|url)$/ - @app.routes.url_helpers.send(method, *args, &block) + def method_missing(method, ...) + if method.match?(/_(path|url)$/) + @app.routes.url_helpers.send(method, ...) else super end @@ -3739,11 +3929,7 @@ def with_https def verify_redirect(url, status = 301) assert_equal status, @response.status assert_equal url, @response.headers["Location"] - assert_equal expected_redirect_body(url), @response.body - end - - def expected_redirect_body(url) - %(You are being redirected.) + assert_equal "", @response.body end end @@ -3866,6 +4052,7 @@ def draw(&block) routes = ActionDispatch::Routing::RouteSet.new routes.draw(&block) @app = self.class.build_app routes + @routes = routes end def test_missing_controller @@ -3886,7 +4073,25 @@ def test_missing_controller_with_to assert_match(/Missing :controller/, ex.message) end - def test_missing_action_on_hash + def test_implicit_controller_with_to + draw do + controller :foo do + get "/foo/bar", to: "bar" + end + end + assert_routing "/foo/bar", controller: "foo", action: "bar" + end + + def test_to_is_a_symbol + ex = assert_raises(ArgumentError) { + draw do + get "/foo/bar", to: :foo + end + } + assert_match(/:to must respond to/, ex.message) + end + + def test_missing_action_with_to ex = assert_raises(ArgumentError) { draw do get "/foo/bar", to: "foo#" @@ -4166,11 +4371,7 @@ def app; APP end def verify_redirect(url, status = 301) assert_equal status, @response.status assert_equal url, @response.headers["Location"] - assert_equal expected_redirect_body(url), @response.body - end - - def expected_redirect_body(url) - %(You are being redirected.) + assert_equal "", @response.body end end @@ -4204,7 +4405,7 @@ class TestGlobRoutingMapper < ActionDispatch::IntegrationTest end end - #include Routes.url_helpers + # include Routes.url_helpers APP = build_app Routes def app; APP end @@ -4230,7 +4431,7 @@ class TestOptimizedNamedRoutes < ActionDispatch::IntegrationTest ok = lambda { |env| [200, { "Content-Type" => "text/plain" }, []] } get "/foo" => ok, as: :foo - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get "/post(/:action(/:id))" => ok, as: :posts end @@ -4246,7 +4447,7 @@ class TestOptimizedNamedRoutes < ActionDispatch::IntegrationTest def app; APP end test "enabled when not mounted and default_url_options is empty" do - assert Routes.url_helpers.optimize_routes_generation? + assert_predicate Routes.url_helpers, :optimize_routes_generation? end test "named route called as singleton method" do @@ -4320,7 +4521,7 @@ def app; APP end include Routes.url_helpers - test "url helpers do not ignore nil parameters when using non-optimized routes" do + test "URL helpers do not ignore nil parameters when using non-optimized routes" do Routes.stub :optimize_routes_generation?, false do get "/categories/1" assert_response :success @@ -4396,22 +4597,22 @@ def app; APP end class TestInvalidUrls < ActionDispatch::IntegrationTest class FooController < ActionController::Base + param_encoding :show, :id, Encoding::ASCII_8BIT + def show render plain: "foo#show" end end - test "invalid UTF-8 encoding returns a 400 Bad Request" do + test "invalid UTF-8 encoding returns a bad request" do with_routing do |set| set.draw do get "/bar/:id", to: redirect("/foo/show/%{id}") - get "/foo/show(/:id)", to: "test_invalid_urls/foo#show" ok = lambda { |env| [200, { "Content-Type" => "text/plain" }, []] } get "/foobar/:id", to: ok - ActiveSupport::Deprecation.silence do - get "/foo(/:action(/:id))", controller: "test_invalid_urls/foo" + ActionDispatch.deprecator.silence do get "/:controller(/:action(/:id))" end end @@ -4422,9 +4623,6 @@ def show get "/foo/%E2%EF%BF%BD%A6" assert_response :bad_request - get "/foo/show/%E2%EF%BF%BD%A6" - assert_response :bad_request - get "/bar/%E2%EF%BF%BD%A6" assert_response :bad_request @@ -4432,6 +4630,36 @@ def show assert_response :bad_request end end + + test "params param_encoding uses ASCII 8bit" do + with_routing do |set| + set.draw do + get "/foo/show(/:id)", to: "test_invalid_urls/foo#show" + get "/bar/show(/:id)", controller: "test_invalid_urls/foo", action: "show" + end + + get "/foo/show/%E2%EF%BF%BD%A6" + assert_response :ok + + get "/bar/show/%E2%EF%BF%BD%A6" + assert_response :ok + end + end + + test "does not encode params besides id" do + with_routing do |set| + set.draw do + get "/foo/show(/:id)", to: "test_invalid_urls/foo#show" + get "/bar/show(/:id)", controller: "test_invalid_urls/foo", action: "show" + end + + get "/foo/show/%E2%EF%BF%BD%A6?something_else=%E2%EF%BF%BD%A6" + assert_response :bad_request + + get "/foo/show/%E2%EF%BF%BD%A6?something_else=%E2%EF%BF%BD%A6" + assert_response :bad_request + end + end end class TestOptionalRootSegments < ActionDispatch::IntegrationTest @@ -4469,7 +4697,7 @@ class TestPortConstraints < ActionDispatch::IntegrationTest get "/integer", to: ok, constraints: { port: 8080 } get "/string", to: ok, constraints: { port: "8080" } - get "/array", to: ok, constraints: { port: [8080] } + get "/array/:idx", to: ok, constraints: { port: [8080], idx: %w[first last] } get "/regexp", to: ok, constraints: { port: /8080/ } end end @@ -4498,7 +4726,10 @@ def test_array_port_constraints get "http://www.example.com/array" assert_response :not_found - get "http://www.example.com:8080/array" + get "http://www.example.com:8080/array/middle" + assert_response :not_found + + get "http://www.example.com:8080/array/first" assert_response :success end @@ -4601,7 +4832,7 @@ def app def test_route_options_are_required_for_url_for assert_raises(ActionController::UrlGenerationError) do - assert_equal "/posts/1", url_for(controller: "posts", action: "show", id: 1, only_path: true) + url_for(controller: "posts", action: "show", id: 1, only_path: true) end assert_equal "/posts/1", url_for(controller: "posts", action: "show", id: 1, bucket_type: "post", only_path: true) @@ -4679,7 +4910,7 @@ def app; APP end include Routes.url_helpers - test "url helpers raise a 'missing keys' error for a nil param with optimized helpers" do + test "URL helpers raise a 'missing keys' error for a nil param with optimized helpers" do url, missing = { action: "show", controller: "products", id: nil }, [:id] message = "No route matches #{url.inspect}, missing required keys: #{missing.inspect}" @@ -4687,25 +4918,40 @@ def app; APP end assert_equal message, error.message end - test "url helpers raise a 'constraint failure' error for a nil param with non-optimized helpers" do + test "URL helpers raise a 'constraint failure' error for a nil param with non-optimized helpers" do url, missing = { action: "show", controller: "products", id: nil }, [:id] message = "No route matches #{url.inspect}, possible unmatched constraints: #{missing.inspect}" error = assert_raises(ActionController::UrlGenerationError, message) { product_path(id: nil) } - assert_equal message, error.message + assert_match message, error.message end - test "url helpers raise message with mixed parameters when generation fails" do + test "URL helpers raise message with mixed parameters when generation fails" do url, missing = { action: "show", controller: "products", id: nil, "id" => "url-tested" }, [:id] message = "No route matches #{url.inspect}, possible unmatched constraints: #{missing.inspect}" - # Optimized url helper + # Optimized URL helper error = assert_raises(ActionController::UrlGenerationError) { product_path(nil, "id" => "url-tested") } - assert_equal message, error.message + assert_match message, error.message - # Non-optimized url helper + # Non-optimized URL helper error = assert_raises(ActionController::UrlGenerationError, message) { product_path(id: nil, "id" => "url-tested") } - assert_equal message, error.message + assert_match message, error.message + end + + test "exceptions have suggestions for fix" do + error = assert_raises(ActionController::UrlGenerationError) { product_path(nil, "id" => "url-tested") } + assert_match "Did you mean?", error.detailed_message + end + + # FIXME: we should fix all locations that raise this exception to provide + # the info DidYouMean needs and then delete this test. Just adding the + # test for now because some parameters to the constructor are optional, and + # we don't want to break other code. + test "correct for empty UrlGenerationError" do + err = ActionController::UrlGenerationError.new("oh no!") + + assert_equal [], err.corrections end end @@ -4737,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 - ActiveSupport::Deprecation.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 @@ -4802,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" @@ -4837,12 +5040,52 @@ def test_paths_with_partial_dynamic_segments_are_recognised end private - def assert_params(params) assert_equal(params, request.path_parameters) end end +class TestOptionalScopesWithOrWithoutParams < ActionDispatch::IntegrationTest + Routes = ActionDispatch::Routing::RouteSet.new.tap do |app| + app.draw do + scope module: "test_optional_scopes_with_or_without_params" do + scope "(:locale)", locale: /en|es/ do + get "home", controller: :home, action: :index + get "with_param/:foo", to: "home#with_param", as: "with_param" + get "without_param", to: "home#without_param" + end + end + end + end + + class HomeController < ActionController::Base + include Routes.url_helpers + + def index + render inline: "<%= with_param_path(foo: 'bar') %> | <%= without_param_path %>" + end + + def with_param; end + def without_param; end + end + + APP = build_app Routes + + def app + APP + end + + def test_stays_unscoped_with_or_without_params + get "/home" + assert_equal "/with_param/bar | /without_param", response.body + end + + def test_preserves_scope_with_or_without_params + get "/es/home" + assert_equal "/es/with_param/bar | /es/without_param", response.body + end +end + class TestPathParameters < ActionDispatch::IntegrationTest Routes = ActionDispatch::Routing::RouteSet.new.tap do |app| app.draw do @@ -4853,7 +5096,7 @@ class TestPathParameters < ActionDispatch::IntegrationTest end end - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":controller(/:action/(:id))" end end @@ -4903,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 @@ -4913,3 +5156,158 @@ def test_paths_with_partial_dynamic_segments_are_recognised ) end end + +class FlashRedirectTest < ActionDispatch::IntegrationTest + SessionKey = "_myapp_session" + Generator = ActiveSupport::CachingKeyGenerator.new( + ActiveSupport::KeyGenerator.new("b3c631c314c0bbca50c1b2843150fe33", iterations: 1000) + ) + Rotations = ActiveSupport::Messages::RotationConfiguration.new + SIGNED_COOKIE_SALT = "signed cookie" + ENCRYPTED_SIGNED_COOKIE_SALT = "signed encrypted cookie" + + class KeyGeneratorMiddleware + def initialize(app) + @app = app + end + + def call(env) + env["action_dispatch.key_generator"] ||= Generator + env["action_dispatch.cookies_rotations"] ||= Rotations + env["action_dispatch.signed_cookie_salt"] = SIGNED_COOKIE_SALT + env["action_dispatch.encrypted_signed_cookie_salt"] = ENCRYPTED_SIGNED_COOKIE_SALT + + @app.call(env) + end + end + + class FooController < ActionController::Base + def bar + render plain: (flash[:foo] || "foo") + end + end + + Routes = ActionDispatch::Routing::RouteSet.new + Routes.draw do + get "/foo", to: redirect { |params, req| req.flash[:foo] = "bar"; "/bar" } + get "/bar", to: "flash_redirect_test/foo#bar" + end + + APP = build_app Routes do |middleware| + middleware.use KeyGeneratorMiddleware + middleware.use ActionDispatch::Session::CookieStore, key: SessionKey + middleware.use ActionDispatch::Flash + middleware.delete ActionDispatch::ShowExceptions + end + + def app + APP + end + + include Routes.url_helpers + + def test_block_redirect_commits_flash + get "/foo", env: { "action_dispatch.key_generator" => Generator } + assert_response :redirect + + follow_redirect! + assert_equal "bar", response.body + end +end + +class TestRecognizePath < ActionDispatch::IntegrationTest + class PageConstraint + attr_reader :key, :pattern + + def initialize(key, pattern) + @key = key + @pattern = pattern + end + + def matches?(request) + pattern.match?(request.path_parameters[key]) + end + end + + stub_controllers do |routes| + Routes = routes + routes.draw do + get "/hash/:foo", to: "pages#show", constraints: { foo: /foo/ } + get "/hash/:bar", to: "pages#show", constraints: { bar: /bar/ } + + get "/proc/:foo", to: "pages#show", constraints: proc { |r| /foo/.match?(r.path_parameters[:foo]) } + get "/proc/:bar", to: "pages#show", constraints: proc { |r| /bar/.match?(r.path_parameters[:bar]) } + + get "/class/:foo", to: "pages#show", constraints: PageConstraint.new(:foo, /foo/) + get "/class/:bar", to: "pages#show", constraints: PageConstraint.new(:bar, /bar/) + end + end + + APP = build_app Routes + def app + APP + end + + def test_hash_constraints_dont_leak_between_routes + expected_params = { controller: "pages", action: "show", bar: "bar" } + actual_params = recognize_path("/hash/bar") + + assert_equal expected_params, actual_params + end + + def test_proc_constraints_dont_leak_between_routes + expected_params = { controller: "pages", action: "show", bar: "bar" } + actual_params = recognize_path("/proc/bar") + + assert_equal expected_params, actual_params + end + + def test_class_constraints_dont_leak_between_routes + expected_params = { controller: "pages", action: "show", bar: "bar" } + actual_params = recognize_path("/class/bar") + + assert_equal expected_params, actual_params + end + + private + def recognize_path(*args) + Routes.recognize_path(*args) + end +end + +class TestRelativeUrlRootGeneration < ActionDispatch::IntegrationTest + config = ActionDispatch::Routing::RouteSet::Config.new("/blog", false) + + stub_controllers(config) do |routes| + Routes = routes + + routes.draw do + get "/", to: "posts#index", as: :posts + get "/:id", to: "posts#show", as: :post + end + end + + include Routes.url_helpers + + APP = build_app Routes + + def app + APP + end + + def test_url_helpers + assert_equal "/blog/", posts_path({}) + assert_equal "/blog/", Routes.url_helpers.posts_path({}) + + assert_equal "/blog/1", post_path(id: "1") + assert_equal "/blog/1", Routes.url_helpers.post_path(id: "1") + end + + def test_optimized_url_helpers + assert_equal "/blog/", posts_path + assert_equal "/blog/", Routes.url_helpers.posts_path + + assert_equal "/blog/1", post_path("1") + assert_equal "/blog/1", Routes.url_helpers.post_path("1") + end +end diff --git a/actionpack/test/dispatch/runner_test.rb b/actionpack/test/dispatch/runner_test.rb index b76bf4a3207ce..f16c7963af213 100644 --- a/actionpack/test/dispatch/runner_test.rb +++ b/actionpack/test/dispatch/runner_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class RunnerTest < ActiveSupport::TestCase diff --git a/actionpack/test/dispatch/server_timing_test.rb b/actionpack/test/dispatch/server_timing_test.rb new file mode 100644 index 0000000000000..110cda79d82db --- /dev/null +++ b/actionpack/test/dispatch/server_timing_test.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ServerTimingTest < ActionDispatch::IntegrationTest + class TestController < ActionController::Base + def index + head :ok + end + + def show + ActiveSupport::Notifications.instrument("custom.event") do + true + end + head :ok + end + + def create + ActiveSupport::Notifications.instrument("custom.event") do + raise + end + end + end + + setup do + @middlewares = [Rack::Lint, ActionDispatch::ServerTiming, Rack::Lint] + @header_name = ActionDispatch::Constants::SERVER_TIMING + end + + teardown do + # Avoid leaking subscription into other tests + # This will break any active instance of the middleware, but we don't + # expect there to be any outside of this file. + ActionDispatch::ServerTiming.unsubscribe + end + + test "server timing header is included in the response" do + with_test_route_set do + get "/" + assert_match(/\w+/, @response.headers[@header_name]) + end + end + + test "includes default action controller events duration" do + with_test_route_set do + get "/" + assert_match(/start_processing.action_controller;dur=\w+/, @response.headers[@header_name]) + assert_match(/process_action.action_controller;dur=\w+/, @response.headers[@header_name]) + end + end + + test "includes custom active support events duration" do + with_test_route_set do + get "/id" + assert_match(/custom.event;dur=\w+/, @response.headers[@header_name]) + end + end + + test "events are tracked by thread" do + barrier = Concurrent::CyclicBarrier.new(2) + + stub_app = -> (env) { + env["action_dispatch.test"].call + [200, {}, "ok"] + } + app = Rack::Lint.new( + ActionDispatch::ServerTiming.new(Rack::Lint.new(stub_app)) + ) + + t1 = Thread.new do + proc = -> { + barrier.wait + barrier.wait + } + env = Rack::MockRequest.env_for("", { "action_dispatch.test" => proc }) + app.call(env) + end + + t2 = Thread.new do + barrier.wait + proc = -> { + ActiveSupport::Notifications.instrument("custom.event") do + true + end + } + env = Rack::MockRequest.env_for("", { "action_dispatch.test" => proc }) + response = app.call(env) + + barrier.wait + + response + end + + headers1 = t1.value[1] + headers2 = t2.value[1] + + assert_match(/custom.event;dur=\w+/, headers2[@header_name]) + assert_no_match(/custom.event;dur=\w+/, headers1[@header_name]) + end + + test "does not overwrite existing header values" do + @middlewares << Class.new do + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = @app.call(env) + header_name = ActionDispatch::Constants::SERVER_TIMING + headers[header_name] = [headers[header_name], %(entry;desc="description")].compact.join(", ") + [ status, headers, body ] + end + end + + with_test_route_set do + get "/" + assert_match(/entry;desc="description"/, @response.headers[@header_name]) + assert_match(/start_processing.action_controller;dur=\w+/, @response.headers[@header_name]) + end + end + + private + def app + @app ||= self.class.build_app do |middleware| + @middlewares.each { |m| middleware.use m } + end + end + + def with_test_route_set + with_routing do |set| + set.draw do + get "/", to: ::ServerTimingTest::TestController.action(:index) + get "/id", to: ::ServerTimingTest::TestController.action(:show) + post "/", to: ::ServerTimingTest::TestController.action(:create) + end + + yield + end + end +end diff --git a/actionpack/test/dispatch/session/abstract_secure_store_test.rb b/actionpack/test/dispatch/session/abstract_secure_store_test.rb new file mode 100644 index 0000000000000..64a6a7c641665 --- /dev/null +++ b/actionpack/test/dispatch/session/abstract_secure_store_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "action_dispatch/middleware/session/abstract_store" + +module ActionDispatch + module Session + class AbstractSecureStoreTest < ActiveSupport::TestCase + class MemoryStore < AbstractSecureStore + class SessionId < Rack::Session::SessionId + attr_reader :cookie_value + + def initialize(session_id, cookie_value) + super(session_id) + @cookie_value = cookie_value + end + end + + def initialize(app) + @sessions = {} + super + end + + def find_session(env, sid) + sid ||= 1 + session = @sessions[sid] ||= {} + [sid, session] + end + + def write_session(env, sid, session, options) + @sessions[sid] = SessionId.new(sid, session) + end + + def session_exists?(req) + true + end + end + + def test_session_is_set + env = {} + as = MemoryStore.new app + as.call(env) + + assert @env + assert Request::Session.find ActionDispatch::Request.new @env + end + + def test_new_session_object_is_merged_with_old + env = {} + as = MemoryStore.new app + as.call(env) + + assert @env + session = Request::Session.find ActionDispatch::Request.new @env + session["foo"] = "bar" + + as.call(@env) + session1 = Request::Session.find ActionDispatch::Request.new @env + + assert_not_equal session, session1 + assert_equal session.to_hash, session1.to_hash + end + + private + def app(&block) + @env = nil + lambda { |env| @env = env } + end + end + end +end diff --git a/actionpack/test/dispatch/session/abstract_store_test.rb b/actionpack/test/dispatch/session/abstract_store_test.rb index fd4d359cf852c..b3c7eeed614e8 100644 --- a/actionpack/test/dispatch/session/abstract_store_test.rb +++ b/actionpack/test/dispatch/session/abstract_store_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "action_dispatch/middleware/session/abstract_store" @@ -19,6 +21,10 @@ def find_session(env, sid) def write_session(env, sid, session, options) @sessions[sid] = session end + + def session_exists?(req) + true + end end def test_session_is_set @@ -46,6 +52,17 @@ def test_new_session_object_is_merged_with_old assert_equal session.to_hash, session1.to_hash end + def test_update_raises_an_exception_if_arg_not_hashable + env = {} + as = MemoryStore.new app + as.call(env) + session = Request::Session.find ActionDispatch::Request.new env + + assert_raise TypeError do + session.update("Not hashable") + end + end + private def app(&block) @env = nil diff --git a/actionpack/test/dispatch/session/cache_store_test.rb b/actionpack/test/dispatch/session/cache_store_test.rb index 859059063f73e..2b7a7d3faab16 100644 --- a/actionpack/test/dispatch/session/cache_store_test.rb +++ b/actionpack/test/dispatch/session/cache_store_test.rb @@ -1,5 +1,6 @@ +# frozen_string_literal: true + require "abstract_unit" -require "fixtures/session_autoload_test/session_autoload_test/foo" class CacheStoreTest < ActionDispatch::IntegrationTest class TestController < ActionController::Base @@ -22,7 +23,7 @@ def get_session_value end def get_session_id - render plain: "#{request.session.id}" + render plain: "#{request.session.id.public_id}" end def call_reset_session @@ -58,7 +59,7 @@ def test_getting_session_value_after_session_reset get "/set_session_value" assert_response :success assert cookies["_session_id"] - session_cookie = cookies.send(:hash_for)["_session_id"] + session_cookie = cookies.get_cookie("_session_id") get "/call_reset_session" assert_response :success @@ -91,6 +92,7 @@ def test_setting_session_value_after_session_reset get "/call_reset_session" assert_response :success assert_not_equal [], headers["Set-Cookie"] + assert_not_nil headers["Set-Cookie"] get "/get_session_value" assert_response :success @@ -148,33 +150,115 @@ def test_doesnt_write_session_cookie_if_session_id_is_already_exists def test_prevents_session_fixation with_test_route_set do - assert_nil @cache.read("_session_id:0xhax") + sid = Rack::Session::SessionId.new("0xhax") + assert_nil @cache.read("_session_id:#{sid.private_id}") + + cookies["_session_id"] = sid.public_id + get "/set_session_value" + + assert_response :success + assert_not_equal sid.public_id, cookies["_session_id"] + assert_nil @cache.read("_session_id:#{sid.private_id}") + assert_equal( + { "foo" => "bar" }, + @cache.read("_session_id:#{Rack::Session::SessionId.new(cookies['_session_id']).private_id}") + ) + end + end + + def test_can_read_session_with_legacy_id + with_test_route_set do + get "/set_session_value" + assert_response :success + assert cookies["_session_id"] - cookies["_session_id"] = "0xhax" + sid = Rack::Session::SessionId.new(cookies["_session_id"]) + session = @cache.read("_session_id:#{sid.private_id}") + @cache.delete("_session_id:#{sid.private_id}") + @cache.write("_session_id:#{sid.public_id}", session) + + get "/get_session_value" + assert_response :success + assert_equal 'foo: "bar"', response.body + end + end + + def test_drop_session_in_the_legacy_id_as_well + with_test_route_set do get "/set_session_value" + assert_response :success + assert cookies["_session_id"] + + sid = Rack::Session::SessionId.new(cookies["_session_id"]) + session = @cache.read("_session_id:#{sid.private_id}") + @cache.delete("_session_id:#{sid.private_id}") + @cache.write("_session_id:#{sid.public_id}", session) + get "/call_reset_session" assert_response :success - assert_not_equal "0xhax", cookies["_session_id"] - assert_nil @cache.read("_session_id:0xhax") - assert_equal({ "foo" => "bar" }, @cache.read("_session_id:#{cookies['_session_id']}")) + assert_not_equal [], headers["Set-Cookie"] + + assert_nil @cache.read("_session_id:#{sid.private_id}") + assert_nil @cache.read("_session_id:#{sid.public_id}") + end + end + + def test_doesnt_generate_same_sid_with_check_collisions + cache_store_options({ check_collisions: true }) + + expected_values = [ + +"eed45f3da20b5c4da3bc3b160f996315", + +"eed45f3da20b5c4da3bc3b160f996315", + +"eed45f3da20b5c4da3bc3b160f996316", + ] + counter = -1 + hex_generator = proc do + counter += 1 + expected_values[counter] + end + + SecureRandom.stub(:hex, hex_generator) do + with_test_route_set do + get "/set_session_value" + assert_response :success + assert cookies["_session_id"] + + first_sid = Rack::Session::SessionId.new(cookies["_session_id"]) + assert_equal expected_values[0], first_sid.public_id + + cookies.delete("_session_id") + + get "/set_session_value" + assert_response :success + assert cookies["_session_id"] + + second_sid = Rack::Session::SessionId.new(cookies["_session_id"]) + assert_equal expected_values[2], second_sid.public_id + end end end private + def cache_store_options(options = {}) + @cache_store_options ||= options + end + + def app + @app ||= self.class.build_app do |middleware| + @cache = ActiveSupport::Cache::MemoryStore.new + middleware.use ActionDispatch::Session::CacheStore, key: "_session_id", cache: @cache, **cache_store_options + middleware.delete ActionDispatch::ShowExceptions + end + end + def with_test_route_set with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action", to: ::CacheStoreTest::TestController end end - @app = self.class.build_app(set) do |middleware| - @cache = ActiveSupport::Cache::MemoryStore.new - middleware.use ActionDispatch::Session::CacheStore, key: "_session_id", cache: @cache - middleware.delete ActionDispatch::ShowExceptions - end - yield end end diff --git a/actionpack/test/dispatch/session/cookie_store_test.rb b/actionpack/test/dispatch/session/cookie_store_test.rb index 63dfc07c0d23a..f5239cd2cc366 100644 --- a/actionpack/test/dispatch/session/cookie_store_test.rb +++ b/actionpack/test/dispatch/session/cookie_store_test.rb @@ -1,14 +1,23 @@ +# frozen_string_literal: true + require "abstract_unit" require "stringio" require "active_support/key_generator" +require "active_support/messages/rotation_configuration" class CookieStoreTest < ActionDispatch::IntegrationTest SessionKey = "_myapp_session" SessionSecret = "b3c631c314c0bbca50c1b2843150fe33" - Generator = ActiveSupport::LegacyKeyGenerator.new(SessionSecret) + SessionSalt = "authenticated encrypted cookie" + + Generator = ActiveSupport::KeyGenerator.new(SessionSecret, iterations: 1000) + Rotations = ActiveSupport::Messages::RotationConfiguration.new + + SameSite = proc { :lax } - Verifier = ActiveSupport::MessageVerifier.new(SessionSecret, digest: "SHA1") - SignedBar = Verifier.generate(foo: "bar", session_id: SecureRandom.hex(16)) + Encryptor = ActiveSupport::MessageEncryptor.new( + Generator.generate_key(SessionSalt, 32), cipher: "aes-256-gcm", serializer: Marshal + ) class TestController < ActionController::Base def no_session_access @@ -21,7 +30,7 @@ def persistent_session_id def set_session_value session[:foo] = "bar" - render plain: Rack::Utils.escape(Verifier.generate(session.to_hash)) + render body: nil end def get_session_value @@ -29,7 +38,7 @@ def get_session_value end def get_session_id - render plain: "id: #{request.session.id}" + render plain: "id: #{request.session.id&.public_id}" end def get_class_after_reset_session @@ -63,19 +72,42 @@ def renew_session_id end end + include CookieAssertions + + def assert_session_cookie(attributes_string, contents) + cookies = parse_set_cookies_headers(headers["Set-Cookie"]) + + if session_cookie = cookies[SessionKey] + if attributes_string + expected_attributes = parse_set_cookie_attributes(attributes_string) + + expected_attributes.each do |key, value| + assert_equal value, session_cookie[key], "expected #{key} to be #{value.inspect}, but was #{session_cookie[key].inspect}" + end + end + + session_value = session_cookie[:value] + session_data = Encryptor.decrypt_and_verify(Rack::Utils.unescape(session_value)) rescue nil + end + + assert_not_nil session_data, "session failed to decrypt" + assert_equal session_data.slice(*contents.keys), contents + end + def test_setting_session_value with_test_route_set do get "/set_session_value" + assert_response :success - assert_equal "_myapp_session=#{response.body}; path=/; HttpOnly", - headers["Set-Cookie"] + assert_session_cookie "path=/; HttpOnly", { "foo" => "bar" } end end def test_getting_session_value with_test_route_set do - cookies[SessionKey] = SignedBar + get "/set_session_value" get "/get_session_value" + assert_response :success assert_equal 'foo: "bar"', response.body end @@ -83,8 +115,9 @@ def test_getting_session_value def test_getting_session_id with_test_route_set do - cookies[SessionKey] = SignedBar + get "/set_session_value" get "/persistent_session_id" + assert_response :success assert_equal 32, response.body.size session_id = response.body @@ -97,15 +130,21 @@ def test_getting_session_id def test_disregards_tampered_sessions with_test_route_set do - cookies[SessionKey] = "BAh7BjoIZm9vIghiYXI%3D--123456780" + encryptor = ActiveSupport::MessageEncryptor.new("A" * 32, cipher: "aes-256-gcm", serializer: Marshal) + + cookies[SessionKey] = encryptor.encrypt_and_sign({ "foo" => "bar", "session_id" => "abc" }) + get "/get_session_value" + assert_response :success assert_equal "foo: nil", response.body end end def test_does_not_set_secure_cookies_over_http - with_test_route_set(secure: true) do + cookie_options(secure: true) + + with_test_route_set do get "/set_session_value" assert_response :success assert_nil headers["Set-Cookie"] @@ -124,21 +163,23 @@ def test_properly_renew_cookies end def test_does_set_secure_cookies_over_https - with_test_route_set(secure: true) do + cookie_options(secure: true) + + with_test_route_set do get "/set_session_value", headers: { "HTTPS" => "on" } + assert_response :success - assert_equal "_myapp_session=#{response.body}; path=/; secure; HttpOnly", - headers["Set-Cookie"] + assert_session_cookie "path=/; secure; HttpOnly", "foo" => "bar" end end # {:foo=>#, :session_id=>"ce8b0752a6ab7c7af3cdb8a80e6b9e46"} - SignedSerializedCookie = "BAh7BzoIZm9vbzodU2Vzc2lvbkF1dG9sb2FkVGVzdDo6Rm9vBjoJQGJhciIIYmF6Og9zZXNzaW9uX2lkIiVjZThiMDc1MmE2YWI3YzdhZjNjZGI4YTgwZTZiOWU0Ng==--2bf3af1ae8bd4e52b9ac2099258ace0c380e601c" + EncryptedSerializedCookie = "9RZ2Fij0qLveUwM4s+CCjGqhpjyUC8jiBIf/AiBr9M3TB8xh2vQZtvSOMfN3uf6oYbbpIDHAcOFIEl69FcW1ozQYeSrCLonYCazoh34ZdYskIQfGwCiSYleVXG1OD9Z4jFqeVArw4Ewm0paOOPLbN1rc6A==--I359v/KWdZ1ok0ey--JFFhuPOY7WUo6tB/eP05Aw==" def test_deserializes_unloaded_classes_on_get_id with_test_route_set do with_autoload_path "session_autoload_test" do - cookies[SessionKey] = SignedSerializedCookie + cookies[SessionKey] = EncryptedSerializedCookie get "/get_session_id" assert_response :success assert_equal "id: ce8b0752a6ab7c7af3cdb8a80e6b9e46", response.body, "should auto-load unloaded class" @@ -149,7 +190,7 @@ def test_deserializes_unloaded_classes_on_get_id def test_deserializes_unloaded_classes_on_get_value with_test_route_set do with_autoload_path "session_autoload_test" do - cookies[SessionKey] = SignedSerializedCookie + cookies[SessionKey] = EncryptedSerializedCookie get "/get_session_value" assert_response :success assert_equal 'foo: #', response.body, "should auto-load unloaded class" @@ -159,9 +200,10 @@ def test_deserializes_unloaded_classes_on_get_value def test_close_raises_when_data_overflows with_test_route_set do - assert_raise(ActionDispatch::Cookies::CookieOverflow) { + error = assert_raise(ActionDispatch::Cookies::CookieOverflow) { get "/raise_data_overflow" } + assert_equal "_myapp_session cookie overflowed with size 5626 bytes", error.message end end @@ -188,12 +230,12 @@ def test_setting_session_value_after_session_reset get "/set_session_value" assert_response :success session_payload = response.body - assert_equal "_myapp_session=#{response.body}; path=/; HttpOnly", - headers["Set-Cookie"] + assert_session_cookie "path=/; HttpOnly", "foo" => "bar" get "/call_reset_session" assert_response :success assert_not_equal [], headers["Set-Cookie"] + assert_not_nil headers["Set-Cookie"] assert_not_nil session_payload assert_not_equal session_payload, cookies[SessionKey] @@ -207,8 +249,7 @@ def test_class_type_after_session_reset with_test_route_set do get "/set_session_value" assert_response :success - assert_equal "_myapp_session=#{response.body}; path=/; HttpOnly", - headers["Set-Cookie"] + assert_session_cookie "path=/; HttpOnly", "foo" => "bar" get "/get_class_after_reset_session" assert_response :success @@ -230,8 +271,7 @@ def test_setting_session_value_after_session_clear with_test_route_set do get "/set_session_value" assert_response :success - assert_equal "_myapp_session=#{response.body}; path=/; HttpOnly", - headers["Set-Cookie"] + assert_session_cookie "path=/; HttpOnly", "foo" => "bar" get "/call_session_clear" assert_response :success @@ -244,7 +284,7 @@ def test_setting_session_value_after_session_clear def test_persistent_session_id with_test_route_set do - cookies[SessionKey] = SignedBar + get "/set_session_value" get "/persistent_session_id" assert_response :success assert_equal 32, response.body.size @@ -259,8 +299,7 @@ def test_persistent_session_id def test_setting_session_id_to_nil_is_respected with_test_route_set do - cookies[SessionKey] = SignedBar - + get "/set_session_value" get "/get_session_id" sid = response.body assert_equal 36, sid.size @@ -271,40 +310,68 @@ def test_setting_session_id_to_nil_is_respected end def test_session_store_with_expire_after - with_test_route_set(expire_after: 5.hours) do + cookie_options(expire_after: 5.hours) + + with_test_route_set do # First request accesses the session time = Time.local(2008, 4, 24) - cookie_body = nil Time.stub :now, time do - expected_expiry = (time + 5.hours).gmtime.strftime("%a, %d %b %Y %H:%M:%S -0000") - - cookies[SessionKey] = SignedBar + expected_expiry = (time + 5.hours).gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT") get "/set_session_value" - assert_response :success - cookie_body = response.body - assert_equal "_myapp_session=#{cookie_body}; path=/; expires=#{expected_expiry}; HttpOnly", - headers["Set-Cookie"] + assert_response :success + assert_session_cookie "path=/; expires=#{expected_expiry}; HttpOnly", "foo" => "bar" end # Second request does not access the session - time = Time.local(2008, 4, 25) + time = time + 3.hours Time.stub :now, time do - expected_expiry = (time + 5.hours).gmtime.strftime("%a, %d %b %Y %H:%M:%S -0000") + expected_expiry = (time + 5.hours).gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT") get "/no_session_access" + assert_response :success + assert_session_cookie "path=/; expires=#{expected_expiry}; HttpOnly", "foo" => "bar" + end + end + end + + def test_session_store_with_expire_after_does_not_accept_expired_session + cookie_options(expire_after: 5.hours) + + with_test_route_set do + # First request accesses the session + time = Time.local(2017, 11, 12) + + Time.stub :now, time do + expected_expiry = (time + 5.hours).gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT") - assert_equal "_myapp_session=#{cookie_body}; path=/; expires=#{expected_expiry}; HttpOnly", - headers["Set-Cookie"] + get "/set_session_value" + get "/get_session_value" + + assert_response :success + assert_equal 'foo: "bar"', response.body + assert_session_cookie "path=/; expires=#{expected_expiry}; HttpOnly", "foo" => "bar" + end + + # Second request is beyond the expiry time and the session is invalidated + time += 5.hours + 1.minute + + Time.stub :now, time do + get "/get_session_value" + + assert_response :success + assert_equal "foo: nil", response.body end end end def test_session_store_with_explicit_domain - with_test_route_set(domain: "example.es") do + cookie_options(domain: "example.es") + + with_test_route_set do get "/set_session_value" assert_match(/domain=example\.es/, headers["Set-Cookie"]) headers["Set-Cookie"] @@ -314,49 +381,90 @@ def test_session_store_with_explicit_domain def test_session_store_without_domain with_test_route_set do get "/set_session_value" - assert_no_match(/domain\=/, headers["Set-Cookie"]) + assert_no_match(/domain=/, headers["Set-Cookie"]) end end def test_session_store_with_nil_domain - with_test_route_set(domain: nil) do + cookie_options(domain: nil) + + with_test_route_set do get "/set_session_value" - assert_no_match(/domain\=/, headers["Set-Cookie"]) + assert_no_match(/domain=/, headers["Set-Cookie"]) end end def test_session_store_with_all_domains - with_test_route_set(domain: :all) do + cookie_options(domain: :all) + + with_test_route_set do + get "/set_session_value" + assert_match(/domain=example\.com/, headers["Set-Cookie"]) + end + end + + test "default same_site derives SameSite from env" do + with_test_route_set do get "/set_session_value" - assert_match(/domain=\.example\.com/, headers["Set-Cookie"]) + assert_set_cookie_attributes("_myapp_session", "SameSite=Lax") + end + end + + test "explicit same_site sets SameSite" do + cookie_options(same_site: :strict) + + with_test_route_set do + get "/set_session_value" + assert_set_cookie_attributes("_myapp_session", "SameSite=Strict") + end + end + + test "explicit nil same_site omits SameSite" do + cookie_options(same_site: nil) + + with_test_route_set do + get "/set_session_value" + assert_not_set_cookie_attributes("_myapp_session", "SameSite") end end private + # Overwrite `get` to set env hash + def get(path, **options) + options[:headers] ||= {} + options[:headers].tap do |config| + config["action_dispatch.secret_key_base"] = SessionSecret + config["action_dispatch.authenticated_encrypted_cookie_salt"] = SessionSalt + config["action_dispatch.use_authenticated_cookie_encryption"] = true + + config["action_dispatch.key_generator"] ||= Generator + config["action_dispatch.cookies_rotations"] ||= Rotations + + config["action_dispatch.cookies_same_site_protection"] ||= SameSite + end + + super + end + + def cookie_options(options = {}) + (@cookie_options ||= { key: SessionKey }).merge!(options) + end - # Overwrite get to send SessionSecret in env hash - def get(path, *args) - args[0] ||= {} - args[0][:headers] ||= {} - args[0][:headers]["action_dispatch.key_generator"] ||= Generator - super(path, *args) + def app + @app ||= self.class.build_app do |middleware| + middleware.use ActionDispatch::Session::CookieStore, cookie_options + middleware.delete ActionDispatch::ShowExceptions + end end - def with_test_route_set(options = {}) + def with_test_route_set with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action", to: ::CookieStoreTest::TestController end end - options = { key: SessionKey }.merge!(options) - - @app = self.class.build_app(set) do |middleware| - middleware.use ActionDispatch::Session::CookieStore, options - middleware.delete ActionDispatch::ShowExceptions - end - yield end end diff --git a/actionpack/test/dispatch/session/mem_cache_store_test.rb b/actionpack/test/dispatch/session/mem_cache_store_test.rb index 121e9ebef7bd0..0405ff3e5fb52 100644 --- a/actionpack/test/dispatch/session/mem_cache_store_test.rb +++ b/actionpack/test/dispatch/session/mem_cache_store_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "securerandom" @@ -36,8 +38,9 @@ def call_reset_session begin require "dalli" - ss = Dalli::Client.new("localhost:11211").stats - raise Dalli::DalliError unless ss["localhost:11211"] + servers = ENV["MEMCACHE_SERVERS"] || "localhost:11211" + ss = Dalli::Client.new(servers).stats + raise Dalli::DalliError unless ss[servers] || ss[servers + ":11211"] def test_setting_and_getting_session_value with_test_route_set do @@ -68,7 +71,7 @@ def test_getting_session_value_after_session_reset get "/set_session_value" assert_response :success assert cookies["_session_id"] - session_cookie = cookies.send(:hash_for)["_session_id"] + session_cookie = cookies.get_cookie("_session_id") get "/call_reset_session" assert_response :success @@ -184,19 +187,24 @@ def test_prevents_session_fixation end private + def app + @app ||= self.class.build_app do |middleware| + middleware.use ActionDispatch::Session::MemCacheStore, + key: "_session_id", namespace: "mem_cache_store_test:#{SecureRandom.hex(10)}", + memcache_server: ENV["MEMCACHE_SERVERS"] || "localhost:11211", + socket_timeout: 60 + middleware.delete ActionDispatch::ShowExceptions + end + end + def with_test_route_set with_routing do |set| set.draw do - ActiveSupport::Deprecation.silence do + ActionDispatch.deprecator.silence do get ":action", to: ::MemCacheStoreTest::TestController end end - @app = self.class.build_app(set) do |middleware| - middleware.use ActionDispatch::Session::MemCacheStore, key: "_session_id", namespace: "mem_cache_store_test:#{SecureRandom.hex(10)}" - middleware.delete ActionDispatch::ShowExceptions - end - yield end end diff --git a/actionpack/test/dispatch/session/test_session_test.rb b/actionpack/test/dispatch/session/test_session_test.rb index 0bf3a8b3ee9fa..d05a34a620274 100644 --- a/actionpack/test/dispatch/session/test_session_test.rb +++ b/actionpack/test/dispatch/session/test_session_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "stringio" @@ -41,6 +43,12 @@ def test_keys_and_values assert_equal %w(1 2), session.values end + def test_dig + session = ActionController::TestSession.new(one: { two: { three: "3" } }) + assert_equal("3", session.dig(:one, :two, :three)) + assert_nil(session.dig(:ruby, :on, :rails)) + end + def test_fetch_returns_default session = ActionController::TestSession.new(one: "1") assert_equal("2", session.fetch(:two, "2")) @@ -60,4 +68,16 @@ def test_fetch_returns_block_value session = ActionController::TestSession.new(one: "1") assert_equal(2, session.fetch("2") { |key| key.to_i }) end + + def test_session_id + session = ActionController::TestSession.new + assert_instance_of String, session.id.public_id + assert_equal(session.id.public_id, session["session_id"]) + end + + def test_merge! + session = ActionController::TestSession.new + session.merge!({ key: "value" }) + assert_equal("value", session["key"]) + end end diff --git a/actionpack/test/dispatch/show_exceptions_test.rb b/actionpack/test/dispatch/show_exceptions_test.rb index 3513534d72f8f..05a33bfddadea 100644 --- a/actionpack/test/dispatch/show_exceptions_test.rb +++ b/actionpack/test/dispatch/show_exceptions_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class ShowExceptionsTest < ActionDispatch::IntegrationTest @@ -7,6 +9,8 @@ def call(env) case req.path when "/not_found" raise AbstractController::ActionNotFound + when "/invalid_mimetype" + raise ActionDispatch::Http::MimeNegotiation::InvalidType when "/bad_params", "/bad_params.json" begin raise StandardError.new @@ -23,56 +27,93 @@ def call(env) rescue raise ActionView::Template::Error.new("template") end + when "/rate_limited" + raise ActionController::TooManyRequests.new else raise "puke!" end end end - ProductionApp = ActionDispatch::ShowExceptions.new(Boomer.new, ActionDispatch::PublicExceptions.new("#{FIXTURE_LOAD_PATH}/public")) + def setup + @app = build_app + end test "skip exceptions app if not showing exceptions" do - @app = ProductionApp assert_raise RuntimeError do - get "/", headers: { "action_dispatch.show_exceptions" => false } + 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 - @app = ProductionApp - - get "/", headers: { "action_dispatch.show_exceptions" => true } + get "/", env: { "action_dispatch.show_exceptions" => :all } assert_response 500 assert_equal "500 error fixture\n", body - get "/bad_params", headers: { "action_dispatch.show_exceptions" => true } + get "/bad_params", env: { "action_dispatch.show_exceptions" => :all } assert_response 400 assert_equal "400 error fixture\n", body - get "/not_found", headers: { "action_dispatch.show_exceptions" => true } + get "/not_found", env: { "action_dispatch.show_exceptions" => :all } assert_response 404 assert_equal "404 error fixture\n", body - get "/method_not_allowed", headers: { "action_dispatch.show_exceptions" => true } + get "/method_not_allowed", env: { "action_dispatch.show_exceptions" => :all } + assert_response 405 + assert_equal "", body + + get "/unknown_http_method", env: { "action_dispatch.show_exceptions" => :all } + assert_response 405 + assert_equal "", body + + 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 - get "/unknown_http_method", headers: { "action_dispatch.show_exceptions" => true } + 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 old_locale, I18n.locale = I18n.locale, :da begin - @app = ProductionApp - - get "/", headers: { "action_dispatch.show_exceptions" => true } + get "/", env: { "action_dispatch.show_exceptions" => :all } assert_response 500 assert_equal "500 localized error fixture\n", body - get "/not_found", headers: { "action_dispatch.show_exceptions" => true } + get "/not_found", env: { "action_dispatch.show_exceptions" => :all } assert_response 404 assert_equal "404 error fixture\n", body ensure @@ -81,16 +122,12 @@ def call(env) end test "sets the HTTP charset parameter" do - @app = ProductionApp - - get "/", headers: { "action_dispatch.show_exceptions" => true } - assert_equal "text/html; charset=utf-8", response.headers["Content-Type"] + get "/", env: { "action_dispatch.show_exceptions" => :all } + assert_equal "text/html; charset=utf-8", response.headers["content-type"] end test "show registered original exception for wrapped exceptions" do - @app = ProductionApp - - get "/not_found_original_exception", headers: { "action_dispatch.show_exceptions" => true } + get "/not_found_original_exception", env: { "action_dispatch.show_exceptions" => :all } assert_response 404 assert_match(/404 error/, body) end @@ -100,37 +137,55 @@ def call(env) assert_kind_of AbstractController::ActionNotFound, env["action_dispatch.exception"] assert_equal "/404", env["PATH_INFO"] assert_equal "/not_found_original_exception", env["action_dispatch.original_path"] - [404, { "Content-Type" => "text/plain" }, ["YOU FAILED"]] + [404, { "content-type" => "text/plain" }, ["YOU FAILED"]] end - @app = ActionDispatch::ShowExceptions.new(Boomer.new, exceptions_app) - get "/not_found_original_exception", headers: { "action_dispatch.show_exceptions" => true } + @app = build_app(exceptions_app) + + get "/not_found_original_exception", env: { "action_dispatch.show_exceptions" => :all } assert_response 404 assert_equal "YOU FAILED", body end - test "returns an empty response if custom exceptions app returns X-Cascade pass" do + test "returns an empty response if custom exceptions app returns x-cascade pass" do exceptions_app = lambda do |env| - [404, { "X-Cascade" => "pass" }, []] + [404, { ActionDispatch::Constants::X_CASCADE => "pass" }, []] end - @app = ActionDispatch::ShowExceptions.new(Boomer.new, exceptions_app) - get "/method_not_allowed", headers: { "action_dispatch.show_exceptions" => true } + @app = build_app(exceptions_app) + + get "/method_not_allowed", env: { "action_dispatch.show_exceptions" => :all } assert_response 405 assert_equal "", body end test "bad params exception is returned in the correct format" do - @app = ProductionApp - - get "/bad_params", headers: { "action_dispatch.show_exceptions" => true } - assert_equal "text/html; charset=utf-8", response.headers["Content-Type"] + get "/bad_params", env: { "action_dispatch.show_exceptions" => :all } + assert_equal "text/html; charset=utf-8", response.headers["content-type"] assert_response 400 assert_match(/400 error/, body) - get "/bad_params.json", headers: { "action_dispatch.show_exceptions" => true } - assert_equal "application/json; charset=utf-8", response.headers["Content-Type"] + get "/bad_params.json", env: { "action_dispatch.show_exceptions" => :all } + assert_equal "application/json; charset=utf-8", response.headers["content-type"] assert_response 400 assert_equal("{\"status\":400,\"error\":\"Bad Request\"}", body) end + + test "failsafe prevents raising if exceptions_app raises" do + old_stderr, $stderr = $stderr, StringIO.new + @app = build_app(->(_) { raise }) + + get "/" + + assert_response 500 + assert_match(/500 Internal Server Error/, body) + ensure + $stderr = old_stderr + end + + private + def build_app(exceptions_app = nil) + exceptions_app ||= ActionDispatch::PublicExceptions.new("#{FIXTURE_LOAD_PATH}/public") + Rack::Lint.new(ActionDispatch::ShowExceptions.new(Rack::Lint.new(Boomer.new), exceptions_app)) + end end diff --git a/actionpack/test/dispatch/ssl_test.rb b/actionpack/test/dispatch/ssl_test.rb index 757e26973f425..f00416a0cdf92 100644 --- a/actionpack/test/dispatch/ssl_test.rb +++ b/actionpack/test/dispatch/ssl_test.rb @@ -1,13 +1,18 @@ +# frozen_string_literal: true + require "abstract_unit" class SSLTest < ActionDispatch::IntegrationTest - HEADERS = Rack::Utils::HeaderHash.new "Content-Type" => "text/html" - attr_accessor :app def build_app(headers: {}, ssl_options: {}) - headers = HEADERS.merge(headers) - ActionDispatch::SSL.new lambda { |env| [200, headers, []] }, ssl_options.reverse_merge(hsts: { subdomains: true }) + app = lambda { |env| [200, headers, []] } + Rack::Lint.new( + ActionDispatch::SSL.new( + Rack::Lint.new(app), + hsts: { subdomains: true }, **ssl_options, + ) + ) end end @@ -19,7 +24,7 @@ def assert_not_redirected(url, headers: {}, redirect: {}) end def assert_redirected(redirect: {}, from: "http://a/b?c=d", to: from.sub("http", "https")) - redirect = { status: 301, body: [] }.merge(redirect) + redirect = { body: [] }.merge(redirect) self.app = build_app ssl_options: { redirect: redirect } @@ -29,9 +34,7 @@ def assert_redirected(redirect: {}, from: "http://a/b?c=d", to: from.sub("http", assert_equal redirect[:body].join, @response.body end - def assert_post_redirected(redirect: {}, from: "http://a/b?c=d", - to: from.sub("http", "https")) - + def assert_post_redirected(redirect: {}, from: "http://a/b?c=d", to: from.sub("http", "https")) self.app = build_app ssl_options: { redirect: redirect } post from @@ -40,7 +43,7 @@ def assert_post_redirected(redirect: {}, from: "http://a/b?c=d", end test "exclude can avoid redirect" do - excluding = { exclude: -> request { request.path =~ /healthcheck/ } } + excluding = { exclude: -> request { request.path.match?(/healthcheck/) } } assert_not_redirected "http://example.org/healthcheck", redirect: excluding assert_redirected from: "http://example.org/", redirect: excluding @@ -62,8 +65,31 @@ def assert_post_redirected(redirect: {}, from: "http://a/b?c=d", assert_post_redirected end - test "redirect with non-301 status" do - assert_redirected redirect: { status: 307 } + test "redirect with custom status" do + assert_redirected redirect: { status: 308 } + end + + test "redirect with unknown request method" do + self.app = build_app + + process :not_an_http_method, "http://a/b?c=d" + + assert_response 307 + assert_redirected_to "https://a/b?c=d" + end + + test "redirect with ssl_default_redirect_status" do + self.app = build_app(ssl_options: { ssl_default_redirect_status: 308 }) + + get "http://a/b?c=d" + + assert_response 301 + assert_redirected_to "https://a/b?c=d" + + post "http://a/b?c=d" + + assert_response 308 + assert_redirected_to "https://a/b?c=d" end test "redirect with custom body" do @@ -96,16 +122,16 @@ def assert_post_redirected(redirect: {}, from: "http://a/b?c=d", end class StrictTransportSecurityTest < SSLTest - EXPECTED = "max-age=15552000" - EXPECTED_WITH_SUBDOMAINS = "max-age=15552000; includeSubDomains" + EXPECTED = "max-age=63072000" + EXPECTED_WITH_SUBDOMAINS = "max-age=63072000; includeSubDomains" def assert_hsts(expected, url: "https://example.org", hsts: { subdomains: true }, headers: {}) self.app = build_app ssl_options: { hsts: hsts }, headers: headers get url if expected.nil? - assert_nil response.headers["Strict-Transport-Security"] + assert_nil response.headers["strict-transport-security"] else - assert_equal expected, response.headers["Strict-Transport-Security"] + assert_equal expected, response.headers["strict-transport-security"] end end @@ -118,7 +144,8 @@ def assert_hsts(expected, url: "https://example.org", hsts: { subdomains: true } end test "defers to app-provided header" do - assert_hsts "app-provided", headers: { "Strict-Transport-Security" => "app-provided" } + headers = { ActionDispatch::Constants::STRICT_TRANSPORT_SECURITY => "app-provided" } + assert_hsts "app-provided", headers: headers end test "hsts: true enables default settings" do @@ -155,64 +182,94 @@ def assert_hsts(expected, url: "https://example.org", hsts: { subdomains: true } end class SecureCookiesTest < SSLTest - DEFAULT = %(id=1; path=/\ntoken=abc; path=/; secure; HttpOnly) - - def get(**options) - self.app = build_app(**options) - super "https://example.org" - end - - def assert_cookies(*expected) - assert_equal expected, response.headers["Set-Cookie"].split("\n") + DEFAULT = if Gem::Version.new(Rack::RELEASE) < Gem::Version.new("3") + %(id=1; path=/\ntoken=abc; path=/; secure; HttpOnly) + else + ["id=1; path=/", "token=abc; path=/; secure; HttpOnly"] end def test_flag_cookies_as_secure - get headers: { "Set-Cookie" => DEFAULT } + get headers: { Rack::SET_COOKIE => DEFAULT } assert_cookies "id=1; path=/; secure", "token=abc; path=/; secure; HttpOnly" end def test_flag_cookies_as_secure_at_end_of_line - get headers: { "Set-Cookie" => "problem=def; path=/; HttpOnly; secure" } + get headers: { Rack::SET_COOKIE => "problem=def; path=/; HttpOnly; secure" } assert_cookies "problem=def; path=/; HttpOnly; secure" end def test_flag_cookies_as_secure_with_more_spaces_before - get headers: { "Set-Cookie" => "problem=def; path=/; HttpOnly; secure" } + get headers: { Rack::SET_COOKIE => "problem=def; path=/; HttpOnly; secure" } assert_cookies "problem=def; path=/; HttpOnly; secure" end def test_flag_cookies_as_secure_with_more_spaces_after - get headers: { "Set-Cookie" => "problem=def; path=/; secure; HttpOnly" } + get headers: { Rack::SET_COOKIE => "problem=def; path=/; secure; HttpOnly" } assert_cookies "problem=def; path=/; secure; HttpOnly" end def test_flag_cookies_as_secure_with_has_not_spaces_before - get headers: { "Set-Cookie" => "problem=def; path=/;secure; HttpOnly" } + get headers: { Rack::SET_COOKIE => "problem=def; path=/;secure; HttpOnly" } assert_cookies "problem=def; path=/;secure; HttpOnly" end def test_flag_cookies_as_secure_with_has_not_spaces_after - get headers: { "Set-Cookie" => "problem=def; path=/; secure;HttpOnly" } + get headers: { Rack::SET_COOKIE => "problem=def; path=/; secure;HttpOnly" } assert_cookies "problem=def; path=/; secure;HttpOnly" end def test_flag_cookies_as_secure_with_ignore_case - get headers: { "Set-Cookie" => "problem=def; path=/; Secure; HttpOnly" } + get headers: { Rack::SET_COOKIE => "problem=def; path=/; Secure; HttpOnly" } assert_cookies "problem=def; path=/; Secure; HttpOnly" end def test_cookies_as_not_secure_with_secure_cookies_disabled - get headers: { "Set-Cookie" => DEFAULT }, ssl_options: { secure_cookies: false } - assert_cookies(*DEFAULT.split("\n")) + get headers: { Rack::SET_COOKIE => DEFAULT }, ssl_options: { secure_cookies: false } + assert_cookies("id=1; path=/", "token=abc; path=/; secure; HttpOnly") + end + + def test_cookies_as_not_secure_with_exclude + excluding = { exclude: -> request { /example/.match?(request.domain) } } + get headers: { Rack::SET_COOKIE => DEFAULT }, ssl_options: { redirect: excluding } + + assert_cookies("id=1; path=/", "token=abc; path=/; secure; HttpOnly") + assert_response :ok end def test_no_cookies get - assert_nil response.headers["Set-Cookie"] + assert_nil response.headers[Rack::SET_COOKIE] end def test_keeps_original_headers_behavior - get headers: { "Connection" => %w[close] } - assert_equal "close", response.headers["Connection"] + get headers: { "connection" => "close" } + assert_equal "close", response.headers["connection"] + end + + # Array-based headers are only supported in Rack 3+ + if Gem::Version.new(Rack::RELEASE) >= Gem::Version.new("3") + def test_flag_cookies_as_secure_with_single_cookie_in_array + get headers: { Rack::SET_COOKIE => ["id=1"] } + assert_cookies "id=1; secure" + end + + def test_flag_cookies_as_secure_with_multiple_cookies_in_array + get headers: { Rack::SET_COOKIE => ["id=1", "problem=def"] } + assert_cookies "id=1; secure", "problem=def; secure" + end end + + private + def get(**options) + self.app = build_app(**options) + super "https://example.org" + end + + def assert_cookies(*expected) + cookies = response.headers[Rack::SET_COOKIE] + if Gem::Version.new(Rack::RELEASE) < Gem::Version.new("3") + cookies = cookies.split("\n") + end + assert_equal expected, cookies + end end diff --git a/actionpack/test/dispatch/static_test.rb b/actionpack/test/dispatch/static_test.rb index bd8318f5f6041..811a629fd137a 100644 --- a/actionpack/test/dispatch/static_test.rb +++ b/actionpack/test/dispatch/static_test.rb @@ -1,16 +1,24 @@ +# frozen_string_literal: true + require "abstract_unit" require "zlib" -module StaticTests +class StaticTest < ActiveSupport::TestCase DummyApp = lambda { |env| - [200, { "Content-Type" => "text/plain" }, ["Hello, World!"]] + [200, { Rack::CONTENT_TYPE => "text/plain" }, ["Hello, World!"]] } + def public_path + "public" + end + def setup silence_warnings do @default_internal_encoding = Encoding.default_internal @default_external_encoding = Encoding.default_external end + @root = "#{FIXTURE_LOAD_PATH}/#{public_path}" + @app = build_app(DummyApp, @root, headers: { "cache-control" => "public, max-age=60" }) end def teardown @@ -29,7 +37,7 @@ def test_handles_urls_with_bad_encoding end def test_handles_urls_with_ascii_8bit - assert_equal "Hello, World!", get("/doorkeeper%E3E4".force_encoding("ASCII-8BIT")).body + assert_equal "Hello, World!", get((+"/doorkeeper%E3E4").force_encoding("ASCII-8BIT")).body end def test_handles_urls_with_ascii_8bit_on_win_31j @@ -37,7 +45,7 @@ def test_handles_urls_with_ascii_8bit_on_win_31j Encoding.default_internal = "Windows-31J" Encoding.default_external = "Windows-31J" end - assert_equal "Hello, World!", get("/doorkeeper%E3E4".force_encoding("ASCII-8BIT")).body + assert_equal "Hello, World!", get((+"/doorkeeper%E3E4").force_encoding("ASCII-8BIT")).body end def test_handles_urls_with_null_byte @@ -69,7 +77,16 @@ def test_serves_file_with_same_name_before_index_in_directory end def test_served_static_file_with_non_english_filename - assert_html "means hello in Japanese\n", get("/foo/#{Rack::Utils.escape("ã“ã‚“ã«ã¡ã¯.html")}") + assert_html "means hello in Japanese\n", get("/foo/%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF.html") + end + + def test_served_gzipped_static_file_with_non_english_filename + response = get("/foo/%E3%81%95%E3%82%88%E3%81%86%E3%81%AA%E3%82%89.html", "HTTP_ACCEPT_ENCODING" => "gzip") + + assert_gzip "/foo/ã•よã†ãªã‚‰.html", response + assert_equal "text/html", response.headers["content-type"] + assert_equal "accept-encoding", response.headers["vary"] + assert_equal "gzip", response.headers["content-encoding"] end def test_serves_static_file_with_exclamation_mark_in_filename @@ -135,13 +152,15 @@ def test_serves_static_file_with_at_symbol_in_filename end end + JAVASCRIPT_MIME_TYPE = Rack::Mime::MIME_TYPES[".js"] + def test_serves_gzip_files_when_header_set file_name = "/gzip/application-a71b3024f80aea3181c09774ca17e712.js" response = get(file_name, "HTTP_ACCEPT_ENCODING" => "gzip") assert_gzip file_name, response - assert_equal "application/javascript", response.headers["Content-Type"] - assert_equal "Accept-Encoding", response.headers["Vary"] - assert_equal "gzip", response.headers["Content-Encoding"] + assert_equal JAVASCRIPT_MIME_TYPE, response.headers["content-type"] + assert_equal "accept-encoding", response.headers["vary"] + assert_equal "gzip", response.headers["content-encoding"] response = get(file_name, "HTTP_ACCEPT_ENCODING" => "Gzip") assert_gzip file_name, response @@ -153,23 +172,69 @@ def test_serves_gzip_files_when_header_set assert_gzip file_name, response response = get(file_name, "HTTP_ACCEPT_ENCODING" => "") - assert_not_equal "gzip", response.headers["Content-Encoding"] + assert_not_equal "gzip", response.headers["content-encoding"] + end + + def test_serves_gzip_files_when_svg + file_name = "/gzip/logo-bcb6d75d927347158af5.svg" + response = get(file_name, "HTTP_ACCEPT_ENCODING" => "gzip") + assert_gzip file_name, response + assert_equal "image/svg+xml", response.headers["Content-Type"] + assert_equal "accept-encoding", response.headers["Vary"] + assert_equal "gzip", response.headers["Content-Encoding"] + end + + def test_set_vary_when_origin_compressed_but_client_cant_accept + file_name = "/gzip/application-a71b3024f80aea3181c09774ca17e712.js" + response = get(file_name, "HTTP_ACCEPT_ENCODING" => "None") + assert_equal "accept-encoding", response.headers["vary"] + end + + def test_serves_brotli_files_when_header_set + file_name = "/gzip/application-a71b3024f80aea3181c09774ca17e712.js" + response = get(file_name, "HTTP_ACCEPT_ENCODING" => "br") + assert_equal JAVASCRIPT_MIME_TYPE, response.headers["content-type"] + assert_equal "accept-encoding", response.headers["vary"] + assert_equal "br", response.headers["content-encoding"] + + response = get(file_name, "HTTP_ACCEPT_ENCODING" => "gzip") + assert_not_equal "br", response.headers["content-encoding"] + end + + def test_serves_brotli_files_before_gzip_files + file_name = "/gzip/application-a71b3024f80aea3181c09774ca17e712.js" + response = get(file_name, "HTTP_ACCEPT_ENCODING" => "gzip, deflate, sdch, br") + assert_equal JAVASCRIPT_MIME_TYPE, response.headers["content-type"] + assert_equal "accept-encoding", response.headers["vary"] + assert_equal "br", response.headers["content-encoding"] end def test_does_not_modify_path_info file_name = "/gzip/application-a71b3024f80aea3181c09774ca17e712.js" - env = { "PATH_INFO" => file_name, "HTTP_ACCEPT_ENCODING" => "gzip", "REQUEST_METHOD" => "POST" } + env = Rack::MockRequest.env_for(file_name, { "PATH_INFO" => file_name, "HTTP_ACCEPT_ENCODING" => "gzip", "REQUEST_METHOD" => "POST" }) @app.call(env) assert_equal file_name, env["PATH_INFO"] end + def test_only_set_one_content_type + file_name = "/gzip/foo.zoo" + gzip_env = Rack::MockRequest.env_for(file_name, { "PATH_INFO" => file_name, "HTTP_ACCEPT_ENCODING" => "gzip", "REQUEST_METHOD" => "GET" }) + response = @app.call(gzip_env) + + env = Rack::MockRequest.env_for(file_name, { "PATH_INFO" => file_name, "REQUEST_METHOD" => "GET" }) + default_response = @app.call(env) + + assert_equal 1, response[1].slice("Content-Type", "content-type").size + assert_equal 1, default_response[1].slice("Content-Type", "content-type").size + end + def test_serves_gzip_with_proper_content_type_fallback file_name = "/gzip/foo.zoo" response = get(file_name, "HTTP_ACCEPT_ENCODING" => "gzip") assert_gzip file_name, response default_response = get(file_name) # no gzip - assert_equal default_response.headers["Content-Type"], response.headers["Content-Type"] + assert_equal default_response.headers["content-type"], response.headers["content-type"] end def test_serves_gzip_files_with_not_modified @@ -177,30 +242,60 @@ def test_serves_gzip_files_with_not_modified last_modified = File.mtime(File.join(@root, "#{file_name}.gz")) response = get(file_name, "HTTP_ACCEPT_ENCODING" => "gzip", "HTTP_IF_MODIFIED_SINCE" => last_modified.httpdate) assert_equal 304, response.status - assert_nil response.headers["Content-Type"] - assert_nil response.headers["Content-Encoding"] - assert_nil response.headers["Vary"] + assert_nil response.headers["content-type"] + assert_nil response.headers["content-encoding"] + assert_nil response.headers["vary"] end def test_serves_files_with_headers headers = { - "Access-Control-Allow-Origin" => "http://rubyonrails.org", - "Cache-Control" => "public, max-age=60", - "X-Custom-Header" => "I'm a teapot" + "access-control-allow-origin" => "http://rubyonrails.org", + "cache-control" => "public, max-age=60", + "x-custom-header" => "I'm a teapot" } - app = ActionDispatch::Static.new(DummyApp, @root, headers: headers) - response = Rack::MockRequest.new(app).request("GET", "/foo/bar.html") + @app = build_app(DummyApp, @root, headers: headers) - assert_equal "http://rubyonrails.org", response.headers["Access-Control-Allow-Origin"] - assert_equal "public, max-age=60", response.headers["Cache-Control"] - assert_equal "I'm a teapot", response.headers["X-Custom-Header"] + response = get("/foo/bar.html") + + assert_equal "http://rubyonrails.org", response.headers["access-control-allow-origin"] + assert_equal "public, max-age=60", response.headers["cache-control"] + assert_equal "I'm a teapot", response.headers["x-custom-header"] end def test_ignores_unknown_http_methods - app = ActionDispatch::Static.new(DummyApp, @root) + response = Rack::MockRequest.new(@app).request("BAD_METHOD", "/foo/bar.html") + assert_equal 200, response.status + end + + def test_custom_handler_called_when_file_is_outside_root + filename = "shared.html.erb" + assert File.exist?(File.join(@root, "..", filename)) + env = Rack::MockRequest.env_for("", { + "REQUEST_METHOD" => "GET", + "REQUEST_PATH" => "/..%2F#{filename}", + "PATH_INFO" => "/..%2F#{filename}", + "REQUEST_URI" => "/..%2F#{filename}", + }) + + dummy_response = DummyApp.call(nil) + app_response = @app.call(env) + + assert_equal dummy_response[0], app_response[0] + assert_equal dummy_response[1], app_response[1] + assert_equal dummy_response[2].to_a, app_response[2].enum_for.to_a + end - assert_nothing_raised { Rack::MockRequest.new(app).request("BAD_METHOD", "/foo/bar.html") } + def test_non_default_static_index + @app = build_app(DummyApp, @root, index: "other-index") + assert_html "/other-index.html", get("/other-index.html") + assert_html "/other-index.html", get("/other-index") + assert_html "/other-index.html", get("/") + assert_html "/other-index.html", get("") + assert_html "/foo/other-index.html", get("/foo/other-index.html") + assert_html "/foo/other-index.html", get("/foo/other-index") + assert_html "/foo/other-index.html", get("/foo/") + assert_html "/foo/other-index.html", get("/foo") end # Windows doesn't allow \ / : * ? " < > | in filenames @@ -221,17 +316,24 @@ def test_serves_static_file_with_asterisk end private + def build_app(app, path, index: "index", headers: {}) + Rack::Lint.new( + ActionDispatch::Static.new( + Rack::Lint.new(app), path, index: index, headers: headers, + ), + ) + end def assert_gzip(file_name, response) expected = File.read("#{FIXTURE_LOAD_PATH}/#{public_path}" + file_name) - actual = Zlib::GzipReader.new(StringIO.new(response.body)).read + actual = ActiveSupport::Gzip.decompress(response.body) assert_equal expected, actual end def assert_html(body, response) assert_equal body, response.body - assert_equal "text/html", response.headers["Content-Type"] - assert_nil response.headers["Vary"] + assert_equal "text/html", response.headers["content-type"] + assert_nil response.headers["vary"] end def get(path, headers = {}) @@ -252,55 +354,7 @@ def with_static_file(file) end end -class StaticTest < ActiveSupport::TestCase - def setup - super - @root = "#{FIXTURE_LOAD_PATH}/public" - @app = ActionDispatch::Static.new(DummyApp, @root, headers: { "Cache-Control" => "public, max-age=60" }) - end - - def public_path - "public" - end - - include StaticTests - - def test_custom_handler_called_when_file_is_outside_root - filename = "shared.html.erb" - assert File.exist?(File.join(@root, "..", filename)) - env = { - "REQUEST_METHOD" => "GET", - "REQUEST_PATH" => "/..%2F#{filename}", - "PATH_INFO" => "/..%2F#{filename}", - "REQUEST_URI" => "/..%2F#{filename}", - "HTTP_VERSION" => "HTTP/1.1", - "SERVER_NAME" => "localhost", - "SERVER_PORT" => "8080", - "QUERY_STRING" => "" - } - assert_equal(DummyApp.call(nil), @app.call(env)) - end - - def test_non_default_static_index - @app = ActionDispatch::Static.new(DummyApp, @root, index: "other-index") - assert_html "/other-index.html", get("/other-index.html") - assert_html "/other-index.html", get("/other-index") - assert_html "/other-index.html", get("/") - assert_html "/other-index.html", get("") - assert_html "/foo/other-index.html", get("/foo/other-index.html") - assert_html "/foo/other-index.html", get("/foo/other-index") - assert_html "/foo/other-index.html", get("/foo/") - assert_html "/foo/other-index.html", get("/foo") - end -end - class StaticEncodingTest < StaticTest - def setup - super - @root = "#{FIXTURE_LOAD_PATH}/公共" - @app = ActionDispatch::Static.new(DummyApp, @root, headers: { "Cache-Control" => "public, max-age=60" }) - end - def public_path "公共" end 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 new file mode 100644 index 0000000000000..848893f2b4c57 --- /dev/null +++ b/actionpack/test/dispatch/system_testing/driver_test.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "support/system_helper" +require "action_dispatch/system_testing/driver" +require "selenium/webdriver" + +class DriverTest < ActiveSupport::TestCase + test "initializing the driver" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium) + assert_equal :selenium, driver.instance_variable_get(:@driver_type) + end + + test "initializing the driver with a browser" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, using: :chrome, screen_size: [1400, 1400], options: { url: "http://example.com/wd/hub" }) + assert_equal :selenium, driver.instance_variable_get(:@driver_type) + assert_equal :chrome, driver.instance_variable_get(:@browser).name + assert_instance_of Selenium::WebDriver::Chrome::Options, driver.instance_variable_get(:@browser).options + assert_equal [1400, 1400], driver.instance_variable_get(:@screen_size) + assert_equal ({ url: "http://example.com/wd/hub" }), driver.instance_variable_get(:@options) + end + + test "initializing the driver with a headless chrome" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, using: :headless_chrome, screen_size: [1400, 1400], options: { url: "http://example.com/wd/hub" }) + assert_equal :selenium, driver.instance_variable_get(:@driver_type) + assert_equal :headless_chrome, driver.instance_variable_get(:@browser).name + assert_instance_of Selenium::WebDriver::Chrome::Options, driver.instance_variable_get(:@browser).options + assert_equal [1400, 1400], driver.instance_variable_get(:@screen_size) + assert_equal ({ url: "http://example.com/wd/hub" }), driver.instance_variable_get(:@options) + end + + test "initializing the driver with a headless chrome and custom path" do + original_driver_path = ::Selenium::WebDriver::Chrome::Service.driver_path + assert_nothing_raised do + ::Selenium::WebDriver::Chrome::Service.driver_path = "bin/test" + ActionDispatch::SystemTesting::Driver.new(:selenium, using: :headless_chrome, screen_size: [1400, 1400]) + end + ensure + ::Selenium::WebDriver::Chrome::Service.driver_path = original_driver_path + end + + test "initializing the driver with a headless firefox" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, using: :headless_firefox, screen_size: [1400, 1400], options: { url: "http://example.com/wd/hub" }) + assert_equal :selenium, driver.instance_variable_get(:@driver_type) + assert_equal :headless_firefox, driver.instance_variable_get(:@browser).name + assert_instance_of Selenium::WebDriver::Firefox::Options, driver.instance_variable_get(:@browser).options + assert_equal [1400, 1400], driver.instance_variable_get(:@screen_size) + assert_equal ({ url: "http://example.com/wd/hub" }), driver.instance_variable_get(:@options) + end + + test "initializing the driver with a headless firefox and custom path" do + original_driver_path = ::Selenium::WebDriver::Firefox::Service.driver_path + assert_nothing_raised do + ::Selenium::WebDriver::Firefox::Service.driver_path = "bin/test" + ActionDispatch::SystemTesting::Driver.new(:selenium, using: :headless_firefox, screen_size: [1400, 1400]) + end + ensure + ::Selenium::WebDriver::Firefox::Service.driver_path = original_driver_path + end + + test "initializing the driver with a cuprite" do + driver = ActionDispatch::SystemTesting::Driver.new(:cuprite, screen_size: [1400, 1400], options: { js_errors: false }) + assert_equal :cuprite, driver.instance_variable_get(:@driver_type) + assert_equal [1400, 1400], driver.instance_variable_get(:@screen_size) + assert_equal ({ js_errors: false }), driver.instance_variable_get(:@options) + end + + test "initializing the driver with Playwright" do + driver = ActionDispatch::SystemTesting::Driver.new(:playwright, screen_size: [1400, 1400], options: { headless: true }) + + assert_equal :playwright, driver.instance_variable_get(:@driver_type) + assert_equal [1400, 1400], driver.instance_variable_get(:@screen_size) + assert_equal ({ headless: true }), driver.instance_variable_get(:@options) + end + + test "define extra capabilities using chrome" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :chrome) do |option| + option.add_argument("start-maximized") + option.add_emulation(device_name: "iphone 6") + option.add_preference(:detach, true) + end + driver.use + + expected = { + "goog:chromeOptions" => { + "args" => ["--disable-search-engine-choice-screen", "start-maximized"], + "mobileEmulation" => { "deviceName" => "iphone 6" }, + "prefs" => { "detach" => true } + }, + "browserName" => "chrome" + } + assert_driver_capabilities driver, expected + end + + test "define extra capabilities using headless_chrome" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :headless_chrome) do |option| + option.add_argument("start-maximized") + option.add_emulation(device_name: "iphone 6") + option.add_preference(:detach, true) + end + driver.use + + expected = { + "goog:chromeOptions" => { + "args" => ["--disable-search-engine-choice-screen", "--headless", "start-maximized"], + "mobileEmulation" => { "deviceName" => "iphone 6" }, + "prefs" => { "detach" => true } + }, + "browserName" => "chrome" + } + assert_driver_capabilities driver, expected + end + + test "define extra capabilities using firefox" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :firefox) do |option| + option.add_preference("browser.startup.homepage", "http://www.seleniumhq.com/") + option.add_argument("--host=127.0.0.1") + end + driver.use + + expected = { + "moz:firefoxOptions" => { + "args" => ["--host=127.0.0.1"], + "prefs" => { "remote.active-protocols" => 1, "browser.startup.homepage" => "http://www.seleniumhq.com/" } + }, + "browserName" => "firefox" + } + assert_driver_capabilities driver, expected + end + + test "define extra capabilities using headless_firefox" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :headless_firefox) do |option| + option.add_preference("browser.startup.homepage", "http://www.seleniumhq.com/") + option.add_argument("--host=127.0.0.1") + end + driver.use + + expected = { + "moz:firefoxOptions" => { + "args" => ["-headless", "--host=127.0.0.1"], + "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) + + assert_nothing_raised do + driver.use + end + end + + test "preloads browser's driver_path with DriverFinder if a path isn't already specified" do + original_driver_path = ::Selenium::WebDriver::Chrome::Service.driver_path + ::Selenium::WebDriver::Chrome::Service.driver_path = nil + + # Our stub must return paths to a real executables, otherwise an internal Selenium assertion will fail. + # Note: SeleniumManager is private api + found_executable = RbConfig.ruby + ::Selenium::WebDriver::SeleniumManager.stub(:binary_paths, { "driver_path" => found_executable, "browser_path" => found_executable }) do + ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :chrome) + end + + assert_equal found_executable, ::Selenium::WebDriver::Chrome::Service.driver_path + ensure + ::Selenium::WebDriver::Chrome::Service.driver_path = original_driver_path + end + + test "does not overwrite existing driver_path during preload" do + original_driver_path = ::Selenium::WebDriver::Chrome::Service.driver_path + # The driver_path must point to a real executable, otherwise an internal Selenium assertion will fail. + ::Selenium::WebDriver::Chrome::Service.driver_path = RbConfig.ruby + + assert_no_changes -> { ::Selenium::WebDriver::Chrome::Service.driver_path } do + ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :chrome) + end + ensure + ::Selenium::WebDriver::Chrome::Service.driver_path = original_driver_path + end + + test "does not configure browser if driver is not :selenium" do + # Check that it does configure browser if the driver is :selenium + assert ActionDispatch::SystemTesting::Driver.new(:selenium).instance_variable_get(:@browser) + + assert_nil ActionDispatch::SystemTesting::Driver.new(:rack_test).instance_variable_get(:@browser) + assert_nil ActionDispatch::SystemTesting::Driver.new(:cuprite).instance_variable_get(:@browser) + end + + test "driver names default to driver type" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium) + assert_equal :selenium, driver.name + end + + test "driver names can by specified explicitly" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, options: { name: :best_driver }) + assert_equal :best_driver, driver.name + end + + private + def assert_driver_capabilities(driver, expected_capabilities) + capabilities = driver.__send__(:browser_options)[:options].as_json + + 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 new file mode 100644 index 0000000000000..bcf7eca1f369d --- /dev/null +++ b/actionpack/test/dispatch/system_testing/screenshot_helper_test.rb @@ -0,0 +1,211 @@ +# 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" + +class ScreenshotHelperTest < ActiveSupport::TestCase + def setup + @new_test = DrivenBySeleniumWithChrome.new("x") + @new_test.send("_screenshot_counter=", nil) + end + + test "image path is saved in tmp directory" do + Rails.stub :root, Pathname.getwd do + assert_equal Rails.root.join("tmp/screenshots/0_x.png").to_s, @new_test.send(:image_path) + end + end + + test "image path unique counter is changed when incremented" do + @new_test.send(:increment_unique) + + Rails.stub :root, Pathname.getwd do + assert_equal Rails.root.join("tmp/screenshots/1_x.png").to_s, @new_test.send(:image_path) + end + end + + # To allow multiple screenshots in same test + test "image path unique counter generates different path in same test" do + Rails.stub :root, Pathname.getwd do + @new_test.send(:increment_unique) + assert_equal Rails.root.join("tmp/screenshots/1_x.png").to_s, @new_test.send(:image_path) + + @new_test.send(:increment_unique) + assert_equal Rails.root.join("tmp/screenshots/2_x.png").to_s, @new_test.send(:image_path) + end + end + + test "image path uses the Capybara.save_path to set a custom directory" do + original_save_path = Capybara.save_path + Capybara.save_path = "custom_dir" + + Rails.stub :root, Pathname.getwd do + assert_equal Rails.root.join("custom_dir/0_x.png").to_s, @new_test.send(:image_path) + end + ensure + Capybara.save_path = original_save_path + end + + test "image path includes failures text if test did not pass" do + Rails.stub :root, Pathname.getwd do + @new_test.stub :passed?, false do + assert_equal Rails.root.join("tmp/screenshots/failures_x.png").to_s, @new_test.send(:image_path) + assert_equal Rails.root.join("tmp/screenshots/failures_x.html").to_s, @new_test.send(:html_path) + end + end + end + + test "image path does not include failures text if test skipped" do + Rails.stub :root, Pathname.getwd do + @new_test.stub :passed?, false do + @new_test.stub :skipped?, true do + assert_equal Rails.root.join("tmp/screenshots/0_x.png").to_s, @new_test.send(:image_path) + assert_equal Rails.root.join("tmp/screenshots/0_x.html").to_s, @new_test.send(:html_path) + end + end + end + end + + test "image name truncates names over 225 characters including counter" do + long_test = DrivenBySeleniumWithChrome.new("x" * 400) + + Rails.stub :root, Pathname.getwd do + assert_equal Rails.root.join("tmp/screenshots/0_#{"x" * 223}.png").to_s, long_test.send(:image_path) + assert_equal Rails.root.join("tmp/screenshots/0_#{"x" * 223}.html").to_s, long_test.send(:html_path) + end + end + + test "defaults to simple output for the screenshot" do + assert_equal "simple", @new_test.send(:output_type) + end + + test "take_screenshot saves image and shows link to it" do + display_image_actual = nil + + Rails.stub :root, Pathname.getwd do + @new_test.stub :save_image, nil do + @new_test.stub :show, -> (img) { display_image_actual = img } do + @new_test.take_screenshot + end + end + end + assert_match %r|\[Screenshot Image\].+?tmp/screenshots/1_x\.png |, display_image_actual + end + + test "take_screenshot saves HTML and shows link to it when using RAILS_SYSTEM_TESTING_SCREENSHOT_HTML env" do + original_html_setting = ENV["RAILS_SYSTEM_TESTING_SCREENSHOT_HTML"] + ENV["RAILS_SYSTEM_TESTING_SCREENSHOT_HTML"] = "1" + + display_image_actual = nil + called_save_html = false + + Rails.stub :root, Pathname.getwd do + @new_test.stub :save_image, nil do + @new_test.stub :show, -> (img) { display_image_actual = img } do + @new_test.stub :save_html, -> { called_save_html = true } do + @new_test.take_screenshot + end + end + end + end + assert called_save_html + assert_match %r|\[Screenshot HTML\].+?tmp/screenshots/1_x\.html |, display_image_actual + ensure + ENV["RAILS_SYSTEM_TESTING_SCREENSHOT_HTML"] = original_html_setting + end + + test "take_screenshot saves HTML and shows link to it when using html: kwarg" do + display_image_actual = nil + called_save_html = false + + Rails.stub :root, Pathname.getwd do + @new_test.stub :save_image, nil do + @new_test.stub :show, -> (img) { display_image_actual = img } do + @new_test.stub :save_html, -> { called_save_html = true } do + @new_test.take_screenshot(html: true) + end + end + end + end + assert called_save_html + assert_match %r|\[Screenshot HTML\].+?tmp/screenshots/1_x\.html |, display_image_actual + end + + test "take_screenshot allows changing screenshot display format via RAILS_SYSTEM_TESTING_SCREENSHOT env" do + original_output_type = ENV["RAILS_SYSTEM_TESTING_SCREENSHOT"] + ENV["RAILS_SYSTEM_TESTING_SCREENSHOT"] = "artifact" + + display_image_actual = nil + + Rails.stub :root, Pathname.getwd do + @new_test.stub :save_image, nil do + @new_test.stub :show, -> (img) { display_image_actual = img } do + @new_test.take_screenshot + end + end + end + + assert_match %r|url=artifact://.+?tmp/screenshots/1_x\.png|, display_image_actual + ensure + ENV["RAILS_SYSTEM_TESTING_SCREENSHOT"] = original_output_type + end + + test "take_screenshot allows changing screenshot display format via screenshot: kwarg" do + display_image_actual = nil + + Rails.stub :root, Pathname.getwd do + @new_test.stub :save_image, nil do + @new_test.stub :show, -> (img) { display_image_actual = img } do + @new_test.take_screenshot(screenshot: "artifact") + end + end + end + + assert_match %r|url=artifact://.+?tmp/screenshots/1_x\.png|, display_image_actual + end + + test "take_failed_screenshot persists the image path in the test metadata" do + Rails.stub :root, Pathname.getwd do + @new_test.stub :passed?, false do + Capybara::Session.stub :instance_created?, true do + @new_test.stub :save_image, nil do + @new_test.stub :show, -> (_) { } do + @new_test.take_failed_screenshot + + assert_equal @new_test.send(:relative_image_path), @new_test.metadata[:failure_screenshot_path] + end + end + end + end + end + end + + test "image path returns the absolute path from root" do + Rails.stub :root, Pathname.getwd.join("..") do + assert_equal Rails.root.join("tmp/screenshots/0_x.png").to_s, @new_test.send(:image_path) + end + end + + test "Non word characters are replaced with dashes in paths" do + non_word_chars_test = DrivenBySeleniumWithChrome.new("x/y\\z?
-span") + + Rails.stub :root, Pathname.getwd do + assert_equal Rails.root.join("tmp/screenshots/0_x-y-z-br-span.png").to_s, non_word_chars_test.send(:image_path) + assert_equal Rails.root.join("tmp/screenshots/0_x-y-z-br-span.html").to_s, non_word_chars_test.send(:html_path) + end + end +end + +class RackTestScreenshotsTest < DrivenByRackTest + test "rack_test driver does not support screenshot" do + assert_not self.send(:supports_screenshot?) + end +end + +class SeleniumScreenshotsTest < DrivenBySeleniumWithChrome + test "selenium driver supports screenshot" do + assert self.send(:supports_screenshot?) + end +end diff --git a/actionpack/test/dispatch/system_testing/server_test.rb b/actionpack/test/dispatch/system_testing/server_test.rb new file mode 100644 index 0000000000000..740e90a4dad69 --- /dev/null +++ b/actionpack/test/dispatch/system_testing/server_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "capybara/dsl" +require "action_dispatch/system_testing/server" + +class ServerTest < ActiveSupport::TestCase + setup do + @old_capybara_server = Capybara.server + end + + test "port is always included" do + ActionDispatch::SystemTesting::Server.new.run + assert Capybara.always_include_port, "expected Capybara.always_include_port to be true" + end + + test "server is changed from `default` to `puma`" do + Capybara.server = :default + ActionDispatch::SystemTesting::Server.new.run + assert_not_equal Capybara.server, Capybara.servers[:default] + end + + test "server is not changed to `puma` when is different than default" do + Capybara.server = :webrick + ActionDispatch::SystemTesting::Server.new.run + assert_equal Capybara.server, Capybara.servers[:webrick] + end + + teardown do + Capybara.server = @old_capybara_server + end +end diff --git a/actionpack/test/dispatch/system_testing/system_test_case_test.rb b/actionpack/test/dispatch/system_testing/system_test_case_test.rb new file mode 100644 index 0000000000000..eb97f98b8dd3a --- /dev/null +++ b/actionpack/test/dispatch/system_testing/system_test_case_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "support/system_helper" +require "selenium/webdriver" + +class SetDriverToRackTestTest < DrivenByRackTest + test "uses rack_test" do + assert_equal :rack_test, Capybara.current_driver + end +end + +class OverrideSeleniumSubclassToRackTestTest < DrivenBySeleniumWithChrome + driven_by :rack_test + + test "uses rack_test" do + assert_equal :rack_test, Capybara.current_driver + end +end + +class OverrideDriverWithExplicitName < DrivenBySeleniumWithChrome + driven_by :selenium, options: { name: :best_driver } + + test "uses specified driver name" do + assert_equal :best_driver, Capybara.current_driver + end +end + +class SetDriverToSeleniumTest < DrivenBySeleniumWithChrome + test "uses selenium" do + assert_equal :selenium, Capybara.current_driver + end +end + +class SetDriverToSeleniumHeadlessChromeTest < DrivenBySeleniumWithHeadlessChrome + test "uses selenium headless chrome" do + assert_equal :selenium, Capybara.current_driver + end +end + +class SetDriverToSeleniumHeadlessFirefoxTest < DrivenBySeleniumWithHeadlessFirefox + test "uses selenium headless firefox" do + assert_equal :selenium, Capybara.current_driver + end +end diff --git a/actionpack/test/dispatch/test_request_test.rb b/actionpack/test/dispatch/test_request_test.rb index 85a6df4975ec1..ccca32718e3af 100644 --- a/actionpack/test/dispatch/test_request_test.rb +++ b/actionpack/test/dispatch/test_request_test.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require "abstract_unit" class TestRequestTest < ActiveSupport::TestCase - test "sane defaults" do + test "reasonable defaults" do env = ActionDispatch::TestRequest.create.env assert_equal "GET", env.delete("REQUEST_METHOD") @@ -12,18 +14,12 @@ class TestRequestTest < ActiveSupport::TestCase assert_equal "/", env.delete("PATH_INFO") assert_equal "", env.delete("SCRIPT_NAME") assert_equal "", env.delete("QUERY_STRING") - assert_equal "0", env.delete("CONTENT_LENGTH") assert_equal "test.host", env.delete("HTTP_HOST") assert_equal "0.0.0.0", env.delete("REMOTE_ADDR") assert_equal "Rails Testing", env.delete("HTTP_USER_AGENT") - assert_equal [1, 3], env.delete("rack.version") - assert_equal "", env.delete("rack.input").string assert_kind_of StringIO, env.delete("rack.errors") - assert_equal true, env.delete("rack.multithread") - assert_equal true, env.delete("rack.multiprocess") - assert_equal false, env.delete("rack.run_once") end test "cookie jar" do @@ -95,25 +91,28 @@ class TestRequestTest < ActiveSupport::TestCase assert_equal "POST", req.request_method end - test "setter methods" do + test "setter methods work and do not change Rack SPEC conformity" do req = ActionDispatch::TestRequest.create({}) get = "GET" [ - "request_method=", "host=", "request_uri=", "path=", "if_modified_since=", "if_none_match=", + "request_method=", "host=", "request_uri=", "if_modified_since=", "if_none_match=", "remote_addr=", "user_agent=", "accept=" ].each do |method| req.send(method, get) end - req.port = 8080 + req.path = "/get" + req.port = "8080" req.accept = "hello goodbye" + Rack::Lint.new(->(_) { [200, {}, []] }).call(req.env) + assert_equal(get, req.get_header("REQUEST_METHOD")) assert_equal(get, req.get_header("HTTP_HOST")) - assert_equal(8080, req.get_header("SERVER_PORT")) + assert_equal("8080", req.get_header("SERVER_PORT")) assert_equal(get, req.get_header("REQUEST_URI")) - assert_equal(get, req.get_header("PATH_INFO")) + assert_equal("/get", req.get_header("PATH_INFO")) assert_equal(get, req.get_header("HTTP_IF_MODIFIED_SINCE")) assert_equal(get, req.get_header("HTTP_IF_NONE_MATCH")) assert_equal(get, req.get_header("REMOTE_ADDR")) diff --git a/actionpack/test/dispatch/test_response_test.rb b/actionpack/test/dispatch/test_response_test.rb index 98eafb5119c6d..81acc429fbf39 100644 --- a/actionpack/test/dispatch/test_response_test.rb +++ b/actionpack/test/dispatch/test_response_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" class TestResponseTest < ActiveSupport::TestCase @@ -23,6 +25,49 @@ def assert_response_code_range(range, predicate) assert_equal response.body, response.parsed_body response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "application/json" }, '{ "foo": "fighters" }') + assert_kind_of ActiveSupport::HashWithIndifferentAccess, response.parsed_body assert_equal({ "foo" => "fighters" }, response.parsed_body) + + response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "text/html" }, <<~HTML) + + + +
Content
+ + + HTML + assert_kind_of(Nokogiri::XML::Document, response.parsed_body) + assert_equal("Content", response.parsed_body.at_xpath("/html/body/div").text) + end + + test "JSON response Hash pattern matching" do + response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "application/json" }, '{ "foo": "fighters" }') + + assert_pattern { response.parsed_body => { foo: /fighter/ } } + end + + test "JSON response Array pattern matching" do + response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "application/json" }, '[{ "foo": "fighters" }, { "nir": "vana" }]') + assert_pattern { response.parsed_body => [{ foo: /fighter/ }, { nir: /vana/ }] } + end + + test "HTML response pattern matching" do + response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "text/html" }, <<~HTML) + + + +

Some main content

+ + + HTML + html = response.parsed_body + + html.at("main") => { name:, content: } + assert_equal "main", name + assert_equal "Some main content", content + + assert_pattern { html.at("main") => { content: "Some main content" } } + assert_pattern { html.at("main") => { content: /content/ } } + assert_pattern { html.at("main") => { children: [{ name: "h1", content: /content/ }] } } end end diff --git a/actionpack/test/dispatch/uploaded_file_test.rb b/actionpack/test/dispatch/uploaded_file_test.rb index 51680216e4922..b40899a7c68b4 100644 --- a/actionpack/test/dispatch/uploaded_file_test.rb +++ b/actionpack/test/dispatch/uploaded_file_test.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + require "abstract_unit" +require "tempfile" +require "stringio" module ActionDispatch class UploadedFileTest < ActiveSupport::TestCase @@ -9,97 +13,130 @@ def test_constructor_with_argument_error end def test_original_filename - uf = Http::UploadedFile.new(filename: "foo", tempfile: Object.new) + uf = Http::UploadedFile.new(filename: "foo", tempfile: Tempfile.new) assert_equal "foo", uf.original_filename end + def test_filename_is_different_object + file_str = "foo" + uf = Http::UploadedFile.new(filename: file_str, tempfile: Tempfile.new) + assert_not_equal file_str.object_id, uf.original_filename.object_id + end + def test_filename_should_be_in_utf_8 - uf = Http::UploadedFile.new(filename: "foo", tempfile: Object.new) + uf = Http::UploadedFile.new(filename: "foo", tempfile: Tempfile.new) assert_equal "UTF-8", uf.original_filename.encoding.to_s end def test_filename_should_always_be_in_utf_8 uf = Http::UploadedFile.new(filename: "foo".encode(Encoding::SHIFT_JIS), - tempfile: Object.new) + tempfile: Tempfile.new) assert_equal "UTF-8", uf.original_filename.encoding.to_s end def test_content_type - uf = Http::UploadedFile.new(type: "foo", tempfile: Object.new) + uf = Http::UploadedFile.new(type: "foo", tempfile: Tempfile.new) assert_equal "foo", uf.content_type end def test_headers - uf = Http::UploadedFile.new(head: "foo", tempfile: Object.new) + uf = Http::UploadedFile.new(head: "foo", tempfile: Tempfile.new) assert_equal "foo", uf.headers end + def test_headers_should_be_in_utf_8 + uf = Http::UploadedFile.new(filename: "foo", head: "foo", tempfile: Tempfile.new) + assert_equal "UTF-8", uf.headers.encoding.to_s + end + + def test_headers_should_always_be_in_utf_8 + uf = Http::UploadedFile.new(filename: "foo", + head: "\xC3foo".dup.force_encoding(Encoding::ASCII_8BIT), + tempfile: Tempfile.new) + assert_equal "UTF-8", uf.headers.encoding.to_s + end + def test_tempfile - uf = Http::UploadedFile.new(tempfile: "foo") - assert_equal "foo", uf.tempfile + tf = Tempfile.new + uf = Http::UploadedFile.new(tempfile: tf) + assert_equal tf, uf.tempfile end - def test_to_io_returns_the_tempfile - tf = Object.new + def test_to_io_returns_file + tf = Tempfile.new uf = Http::UploadedFile.new(tempfile: tf) - assert_equal tf, uf.to_io + assert_equal tf.to_io, uf.to_io end def test_delegates_path_to_tempfile - tf = Class.new { def path; "thunderhorse" end } - uf = Http::UploadedFile.new(tempfile: tf.new) - assert_equal "thunderhorse", uf.path + tf = Tempfile.new + uf = Http::UploadedFile.new(tempfile: tf) + assert_equal tf.path, uf.path end def test_delegates_open_to_tempfile - tf = Class.new { def open; "thunderhorse" end } - uf = Http::UploadedFile.new(tempfile: tf.new) - assert_equal "thunderhorse", uf.open + tf = Tempfile.new + tf.close + uf = Http::UploadedFile.new(tempfile: tf) + assert_equal tf, uf.open + assert_not tf.closed? end def test_delegates_close_to_tempfile - tf = Class.new { def close(unlink_now = false); "thunderhorse" end } - uf = Http::UploadedFile.new(tempfile: tf.new) - assert_equal "thunderhorse", uf.close + tf = Tempfile.new + uf = Http::UploadedFile.new(tempfile: tf) + uf.close + assert_predicate tf, :closed? end def test_close_accepts_parameter - tf = Class.new { def close(unlink_now = false); "thunderhorse: #{unlink_now}" end } - uf = Http::UploadedFile.new(tempfile: tf.new) - assert_equal "thunderhorse: true", uf.close(true) + tf = Tempfile.new + uf = Http::UploadedFile.new(tempfile: tf) + uf.close(true) + assert_predicate tf, :closed? + assert_nil tf.path end def test_delegates_read_to_tempfile - tf = Class.new { def read(length = nil, buffer = nil); "thunderhorse" end } - uf = Http::UploadedFile.new(tempfile: tf.new) + tf = Tempfile.new + tf << "thunderhorse" + tf.rewind + uf = Http::UploadedFile.new(tempfile: tf) assert_equal "thunderhorse", uf.read end def test_delegates_read_to_tempfile_with_params - tf = Class.new { def read(length = nil, buffer = nil); [length, buffer] end } - uf = Http::UploadedFile.new(tempfile: tf.new) - assert_equal %w{ thunder horse }, uf.read(*%w{ thunder horse }) + tf = Tempfile.new + tf << "thunderhorse" + tf.rewind + uf = Http::UploadedFile.new(tempfile: tf) + assert_equal "thunder", uf.read(7) + assert_equal "horse", uf.read(5, String.new) end - def test_delegate_respects_respond_to? - tf = Class.new { def read; yield end; private :read } - uf = Http::UploadedFile.new(tempfile: tf.new) - assert_raises(NoMethodError) do - uf.read - end + def test_delegate_eof_to_tempfile + tf = Tempfile.new + tf << "thunderhorse" + uf = Http::UploadedFile.new(tempfile: tf) + assert_equal true, uf.eof? + tf.rewind + assert_equal false, uf.eof? end - def test_delegate_eof_to_tempfile - tf = Class.new { def eof?; true end; } - uf = Http::UploadedFile.new(tempfile: tf.new) - assert uf.eof? + def test_delegate_to_path_to_tempfile + tf = Tempfile.new + uf = Http::UploadedFile.new(tempfile: tf) + assert_equal tf.to_path, uf.to_path end - def test_respond_to? - tf = Class.new { def read; yield end } - uf = Http::UploadedFile.new(tempfile: tf.new) - assert uf.respond_to?(:headers), "responds to headers" - assert uf.respond_to?(:read), "responds to read" + def test_io_copy_stream + tf = Tempfile.new + tf << "thunderhorse" + tf.rewind + uf = Http::UploadedFile.new(tempfile: tf) + result = StringIO.new + IO.copy_stream(uf, result) + assert_equal "thunderhorse", result.string end end end diff --git a/actionpack/test/dispatch/url_generation_test.rb b/actionpack/test/dispatch/url_generation_test.rb index 5d81fd68347de..25442e37c3941 100644 --- a/actionpack/test/dispatch/url_generation_test.rb +++ b/actionpack/test/dispatch/url_generation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module TestUrlGeneration @@ -10,10 +12,25 @@ class ::MyRouteGeneratingController < ActionController::Base def index render plain: foo_path end + + def add_trailing_slash + render plain: url_for(trailing_slash: true, params: request.query_parameters, format: params[:format]) + end + + def trailing_slash_default + if params[:url] + render plain: trailing_slash_default_url(format: params[:url_format]) + else + render plain: trailing_slash_default_path(format: params[:url_format]) + end + end end Routes.draw do get "/foo", to: "my_route_generating#index", as: :foo + get "(/optional/:optional_id)/baz", to: "my_route_generating#index", as: :baz + get "/add_trailing_slash", to: "my_route_generating#add_trailing_slash", as: :add_trailing_slash + get "/trailing_slash_default", to: "my_route_generating#trailing_slash_default", as: :trailing_slash_default, trailing_slash: true resources :bars @@ -53,6 +70,17 @@ def app assert_equal "http://www.example.com/foo", foo_url(protocol: "http") end + test "respects secure_protocol configuration when protocol not present" do + old_secure_protocol = ActionDispatch::Http::URL.secure_protocol + + begin + ActionDispatch::Http::URL.secure_protocol = true + assert_equal "https://www.example.com/foo", foo_url(protocol: nil) + ensure + ActionDispatch::Http::URL.secure_protocol = old_secure_protocol + end + end + test "extracting protocol from host when protocol not present" do assert_equal "httpz://www.example.com/foo", foo_url(host: "httpz://www.example.com", protocol: nil) end @@ -114,7 +142,86 @@ def app assert_equal "http://example.com/foo", foo_url(subdomain: "") end + test "keep optional path parameter when given" do + assert_equal "http://www.example.com/optional/123/baz", baz_url(optional_id: 123) + end + + test "keep optional path parameter when true" do + assert_equal "http://www.example.com/optional/true/baz", baz_url(optional_id: true) + end + + test "omit optional path parameter when false" do + assert_equal "http://www.example.com/optional/false/baz", baz_url(optional_id: false) + end + + test "omit optional path parameter when blank" do + assert_equal "http://www.example.com/baz", baz_url(optional_id: "") + end + + test "keep positional path parameter when true" do + assert_equal "http://www.example.com/optional/true/baz", baz_url(true) + end + + test "omit positional path parameter when false" do + assert_equal "http://www.example.com/optional/false/baz", baz_url(false) + end + + test "omit positional path parameter when blank" do + assert_equal "http://www.example.com/baz", baz_url("") + end + + test "generating the current URL with a trailing slashes" do + get "/add_trailing_slash" + assert_equal "http://www.example.com/add_trailing_slash/", response.body + end + + test "generating the current URL with a trailing slashes and query string" do + get "/add_trailing_slash?a=b" + assert_equal "http://www.example.com/add_trailing_slash/?a=b", response.body + end + + test "generating the current URL with a trailing slashes and format indicator" do + get "/add_trailing_slash.json" + assert_equal "http://www.example.com/add_trailing_slash.json", response.body + end + + test "generating the path with `trailing_slashes: true` default options" do + get "/trailing_slash_default" + assert_equal "/trailing_slash_default/", response.body + + get "/trailing_slash_default?url=1" + assert_equal "http://www.example.com/trailing_slash_default/", response.body + end + + test "generating the path with `trailing_slashes: true` default options and format" do + get "/trailing_slash_default?url_format=json" + assert_equal "/trailing_slash_default.json", response.body + + get "/trailing_slash_default?url=1&url_format=json" + assert_equal "http://www.example.com/trailing_slash_default.json", response.body + end + test "generating URLs with trailing slashes" do + assert_equal "/bars/", bars_path( + trailing_slash: true, + ) + end + + test "generating URLs with trailing slashes and dot including param" do + assert_equal "/bars/hax0r.json/", bar_path( + "hax0r.json", + trailing_slash: true, + ) + end + + test "generating URLs with trailing slashes and query string" do + assert_equal "/bars/?a=b", bars_path( + trailing_slash: true, + a: "b" + ) + end + + test "generating URLs with trailing slashes and format" do assert_equal "/bars.json", bars_path( trailing_slash: true, format: "json" diff --git a/actionpack/test/fixtures/alternate_helpers/foo_helper.rb b/actionpack/test/fixtures/alternate_helpers/foo_helper.rb index 25285844733fd..c1a995af5f8c2 100644 --- a/actionpack/test/fixtures/alternate_helpers/foo_helper.rb +++ b/actionpack/test/fixtures/alternate_helpers/foo_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module FooHelper - redefine_method(:baz) {} + redefine_method(:baz) { } end diff --git a/actionpack/test/fixtures/bad_customers/_bad_customer.html.erb b/actionpack/test/fixtures/bad_customers/_bad_customer.html.erb deleted file mode 100644 index d22af431ec27f..0000000000000 --- a/actionpack/test/fixtures/bad_customers/_bad_customer.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= greeting %> bad customer: <%= bad_customer.name %><%= bad_customer_counter %> \ No newline at end of file diff --git a/actionpack/test/fixtures/company.rb b/actionpack/test/fixtures/company.rb index 9f527acdd8978..93afdd5472f49 100644 --- a/actionpack/test/fixtures/company.rb +++ b/actionpack/test/fixtures/company.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Company < ActiveRecord::Base has_one :mascot self.sequence_name = :companies_nonstd_seq diff --git a/actionpack/test/fixtures/functional_caching/_formatted_partial.html.erb b/actionpack/test/fixtures/functional_caching/_formatted_partial.html.erb new file mode 100644 index 0000000000000..aad73c0d6b79a --- /dev/null +++ b/actionpack/test/fixtures/functional_caching/_formatted_partial.html.erb @@ -0,0 +1 @@ +

Hello!

diff --git a/actionpack/test/fixtures/functional_caching/formatted_fragment_cached.html.erb b/actionpack/test/fixtures/functional_caching/formatted_fragment_cached.html.erb index 9b88fa1f5a8dc..dfcd423978960 100644 --- a/actionpack/test/fixtures/functional_caching/formatted_fragment_cached.html.erb +++ b/actionpack/test/fixtures/functional_caching/formatted_fragment_cached.html.erb @@ -1,3 +1,3 @@ -<%= cache do %>

ERB

<% end %> +<%= cache("fragment") do %>

ERB

<% end %> diff --git a/actionpack/test/fixtures/functional_caching/formatted_fragment_cached.xml.builder b/actionpack/test/fixtures/functional_caching/formatted_fragment_cached.xml.builder index efdcc28e0f729..65995797403bc 100644 --- a/actionpack/test/fixtures/functional_caching/formatted_fragment_cached.xml.builder +++ b/actionpack/test/fixtures/functional_caching/formatted_fragment_cached.xml.builder @@ -1,5 +1,5 @@ xml.body do - cache do + cache("fragment") do xml.p "Builder" end end diff --git a/actionpack/test/fixtures/functional_caching/formatted_fragment_cached_with_variant.html+phone.erb b/actionpack/test/fixtures/functional_caching/formatted_fragment_cached_with_variant.html+phone.erb index e523b74ae3a0b..abf7017ce6217 100644 --- a/actionpack/test/fixtures/functional_caching/formatted_fragment_cached_with_variant.html+phone.erb +++ b/actionpack/test/fixtures/functional_caching/formatted_fragment_cached_with_variant.html+phone.erb @@ -1,3 +1,3 @@ -<%= cache do %>

PHONE

<% end %> +<%= cache("fragment") do %>

PHONE

<% end %> diff --git a/actionpack/test/fixtures/functional_caching/fragment_cached.html.erb b/actionpack/test/fixtures/functional_caching/fragment_cached.html.erb index fa5e6bd318670..1148d83ad7ed2 100644 --- a/actionpack/test/fixtures/functional_caching/fragment_cached.html.erb +++ b/actionpack/test/fixtures/functional_caching/fragment_cached.html.erb @@ -1,3 +1,3 @@ Hello -<%= cache do %>This bit's fragment cached<% end %> +<%= cache "fragment" do %>This bit's fragment cached<% end %> <%= 'Ciao' %> diff --git a/actionpack/test/fixtures/functional_caching/html_fragment_cached_with_partial.html.erb b/actionpack/test/fixtures/functional_caching/html_fragment_cached_with_partial.html.erb index a9462d3499814..d81c5a1b61ca1 100644 --- a/actionpack/test/fixtures/functional_caching/html_fragment_cached_with_partial.html.erb +++ b/actionpack/test/fixtures/functional_caching/html_fragment_cached_with_partial.html.erb @@ -1 +1 @@ -<%= render :partial => 'partial' %> \ No newline at end of file +<%= render partial: 'partial' %> \ No newline at end of file diff --git a/actionpack/test/fixtures/functional_caching/inline_fragment_cached.html.erb b/actionpack/test/fixtures/functional_caching/inline_fragment_cached.html.erb index 41647f1404ced..bc8decb97271c 100644 --- a/actionpack/test/fixtures/functional_caching/inline_fragment_cached.html.erb +++ b/actionpack/test/fixtures/functional_caching/inline_fragment_cached.html.erb @@ -1,2 +1,2 @@ -<%= render :inline => 'Some inline content' %> +<%= render inline: 'Some inline content' %> <%= cache do %>Some cached content<% end %> diff --git a/actionpack/test/fixtures/functional_caching/xml_fragment_cached_with_html_partial.xml.builder b/actionpack/test/fixtures/functional_caching/xml_fragment_cached_with_html_partial.xml.builder new file mode 100644 index 0000000000000..2bdda3af18bed --- /dev/null +++ b/actionpack/test/fixtures/functional_caching/xml_fragment_cached_with_html_partial.xml.builder @@ -0,0 +1,5 @@ +cache do + xml.title "Hello!" +end + +xml.body cdata_section(render("formatted_partial")) diff --git a/actionpack/test/fixtures/helpers/abc_helper.rb b/actionpack/test/fixtures/helpers/abc_helper.rb index cf2774bb5f487..999b9b5c6e4c8 100644 --- a/actionpack/test/fixtures/helpers/abc_helper.rb +++ b/actionpack/test/fixtures/helpers/abc_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module AbcHelper def bare_a() end end diff --git a/actionpack/test/fixtures/helpers/fun/games_helper.rb b/actionpack/test/fixtures/helpers/fun/games_helper.rb index 2d5e50f5a5f70..8b325927f3f63 100644 --- a/actionpack/test/fixtures/helpers/fun/games_helper.rb +++ b/actionpack/test/fixtures/helpers/fun/games_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Fun module GamesHelper def stratego() "Iz guuut!" end diff --git a/actionpack/test/fixtures/helpers/fun/pdf_helper.rb b/actionpack/test/fixtures/helpers/fun/pdf_helper.rb index 16057fd466470..7ce6591de3555 100644 --- a/actionpack/test/fixtures/helpers/fun/pdf_helper.rb +++ b/actionpack/test/fixtures/helpers/fun/pdf_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Fun module PdfHelper def foobar() "baz" end diff --git a/actionpack/test/fixtures/helpers/just_me_helper.rb b/actionpack/test/fixtures/helpers/just_me_helper.rb index 9b43fc6d4962b..bd977a22d97fe 100644 --- a/actionpack/test/fixtures/helpers/just_me_helper.rb +++ b/actionpack/test/fixtures/helpers/just_me_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JustMeHelper def me() "mine!" end end diff --git a/actionpack/test/fixtures/helpers/me_too_helper.rb b/actionpack/test/fixtures/helpers/me_too_helper.rb index 8e312e7cd094f..c6fc053dee4e3 100644 --- a/actionpack/test/fixtures/helpers/me_too_helper.rb +++ b/actionpack/test/fixtures/helpers/me_too_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MeTooHelper def me() "me too!" end end diff --git a/actionpack/test/fixtures/helpers1_pack/pack1_helper.rb b/actionpack/test/fixtures/helpers1_pack/pack1_helper.rb index 9faa4277363e8..cf75b6875e324 100644 --- a/actionpack/test/fixtures/helpers1_pack/pack1_helper.rb +++ b/actionpack/test/fixtures/helpers1_pack/pack1_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Pack1Helper def conflicting_helper "pack1" diff --git a/actionpack/test/fixtures/helpers2_pack/pack2_helper.rb b/actionpack/test/fixtures/helpers2_pack/pack2_helper.rb index cf56697dfb8de..c8e51d40a248c 100644 --- a/actionpack/test/fixtures/helpers2_pack/pack2_helper.rb +++ b/actionpack/test/fixtures/helpers2_pack/pack2_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Pack2Helper def conflicting_helper "pack2" diff --git a/actionpack/test/fixtures/helpers_typo/admin/users_helper.rb b/actionpack/test/fixtures/helpers_typo/admin/users_helper.rb index 64aa1a0476446..0455e26b93b0d 100644 --- a/actionpack/test/fixtures/helpers_typo/admin/users_helper.rb +++ b/actionpack/test/fixtures/helpers_typo/admin/users_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Admin module UsersHelpeR end diff --git a/actionpack/test/fixtures/layouts/builder.builder b/actionpack/test/fixtures/layouts/builder.builder index 7c7d4b2dd13ad..c55488edd05c1 100644 --- a/actionpack/test/fixtures/layouts/builder.builder +++ b/actionpack/test/fixtures/layouts/builder.builder @@ -1,3 +1,3 @@ xml.wrapper do xml << yield -end \ No newline at end of file +end diff --git a/actionpack/test/fixtures/layouts/with_html_partial.html.erb b/actionpack/test/fixtures/layouts/with_html_partial.html.erb index fd2896aeaa915..e84401f360803 100644 --- a/actionpack/test/fixtures/layouts/with_html_partial.html.erb +++ b/actionpack/test/fixtures/layouts/with_html_partial.html.erb @@ -1 +1 @@ -<%= render :partial => "partial_only_html" %><%= yield %> +<%= render partial: "partial_only_html" %><%= yield %> diff --git a/actionpack/test/fixtures/load_me.rb b/actionpack/test/fixtures/load_me.rb index e516512a4e465..efafe6898fe6f 100644 --- a/actionpack/test/fixtures/load_me.rb +++ b/actionpack/test/fixtures/load_me.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class LoadMe end diff --git a/actionpack/test/fixtures/localized/hello_world.de_AT.html b/actionpack/test/fixtures/localized/hello_world.de_AT.html new file mode 100644 index 0000000000000..3b6e180261129 --- /dev/null +++ b/actionpack/test/fixtures/localized/hello_world.de_AT.html @@ -0,0 +1 @@ +Guten Morgen \ No newline at end of file diff --git a/actionpack/test/fixtures/old_content_type/render_default_for_builder.builder b/actionpack/test/fixtures/old_content_type/render_default_for_builder.builder index 598d62e2fca55..15c8a7f5cf9bd 100644 --- a/actionpack/test/fixtures/old_content_type/render_default_for_builder.builder +++ b/actionpack/test/fixtures/old_content_type/render_default_for_builder.builder @@ -1 +1 @@ -xml.p "Hello world!" \ No newline at end of file +xml.p "Hello world!" diff --git "a/actionpack/test/fixtures/public/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html" "b/actionpack/test/fixtures/public/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html" new file mode 100644 index 0000000000000..627bb2469f636 --- /dev/null +++ "b/actionpack/test/fixtures/public/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html" @@ -0,0 +1 @@ +means goodbye in Japanese diff --git "a/actionpack/test/fixtures/public/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html.gz" "b/actionpack/test/fixtures/public/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html.gz" new file mode 100644 index 0000000000000..4f484cfe86846 Binary files /dev/null and "b/actionpack/test/fixtures/public/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html.gz" differ diff --git a/actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js.br b/actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js.br new file mode 100644 index 0000000000000..83088e506734f Binary files /dev/null and b/actionpack/test/fixtures/public/gzip/application-a71b3024f80aea3181c09774ca17e712.js.br differ diff --git a/actionpack/test/fixtures/public/gzip/logo-bcb6d75d927347158af5.svg b/actionpack/test/fixtures/public/gzip/logo-bcb6d75d927347158af5.svg new file mode 100644 index 0000000000000..a63aa6d1672c9 --- /dev/null +++ b/actionpack/test/fixtures/public/gzip/logo-bcb6d75d927347158af5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/actionpack/test/fixtures/public/gzip/logo-bcb6d75d927347158af5.svg.gz b/actionpack/test/fixtures/public/gzip/logo-bcb6d75d927347158af5.svg.gz new file mode 100644 index 0000000000000..60a6e063ec560 Binary files /dev/null and b/actionpack/test/fixtures/public/gzip/logo-bcb6d75d927347158af5.svg.gz differ diff --git a/actionpack/test/fixtures/respond_to/using_defaults.xml.builder b/actionpack/test/fixtures/respond_to/using_defaults.xml.builder index 598d62e2fca55..15c8a7f5cf9bd 100644 --- a/actionpack/test/fixtures/respond_to/using_defaults.xml.builder +++ b/actionpack/test/fixtures/respond_to/using_defaults.xml.builder @@ -1 +1 @@ -xml.p "Hello world!" \ No newline at end of file +xml.p "Hello world!" diff --git a/actionpack/test/fixtures/respond_to/using_defaults_with_type_list.xml.builder b/actionpack/test/fixtures/respond_to/using_defaults_with_type_list.xml.builder index 598d62e2fca55..15c8a7f5cf9bd 100644 --- a/actionpack/test/fixtures/respond_to/using_defaults_with_type_list.xml.builder +++ b/actionpack/test/fixtures/respond_to/using_defaults_with_type_list.xml.builder @@ -1 +1 @@ -xml.p "Hello world!" \ No newline at end of file +xml.p "Hello world!" diff --git a/actionpack/test/fixtures/ruby_template.ruby b/actionpack/test/fixtures/ruby_template.ruby index 5097bce47c098..3e0bc445a2b61 100644 --- a/actionpack/test/fixtures/ruby_template.ruby +++ b/actionpack/test/fixtures/ruby_template.ruby @@ -1,2 +1,2 @@ -body = "" +body = +"" body << ["Hello", "from", "Ruby", "code"].join(" ") diff --git a/actionpack/test/fixtures/session_autoload_test/session_autoload_test/foo.rb b/actionpack/test/fixtures/session_autoload_test/session_autoload_test/foo.rb index 18fa5cd923bd5..deb81c647dd18 100644 --- a/actionpack/test/fixtures/session_autoload_test/session_autoload_test/foo.rb +++ b/actionpack/test/fixtures/session_autoload_test/session_autoload_test/foo.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SessionAutoloadTest class Foo def initialize(bar = "baz") diff --git a/actionpack/test/fixtures/test/formatted_xml_erb.builder b/actionpack/test/fixtures/test/formatted_xml_erb.builder deleted file mode 100644 index 14fd3549fbcdb..0000000000000 --- a/actionpack/test/fixtures/test/formatted_xml_erb.builder +++ /dev/null @@ -1 +0,0 @@ -xml.test 'failed' \ No newline at end of file diff --git a/actionpack/test/fixtures/test/formatted_xml_erb.html.erb b/actionpack/test/fixtures/test/formatted_xml_erb.html.erb deleted file mode 100644 index 0c855a604bc1e..0000000000000 --- a/actionpack/test/fixtures/test/formatted_xml_erb.html.erb +++ /dev/null @@ -1 +0,0 @@ -passed formatted html erb \ No newline at end of file diff --git a/actionpack/test/fixtures/test/hello_xml_world.builder b/actionpack/test/fixtures/test/hello_xml_world.builder index e7081b89feb6a..d16bb6b5cbd68 100644 --- a/actionpack/test/fixtures/test/hello_xml_world.builder +++ b/actionpack/test/fixtures/test/hello_xml_world.builder @@ -8,4 +8,4 @@ xml.html do xml.p "monks" xml.p "wiseguys" end -end \ No newline at end of file +end diff --git "a/actionpack/test/fixtures/\345\205\254\345\205\261/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html" "b/actionpack/test/fixtures/\345\205\254\345\205\261/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html" new file mode 100644 index 0000000000000..627bb2469f636 --- /dev/null +++ "b/actionpack/test/fixtures/\345\205\254\345\205\261/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html" @@ -0,0 +1 @@ +means goodbye in Japanese diff --git "a/actionpack/test/fixtures/\345\205\254\345\205\261/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html.gz" "b/actionpack/test/fixtures/\345\205\254\345\205\261/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html.gz" new file mode 100644 index 0000000000000..4f484cfe86846 Binary files /dev/null and "b/actionpack/test/fixtures/\345\205\254\345\205\261/foo/\343\201\225\343\202\210\343\201\206\343\201\252\343\202\211.html.gz" differ diff --git "a/actionpack/test/fixtures/\345\205\254\345\205\261/gzip/application-a71b3024f80aea3181c09774ca17e712.js.br" "b/actionpack/test/fixtures/\345\205\254\345\205\261/gzip/application-a71b3024f80aea3181c09774ca17e712.js.br" new file mode 100644 index 0000000000000..83088e506734f Binary files /dev/null and "b/actionpack/test/fixtures/\345\205\254\345\205\261/gzip/application-a71b3024f80aea3181c09774ca17e712.js.br" differ diff --git "a/actionpack/test/fixtures/\345\205\254\345\205\261/gzip/logo-bcb6d75d927347158af5.svg" "b/actionpack/test/fixtures/\345\205\254\345\205\261/gzip/logo-bcb6d75d927347158af5.svg" new file mode 100644 index 0000000000000..a63aa6d1672c9 --- /dev/null +++ "b/actionpack/test/fixtures/\345\205\254\345\205\261/gzip/logo-bcb6d75d927347158af5.svg" @@ -0,0 +1 @@ + \ No newline at end of file diff --git "a/actionpack/test/fixtures/\345\205\254\345\205\261/gzip/logo-bcb6d75d927347158af5.svg.gz" "b/actionpack/test/fixtures/\345\205\254\345\205\261/gzip/logo-bcb6d75d927347158af5.svg.gz" new file mode 100644 index 0000000000000..60a6e063ec560 Binary files /dev/null and "b/actionpack/test/fixtures/\345\205\254\345\205\261/gzip/logo-bcb6d75d927347158af5.svg.gz" differ diff --git a/actionpack/test/journey/gtg/builder_test.rb b/actionpack/test/journey/gtg/builder_test.rb index aa8427b2650ab..4f064779e4f3a 100644 --- a/actionpack/test/journey/gtg/builder_test.rb +++ b/actionpack/test/journey/gtg/builder_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch @@ -6,13 +8,13 @@ module GTG class TestBuilder < ActiveSupport::TestCase def test_following_states_multi table = tt ["a|a"] - assert_equal 1, table.move([0], "a").length + assert_equal 1, table.move([0, nil], "a", "a", 0, true).each_slice(2).count end def test_following_states_multi_regexp table = tt [":a|b"] - assert_equal 1, table.move([0], "fooo").length - assert_equal 2, table.move([0], "b").length + assert_equal 1, table.move([0, nil], "fooo", "fooo", 0, true).each_slice(2).count + assert_equal 2, table.move([0, nil], "b", "b", 0, true).each_slice(2).count end def test_multi_path @@ -23,9 +25,9 @@ def test_multi_path [2, "b"], [2, "/"], [1, "c"], - ].inject([0]) { |state, (exp, sym)| - new = table.move(state, sym) - assert_equal exp, new.length + ].inject([0, nil]) { |state, (exp, sym)| + new = table.move(state, sym, sym, 0, sym != "/") + assert_equal exp, new.each_slice(2).count new } end @@ -38,10 +40,10 @@ def test_match_data_ambiguous /articles/:id(.:format) } - sim = NFA::Simulator.new table + sim = Simulator.new table - match = sim.match "/articles/new" - assert_equal 2, match.memos.length + memos = sim.memos "/articles/new" + assert_equal 2, memos.length end ## @@ -52,10 +54,27 @@ def test_match_same_paths /articles/new(.:format) } - sim = NFA::Simulator.new table + sim = Simulator.new table + + memos = sim.memos "/articles/new" + assert_equal 2, memos.length + end + + def test_catchall + table = tt %w{ + / + /*unmatched_route + } + + sim = Simulator.new table + + # matches just the /*unmatched_route + memos = sim.memos "/test" + assert_equal 1, memos.length - match = sim.match "/articles/new" - assert_equal 2, match.memos.length + # matches just the / + memos = sim.memos "/" + assert_equal 1, memos.length end private diff --git a/actionpack/test/journey/gtg/transition_table_test.rb b/actionpack/test/journey/gtg/transition_table_test.rb index c7315c0338692..7394764a4c8f9 100644 --- a/actionpack/test/journey/gtg/transition_table_test.rb +++ b/actionpack/test/journey/gtg/transition_table_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" require "active_support/json/decoding" @@ -19,7 +21,7 @@ def test_to_json assert json["accepting"] end - if system("dot -V 2>/dev/null") + if system("dot -V", 2 => File::NULL) def test_to_svg table = tt %w{ /articles(.:format) @@ -35,25 +37,25 @@ def test_to_svg def test_simulate_gt sim = simulator_for ["/foo", "/bar"] - assert_match sim, "/foo" + assert_match_route sim, "/foo" end def test_simulate_gt_regexp sim = simulator_for [":foo"] - assert_match sim, "foo" + assert_match_route sim, "foo" end def test_simulate_gt_regexp_mix sim = simulator_for ["/get", "/:method/foo"] - assert_match sim, "/get" - assert_match sim, "/get/foo" + assert_match_route sim, "/get" + assert_match_route sim, "/get/foo" end def test_simulate_optional sim = simulator_for ["/foo(/bar)"] - assert_match sim, "/foo" - assert_match sim, "/foo/bar" - assert_no_match sim, "/foo/" + assert_match_route sim, "/foo" + assert_match_route sim, "/foo/bar" + assert_no_match_route sim, "/foo/" end def test_match_data @@ -65,11 +67,11 @@ def test_match_data sim = GTG::Simulator.new tt - match = sim.match "/get" - assert_equal [paths.first], match.memos + memos = sim.memos "/get" + assert_equal [paths.first], memos - match = sim.match "/get/foo" - assert_equal [paths.last], match.memos + memos = sim.memos "/get/foo" + assert_equal [paths.last], memos end def test_match_data_ambiguous @@ -86,8 +88,8 @@ def test_match_data_ambiguous builder = GTG::Builder.new ast sim = GTG::Simulator.new builder.transition_table - match = sim.match "/articles/new" - assert_equal [paths[1], paths[3]], match.memos + memos = sim.memos "/articles/new" + assert_equal Set[paths[1], paths[3]], Set.new(memos) end private @@ -109,6 +111,14 @@ def tt(paths) def simulator_for(paths) GTG::Simulator.new tt(paths) end + + def assert_match_route(simulator, path) + assert simulator.memos(path), "Simulator should match #{path}." + end + + def assert_no_match_route(simulator, path) + assert_not simulator.memos(path) { nil }, "Simulator should not match #{path}." + end end end end diff --git a/actionpack/test/journey/nfa/simulator_test.rb b/actionpack/test/journey/nfa/simulator_test.rb deleted file mode 100644 index 38f99398cb889..0000000000000 --- a/actionpack/test/journey/nfa/simulator_test.rb +++ /dev/null @@ -1,98 +0,0 @@ -require "abstract_unit" - -module ActionDispatch - module Journey - module NFA - class TestSimulator < ActiveSupport::TestCase - def test_simulate_simple - sim = simulator_for ["/foo"] - assert_match sim, "/foo" - end - - def test_simulate_simple_no_match - sim = simulator_for ["/foo"] - assert_no_match sim, "foo" - end - - def test_simulate_simple_no_match_too_long - sim = simulator_for ["/foo"] - assert_no_match sim, "/foo/bar" - end - - def test_simulate_simple_no_match_wrong_string - sim = simulator_for ["/foo"] - assert_no_match sim, "/bar" - end - - def test_simulate_regex - sim = simulator_for ["/:foo/bar"] - assert_match sim, "/bar/bar" - assert_match sim, "/foo/bar" - end - - def test_simulate_or - sim = simulator_for ["/foo", "/bar"] - assert_match sim, "/bar" - assert_match sim, "/foo" - assert_no_match sim, "/baz" - end - - def test_simulate_optional - sim = simulator_for ["/foo(/bar)"] - assert_match sim, "/foo" - assert_match sim, "/foo/bar" - assert_no_match sim, "/foo/" - end - - def test_matchdata_has_memos - paths = %w{ /foo /bar } - parser = Journey::Parser.new - asts = paths.map { |x| - ast = parser.parse x - ast.each { |n| n.memo = ast } - ast - } - - expected = asts.first - - builder = Builder.new Nodes::Or.new asts - - sim = Simulator.new builder.transition_table - - md = sim.match "/foo" - assert_equal [expected], md.memos - end - - def test_matchdata_memos_on_merge - parser = Journey::Parser.new - routes = [ - "/articles(.:format)", - "/articles/new(.:format)", - "/articles/:id/edit(.:format)", - "/articles/:id(.:format)", - ].map { |path| - ast = parser.parse path - ast.each { |n| n.memo = ast } - ast - } - - asts = routes.dup - - ast = Nodes::Or.new routes - - nfa = Journey::NFA::Builder.new ast - sim = Simulator.new nfa.transition_table - md = sim.match "/articles" - assert_equal [asts.first], md.memos - end - - def simulator_for(paths) - parser = Journey::Parser.new - asts = paths.map { |x| parser.parse x } - builder = Builder.new Nodes::Or.new asts - Simulator.new builder.transition_table - end - end - end - end -end diff --git a/actionpack/test/journey/nfa/transition_table_test.rb b/actionpack/test/journey/nfa/transition_table_test.rb deleted file mode 100644 index 0bc6bc1cf81b1..0000000000000 --- a/actionpack/test/journey/nfa/transition_table_test.rb +++ /dev/null @@ -1,72 +0,0 @@ -require "abstract_unit" - -module ActionDispatch - module Journey - module NFA - class TestTransitionTable < ActiveSupport::TestCase - def setup - @parser = Journey::Parser.new - end - - def test_eclosure - table = tt "/" - assert_equal [0], table.eclosure(0) - - table = tt ":a|:b" - assert_equal 3, table.eclosure(0).length - - table = tt "(:a|:b)" - assert_equal 5, table.eclosure(0).length - assert_equal 5, table.eclosure([0]).length - end - - def test_following_states_one - table = tt "/" - - assert_equal [1], table.following_states(0, "/") - assert_equal [1], table.following_states([0], "/") - end - - def test_following_states_group - table = tt "a|b" - states = table.eclosure 0 - - assert_equal 1, table.following_states(states, "a").length - assert_equal 1, table.following_states(states, "b").length - end - - def test_following_states_multi - table = tt "a|a" - states = table.eclosure 0 - - assert_equal 2, table.following_states(states, "a").length - assert_equal 0, table.following_states(states, "b").length - end - - def test_following_states_regexp - table = tt "a|:a" - states = table.eclosure 0 - - assert_equal 1, table.following_states(states, "a").length - assert_equal 1, table.following_states(states, /[^\.\/\?]+/).length - assert_equal 0, table.following_states(states, "b").length - end - - def test_alphabet - table = tt "a|:a" - assert_equal [/[^\.\/\?]+/, "a"], table.alphabet - - table = tt "a|a" - assert_equal ["a"], table.alphabet - end - - private - def tt(string) - ast = @parser.parse string - builder = Builder.new ast - builder.transition_table - end - end - end - end -end diff --git a/actionpack/test/journey/nodes/ast_test.rb b/actionpack/test/journey/nodes/ast_test.rb new file mode 100644 index 0000000000000..b809c80b6f8d5 --- /dev/null +++ b/actionpack/test/journey/nodes/ast_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "abstract_unit" + +module ActionDispatch + module Journey + module Nodes + class TestAst < ActiveSupport::TestCase + def test_ast_sets_regular_expressions + requirements = { name: /(tender|love)/, value: /./ } + path = "/page/:name/:value" + tree = Journey::Parser.new.parse(path) + + ast = Ast.new(tree, true) + ast.requirements = requirements + + nodes = ast.root.grep(Nodes::Symbol) + assert_equal 2, nodes.length + nodes.each do |node| + assert_equal requirements[node.to_sym], node.regexp + end + end + + def test_sets_memo_for_terminal_nodes + route = Object.new + tree = Journey::Parser.new.parse("/path") + + ast = Ast.new(tree, true) + ast.route = route + + nodes = ast.root.grep(Nodes::Terminal) + nodes.each do |node| + assert_equal route, node.memo + end + end + + def test_contains_glob + tree = Journey::Parser.new.parse("/*glob") + ast = Ast.new(tree, true) + + assert_predicate ast, :glob? + end + + def test_does_not_contain_glob + tree = Journey::Parser.new.parse("/") + ast = Ast.new(tree, true) + + assert_not_predicate ast, :glob? + end + + def test_names + tree = Journey::Parser.new.parse("/:path/:symbol") + ast = Ast.new(tree, true) + + assert_equal ["path", "symbol"], ast.names + end + + def test_path_params + tree = Journey::Parser.new.parse("/:path/:symbol") + ast = Ast.new(tree, true) + + assert_equal [:path, :symbol], ast.path_params + end + + def test_wildcard_options_when_formatted + tree = Journey::Parser.new.parse("/*glob") + ast = Ast.new(tree, true) + + wildcard_options = ast.wildcard_options + assert_equal %r{.+?}m, wildcard_options[:glob] + end + + def test_wildcard_options_when_false + tree = Journey::Parser.new.parse("/*glob") + ast = Ast.new(tree, false) + + wildcard_options = ast.wildcard_options + assert_nil wildcard_options[:glob] + end + + def test_wildcard_options_when_nil + tree = Journey::Parser.new.parse("/*glob") + ast = Ast.new(tree, nil) + + wildcard_options = ast.wildcard_options + assert_equal %r{.+?}m, wildcard_options[:glob] + end + end + end + end +end diff --git a/actionpack/test/journey/nodes/symbol_test.rb b/actionpack/test/journey/nodes/symbol_test.rb index baf60f40b8c14..e69de29bb2d1d 100644 --- a/actionpack/test/journey/nodes/symbol_test.rb +++ b/actionpack/test/journey/nodes/symbol_test.rb @@ -1,17 +0,0 @@ -require "abstract_unit" - -module ActionDispatch - module Journey - module Nodes - class TestSymbol < ActiveSupport::TestCase - def test_default_regexp? - sym = Symbol.new "foo" - assert sym.default_regexp? - - sym.regexp = nil - assert_not sym.default_regexp? - end - end - end - end -end diff --git a/actionpack/test/journey/path/pattern_test.rb b/actionpack/test/journey/path/pattern_test.rb index 2c746179441dd..bad1ee6acd34f 100644 --- a/actionpack/test/journey/path/pattern_test.rb +++ b/actionpack/test/journey/path/pattern_test.rb @@ -1,9 +1,14 @@ +# frozen_string_literal: true + require "abstract_unit" +require "support/path_helper" module ActionDispatch module Journey module Path class TestPattern < ActiveSupport::TestCase + include PathHelper + SEPARATORS = ["/", ".", "?"].join x = /.+/ @@ -21,7 +26,7 @@ class TestPattern < ActiveSupport::TestCase "/:foo|*bar" => %r{\A/(?:([^/.?]+)|(.+))\Z}, }.each do |path, expected| define_method(:"test_to_regexp_#{Regexp.escape(path)}") do - path = Pattern.build( + path = build_path( path, { controller: /.+/ }, SEPARATORS, @@ -32,20 +37,20 @@ class TestPattern < ActiveSupport::TestCase end { - "/:controller(/:action)" => %r{\A/(#{x})(?:/([^/.?]+))?}, - "/:controller/foo" => %r{\A/(#{x})/foo}, - "/:controller/:action" => %r{\A/(#{x})/([^/.?]+)}, - "/:controller" => %r{\A/(#{x})}, - "/:controller(/:action(/:id))" => %r{\A/(#{x})(?:/([^/.?]+)(?:/([^/.?]+))?)?}, - "/:controller/:action.xml" => %r{\A/(#{x})/([^/.?]+)\.xml}, - "/:controller.:format" => %r{\A/(#{x})\.([^/.?]+)}, - "/:controller(.:format)" => %r{\A/(#{x})(?:\.([^/.?]+))?}, - "/:controller/*foo" => %r{\A/(#{x})/(.+)}, - "/:controller/*foo/bar" => %r{\A/(#{x})/(.+)/bar}, - "/:foo|*bar" => %r{\A/(?:([^/.?]+)|(.+))}, + "/:controller(/:action)" => %r{\A/(#{x})(?:/([^/.?]+))?(?:\b|\Z|/)}, + "/:controller/foo" => %r{\A/(#{x})/foo(?:\b|\Z|/)}, + "/:controller/:action" => %r{\A/(#{x})/([^/.?]+)(?:\b|\Z|/)}, + "/:controller" => %r{\A/(#{x})(?:\b|\Z|/)}, + "/:controller(/:action(/:id))" => %r{\A/(#{x})(?:/([^/.?]+)(?:/([^/.?]+))?)?(?:\b|\Z|/)}, + "/:controller/:action.xml" => %r{\A/(#{x})/([^/.?]+)\.xml(?:\b|\Z|/)}, + "/:controller.:format" => %r{\A/(#{x})\.([^/.?]+)(?:\b|\Z|/)}, + "/:controller(.:format)" => %r{\A/(#{x})(?:\.([^/.?]+))?(?:\b|\Z|/)}, + "/:controller/*foo" => %r{\A/(#{x})/(.+)(?:\b|\Z|/)}, + "/:controller/*foo/bar" => %r{\A/(#{x})/(.+)/bar(?:\b|\Z|/)}, + "/:foo|*bar" => %r{\A/(?:([^/.?]+)|(.+))(?:\b|\Z|/)}, }.each do |path, expected| define_method(:"test_to_non_anchored_regexp_#{Regexp.escape(path)}") do - path = Pattern.build( + path = build_path( path, { controller: /.+/ }, SEPARATORS, @@ -68,7 +73,7 @@ class TestPattern < ActiveSupport::TestCase "/:controller/*foo/bar" => %w{ controller foo }, }.each do |path, expected| define_method(:"test_names_#{Regexp.escape(path)}") do - path = Pattern.build( + path = build_path( path, { controller: /.+/ }, SEPARATORS, @@ -79,7 +84,7 @@ class TestPattern < ActiveSupport::TestCase end def test_to_regexp_with_extended_group - path = Pattern.build( + path = build_path( "/page/:name", { name: / #ROFL @@ -100,13 +105,13 @@ def test_optional_names ["/:foo(/:bar)", %w{ bar }], ["/:foo(/:bar)/:lol(/:baz)", %w{ bar baz }], ].each do |pattern, list| - path = Pattern.from_string pattern + path = path_from_string pattern assert_equal list.sort, path.optional_names.sort end end def test_to_regexp_match_non_optional - path = Pattern.build( + path = build_path( "/:name", { name: /\d+/ }, SEPARATORS, @@ -117,7 +122,7 @@ def test_to_regexp_match_non_optional end def test_to_regexp_with_group - path = Pattern.build( + path = build_path( "/page/:name", { name: /(tender|love)/ }, SEPARATORS, @@ -128,24 +133,8 @@ def test_to_regexp_with_group assert_no_match(path, "/page/loving") end - def test_ast_sets_regular_expressions - requirements = { name: /(tender|love)/, value: /./ } - path = Pattern.build( - "/page/:name/:value", - requirements, - SEPARATORS, - true - ) - - nodes = path.ast.grep(Nodes::Symbol) - assert_equal 2, nodes.length - nodes.each do |node| - assert_equal requirements[node.to_sym], node.regexp - end - end - def test_match_data_with_group - path = Pattern.build( + path = build_path( "/page/:name", { name: /(tender|love)/ }, SEPARATORS, @@ -157,7 +146,7 @@ def test_match_data_with_group end def test_match_data_with_multi_group - path = Pattern.build( + path = build_path( "/page/:name/:id", { name: /t(((ender|love)))()/ }, SEPARATORS, @@ -172,7 +161,7 @@ def test_match_data_with_multi_group def test_star_with_custom_re z = /\d+/ - path = Pattern.build( + path = build_path( "/page/*foo", { foo: z }, SEPARATORS, @@ -182,7 +171,7 @@ def test_star_with_custom_re end def test_insensitive_regexp_with_group - path = Pattern.build( + path = build_path( "/page/:name/aaron", { name: /(tender|love)/i }, SEPARATORS, @@ -194,27 +183,27 @@ def test_insensitive_regexp_with_group end def test_to_regexp_with_strexp - path = Pattern.build("/:controller", {}, SEPARATORS, true) + path = build_path("/:controller", {}, SEPARATORS, true) x = %r{\A/([^/.?]+)\Z} assert_equal(x.source, path.source) end def test_to_regexp_defaults - path = Pattern.from_string "/:controller(/:action(/:id))" + path = path_from_string "/:controller(/:action(/:id))" expected = %r{\A/([^/.?]+)(?:/([^/.?]+)(?:/([^/.?]+))?)?\Z} assert_equal expected, path.to_regexp end def test_failed_match - path = Pattern.from_string "/:controller(/:action(/:id(.:format)))" + path = path_from_string "/:controller(/:action(/:id(.:format)))" uri = "content" assert_not path =~ uri end def test_match_controller - path = Pattern.from_string "/:controller(/:action(/:id(.:format)))" + path = path_from_string "/:controller(/:action(/:id(.:format)))" uri = "/content" match = path =~ uri @@ -226,7 +215,7 @@ def test_match_controller end def test_match_controller_action - path = Pattern.from_string "/:controller(/:action(/:id(.:format)))" + path = path_from_string "/:controller(/:action(/:id(.:format)))" uri = "/content/list" match = path =~ uri @@ -238,7 +227,7 @@ def test_match_controller_action end def test_match_controller_action_id - path = Pattern.from_string "/:controller(/:action(/:id(.:format)))" + path = path_from_string "/:controller(/:action(/:id(.:format)))" uri = "/content/list/10" match = path =~ uri @@ -250,7 +239,7 @@ def test_match_controller_action_id end def test_match_literal - path = Path::Pattern.from_string "/books(/:action(.:format))" + path = path_from_string "/books(/:action(.:format))" uri = "/books" match = path =~ uri @@ -260,7 +249,7 @@ def test_match_literal end def test_match_literal_with_action - path = Path::Pattern.from_string "/books(/:action(.:format))" + path = path_from_string "/books(/:action(.:format))" uri = "/books/list" match = path =~ uri @@ -270,7 +259,7 @@ def test_match_literal_with_action end def test_match_literal_with_action_and_format - path = Path::Pattern.from_string "/books(/:action(.:format))" + path = path_from_string "/books(/:action(.:format))" uri = "/books/list.rss" match = path =~ uri @@ -278,6 +267,46 @@ def test_match_literal_with_action_and_format assert_equal "list", match[1] assert_equal "rss", match[2] end + + def test_named_captures + path = path_from_string "/books(/:action(.:format))" + + uri = "/books/list.rss" + match = path =~ uri + named_captures = { "action" => "list", "format" => "rss" } + assert_equal named_captures, match.named_captures + end + + def test_requirements_for_missing_keys_check + name_regex = /test/ + + path = build_path( + "/page/:name", + { name: name_regex }, + SEPARATORS, + true + ) + + transformed_regex = path.requirements_for_missing_keys_check[:name] + assert_not_nil transformed_regex + assert_equal(transformed_regex, /\A#{name_regex}\Z/) + end + + def test_requirements_for_missing_keys_check_memoization + name_regex = /test/ + + path = build_path( + "/page/:name", + { name: name_regex }, + SEPARATORS, + true + ) + + first_call = path.requirements_for_missing_keys_check[:name] + second_call = path.requirements_for_missing_keys_check[:name] + + assert_equal(first_call.object_id, second_call.object_id) + end end end end diff --git a/actionpack/test/journey/route/definition/parser_test.rb b/actionpack/test/journey/route/definition/parser_test.rb index 8c6e3c0371eb7..39693198b8eb9 100644 --- a/actionpack/test/journey/route/definition/parser_test.rb +++ b/actionpack/test/journey/route/definition/parser_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch diff --git a/actionpack/test/journey/route/definition/scanner_test.rb b/actionpack/test/journey/route/definition/scanner_test.rb index 98578ddbf1cc7..2d0245ca5edb2 100644 --- a/actionpack/test/journey/route/definition/scanner_test.rb +++ b/actionpack/test/journey/route/definition/scanner_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch @@ -8,61 +10,79 @@ def setup @scanner = Scanner.new end - # /page/:id(/:action)(.:format) - def test_tokens - [ - ["/", [[:SLASH, "/"]]], - ["*omg", [[:STAR, "*omg"]]], - ["/page", [[:SLASH, "/"], [:LITERAL, "page"]]], - ["/page!", [[:SLASH, "/"], [:LITERAL, "page!"]]], - ["/page$", [[:SLASH, "/"], [:LITERAL, "page$"]]], - ["/page&", [[:SLASH, "/"], [:LITERAL, "page&"]]], - ["/page'", [[:SLASH, "/"], [:LITERAL, "page'"]]], - ["/page*", [[:SLASH, "/"], [:LITERAL, "page*"]]], - ["/page+", [[:SLASH, "/"], [:LITERAL, "page+"]]], - ["/page,", [[:SLASH, "/"], [:LITERAL, "page,"]]], - ["/page;", [[:SLASH, "/"], [:LITERAL, "page;"]]], - ["/page=", [[:SLASH, "/"], [:LITERAL, "page="]]], - ["/page@", [[:SLASH, "/"], [:LITERAL, "page@"]]], - ['/page\:', [[:SLASH, "/"], [:LITERAL, "page:"]]], - ['/page\(', [[:SLASH, "/"], [:LITERAL, "page("]]], - ['/page\)', [[:SLASH, "/"], [:LITERAL, "page)"]]], - ["/~page", [[:SLASH, "/"], [:LITERAL, "~page"]]], - ["/pa-ge", [[:SLASH, "/"], [:LITERAL, "pa-ge"]]], - ["/:page", [[:SLASH, "/"], [:SYMBOL, ":page"]]], - ["/(:page)", [ - [:SLASH, "/"], - [:LPAREN, "("], - [:SYMBOL, ":page"], - [:RPAREN, ")"], - ]], - ["(/:action)", [ - [:LPAREN, "("], - [:SLASH, "/"], - [:SYMBOL, ":action"], - [:RPAREN, ")"], - ]], - ["(())", [[:LPAREN, "("], - [:LPAREN, "("], [:RPAREN, ")"], [:RPAREN, ")"]]], - ["(.:format)", [ - [:LPAREN, "("], - [:DOT, "."], - [:SYMBOL, ":format"], - [:RPAREN, ")"], + CASES = [ + ["/", [:SLASH]], + ["*omg", [:STAR]], + ["/page", [:SLASH, :LITERAL]], + ["/page!", [:SLASH, :LITERAL]], + ["/page$", [:SLASH, :LITERAL]], + ["/page&", [:SLASH, :LITERAL]], + ["/page'", [:SLASH, :LITERAL]], + ["/page*", [:SLASH, :LITERAL]], + ["/page+", [:SLASH, :LITERAL]], + ["/page,", [:SLASH, :LITERAL]], + ["/page;", [:SLASH, :LITERAL]], + ["/page=", [:SLASH, :LITERAL]], + ["/page@", [:SLASH, :LITERAL]], + ['/page\:', [:SLASH, :LITERAL]], + ['/page\(', [:SLASH, :LITERAL]], + ['/page\)', [:SLASH, :LITERAL]], + ["/~page", [:SLASH, :LITERAL]], + ["/pa-ge", [:SLASH, :LITERAL]], + ["/:page", [:SLASH, :SYMBOL]], + ["/:page|*foo", [ + :SLASH, + :SYMBOL, + :OR, + :STAR ]], - ].each do |str, expected| - @scanner.scan_setup str - assert_tokens expected, @scanner + ["/(:page)", [ + :SLASH, + :LPAREN, + :SYMBOL, + :RPAREN, + ]], + ["(/:action)", [ + :LPAREN, + :SLASH, + :SYMBOL, + :RPAREN, + ]], + ["(())", [ + :LPAREN, + :LPAREN, + :RPAREN, + :RPAREN, + ]], + ["(.:format)", [ + :LPAREN, + :DOT, + :SYMBOL, + :RPAREN, + ]], + ["/sort::sort", [ + :SLASH, + :LITERAL, + :LITERAL, + :SYMBOL + ]], + ] + + CASES.each do |pattern, expected_tokens| + test "Scanning `#{pattern}`" do + @scanner.scan_setup pattern + assert_tokens expected_tokens, @scanner, pattern end end - def assert_tokens(tokens, scanner) - toks = [] - while tok = scanner.next_token - toks << tok + private + def assert_tokens(expected_tokens, scanner, pattern) + actual_tokens = [] + while token = scanner.next_token + actual_tokens << token + end + assert_equal expected_tokens, actual_tokens, "Wrong tokens for `#{pattern}`" end - assert_equal tokens, toks - end end end end diff --git a/actionpack/test/journey/route_test.rb b/actionpack/test/journey/route_test.rb index 8fd73970b87fc..ffcbea30cc754 100644 --- a/actionpack/test/journey/route_test.rb +++ b/actionpack/test/journey/route_test.rb @@ -1,13 +1,18 @@ +# frozen_string_literal: true + require "abstract_unit" +require "support/path_helper" module ActionDispatch module Journey class TestRoute < ActiveSupport::TestCase + include PathHelper + def test_initialize app = Object.new - path = Path::Pattern.from_string "/:controller(/:action(/:id(.:format)))" + path = path_from_string "/:controller(/:action(/:id(.:format)))" defaults = {} - route = Route.build("name", app, path, {}, [], defaults) + route = Route.new(name: "name", app: app, path: path, defaults: defaults) assert_equal app, route.app assert_equal path, route.path @@ -15,10 +20,9 @@ def test_initialize end def test_route_adds_itself_as_memo - app = Object.new - path = Path::Pattern.from_string "/:controller(/:action(/:id(.:format)))" - defaults = {} - route = Route.build("name", app, path, {}, [], defaults) + app = Object.new + path = path_from_string "/:controller(/:action(/:id(.:format)))" + route = Route.new(name: "name", app: app, path: path) route.ast.grep(Nodes::Terminal).each do |node| assert_equal route, node.memo @@ -26,38 +30,39 @@ def test_route_adds_itself_as_memo end def test_path_requirements_override_defaults - path = Path::Pattern.build(":name", { name: /love/ }, "/", true) - defaults = { name: "tender" } - route = Route.build("name", nil, path, {}, [], defaults) + path = build_path(":name", { name: /love/ }, "/", true) + defaults = { name: "tender" } + route = Route.new(name: "name", path: path, defaults: defaults) assert_equal(/love/, route.requirements[:name]) end def test_ip_address - path = Path::Pattern.from_string "/messages/:id(.:format)" - route = Route.build("name", nil, path, { ip: "192.168.1.1" }, [], - controller: "foo", action: "bar") + path = path_from_string "/messages/:id(.:format)" + route = Route.new(name: "name", path: path, constraints: { ip: "192.168.1.1" }, + defaults: { controller: "foo", action: "bar" }) assert_equal "192.168.1.1", route.ip end def test_default_ip - path = Path::Pattern.from_string "/messages/:id(.:format)" - route = Route.build("name", nil, path, {}, [], - controller: "foo", action: "bar") + path = path_from_string "/messages/:id(.:format)" + route = Route.new(name: "name", path: path, + defaults: { controller: "foo", action: "bar" }) assert_equal(//, route.ip) end def test_format_with_star - path = Path::Pattern.from_string "/:controller/*extra" - route = Route.build("name", nil, path, {}, [], - controller: "foo", action: "bar") + path = path_from_string "/:controller/*extra" + route = Route.new(name: "name", path: path, + defaults: { controller: "foo", action: "bar" }) assert_equal "/foo/himom", route.format( controller: "foo", extra: "himom") end def test_connects_all_match - path = Path::Pattern.from_string "/:controller(/:action(/:id(.:format)))" - route = Route.build("name", nil, path, { action: "bar" }, [], controller: "foo") + path = path_from_string "/:controller(/:action(/:id(.:format)))" + route = Route.new(name: "name", path: path, constraints: { action: "bar" }, + defaults: { controller: "foo" }) assert_equal "/foo/bar/10", route.format( controller: "foo", @@ -66,35 +71,34 @@ def test_connects_all_match end def test_extras_are_not_included_if_optional - path = Path::Pattern.from_string "/page/:id(/:action)" - route = Route.build("name", nil, path, {}, [], action: "show") + path = path_from_string "/page/:id(/:action)" + route = Route.new(name: "name", path: path, defaults: { action: "show" }) assert_equal "/page/10", route.format(id: 10) end def test_extras_are_not_included_if_optional_with_parameter - path = Path::Pattern.from_string "(/sections/:section)/pages/:id" - route = Route.build("name", nil, path, {}, [], action: "show") + path = path_from_string "(/sections/:section)/pages/:id" + route = Route.new(name: "name", path: path, defaults: { action: "show" }) assert_equal "/pages/10", route.format(id: 10) end def test_extras_are_not_included_if_optional_parameter_is_nil - path = Path::Pattern.from_string "(/sections/:section)/pages/:id" - route = Route.build("name", nil, path, {}, [], action: "show") + path = path_from_string "(/sections/:section)/pages/:id" + route = Route.new(name: "name", path: path, defaults: { action: "show" }) assert_equal "/pages/10", route.format(id: 10, section: nil) end def test_score - constraints = {} defaults = { controller: "pages", action: "show" } - path = Path::Pattern.from_string "/page/:id(/:action)(.:format)" - specific = Route.build "name", nil, path, constraints, [:controller, :action], defaults + path = path_from_string "/page/:id(/:action)(.:format)" + specific = Route.new name: "name", path: path, required_defaults: [:controller, :action], defaults: defaults - path = Path::Pattern.from_string "/:controller(/:action(/:id))(.:format)" - generic = Route.build "name", nil, path, constraints, [], {} + path = path_from_string "/:controller(/:action(/:id))(.:format)" + generic = Route.new name: "name", path: path knowledge = { "id" => true, "controller" => true, "action" => true } diff --git a/actionpack/test/journey/router/utils_test.rb b/actionpack/test/journey/router/utils_test.rb index b77bf6628af56..6a77c2f8b7617 100644 --- a/actionpack/test/journey/router/utils_test.rb +++ b/actionpack/test/journey/router/utils_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch @@ -16,12 +18,12 @@ def test_fragment_escape assert_equal "a/b%20c+d%25?e", Utils.escape_fragment("a/b c+d%?e") end - def test_uri_unescape - assert_equal "a/b c+d", Utils.unescape_uri("a%2Fb%20c+d") + def test_CGI_unescapeURIComponent + assert_equal "a/b c+d", CGI.unescapeURIComponent("a%2Fb%20c+d") end - def test_uri_unescape_with_utf8_string - assert_equal "Å aÅ¡inková", Utils.unescape_uri("%C5%A0a%C5%A1inkov%C3%A1".force_encoding(Encoding::US_ASCII)) + def test_CGI_unescapeURIComponent_with_utf8_string + assert_equal "Å aÅ¡inková", CGI.unescapeURIComponent((+"%C5%A0a%C5%A1inkov%C3%A1").force_encoding(Encoding::US_ASCII)) end def test_normalize_path_not_greedy @@ -31,6 +33,15 @@ def test_normalize_path_not_greedy def test_normalize_path_uppercase assert_equal "/foo%AAbar%AAbaz", Utils.normalize_path("/foo%aabar%aabaz") end + + def test_normalize_path_maintains_string_encoding + path = "/foo%AAbar%AAbaz".b + assert_equal Encoding::BINARY, Utils.normalize_path(path).encoding + end + + def test_normalize_path_with_nil + assert_equal "/", Utils.normalize_path(nil) + end end end end diff --git a/actionpack/test/journey/router_test.rb b/actionpack/test/journey/router_test.rb index f223a125a3129..cbe335cdbe7c3 100644 --- a/actionpack/test/journey/router_test.rb +++ b/actionpack/test/journey/router_test.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "abstract_unit" +require "rack/utils" module ActionDispatch module Journey @@ -28,7 +31,7 @@ def test_dashes def test_unicode get "/ã»ã’", to: "foo#bar" - #match the escaped version of /ã»ã’ + # match the escaped version of /ã»ã’ env = rails_env "PATH_INFO" => "/%E3%81%BB%E3%81%92" called = false router.recognize(env) do |r, params| @@ -38,7 +41,7 @@ def test_unicode end def test_regexp_first_precedence - get "/whois/:domain", domain: /\w+\.[\w\.]+/, to: "foo#bar" + get "/whois/:domain", domain: /\w+\.[\w.]+/, to: "foo#bar" get "/whois/:id(.:format)", to: "foo#baz" env = rails_env "PATH_INFO" => "/whois/example.com" @@ -58,31 +61,31 @@ def test_required_parts_verified_are_anchored get "/foo/:id", id: /\d/, anchor: false, to: "foo#bar" assert_raises(ActionController::UrlGenerationError) do - @formatter.generate(nil, { controller: "foo", action: "bar", id: "10" }, {}) + @route_set.url_for({ controller: "foo", action: "bar", id: "10" }, nil) end end def test_required_parts_are_verified_when_building get "/foo/:id", id: /\d+/, anchor: false, to: "foo#bar" - path, _ = @formatter.generate(nil, { controller: "foo", action: "bar", id: "10" }, {}) + path, _ = _generate(nil, { controller: "foo", action: "bar", id: "10" }, {}) assert_equal "/foo/10", path assert_raises(ActionController::UrlGenerationError) do - @formatter.generate(nil, { id: "aa" }, {}) + _generate(nil, { id: "aa" }, {}) end end def test_only_required_parts_are_verified get "/foo(/:id)", id: /\d/, to: "foo#bar" - path, _ = @formatter.generate(nil, { controller: "foo", action: "bar", id: "10" }, {}) + path, _ = _generate(nil, { controller: "foo", action: "bar", id: "10" }, {}) assert_equal "/foo/10", path - path, _ = @formatter.generate(nil, { controller: "foo", action: "bar" }, {}) + path, _ = _generate(nil, { controller: "foo", action: "bar" }, {}) assert_equal "/foo", path - path, _ = @formatter.generate(nil, { controller: "foo", action: "bar", id: "aa" }, {}) + path, _ = _generate(nil, { controller: "foo", action: "bar", id: "aa" }, {}) assert_equal "/foo/aa", path end @@ -91,7 +94,7 @@ def test_knows_what_parts_are_missing_from_named_route get "/foo/:id", as: route_name, id: /\d+/, to: "foo#bar" error = assert_raises(ActionController::UrlGenerationError) do - @formatter.generate(route_name, {}, {}) + _generate(route_name, {}, {}) end assert_match(/missing required keys: \[:id\]/, error.message) @@ -101,17 +104,17 @@ def test_does_not_include_missing_keys_message route_name = "gorby_thunderhorse" error = assert_raises(ActionController::UrlGenerationError) do - @formatter.generate(route_name, {}, {}) + _generate(route_name, {}, {}) end assert_no_match(/missing required keys: \[\]/, error.message) end - def test_X_Cascade + def test_x_cascade get "/messages(.:format)", to: "foo#bar" resp = router.serve(rails_env("REQUEST_METHOD" => "GET", "PATH_INFO" => "/lol")) assert_equal ["Not Found"], resp.last - assert_equal "pass", resp[1]["X-Cascade"] + assert_equal "pass", resp[1][Constants::X_CASCADE] assert_equal 404, resp.first end @@ -144,10 +147,16 @@ def test_recognize_with_unbound_regexp env = rails_env "PATH_INFO" => "/foo/bar" - router.recognize(env) { |*_| } + recognized = false + + router.recognize(env) do |*_| + assert_equal "/foo", env.env["SCRIPT_NAME"] + assert_equal "/bar", env.env["PATH_INFO"] + + recognized = true + end - assert_equal "/foo", env.env["SCRIPT_NAME"] - assert_equal "/bar", env.env["PATH_INFO"] + assert recognized end def test_bound_regexp_keeps_path_info @@ -184,14 +193,14 @@ def test_path_not_found def test_required_part_in_recall get "/messages/:a/:b", to: "foo#bar" - path, _ = @formatter.generate(nil, { controller: "foo", action: "bar", a: "a" }, b: "b") + path, _ = _generate(nil, { controller: "foo", action: "bar", a: "a" }, { b: "b" }) assert_equal "/messages/a/b", path end def test_splat_in_recall get "/*path", to: "foo#bar" - path, _ = @formatter.generate(nil, { controller: "foo", action: "bar" }, path: "b") + path, _ = _generate(nil, { controller: "foo", action: "bar" }, { path: "b" }) assert_equal "/b", path end @@ -199,7 +208,7 @@ def test_recall_should_be_used_when_scoring get "/messages/:action(/:id(.:format))", to: "foo#bar" get "/messages/:id(.:format)", to: "bar#baz" - path, _ = @formatter.generate(nil, { controller: "foo", id: 10 }, action: "index") + path, _ = _generate(nil, { controller: "foo", id: 10 }, { action: "index" }) assert_equal "/messages/index/10", path end @@ -209,48 +218,32 @@ def test_nil_path_parts_are_ignored params = { controller: "tasks", format: nil } extras = { action: "lol" } - path, _ = @formatter.generate(nil, params, extras) - assert_equal "/tasks", path + path, _ = _generate(nil, params, extras) + assert_equal "/tasks/index", path end def test_generate_slash params = [ [:controller, "tasks"], [:action, "show"] ] - get "/", Hash[params] + get "/", **Hash[params] - path, _ = @formatter.generate(nil, Hash[params], {}) + path, _ = _generate(nil, Hash[params], {}) assert_equal "/", path end - def test_generate_calls_param_proc - get "/:controller(/:action)", to: "foo#bar" - - parameterized = [] - params = [ [:controller, "tasks"], - [:action, "show"] ] - - @formatter.generate( - nil, - Hash[params], - {}, - lambda { |k, v| parameterized << [k, v]; v }) - - assert_equal params.map(&:to_s).sort, parameterized.map(&:to_s).sort - end - def test_generate_id get "/:controller(/:action)", to: "foo#bar" - path, params = @formatter.generate( + path, params = _generate( nil, { id: 1, controller: "tasks", action: "show" }, {}) assert_equal "/tasks/show", path - assert_equal({ id: 1 }, params) + assert_equal({ id: "1" }, params) end def test_generate_escapes get "/:controller(/:action)", to: "foo#bar" - path, _ = @formatter.generate(nil, + path, _ = _generate(nil, { controller: "tasks", action: "a/b c+d", }, {}) @@ -260,7 +253,7 @@ def test_generate_escapes def test_generate_escapes_with_namespaced_controller get "/:controller(/:action)", to: "foo#bar" - path, _ = @formatter.generate( + path, _ = _generate( nil, { controller: "admin/tasks", action: "a/b c+d", }, {}) @@ -270,19 +263,19 @@ def test_generate_escapes_with_namespaced_controller def test_generate_extra_params get "/:controller(/:action)", to: "foo#bar" - path, params = @formatter.generate( + path, params = _generate( nil, { id: 1, controller: "tasks", action: "show", relative_url_root: nil }, {}) assert_equal "/tasks/show", path - assert_equal({ id: 1, relative_url_root: nil }, params) + assert_equal({ id: "1" }, params) end def test_generate_missing_keys_no_matches_different_format_keys get "/:controller/:action/:name", to: "foo#bar" - primarty_parameters = { + primary_parameters = { id: 1, controller: "tasks", action: "show", @@ -295,12 +288,12 @@ def test_generate_missing_keys_no_matches_different_format_keys missing_parameters = { missing_key => "task_1" } - request_parameters = primarty_parameters.merge(redirection_parameters).merge(missing_parameters) + request_parameters = primary_parameters.merge(redirection_parameters).merge(missing_parameters) - message = "No route matches #{Hash[request_parameters.sort_by { |k, v|k.to_s }].inspect}, missing required keys: #{[missing_key.to_sym].inspect}" + message = "No route matches #{Hash[request_parameters.sort_by { |k, _|k.to_s }].inspect}, missing required keys: #{[missing_key.to_sym].inspect}" error = assert_raises(ActionController::UrlGenerationError) do - @formatter.generate( + _generate( nil, request_parameters, request_parameters) end assert_equal message, error.message @@ -309,10 +302,10 @@ def test_generate_missing_keys_no_matches_different_format_keys def test_generate_uses_recall_if_needed get "/:controller(/:action(/:id))", to: "foo#bar" - path, params = @formatter.generate( + path, params = _generate( nil, { controller: "tasks", id: 10 }, - action: "index") + { action: "index" }) assert_equal "/tasks/index/10", path assert_equal({}, params) end @@ -320,11 +313,11 @@ def test_generate_uses_recall_if_needed def test_generate_with_name get "/:controller(/:action)", to: "foo#bar", as: "tasks" - path, params = @formatter.generate( + path, params = _generate( "tasks", { controller: "tasks" }, - controller: "tasks", action: "index") - assert_equal "/tasks", path + { controller: "tasks", action: "index" }) + assert_equal "/tasks/index", path assert_equal({}, params) end @@ -491,17 +484,35 @@ def test_multi_verb_recognition assert_not called end + def test_eager_load_with_routes + get "/foo-bar", to: "foo#bar" + assert_nil router.eager_load! + end + + def test_eager_load_without_routes + assert_nil router.eager_load! + end + private + def _generate(route_name, options, recall) + if recall + options = options.merge(_recall: recall) + end + path = @route_set.path_for(options, route_name) + uri = URI.parse path + params = ActionDispatch::ParamBuilder.from_query_string(uri.query).symbolize_keys + [uri.path, params] + end - def get(*args) - ActiveSupport::Deprecation.silence do - mapper.get(*args) + def get(...) + ActionDispatch.deprecator.silence do + mapper.get(...) end end - def match(*args) - ActiveSupport::Deprecation.silence do - mapper.match(*args) + def match(...) + ActionDispatch.deprecator.silence do + mapper.match(...) end end diff --git a/actionpack/test/journey/routes_test.rb b/actionpack/test/journey/routes_test.rb index d8db5ffad1152..1b23d72849b76 100644 --- a/actionpack/test/journey/routes_test.rb +++ b/actionpack/test/journey/routes_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch @@ -15,11 +17,11 @@ def setup def test_clear mapper.get "/foo(/:id)", to: "foo#bar", as: "aaron" - assert_not_predicate routes, :empty? + assert_not_empty routes assert_equal 1, routes.length routes.clear - assert routes.empty? + assert_empty routes assert_equal 0, routes.length end @@ -41,14 +43,26 @@ def test_partition_route mapper.get "/foo(/:id)", to: "foo#bar", as: "aaron" assert_equal 1, @routes.anchored_routes.length - assert_predicate @routes.custom_routes, :empty? + assert_empty @routes.custom_routes - mapper.get "/hello/:who", to: "foo#bar", as: "bar", who: /\d/ + mapper.get "/not_anchored/hello/:who-notanchored", to: "foo#bar", as: "bar", who: /\d/, anchor: false assert_equal 1, @routes.custom_routes.length assert_equal 1, @routes.anchored_routes.length end + def test_custom_anchored_not_partition_route + mapper.get "/foo/:bar", to: "foo#bar", as: "aaron" + + assert_equal 1, @routes.anchored_routes.length + assert_empty @routes.custom_routes + + mapper.get "/:user/:repo", to: "foo#bar", as: "bar", repo: /[\w.]+/ + + assert_equal 2, @routes.anchored_routes.length + assert_empty @routes.custom_routes + end + def test_first_name_wins mapper.get "/hello", to: "foo#bar", as: "aaron" assert_raise(ArgumentError) do diff --git a/actionpack/test/lib/controller/fake_controllers.rb b/actionpack/test/lib/controller/fake_controllers.rb index 1a2863b6891c4..e985716f43ba4 100644 --- a/actionpack/test/lib/controller/fake_controllers.rb +++ b/actionpack/test/lib/controller/fake_controllers.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ContentController < ActionController::Base; end module Admin diff --git a/actionpack/test/lib/controller/fake_models.rb b/actionpack/test/lib/controller/fake_models.rb index b768553e7a93a..01c7ec26ae949 100644 --- a/actionpack/test/lib/controller/fake_models.rb +++ b/actionpack/test/lib/controller/fake_models.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_model" Customer = Struct.new(:name, :id) do @@ -26,6 +28,10 @@ def errors def persisted? id.present? end + + def cache_key + "#{name}/#{id}" + end end Post = Struct.new(:title, :author_name, :body, :secret, :persisted, :written_on, :cost) do diff --git a/actionpack/test/lib/test_renderable.rb b/actionpack/test/lib/test_renderable.rb new file mode 100644 index 0000000000000..46e2f8983c5c2 --- /dev/null +++ b/actionpack/test/lib/test_renderable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class TestRenderable + def render_in(_view_context) + "Hello, World!" + end + + def format + :html + end +end diff --git a/actionpack/test/routing/helper_test.rb b/actionpack/test/routing/helper_test.rb index 0debacedf7161..d13b043b0bead 100644 --- a/actionpack/test/routing/helper_test.rb +++ b/actionpack/test/routing/helper_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "abstract_unit" module ActionDispatch diff --git a/actionpack/test/support/etag_helper.rb b/actionpack/test/support/etag_helper.rb new file mode 100644 index 0000000000000..620bd4ad0d850 --- /dev/null +++ b/actionpack/test/support/etag_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module EtagHelper + def weak_etag(record) + "W/#{strong_etag record}" + end + + def strong_etag(record) + %("#{ActiveSupport::Digest.hexdigest(ActiveSupport::Cache.expand_cache_key(record))}") + end +end diff --git a/actionpack/test/support/path_helper.rb b/actionpack/test/support/path_helper.rb new file mode 100644 index 0000000000000..df615821381f8 --- /dev/null +++ b/actionpack/test/support/path_helper.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ActionDispatch + module Journey + module PathHelper + def path_from_string(string) + build_path(string, {}, "/.?", true) + end + + def build_path(path, requirements, separators, anchored, formatted = true) + parser = ActionDispatch::Journey::Parser.new + ast = parser.parse path + ast = Journey::Ast.new(ast, formatted) + ActionDispatch::Journey::Path::Pattern.new( + ast, + requirements, + separators, + anchored + ) + end + end + end +end diff --git a/actionpack/test/support/rack_parsing_override.rb b/actionpack/test/support/rack_parsing_override.rb new file mode 100644 index 0000000000000..982065df4f0db --- /dev/null +++ b/actionpack/test/support/rack_parsing_override.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Because we implement our own query string parsing, and it is extremely +# similar to Rack's in most simple cases, it would be very easy to have +# locations sneak in where we unknowingly depend on Rack's +# interpretation rather than our own, potentially creating edge-case +# incompatibilities. +# +# To counter that, we monkey-patch Rack in our tests to allow-list the +# call sites that are known to be safe & appropriate. +# +# This file may need to change in response to future Rack changes. If +# you're here because you're adding new code/tests to Rails, though, you +# probably need to work out how to ensure you're using the Rails query +# parser instead. +module RackParsingOverride + UnexpectedCall = Class.new(Exception) + + # The only expected calls to Rack::QueryParser#parse_nested_query are + # from Rack::Request#GET/POST, which are separately protected below. + module ParserPatch + def parse_nested_query(*) + unless caller_locations.any? { |loc| loc.path == __FILE__ && (loc.lineno == RackParsingOverride::GET_LINE || loc.lineno == RackParsingOverride::POST_LINE) } + raise UnexpectedCall, "Unexpected call to Rack::QueryParser#parse_nested_query" + end + super + end + end + + # This is where we do the real checking, because we need to catch + # every caller that might _use_ the cached result of Rack's parsing, + # not just the first call site where parsing gets triggered. + module RequestPatch + # Single list of permitted callers -- we don't care about GET vs POST + def self.permitted_caller? + caller_locations.any? do |loc| + # Our parser calls Rack's to prepopulate caches + loc.path.end_with?("lib/action_dispatch/http/request.rb") && loc.base_label == "request_parameters_list" || + # and as a fallback for older Rack versions + loc.path.end_with?("lib/action_dispatch/http/request.rb") && loc.base_label == "fallback_request_parameters" || + # This specifically tests that a "pure" Rack middleware + # doesn't interfere with our parsing + (loc.path.end_with?("test/dispatch/request/query_string_parsing_test.rb") && loc.base_label == "populate_rack_cache") || + # Rack::MethodOverride obviously uses Rack's parsing, and + # that's fine: it's looking for a simple top-level key. + # Checking for a specific internal method is fragile, but we + # don't want to ignore any app that happens to have + # MethodOverride on its call stack! + (loc.path.end_with?("lib/rack/method_override.rb") && loc.base_label == "method_override_param") + end + end + + def params + unless RequestPatch.permitted_caller? + raise UnexpectedCall, "Unexpected call to Rack::Request#params" + end + super + end + ::RackParsingOverride::PARAMS_LINE = __LINE__ - 2 + + def GET + unless RequestPatch.permitted_caller? + raise UnexpectedCall, "Unexpected call to Rack::Request#GET" + end + super + end + ::RackParsingOverride::GET_LINE = __LINE__ - 2 + + def POST + unless RequestPatch.permitted_caller? + raise UnexpectedCall, "Unexpected call to Rack::Request#POST" + end + super + end + ::RackParsingOverride::POST_LINE = __LINE__ - 2 + end + + Rack::QueryParser.class_eval do + # Being careful here, as this is more internal + unless method_defined?(:parse_nested_query) + raise "Rack changed? Can't patch absent Rack::QueryParser#parse_nested_query" + end + prepend ParserPatch + end + + Rack::Request.prepend RequestPatch +end 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/.gitignore b/actiontext/.gitignore new file mode 100644 index 0000000000000..1a3527fdd7ae4 --- /dev/null +++ b/actiontext/.gitignore @@ -0,0 +1,8 @@ +/test/dummy/storage/*.sqlite3 +/test/dummy/storage/*.sqlite3-* +/test/dummy/db/*.sqlite3 +/test/dummy/db/*.sqlite3-* +/test/dummy/log/*.log +/test/dummy/public/packs-test +/test/dummy/tmp/ +/tmp/ diff --git a/actiontext/CHANGELOG.md b/actiontext/CHANGELOG.md new file mode 100644 index 0000000000000..99290fd5856f4 --- /dev/null +++ b/actiontext/CHANGELOG.md @@ -0,0 +1,21 @@ +* 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* + +* 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/MIT-LICENSE b/actiontext/MIT-LICENSE new file mode 100644 index 0000000000000..18b341857b9f9 --- /dev/null +++ b/actiontext/MIT-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 37signals LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/actiontext/README.md b/actiontext/README.md new file mode 100644 index 0000000000000..20d2d3e8255ec --- /dev/null +++ b/actiontext/README.md @@ -0,0 +1,13 @@ +# Action Text + +Action Text brings rich text content and editing to \Rails. It includes the [Trix editor](https://trix-editor.org) that handles everything from formatting to links to quotes to lists to embedded images and galleries. The rich text content generated by the Trix editor is saved in its own RichText model that's associated with any existing Active Record model in the application. Any embedded images (or other attachments) are automatically stored using Active Storage and associated with the included RichText model. + +You can read more about Action Text in the [Action Text Overview](https://guides.rubyonrails.org/action_text_overview.html) guide. + +## Development + +The JavaScript for Action Text is distributed both as a npm module under @rails/actiontext and via the asset pipeline as actiontext.js (and we mirror Trix as trix.js). To ensure that the latter remains in sync, you must run `yarn build` and checkin the artifacts whenever the JavaScript source or the Trix dependency is bumped. CSS changes must be brought over manually to app/assets/stylesheets/trix.css + +## License + +Action Text is released under the [MIT License](https://opensource.org/licenses/MIT). diff --git a/actiontext/Rakefile b/actiontext/Rakefile new file mode 100644 index 0000000000000..1964853ac9bb3 --- /dev/null +++ b/actiontext/Rakefile @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "bundler/gem_tasks" +require "rake/testtask" + +ENV["RAILS_MINITEST_PLUGIN"] = "true" + +Rake::TestTask.new do |t| + t.libs << "test" + t.test_files = FileList["test/**/*_test.rb"].exclude("test/system/**/*", "test/dummy/**/*") + t.verbose = true + t.options = "--profile" if ENV["CI"] +end + +Rake::TestTask.new "test:system" do |t| + t.libs << "test" + t.test_files = FileList["test/system/**/*_test.rb"] + t.verbose = true + t.options = "--profile" if ENV["CI"] +end + +namespace :test 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 + + task :railties do + ["action_text/engine"].all? do |railtie| + sh(Gem.ruby, "-r", railtie, "-e", "'OK'") + end || raise("Failures") + end +end + +task default: :test diff --git a/actiontext/actiontext.gemspec b/actiontext/actiontext.gemspec new file mode 100644 index 0000000000000..e351bba47fd17 --- /dev/null +++ b/actiontext/actiontext.gemspec @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip + +Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = "actiontext" + s.version = version + s.summary = "Rich text framework." + s.description = "Edit and display rich text in Rails applications." + + s.required_ruby_version = ">= 3.2.0" + + s.license = "MIT" + + s.authors = ["Javan Makhmali", "Sam Stephenson", "David Heinemeier Hansson"] + s.email = ["javan@javan.us", "sstephenson@gmail.com", "david@loudthinking.com"] + s.homepage = "https://rubyonrails.org" + + s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.md", "lib/**/*", "app/**/*", "config/**/*", "db/**/*", "package.json"] + s.require_path = "lib" + + s.metadata = { + "bug_tracker_uri" => "https://github.com/rails/rails/issues", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actiontext/CHANGELOG.md", + "documentation_uri" => "https://api.rubyonrails.org/v#{version}/", + "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actiontext", + "rubygems_mfa_required" => "true", + } + + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + + s.add_dependency "activesupport", version + s.add_dependency "activerecord", version + s.add_dependency "activestorage", version + s.add_dependency "actionpack", version + + 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 new file mode 100644 index 0000000000000..1f28b2bca67c9 --- /dev/null +++ b/actiontext/app/assets/javascripts/.gitattributes @@ -0,0 +1,2 @@ +actiontext.js linguist-generated +actiontext.esm.js linguist-generated diff --git a/actiontext/app/assets/javascripts/actiontext.esm.js b/actiontext/app/assets/javascripts/actiontext.esm.js new file mode 100644 index 0000000000000..281013756ceaf --- /dev/null +++ b/actiontext/app/assets/javascripts/actiontext.esm.js @@ -0,0 +1,989 @@ +var sparkMd5 = { + exports: {} +}; + +(function(module, exports) { + (function(factory) { + { + module.exports = factory(); + } + })((function(undefined$1) { + var hex_chr = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f" ]; + function md5cycle(x, k) { + var a = x[0], b = x[1], c = x[2], d = x[3]; + a += (b & c | ~b & d) + k[0] - 680876936 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[1] - 389564586 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[2] + 606105819 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[3] - 1044525330 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & c | ~b & d) + k[4] - 176418897 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[5] + 1200080426 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[6] - 1473231341 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[7] - 45705983 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & c | ~b & d) + k[8] + 1770035416 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[9] - 1958414417 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[10] - 42063 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[11] - 1990404162 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & c | ~b & d) + k[12] + 1804603682 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[13] - 40341101 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[14] - 1502002290 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[15] + 1236535329 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & d | c & ~d) + k[1] - 165796510 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[6] - 1069501632 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[11] + 643717713 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[0] - 373897302 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b & d | c & ~d) + k[5] - 701558691 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[10] + 38016083 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[15] - 660478335 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[4] - 405537848 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b & d | c & ~d) + k[9] + 568446438 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[14] - 1019803690 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[3] - 187363961 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[8] + 1163531501 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b & d | c & ~d) + k[13] - 1444681467 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[2] - 51403784 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[7] + 1735328473 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[12] - 1926607734 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b ^ c ^ d) + k[5] - 378558 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[8] - 2022574463 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[11] + 1839030562 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[14] - 35309556 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (b ^ c ^ d) + k[1] - 1530992060 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[4] + 1272893353 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[7] - 155497632 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[10] - 1094730640 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (b ^ c ^ d) + k[13] + 681279174 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[0] - 358537222 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[3] - 722521979 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[6] + 76029189 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (b ^ c ^ d) + k[9] - 640364487 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[12] - 421815835 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[15] + 530742520 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[2] - 995338651 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (c ^ (b | ~d)) + k[0] - 198630844 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[7] + 1126891415 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[14] - 1416354905 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[5] - 57434055 | 0; + b = (b << 21 | b >>> 11) + c | 0; + a += (c ^ (b | ~d)) + k[12] + 1700485571 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[3] - 1894986606 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[10] - 1051523 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[1] - 2054922799 | 0; + b = (b << 21 | b >>> 11) + c | 0; + a += (c ^ (b | ~d)) + k[8] + 1873313359 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[15] - 30611744 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[6] - 1560198380 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[13] + 1309151649 | 0; + b = (b << 21 | b >>> 11) + c | 0; + a += (c ^ (b | ~d)) + k[4] - 145523070 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[11] - 1120210379 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[2] + 718787259 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[9] - 343485551 | 0; + b = (b << 21 | b >>> 11) + c | 0; + x[0] = a + x[0] | 0; + x[1] = b + x[1] | 0; + x[2] = c + x[2] | 0; + x[3] = d + x[3] | 0; + } + function md5blk(s) { + var md5blks = [], i; + for (i = 0; i < 64; i += 4) { + md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) + (s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24); + } + return md5blks; + } + function md5blk_array(a) { + var md5blks = [], i; + for (i = 0; i < 64; i += 4) { + md5blks[i >> 2] = a[i] + (a[i + 1] << 8) + (a[i + 2] << 16) + (a[i + 3] << 24); + } + return md5blks; + } + function md51(s) { + var n = s.length, state = [ 1732584193, -271733879, -1732584194, 271733878 ], i, length, tail, tmp, lo, hi; + for (i = 64; i <= n; i += 64) { + md5cycle(state, md5blk(s.substring(i - 64, i))); + } + s = s.substring(i - 64); + length = s.length; + tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3); + } + tail[i >> 2] |= 128 << (i % 4 << 3); + if (i > 55) { + md5cycle(state, tail); + for (i = 0; i < 16; i += 1) { + tail[i] = 0; + } + } + tmp = n * 8; + tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/); + lo = parseInt(tmp[2], 16); + hi = parseInt(tmp[1], 16) || 0; + tail[14] = lo; + tail[15] = hi; + md5cycle(state, tail); + return state; + } + function md51_array(a) { + var n = a.length, state = [ 1732584193, -271733879, -1732584194, 271733878 ], i, length, tail, tmp, lo, hi; + for (i = 64; i <= n; i += 64) { + md5cycle(state, md5blk_array(a.subarray(i - 64, i))); + } + a = i - 64 < n ? a.subarray(i - 64) : new Uint8Array(0); + length = a.length; + tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= a[i] << (i % 4 << 3); + } + tail[i >> 2] |= 128 << (i % 4 << 3); + if (i > 55) { + md5cycle(state, tail); + for (i = 0; i < 16; i += 1) { + tail[i] = 0; + } + } + tmp = n * 8; + tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/); + lo = parseInt(tmp[2], 16); + hi = parseInt(tmp[1], 16) || 0; + tail[14] = lo; + tail[15] = hi; + md5cycle(state, tail); + return state; + } + function rhex(n) { + var s = "", j; + for (j = 0; j < 4; j += 1) { + s += hex_chr[n >> j * 8 + 4 & 15] + hex_chr[n >> j * 8 & 15]; + } + return s; + } + function hex(x) { + var i; + for (i = 0; i < x.length; i += 1) { + x[i] = rhex(x[i]); + } + return x.join(""); + } + if (hex(md51("hello")) !== "5d41402abc4b2a76b9719d911017c592") ; + if (typeof ArrayBuffer !== "undefined" && !ArrayBuffer.prototype.slice) { + (function() { + function clamp(val, length) { + val = val | 0 || 0; + if (val < 0) { + return Math.max(val + length, 0); + } + return Math.min(val, length); + } + ArrayBuffer.prototype.slice = function(from, to) { + var length = this.byteLength, begin = clamp(from, length), end = length, num, target, targetArray, sourceArray; + if (to !== undefined$1) { + end = clamp(to, length); + } + if (begin > end) { + return new ArrayBuffer(0); + } + num = end - begin; + target = new ArrayBuffer(num); + targetArray = new Uint8Array(target); + sourceArray = new Uint8Array(this, begin, num); + targetArray.set(sourceArray); + return target; + }; + })(); + } + function toUtf8(str) { + if (/[\u0080-\uFFFF]/.test(str)) { + str = unescape(encodeURIComponent(str)); + } + return str; + } + function utf8Str2ArrayBuffer(str, returnUInt8Array) { + var length = str.length, buff = new ArrayBuffer(length), arr = new Uint8Array(buff), i; + for (i = 0; i < length; i += 1) { + arr[i] = str.charCodeAt(i); + } + return returnUInt8Array ? arr : buff; + } + function arrayBuffer2Utf8Str(buff) { + return String.fromCharCode.apply(null, new Uint8Array(buff)); + } + function concatenateArrayBuffers(first, second, returnUInt8Array) { + var result = new Uint8Array(first.byteLength + second.byteLength); + result.set(new Uint8Array(first)); + result.set(new Uint8Array(second), first.byteLength); + return returnUInt8Array ? result : result.buffer; + } + function hexToBinaryString(hex) { + var bytes = [], length = hex.length, x; + for (x = 0; x < length - 1; x += 2) { + bytes.push(parseInt(hex.substr(x, 2), 16)); + } + return String.fromCharCode.apply(String, bytes); + } + function SparkMD5() { + this.reset(); + } + SparkMD5.prototype.append = function(str) { + this.appendBinary(toUtf8(str)); + return this; + }; + SparkMD5.prototype.appendBinary = function(contents) { + this._buff += contents; + this._length += contents.length; + var length = this._buff.length, i; + for (i = 64; i <= length; i += 64) { + md5cycle(this._hash, md5blk(this._buff.substring(i - 64, i))); + } + this._buff = this._buff.substring(i - 64); + return this; + }; + SparkMD5.prototype.end = function(raw) { + var buff = this._buff, length = buff.length, i, tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], ret; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= buff.charCodeAt(i) << (i % 4 << 3); + } + this._finish(tail, length); + ret = hex(this._hash); + if (raw) { + ret = hexToBinaryString(ret); + } + this.reset(); + return ret; + }; + SparkMD5.prototype.reset = function() { + this._buff = ""; + this._length = 0; + this._hash = [ 1732584193, -271733879, -1732584194, 271733878 ]; + return this; + }; + SparkMD5.prototype.getState = function() { + return { + buff: this._buff, + length: this._length, + hash: this._hash.slice() + }; + }; + SparkMD5.prototype.setState = function(state) { + this._buff = state.buff; + this._length = state.length; + this._hash = state.hash; + return this; + }; + SparkMD5.prototype.destroy = function() { + delete this._hash; + delete this._buff; + delete this._length; + }; + SparkMD5.prototype._finish = function(tail, length) { + var i = length, tmp, lo, hi; + tail[i >> 2] |= 128 << (i % 4 << 3); + if (i > 55) { + md5cycle(this._hash, tail); + for (i = 0; i < 16; i += 1) { + tail[i] = 0; + } + } + tmp = this._length * 8; + tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/); + lo = parseInt(tmp[2], 16); + hi = parseInt(tmp[1], 16) || 0; + tail[14] = lo; + tail[15] = hi; + md5cycle(this._hash, tail); + }; + SparkMD5.hash = function(str, raw) { + return SparkMD5.hashBinary(toUtf8(str), raw); + }; + SparkMD5.hashBinary = function(content, raw) { + var hash = md51(content), ret = hex(hash); + return raw ? hexToBinaryString(ret) : ret; + }; + SparkMD5.ArrayBuffer = function() { + this.reset(); + }; + SparkMD5.ArrayBuffer.prototype.append = function(arr) { + var buff = concatenateArrayBuffers(this._buff.buffer, arr, true), length = buff.length, i; + this._length += arr.byteLength; + for (i = 64; i <= length; i += 64) { + md5cycle(this._hash, md5blk_array(buff.subarray(i - 64, i))); + } + this._buff = i - 64 < length ? new Uint8Array(buff.buffer.slice(i - 64)) : new Uint8Array(0); + return this; + }; + SparkMD5.ArrayBuffer.prototype.end = function(raw) { + var buff = this._buff, length = buff.length, tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], i, ret; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= buff[i] << (i % 4 << 3); + } + this._finish(tail, length); + ret = hex(this._hash); + if (raw) { + ret = hexToBinaryString(ret); + } + this.reset(); + return ret; + }; + SparkMD5.ArrayBuffer.prototype.reset = function() { + this._buff = new Uint8Array(0); + this._length = 0; + this._hash = [ 1732584193, -271733879, -1732584194, 271733878 ]; + return this; + }; + SparkMD5.ArrayBuffer.prototype.getState = function() { + var state = SparkMD5.prototype.getState.call(this); + state.buff = arrayBuffer2Utf8Str(state.buff); + return state; + }; + SparkMD5.ArrayBuffer.prototype.setState = function(state) { + state.buff = utf8Str2ArrayBuffer(state.buff, true); + return SparkMD5.prototype.setState.call(this, state); + }; + SparkMD5.ArrayBuffer.prototype.destroy = SparkMD5.prototype.destroy; + SparkMD5.ArrayBuffer.prototype._finish = SparkMD5.prototype._finish; + SparkMD5.ArrayBuffer.hash = function(arr, raw) { + var hash = md51_array(new Uint8Array(arr)), ret = hex(hash); + return raw ? hexToBinaryString(ret) : ret; + }; + return SparkMD5; + })); +})(sparkMd5); + +var SparkMD5 = sparkMd5.exports; + +const fileSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; + +class FileChecksum { + static create(file, callback) { + const instance = new FileChecksum(file); + instance.create(callback); + } + constructor(file) { + this.file = file; + this.chunkSize = 2097152; + this.chunkCount = Math.ceil(this.file.size / this.chunkSize); + this.chunkIndex = 0; + } + create(callback) { + this.callback = callback; + this.md5Buffer = new SparkMD5.ArrayBuffer; + this.fileReader = new FileReader; + this.fileReader.addEventListener("load", (event => this.fileReaderDidLoad(event))); + this.fileReader.addEventListener("error", (event => this.fileReaderDidError(event))); + this.readNextChunk(); + } + fileReaderDidLoad(event) { + this.md5Buffer.append(event.target.result); + if (!this.readNextChunk()) { + const binaryDigest = this.md5Buffer.end(true); + const base64digest = btoa(binaryDigest); + this.callback(null, base64digest); + } + } + fileReaderDidError(event) { + this.callback(`Error reading ${this.file.name}`); + } + readNextChunk() { + if (this.chunkIndex < this.chunkCount || this.chunkIndex == 0 && this.chunkCount == 0) { + const start = this.chunkIndex * this.chunkSize; + const end = Math.min(start + this.chunkSize, this.file.size); + const bytes = fileSlice.call(this.file, start, end); + this.fileReader.readAsArrayBuffer(bytes); + this.chunkIndex++; + return true; + } else { + return false; + } + } +} + +function getMetaValue(name) { + const element = findElement(document.head, `meta[name="${name}"]`); + if (element) { + return element.getAttribute("content"); + } +} + +function findElements(root, selector) { + if (typeof root == "string") { + selector = root; + root = document; + } + const elements = root.querySelectorAll(selector); + return toArray(elements); +} + +function findElement(root, selector) { + if (typeof root == "string") { + selector = root; + root = document; + } + return root.querySelector(selector); +} + +function dispatchEvent(element, type, eventInit = {}) { + const {disabled: disabled} = element; + const {bubbles: bubbles, cancelable: cancelable, detail: detail} = eventInit; + const event = document.createEvent("Event"); + event.initEvent(type, bubbles || true, cancelable || true); + event.detail = detail || {}; + try { + element.disabled = false; + element.dispatchEvent(event); + } finally { + element.disabled = disabled; + } + return event; +} + +function toArray(value) { + if (Array.isArray(value)) { + return value; + } else if (Array.from) { + return Array.from(value); + } else { + return [].slice.call(value); + } +} + +class BlobRecord { + constructor(file, checksum, url, customHeaders = {}) { + this.file = file; + this.attributes = { + filename: file.name, + content_type: file.type || "application/octet-stream", + byte_size: file.size, + checksum: checksum + }; + this.xhr = new XMLHttpRequest; + this.xhr.open("POST", url, true); + this.xhr.responseType = "json"; + this.xhr.setRequestHeader("Content-Type", "application/json"); + this.xhr.setRequestHeader("Accept", "application/json"); + this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + Object.keys(customHeaders).forEach((headerKey => { + this.xhr.setRequestHeader(headerKey, customHeaders[headerKey]); + })); + const csrfToken = getMetaValue("csrf-token"); + if (csrfToken != undefined) { + this.xhr.setRequestHeader("X-CSRF-Token", csrfToken); + } + this.xhr.addEventListener("load", (event => this.requestDidLoad(event))); + this.xhr.addEventListener("error", (event => this.requestDidError(event))); + } + get status() { + return this.xhr.status; + } + get response() { + const {responseType: responseType, response: response} = this.xhr; + if (responseType == "json") { + return response; + } else { + return JSON.parse(response); + } + } + create(callback) { + this.callback = callback; + this.xhr.send(JSON.stringify({ + blob: this.attributes + })); + } + requestDidLoad(event) { + if (this.status >= 200 && this.status < 300) { + const {response: response} = this; + const {direct_upload: direct_upload} = response; + delete response.direct_upload; + this.attributes = response; + this.directUploadData = direct_upload; + this.callback(null, this.toJSON()); + } else { + this.requestDidError(event); + } + } + requestDidError(event) { + this.callback(`Error creating Blob for "${this.file.name}". Status: ${this.status}`); + } + toJSON() { + const result = {}; + for (const key in this.attributes) { + result[key] = this.attributes[key]; + } + return result; + } +} + +class BlobUpload { + constructor(blob) { + this.blob = blob; + this.file = blob.file; + const {url: url, headers: headers} = blob.directUploadData; + this.xhr = new XMLHttpRequest; + this.xhr.open("PUT", url, true); + this.xhr.responseType = "text"; + for (const key in headers) { + this.xhr.setRequestHeader(key, headers[key]); + } + this.xhr.addEventListener("load", (event => this.requestDidLoad(event))); + this.xhr.addEventListener("error", (event => this.requestDidError(event))); + } + create(callback) { + this.callback = callback; + this.xhr.send(this.file.slice()); + } + requestDidLoad(event) { + const {status: status, response: response} = this.xhr; + if (status >= 200 && status < 300) { + this.callback(null, response); + } else { + this.requestDidError(event); + } + } + requestDidError(event) { + this.callback(`Error storing "${this.file.name}". Status: ${this.xhr.status}`); + } +} + +let id = 0; + +class DirectUpload { + constructor(file, url, delegate, customHeaders = {}) { + this.id = ++id; + this.file = file; + this.url = url; + this.delegate = delegate; + this.customHeaders = customHeaders; + } + create(callback) { + FileChecksum.create(this.file, ((error, checksum) => { + if (error) { + callback(error); + return; + } + const blob = new BlobRecord(this.file, checksum, this.url, this.customHeaders); + notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr); + blob.create((error => { + if (error) { + callback(error); + } else { + const upload = new BlobUpload(blob); + notify(this.delegate, "directUploadWillStoreFileWithXHR", upload.xhr); + upload.create((error => { + if (error) { + callback(error); + } else { + callback(null, blob.toJSON()); + } + })); + } + })); + })); + } +} + +function notify(object, methodName, ...messages) { + if (object && typeof object[methodName] == "function") { + return object[methodName](...messages); + } +} + +class DirectUploadController { + constructor(input, file) { + this.input = input; + this.file = file; + this.directUpload = new DirectUpload(this.file, this.url, this); + this.dispatch("initialize"); + } + start(callback) { + const hiddenInput = document.createElement("input"); + hiddenInput.type = "hidden"; + hiddenInput.name = this.input.name; + this.input.insertAdjacentElement("beforebegin", hiddenInput); + this.dispatch("start"); + this.directUpload.create(((error, attributes) => { + if (error) { + hiddenInput.parentNode.removeChild(hiddenInput); + this.dispatchError(error); + } else { + hiddenInput.value = attributes.signed_id; + } + this.dispatch("end"); + callback(error); + })); + } + uploadRequestDidProgress(event) { + const progress = event.loaded / event.total * 90; + if (progress) { + this.dispatch("progress", { + progress: progress + }); + } + } + get url() { + return this.input.getAttribute("data-direct-upload-url"); + } + dispatch(name, detail = {}) { + detail.file = this.file; + detail.id = this.directUpload.id; + return dispatchEvent(this.input, `direct-upload:${name}`, { + detail: detail + }); + } + dispatchError(error) { + const event = this.dispatch("error", { + error: error + }); + if (!event.defaultPrevented) { + alert(error); + } + } + directUploadWillCreateBlobWithXHR(xhr) { + this.dispatch("before-blob-request", { + xhr: xhr + }); + } + directUploadWillStoreFileWithXHR(xhr) { + this.dispatch("before-storage-request", { + 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])"; + +class DirectUploadsController { + constructor(form) { + this.form = form; + this.inputs = findElements(form, inputSelector).filter((input => input.files.length)); + } + start(callback) { + const controllers = this.createDirectUploadControllers(); + const startNextController = () => { + const controller = controllers.shift(); + if (controller) { + controller.start((error => { + if (error) { + callback(error); + this.dispatch("end"); + } else { + startNextController(); + } + })); + } else { + callback(); + this.dispatch("end"); + } + }; + this.dispatch("start"); + startNextController(); + } + createDirectUploadControllers() { + const controllers = []; + this.inputs.forEach((input => { + toArray(input.files).forEach((file => { + const controller = new DirectUploadController(input, file); + controllers.push(controller); + })); + })); + return controllers; + } + dispatch(name, detail = {}) { + return dispatchEvent(this.form, `direct-uploads:${name}`, { + detail: detail + }); + } +} + +const processingAttribute = "data-direct-uploads-processing"; + +const submitButtonsByForm = new WeakMap; + +let started = false; + +function start() { + if (!started) { + started = true; + document.addEventListener("click", didClick, true); + document.addEventListener("submit", didSubmitForm, true); + document.addEventListener("ajax:before", didSubmitRemoteElement); + } +} + +function didClick(event) { + const button = event.target.closest("button, input"); + if (button && button.type === "submit" && button.form) { + submitButtonsByForm.set(button.form, button); + } +} + +function didSubmitForm(event) { + handleFormSubmissionEvent(event); +} + +function didSubmitRemoteElement(event) { + if (event.target.tagName == "FORM") { + handleFormSubmissionEvent(event); + } +} + +function handleFormSubmissionEvent(event) { + const form = event.target; + if (form.hasAttribute(processingAttribute)) { + event.preventDefault(); + return; + } + const controller = new DirectUploadsController(form); + const {inputs: inputs} = controller; + if (inputs.length) { + event.preventDefault(); + form.setAttribute(processingAttribute, ""); + inputs.forEach(disable); + controller.start((error => { + form.removeAttribute(processingAttribute); + if (error) { + inputs.forEach(enable); + } else { + submitForm(form); + } + })); + } +} + +function submitForm(form) { + let button = submitButtonsByForm.get(form) || findElement(form, "input[type=submit], button[type=submit]"); + if (button) { + const {disabled: disabled} = button; + button.disabled = false; + button.focus(); + button.click(); + button.disabled = disabled; + } else { + button = document.createElement("input"); + button.type = "submit"; + button.style.display = "none"; + form.appendChild(button); + button.click(); + form.removeChild(button); + } + submitButtonsByForm.delete(form); +} + +function disable(input) { + input.disabled = true; +} + +function enable(input) { + input.disabled = false; +} + +function autostart() { + if (window.ActiveStorage) { + start(); + } +} + +setTimeout(autostart, 1); + +class AttachmentUpload { + constructor(attachment, element, file = attachment.file) { + this.attachment = attachment; + this.element = element; + this.directUpload = new DirectUpload(file, this.directUploadUrl, this); + this.file = file; + } + 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 * 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, resolve, reject) { + if (error) { + this.dispatchError(error, reject); + } else { + resolve({ + sgid: attributes.attachable_sgid, + url: this.createBlobUrl(attributes.signed_id, attributes.filename) + }); + this.dispatch("end"); + } + } + createBlobUrl(signedId, filename) { + return this.blobUrlTemplate.replace(":signed_id", signedId).replace(":filename", encodeURIComponent(filename)); + } + dispatch(name, detail = {}) { + detail.attachment = this.attachment; + return dispatchEvent(this.element, `direct-upload:${name}`, { + detail: detail + }); + } + dispatchError(error, reject) { + const event = this.dispatch("error", { + error: error + }); + if (!event.defaultPrevented) { + reject(error); + } + } + get directUploadUrl() { + return this.element.dataset.directUploadUrl; + } + get blobUrlTemplate() { + return this.element.dataset.blobUrlTemplate; + } +} + +addEventListener("trix-attachment-add", (event => { + const {attachment: attachment, target: target} = event; + if (attachment.file) { + 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 new file mode 100644 index 0000000000000..8de57d54fe345 --- /dev/null +++ b/actiontext/app/assets/javascripts/actiontext.js @@ -0,0 +1,965 @@ +(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: {} + }; + (function(module, exports) { + (function(factory) { + { + module.exports = factory(); + } + })((function(undefined$1) { + var hex_chr = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f" ]; + function md5cycle(x, k) { + var a = x[0], b = x[1], c = x[2], d = x[3]; + a += (b & c | ~b & d) + k[0] - 680876936 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[1] - 389564586 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[2] + 606105819 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[3] - 1044525330 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & c | ~b & d) + k[4] - 176418897 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[5] + 1200080426 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[6] - 1473231341 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[7] - 45705983 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & c | ~b & d) + k[8] + 1770035416 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[9] - 1958414417 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[10] - 42063 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[11] - 1990404162 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & c | ~b & d) + k[12] + 1804603682 | 0; + a = (a << 7 | a >>> 25) + b | 0; + d += (a & b | ~a & c) + k[13] - 40341101 | 0; + d = (d << 12 | d >>> 20) + a | 0; + c += (d & a | ~d & b) + k[14] - 1502002290 | 0; + c = (c << 17 | c >>> 15) + d | 0; + b += (c & d | ~c & a) + k[15] + 1236535329 | 0; + b = (b << 22 | b >>> 10) + c | 0; + a += (b & d | c & ~d) + k[1] - 165796510 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[6] - 1069501632 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[11] + 643717713 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[0] - 373897302 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b & d | c & ~d) + k[5] - 701558691 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[10] + 38016083 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[15] - 660478335 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[4] - 405537848 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b & d | c & ~d) + k[9] + 568446438 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[14] - 1019803690 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[3] - 187363961 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[8] + 1163531501 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b & d | c & ~d) + k[13] - 1444681467 | 0; + a = (a << 5 | a >>> 27) + b | 0; + d += (a & c | b & ~c) + k[2] - 51403784 | 0; + d = (d << 9 | d >>> 23) + a | 0; + c += (d & b | a & ~b) + k[7] + 1735328473 | 0; + c = (c << 14 | c >>> 18) + d | 0; + b += (c & a | d & ~a) + k[12] - 1926607734 | 0; + b = (b << 20 | b >>> 12) + c | 0; + a += (b ^ c ^ d) + k[5] - 378558 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[8] - 2022574463 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[11] + 1839030562 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[14] - 35309556 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (b ^ c ^ d) + k[1] - 1530992060 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[4] + 1272893353 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[7] - 155497632 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[10] - 1094730640 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (b ^ c ^ d) + k[13] + 681279174 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[0] - 358537222 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[3] - 722521979 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[6] + 76029189 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (b ^ c ^ d) + k[9] - 640364487 | 0; + a = (a << 4 | a >>> 28) + b | 0; + d += (a ^ b ^ c) + k[12] - 421815835 | 0; + d = (d << 11 | d >>> 21) + a | 0; + c += (d ^ a ^ b) + k[15] + 530742520 | 0; + c = (c << 16 | c >>> 16) + d | 0; + b += (c ^ d ^ a) + k[2] - 995338651 | 0; + b = (b << 23 | b >>> 9) + c | 0; + a += (c ^ (b | ~d)) + k[0] - 198630844 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[7] + 1126891415 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[14] - 1416354905 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[5] - 57434055 | 0; + b = (b << 21 | b >>> 11) + c | 0; + a += (c ^ (b | ~d)) + k[12] + 1700485571 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[3] - 1894986606 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[10] - 1051523 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[1] - 2054922799 | 0; + b = (b << 21 | b >>> 11) + c | 0; + a += (c ^ (b | ~d)) + k[8] + 1873313359 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[15] - 30611744 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[6] - 1560198380 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[13] + 1309151649 | 0; + b = (b << 21 | b >>> 11) + c | 0; + a += (c ^ (b | ~d)) + k[4] - 145523070 | 0; + a = (a << 6 | a >>> 26) + b | 0; + d += (b ^ (a | ~c)) + k[11] - 1120210379 | 0; + d = (d << 10 | d >>> 22) + a | 0; + c += (a ^ (d | ~b)) + k[2] + 718787259 | 0; + c = (c << 15 | c >>> 17) + d | 0; + b += (d ^ (c | ~a)) + k[9] - 343485551 | 0; + b = (b << 21 | b >>> 11) + c | 0; + x[0] = a + x[0] | 0; + x[1] = b + x[1] | 0; + x[2] = c + x[2] | 0; + x[3] = d + x[3] | 0; + } + function md5blk(s) { + var md5blks = [], i; + for (i = 0; i < 64; i += 4) { + md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) + (s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24); + } + return md5blks; + } + function md5blk_array(a) { + var md5blks = [], i; + for (i = 0; i < 64; i += 4) { + md5blks[i >> 2] = a[i] + (a[i + 1] << 8) + (a[i + 2] << 16) + (a[i + 3] << 24); + } + return md5blks; + } + function md51(s) { + var n = s.length, state = [ 1732584193, -271733879, -1732584194, 271733878 ], i, length, tail, tmp, lo, hi; + for (i = 64; i <= n; i += 64) { + md5cycle(state, md5blk(s.substring(i - 64, i))); + } + s = s.substring(i - 64); + length = s.length; + tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3); + } + tail[i >> 2] |= 128 << (i % 4 << 3); + if (i > 55) { + md5cycle(state, tail); + for (i = 0; i < 16; i += 1) { + tail[i] = 0; + } + } + tmp = n * 8; + tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/); + lo = parseInt(tmp[2], 16); + hi = parseInt(tmp[1], 16) || 0; + tail[14] = lo; + tail[15] = hi; + md5cycle(state, tail); + return state; + } + function md51_array(a) { + var n = a.length, state = [ 1732584193, -271733879, -1732584194, 271733878 ], i, length, tail, tmp, lo, hi; + for (i = 64; i <= n; i += 64) { + md5cycle(state, md5blk_array(a.subarray(i - 64, i))); + } + a = i - 64 < n ? a.subarray(i - 64) : new Uint8Array(0); + length = a.length; + tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= a[i] << (i % 4 << 3); + } + tail[i >> 2] |= 128 << (i % 4 << 3); + if (i > 55) { + md5cycle(state, tail); + for (i = 0; i < 16; i += 1) { + tail[i] = 0; + } + } + tmp = n * 8; + tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/); + lo = parseInt(tmp[2], 16); + hi = parseInt(tmp[1], 16) || 0; + tail[14] = lo; + tail[15] = hi; + md5cycle(state, tail); + return state; + } + function rhex(n) { + var s = "", j; + for (j = 0; j < 4; j += 1) { + s += hex_chr[n >> j * 8 + 4 & 15] + hex_chr[n >> j * 8 & 15]; + } + return s; + } + function hex(x) { + var i; + for (i = 0; i < x.length; i += 1) { + x[i] = rhex(x[i]); + } + return x.join(""); + } + if (hex(md51("hello")) !== "5d41402abc4b2a76b9719d911017c592") ; + if (typeof ArrayBuffer !== "undefined" && !ArrayBuffer.prototype.slice) { + (function() { + function clamp(val, length) { + val = val | 0 || 0; + if (val < 0) { + return Math.max(val + length, 0); + } + return Math.min(val, length); + } + ArrayBuffer.prototype.slice = function(from, to) { + var length = this.byteLength, begin = clamp(from, length), end = length, num, target, targetArray, sourceArray; + if (to !== undefined$1) { + end = clamp(to, length); + } + if (begin > end) { + return new ArrayBuffer(0); + } + num = end - begin; + target = new ArrayBuffer(num); + targetArray = new Uint8Array(target); + sourceArray = new Uint8Array(this, begin, num); + targetArray.set(sourceArray); + return target; + }; + })(); + } + function toUtf8(str) { + if (/[\u0080-\uFFFF]/.test(str)) { + str = unescape(encodeURIComponent(str)); + } + return str; + } + function utf8Str2ArrayBuffer(str, returnUInt8Array) { + var length = str.length, buff = new ArrayBuffer(length), arr = new Uint8Array(buff), i; + for (i = 0; i < length; i += 1) { + arr[i] = str.charCodeAt(i); + } + return returnUInt8Array ? arr : buff; + } + function arrayBuffer2Utf8Str(buff) { + return String.fromCharCode.apply(null, new Uint8Array(buff)); + } + function concatenateArrayBuffers(first, second, returnUInt8Array) { + var result = new Uint8Array(first.byteLength + second.byteLength); + result.set(new Uint8Array(first)); + result.set(new Uint8Array(second), first.byteLength); + return returnUInt8Array ? result : result.buffer; + } + function hexToBinaryString(hex) { + var bytes = [], length = hex.length, x; + for (x = 0; x < length - 1; x += 2) { + bytes.push(parseInt(hex.substr(x, 2), 16)); + } + return String.fromCharCode.apply(String, bytes); + } + function SparkMD5() { + this.reset(); + } + SparkMD5.prototype.append = function(str) { + this.appendBinary(toUtf8(str)); + return this; + }; + SparkMD5.prototype.appendBinary = function(contents) { + this._buff += contents; + this._length += contents.length; + var length = this._buff.length, i; + for (i = 64; i <= length; i += 64) { + md5cycle(this._hash, md5blk(this._buff.substring(i - 64, i))); + } + this._buff = this._buff.substring(i - 64); + return this; + }; + SparkMD5.prototype.end = function(raw) { + var buff = this._buff, length = buff.length, i, tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], ret; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= buff.charCodeAt(i) << (i % 4 << 3); + } + this._finish(tail, length); + ret = hex(this._hash); + if (raw) { + ret = hexToBinaryString(ret); + } + this.reset(); + return ret; + }; + SparkMD5.prototype.reset = function() { + this._buff = ""; + this._length = 0; + this._hash = [ 1732584193, -271733879, -1732584194, 271733878 ]; + return this; + }; + SparkMD5.prototype.getState = function() { + return { + buff: this._buff, + length: this._length, + hash: this._hash.slice() + }; + }; + SparkMD5.prototype.setState = function(state) { + this._buff = state.buff; + this._length = state.length; + this._hash = state.hash; + return this; + }; + SparkMD5.prototype.destroy = function() { + delete this._hash; + delete this._buff; + delete this._length; + }; + SparkMD5.prototype._finish = function(tail, length) { + var i = length, tmp, lo, hi; + tail[i >> 2] |= 128 << (i % 4 << 3); + if (i > 55) { + md5cycle(this._hash, tail); + for (i = 0; i < 16; i += 1) { + tail[i] = 0; + } + } + tmp = this._length * 8; + tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/); + lo = parseInt(tmp[2], 16); + hi = parseInt(tmp[1], 16) || 0; + tail[14] = lo; + tail[15] = hi; + md5cycle(this._hash, tail); + }; + SparkMD5.hash = function(str, raw) { + return SparkMD5.hashBinary(toUtf8(str), raw); + }; + SparkMD5.hashBinary = function(content, raw) { + var hash = md51(content), ret = hex(hash); + return raw ? hexToBinaryString(ret) : ret; + }; + SparkMD5.ArrayBuffer = function() { + this.reset(); + }; + SparkMD5.ArrayBuffer.prototype.append = function(arr) { + var buff = concatenateArrayBuffers(this._buff.buffer, arr, true), length = buff.length, i; + this._length += arr.byteLength; + for (i = 64; i <= length; i += 64) { + md5cycle(this._hash, md5blk_array(buff.subarray(i - 64, i))); + } + this._buff = i - 64 < length ? new Uint8Array(buff.buffer.slice(i - 64)) : new Uint8Array(0); + return this; + }; + SparkMD5.ArrayBuffer.prototype.end = function(raw) { + var buff = this._buff, length = buff.length, tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], i, ret; + for (i = 0; i < length; i += 1) { + tail[i >> 2] |= buff[i] << (i % 4 << 3); + } + this._finish(tail, length); + ret = hex(this._hash); + if (raw) { + ret = hexToBinaryString(ret); + } + this.reset(); + return ret; + }; + SparkMD5.ArrayBuffer.prototype.reset = function() { + this._buff = new Uint8Array(0); + this._length = 0; + this._hash = [ 1732584193, -271733879, -1732584194, 271733878 ]; + return this; + }; + SparkMD5.ArrayBuffer.prototype.getState = function() { + var state = SparkMD5.prototype.getState.call(this); + state.buff = arrayBuffer2Utf8Str(state.buff); + return state; + }; + SparkMD5.ArrayBuffer.prototype.setState = function(state) { + state.buff = utf8Str2ArrayBuffer(state.buff, true); + return SparkMD5.prototype.setState.call(this, state); + }; + SparkMD5.ArrayBuffer.prototype.destroy = SparkMD5.prototype.destroy; + SparkMD5.ArrayBuffer.prototype._finish = SparkMD5.prototype._finish; + SparkMD5.ArrayBuffer.hash = function(arr, raw) { + var hash = md51_array(new Uint8Array(arr)), ret = hex(hash); + return raw ? hexToBinaryString(ret) : ret; + }; + return SparkMD5; + })); + })(sparkMd5); + var SparkMD5 = sparkMd5.exports; + const fileSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; + class FileChecksum { + static create(file, callback) { + const instance = new FileChecksum(file); + instance.create(callback); + } + constructor(file) { + this.file = file; + this.chunkSize = 2097152; + this.chunkCount = Math.ceil(this.file.size / this.chunkSize); + this.chunkIndex = 0; + } + create(callback) { + this.callback = callback; + this.md5Buffer = new SparkMD5.ArrayBuffer; + this.fileReader = new FileReader; + this.fileReader.addEventListener("load", (event => this.fileReaderDidLoad(event))); + this.fileReader.addEventListener("error", (event => this.fileReaderDidError(event))); + this.readNextChunk(); + } + fileReaderDidLoad(event) { + this.md5Buffer.append(event.target.result); + if (!this.readNextChunk()) { + const binaryDigest = this.md5Buffer.end(true); + const base64digest = btoa(binaryDigest); + this.callback(null, base64digest); + } + } + fileReaderDidError(event) { + this.callback(`Error reading ${this.file.name}`); + } + readNextChunk() { + if (this.chunkIndex < this.chunkCount || this.chunkIndex == 0 && this.chunkCount == 0) { + const start = this.chunkIndex * this.chunkSize; + const end = Math.min(start + this.chunkSize, this.file.size); + const bytes = fileSlice.call(this.file, start, end); + this.fileReader.readAsArrayBuffer(bytes); + this.chunkIndex++; + return true; + } else { + return false; + } + } + } + function getMetaValue(name) { + const element = findElement(document.head, `meta[name="${name}"]`); + if (element) { + return element.getAttribute("content"); + } + } + function findElements(root, selector) { + if (typeof root == "string") { + selector = root; + root = document; + } + const elements = root.querySelectorAll(selector); + return toArray(elements); + } + function findElement(root, selector) { + if (typeof root == "string") { + selector = root; + root = document; + } + return root.querySelector(selector); + } + function dispatchEvent(element, type, eventInit = {}) { + const {disabled: disabled} = element; + const {bubbles: bubbles, cancelable: cancelable, detail: detail} = eventInit; + const event = document.createEvent("Event"); + event.initEvent(type, bubbles || true, cancelable || true); + event.detail = detail || {}; + try { + element.disabled = false; + element.dispatchEvent(event); + } finally { + element.disabled = disabled; + } + return event; + } + function toArray(value) { + if (Array.isArray(value)) { + return value; + } else if (Array.from) { + return Array.from(value); + } else { + return [].slice.call(value); + } + } + class BlobRecord { + constructor(file, checksum, url, customHeaders = {}) { + this.file = file; + this.attributes = { + filename: file.name, + content_type: file.type || "application/octet-stream", + byte_size: file.size, + checksum: checksum + }; + this.xhr = new XMLHttpRequest; + this.xhr.open("POST", url, true); + this.xhr.responseType = "json"; + this.xhr.setRequestHeader("Content-Type", "application/json"); + this.xhr.setRequestHeader("Accept", "application/json"); + this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + Object.keys(customHeaders).forEach((headerKey => { + this.xhr.setRequestHeader(headerKey, customHeaders[headerKey]); + })); + const csrfToken = getMetaValue("csrf-token"); + if (csrfToken != undefined) { + this.xhr.setRequestHeader("X-CSRF-Token", csrfToken); + } + this.xhr.addEventListener("load", (event => this.requestDidLoad(event))); + this.xhr.addEventListener("error", (event => this.requestDidError(event))); + } + get status() { + return this.xhr.status; + } + get response() { + const {responseType: responseType, response: response} = this.xhr; + if (responseType == "json") { + return response; + } else { + return JSON.parse(response); + } + } + create(callback) { + this.callback = callback; + this.xhr.send(JSON.stringify({ + blob: this.attributes + })); + } + requestDidLoad(event) { + if (this.status >= 200 && this.status < 300) { + const {response: response} = this; + const {direct_upload: direct_upload} = response; + delete response.direct_upload; + this.attributes = response; + this.directUploadData = direct_upload; + this.callback(null, this.toJSON()); + } else { + this.requestDidError(event); + } + } + requestDidError(event) { + this.callback(`Error creating Blob for "${this.file.name}". Status: ${this.status}`); + } + toJSON() { + const result = {}; + for (const key in this.attributes) { + result[key] = this.attributes[key]; + } + return result; + } + } + class BlobUpload { + constructor(blob) { + this.blob = blob; + this.file = blob.file; + const {url: url, headers: headers} = blob.directUploadData; + this.xhr = new XMLHttpRequest; + this.xhr.open("PUT", url, true); + this.xhr.responseType = "text"; + for (const key in headers) { + this.xhr.setRequestHeader(key, headers[key]); + } + this.xhr.addEventListener("load", (event => this.requestDidLoad(event))); + this.xhr.addEventListener("error", (event => this.requestDidError(event))); + } + create(callback) { + this.callback = callback; + this.xhr.send(this.file.slice()); + } + requestDidLoad(event) { + const {status: status, response: response} = this.xhr; + if (status >= 200 && status < 300) { + this.callback(null, response); + } else { + this.requestDidError(event); + } + } + requestDidError(event) { + this.callback(`Error storing "${this.file.name}". Status: ${this.xhr.status}`); + } + } + let id = 0; + class DirectUpload { + constructor(file, url, delegate, customHeaders = {}) { + this.id = ++id; + this.file = file; + this.url = url; + this.delegate = delegate; + this.customHeaders = customHeaders; + } + create(callback) { + FileChecksum.create(this.file, ((error, checksum) => { + if (error) { + callback(error); + return; + } + const blob = new BlobRecord(this.file, checksum, this.url, this.customHeaders); + notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr); + blob.create((error => { + if (error) { + callback(error); + } else { + const upload = new BlobUpload(blob); + notify(this.delegate, "directUploadWillStoreFileWithXHR", upload.xhr); + upload.create((error => { + if (error) { + callback(error); + } else { + callback(null, blob.toJSON()); + } + })); + } + })); + })); + } + } + function notify(object, methodName, ...messages) { + if (object && typeof object[methodName] == "function") { + return object[methodName](...messages); + } + } + class DirectUploadController { + constructor(input, file) { + this.input = input; + this.file = file; + this.directUpload = new DirectUpload(this.file, this.url, this); + this.dispatch("initialize"); + } + start(callback) { + const hiddenInput = document.createElement("input"); + hiddenInput.type = "hidden"; + hiddenInput.name = this.input.name; + this.input.insertAdjacentElement("beforebegin", hiddenInput); + this.dispatch("start"); + this.directUpload.create(((error, attributes) => { + if (error) { + hiddenInput.parentNode.removeChild(hiddenInput); + this.dispatchError(error); + } else { + hiddenInput.value = attributes.signed_id; + } + this.dispatch("end"); + callback(error); + })); + } + uploadRequestDidProgress(event) { + const progress = event.loaded / event.total * 90; + if (progress) { + this.dispatch("progress", { + progress: progress + }); + } + } + get url() { + return this.input.getAttribute("data-direct-upload-url"); + } + dispatch(name, detail = {}) { + detail.file = this.file; + detail.id = this.directUpload.id; + return dispatchEvent(this.input, `direct-upload:${name}`, { + detail: detail + }); + } + dispatchError(error) { + const event = this.dispatch("error", { + error: error + }); + if (!event.defaultPrevented) { + alert(error); + } + } + directUploadWillCreateBlobWithXHR(xhr) { + this.dispatch("before-blob-request", { + xhr: xhr + }); + } + directUploadWillStoreFileWithXHR(xhr) { + this.dispatch("before-storage-request", { + 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])"; + class DirectUploadsController { + constructor(form) { + this.form = form; + this.inputs = findElements(form, inputSelector).filter((input => input.files.length)); + } + start(callback) { + const controllers = this.createDirectUploadControllers(); + const startNextController = () => { + const controller = controllers.shift(); + if (controller) { + controller.start((error => { + if (error) { + callback(error); + this.dispatch("end"); + } else { + startNextController(); + } + })); + } else { + callback(); + this.dispatch("end"); + } + }; + this.dispatch("start"); + startNextController(); + } + createDirectUploadControllers() { + const controllers = []; + this.inputs.forEach((input => { + toArray(input.files).forEach((file => { + const controller = new DirectUploadController(input, file); + controllers.push(controller); + })); + })); + return controllers; + } + dispatch(name, detail = {}) { + return dispatchEvent(this.form, `direct-uploads:${name}`, { + detail: detail + }); + } + } + const processingAttribute = "data-direct-uploads-processing"; + const submitButtonsByForm = new WeakMap; + let started = false; + function start() { + if (!started) { + started = true; + document.addEventListener("click", didClick, true); + document.addEventListener("submit", didSubmitForm, true); + document.addEventListener("ajax:before", didSubmitRemoteElement); + } + } + function didClick(event) { + const button = event.target.closest("button, input"); + if (button && button.type === "submit" && button.form) { + submitButtonsByForm.set(button.form, button); + } + } + function didSubmitForm(event) { + handleFormSubmissionEvent(event); + } + function didSubmitRemoteElement(event) { + if (event.target.tagName == "FORM") { + handleFormSubmissionEvent(event); + } + } + function handleFormSubmissionEvent(event) { + const form = event.target; + if (form.hasAttribute(processingAttribute)) { + event.preventDefault(); + return; + } + const controller = new DirectUploadsController(form); + const {inputs: inputs} = controller; + if (inputs.length) { + event.preventDefault(); + form.setAttribute(processingAttribute, ""); + inputs.forEach(disable); + controller.start((error => { + form.removeAttribute(processingAttribute); + if (error) { + inputs.forEach(enable); + } else { + submitForm(form); + } + })); + } + } + function submitForm(form) { + let button = submitButtonsByForm.get(form) || findElement(form, "input[type=submit], button[type=submit]"); + if (button) { + const {disabled: disabled} = button; + button.disabled = false; + button.focus(); + button.click(); + button.disabled = disabled; + } else { + button = document.createElement("input"); + button.type = "submit"; + button.style.display = "none"; + form.appendChild(button); + button.click(); + form.removeChild(button); + } + submitButtonsByForm.delete(form); + } + function disable(input) { + input.disabled = true; + } + function enable(input) { + input.disabled = false; + } + function autostart() { + if (window.ActiveStorage) { + start(); + } + } + setTimeout(autostart, 1); + class AttachmentUpload { + constructor(attachment, element, file = attachment.file) { + this.attachment = attachment; + this.element = element; + this.directUpload = new DirectUpload(file, this.directUploadUrl, this); + this.file = file; + } + 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 * 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, resolve, reject) { + if (error) { + this.dispatchError(error, reject); + } else { + resolve({ + sgid: attributes.attachable_sgid, + url: this.createBlobUrl(attributes.signed_id, attributes.filename) + }); + this.dispatch("end"); + } + } + createBlobUrl(signedId, filename) { + return this.blobUrlTemplate.replace(":signed_id", signedId).replace(":filename", encodeURIComponent(filename)); + } + dispatch(name, detail = {}) { + detail.attachment = this.attachment; + return dispatchEvent(this.element, `direct-upload:${name}`, { + detail: detail + }); + } + dispatchError(error, reject) { + const event = this.dispatch("error", { + error: error + }); + if (!event.defaultPrevented) { + reject(error); + } + } + get directUploadUrl() { + return this.element.dataset.directUploadUrl; + } + get blobUrlTemplate() { + return this.element.dataset.blobUrlTemplate; + } + } + addEventListener("trix-attachment-add", (event => { + const {attachment: attachment, target: target} = event; + if (attachment.file) { + 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/helpers/action_text/content_helper.rb b/actiontext/app/helpers/action_text/content_helper.rb new file mode 100644 index 0000000000000..cae096b2a0538 --- /dev/null +++ b/actiontext/app/helpers/action_text/content_helper.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "rails-html-sanitizer" + +module ActionText + module ContentHelper + mattr_accessor(:sanitizer, default: Rails::HTML4::Sanitizer.safe_list_sanitizer.new) + mattr_accessor(:allowed_tags) + mattr_accessor(:allowed_attributes) + mattr_accessor(:scrubber) + + def render_action_text_content(content) + self.prefix_partial_path_with_controller_namespace = false + sanitize_action_text_content(render_action_text_attachments(content)) + end + + def sanitize_content_attachment(content_attachment) + sanitizer.sanitize( + content_attachment, + tags: sanitizer_allowed_tags, + attributes: sanitizer_allowed_attributes, + scrubber: scrubber, + ) + end + + def sanitize_action_text_content(content) + sanitizer.sanitize( + content.to_html, + tags: sanitizer_allowed_tags, + attributes: sanitizer_allowed_attributes, + scrubber: scrubber, + ).html_safe + end + + def render_action_text_attachments(content) + content.render_attachments do |attachment| + unless attachment.in?(content.gallery_attachments) + attachment.node.tap do |node| + node.inner_html = render_action_text_attachment attachment, locals: { in_gallery: false } + end + end + end.render_attachment_galleries do |attachment_gallery| + render(layout: attachment_gallery, object: attachment_gallery) do + attachment_gallery.attachments.map do |attachment| + attachment.node.inner_html = render_action_text_attachment attachment, locals: { in_gallery: true } + attachment.to_html + end.join.html_safe + end.chomp + end + end + + def render_action_text_attachment(attachment, locals: {}) # :nodoc: + options = { locals: locals, object: attachment, partial: attachment } + + if attachment.respond_to?(:to_attachable_partial_path) + options[:partial] = attachment.to_attachable_partial_path + end + + if attachment.respond_to?(:model_name) + options[:as] = attachment.model_name.element + end + + render(**options).chomp + end + + def sanitizer_allowed_tags + allowed_tags || (sanitizer.class.allowed_tags + [ ActionText::Attachment.tag_name, "figure", "figcaption" ]) + end + + def sanitizer_allowed_attributes + allowed_attributes || (sanitizer.class.allowed_attributes + ActionText::Attachment::ATTRIBUTES) + end + end +end diff --git a/actiontext/app/helpers/action_text/tag_helper.rb b/actiontext/app/helpers/action_text/tag_helper.rb new file mode 100644 index 0000000000000..0d24dbf48639b --- /dev/null +++ b/actiontext/app/helpers/action_text/tag_helper.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/object/try" +require "action_view/helpers/tags/placeholderable" + +module ActionText + module TagHelper + # 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. + # + # #### Options + # * `:class` - Defaults to "trix-content" so that default styles will be + # applied. Setting this to a different value will prevent default styles + # from being applied. + # * `[:data][:direct_upload_url]` - Defaults to `rails_direct_uploads_url`. + # * `[:data][:blob_url_template]` - Defaults to + # `rails_service_blob_url(":signed_id", ":filename")`. + # + # + # #### Example + # + # rich_textarea_tag "content", message.content + # # + # # + # + # 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 + + 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") + + render RichText.editor.editor_tag(options) + end + alias_method :rich_text_area_tag, :rich_textarea_tag + end +end + +module ActionView::Helpers + class Tags::ActionText < Tags::Base + include Tags::Placeholderable + + def render(&block) + options = @options.stringify_keys + 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 + + module FormHelper + # 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. + # + # #### Options + # * `:class` - Defaults to "trix-content" which ensures default styling is + # applied. + # * `:value` - Adds a default value to the HTML input tag. + # * `[:data][:direct_upload_url]` - Defaults to `rails_direct_uploads_url`. + # * `[:data][:blob_url_template]` - Defaults to + # `rails_service_blob_url(":signed_id", ":filename")`. + # + # + # #### Example + # rich_textarea :message, :content + # # + # # + # + # rich_textarea :message, :content, value: "

Default message

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

Default message

" + # end + # # + # # + 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 + + class FormBuilder + # Wraps ActionView::Helpers::FormHelper#rich_textarea for form builders: + # + # <%= form_with model: @message do |f| %> + # <%= f.rich_textarea :content %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + 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 +end diff --git a/actiontext/app/javascript/actiontext/attachment_upload.js b/actiontext/app/javascript/actiontext/attachment_upload.js new file mode 100644 index 0000000000000..34a8802e4c1f4 --- /dev/null +++ b/actiontext/app/javascript/actiontext/attachment_upload.js @@ -0,0 +1,111 @@ +import { DirectUpload, dispatchEvent } from "@rails/activestorage" + +export class AttachmentUpload { + constructor(attachment, element, file = attachment.file) { + this.attachment = attachment + this.element = element + this.directUpload = new DirectUpload(file, this.directUploadUrl, this) + this.file = file + } + + 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 => { + // 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, resolve, reject) { + if (error) { + this.dispatchError(error, reject) + } else { + resolve({ + sgid: attributes.attachable_sgid, + url: this.createBlobUrl(attributes.signed_id, attributes.filename) + }) + this.dispatch("end") + } + } + + createBlobUrl(signedId, filename) { + return this.blobUrlTemplate + .replace(":signed_id", signedId) + .replace(":filename", encodeURIComponent(filename)) + } + + dispatch(name, detail = {}) { + detail.attachment = this.attachment + return dispatchEvent(this.element, `direct-upload:${name}`, { detail }) + } + + dispatchError(error, reject) { + const event = this.dispatch("error", { error }) + if (!event.defaultPrevented) { + reject(error) + } + } + + get directUploadUrl() { + return this.element.dataset.directUploadUrl + } + + get blobUrlTemplate() { + return this.element.dataset.blobUrlTemplate + } +} diff --git a/actiontext/app/javascript/actiontext/index.js b/actiontext/app/javascript/actiontext/index.js new file mode 100644 index 0000000000000..86a57189f0880 --- /dev/null +++ b/actiontext/app/javascript/actiontext/index.js @@ -0,0 +1,19 @@ +import { AttachmentUpload } from "./attachment_upload" + +addEventListener("trix-attachment-add", event => { + const { attachment, target } = event + + if (attachment.file) { + 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/encrypted_rich_text.rb b/actiontext/app/models/action_text/encrypted_rich_text.rb new file mode 100644 index 0000000000000..971cdd6020580 --- /dev/null +++ b/actiontext/app/models/action_text/encrypted_rich_text.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + class EncryptedRichText < RichText + encrypts :body + end +end + +ActiveSupport.run_load_hooks :action_text_encrypted_rich_text, ActionText::EncryptedRichText diff --git a/actiontext/app/models/action_text/record.rb b/actiontext/app/models/action_text/record.rb new file mode 100644 index 0000000000000..49904c4ae07a2 --- /dev/null +++ b/actiontext/app/models/action_text/record.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + class Record < ActiveRecord::Base # :nodoc: + self.abstract_class = true + end +end + +ActiveSupport.run_load_hooks :action_text_record, ActionText::Record diff --git a/actiontext/app/models/action_text/rich_text.rb b/actiontext/app/models/action_text/rich_text.rb new file mode 100644 index 0000000000000..47de7c7645948 --- /dev/null +++ b/actiontext/app/models/action_text/rich_text.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + # # Action Text RichText + # + # The RichText record holds the content produced by the Trix editor in a + # serialized `body` attribute. It also holds all the references to the embedded + # files, which are stored using Active Storage. This record is then associated + # with the Active Record model the application desires to have rich text content + # using the `has_rich_text` class method. + # + # class Message < ActiveRecord::Base + # has_rich_text :content + # end + # + # message = Message.create!(content: "

Funny times!

") + # message.content #=> # "

Funny times!

" + # message.content.to_plain_text # => "Funny times!" + # + # message = Message.create!(content: "
safe
") + # message.content #=> # "
safeunsafe
" + # message.content.to_plain_text # => "safeunsafe" + class RichText < Record + ## + # :method: to_s + # + # Safely transforms RichText into an HTML String. + # + # message = Message.create!(content: "

Funny times!

") + # message.content.to_s # => "

Funny times!

" + # + # 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 + + ## + # :method: record + # + # Returns the associated record. + belongs_to :record, polymorphic: true, touch: true + + ## + # :method: embeds + # + # Returns the ActiveStorage::Attachment records from the embedded files. + # + # 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 + self.embeds = body.attachables.grep(ActiveStorage::Blob).uniq if body.present? + end + + # Returns a plain-text version of the markup contained by the `body` attribute, + # with tags removed but HTML entities encoded. + # + # message = Message.create!(content: "

Funny times!

") + # message.content.to_plain_text # => "Funny times!" + # + # NOTE: that the returned string is not HTML safe and should not be rendered in + # browsers. + # + # message = Message.create!(content: "<script>alert()</script>") + # message.content.to_plain_text # => "" + def to_plain_text + body&.to_plain_text.to_s + end + + # Returns the `body` attribute in a format that makes it editable in the Trix + # editor. Previews of attachments are rendered inline. + # + # content = "

Funny Times!

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

Funny times!

+ # #
+ # # + # #
+ # #
+ def 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 + end +end + +ActiveSupport.run_load_hooks :action_text_rich_text, ActionText::RichText diff --git a/actiontext/app/views/action_text/attachables/_content_attachment.html.erb b/actiontext/app/views/action_text/attachables/_content_attachment.html.erb new file mode 100644 index 0000000000000..e2db56a03faac --- /dev/null +++ b/actiontext/app/views/action_text/attachables/_content_attachment.html.erb @@ -0,0 +1,3 @@ +
+ <%= content_attachment.attachable %> +
diff --git a/actiontext/app/views/action_text/attachables/_missing_attachable.html.erb b/actiontext/app/views/action_text/attachables/_missing_attachable.html.erb new file mode 100644 index 0000000000000..5ffd93b89e042 --- /dev/null +++ b/actiontext/app/views/action_text/attachables/_missing_attachable.html.erb @@ -0,0 +1 @@ +<%= "☒" -%> diff --git a/actiontext/app/views/action_text/attachables/_remote_image.html.erb b/actiontext/app/views/action_text/attachables/_remote_image.html.erb new file mode 100644 index 0000000000000..3372f8d940b34 --- /dev/null +++ b/actiontext/app/views/action_text/attachables/_remote_image.html.erb @@ -0,0 +1,8 @@ +
+ <%= image_tag(remote_image.url, width: remote_image.width, height: remote_image.height) %> + <% if caption = remote_image.try(:caption) %> +
+ <%= caption %> +
+ <% end %> +
diff --git a/actiontext/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb b/actiontext/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb new file mode 100644 index 0000000000000..6bc8674dc51de --- /dev/null +++ b/actiontext/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb @@ -0,0 +1,3 @@ + diff --git a/actiontext/app/views/action_text/contents/_content.html.erb b/actiontext/app/views/action_text/contents/_content.html.erb new file mode 100644 index 0000000000000..898a5066770e0 --- /dev/null +++ b/actiontext/app/views/action_text/contents/_content.html.erb @@ -0,0 +1 @@ +<%= render_action_text_content(content) %> diff --git a/actiontext/app/views/active_storage/blobs/_blob.html.erb b/actiontext/app/views/active_storage/blobs/_blob.html.erb new file mode 100644 index 0000000000000..49ba357dd1d4a --- /dev/null +++ b/actiontext/app/views/active_storage/blobs/_blob.html.erb @@ -0,0 +1,14 @@ +
attachment--<%= blob.filename.extension %>"> + <% if blob.representable? %> + <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> + <% end %> + +
+ <% if caption = blob.try(:caption) %> + <%= caption %> + <% else %> + <%= blob.filename %> + <%= number_to_human_size blob.byte_size %> + <% end %> +
+
diff --git a/actiontext/app/views/layouts/action_text/contents/_content.html.erb b/actiontext/app/views/layouts/action_text/contents/_content.html.erb new file mode 100644 index 0000000000000..9e3c0d0dff0fd --- /dev/null +++ b/actiontext/app/views/layouts/action_text/contents/_content.html.erb @@ -0,0 +1,3 @@ +
+ <%= yield -%> +
diff --git a/actiontext/bin/test b/actiontext/bin/test new file mode 100755 index 0000000000000..c53377cc970f4 --- /dev/null +++ b/actiontext/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/actiontext/db/migrate/20180528164100_create_action_text_tables.rb b/actiontext/db/migrate/20180528164100_create_action_text_tables.rb new file mode 100644 index 0000000000000..6eb2e90b8bcca --- /dev/null +++ b/actiontext/db/migrate/20180528164100_create_action_text_tables.rb @@ -0,0 +1,25 @@ +class CreateActionTextTables < ActiveRecord::Migration[6.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :action_text_rich_texts, id: primary_key_type do |t| + t.string :name, null: false + t.text :body, size: :long + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + + t.timestamps + + t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/actiontext/lib/action_text.rb b/actiontext/lib/action_text.rb new file mode 100644 index 0000000000000..0fdb37511c227 --- /dev/null +++ b/actiontext/lib/action_text.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/rails" + +require "action_text/version" +require "action_text/deprecator" + +require "nokogiri" + +# :markup: markdown +# :include: ../README.md +module ActionText + extend ActiveSupport::Autoload + + autoload :Attachable + 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 + + module Attachables + extend ActiveSupport::Autoload + + autoload :ContentAttachment + autoload :MissingAttachable + autoload :RemoteImage + end + + module Attachments + extend ActiveSupport::Autoload + + autoload :Caching + autoload :Conversion + autoload :Minification + autoload :TrixConversion + end + + class << self + def html_document_class + return @html_document_class if defined?(@html_document_class) + @html_document_class = + defined?(Nokogiri::HTML5) ? Nokogiri::HTML5::Document : Nokogiri::HTML4::Document + end + + def html_document_fragment_class + return @html_document_fragment_class if defined?(@html_document_fragment_class) + @html_document_fragment_class = + defined?(Nokogiri::HTML5) ? Nokogiri::HTML5::DocumentFragment : Nokogiri::HTML4::DocumentFragment + end + end +end diff --git a/actiontext/lib/action_text/attachable.rb b/actiontext/lib/action_text/attachable.rb new file mode 100644 index 0000000000000..3fc621ab21f08 --- /dev/null +++ b/actiontext/lib/action_text/attachable.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + # # Action Text Attachable + # + # Include this module to make a record attachable to an ActionText::Content. + # + # class Person < ApplicationRecord + # include ActionText::Attachable + # end + # + # person = Person.create! name: "Javan" + # html = %Q() + # content = ActionText::Content.new(html) + # content.attachables # => [person] + module Attachable + extend ActiveSupport::Concern + + LOCATOR_NAME = "attachable" + + class << self + # Extracts the `ActionText::Attachable` from the attachment HTML node: + # + # person = Person.create! name: "Javan" + # html = %Q() + # fragment = ActionText::Fragment.wrap(html) + # attachment_node = fragment.find_all(ActionText::Attachment.tag_name).first + # ActionText::Attachable.from_node(attachment_node) # => person + def from_node(node) + if attachable = attachable_from_sgid(node["sgid"]) + attachable + elsif attachable = ActionText::Attachables::ContentAttachment.from_node(node) + attachable + elsif attachable = ActionText::Attachables::RemoteImage.from_node(node) + attachable + else + ActionText::Attachables::MissingAttachable.new(node["sgid"]) + end + end + + def from_attachable_sgid(sgid, options = {}) + method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed + record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME)) + record || raise(ActiveRecord::RecordNotFound) + end + + private + def attachable_from_sgid(sgid) + from_attachable_sgid(sgid) + rescue ActiveRecord::RecordNotFound + nil + end + end + + class_methods do + def from_attachable_sgid(sgid) + ActionText::Attachable.from_attachable_sgid(sgid, only: self) + end + + # Returns the path to the partial that is used for rendering missing + # attachables. Defaults to "action_text/attachables/missing_attachable". + # + # Override to render a different partial: + # + # class User < ApplicationRecord + # def self.to_missing_attachable_partial_path + # "users/missing_attachable" + # end + # end + def to_missing_attachable_partial_path + ActionText::Attachables::MissingAttachable::DEFAULT_PARTIAL_PATH + end + end + + # Returns the Signed Global ID for the attachable. The purpose of the ID is set + # to 'attachable' so it can't be reused for other purposes. + def attachable_sgid + to_sgid(expires_in: nil, for: LOCATOR_NAME).to_s + end + + def attachable_content_type + try(:content_type) || "application/octet-stream" + end + + def attachable_filename + filename.to_s if respond_to?(:filename) + end + + def attachable_filesize + try(:byte_size) || try(:filesize) + end + + def attachable_metadata + try(:metadata) || {} + end + + def previewable_attachable? + false + end + + # Returns the path to the partial that is used for rendering the attachable in + # Trix. Defaults to `to_partial_path`. + # + # Override to render a different partial: + # + # class User < ApplicationRecord + # def to_trix_content_attachment_partial_path + # "users/trix_content_attachment" + # 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 + + # Returns the path to the partial that is used for rendering the attachable. + # Defaults to `to_partial_path`. + # + # Override to render a different partial: + # + # class User < ApplicationRecord + # def to_attachable_partial_path + # "users/attachable" + # end + # end + def to_attachable_partial_path + to_partial_path + end + + def to_rich_text_attributes(attributes = {}) + attributes.dup.tap do |attrs| + attrs[:sgid] = attachable_sgid + attrs[:content_type] = attachable_content_type + attrs[:previewable] = true if previewable_attachable? + attrs[:filename] = attachable_filename + attrs[:filesize] = attachable_filesize + attrs[:width] = attachable_metadata[:width] + attrs[:height] = attachable_metadata[:height] + end.compact + end + + private + def attribute_names_for_serialization + super + ["attachable_sgid"] + end + + def read_attribute_for_serialization(key) + if key == "attachable_sgid" + persisted? ? super : nil + else + super + end + end + end +end diff --git a/actiontext/lib/action_text/attachables/content_attachment.rb b/actiontext/lib/action_text/attachables/content_attachment.rb new file mode 100644 index 0000000000000..17f2dceb97821 --- /dev/null +++ b/actiontext/lib/action_text/attachables/content_attachment.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module Attachables + class ContentAttachment # :nodoc: + include ActiveModel::Model + + def self.from_node(node) + attachment = new(content_type: node["content-type"], content: node["content"]) + attachment if attachment.valid? + end + + attr_accessor :content_type, :content + + validates_format_of :content_type, with: /html/ + validates_presence_of :content + + def attachable_plain_text_representation(caption) + content_instance.fragment.source + end + + def to_html + @to_html ||= content_instance.render(content_instance) + end + + def to_s + to_html + end + + def to_partial_path + "action_text/attachables/content_attachment" + end + + private + def content_instance + @content_instance ||= ActionText::Content.new(content) + end + end + end +end diff --git a/actiontext/lib/action_text/attachables/missing_attachable.rb b/actiontext/lib/action_text/attachables/missing_attachable.rb new file mode 100644 index 0000000000000..47f0d68fa3a22 --- /dev/null +++ b/actiontext/lib/action_text/attachables/missing_attachable.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module Attachables + class MissingAttachable + extend ActiveModel::Naming + + DEFAULT_PARTIAL_PATH = "action_text/attachables/missing_attachable" + + def initialize(sgid) + @sgid = SignedGlobalID.parse(sgid, for: ActionText::Attachable::LOCATOR_NAME) + end + + def to_partial_path + if model + model.to_missing_attachable_partial_path + else + DEFAULT_PARTIAL_PATH + end + end + + def model + @sgid&.model_name.to_s.safe_constantize + end + end + end +end diff --git a/actiontext/lib/action_text/attachables/remote_image.rb b/actiontext/lib/action_text/attachables/remote_image.rb new file mode 100644 index 0000000000000..d5e99f157950c --- /dev/null +++ b/actiontext/lib/action_text/attachables/remote_image.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module Attachables + class RemoteImage + extend ActiveModel::Naming + + class << self + def from_node(node) + 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 + + def attributes_from_node(node) + { url: node["url"], + content_type: node["content-type"], + width: node["width"], + height: node["height"] } + end + end + + attr_reader :url, :content_type, :width, :height + + def initialize(attributes = {}) + @url = attributes[:url] + @content_type = attributes[:content_type] + @width = attributes[:width] + @height = attributes[:height] + end + + def attachable_plain_text_representation(caption) + "[#{caption || "Image"}]" + end + + def to_partial_path + "action_text/attachables/remote_image" + end + end + end +end diff --git a/actiontext/lib/action_text/attachment.rb b/actiontext/lib/action_text/attachment.rb new file mode 100644 index 0000000000000..93081d8ce3827 --- /dev/null +++ b/actiontext/lib/action_text/attachment.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/object/try" + +module ActionText + # # Action Text Attachment + # + # Attachments serialize attachables to HTML or plain text. + # + # class Person < ApplicationRecord + # include ActionText::Attachable + # end + # + # attachable = Person.create! name: "Javan" + # attachment = ActionText::Attachment.from_attachable(attachable) + # attachment.to_html # => " "[racecar.jpg]" + # + # Use the `caption` when set: + # + # attachment = ActionText::Attachment.from_attachable(attachable, caption: "Vroom vroom") + # attachment.to_plain_text # => "[Vroom vroom]" + # + # The presentation can be overridden by implementing the + # `attachable_plain_text_representation` method: + # + # class Person < ApplicationRecord + # include ActionText::Attachable + # + # def attachable_plain_text_representation + # "[#{name}]" + # end + # end + # + # attachable = Person.create! name: "Javan" + # attachment = ActionText::Attachment.from_attachable(attachable) + # attachment.to_plain_text # => "[Javan]" + def to_plain_text + if respond_to?(:attachable_plain_text_representation) + attachable_plain_text_representation(caption) + else + caption.to_s + end + end + + # Converts the attachment to HTML. + # + # attachable = Person.create! name: "Javan" + # attachment = ActionText::Attachment.from_attachable(attachable) + # attachment.to_html # => "" + end + + private + def node_attributes + @node_attributes ||= ATTRIBUTES.to_h { |name| [ name.underscore, node[name] ] }.compact + end + + def attachable_attributes + @attachable_attributes ||= (attachable.try(:to_rich_text_attributes) || {}).stringify_keys + end + + def sgid_attributes + @sgid_attributes ||= node_attributes.slice("sgid").presence || attachable_attributes.slice("sgid") + end + end +end diff --git a/actiontext/lib/action_text/attachment_gallery.rb b/actiontext/lib/action_text/attachment_gallery.rb new file mode 100644 index 0000000000000..fc690900ee3b4 --- /dev/null +++ b/actiontext/lib/action_text/attachment_gallery.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + class AttachmentGallery + include ActiveModel::Model + + TAG_NAME = "div" + private_constant :TAG_NAME + + class << self + def fragment_by_canonicalizing_attachment_galleries(content) + fragment_by_replacing_attachment_gallery_nodes(content) do |node| + "<#{TAG_NAME}>#{node.inner_html}" + end + end + + def fragment_by_replacing_attachment_gallery_nodes(content) + Fragment.wrap(content).update do |source| + find_attachment_gallery_nodes(source).each do |node| + node.replace(yield(node).to_s) + end + end + end + + def find_attachment_gallery_nodes(content) + Fragment.wrap(content).find_all(selector).select do |node| + node.children.all? do |child| + if child.text? + /\A(\n|\ )*\z/.match?(child.text) + else + child.matches? attachment_selector + end + end + end + end + + def from_node(node) + new(node) + end + + def attachment_selector + "#{ActionText::Attachment.tag_name}[presentation=gallery]" + end + + def selector + "#{TAG_NAME}:has(#{attachment_selector} + #{attachment_selector})" + end + end + + attr_reader :node + + def initialize(node) + @node = node + end + + def attachments + @attachments ||= node.css(ActionText::AttachmentGallery.attachment_selector).map do |node| + ActionText::Attachment.from_node(node).with_full_attributes + end + end + + def size + attachments.size + end + + def inspect + "#<#{self.class.name} size=#{size.inspect}>" + end + end +end diff --git a/actiontext/lib/action_text/attachments/caching.rb b/actiontext/lib/action_text/attachments/caching.rb new file mode 100644 index 0000000000000..10f50dae49fa5 --- /dev/null +++ b/actiontext/lib/action_text/attachments/caching.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module Attachments + module Caching + def cache_key(*args) + [self.class.name, cache_digest, *attachable.cache_key(*args)].join("/") + end + + private + def cache_digest + OpenSSL::Digest::SHA256.hexdigest(node.to_s) + end + end + end +end diff --git a/actiontext/lib/action_text/attachments/conversion.rb b/actiontext/lib/action_text/attachments/conversion.rb new file mode 100644 index 0000000000000..30342e3cc1ba2 --- /dev/null +++ b/actiontext/lib/action_text/attachments/conversion.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/object/try" + +module ActionText + module Attachments + module Conversion # :nodoc: + extend ActiveSupport::Concern + + class_methods do + def fragment_by_converting_editor_attachments(content) + canonical_fragment = Fragment.wrap(content) + + RichText.editor.as_canonical(canonical_fragment) + end + end + + def to_editor_attachment + dup.to_editor_attachment! + end + + def to_editor_attachment! # :nodoc: + if (content = editor_attachment_content) + node["content"] = content + end + self + end + + private + def editor_attachment_content + if partial_path = ( + attachable.try(:to_editor_content_attachment_partial_path) || + ActionText.deprecator.silence { attachable.try(:to_trix_content_attachment_partial_path) } + ) + ActionText::Content.render(partial: partial_path, formats: :html, object: self, as: model_name.element) + end + end + end + end +end diff --git a/actiontext/lib/action_text/attachments/minification.rb b/actiontext/lib/action_text/attachments/minification.rb new file mode 100644 index 0000000000000..8ebe6ddbd9ad9 --- /dev/null +++ b/actiontext/lib/action_text/attachments/minification.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module Attachments + module Minification + extend ActiveSupport::Concern + + class_methods do + def fragment_by_minifying_attachments(content) + Fragment.wrap(content).replace(ActionText::Attachment.tag_name) do |node| + node.tap { |n| n.inner_html = "" } + end + end + end + end + end +end diff --git a/actiontext/lib/action_text/attachments/trix_conversion.rb b/actiontext/lib/action_text/attachments/trix_conversion.rb new file mode 100644 index 0000000000000..de570a95c4c3f --- /dev/null +++ b/actiontext/lib/action_text/attachments/trix_conversion.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/object/try" + +module ActionText + module Attachments + # DEPRECATED + module TrixConversion + extend ActiveSupport::Concern + + class_methods do + def fragment_by_converting_trix_attachments(content) + fragment_by_converting_editor_attachments(content) + end + deprecate :fragment_by_converting_trix_attachments, deprecator: ActionText.deprecator + + def from_trix_attachment(trix_attachment) + from_attributes(trix_attachment.attributes) + end + deprecate :from_trix_attachment, deprecator: ActionText.deprecator + end + + def to_trix_attachment(content = trix_attachment_content) + attributes = full_attributes.dup + attributes["content"] = content if content + TrixAttachment.from_attributes(attributes) + end + deprecate :to_trix_attachment, deprecator: ActionText.deprecator + + private + def trix_attachment_content + if partial_path = attachable.try(:to_trix_content_attachment_partial_path) + ActionText::Content.render(partial: partial_path, formats: :html, object: self, as: model_name.element) + end + end + end + end +end diff --git a/actiontext/lib/action_text/attribute.rb b/actiontext/lib/action_text/attribute.rb new file mode 100644 index 0000000000000..4f4d2e6e2ce4e --- /dev/null +++ b/actiontext/lib/action_text/attribute.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module Attribute + extend ActiveSupport::Concern + + class_methods do + # Provides access to a dependent RichText model that holds the body and + # attachments for a single named rich text attribute. This dependent attribute + # is lazily instantiated and will be auto-saved when it's been changed. Example: + # + # class Message < ActiveRecord::Base + # has_rich_text :content + # end + # + # message = Message.create!(content: "

Funny times!

") + # message.content? #=> true + # message.content.to_s # => "

Funny times!

" + # message.content.to_plain_text # => "Funny times!" + # + # The dependent RichText model will also automatically process attachments links + # as sent via the Trix-powered editor. These attachments are associated with the + # RichText model using Active Storage. + # + # If you wish to preload the dependent RichText model, you can use the named + # scope: + # + # Message.all.with_rich_text_content # Avoids N+1 queries when you just want the body, not the attachments. + # Message.all.with_rich_text_content_and_embeds # Avoids N+1 queries when you just want the body and attachments. + # Message.all.with_all_rich_text # Loads all rich text associations. + # + # #### Options + # + # * `:encrypted` - Pass true to encrypt the rich text attribute. The + # encryption will be non-deterministic. See + # `ActiveRecord::Encryption::EncryptableRecord.encrypts`. Default: false. + # + # * `:strict_loading` - Pass true to force strict loading. When omitted, + # `strict_loading:` will be set to the value of the + # `strict_loading_by_default` class attribute (false by default). + # + # * `:store_if_blank` - Pass false to not create RichText records with empty values, + # if a blank value is provided. Default: true. + # + # + # Note: Action Text relies on polymorphic associations, which in turn store + # class names in the database. When renaming classes that use `has_rich_text`, + # make sure to also update the class names in the + # `action_text_rich_texts.record_type` polymorphic type column of the + # corresponding rows. + def has_rich_text(name, encrypted: false, strict_loading: strict_loading_by_default, store_if_blank: true) + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + rich_text_#{name} || build_rich_text_#{name} + end + + def #{name}? + rich_text_#{name}.present? + end + CODE + + if store_if_blank + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name}=(body) + self.#{name}.body = body + end + CODE + else + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name}=(body) + if body.present? + self.#{name}.body = body + else + if #{name}? + self.#{name}.body = body + self.#{name}.mark_for_destruction + end + end + end + CODE + end + + rich_text_class_name = encrypted ? "ActionText::EncryptedRichText" : "ActionText::RichText" + has_one :"rich_text_#{name}", -> { where(name: name) }, + class_name: rich_text_class_name, as: :record, inverse_of: :record, autosave: true, dependent: :destroy, + strict_loading: strict_loading + + scope :"with_rich_text_#{name}", -> { includes("rich_text_#{name}") } + scope :"with_rich_text_#{name}_and_embeds", -> { includes("rich_text_#{name}": { embeds_attachments: :blob }) } + end + + # Eager load all dependent RichText models in bulk. + def with_all_rich_text + includes(rich_text_association_names) + end + + # Returns the names of all rich text associations. + def rich_text_association_names + reflect_on_all_associations(:has_one).collect(&:name).select { |n| n.start_with?("rich_text_") } + end + end + end +end diff --git a/actiontext/lib/action_text/content.rb b/actiontext/lib/action_text/content.rb new file mode 100644 index 0000000000000..a85a9899d1c46 --- /dev/null +++ b/actiontext/lib/action_text/content.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + # # Action Text Content + # + # The `ActionText::Content` class wraps an HTML fragment to add support for + # parsing, rendering and serialization. It can be used to extract links and + # attachments, convert the fragment to plain text, or serialize the fragment to + # the database. + # + # The ActionText::RichText record serializes the `body` attribute as + # `ActionText::Content`. + # + # class Message < ActiveRecord::Base + # has_rich_text :content + # end + # + # message = Message.create!(content: "

Funny times!

") + # body = message.content.body # => # + # body.to_s # => "

Funny times!

" + # body.to_plain_text # => "Funny times!" + class Content + include Rendering, Serialization, ContentHelper + + attr_reader :fragment + + delegate :deconstruct, to: :fragment + delegate :blank?, :empty?, :html_safe, :present?, to: :to_html # Delegating to to_html to avoid including the layout + + class << self + def fragment_by_canonicalizing_content(content) + fragment = ActionText::Attachment.fragment_by_canonicalizing_attachments(content) + fragment = ActionText::AttachmentGallery.fragment_by_canonicalizing_attachment_galleries(fragment) + fragment + end + end + + def initialize(content = nil, options = {}) + options.with_defaults! canonicalize: true + + if options[:canonicalize] + @fragment = self.class.fragment_by_canonicalizing_content(content) + else + @fragment = ActionText::Fragment.wrap(content) + end + end + + # Extracts links from the HTML fragment: + # + # html = 'Example' + # content = ActionText::Content.new(html) + # content.links # => ["http://example.com/"] + def links + @links ||= fragment.find_all("a[href]").map { |a| a["href"] }.uniq + end + + # Extracts ActionText::Attachment objects from the HTML fragment: + # + # attachable = ActiveStorage::Blob.first + # html = %Q() + # content = ActionText::Content.new(html) + # content.attachments # => [#
) + # content = ActionText::Content.new(html) + # content.attachables # => [attachable] + def attachables + @attachables ||= attachment_nodes.map do |node| + ActionText::Attachable.from_node(node) + end + end + + def append_attachables(attachables) + attachments = ActionText::Attachment.from_attachables(attachables) + self.class.new([self.to_s.presence, *attachments].compact.join("\n")) + end + + def render_attachments(**options, &block) + content = fragment.replace(ActionText::Attachment.tag_name) do |node| + if node.key?("content") + sanitized_content = sanitize_content_attachment(node.remove_attribute("content").to_s) + node["content"] = sanitized_content if sanitized_content.present? + end + block.call(attachment_for_node(node, **options)) + end + self.class.new(content, canonicalize: false) + end + + def render_attachment_galleries(&block) + content = ActionText::AttachmentGallery.fragment_by_replacing_attachment_gallery_nodes(fragment) do |node| + block.call(attachment_gallery_for_node(node)) + end + self.class.new(content, canonicalize: false) + end + + # Returns a plain-text version of the markup contained by the content, with tags + # removed but HTML entities encoded. + # + # content = ActionText::Content.new("

Funny times!

") + # content.to_plain_text # => "Funny times!" + # + # content = ActionText::Content.new("
safe
") + # content.to_plain_text # => "safeunsafe" + # + # NOTE: that the returned string is not HTML safe and should not be rendered in + # browsers without additional sanitization. + # + # content = ActionText::Content.new("<script>alert()</script>") + # content.to_plain_text # => "" + # ActionText::ContentHelper.sanitizer.sanitize(content.to_plain_text) # => "" + def to_plain_text + render_attachments(with_full_attributes: false, &:to_plain_text).fragment.to_plain_text + end + + def to_trix_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 + fragment.to_html + end + + def to_rendered_html_with_layout + render layout: "action_text/contents/content", partial: to_partial_path, formats: :html, locals: { content: self } + end + + def to_partial_path + "action_text/contents/content" + end + + # Safely transforms Content into an HTML String. + # + # content = ActionText::Content.new(content: "

Funny times!

") + # content.to_s # => "

Funny times!

" + # + # content = ActionText::Content.new("
safe
") + # content.to_s # => "
safeunsafe
" + def to_s + to_rendered_html_with_layout + end + + def as_json(*) + to_html + end + + def inspect + "#<#{self.class.name} #{to_html.truncate(25).inspect}>" + end + + def ==(other) + if self.class == other.class + to_html == other.to_html + elsif other.is_a?(self.class) + to_s == other.to_s + end + end + + private + def attachment_nodes + @attachment_nodes ||= fragment.find_all(ActionText::Attachment.tag_name) + end + + def attachment_gallery_nodes + @attachment_gallery_nodes ||= ActionText::AttachmentGallery.find_attachment_gallery_nodes(fragment) + end + + def attachment_for_node(node, with_full_attributes: true) + attachment = ActionText::Attachment.from_node(node) + with_full_attributes ? attachment.with_full_attributes : attachment + end + + def attachment_gallery_for_node(node) + ActionText::AttachmentGallery.from_node(node) + end + end +end + +ActiveSupport.run_load_hooks :action_text_content, ActionText::Content diff --git a/actiontext/lib/action_text/deprecator.rb b/actiontext/lib/action_text/deprecator.rb new file mode 100644 index 0000000000000..dfeff96e4c298 --- /dev/null +++ b/actiontext/lib/action_text/deprecator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end 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/encryption.rb b/actiontext/lib/action_text/encryption.rb new file mode 100644 index 0000000000000..0566112889ebb --- /dev/null +++ b/actiontext/lib/action_text/encryption.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module Encryption + def encrypt + transaction do + super + encrypt_rich_texts if has_encrypted_rich_texts? + end + end + + def decrypt + transaction do + super + decrypt_rich_texts if has_encrypted_rich_texts? + end + end + + private + def encrypt_rich_texts + encryptable_rich_texts.each(&:encrypt) + end + + def decrypt_rich_texts + encryptable_rich_texts.each(&:decrypt) + end + + def has_encrypted_rich_texts? + encryptable_rich_texts.present? + end + + def encryptable_rich_texts + @encryptable_rich_texts ||= self.class.rich_text_association_names + .filter_map { |attribute_name| send(attribute_name) } + .find_all { |record| record.is_a?(ActionText::EncryptedRichText) } + end + end +end diff --git a/actiontext/lib/action_text/engine.rb b/actiontext/lib/action_text/engine.rb new file mode 100644 index 0000000000000..c57c02c014e4a --- /dev/null +++ b/actiontext/lib/action_text/engine.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "rails" +require "action_controller/railtie" +require "active_record/railtie" +require "active_storage/engine" + +require "action_text" +require "action_text/trix" + +module ActionText + class Engine < Rails::Engine + isolate_namespace ActionText + 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 + #{root}/app/models + ) + + initializer "action_text.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_text] = ActionText.deprecator + end + + initializer "action_text.attribute" do + ActiveSupport.on_load(:active_record) do + include ActionText::Attribute + prepend ActionText::Encryption + end + end + + initializer "action_text.asset" do + if Rails.application.config.respond_to?(:assets) + Rails.application.config.assets.precompile += %w( actiontext.js actiontext.esm.js ) + end + end + + initializer "action_text.attachable" do + ActiveSupport.on_load(:active_storage_blob) do + include ActionText::Attachable + + def previewable_attachable? + representable? + end + + def attachable_plain_text_representation(caption = nil) + "[#{caption || filename}]" + 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 + end + + initializer "action_text.helper" do + %i[action_controller_base action_mailer].each do |base| + ActiveSupport.on_load(base) do + helper ActionText::Engine.helpers + end + 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 + around_action do |controller, action| + ActionText::Content.with_renderer(controller, &action) + end + end + end + end + + initializer "action_text.system_test_helper" do + ActiveSupport.on_load(:action_dispatch_system_test_case) do + require "action_text/system_test_helper" + include ActionText::SystemTestHelper + end + end + + initializer "action_text.configure" do |app| + ActionText::Attachment.tag_name = app.config.action_text.attachment_tag_name + end + + config.after_initialize do |app| + if klass = app.config.action_text.sanitizer_vendor + ActiveSupport.on_load(:action_view) do + ActionText::ContentHelper.sanitizer = klass.safe_list_sanitizer.new + end + end + end + end +end diff --git a/actiontext/lib/action_text/fixture_set.rb b/actiontext/lib/action_text/fixture_set.rb new file mode 100644 index 0000000000000..68492d828460c --- /dev/null +++ b/actiontext/lib/action_text/fixture_set.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + # # Action Text FixtureSet + # + # Fixtures are a way of organizing data that you want to test against; in short, + # sample data. + # + # To learn more about fixtures, read the ActiveRecord::FixtureSet documentation. + # + # ### YAML + # + # Like other Active Record-backed models, ActionText::RichText records inherit + # from ActiveRecord::Base instances and can therefore be populated by fixtures. + # + # Consider an `Article` class: + # + # class Article < ApplicationRecord + # has_rich_text :content + # end + # + # To declare fixture data for the related `content`, first declare fixture data + # for `Article` instances in `test/fixtures/articles.yml`: + # + # first: + # title: An Article + # + # Then declare the ActionText::RichText fixture data in + # `test/fixtures/action_text/rich_texts.yml`, making sure to declare each + # entry's `record:` key as a polymorphic relationship: + # + # first: + # record: first (Article) + # name: content + # body:
Hello, world.
+ # + # When processed, Active Record will insert database records for each fixture + # entry and will ensure the Action Text relationship is intact. + class FixtureSet + # Fixtures support Action Text attachments as part of their `body` HTML. + # + # ### Examples + # + # For example, consider a second `Article` fixture declared in + # `test/fixtures/articles.yml`: + # + # second: + # title: Another Article + # + # You can attach a mention of `articles(:first)` to `second`'s `content` by + # embedding a call to `ActionText::FixtureSet.attachment` in the `body:` value + # in `test/fixtures/action_text/rich_texts.yml`: + # + # second: + # record: second (Article) + # name: content + # body:
Hello, <%= ActionText::FixtureSet.attachment("articles", :first) %>
+ # + 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/fragment.rb b/actiontext/lib/action_text/fragment.rb new file mode 100644 index 0000000000000..34edde6013fbf --- /dev/null +++ b/actiontext/lib/action_text/fragment.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + class Fragment + class << self + def wrap(fragment_or_html) + case fragment_or_html + when self + fragment_or_html + when Nokogiri::XML::DocumentFragment # base class for all fragments + new(fragment_or_html) + else + from_html(fragment_or_html) + end + end + + def from_html(html) + new(ActionText::HtmlConversion.fragment_for_html(html.to_s.strip)) + end + end + + attr_reader :source + + delegate :deconstruct, to: "source.elements" + + def initialize(source) + @source = source + end + + def find_all(selector) + source.css(selector) + end + + def update + yield source = self.source.dup + self.class.new(source) + end + + def replace(selector) + update do |source| + source.css(selector).each do |node| + replacement_node = yield(node) + node.replace(replacement_node.to_s) if node != replacement_node + end + end + end + + def to_plain_text + @plain_text ||= PlainTextConversion.node_to_plain_text(source) + end + + def to_html + @html ||= HtmlConversion.node_to_html(source) + end + + def to_s + to_html + end + end +end diff --git a/actiontext/lib/action_text/gem_version.rb b/actiontext/lib/action_text/gem_version.rb new file mode 100644 index 0000000000000..9bad1e6af8884 --- /dev/null +++ b/actiontext/lib/action_text/gem_version.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + # Returns the currently loaded version of Action Text as a `Gem::Version`. + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 8 + MINOR = 2 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/actiontext/lib/action_text/html_conversion.rb b/actiontext/lib/action_text/html_conversion.rb new file mode 100644 index 0000000000000..e7712c0326b60 --- /dev/null +++ b/actiontext/lib/action_text/html_conversion.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module HtmlConversion + extend self + + def node_to_html(node) + node.to_html(save_with: Nokogiri::XML::Node::SaveOptions::AS_HTML) + end + + def fragment_for_html(html) + document.fragment(html) + end + + def create_element(tag_name, attributes = {}) + document.create_element(tag_name, attributes) + end + + private + def document + ActionText.html_document_class.new.tap { |doc| doc.encoding = "UTF-8" } + end + end +end diff --git a/actiontext/lib/action_text/plain_text_conversion.rb b/actiontext/lib/action_text/plain_text_conversion.rb new file mode 100644 index 0000000000000..dc69e1309ef38 --- /dev/null +++ b/actiontext/lib/action_text/plain_text_conversion.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module PlainTextConversion + extend self + + def node_to_plain_text(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, child_values) + if respond_to?(plain_text_method_for_node(node), true) + send(plain_text_method_for_node(node), node, child_values) + else + plain_text_for_child_values(child_values) + end + end + + def plain_text_method_for_node(node) + :"plain_text_for_#{node.name}_node" + end + + def plain_text_for_child_values(child_values) + child_values.join + end + + def plain_text_for_unsupported_node(node, _child_values) + "" + end + + %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, 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, 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, _child_values) + "\n" + end + + def plain_text_for_text_node(node, _child_values) + remove_trailing_newlines(node.text) + end + + 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, child_values) + "[#{remove_trailing_newlines(plain_text_for_child_values(child_values))}]" + end + + def plain_text_for_blockquote_node(node, child_values) + text = plain_text_for_block(node, child_values) + return "“â€" if text.blank? + + text = text.dup + text.insert(text.rindex(/\S/) + 1, "â€") + text.insert(text.index(/\S/), "“") + text + end + + 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" + end + + def remove_trailing_newlines(text) + text.chomp("") + end + + def bullet_for_li_node(node) + if list_node_name_for_li_node(node) == "ol" + index = node.parent.elements.index(node) + "#{index + 1}." + else + "•" + end + end + + def list_node_name_for_li_node(node) + node.ancestors.lazy.map(&:name).grep(/^[uo]l$/).first + end + + def indentation_for_li_node(node) + depth = list_node_depth_for_node(node) + if depth > 1 + " " * (depth - 1) + end + end + + def list_node_depth_for_node(node) + node.ancestors.map(&:name).grep(/^[uo]l$/).count + end + + def break_if_nested_list(node, text) + if list_node_depth_for_node(node) > 0 + "\n#{text}" + else + 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/rendering.rb b/actiontext/lib/action_text/rendering.rb new file mode 100644 index 0000000000000..0faf06f88a115 --- /dev/null +++ b/actiontext/lib/action_text/rendering.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/module/attribute_accessors_per_thread" + +module ActionText + module Rendering # :nodoc: + extend ActiveSupport::Concern + + included do + thread_cattr_accessor :renderer, instance_accessor: false + delegate :render, to: :class + end + + class_methods do + def action_controller_renderer + @action_controller_renderer ||= Class.new(ActionController::Base).renderer + end + + def with_renderer(renderer) + previous_renderer = self.renderer + self.renderer = renderer + yield + ensure + self.renderer = previous_renderer + end + + def render(*args, &block) + (renderer || action_controller_renderer).render_to_string(*args, &block) + end + end + end +end diff --git a/actiontext/lib/action_text/serialization.rb b/actiontext/lib/action_text/serialization.rb new file mode 100644 index 0000000000000..5d6aedd65af98 --- /dev/null +++ b/actiontext/lib/action_text/serialization.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module Serialization + extend ActiveSupport::Concern + + class_methods do + def load(content) + new(content) if content + end + + def dump(content) + case content + when nil + nil + when self + content.to_html + when ActionText::RichText + content.body.to_html + else + new(content).to_html + end + end + end + + # Marshal compatibility + + class_methods do + alias_method :_load, :load + end + + def _dump(*) + self.class.dump(self) + end + end +end diff --git a/actiontext/lib/action_text/system_test_helper.rb b/actiontext/lib/action_text/system_test_helper.rb new file mode 100644 index 0000000000000..bbe99d9fe6590 --- /dev/null +++ b/actiontext/lib/action_text/system_test_helper.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + module SystemTestHelper + # Locates a Trix editor and fills it in with the given HTML. + # + # The editor can be found by: + # + # * its `id` + # * its `placeholder` + # * the text from its `label` element + # * its `aria-label` + # * the `name` of its input + # + # Additional options are forwarded to Capybara as filters + # + # Examples: + # + # # + # fill_in_rich_textarea "message_content", with: "Hello world!" + # + # # + # fill_in_rich_textarea "Your message here", with: "Hello world!" + # + # # + # # + # fill_in_rich_textarea "Message content", with: "Hello world!" + # + # # + # fill_in_rich_textarea "Message content", with: "Hello world!" + # + # # + # # + # fill_in_rich_textarea "message[content]", with: "Hello world!" + 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 +end + +%i[rich_textarea rich_text_area].each do |rich_textarea| + 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 + 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.where \ + XPath.attr(:id).equals(locator) | + XPath.attr(:placeholder).equals(locator) | + XPath.attr(:"aria-label").equals(locator) | + XPath.attr(:input).equals(input_located_by_name) | + XPath.attr(:id).equals(input_located_by_label) + end + end + end +end diff --git a/actiontext/lib/action_text/trix_attachment.rb b/actiontext/lib/action_text/trix_attachment.rb new file mode 100644 index 0000000000000..53c8ec2f522fa --- /dev/null +++ b/actiontext/lib/action_text/trix_attachment.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionText + # DEPRECATED + class TrixAttachment + TAG_NAME = "figure" + SELECTOR = "[data-trix-attachment]" + + COMPOSED_ATTRIBUTES = %w( caption presentation ) + ATTRIBUTES = %w( sgid contentType url href filename filesize width height previewable content ) + COMPOSED_ATTRIBUTES + ATTRIBUTE_TYPES = { + "previewable" => ->(value) { value.to_s == "true" }, + "filesize" => ->(value) { Integer(value.to_s, exception: false) || value }, + "width" => ->(value) { Integer(value.to_s, exception: false) }, + "height" => ->(value) { Integer(value.to_s, exception: false) }, + :default => ->(value) { value.to_s } + } + + class << self + def from_attributes(attributes) + attributes = process_attributes(attributes) + + trix_attachment_attributes = attributes.except(*COMPOSED_ATTRIBUTES) + trix_attributes = attributes.slice(*COMPOSED_ATTRIBUTES) + + node = ActionText::HtmlConversion.create_element(TAG_NAME) + node["data-trix-attachment"] = JSON.generate(trix_attachment_attributes) + node["data-trix-attributes"] = JSON.generate(trix_attributes) if trix_attributes.any? + + new(node) + end + + private + def process_attributes(attributes) + typecast_attribute_values(transform_attribute_keys(attributes)) + end + + def transform_attribute_keys(attributes) + attributes.transform_keys { |key| key.to_s.underscore.camelize(:lower) } + end + + def typecast_attribute_values(attributes) + attributes.to_h do |key, value| + typecast = ATTRIBUTE_TYPES[key] || ATTRIBUTE_TYPES[:default] + [key, typecast.call(value)] + end + end + end + + attr_reader :node + + def initialize(node) + @node = node + end + + def attributes + @attributes ||= attachment_attributes.merge(composed_attributes).slice(*ATTRIBUTES) + end + + def to_html + ActionText::HtmlConversion.node_to_html(node) + end + + def to_s + to_html + end + + private + def attachment_attributes + read_json_object_attribute("data-trix-attachment") + end + + def composed_attributes + read_json_object_attribute("data-trix-attributes") + end + + def read_json_object_attribute(name) + read_json_attribute(name) || {} + end + + def read_json_attribute(name) + if value = node[name] + begin + JSON.parse(value) + rescue => e + Rails.logger.error "[#{self.class.name}] Couldn't parse JSON #{value} from NODE #{node.inspect}" + Rails.logger.error "[#{self.class.name}] Failed with #{e.class}: #{e.backtrace}" + nil + end + end + end + end +end diff --git a/actiontext/lib/action_text/version.rb b/actiontext/lib/action_text/version.rb new file mode 100644 index 0000000000000..66a6b42af0cf4 --- /dev/null +++ b/actiontext/lib/action_text/version.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# :markup: markdown + +require_relative "gem_version" + +module ActionText + # Returns the currently loaded version of Action Text as a `Gem::Version`. + def self.version + gem_version + end +end diff --git a/actiontext/lib/generators/action_text/install/install_generator.rb b/actiontext/lib/generators/action_text/install/install_generator.rb new file mode 100644 index 0000000000000..15348505eb4dc --- /dev/null +++ b/actiontext/lib/generators/action_text/install/install_generator.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "pathname" +require "json" + +module ActionText + 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" + elsif using_node? + run "yarn add @rails/actiontext" + end + end + + 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 "@rails/actiontext"\n) + else + say <<~INSTRUCTIONS, :green + 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 "@rails/actiontext", to: "actiontext.esm.js"\n) + end + end + + def create_actiontext_files + template "actiontext.css", "app/assets/stylesheets/actiontext.css" + + gem_root = "#{__dir__}/../../../.." + + copy_file "#{gem_root}/app/views/active_storage/blobs/_blob.html.erb", + "app/views/active_storage/blobs/_blob.html.erb" + + copy_file "#{gem_root}/app/views/layouts/action_text/contents/_content.html.erb", + "app/views/layouts/action_text/contents/_content.html.erb" + end + + def create_migrations + rails_command "railties:install:migrations FROM=active_storage,action_text", inline: true + end + + def using_js_runtime? + @using_js_runtime ||= Pathname(destination_root).join("package.json").exist? + end + + def using_bun? + # Cannot assume yarn.lock has been generated yet so we look for a file known to + # be generated by the jsbundling-rails gem + @using_bun ||= using_js_runtime? && Pathname(destination_root).join("bun.config.js").exist? + end + + def using_node? + # Bun is the only runtime that _isn't_ node. + @using_node ||= using_js_runtime? && !Pathname(destination_root).join("bun.config.js").exist? + end + + hook_for :test_framework + end + end +end diff --git a/actiontext/lib/generators/action_text/install/templates/actiontext.css b/actiontext/lib/generators/action_text/install/templates/actiontext.css new file mode 100644 index 0000000000000..9b6bcb0649dc5 --- /dev/null +++ b/actiontext/lib/generators/action_text/install/templates/actiontext.css @@ -0,0 +1,440 @@ +/* + * Default Trix editor styles. See Action Text overwrites below. +*/ + +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: black; } + 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-cursor-target]::-moz-selection, trix-editor [data-trix-mutable] ::-moz-selection { + background: none; } + +trix-editor [data-trix-mutable]::selection, +trix-editor [data-trix-cursor-target]::selection, trix-editor [data-trix-mutable] ::selection { + background: none; } + +trix-editor .attachment__caption-editor:focus[data-trix-mutable]::-moz-selection { + background: highlight; } + +trix-editor .attachment__caption-editor:focus[data-trix-mutable]::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: ' \2022 '; } + .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%; } + +/* + * We need to override trix.css’s image gallery styles to accommodate the + * element we wrap around attachments. Otherwise, + * images in galleries will be squished by the max-width: 33%; rule. +*/ +.trix-content .attachment-gallery > action-text-attachment, +.trix-content .attachment-gallery > .attachment { + flex: 1 0 33%; + padding: 0 0.5em; + max-width: 33%; +} + +.trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment, +.trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment, +.trix-content .attachment-gallery.attachment-gallery--4 > .attachment { + flex-basis: 50%; + max-width: 50%; +} + +.trix-content action-text-attachment .attachment { + padding: 0 !important; + max-width: 100% !important; +} diff --git a/actiontext/lib/rails/generators/test_unit/install_generator.rb b/actiontext/lib/rails/generators/test_unit/install_generator.rb new file mode 100644 index 0000000000000..f0479aa88c9a1 --- /dev/null +++ b/actiontext/lib/rails/generators/test_unit/install_generator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# :markup: markdown + +module TestUnit + module Generators + class InstallGenerator < ::Rails::Generators::Base + source_root File.expand_path("templates", __dir__) + + def create_test_files + template "fixtures.yml", "test/fixtures/action_text/rich_texts.yml" + end + end + end +end diff --git a/actiontext/lib/rails/generators/test_unit/templates/fixtures.yml b/actiontext/lib/rails/generators/test_unit/templates/fixtures.yml new file mode 100644 index 0000000000000..8b371ea604af5 --- /dev/null +++ b/actiontext/lib/rails/generators/test_unit/templates/fixtures.yml @@ -0,0 +1,4 @@ +# one: +# record: name_of_fixture (ClassOfFixture) +# name: content +# body:

In a million stars!

diff --git a/actiontext/lib/tasks/actiontext.rake b/actiontext/lib/tasks/actiontext.rake new file mode 100644 index 0000000000000..4d5da8669d4f0 --- /dev/null +++ b/actiontext/lib/tasks/actiontext.rake @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +desc "Copy over the migration, stylesheet, and JavaScript files" +task "action_text:install" do + Rails::Command.invoke :generate, ["action_text:install"] +end diff --git a/actiontext/package.json b/actiontext/package.json new file mode 100644 index 0000000000000..51de2cdac5927 --- /dev/null +++ b/actiontext/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rails/actiontext", + "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", + "files": [ + "app/assets/javascripts/*.js" + ], + "homepage": "https://rubyonrails.org/", + "repository": { + "type": "git", + "url": "git+https://github.com/rails/rails.git" + }, + "bugs": { + "url": "https://github.com/rails/rails/issues" + }, + "author": "37signals LLC", + "contributors": [ + "Javan Makhmali ", + "Sam Stephenson " + ], + "license": "MIT", + "dependencies": { + "@rails/activestorage": ">= 8.1.0-alpha" + }, + "peerDependencies": { + "trix": "^2.0.0" + }, + "devDependencies": { + "@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": { + "build": "rollup --config rollup.config.js" + } +} diff --git a/actiontext/rollup.config.js b/actiontext/rollup.config.js new file mode 100644 index 0000000000000..00c19ad52553a --- /dev/null +++ b/actiontext/rollup.config.js @@ -0,0 +1,41 @@ +import resolve from "@rollup/plugin-node-resolve" +import commonjs from "@rollup/plugin-commonjs" +import { terser } from "rollup-plugin-terser" + +const terserOptions = { + mangle: false, + compress: false, + format: { + beautify: true, + indent_level: 2 + } +} + +export default [ + { + input: "app/javascript/actiontext/index.js", + output: { + file: "app/assets/javascripts/actiontext.js", + format: "umd", + name: "ActionText" + }, + plugins: [ + resolve(), + commonjs(), + terser(terserOptions) + ] + }, + + { + input: "app/javascript/actiontext/index.js", + output: { + file: "app/assets/javascripts/actiontext.esm.js", + format: "es" + }, + plugins: [ + resolve(), + commonjs(), + terser(terserOptions) + ] + } +] diff --git a/actiontext/test/application_system_test_case.rb b/actiontext/test/application_system_test_case.rb new file mode 100644 index 0000000000000..a24f473598c5b --- /dev/null +++ b/actiontext/test/application_system_test_case.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome +end + +Capybara.server = :puma, { Silent: true } diff --git a/actiontext/test/dummy/Rakefile b/actiontext/test/dummy/Rakefile new file mode 100644 index 0000000000000..9a5ea7383aa83 --- /dev/null +++ b/actiontext/test/dummy/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/actiontext/test/dummy/app/assets/config/manifest.js b/actiontext/test/dummy/app/assets/config/manifest.js new file mode 100644 index 0000000000000..4c9ab805cb7e1 --- /dev/null +++ b/actiontext/test/dummy/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_tree ../../javascript .js diff --git a/actiontext/test/dummy/app/assets/images/.keep b/actiontext/test/dummy/app/assets/images/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/dummy/app/assets/stylesheets/application.css b/actiontext/test/dummy/app/assets/stylesheets/application.css new file mode 100644 index 0000000000000..6fbebd3213bbd --- /dev/null +++ b/actiontext/test/dummy/app/assets/stylesheets/application.css @@ -0,0 +1,16 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require trix + *= require_tree . + *= require_self + */ diff --git a/railties/lib/rails/generators/css/assets/templates/stylesheet.css b/actiontext/test/dummy/app/assets/stylesheets/messages.css similarity index 100% rename from railties/lib/rails/generators/css/assets/templates/stylesheet.css rename to actiontext/test/dummy/app/assets/stylesheets/messages.css diff --git a/actiontext/test/dummy/app/assets/stylesheets/scaffold.css b/actiontext/test/dummy/app/assets/stylesheets/scaffold.css new file mode 100644 index 0000000000000..cd4f3de38d1f0 --- /dev/null +++ b/actiontext/test/dummy/app/assets/stylesheets/scaffold.css @@ -0,0 +1,80 @@ +body { + background-color: #fff; + color: #333; + margin: 33px; +} + +body, p, ol, ul, td { + font-family: verdana, arial, helvetica, sans-serif; + font-size: 13px; + line-height: 18px; +} + +pre { + background-color: #eee; + padding: 10px; + font-size: 11px; +} + +a { + color: #000; +} + +a:visited { + color: #666; +} + +a:hover { + color: #fff; + background-color: #000; +} + +th { + padding-bottom: 5px; +} + +td { + padding: 0 5px 7px; +} + +div.field, +div.actions { + margin-bottom: 10px; +} + +#notice { + color: green; +} + +.field_with_errors { + padding: 2px; + background-color: red; + display: table; +} + +#error_explanation { + width: 450px; + border: 2px solid red; + padding: 7px 7px 0; + margin-bottom: 20px; + background-color: #f0f0f0; +} + +#error_explanation h2 { + text-align: left; + font-weight: bold; + padding: 5px 5px 5px 15px; + font-size: 12px; + margin: -7px -7px 0; + background-color: #c00; + color: #fff; +} + +#error_explanation ul li { + font-size: 12px; + list-style: square; +} + +label { + display: block; +} diff --git a/actiontext/test/dummy/app/channels/application_cable/channel.rb b/actiontext/test/dummy/app/channels/application_cable/channel.rb new file mode 100644 index 0000000000000..d67269728300b --- /dev/null +++ b/actiontext/test/dummy/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/actiontext/test/dummy/app/channels/application_cable/connection.rb b/actiontext/test/dummy/app/channels/application_cable/connection.rb new file mode 100644 index 0000000000000..0ff5442f476f9 --- /dev/null +++ b/actiontext/test/dummy/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/actiontext/test/dummy/app/controllers/admin/messages_controller.rb b/actiontext/test/dummy/app/controllers/admin/messages_controller.rb new file mode 100644 index 0000000000000..7c3316324b514 --- /dev/null +++ b/actiontext/test/dummy/app/controllers/admin/messages_controller.rb @@ -0,0 +1,5 @@ +class Admin::MessagesController < ActionController::Base + def show + @message = Message.find(params[:id]) + end +end diff --git a/actiontext/test/dummy/app/controllers/concerns/.keep b/actiontext/test/dummy/app/controllers/concerns/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/dummy/app/controllers/messages_controller.rb b/actiontext/test/dummy/app/controllers/messages_controller.rb new file mode 100644 index 0000000000000..230e8ad3f91c5 --- /dev/null +++ b/actiontext/test/dummy/app/controllers/messages_controller.rb @@ -0,0 +1,62 @@ +class MessagesController < ActionController::Base + before_action :set_message, only: [:show, :edit, :update, :destroy] + + # This class intentionally does not extend ApplicationController, so the + # layout must be set manually. See commit 614e813 for details + layout "application" + + # GET /messages + def index + @messages = Message.all + end + + # GET /messages/1 + def show + end + + # GET /messages/new + def new + @message = Message.new + end + + # GET /messages/1/edit + def edit + end + + # POST /messages + def create + @message = Message.new(message_params) + + if @message.save + redirect_to @message, notice: 'Message was successfully created.' + else + render :new, status: :unprocessable_entity + end + end + + # PATCH/PUT /messages/1 + def update + if @message.update(message_params) + redirect_to @message, notice: 'Message was successfully updated.' + else + render :edit, status: :unprocessable_entity + end + end + + # DELETE /messages/1 + def destroy + @message.destroy + redirect_to messages_url, notice: 'Message was successfully destroyed.' + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_message + @message = Message.find(params[:id]) + end + + # Only allow a trusted parameter list through. + def message_params + params.expect(message: [:subject, :content]) + end +end diff --git a/actiontext/test/dummy/app/helpers/application_helper.rb b/actiontext/test/dummy/app/helpers/application_helper.rb new file mode 100644 index 0000000000000..de6be7945c6a5 --- /dev/null +++ b/actiontext/test/dummy/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/actiontext/test/dummy/app/helpers/messages_helper.rb b/actiontext/test/dummy/app/helpers/messages_helper.rb new file mode 100644 index 0000000000000..f1bca9f6ca004 --- /dev/null +++ b/actiontext/test/dummy/app/helpers/messages_helper.rb @@ -0,0 +1,2 @@ +module MessagesHelper +end diff --git a/actiontext/test/dummy/app/javascript/application.js b/actiontext/test/dummy/app/javascript/application.js new file mode 100644 index 0000000000000..807fb4ba44de6 --- /dev/null +++ b/actiontext/test/dummy/app/javascript/application.js @@ -0,0 +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/jobs/application_job.rb b/actiontext/test/dummy/app/jobs/application_job.rb new file mode 100644 index 0000000000000..d394c3d106230 --- /dev/null +++ b/actiontext/test/dummy/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/actiontext/test/dummy/app/jobs/broadcast_job.rb b/actiontext/test/dummy/app/jobs/broadcast_job.rb new file mode 100644 index 0000000000000..109b34ef502c8 --- /dev/null +++ b/actiontext/test/dummy/app/jobs/broadcast_job.rb @@ -0,0 +1,9 @@ +class BroadcastJob < ApplicationJob + def perform(file, message) + File.write(file, <<~HTML) + + + + HTML + end +end diff --git a/actiontext/test/dummy/app/mailers/application_mailer.rb b/actiontext/test/dummy/app/mailers/application_mailer.rb new file mode 100644 index 0000000000000..3c34c8148f105 --- /dev/null +++ b/actiontext/test/dummy/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/actiontext/test/dummy/app/mailers/messages_mailer.rb b/actiontext/test/dummy/app/mailers/messages_mailer.rb new file mode 100644 index 0000000000000..ce0af3b3cac45 --- /dev/null +++ b/actiontext/test/dummy/app/mailers/messages_mailer.rb @@ -0,0 +1,6 @@ +class MessagesMailer < ApplicationMailer + def notification + @message = params[:message] + mail to: params[:recipient], subject: "NEW MESSAGE: #{@message.subject}" + end +end diff --git a/actiontext/test/dummy/app/models/application_record.rb b/actiontext/test/dummy/app/models/application_record.rb new file mode 100644 index 0000000000000..b63caeb8a5c4a --- /dev/null +++ b/actiontext/test/dummy/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/actiontext/test/dummy/app/models/concerns/.keep b/actiontext/test/dummy/app/models/concerns/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/dummy/app/models/encrypted_message.rb b/actiontext/test/dummy/app/models/encrypted_message.rb new file mode 100644 index 0000000000000..fec02f2c06e26 --- /dev/null +++ b/actiontext/test/dummy/app/models/encrypted_message.rb @@ -0,0 +1,3 @@ +class EncryptedMessage < Message + has_rich_text :content, encrypted: true +end \ No newline at end of file diff --git a/actiontext/test/dummy/app/models/message.rb b/actiontext/test/dummy/app/models/message.rb new file mode 100644 index 0000000000000..7bce50753ca0c --- /dev/null +++ b/actiontext/test/dummy/app/models/message.rb @@ -0,0 +1,7 @@ +class Message < ApplicationRecord + has_rich_text :content + has_rich_text :body + + has_one :review + accepts_nested_attributes_for :review +end diff --git a/actiontext/test/dummy/app/models/message_without_blanks.rb b/actiontext/test/dummy/app/models/message_without_blanks.rb new file mode 100644 index 0000000000000..e7c7d0805dd7f --- /dev/null +++ b/actiontext/test/dummy/app/models/message_without_blanks.rb @@ -0,0 +1,5 @@ +class MessageWithoutBlanks < ApplicationRecord + self.table_name = Message.table_name + + has_rich_text :content, store_if_blank: false +end diff --git a/actiontext/test/dummy/app/models/message_without_blanks_with_content_validation.rb b/actiontext/test/dummy/app/models/message_without_blanks_with_content_validation.rb new file mode 100644 index 0000000000000..daf266bbedce9 --- /dev/null +++ b/actiontext/test/dummy/app/models/message_without_blanks_with_content_validation.rb @@ -0,0 +1,3 @@ +class MessageWithoutBlanksWithContentValidation < MessageWithoutBlanks + validates :content, presence: true +end diff --git a/actiontext/test/dummy/app/models/page.rb b/actiontext/test/dummy/app/models/page.rb new file mode 100644 index 0000000000000..dfebf282a77d8 --- /dev/null +++ b/actiontext/test/dummy/app/models/page.rb @@ -0,0 +1,4 @@ +class Page < ApplicationRecord + include ActionText::Attachable +end + diff --git a/actiontext/test/dummy/app/models/person.rb b/actiontext/test/dummy/app/models/person.rb new file mode 100644 index 0000000000000..71e35949e739a --- /dev/null +++ b/actiontext/test/dummy/app/models/person.rb @@ -0,0 +1,19 @@ +class Person < ApplicationRecord + include ActionText::Attachable + + def self.to_missing_attachable_partial_path + "people/missing_attachable" + 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 + + def to_attachable_partial_path + "people/attachable" + end +end diff --git a/actiontext/test/dummy/app/models/review.rb b/actiontext/test/dummy/app/models/review.rb new file mode 100644 index 0000000000000..e54a37685d74d --- /dev/null +++ b/actiontext/test/dummy/app/models/review.rb @@ -0,0 +1,5 @@ +class Review < ApplicationRecord + belongs_to :message + + has_rich_text :content +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/admin/messages/show.html.erb b/actiontext/test/dummy/app/views/admin/messages/show.html.erb new file mode 100644 index 0000000000000..ba9067f2018dc --- /dev/null +++ b/actiontext/test/dummy/app/views/admin/messages/show.html.erb @@ -0,0 +1,16 @@ +
+
Subject
+
+ <%= @message.subject %> +
+ +
Content (Plain Text)
+
+
<%= @message.content.to_plain_text %>
+
+ +
Content (HTML)
+
+
<%= @message.content %>
+
+
diff --git a/actiontext/test/dummy/app/views/layouts/application.html.erb b/actiontext/test/dummy/app/views/layouts/application.html.erb new file mode 100644 index 0000000000000..8217faf46d48e --- /dev/null +++ b/actiontext/test/dummy/app/views/layouts/application.html.erb @@ -0,0 +1,16 @@ + + + + Dummy + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "application" %> + <%= javascript_importmap_tags %> + + + + <%= yield %> + + diff --git a/actiontext/test/dummy/app/views/layouts/mailer.html.erb b/actiontext/test/dummy/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000000000..3aac9002edca7 --- /dev/null +++ b/actiontext/test/dummy/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/actiontext/test/dummy/app/views/layouts/mailer.text.erb b/actiontext/test/dummy/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000000000..37f0bddbd746b --- /dev/null +++ b/actiontext/test/dummy/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/actiontext/test/dummy/app/views/messages/_form.html.erb b/actiontext/test/dummy/app/views/messages/_form.html.erb new file mode 100644 index 0000000000000..d3a36cd7b631b --- /dev/null +++ b/actiontext/test/dummy/app/views/messages/_form.html.erb @@ -0,0 +1,42 @@ +<%= form_with(model: message) do |form| %> + <% if message.errors.any? %> +
+

<%= pluralize(message.errors.count, "error") %> prohibited this message from being saved:

+ +
    + <% message.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :subject %> + <%= form.text_field :subject %> +
+ +
+ + + <% Person.all.each do |person| %> + + <% end %> + + + <%= form.label :content, "Message content label" %> + <%= form.rich_textarea :content, class: "trix-content", toolbar: "toolbar", + placeholder: "Your message here", aria: { label: "Message content aria-label" } %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/actiontext/test/dummy/app/views/messages/edit.html.erb b/actiontext/test/dummy/app/views/messages/edit.html.erb new file mode 100644 index 0000000000000..90ad68c7881e5 --- /dev/null +++ b/actiontext/test/dummy/app/views/messages/edit.html.erb @@ -0,0 +1,6 @@ +

Editing Message

+ +<%= render 'form', message: @message %> + +<%= link_to 'Show', @message %> | +<%= link_to 'Back', messages_path %> diff --git a/actiontext/test/dummy/app/views/messages/edit.json.erb b/actiontext/test/dummy/app/views/messages/edit.json.erb new file mode 100644 index 0000000000000..9c4d24a50a4c1 --- /dev/null +++ b/actiontext/test/dummy/app/views/messages/edit.json.erb @@ -0,0 +1,4 @@ +{ + "id": <%= @message.id %>, + "form": "<%= j render partial: "form", formats: :html, locals: { message: @message } %>" +} diff --git a/actiontext/test/dummy/app/views/messages/index.html.erb b/actiontext/test/dummy/app/views/messages/index.html.erb new file mode 100644 index 0000000000000..a8c97468c6900 --- /dev/null +++ b/actiontext/test/dummy/app/views/messages/index.html.erb @@ -0,0 +1,29 @@ +

<%= notice %>

+ +

Messages

+ + + + + + + + + + + + <% @messages.each do |message| %> + + + + + + + + <% end %> + +
SubjectContent
<%= message.subject %><%= message.content %><%= link_to 'Show', message %><%= link_to 'Edit', edit_message_path(message) %><%= link_to 'Destroy', message, method: :delete, data: { confirm: 'Are you sure?' } %>
+ +
+ +<%= link_to 'New Message', new_message_path %> diff --git a/actiontext/test/dummy/app/views/messages/new.html.erb b/actiontext/test/dummy/app/views/messages/new.html.erb new file mode 100644 index 0000000000000..6cbd3b8ffe5c0 --- /dev/null +++ b/actiontext/test/dummy/app/views/messages/new.html.erb @@ -0,0 +1,5 @@ +

New Message

+ +<%= render 'form', message: @message %> + +<%= link_to 'Back', messages_path %> diff --git a/actiontext/test/dummy/app/views/messages/show.html.erb b/actiontext/test/dummy/app/views/messages/show.html.erb new file mode 100644 index 0000000000000..50fa9534212bd --- /dev/null +++ b/actiontext/test/dummy/app/views/messages/show.html.erb @@ -0,0 +1,8 @@ +

<%= notice %>

+ +

<%= @message.subject %>

+ +
<%= @message.content %>
+ +<%= link_to 'Edit', edit_message_path(@message) %> | +<%= link_to 'Back', messages_path %> diff --git a/actiontext/test/dummy/app/views/messages/show.json.erb b/actiontext/test/dummy/app/views/messages/show.json.erb new file mode 100644 index 0000000000000..7db887350ad89 --- /dev/null +++ b/actiontext/test/dummy/app/views/messages/show.json.erb @@ -0,0 +1,5 @@ +{ + "id": <%= @message.id %>, + "subject": "<%= j @message.subject %>", + "content": "<%= j @message.content %>" +} diff --git a/actiontext/test/dummy/app/views/messages_mailer/notification.html.erb b/actiontext/test/dummy/app/views/messages_mailer/notification.html.erb new file mode 100644 index 0000000000000..e65604cecf967 --- /dev/null +++ b/actiontext/test/dummy/app/views/messages_mailer/notification.html.erb @@ -0,0 +1 @@ +
<%= @message.content %>
diff --git a/actiontext/test/dummy/app/views/people/_attachable.html.erb b/actiontext/test/dummy/app/views/people/_attachable.html.erb new file mode 100644 index 0000000000000..b423eb498119a --- /dev/null +++ b/actiontext/test/dummy/app/views/people/_attachable.html.erb @@ -0,0 +1 @@ +<%= person.name %> diff --git a/actiontext/test/dummy/app/views/people/_missing_attachable.html.erb b/actiontext/test/dummy/app/views/people/_missing_attachable.html.erb new file mode 100644 index 0000000000000..9bf6141bc0dac --- /dev/null +++ b/actiontext/test/dummy/app/views/people/_missing_attachable.html.erb @@ -0,0 +1 @@ +Missing person diff --git a/actiontext/test/dummy/app/views/people/_trix_content_attachment.html.erb b/actiontext/test/dummy/app/views/people/_trix_content_attachment.html.erb new file mode 100644 index 0000000000000..7db2334126d20 --- /dev/null +++ b/actiontext/test/dummy/app/views/people/_trix_content_attachment.html.erb @@ -0,0 +1,3 @@ + + <%= person.name %> + diff --git a/actiontext/test/dummy/bin/rails b/actiontext/test/dummy/bin/rails new file mode 100755 index 0000000000000..efc0377492f7e --- /dev/null +++ b/actiontext/test/dummy/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/actiontext/test/dummy/bin/rake b/actiontext/test/dummy/bin/rake new file mode 100755 index 0000000000000..4fbf10b960ef7 --- /dev/null +++ b/actiontext/test/dummy/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/actiontext/test/dummy/bin/setup b/actiontext/test/dummy/bin/setup new file mode 100755 index 0000000000000..3cd5a9d7801ca --- /dev/null +++ b/actiontext/test/dummy/bin/setup @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +require "fileutils" + +# path to your application root. +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + puts "\n== Restarting application server ==" + system! "bin/rails restart" +end diff --git a/actiontext/test/dummy/config.ru b/actiontext/test/dummy/config.ru new file mode 100644 index 0000000000000..4a3c09a6889a9 --- /dev/null +++ b/actiontext/test/dummy/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/actiontext/test/dummy/config/application.rb b/actiontext/test/dummy/config/application.rb new file mode 100644 index 0000000000000..8154f557052d9 --- /dev/null +++ b/actiontext/test/dummy/config/application.rb @@ -0,0 +1,27 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Dummy + class Application < Rails::Application + config.load_defaults Rails::VERSION::STRING.to_f + + # For compatibility with applications that use this config + config.action_controller.include_all_helpers = false + + config.active_record.table_name_prefix = 'prefix_' + config.active_record.table_name_suffix = '_suffix' + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/actiontext/test/dummy/config/boot.rb b/actiontext/test/dummy/config/boot.rb new file mode 100644 index 0000000000000..d5e0f0fdc80b8 --- /dev/null +++ b/actiontext/test/dummy/config/boot.rb @@ -0,0 +1,5 @@ +# Set up gems listed in the Gemfile. +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../../Gemfile", __dir__) + +require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) +$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) diff --git a/actiontext/test/dummy/config/cable.yml b/actiontext/test/dummy/config/cable.yml new file mode 100644 index 0000000000000..98367f8954247 --- /dev/null +++ b/actiontext/test/dummy/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: dummy_production diff --git a/actiontext/test/dummy/config/database.yml b/actiontext/test/dummy/config/database.yml new file mode 100644 index 0000000000000..796466ba23eed --- /dev/null +++ b/actiontext/test/dummy/config/database.yml @@ -0,0 +1,25 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + +production: + <<: *default + database: storage/production.sqlite3 diff --git a/actiontext/test/dummy/config/environment.rb b/actiontext/test/dummy/config/environment.rb new file mode 100644 index 0000000000000..cac5315775258 --- /dev/null +++ b/actiontext/test/dummy/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/actiontext/test/dummy/config/environments/development.rb b/actiontext/test/dummy/config/environments/development.rb new file mode 100644 index 0000000000000..4d134bb530348 --- /dev/null +++ b/actiontext/test/dummy/config/environments/development.rb @@ -0,0 +1,73 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing + config.server_timing = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/actiontext/test/dummy/config/environments/production.rb b/actiontext/test/dummy/config/environments/production.rb new file mode 100644 index 0000000000000..999e332a7e2be --- /dev/null +++ b/actiontext/test/dummy/config/environments/production.rb @@ -0,0 +1,89 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] + # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. + # config.public_file_server.enabled = false + + # Compress CSS using a preprocessor. + # config.assets.css_compressor = :sass + + # Do not fall back to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain. + # config.action_cable.mount_path = nil + # config.action_cable.url = "wss://example.com/cable" + # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Log to STDOUT by default + config.logger = ActiveSupport::Logger.new(STDOUT) + .tap { |logger| logger.formatter = ::Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + + # Prepend all log lines with the following tags. + config.log_tags = [ :request_id ] + + # "info" includes generic and useful information about system operation, but avoids logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). Use "debug" + # for everything. + config.log_level = ENV.fetch("RAILS_LOG_LEVEL") { "info" } + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment). + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "dummy_production" + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false +end diff --git a/actiontext/test/dummy/config/environments/test.rb b/actiontext/test/dummy/config/environments/test.rb new file mode 100644 index 0000000000000..5b1b89421f004 --- /dev/null +++ b/actiontext/test/dummy/config/environments/test.rb @@ -0,0 +1,63 @@ +require "active_support/core_ext/integer/time" + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Raise exceptions instead of rendering exception templates. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/actiontext/test/dummy/config/importmap.rb b/actiontext/test/dummy/config/importmap.rb new file mode 100644 index 0000000000000..c2071c6baf87a --- /dev/null +++ b/actiontext/test/dummy/config/importmap.rb @@ -0,0 +1,6 @@ +# Pin npm packages by running ./bin/importmap + +pin "application", preload: true +pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true +pin "trix" +pin "@rails/actiontext", to: "actiontext.esm.js" diff --git a/actiontext/test/dummy/config/initializers/assets.rb b/actiontext/test/dummy/config/initializers/assets.rb new file mode 100644 index 0000000000000..2eeef966fe872 --- /dev/null +++ b/actiontext/test/dummy/config/initializers/assets.rb @@ -0,0 +1,12 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in the app/assets +# folder are already added. +# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/actiontext/test/dummy/config/initializers/content_security_policy.rb b/actiontext/test/dummy/config/initializers/content_security_policy.rb new file mode 100644 index 0000000000000..b3076b38fe143 --- /dev/null +++ b/actiontext/test/dummy/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/actiontext/test/dummy/config/initializers/filter_parameter_logging.rb b/actiontext/test/dummy/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000000000..adc6568ce8372 --- /dev/null +++ b/actiontext/test/dummy/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be filtered from the log file. Use this to limit dissemination of +# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported +# notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +] diff --git a/actiontext/test/dummy/config/initializers/inflections.rb b/actiontext/test/dummy/config/initializers/inflections.rb new file mode 100644 index 0000000000000..3860f659ead02 --- /dev/null +++ b/actiontext/test/dummy/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/actiontext/test/dummy/config/locales/en.yml b/actiontext/test/dummy/config/locales/en.yml new file mode 100644 index 0000000000000..6c349ae5e3743 --- /dev/null +++ b/actiontext/test/dummy/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/actiontext/test/dummy/config/puma.rb b/actiontext/test/dummy/config/puma.rb new file mode 100644 index 0000000000000..09a5c4b7865e7 --- /dev/null +++ b/actiontext/test/dummy/config/puma.rb @@ -0,0 +1,38 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. + +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies that the worker count should equal the number of processors in production. +if ENV["RAILS_ENV"] == "production" + worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) + workers worker_count if worker_count > 1 +end + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT") { 3000 } + +# Specifies the `environment` that Puma will run in. +environment ENV.fetch("RAILS_ENV") { "development" } + +# Specifies the `pidfile` that Puma will use. +if ENV["PIDFILE"] + pidfile ENV["PIDFILE"] +else + pidfile "tmp/pids/server.pid" if ENV.fetch("RAILS_ENV", "development") == "development" +end + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart diff --git a/actiontext/test/dummy/config/routes.rb b/actiontext/test/dummy/config/routes.rb new file mode 100644 index 0000000000000..e7b45701e14e2 --- /dev/null +++ b/actiontext/test/dummy/config/routes.rb @@ -0,0 +1,7 @@ +Rails.application.routes.draw do + resources :messages + + namespace :admin do + resources :messages, only: [:show] + end +end diff --git a/actiontext/test/dummy/config/storage.yml b/actiontext/test/dummy/config/storage.yml new file mode 100644 index 0000000000000..927dc537c8a6c --- /dev/null +++ b/actiontext/test/dummy/config/storage.yml @@ -0,0 +1,27 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/actiontext/test/dummy/db/migrate/20180208205311_create_messages.rb b/actiontext/test/dummy/db/migrate/20180208205311_create_messages.rb new file mode 100644 index 0000000000000..355284082449a --- /dev/null +++ b/actiontext/test/dummy/db/migrate/20180208205311_create_messages.rb @@ -0,0 +1,8 @@ +class CreateMessages < ActiveRecord::Migration[6.0] + def change + create_table :messages do |t| + t.string :subject + t.timestamps + end + end +end diff --git a/actiontext/test/dummy/db/migrate/20180212164506_create_active_storage_tables.rb b/actiontext/test/dummy/db/migrate/20180212164506_create_active_storage_tables.rb new file mode 100644 index 0000000000000..87798267b4764 --- /dev/null +++ b/actiontext/test/dummy/db/migrate/20180212164506_create_active_storage_tables.rb @@ -0,0 +1,36 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[5.2] + def change + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + t.datetime :created_at, null: false + + t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records do |t| + t.belongs_to :blob, null: false, index: false + t.string :variation_digest, null: false + + t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/actiontext/test/dummy/db/migrate/20180528164100_create_action_text_tables.rb b/actiontext/test/dummy/db/migrate/20180528164100_create_action_text_tables.rb new file mode 100644 index 0000000000000..e7c66ea6aed68 --- /dev/null +++ b/actiontext/test/dummy/db/migrate/20180528164100_create_action_text_tables.rb @@ -0,0 +1,13 @@ +class CreateActionTextTables < ActiveRecord::Migration[6.0] + def change + create_table :action_text_rich_texts do |t| + t.string :name, null: false + t.text :body, size: :long + t.references :record, null: false, polymorphic: true, index: false + + t.timestamps + + t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true + end + end +end diff --git a/actiontext/test/dummy/db/migrate/20181003185713_create_people.rb b/actiontext/test/dummy/db/migrate/20181003185713_create_people.rb new file mode 100644 index 0000000000000..6928a8e20d9db --- /dev/null +++ b/actiontext/test/dummy/db/migrate/20181003185713_create_people.rb @@ -0,0 +1,9 @@ +class CreatePeople < ActiveRecord::Migration[6.0] + def change + create_table :people do |t| + t.string :name + + t.timestamps + end + end +end diff --git a/actiontext/test/dummy/db/migrate/20190305172303_create_pages.rb b/actiontext/test/dummy/db/migrate/20190305172303_create_pages.rb new file mode 100644 index 0000000000000..3a71e55d94a3b --- /dev/null +++ b/actiontext/test/dummy/db/migrate/20190305172303_create_pages.rb @@ -0,0 +1,9 @@ +class CreatePages < ActiveRecord::Migration[6.0] + def change + create_table :pages do |t| + t.string :title + + t.timestamps + end + end +end diff --git a/actiontext/test/dummy/db/migrate/20190317200724_create_reviews.rb b/actiontext/test/dummy/db/migrate/20190317200724_create_reviews.rb new file mode 100644 index 0000000000000..96e0eab287dd4 --- /dev/null +++ b/actiontext/test/dummy/db/migrate/20190317200724_create_reviews.rb @@ -0,0 +1,8 @@ +class CreateReviews < ActiveRecord::Migration[6.0] + def change + create_table :reviews do |t| + t.belongs_to :message, null: false + t.string :author_name, null: false + end + end +end diff --git a/actiontext/test/dummy/db/schema.rb b/actiontext/test/dummy/db/schema.rb new file mode 100644 index 0000000000000..8ad24850b0635 --- /dev/null +++ b/actiontext/test/dummy/db/schema.rb @@ -0,0 +1,78 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +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" + t.string "record_type", null: false + t.integer "record_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true + end + + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.integer "record_id", null: false + t.integer "blob_id", null: false + t.datetime "created_at", precision: nil, null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", precision: nil, null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.integer "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "messages", force: :cascade do |t| + t.string "subject" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "pages", force: :cascade do |t| + t.string "title" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "people", force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "reviews", force: :cascade do |t| + t.integer "message_id", null: false + t.string "author_name", null: false + t.index ["message_id"], name: "index_reviews_on_message_id" + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" +end diff --git a/actiontext/test/dummy/lib/assets/.keep b/actiontext/test/dummy/lib/assets/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/dummy/log/.keep b/actiontext/test/dummy/log/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/dummy/public/400.html b/actiontext/test/dummy/public/400.html new file mode 100644 index 0000000000000..f59c79ab82f05 --- /dev/null +++ b/actiontext/test/dummy/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actiontext/test/dummy/public/404.html b/actiontext/test/dummy/public/404.html new file mode 100644 index 0000000000000..26d16027c6a4c --- /dev/null +++ b/actiontext/test/dummy/public/404.html @@ -0,0 +1,114 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actiontext/test/dummy/public/422.html b/actiontext/test/dummy/public/422.html new file mode 100644 index 0000000000000..ed5a5805d0e5f --- /dev/null +++ b/actiontext/test/dummy/public/422.html @@ -0,0 +1,114 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actiontext/test/dummy/public/500.html b/actiontext/test/dummy/public/500.html new file mode 100644 index 0000000000000..318723853a010 --- /dev/null +++ b/actiontext/test/dummy/public/500.html @@ -0,0 +1,114 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actiontext/test/dummy/public/apple-touch-icon-precomposed.png b/actiontext/test/dummy/public/apple-touch-icon-precomposed.png new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/dummy/public/apple-touch-icon.png b/actiontext/test/dummy/public/apple-touch-icon.png new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/dummy/public/favicon.ico b/actiontext/test/dummy/public/favicon.ico new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/dummy/storage/.keep b/actiontext/test/dummy/storage/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d 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/dummy/tmp/.keep b/actiontext/test/dummy/tmp/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/dummy/tmp/storage/.keep b/actiontext/test/dummy/tmp/storage/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/actiontext/test/fixtures/action_text/rich_texts.yml b/actiontext/test/fixtures/action_text/rich_texts.yml new file mode 100644 index 0000000000000..c3ad06d4d7877 --- /dev/null +++ b/actiontext/test/fixtures/action_text/rich_texts.yml @@ -0,0 +1,9 @@ +hello_alice_message_content: + record: hello_alice (Message) + name: content + 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!
diff --git a/actiontext/test/fixtures/files/racecar.jpg b/actiontext/test/fixtures/files/racecar.jpg new file mode 100644 index 0000000000000..934b4caa22704 Binary files /dev/null and b/actiontext/test/fixtures/files/racecar.jpg differ diff --git a/actiontext/test/fixtures/messages.yml b/actiontext/test/fixtures/messages.yml new file mode 100644 index 0000000000000..003b2ac100713 --- /dev/null +++ b/actiontext/test/fixtures/messages.yml @@ -0,0 +1,8 @@ +hello_alice: + subject: "A message to Alice" + +hello_world: + subject: "A greeting" + +racecar: + subject: "A racecar" diff --git a/actiontext/test/fixtures/people.yml b/actiontext/test/fixtures/people.yml new file mode 100644 index 0000000000000..2ceb0704aa788 --- /dev/null +++ b/actiontext/test/fixtures/people.yml @@ -0,0 +1,2 @@ +alice: + name: "Alice" diff --git a/actiontext/test/fixtures/reviews.yml b/actiontext/test/fixtures/reviews.yml new file mode 100644 index 0000000000000..d06a8cc1dd2c1 --- /dev/null +++ b/actiontext/test/fixtures/reviews.yml @@ -0,0 +1,3 @@ +hello_world: + message: $LABEL + author_name: "Ruby" diff --git a/actiontext/test/integration/controller_render_test.rb b/actiontext/test/integration/controller_render_test.rb new file mode 100644 index 0000000000000..afedaae17c520 --- /dev/null +++ b/actiontext/test/integration/controller_render_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::ControllerRenderTest < ActionDispatch::IntegrationTest + test "uses current request environment" do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + message = Message.create!(content: ActionText::Content.new.append_attachables(blob)) + + host! "loocalhoost" + get message_path(message) + assert_select "#content img" do |imgs| + imgs.each { |img| assert_match %r"//loocalhoost/", img["src"] } + end + end + + test "renders as HTML when the request format is not HTML" do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + message = Message.create!(content: ActionText::Content.new.append_attachables(blob)) + + host! "loocalhoost" + get message_path(message, format: :json) + content = ActionText.html_document_fragment_class.parse(response.parsed_body["content"]) + assert_select content, "img:match('src', ?)", %r"//loocalhoost/.+/racecar" + end + + test "renders Trix with content attachment as HTML when the request format is not HTML" do + message_with_person_attachment = messages(:hello_alice) + + get edit_message_path(message_with_person_attachment, format: :json) + + form_html = response.parsed_body["form"] + assert_match %r" class=\S+mentionable-person\b", form_html + end + + test "resolves partials when controller is namespaced" do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + message = Message.create!(content: ActionText::Content.new.append_attachables(blob)) + + get admin_message_path(message) + assert_select "#content-html .trix-content .attachment--jpg" + end + + test "resolves ActionText::Attachable based on their to_attachable_partial_path" do + alice = people(:alice) + + get messages_path + + assert_select ".mentioned-person", text: alice.name + end + + test "resolves missing ActionText::Attachable based on their to_missing_attachable_partial_path" do + alice = people(:alice) + alice.destroy! + + get messages_path + + assert_select ".missing-attachable", text: "Missing person" + end +end diff --git a/actiontext/test/integration/job_render_test.rb b/actiontext/test/integration/job_render_test.rb new file mode 100644 index 0000000000000..d0f4211226b9c --- /dev/null +++ b/actiontext/test/integration/job_render_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::JobRenderTest < ActiveJob::TestCase + include Rails::Dom::Testing::Assertions::SelectorAssertions + + test "uses app default_url_options" do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + message = Message.create!(content: ActionText::Content.new.append_attachables(blob)) + + Dir.mktmpdir do |dir| + file = File.join(dir, "broadcast.html") + + BroadcastJob.perform_later(file, message) + + with_default_url_options(host: "foo.example.com", port: 9001) do + perform_enqueued_jobs + end + + rendered = ActionText.html_document_fragment_class.parse(File.read(file)) + assert_select rendered, "img:match('src', ?)", %r"//foo.example.com:9001/.+/racecar" + end + end + + private + def with_default_url_options(default_url_options) + original_default_url_options = Dummy::Application.default_url_options + Dummy::Application.default_url_options = default_url_options + yield + ensure + Dummy::Application.default_url_options = original_default_url_options + end +end diff --git a/actiontext/test/integration/mailer_render_test.rb b/actiontext/test/integration/mailer_render_test.rb new file mode 100644 index 0000000000000..620eac0110602 --- /dev/null +++ b/actiontext/test/integration/mailer_render_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::MailerRenderTest < ActionMailer::TestCase + test "uses default_url_options" do + original_default_url_options = ActionMailer::Base.default_url_options + ActionMailer::Base.default_url_options = { host: "hoost" } + + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + message = Message.new(content: ActionText::Content.new.append_attachables(blob)) + + MessagesMailer.with(recipient: "test", message: message).notification.deliver_now + + assert_select_email do + assert_select "#message-content img" do |imgs| + imgs.each { |img| assert_match %r"//hoost/", img["src"] } + end + end + ensure + ActionMailer::Base.default_url_options = original_default_url_options + end +end diff --git a/actiontext/test/javascript_package_test.rb b/actiontext/test/javascript_package_test.rb new file mode 100644 index 0000000000000..e4531e29b45d5 --- /dev/null +++ b/actiontext/test/javascript_package_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "test_helper" + +class JavascriptPackageTest < ActiveSupport::TestCase + def test_compiled_code_is_in_sync_with_source_code + compiled_files = %w[ + app/assets/javascripts/actiontext.js + app/assets/javascripts/actiontext.esm.js + ].map do |file| + Pathname(file).expand_path("#{__dir__}/..") + end + + assert_no_changes -> { compiled_files.map(&:read) } do + system "yarn build", exception: true + end + end +end diff --git a/actiontext/test/models/table_name_test.rb b/actiontext/test/models/table_name_test.rb new file mode 100644 index 0000000000000..aa47706b21685 --- /dev/null +++ b/actiontext/test/models/table_name_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::TableNameTest < ActiveSupport::TestCase + setup do + @old_prefix = ActiveRecord::Base.table_name_prefix + @old_suffix = ActiveRecord::Base.table_name_suffix + + ActiveRecord::Base.table_name_prefix = @prefix = "abc_" + ActiveRecord::Base.table_name_suffix = @suffix = "_xyz" + + @models = [ActionText::RichText, ActionText::EncryptedRichText] + @models.map(&:reset_table_name) + end + + teardown do + ActiveRecord::Base.table_name_prefix = @old_prefix + ActiveRecord::Base.table_name_suffix = @old_suffix + + @models.map(&:reset_table_name) + end + + test "prefix and suffix are added to the Action Text tables' name" do + assert_equal( + "#{@prefix}action_text_rich_texts#{@suffix}", + ActionText::RichText.table_name + ) + assert_equal( + "#{@prefix}action_text_rich_texts#{@suffix}", + ActionText::EncryptedRichText.table_name + ) + end +end diff --git a/actiontext/test/system/rich_text_editor_test.rb b/actiontext/test/system/rich_text_editor_test.rb new file mode 100644 index 0000000000000..65a2a00076b53 --- /dev/null +++ b/actiontext/test/system/rich_text_editor_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "application_system_test_case" + +class ActionText::RichTextEditorTest < ApplicationSystemTestCase + test "attaches and uploads image file" do + image_file = file_fixture("racecar.jpg") + + visit new_message_url + attach_file image_file do + click_button "Attach Files" + end + within :rich_text_area do + assert_selector :element, "img", src: %r{/rails/active_storage/blobs/redirect/.*/#{image_file.basename}\Z} + end + click_button "Create Message" + + within class: "trix-content" do + assert_selector :element, "img", src: %r{/rails/active_storage/representations/redirect/.*/#{image_file.basename}\Z} + end + end +end diff --git a/actiontext/test/system/system_test_helper_test.rb b/actiontext/test/system/system_test_helper_test.rb new file mode 100644 index 0000000000000..35c6ed65613fa --- /dev/null +++ b/actiontext/test/system/system_test_helper_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "application_system_test_case" + +class ActionText::SystemTestHelperTest < ApplicationSystemTestCase + def setup + visit new_message_url + end + + 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", 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 new file mode 100644 index 0000000000000..da69d4bfe6675 --- /dev/null +++ b/actiontext/test/template/form_helper_test.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::FormHelperTest < ActionView::TestCase + tests ActionText::TagHelper + + def form_with(*, **) + @output_buffer = super + end + + teardown do + I18n.backend.reload! + end + + setup do + I18n.backend.store_translations("placeholder", + activerecord: { + attributes: { + message: { + title: "Story title" + } + } + } + ) + end + + test "#rich_textarea_tag helper" do + message = Message.new + + concat rich_textarea_tag :content, message.content, { input: "trix_input_1" } + + 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(<<~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 + message = Message.new content: "

hello world

" + + concat rich_textarea :message, :title, value: message.content, input: "trix_input_1" + + assert_dom_equal(<<~HTML, output_buffer) + + + + HTML + end + + test "form with rich text area" do + form_with model: Message.new, scope: :message do |form| + form.rich_textarea :content + end + + 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 + form_with model: Message.new, scope: :message do |form| + form.rich_textarea :content, class: "custom-class" + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + + test "form with rich text area and error wrapper" do + message = Message.new + message.errors.add(:content, :blank) + + form_with model: message, scope: :message do |form| + form.rich_textarea :content + end + + assert_dom_equal(<<~HTML, output_buffer) +
+
+ + + +
+
+ HTML + end + + test "form with rich text area for non-attribute" do + form_with model: Message.new, scope: :message do |form| + form.rich_textarea :not_an_attribute + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + + test "modelless form with rich text area" do + form_with url: "/messages", scope: :message do |form| + form.rich_textarea :content, { input: "trix_input_2" } + end + + 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 + form_with model: Message.new, scope: :message do |form| + form.rich_textarea :content, placeholder: true + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + + test "form with rich text area having placeholder with locale" do + I18n.with_locale :placeholder do + form_with model: Message.new, scope: :message do |form| + form.rich_textarea :title, placeholder: true + end + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + + test "form with rich text area with value" do + form_with model: Message.new, scope: :message do |form| + form.rich_textarea :title, value: "

hello world

" + end + + 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 + form_with model: Message.new, scope: :message do |form| + form.rich_textarea :title, form: "other_form" + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + + test "form with rich text area with data[direct_upload_url]" do + form_with model: Message.new, scope: :message do |form| + form.rich_textarea :content, data: { direct_upload_url: "http://test.host/direct_uploads" } + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + + test "form with rich text area with data[blob_url_template]" do + form_with model: Message.new, scope: :message do |form| + form.rich_textarea :content, data: { blob_url_template: "http://test.host/blobs/:signed_id/:filename" } + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end +end diff --git a/actiontext/test/test_helper.rb b/actiontext/test/test_helper.rb new file mode 100644 index 0000000000000..309690d8dc620 --- /dev/null +++ b/actiontext/test/test_helper.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../../tools/strict_warnings" + +# Configure Rails Environment +ENV["RAILS_ENV"] = "test" + +require_relative "../test/dummy/config/environment" +require "active_record/testing/query_assertions" +ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] +require "rails/test_help" + +require "rails/test_unit/reporter" +Rails::TestUnitReporter.executable = "bin/test" + +# Disable available locale checks to allow to add locale after initialized. +I18n.enforce_available_locales = false + +# Load fixtures from the engine +if ActiveSupport::TestCase.respond_to?(:fixture_paths=) + ActiveSupport::TestCase.fixture_paths = [File.expand_path("fixtures", __dir__)] + ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths + ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files" + ActiveSupport::TestCase.fixtures :all +end + +class ActiveSupport::TestCase + module QueryHelpers + include ActiveJob::TestHelper + include ActiveRecord::Assertions::QueryAssertions + end + + private + def create_file_blob(filename:, content_type:, metadata: nil) + ActiveStorage::Blob.create_and_upload! io: file_fixture(filename).open, filename: filename, content_type: content_type, metadata: metadata + end +end + +# Encryption +ActiveRecord::Encryption.configure \ + primary_key: "test master key", + deterministic_key: "test deterministic key", + key_derivation_salt: "testing key derivation salt", + support_unencrypted_data: true + +require_relative "../../tools/test_common" diff --git a/actiontext/test/unit/attachable_test.rb b/actiontext/test/unit/attachable_test.rb new file mode 100644 index 0000000000000..bdf46b42d64fc --- /dev/null +++ b/actiontext/test/unit/attachable_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::AttachableTest < ActiveSupport::TestCase + test "as_json is a hash when the attachable is persisted" do + freeze_time do + attachable = ActiveStorage::Blob.create_after_unfurling!(io: StringIO.new("test"), filename: "test.txt", key: 123) + attributes = { + id: attachable.id, + key: "123", + filename: "test.txt", + content_type: "text/plain", + metadata: { identified: true }, + service_name: "test", + byte_size: 4, + checksum: "CY9rzUYh03PK3k6DJie09g==", + created_at: Time.zone.now.as_json, + attachable_sgid: attachable.attachable_sgid + }.deep_stringify_keys + + assert_equal attributes, attachable.as_json + end + end + + test "as_json is a hash when the attachable is a new record" do + attachable = ActiveStorage::Blob.build_after_unfurling(io: StringIO.new("test"), filename: "test.txt", key: 123) + attributes = { + id: nil, + key: "123", + filename: "test.txt", + content_type: "text/plain", + metadata: { identified: true }, + service_name: "test", + byte_size: 4, + checksum: "CY9rzUYh03PK3k6DJie09g==", + created_at: nil, + attachable_sgid: nil + }.deep_stringify_keys + + assert_equal attributes, attachable.as_json + end + + test "attachable_sgid is included in as_json when only option is nil or includes attachable_sgid" do + attachable = ActiveStorage::Blob.create_after_unfurling!(io: StringIO.new("test"), filename: "test.txt", key: 123) + + assert_equal({ "id" => attachable.id }, attachable.as_json(only: :id)) + assert_equal({ "id" => attachable.id }, attachable.as_json(only: [:id])) + assert_equal(attachable.as_json.except("attachable_sgid"), attachable.as_json(except: :attachable_sgid)) + assert_equal(attachable.as_json.except("attachable_sgid"), attachable.as_json(except: [:attachable_sgid])) + end +end diff --git a/actiontext/test/unit/attachment_test.rb b/actiontext/test/unit/attachment_test.rb new file mode 100644 index 0000000000000..416994ed948c9 --- /dev/null +++ b/actiontext/test/unit/attachment_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::AttachmentTest < ActiveSupport::TestCase + test "from_attachable" do + attachment = ActionText::Attachment.from_attachable(attachable, caption: "Captioned") + assert_equal attachable, attachment.attachable + assert_equal "Captioned", attachment.caption + end + + test "proxies missing methods to attachable" do + attachable.instance_eval { def proxied; "proxied"; end } + attachment = ActionText::Attachment.from_attachable(attachable) + assert_equal "proxied", attachment.proxied + end + + test "proxies #to_param to attachable" do + attachment = ActionText::Attachment.from_attachable(attachable) + assert_equal attachable.to_param, attachment.to_param + end + + test "converts to TrixAttachment" do + attachment = attachment_from_html(%Q()) + + 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_equal attachable.filename.to_s, trix_attachment.attributes["filename"] + assert_equal attachable.byte_size, trix_attachment.attributes["filesize"] + assert_equal "Captioned", trix_attachment.attributes["caption"] + + 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 + + test "converts to TrixAttachment with content" do + attachable = Person.create! name: "Javan" + attachment = attachment_from_html(%Q()) + + 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 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 + + test "converts to plain text" do + assert_equal "[Vroom vroom]", ActionText::Attachment.from_attachable(attachable, caption: "Vroom vroom").to_plain_text + assert_equal "[racecar.jpg]", ActionText::Attachment.from_attachable(attachable).to_plain_text + end + + test "converts HTML content attachment" do + attachment = attachment_from_html('') + attachable = attachment.attachable + + assert_kind_of ActionText::Attachables::ContentAttachment, attachable + assert_equal "text/html", attachable.content_type + assert_equal "abc", attachable.content + + 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 + + test "renders content attachment" do + html = '' + attachment = attachment_from_html(html) + attachable = attachment.attachable + + ActionText::Content.with_renderer MessagesController.renderer do + assert_equal "

abc

", attachable.to_html.strip + end + end + + 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 + end + + test "defaults trix partial to model partial" do + attachable = Page.create! title: "Homepage" + 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 + def attachment_from_html(html) + ActionText::Content.new(html).attachments.first + end + + def attachable + @attachment ||= create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + end +end diff --git a/actiontext/test/unit/content_test.rb b/actiontext/test/unit/content_test.rb new file mode 100644 index 0000000000000..7c8bfc4375ed8 --- /dev/null +++ b/actiontext/test/unit/content_test.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::ContentTest < ActiveSupport::TestCase + test "equality" do + html = "
test
" + content = content_from_html(html) + assert_equal content, content_from_html(html) + assert_not_equal content, html + end + + test "marshal serialization" do + content = content_from_html("Hello!") + assert_equal content, Marshal.load(Marshal.dump(content)) + end + + test "roundtrips HTML without additional newlines" do + html = "
a
" + content = content_from_html(html) + assert_equal html, content.to_html + end + + test "extracts links" do + html = '1
1' + content = content_from_html(html) + assert_equal ["http://example.com/1"], content.links + end + + test "extracts attachables" do + attachable = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + html = %Q() + + content = content_from_html(html) + assert_equal 1, content.attachments.size + + attachment = content.attachments.first + assert_equal "Captioned", attachment.caption + assert_equal attachable, attachment.attachable + end + + test "extracts remote image attachables" do + html = '' + + content = content_from_html(html) + assert_equal 1, content.attachments.size + + attachment = content.attachments.first + assert_equal "Captioned", attachment.caption + + attachable = attachment.attachable + assert_kind_of ActionText::Attachables::RemoteImage, attachable + assert_equal "http://example.com/cat.jpg", attachable.url + assert_equal "100", attachable.width + 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() + file.destroy! + content = content_from_html(html) + assert_equal 1, content.attachments.size + + attachable = content.attachments.first.attachable + assert_kind_of ActionText::Attachables::MissingAttachable, attachable + assert_equal file.class, attachable.model + assert_equal ActionText::Attachables::MissingAttachable::DEFAULT_PARTIAL_PATH, attachable.to_partial_path + end + + test "extracts missing attachables" 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 + assert_nil attachable.model + end + + test "converts Trix-formatted attachments" do + html = %Q(
) + content = content_from_html(html) + assert_equal 1, content.attachments.size + assert_equal '', content.to_html + end + + test "converts Trix-formatted attachments with custom tag name" do + with_attachment_tag_name("arbitrary-tag") do + html = %Q(
) + content = content_from_html(html) + assert_equal 1, content.attachments.size + assert_equal '', content.to_html + end + end + + test "ignores Trix-formatted attachments with malformed JSON" do + html = %Q(
) + content = content_from_html(html) + assert_equal 0, content.attachments.size + end + + test "minifies attachment markup" do + html = '
HTML
' + assert_equal '', content_from_html(html).to_html + end + + test "canonicalizes attachment gallery markup" do + attachment_html = '' + html = %Q() + assert_equal "
#{attachment_html}
", content_from_html(html).to_html + end + + test "canonicalizes attachment gallery markup with whitespace" do + attachment_html = %Q(\n \n \n) + html = %Q() + assert_equal "
#{attachment_html}
", content_from_html(html).to_html + end + + test "canonicalizes nested attachment gallery markup" do + attachment_html = '' + html = %Q(
) + assert_equal "
#{attachment_html}
", content_from_html(html).to_html + end + + test "renders with layout when ApplicationController is not defined" do + html = "

Hello world

" + rendered = content_from_html(html).to_rendered_html_with_layout + + assert_includes rendered, html + assert_match %r/\A#{Regexp.escape '
'}/, rendered + assert_not defined?(::ApplicationController) + end + + test "does basic sanitization" do + html = "
safe
" + rendered = content_from_html(html).to_rendered_html_with_layout + + assert_not_includes rendered, "">' + trix_html = '
' + 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 = '
' + 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 + html = "

Hello world

" + rendered = nil + Thread.new { rendered = content_from_html(html).to_rendered_html_with_layout }.join + + assert_includes rendered, html + assert_match %r/\A#{Regexp.escape '
'}/, rendered + end + + test "replace certain nodes" do + html = <<~HTML +
+

replace me

+

ignore me

+
+ HTML + + expected_html = <<~HTML +
+

replaced

+

ignore me

+
+ HTML + + content = content_from_html(html) + replaced_fragment = content.fragment.replace("p") do |node| + if node.text =~ /replace me/ + "

replaced

" + else + node + end + end + + assert_equal expected_html.strip, replaced_fragment.to_html + end + + test "delegates pattern matching to Nokogiri" do + content = ActionText::Content.new <<~HTML +

Hello, world

+ +
The body
+ HTML + + content => [h1, div] + + assert_pattern { h1 => { name: "h1", content: "Hello, world", attributes: [{ name: "id", value: "hello-world" }] } } + refute_pattern { h1 => { name: "h1", content: "Goodbye, world" } } + assert_pattern { div => { content: "The body" } } + end + + private + def content_from_html(html) + ActionText::Content.new(html).tap do |content| + assert_nothing_raised { content.to_s } + end + end + + def with_attachment_tag_name(tag_name) + previous_tag_name = ActionText::Attachment.tag_name + ActionText::Attachment.tag_name = tag_name + + yield + ensure + ActionText::Attachment.tag_name = previous_tag_name + end +end 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/fixture_set_test.rb b/actiontext/test/unit/fixture_set_test.rb new file mode 100644 index 0000000000000..08108c27f6711 --- /dev/null +++ b/actiontext/test/unit/fixture_set_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::FixtureSetTest < ActiveSupport::TestCase + def test_action_text_attachment + message = messages(:hello_world) + review = reviews(:hello_world) + + attachments = review.content.body.attachments + + assert_includes attachments.map(&:attachable), message + end +end diff --git a/actiontext/test/unit/model_encryption_test.rb b/actiontext/test/unit/model_encryption_test.rb new file mode 100644 index 0000000000000..754dbd2010d5c --- /dev/null +++ b/actiontext/test/unit/model_encryption_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::ModelEncryptionTest < ActiveSupport::TestCase + test "encrypt content based on :encrypted option at declaration time" do + encrypted_message = EncryptedMessage.create!(subject: "Greetings", content: "Hey there") + assert_encrypted_rich_text_attribute encrypted_message, :content, "Hey there" + + clear_message = Message.create!(subject: "Greetings", content: "Hey there") + assert_not_encrypted_rich_text_attribute clear_message, :content, "Hey there" + end + + test "include rich text attributes when encrypting the model" do + content = "

the space force is here, we are safe now!

" + + message = ActiveRecord::Encryption.without_encryption do + EncryptedMessage.create!(subject: "Greetings", content: content) + end + + message.encrypt + + assert_encrypted_rich_text_attribute(message, :content, content) + end + + private + def assert_encrypted_rich_text_attribute(model, attribute_name, expected_value) + assert_not_equal expected_value, model.send(attribute_name).ciphertext_for(:body) + assert_equal expected_value, model.reload.send(attribute_name).body.to_html + end + + def assert_not_encrypted_rich_text_attribute(model, attribute_name, expected_value) + assert_equal expected_value, model.send(attribute_name).ciphertext_for(:body) + assert_equal expected_value, model.reload.send(attribute_name).body.to_html + end +end diff --git a/actiontext/test/unit/model_test.rb b/actiontext/test/unit/model_test.rb new file mode 100644 index 0000000000000..e57d6567efb56 --- /dev/null +++ b/actiontext/test/unit/model_test.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::ModelTest < ActiveSupport::TestCase + include QueryHelpers + + test "html conversion" do + message = Message.new(subject: "Greetings", content: "

Hello world

") + assert_equal %Q(
\n

Hello world

\n
\n), "#{message.content}" + end + + test "plain text conversion" do + message = Message.new(subject: "Greetings", content: "

Hello world

") + assert_equal "Hello world", message.content.to_plain_text + end + + test "without content" do + assert_difference("ActionText::RichText.count" => 0) do + message = Message.create!(subject: "Greetings") + assert_nil message.content + assert_predicate message.content, :blank? + assert_predicate message.content, :empty? + assert_not message.content? + assert_not message.content.present? + end + end + + test "with blank content" do + assert_difference("ActionText::RichText.count" => 1) do + message = Message.create!(subject: "Greetings", content: "") + assert_not message.content.nil? + assert_predicate message.content, :blank? + assert_predicate message.content, :empty? + assert_not message.content? + assert_not message.content.present? + end + end + + test "embed extraction" do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + message = Message.create!(subject: "Greetings", content: ActionText::Content.new("Hello world").append_attachables(blob)) + assert_equal "racecar.jpg", message.content.embeds.first.filename.to_s + end + + test "embed extraction only extracts file attachments" do + remote_image_html = '' + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + content = ActionText::Content.new(remote_image_html).append_attachables(blob) + message = Message.create!(subject: "Greetings", content: content) + assert_equal [ActionText::Attachables::RemoteImage, ActiveStorage::Blob], message.content.body.attachables.map(&:class) + assert_equal [ActiveStorage::Attachment], message.content.embeds.map(&:class) + end + + test "embed extraction deduplicates file attachments" do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + content = ActionText::Content.new("Hello world").append_attachables([ blob, blob ]) + + assert_nothing_raised do + Message.create!(subject: "Greetings", content: content) + end + end + + test "embed extraction occurs before validation" do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + content = ActionText::Content.new.append_attachables(blob) + message = Message.build(subject: "Greetings", content: content) + + assert_changes -> { message.content.embeds.empty? }, from: true, to: false do + message.content.validate + end + + embeds = message.content.embeds + assert_kind_of ActiveStorage::Attached::Many, embeds + assert_kind_of ActiveStorage::Attachment, embeds.first + assert_equal blob, embeds.first.blob + end + + test "saving content" do + message = Message.create!(subject: "Greetings", content: "

Hello world

") + assert_equal "Hello world", message.content.to_plain_text + end + + test "duplicating content" do + message = Message.create!(subject: "Greetings", content: "Hello!") + other_message = Message.create!(subject: "Greetings", content: message.content) + + assert_equal message.content.body.to_html, other_message.content.body.to_html + end + + test "saving body" do + message = Message.create(subject: "Greetings", body: "

Hello world

") + assert_equal "Hello world", message.body.to_plain_text + end + + test "saving content via nested attributes" do + message = Message.create! subject: "Greetings", content: "

Hello world

", + review_attributes: { author_name: "Marcia", content: "Nice work!" } + assert_equal "Nice work!", message.review.content.to_plain_text + end + + test "updating content via nested attributes" do + message = Message.create! subject: "Greetings", content: "

Hello world

", + review_attributes: { author_name: "Marcia", content: "Nice work!" } + + message.update! review_attributes: { id: message.review.id, content: "Great work!" } + assert_equal "Great work!", message.review.reload.content.to_plain_text + end + + test "building content lazily on existing record" do + message = Message.create!(subject: "Greetings") + + assert_no_difference -> { ActionText::RichText.count } do + assert_kind_of ActionText::RichText, message.content + end + end + + test "eager loading" do + Message.create!(subject: "Subject", content: "

Content

") + + message = assert_queries_count(2) { Message.with_rich_text_content.last } + assert_no_queries do + assert_equal "Content", message.content.to_plain_text + end + end + + test "eager loading all rich text" do + 2.times do + Message.create!(subject: "Subject", content: "

Content

", body: "

Body

") + end + + message = assert_queries_count(3) do + # 3 queries: + # messages x 1 + # action texts (content) x 1 + # action texts (body) x 1 + Message.with_all_rich_text.to_a.last + end + + assert_no_queries do + assert_equal "Content", message.content.to_plain_text + assert_equal "Body", message.body.to_plain_text + end + end + + test "with blank content and store_if_blank: false" do + assert_difference("ActionText::RichText.count" => 0) do + message = MessageWithoutBlanks.create!(subject: "Greetings", content: "") + assert_nil message.content + assert_predicate message.content, :blank? + assert_predicate message.content, :empty? + assert_not message.content? + assert_not message.content.present? + end + end + + test "if allowing blanks, updates rich text record on edit" do + message = Message.create!(subject: "Greetings", content: "content") + assert_difference("ActionText::RichText.count" => 0) do + message.update(content: "") + end + end + + test "if disallowing blanks, deletes rich text record on edit" do + message = MessageWithoutBlanks.create!(subject: "Greetings", content: "content") + assert_difference("ActionText::RichText.count" => -1) do + message.update(content: "") + end + end + + test "if disallowing blanks, can still validate presence" do + message1 = MessageWithoutBlanksWithContentValidation.new(subject: "Greetings", content: "") + assert_not_predicate message1, :valid? + message1.content = "content" + assert_predicate message1, :valid? + + message2 = MessageWithoutBlanksWithContentValidation.new(subject: "Greetings", content: "content") + assert_predicate message2, :valid? + message2.content = "" + assert_not_predicate message2, :valid? + end +end diff --git a/actiontext/test/unit/plain_text_conversion_test.rb b/actiontext/test/unit/plain_text_conversion_test.rb new file mode 100644 index 0000000000000..7889e6a4855a3 --- /dev/null +++ b/actiontext/test/unit/plain_text_conversion_test.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionText::PlainTextConversionTest < ActiveSupport::TestCase + test "

tags are separated by two new lines" do + assert_converted_to( + "Hello world!\n\nHow are you?", + "

Hello world!

How are you?

" + ) + end + + test "
tags are separated by two new lines" do + assert_converted_to( + "“Hello world!â€\n\n“How are you?â€", + "
Hello world!
How are you?
" + ) + end + + test "
tag with whitespace" do + assert_converted_to( + " “Hello world!†", + "
Hello world!
" + ) + end + + test "
tag with only whitespace" do + assert_converted_to( + "“â€", + "
" + ) + end + + test "
    tags are separated by two new lines" do + assert_converted_to( + "Hello world!\n\n1. list1\n\n1. list2\n\nHow are you?", + "

    Hello world!

    1. list1
    1. list2

    How are you?

    " + ) + end + + test "